2026-07-20 16:48:30 +08:00

306 lines
12 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"
"time"
)
const (
// StatusPending 表示申请尚未形成最终财务结论;运营通过后总体状态仍保持 pending。
StatusPending = "pending"
// OperationsStatusPending 保证所有新申请先进入运营初审102 迁移只把执行前已经终审的历史行标记为 skipped。
OperationsStatusPending = "pending"
// MethodUSDTTRC20 是当前 H5 提现页唯一收款方式,后台列表按该字段展示提现方式。
MethodUSDTTRC20 = "usdt_trc20"
)
// ErrApplicationOutcomeUnknown 表示 INSERT 的返回结果和后续事实查询都不可用。
// 调用方不能把它当成“确认未落库”而释放已冻结资金,只能保留冻结并让同一业务幂等键后续重试收敛。
var ErrApplicationOutcomeUnknown = errors.New("finance withdrawal application outcome is unknown")
// CreateApplicationCommand 是 H5 提交到后台审核链的提现申请事实。
// gateway 在调用前已经完成身份、收款地址校验以及 wallet 原子冻结,这里只保护表字段的持久化边界。
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
OperationsStatus string
Status string
CreatedAtMS int64
UpdatedAtMS int64
}
// Writer 把 gateway 与后台提现审核存储解耦,测试可以用内存 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 将用户提现请求落成运营待审申请,总体状态在财务终审前一直保持 pending。
// 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, source_system, source_application_id, 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, operations_status, created_at_ms, updated_at_ms)
VALUES
(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
command.AppCode,
"hyapp_wallet",
command.FreezeCommandID,
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,
OperationsStatusPending,
command.CreatedAtMS,
command.CreatedAtMS,
)
if err != nil {
return w.reconcileApplicationAfterInsertError(ctx, command, err)
}
id, err := result.LastInsertId()
if err != nil {
return w.reconcileApplicationAfterInsertError(ctx, command, 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,
OperationsStatus: OperationsStatusPending,
Status: StatusPending,
CreatedAtMS: command.CreatedAtMS,
UpdatedAtMS: command.CreatedAtMS,
}, nil
}
func (w *MySQLWriter) reconcileApplicationAfterInsertError(ctx context.Context, command CreateApplicationCommand, insertErr error) (Application, error) {
// ExecContext/LastInsertId 的错误不等于 INSERT 一定没有提交,例如响应在服务端提交后丢失。
// 重查使用脱离请求取消信号的短上下文,否则原请求超时会让结果确认必然失败并错误回滚钱包冻结。
lookupCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 2*time.Second)
defer cancel()
existing, found, lookupErr := w.getApplicationByFreezeCommand(lookupCtx, command.AppCode, command.FreezeCommandID)
if found {
return existing, nil
}
if lookupErr != nil {
return Application{}, fmt.Errorf("%w: insert failed: %v; result lookup failed: %v", ErrApplicationOutcomeUnknown, insertErr, lookupErr)
}
return Application{}, insertErr
}
// IsApplicationOutcomeUnknown 供冻结编排区分“确认未写入”和“无法确认”;只有前者允许补偿释放。
func IsApplicationOutcomeUnknown(err error) bool {
return errors.Is(err, ErrApplicationOutcomeUnknown)
}
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, operations_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.OperationsStatus,
&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 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
}