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 {
|
func parseListQuery(c *gin.Context) listQuery {
|
||||||
options := shared.ListOptions(c)
|
options := shared.ListOptions(c)
|
||||||
return normalizeListQuery(listQuery{
|
return normalizeListQuery(listQuery{
|
||||||
Page: options.Page,
|
Page: options.Page,
|
||||||
PageSize: options.PageSize,
|
PageSize: options.PageSize,
|
||||||
Keyword: options.Keyword,
|
Keyword: options.Keyword,
|
||||||
Status: options.Status,
|
Status: options.Status,
|
||||||
|
SortBy: firstQuery(c, "sort_by", "sortBy"),
|
||||||
|
SortDirection: firstQuery(c, "sort_direction", "sortDirection", "order"),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,10 +1,12 @@
|
|||||||
package appuser
|
package appuser
|
||||||
|
|
||||||
type listQuery struct {
|
type listQuery struct {
|
||||||
Page int
|
Page int
|
||||||
PageSize int
|
PageSize int
|
||||||
Keyword string
|
Keyword string
|
||||||
Status string
|
Status string
|
||||||
|
SortBy string
|
||||||
|
SortDirection string
|
||||||
}
|
}
|
||||||
|
|
||||||
type loginLogQuery struct {
|
type loginLogQuery struct {
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@ -88,6 +89,11 @@ func (s *Service) ListUsers(ctx context.Context, query listQuery) ([]AppUser, in
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, 0, err
|
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(`
|
rows, err := s.userDB.QueryContext(ctx, fmt.Sprintf(`
|
||||||
SELECT u.user_id,
|
SELECT u.user_id,
|
||||||
u.current_display_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)
|
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
|
||||||
ORDER BY u.created_at_ms DESC, u.user_id DESC
|
%s
|
||||||
LIMIT ? OFFSET ?
|
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 {
|
if err != nil {
|
||||||
return nil, 0, err
|
return nil, 0, err
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
|
|
||||||
items := make([]AppUser, 0, query.PageSize)
|
items, userIDs, err := scanAppUserRows(rows, query.PageSize)
|
||||||
userIDs := make([]int64, 0, 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() {
|
for rows.Next() {
|
||||||
var item AppUser
|
var item AppUser
|
||||||
var userID int64
|
var userID int64
|
||||||
@ -157,19 +232,16 @@ func (s *Service) ListUsers(ctx context.Context, query listQuery) ([]AppUser, in
|
|||||||
&item.UpdatedAtMs,
|
&item.UpdatedAtMs,
|
||||||
&item.LastActiveAtMs,
|
&item.LastActiveAtMs,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, 0, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
item.UserID = strconv.FormatInt(userID, 10)
|
item.UserID = strconv.FormatInt(userID, 10)
|
||||||
items = append(items, item)
|
items = append(items, item)
|
||||||
userIDs = append(userIDs, userID)
|
userIDs = append(userIDs, userID)
|
||||||
}
|
}
|
||||||
if err := rows.Err(); err != nil {
|
if err := rows.Err(); err != nil {
|
||||||
return nil, 0, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
if err := s.fillBalances(ctx, items, userIDs); err != nil {
|
return items, userIDs, nil
|
||||||
return nil, 0, err
|
|
||||||
}
|
|
||||||
return items, total, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) GetUser(ctx context.Context, userID int64) (AppUser, error) {
|
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 {
|
if s.walletDB == nil || len(userIDs) == 0 {
|
||||||
return nil
|
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))
|
index := make(map[int64]int, len(userIDs))
|
||||||
for i, id := range userIDs {
|
for i, id := range userIDs {
|
||||||
index[id] = i
|
index[id] = i
|
||||||
}
|
}
|
||||||
for rows.Next() {
|
appCode := appctx.FromContext(ctx)
|
||||||
var userID int64
|
const chunkSize = 500
|
||||||
var assetType string
|
for start := 0; start < len(userIDs); start += chunkSize {
|
||||||
var amount int64
|
end := start + chunkSize
|
||||||
if err := rows.Scan(&userID, &assetType, &amount); err != nil {
|
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
|
return err
|
||||||
}
|
}
|
||||||
i, ok := index[userID]
|
for rows.Next() {
|
||||||
if !ok {
|
var userID int64
|
||||||
continue
|
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) {
|
if err := rows.Err(); err != nil {
|
||||||
case "COIN":
|
_ = rows.Close()
|
||||||
items[i].Coin = amount
|
return err
|
||||||
|
}
|
||||||
|
if err := rows.Close(); err != nil {
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return rows.Err()
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func normalizeListQuery(query listQuery) listQuery {
|
func normalizeListQuery(query listQuery) listQuery {
|
||||||
@ -514,9 +602,70 @@ func normalizeListQuery(query listQuery) listQuery {
|
|||||||
}
|
}
|
||||||
query.Keyword = strings.TrimSpace(query.Keyword)
|
query.Keyword = strings.TrimSpace(query.Keyword)
|
||||||
query.Status = strings.ToLower(strings.TrimSpace(query.Status))
|
query.Status = strings.ToLower(strings.TrimSpace(query.Status))
|
||||||
|
query.SortBy = normalizeAppUserSortBy(query.SortBy)
|
||||||
|
query.SortDirection = normalizeSortDirection(query.SortDirection)
|
||||||
return query
|
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) {
|
func countRows(ctx context.Context, db *sql.DB, whereSQL string, args ...any) (int64, error) {
|
||||||
var total int64
|
var total int64
|
||||||
err := db.QueryRowContext(ctx, "SELECT COUNT(*) "+whereSQL, args...).Scan(&total)
|
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