diff --git a/server/admin/internal/modules/appuser/handler.go b/server/admin/internal/modules/appuser/handler.go index c95873e2..3b67b485 100644 --- a/server/admin/internal/modules/appuser/handler.go +++ b/server/admin/internal/modules/appuser/handler.go @@ -155,10 +155,12 @@ func (h *Handler) setUserStatus(c *gin.Context, status string, action string, su func parseListQuery(c *gin.Context) listQuery { options := shared.ListOptions(c) return normalizeListQuery(listQuery{ - Page: options.Page, - PageSize: options.PageSize, - Keyword: options.Keyword, - Status: options.Status, + Page: options.Page, + PageSize: options.PageSize, + Keyword: options.Keyword, + Status: options.Status, + SortBy: firstQuery(c, "sort_by", "sortBy"), + SortDirection: firstQuery(c, "sort_direction", "sortDirection", "order"), }) } diff --git a/server/admin/internal/modules/appuser/request.go b/server/admin/internal/modules/appuser/request.go index 5fec41ce..f87e9aca 100644 --- a/server/admin/internal/modules/appuser/request.go +++ b/server/admin/internal/modules/appuser/request.go @@ -1,10 +1,12 @@ package appuser type listQuery struct { - Page int - PageSize int - Keyword string - Status string + Page int + PageSize int + Keyword string + Status string + SortBy string + SortDirection string } type loginLogQuery struct { diff --git a/server/admin/internal/modules/appuser/service.go b/server/admin/internal/modules/appuser/service.go index 8bcac220..9cced22b 100644 --- a/server/admin/internal/modules/appuser/service.go +++ b/server/admin/internal/modules/appuser/service.go @@ -6,6 +6,7 @@ import ( "errors" "fmt" "log/slog" + "sort" "strconv" "strings" "time" @@ -88,6 +89,11 @@ func (s *Service) ListUsers(ctx context.Context, query listQuery) ([]AppUser, in if err != nil { return nil, 0, err } + if query.SortBy == "coin" { + // 金币余额来自 wallet 库,必须先补齐当前筛选结果的余额再排序分页,避免只按当前页局部重排。 + items, err := s.listUsersSortedByCoin(ctx, query, whereSQL, args) + return items, total, err + } rows, err := s.userDB.QueryContext(ctx, fmt.Sprintf(` SELECT u.user_id, u.current_display_user_id, @@ -128,16 +134,85 @@ func (s *Service) ListUsers(ctx context.Context, query listQuery) ([]AppUser, in 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 + %s LIMIT ? OFFSET ? - `, whereSQL), append(args, query.PageSize, offset(query.Page, query.PageSize))...) + `, whereSQL, appUserOrderSQL(query)), 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) + 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 + } + return items, total, nil +} + +func (s *Service) listUsersSortedByCoin(ctx context.Context, query listQuery, whereSQL string, args []any) ([]AppUser, error) { + 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 + `, whereSQL), 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) + return paginateAppUsers(items, query.Page, query.PageSize), 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 @@ -157,19 +232,16 @@ func (s *Service) ListUsers(ctx context.Context, query listQuery) ([]AppUser, in &item.UpdatedAtMs, &item.LastActiveAtMs, ); err != nil { - return nil, 0, err + 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, 0, err + return nil, nil, err } - if err := s.fillBalances(ctx, items, userIDs); err != nil { - return nil, 0, err - } - return items, total, nil + return items, userIDs, nil } func (s *Service) GetUser(ctx context.Context, userID int64) (AppUser, error) { @@ -462,44 +534,60 @@ func (s *Service) fillBalances(ctx context.Context, items []AppUser, userIDs []i if s.walletDB == nil || len(userIDs) == 0 { return nil } - placeholders := strings.TrimRight(strings.Repeat("?,", len(userIDs)), ",") - args := make([]any, 0, len(userIDs)+1) - args = append(args, appctx.FromContext(ctx)) - for _, id := range userIDs { - args = append(args, id) - } - args = append(args, "COIN") - 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 = ? - `, 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 { + 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] + placeholders := strings.TrimRight(strings.Repeat("?,", len(chunk)), ",") + args := make([]any, 0, len(chunk)+2) + args = append(args, appCode) + for _, id := range chunk { + args = append(args, id) + } + args = append(args, "COIN") + 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 = ? + `, placeholders), args...) + if err != nil { return err } - i, ok := index[userID] - if !ok { - continue + for rows.Next() { + var userID int64 + var assetType string + var amount int64 + if err := rows.Scan(&userID, &assetType, &amount); err != nil { + _ = rows.Close() + return err + } + i, ok := index[userID] + if !ok { + continue + } + switch strings.ToUpper(assetType) { + case "COIN": + items[i].Coin = amount + } } - switch strings.ToUpper(assetType) { - case "COIN": - items[i].Coin = amount + if err := rows.Err(); err != nil { + _ = rows.Close() + return err + } + if err := rows.Close(); err != nil { + return err } } - return rows.Err() + return nil } func normalizeListQuery(query listQuery) listQuery { @@ -514,9 +602,70 @@ func normalizeListQuery(query listQuery) listQuery { } query.Keyword = strings.TrimSpace(query.Keyword) query.Status = strings.ToLower(strings.TrimSpace(query.Status)) + query.SortBy = normalizeAppUserSortBy(query.SortBy) + query.SortDirection = normalizeSortDirection(query.SortDirection) return query } +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 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) diff --git a/server/admin/internal/modules/appuser/service_test.go b/server/admin/internal/modules/appuser/service_test.go new file mode 100644 index 00000000..8f68b1cc --- /dev/null +++ b/server/admin/internal/modules/appuser/service_test.go @@ -0,0 +1,45 @@ +package appuser + +import "testing" + +func TestNormalizeListQuerySort(t *testing.T) { + query := normalizeListQuery(listQuery{Page: 0, PageSize: 1000, SortBy: "coins", SortDirection: "asc"}) + if query.Page != 1 || query.PageSize != 100 || query.SortBy != "coin" || query.SortDirection != "asc" { + t.Fatalf("coin sort query mismatch: %+v", query) + } + + query = normalizeListQuery(listQuery{SortBy: "createdAtMs", SortDirection: "unknown"}) + if query.SortBy != "created_at" || query.SortDirection != "desc" { + t.Fatalf("created sort default mismatch: %+v", query) + } +} + +func TestAppUserOrderSQL(t *testing.T) { + asc := appUserOrderSQL(listQuery{SortBy: "created_at", SortDirection: "asc"}) + if asc != "ORDER BY u.created_at_ms ASC, u.user_id ASC" { + t.Fatalf("created asc order mismatch: %s", asc) + } + + desc := appUserOrderSQL(listQuery{SortBy: "created_at", SortDirection: "desc"}) + if desc != "ORDER BY u.created_at_ms DESC, u.user_id DESC" { + t.Fatalf("created desc order mismatch: %s", desc) + } +} + +func TestSortAppUsersByCoin(t *testing.T) { + items := []AppUser{ + {UserID: "1001", Coin: 10}, + {UserID: "1003", Coin: 10}, + {UserID: "1002", Coin: 80}, + } + + sortAppUsersByCoin(items, "desc") + if got := []string{items[0].UserID, items[1].UserID, items[2].UserID}; got[0] != "1002" || got[1] != "1003" || got[2] != "1001" { + t.Fatalf("coin desc order mismatch: %+v", got) + } + + sortAppUsersByCoin(items, "asc") + if got := []string{items[0].UserID, items[1].UserID, items[2].UserID}; got[0] != "1001" || got[1] != "1003" || got[2] != "1002" { + t.Fatalf("coin asc order mismatch: %+v", got) + } +}