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 }