权限相关

This commit is contained in:
zhx 2026-06-15 10:44:18 +08:00
parent 7744232add
commit 152bdf232e
4 changed files with 219 additions and 12 deletions

View File

@ -122,6 +122,13 @@ func (r *Reader) ListBDProfiles(ctx context.Context, query listQuery, role strin
if err != nil { if err != nil {
return nil, 0, err 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(` rows, err := r.db.QueryContext(ctx, fmt.Sprintf(`
SELECT bp.user_id, bp.role, `+userCountryRegionIDSQL("user_region")+`, COALESCE(bp.parent_leader_user_id, 0), 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, 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 %s
ORDER BY bp.created_at_ms DESC, bp.user_id DESC ORDER BY bp.created_at_ms DESC, bp.user_id DESC
LIMIT ? OFFSET ? %s
`, whereSQL), append(args, query.PageSize, offset(query.Page, query.PageSize))...) `, whereSQL, limitSQL), queryArgs...)
if err != nil { if err != nil {
return nil, 0, err 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 { if err := r.fillBDProfileSalaryWallets(ctx, role, items); err != nil {
return nil, 0, err return nil, 0, err
} }
if query.SortBy == sortBySalaryWallet {
sortBDProfilesBySalaryWallet(items, query.SortDirection)
items = paginateBDProfiles(items, query.Page, query.PageSize)
}
return items, total, nil return items, total, nil
} }
@ -225,6 +236,13 @@ func (r *Reader) listBDLeaderProfiles(ctx context.Context, query listQuery) ([]*
if err != nil { if err != nil {
return nil, 0, err 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(` rows, err := r.db.QueryContext(ctx, fmt.Sprintf(`
SELECT bp.user_id, 'bd_leader', `+userCountryRegionIDSQL("user_region")+`, 0, 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, 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 %s
ORDER BY bp.created_at_ms DESC, bp.user_id DESC ORDER BY bp.created_at_ms DESC, bp.user_id DESC
LIMIT ? OFFSET ? %s
`, whereSQL), append(args, query.PageSize, offset(query.Page, query.PageSize))...) `, whereSQL, limitSQL), queryArgs...)
if err != nil { if err != nil {
return nil, 0, err 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 { if err := r.fillBDProfileSalaryWallets(ctx, salaryWalletRoleBDLeader, items); err != nil {
return nil, 0, err return nil, 0, err
} }
if query.SortBy == sortBySalaryWallet {
sortBDProfilesBySalaryWallet(items, query.SortDirection)
items = paginateBDProfiles(items, query.Page, query.PageSize)
}
return items, total, nil return items, total, nil
} }
@ -579,6 +601,13 @@ func (r *Reader) ListAgencies(ctx context.Context, query listQuery) ([]*userclie
if err != nil { if err != nil {
return nil, 0, err 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(` rows, err := r.db.QueryContext(ctx, fmt.Sprintf(`
SELECT a.agency_id, a.owner_user_id, `+userCountryRegionIDSQL("owner_region")+`, a.parent_bd_user_id, 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, 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")+` COALESCE(parent_bd.avatar, ''), `+userCountryRegionNameSQL("owner_region")+`
%s %s
ORDER BY a.created_at_ms DESC, a.agency_id DESC ORDER BY a.created_at_ms DESC, a.agency_id DESC
LIMIT ? OFFSET ? %s
`, whereSQL), append(args, query.PageSize, offset(query.Page, query.PageSize))...) `, whereSQL, limitSQL), queryArgs...)
if err != nil { if err != nil {
return nil, 0, err 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 { if err := r.fillAgencySalaryWallets(ctx, items); err != nil {
return nil, 0, err return nil, 0, err
} }
if query.SortBy == sortBySalaryWallet {
sortAgenciesBySalaryWallet(items, query.SortDirection)
items = paginateAgencies(items, query.Page, query.PageSize)
}
return items, total, nil return items, total, nil
} }
@ -676,7 +709,8 @@ func (r *Reader) ListHostProfiles(ctx context.Context, query listQuery) ([]*user
} }
limitSQL := "LIMIT ? OFFSET ?" limitSQL := "LIMIT ? OFFSET ?"
queryArgs := append(args, query.PageSize, offset(query.Page, query.PageSize)) queryArgs := append(args, query.PageSize, offset(query.Page, query.PageSize))
if query.SortBy == "diamond" { if query.SortBy == sortByDiamond || query.SortBy == sortBySalaryWallet {
// 主播钻石和工资余额都是列表外部聚合值;排序时取全量匹配集,聚合后稳定排序再分页。
limitSQL = "" limitSQL = ""
queryArgs = args queryArgs = args
} }
@ -739,10 +773,14 @@ func (r *Reader) ListHostProfiles(ctx context.Context, query listQuery) ([]*user
if err := r.fillHostSalaryWallets(ctx, items); err != nil { if err := r.fillHostSalaryWallets(ctx, items); err != nil {
return nil, 0, err return nil, 0, err
} }
if query.SortBy == "diamond" { if query.SortBy == sortByDiamond {
sortHostProfilesByDiamond(items, query.SortDirection) sortHostProfilesByDiamond(items, query.SortDirection)
items = paginateHostProfiles(items, query.Page, query.PageSize) 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 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) { func sortHostProfilesByDiamond(items []*userclient.HostProfile, direction string) {
sort.SliceStable(items, func(i, j int) bool { sort.SliceStable(items, func(i, j int) bool {
if items[i].Diamond == items[j].Diamond { 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 { func paginateCoinSellerListItems(items []*CoinSellerListItem, page int, pageSize int) []*CoinSellerListItem {
if page < 1 { if page < 1 {
page = 1 page = 1
@ -1161,6 +1265,42 @@ func paginateCoinSellerListItems(items []*CoinSellerListItem, page int, pageSize
return items[start:end] 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 { func paginateHostProfiles(items []*userclient.HostProfile, page int, pageSize int) []*userclient.HostProfile {
if page < 1 { if page < 1 {
page = 1 page = 1

View File

@ -20,6 +20,7 @@ const (
paidCurrencyUSDT = "USDT" paidCurrencyUSDT = "USDT"
sortByDiamond = "diamond" sortByDiamond = "diamond"
sortByMerchantBalance = "merchant_balance" sortByMerchantBalance = "merchant_balance"
sortBySalaryWallet = "salary_wallet"
sortByTotalRechargeUSDT = "total_recharge_usdt" sortByTotalRechargeUSDT = "total_recharge_usdt"
bdLeaderPositionAliasMaxRunes = 64 bdLeaderPositionAliasMaxRunes = 64
) )
@ -589,7 +590,7 @@ func normalizeListQuery(query listQuery) listQuery {
query.Status = strings.ToLower(strings.TrimSpace(query.Status)) query.Status = strings.ToLower(strings.TrimSpace(query.Status))
query.SortBy = strings.ToLower(strings.TrimSpace(query.SortBy)) query.SortBy = strings.ToLower(strings.TrimSpace(query.SortBy))
switch query.SortBy { switch query.SortBy {
case sortByDiamond, sortByMerchantBalance, sortByTotalRechargeUSDT: case sortByDiamond, sortByMerchantBalance, sortBySalaryWallet, sortByTotalRechargeUSDT:
default: default:
query.SortBy = "" query.SortBy = ""
query.SortDirection = "" query.SortDirection = ""

View File

@ -3,11 +3,13 @@ package hostorg
import ( import (
"strings" "strings"
"testing" "testing"
"hyapp-admin-server/internal/integration/userclient"
) )
// TestNormalizeListQueryAllowsCoinSellerComputedSorts 锁定币商余额和累充 USDT 排序字段会被后台列表查询接受 // TestNormalizeListQueryAllowsComputedSorts 锁定后台列表支持的计算排序字段不会在查询归一化时被清掉
func TestNormalizeListQueryAllowsCoinSellerComputedSorts(t *testing.T) { func TestNormalizeListQueryAllowsComputedSorts(t *testing.T) {
for _, sortBy := range []string{sortByMerchantBalance, sortByTotalRechargeUSDT} { for _, sortBy := range []string{sortByMerchantBalance, sortBySalaryWallet, sortByTotalRechargeUSDT} {
query := normalizeListQuery(listQuery{Page: 1, PageSize: 50, SortBy: sortBy, SortDirection: "asc"}) query := normalizeListQuery(listQuery{Page: 1, PageSize: 50, SortBy: sortBy, SortDirection: "asc"})
if query.SortBy != sortBy || query.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) 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) 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)
}
}

View File

@ -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';