diff --git a/server/admin/internal/modules/hostorg/reader.go b/server/admin/internal/modules/hostorg/reader.go index 00f2c486..b9e3f903 100644 --- a/server/admin/internal/modules/hostorg/reader.go +++ b/server/admin/internal/modules/hostorg/reader.go @@ -122,6 +122,13 @@ func (r *Reader) ListBDProfiles(ctx context.Context, query listQuery, role strin if err != nil { return nil, 0, err } + limitSQL := "LIMIT ? OFFSET ?" + queryArgs := append(args, query.PageSize, offset(query.Page, query.PageSize)) + if query.SortBy == sortBySalaryWallet { + // 工资钱包余额来自 wallet-service 读模型,不能只排序 SQL 当前页;先取出所有匹配身份,补齐余额后再分页。 + limitSQL = "" + queryArgs = args + } rows, err := r.db.QueryContext(ctx, fmt.Sprintf(` SELECT bp.user_id, bp.role, `+userCountryRegionIDSQL("user_region")+`, COALESCE(bp.parent_leader_user_id, 0), bp.status, bp.created_by_user_id, bp.created_at_ms, bp.updated_at_ms, @@ -140,8 +147,8 @@ func (r *Reader) ListBDProfiles(ctx context.Context, query listQuery, role strin ) %s ORDER BY bp.created_at_ms DESC, bp.user_id DESC - LIMIT ? OFFSET ? - `, whereSQL), append(args, query.PageSize, offset(query.Page, query.PageSize))...) + %s + `, whereSQL, limitSQL), queryArgs...) if err != nil { return nil, 0, err } @@ -189,6 +196,10 @@ func (r *Reader) ListBDProfiles(ctx context.Context, query listQuery, role strin if err := r.fillBDProfileSalaryWallets(ctx, role, items); err != nil { return nil, 0, err } + if query.SortBy == sortBySalaryWallet { + sortBDProfilesBySalaryWallet(items, query.SortDirection) + items = paginateBDProfiles(items, query.Page, query.PageSize) + } return items, total, nil } @@ -225,6 +236,13 @@ func (r *Reader) listBDLeaderProfiles(ctx context.Context, query listQuery) ([]* if err != nil { return nil, 0, err } + limitSQL := "LIMIT ? OFFSET ?" + queryArgs := append(args, query.PageSize, offset(query.Page, query.PageSize)) + if query.SortBy == sortBySalaryWallet { + // BD Leader 工资余额同样在 wallet-service;排序必须等 Admin 工资钱包资产填充完成后再裁剪当前页。 + limitSQL = "" + queryArgs = args + } rows, err := r.db.QueryContext(ctx, fmt.Sprintf(` SELECT bp.user_id, 'bd_leader', `+userCountryRegionIDSQL("user_region")+`, 0, bp.status, bp.created_by_user_id, bp.created_at_ms, bp.updated_at_ms, @@ -242,8 +260,8 @@ func (r *Reader) listBDLeaderProfiles(ctx context.Context, query listQuery) ([]* ) %s ORDER BY bp.created_at_ms DESC, bp.user_id DESC - LIMIT ? OFFSET ? - `, whereSQL), append(args, query.PageSize, offset(query.Page, query.PageSize))...) + %s + `, whereSQL, limitSQL), queryArgs...) if err != nil { return nil, 0, err } @@ -289,6 +307,10 @@ func (r *Reader) listBDLeaderProfiles(ctx context.Context, query listQuery) ([]* if err := r.fillBDProfileSalaryWallets(ctx, salaryWalletRoleBDLeader, items); err != nil { return nil, 0, err } + if query.SortBy == sortBySalaryWallet { + sortBDProfilesBySalaryWallet(items, query.SortDirection) + items = paginateBDProfiles(items, query.Page, query.PageSize) + } return items, total, nil } @@ -579,6 +601,13 @@ func (r *Reader) ListAgencies(ctx context.Context, query listQuery) ([]*userclie if err != nil { return nil, 0, err } + limitSQL := "LIMIT ? OFFSET ?" + queryArgs := append(args, query.PageSize, offset(query.Page, query.PageSize)) + if query.SortBy == sortBySalaryWallet { + // Agency 展示的是 owner 的 Agency 工资钱包;先补余额再排序,保证跨页顺序和真实余额一致。 + limitSQL = "" + queryArgs = args + } rows, err := r.db.QueryContext(ctx, fmt.Sprintf(` SELECT a.agency_id, a.owner_user_id, `+userCountryRegionIDSQL("owner_region")+`, a.parent_bd_user_id, a.name, a.status, a.join_enabled, a.max_hosts, @@ -589,8 +618,8 @@ func (r *Reader) ListAgencies(ctx context.Context, query listQuery) ([]*userclie COALESCE(parent_bd.avatar, ''), `+userCountryRegionNameSQL("owner_region")+` %s ORDER BY a.created_at_ms DESC, a.agency_id DESC - LIMIT ? OFFSET ? - `, whereSQL), append(args, query.PageSize, offset(query.Page, query.PageSize))...) + %s + `, whereSQL, limitSQL), queryArgs...) if err != nil { return nil, 0, err } @@ -629,6 +658,10 @@ func (r *Reader) ListAgencies(ctx context.Context, query listQuery) ([]*userclie if err := r.fillAgencySalaryWallets(ctx, items); err != nil { return nil, 0, err } + if query.SortBy == sortBySalaryWallet { + sortAgenciesBySalaryWallet(items, query.SortDirection) + items = paginateAgencies(items, query.Page, query.PageSize) + } return items, total, nil } @@ -676,7 +709,8 @@ func (r *Reader) ListHostProfiles(ctx context.Context, query listQuery) ([]*user } limitSQL := "LIMIT ? OFFSET ?" queryArgs := append(args, query.PageSize, offset(query.Page, query.PageSize)) - if query.SortBy == "diamond" { + if query.SortBy == sortByDiamond || query.SortBy == sortBySalaryWallet { + // 主播钻石和工资余额都是列表外部聚合值;排序时取全量匹配集,聚合后稳定排序再分页。 limitSQL = "" queryArgs = args } @@ -739,10 +773,14 @@ func (r *Reader) ListHostProfiles(ctx context.Context, query listQuery) ([]*user if err := r.fillHostSalaryWallets(ctx, items); err != nil { return nil, 0, err } - if query.SortBy == "diamond" { + if query.SortBy == sortByDiamond { sortHostProfilesByDiamond(items, query.SortDirection) items = paginateHostProfiles(items, query.Page, query.PageSize) } + if query.SortBy == sortBySalaryWallet { + sortHostProfilesBySalaryWallet(items, query.SortDirection) + items = paginateHostProfiles(items, query.Page, query.PageSize) + } return items, total, nil } @@ -1131,6 +1169,50 @@ func coinSellerSortValue(item *CoinSellerListItem, sortBy string) int64 { } } +// sortBDProfilesBySalaryWallet 按当前身份的工资钱包可用余额排序;余额相同时保留 SQL 默认创建时间顺序,避免同值行跳动。 +func sortBDProfilesBySalaryWallet(items []*userclient.BDProfile, direction string) { + sort.SliceStable(items, func(i, j int) bool { + left := bdProfileSalaryWalletValue(items[i]) + right := bdProfileSalaryWalletValue(items[j]) + if left == right { + return false + } + if direction == "asc" { + return left < right + } + return left > right + }) +} + +func bdProfileSalaryWalletValue(item *userclient.BDProfile) int64 { + if item == nil { + return 0 + } + return item.SalaryWallet.AvailableAmount +} + +// sortAgenciesBySalaryWallet 按 Agency owner 对应的 Agency 工资钱包余额排序,不混用 owner 的其他身份钱包。 +func sortAgenciesBySalaryWallet(items []*userclient.Agency, direction string) { + sort.SliceStable(items, func(i, j int) bool { + left := agencySalaryWalletValue(items[i]) + right := agencySalaryWalletValue(items[j]) + if left == right { + return false + } + if direction == "asc" { + return left < right + } + return left > right + }) +} + +func agencySalaryWalletValue(item *userclient.Agency) int64 { + if item == nil { + return 0 + } + return item.SalaryWallet.AvailableAmount +} + func sortHostProfilesByDiamond(items []*userclient.HostProfile, direction string) { sort.SliceStable(items, func(i, j int) bool { if items[i].Diamond == items[j].Diamond { @@ -1143,6 +1225,28 @@ func sortHostProfilesByDiamond(items []*userclient.HostProfile, direction string }) } +// sortHostProfilesBySalaryWallet 按主播工资钱包可用余额排序,和钻石排序一样在分页前处理跨页顺序。 +func sortHostProfilesBySalaryWallet(items []*userclient.HostProfile, direction string) { + sort.SliceStable(items, func(i, j int) bool { + left := hostProfileSalaryWalletValue(items[i]) + right := hostProfileSalaryWalletValue(items[j]) + if left == right { + return false + } + if direction == "asc" { + return left < right + } + return left > right + }) +} + +func hostProfileSalaryWalletValue(item *userclient.HostProfile) int64 { + if item == nil { + return 0 + } + return item.SalaryWallet.AvailableAmount +} + func paginateCoinSellerListItems(items []*CoinSellerListItem, page int, pageSize int) []*CoinSellerListItem { if page < 1 { page = 1 @@ -1161,6 +1265,42 @@ func paginateCoinSellerListItems(items []*CoinSellerListItem, page int, pageSize return items[start:end] } +func paginateBDProfiles(items []*userclient.BDProfile, page int, pageSize int) []*userclient.BDProfile { + if page < 1 { + page = 1 + } + if pageSize < 1 { + pageSize = 20 + } + start := (page - 1) * pageSize + if start >= len(items) { + return []*userclient.BDProfile{} + } + end := start + pageSize + if end > len(items) { + end = len(items) + } + return items[start:end] +} + +func paginateAgencies(items []*userclient.Agency, page int, pageSize int) []*userclient.Agency { + if page < 1 { + page = 1 + } + if pageSize < 1 { + pageSize = 20 + } + start := (page - 1) * pageSize + if start >= len(items) { + return []*userclient.Agency{} + } + end := start + pageSize + if end > len(items) { + end = len(items) + } + return items[start:end] +} + func paginateHostProfiles(items []*userclient.HostProfile, page int, pageSize int) []*userclient.HostProfile { if page < 1 { page = 1 diff --git a/server/admin/internal/modules/hostorg/service.go b/server/admin/internal/modules/hostorg/service.go index b420e899..d33e3bec 100644 --- a/server/admin/internal/modules/hostorg/service.go +++ b/server/admin/internal/modules/hostorg/service.go @@ -20,6 +20,7 @@ const ( paidCurrencyUSDT = "USDT" sortByDiamond = "diamond" sortByMerchantBalance = "merchant_balance" + sortBySalaryWallet = "salary_wallet" sortByTotalRechargeUSDT = "total_recharge_usdt" bdLeaderPositionAliasMaxRunes = 64 ) @@ -589,7 +590,7 @@ func normalizeListQuery(query listQuery) listQuery { query.Status = strings.ToLower(strings.TrimSpace(query.Status)) query.SortBy = strings.ToLower(strings.TrimSpace(query.SortBy)) switch query.SortBy { - case sortByDiamond, sortByMerchantBalance, sortByTotalRechargeUSDT: + case sortByDiamond, sortByMerchantBalance, sortBySalaryWallet, sortByTotalRechargeUSDT: default: query.SortBy = "" query.SortDirection = "" diff --git a/server/admin/internal/modules/hostorg/service_test.go b/server/admin/internal/modules/hostorg/service_test.go index 5c671bfc..e864a367 100644 --- a/server/admin/internal/modules/hostorg/service_test.go +++ b/server/admin/internal/modules/hostorg/service_test.go @@ -3,11 +3,13 @@ package hostorg import ( "strings" "testing" + + "hyapp-admin-server/internal/integration/userclient" ) -// TestNormalizeListQueryAllowsCoinSellerComputedSorts 锁定币商余额和累充 USDT 排序字段会被后台列表查询接受。 -func TestNormalizeListQueryAllowsCoinSellerComputedSorts(t *testing.T) { - for _, sortBy := range []string{sortByMerchantBalance, sortByTotalRechargeUSDT} { +// TestNormalizeListQueryAllowsComputedSorts 锁定后台列表支持的计算排序字段不会在查询归一化时被清掉。 +func TestNormalizeListQueryAllowsComputedSorts(t *testing.T) { + for _, sortBy := range []string{sortByMerchantBalance, sortBySalaryWallet, sortByTotalRechargeUSDT} { query := normalizeListQuery(listQuery{Page: 1, PageSize: 50, SortBy: sortBy, SortDirection: "asc"}) if query.SortBy != sortBy || query.SortDirection != "asc" { t.Fatalf("expected sort %s asc to be kept, got sort_by=%q direction=%q", sortBy, query.SortBy, query.SortDirection) @@ -96,3 +98,45 @@ func TestSortCoinSellerListItemsComputedFields(t *testing.T) { t.Fatalf("total recharge usdt asc order mismatch: %v", got) } } + +// TestSortSalaryWalletComputedFields 覆盖四个组织列表的工资钱包余额排序;这些余额来自 wallet-service,必须在分页前排序。 +func TestSortSalaryWalletComputedFields(t *testing.T) { + bds := []*userclient.BDProfile{ + {UserID: 1, SalaryWallet: userclient.SalaryWallet{AvailableAmount: 100}}, + {UserID: 2, SalaryWallet: userclient.SalaryWallet{AvailableAmount: 300}}, + {UserID: 3, SalaryWallet: userclient.SalaryWallet{AvailableAmount: 200}}, + } + sortBDProfilesBySalaryWallet(bds, "desc") + if got := []int64{bds[0].UserID, bds[1].UserID, bds[2].UserID}; got[0] != 2 || got[1] != 3 || got[2] != 1 { + t.Fatalf("bd salary wallet desc order mismatch: %v", got) + } + if page := paginateBDProfiles(bds, 2, 2); len(page) != 1 || page[0].UserID != 1 { + t.Fatalf("bd salary wallet pagination mismatch: %+v", page) + } + + agencies := []*userclient.Agency{ + {AgencyID: 10, SalaryWallet: userclient.SalaryWallet{AvailableAmount: 100}}, + {AgencyID: 20, SalaryWallet: userclient.SalaryWallet{AvailableAmount: 300}}, + {AgencyID: 30, SalaryWallet: userclient.SalaryWallet{AvailableAmount: 200}}, + } + sortAgenciesBySalaryWallet(agencies, "asc") + if got := []int64{agencies[0].AgencyID, agencies[1].AgencyID, agencies[2].AgencyID}; got[0] != 10 || got[1] != 30 || got[2] != 20 { + t.Fatalf("agency salary wallet asc order mismatch: %v", got) + } + if page := paginateAgencies(agencies, 2, 2); len(page) != 1 || page[0].AgencyID != 20 { + t.Fatalf("agency salary wallet pagination mismatch: %+v", page) + } + + hosts := []*userclient.HostProfile{ + {UserID: 1, SalaryWallet: userclient.SalaryWallet{AvailableAmount: 100}}, + {UserID: 2, SalaryWallet: userclient.SalaryWallet{AvailableAmount: 300}}, + {UserID: 3, SalaryWallet: userclient.SalaryWallet{AvailableAmount: 200}}, + } + sortHostProfilesBySalaryWallet(hosts, "desc") + if got := []int64{hosts[0].UserID, hosts[1].UserID, hosts[2].UserID}; got[0] != 2 || got[1] != 3 || got[2] != 1 { + t.Fatalf("host salary wallet desc order mismatch: %v", got) + } + if page := paginateHostProfiles(hosts, 2, 2); len(page) != 1 || page[0].UserID != 1 { + t.Fatalf("host salary wallet pagination mismatch: %+v", page) + } +} diff --git a/server/admin/migrations/052_coin_seller_exchange_rate_permission.sql b/server/admin/migrations/052_coin_seller_exchange_rate_permission.sql new file mode 100644 index 00000000..6daaf1c4 --- /dev/null +++ b/server/admin/migrations/052_coin_seller_exchange_rate_permission.sql @@ -0,0 +1,22 @@ +SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci; + +SET @now_ms = CAST(UNIX_TIMESTAMP(UTC_TIMESTAMP(3)) * 1000 AS UNSIGNED); + +-- 线上生产环境关闭 bootstrap 和 AutoMigrate,新按钮权限必须通过 migration 落库, +-- 否则前端即使已经上线,也会因为登录权限列表缺少该 code 而隐藏“工资兑换比例”按钮。 +INSERT INTO admin_permissions (name, code, kind, description, created_at_ms, updated_at_ms) VALUES + ('币商工资兑换比例', 'coin-seller:exchange-rate', 'button', '', @now_ms, @now_ms) +ON DUPLICATE KEY UPDATE + name = VALUES(name), + kind = VALUES(kind), + description = VALUES(description), + updated_at_ms = @now_ms; + +-- 平台管理员应拥有全部后台能力;运维管理员也需要维护币商工资转金币比例。 +-- INSERT IGNORE 保持幂等,重复执行不会产生重复绑定。 +INSERT IGNORE INTO admin_role_permissions (role_id, permission_id) +SELECT admin_role.id, admin_permission.id +FROM admin_roles admin_role +JOIN admin_permissions admin_permission +WHERE admin_role.code IN ('platform-admin', 'ops-admin') + AND admin_permission.code = 'coin-seller:exchange-rate';