From 0b846f9069b05a3479eafb3ee0b3e4724ef0af7c Mon Sep 17 00:00:00 2001 From: zhx Date: Thu, 2 Jul 2026 14:22:37 +0800 Subject: [PATCH] add host withdrawal admin list --- ..._entries_asset_counterparty_time_index.sql | 12 + server/admin/cmd/server/main.go | 2 + .../internal/modules/hostwithdrawal/dto.go | 34 + .../modules/hostwithdrawal/handler.go | 86 +++ .../modules/hostwithdrawal/request.go | 119 ++++ .../internal/modules/hostwithdrawal/routes.go | 15 + .../modules/hostwithdrawal/service.go | 603 ++++++++++++++++++ .../modules/hostwithdrawal/service_test.go | 141 ++++ server/admin/internal/repository/seed.go | 11 +- server/admin/internal/repository/seed_test.go | 2 + server/admin/internal/router/router.go | 3 + .../072_host_withdrawal_navigation.sql | 70 ++ .../mysql/initdb/001_wallet_service.sql | 14 +- 13 files changed, 1107 insertions(+), 5 deletions(-) create mode 100644 scripts/mysql/054_wallet_entries_asset_counterparty_time_index.sql create mode 100644 server/admin/internal/modules/hostwithdrawal/dto.go create mode 100644 server/admin/internal/modules/hostwithdrawal/handler.go create mode 100644 server/admin/internal/modules/hostwithdrawal/request.go create mode 100644 server/admin/internal/modules/hostwithdrawal/routes.go create mode 100644 server/admin/internal/modules/hostwithdrawal/service.go create mode 100644 server/admin/internal/modules/hostwithdrawal/service_test.go create mode 100644 server/admin/migrations/072_host_withdrawal_navigation.sql diff --git a/scripts/mysql/054_wallet_entries_asset_counterparty_time_index.sql b/scripts/mysql/054_wallet_entries_asset_counterparty_time_index.sql new file mode 100644 index 00000000..63a9e597 --- /dev/null +++ b/scripts/mysql/054_wallet_entries_asset_counterparty_time_index.sql @@ -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; diff --git a/server/admin/cmd/server/main.go b/server/admin/cmd/server/main.go index c6f78ad0..e1e21957 100644 --- a/server/admin/cmd/server/main.go +++ b/server/admin/cmd/server/main.go @@ -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), diff --git a/server/admin/internal/modules/hostwithdrawal/dto.go b/server/admin/internal/modules/hostwithdrawal/dto.go new file mode 100644 index 00000000..446e5d5a --- /dev/null +++ b/server/admin/internal/modules/hostwithdrawal/dto.go @@ -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"` +} diff --git a/server/admin/internal/modules/hostwithdrawal/handler.go b/server/admin/internal/modules/hostwithdrawal/handler.go new file mode 100644 index 00000000..c023e4c2 --- /dev/null +++ b/server/admin/internal/modules/hostwithdrawal/handler.go @@ -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 +} diff --git a/server/admin/internal/modules/hostwithdrawal/request.go b/server/admin/internal/modules/hostwithdrawal/request.go new file mode 100644 index 00000000..8547aa00 --- /dev/null +++ b/server/admin/internal/modules/hostwithdrawal/request.go @@ -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 +} diff --git a/server/admin/internal/modules/hostwithdrawal/routes.go b/server/admin/internal/modules/hostwithdrawal/routes.go new file mode 100644 index 00000000..5f5fa1fd --- /dev/null +++ b/server/admin/internal/modules/hostwithdrawal/routes.go @@ -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) +} diff --git a/server/admin/internal/modules/hostwithdrawal/service.go b/server/admin/internal/modules/hostwithdrawal/service.go new file mode 100644 index 00000000..62a90837 --- /dev/null +++ b/server/admin/internal/modules/hostwithdrawal/service.go @@ -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 +} diff --git a/server/admin/internal/modules/hostwithdrawal/service_test.go b/server/admin/internal/modules/hostwithdrawal/service_test.go new file mode 100644 index 00000000..17c31e26 --- /dev/null +++ b/server/admin/internal/modules/hostwithdrawal/service_test.go @@ -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") + } +} diff --git a/server/admin/internal/repository/seed.go b/server/admin/internal/repository/seed.go index d70722e6..4a819b74 100644 --- a/server/admin/internal/repository/seed.go +++ b/server/admin/internal/repository/seed.go @@ -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 } diff --git a/server/admin/internal/repository/seed_test.go b/server/admin/internal/repository/seed_test.go index 2fe51f86..2ad0b007 100644 --- a/server/admin/internal/repository/seed_test.go +++ b/server/admin/internal/repository/seed_test.go @@ -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) diff --git a/server/admin/internal/router/router.go b/server/admin/internal/router/router.go index 5d3d740f..632d2853 100644 --- a/server/admin/internal/router/router.go +++ b/server/admin/internal/router/router.go @@ -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) diff --git a/server/admin/migrations/072_host_withdrawal_navigation.sql b/server/admin/migrations/072_host_withdrawal_navigation.sql new file mode 100644 index 00000000..e54c4164 --- /dev/null +++ b/server/admin/migrations/072_host_withdrawal_navigation.sql @@ -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; diff --git a/services/wallet-service/deploy/mysql/initdb/001_wallet_service.sql b/services/wallet-service/deploy/mysql/initdb/001_wallet_service.sql index 858c1d77..132cca14 100644 --- a/services/wallet-service/deploy/mysql/initdb/001_wallet_service.sql +++ b/services/wallet-service/deploy/mysql/initdb/001_wallet_service.sql @@ -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',