feat: update app user admin fields
This commit is contained in:
parent
b1a9d818aa
commit
c56186ab48
@ -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"),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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)
|
||||
|
||||
45
server/admin/internal/modules/appuser/service_test.go
Normal file
45
server/admin/internal/modules/appuser/service_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user