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