add host withdrawal admin list

This commit is contained in:
zhx 2026-07-02 14:22:37 +08:00
parent 40b8b6a8ea
commit 0b846f9069
13 changed files with 1107 additions and 5 deletions

View File

@ -0,0 +1,12 @@
USE hyapp_wallet;
-- 主播提现后台页会用来源工资用户反查币商侧 COIN_SELLER_COIN 分录;
-- counterparty_user_id 是工资来源用户,组合索引避免按资产时间大范围扫描后再过滤来源用户。
SET @ddl := IF(
(SELECT COUNT(*) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'wallet_entries' AND INDEX_NAME = 'idx_wallet_entries_asset_counterparty_time') = 0,
'ALTER TABLE wallet_entries ADD INDEX idx_wallet_entries_asset_counterparty_time (app_code, asset_type, counterparty_user_id, created_at_ms, entry_id)',
'SELECT 1'
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;

View File

@ -50,6 +50,7 @@ import (
hostagencypolicymodule "hyapp-admin-server/internal/modules/hostagencypolicy"
hostorgmodule "hyapp-admin-server/internal/modules/hostorg"
hostsalarysettlementmodule "hyapp-admin-server/internal/modules/hostsalarysettlement"
hostwithdrawalmodule "hyapp-admin-server/internal/modules/hostwithdrawal"
inviteactivityrewardmodule "hyapp-admin-server/internal/modules/inviteactivityreward"
jobmodule "hyapp-admin-server/internal/modules/job"
levelconfigmodule "hyapp-admin-server/internal/modules/levelconfig"
@ -320,6 +321,7 @@ func main() {
HostAgencyPolicy: hostagencypolicymodule.New(store, walletDB, auditHandler),
HostOrg: hostorgmodule.New(userclient.NewGRPC(userConn), walletclient.NewGRPC(walletConn), userDB, walletDB, sqlDB, auditHandler),
HostSalarySettlement: hostsalarysettlementmodule.New(walletDB, userDB, walletv1.NewWalletCronServiceClient(walletConn), cfg.WalletService.RequestTimeout, auditHandler),
HostWithdrawal: hostwithdrawalmodule.New(sqlDB, walletDB, userDB),
InviteActivityReward: inviteactivityrewardmodule.New(activityclient.NewGRPC(activityConn), userDB, auditHandler),
Job: jobmodule.New(store, cfg, auditHandler),
LevelConfig: levelconfigmodule.New(activityclient.NewGRPC(activityConn), walletclient.NewGRPC(walletConn), auditHandler),

View File

@ -0,0 +1,34 @@
package hostwithdrawal
type userDTO struct {
UserID string `json:"userId"`
DisplayUserID string `json:"displayUserId"`
Username string `json:"username"`
Avatar string `json:"avatar"`
}
type itemDTO struct {
ID string `json:"id"`
RecordType string `json:"recordType"`
Identity string `json:"identity"`
SalaryAssetType string `json:"salaryAssetType"`
SourceUserID string `json:"sourceUserId"`
SourceUser userDTO `json:"sourceUser"`
SellerUserID string `json:"sellerUserId"`
SellerUser userDTO `json:"sellerUser"`
USDMinorAmount int64 `json:"usdMinorAmount"`
CoinAmount int64 `json:"coinAmount"`
Status string `json:"status"`
TransactionID string `json:"transactionId"`
CommandID string `json:"commandId"`
ApplicationID string `json:"applicationId"`
FreezeTransactionID string `json:"freezeTransactionId"`
AuditTransactionID string `json:"auditTransactionId"`
WithdrawMethod string `json:"withdrawMethod"`
WithdrawAddress string `json:"withdrawAddress"`
AuditRemark string `json:"auditRemark"`
AuditImageURL string `json:"auditImageUrl"`
Metadata map[string]any `json:"metadata"`
CreatedAtMS int64 `json:"createdAtMs"`
UpdatedAtMS int64 `json:"updatedAtMs"`
}

View File

@ -0,0 +1,86 @@
package hostwithdrawal
import (
"database/sql"
"errors"
"strconv"
"strings"
"hyapp-admin-server/internal/appctx"
"hyapp-admin-server/internal/modules/shared"
"hyapp-admin-server/internal/response"
"github.com/gin-gonic/gin"
)
type Handler struct {
service *Service
}
func New(adminDB *sql.DB, walletDB *sql.DB, userDB *sql.DB) *Handler {
return &Handler{service: NewService(adminDB, walletDB, userDB)}
}
func (h *Handler) List(c *gin.Context) {
query, ok := parseListQuery(c, appctx.FromContext(c.Request.Context()))
if !ok {
return
}
items, total, err := h.service.List(c.Request.Context(), query)
if err != nil {
if errors.Is(err, errInvalidArgument) {
response.BadRequest(c, "主播提现筛选参数不正确")
return
}
response.ServerError(c, "获取主播提现列表失败")
return
}
response.OK(c, response.Page{Items: items, Page: query.Page, PageSize: query.PageSize, Total: total})
}
func parseListQuery(c *gin.Context, defaultAppCode string) (listQuery, bool) {
options := shared.ListOptions(c)
startAtMS, ok := optionalInt64(c, "start_at_ms", "startAtMs")
if !ok {
response.BadRequest(c, "开始时间不正确")
return listQuery{}, false
}
endAtMS, ok := optionalInt64(c, "end_at_ms", "endAtMs")
if !ok {
response.BadRequest(c, "结束时间不正确")
return listQuery{}, false
}
query, err := normalizeListQuery(listQuery{
Page: options.Page,
PageSize: options.PageSize,
AppCode: firstNonEmpty(c.Query("app_code"), c.Query("appCode"), defaultAppCode),
RecordType: firstNonEmpty(c.Query("record_type"), c.Query("recordType")),
Identity: firstNonEmpty(c.Query("identity"), c.Query("salary_asset_type"), c.Query("salaryAssetType")),
Status: options.Status,
Keyword: options.Keyword,
StartAtMS: startAtMS,
EndAtMS: endAtMS,
})
if err != nil {
response.BadRequest(c, "主播提现筛选参数不正确")
return listQuery{}, false
}
return query, true
}
func optionalInt64(c *gin.Context, keys ...string) (int64, bool) {
value := strings.TrimSpace(firstNonEmpty(queryValues(c, keys...)...))
if value == "" {
return 0, true
}
parsed, err := strconv.ParseInt(value, 10, 64)
return parsed, err == nil && parsed >= 0
}
func queryValues(c *gin.Context, keys ...string) []string {
values := make([]string, 0, len(keys))
for _, key := range keys {
values = append(values, c.Query(key))
}
return values
}

View File

@ -0,0 +1,119 @@
package hostwithdrawal
import (
"errors"
"strings"
)
const (
recordTypeSalaryTransfer = "salary_transfer"
recordTypeWithdrawal = "withdrawal"
statusCompleted = "completed"
statusPending = "pending"
statusApproved = "approved"
statusRejected = "rejected"
assetCoinSellerCoin = "COIN_SELLER_COIN"
bizSalaryTransfer = "salary_transfer_to_coin_seller"
transactionSucceeded = "succeeded"
assetHostSalary = "HOST_SALARY_USD"
assetAgencySalary = "AGENCY_SALARY_USD"
assetBDSalary = "BD_SALARY_USD"
assetAdminSalary = "ADMIN_SALARY_USD"
)
var errInvalidArgument = errors.New("invalid argument")
type listQuery struct {
Page int
PageSize int
AppCode string
RecordType string
Identity string
SalaryAssetType string
Status string
Keyword string
StartAtMS int64
EndAtMS int64
}
func normalizeListQuery(query listQuery) (listQuery, error) {
if query.Page < 1 {
query.Page = 1
}
if query.PageSize < 1 {
query.PageSize = 20
}
if query.PageSize > 100 {
query.PageSize = 100
}
query.AppCode = strings.ToLower(strings.TrimSpace(query.AppCode))
query.RecordType = strings.TrimSpace(query.RecordType)
query.Identity = strings.TrimSpace(query.Identity)
query.Status = strings.TrimSpace(query.Status)
query.Keyword = strings.TrimSpace(query.Keyword)
if query.StartAtMS > 0 && query.EndAtMS > 0 && query.StartAtMS >= query.EndAtMS {
return listQuery{}, errInvalidArgument
}
switch strings.ToLower(query.RecordType) {
case "", "all":
query.RecordType = ""
case recordTypeSalaryTransfer:
query.RecordType = recordTypeSalaryTransfer
case recordTypeWithdrawal:
query.RecordType = recordTypeWithdrawal
default:
return listQuery{}, errInvalidArgument
}
salaryAssetType, identity, err := normalizeIdentity(query.Identity)
if err != nil {
return listQuery{}, err
}
query.SalaryAssetType = salaryAssetType
query.Identity = identity
switch strings.ToLower(query.Status) {
case "", "all":
query.Status = ""
case statusCompleted:
query.Status = statusCompleted
case statusPending:
query.Status = statusPending
case statusApproved:
query.Status = statusApproved
case statusRejected:
query.Status = statusRejected
default:
return listQuery{}, errInvalidArgument
}
return query, nil
}
func normalizeIdentity(value string) (string, string, error) {
switch strings.ToUpper(strings.TrimSpace(value)) {
case "", "ALL":
return "", "", nil
case "HOST", assetHostSalary:
return assetHostSalary, "host", nil
case "AGENCY", assetAgencySalary:
return assetAgencySalary, "agency", nil
case "BD", assetBDSalary:
return assetBDSalary, "bd", nil
case "ADMIN", "BD_LEADER", "BD_LEADER_ADMIN", assetAdminSalary:
return assetAdminSalary, "bd_leader_admin", nil
default:
return "", "", errInvalidArgument
}
}
func identityFromAsset(assetType string) string {
_, identity, err := normalizeIdentity(assetType)
if err != nil {
return ""
}
return identity
}

View File

@ -0,0 +1,15 @@
package hostwithdrawal
import (
"hyapp-admin-server/internal/middleware"
"github.com/gin-gonic/gin"
)
func RegisterRoutes(protected *gin.RouterGroup, h *Handler) {
if h == nil {
return
}
protected.GET("/admin/host-withdrawals", middleware.RequirePermission("host-withdrawal:view"), h.List)
}

View File

@ -0,0 +1,603 @@
package hostwithdrawal
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"sort"
"strconv"
"strings"
"time"
"hyapp-admin-server/internal/modules/shared"
)
type Service struct {
adminDB *sql.DB
walletDB *sql.DB
userDB *sql.DB
}
type userProfile struct {
UserID int64
DisplayUserID string
Username string
Avatar string
}
func NewService(adminDB *sql.DB, walletDB *sql.DB, userDB *sql.DB) *Service {
return &Service{adminDB: adminDB, walletDB: walletDB, userDB: userDB}
}
func (s *Service) List(ctx context.Context, query listQuery) ([]itemDTO, int64, error) {
var err error
query, err = normalizeListQuery(query)
if err != nil {
return nil, 0, err
}
if s == nil || s.adminDB == nil || s.walletDB == nil {
return nil, 0, fmt.Errorf("host withdrawal storage is not configured")
}
keywordUserIDs, err := s.resolveKeywordUserIDs(ctx, query.AppCode, query.Keyword)
if err != nil {
return nil, 0, err
}
fetchLimit := offset(query.Page, query.PageSize) + query.PageSize
items := make([]itemDTO, 0, fetchLimit)
total := int64(0)
if query.RecordType == "" || query.RecordType == recordTypeSalaryTransfer {
// 工资转币商只展示已完成账本事实pending/approved/rejected 属于提现申请状态,不能误筛到钱包转账。
if query.Status == "" || query.Status == statusCompleted {
count, err := s.countSalaryTransfers(ctx, query, keywordUserIDs)
if err != nil {
return nil, 0, err
}
total += count
records, err := s.listSalaryTransfers(ctx, query, keywordUserIDs, fetchLimit)
if err != nil {
return nil, 0, err
}
items = append(items, records...)
}
}
if query.RecordType == "" || query.RecordType == recordTypeWithdrawal {
// 提现申请状态来自后台申请表completed 只代表钱包转币商完成态,所以提现分支直接跳过。
if query.Status != statusCompleted {
count, err := s.countWithdrawalApplications(ctx, query, keywordUserIDs)
if err != nil {
return nil, 0, err
}
total += count
records, err := s.listWithdrawalApplications(ctx, query, keywordUserIDs, fetchLimit)
if err != nil {
return nil, 0, err
}
items = append(items, records...)
}
}
enriched, err := s.enrichUsers(ctx, query.AppCode, items)
if err != nil {
return nil, 0, err
}
return pageMergedItems(enriched, query.Page, query.PageSize), total, nil
}
func (s *Service) countSalaryTransfers(ctx context.Context, query listQuery, keywordUserIDs []int64) (int64, error) {
whereSQL, args := salaryTransferWhere(query, keywordUserIDs)
var total int64
err := s.walletDB.QueryRowContext(ctx, `
SELECT COUNT(*)
FROM wallet_entries e
JOIN wallet_transactions wt ON wt.app_code = e.app_code AND wt.transaction_id = e.transaction_id
`+whereSQL,
args...,
).Scan(&total)
return total, err
}
func (s *Service) listSalaryTransfers(ctx context.Context, query listQuery, keywordUserIDs []int64, limit int) ([]itemDTO, error) {
if limit <= 0 {
return nil, nil
}
whereSQL, args := salaryTransferWhere(query, keywordUserIDs)
rows, err := s.walletDB.QueryContext(ctx, `
SELECT e.entry_id, e.transaction_id, wt.command_id, e.user_id, e.counterparty_user_id,
e.available_delta, e.available_after, COALESCE(CAST(wt.metadata_json AS CHAR), '{}'), e.created_at_ms
FROM wallet_entries e
JOIN wallet_transactions wt ON wt.app_code = e.app_code AND wt.transaction_id = e.transaction_id
`+whereSQL+`
ORDER BY e.created_at_ms DESC, e.entry_id DESC
LIMIT ?`,
append(args, limit)...,
)
if err != nil {
return nil, err
}
defer rows.Close()
items := make([]itemDTO, 0, limit)
for rows.Next() {
var (
entryID int64
transactionID string
commandID string
sellerUserID int64
sourceUserID int64
availableDelta int64
sellerBalanceAfter int64
metadataJSON string
createdAtMS int64
)
if err := rows.Scan(&entryID, &transactionID, &commandID, &sellerUserID, &sourceUserID, &availableDelta, &sellerBalanceAfter, &metadataJSON, &createdAtMS); err != nil {
return nil, err
}
metadata, err := parseMetadataJSON(metadataJSON)
if err != nil {
return nil, err
}
if value := metadataInt64(metadata, "source_user_id", "sourceUserId"); value > 0 {
sourceUserID = value
}
if value := metadataInt64(metadata, "seller_user_id", "sellerUserId"); value > 0 {
sellerUserID = value
}
assetType := firstNonEmpty(metadataString(metadata, "salary_asset_type"), query.SalaryAssetType)
coinAmount := metadataInt64(metadata, "coin_amount", "coinAmount")
if coinAmount == 0 {
coinAmount = absInt64(availableDelta)
}
if metadata == nil {
metadata = map[string]any{}
}
metadata["seller_balance_after"] = sellerBalanceAfter
items = append(items, itemDTO{
ID: fmt.Sprintf("%s:%d", recordTypeSalaryTransfer, entryID),
RecordType: recordTypeSalaryTransfer,
Identity: identityFromAsset(assetType),
SalaryAssetType: assetType,
SourceUserID: formatOptionalID(sourceUserID),
SourceUser: userDTO{UserID: formatOptionalID(sourceUserID)},
SellerUserID: formatOptionalID(sellerUserID),
SellerUser: userDTO{UserID: formatOptionalID(sellerUserID)},
USDMinorAmount: metadataInt64(metadata, "salary_usd_minor", "salaryUsdMinor"),
CoinAmount: coinAmount,
Status: statusCompleted,
TransactionID: transactionID,
CommandID: commandID,
Metadata: metadata,
CreatedAtMS: createdAtMS,
UpdatedAtMS: createdAtMS,
})
}
return items, rows.Err()
}
func (s *Service) countWithdrawalApplications(ctx context.Context, query listQuery, keywordUserIDs []int64) (int64, error) {
whereSQL, args := withdrawalWhere(query, keywordUserIDs)
var total int64
err := s.adminDB.QueryRowContext(ctx, `
SELECT COUNT(*)
FROM admin_user_withdrawal_applications a
`+whereSQL,
args...,
).Scan(&total)
return total, err
}
func (s *Service) listWithdrawalApplications(ctx context.Context, query listQuery, keywordUserIDs []int64, limit int) ([]itemDTO, error) {
if limit <= 0 {
return nil, nil
}
whereSQL, args := withdrawalWhere(query, keywordUserIDs)
rows, err := s.adminDB.QueryContext(ctx, `
SELECT id, user_id, salary_asset_type, withdraw_amount_minor, withdraw_method, withdraw_address,
freeze_command_id, freeze_transaction_id, audit_command_id, audit_transaction_id,
status, COALESCE(audit_remark, ''), audit_image_url, created_at_ms, updated_at_ms
FROM admin_user_withdrawal_applications a
`+whereSQL+`
ORDER BY a.created_at_ms DESC, a.id DESC
LIMIT ?`,
append(args, limit)...,
)
if err != nil {
return nil, err
}
defer rows.Close()
items := make([]itemDTO, 0, limit)
for rows.Next() {
var (
id int64
userIDText string
assetType string
amountMinor int64
withdrawMethod string
withdrawAddress string
freezeCommandID string
freezeTransactionID string
auditCommandID string
auditTransactionID string
status string
auditRemark string
auditImageURL string
createdAtMS int64
updatedAtMS int64
)
if err := rows.Scan(&id, &userIDText, &assetType, &amountMinor, &withdrawMethod, &withdrawAddress, &freezeCommandID, &freezeTransactionID, &auditCommandID, &auditTransactionID, &status, &auditRemark, &auditImageURL, &createdAtMS, &updatedAtMS); err != nil {
return nil, err
}
sourceUserID := parseInt64OrZero(userIDText)
items = append(items, itemDTO{
ID: fmt.Sprintf("%s:%d", recordTypeWithdrawal, id),
RecordType: recordTypeWithdrawal,
Identity: identityFromAsset(assetType),
SalaryAssetType: assetType,
SourceUserID: formatOptionalID(sourceUserID),
SourceUser: userDTO{UserID: firstNonEmpty(formatOptionalID(sourceUserID), userIDText)},
USDMinorAmount: amountMinor,
Status: status,
CommandID: firstNonEmpty(auditCommandID, freezeCommandID),
ApplicationID: strconv.FormatInt(id, 10),
FreezeTransactionID: freezeTransactionID,
AuditTransactionID: auditTransactionID,
WithdrawMethod: withdrawMethod,
WithdrawAddress: withdrawAddress,
AuditRemark: auditRemark,
AuditImageURL: auditImageURL,
Metadata: map[string]any{},
CreatedAtMS: createdAtMS,
UpdatedAtMS: updatedAtMS,
})
}
return items, rows.Err()
}
func salaryTransferWhere(query listQuery, keywordUserIDs []int64) (string, []any) {
where := "WHERE e.app_code = ? AND e.asset_type = ? AND wt.biz_type = ? AND wt.status = ?"
args := []any{query.AppCode, assetCoinSellerCoin, bizSalaryTransfer, transactionSucceeded}
if query.SalaryAssetType != "" {
// 身份维度是工资资产类型,来源写在交易 metadata 中;只筛工资转币商事实,不触碰普通币商出货或后台进货。
where += " AND JSON_UNQUOTE(JSON_EXTRACT(wt.metadata_json, '$.salary_asset_type')) = ?"
args = append(args, query.SalaryAssetType)
}
if query.StartAtMS > 0 {
where += " AND e.created_at_ms >= ?"
args = append(args, query.StartAtMS)
}
if query.EndAtMS > 0 {
where += " AND e.created_at_ms < ?"
args = append(args, query.EndAtMS)
}
if keywordSQL, keywordArgs := salaryTransferKeywordSQL(query.Keyword, keywordUserIDs); keywordSQL != "" {
where += " AND (" + keywordSQL + ")"
args = append(args, keywordArgs...)
}
return where, args
}
func salaryTransferKeywordSQL(keyword string, userIDs []int64) (string, []any) {
clauses := make([]string, 0, 4)
args := make([]any, 0)
if len(userIDs) > 0 {
clauses = append(clauses, "e.user_id IN ("+placeholders(len(userIDs))+")")
for _, id := range userIDs {
args = append(args, id)
}
clauses = append(clauses, "e.counterparty_user_id IN ("+placeholders(len(userIDs))+")")
for _, id := range userIDs {
args = append(args, id)
}
}
if keyword = strings.TrimSpace(keyword); keyword != "" {
like := "%" + keyword + "%"
clauses = append(clauses, "e.transaction_id LIKE ?", "wt.command_id LIKE ?", "CAST(e.user_id AS CHAR) LIKE ?", "CAST(e.counterparty_user_id AS CHAR) LIKE ?")
args = append(args, like, like, like, like)
}
return strings.Join(clauses, " OR "), args
}
func withdrawalWhere(query listQuery, keywordUserIDs []int64) (string, []any) {
where := "WHERE a.app_code = ?"
args := []any{query.AppCode}
if query.SalaryAssetType != "" {
where += " AND a.salary_asset_type = ?"
args = append(args, query.SalaryAssetType)
}
if query.Status != "" {
where += " AND a.status = ?"
args = append(args, query.Status)
}
if query.StartAtMS > 0 {
where += " AND a.created_at_ms >= ?"
args = append(args, query.StartAtMS)
}
if query.EndAtMS > 0 {
where += " AND a.created_at_ms < ?"
args = append(args, query.EndAtMS)
}
if keywordSQL, keywordArgs := withdrawalKeywordSQL(query.Keyword, keywordUserIDs); keywordSQL != "" {
where += " AND (" + keywordSQL + ")"
args = append(args, keywordArgs...)
}
return where, args
}
func withdrawalKeywordSQL(keyword string, userIDs []int64) (string, []any) {
clauses := make([]string, 0, 8)
args := make([]any, 0)
if len(userIDs) > 0 {
clauses = append(clauses, "a.user_id IN ("+placeholders(len(userIDs))+")")
for _, id := range userIDs {
args = append(args, strconv.FormatInt(id, 10))
}
}
if keyword = strings.TrimSpace(keyword); keyword != "" {
like := "%" + keyword + "%"
clauses = append(clauses,
"CAST(a.id AS CHAR) LIKE ?",
"a.user_id LIKE ?",
"a.withdraw_method LIKE ?",
"a.withdraw_address LIKE ?",
"a.freeze_transaction_id LIKE ?",
"a.audit_transaction_id LIKE ?",
"a.approver_name LIKE ?",
)
args = append(args, like, like, like, like, like, like, like)
}
return strings.Join(clauses, " OR "), args
}
func (s *Service) resolveKeywordUserIDs(ctx context.Context, appCode string, keyword string) ([]int64, error) {
keyword = strings.TrimSpace(keyword)
if keyword == "" {
return nil, nil
}
userIDs := make([]int64, 0, 4)
if numeric, err := strconv.ParseInt(keyword, 10, 64); err == nil && numeric > 0 {
// 账务事实可能早于用户资料补全;数字关键字直接加入候选,避免资料缺失吞掉真实流水。
userIDs = append(userIDs, numeric)
}
if s == nil || s.userDB == nil {
return positiveUniqueInt64s(userIDs), nil
}
matchSQL, matchArgs := shared.UserIdentityExactSQL("u", "u.user_id", keyword, time.Now().UnixMilli())
args := append([]any{appCode}, matchArgs...)
rows, err := s.userDB.QueryContext(ctx, `
SELECT u.user_id
FROM users u
WHERE u.app_code = ? AND `+matchSQL+`
ORDER BY u.updated_at_ms DESC, u.user_id DESC
LIMIT 1000`,
args...,
)
if err != nil {
return nil, err
}
defer rows.Close()
for rows.Next() {
var userID int64
if err := rows.Scan(&userID); err != nil {
return nil, err
}
userIDs = append(userIDs, userID)
}
return positiveUniqueInt64s(userIDs), rows.Err()
}
func (s *Service) enrichUsers(ctx context.Context, appCode string, items []itemDTO) ([]itemDTO, error) {
userIDs := make([]int64, 0, len(items)*2)
for _, item := range items {
if id := parseInt64OrZero(item.SourceUserID); id > 0 {
userIDs = append(userIDs, id)
}
if id := parseInt64OrZero(item.SellerUserID); id > 0 {
userIDs = append(userIDs, id)
}
}
profiles, err := s.userProfiles(ctx, appCode, userIDs)
if err != nil {
return nil, err
}
out := append([]itemDTO(nil), items...)
for i := range out {
if id := parseInt64OrZero(out[i].SourceUserID); id > 0 {
if profile, ok := profiles[id]; ok {
out[i].SourceUser = userDTOFromProfile(profile)
}
}
if id := parseInt64OrZero(out[i].SellerUserID); id > 0 {
if profile, ok := profiles[id]; ok {
out[i].SellerUser = userDTOFromProfile(profile)
}
}
}
return out, nil
}
func (s *Service) userProfiles(ctx context.Context, appCode string, userIDs []int64) (map[int64]userProfile, error) {
result := make(map[int64]userProfile, len(userIDs))
if s == nil || s.userDB == nil || len(userIDs) == 0 {
return result, nil
}
userIDs = positiveUniqueInt64s(userIDs)
if len(userIDs) == 0 {
return result, nil
}
args := make([]any, 0, len(userIDs)+1)
args = append(args, appCode)
for _, id := range userIDs {
args = append(args, id)
}
rows, err := s.userDB.QueryContext(ctx, fmt.Sprintf(`
SELECT user_id, current_display_user_id, COALESCE(username, ''), COALESCE(avatar, '')
FROM users
WHERE app_code = ? AND user_id IN (%s)`,
placeholders(len(userIDs)),
), args...)
if err != nil {
return nil, err
}
defer rows.Close()
for rows.Next() {
var profile userProfile
if err := rows.Scan(&profile.UserID, &profile.DisplayUserID, &profile.Username, &profile.Avatar); err != nil {
return nil, err
}
result[profile.UserID] = profile
}
return result, rows.Err()
}
func userDTOFromProfile(profile userProfile) userDTO {
return userDTO{
UserID: strconv.FormatInt(profile.UserID, 10),
DisplayUserID: profile.DisplayUserID,
Username: profile.Username,
Avatar: profile.Avatar,
}
}
func pageMergedItems(items []itemDTO, page int, pageSize int) []itemDTO {
sort.SliceStable(items, func(i int, j int) bool {
if items[i].CreatedAtMS != items[j].CreatedAtMS {
return items[i].CreatedAtMS > items[j].CreatedAtMS
}
if items[i].RecordType != items[j].RecordType {
return items[i].RecordType < items[j].RecordType
}
return recordNumericID(items[i]) > recordNumericID(items[j])
})
start := offset(page, pageSize)
if start >= len(items) {
return []itemDTO{}
}
end := start + pageSize
if end > len(items) {
end = len(items)
}
return items[start:end]
}
func recordNumericID(item itemDTO) int64 {
if item.RecordType == recordTypeWithdrawal {
return parseInt64OrZero(item.ApplicationID)
}
parts := strings.Split(item.ID, ":")
if len(parts) == 2 {
return parseInt64OrZero(parts[1])
}
return 0
}
func parseMetadataJSON(value string) (map[string]any, error) {
value = strings.TrimSpace(value)
if value == "" || value == "null" {
return map[string]any{}, nil
}
var metadata map[string]any
if err := json.Unmarshal([]byte(value), &metadata); err != nil {
return nil, err
}
if metadata == nil {
return map[string]any{}, nil
}
return metadata, nil
}
func metadataString(metadata map[string]any, key string) string {
value, ok := metadata[key]
if !ok || value == nil {
return ""
}
if typed, ok := value.(string); ok {
return strings.TrimSpace(typed)
}
return strings.TrimSpace(fmt.Sprint(value))
}
func metadataInt64(metadata map[string]any, keys ...string) int64 {
for _, key := range keys {
value, ok := metadata[key]
if !ok || value == nil {
continue
}
switch typed := value.(type) {
case float64:
return int64(typed)
case int64:
return typed
case int:
return int64(typed)
case string:
if parsed, err := strconv.ParseInt(strings.TrimSpace(typed), 10, 64); err == nil {
return parsed
}
}
}
return 0
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
if value = strings.TrimSpace(value); value != "" {
return value
}
}
return ""
}
func formatOptionalID(value int64) string {
if value <= 0 {
return ""
}
return strconv.FormatInt(value, 10)
}
func parseInt64OrZero(value string) int64 {
parsed, err := strconv.ParseInt(strings.TrimSpace(value), 10, 64)
if err != nil || parsed <= 0 {
return 0
}
return parsed
}
func absInt64(value int64) int64 {
if value < 0 {
return -value
}
return value
}
func offset(page int, pageSize int) int {
if page < 1 {
page = 1
}
return (page - 1) * pageSize
}
func placeholders(count int) string {
if count <= 0 {
return ""
}
return strings.TrimRight(strings.Repeat("?,", count), ",")
}
func positiveUniqueInt64s(values []int64) []int64 {
seen := make(map[int64]struct{}, len(values))
result := make([]int64, 0, len(values))
for _, value := range values {
if value <= 0 {
continue
}
if _, ok := seen[value]; ok {
continue
}
seen[value] = struct{}{}
result = append(result, value)
}
return result
}

View File

@ -0,0 +1,141 @@
package hostwithdrawal
import (
"os"
"reflect"
"strings"
"testing"
"github.com/gin-gonic/gin"
)
func TestNormalizeListQueryMapsIdentityAndRejectsBadInput(t *testing.T) {
query, err := normalizeListQuery(listQuery{
Page: -1,
PageSize: 1000,
AppCode: " LaLu ",
RecordType: "all",
Identity: "BD_LEADER",
Status: "all",
Keyword: " 168557 ",
StartAtMS: 100,
EndAtMS: 200,
})
if err != nil {
t.Fatalf("normalize query failed: %v", err)
}
if query.Page != 1 || query.PageSize != 100 || query.AppCode != "lalu" || query.RecordType != "" || query.Status != "" || query.Keyword != "168557" {
t.Fatalf("normalized basic fields mismatch: %+v", query)
}
if query.SalaryAssetType != assetAdminSalary || query.Identity != "bd_leader_admin" {
t.Fatalf("identity mapping mismatch: %+v", query)
}
if _, err := normalizeListQuery(listQuery{Status: "done"}); err == nil {
t.Fatal("expected invalid status to fail")
}
if _, err := normalizeListQuery(listQuery{StartAtMS: 200, EndAtMS: 200}); err == nil {
t.Fatal("expected closed time range to fail")
}
}
func TestSalaryTransferWhereUsesIdentityStatusTimeAndKeywordUsers(t *testing.T) {
where, args := salaryTransferWhere(listQuery{
AppCode: "lalu",
SalaryAssetType: assetHostSalary,
Keyword: "cmd",
StartAtMS: 100,
EndAtMS: 200,
}, []int64{11, 22})
wantWhere := "WHERE e.app_code = ? AND e.asset_type = ? AND wt.biz_type = ? AND wt.status = ? AND JSON_UNQUOTE(JSON_EXTRACT(wt.metadata_json, '$.salary_asset_type')) = ? AND e.created_at_ms >= ? AND e.created_at_ms < ? AND (e.user_id IN (?,?) OR e.counterparty_user_id IN (?,?) OR e.transaction_id LIKE ? OR wt.command_id LIKE ? OR CAST(e.user_id AS CHAR) LIKE ? OR CAST(e.counterparty_user_id AS CHAR) LIKE ?)"
if where != wantWhere {
t.Fatalf("where mismatch:\nwant %s\n got %s", wantWhere, where)
}
wantArgs := []any{"lalu", assetCoinSellerCoin, bizSalaryTransfer, transactionSucceeded, assetHostSalary, int64(100), int64(200), int64(11), int64(22), int64(11), int64(22), "%cmd%", "%cmd%", "%cmd%", "%cmd%"}
if !reflect.DeepEqual(args, wantArgs) {
t.Fatalf("args mismatch:\nwant %#v\n got %#v", wantArgs, args)
}
}
func TestWithdrawalWhereUsesStatusIdentityTimeAndKeywordUsers(t *testing.T) {
where, args := withdrawalWhere(listQuery{
AppCode: "lalu",
SalaryAssetType: assetBDSalary,
Status: statusPending,
Keyword: "upi",
StartAtMS: 100,
EndAtMS: 200,
}, []int64{77})
wantWhere := "WHERE a.app_code = ? AND a.salary_asset_type = ? AND a.status = ? AND a.created_at_ms >= ? AND a.created_at_ms < ? AND (a.user_id IN (?) OR CAST(a.id AS CHAR) LIKE ? OR a.user_id LIKE ? OR a.withdraw_method LIKE ? OR a.withdraw_address LIKE ? OR a.freeze_transaction_id LIKE ? OR a.audit_transaction_id LIKE ? OR a.approver_name LIKE ?)"
if where != wantWhere {
t.Fatalf("where mismatch:\nwant %s\n got %s", wantWhere, where)
}
wantArgs := []any{"lalu", assetBDSalary, statusPending, int64(100), int64(200), "77", "%upi%", "%upi%", "%upi%", "%upi%", "%upi%", "%upi%", "%upi%"}
if !reflect.DeepEqual(args, wantArgs) {
t.Fatalf("args mismatch:\nwant %#v\n got %#v", wantArgs, args)
}
}
func TestPageMergedItemsSortsAndSlicesStableMixedSources(t *testing.T) {
items := []itemDTO{
{ID: "salary_transfer:8", RecordType: recordTypeSalaryTransfer, CreatedAtMS: 100},
{ID: "withdrawal:7", RecordType: recordTypeWithdrawal, ApplicationID: "7", CreatedAtMS: 100},
{ID: "salary_transfer:9", RecordType: recordTypeSalaryTransfer, CreatedAtMS: 100},
{ID: "withdrawal:11", RecordType: recordTypeWithdrawal, ApplicationID: "11", CreatedAtMS: 90},
{ID: "salary_transfer:1", RecordType: recordTypeSalaryTransfer, CreatedAtMS: 80},
}
page := pageMergedItems(items, 1, 4)
gotIDs := []string{page[0].ID, page[1].ID, page[2].ID, page[3].ID}
wantIDs := []string{"salary_transfer:9", "salary_transfer:8", "withdrawal:7", "withdrawal:11"}
if !reflect.DeepEqual(gotIDs, wantIDs) {
t.Fatalf("page sort mismatch:\nwant %#v\n got %#v", wantIDs, gotIDs)
}
secondPage := pageMergedItems(items, 2, 2)
gotIDs = []string{secondPage[0].ID, secondPage[1].ID}
wantIDs = []string{"withdrawal:7", "withdrawal:11"}
if !reflect.DeepEqual(gotIDs, wantIDs) {
t.Fatalf("second page mismatch:\nwant %#v\n got %#v", wantIDs, gotIDs)
}
}
func TestRegisterRoutesExposesHostWithdrawalsEndpoint(t *testing.T) {
gin.SetMode(gin.TestMode)
engine := gin.New()
group := engine.Group("/api/v1")
RegisterRoutes(group, &Handler{})
for _, route := range engine.Routes() {
if route.Method == "GET" && route.Path == "/api/v1/admin/host-withdrawals" {
return
}
}
t.Fatal("host withdrawals route was not registered")
}
func TestHostWithdrawalIndexSQLKeepsRequiredReadPaths(t *testing.T) {
adminSQL, err := os.ReadFile("../../../migrations/072_host_withdrawal_navigation.sql")
if err != nil {
t.Fatalf("read admin migration failed: %v", err)
}
walletSQL, err := os.ReadFile("../../../../../scripts/mysql/054_wallet_entries_asset_counterparty_time_index.sql")
if err != nil {
t.Fatalf("read wallet migration failed: %v", err)
}
for _, snippet := range []string{
"idx_admin_withdrawal_app_time (app_code, created_at_ms, id)",
"idx_admin_withdrawal_app_asset_time (app_code, salary_asset_type, created_at_ms, id)",
"idx_admin_withdrawal_app_status_asset_time (app_code, status, salary_asset_type, created_at_ms, id)",
"idx_admin_withdrawal_app_user_time (app_code, user_id, created_at_ms, id)",
} {
if !strings.Contains(string(adminSQL), snippet) {
t.Fatalf("admin migration missing index snippet %q", snippet)
}
}
if !strings.Contains(string(walletSQL), "idx_wallet_entries_asset_counterparty_time (app_code, asset_type, counterparty_user_id, created_at_ms, entry_id)") {
t.Fatal("wallet migration missing counterparty index")
}
}

View File

@ -104,6 +104,7 @@ var defaultPermissions = []model.Permission{
{Name: "币商工资兑换比例", Code: "coin-seller:exchange-rate", Kind: "button"},
{Name: "金币流水查看", Code: "coin-ledger:view", Kind: "menu"},
{Name: "币商流水查看", Code: "coin-seller-ledger:view", Kind: "menu"},
{Name: "主播提现查看", Code: "host-withdrawal:view", Kind: "menu"},
{Name: "金币增减查看", Code: "coin-adjustment:view", Kind: "menu"},
{Name: "金币增减创建", Code: "coin-adjustment:create", Kind: "button"},
{Name: "举报列表查看", Code: "report:view", Kind: "menu"},
@ -342,6 +343,7 @@ func (s *Store) seedMenus() error {
{ParentID: &hostOrgID, Title: "工资政策", Code: "host-agency-policy", Path: "/host/salary-policies", Icon: "wallet", PermissionCode: "host-agency-policy:view", Sort: 85, Visible: true},
{ParentID: &hostOrgID, Title: "工资结算", Code: "host-salary-settlement", Path: "/host/salary-settlements", Icon: "receipt", PermissionCode: "host-salary-settlement:view", Sort: 86, Visible: true},
{ParentID: &hostOrgID, Title: "Coin Saller列表", Code: "host-org-coin-sellers", Path: "/host/coin-sellers", Icon: "wallet", PermissionCode: "coin-seller:view", Sort: 87, Visible: true},
{ParentID: &hostOrgID, Title: "主播提现", Code: "host-withdrawals", Path: "/host/withdrawals", Icon: "receipt", PermissionCode: "host-withdrawal:view", Sort: 88, Visible: true},
}
for _, menu := range children {
if _, err := s.syncDefaultMenu(s.db, menu); err != nil {
@ -642,7 +644,7 @@ func defaultRolePermissionCodes(code string) []string {
"agency:view", "agency:create", "agency:status", "agency:delete",
"bd:view", "bd:create", "bd:update",
"coin-seller:view", "coin-seller:create", "coin-seller:update", "coin-seller:stock-credit", "coin-seller:exchange-rate",
"coin-ledger:view", "coin-seller-ledger:view", "coin-adjustment:view", "coin-adjustment:create", "report:view", "gift-diamond:view", "gift-diamond:update", "full-server-notice:view", "full-server-notice:send", "payment-bill:view", "payment-third-party:view", "payment-third-party:update", "payment-temporary-link:view", "payment-temporary-link:create", "finance-application:create", "finance-withdrawal:view", "finance-operation:user-coin-credit", "finance-operation:user-coin-debit", "finance-operation:user-wallet-credit", "finance-operation:user-wallet-debit", "finance-operation:coin-seller-coin-credit", "finance-operation:coin-seller-coin-debit", "payment-product:view", "payment-product:create", "payment-product:update", "payment-product:delete",
"coin-ledger:view", "coin-seller-ledger:view", "host-withdrawal:view", "coin-adjustment:view", "coin-adjustment:create", "report:view", "gift-diamond:view", "gift-diamond:update", "full-server-notice:view", "full-server-notice:send", "payment-bill:view", "payment-third-party:view", "payment-third-party:update", "payment-temporary-link:view", "payment-temporary-link:create", "finance-application:create", "finance-withdrawal:view", "finance-operation:user-coin-credit", "finance-operation:user-coin-debit", "finance-operation:user-wallet-credit", "finance-operation:user-wallet-debit", "finance-operation:coin-seller-coin-credit", "finance-operation:coin-seller-coin-debit", "payment-product:view", "payment-product:create", "payment-product:update", "payment-product:delete",
"lucky-gift:view", "lucky-gift:update", "wheel:view", "wheel:update",
"game:view", "game:create", "game:update", "game:status", "game:delete",
"daily-task:view", "daily-task:create", "daily-task:update", "daily-task:status",
@ -659,7 +661,7 @@ func defaultRolePermissionCodes(code string) []string {
"upload:create",
}
case "auditor":
return []string{"overview:view", "log:view", "user:view", "team:view", "app-user:view", "level-config:view", "pretty-id:view", "risk-config:view", "region-block:view", "room:view", "room-pin:view", "room-config:view", "room-whitelist:view", "room-robot:view", "app-config:view", "app-version:view", "resource:view", "resource-shop:view", "resource-group:view", "resource-grant:view", "gift:view", "emoji-pack:view", "host-agency-policy:view", "team-salary-policy:view", "host-salary-settlement:view", "coin-ledger:view", "coin-seller-ledger:view", "coin-adjustment:view", "report:view", "gift-diamond:view", "full-server-notice:view", "lucky-gift:view", "wheel:view", "payment-bill:view", "payment-third-party:view", "payment-temporary-link:view", "payment-product:view", "game:view", "daily-task:view", "achievement:view", "seven-day-checkin:view", "room-rocket:view", "red-packet:view", "cp-config:view", "cp-weekly-rank:view", "vip-config:view", "weekly-star:view", "agency-opening:view", "role:view", "permission:view", "job:view"}
return []string{"overview:view", "log:view", "user:view", "team:view", "app-user:view", "level-config:view", "pretty-id:view", "risk-config:view", "region-block:view", "room:view", "room-pin:view", "room-config:view", "room-whitelist:view", "room-robot:view", "app-config:view", "app-version:view", "resource:view", "resource-shop:view", "resource-group:view", "resource-grant:view", "gift:view", "emoji-pack:view", "host-agency-policy:view", "team-salary-policy:view", "host-salary-settlement:view", "host-withdrawal:view", "coin-ledger:view", "coin-seller-ledger:view", "coin-adjustment:view", "report:view", "gift-diamond:view", "full-server-notice:view", "lucky-gift:view", "wheel:view", "payment-bill:view", "payment-third-party:view", "payment-temporary-link:view", "payment-product:view", "game:view", "daily-task:view", "achievement:view", "seven-day-checkin:view", "room-rocket:view", "red-packet:view", "cp-config:view", "cp-weekly-rank:view", "vip-config:view", "weekly-star:view", "agency-opening:view", "role:view", "permission:view", "job:view"}
case "readonly":
return []string{
"overview:view",
@ -689,6 +691,7 @@ func defaultRolePermissionCodes(code string) []string {
"host-agency-policy:view",
"team-salary-policy:view",
"host-salary-settlement:view",
"host-withdrawal:view",
"agency:view",
"bd:view",
"coin-seller:view",
@ -750,7 +753,7 @@ func defaultRolePermissionMigrationCodes(code string) []string {
"region:view", "region:create", "region:update", "region:status",
"host-agency-policy:view", "host-agency-policy:create", "host-agency-policy:update", "host-agency-policy:delete", "host-agency-policy:publish", "team-salary-policy:view", "team-salary-policy:create", "team-salary-policy:update", "team-salary-policy:delete", "host-salary-settlement:view", "host-salary-settlement:settle",
"coin-seller:view", "coin-seller:create", "coin-seller:update", "coin-seller:stock-credit", "coin-seller:exchange-rate",
"coin-ledger:view", "coin-seller-ledger:view", "coin-adjustment:view", "coin-adjustment:create", "report:view", "gift-diamond:view", "gift-diamond:update", "full-server-notice:view", "full-server-notice:send", "payment-bill:view", "payment-third-party:view", "payment-third-party:update", "payment-temporary-link:view", "payment-temporary-link:create", "finance-application:create", "finance-operation:user-coin-credit", "finance-operation:user-coin-debit", "finance-operation:user-wallet-credit", "finance-operation:user-wallet-debit", "finance-operation:coin-seller-coin-credit", "finance-operation:coin-seller-coin-debit", "payment-product:view", "payment-product:create", "payment-product:update", "payment-product:delete",
"coin-ledger:view", "coin-seller-ledger:view", "host-withdrawal:view", "coin-adjustment:view", "coin-adjustment:create", "report:view", "gift-diamond:view", "gift-diamond:update", "full-server-notice:view", "full-server-notice:send", "payment-bill:view", "payment-third-party:view", "payment-third-party:update", "payment-temporary-link:view", "payment-temporary-link:create", "finance-application:create", "finance-operation:user-coin-credit", "finance-operation:user-coin-debit", "finance-operation:user-wallet-credit", "finance-operation:user-wallet-debit", "finance-operation:coin-seller-coin-credit", "finance-operation:coin-seller-coin-debit", "payment-product:view", "payment-product:create", "payment-product:update", "payment-product:delete",
"lucky-gift:view", "lucky-gift:update", "wheel:view", "wheel:update",
"game:view", "game:create", "game:update", "game:status", "game:delete",
"daily-task:view", "daily-task:create", "daily-task:update", "daily-task:status",
@ -764,7 +767,7 @@ func defaultRolePermissionMigrationCodes(code string) []string {
"agency-opening:view", "agency-opening:create", "agency-opening:update",
}
case "auditor", "readonly":
return []string{"team:view", "level-config:view", "pretty-id:view", "risk-config:view", "region-block:view", "room:view", "room-pin:view", "room-config:view", "room-whitelist:view", "room-robot:view", "app-config:view", "app-version:view", "resource:view", "resource-shop:view", "resource-group:view", "resource-grant:view", "gift:view", "emoji-pack:view", "host-agency-policy:view", "team-salary-policy:view", "host-salary-settlement:view", "coin-seller:view", "coin-ledger:view", "coin-seller-ledger:view", "coin-adjustment:view", "report:view", "gift-diamond:view", "full-server-notice:view", "lucky-gift:view", "wheel:view", "payment-bill:view", "payment-third-party:view", "payment-temporary-link:view", "payment-product:view", "game:view", "daily-task:view", "achievement:view", "seven-day-checkin:view", "room-rocket:view", "red-packet:view", "cp-config:view", "cp-weekly-rank:view", "vip-config:view", "weekly-star:view", "agency-opening:view"}
return []string{"team:view", "level-config:view", "pretty-id:view", "risk-config:view", "region-block:view", "room:view", "room-pin:view", "room-config:view", "room-whitelist:view", "room-robot:view", "app-config:view", "app-version:view", "resource:view", "resource-shop:view", "resource-group:view", "resource-grant:view", "gift:view", "emoji-pack:view", "host-agency-policy:view", "team-salary-policy:view", "host-salary-settlement:view", "host-withdrawal:view", "coin-seller:view", "coin-ledger:view", "coin-seller-ledger:view", "coin-adjustment:view", "report:view", "gift-diamond:view", "full-server-notice:view", "lucky-gift:view", "wheel:view", "payment-bill:view", "payment-third-party:view", "payment-temporary-link:view", "payment-product:view", "game:view", "daily-task:view", "achievement:view", "seven-day-checkin:view", "room-rocket:view", "red-packet:view", "cp-config:view", "cp-weekly-rank:view", "vip-config:view", "weekly-star:view", "agency-opening:view"}
default:
return nil
}

View File

@ -26,6 +26,7 @@ func TestDefaultPermissionSeedUsesFinancePermissions(t *testing.T) {
"finance-operation:user-wallet-debit",
"finance-operation:coin-seller-coin-credit",
"finance-operation:coin-seller-coin-debit",
"host-withdrawal:view",
} {
if _, ok := permissions[code]; !ok {
t.Fatalf("finance permission %s missing from default seed", code)
@ -46,6 +47,7 @@ func TestDefaultRoleFinancePermissionTemplates(t *testing.T) {
"finance-operation:user-wallet-debit",
"finance-operation:coin-seller-coin-credit",
"finance-operation:coin-seller-coin-debit",
"host-withdrawal:view",
} {
if _, ok := opsPermissions[code]; !ok {
t.Fatalf("ops-admin default permissions missing %s", code)

View File

@ -28,6 +28,7 @@ import (
"hyapp-admin-server/internal/modules/hostagencypolicy"
"hyapp-admin-server/internal/modules/hostorg"
"hyapp-admin-server/internal/modules/hostsalarysettlement"
"hyapp-admin-server/internal/modules/hostwithdrawal"
"hyapp-admin-server/internal/modules/inviteactivityreward"
"hyapp-admin-server/internal/modules/job"
"hyapp-admin-server/internal/modules/levelconfig"
@ -87,6 +88,7 @@ type Handlers struct {
HostAgencyPolicy *hostagencypolicy.Handler
HostOrg *hostorg.Handler
HostSalarySettlement *hostsalarysettlement.Handler
HostWithdrawal *hostwithdrawal.Handler
InviteActivityReward *inviteactivityreward.Handler
Job *job.Handler
LevelConfig *levelconfig.Handler
@ -161,6 +163,7 @@ func New(cfg config.Config, auth *service.AuthService, h Handlers) *gin.Engine {
hostagencypolicy.RegisterRoutes(protected, h.HostAgencyPolicy)
hostsalarysettlement.RegisterRoutes(protected, h.HostSalarySettlement)
hostorg.RegisterRoutes(protected, h.HostOrg)
hostwithdrawal.RegisterRoutes(protected, h.HostWithdrawal)
inviteactivityreward.RegisterRoutes(protected, h.InviteActivityReward)
audit.RegisterRoutes(protected, h.Audit)
payment.RegisterRoutes(protected, h.Payment)

View File

@ -0,0 +1,70 @@
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
-- 主播提现页只读工资转币商和工资提现申请事实;菜单和权限放在团队管理下,不承接财务审核动作。
SET @now_ms = CAST(UNIX_TIMESTAMP(UTC_TIMESTAMP(3)) * 1000 AS UNSIGNED);
INSERT INTO admin_permissions (name, code, kind, description, created_at_ms, updated_at_ms) VALUES
('主播提现查看', 'host-withdrawal:view', 'menu', '', @now_ms, @now_ms)
ON DUPLICATE KEY UPDATE
name = VALUES(name),
kind = VALUES(kind),
description = VALUES(description),
updated_at_ms = @now_ms;
INSERT INTO admin_menus (parent_id, title, code, path, icon, permission_code, sort, visible, created_at_ms, updated_at_ms)
SELECT parent.id, '主播提现', 'host-withdrawals', '/host/withdrawals', 'receipt', 'host-withdrawal:view', 88, TRUE, @now_ms, @now_ms
FROM admin_menus parent
WHERE parent.code = 'host-org'
ON DUPLICATE KEY UPDATE
parent_id = VALUES(parent_id),
title = VALUES(title),
path = VALUES(path),
icon = VALUES(icon),
permission_code = VALUES(permission_code),
sort = VALUES(sort),
visible = VALUES(visible),
updated_at_ms = @now_ms;
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', 'auditor', 'readonly')
AND admin_permission.code = 'host-withdrawal:view';
-- 后台提现申请列表会按 app/时间、身份、状态和用户检索;补组合索引避免新页面常规筛选退化成全表扫描。
SET @ddl := IF(
(SELECT COUNT(*) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'admin_user_withdrawal_applications' AND INDEX_NAME = 'idx_admin_withdrawal_app_time') = 0,
'ALTER TABLE admin_user_withdrawal_applications ADD INDEX idx_admin_withdrawal_app_time (app_code, created_at_ms, id)',
'SELECT 1'
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @ddl := IF(
(SELECT COUNT(*) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'admin_user_withdrawal_applications' AND INDEX_NAME = 'idx_admin_withdrawal_app_asset_time') = 0,
'ALTER TABLE admin_user_withdrawal_applications ADD INDEX idx_admin_withdrawal_app_asset_time (app_code, salary_asset_type, created_at_ms, id)',
'SELECT 1'
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @ddl := IF(
(SELECT COUNT(*) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'admin_user_withdrawal_applications' AND INDEX_NAME = 'idx_admin_withdrawal_app_status_asset_time') = 0,
'ALTER TABLE admin_user_withdrawal_applications ADD INDEX idx_admin_withdrawal_app_status_asset_time (app_code, status, salary_asset_type, created_at_ms, id)',
'SELECT 1'
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @ddl := IF(
(SELECT COUNT(*) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'admin_user_withdrawal_applications' AND INDEX_NAME = 'idx_admin_withdrawal_app_user_time') = 0,
'ALTER TABLE admin_user_withdrawal_applications ADD INDEX idx_admin_withdrawal_app_user_time (app_code, user_id, created_at_ms, id)',
'SELECT 1'
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;

View File

@ -63,7 +63,8 @@ CREATE TABLE IF NOT EXISTS wallet_entries (
KEY idx_wallet_entries_user_time (app_code, user_id, created_at_ms),
KEY idx_wallet_entries_tx (app_code, transaction_id),
KEY idx_wallet_entries_asset_time (app_code, asset_type, created_at_ms),
KEY idx_wallet_entries_asset_user_time (app_code, asset_type, user_id, created_at_ms, entry_id)
KEY idx_wallet_entries_asset_user_time (app_code, asset_type, user_id, created_at_ms, entry_id),
KEY idx_wallet_entries_asset_counterparty_time (app_code, asset_type, counterparty_user_id, created_at_ms, entry_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='钱包复式流水表';
-- 金币流水和统计回放会按 app/asset/user/time 顺序读取钱包分录;
@ -77,6 +78,17 @@ PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
-- 工资转币商查询从币商侧分录反查来源工资用户;老表缺对手用户组合索引时,
-- source_user 关键词会在 COIN_SELLER_COIN 分录中做大范围过滤。
SET @ddl := IF(
(SELECT COUNT(*) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'wallet_entries' AND INDEX_NAME = 'idx_wallet_entries_asset_counterparty_time') = 0,
'ALTER TABLE wallet_entries ADD INDEX idx_wallet_entries_asset_counterparty_time (app_code, asset_type, counterparty_user_id, created_at_ms, entry_id)',
'SELECT 1'
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
CREATE TABLE IF NOT EXISTS wallet_outbox (
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码,用于多租户隔离',
event_id VARCHAR(128) NOT NULL COMMENT '事件幂等 ID',