From d4a9fbec01e263290ff7b368e6a4ededfce78cc4 Mon Sep 17 00:00:00 2001 From: zhx Date: Mon, 20 Jul 2026 15:48:08 +0800 Subject: [PATCH] feat(admin): review ChatApp3 withdrawals --- .../admin/configs/config.tencent.example.yaml | 9 +- server/admin/configs/config.yaml | 11 +- server/admin/internal/config/config.go | 6 + server/admin/internal/config/config_test.go | 28 ++ server/admin/internal/model/models.go | 80 +++--- .../financewithdrawal/dingtalk_notifier.go | 20 +- .../modules/financewithdrawal/handler.go | 10 + .../modules/financewithdrawal/response.go | 80 +++--- .../modules/financewithdrawal/service.go | 210 ++++++++++++++- .../modules/financewithdrawal/service_test.go | 195 ++++++++++++++ .../withdrawal_application_repository.go | 253 +++++++++++++++++- .../withdrawal_application_repository_test.go | 185 +++++++++++++ .../108_withdrawal_balance_before.sql | 19 ++ ...09_external_withdrawal_payout_snapshot.sql | 101 +++++++ 14 files changed, 1121 insertions(+), 86 deletions(-) create mode 100644 server/admin/migrations/108_withdrawal_balance_before.sql create mode 100644 server/admin/migrations/109_external_withdrawal_payout_snapshot.sql diff --git a/server/admin/configs/config.tencent.example.yaml b/server/admin/configs/config.tencent.example.yaml index a1419ef0..bd8ac2e4 100644 --- a/server/admin/configs/config.tencent.example.yaml +++ b/server/admin/configs/config.tencent.example.yaml @@ -69,8 +69,15 @@ finance_google_paid_sync: poll_interval: "15s" batch_size: 100 request_timeout: "2m" -# 生产通过 HYAPP_ADMIN_ASLAN_WITHDRAWAL_* 注入密钥、回调地址并开启。 +# 生产按 App 通过 HYAPP_ADMIN__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" diff --git a/server/admin/configs/config.yaml b/server/admin/configs/config.yaml index 1663283c..70352077 100644 --- a/server/admin/configs/config.yaml +++ b/server/admin/configs/config.yaml @@ -67,9 +67,16 @@ finance_google_paid_sync: poll_interval: "15s" batch_size: 100 request_timeout: "2m" -# 外部钱包统一提现双审来源。生产密钥和回调地址只从 -# HYAPP_ADMIN_ASLAN_WITHDRAWAL_* 环境变量注入,仓库不保存资金接口凭证。 +# 外部钱包统一提现双审来源。生产密钥和回调地址只从对应 App 的 +# HYAPP_ADMIN__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" diff --git a/server/admin/internal/config/config.go b/server/admin/internal/config/config.go index 65f6da7d..79e21871 100644 --- a/server/admin/internal/config/config.go +++ b/server/admin/internal/config/config.go @@ -432,6 +432,12 @@ func Default() Config { }, }, WithdrawalSources: []WithdrawalSourceConfig{ + { + Enabled: false, + AppCode: "yumi", + SourceSystem: "chatapp3", + RequestTimeout: 5 * time.Second, + }, { Enabled: false, AppCode: "aslan", diff --git a/server/admin/internal/config/config_test.go b/server/admin/internal/config/config_test.go index e81c724e..88faa235 100644 --- a/server/admin/internal/config/config_test.go +++ b/server/admin/internal/config/config_test.go @@ -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") { diff --git a/server/admin/internal/model/models.go b/server/admin/internal/model/models.go index 31762f82..81a18593 100644 --- a/server/admin/internal/model/models.go +++ b/server/admin/internal/model/models.go @@ -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 { diff --git a/server/admin/internal/modules/financewithdrawal/dingtalk_notifier.go b/server/admin/internal/modules/financewithdrawal/dingtalk_notifier.go index e161d677..87df92c7 100644 --- a/server/admin/internal/modules/financewithdrawal/dingtalk_notifier.go +++ b/server/admin/internal/modules/financewithdrawal/dingtalk_notifier.go @@ -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 "-" diff --git a/server/admin/internal/modules/financewithdrawal/handler.go b/server/admin/internal/modules/financewithdrawal/handler.go index 203beb44..56c7117f 100644 --- a/server/admin/internal/modules/financewithdrawal/handler.go +++ b/server/admin/internal/modules/financewithdrawal/handler.go @@ -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, diff --git a/server/admin/internal/modules/financewithdrawal/response.go b/server/admin/internal/modules/financewithdrawal/response.go index c4aca4eb..36cbc15d 100644 --- a/server/admin/internal/modules/financewithdrawal/response.go +++ b/server/admin/internal/modules/financewithdrawal/response.go @@ -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, diff --git a/server/admin/internal/modules/financewithdrawal/service.go b/server/admin/internal/modules/financewithdrawal/service.go index 8d15856a..2d8f817d 100644 --- a/server/admin/internal/modules/financewithdrawal/service.go +++ b/server/admin/internal/modules/financewithdrawal/service.go @@ -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) } diff --git a/server/admin/internal/modules/financewithdrawal/service_test.go b/server/admin/internal/modules/financewithdrawal/service_test.go index 12e12fe6..25947ebc 100644 --- a/server/admin/internal/modules/financewithdrawal/service_test.go +++ b/server/admin/internal/modules/financewithdrawal/service_test.go @@ -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), + ) +} diff --git a/server/admin/internal/repository/withdrawal_application_repository.go b/server/admin/internal/repository/withdrawal_application_repository.go index c5adf8ee..017f6ad6 100644 --- a/server/admin/internal/repository/withdrawal_application_repository.go +++ b/server/admin/internal/repository/withdrawal_application_repository.go @@ -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() diff --git a/server/admin/internal/repository/withdrawal_application_repository_test.go b/server/admin/internal/repository/withdrawal_application_repository_test.go index 830c93fe..4782c4eb 100644 --- a/server/admin/internal/repository/withdrawal_application_repository_test.go +++ b/server/admin/internal/repository/withdrawal_application_repository_test.go @@ -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), + ) +} diff --git a/server/admin/migrations/108_withdrawal_balance_before.sql b/server/admin/migrations/108_withdrawal_balance_before.sql new file mode 100644 index 00000000..5fce77dd --- /dev/null +++ b/server/admin/migrations/108_withdrawal_balance_before.sql @@ -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; diff --git a/server/admin/migrations/109_external_withdrawal_payout_snapshot.sql b/server/admin/migrations/109_external_withdrawal_payout_snapshot.sql new file mode 100644 index 00000000..03d5d124 --- /dev/null +++ b/server/admin/migrations/109_external_withdrawal_payout_snapshot.sql @@ -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;