2026-07-02 14:22:37 +08:00

604 lines
18 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
}