1870 lines
63 KiB
Go
1870 lines
63 KiB
Go
package hostorg
|
||
|
||
import (
|
||
"context"
|
||
"database/sql"
|
||
"fmt"
|
||
"sort"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
|
||
"hyapp-admin-server/internal/appctx"
|
||
"hyapp-admin-server/internal/integration/userclient"
|
||
"hyapp-admin-server/internal/modules/shared"
|
||
)
|
||
|
||
type Reader struct {
|
||
db *sql.DB
|
||
walletDB *sql.DB
|
||
adminDB *sql.DB
|
||
}
|
||
|
||
func NewReader(db *sql.DB, walletDB *sql.DB, adminDB *sql.DB) *Reader {
|
||
return &Reader{db: db, walletDB: walletDB, adminDB: adminDB}
|
||
}
|
||
|
||
type CoinSellerListItem struct {
|
||
UserID int64 `json:"userId,string"`
|
||
Status string `json:"status"`
|
||
MerchantAssetType string `json:"merchantAssetType"`
|
||
MerchantBalance int64 `json:"merchantBalance"`
|
||
// TotalRechargeUSDTMicro 是币商 USDT 进货累计付款微单位,只统计 counts_as_seller_recharge 的库存记录。
|
||
TotalRechargeUSDTMicro int64 `json:"totalRechargeUsdtMicro"`
|
||
Contact string `json:"contact"`
|
||
CanManageSubCoinSellers bool `json:"canManageSubCoinSellers"`
|
||
SubCoinSellerCount int64 `json:"subCoinSellerCount"`
|
||
DisplayUserID string `json:"displayUserId"`
|
||
Username string `json:"username"`
|
||
Avatar string `json:"avatar"`
|
||
RegionID int64 `json:"regionId"`
|
||
RegionName string `json:"regionName"`
|
||
CreatedByUserID int64 `json:"createdByUserId"`
|
||
CreatedAtMs int64 `json:"createdAtMs"`
|
||
UpdatedAtMs int64 `json:"updatedAtMs"`
|
||
}
|
||
|
||
type CoinSellerSubApplicationListItem struct {
|
||
ApplicationID int64 `json:"applicationId,string"`
|
||
CommandID string `json:"commandId"`
|
||
Parent UserIdentity `json:"parent"`
|
||
Target UserIdentity `json:"target"`
|
||
Status string `json:"status"`
|
||
RelationID int64 `json:"relationId,string"`
|
||
ReviewedByAdminID int64 `json:"reviewedByAdminId,string"`
|
||
ReviewReason string `json:"reviewReason"`
|
||
ReviewedAtMs int64 `json:"reviewedAtMs"`
|
||
CreatedAtMs int64 `json:"createdAtMs"`
|
||
UpdatedAtMs int64 `json:"updatedAtMs"`
|
||
}
|
||
|
||
type UserIdentity struct {
|
||
UserID int64 `json:"userId,string"`
|
||
DisplayUserID string `json:"displayUserId"`
|
||
Username string `json:"username"`
|
||
Avatar string `json:"avatar"`
|
||
RegionID int64 `json:"regionId"`
|
||
RegionName string `json:"regionName"`
|
||
}
|
||
|
||
type ManagerListItem struct {
|
||
UserID int64 `json:"userId,string"`
|
||
DisplayUserID string `json:"displayUserId"`
|
||
Username string `json:"username"`
|
||
Avatar string `json:"avatar"`
|
||
RegionID int64 `json:"regionId"`
|
||
RegionName string `json:"regionName"`
|
||
Status string `json:"status"`
|
||
Contact string `json:"contact"`
|
||
CanGrantAvatarFrame bool `json:"canGrantAvatarFrame"`
|
||
CanGrantVehicle bool `json:"canGrantVehicle"`
|
||
CanGrantBadge bool `json:"canGrantBadge"`
|
||
CanGrantVIP bool `json:"canGrantVip"`
|
||
CanUpdateUserLevel bool `json:"canUpdateUserLevel"`
|
||
CanAddBDLeader bool `json:"canAddBdLeader"`
|
||
CanAddAdmin bool `json:"canAddAdmin"`
|
||
CanAddSuperadmin bool `json:"canAddSuperadmin"`
|
||
CanBlockUser bool `json:"canBlockUser"`
|
||
CanTransferCountry bool `json:"canTransferUserCountry"`
|
||
BDLeaderCount int64 `json:"bdLeaderCount"`
|
||
LastInvitedAtMs int64 `json:"lastInvitedAtMs"`
|
||
CreatedAtMs int64 `json:"createdAtMs"`
|
||
UpdatedAtMs int64 `json:"updatedAtMs"`
|
||
}
|
||
|
||
type CoinSellerSalaryRateTier struct {
|
||
RegionID int64 `json:"regionId,string"`
|
||
MinUSDMinor int64 `json:"minUsdMinor"`
|
||
MaxUSDMinor int64 `json:"maxUsdMinor"`
|
||
MinUSD string `json:"minUsd"`
|
||
MaxUSD string `json:"maxUsd"`
|
||
CoinPerUSD int64 `json:"coinPerUsd"`
|
||
Status string `json:"status"`
|
||
Enabled bool `json:"enabled"`
|
||
SortOrder int `json:"sortOrder"`
|
||
UpdatedAtMs int64 `json:"updatedAtMs"`
|
||
}
|
||
|
||
func userCountryRegionJoin(userAlias, countryRegionAlias, regionAlias string) string {
|
||
// 组织列表里的区域只用于后台展示、搜索和筛选,必须以用户当前国家归属的 active 区域为准。
|
||
// 身份表不再保存区域;这里始终按用户国家映射 active 区域,避免用户国家调整后列表继续显示旧值。
|
||
return fmt.Sprintf(`
|
||
LEFT JOIN region_countries %s ON %s.app_code = %s.app_code
|
||
AND %s.country_code = %s.country
|
||
AND %s.status = 'active'
|
||
LEFT JOIN regions %s ON %s.app_code = %s.app_code
|
||
AND %s.region_id = %s.region_id
|
||
AND %s.status = 'active'`,
|
||
countryRegionAlias, countryRegionAlias, userAlias,
|
||
countryRegionAlias, userAlias,
|
||
countryRegionAlias,
|
||
regionAlias, regionAlias, countryRegionAlias,
|
||
regionAlias, countryRegionAlias,
|
||
regionAlias,
|
||
)
|
||
}
|
||
|
||
func userCountryRegionIDSQL(regionAlias string) string {
|
||
return fmt.Sprintf("COALESCE(%s.region_id, 0)", regionAlias)
|
||
}
|
||
|
||
func userCountryRegionNameSQL(regionAlias string) string {
|
||
return fmt.Sprintf("COALESCE(%s.name, '')", regionAlias)
|
||
}
|
||
|
||
// ListBDProfiles 只读 user-service 的 BD/BD Leader 事实表,后台列表按角色选择物理表,避免把两种身份重新混在一起。
|
||
func (r *Reader) ListBDProfiles(ctx context.Context, query listQuery, role string) ([]*userclient.BDProfile, int64, error) {
|
||
if r == nil || r.db == nil {
|
||
return nil, 0, errUserDBNotConfigured()
|
||
}
|
||
if role == "bd_leader" {
|
||
return r.listBDLeaderProfiles(ctx, query)
|
||
}
|
||
appCode := appctx.FromContext(ctx)
|
||
whereSQL := `
|
||
FROM bd_profiles bp
|
||
LEFT JOIN users u ON u.app_code = bp.app_code AND u.user_id = bp.user_id
|
||
` + userCountryRegionJoin("u", "user_country_region", "user_region") + `
|
||
LEFT JOIN users parent_leader ON parent_leader.app_code = bp.app_code AND parent_leader.user_id = bp.parent_leader_user_id
|
||
LEFT JOIN users creator ON creator.app_code = bp.app_code AND creator.user_id = bp.created_by_user_id
|
||
WHERE bp.app_code = ? AND bp.role = ?`
|
||
args := []any{appCode, role}
|
||
if query.Status != "" {
|
||
whereSQL += " AND bp.status = ?"
|
||
args = append(args, query.Status)
|
||
}
|
||
if query.RegionID > 0 {
|
||
whereSQL += " AND user_region.region_id = ?"
|
||
args = append(args, query.RegionID)
|
||
}
|
||
if query.ParentLeaderUserID > 0 {
|
||
whereSQL += " AND bp.parent_leader_user_id = ?"
|
||
args = append(args, query.ParentLeaderUserID)
|
||
} else if !query.ParentLeaderFilter.IsEmpty() {
|
||
parentSQL, parentArgs := shared.UserIdentityFieldsSQL("parent_leader", "bp.parent_leader_user_id", query.ParentLeaderFilter, time.Now().UnixMilli())
|
||
whereSQL += " AND " + parentSQL
|
||
args = append(args, parentArgs...)
|
||
}
|
||
if !query.UserFilter.IsEmpty() {
|
||
matchSQL, matchArgs := shared.UserIdentityFieldsSQL("u", "bp.user_id", query.UserFilter, time.Now().UnixMilli())
|
||
whereSQL += " AND " + matchSQL
|
||
args = append(args, matchArgs...)
|
||
} else if keyword := strings.TrimSpace(query.Keyword); keyword != "" {
|
||
like := "%" + keyword + "%"
|
||
matchSQL, matchArgs := shared.UserIdentityKeywordSQL("u", "bp.user_id", keyword, time.Now().UnixMilli())
|
||
whereSQL += " AND (" + matchSQL + " OR user_region.name LIKE ? OR u.contact_info LIKE ?)"
|
||
args = append(args, matchArgs...)
|
||
args = append(args, like, like)
|
||
}
|
||
|
||
total, err := countRows(ctx, r.db, whereSQL, args...)
|
||
if err != nil {
|
||
return nil, 0, err
|
||
}
|
||
limitSQL := "LIMIT ? OFFSET ?"
|
||
queryArgs := append([]any{agencyStatusDeleted}, args...)
|
||
queryArgs = append(queryArgs, query.PageSize, offset(query.Page, query.PageSize))
|
||
if query.SortBy == sortBySalaryWallet {
|
||
// 工资钱包余额来自 wallet-service 读模型,不能只排序 SQL 当前页;先取出所有匹配身份,补齐余额后再分页。
|
||
limitSQL = ""
|
||
queryArgs = append([]any{agencyStatusDeleted}, 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,
|
||
COALESCE(u.current_display_user_id, ''), COALESCE(u.username, ''),
|
||
COALESCE(u.avatar, ''), COALESCE(u.contact_info, ''), `+userCountryRegionNameSQL("user_region")+`,
|
||
COALESCE(parent_leader.current_display_user_id, ''), COALESCE(parent_leader.username, ''),
|
||
COALESCE(parent_leader.avatar, ''),
|
||
COALESCE(creator.current_display_user_id, ''), COALESCE(creator.username, ''),
|
||
COALESCE(creator.avatar, ''),
|
||
(
|
||
SELECT COUNT(1)
|
||
FROM agencies child_agency
|
||
WHERE child_agency.app_code = bp.app_code
|
||
AND child_agency.parent_bd_user_id = bp.user_id
|
||
AND child_agency.status <> ?
|
||
)
|
||
%s
|
||
ORDER BY bp.created_at_ms DESC, bp.user_id DESC
|
||
%s
|
||
`, whereSQL, limitSQL), queryArgs...)
|
||
if err != nil {
|
||
return nil, 0, err
|
||
}
|
||
defer rows.Close()
|
||
|
||
items := make([]*userclient.BDProfile, 0, query.PageSize)
|
||
for rows.Next() {
|
||
item := &userclient.BDProfile{}
|
||
if err := rows.Scan(
|
||
&item.UserID,
|
||
&item.Role,
|
||
&item.RegionID,
|
||
&item.ParentLeaderUserID,
|
||
&item.Status,
|
||
&item.CreatedByUserID,
|
||
&item.CreatedAtMs,
|
||
&item.UpdatedAtMs,
|
||
&item.DisplayUserID,
|
||
&item.Username,
|
||
&item.Avatar,
|
||
&item.Contact,
|
||
&item.RegionName,
|
||
&item.ParentLeaderDisplayID,
|
||
&item.ParentLeaderUsername,
|
||
&item.ParentLeaderAvatar,
|
||
&item.CreatedByDisplayUserID,
|
||
&item.CreatedByUsername,
|
||
&item.CreatedByAvatar,
|
||
&item.AgencyCount,
|
||
); err != nil {
|
||
return nil, 0, err
|
||
}
|
||
items = append(items, item)
|
||
}
|
||
if err := rows.Err(); err != nil {
|
||
return nil, 0, err
|
||
}
|
||
if err := r.fillBDProfileCreators(ctx, items); err != nil {
|
||
return nil, 0, err
|
||
}
|
||
if role == "bd_leader" {
|
||
if err := r.fillBDLeaderPositionAliases(ctx, appCode, items); err != nil {
|
||
return nil, 0, err
|
||
}
|
||
}
|
||
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
|
||
}
|
||
|
||
func (r *Reader) listBDLeaderProfiles(ctx context.Context, query listQuery) ([]*userclient.BDProfile, int64, error) {
|
||
appCode := appctx.FromContext(ctx)
|
||
if query.ParentLeaderUserID > 0 {
|
||
// Leader 身份没有上级 Leader 字段;带父级筛选时直接返回空结果,避免误用普通 BD 的父级语义。
|
||
return []*userclient.BDProfile{}, 0, nil
|
||
}
|
||
whereSQL := `
|
||
FROM bd_leader_profiles bp
|
||
LEFT JOIN users u ON u.app_code = bp.app_code AND u.user_id = bp.user_id
|
||
` + userCountryRegionJoin("u", "user_country_region", "user_region") + `
|
||
LEFT JOIN users creator ON creator.app_code = bp.app_code AND creator.user_id = bp.created_by_user_id
|
||
WHERE bp.app_code = ?`
|
||
args := []any{appCode}
|
||
if query.Status != "" {
|
||
whereSQL += " AND bp.status = ?"
|
||
args = append(args, query.Status)
|
||
}
|
||
if query.RegionID > 0 {
|
||
whereSQL += " AND user_region.region_id = ?"
|
||
args = append(args, query.RegionID)
|
||
}
|
||
if !query.UserFilter.IsEmpty() {
|
||
matchSQL, matchArgs := shared.UserIdentityFieldsSQL("u", "bp.user_id", query.UserFilter, time.Now().UnixMilli())
|
||
whereSQL += " AND " + matchSQL
|
||
args = append(args, matchArgs...)
|
||
} else if keyword := strings.TrimSpace(query.Keyword); keyword != "" {
|
||
like := "%" + keyword + "%"
|
||
matchSQL, matchArgs := shared.UserIdentityKeywordSQL("u", "bp.user_id", keyword, time.Now().UnixMilli())
|
||
whereSQL += " AND (" + matchSQL + " OR user_region.name LIKE ? OR u.contact_info LIKE ?)"
|
||
args = append(args, matchArgs...)
|
||
args = append(args, like, like)
|
||
}
|
||
|
||
total, err := countRows(ctx, r.db, whereSQL, args...)
|
||
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,
|
||
COALESCE(u.current_display_user_id, ''), COALESCE(u.username, ''),
|
||
COALESCE(u.avatar, ''), COALESCE(u.contact_info, ''), `+userCountryRegionNameSQL("user_region")+`,
|
||
'', '', '',
|
||
COALESCE(creator.current_display_user_id, ''), COALESCE(creator.username, ''),
|
||
COALESCE(creator.avatar, ''),
|
||
(
|
||
SELECT COUNT(1)
|
||
FROM bd_profiles child
|
||
WHERE child.app_code = bp.app_code
|
||
AND child.role = 'bd'
|
||
AND child.parent_leader_user_id = bp.user_id
|
||
)
|
||
%s
|
||
ORDER BY bp.created_at_ms DESC, bp.user_id DESC
|
||
%s
|
||
`, whereSQL, limitSQL), queryArgs...)
|
||
if err != nil {
|
||
return nil, 0, err
|
||
}
|
||
defer rows.Close()
|
||
|
||
items := make([]*userclient.BDProfile, 0, query.PageSize)
|
||
for rows.Next() {
|
||
item := &userclient.BDProfile{}
|
||
if err := rows.Scan(
|
||
&item.UserID,
|
||
&item.Role,
|
||
&item.RegionID,
|
||
&item.ParentLeaderUserID,
|
||
&item.Status,
|
||
&item.CreatedByUserID,
|
||
&item.CreatedAtMs,
|
||
&item.UpdatedAtMs,
|
||
&item.DisplayUserID,
|
||
&item.Username,
|
||
&item.Avatar,
|
||
&item.Contact,
|
||
&item.RegionName,
|
||
&item.ParentLeaderDisplayID,
|
||
&item.ParentLeaderUsername,
|
||
&item.ParentLeaderAvatar,
|
||
&item.CreatedByDisplayUserID,
|
||
&item.CreatedByUsername,
|
||
&item.CreatedByAvatar,
|
||
&item.SubBDCount,
|
||
); err != nil {
|
||
return nil, 0, err
|
||
}
|
||
items = append(items, item)
|
||
}
|
||
if err := rows.Err(); err != nil {
|
||
return nil, 0, err
|
||
}
|
||
if err := r.fillBDProfileCreators(ctx, items); err != nil {
|
||
return nil, 0, err
|
||
}
|
||
if err := r.fillBDLeaderPositionAliases(ctx, appCode, items); err != nil {
|
||
return nil, 0, err
|
||
}
|
||
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
|
||
}
|
||
|
||
func (r *Reader) GetBDLeader(ctx context.Context, userID int64) (*userclient.BDProfile, error) {
|
||
if r == nil || r.db == nil {
|
||
return nil, errUserDBNotConfigured()
|
||
}
|
||
appCode := appctx.FromContext(ctx)
|
||
item := &userclient.BDProfile{}
|
||
err := r.db.QueryRowContext(ctx, `
|
||
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,
|
||
COALESCE(u.current_display_user_id, ''), COALESCE(u.username, ''),
|
||
COALESCE(u.avatar, ''), COALESCE(u.contact_info, ''), `+userCountryRegionNameSQL("user_region")+`,
|
||
COALESCE(creator.current_display_user_id, ''), COALESCE(creator.username, ''),
|
||
COALESCE(creator.avatar, ''),
|
||
(
|
||
SELECT COUNT(1)
|
||
FROM bd_profiles child
|
||
WHERE child.app_code = bp.app_code
|
||
AND child.role = 'bd'
|
||
AND child.parent_leader_user_id = bp.user_id
|
||
)
|
||
FROM bd_leader_profiles bp
|
||
LEFT JOIN users u ON u.app_code = bp.app_code AND u.user_id = bp.user_id
|
||
`+userCountryRegionJoin("u", "user_country_region", "user_region")+`
|
||
LEFT JOIN users creator ON creator.app_code = bp.app_code AND creator.user_id = bp.created_by_user_id
|
||
WHERE bp.app_code = ? AND bp.user_id = ?
|
||
`, appCode, userID).Scan(
|
||
&item.UserID,
|
||
&item.Role,
|
||
&item.RegionID,
|
||
&item.ParentLeaderUserID,
|
||
&item.Status,
|
||
&item.CreatedByUserID,
|
||
&item.CreatedAtMs,
|
||
&item.UpdatedAtMs,
|
||
&item.DisplayUserID,
|
||
&item.Username,
|
||
&item.Avatar,
|
||
&item.Contact,
|
||
&item.RegionName,
|
||
&item.CreatedByDisplayUserID,
|
||
&item.CreatedByUsername,
|
||
&item.CreatedByAvatar,
|
||
&item.SubBDCount,
|
||
)
|
||
if err == sql.ErrNoRows {
|
||
return nil, fmt.Errorf("bd leader not found")
|
||
}
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
items := []*userclient.BDProfile{item}
|
||
if err := r.fillBDProfileCreators(ctx, items); err != nil {
|
||
return nil, err
|
||
}
|
||
if err := r.fillBDLeaderPositionAliases(ctx, appCode, items); err != nil {
|
||
return nil, err
|
||
}
|
||
if err := r.fillBDProfileSalaryWallets(ctx, salaryWalletRoleBDLeader, items); err != nil {
|
||
return nil, err
|
||
}
|
||
return item, nil
|
||
}
|
||
|
||
func (r *Reader) fillBDProfileCreators(ctx context.Context, items []*userclient.BDProfile) error {
|
||
if r == nil || r.adminDB == nil || len(items) == 0 {
|
||
return nil
|
||
}
|
||
seen := make(map[int64]struct{}, len(items))
|
||
ids := make([]any, 0, len(items))
|
||
for _, item := range items {
|
||
if item.CreatedByUserID <= 0 {
|
||
continue
|
||
}
|
||
if _, ok := seen[item.CreatedByUserID]; ok {
|
||
continue
|
||
}
|
||
seen[item.CreatedByUserID] = struct{}{}
|
||
ids = append(ids, item.CreatedByUserID)
|
||
}
|
||
if len(ids) == 0 {
|
||
return nil
|
||
}
|
||
|
||
rows, err := r.adminDB.QueryContext(ctx, `
|
||
SELECT id, username, name
|
||
FROM admin_users
|
||
WHERE id IN (`+sqlPlaceholders(len(ids))+`)
|
||
`, ids...)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer rows.Close()
|
||
|
||
type creator struct {
|
||
account string
|
||
name string
|
||
}
|
||
creators := make(map[int64]creator, len(ids))
|
||
for rows.Next() {
|
||
var id int64
|
||
var item creator
|
||
if err := rows.Scan(&id, &item.account, &item.name); err != nil {
|
||
return err
|
||
}
|
||
creators[id] = item
|
||
}
|
||
if err := rows.Err(); err != nil {
|
||
return err
|
||
}
|
||
for _, item := range items {
|
||
creator, ok := creators[item.CreatedByUserID]
|
||
if !ok {
|
||
continue
|
||
}
|
||
item.CreatedByDisplayUserID = creator.account
|
||
item.CreatedByUsername = firstNonBlank(creator.name, creator.account)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func (r *Reader) fillBDLeaderPositionAliases(ctx context.Context, appCode string, items []*userclient.BDProfile) error {
|
||
if r == nil || r.adminDB == nil || len(items) == 0 {
|
||
return nil
|
||
}
|
||
ids := make([]any, 0, len(items))
|
||
for _, item := range items {
|
||
if item.UserID > 0 {
|
||
ids = append(ids, item.UserID)
|
||
}
|
||
}
|
||
if len(ids) == 0 {
|
||
return nil
|
||
}
|
||
args := append([]any{appCode}, ids...)
|
||
rows, err := r.adminDB.QueryContext(ctx, `
|
||
SELECT user_id, position_alias
|
||
FROM admin_bd_leader_position_aliases
|
||
WHERE app_code = ? AND user_id IN (`+sqlPlaceholders(len(ids))+`)
|
||
`, args...)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer rows.Close()
|
||
|
||
aliases := make(map[int64]string, len(ids))
|
||
for rows.Next() {
|
||
var userID int64
|
||
var alias string
|
||
if err := rows.Scan(&userID, &alias); err != nil {
|
||
return err
|
||
}
|
||
aliases[userID] = strings.TrimSpace(alias)
|
||
}
|
||
if err := rows.Err(); err != nil {
|
||
return err
|
||
}
|
||
for _, item := range items {
|
||
item.PositionAlias = aliases[item.UserID]
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func (r *Reader) SaveBDLeaderPositionAlias(ctx context.Context, userID int64, actorID int64, alias string) error {
|
||
if r == nil || r.adminDB == nil {
|
||
return fmt.Errorf("admin mysql is not configured")
|
||
}
|
||
if userID <= 0 {
|
||
return fmt.Errorf("user_id is required")
|
||
}
|
||
appCode := appctx.FromContext(ctx)
|
||
positionAlias := strings.TrimSpace(alias)
|
||
if positionAlias == "" {
|
||
// 空别名表示恢复 H5 配置表里的默认 admin 展示名,删除行比保留空值更清晰。
|
||
_, err := r.adminDB.ExecContext(ctx, `
|
||
DELETE FROM admin_bd_leader_position_aliases
|
||
WHERE app_code = ? AND user_id = ?
|
||
`, appCode, userID)
|
||
return err
|
||
}
|
||
nowMs := time.Now().UnixMilli()
|
||
_, err := r.adminDB.ExecContext(ctx, `
|
||
INSERT INTO admin_bd_leader_position_aliases (
|
||
app_code, user_id, position_alias, created_by_admin_id, updated_by_admin_id, created_at_ms, updated_at_ms
|
||
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
||
ON DUPLICATE KEY UPDATE
|
||
position_alias = VALUES(position_alias),
|
||
updated_by_admin_id = VALUES(updated_by_admin_id),
|
||
updated_at_ms = VALUES(updated_at_ms)
|
||
`, appCode, userID, positionAlias, actorID, actorID, nowMs, nowMs)
|
||
return err
|
||
}
|
||
|
||
func (r *Reader) DeleteBDLeader(ctx context.Context, userID int64) (*userclient.BDProfile, error) {
|
||
if r == nil || r.db == nil {
|
||
return nil, errUserDBNotConfigured()
|
||
}
|
||
appCode := appctx.FromContext(ctx)
|
||
item := &userclient.BDProfile{}
|
||
err := r.db.QueryRowContext(ctx, fmt.Sprintf(`
|
||
SELECT bp.user_id, 'bd_leader', %s, 0,
|
||
bp.status, bp.created_by_user_id, bp.created_at_ms, bp.updated_at_ms
|
||
FROM bd_leader_profiles bp
|
||
LEFT JOIN users u ON u.app_code = bp.app_code AND u.user_id = bp.user_id
|
||
%s
|
||
WHERE bp.app_code = ? AND bp.user_id = ?
|
||
`, userCountryRegionIDSQL("user_region"), userCountryRegionJoin("u", "user_country_region", "user_region")), appCode, userID).Scan(
|
||
&item.UserID,
|
||
&item.Role,
|
||
&item.RegionID,
|
||
&item.ParentLeaderUserID,
|
||
&item.Status,
|
||
&item.CreatedByUserID,
|
||
&item.CreatedAtMs,
|
||
&item.UpdatedAtMs,
|
||
)
|
||
if err == sql.ErrNoRows {
|
||
return nil, fmt.Errorf("bd leader not found")
|
||
}
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
result, err := r.db.ExecContext(ctx, `
|
||
DELETE FROM bd_leader_profiles
|
||
WHERE app_code = ? AND user_id = ?
|
||
`, appCode, userID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
affected, err := result.RowsAffected()
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if affected == 0 {
|
||
return nil, fmt.Errorf("bd leader not found")
|
||
}
|
||
if r.adminDB != nil {
|
||
_, _ = r.adminDB.ExecContext(ctx, `
|
||
DELETE FROM admin_bd_leader_position_aliases
|
||
WHERE app_code = ? AND user_id = ?
|
||
`, appCode, userID)
|
||
}
|
||
return item, nil
|
||
}
|
||
|
||
// CreateManagerProfile 创建或恢复经理身份。
|
||
// Manager 是 manager_profiles 表里的独立身份;这里只写经理事实和联系方式,不改 Host/Agency/BD 任一关系。
|
||
func (r *Reader) CreateManagerProfile(ctx context.Context, userID int64, actorID int64, req createManagerRequest) (*ManagerListItem, error) {
|
||
if r == nil || r.db == nil {
|
||
return nil, errUserDBNotConfigured()
|
||
}
|
||
if userID <= 0 {
|
||
return nil, fmt.Errorf("target_user_id is required")
|
||
}
|
||
contact := strings.TrimSpace(req.Contact)
|
||
if len([]rune(contact)) > 128 {
|
||
return nil, fmt.Errorf("contact is too long")
|
||
}
|
||
permissions := req.permissionsWithDefault()
|
||
nowMs := time.Now().UnixMilli()
|
||
appCode := appctx.FromContext(ctx)
|
||
// ON DUPLICATE KEY 用于后台重复添加同一经理时刷新联系方式并恢复启用状态,
|
||
// 不新增第二条身份,也不触碰该经理已经邀请出的 BD Leader/Admin 归属统计。
|
||
if _, err := r.db.ExecContext(ctx, `
|
||
INSERT INTO manager_profiles (
|
||
app_code, user_id, status, contact_info, can_grant_avatar_frame, can_grant_vehicle, can_grant_badge,
|
||
can_grant_vip, can_update_user_level, can_add_bd_leader, can_add_admin, can_add_superadmin, can_block_user, can_transfer_user_country,
|
||
created_by_admin_id, created_at_ms, updated_at_ms
|
||
) VALUES (?, ?, 'active', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||
ON DUPLICATE KEY UPDATE
|
||
status = 'active',
|
||
contact_info = VALUES(contact_info),
|
||
can_grant_avatar_frame = VALUES(can_grant_avatar_frame),
|
||
can_grant_vehicle = VALUES(can_grant_vehicle),
|
||
can_grant_badge = VALUES(can_grant_badge),
|
||
can_grant_vip = VALUES(can_grant_vip),
|
||
can_update_user_level = VALUES(can_update_user_level),
|
||
can_add_bd_leader = VALUES(can_add_bd_leader),
|
||
can_add_admin = VALUES(can_add_admin),
|
||
can_add_superadmin = VALUES(can_add_superadmin),
|
||
can_block_user = VALUES(can_block_user),
|
||
can_transfer_user_country = VALUES(can_transfer_user_country),
|
||
updated_at_ms = VALUES(updated_at_ms)
|
||
`, appCode, userID, contact, permissions.CanGrantAvatarFrame, permissions.CanGrantVehicle,
|
||
permissions.CanGrantBadge, permissions.CanGrantVIP, permissions.CanUpdateUserLevel, permissions.CanAddBDLeader, permissions.CanAddAdmin,
|
||
permissions.CanAddSuperadmin, permissions.CanBlockUser, permissions.CanTransferCountry, actorID, nowMs, nowMs); err != nil {
|
||
return nil, err
|
||
}
|
||
items, _, err := r.ListManagers(ctx, listQuery{Keyword: strconv.FormatInt(userID, 10), Page: 1, PageSize: 1})
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if len(items) == 0 {
|
||
return nil, fmt.Errorf("manager not found")
|
||
}
|
||
return items[0], nil
|
||
}
|
||
|
||
// UpdateManagerProfile 局部更新经理运营资料、身份状态和经理中心权限。
|
||
// 未传入的状态/布尔权限不参与 SET,避免后台只编辑联系方式时把现有开关误恢复成默认值。
|
||
func (r *Reader) UpdateManagerProfile(ctx context.Context, userID int64, actorID int64, req updateManagerRequest) (*ManagerListItem, error) {
|
||
if r == nil || r.db == nil {
|
||
return nil, errUserDBNotConfigured()
|
||
}
|
||
if userID <= 0 {
|
||
return nil, fmt.Errorf("manager user_id is required")
|
||
}
|
||
assignments := []string{"updated_at_ms = ?"}
|
||
args := []any{time.Now().UnixMilli()}
|
||
if req.Contact != nil {
|
||
contact := strings.TrimSpace(*req.Contact)
|
||
if len([]rune(contact)) > 128 {
|
||
return nil, fmt.Errorf("contact is too long")
|
||
}
|
||
assignments = append(assignments, "contact_info = ?")
|
||
args = append(args, contact)
|
||
}
|
||
if err := req.appendStatusAssignment(&assignments, &args); err != nil {
|
||
return nil, err
|
||
}
|
||
req.appendPermissionAssignments(&assignments, &args)
|
||
args = append(args, appctx.FromContext(ctx), userID)
|
||
// 权限更新只作用于已存在经理;RowsAffected=0 时返回 not found,避免 PUT 顺手创建新经理绕过创建审计。
|
||
result, err := r.db.ExecContext(ctx, `
|
||
UPDATE manager_profiles
|
||
SET `+strings.Join(assignments, ", ")+`
|
||
WHERE app_code = ? AND user_id = ?
|
||
`, args...)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
affected, err := result.RowsAffected()
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if affected == 0 {
|
||
return nil, fmt.Errorf("manager not found")
|
||
}
|
||
_ = actorID
|
||
items, _, err := r.ListManagers(ctx, listQuery{Keyword: strconv.FormatInt(userID, 10), Page: 1, PageSize: 1})
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if len(items) == 0 {
|
||
return nil, fmt.Errorf("manager not found")
|
||
}
|
||
return items[0], nil
|
||
}
|
||
|
||
// ListManagers 按 manager_profiles 的独立身份事实读取经理列表。
|
||
// 经理是 BD Leader/Admin 的上级,不属于 Host/Agency/BD 链路;下级 BD Leader 只作为统计信息左连汇总。
|
||
func (r *Reader) ListManagers(ctx context.Context, query listQuery) ([]*ManagerListItem, int64, error) {
|
||
if r == nil || r.db == nil {
|
||
return nil, 0, errUserDBNotConfigured()
|
||
}
|
||
appCode := appctx.FromContext(ctx)
|
||
whereSQL := `
|
||
FROM manager_profiles mp
|
||
JOIN users manager ON manager.app_code = mp.app_code AND manager.user_id = mp.user_id
|
||
LEFT JOIN bd_leader_profiles bl ON bl.app_code = mp.app_code AND bl.created_by_user_id = mp.user_id
|
||
` + userCountryRegionJoin("manager", "manager_country_region", "manager_region") + `
|
||
WHERE mp.app_code = ?`
|
||
args := []any{appCode}
|
||
if query.Status != "" {
|
||
whereSQL += " AND mp.status = ?"
|
||
args = append(args, query.Status)
|
||
}
|
||
if query.RegionID > 0 {
|
||
whereSQL += " AND manager_region.region_id = ?"
|
||
args = append(args, query.RegionID)
|
||
}
|
||
if !query.UserFilter.IsEmpty() {
|
||
managerSQL, managerArgs := shared.UserIdentityFieldsSQL("manager", "mp.user_id", query.UserFilter, time.Now().UnixMilli())
|
||
whereSQL += " AND " + managerSQL
|
||
args = append(args, managerArgs...)
|
||
} else if keyword := strings.TrimSpace(query.Keyword); keyword != "" {
|
||
like := "%" + keyword + "%"
|
||
managerSQL, managerArgs := shared.UserIdentityKeywordSQL("manager", "mp.user_id", keyword, time.Now().UnixMilli())
|
||
whereSQL += " AND (" + managerSQL + " OR manager_region.name LIKE ?)"
|
||
args = append(args, managerArgs...)
|
||
args = append(args, like)
|
||
}
|
||
|
||
var total int64
|
||
if err := r.db.QueryRowContext(ctx, `SELECT COUNT(DISTINCT mp.user_id) `+whereSQL, args...).Scan(&total); err != nil {
|
||
return nil, 0, err
|
||
}
|
||
rows, err := r.db.QueryContext(ctx, fmt.Sprintf(`
|
||
SELECT mp.user_id, COALESCE(manager.current_display_user_id, ''), COALESCE(manager.username, ''),
|
||
COALESCE(manager.avatar, ''), `+userCountryRegionIDSQL("manager_region")+`, `+userCountryRegionNameSQL("manager_region")+`,
|
||
mp.status, COALESCE(mp.contact_info, ''),
|
||
mp.can_grant_avatar_frame, mp.can_grant_vehicle, mp.can_grant_badge, mp.can_grant_vip, mp.can_update_user_level,
|
||
mp.can_add_bd_leader, mp.can_add_admin, mp.can_add_superadmin, mp.can_block_user, mp.can_transfer_user_country,
|
||
COUNT(DISTINCT bl.user_id), COALESCE(MAX(bl.created_at_ms), 0),
|
||
mp.created_at_ms, mp.updated_at_ms
|
||
%s
|
||
GROUP BY mp.user_id, manager.current_display_user_id, manager.username, manager.avatar,
|
||
manager_region.region_id, manager_region.name, mp.status, mp.contact_info,
|
||
mp.can_grant_avatar_frame, mp.can_grant_vehicle, mp.can_grant_badge, mp.can_grant_vip, mp.can_update_user_level,
|
||
mp.can_add_bd_leader, mp.can_add_admin, mp.can_add_superadmin, mp.can_block_user, mp.can_transfer_user_country,
|
||
mp.created_at_ms, mp.updated_at_ms
|
||
ORDER BY mp.updated_at_ms DESC, mp.user_id DESC
|
||
LIMIT ? OFFSET ?
|
||
`, whereSQL), append(args, query.PageSize, offset(query.Page, query.PageSize))...)
|
||
if err != nil {
|
||
return nil, 0, err
|
||
}
|
||
defer rows.Close()
|
||
|
||
items := make([]*ManagerListItem, 0, query.PageSize)
|
||
for rows.Next() {
|
||
item := &ManagerListItem{}
|
||
if err := rows.Scan(
|
||
&item.UserID,
|
||
&item.DisplayUserID,
|
||
&item.Username,
|
||
&item.Avatar,
|
||
&item.RegionID,
|
||
&item.RegionName,
|
||
&item.Status,
|
||
&item.Contact,
|
||
&item.CanGrantAvatarFrame,
|
||
&item.CanGrantVehicle,
|
||
&item.CanGrantBadge,
|
||
&item.CanGrantVIP,
|
||
&item.CanUpdateUserLevel,
|
||
&item.CanAddBDLeader,
|
||
&item.CanAddAdmin,
|
||
&item.CanAddSuperadmin,
|
||
&item.CanBlockUser,
|
||
&item.CanTransferCountry,
|
||
&item.BDLeaderCount,
|
||
&item.LastInvitedAtMs,
|
||
&item.CreatedAtMs,
|
||
&item.UpdatedAtMs,
|
||
); err != nil {
|
||
return nil, 0, err
|
||
}
|
||
items = append(items, item)
|
||
}
|
||
return items, total, rows.Err()
|
||
}
|
||
|
||
// ListAgencies 只读 user-service 的 Agency 事实表;后台关闭和入会开关仍走写命令。
|
||
func (r *Reader) ListAgencies(ctx context.Context, query listQuery) ([]*userclient.Agency, int64, error) {
|
||
if r == nil || r.db == nil {
|
||
return nil, 0, errUserDBNotConfigured()
|
||
}
|
||
appCode := appctx.FromContext(ctx)
|
||
whereSQL := `
|
||
FROM agencies a
|
||
LEFT JOIN users owner ON owner.app_code = a.app_code AND owner.user_id = a.owner_user_id
|
||
` + userCountryRegionJoin("owner", "owner_country_region", "owner_region") + `
|
||
LEFT JOIN users parent_bd ON parent_bd.app_code = a.app_code AND parent_bd.user_id = a.parent_bd_user_id
|
||
WHERE a.app_code = ?`
|
||
args := []any{appCode}
|
||
if query.Status != "" {
|
||
whereSQL += " AND a.status = ?"
|
||
args = append(args, query.Status)
|
||
} else {
|
||
whereSQL += " AND a.status <> ?"
|
||
args = append(args, "deleted")
|
||
}
|
||
if query.RegionID > 0 {
|
||
whereSQL += " AND owner_region.region_id = ?"
|
||
args = append(args, query.RegionID)
|
||
}
|
||
if query.ParentBDUserID > 0 {
|
||
whereSQL += " AND a.parent_bd_user_id = ?"
|
||
args = append(args, query.ParentBDUserID)
|
||
} else if !query.ParentBDFilter.IsEmpty() {
|
||
parentSQL, parentArgs := shared.UserIdentityFieldsSQL("parent_bd", "a.parent_bd_user_id", query.ParentBDFilter, time.Now().UnixMilli())
|
||
whereSQL += " AND " + parentSQL
|
||
args = append(args, parentArgs...)
|
||
}
|
||
if !query.OwnerFilter.IsEmpty() {
|
||
ownerSQL, ownerArgs := shared.UserIdentityFieldsSQL("owner", "a.owner_user_id", query.OwnerFilter, time.Now().UnixMilli())
|
||
whereSQL += " AND " + ownerSQL
|
||
args = append(args, ownerArgs...)
|
||
} else if keyword := strings.TrimSpace(query.Keyword); keyword != "" {
|
||
like := "%" + keyword + "%"
|
||
nowMs := time.Now().UnixMilli()
|
||
ownerSQL, ownerArgs := shared.UserIdentityKeywordSQL("owner", "a.owner_user_id", keyword, nowMs)
|
||
parentSQL, parentArgs := shared.UserIdentityKeywordSQL("parent_bd", "a.parent_bd_user_id", keyword, nowMs)
|
||
whereSQL += " AND (a.name LIKE ? OR CAST(a.agency_id AS CHAR) LIKE ? OR " + ownerSQL + " OR " + parentSQL + " OR owner_region.name LIKE ? OR owner.contact_info LIKE ?)"
|
||
args = append(args, like, like)
|
||
args = append(args, ownerArgs...)
|
||
args = append(args, parentArgs...)
|
||
args = append(args, like, like)
|
||
}
|
||
|
||
total, err := countRows(ctx, r.db, whereSQL, args...)
|
||
if err != nil {
|
||
return nil, 0, err
|
||
}
|
||
limitSQL := "LIMIT ? OFFSET ?"
|
||
queryArgs := append([]any{membershipStatusActive}, args...)
|
||
queryArgs = append(queryArgs, query.PageSize, offset(query.Page, query.PageSize))
|
||
if query.SortBy == sortBySalaryWallet {
|
||
// Agency 展示的是 owner 的 Agency 工资钱包;先补余额再排序,保证跨页顺序和真实余额一致。
|
||
limitSQL = ""
|
||
queryArgs = append([]any{membershipStatusActive}, 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,
|
||
a.created_by_user_id, a.created_at_ms, a.updated_at_ms,
|
||
COALESCE(owner.current_display_user_id, ''), COALESCE(owner.username, ''),
|
||
COALESCE(owner.avatar, ''), COALESCE(owner.contact_info, ''),
|
||
COALESCE(parent_bd.current_display_user_id, ''), COALESCE(parent_bd.username, ''),
|
||
COALESCE(parent_bd.avatar, ''), `+userCountryRegionNameSQL("owner_region")+`,
|
||
(
|
||
SELECT COUNT(*)
|
||
FROM agency_memberships active_membership
|
||
WHERE active_membership.app_code = a.app_code
|
||
AND active_membership.agency_id = a.agency_id
|
||
AND active_membership.status = ?
|
||
)
|
||
%s
|
||
ORDER BY a.created_at_ms DESC, a.agency_id DESC
|
||
%s
|
||
`, whereSQL, limitSQL), queryArgs...)
|
||
if err != nil {
|
||
return nil, 0, err
|
||
}
|
||
defer rows.Close()
|
||
|
||
items := make([]*userclient.Agency, 0, query.PageSize)
|
||
for rows.Next() {
|
||
item := &userclient.Agency{}
|
||
if err := rows.Scan(
|
||
&item.AgencyID,
|
||
&item.OwnerUserID,
|
||
&item.RegionID,
|
||
&item.ParentBDUserID,
|
||
&item.Name,
|
||
&item.Status,
|
||
&item.JoinEnabled,
|
||
&item.MaxHosts,
|
||
&item.CreatedByUserID,
|
||
&item.CreatedAtMs,
|
||
&item.UpdatedAtMs,
|
||
&item.OwnerDisplayUserID,
|
||
&item.OwnerUsername,
|
||
&item.OwnerAvatar,
|
||
&item.Contact,
|
||
&item.ParentBDDisplayUserID,
|
||
&item.ParentBDUsername,
|
||
&item.ParentBDAvatar,
|
||
&item.RegionName,
|
||
&item.ActiveHostCount,
|
||
); err != nil {
|
||
return nil, 0, err
|
||
}
|
||
items = append(items, item)
|
||
}
|
||
if err := rows.Err(); err != nil {
|
||
return nil, 0, err
|
||
}
|
||
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
|
||
}
|
||
|
||
// ListHostProfiles 只读主播身份事实;成员变更和主播状态变更不在这个列表接口里发生。
|
||
func (r *Reader) ListHostProfiles(ctx context.Context, query listQuery) ([]*userclient.HostProfile, int64, error) {
|
||
if r == nil || r.db == nil {
|
||
return nil, 0, errUserDBNotConfigured()
|
||
}
|
||
appCode := appctx.FromContext(ctx)
|
||
whereSQL := `
|
||
FROM host_profiles hp
|
||
LEFT JOIN users u ON u.app_code = hp.app_code AND u.user_id = hp.user_id
|
||
` + userCountryRegionJoin("u", "user_country_region", "user_region") + `
|
||
LEFT JOIN agencies a ON a.app_code = hp.app_code AND a.agency_id = hp.current_agency_id
|
||
LEFT JOIN users agency_owner ON agency_owner.app_code = a.app_code AND agency_owner.user_id = a.owner_user_id
|
||
WHERE hp.app_code = ?`
|
||
args := []any{appCode}
|
||
if query.Status != "" {
|
||
whereSQL += " AND hp.status = ?"
|
||
args = append(args, query.Status)
|
||
}
|
||
if query.RegionID > 0 {
|
||
whereSQL += " AND user_region.region_id = ?"
|
||
args = append(args, query.RegionID)
|
||
}
|
||
if query.AgencyID > 0 {
|
||
whereSQL += " AND hp.current_agency_id = ?"
|
||
args = append(args, query.AgencyID)
|
||
}
|
||
if !query.UserFilter.IsEmpty() {
|
||
hostSQL, hostArgs := shared.UserIdentityFieldsSQL("u", "hp.user_id", query.UserFilter, time.Now().UnixMilli())
|
||
whereSQL += " AND " + hostSQL
|
||
args = append(args, hostArgs...)
|
||
} else if keyword := strings.TrimSpace(query.Keyword); keyword != "" {
|
||
like := "%" + keyword + "%"
|
||
nowMs := time.Now().UnixMilli()
|
||
hostSQL, hostArgs := shared.UserIdentityKeywordSQL("u", "hp.user_id", keyword, nowMs)
|
||
agencyOwnerSQL, agencyOwnerArgs := shared.UserIdentityKeywordSQL("agency_owner", "a.owner_user_id", keyword, nowMs)
|
||
whereSQL += " AND (" + hostSQL + " OR a.name LIKE ? OR " + agencyOwnerSQL + " OR user_region.name LIKE ? OR u.contact_info LIKE ?)"
|
||
args = append(args, hostArgs...)
|
||
args = append(args, like)
|
||
args = append(args, agencyOwnerArgs...)
|
||
args = append(args, like, like)
|
||
}
|
||
|
||
total, err := countRows(ctx, r.db, whereSQL, args...)
|
||
if err != nil {
|
||
return nil, 0, err
|
||
}
|
||
limitSQL := "LIMIT ? OFFSET ?"
|
||
queryArgs := append(args, query.PageSize, offset(query.Page, query.PageSize))
|
||
if query.SortBy == sortByDiamond || query.SortBy == sortBySalaryWallet {
|
||
// 主播钻石和工资余额都是列表外部聚合值;排序时取全量匹配集,聚合后稳定排序再分页。
|
||
limitSQL = ""
|
||
queryArgs = args
|
||
}
|
||
rows, err := r.db.QueryContext(ctx, fmt.Sprintf(`
|
||
SELECT hp.user_id, hp.status, `+userCountryRegionIDSQL("user_region")+`, COALESCE(hp.current_agency_id, 0),
|
||
COALESCE(hp.current_membership_id, 0), hp.source,
|
||
hp.first_became_host_at_ms, hp.created_at_ms, hp.updated_at_ms,
|
||
COALESCE(u.current_display_user_id, ''), COALESCE(u.username, ''),
|
||
COALESCE(u.avatar, ''), COALESCE(u.contact_info, ''), `+userCountryRegionNameSQL("user_region")+`, COALESCE(a.name, ''),
|
||
COALESCE(a.owner_user_id, 0), COALESCE(agency_owner.current_display_user_id, ''),
|
||
COALESCE(agency_owner.username, ''), COALESCE(agency_owner.avatar, '')
|
||
%s
|
||
ORDER BY hp.created_at_ms DESC, hp.user_id DESC
|
||
%s
|
||
`, whereSQL, limitSQL), queryArgs...)
|
||
if err != nil {
|
||
return nil, 0, err
|
||
}
|
||
defer rows.Close()
|
||
|
||
items := make([]*userclient.HostProfile, 0, query.PageSize)
|
||
userIDs := make([]int64, 0, query.PageSize)
|
||
for rows.Next() {
|
||
item := &userclient.HostProfile{}
|
||
if err := rows.Scan(
|
||
&item.UserID,
|
||
&item.Status,
|
||
&item.RegionID,
|
||
&item.CurrentAgencyID,
|
||
&item.CurrentMembershipID,
|
||
&item.Source,
|
||
&item.FirstBecameHostAtMs,
|
||
&item.CreatedAtMs,
|
||
&item.UpdatedAtMs,
|
||
&item.DisplayUserID,
|
||
&item.Username,
|
||
&item.Avatar,
|
||
&item.Contact,
|
||
&item.RegionName,
|
||
&item.CurrentAgencyName,
|
||
&item.CurrentAgencyOwnerUserID,
|
||
&item.CurrentAgencyOwnerDisplayUserID,
|
||
&item.CurrentAgencyOwnerUsername,
|
||
&item.CurrentAgencyOwnerAvatar,
|
||
); err != nil {
|
||
return nil, 0, err
|
||
}
|
||
items = append(items, item)
|
||
userIDs = append(userIDs, item.UserID)
|
||
}
|
||
if err := rows.Err(); err != nil {
|
||
return nil, 0, err
|
||
}
|
||
diamonds, err := r.hostPeriodDiamonds(ctx, appCode, userIDs)
|
||
if err != nil {
|
||
return nil, 0, err
|
||
}
|
||
for _, item := range items {
|
||
item.Diamond = diamonds[item.UserID]
|
||
}
|
||
if err := r.fillHostSalaryWallets(ctx, items); err != nil {
|
||
return nil, 0, err
|
||
}
|
||
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
|
||
}
|
||
|
||
// ListCoinSellers 聚合 user-service 身份事实和 wallet-service 币商余额,只做后台展示读模型。
|
||
func (r *Reader) ListCoinSellers(ctx context.Context, query listQuery) ([]*CoinSellerListItem, int64, error) {
|
||
if r == nil || r.db == nil {
|
||
return nil, 0, errUserDBNotConfigured()
|
||
}
|
||
appCode := appctx.FromContext(ctx)
|
||
if err := r.ensureCoinSellerContactColumn(ctx); err != nil {
|
||
return nil, 0, err
|
||
}
|
||
if err := r.ensureCoinSellerSubPermissionColumn(ctx); err != nil {
|
||
return nil, 0, err
|
||
}
|
||
whereSQL := `
|
||
FROM coin_seller_profiles csp
|
||
LEFT JOIN users u ON u.app_code = csp.app_code AND u.user_id = csp.user_id
|
||
LEFT JOIN regions r ON r.app_code = csp.app_code AND r.region_id = u.region_id
|
||
WHERE csp.app_code = ?`
|
||
args := []any{appCode}
|
||
if query.Status != "" {
|
||
whereSQL += " AND csp.status = ?"
|
||
args = append(args, query.Status)
|
||
}
|
||
if query.RegionID > 0 {
|
||
whereSQL += " AND u.region_id = ?"
|
||
args = append(args, query.RegionID)
|
||
}
|
||
if !query.UserFilter.IsEmpty() {
|
||
matchSQL, matchArgs := shared.UserIdentityFieldsSQL("u", "csp.user_id", query.UserFilter, time.Now().UnixMilli())
|
||
whereSQL += " AND " + matchSQL
|
||
args = append(args, matchArgs...)
|
||
} else if keyword := strings.TrimSpace(query.Keyword); keyword != "" {
|
||
like := "%" + keyword + "%"
|
||
matchSQL, matchArgs := shared.UserIdentityKeywordSQL("u", "csp.user_id", keyword, time.Now().UnixMilli())
|
||
whereSQL += " AND (" + matchSQL + " OR u.contact_info LIKE ? OR csp.contact_info LIKE ?)"
|
||
args = append(args, matchArgs...)
|
||
args = append(args, like, like)
|
||
}
|
||
|
||
total, err := countRows(ctx, r.db, whereSQL, args...)
|
||
if err != nil {
|
||
return nil, 0, err
|
||
}
|
||
limitSQL := "LIMIT ? OFFSET ?"
|
||
queryArgs := append(args, query.PageSize, offset(query.Page, query.PageSize))
|
||
if isCoinSellerComputedSort(query.SortBy) {
|
||
// 币商余额和累充 USDT 都来自 wallet-service 聚合,必须先读取所有匹配币商再排序分页,避免只排序当前页。
|
||
limitSQL = ""
|
||
queryArgs = args
|
||
}
|
||
rows, err := r.db.QueryContext(ctx, fmt.Sprintf(`
|
||
SELECT csp.user_id, csp.status, csp.merchant_asset_type,
|
||
COALESCE(NULLIF(u.contact_info, ''), csp.contact_info, ''),
|
||
csp.can_manage_sub_coin_sellers,
|
||
COALESCE((
|
||
SELECT COUNT(*)
|
||
FROM coin_seller_relations csr
|
||
INNER JOIN coin_seller_profiles child_csp
|
||
ON child_csp.app_code = csr.app_code
|
||
AND child_csp.user_id = csr.child_user_id
|
||
AND child_csp.status = 'active'
|
||
AND child_csp.merchant_asset_type = ?
|
||
INNER JOIN users child_u
|
||
ON child_u.app_code = csr.app_code
|
||
AND child_u.user_id = csr.child_user_id
|
||
AND child_u.status = 'active'
|
||
WHERE csr.app_code = csp.app_code
|
||
AND csr.parent_user_id = csp.user_id
|
||
AND csr.status = 'active'
|
||
), 0),
|
||
csp.created_by_user_id, csp.created_at_ms, csp.updated_at_ms,
|
||
COALESCE(u.current_display_user_id, ''), COALESCE(u.username, ''),
|
||
COALESCE(u.avatar, ''), COALESCE(u.region_id, 0),
|
||
COALESCE(r.name, '')
|
||
%s
|
||
ORDER BY csp.created_at_ms DESC, csp.user_id DESC
|
||
%s
|
||
`, whereSQL, limitSQL), append([]any{coinSellerMerchantAssetType}, queryArgs...)...)
|
||
if err != nil {
|
||
return nil, 0, err
|
||
}
|
||
defer rows.Close()
|
||
|
||
items := make([]*CoinSellerListItem, 0, query.PageSize)
|
||
userIDs := make([]int64, 0, query.PageSize)
|
||
for rows.Next() {
|
||
item := &CoinSellerListItem{}
|
||
if err := rows.Scan(
|
||
&item.UserID,
|
||
&item.Status,
|
||
&item.MerchantAssetType,
|
||
&item.Contact,
|
||
&item.CanManageSubCoinSellers,
|
||
&item.SubCoinSellerCount,
|
||
&item.CreatedByUserID,
|
||
&item.CreatedAtMs,
|
||
&item.UpdatedAtMs,
|
||
&item.DisplayUserID,
|
||
&item.Username,
|
||
&item.Avatar,
|
||
&item.RegionID,
|
||
&item.RegionName,
|
||
); err != nil {
|
||
return nil, 0, err
|
||
}
|
||
items = append(items, item)
|
||
userIDs = append(userIDs, item.UserID)
|
||
}
|
||
if err := rows.Err(); err != nil {
|
||
return nil, 0, err
|
||
}
|
||
balances, err := r.coinSellerBalances(ctx, appCode, userIDs)
|
||
if err != nil {
|
||
return nil, 0, err
|
||
}
|
||
rechargeTotals, err := r.coinSellerRechargeTotals(ctx, appCode, userIDs)
|
||
if err != nil {
|
||
return nil, 0, err
|
||
}
|
||
for _, item := range items {
|
||
item.MerchantBalance = balances[item.UserID]
|
||
item.TotalRechargeUSDTMicro = rechargeTotals[item.UserID]
|
||
}
|
||
if isCoinSellerComputedSort(query.SortBy) {
|
||
sortCoinSellerListItems(items, query.SortBy, query.SortDirection)
|
||
items = paginateCoinSellerListItems(items, query.Page, query.PageSize)
|
||
}
|
||
return items, total, nil
|
||
}
|
||
|
||
func (r *Reader) ListCoinSellerSubApplications(ctx context.Context, query listQuery) ([]*CoinSellerSubApplicationListItem, int64, error) {
|
||
if r == nil || r.db == nil {
|
||
return nil, 0, errUserDBNotConfigured()
|
||
}
|
||
appCode := appctx.FromContext(ctx)
|
||
whereSQL := `
|
||
FROM coin_seller_sub_applications app
|
||
LEFT JOIN users parent_u ON parent_u.app_code = app.app_code AND parent_u.user_id = app.parent_user_id
|
||
LEFT JOIN regions parent_r ON parent_r.app_code = parent_u.app_code AND parent_r.region_id = parent_u.region_id
|
||
LEFT JOIN users target_u ON target_u.app_code = app.app_code AND target_u.user_id = app.target_user_id
|
||
LEFT JOIN regions target_r ON target_r.app_code = target_u.app_code AND target_r.region_id = target_u.region_id
|
||
WHERE app.app_code = ?`
|
||
args := []any{appCode}
|
||
if query.Status != "" {
|
||
whereSQL += " AND app.status = ?"
|
||
args = append(args, query.Status)
|
||
}
|
||
if query.ParentUserID > 0 {
|
||
whereSQL += " AND app.parent_user_id = ?"
|
||
args = append(args, query.ParentUserID)
|
||
}
|
||
if query.TargetUserID > 0 {
|
||
whereSQL += " AND app.target_user_id = ?"
|
||
args = append(args, query.TargetUserID)
|
||
}
|
||
if keyword := strings.TrimSpace(query.Keyword); keyword != "" {
|
||
parentSQL, parentArgs := shared.UserIdentityKeywordSQL("parent_u", "app.parent_user_id", keyword, time.Now().UnixMilli())
|
||
targetSQL, targetArgs := shared.UserIdentityKeywordSQL("target_u", "app.target_user_id", keyword, time.Now().UnixMilli())
|
||
whereSQL += " AND (" + parentSQL + " OR " + targetSQL + " OR app.command_id LIKE ? OR app.review_reason LIKE ?)"
|
||
args = append(args, parentArgs...)
|
||
args = append(args, targetArgs...)
|
||
like := "%" + keyword + "%"
|
||
args = append(args, like, like)
|
||
}
|
||
total, err := countRows(ctx, r.db, whereSQL, args...)
|
||
if err != nil {
|
||
return nil, 0, err
|
||
}
|
||
rows, err := r.db.QueryContext(ctx, fmt.Sprintf(`
|
||
SELECT app.application_id, app.command_id, app.status, app.relation_id,
|
||
app.reviewed_by_admin_id, app.review_reason, app.reviewed_at_ms,
|
||
app.created_at_ms, app.updated_at_ms,
|
||
app.parent_user_id, COALESCE(parent_u.current_display_user_id, ''),
|
||
COALESCE(parent_u.username, ''), COALESCE(parent_u.avatar, ''),
|
||
COALESCE(parent_u.region_id, 0), COALESCE(parent_r.name, ''),
|
||
app.target_user_id, COALESCE(target_u.current_display_user_id, ''),
|
||
COALESCE(target_u.username, ''), COALESCE(target_u.avatar, ''),
|
||
COALESCE(target_u.region_id, 0), COALESCE(target_r.name, '')
|
||
%s
|
||
ORDER BY app.created_at_ms DESC, app.application_id DESC
|
||
LIMIT ? OFFSET ?
|
||
`, whereSQL), append(args, query.PageSize, offset(query.Page, query.PageSize))...)
|
||
if err != nil {
|
||
return nil, 0, err
|
||
}
|
||
defer rows.Close()
|
||
items := make([]*CoinSellerSubApplicationListItem, 0, query.PageSize)
|
||
for rows.Next() {
|
||
item := &CoinSellerSubApplicationListItem{}
|
||
if err := rows.Scan(
|
||
&item.ApplicationID,
|
||
&item.CommandID,
|
||
&item.Status,
|
||
&item.RelationID,
|
||
&item.ReviewedByAdminID,
|
||
&item.ReviewReason,
|
||
&item.ReviewedAtMs,
|
||
&item.CreatedAtMs,
|
||
&item.UpdatedAtMs,
|
||
&item.Parent.UserID,
|
||
&item.Parent.DisplayUserID,
|
||
&item.Parent.Username,
|
||
&item.Parent.Avatar,
|
||
&item.Parent.RegionID,
|
||
&item.Parent.RegionName,
|
||
&item.Target.UserID,
|
||
&item.Target.DisplayUserID,
|
||
&item.Target.Username,
|
||
&item.Target.Avatar,
|
||
&item.Target.RegionID,
|
||
&item.Target.RegionName,
|
||
); err != nil {
|
||
return nil, 0, err
|
||
}
|
||
items = append(items, item)
|
||
}
|
||
if err := rows.Err(); err != nil {
|
||
return nil, 0, err
|
||
}
|
||
return items, total, nil
|
||
}
|
||
|
||
func (r *Reader) ListCoinSellerSubSellers(ctx context.Context, parentUserID int64, query listQuery) ([]*CoinSellerListItem, int64, error) {
|
||
if r == nil || r.db == nil {
|
||
return nil, 0, errUserDBNotConfigured()
|
||
}
|
||
if parentUserID <= 0 {
|
||
return nil, 0, fmt.Errorf("parent_user_id is required")
|
||
}
|
||
appCode := appctx.FromContext(ctx)
|
||
whereSQL := `
|
||
FROM coin_seller_relations relation
|
||
INNER JOIN users u
|
||
ON u.app_code = relation.app_code
|
||
AND u.user_id = relation.child_user_id
|
||
AND u.status = 'active'
|
||
INNER JOIN coin_seller_profiles csp
|
||
ON csp.app_code = relation.app_code
|
||
AND csp.user_id = relation.child_user_id
|
||
AND csp.status = 'active'
|
||
AND csp.merchant_asset_type = ?
|
||
LEFT JOIN regions r ON r.app_code = u.app_code AND r.region_id = u.region_id
|
||
WHERE relation.app_code = ?
|
||
AND relation.parent_user_id = ?
|
||
AND relation.status = 'active'`
|
||
args := []any{coinSellerMerchantAssetType, appCode, parentUserID}
|
||
total, err := countRows(ctx, r.db, whereSQL, args...)
|
||
if err != nil {
|
||
return nil, 0, err
|
||
}
|
||
rows, err := r.db.QueryContext(ctx, fmt.Sprintf(`
|
||
SELECT csp.user_id, csp.status, csp.merchant_asset_type,
|
||
COALESCE(NULLIF(u.contact_info, ''), csp.contact_info, ''),
|
||
csp.can_manage_sub_coin_sellers,
|
||
COALESCE((
|
||
SELECT COUNT(*)
|
||
FROM coin_seller_relations csr
|
||
INNER JOIN coin_seller_profiles child_csp
|
||
ON child_csp.app_code = csr.app_code
|
||
AND child_csp.user_id = csr.child_user_id
|
||
AND child_csp.status = 'active'
|
||
AND child_csp.merchant_asset_type = ?
|
||
INNER JOIN users child_u
|
||
ON child_u.app_code = csr.app_code
|
||
AND child_u.user_id = csr.child_user_id
|
||
AND child_u.status = 'active'
|
||
WHERE csr.app_code = csp.app_code
|
||
AND csr.parent_user_id = csp.user_id
|
||
AND csr.status = 'active'
|
||
), 0),
|
||
csp.created_by_user_id, csp.created_at_ms, csp.updated_at_ms,
|
||
COALESCE(u.current_display_user_id, ''), COALESCE(u.username, ''),
|
||
COALESCE(u.avatar, ''), COALESCE(u.region_id, 0),
|
||
COALESCE(r.name, '')
|
||
%s
|
||
ORDER BY relation.created_at_ms DESC, relation.relation_id DESC
|
||
LIMIT ? OFFSET ?
|
||
`, whereSQL), append([]any{coinSellerMerchantAssetType}, append(args, query.PageSize, offset(query.Page, query.PageSize))...)...)
|
||
if err != nil {
|
||
return nil, 0, err
|
||
}
|
||
defer rows.Close()
|
||
items := make([]*CoinSellerListItem, 0, query.PageSize)
|
||
userIDs := make([]int64, 0, query.PageSize)
|
||
for rows.Next() {
|
||
item := &CoinSellerListItem{}
|
||
if err := rows.Scan(
|
||
&item.UserID,
|
||
&item.Status,
|
||
&item.MerchantAssetType,
|
||
&item.Contact,
|
||
&item.CanManageSubCoinSellers,
|
||
&item.SubCoinSellerCount,
|
||
&item.CreatedByUserID,
|
||
&item.CreatedAtMs,
|
||
&item.UpdatedAtMs,
|
||
&item.DisplayUserID,
|
||
&item.Username,
|
||
&item.Avatar,
|
||
&item.RegionID,
|
||
&item.RegionName,
|
||
); err != nil {
|
||
return nil, 0, err
|
||
}
|
||
items = append(items, item)
|
||
userIDs = append(userIDs, item.UserID)
|
||
}
|
||
if err := rows.Err(); err != nil {
|
||
return nil, 0, err
|
||
}
|
||
balances, err := r.coinSellerBalances(ctx, appCode, userIDs)
|
||
if err != nil {
|
||
return nil, 0, err
|
||
}
|
||
rechargeTotals, err := r.coinSellerRechargeTotals(ctx, appCode, userIDs)
|
||
if err != nil {
|
||
return nil, 0, err
|
||
}
|
||
for _, item := range items {
|
||
item.MerchantBalance = balances[item.UserID]
|
||
item.TotalRechargeUSDTMicro = rechargeTotals[item.UserID]
|
||
}
|
||
return items, total, nil
|
||
}
|
||
|
||
func (r *Reader) UpdateCoinSellerContact(ctx context.Context, userID int64, contact string) error {
|
||
if r == nil || r.db == nil {
|
||
return errUserDBNotConfigured()
|
||
}
|
||
// 币商联系方式现在是用户主资料,后台行内编辑和 H5 自助修改必须写同一个 users 字段,避免同一用户多身份展示不同联系方式。
|
||
_, err := r.db.ExecContext(ctx, `
|
||
UPDATE users
|
||
SET contact_info = ?, updated_at_ms = ?
|
||
WHERE app_code = ? AND user_id = ?
|
||
`, strings.TrimSpace(contact), time.Now().UnixMilli(), appctx.FromContext(ctx), userID)
|
||
return err
|
||
}
|
||
|
||
func (r *Reader) ListCoinSellerSalaryRates(ctx context.Context, regionID int64) ([]CoinSellerSalaryRateTier, error) {
|
||
if r == nil || r.walletDB == nil {
|
||
return nil, fmt.Errorf("wallet mysql is not configured")
|
||
}
|
||
if regionID <= 0 {
|
||
return nil, fmt.Errorf("region_id is required")
|
||
}
|
||
if err := r.ensureCoinSellerSalaryRateTable(ctx); err != nil {
|
||
return nil, err
|
||
}
|
||
rows, err := r.walletDB.QueryContext(ctx, `
|
||
SELECT region_id, min_usd_minor, max_usd_minor, coin_per_usd, status, sort_order, updated_at_ms
|
||
FROM coin_seller_salary_exchange_rate_tiers
|
||
WHERE app_code = ? AND region_id = ?
|
||
ORDER BY sort_order ASC, min_usd_minor ASC, tier_id ASC
|
||
`, appctx.FromContext(ctx), regionID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer rows.Close()
|
||
|
||
items := make([]CoinSellerSalaryRateTier, 0)
|
||
for rows.Next() {
|
||
var item CoinSellerSalaryRateTier
|
||
if err := rows.Scan(&item.RegionID, &item.MinUSDMinor, &item.MaxUSDMinor, &item.CoinPerUSD, &item.Status, &item.SortOrder, &item.UpdatedAtMs); err != nil {
|
||
return nil, err
|
||
}
|
||
item.Enabled = item.Status == "active"
|
||
item.MinUSD = formatUSDMinor(item.MinUSDMinor)
|
||
item.MaxUSD = formatUSDMinor(item.MaxUSDMinor)
|
||
items = append(items, item)
|
||
}
|
||
if err := rows.Err(); err != nil {
|
||
return nil, err
|
||
}
|
||
return items, nil
|
||
}
|
||
|
||
func (r *Reader) ReplaceCoinSellerSalaryRates(ctx context.Context, regionID int64, actorID int64, tiers []CoinSellerSalaryRateTier) ([]CoinSellerSalaryRateTier, error) {
|
||
if r == nil || r.walletDB == nil {
|
||
return nil, fmt.Errorf("wallet mysql is not configured")
|
||
}
|
||
if regionID <= 0 {
|
||
return nil, fmt.Errorf("region_id is required")
|
||
}
|
||
if err := r.ensureCoinSellerSalaryRateTable(ctx); err != nil {
|
||
return nil, err
|
||
}
|
||
tx, err := r.walletDB.BeginTx(ctx, nil)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer func() { _ = tx.Rollback() }()
|
||
|
||
appCode := appctx.FromContext(ctx)
|
||
nowMs := time.Now().UnixMilli()
|
||
if _, err := tx.ExecContext(ctx, `
|
||
DELETE FROM coin_seller_salary_exchange_rate_tiers
|
||
WHERE app_code = ? AND region_id = ?
|
||
`, appCode, regionID); err != nil {
|
||
return nil, err
|
||
}
|
||
for index, tier := range tiers {
|
||
status := strings.ToLower(strings.TrimSpace(tier.Status))
|
||
if status == "" {
|
||
if tier.Enabled {
|
||
status = "active"
|
||
} else {
|
||
status = "disabled"
|
||
}
|
||
}
|
||
if status != "active" && status != "disabled" {
|
||
return nil, fmt.Errorf("rate status is invalid")
|
||
}
|
||
sortOrder := tier.SortOrder
|
||
if sortOrder == 0 {
|
||
sortOrder = (index + 1) * 10
|
||
}
|
||
_, err := tx.ExecContext(ctx, `
|
||
INSERT INTO coin_seller_salary_exchange_rate_tiers (
|
||
app_code, region_id, min_usd_minor, max_usd_minor, coin_per_usd,
|
||
status, sort_order, created_by_user_id, updated_by_user_id, created_at_ms, updated_at_ms
|
||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||
`, appCode, regionID, tier.MinUSDMinor, tier.MaxUSDMinor, tier.CoinPerUSD, status, sortOrder, actorID, actorID, nowMs, nowMs)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
}
|
||
if err := tx.Commit(); err != nil {
|
||
return nil, err
|
||
}
|
||
return r.ListCoinSellerSalaryRates(ctx, regionID)
|
||
}
|
||
|
||
func (r *Reader) ensureCoinSellerSalaryRateTable(ctx context.Context) error {
|
||
if _, err := r.walletDB.ExecContext(ctx, `
|
||
CREATE TABLE IF NOT EXISTS coin_seller_salary_exchange_rate_tiers (
|
||
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码,用于多租户隔离',
|
||
region_id BIGINT NOT NULL COMMENT '币商所属区域 ID',
|
||
tier_id BIGINT NOT NULL AUTO_INCREMENT COMMENT '区间 ID',
|
||
min_usd_minor BIGINT NOT NULL COMMENT '起始美元金额,单位美分,包含',
|
||
max_usd_minor BIGINT NOT NULL COMMENT '结束美元金额,单位美分,包含',
|
||
coin_per_usd BIGINT NOT NULL COMMENT '每 1 USD 工资可兑换的币商专用金币数量',
|
||
status VARCHAR(24) NOT NULL DEFAULT 'active' COMMENT '状态:active/disabled',
|
||
sort_order INT NOT NULL DEFAULT 0 COMMENT '排序权重',
|
||
created_by_user_id BIGINT NOT NULL DEFAULT 0 COMMENT '创建人用户 ID',
|
||
updated_by_user_id BIGINT NOT NULL DEFAULT 0 COMMENT '更新人用户 ID',
|
||
created_at_ms BIGINT NOT NULL COMMENT '创建时间,UTC epoch ms',
|
||
updated_at_ms BIGINT NOT NULL COMMENT '更新时间,UTC epoch ms',
|
||
PRIMARY KEY (tier_id),
|
||
KEY idx_coin_seller_salary_rates_region (app_code, region_id, status, min_usd_minor, max_usd_minor),
|
||
KEY idx_coin_seller_salary_rates_sort (app_code, region_id, status, sort_order)
|
||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='币商工资兑换比例区间表'`); err != nil {
|
||
return err
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func (r *Reader) ensureCoinSellerContactColumn(ctx context.Context) error {
|
||
var count int
|
||
if err := r.db.QueryRowContext(ctx, `
|
||
SELECT COUNT(*)
|
||
FROM information_schema.columns
|
||
WHERE table_schema = DATABASE()
|
||
AND table_name = 'coin_seller_profiles'
|
||
AND column_name = 'contact_info'
|
||
`).Scan(&count); err != nil {
|
||
return err
|
||
}
|
||
if count > 0 {
|
||
return nil
|
||
}
|
||
_, err := r.db.ExecContext(ctx, `
|
||
ALTER TABLE coin_seller_profiles
|
||
ADD COLUMN contact_info VARCHAR(128) NOT NULL DEFAULT '' AFTER merchant_asset_type
|
||
`)
|
||
return err
|
||
}
|
||
|
||
func (r *Reader) ensureCoinSellerSubPermissionColumn(ctx context.Context) error {
|
||
var count int
|
||
if err := r.db.QueryRowContext(ctx, `
|
||
SELECT COUNT(*)
|
||
FROM information_schema.columns
|
||
WHERE table_schema = DATABASE()
|
||
AND table_name = 'coin_seller_profiles'
|
||
AND column_name = 'can_manage_sub_coin_sellers'
|
||
`).Scan(&count); err != nil {
|
||
return err
|
||
}
|
||
if count > 0 {
|
||
return nil
|
||
}
|
||
_, err := r.db.ExecContext(ctx, `
|
||
ALTER TABLE coin_seller_profiles
|
||
ADD COLUMN can_manage_sub_coin_sellers TINYINT(1) NOT NULL DEFAULT 1 AFTER contact_info
|
||
`)
|
||
return err
|
||
}
|
||
|
||
func (r *Reader) coinSellerBalances(ctx context.Context, appCode string, userIDs []int64) (map[int64]int64, error) {
|
||
result := make(map[int64]int64, len(userIDs))
|
||
if r == nil || r.walletDB == nil || len(userIDs) == 0 {
|
||
return result, nil
|
||
}
|
||
placeholders := strings.TrimRight(strings.Repeat("?,", len(userIDs)), ",")
|
||
args := []any{appCode, "COIN_SELLER_COIN"}
|
||
for _, userID := range userIDs {
|
||
args = append(args, userID)
|
||
}
|
||
rows, err := r.walletDB.QueryContext(ctx, fmt.Sprintf(`
|
||
SELECT user_id, available_amount
|
||
FROM wallet_accounts
|
||
WHERE app_code = ? AND asset_type = ? AND user_id IN (%s)
|
||
`, placeholders), args...)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer rows.Close()
|
||
for rows.Next() {
|
||
var userID int64
|
||
var amount int64
|
||
if err := rows.Scan(&userID, &amount); err != nil {
|
||
return nil, err
|
||
}
|
||
result[userID] = amount
|
||
}
|
||
return result, rows.Err()
|
||
}
|
||
|
||
// coinSellerRechargeTotals 从币商进货专表汇总 USDT 付款,金币补偿不会计入累充口径。
|
||
func (r *Reader) coinSellerRechargeTotals(ctx context.Context, appCode string, userIDs []int64) (map[int64]int64, error) {
|
||
result := make(map[int64]int64, len(userIDs))
|
||
if r == nil || r.walletDB == nil || len(userIDs) == 0 {
|
||
return result, nil
|
||
}
|
||
placeholders := sqlPlaceholders(len(userIDs))
|
||
args := []any{appCode, paidCurrencyUSDT}
|
||
for _, userID := range userIDs {
|
||
args = append(args, userID)
|
||
}
|
||
rows, err := r.walletDB.QueryContext(ctx, fmt.Sprintf(`
|
||
SELECT seller_user_id, COALESCE(SUM(paid_amount_micro), 0)
|
||
FROM coin_seller_stock_records
|
||
WHERE app_code = ?
|
||
AND counts_as_seller_recharge = TRUE
|
||
AND paid_currency_code = ?
|
||
AND seller_user_id IN (%s)
|
||
GROUP BY seller_user_id
|
||
`, placeholders), args...)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer rows.Close()
|
||
for rows.Next() {
|
||
var userID int64
|
||
var amount int64
|
||
if err := rows.Scan(&userID, &amount); err != nil {
|
||
return nil, err
|
||
}
|
||
result[userID] = amount
|
||
}
|
||
return result, rows.Err()
|
||
}
|
||
|
||
func (r *Reader) hostPeriodDiamonds(ctx context.Context, appCode string, userIDs []int64) (map[int64]int64, error) {
|
||
result := make(map[int64]int64, len(userIDs))
|
||
if r == nil || r.walletDB == nil || len(userIDs) == 0 {
|
||
return result, nil
|
||
}
|
||
placeholders := strings.TrimRight(strings.Repeat("?,", len(userIDs)), ",")
|
||
args := []any{appCode, time.Now().UTC().Format("2006-01")}
|
||
for _, userID := range userIDs {
|
||
args = append(args, userID)
|
||
}
|
||
rows, err := r.walletDB.QueryContext(ctx, fmt.Sprintf(`
|
||
SELECT user_id, total_diamonds
|
||
FROM host_period_diamond_accounts
|
||
WHERE app_code = ? AND cycle_key = ? AND user_id IN (%s)
|
||
`, placeholders), args...)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer rows.Close()
|
||
for rows.Next() {
|
||
var userID int64
|
||
var amount int64
|
||
if err := rows.Scan(&userID, &amount); err != nil {
|
||
return nil, err
|
||
}
|
||
result[userID] = amount
|
||
}
|
||
return result, rows.Err()
|
||
}
|
||
|
||
func isCoinSellerComputedSort(sortBy string) bool {
|
||
return sortBy == sortByMerchantBalance || sortBy == sortByTotalRechargeUSDT
|
||
}
|
||
|
||
// sortCoinSellerListItems 只处理 wallet 聚合字段排序,基础身份字段仍使用 SQL 默认创建时间排序。
|
||
func sortCoinSellerListItems(items []*CoinSellerListItem, sortBy string, direction string) {
|
||
sort.SliceStable(items, func(i, j int) bool {
|
||
left := coinSellerSortValue(items[i], sortBy)
|
||
right := coinSellerSortValue(items[j], sortBy)
|
||
if left == right {
|
||
return false
|
||
}
|
||
if direction == "asc" {
|
||
return left < right
|
||
}
|
||
return left > right
|
||
})
|
||
}
|
||
|
||
func coinSellerSortValue(item *CoinSellerListItem, sortBy string) int64 {
|
||
if item == nil {
|
||
return 0
|
||
}
|
||
switch sortBy {
|
||
case sortByTotalRechargeUSDT:
|
||
return item.TotalRechargeUSDTMicro
|
||
case sortByMerchantBalance:
|
||
return item.MerchantBalance
|
||
default:
|
||
return 0
|
||
}
|
||
}
|
||
|
||
// 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 {
|
||
return false
|
||
}
|
||
if direction == "asc" {
|
||
return items[i].Diamond < items[j].Diamond
|
||
}
|
||
return items[i].Diamond > items[j].Diamond
|
||
})
|
||
}
|
||
|
||
// 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
|
||
}
|
||
if pageSize < 1 {
|
||
pageSize = 20
|
||
}
|
||
start := (page - 1) * pageSize
|
||
if start >= len(items) {
|
||
return []*CoinSellerListItem{}
|
||
}
|
||
end := start + pageSize
|
||
if end > len(items) {
|
||
end = len(items)
|
||
}
|
||
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
|
||
}
|
||
if pageSize < 1 {
|
||
pageSize = 20
|
||
}
|
||
start := (page - 1) * pageSize
|
||
if start >= len(items) {
|
||
return []*userclient.HostProfile{}
|
||
}
|
||
end := start + pageSize
|
||
if end > len(items) {
|
||
end = len(items)
|
||
}
|
||
return items[start:end]
|
||
}
|
||
|
||
func sqlPlaceholders(count int) string {
|
||
if count <= 0 {
|
||
return ""
|
||
}
|
||
return strings.TrimRight(strings.Repeat("?,", count), ",")
|
||
}
|
||
|
||
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)
|
||
return total, err
|
||
}
|
||
|
||
func offset(page int, pageSize int) int {
|
||
return (page - 1) * pageSize
|
||
}
|
||
|
||
func errUserDBNotConfigured() error {
|
||
return fmt.Errorf("user mysql is not configured")
|
||
}
|