507 lines
16 KiB
Go
507 lines
16 KiB
Go
package appuser
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"hyapp-admin-server/internal/appctx"
|
|
"hyapp-admin-server/internal/integration/userclient"
|
|
"hyapp-admin-server/internal/security"
|
|
)
|
|
|
|
var (
|
|
ErrInvalidArgument = errors.New("invalid argument")
|
|
ErrNotFound = errors.New("not found")
|
|
)
|
|
|
|
type Service struct {
|
|
userClient userclient.Client
|
|
userDB *sql.DB
|
|
walletDB *sql.DB
|
|
}
|
|
|
|
type AppUser struct {
|
|
Avatar string `json:"avatar"`
|
|
Coin int64 `json:"coin"`
|
|
Country string `json:"country"`
|
|
CountryDisplayName string `json:"countryDisplayName"`
|
|
CountryName string `json:"countryName"`
|
|
CreatedAtMs int64 `json:"createdAtMs"`
|
|
Diamond int64 `json:"diamond"`
|
|
DisplayUserID string `json:"displayUserId"`
|
|
Gender string `json:"gender"`
|
|
LastActiveAtMs int64 `json:"lastActiveAtMs"`
|
|
RegionID int64 `json:"regionId"`
|
|
RegionName string `json:"regionName"`
|
|
Status string `json:"status"`
|
|
UpdatedAtMs int64 `json:"updatedAtMs"`
|
|
UserID string `json:"userId"`
|
|
Username string `json:"username"`
|
|
}
|
|
|
|
func NewService(userClient userclient.Client, userDB *sql.DB, walletDB *sql.DB) *Service {
|
|
return &Service{userClient: userClient, userDB: userDB, walletDB: walletDB}
|
|
}
|
|
|
|
func (s *Service) ListUsers(ctx context.Context, query listQuery) ([]AppUser, int64, error) {
|
|
if s.userDB == nil {
|
|
return nil, 0, fmt.Errorf("user mysql is not configured")
|
|
}
|
|
query = normalizeListQuery(query)
|
|
appCode := appctx.FromContext(ctx)
|
|
whereSQL := "FROM users u WHERE u.app_code = ?"
|
|
args := []any{appCode}
|
|
if query.Status != "" {
|
|
whereSQL += " AND u.status = ?"
|
|
args = append(args, query.Status)
|
|
}
|
|
if query.Keyword != "" {
|
|
like := "%" + query.Keyword + "%"
|
|
whereSQL += " AND (CAST(u.user_id AS CHAR) LIKE ? OR u.current_display_user_id LIKE ? OR u.username LIKE ?)"
|
|
args = append(args, like, like, like)
|
|
}
|
|
|
|
total, err := countRows(ctx, s.userDB, whereSQL, args...)
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
rows, err := s.userDB.QueryContext(ctx, fmt.Sprintf(`
|
|
SELECT u.user_id,
|
|
u.current_display_user_id,
|
|
COALESCE(u.username, ''),
|
|
COALESCE(u.avatar, ''),
|
|
COALESCE(u.gender, ''),
|
|
COALESCE(u.country, ''),
|
|
COALESCE((SELECT c.country_name FROM countries c WHERE c.app_code = u.app_code AND c.country_code = u.country LIMIT 1), ''),
|
|
COALESCE((SELECT c.country_display_name FROM countries c WHERE c.app_code = u.app_code AND c.country_code = u.country LIMIT 1), ''),
|
|
CASE
|
|
WHEN COALESCE(u.region_id, 0) = 0 THEN 0
|
|
WHEN EXISTS (
|
|
SELECT 1 FROM regions rg
|
|
WHERE rg.app_code = u.app_code
|
|
AND rg.region_id = u.region_id
|
|
AND rg.status = 'active'
|
|
AND rg.region_code NOT IN ('Australia and New Zealand', 'Caribbean', 'Melanesia', 'Micronesia', 'Polynesia', 'Southern Africa', 'UNSPECIFIED')
|
|
LIMIT 1
|
|
) THEN u.region_id
|
|
ELSE 0
|
|
END,
|
|
CASE
|
|
WHEN COALESCE(u.region_id, 0) = 0 OR NOT EXISTS (
|
|
SELECT 1 FROM regions rg
|
|
WHERE rg.app_code = u.app_code
|
|
AND rg.region_id = u.region_id
|
|
AND rg.status = 'active'
|
|
AND rg.region_code NOT IN ('Australia and New Zealand', 'Caribbean', 'Melanesia', 'Micronesia', 'Polynesia', 'Southern Africa', 'UNSPECIFIED')
|
|
LIMIT 1
|
|
) THEN 'GLOBAL'
|
|
ELSE COALESCE((SELECT rg.name FROM regions rg WHERE rg.app_code = u.app_code AND rg.region_id = u.region_id LIMIT 1), '')
|
|
END,
|
|
u.status,
|
|
u.created_at_ms,
|
|
u.updated_at_ms,
|
|
GREATEST(
|
|
COALESCE((SELECT MAX(s.updated_at_ms) FROM auth_sessions s WHERE s.app_code = u.app_code AND s.user_id = u.user_id), 0),
|
|
COALESCE((SELECT MAX(l.created_at_ms) FROM login_audit l WHERE l.app_code = u.app_code AND l.user_id = u.user_id), 0)
|
|
)
|
|
%s
|
|
ORDER BY u.created_at_ms DESC, u.user_id DESC
|
|
LIMIT ? OFFSET ?
|
|
`, whereSQL), append(args, query.PageSize, offset(query.Page, query.PageSize))...)
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
items := make([]AppUser, 0, query.PageSize)
|
|
userIDs := make([]int64, 0, query.PageSize)
|
|
for rows.Next() {
|
|
var item AppUser
|
|
var userID int64
|
|
if err := rows.Scan(
|
|
&userID,
|
|
&item.DisplayUserID,
|
|
&item.Username,
|
|
&item.Avatar,
|
|
&item.Gender,
|
|
&item.Country,
|
|
&item.CountryName,
|
|
&item.CountryDisplayName,
|
|
&item.RegionID,
|
|
&item.RegionName,
|
|
&item.Status,
|
|
&item.CreatedAtMs,
|
|
&item.UpdatedAtMs,
|
|
&item.LastActiveAtMs,
|
|
); err != nil {
|
|
return nil, 0, err
|
|
}
|
|
item.UserID = strconv.FormatInt(userID, 10)
|
|
items = append(items, item)
|
|
userIDs = append(userIDs, userID)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, 0, err
|
|
}
|
|
if err := s.fillBalances(ctx, items, userIDs); err != nil {
|
|
return nil, 0, err
|
|
}
|
|
return items, total, nil
|
|
}
|
|
|
|
func (s *Service) GetUser(ctx context.Context, userID int64) (AppUser, error) {
|
|
if s.userDB == nil {
|
|
return AppUser{}, fmt.Errorf("user mysql is not configured")
|
|
}
|
|
var item AppUser
|
|
var scannedUserID int64
|
|
appCode := appctx.FromContext(ctx)
|
|
err := s.userDB.QueryRowContext(ctx, `
|
|
SELECT u.user_id,
|
|
u.current_display_user_id,
|
|
COALESCE(u.username, ''),
|
|
COALESCE(u.avatar, ''),
|
|
COALESCE(u.gender, ''),
|
|
COALESCE(u.country, ''),
|
|
COALESCE((SELECT c.country_name FROM countries c WHERE c.app_code = u.app_code AND c.country_code = u.country LIMIT 1), ''),
|
|
COALESCE((SELECT c.country_display_name FROM countries c WHERE c.app_code = u.app_code AND c.country_code = u.country LIMIT 1), ''),
|
|
CASE
|
|
WHEN COALESCE(u.region_id, 0) = 0 THEN 0
|
|
WHEN EXISTS (
|
|
SELECT 1 FROM regions rg
|
|
WHERE rg.app_code = u.app_code
|
|
AND rg.region_id = u.region_id
|
|
AND rg.status = 'active'
|
|
AND rg.region_code NOT IN ('Australia and New Zealand', 'Caribbean', 'Melanesia', 'Micronesia', 'Polynesia', 'Southern Africa', 'UNSPECIFIED')
|
|
LIMIT 1
|
|
) THEN u.region_id
|
|
ELSE 0
|
|
END,
|
|
CASE
|
|
WHEN COALESCE(u.region_id, 0) = 0 OR NOT EXISTS (
|
|
SELECT 1 FROM regions rg
|
|
WHERE rg.app_code = u.app_code
|
|
AND rg.region_id = u.region_id
|
|
AND rg.status = 'active'
|
|
AND rg.region_code NOT IN ('Australia and New Zealand', 'Caribbean', 'Melanesia', 'Micronesia', 'Polynesia', 'Southern Africa', 'UNSPECIFIED')
|
|
LIMIT 1
|
|
) THEN 'GLOBAL'
|
|
ELSE COALESCE((SELECT rg.name FROM regions rg WHERE rg.app_code = u.app_code AND rg.region_id = u.region_id LIMIT 1), '')
|
|
END,
|
|
u.status,
|
|
u.created_at_ms,
|
|
u.updated_at_ms,
|
|
GREATEST(
|
|
COALESCE((SELECT MAX(s.updated_at_ms) FROM auth_sessions s WHERE s.app_code = u.app_code AND s.user_id = u.user_id), 0),
|
|
COALESCE((SELECT MAX(l.created_at_ms) FROM login_audit l WHERE l.app_code = u.app_code AND l.user_id = u.user_id), 0)
|
|
)
|
|
FROM users u
|
|
WHERE u.app_code = ? AND u.user_id = ?
|
|
`, appCode, userID).Scan(
|
|
&scannedUserID,
|
|
&item.DisplayUserID,
|
|
&item.Username,
|
|
&item.Avatar,
|
|
&item.Gender,
|
|
&item.Country,
|
|
&item.CountryName,
|
|
&item.CountryDisplayName,
|
|
&item.RegionID,
|
|
&item.RegionName,
|
|
&item.Status,
|
|
&item.CreatedAtMs,
|
|
&item.UpdatedAtMs,
|
|
&item.LastActiveAtMs,
|
|
)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return AppUser{}, ErrNotFound
|
|
}
|
|
if err != nil {
|
|
return AppUser{}, err
|
|
}
|
|
item.UserID = strconv.FormatInt(scannedUserID, 10)
|
|
items := []AppUser{item}
|
|
if err := s.fillBalances(ctx, items, []int64{userID}); err != nil {
|
|
return AppUser{}, err
|
|
}
|
|
return items[0], nil
|
|
}
|
|
|
|
func (s *Service) UpdateUser(ctx context.Context, userID int64, req updateUserRequest) (AppUser, error) {
|
|
if s.userDB == nil {
|
|
return AppUser{}, fmt.Errorf("user mysql is not configured")
|
|
}
|
|
sets := make([]string, 0, 6)
|
|
args := make([]any, 0, 8)
|
|
if req.Username != nil {
|
|
value := strings.TrimSpace(*req.Username)
|
|
if len(value) > 64 {
|
|
return AppUser{}, fmt.Errorf("%w: 用户名称不能超过 64 个字符", ErrInvalidArgument)
|
|
}
|
|
sets = append(sets, "username = ?")
|
|
args = append(args, nullableString(value))
|
|
}
|
|
if req.Avatar != nil {
|
|
value := strings.TrimSpace(*req.Avatar)
|
|
if len(value) > 512 {
|
|
return AppUser{}, fmt.Errorf("%w: 头像不能超过 512 个字符", ErrInvalidArgument)
|
|
}
|
|
sets = append(sets, "avatar = ?")
|
|
args = append(args, nullableString(value))
|
|
}
|
|
if req.Gender != nil {
|
|
value := strings.ToLower(strings.TrimSpace(*req.Gender))
|
|
if len(value) > 32 {
|
|
return AppUser{}, fmt.Errorf("%w: 性别不能超过 32 个字符", ErrInvalidArgument)
|
|
}
|
|
sets = append(sets, "gender = ?")
|
|
args = append(args, nullableString(value))
|
|
}
|
|
if req.Country != nil {
|
|
country, regionID, err := s.resolveCountryRegion(ctx, *req.Country)
|
|
if err != nil {
|
|
return AppUser{}, err
|
|
}
|
|
sets = append(sets, "country = ?", "region_id = ?")
|
|
args = append(args, nullableString(country), nullableInt64(regionID))
|
|
}
|
|
if len(sets) == 0 {
|
|
return s.GetUser(ctx, userID)
|
|
}
|
|
|
|
sets = append(sets, "updated_at_ms = ?")
|
|
appCode := appctx.FromContext(ctx)
|
|
args = append(args, nowMillis())
|
|
args = append(args, appCode, userID)
|
|
result, err := s.userDB.ExecContext(ctx, "UPDATE users SET "+strings.Join(sets, ", ")+" WHERE app_code = ? AND user_id = ?", args...)
|
|
if err != nil {
|
|
return AppUser{}, err
|
|
}
|
|
affected, err := result.RowsAffected()
|
|
if err != nil {
|
|
return AppUser{}, err
|
|
}
|
|
if affected == 0 {
|
|
return AppUser{}, ErrNotFound
|
|
}
|
|
return s.GetUser(ctx, userID)
|
|
}
|
|
|
|
func (s *Service) SetUserStatus(ctx context.Context, userID int64, status string) (AppUser, error) {
|
|
if s.userDB == nil {
|
|
return AppUser{}, fmt.Errorf("user mysql is not configured")
|
|
}
|
|
status = strings.ToLower(strings.TrimSpace(status))
|
|
if status != "active" && status != "banned" && status != "disabled" {
|
|
return AppUser{}, fmt.Errorf("%w: 状态不正确", ErrInvalidArgument)
|
|
}
|
|
tx, err := s.userDB.BeginTx(ctx, nil)
|
|
if err != nil {
|
|
return AppUser{}, err
|
|
}
|
|
defer rollbackIfOpen(tx)
|
|
|
|
now := nowMillis()
|
|
appCode := appctx.FromContext(ctx)
|
|
result, err := tx.ExecContext(ctx, `UPDATE users SET status = ?, updated_at_ms = ? WHERE app_code = ? AND user_id = ?`, status, now, appCode, userID)
|
|
if err != nil {
|
|
return AppUser{}, err
|
|
}
|
|
affected, err := result.RowsAffected()
|
|
if err != nil {
|
|
return AppUser{}, err
|
|
}
|
|
if affected == 0 {
|
|
return AppUser{}, ErrNotFound
|
|
}
|
|
if status == "banned" || status == "disabled" {
|
|
if _, err := tx.ExecContext(ctx, `
|
|
UPDATE auth_sessions
|
|
SET revoked_at_ms = ?, revoked_reason = ?, revoked_request_id = '', revoked_by = ?, updated_at_ms = ?
|
|
WHERE app_code = ? AND user_id = ? AND revoked_at_ms IS NULL
|
|
`, now, "ADMIN_USER_STATUS", "admin", now, appCode, userID); err != nil {
|
|
return AppUser{}, err
|
|
}
|
|
}
|
|
if err := tx.Commit(); err != nil {
|
|
return AppUser{}, err
|
|
}
|
|
return s.GetUser(ctx, userID)
|
|
}
|
|
|
|
func (s *Service) SetPassword(ctx context.Context, userID int64, password string) error {
|
|
if s.userDB == nil {
|
|
return fmt.Errorf("user mysql is not configured")
|
|
}
|
|
password = strings.TrimSpace(password)
|
|
if len(password) > 128 {
|
|
return fmt.Errorf("%w: 密码不能超过 128 个字符", ErrInvalidArgument)
|
|
}
|
|
hash, err := security.HashPassword(password)
|
|
if err != nil {
|
|
return fmt.Errorf("%w: %v", ErrInvalidArgument, err)
|
|
}
|
|
var exists int
|
|
appCode := appctx.FromContext(ctx)
|
|
if err := s.userDB.QueryRowContext(ctx, `SELECT 1 FROM users WHERE app_code = ? AND user_id = ?`, appCode, userID).Scan(&exists); err != nil {
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return ErrNotFound
|
|
}
|
|
return err
|
|
}
|
|
now := nowMillis()
|
|
_, err = s.userDB.ExecContext(ctx, `
|
|
INSERT INTO password_accounts (app_code, user_id, password_hash, hash_alg, created_at_ms, updated_at_ms)
|
|
VALUES (?, ?, ?, 'bcrypt', ?, ?)
|
|
ON DUPLICATE KEY UPDATE password_hash = VALUES(password_hash), hash_alg = VALUES(hash_alg), updated_at_ms = VALUES(updated_at_ms)
|
|
`, appCode, userID, hash, now, now)
|
|
return err
|
|
}
|
|
|
|
func (s *Service) resolveCountryRegion(ctx context.Context, input string) (string, int64, error) {
|
|
country := normalizeCountryCode(input)
|
|
if country == "" {
|
|
return "", 0, fmt.Errorf("%w: 请选择国家", ErrInvalidArgument)
|
|
}
|
|
if !validCountryCode(country) {
|
|
return "", 0, fmt.Errorf("%w: 国家码必须是 2 到 3 位大写字母", ErrInvalidArgument)
|
|
}
|
|
|
|
appCode := appctx.FromContext(ctx)
|
|
var canonical string
|
|
var regionID int64
|
|
err := s.userDB.QueryRowContext(ctx, `
|
|
SELECT c.country_code,
|
|
COALESCE((
|
|
SELECT rg.region_id
|
|
FROM region_countries rc
|
|
INNER JOIN regions rg ON rg.region_id = rc.region_id
|
|
WHERE rc.app_code = c.app_code
|
|
AND rg.app_code = c.app_code
|
|
AND rc.country_code = c.country_code
|
|
AND rc.status = 'active'
|
|
AND rg.status = 'active'
|
|
AND rg.region_code NOT IN ('Australia and New Zealand', 'Caribbean', 'Melanesia', 'Micronesia', 'Polynesia', 'Southern Africa', 'UNSPECIFIED')
|
|
ORDER BY rg.sort_order ASC, rg.region_id ASC
|
|
LIMIT 1
|
|
), 0)
|
|
FROM countries c
|
|
WHERE c.app_code = ? AND c.country_code = ? AND c.enabled = TRUE
|
|
`, appCode, country).Scan(&canonical, ®ionID)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return "", 0, fmt.Errorf("%w: 国家不存在或已停用", ErrInvalidArgument)
|
|
}
|
|
if err != nil {
|
|
return "", 0, err
|
|
}
|
|
return canonical, regionID, nil
|
|
}
|
|
|
|
func (s *Service) fillBalances(ctx context.Context, items []AppUser, userIDs []int64) error {
|
|
if s.walletDB == nil || len(userIDs) == 0 {
|
|
return nil
|
|
}
|
|
placeholders := strings.TrimRight(strings.Repeat("?,", len(userIDs)), ",")
|
|
args := make([]any, 0, len(userIDs)+2)
|
|
args = append(args, appctx.FromContext(ctx))
|
|
for _, id := range userIDs {
|
|
args = append(args, id)
|
|
}
|
|
args = append(args, "COIN", "DIAMOND")
|
|
rows, err := s.walletDB.QueryContext(ctx, fmt.Sprintf(`
|
|
SELECT user_id, asset_type, available_amount
|
|
FROM wallet_accounts
|
|
WHERE app_code = ? AND user_id IN (%s) AND asset_type IN (?, ?)
|
|
`, placeholders), args...)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer rows.Close()
|
|
|
|
index := make(map[int64]int, len(userIDs))
|
|
for i, id := range userIDs {
|
|
index[id] = i
|
|
}
|
|
for rows.Next() {
|
|
var userID int64
|
|
var assetType string
|
|
var amount int64
|
|
if err := rows.Scan(&userID, &assetType, &amount); err != nil {
|
|
return err
|
|
}
|
|
i, ok := index[userID]
|
|
if !ok {
|
|
continue
|
|
}
|
|
switch strings.ToUpper(assetType) {
|
|
case "COIN":
|
|
items[i].Coin = amount
|
|
case "DIAMOND":
|
|
items[i].Diamond = amount
|
|
}
|
|
}
|
|
return rows.Err()
|
|
}
|
|
|
|
func normalizeListQuery(query listQuery) listQuery {
|
|
if query.Page < 1 {
|
|
query.Page = 1
|
|
}
|
|
if query.PageSize < 1 {
|
|
query.PageSize = 20
|
|
}
|
|
if query.PageSize > 100 {
|
|
query.PageSize = 100
|
|
}
|
|
query.Keyword = strings.TrimSpace(query.Keyword)
|
|
query.Status = strings.ToLower(strings.TrimSpace(query.Status))
|
|
return query
|
|
}
|
|
|
|
func countRows(ctx context.Context, db *sql.DB, whereSQL string, args ...any) (int64, error) {
|
|
var total int64
|
|
err := db.QueryRowContext(ctx, "SELECT COUNT(*) "+whereSQL, args...).Scan(&total)
|
|
return total, err
|
|
}
|
|
|
|
func offset(page int, pageSize int) int {
|
|
return (page - 1) * pageSize
|
|
}
|
|
|
|
func nullableString(value string) sql.NullString {
|
|
return sql.NullString{String: value, Valid: value != ""}
|
|
}
|
|
|
|
func nullableInt64(value int64) sql.NullInt64 {
|
|
return sql.NullInt64{Int64: value, Valid: value > 0}
|
|
}
|
|
|
|
func normalizeCountryCode(value string) string {
|
|
return strings.ToUpper(strings.TrimSpace(value))
|
|
}
|
|
|
|
func validCountryCode(value string) bool {
|
|
if len(value) < 2 || len(value) > 3 {
|
|
return false
|
|
}
|
|
for _, char := range value {
|
|
if char < 'A' || char > 'Z' {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func nowMillis() int64 {
|
|
return time.Now().UnixMilli()
|
|
}
|
|
|
|
func rollbackIfOpen(tx *sql.Tx) {
|
|
_ = tx.Rollback()
|
|
}
|