feat: update app user admin fields

This commit is contained in:
zhx 2026-06-05 13:58:16 +08:00
parent b1a9d818aa
commit c56186ab48
4 changed files with 245 additions and 47 deletions

View File

@ -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"),
})
}

View File

@ -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 {

View File

@ -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)

View File

@ -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)
}
}