package financewithdrawal import ( "context" "database/sql" "errors" "fmt" "strings" mysqlDriver "github.com/go-sql-driver/mysql" ) const ( // StatusPending 与后台 finance 用户提现申请列表的待审核状态保持一致;H5 只创建申请,不在入口层审批。 StatusPending = "pending" // MethodUSDTTRC20 是当前 H5 提现页唯一收款方式,后台列表按该字段展示提现方式。 MethodUSDTTRC20 = "usdt_trc20" ) // CreateApplicationCommand 是 H5 提交到后台 finance 的提现申请事实。 // gateway 在调用前已经完成身份、余额和收款地址校验,这里只保护表字段的持久化边界。 type CreateApplicationCommand struct { AppCode string UserID int64 SalaryAssetType string WithdrawAmount string WithdrawAmountMinor int64 WithdrawMethod string WithdrawAddress string FreezeCommandID string FreezeTransactionID string CreatedAtMS int64 } // Application 是写入后台申请表后的最小返回值;H5 只需要知道申请已进入财务审核队列。 type Application struct { ID int64 AppCode string UserID int64 SalaryAssetType string WithdrawAmount string WithdrawAmountMinor int64 WithdrawMethod string WithdrawAddress string FreezeCommandID string FreezeTransactionID string Status string CreatedAtMS int64 UpdatedAtMS int64 } // Writer 把 gateway 与后台 finance 存储解耦,测试可以用内存 fake 验证 H5 编排,不 import admin internal 包。 type Writer interface { CreateApplication(ctx context.Context, command CreateApplicationCommand) (Application, error) } // Notifier 负责把用户提现申请通知到财务协作工具;通知失败不能回滚已冻结和已落库的申请。 type Notifier interface { NotifyApplicationCreated(ctx context.Context, application Application) error } // MySQLWriter 只写后台用户提现申请表,不处理钱包扣款、冻结或三方出款。 // gateway 必须在调用它之前完成钱包冻结,审批链路只负责扣减 frozen 或释放 frozen,避免申请存在但工资仍可被用户二次使用。 type MySQLWriter struct { db *sql.DB } // OpenMySQLWriter 使用后台 hyapp_admin DSN 创建提现申请写入器;空 DSN 表示当前环境未开启后台库直连能力。 func OpenMySQLWriter(dsn string) (*MySQLWriter, error) { dsn = strings.TrimSpace(dsn) if dsn == "" { return nil, nil } db, err := sql.Open("mysql", dsn) if err != nil { return nil, err } return &MySQLWriter{db: db}, nil } // Close 关闭后台库连接池;nil receiver 方便 app 启动失败路径统一清理。 func (w *MySQLWriter) Close() error { if w == nil || w.db == nil { return nil } return w.db.Close() } // CreateApplication 将用户提现请求落成后台 finance 的待审核申请。 // withdraw_amount 使用 decimal 字符串写入,避免 gateway 在 float 和 decimal 之间来回转换造成金额精度漂移。 func (w *MySQLWriter) CreateApplication(ctx context.Context, command CreateApplicationCommand) (Application, error) { if w == nil || w.db == nil { return Application{}, errors.New("finance withdrawal writer is not configured") } command = normalizeCreateApplicationCommand(command) if err := validateCreateApplicationCommand(command); err != nil { return Application{}, err } if existing, found, err := w.getApplicationByFreezeCommand(ctx, command.AppCode, command.FreezeCommandID); err != nil || found { if err != nil { return Application{}, err } return existing, nil } result, err := w.db.ExecContext(ctx, ` INSERT INTO admin_user_withdrawal_applications (app_code, user_id, salary_asset_type, withdraw_amount, withdraw_amount_minor, withdraw_method, withdraw_address, freeze_command_id, freeze_transaction_id, status, created_at_ms, updated_at_ms) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, command.AppCode, formatUserID(command.UserID), command.SalaryAssetType, command.WithdrawAmount, command.WithdrawAmountMinor, command.WithdrawMethod, command.WithdrawAddress, command.FreezeCommandID, command.FreezeTransactionID, StatusPending, command.CreatedAtMS, command.CreatedAtMS, ) if err != nil { if isDuplicateWithdrawalFreezeCommand(err) { if existing, found, lookupErr := w.getApplicationByFreezeCommand(ctx, command.AppCode, command.FreezeCommandID); lookupErr != nil || found { if lookupErr != nil { return Application{}, lookupErr } return existing, nil } } return Application{}, err } id, err := result.LastInsertId() if err != nil { return Application{}, err } return Application{ ID: id, AppCode: command.AppCode, UserID: command.UserID, SalaryAssetType: command.SalaryAssetType, WithdrawAmount: command.WithdrawAmount, WithdrawAmountMinor: command.WithdrawAmountMinor, WithdrawMethod: command.WithdrawMethod, WithdrawAddress: command.WithdrawAddress, FreezeCommandID: command.FreezeCommandID, FreezeTransactionID: command.FreezeTransactionID, Status: StatusPending, CreatedAtMS: command.CreatedAtMS, UpdatedAtMS: command.CreatedAtMS, }, nil } func (w *MySQLWriter) getApplicationByFreezeCommand(ctx context.Context, appCode string, freezeCommandID string) (Application, bool, error) { row := w.db.QueryRowContext(ctx, ` SELECT id, app_code, user_id, salary_asset_type, withdraw_amount, withdraw_amount_minor, withdraw_method, withdraw_address, freeze_command_id, freeze_transaction_id, status, created_at_ms, updated_at_ms FROM admin_user_withdrawal_applications WHERE app_code = ? AND freeze_command_id = ? LIMIT 1`, appCode, freezeCommandID) var item Application var userID string if err := row.Scan( &item.ID, &item.AppCode, &userID, &item.SalaryAssetType, &item.WithdrawAmount, &item.WithdrawAmountMinor, &item.WithdrawMethod, &item.WithdrawAddress, &item.FreezeCommandID, &item.FreezeTransactionID, &item.Status, &item.CreatedAtMS, &item.UpdatedAtMS, ); err != nil { if errors.Is(err, sql.ErrNoRows) { return Application{}, false, nil } return Application{}, false, err } parsedUserID, err := parseUserID(userID) if err != nil { return Application{}, false, err } item.UserID = parsedUserID return item, true, nil } func isDuplicateWithdrawalFreezeCommand(err error) bool { var mysqlErr *mysqlDriver.MySQLError return errors.As(err, &mysqlErr) && mysqlErr.Number == 1062 } func normalizeCreateApplicationCommand(command CreateApplicationCommand) CreateApplicationCommand { command.AppCode = strings.TrimSpace(command.AppCode) command.SalaryAssetType = strings.ToUpper(strings.TrimSpace(command.SalaryAssetType)) command.WithdrawAmount = strings.TrimSpace(command.WithdrawAmount) command.WithdrawMethod = strings.TrimSpace(command.WithdrawMethod) command.WithdrawAddress = strings.TrimSpace(command.WithdrawAddress) command.FreezeCommandID = strings.TrimSpace(command.FreezeCommandID) command.FreezeTransactionID = strings.TrimSpace(command.FreezeTransactionID) if command.WithdrawMethod == "" { command.WithdrawMethod = MethodUSDTTRC20 } return command } func validateCreateApplicationCommand(command CreateApplicationCommand) error { switch { case command.AppCode == "": return errors.New("app_code is required") case command.UserID <= 0: return errors.New("user_id is required") case command.SalaryAssetType == "": return errors.New("salary_asset_type is required") case command.WithdrawAmount == "": return errors.New("withdraw_amount is required") case command.WithdrawAmountMinor <= 0: return errors.New("withdraw_amount_minor is required") case command.WithdrawMethod == "": return errors.New("withdraw_method is required") case command.WithdrawAddress == "": return errors.New("withdraw_address is required") case command.FreezeCommandID == "": return errors.New("freeze_command_id is required") case command.FreezeTransactionID == "": return errors.New("freeze_transaction_id is required") case command.CreatedAtMS <= 0: return errors.New("created_at_ms is required") default: return nil } } func formatUserID(userID int64) string { return fmt.Sprintf("%d", userID) } func parseUserID(value string) (int64, error) { var userID int64 _, err := fmt.Sscanf(strings.TrimSpace(value), "%d", &userID) if err != nil || userID <= 0 { return 0, errors.New("user_id is invalid") } return userID, nil }