253 lines
8.4 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 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
}