feat(admin): review ChatApp3 withdrawals
This commit is contained in:
parent
513509c5e6
commit
d4a9fbec01
@ -69,8 +69,15 @@ finance_google_paid_sync:
|
||||
poll_interval: "15s"
|
||||
batch_size: 100
|
||||
request_timeout: "2m"
|
||||
# 生产通过 HYAPP_ADMIN_ASLAN_WITHDRAWAL_* 注入密钥、回调地址并开启。
|
||||
# 生产按 App 通过 HYAPP_ADMIN_<APP>_WITHDRAWAL_* 注入密钥、回调地址并开启。
|
||||
withdrawal_sources:
|
||||
- enabled: true
|
||||
app_code: "yumi"
|
||||
source_system: "chatapp3"
|
||||
submit_token: ""
|
||||
callback_url: ""
|
||||
callback_token: ""
|
||||
request_timeout: "5s"
|
||||
- enabled: false
|
||||
app_code: "aslan"
|
||||
source_system: "likei"
|
||||
|
||||
@ -67,9 +67,16 @@ finance_google_paid_sync:
|
||||
poll_interval: "15s"
|
||||
batch_size: 100
|
||||
request_timeout: "2m"
|
||||
# 外部钱包统一提现双审来源。生产密钥和回调地址只从
|
||||
# HYAPP_ADMIN_ASLAN_WITHDRAWAL_* 环境变量注入,仓库不保存资金接口凭证。
|
||||
# 外部钱包统一提现双审来源。生产密钥和回调地址只从对应 App 的
|
||||
# HYAPP_ADMIN_<APP>_WITHDRAWAL_* 环境变量注入,仓库不保存资金接口凭证。
|
||||
withdrawal_sources:
|
||||
- enabled: false
|
||||
app_code: "yumi"
|
||||
source_system: "chatapp3"
|
||||
submit_token: ""
|
||||
callback_url: ""
|
||||
callback_token: ""
|
||||
request_timeout: "5s"
|
||||
- enabled: false
|
||||
app_code: "aslan"
|
||||
source_system: "likei"
|
||||
|
||||
@ -432,6 +432,12 @@ func Default() Config {
|
||||
},
|
||||
},
|
||||
WithdrawalSources: []WithdrawalSourceConfig{
|
||||
{
|
||||
Enabled: false,
|
||||
AppCode: "yumi",
|
||||
SourceSystem: "chatapp3",
|
||||
RequestTimeout: 5 * time.Second,
|
||||
},
|
||||
{
|
||||
Enabled: false,
|
||||
AppCode: "aslan",
|
||||
|
||||
@ -84,6 +84,30 @@ func TestLoadEmptyPathUsesDefault(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestYumiWithdrawalSourceEnvOverride(t *testing.T) {
|
||||
t.Setenv("HYAPP_ADMIN_YUMI_WITHDRAWAL_ENABLED", "true")
|
||||
t.Setenv("HYAPP_ADMIN_YUMI_WITHDRAWAL_SOURCE_SYSTEM", "chatapp3")
|
||||
t.Setenv("HYAPP_ADMIN_YUMI_WITHDRAWAL_SUBMIT_TOKEN", "yumi-submit-token")
|
||||
t.Setenv("HYAPP_ADMIN_YUMI_WITHDRAWAL_CALLBACK_URL", "https://yumi.example/console/integration/withdrawal-review/decision")
|
||||
t.Setenv("HYAPP_ADMIN_YUMI_WITHDRAWAL_CALLBACK_TOKEN", "yumi-callback-token")
|
||||
|
||||
cfg := Default()
|
||||
cfg.Normalize()
|
||||
for _, source := range cfg.WithdrawalSources {
|
||||
if source.AppCode != "yumi" {
|
||||
continue
|
||||
}
|
||||
if !source.Enabled || source.SourceSystem != "chatapp3" ||
|
||||
source.SubmitToken != "yumi-submit-token" ||
|
||||
source.CallbackURL != "https://yumi.example/console/integration/withdrawal-review/decision" ||
|
||||
source.CallbackToken != "yumi-callback-token" {
|
||||
t.Fatalf("Yumi withdrawal source override mismatch: %#v", source)
|
||||
}
|
||||
return
|
||||
}
|
||||
t.Fatal("Yumi withdrawal source not found")
|
||||
}
|
||||
|
||||
func TestValidateRejectsInvalidTrustedProxy(t *testing.T) {
|
||||
cfg := Default()
|
||||
cfg.TrustedProxies = []string{"not-a-proxy-range"}
|
||||
@ -162,6 +186,10 @@ func TestAslanMongoLegacyMySQLFallbackEnvOverride(t *testing.T) {
|
||||
func TestTencentExampleRequiresRuntimeLegacyMySQLDSN(t *testing.T) {
|
||||
t.Setenv("HYAPP_ADMIN_ASLAN_DASHBOARD_LEGACY_MYSQL_DSN", "")
|
||||
t.Setenv("HYAPP_ADMIN_ASLAN_LEGACY_MYSQL_DSN", "")
|
||||
// 腾讯云示例默认开启 Yumi 提现来源;用例只验证 legacy MySQL 必须运行时注入,因此同时提供不落盘的测试提现凭据。
|
||||
t.Setenv("HYAPP_ADMIN_YUMI_WITHDRAWAL_SUBMIT_TOKEN", "test-yumi-submit-token")
|
||||
t.Setenv("HYAPP_ADMIN_YUMI_WITHDRAWAL_CALLBACK_URL", "https://yumi.example/console/integration/withdrawal-review/decision")
|
||||
t.Setenv("HYAPP_ADMIN_YUMI_WITHDRAWAL_CALLBACK_TOKEN", "test-yumi-callback-token")
|
||||
|
||||
_, err := Load("../../configs/config.tencent.example.yaml")
|
||||
if err == nil || !strings.Contains(err.Error(), "legacy_mysql_dsn must be injected by environment") {
|
||||
|
||||
@ -44,6 +44,9 @@ const (
|
||||
WithdrawalOperationsStatusApproved = "approved"
|
||||
WithdrawalOperationsStatusRejected = "rejected"
|
||||
|
||||
WithdrawalExternalFinanceClaimStatusClaimed = "claimed"
|
||||
WithdrawalExternalFinanceClaimStatusFinalized = "finalized"
|
||||
|
||||
ExternalAdminStatusActive = "active"
|
||||
ExternalAdminStatusDisabled = "disabled"
|
||||
)
|
||||
@ -732,40 +735,49 @@ func (order CoinSellerRechargeOrder) BusinessTimeMS() int64 {
|
||||
}
|
||||
|
||||
type UserWithdrawalApplication struct {
|
||||
ID uint `gorm:"primaryKey;index:idx_admin_withdrawal_app_operations_status_time,priority:4" json:"id"`
|
||||
AppCode string `gorm:"size:32;index:idx_admin_withdrawal_app_status_time;index:idx_admin_withdrawal_app_operations_status_time,priority:1;not null" json:"appCode"`
|
||||
SourceSystem string `gorm:"column:source_system;size:32;not null;default:hyapp_wallet" json:"sourceSystem"`
|
||||
SourceApplicationID string `gorm:"column:source_application_id;size:128;not null;default:''" json:"sourceApplicationId"`
|
||||
UserID string `gorm:"size:64;index:idx_admin_withdrawal_user;not null" json:"userId"`
|
||||
SalaryAssetType string `gorm:"size:64;not null;default:''" json:"salaryAssetType"`
|
||||
WithdrawAmount string `gorm:"type:decimal(18,2);not null;default:0.00" json:"withdrawAmount"`
|
||||
WithdrawAmountMinor int64 `gorm:"not null;default:0" json:"withdrawAmountMinor"`
|
||||
PointFeeAmount int64 `gorm:"not null;default:0" json:"pointFeeAmount"`
|
||||
PointNetAmount int64 `gorm:"not null;default:0" json:"pointNetAmount"`
|
||||
PointsPerUSD int64 `gorm:"not null;default:100000" json:"pointsPerUsd"`
|
||||
PointFeeBPS int32 `gorm:"not null;default:500" json:"pointFeeBps"`
|
||||
PointPolicyInstance string `gorm:"column:point_policy_instance_code;size:96;not null;default:''" json:"pointPolicyInstanceCode"`
|
||||
WithdrawMethod string `gorm:"size:64;index:idx_admin_withdrawal_method;not null;default:''" json:"withdrawMethod"`
|
||||
WithdrawAddress string `gorm:"size:255;not null;default:''" json:"withdrawAddress"`
|
||||
FreezeCommandID string `gorm:"size:128;not null;default:''" json:"freezeCommandId"`
|
||||
FreezeTransactionID string `gorm:"size:128;not null;default:''" json:"freezeTransactionId"`
|
||||
AuditCommandID string `gorm:"size:128;not null;default:''" json:"auditCommandId"`
|
||||
AuditTransactionID string `gorm:"size:128;not null;default:''" json:"auditTransactionId"`
|
||||
Status string `gorm:"size:32;index:idx_admin_withdrawal_app_status_time;not null;default:pending" json:"status"`
|
||||
ApproverUserID *uint `gorm:"index:idx_admin_withdrawal_approver" json:"approverUserId"`
|
||||
ApproverName string `gorm:"size:64;not null;default:''" json:"approverName"`
|
||||
AuditRemark string `gorm:"type:text" json:"auditRemark"`
|
||||
AuditImageURL string `gorm:"column:audit_image_url;size:1024;not null;default:''" json:"auditImageUrl"`
|
||||
ApprovedAtMS *int64 `gorm:"column:approved_at_ms" json:"approvedAtMs"`
|
||||
OperationsStatus string `gorm:"column:operations_status;size:32;not null;default:pending;index:idx_admin_withdrawal_app_operations_status_time,priority:2" json:"operationsStatus"`
|
||||
OperationsReviewerUserID *uint `gorm:"column:operations_reviewer_user_id" json:"operationsReviewerUserId"`
|
||||
OperationsReviewerName string `gorm:"column:operations_reviewer_name;size:64;not null;default:''" json:"operationsReviewerName"`
|
||||
OperationsAuditRemark string `gorm:"column:operations_audit_remark;type:text" json:"operationsAuditRemark"`
|
||||
OperationsAuditCommandID string `gorm:"column:operations_audit_command_id;size:128;not null;default:''" json:"operationsAuditCommandId"`
|
||||
OperationsAuditTransactionID string `gorm:"column:operations_audit_transaction_id;size:128;not null;default:''" json:"operationsAuditTransactionId"`
|
||||
OperationsReviewedAtMS *int64 `gorm:"column:operations_reviewed_at_ms" json:"operationsReviewedAtMs"`
|
||||
CreatedAtMS int64 `gorm:"column:created_at_ms;autoCreateTime:milli;index:idx_admin_withdrawal_app_status_time;index:idx_admin_withdrawal_app_operations_status_time,priority:3" json:"createdAtMs"`
|
||||
UpdatedAtMS int64 `gorm:"column:updated_at_ms;autoUpdateTime:milli" json:"updatedAtMs"`
|
||||
ID uint `gorm:"primaryKey;index:idx_admin_withdrawal_app_operations_status_time,priority:4" json:"id"`
|
||||
AppCode string `gorm:"size:32;index:idx_admin_withdrawal_app_status_time;index:idx_admin_withdrawal_app_operations_status_time,priority:1;not null" json:"appCode"`
|
||||
SourceSystem string `gorm:"column:source_system;size:32;not null;default:hyapp_wallet" json:"sourceSystem"`
|
||||
SourceApplicationID string `gorm:"column:source_application_id;size:128;not null;default:''" json:"sourceApplicationId"`
|
||||
UserID string `gorm:"size:64;index:idx_admin_withdrawal_user;not null" json:"userId"`
|
||||
SalaryAssetType string `gorm:"size:64;not null;default:''" json:"salaryAssetType"`
|
||||
WithdrawAmount string `gorm:"type:decimal(18,2);not null;default:0.00" json:"withdrawAmount"`
|
||||
WithdrawAmountMinor int64 `gorm:"not null;default:0" json:"withdrawAmountMinor"`
|
||||
// BalanceBefore 是来源钱包在扣款前固化的可用余额;nil 表示历史申请没有可信快照,禁止回查当前余额补写。
|
||||
BalanceBefore *string `gorm:"column:balance_before;type:decimal(18,2)" json:"balanceBefore"`
|
||||
// 外部来源的费率只是申请时政策快照;手续费和实际打款额才是财务审核与统计使用的不可变资金事实。
|
||||
ServiceChargeRatio *string `gorm:"column:service_charge_ratio;type:decimal(9,4)" json:"serviceChargeRatio"`
|
||||
ServiceCharge *string `gorm:"column:service_charge;type:decimal(18,2)" json:"serviceCharge"`
|
||||
ActualAmount *string `gorm:"column:actual_amount;type:decimal(18,2)" json:"actualAmount"`
|
||||
ActualAmountMinor *int64 `gorm:"column:actual_amount_minor" json:"actualAmountMinor"`
|
||||
ExternalFinanceClaimDecision *string `gorm:"column:external_finance_claim_decision;size:16" json:"externalFinanceClaimDecision"`
|
||||
ExternalFinanceClaimStatus *string `gorm:"column:external_finance_claim_status;size:16" json:"externalFinanceClaimStatus"`
|
||||
PointFeeAmount int64 `gorm:"not null;default:0" json:"pointFeeAmount"`
|
||||
PointNetAmount int64 `gorm:"not null;default:0" json:"pointNetAmount"`
|
||||
PointsPerUSD int64 `gorm:"not null;default:100000" json:"pointsPerUsd"`
|
||||
PointFeeBPS int32 `gorm:"not null;default:500" json:"pointFeeBps"`
|
||||
PointPolicyInstance string `gorm:"column:point_policy_instance_code;size:96;not null;default:''" json:"pointPolicyInstanceCode"`
|
||||
WithdrawMethod string `gorm:"size:64;index:idx_admin_withdrawal_method;not null;default:''" json:"withdrawMethod"`
|
||||
WithdrawAddress string `gorm:"size:255;not null;default:''" json:"withdrawAddress"`
|
||||
FreezeCommandID string `gorm:"size:128;not null;default:''" json:"freezeCommandId"`
|
||||
FreezeTransactionID string `gorm:"size:128;not null;default:''" json:"freezeTransactionId"`
|
||||
AuditCommandID string `gorm:"size:128;not null;default:''" json:"auditCommandId"`
|
||||
AuditTransactionID string `gorm:"size:128;not null;default:''" json:"auditTransactionId"`
|
||||
Status string `gorm:"size:32;index:idx_admin_withdrawal_app_status_time;not null;default:pending" json:"status"`
|
||||
ApproverUserID *uint `gorm:"index:idx_admin_withdrawal_approver" json:"approverUserId"`
|
||||
ApproverName string `gorm:"size:64;not null;default:''" json:"approverName"`
|
||||
AuditRemark string `gorm:"type:text" json:"auditRemark"`
|
||||
AuditImageURL string `gorm:"column:audit_image_url;size:1024;not null;default:''" json:"auditImageUrl"`
|
||||
ApprovedAtMS *int64 `gorm:"column:approved_at_ms" json:"approvedAtMs"`
|
||||
OperationsStatus string `gorm:"column:operations_status;size:32;not null;default:pending;index:idx_admin_withdrawal_app_operations_status_time,priority:2" json:"operationsStatus"`
|
||||
OperationsReviewerUserID *uint `gorm:"column:operations_reviewer_user_id" json:"operationsReviewerUserId"`
|
||||
OperationsReviewerName string `gorm:"column:operations_reviewer_name;size:64;not null;default:''" json:"operationsReviewerName"`
|
||||
OperationsAuditRemark string `gorm:"column:operations_audit_remark;type:text" json:"operationsAuditRemark"`
|
||||
OperationsAuditCommandID string `gorm:"column:operations_audit_command_id;size:128;not null;default:''" json:"operationsAuditCommandId"`
|
||||
OperationsAuditTransactionID string `gorm:"column:operations_audit_transaction_id;size:128;not null;default:''" json:"operationsAuditTransactionId"`
|
||||
OperationsReviewedAtMS *int64 `gorm:"column:operations_reviewed_at_ms" json:"operationsReviewedAtMs"`
|
||||
CreatedAtMS int64 `gorm:"column:created_at_ms;autoCreateTime:milli;index:idx_admin_withdrawal_app_status_time;index:idx_admin_withdrawal_app_operations_status_time,priority:3" json:"createdAtMs"`
|
||||
UpdatedAtMS int64 `gorm:"column:updated_at_ms;autoUpdateTime:milli" json:"updatedAtMs"`
|
||||
}
|
||||
|
||||
func (UserWithdrawalApplication) TableName() string {
|
||||
|
||||
@ -55,11 +55,18 @@ func withdrawalReviewPendingMarkdown(application model.UserWithdrawalApplication
|
||||
fmt.Sprintf("- 来源系统:%s", withdrawalMarkdownValue(application.SourceSystem)),
|
||||
fmt.Sprintf("- 来源单号:%s", withdrawalMarkdownValue(application.SourceApplicationID)),
|
||||
fmt.Sprintf("- 用户ID:%s", withdrawalMarkdownValue(application.UserID)),
|
||||
fmt.Sprintf("- 提现金额:$ %s", withdrawalMarkdownValue(application.WithdrawAmount)),
|
||||
fmt.Sprintf("- 申请金额:$ %s", withdrawalMarkdownValue(application.WithdrawAmount)),
|
||||
fmt.Sprintf("- 提现方式:%s", withdrawalMarkdownValue(application.WithdrawMethod)),
|
||||
fmt.Sprintf("- 提现地址:%s", withdrawalMarkdownValue(application.WithdrawAddress)),
|
||||
fmt.Sprintf("- 申请时间:%s", formatWithdrawalNotifyMillis(application.CreatedAtMS)),
|
||||
}
|
||||
if hasWithdrawalPayoutSnapshot(application) {
|
||||
// 协作通知展示来源申请时的不可变快照,财务不应在审核时按当前费率重算。
|
||||
lines = append(lines,
|
||||
fmt.Sprintf("- 手续费:$ %s(费率 %s%%)", withdrawalOptionalMarkdownValue(application.ServiceCharge), withdrawalOptionalMarkdownValue(application.ServiceChargeRatio)),
|
||||
fmt.Sprintf("- 实际打款金额:$ %s", withdrawalOptionalMarkdownValue(application.ActualAmount)),
|
||||
)
|
||||
}
|
||||
if stage == repository.WithdrawalApplicationReviewStageFinance {
|
||||
// 财务提醒保留运营审核快照,接收人无需先打开后台就能确认这是一张已完成初审的申请。
|
||||
lines = append(lines,
|
||||
@ -72,6 +79,17 @@ func withdrawalReviewPendingMarkdown(application model.UserWithdrawalApplication
|
||||
return title, strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
func hasWithdrawalPayoutSnapshot(application model.UserWithdrawalApplication) bool {
|
||||
return application.ServiceChargeRatio != nil || application.ServiceCharge != nil || application.ActualAmount != nil || application.ActualAmountMinor != nil
|
||||
}
|
||||
|
||||
func withdrawalOptionalMarkdownValue(value *string) string {
|
||||
if value == nil {
|
||||
return "-"
|
||||
}
|
||||
return withdrawalMarkdownValue(*value)
|
||||
}
|
||||
|
||||
func formatWithdrawalNotifyMillis(value int64) string {
|
||||
if value <= 0 {
|
||||
return "-"
|
||||
|
||||
@ -50,6 +50,11 @@ func (h *Handler) CreateExternalApplication(c *gin.Context) {
|
||||
SalaryAssetType string `json:"salaryAssetType" binding:"required"`
|
||||
WithdrawAmount string `json:"withdrawAmount" binding:"required"`
|
||||
WithdrawAmountMinor int64 `json:"withdrawAmountMinor" binding:"required"`
|
||||
BalanceBefore string `json:"balanceBefore"`
|
||||
ServiceChargeRatio string `json:"serviceChargeRatio"`
|
||||
ServiceCharge string `json:"serviceCharge"`
|
||||
ActualAmount string `json:"actualAmount"`
|
||||
ActualAmountMinor *int64 `json:"actualAmountMinor"`
|
||||
WithdrawMethod string `json:"withdrawMethod" binding:"required"`
|
||||
WithdrawAddress string `json:"withdrawAddress" binding:"required"`
|
||||
CreatedAtMS int64 `json:"createdAtMs"`
|
||||
@ -70,6 +75,11 @@ func (h *Handler) CreateExternalApplication(c *gin.Context) {
|
||||
SalaryAssetType: req.SalaryAssetType,
|
||||
WithdrawAmount: req.WithdrawAmount,
|
||||
WithdrawAmountMinor: req.WithdrawAmountMinor,
|
||||
BalanceBefore: req.BalanceBefore,
|
||||
ServiceChargeRatio: req.ServiceChargeRatio,
|
||||
ServiceCharge: req.ServiceCharge,
|
||||
ActualAmount: req.ActualAmount,
|
||||
ActualAmountMinor: req.ActualAmountMinor,
|
||||
WithdrawMethod: req.WithdrawMethod,
|
||||
WithdrawAddress: req.WithdrawAddress,
|
||||
CreatedAtMS: req.CreatedAtMS,
|
||||
|
||||
@ -3,39 +3,46 @@ package financewithdrawal
|
||||
import "hyapp-admin-server/internal/model"
|
||||
|
||||
type withdrawalApplicationDTO struct {
|
||||
ID uint `json:"id"`
|
||||
AppCode string `json:"appCode"`
|
||||
AppName string `json:"appName"`
|
||||
SourceSystem string `json:"sourceSystem"`
|
||||
SourceApplicationID string `json:"sourceApplicationId"`
|
||||
UserID string `json:"userId"`
|
||||
SalaryAssetType string `json:"salaryAssetType"`
|
||||
WithdrawAmount string `json:"withdrawAmount"`
|
||||
WithdrawAmountMinor int64 `json:"withdrawAmountMinor"`
|
||||
PointGrossAmount int64 `json:"pointGrossAmount,omitempty"`
|
||||
PointFeeAmount int64 `json:"pointFeeAmount,omitempty"`
|
||||
PointNetAmount int64 `json:"pointNetAmount,omitempty"`
|
||||
PointsPerUSD int64 `json:"pointsPerUsd,omitempty"`
|
||||
PointFeeBPS int32 `json:"pointFeeBps,omitempty"`
|
||||
PointPolicyInstance string `json:"pointPolicyInstanceCode,omitempty"`
|
||||
WithdrawMethod string `json:"withdrawMethod"`
|
||||
WithdrawAddress string `json:"withdrawAddress"`
|
||||
FreezeTransactionID string `json:"freezeTransactionId"`
|
||||
AuditTransactionID string `json:"auditTransactionId"`
|
||||
Status string `json:"status"`
|
||||
ApproverUserID *uint `json:"approverUserId"`
|
||||
ApproverName string `json:"approverName"`
|
||||
AuditRemark string `json:"auditRemark"`
|
||||
AuditImageURL string `json:"auditImageUrl"`
|
||||
ApprovedAtMS *int64 `json:"approvedAtMs"`
|
||||
OperationsStatus string `json:"operationsStatus"`
|
||||
OperationsReviewerUserID *uint `json:"operationsReviewerUserId"`
|
||||
OperationsReviewerName string `json:"operationsReviewerName"`
|
||||
OperationsAuditRemark string `json:"operationsAuditRemark"`
|
||||
OperationsAuditTransactionID string `json:"operationsAuditTransactionId"`
|
||||
OperationsReviewedAtMS *int64 `json:"operationsReviewedAtMs"`
|
||||
CreatedAtMS int64 `json:"createdAtMs"`
|
||||
UpdatedAtMS int64 `json:"updatedAtMs"`
|
||||
ID uint `json:"id"`
|
||||
AppCode string `json:"appCode"`
|
||||
AppName string `json:"appName"`
|
||||
SourceSystem string `json:"sourceSystem"`
|
||||
SourceApplicationID string `json:"sourceApplicationId"`
|
||||
UserID string `json:"userId"`
|
||||
SalaryAssetType string `json:"salaryAssetType"`
|
||||
WithdrawAmount string `json:"withdrawAmount"`
|
||||
WithdrawAmountMinor int64 `json:"withdrawAmountMinor"`
|
||||
BalanceBefore *string `json:"balanceBefore"`
|
||||
ServiceChargeRatio *string `json:"serviceChargeRatio"`
|
||||
ServiceCharge *string `json:"serviceCharge"`
|
||||
ActualAmount *string `json:"actualAmount"`
|
||||
ActualAmountMinor *int64 `json:"actualAmountMinor"`
|
||||
ExternalFinanceClaimDecision *string `json:"externalFinanceClaimDecision"`
|
||||
ExternalFinanceClaimStatus *string `json:"externalFinanceClaimStatus"`
|
||||
PointGrossAmount int64 `json:"pointGrossAmount,omitempty"`
|
||||
PointFeeAmount int64 `json:"pointFeeAmount,omitempty"`
|
||||
PointNetAmount int64 `json:"pointNetAmount,omitempty"`
|
||||
PointsPerUSD int64 `json:"pointsPerUsd,omitempty"`
|
||||
PointFeeBPS int32 `json:"pointFeeBps,omitempty"`
|
||||
PointPolicyInstance string `json:"pointPolicyInstanceCode,omitempty"`
|
||||
WithdrawMethod string `json:"withdrawMethod"`
|
||||
WithdrawAddress string `json:"withdrawAddress"`
|
||||
FreezeTransactionID string `json:"freezeTransactionId"`
|
||||
AuditTransactionID string `json:"auditTransactionId"`
|
||||
Status string `json:"status"`
|
||||
ApproverUserID *uint `json:"approverUserId"`
|
||||
ApproverName string `json:"approverName"`
|
||||
AuditRemark string `json:"auditRemark"`
|
||||
AuditImageURL string `json:"auditImageUrl"`
|
||||
ApprovedAtMS *int64 `json:"approvedAtMs"`
|
||||
OperationsStatus string `json:"operationsStatus"`
|
||||
OperationsReviewerUserID *uint `json:"operationsReviewerUserId"`
|
||||
OperationsReviewerName string `json:"operationsReviewerName"`
|
||||
OperationsAuditRemark string `json:"operationsAuditRemark"`
|
||||
OperationsAuditTransactionID string `json:"operationsAuditTransactionId"`
|
||||
OperationsReviewedAtMS *int64 `json:"operationsReviewedAtMs"`
|
||||
CreatedAtMS int64 `json:"createdAtMs"`
|
||||
UpdatedAtMS int64 `json:"updatedAtMs"`
|
||||
}
|
||||
|
||||
func withdrawalApplicationDTOFromModel(item model.UserWithdrawalApplication) withdrawalApplicationDTO {
|
||||
@ -49,6 +56,13 @@ func withdrawalApplicationDTOFromModel(item model.UserWithdrawalApplication) wit
|
||||
SalaryAssetType: item.SalaryAssetType,
|
||||
WithdrawAmount: item.WithdrawAmount,
|
||||
WithdrawAmountMinor: item.WithdrawAmountMinor,
|
||||
BalanceBefore: item.BalanceBefore,
|
||||
ServiceChargeRatio: item.ServiceChargeRatio,
|
||||
ServiceCharge: item.ServiceCharge,
|
||||
ActualAmount: item.ActualAmount,
|
||||
ActualAmountMinor: item.ActualAmountMinor,
|
||||
ExternalFinanceClaimDecision: item.ExternalFinanceClaimDecision,
|
||||
ExternalFinanceClaimStatus: item.ExternalFinanceClaimStatus,
|
||||
WithdrawMethod: item.WithdrawMethod,
|
||||
WithdrawAddress: item.WithdrawAddress,
|
||||
FreezeTransactionID: item.FreezeTransactionID,
|
||||
|
||||
@ -51,6 +51,11 @@ type externalWithdrawalApplicationInput struct {
|
||||
SalaryAssetType string
|
||||
WithdrawAmount string
|
||||
WithdrawAmountMinor int64
|
||||
BalanceBefore string
|
||||
ServiceChargeRatio string
|
||||
ServiceCharge string
|
||||
ActualAmount string
|
||||
ActualAmountMinor *int64
|
||||
WithdrawMethod string
|
||||
WithdrawAddress string
|
||||
CreatedAtMS int64
|
||||
@ -88,6 +93,10 @@ func (s *Service) CreateExternalApplication(ctx context.Context, input externalW
|
||||
input.UserID = strings.TrimSpace(input.UserID)
|
||||
input.SalaryAssetType = strings.TrimSpace(input.SalaryAssetType)
|
||||
input.WithdrawAmount = strings.TrimSpace(input.WithdrawAmount)
|
||||
input.BalanceBefore = strings.TrimSpace(input.BalanceBefore)
|
||||
input.ServiceChargeRatio = strings.TrimSpace(input.ServiceChargeRatio)
|
||||
input.ServiceCharge = strings.TrimSpace(input.ServiceCharge)
|
||||
input.ActualAmount = strings.TrimSpace(input.ActualAmount)
|
||||
input.WithdrawMethod = strings.TrimSpace(input.WithdrawMethod)
|
||||
input.WithdrawAddress = strings.TrimSpace(input.WithdrawAddress)
|
||||
if !s.sources.HasSource(input.AppCode, input.SourceSystem) {
|
||||
@ -102,11 +111,17 @@ func (s *Service) CreateExternalApplication(ctx context.Context, input externalW
|
||||
if err := validateExternalWithdrawalAmount(input.WithdrawAmount, input.WithdrawAmountMinor); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := validateExternalWithdrawalBalanceBefore(input.BalanceBefore, input.WithdrawAmount); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := validateExternalWithdrawalPayoutSnapshot(input); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
createdAtMS := input.CreatedAtMS
|
||||
if createdAtMS <= 0 {
|
||||
createdAtMS = s.now().UnixMilli()
|
||||
}
|
||||
application, err := s.store.CreateExternalWithdrawalApplication(model.UserWithdrawalApplication{
|
||||
application, created, err := s.store.CreateExternalWithdrawalApplication(model.UserWithdrawalApplication{
|
||||
AppCode: input.AppCode,
|
||||
SourceSystem: input.SourceSystem,
|
||||
SourceApplicationID: input.SourceApplicationID,
|
||||
@ -114,6 +129,11 @@ func (s *Service) CreateExternalApplication(ctx context.Context, input externalW
|
||||
SalaryAssetType: input.SalaryAssetType,
|
||||
WithdrawAmount: input.WithdrawAmount,
|
||||
WithdrawAmountMinor: input.WithdrawAmountMinor,
|
||||
BalanceBefore: optionalDecimalString(input.BalanceBefore),
|
||||
ServiceChargeRatio: optionalDecimalString(input.ServiceChargeRatio),
|
||||
ServiceCharge: optionalDecimalString(input.ServiceCharge),
|
||||
ActualAmount: optionalDecimalString(input.ActualAmount),
|
||||
ActualAmountMinor: input.ActualAmountMinor,
|
||||
WithdrawMethod: input.WithdrawMethod,
|
||||
WithdrawAddress: input.WithdrawAddress,
|
||||
// 外部来源在提交前已经完成扣款;这两个字段保留来源事实,真正终态由 callback adapter 执行,绝不误调用 HY wallet。
|
||||
@ -127,8 +147,10 @@ func (s *Service) CreateExternalApplication(ctx context.Context, input externalW
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 外部 Likei/Aslan 申请直接进入 Admin,不经过 Gateway 的创建通知;落库成功后在这里补齐运营阶段提醒。
|
||||
s.notifyReviewPending(ctx, *application, repository.WithdrawalApplicationReviewStageOperations)
|
||||
// 来源采用持久重试;只有首次成功落库才提醒运营,HTTP 响应丢失后的幂等重放不能反复轰炸审核群。
|
||||
if created {
|
||||
s.notifyReviewPending(ctx, *application, repository.WithdrawalApplicationReviewStageOperations)
|
||||
}
|
||||
dto := withdrawalApplicationDTOFromModel(*application)
|
||||
return &dto, nil
|
||||
}
|
||||
@ -145,6 +167,128 @@ func validateExternalWithdrawalAmount(amount string, amountMinor int64) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateExternalWithdrawalBalanceBefore(balanceBefore string, withdrawAmount string) error {
|
||||
if balanceBefore == "" {
|
||||
// 兼容字段上线前已经进入 Likei 持久重试队列的申请;这些历史单在审核页明确显示“-”。
|
||||
return nil
|
||||
}
|
||||
balance, ok := new(big.Rat).SetString(balanceBefore)
|
||||
if !ok || balance.Sign() < 0 {
|
||||
return errors.New("提现前余额不正确")
|
||||
}
|
||||
amount, ok := new(big.Rat).SetString(withdrawAmount)
|
||||
if !ok || balance.Cmp(amount) < 0 {
|
||||
return errors.New("提现前余额不能小于提现金额")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateExternalWithdrawalPayoutSnapshot(input externalWithdrawalApplicationInput) error {
|
||||
ratioText := strings.TrimSpace(input.ServiceChargeRatio)
|
||||
chargeText := strings.TrimSpace(input.ServiceCharge)
|
||||
actualText := strings.TrimSpace(input.ActualAmount)
|
||||
presentCount := 0
|
||||
for _, present := range []bool{ratioText != "", chargeText != "", actualText != "", input.ActualAmountMinor != nil} {
|
||||
if present {
|
||||
presentCount++
|
||||
}
|
||||
}
|
||||
if presentCount == 0 {
|
||||
// ChatApp3 的历史字段同时包含费率和已向上取整的手续费结果;缺快照时 Admin 无法安全推导实际打款额。
|
||||
if strings.EqualFold(strings.TrimSpace(input.SourceSystem), "chatapp3") {
|
||||
return errors.New("ChatApp3 提现缺少手续费与实际打款快照")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if presentCount != 4 {
|
||||
return errors.New("提现手续费与实际打款快照不完整")
|
||||
}
|
||||
|
||||
ratio, _, ok := parseExternalFixedDecimal(ratioText, 9, 4)
|
||||
if !ok || ratio.Sign() < 0 || ratio.Cmp(big.NewRat(100, 1)) > 0 {
|
||||
return errors.New("提现手续费率不正确")
|
||||
}
|
||||
charge, chargeMinor, ok := parseExternalFixedDecimal(chargeText, 18, 2)
|
||||
if !ok || charge.Sign() < 0 {
|
||||
return errors.New("提现手续费不正确")
|
||||
}
|
||||
actual, actualMinor, ok := parseExternalFixedDecimal(actualText, 18, 2)
|
||||
if !ok || actual.Sign() <= 0 {
|
||||
return errors.New("实际打款金额不正确")
|
||||
}
|
||||
if !actualMinor.IsInt64() || actualMinor.Int64() != *input.ActualAmountMinor {
|
||||
return errors.New("实际打款金额与最小单位金额不一致")
|
||||
}
|
||||
gross, _, ok := parseExternalFixedDecimal(input.WithdrawAmount, 18, 2)
|
||||
if !ok || new(big.Rat).Add(charge, actual).Cmp(gross) != 0 {
|
||||
return errors.New("申请金额、手续费与实际打款金额不一致")
|
||||
}
|
||||
// 同时校验最小单位表示,防止 decimal 展示正确但上游分值被截断或溢出。
|
||||
payoutMinor := new(big.Int).Add(chargeMinor, big.NewInt(*input.ActualAmountMinor))
|
||||
if !payoutMinor.IsInt64() || payoutMinor.Int64() != input.WithdrawAmountMinor {
|
||||
return errors.New("手续费、实际打款与申请金额的最小单位不一致")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseExternalFixedDecimal(value string, precision int, scale int) (*big.Rat, *big.Int, bool) {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" || len(value) > 64 || precision <= 0 || scale < 0 || scale > precision {
|
||||
return nil, nil, false
|
||||
}
|
||||
negative := false
|
||||
if value[0] == '+' || value[0] == '-' {
|
||||
negative = value[0] == '-'
|
||||
value = value[1:]
|
||||
}
|
||||
parts := strings.Split(value, ".")
|
||||
if len(parts) > 2 || parts[0] == "" || !isExternalDecimalDigits(parts[0]) {
|
||||
return nil, nil, false
|
||||
}
|
||||
fraction := ""
|
||||
if len(parts) == 2 {
|
||||
fraction = parts[1]
|
||||
if fraction == "" || !isExternalDecimalDigits(fraction) {
|
||||
return nil, nil, false
|
||||
}
|
||||
}
|
||||
if len(fraction) > scale {
|
||||
// 文本小数位超出列 scale 时即使多出的是 0 也拒绝,避免契约精度在入库时被隐式改写。
|
||||
return nil, nil, false
|
||||
}
|
||||
scaleFactor := new(big.Int).Exp(big.NewInt(10), big.NewInt(int64(scale)), nil)
|
||||
scaledDigits := parts[0] + fraction + strings.Repeat("0", scale-len(fraction))
|
||||
scaled, ok := new(big.Int).SetString(scaledDigits, 10)
|
||||
if !ok {
|
||||
return nil, nil, false
|
||||
}
|
||||
if negative {
|
||||
scaled.Neg(scaled)
|
||||
}
|
||||
maxScaled := new(big.Int).Sub(new(big.Int).Exp(big.NewInt(10), big.NewInt(int64(precision)), nil), big.NewInt(1))
|
||||
if new(big.Int).Abs(new(big.Int).Set(scaled)).Cmp(maxScaled) > 0 {
|
||||
return nil, nil, false
|
||||
}
|
||||
// 先按存储 scale 构造精确有理数,后续所有金额恒等式都不经过 float64 或四舍五入。
|
||||
return new(big.Rat).SetFrac(new(big.Int).Set(scaled), scaleFactor), scaled, true
|
||||
}
|
||||
|
||||
func isExternalDecimalDigits(value string) bool {
|
||||
for i := 0; i < len(value); i++ {
|
||||
if value[i] < '0' || value[i] > '9' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return value != ""
|
||||
}
|
||||
|
||||
func optionalDecimalString(value string) *string {
|
||||
if value == "" {
|
||||
return nil
|
||||
}
|
||||
return &value
|
||||
}
|
||||
|
||||
func (s *Service) ListApplications(actor shared.Actor, options repository.WithdrawalApplicationListOptions) ([]withdrawalApplicationDTO, int64, error) {
|
||||
if s == nil || s.store == nil {
|
||||
return nil, 0, errors.New("admin store is not configured")
|
||||
@ -242,6 +386,16 @@ func (s *Service) auditApplication(ctx context.Context, actor shared.Actor, id u
|
||||
}
|
||||
nowMS := s.now().UnixMilli()
|
||||
commandID := withdrawalAuditCommandID(id, stage)
|
||||
if stage == repository.WithdrawalApplicationReviewStageFinance {
|
||||
current, err := s.store.GetWithdrawalApplicationForApp(appCode, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if isExternalWithdrawal(*current) {
|
||||
// 外部钱包回调和 Admin 终态不可能共享一个数据库事务;先持久首次财务 claim 才能安全调用回调。
|
||||
return s.executeExternalFinanceDecision(ctx, actor, id, appCode, decision, remark, auditImageURL, commandID, nowMS)
|
||||
}
|
||||
}
|
||||
if stage == repository.WithdrawalApplicationReviewStageOperations && decision == model.WithdrawalApplicationStatusRejected {
|
||||
return s.executeOperationsRejection(ctx, actor, id, appCode, remark, requestID, commandID, nowMS)
|
||||
}
|
||||
@ -373,6 +527,54 @@ func (s *Service) executeOperationsRejection(ctx context.Context, actor shared.A
|
||||
return &dto, nil
|
||||
}
|
||||
|
||||
// executeExternalFinanceDecision 把外部财务终审拆成 claim -> 来源幂等回调 -> finalize。
|
||||
// claim 一旦提交,后续任何审核人的同决策重试都只能沿用第一次审计快照,相反决策在回调前被拒绝。
|
||||
func (s *Service) executeExternalFinanceDecision(ctx context.Context, actor shared.Actor, id uint, appCode string, decision string, remark string, auditImageURL string, commandID string, nowMS int64) (*withdrawalApplicationDTO, error) {
|
||||
claimed, err := s.store.ClaimExternalWithdrawalFinanceDecisionForApp(appCode, id, repository.WithdrawalApplicationAuditInput{
|
||||
Stage: repository.WithdrawalApplicationReviewStageFinance,
|
||||
Decision: decision,
|
||||
ApproverUserID: actor.UserID,
|
||||
ApproverName: actor.Username,
|
||||
AuditRemark: remark,
|
||||
AuditImageURL: auditImageURL,
|
||||
AuditCommandID: commandID,
|
||||
ApprovedAtMS: nowMS,
|
||||
}, func(item model.UserWithdrawalApplication) error {
|
||||
return validateWithdrawalDecisionTarget(item)
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
claimStatus := ""
|
||||
if claimed.ExternalFinanceClaimStatus != nil {
|
||||
claimStatus = strings.TrimSpace(*claimed.ExternalFinanceClaimStatus)
|
||||
}
|
||||
if claimStatus == model.WithdrawalExternalFinanceClaimStatusFinalized {
|
||||
// 上次 finalize 可能已提交但返回链路中断;终态幂等返回不再重复回调。
|
||||
dto := withdrawalApplicationDTOFromModel(*claimed)
|
||||
return &dto, nil
|
||||
}
|
||||
if claimStatus != model.WithdrawalExternalFinanceClaimStatusClaimed || claimed.ExternalFinanceClaimDecision == nil ||
|
||||
claimed.ApproverUserID == nil || *claimed.ApproverUserID == 0 || claimed.ApprovedAtMS == nil || *claimed.ApprovedAtMS <= 0 || strings.TrimSpace(claimed.AuditCommandID) == "" {
|
||||
return nil, errors.New("外部提现财务 claim 不完整")
|
||||
}
|
||||
claimedDecision := strings.TrimSpace(*claimed.ExternalFinanceClaimDecision)
|
||||
claimedRemark := strings.TrimSpace(claimed.AuditRemark)
|
||||
claimedImageURL := strings.TrimSpace(claimed.AuditImageURL)
|
||||
claimedActor := shared.Actor{UserID: *claimed.ApproverUserID, Username: claimed.ApproverName}
|
||||
transactionID, _, err := s.applyWithdrawalDecision(ctx, *claimed, claimedDecision, claimed.AuditCommandID, appCode, claimedActor, claimedRemark, claimedImageURL, id)
|
||||
if err != nil {
|
||||
// claim 已在独立事务持久,回调失败只返错误供财务按相同决策重试,不得回退或改写 claim。
|
||||
return nil, err
|
||||
}
|
||||
updated, err := s.store.FinalizeExternalWithdrawalFinanceDecisionForApp(appCode, id, claimedDecision, claimed.AuditCommandID, transactionID, s.now().UnixMilli())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dto := withdrawalApplicationDTOFromModel(*updated)
|
||||
return &dto, nil
|
||||
}
|
||||
|
||||
func withdrawalWalletUserID(item model.UserWithdrawalApplication) (int64, error) {
|
||||
userID, err := strconv.ParseInt(strings.TrimSpace(item.UserID), 10, 64)
|
||||
if err != nil || userID <= 0 || strings.TrimSpace(item.SalaryAssetType) == "" || item.WithdrawAmountMinor <= 0 || strings.TrimSpace(item.FreezeTransactionID) == "" {
|
||||
@ -522,7 +724,7 @@ func (s *Service) applyWalletDecision(ctx context.Context, decision string, comm
|
||||
}
|
||||
|
||||
func withdrawalAuditCommandID(id uint, stage repository.WithdrawalApplicationReviewStage) string {
|
||||
// 同一阶段的通过和拒绝复用 command id;并发相反决策会在 wallet request hash 校验处冲突,不能先后 settle/release 同一冻结金额。
|
||||
// 同一阶段的通过和拒绝复用 command id;内部 wallet 用请求哈希防相反决策,外部回调则由持久 finance claim 先锁定决策和审计上下文。
|
||||
return fmt.Sprintf("salary-withdrawal:%d:%s", id, stage)
|
||||
}
|
||||
|
||||
|
||||
@ -2,13 +2,19 @@ package financewithdrawal
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"hyapp-admin-server/internal/appctx"
|
||||
"hyapp-admin-server/internal/config"
|
||||
"hyapp-admin-server/internal/integration/activityclient"
|
||||
"hyapp-admin-server/internal/integration/walletclient"
|
||||
"hyapp-admin-server/internal/integration/withdrawalsource"
|
||||
"hyapp-admin-server/internal/model"
|
||||
"hyapp-admin-server/internal/modules/shared"
|
||||
"hyapp-admin-server/internal/repository"
|
||||
@ -45,6 +51,167 @@ func TestOperationsApprovalMovesToFinanceWithoutWalletOrUserNotice(t *testing.T)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateExternalWithdrawalBalanceBeforeUsesSubmissionSnapshot(t *testing.T) {
|
||||
t.Parallel()
|
||||
if err := validateExternalWithdrawalBalanceBefore("84.50", "84.00"); err != nil {
|
||||
t.Fatalf("valid balance snapshot rejected: %v", err)
|
||||
}
|
||||
if err := validateExternalWithdrawalBalanceBefore("", "84.00"); err != nil {
|
||||
t.Fatalf("legacy application without snapshot must remain compatible: %v", err)
|
||||
}
|
||||
if err := validateExternalWithdrawalBalanceBefore("83.99", "84.00"); err == nil {
|
||||
t.Fatal("balance below withdrawal amount must be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateExternalWithdrawalPayoutSnapshotUsesPersistedNetAmount(t *testing.T) {
|
||||
t.Parallel()
|
||||
minor := func(value int64) *int64 { return &value }
|
||||
|
||||
// ChatApp3 对 20% 手续费向上取整:84 * 20% = 16.8,持久化费用为 17,净打款为 67。
|
||||
// Admin 只校验这三个申请时事实的金额恒等式,不复制上游取整策略。
|
||||
valid := externalWithdrawalApplicationInput{
|
||||
SourceSystem: "chatapp3", WithdrawAmount: "84", WithdrawAmountMinor: 8400,
|
||||
ServiceChargeRatio: "20", ServiceCharge: "17", ActualAmount: "67", ActualAmountMinor: minor(6700),
|
||||
}
|
||||
if err := validateExternalWithdrawalPayoutSnapshot(valid); err != nil {
|
||||
t.Fatalf("persisted ChatApp3 payout snapshot rejected: %v", err)
|
||||
}
|
||||
onePercent := valid
|
||||
onePercent.WithdrawAmount = "100.00"
|
||||
onePercent.WithdrawAmountMinor = 10000
|
||||
onePercent.ServiceChargeRatio = "1"
|
||||
onePercent.ServiceCharge = "1.00"
|
||||
onePercent.ActualAmount = "99.00"
|
||||
onePercent.ActualAmountMinor = minor(9900)
|
||||
if err := validateExternalWithdrawalPayoutSnapshot(onePercent); err != nil {
|
||||
t.Fatalf("percentage-point value 1 must mean 1%%, not a fraction: %v", err)
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
input externalWithdrawalApplicationInput
|
||||
}{
|
||||
{name: "chatapp snapshot required", input: externalWithdrawalApplicationInput{SourceSystem: "chatapp3", WithdrawAmount: "84", WithdrawAmountMinor: 8400}},
|
||||
{name: "partial snapshot", input: externalWithdrawalApplicationInput{SourceSystem: "likei", WithdrawAmount: "84", WithdrawAmountMinor: 8400, ServiceChargeRatio: "20"}},
|
||||
{name: "actual minor mismatch", input: func() externalWithdrawalApplicationInput {
|
||||
item := valid
|
||||
item.ActualAmountMinor = minor(6699)
|
||||
return item
|
||||
}()},
|
||||
{name: "gross decimal mismatch", input: func() externalWithdrawalApplicationInput { item := valid; item.ServiceCharge = "16"; return item }()},
|
||||
{name: "gross minor mismatch", input: func() externalWithdrawalApplicationInput { item := valid; item.WithdrawAmountMinor = 8401; return item }()},
|
||||
{name: "ratio over range", input: func() externalWithdrawalApplicationInput {
|
||||
item := valid
|
||||
item.ServiceChargeRatio = "100.0001"
|
||||
return item
|
||||
}()},
|
||||
{name: "ratio scale overflow", input: func() externalWithdrawalApplicationInput {
|
||||
item := valid
|
||||
item.ServiceChargeRatio = "1.00000"
|
||||
return item
|
||||
}()},
|
||||
{name: "amount scale overflow", input: func() externalWithdrawalApplicationInput { item := valid; item.ActualAmount = "67.000"; return item }()},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if err := validateExternalWithdrawalPayoutSnapshot(tt.input); err == nil {
|
||||
t.Fatal("invalid external payout snapshot must be rejected")
|
||||
}
|
||||
})
|
||||
}
|
||||
if err := validateExternalWithdrawalPayoutSnapshot(externalWithdrawalApplicationInput{SourceSystem: "likei", WithdrawAmount: "84", WithdrawAmountMinor: 8400}); err != nil {
|
||||
t.Fatalf("source without the optional payout snapshot must remain compatible: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithdrawalReviewPendingMarkdownIncludesImmutablePayoutBreakdown(t *testing.T) {
|
||||
t.Parallel()
|
||||
ratio, charge, actual := "20.0000", "17.00", "67.00"
|
||||
_, markdown := withdrawalReviewPendingMarkdown(model.UserWithdrawalApplication{
|
||||
ID: 9, AppCode: "yumi", SourceSystem: "chatapp3", SourceApplicationID: "88", UserID: "10001",
|
||||
WithdrawAmount: "84.00", ServiceChargeRatio: &ratio, ServiceCharge: &charge, ActualAmount: &actual,
|
||||
}, repository.WithdrawalApplicationReviewStageFinance)
|
||||
for _, want := range []string{"- 申请金额:$ 84.00", "- 手续费:$ 17.00(费率 20.0000%)", "- 实际打款金额:$ 67.00"} {
|
||||
if !strings.Contains(markdown, want) {
|
||||
t.Fatalf("withdrawal review markdown missing %q:\n%s", want, markdown)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestExternalFinanceRetryAfterFinalizeFailureUsesFirstClaimSnapshot(t *testing.T) {
|
||||
svc, mock, _, _, closeService := newWithdrawalAuditServiceTest(t)
|
||||
defer closeService()
|
||||
|
||||
callbacks := make(chan withdrawalsource.DecisionRequest, 2)
|
||||
callbackServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
if req.Header.Get("Authorization") != "Bearer callback-token" {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
var payload withdrawalsource.DecisionRequest
|
||||
if err := json.NewDecoder(req.Body).Decode(&payload); err != nil {
|
||||
http.Error(w, "bad json", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
callbacks <- payload
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
defer callbackServer.Close()
|
||||
svc.sources = withdrawalsource.New([]config.WithdrawalSourceConfig{{
|
||||
Enabled: true, AppCode: "yumi", SourceSystem: "chatapp3", SubmitToken: "submit-token",
|
||||
CallbackURL: callbackServer.URL, CallbackToken: "callback-token", RequestTimeout: time.Second,
|
||||
}})
|
||||
|
||||
commandID := "salary-withdrawal:77:finance"
|
||||
firstImage := "https://example.com/first-proof.png"
|
||||
// 第一次 claim 独立提交,回调成功;故意让 finalize UPDATE 失败,模拟来源已终态但 Admin 仍是 claimed。
|
||||
mock.ExpectBegin()
|
||||
mock.ExpectQuery("SELECT \\* FROM `admin_user_withdrawal_applications`.*FOR UPDATE").WithArgs("yumi", uint(77), 1).
|
||||
WillReturnRows(externalFinanceAuditRows("", "", 0, "", "", "", "", ""))
|
||||
mock.ExpectExec("UPDATE `admin_user_withdrawal_applications` SET").WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectCommit()
|
||||
mock.ExpectBegin()
|
||||
mock.ExpectQuery("SELECT \\* FROM `admin_user_withdrawal_applications`.*FOR UPDATE").WithArgs("yumi", uint(77), 1).
|
||||
WillReturnRows(externalFinanceAuditRows(model.WithdrawalExternalFinanceClaimStatusClaimed, model.WithdrawalApplicationStatusApproved, 9, "first-finance", "first remark", firstImage, commandID, ""))
|
||||
finalizeFailure := errors.New("admin finalize failed")
|
||||
mock.ExpectExec("UPDATE `admin_user_withdrawal_applications` SET").WillReturnError(finalizeFailure)
|
||||
mock.ExpectRollback()
|
||||
|
||||
_, err := svc.executeExternalFinanceDecision(context.Background(), shared.Actor{UserID: 9, Username: "first-finance"}, 77, "yumi", model.WithdrawalApplicationStatusApproved, "first remark", firstImage, commandID, 1700000300000)
|
||||
if !errors.Is(err, finalizeFailure) {
|
||||
t.Fatalf("first callback must surface local finalize failure, got %v", err)
|
||||
}
|
||||
|
||||
// 第二个财务人使用不同备注/凭证重试:claim 不 UPDATE,回调仍必须使用第一个人的完整快照。
|
||||
mock.ExpectBegin()
|
||||
mock.ExpectQuery("SELECT \\* FROM `admin_user_withdrawal_applications`.*FOR UPDATE").WithArgs("yumi", uint(77), 1).
|
||||
WillReturnRows(externalFinanceAuditRows(model.WithdrawalExternalFinanceClaimStatusClaimed, model.WithdrawalApplicationStatusApproved, 9, "first-finance", "first remark", firstImage, commandID, ""))
|
||||
mock.ExpectCommit()
|
||||
mock.ExpectBegin()
|
||||
mock.ExpectQuery("SELECT \\* FROM `admin_user_withdrawal_applications`.*FOR UPDATE").WithArgs("yumi", uint(77), 1).
|
||||
WillReturnRows(externalFinanceAuditRows(model.WithdrawalExternalFinanceClaimStatusClaimed, model.WithdrawalApplicationStatusApproved, 9, "first-finance", "first remark", firstImage, commandID, ""))
|
||||
mock.ExpectExec("UPDATE `admin_user_withdrawal_applications` SET").WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectCommit()
|
||||
|
||||
dto, err := svc.executeExternalFinanceDecision(context.Background(), shared.Actor{UserID: 19, Username: "retry-finance"}, 77, "yumi", model.WithdrawalApplicationStatusApproved, "changed remark", "https://example.com/changed.png", commandID, 1700000400000)
|
||||
if err != nil {
|
||||
t.Fatalf("retry external finance decision failed: %v", err)
|
||||
}
|
||||
if dto.Status != model.WithdrawalApplicationStatusApproved || dto.ApproverUserID == nil || *dto.ApproverUserID != 9 || dto.ApproverName != "first-finance" || dto.AuditRemark != "first remark" || dto.AuditImageURL != firstImage {
|
||||
t.Fatalf("retry terminal state did not preserve first claim: %+v", dto)
|
||||
}
|
||||
firstCallback, secondCallback := <-callbacks, <-callbacks
|
||||
for index, payload := range []withdrawalsource.DecisionRequest{firstCallback, secondCallback} {
|
||||
if payload.Decision != "PASS" || payload.ReviewerUserID != 9 || payload.ReviewerName != "first-finance" || payload.Remark != "first remark" || payload.CommandID != commandID || len(payload.CredentialURLs) != 1 || payload.CredentialURLs[0] != firstImage {
|
||||
t.Fatalf("callback %d did not use first durable claim: %+v", index+1, payload)
|
||||
}
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatalf("sql expectations mismatch: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOperationsRejectionReleasesFrozenBalanceAndSendsFinalNotice(t *testing.T) {
|
||||
svc, mock, wallet, activity, closeService := newWithdrawalAuditServiceTest(t)
|
||||
defer closeService()
|
||||
@ -139,6 +306,10 @@ func TestFinanceReviewRejectsOperationsPendingBeforeWalletOrNotice(t *testing.T)
|
||||
svc, mock, wallet, activity, closeService := newWithdrawalAuditServiceTest(t)
|
||||
defer closeService()
|
||||
expectWithdrawalMoneyAccess(mock, 9, "lalu")
|
||||
// 财务入口先识别外部来源;hyapp_wallet 行仍进入原有持锁审核事务。
|
||||
mock.ExpectQuery("SELECT \\* FROM `admin_user_withdrawal_applications` WHERE app_code = \\? AND id = \\?").
|
||||
WithArgs("lalu", uint(77), 1).
|
||||
WillReturnRows(withdrawalAuditRows(model.WithdrawalApplicationStatusPending, model.WithdrawalOperationsStatusPending))
|
||||
mock.ExpectBegin()
|
||||
mock.ExpectQuery("SELECT \\* FROM `admin_user_withdrawal_applications`.*FOR UPDATE").
|
||||
WithArgs("lalu", uint(77), 1).
|
||||
@ -453,3 +624,27 @@ func withdrawalAuditRowsWithOperationsContext(status string, operationsStatus st
|
||||
int64(1_700_000_000_000), int64(1_700_000_000_000),
|
||||
)
|
||||
}
|
||||
|
||||
func externalFinanceAuditRows(claimStatus string, claimDecision string, reviewerID uint, reviewerName string, remark string, imageURL string, commandID string, transactionID string) *sqlmock.Rows {
|
||||
var storedClaimStatus any
|
||||
var storedClaimDecision any
|
||||
var storedReviewerID any
|
||||
var approvedAtMS any
|
||||
if claimStatus != "" {
|
||||
storedClaimStatus = claimStatus
|
||||
storedClaimDecision = claimDecision
|
||||
storedReviewerID = reviewerID
|
||||
approvedAtMS = int64(1_700_000_300_000)
|
||||
}
|
||||
return sqlmock.NewRows([]string{
|
||||
"id", "app_code", "source_system", "source_application_id", "user_id", "salary_asset_type",
|
||||
"withdraw_amount", "withdraw_amount_minor", "withdraw_method", "withdraw_address", "status", "operations_status",
|
||||
"external_finance_claim_decision", "external_finance_claim_status", "approver_user_id", "approver_name",
|
||||
"audit_remark", "audit_image_url", "audit_command_id", "audit_transaction_id", "approved_at_ms", "created_at_ms", "updated_at_ms",
|
||||
}).AddRow(
|
||||
77, "yumi", "chatapp3", "88001", "42001", "SALARY", "84.00", int64(8400), "usdt_trc20", "TRON-address",
|
||||
model.WithdrawalApplicationStatusPending, model.WithdrawalOperationsStatusApproved,
|
||||
storedClaimDecision, storedClaimStatus, storedReviewerID, reviewerName, remark, imageURL, commandID, transactionID, approvedAtMS,
|
||||
int64(1_700_000_000_000), int64(1_700_000_000_000),
|
||||
)
|
||||
}
|
||||
|
||||
@ -2,6 +2,7 @@ package repository
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"math/big"
|
||||
"strings"
|
||||
|
||||
"hyapp-admin-server/internal/model"
|
||||
@ -59,12 +60,12 @@ type WithdrawalApplicationClaimValidator func(application model.UserWithdrawalAp
|
||||
|
||||
// CreateExternalWithdrawalApplication 以 App、来源系统、来源申请号作为业务幂等键。
|
||||
// 来源钱包采用持久重试投递;重复提交必须返回同一 admin 申请,不能制造两张可独立审核的资金单。
|
||||
func (s *Store) CreateExternalWithdrawalApplication(application model.UserWithdrawalApplication) (*model.UserWithdrawalApplication, error) {
|
||||
func (s *Store) CreateExternalWithdrawalApplication(application model.UserWithdrawalApplication) (*model.UserWithdrawalApplication, bool, error) {
|
||||
application.AppCode = strings.TrimSpace(application.AppCode)
|
||||
application.SourceSystem = strings.TrimSpace(application.SourceSystem)
|
||||
application.SourceApplicationID = strings.TrimSpace(application.SourceApplicationID)
|
||||
if application.AppCode == "" || application.SourceSystem == "" || application.SourceApplicationID == "" {
|
||||
return nil, errors.New("external withdrawal source identity is incomplete")
|
||||
return nil, false, errors.New("external withdrawal source identity is incomplete")
|
||||
}
|
||||
|
||||
var existing model.UserWithdrawalApplication
|
||||
@ -76,18 +77,20 @@ func (s *Store) CreateExternalWithdrawalApplication(application model.UserWithdr
|
||||
).First(&existing)
|
||||
if lookup.Error == nil {
|
||||
if err := validateExternalWithdrawalReplay(existing, application); err != nil {
|
||||
return nil, err
|
||||
return nil, false, err
|
||||
}
|
||||
return &existing, nil
|
||||
return &existing, false, nil
|
||||
}
|
||||
if !errors.Is(lookup.Error, gorm.ErrRecordNotFound) {
|
||||
return nil, lookup.Error
|
||||
return nil, false, lookup.Error
|
||||
}
|
||||
|
||||
result := s.db.Clauses(clause.OnConflict{DoNothing: true}).Create(&application)
|
||||
if result.Error != nil {
|
||||
return nil, result.Error
|
||||
return nil, false, result.Error
|
||||
}
|
||||
// 首次创建才通知运营。顺序重放会在上面的查询直接返回;并发竞争时 MySQL 的 no-op conflict 通常 RowsAffected=0,避免两次提醒。
|
||||
created := result.RowsAffected == 1
|
||||
// MySQL 对 ON DUPLICATE KEY 的 RowsAffected 会受连接参数影响;无论本次创建还是并发重放,都按业务键回读唯一事实。
|
||||
if err := s.db.Where(
|
||||
"app_code = ? AND source_system = ? AND source_application_id = ?",
|
||||
@ -95,18 +98,23 @@ func (s *Store) CreateExternalWithdrawalApplication(application model.UserWithdr
|
||||
application.SourceSystem,
|
||||
application.SourceApplicationID,
|
||||
).First(&existing).Error; err != nil {
|
||||
return nil, err
|
||||
return nil, false, err
|
||||
}
|
||||
if err := validateExternalWithdrawalReplay(existing, application); err != nil {
|
||||
return nil, err
|
||||
return nil, false, err
|
||||
}
|
||||
return &existing, nil
|
||||
return &existing, created, nil
|
||||
}
|
||||
|
||||
func validateExternalWithdrawalReplay(existing model.UserWithdrawalApplication, replay model.UserWithdrawalApplication) error {
|
||||
if existing.UserID != replay.UserID ||
|
||||
existing.SalaryAssetType != replay.SalaryAssetType ||
|
||||
existing.WithdrawAmountMinor != replay.WithdrawAmountMinor ||
|
||||
!sameOptionalDecimal(existing.BalanceBefore, replay.BalanceBefore) ||
|
||||
!sameOptionalDecimal(existing.ServiceChargeRatio, replay.ServiceChargeRatio) ||
|
||||
!sameOptionalDecimal(existing.ServiceCharge, replay.ServiceCharge) ||
|
||||
!sameOptionalDecimal(existing.ActualAmount, replay.ActualAmount) ||
|
||||
!sameOptionalInt64(existing.ActualAmountMinor, replay.ActualAmountMinor) ||
|
||||
existing.WithdrawMethod != replay.WithdrawMethod ||
|
||||
existing.WithdrawAddress != replay.WithdrawAddress {
|
||||
// 相同来源单号只能重放完全一致的资金事实,不能用幂等接口覆盖已进入人工审核的金额或收款地址。
|
||||
@ -115,6 +123,28 @@ func validateExternalWithdrawalReplay(existing model.UserWithdrawalApplication,
|
||||
return nil
|
||||
}
|
||||
|
||||
func sameOptionalDecimal(existing *string, replay *string) bool {
|
||||
// 快照字段上线前的旧单没有可信值;旧单重试不能因新增字段被卡死,但已落库的快照禁止被丢弃或篡改。
|
||||
if existing == nil {
|
||||
return true
|
||||
}
|
||||
if replay == nil {
|
||||
return false
|
||||
}
|
||||
// DECIMAL(18,2) 回读会把来源的 84.5 规范化成 84.50;资金幂等比较必须按精确十进制数值,而非展示字符串。
|
||||
existingValue, existingOK := new(big.Rat).SetString(strings.TrimSpace(*existing))
|
||||
replayValue, replayOK := new(big.Rat).SetString(strings.TrimSpace(*replay))
|
||||
return existingOK && replayOK && existingValue.Cmp(replayValue) == 0
|
||||
}
|
||||
|
||||
func sameOptionalInt64(existing *int64, replay *int64) bool {
|
||||
// 与可选 decimal 快照保持相同的旧单兼容边界;一旦存在最小单位事实,幂等重放必须完全一致。
|
||||
if existing == nil {
|
||||
return true
|
||||
}
|
||||
return replay != nil && *existing == *replay
|
||||
}
|
||||
|
||||
func (s *Store) GetWithdrawalApplication(id uint) (*model.UserWithdrawalApplication, error) {
|
||||
var application model.UserWithdrawalApplication
|
||||
if err := s.db.First(&application, id).Error; err != nil {
|
||||
@ -287,6 +317,202 @@ func (s *Store) FinalizeWithdrawalOperationsRejectionForApp(appCode string, id u
|
||||
return &application, nil
|
||||
}
|
||||
|
||||
// ClaimExternalWithdrawalFinanceDecisionForApp 在调用来源钱包前独立提交首次财务决策。
|
||||
// 调用方重试可以更换登录人,但只能沿用已持久的 decision/actor/remark/image/command/time;
|
||||
// 这使“来源回调 2xx、Admin finalize 失败”后的再次回调仍使用完全相同的幂等请求。
|
||||
func (s *Store) ClaimExternalWithdrawalFinanceDecisionForApp(appCode string, id uint, input WithdrawalApplicationAuditInput, validate WithdrawalApplicationClaimValidator) (*model.UserWithdrawalApplication, error) {
|
||||
input.AuditCommandID = strings.TrimSpace(input.AuditCommandID)
|
||||
input.ApproverName = strings.TrimSpace(input.ApproverName)
|
||||
input.AuditRemark = strings.TrimSpace(input.AuditRemark)
|
||||
input.AuditImageURL = strings.TrimSpace(input.AuditImageURL)
|
||||
if id == 0 || input.Stage != WithdrawalApplicationReviewStageFinance ||
|
||||
(input.Decision != model.WithdrawalApplicationStatusApproved && input.Decision != model.WithdrawalApplicationStatusRejected) ||
|
||||
input.ApproverUserID == 0 || input.ApproverName == "" || input.AuditCommandID == "" || input.ApprovedAtMS <= 0 {
|
||||
return nil, errors.New("external withdrawal finance claim is incomplete")
|
||||
}
|
||||
if input.Decision == model.WithdrawalApplicationStatusApproved && input.AuditImageURL == "" {
|
||||
return nil, errors.New("external withdrawal approval claim credential is required")
|
||||
}
|
||||
if input.Decision == model.WithdrawalApplicationStatusRejected && input.AuditRemark == "" {
|
||||
return nil, errors.New("external withdrawal rejection claim remark is required")
|
||||
}
|
||||
|
||||
var application model.UserWithdrawalApplication
|
||||
err := s.db.Transaction(func(tx *gorm.DB) error {
|
||||
query := tx.Clauses(clause.Locking{Strength: "UPDATE"})
|
||||
if strings.TrimSpace(appCode) != "" {
|
||||
query = query.Where("app_code = ?", strings.TrimSpace(appCode))
|
||||
}
|
||||
if err := query.First(&application, id).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if !isExternalWithdrawalSource(application) {
|
||||
return errors.New("durable external finance claim only supports external withdrawals")
|
||||
}
|
||||
|
||||
claimDecision := optionalWithdrawalString(application.ExternalFinanceClaimDecision)
|
||||
claimStatus := optionalWithdrawalString(application.ExternalFinanceClaimStatus)
|
||||
if claimStatus == model.WithdrawalExternalFinanceClaimStatusFinalized {
|
||||
// 本地 commit 结果不确定时,相同决策重试直接返回终态;相反决策不得借重试改写。
|
||||
if application.Status != claimDecision || input.Decision != claimDecision || strings.TrimSpace(application.AuditCommandID) != input.AuditCommandID {
|
||||
return ErrWithdrawalApplicationStageNotReviewable
|
||||
}
|
||||
return validateExternalFinanceClaimSnapshot(application)
|
||||
}
|
||||
if application.Status != model.WithdrawalApplicationStatusPending {
|
||||
return ErrWithdrawalApplicationAlreadyAudited
|
||||
}
|
||||
if reviewErr := withdrawalApplicationReviewError(application, WithdrawalApplicationReviewStageFinance); reviewErr != nil {
|
||||
return reviewErr
|
||||
}
|
||||
|
||||
switch claimStatus {
|
||||
case "":
|
||||
if claimDecision != "" {
|
||||
return ErrWithdrawalApplicationStageNotReviewable
|
||||
}
|
||||
if validate != nil {
|
||||
if err := validate(application); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
claimStatusValue := model.WithdrawalExternalFinanceClaimStatusClaimed
|
||||
claimDecisionValue := input.Decision
|
||||
updates := map[string]any{
|
||||
"external_finance_claim_decision": input.Decision,
|
||||
"external_finance_claim_status": claimStatusValue,
|
||||
"approver_user_id": input.ApproverUserID,
|
||||
"approver_name": input.ApproverName,
|
||||
"audit_remark": input.AuditRemark,
|
||||
"audit_image_url": input.AuditImageURL,
|
||||
"audit_command_id": input.AuditCommandID,
|
||||
"approved_at_ms": input.ApprovedAtMS,
|
||||
"updated_at_ms": input.ApprovedAtMS,
|
||||
}
|
||||
if err := tx.Model(&application).Updates(updates).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
application.ExternalFinanceClaimDecision = &claimDecisionValue
|
||||
application.ExternalFinanceClaimStatus = &claimStatusValue
|
||||
application.ApproverUserID = &input.ApproverUserID
|
||||
application.ApproverName = input.ApproverName
|
||||
application.AuditRemark = input.AuditRemark
|
||||
application.AuditImageURL = input.AuditImageURL
|
||||
application.AuditCommandID = input.AuditCommandID
|
||||
application.ApprovedAtMS = &input.ApprovedAtMS
|
||||
application.UpdatedAtMS = input.ApprovedAtMS
|
||||
return nil
|
||||
case model.WithdrawalExternalFinanceClaimStatusClaimed:
|
||||
if claimDecision != input.Decision || strings.TrimSpace(application.AuditCommandID) != input.AuditCommandID {
|
||||
return ErrWithdrawalApplicationStageNotReviewable
|
||||
}
|
||||
if err := validateExternalFinanceClaimSnapshot(application); err != nil {
|
||||
return err
|
||||
}
|
||||
if validate != nil {
|
||||
return validate(application)
|
||||
}
|
||||
return nil
|
||||
default:
|
||||
return ErrWithdrawalApplicationStageNotReviewable
|
||||
}
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &application, nil
|
||||
}
|
||||
|
||||
// FinalizeExternalWithdrawalFinanceDecisionForApp 只把已持久 claimed 的外部财务决策收敛到本地终态。
|
||||
// transaction id 来自已成功的来源回调;终态重试必须与首次 claim 的决策、command 和回调事务完全一致。
|
||||
func (s *Store) FinalizeExternalWithdrawalFinanceDecisionForApp(appCode string, id uint, decision string, commandID string, transactionID string, completedAtMS int64) (*model.UserWithdrawalApplication, error) {
|
||||
decision = strings.TrimSpace(decision)
|
||||
commandID = strings.TrimSpace(commandID)
|
||||
transactionID = strings.TrimSpace(transactionID)
|
||||
if id == 0 || (decision != model.WithdrawalApplicationStatusApproved && decision != model.WithdrawalApplicationStatusRejected) || commandID == "" || transactionID == "" || completedAtMS <= 0 {
|
||||
return nil, errors.New("external withdrawal finance finalization is incomplete")
|
||||
}
|
||||
|
||||
var application model.UserWithdrawalApplication
|
||||
err := s.db.Transaction(func(tx *gorm.DB) error {
|
||||
query := tx.Clauses(clause.Locking{Strength: "UPDATE"})
|
||||
if strings.TrimSpace(appCode) != "" {
|
||||
query = query.Where("app_code = ?", strings.TrimSpace(appCode))
|
||||
}
|
||||
if err := query.First(&application, id).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if !isExternalWithdrawalSource(application) {
|
||||
return errors.New("external withdrawal finance finalization only supports external withdrawals")
|
||||
}
|
||||
claimDecision := optionalWithdrawalString(application.ExternalFinanceClaimDecision)
|
||||
claimStatus := optionalWithdrawalString(application.ExternalFinanceClaimStatus)
|
||||
if claimStatus == model.WithdrawalExternalFinanceClaimStatusFinalized {
|
||||
if application.Status != decision || claimDecision != decision || strings.TrimSpace(application.AuditCommandID) != commandID || strings.TrimSpace(application.AuditTransactionID) != transactionID {
|
||||
return ErrWithdrawalApplicationStageNotReviewable
|
||||
}
|
||||
return validateExternalFinanceClaimSnapshot(application)
|
||||
}
|
||||
if application.Status != model.WithdrawalApplicationStatusPending || claimStatus != model.WithdrawalExternalFinanceClaimStatusClaimed || claimDecision != decision || strings.TrimSpace(application.AuditCommandID) != commandID {
|
||||
return ErrWithdrawalApplicationStageNotReviewable
|
||||
}
|
||||
if reviewErr := withdrawalApplicationReviewError(application, WithdrawalApplicationReviewStageFinance); reviewErr != nil {
|
||||
return reviewErr
|
||||
}
|
||||
if err := validateExternalFinanceClaimSnapshot(application); err != nil {
|
||||
return err
|
||||
}
|
||||
finalizedStatus := model.WithdrawalExternalFinanceClaimStatusFinalized
|
||||
updates := map[string]any{
|
||||
"status": decision,
|
||||
"external_finance_claim_status": finalizedStatus,
|
||||
"audit_transaction_id": transactionID,
|
||||
"updated_at_ms": completedAtMS,
|
||||
}
|
||||
if err := tx.Model(&application).Updates(updates).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
application.Status = decision
|
||||
application.ExternalFinanceClaimStatus = &finalizedStatus
|
||||
application.AuditTransactionID = transactionID
|
||||
application.UpdatedAtMS = completedAtMS
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &application, nil
|
||||
}
|
||||
|
||||
func validateExternalFinanceClaimSnapshot(application model.UserWithdrawalApplication) error {
|
||||
decision := optionalWithdrawalString(application.ExternalFinanceClaimDecision)
|
||||
if application.ApproverUserID == nil || *application.ApproverUserID == 0 || strings.TrimSpace(application.ApproverName) == "" || application.ApprovedAtMS == nil || *application.ApprovedAtMS <= 0 || strings.TrimSpace(application.AuditCommandID) == "" {
|
||||
return errors.New("external withdrawal finance claim snapshot is incomplete")
|
||||
}
|
||||
if decision == model.WithdrawalApplicationStatusApproved && strings.TrimSpace(application.AuditImageURL) == "" {
|
||||
return errors.New("external withdrawal finance approval claim snapshot is incomplete")
|
||||
}
|
||||
if decision == model.WithdrawalApplicationStatusRejected && strings.TrimSpace(application.AuditRemark) == "" {
|
||||
return errors.New("external withdrawal finance rejection claim snapshot is incomplete")
|
||||
}
|
||||
if decision != model.WithdrawalApplicationStatusApproved && decision != model.WithdrawalApplicationStatusRejected {
|
||||
return errors.New("external withdrawal finance claim decision is invalid")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func optionalWithdrawalString(value *string) string {
|
||||
if value == nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(*value)
|
||||
}
|
||||
|
||||
func isExternalWithdrawalSource(application model.UserWithdrawalApplication) bool {
|
||||
sourceSystem := strings.ToLower(strings.TrimSpace(application.SourceSystem))
|
||||
return sourceSystem != "" && sourceSystem != "hyapp_wallet"
|
||||
}
|
||||
|
||||
// ReviewWithdrawalApplicationForApp 把普通阶段校验、跨服务资金/通知动作和本地状态更新收敛在同一把申请行锁内。
|
||||
// 提现审核是低 QPS、高价值资金边界;这里有意在钱包和 inbox 网络调用期间持有行锁,让运营通过及财务通过/拒绝严格串行。
|
||||
// 外部动作必须使用阶段固定的幂等 command/event id:事务回滚后重试不会重复扣款或通知,而相反决策会以同一 command id 的请求哈希冲突失败。
|
||||
@ -321,6 +547,10 @@ func (s *Store) ReviewWithdrawalApplicationForApp(appCode string, id uint, input
|
||||
if err := query.First(&application, id).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if input.Stage == WithdrawalApplicationReviewStageFinance && isExternalWithdrawalSource(application) {
|
||||
// 外部回调无法参与 Admin DB 事务,禁止绕过持久 claim 重新引入“回调成功、本地回滚”窗口。
|
||||
return errors.New("external withdrawal finance review must use durable claim")
|
||||
}
|
||||
if reviewErr := withdrawalApplicationReviewError(application, input.Stage); reviewErr != nil {
|
||||
return reviewErr
|
||||
}
|
||||
@ -424,8 +654,8 @@ func applyWithdrawalApplicationReview(application *model.UserWithdrawalApplicati
|
||||
application.ApprovedAtMS = &input.ApprovedAtMS
|
||||
}
|
||||
|
||||
// ApprovedWithdrawalStats 按审批通过时间聚合用户提现申请的 USDT 金额。
|
||||
// 旧工资提现用 withdraw_amount_minor 保存美分;Huwaa POINT 提现该字段保存 gross points,因此必须用 withdraw_amount 的 USD 展示值折算。
|
||||
// ApprovedWithdrawalStats 按审批通过时间聚合用户提现的实际 USDT 打款金额。
|
||||
// 外部来源优先使用申请时固化的 actual_amount_minor;无快照旧单才回退 gross 口径,POINT 旧单仍需从 USD 展示值折算。
|
||||
type ApprovedWithdrawalStats struct {
|
||||
ApprovedCount int64
|
||||
ApprovedUSDMinor int64
|
||||
@ -447,6 +677,7 @@ func (s *Store) ApprovedWithdrawalStats(appCode string, startAtMS int64, endAtMS
|
||||
row := query.Select(`
|
||||
COUNT(*),
|
||||
COALESCE(SUM(CASE
|
||||
WHEN actual_amount_minor IS NOT NULL THEN actual_amount_minor
|
||||
WHEN salary_asset_type IN ('POINT', 'COIN_SELLER_POINT') THEN CAST(withdraw_amount * 100 AS SIGNED)
|
||||
ELSE withdraw_amount_minor
|
||||
END), 0)`).Row()
|
||||
|
||||
@ -65,6 +65,78 @@ func TestListWithdrawalApplicationsFiltersFinanceStageAndSorts(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSameOptionalDecimalTreatsEquivalentMySQLScaleAsReplay(t *testing.T) {
|
||||
t.Parallel()
|
||||
existing := "84.50"
|
||||
replay := "84.5"
|
||||
if !sameOptionalDecimal(&existing, &replay) {
|
||||
t.Fatal("equivalent decimal scales must remain idempotent after MySQL DECIMAL normalization")
|
||||
}
|
||||
changed := "84.51"
|
||||
if sameOptionalDecimal(&existing, &changed) {
|
||||
t.Fatal("different balance snapshot must be rejected as a conflicting replay")
|
||||
}
|
||||
if !sameOptionalDecimal(nil, &changed) {
|
||||
t.Fatal("legacy row without balance snapshot must remain replay compatible")
|
||||
}
|
||||
existingMinor, replayMinor, changedMinor := int64(6700), int64(6700), int64(6699)
|
||||
if !sameOptionalInt64(&existingMinor, &replayMinor) || sameOptionalInt64(&existingMinor, &changedMinor) || !sameOptionalInt64(nil, &changedMinor) {
|
||||
t.Fatal("optional minor-unit replay comparison must preserve legacy nil and reject changed persisted facts")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateExternalWithdrawalReplayProtectsPayoutSnapshot(t *testing.T) {
|
||||
t.Parallel()
|
||||
ratio, charge, actual, actualMinor := "20.0000", "17.00", "67.00", int64(6700)
|
||||
existing := model.UserWithdrawalApplication{
|
||||
UserID: "10001", SalaryAssetType: "SALARY", WithdrawAmountMinor: 8400,
|
||||
ServiceChargeRatio: &ratio, ServiceCharge: &charge, ActualAmount: &actual, ActualAmountMinor: &actualMinor,
|
||||
WithdrawMethod: "usdt_trc20", WithdrawAddress: "T-address",
|
||||
}
|
||||
ratioReplay, chargeReplay, actualReplay, actualMinorReplay := "20", "17", "67", int64(6700)
|
||||
replay := existing
|
||||
replay.ServiceChargeRatio = &ratioReplay
|
||||
replay.ServiceCharge = &chargeReplay
|
||||
replay.ActualAmount = &actualReplay
|
||||
replay.ActualAmountMinor = &actualMinorReplay
|
||||
if err := validateExternalWithdrawalReplay(existing, replay); err != nil {
|
||||
t.Fatalf("equivalent MySQL decimal scales must remain idempotent: %v", err)
|
||||
}
|
||||
changedActual := "66.99"
|
||||
replay.ActualAmount = &changedActual
|
||||
if err := validateExternalWithdrawalReplay(existing, replay); err == nil {
|
||||
t.Fatal("changed payout snapshot must conflict with the persisted source application")
|
||||
}
|
||||
legacy := existing
|
||||
legacy.ServiceChargeRatio = nil
|
||||
legacy.ServiceCharge = nil
|
||||
legacy.ActualAmount = nil
|
||||
legacy.ActualAmountMinor = nil
|
||||
replay.ActualAmount = &actualReplay
|
||||
if err := validateExternalWithdrawalReplay(legacy, replay); err != nil {
|
||||
t.Fatalf("legacy row without payout columns must remain replay compatible: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApprovedWithdrawalStatsPrefersActualPayoutSnapshot(t *testing.T) {
|
||||
store, mock, closeStore := newRepositorySQLMock(t)
|
||||
defer closeStore()
|
||||
|
||||
mock.ExpectQuery("(?s)SELECT.*WHEN actual_amount_minor IS NOT NULL THEN actual_amount_minor.*FROM `admin_user_withdrawal_applications`.*status = \\?.*app_code = \\?.*approved_at_ms >= \\?.*approved_at_ms < \\?").
|
||||
WithArgs(model.WithdrawalApplicationStatusApproved, "yumi", int64(1700000000000), int64(1700086400000)).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"approved_count", "approved_usd_minor"}).AddRow(2, 6700))
|
||||
stats, err := store.ApprovedWithdrawalStats(" yumi ", 1700000000000, 1700086400000)
|
||||
if err != nil {
|
||||
t.Fatalf("aggregate approved withdrawal stats failed: %v", err)
|
||||
}
|
||||
if stats.ApprovedCount != 2 || stats.ApprovedUSDMinor != 6700 {
|
||||
t.Fatalf("approved withdrawal payout stats mismatch: %+v", stats)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatalf("sql expectations mismatch: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListWithdrawalApplicationsOperationsStageExcludesLegacySkipped(t *testing.T) {
|
||||
store, mock, closeStore := newRepositorySQLMock(t)
|
||||
defer closeStore()
|
||||
@ -224,6 +296,95 @@ func TestClaimWithdrawalOperationsRejectionRetryPreservesFirstAuditContext(t *te
|
||||
}
|
||||
}
|
||||
|
||||
func TestExternalFinanceClaimPersistsFirstDecisionAndRejectsOppositeRetry(t *testing.T) {
|
||||
store, mock, closeStore := newRepositorySQLMock(t)
|
||||
defer closeStore()
|
||||
|
||||
commandID := "salary-withdrawal:77:finance"
|
||||
firstImage := "https://example.com/first-proof.png"
|
||||
mock.ExpectBegin()
|
||||
mock.ExpectQuery("SELECT \\* FROM `admin_user_withdrawal_applications`.*FOR UPDATE").
|
||||
WithArgs("yumi", uint(77), 1).
|
||||
WillReturnRows(externalFinanceWithdrawalRows("", "", 0, "", "", "", "", ""))
|
||||
mock.ExpectExec("UPDATE `admin_user_withdrawal_applications` SET").WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectCommit()
|
||||
|
||||
claimed, err := store.ClaimExternalWithdrawalFinanceDecisionForApp("yumi", 77, WithdrawalApplicationAuditInput{
|
||||
Stage: WithdrawalApplicationReviewStageFinance, Decision: model.WithdrawalApplicationStatusApproved,
|
||||
ApproverUserID: 9, ApproverName: "first-finance", AuditRemark: "first remark", AuditImageURL: firstImage,
|
||||
AuditCommandID: commandID, ApprovedAtMS: 1700000300000,
|
||||
}, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("claim external finance decision failed: %v", err)
|
||||
}
|
||||
if claimed.ExternalFinanceClaimStatus == nil || *claimed.ExternalFinanceClaimStatus != model.WithdrawalExternalFinanceClaimStatusClaimed ||
|
||||
claimed.ExternalFinanceClaimDecision == nil || *claimed.ExternalFinanceClaimDecision != model.WithdrawalApplicationStatusApproved ||
|
||||
claimed.ApproverUserID == nil || *claimed.ApproverUserID != 9 || claimed.AuditImageURL != firstImage {
|
||||
t.Fatalf("external finance claim snapshot mismatch: %+v", claimed)
|
||||
}
|
||||
|
||||
// 同决策换人重试只返回第一次快照,不执行 UPDATE。
|
||||
mock.ExpectBegin()
|
||||
mock.ExpectQuery("SELECT \\* FROM `admin_user_withdrawal_applications`.*FOR UPDATE").
|
||||
WithArgs("yumi", uint(77), 1).
|
||||
WillReturnRows(externalFinanceWithdrawalRows(model.WithdrawalExternalFinanceClaimStatusClaimed, model.WithdrawalApplicationStatusApproved, 9, "first-finance", "first remark", firstImage, commandID, ""))
|
||||
mock.ExpectCommit()
|
||||
retried, err := store.ClaimExternalWithdrawalFinanceDecisionForApp("yumi", 77, WithdrawalApplicationAuditInput{
|
||||
Stage: WithdrawalApplicationReviewStageFinance, Decision: model.WithdrawalApplicationStatusApproved,
|
||||
ApproverUserID: 19, ApproverName: "retry-finance", AuditRemark: "changed remark", AuditImageURL: "https://example.com/changed.png",
|
||||
AuditCommandID: commandID, ApprovedAtMS: 1700000400000,
|
||||
}, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("retry external finance claim failed: %v", err)
|
||||
}
|
||||
if retried.ApproverUserID == nil || *retried.ApproverUserID != 9 || retried.ApproverName != "first-finance" || retried.AuditRemark != "first remark" || retried.AuditImageURL != firstImage {
|
||||
t.Fatalf("retry overwrote first external finance claim: %+v", retried)
|
||||
}
|
||||
|
||||
// 相反决策在来源回调之前拒绝,不允许复用同一 command 改写首次 claim。
|
||||
mock.ExpectBegin()
|
||||
mock.ExpectQuery("SELECT \\* FROM `admin_user_withdrawal_applications`.*FOR UPDATE").
|
||||
WithArgs("yumi", uint(77), 1).
|
||||
WillReturnRows(externalFinanceWithdrawalRows(model.WithdrawalExternalFinanceClaimStatusClaimed, model.WithdrawalApplicationStatusApproved, 9, "first-finance", "first remark", firstImage, commandID, ""))
|
||||
mock.ExpectRollback()
|
||||
_, err = store.ClaimExternalWithdrawalFinanceDecisionForApp("yumi", 77, WithdrawalApplicationAuditInput{
|
||||
Stage: WithdrawalApplicationReviewStageFinance, Decision: model.WithdrawalApplicationStatusRejected,
|
||||
ApproverUserID: 19, ApproverName: "retry-finance", AuditRemark: "reject instead",
|
||||
AuditCommandID: commandID, ApprovedAtMS: 1700000400000,
|
||||
}, nil)
|
||||
if !errors.Is(err, ErrWithdrawalApplicationStageNotReviewable) {
|
||||
t.Fatalf("opposite external finance decision must be blocked, got %v", err)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatalf("sql expectations mismatch: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFinalizeExternalFinanceDecisionUsesClaimedTerminalContext(t *testing.T) {
|
||||
store, mock, closeStore := newRepositorySQLMock(t)
|
||||
defer closeStore()
|
||||
|
||||
commandID := "salary-withdrawal:77:finance"
|
||||
transactionID := "external:" + commandID
|
||||
mock.ExpectBegin()
|
||||
mock.ExpectQuery("SELECT \\* FROM `admin_user_withdrawal_applications`.*FOR UPDATE").
|
||||
WithArgs("yumi", uint(77), 1).
|
||||
WillReturnRows(externalFinanceWithdrawalRows(model.WithdrawalExternalFinanceClaimStatusClaimed, model.WithdrawalApplicationStatusApproved, 9, "first-finance", "paid", "https://example.com/proof.png", commandID, ""))
|
||||
mock.ExpectExec("UPDATE `admin_user_withdrawal_applications` SET").WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectCommit()
|
||||
|
||||
item, err := store.FinalizeExternalWithdrawalFinanceDecisionForApp("yumi", 77, model.WithdrawalApplicationStatusApproved, commandID, transactionID, 1700000300001)
|
||||
if err != nil {
|
||||
t.Fatalf("finalize external finance decision failed: %v", err)
|
||||
}
|
||||
if item.Status != model.WithdrawalApplicationStatusApproved || item.ExternalFinanceClaimStatus == nil || *item.ExternalFinanceClaimStatus != model.WithdrawalExternalFinanceClaimStatusFinalized || item.AuditTransactionID != transactionID || item.ApproverUserID == nil || *item.ApproverUserID != 9 {
|
||||
t.Fatalf("external finance terminal snapshot mismatch: %+v", item)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatalf("sql expectations mismatch: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithdrawalOperationsRejectionClaimSerializesConcurrentFirstAndRetryRealMySQL(t *testing.T) {
|
||||
baseDSN := strings.TrimSpace(os.Getenv("WITHDRAWAL_REVIEW_MYSQL_TEST_DSN"))
|
||||
if baseDSN == "" {
|
||||
@ -495,3 +656,27 @@ func withdrawalApplicationRowsWithOperationsContext(status string, operationsSta
|
||||
int64(1700000000000), int64(1700000000000),
|
||||
)
|
||||
}
|
||||
|
||||
func externalFinanceWithdrawalRows(claimStatus string, claimDecision string, reviewerID uint, reviewerName string, remark string, imageURL string, commandID string, transactionID string) *sqlmock.Rows {
|
||||
var storedClaimStatus any
|
||||
var storedClaimDecision any
|
||||
var storedReviewerID any
|
||||
var approvedAtMS any
|
||||
if claimStatus != "" {
|
||||
storedClaimStatus = claimStatus
|
||||
storedClaimDecision = claimDecision
|
||||
storedReviewerID = reviewerID
|
||||
approvedAtMS = int64(1700000300000)
|
||||
}
|
||||
return sqlmock.NewRows([]string{
|
||||
"id", "app_code", "source_system", "source_application_id", "user_id", "salary_asset_type",
|
||||
"withdraw_amount", "withdraw_amount_minor", "withdraw_method", "withdraw_address", "status", "operations_status",
|
||||
"external_finance_claim_decision", "external_finance_claim_status", "approver_user_id", "approver_name",
|
||||
"audit_remark", "audit_image_url", "audit_command_id", "audit_transaction_id", "approved_at_ms", "created_at_ms", "updated_at_ms",
|
||||
}).AddRow(
|
||||
77, "yumi", "chatapp3", "88001", "42001", "SALARY", "84.00", int64(8400), "usdt_trc20", "TRON-address",
|
||||
model.WithdrawalApplicationStatusPending, model.WithdrawalOperationsStatusApproved,
|
||||
storedClaimDecision, storedClaimStatus, storedReviewerID, reviewerName, remark, imageURL, commandID, transactionID, approvedAtMS,
|
||||
int64(1700000000000), int64(1700000000000),
|
||||
)
|
||||
}
|
||||
|
||||
19
server/admin/migrations/108_withdrawal_balance_before.sql
Normal file
19
server/admin/migrations/108_withdrawal_balance_before.sql
Normal file
@ -0,0 +1,19 @@
|
||||
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- 提现前余额必须由来源钱包在申请时固化。NULL 明确表示历史申请没有可信快照,
|
||||
-- 后台不能用审核时的当前余额回填,否则充值、扣款或退款会让财务看到错误事实。
|
||||
SET @balance_before_exists = (
|
||||
SELECT COUNT(*)
|
||||
FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'admin_user_withdrawal_applications'
|
||||
AND COLUMN_NAME = 'balance_before'
|
||||
);
|
||||
SET @balance_before_ddl = IF(
|
||||
@balance_before_exists = 0,
|
||||
'ALTER TABLE admin_user_withdrawal_applications ADD COLUMN balance_before DECIMAL(18,2) NULL COMMENT ''来源钱包申请提现前可用余额快照'' AFTER withdraw_amount_minor, ALGORITHM=INSTANT',
|
||||
'SELECT 1'
|
||||
);
|
||||
PREPARE stmt FROM @balance_before_ddl;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
@ -0,0 +1,101 @@
|
||||
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- 外部来源必须把申请时的费率、手续费和实际打款额作为不可变事实提交。
|
||||
-- 四列均允许 NULL 兼容历史申请;迁移只做 INSTANT 元数据变更,不建索引、不扫描或回填提现大表。
|
||||
SET @service_charge_ratio_exists = (
|
||||
SELECT COUNT(*)
|
||||
FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'admin_user_withdrawal_applications'
|
||||
AND COLUMN_NAME = 'service_charge_ratio'
|
||||
);
|
||||
SET @service_charge_ratio_ddl = IF(
|
||||
@service_charge_ratio_exists = 0,
|
||||
'ALTER TABLE admin_user_withdrawal_applications ADD COLUMN service_charge_ratio DECIMAL(9,4) NULL COMMENT ''来源申请时手续费率快照,单位为百分比点'' AFTER balance_before, ALGORITHM=INSTANT',
|
||||
'SELECT 1'
|
||||
);
|
||||
PREPARE stmt FROM @service_charge_ratio_ddl;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
SET @service_charge_exists = (
|
||||
SELECT COUNT(*)
|
||||
FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'admin_user_withdrawal_applications'
|
||||
AND COLUMN_NAME = 'service_charge'
|
||||
);
|
||||
SET @service_charge_ddl = IF(
|
||||
@service_charge_exists = 0,
|
||||
'ALTER TABLE admin_user_withdrawal_applications ADD COLUMN service_charge DECIMAL(18,2) NULL COMMENT ''来源申请时手续费金额快照,单位为 USD'' AFTER service_charge_ratio, ALGORITHM=INSTANT',
|
||||
'SELECT 1'
|
||||
);
|
||||
PREPARE stmt FROM @service_charge_ddl;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
SET @actual_amount_exists = (
|
||||
SELECT COUNT(*)
|
||||
FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'admin_user_withdrawal_applications'
|
||||
AND COLUMN_NAME = 'actual_amount'
|
||||
);
|
||||
SET @actual_amount_ddl = IF(
|
||||
@actual_amount_exists = 0,
|
||||
'ALTER TABLE admin_user_withdrawal_applications ADD COLUMN actual_amount DECIMAL(18,2) NULL COMMENT ''来源申请时实际打款金额快照,单位为 USD'' AFTER service_charge, ALGORITHM=INSTANT',
|
||||
'SELECT 1'
|
||||
);
|
||||
PREPARE stmt FROM @actual_amount_ddl;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
SET @actual_amount_minor_exists = (
|
||||
SELECT COUNT(*)
|
||||
FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'admin_user_withdrawal_applications'
|
||||
AND COLUMN_NAME = 'actual_amount_minor'
|
||||
);
|
||||
SET @actual_amount_minor_ddl = IF(
|
||||
@actual_amount_minor_exists = 0,
|
||||
'ALTER TABLE admin_user_withdrawal_applications ADD COLUMN actual_amount_minor BIGINT NULL COMMENT ''来源申请时实际打款金额最小单位快照,USD 为美分'' AFTER actual_amount, ALGORITHM=INSTANT',
|
||||
'SELECT 1'
|
||||
);
|
||||
PREPARE stmt FROM @actual_amount_minor_ddl;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
-- 外部来源财务回调不能与 Admin 本地终态共用一个事务:回调 2xx 后若本地提交失败,
|
||||
-- nullable claim 字段保存第一次决策边界;审核人、备注、凭证、command 和时间复用现有财务审计列。
|
||||
SET @external_finance_claim_decision_exists = (
|
||||
SELECT COUNT(*)
|
||||
FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'admin_user_withdrawal_applications'
|
||||
AND COLUMN_NAME = 'external_finance_claim_decision'
|
||||
);
|
||||
SET @external_finance_claim_decision_ddl = IF(
|
||||
@external_finance_claim_decision_exists = 0,
|
||||
'ALTER TABLE admin_user_withdrawal_applications ADD COLUMN external_finance_claim_decision VARCHAR(16) NULL COMMENT ''外部来源财务首次决策 claim'' AFTER actual_amount_minor, ALGORITHM=INSTANT',
|
||||
'SELECT 1'
|
||||
);
|
||||
PREPARE stmt FROM @external_finance_claim_decision_ddl;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
SET @external_finance_claim_status_exists = (
|
||||
SELECT COUNT(*)
|
||||
FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'admin_user_withdrawal_applications'
|
||||
AND COLUMN_NAME = 'external_finance_claim_status'
|
||||
);
|
||||
SET @external_finance_claim_status_ddl = IF(
|
||||
@external_finance_claim_status_exists = 0,
|
||||
'ALTER TABLE admin_user_withdrawal_applications ADD COLUMN external_finance_claim_status VARCHAR(16) NULL COMMENT ''外部来源财务 claim 状态:claimed/finalized'' AFTER external_finance_claim_decision, ALGORITHM=INSTANT',
|
||||
'SELECT 1'
|
||||
);
|
||||
PREPARE stmt FROM @external_finance_claim_status_ddl;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
Loading…
x
Reference in New Issue
Block a user