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 PointFeeAmount int64 PointNetAmount int64 PointsPerUSD int64 PointFeeBPS int32 PointPolicyInstance string 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 PointFeeAmount int64 PointNetAmount int64 PointsPerUSD int64 PointFeeBPS int32 PointPolicyInstance string 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, point_fee_amount, point_net_amount, points_per_usd, point_fee_bps, point_policy_instance_code, 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.PointFeeAmount, command.PointNetAmount, command.PointsPerUSD, command.PointFeeBPS, command.PointPolicyInstance, 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, PointFeeAmount: command.PointFeeAmount, PointNetAmount: command.PointNetAmount, PointsPerUSD: command.PointsPerUSD, PointFeeBPS: command.PointFeeBPS, PointPolicyInstance: command.PointPolicyInstance, 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, point_fee_amount, point_net_amount, points_per_usd, point_fee_bps, point_policy_instance_code, 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.PointFeeAmount, &item.PointNetAmount, &item.PointsPerUSD, &item.PointFeeBPS, &item.PointPolicyInstance, &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) command.PointPolicyInstance = strings.TrimSpace(command.PointPolicyInstance) 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: if command.SalaryAssetType == "POINT" || command.SalaryAssetType == "COIN_SELLER_POINT" { // POINT 的政策快照会被财务审核原样复用;写入前先验证内部一致性,避免冻结成功但审核无法结算。 if command.PointsPerUSD <= 0 || command.PointFeeBPS < 0 || command.PointFeeBPS > 10000 || command.PointFeeAmount < 0 || command.PointNetAmount < 0 || command.PointFeeAmount+command.PointNetAmount != command.WithdrawAmountMinor || command.PointFeeAmount != command.WithdrawAmountMinor*int64(command.PointFeeBPS)/10000 { return errors.New("point withdrawal policy snapshot is invalid") } } 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 }