2026-06-29 14:26:06 +08:00

983 lines
33 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package appuser
import (
"context"
"database/sql"
"errors"
"fmt"
"log/slog"
"sort"
"strconv"
"strings"
"time"
"hyapp-admin-server/internal/appctx"
"hyapp-admin-server/internal/integration/activityclient"
"hyapp-admin-server/internal/integration/userclient"
"hyapp-admin-server/internal/modules/shared"
"hyapp-admin-server/internal/security"
activityv1 "hyapp.local/api/proto/activity/v1"
)
var (
ErrInvalidArgument = errors.New("invalid argument")
ErrNotFound = errors.New("not found")
)
type Service struct {
userClient userclient.Client
activityClient activityclient.Client
adminDB *sql.DB
userDB *sql.DB
walletDB *sql.DB
}
type AppUser struct {
Avatar string `json:"avatar"`
Balances []AppUserAssetBalance `json:"balances,omitempty"`
Coin int64 `json:"coin"`
Country string `json:"country"`
CountryDisplayName string `json:"countryDisplayName"`
CountryName string `json:"countryName"`
CreatedAtMs int64 `json:"createdAtMs"`
DefaultDisplayUserID string `json:"defaultDisplayUserId"`
Diamond int64 `json:"diamond"`
DisplayUserID string `json:"displayUserId"`
EquippedResources []AppUserResource `json:"equippedResources,omitempty"`
Gender string `json:"gender"`
LastActiveAtMs int64 `json:"lastActiveAtMs"`
PrettyDisplayUserID string `json:"prettyDisplayUserId"`
PrettyID string `json:"prettyId"`
RegionID int64 `json:"regionId"`
RegionName string `json:"regionName"`
Resources []AppUserResource `json:"resources,omitempty"`
Status string `json:"status"`
UpdatedAtMs int64 `json:"updatedAtMs"`
UserID string `json:"userId"`
Username string `json:"username"`
VIP AppUserVIP `json:"vip"`
}
type AppUserAssetBalance struct {
AssetType string `json:"assetType"`
AvailableAmount int64 `json:"availableAmount"`
FrozenAmount int64 `json:"frozenAmount"`
TotalAmount int64 `json:"totalAmount"`
Version int64 `json:"version"`
UpdatedAtMs int64 `json:"updatedAtMs"`
}
type AppUserVIP struct {
Level int32 `json:"level"`
Name string `json:"name"`
Active bool `json:"active"`
StartedAtMs int64 `json:"startedAtMs"`
ExpiresAtMs int64 `json:"expiresAtMs"`
UpdatedAtMs int64 `json:"updatedAtMs"`
}
type AppUserResource struct {
EntitlementID string `json:"entitlementId"`
ResourceID int64 `json:"resourceId"`
ResourceCode string `json:"resourceCode"`
ResourceType string `json:"resourceType"`
Name string `json:"name"`
Status string `json:"status"`
Quantity int64 `json:"quantity"`
RemainingQuantity int64 `json:"remainingQuantity"`
EffectiveAtMs int64 `json:"effectiveAtMs"`
ExpiresAtMs int64 `json:"expiresAtMs"`
SourceGrantID string `json:"sourceGrantId"`
AssetURL string `json:"assetUrl"`
PreviewURL string `json:"previewUrl"`
AnimationURL string `json:"animationUrl"`
Equipped bool `json:"equipped"`
CreatedAtMs int64 `json:"createdAtMs"`
UpdatedAtMs int64 `json:"updatedAtMs"`
}
type SetUserStatusResult struct {
User AppUser `json:"user"`
RevokedSessionCount int64 `json:"revokedSessionCount"`
AccessTokenRevoked bool `json:"accessTokenRevoked"`
AccessTokenError string `json:"accessTokenRevokeError,omitempty"`
IMKicked bool `json:"imKicked"`
IMKickError string `json:"imKickError,omitempty"`
RoomEvicted bool `json:"roomEvicted"`
RoomID string `json:"roomId,omitempty"`
RTCKicked bool `json:"rtcKicked"`
RTCKickError string `json:"rtcKickError,omitempty"`
RoomEvictError string `json:"roomEvictError,omitempty"`
}
func NewService(userClient userclient.Client, activityClient activityclient.Client, adminDB *sql.DB, userDB *sql.DB, walletDB *sql.DB) *Service {
return &Service{userClient: userClient, activityClient: activityClient, adminDB: adminDB, 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)
nowMs := nowMillis()
whereSQL, args := appUserListWhereSQLAt(appCode, query, nowMs)
total, err := countRows(ctx, s.userDB, whereSQL, args...)
if err != nil {
return nil, 0, err
}
if query.SortBy == "coin" {
// 金币余额来自 wallet 库,必须先补齐当前筛选结果的余额再排序分页,避免只按当前页局部重排。
items, err := s.listUsersSortedByCoin(ctx, query, whereSQL, args, nowMs)
return items, total, err
}
rows, err := s.userDB.QueryContext(ctx, fmt.Sprintf(`
SELECT u.user_id,
u.current_display_user_id,
u.default_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
%s
%s
LIMIT ? OFFSET ?
`, appUserPrettySelectColumns(), whereSQL, appUserOrderSQL(query)), append(appUserSelectArgs(nowMs, args), query.PageSize, offset(query.Page, query.PageSize))...)
if err != nil {
return nil, 0, err
}
defer rows.Close()
items, userIDs, err := scanAppUserRows(rows, query.PageSize)
if err != nil {
return nil, 0, err
}
if err := s.fillBalances(ctx, items, userIDs); err != nil {
return nil, 0, err
}
if err := s.fillVIPLevels(ctx, items, userIDs); err != nil {
return nil, 0, err
}
return items, total, nil
}
func (s *Service) listUsersSortedByCoin(ctx context.Context, query listQuery, whereSQL string, args []any, nowMs int64) ([]AppUser, error) {
rows, err := s.userDB.QueryContext(ctx, fmt.Sprintf(`
SELECT u.user_id,
u.current_display_user_id,
u.default_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
%s
`, appUserPrettySelectColumns(), whereSQL), appUserSelectArgs(nowMs, args)...)
if err != nil {
return nil, err
}
defer rows.Close()
items, userIDs, err := scanAppUserRows(rows, query.PageSize)
if err != nil {
return nil, err
}
if err := s.fillBalances(ctx, items, userIDs); err != nil {
return nil, err
}
sortAppUsersByCoin(items, query.SortDirection)
pagedItems := paginateAppUsers(items, query.Page, query.PageSize)
if err := s.fillVIPLevels(ctx, pagedItems, appUserNumericIDs(pagedItems)); err != nil {
return nil, err
}
return pagedItems, nil
}
func scanAppUserRows(rows *sql.Rows, capacity int) ([]AppUser, []int64, error) {
items := make([]AppUser, 0, capacity)
userIDs := make([]int64, 0, capacity)
for rows.Next() {
var item AppUser
var userID int64
if err := rows.Scan(
&userID,
&item.DisplayUserID,
&item.DefaultDisplayUserID,
&item.Username,
&item.Avatar,
&item.Gender,
&item.Country,
&item.CountryName,
&item.CountryDisplayName,
&item.RegionID,
&item.RegionName,
&item.Status,
&item.CreatedAtMs,
&item.UpdatedAtMs,
&item.LastActiveAtMs,
&item.PrettyDisplayUserID,
&item.PrettyID,
); err != nil {
return nil, nil, err
}
item.UserID = strconv.FormatInt(userID, 10)
items = append(items, item)
userIDs = append(userIDs, userID)
}
if err := rows.Err(); err != nil {
return nil, nil, err
}
return items, userIDs, 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)
nowMs := nowMillis()
err := s.userDB.QueryRowContext(ctx, `
SELECT u.user_id,
u.current_display_user_id,
u.default_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)
)
`+appUserPrettySelectColumns()+`
FROM users u
WHERE u.app_code = ? AND u.user_id = ?
`, nowMs, nowMs, appCode, userID).Scan(
&scannedUserID,
&item.DisplayUserID,
&item.DefaultDisplayUserID,
&item.Username,
&item.Avatar,
&item.Gender,
&item.Country,
&item.CountryName,
&item.CountryDisplayName,
&item.RegionID,
&item.RegionName,
&item.Status,
&item.CreatedAtMs,
&item.UpdatedAtMs,
&item.LastActiveAtMs,
&item.PrettyDisplayUserID,
&item.PrettyID,
)
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
}
if err := s.fillVIPLevels(ctx, items, []int64{userID}); err != nil {
return AppUser{}, err
}
if err := s.fillUserResources(ctx, items, userID); err != nil {
return AppUser{}, err
}
return items[0], nil
}
func (s *Service) UpdateUser(ctx context.Context, userID int64, adminUserID int64, requestID string, req updateUserRequest) (AppUser, error) {
if s.userDB == nil {
return AppUser{}, fmt.Errorf("user mysql is not configured")
}
sets := make([]string, 0, 4)
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 len(sets) > 0 {
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
}
}
if req.Country != nil {
if s.userClient == nil {
return AppUser{}, fmt.Errorf("user client is not configured")
}
change, err := s.userClient.AdminChangeUserCountry(ctx, userclient.AdminChangeUserCountryRequest{
RequestID: strings.TrimSpace(requestID),
Caller: "hyapp-admin-server",
TargetUserID: userID,
Country: *req.Country,
AdminUserID: adminUserID,
Reason: "admin_user_country_changed",
})
if err != nil {
return AppUser{}, err
}
if change != nil && change.RegionChanged {
s.removeOldRegionBroadcastMemberBestEffort(ctx, userID, change.OldRegionID, change.NewRegionID, strings.TrimSpace(requestID), "admin_user_country_changed")
}
}
if len(sets) == 0 && req.Country == nil {
return s.GetUser(ctx, userID)
}
return s.GetUser(ctx, userID)
}
func (s *Service) removeOldRegionBroadcastMemberBestEffort(ctx context.Context, userID int64, oldRegionID int64, newRegionID int64, requestID string, reason string) {
if s == nil || s.activityClient == nil || userID <= 0 || oldRegionID <= 0 || oldRegionID == newRegionID {
return
}
_, err := s.activityClient.RemoveRegionBroadcastMember(ctx, &activityv1.RemoveRegionBroadcastMemberRequest{
Meta: &activityv1.RequestMeta{
RequestId: strings.TrimSpace(requestID),
Caller: "hyapp-admin-server",
SentAtMs: nowMillis(),
AppCode: appctx.FromContext(ctx),
},
UserId: userID,
RegionId: oldRegionID,
Reason: reason,
})
if err != nil {
// 后台国家更新已经写入 usersIM 成员移除是外部副作用失败不回滚资料callback 会阻止用户再次加入旧区域群。
slog.WarnContext(ctx, "admin_im_region_group_member_remove_failed",
slog.Int64("user_id", userID),
slog.Int64("old_region_id", oldRegionID),
slog.Int64("new_region_id", newRegionID),
slog.String("request_id", requestID),
slog.String("error", err.Error()),
)
}
}
func (s *Service) SetUserStatus(ctx context.Context, userID int64, status string, operatorUserID int64, requestID string) (SetUserStatusResult, error) {
status = strings.ToLower(strings.TrimSpace(status))
if status != "active" && status != "banned" && status != "disabled" {
return SetUserStatusResult{}, fmt.Errorf("%w: 状态不正确", ErrInvalidArgument)
}
if s.userClient == nil {
return SetUserStatusResult{}, fmt.Errorf("user service client is not configured")
}
result, err := s.userClient.SetUserStatus(ctx, userclient.SetUserStatusRequest{
RequestID: strings.TrimSpace(requestID),
Caller: "hyapp-admin-server",
TargetUserID: userID,
Status: status,
OperatorType: "admin",
OperatorUserID: operatorUserID,
Reason: "admin_app_user_status",
})
if err != nil {
return SetUserStatusResult{}, err
}
user, err := s.GetUser(ctx, userID)
if err != nil {
return SetUserStatusResult{}, err
}
output := SetUserStatusResult{User: user}
if result != nil {
output.RevokedSessionCount = result.RevokedSessionCount
output.AccessTokenRevoked = result.AccessTokenRevoked
output.AccessTokenError = result.AccessTokenError
output.IMKicked = result.IMKicked
output.IMKickError = result.IMKickError
output.RoomEvicted = result.RoomEvicted
output.RoomID = result.RoomID
output.RTCKicked = result.RTCKicked
output.RTCKickError = result.RTCKickError
output.RoomEvictError = result.RoomEvictError
}
return output, nil
}
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) fillBalances(ctx context.Context, items []AppUser, userIDs []int64) error {
if s.walletDB == nil || len(userIDs) == 0 {
return nil
}
index := make(map[int64]int, len(userIDs))
for i, id := range userIDs {
index[id] = i
}
appCode := appctx.FromContext(ctx)
const chunkSize = 500
for start := 0; start < len(userIDs); start += chunkSize {
end := start + chunkSize
if end > len(userIDs) {
end = len(userIDs)
}
// 用户列表金币排序会对完整筛选集补余额,按块查询可以避免 IN 参数过长导致后台列表失败。
chunk := userIDs[start:end]
args := make([]any, 0, len(chunk)+1)
args = append(args, appCode)
for _, id := range chunk {
args = append(args, id)
}
rows, err := s.walletDB.QueryContext(ctx, fmt.Sprintf(`
SELECT user_id, asset_type, available_amount, frozen_amount, version, updated_at_ms
FROM wallet_accounts
WHERE app_code = ? AND user_id IN (%s)
ORDER BY user_id ASC, asset_type ASC
`, placeholders(len(chunk))), args...)
if err != nil {
return err
}
for rows.Next() {
var userID int64
var balance AppUserAssetBalance
if err := rows.Scan(
&userID,
&balance.AssetType,
&balance.AvailableAmount,
&balance.FrozenAmount,
&balance.Version,
&balance.UpdatedAtMs,
); err != nil {
_ = rows.Close()
return err
}
i, ok := index[userID]
if !ok {
continue
}
balance.AssetType = strings.ToUpper(strings.TrimSpace(balance.AssetType))
balance.TotalAmount = balance.AvailableAmount + balance.FrozenAmount
// 钱包资产明细作为详情页“全部资产”的余额来源;金币和钻石仍同步到顶层字段,兼容列表排序和旧页面读取。
items[i].Balances = append(items[i].Balances, balance)
switch balance.AssetType {
case "COIN":
items[i].Coin = balance.AvailableAmount
case "DIAMOND":
items[i].Diamond = balance.AvailableAmount
}
}
if err := rows.Err(); err != nil {
_ = rows.Close()
return err
}
if err := rows.Close(); err != nil {
return err
}
}
return nil
}
func (s *Service) fillVIPLevels(ctx context.Context, items []AppUser, userIDs []int64) error {
if s.walletDB == nil || len(userIDs) == 0 {
return nil
}
index := make(map[int64]int, len(userIDs))
for i, id := range userIDs {
index[id] = i
}
appCode := appctx.FromContext(ctx)
nowMs := nowMillis()
const chunkSize = 500
for start := 0; start < len(userIDs); start += chunkSize {
end := start + chunkSize
if end > len(userIDs) {
end = len(userIDs)
}
chunk := userIDs[start:end]
args := make([]any, 0, len(chunk)+1)
args = append(args, appCode)
for _, id := range chunk {
args = append(args, id)
}
rows, err := s.walletDB.QueryContext(ctx, fmt.Sprintf(`
SELECT m.user_id, m.level, COALESCE(v.name, m.name), m.started_at_ms, m.expires_at_ms, m.updated_at_ms
FROM user_vip_memberships m
LEFT JOIN vip_levels v ON v.app_code = m.app_code AND v.level = m.level
WHERE m.app_code = ? AND m.user_id IN (%s)
`, placeholders(len(chunk))), args...)
if err != nil {
return err
}
for rows.Next() {
var userID int64
var vip AppUserVIP
if err := rows.Scan(&userID, &vip.Level, &vip.Name, &vip.StartedAtMs, &vip.ExpiresAtMs, &vip.UpdatedAtMs); err != nil {
_ = rows.Close()
return err
}
i, ok := index[userID]
if !ok {
continue
}
// 这里刻意沿用 wallet-service GetMyVip 的展示口径:只看 level 和 expires_at_ms 计算当前 VIP
// 过期会员仍保留历史行,但后台用户列表不展示为有效等级。
vip.Active = vip.Level > 0 && vip.ExpiresAtMs > nowMs
if !vip.Active {
vip.Level = 0
vip.Name = ""
}
items[i].VIP = vip
}
if err := rows.Err(); err != nil {
_ = rows.Close()
return err
}
if err := rows.Close(); err != nil {
return err
}
}
return nil
}
func (s *Service) fillUserResources(ctx context.Context, items []AppUser, userID int64) error {
if s.walletDB == nil || len(items) == 0 || userID <= 0 {
return nil
}
appCode := appctx.FromContext(ctx)
nowMs := nowMillis()
rows, err := s.walletDB.QueryContext(ctx, `
SELECT e.entitlement_id, e.resource_id, r.resource_code, r.resource_type, r.name, e.status,
e.quantity, e.remaining_quantity, e.effective_at_ms, e.expires_at_ms, e.source_grant_id,
COALESCE(r.asset_url, ''), COALESCE(r.preview_url, ''), COALESCE(r.animation_url, ''),
CASE WHEN eq.entitlement_id IS NULL THEN FALSE ELSE TRUE END AS equipped,
e.created_at_ms, e.updated_at_ms
FROM user_resource_entitlements e
JOIN resources r ON r.app_code = e.app_code AND r.resource_id = e.resource_id
LEFT JOIN user_resource_equipment eq
ON eq.app_code = e.app_code
AND eq.user_id = e.user_id
AND eq.resource_id = e.resource_id
AND eq.entitlement_id = e.entitlement_id
WHERE e.app_code = ? AND e.user_id = ?
AND e.status = 'active'
AND e.effective_at_ms <= ? AND (e.expires_at_ms = 0 OR e.expires_at_ms > ?)
AND e.remaining_quantity > 0
AND r.status = 'active'
ORDER BY equipped DESC, r.resource_type ASC, e.updated_at_ms DESC, e.created_at_ms DESC
`, appCode, userID, nowMs, nowMs)
if err != nil {
return err
}
defer rows.Close()
resources := make([]AppUserResource, 0)
equipped := make([]AppUserResource, 0)
for rows.Next() {
var resource AppUserResource
if err := rows.Scan(
&resource.EntitlementID,
&resource.ResourceID,
&resource.ResourceCode,
&resource.ResourceType,
&resource.Name,
&resource.Status,
&resource.Quantity,
&resource.RemainingQuantity,
&resource.EffectiveAtMs,
&resource.ExpiresAtMs,
&resource.SourceGrantID,
&resource.AssetURL,
&resource.PreviewURL,
&resource.AnimationURL,
&resource.Equipped,
&resource.CreatedAtMs,
&resource.UpdatedAtMs,
); err != nil {
return err
}
resources = append(resources, resource)
if resource.Equipped {
// 装备状态只从 user_resource_equipment 读取;前端可以直接展示当前佩戴,不需要再按类型猜测。
equipped = append(equipped, resource)
}
}
if err := rows.Err(); err != nil {
return err
}
items[0].Resources = resources
items[0].EquippedResources = equipped
return nil
}
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.Country = normalizeCountryCode(query.Country)
query.Keyword = strings.TrimSpace(query.Keyword)
query.UserFilter.UserID = strings.TrimSpace(query.UserFilter.UserID)
query.UserFilter.DisplayUserID = strings.TrimSpace(query.UserFilter.DisplayUserID)
query.UserFilter.Username = strings.TrimSpace(query.UserFilter.Username)
if query.RegionID < 0 {
query.RegionID = 0
query.RegionIDSet = false
}
if query.StartMs < 0 {
query.StartMs = 0
}
if query.EndMs < 0 {
query.EndMs = 0
}
query.Status = strings.ToLower(strings.TrimSpace(query.Status))
query.SortBy = normalizeAppUserSortBy(query.SortBy)
query.SortDirection = normalizeSortDirection(query.SortDirection)
return query
}
func appUserListWhereSQL(appCode string, query listQuery) (string, []any) {
return appUserListWhereSQLAt(appCode, query, nowMillis())
}
func appUserListWhereSQLAt(appCode string, query listQuery, nowMs int64) (string, []any) {
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.UserFilter.IsEmpty() {
matchSQL, matchArgs := shared.UserIdentityFieldsSQL("u", "u.user_id", query.UserFilter, nowMs)
whereSQL += " AND " + matchSQL
args = append(args, matchArgs...)
} else if query.Keyword != "" {
matchSQL, matchArgs := shared.UserIdentityKeywordSQL("u", "u.user_id", query.Keyword, nowMs)
whereSQL += " AND " + matchSQL
args = append(args, matchArgs...)
}
if query.Country != "" {
// 国家筛选用 users.country 的 ISO code 精确命中;前端传展示名时不会被猜测转换,避免误筛到错误国家。
whereSQL += " AND UPPER(u.country) = ?"
args = append(args, query.Country)
}
if query.RegionIDSet {
if query.RegionID == 0 {
// 列表展示里无区域或无有效业务区域都会显示为 GLOBAL筛选 GLOBAL 时保持同一套展示口径。
whereSQL += " AND (COALESCE(u.region_id, 0) = 0 OR NOT " + appUserValidRegionExistsSQL("u") + ")"
} else {
whereSQL += " AND u.region_id = ?"
args = append(args, query.RegionID)
}
}
if query.StartMs > 0 {
// 时间区间筛选的是用户创建时间,和列表“创建 / 活跃”列的第一行保持一致。
whereSQL += " AND u.created_at_ms >= ?"
args = append(args, query.StartMs)
}
if query.EndMs > 0 {
whereSQL += " AND u.created_at_ms < ?"
args = append(args, query.EndMs)
}
return whereSQL, args
}
func appUserValidRegionExistsSQL(userAlias string) string {
return fmt.Sprintf(`EXISTS (
SELECT 1 FROM regions rg
WHERE rg.app_code = %[1]s.app_code
AND rg.region_id = %[1]s.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
)`, userAlias)
}
func appUserPrettySelectColumns() string {
return `,
COALESCE((
SELECT lease.display_user_id
FROM pretty_display_user_id_leases lease
WHERE lease.app_code = u.app_code
AND lease.user_id = u.user_id
AND lease.status = 'active'
AND (COALESCE(lease.expires_at_ms, 0) = 0 OR lease.expires_at_ms > ?)
ORDER BY lease.created_at_ms DESC, lease.lease_id DESC
LIMIT 1
), ''),
COALESCE((
SELECT pdi.pretty_id
FROM pretty_display_user_id_leases lease
JOIN pretty_display_ids pdi
ON pdi.app_code = lease.app_code
AND pdi.assigned_lease_id = lease.lease_id
AND pdi.status = 'assigned'
WHERE lease.app_code = u.app_code
AND lease.user_id = u.user_id
AND lease.status = 'active'
AND (COALESCE(lease.expires_at_ms, 0) = 0 OR lease.expires_at_ms > ?)
ORDER BY lease.created_at_ms DESC, lease.lease_id DESC
LIMIT 1
), '')`
}
func appUserSelectArgs(nowMs int64, whereArgs []any) []any {
// 用户资料页展示“原始短号 + 靓号”时,靓号以租约表为准读取,不能只依赖 users.current_display_user_id。
// 本地 initdb 或历史修复脚本可能只重放 users 快照active lease 和 pretty_display_ids 才是当前靓号归属的审计来源。
args := make([]any, 0, len(whereArgs)+2)
args = append(args, nowMs, nowMs)
args = append(args, whereArgs...)
return args
}
func normalizeAppUserSortBy(value string) string {
switch strings.ToLower(strings.TrimSpace(value)) {
case "coin", "coins":
return "coin"
case "created_at", "createdat", "created_at_ms", "createdatms", "created":
return "created_at"
default:
return "created_at"
}
}
func normalizeSortDirection(value string) string {
switch strings.ToLower(strings.TrimSpace(value)) {
case "asc":
return "asc"
default:
return "desc"
}
}
func appUserOrderSQL(query listQuery) string {
if query.SortBy == "created_at" && query.SortDirection == "asc" {
return "ORDER BY u.created_at_ms ASC, u.user_id ASC"
}
return "ORDER BY u.created_at_ms DESC, u.user_id DESC"
}
func sortAppUsersByCoin(items []AppUser, direction string) {
sort.SliceStable(items, func(i, j int) bool {
left := items[i]
right := items[j]
if left.Coin != right.Coin {
if direction == "asc" {
return left.Coin < right.Coin
}
return left.Coin > right.Coin
}
// 金币相同的时候用内部用户 ID 做稳定排序,避免同余额用户在翻页和刷新时抖动。
leftID, _ := strconv.ParseInt(left.UserID, 10, 64)
rightID, _ := strconv.ParseInt(right.UserID, 10, 64)
if direction == "asc" {
return leftID < rightID
}
return leftID > rightID
})
}
func paginateAppUsers(items []AppUser, page int, pageSize int) []AppUser {
start := offset(page, pageSize)
if start >= len(items) {
return []AppUser{}
}
end := start + pageSize
if end > len(items) {
end = len(items)
}
return items[start:end]
}
func appUserNumericIDs(items []AppUser) []int64 {
ids := make([]int64, 0, len(items))
for _, item := range items {
id, err := strconv.ParseInt(item.UserID, 10, 64)
if err != nil || id <= 0 {
continue
}
ids = append(ids, id)
}
return ids
}
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.Null[string] {
return sql.Null[string]{V: value, Valid: value != ""}
}
func normalizeCountryCode(value string) string {
return strings.ToUpper(strings.TrimSpace(value))
}
func nowMillis() int64 {
return time.Now().UnixMilli()
}
func rollbackIfOpen(tx *sql.Tx) {
_ = tx.Rollback()
}