651 lines
33 KiB
Go
651 lines
33 KiB
Go
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"
|
||
activityv1 "hyapp.local/api/proto/activity/v1"
|
||
walletv1 "hyapp.local/api/proto/wallet/v1"
|
||
|
||
"github.com/DATA-DOG/go-sqlmock"
|
||
"gorm.io/driver/mysql"
|
||
"gorm.io/gorm"
|
||
)
|
||
|
||
func TestOperationsApprovalMovesToFinanceWithoutWalletOrUserNotice(t *testing.T) {
|
||
svc, mock, wallet, activity, closeService := newWithdrawalAuditServiceTest(t)
|
||
defer closeService()
|
||
expectWithdrawalMoneyAccess(mock, 8, "lalu")
|
||
expectWithdrawalReviewLock(mock, model.WithdrawalApplicationStatusPending, model.WithdrawalOperationsStatusPending)
|
||
mock.ExpectExec("UPDATE `admin_user_withdrawal_applications` SET").WillReturnResult(sqlmock.NewResult(0, 1))
|
||
mock.ExpectCommit()
|
||
|
||
dto, err := svc.ApproveOperationsApplication(appctx.WithContext(context.Background(), "lalu"), shared.Actor{
|
||
UserID: 8, Username: "operations", Permissions: []string{permissionAuditOperationsWithdrawals},
|
||
}, 77, "资料无误", "request-ops-approve")
|
||
if err != nil {
|
||
t.Fatalf("approve operations withdrawal failed: %v", err)
|
||
}
|
||
if dto.Status != model.WithdrawalApplicationStatusPending || dto.OperationsStatus != model.WithdrawalOperationsStatusApproved || dto.OperationsReviewerName != "operations" {
|
||
t.Fatalf("operations approval state mismatch: %+v", dto)
|
||
}
|
||
if wallet.settleSalary != nil || wallet.releaseSalary != nil || wallet.settlePoint != nil || wallet.releasePoint != nil || activity.calls != 0 {
|
||
t.Fatalf("operations approval must not touch wallet or send final notice: wallet=%+v notice_calls=%d", wallet, activity.calls)
|
||
}
|
||
if err := mock.ExpectationsWereMet(); err != nil {
|
||
t.Fatalf("sql expectations mismatch: %v", err)
|
||
}
|
||
}
|
||
|
||
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()
|
||
expectWithdrawalMoneyAccess(mock, 8, "lalu")
|
||
expectWithdrawalRejectionClaim(mock, model.WithdrawalOperationsStatusPending)
|
||
expectWithdrawalRejectionFinalize(mock)
|
||
|
||
dto, err := svc.RejectOperationsApplication(appctx.WithContext(context.Background(), "lalu"), shared.Actor{
|
||
UserID: 8, Username: "operations", Permissions: []string{permissionAuditOperationsWithdrawals},
|
||
}, 77, "收款资料不符", "request-ops-reject")
|
||
if err != nil {
|
||
t.Fatalf("reject operations withdrawal failed: %v", err)
|
||
}
|
||
if wallet.releaseSalary == nil || wallet.releaseSalary.GetCommandId() != "salary-withdrawal:77:operations" || wallet.releaseSalary.GetSalaryUsdMinor() != 5000 {
|
||
t.Fatalf("operations rejection release mismatch: %+v", wallet.releaseSalary)
|
||
}
|
||
if wallet.settleSalary != nil || wallet.settlePoint != nil || wallet.releasePoint != nil {
|
||
t.Fatalf("operations rejection must only release matching salary asset: %+v", wallet)
|
||
}
|
||
if activity.calls != 1 || activity.last.GetProducerEventId() != "operations-withdrawal:77:rejected" || activity.last.GetProducerEventType() != "operations_withdrawal_rejected" {
|
||
t.Fatalf("operations rejection final notice mismatch: calls=%d request=%+v", activity.calls, activity.last)
|
||
}
|
||
if dto.Status != model.WithdrawalApplicationStatusRejected || dto.OperationsStatus != model.WithdrawalOperationsStatusRejected || dto.OperationsAuditTransactionID != "salary-release-tx" {
|
||
t.Fatalf("operations rejection state mismatch: %+v", dto)
|
||
}
|
||
if err := mock.ExpectationsWereMet(); err != nil {
|
||
t.Fatalf("sql expectations mismatch: %v", err)
|
||
}
|
||
}
|
||
|
||
func TestOperationsRejectionNoticeFailureStaysRejectingAndRetryKeepsFirstAuditContext(t *testing.T) {
|
||
svc, mock, wallet, activity, closeService := newWithdrawalAuditServiceTest(t)
|
||
defer closeService()
|
||
noticeFailure := errors.New("activity unavailable")
|
||
activity.failures = []error{noticeFailure, nil}
|
||
|
||
expectWithdrawalMoneyAccess(mock, 8, "lalu")
|
||
expectWithdrawalRejectionClaim(mock, model.WithdrawalOperationsStatusPending)
|
||
_, err := svc.RejectOperationsApplication(appctx.WithContext(context.Background(), "lalu"), shared.Actor{
|
||
UserID: 8, Username: "first-operator", Permissions: []string{permissionAuditOperationsWithdrawals},
|
||
}, 77, "first rejection reason", "request-ops-reject-first")
|
||
if !errors.Is(err, noticeFailure) {
|
||
t.Fatalf("first rejection must surface notice failure, got %v", err)
|
||
}
|
||
|
||
// 第二个运营人员重试时只能沿用第一次 claim 快照;数据库 rejecting 行禁止改点通过,也不允许改写拒绝原因。
|
||
expectWithdrawalMoneyAccess(mock, 18, "lalu")
|
||
expectWithdrawalRejectionRetryClaim(mock, 8, "first-operator", "first rejection reason")
|
||
expectWithdrawalRejectionFinalizeWithContext(mock, 8, "first-operator", "first rejection reason")
|
||
dto, err := svc.RejectOperationsApplication(appctx.WithContext(context.Background(), "lalu"), shared.Actor{
|
||
UserID: 18, Username: "retry-operator", Permissions: []string{permissionAuditOperationsWithdrawals},
|
||
}, 77, "changed retry reason", "request-ops-reject-retry")
|
||
if err != nil {
|
||
t.Fatalf("retry rejecting withdrawal failed: %v", err)
|
||
}
|
||
if dto.OperationsStatus != model.WithdrawalOperationsStatusRejected || wallet.releaseSalary.GetOperatorUserId() != 8 || wallet.releaseSalary.GetReason() != "first rejection reason" {
|
||
t.Fatalf("retry must preserve first claim context: dto=%+v wallet=%+v", dto, wallet.releaseSalary)
|
||
}
|
||
if activity.calls != 2 || activity.last == nil || activity.last.GetBody() != "Your withdrawal request has been rejected. Reason: first rejection reason" {
|
||
t.Fatalf("retry notice must preserve first rejection reason: calls=%d request=%+v", activity.calls, activity.last)
|
||
}
|
||
if err := mock.ExpectationsWereMet(); err != nil {
|
||
t.Fatalf("sql expectations mismatch: %v", err)
|
||
}
|
||
}
|
||
|
||
func TestOperationsApprovalRejectsDurableRejectingClaimBeforeWalletOrNotice(t *testing.T) {
|
||
svc, mock, wallet, activity, closeService := newWithdrawalAuditServiceTest(t)
|
||
defer closeService()
|
||
expectWithdrawalMoneyAccess(mock, 18, "lalu")
|
||
mock.ExpectBegin()
|
||
mock.ExpectQuery("SELECT \\* FROM `admin_user_withdrawal_applications`.*FOR UPDATE").
|
||
WithArgs("lalu", uint(77), 1).
|
||
WillReturnRows(withdrawalAuditRowsWithOperationsContext(model.WithdrawalApplicationStatusPending, model.WithdrawalOperationsStatusRejecting, 8, "first-operator", "first rejection reason"))
|
||
mock.ExpectRollback()
|
||
|
||
_, err := svc.ApproveOperationsApplication(appctx.WithContext(context.Background(), "lalu"), shared.Actor{
|
||
UserID: 18, Username: "retry-operator", Permissions: []string{permissionAuditOperationsWithdrawals},
|
||
}, 77, "改为通过", "request-ops-approve-after-reject")
|
||
if !errors.Is(err, repository.ErrWithdrawalApplicationStageNotReviewable) {
|
||
t.Fatalf("rejecting claim must block operations approval, got %v", err)
|
||
}
|
||
if wallet.settleSalary != nil || wallet.releaseSalary != nil || wallet.settlePoint != nil || wallet.releasePoint != nil || activity.calls != 0 {
|
||
t.Fatalf("rejecting gate must run before integrations: wallet=%+v notices=%d", wallet, activity.calls)
|
||
}
|
||
if err := mock.ExpectationsWereMet(); err != nil {
|
||
t.Fatalf("sql expectations mismatch: %v", err)
|
||
}
|
||
}
|
||
|
||
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).
|
||
WillReturnRows(withdrawalAuditRows(model.WithdrawalApplicationStatusPending, model.WithdrawalOperationsStatusPending))
|
||
mock.ExpectRollback()
|
||
|
||
_, err := svc.ApproveApplication(appctx.WithContext(context.Background(), "lalu"), shared.Actor{
|
||
UserID: 9, Username: "finance", Permissions: []string{permissionAuditWithdrawalApplications},
|
||
}, 77, "已打款", "https://example.com/proof.png", "request-finance-approve")
|
||
if !errors.Is(err, repository.ErrWithdrawalApplicationStageNotReviewable) {
|
||
t.Fatalf("finance must reject operations-pending application, got %v", err)
|
||
}
|
||
if wallet.settleSalary != nil || wallet.releaseSalary != nil || wallet.settlePoint != nil || wallet.releasePoint != nil || activity.calls != 0 {
|
||
t.Fatalf("stage gate must run before wallet or notice: wallet=%+v notice_calls=%d", wallet, activity.calls)
|
||
}
|
||
if err := mock.ExpectationsWereMet(); err != nil {
|
||
t.Fatalf("sql expectations mismatch: %v", err)
|
||
}
|
||
}
|
||
|
||
func TestOperationsListRejectsExplicitAppOutsideMoneyScope(t *testing.T) {
|
||
svc, mock, wallet, activity, closeService := newWithdrawalAuditServiceTest(t)
|
||
defer closeService()
|
||
expectWithdrawalMoneyAccess(mock, 8, "lalu")
|
||
|
||
_, _, err := svc.ListOperationsApplications(shared.Actor{UserID: 8}, repository.WithdrawalApplicationListOptions{AppCode: "huwaa"})
|
||
if !errors.Is(err, errWithdrawalMoneyScopeForbidden) {
|
||
t.Fatalf("out-of-scope list must be forbidden, got %v", err)
|
||
}
|
||
if wallet.settleSalary != nil || wallet.releaseSalary != nil || activity.calls != 0 {
|
||
t.Fatalf("out-of-scope list must not call integrations: wallet=%+v notices=%d", wallet, activity.calls)
|
||
}
|
||
if err := mock.ExpectationsWereMet(); err != nil {
|
||
t.Fatalf("sql expectations mismatch: %v", err)
|
||
}
|
||
}
|
||
|
||
func TestOperationsListWithoutAppRestrictsToMoneyScopeApps(t *testing.T) {
|
||
svc, mock, _, _, closeService := newWithdrawalAuditServiceTest(t)
|
||
defer closeService()
|
||
expectWithdrawalMoneyAccess(mock, 8, "lalu", "fami")
|
||
mock.ExpectQuery("SELECT count\\(\\*\\) FROM `admin_user_withdrawal_applications`.*app_code IN.*operations_status <> \\?").
|
||
WithArgs("fami", "lalu", model.WithdrawalOperationsStatusSkipped).
|
||
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(0))
|
||
mock.ExpectQuery("SELECT \\* FROM `admin_user_withdrawal_applications`.*app_code IN.*operations_status <> \\?").
|
||
WithArgs("fami", "lalu", model.WithdrawalOperationsStatusSkipped, 20).
|
||
WillReturnRows(sqlmock.NewRows([]string{"id"}))
|
||
|
||
items, total, err := svc.ListOperationsApplications(shared.Actor{UserID: 8}, repository.WithdrawalApplicationListOptions{Page: 1, PageSize: 20})
|
||
if err != nil || total != 0 || len(items) != 0 {
|
||
t.Fatalf("money-scoped operations list mismatch: total=%d items=%+v err=%v", total, items, err)
|
||
}
|
||
if err := mock.ExpectationsWereMet(); err != nil {
|
||
t.Fatalf("sql expectations mismatch: %v", err)
|
||
}
|
||
}
|
||
|
||
func TestFinanceAuditRejectsHeaderAppOutsideMoneyScopeBeforeWallet(t *testing.T) {
|
||
svc, mock, wallet, activity, closeService := newWithdrawalAuditServiceTest(t)
|
||
defer closeService()
|
||
expectWithdrawalMoneyAccess(mock, 9, "fami")
|
||
|
||
_, err := svc.ApproveApplication(appctx.WithContext(context.Background(), "lalu"), shared.Actor{
|
||
UserID: 9, Username: "finance", Permissions: []string{permissionAuditWithdrawalApplications},
|
||
}, 77, "paid", "https://example.com/proof.png", "request-out-of-scope")
|
||
if !errors.Is(err, errWithdrawalMoneyScopeForbidden) {
|
||
t.Fatalf("out-of-scope audit must be forbidden, got %v", err)
|
||
}
|
||
if wallet.settleSalary != nil || wallet.releaseSalary != nil || wallet.settlePoint != nil || wallet.releasePoint != nil || activity.calls != 0 {
|
||
t.Fatalf("money scope gate must run before row lock, wallet, or notice: wallet=%+v notices=%d", wallet, activity.calls)
|
||
}
|
||
if err := mock.ExpectationsWereMet(); err != nil {
|
||
t.Fatalf("sql expectations mismatch: %v", err)
|
||
}
|
||
}
|
||
|
||
func TestWithdrawalAuditNotificationEventIDPreservesFinanceCompatibility(t *testing.T) {
|
||
t.Parallel()
|
||
if got := withdrawalAuditNotificationEventID(77, repository.WithdrawalApplicationReviewStageFinance, model.WithdrawalApplicationStatusApproved); got != "finance-withdrawal:77:approved" {
|
||
t.Fatalf("finance event id = %q", got)
|
||
}
|
||
if got := withdrawalAuditNotificationEventID(77, repository.WithdrawalApplicationReviewStageOperations, model.WithdrawalApplicationStatusRejected); got != "operations-withdrawal:77:rejected" {
|
||
t.Fatalf("operations event id = %q", got)
|
||
}
|
||
}
|
||
|
||
func TestApplyWalletDecisionRoutesPointApprovalToPointSettlement(t *testing.T) {
|
||
wallet := &fakeAuditWalletClient{}
|
||
svc := &Service{wallet: wallet}
|
||
|
||
transactionID, err := svc.applyWalletDecision(context.Background(), model.WithdrawalApplicationStatusApproved, "audit:77:approved", "fami", 42001, "POINT", 1_000_000, 25_000, 975_000, 200_000, 250, shared.Actor{UserID: 90001}, "paid", 77)
|
||
if err != nil {
|
||
t.Fatalf("apply point approval failed: %v", err)
|
||
}
|
||
if transactionID != "point-settle-tx" {
|
||
t.Fatalf("point approval transaction id mismatch: %s", transactionID)
|
||
}
|
||
if wallet.settlePoint == nil ||
|
||
wallet.settlePoint.GetCommandId() != "audit:77:approved" ||
|
||
wallet.settlePoint.GetAppCode() != "fami" ||
|
||
wallet.settlePoint.GetUserId() != 42001 ||
|
||
wallet.settlePoint.GetAssetType() != "POINT" ||
|
||
wallet.settlePoint.GetGrossPointAmount() != 1_000_000 ||
|
||
wallet.settlePoint.GetFeePointAmount() != 25_000 ||
|
||
wallet.settlePoint.GetNetPointAmount() != 975_000 ||
|
||
wallet.settlePoint.GetPointsPerUsd() != 200_000 ||
|
||
wallet.settlePoint.GetFeeBps() != 250 ||
|
||
wallet.settlePoint.GetOperatorUserId() != 90001 ||
|
||
wallet.settlePoint.GetWithdrawalApplicationId() != "77" {
|
||
t.Fatalf("SettlePointWithdrawal payload mismatch: %+v", wallet.settlePoint)
|
||
}
|
||
if wallet.settleSalary != nil || wallet.releasePoint != nil || wallet.releaseSalary != nil {
|
||
t.Fatalf("point approval must not call legacy salary or release RPCs: wallet=%+v", wallet)
|
||
}
|
||
}
|
||
|
||
func TestApplyWalletDecisionRoutesPointRejectionToPointRelease(t *testing.T) {
|
||
wallet := &fakeAuditWalletClient{}
|
||
svc := &Service{wallet: wallet}
|
||
|
||
transactionID, err := svc.applyWalletDecision(context.Background(), model.WithdrawalApplicationStatusRejected, "audit:78:rejected", "huwaa", 42002, "COIN_SELLER_POINT", 2_000_000, 100_000, 1_900_000, 100_000, 500, shared.Actor{UserID: 90002}, "bad address", 78)
|
||
if err != nil {
|
||
t.Fatalf("apply point rejection failed: %v", err)
|
||
}
|
||
if transactionID != "point-release-tx" {
|
||
t.Fatalf("point rejection transaction id mismatch: %s", transactionID)
|
||
}
|
||
if wallet.releasePoint == nil ||
|
||
wallet.releasePoint.GetCommandId() != "audit:78:rejected" ||
|
||
wallet.releasePoint.GetAppCode() != "huwaa" ||
|
||
wallet.releasePoint.GetUserId() != 42002 ||
|
||
wallet.releasePoint.GetAssetType() != "COIN_SELLER_POINT" ||
|
||
wallet.releasePoint.GetGrossPointAmount() != 2_000_000 ||
|
||
wallet.releasePoint.GetFeePointAmount() != 100_000 ||
|
||
wallet.releasePoint.GetNetPointAmount() != 1_900_000 ||
|
||
wallet.releasePoint.GetOperatorUserId() != 90002 ||
|
||
wallet.releasePoint.GetReason() != "bad address" ||
|
||
wallet.releasePoint.GetWithdrawalApplicationId() != "78" {
|
||
t.Fatalf("ReleasePointWithdrawal payload mismatch: %+v", wallet.releasePoint)
|
||
}
|
||
if wallet.settlePoint != nil || wallet.settleSalary != nil || wallet.releaseSalary != nil {
|
||
t.Fatalf("point rejection must not call legacy salary or settle RPCs: wallet=%+v", wallet)
|
||
}
|
||
}
|
||
|
||
func TestApplyWalletDecisionKeepsLegacySalaryAssetsOnSalaryRPC(t *testing.T) {
|
||
wallet := &fakeAuditWalletClient{}
|
||
svc := &Service{wallet: wallet}
|
||
|
||
transactionID, err := svc.applyWalletDecision(context.Background(), model.WithdrawalApplicationStatusApproved, "audit:79:approved", "lalu", 42003, "HOST_SALARY_USD", 12_345, 0, 0, 0, 0, shared.Actor{UserID: 90003}, "", 79)
|
||
if err != nil {
|
||
t.Fatalf("apply salary approval failed: %v", err)
|
||
}
|
||
if transactionID != "salary-settle-tx" {
|
||
t.Fatalf("salary approval transaction id mismatch: %s", transactionID)
|
||
}
|
||
if wallet.settleSalary == nil ||
|
||
wallet.settleSalary.GetAppCode() != "lalu" ||
|
||
wallet.settleSalary.GetUserId() != 42003 ||
|
||
wallet.settleSalary.GetSalaryAssetType() != "HOST_SALARY_USD" ||
|
||
wallet.settleSalary.GetSalaryUsdMinor() != 12_345 ||
|
||
wallet.settleSalary.GetWithdrawalApplicationId() != "79" {
|
||
t.Fatalf("SettleSalaryWithdrawal payload mismatch: %+v", wallet.settleSalary)
|
||
}
|
||
if wallet.settlePoint != nil || wallet.releasePoint != nil {
|
||
t.Fatalf("legacy salary asset must not call point RPCs: wallet=%+v", wallet)
|
||
}
|
||
}
|
||
|
||
type fakeAuditWalletClient struct {
|
||
walletclient.Client
|
||
settlePoint *walletv1.SettlePointWithdrawalRequest
|
||
releasePoint *walletv1.ReleasePointWithdrawalRequest
|
||
settleSalary *walletv1.SettleSalaryWithdrawalRequest
|
||
releaseSalary *walletv1.ReleaseSalaryWithdrawalRequest
|
||
}
|
||
|
||
func (f *fakeAuditWalletClient) SettlePointWithdrawal(_ context.Context, req *walletv1.SettlePointWithdrawalRequest) (*walletv1.SettlePointWithdrawalResponse, error) {
|
||
f.settlePoint = req
|
||
return &walletv1.SettlePointWithdrawalResponse{TransactionId: "point-settle-tx"}, nil
|
||
}
|
||
|
||
func (f *fakeAuditWalletClient) ReleasePointWithdrawal(_ context.Context, req *walletv1.ReleasePointWithdrawalRequest) (*walletv1.ReleasePointWithdrawalResponse, error) {
|
||
f.releasePoint = req
|
||
return &walletv1.ReleasePointWithdrawalResponse{TransactionId: "point-release-tx"}, nil
|
||
}
|
||
|
||
func (f *fakeAuditWalletClient) SettleSalaryWithdrawal(_ context.Context, req *walletv1.SettleSalaryWithdrawalRequest) (*walletv1.SettleSalaryWithdrawalResponse, error) {
|
||
f.settleSalary = req
|
||
return &walletv1.SettleSalaryWithdrawalResponse{TransactionId: "salary-settle-tx"}, nil
|
||
}
|
||
|
||
func (f *fakeAuditWalletClient) ReleaseSalaryWithdrawal(_ context.Context, req *walletv1.ReleaseSalaryWithdrawalRequest) (*walletv1.ReleaseSalaryWithdrawalResponse, error) {
|
||
f.releaseSalary = req
|
||
return &walletv1.ReleaseSalaryWithdrawalResponse{TransactionId: "salary-release-tx"}, nil
|
||
}
|
||
|
||
type fakeAuditActivityClient struct {
|
||
activityclient.Client
|
||
last *activityv1.CreateInboxMessageRequest
|
||
calls int
|
||
failures []error
|
||
}
|
||
|
||
func (f *fakeAuditActivityClient) CreateInboxMessage(_ context.Context, req *activityv1.CreateInboxMessageRequest) (*activityv1.CreateInboxMessageResponse, error) {
|
||
f.last = req
|
||
f.calls++
|
||
if index := f.calls - 1; index < len(f.failures) && f.failures[index] != nil {
|
||
return nil, f.failures[index]
|
||
}
|
||
return &activityv1.CreateInboxMessageResponse{}, nil
|
||
}
|
||
|
||
func newWithdrawalAuditServiceTest(t *testing.T) (*Service, sqlmock.Sqlmock, *fakeAuditWalletClient, *fakeAuditActivityClient, func()) {
|
||
t.Helper()
|
||
sqlDB, mock, err := sqlmock.New()
|
||
if err != nil {
|
||
t.Fatalf("create sql mock failed: %v", err)
|
||
}
|
||
gormDB, err := gorm.Open(mysql.New(mysql.Config{Conn: sqlDB, SkipInitializeWithVersion: true}), &gorm.Config{})
|
||
if err != nil {
|
||
_ = sqlDB.Close()
|
||
t.Fatalf("create gorm db failed: %v", err)
|
||
}
|
||
wallet := &fakeAuditWalletClient{}
|
||
activity := &fakeAuditActivityClient{}
|
||
svc := &Service{
|
||
store: repository.New(gormDB), wallet: wallet, activity: activity,
|
||
now: func() time.Time { return time.UnixMilli(1_700_000_300_000).UTC() },
|
||
}
|
||
return svc, mock, wallet, activity, func() { _ = sqlDB.Close() }
|
||
}
|
||
|
||
func expectWithdrawalReviewLock(mock sqlmock.Sqlmock, status string, operationsStatus string) {
|
||
mock.ExpectBegin()
|
||
mock.ExpectQuery("SELECT \\* FROM `admin_user_withdrawal_applications`.*FOR UPDATE").
|
||
WithArgs("lalu", uint(77), 1).
|
||
WillReturnRows(withdrawalAuditRows(status, operationsStatus))
|
||
}
|
||
|
||
func expectWithdrawalRejectionClaim(mock sqlmock.Sqlmock, operationsStatus string) {
|
||
mock.ExpectBegin()
|
||
mock.ExpectQuery("SELECT \\* FROM `admin_user_withdrawal_applications`.*FOR UPDATE").
|
||
WithArgs("lalu", uint(77), 1).
|
||
WillReturnRows(withdrawalAuditRows(model.WithdrawalApplicationStatusPending, operationsStatus))
|
||
if operationsStatus == model.WithdrawalOperationsStatusPending {
|
||
mock.ExpectExec("UPDATE `admin_user_withdrawal_applications` SET").WillReturnResult(sqlmock.NewResult(0, 1))
|
||
}
|
||
mock.ExpectCommit()
|
||
}
|
||
|
||
func expectWithdrawalRejectionRetryClaim(mock sqlmock.Sqlmock, reviewerID uint, reviewerName string, remark string) {
|
||
mock.ExpectBegin()
|
||
mock.ExpectQuery("SELECT \\* FROM `admin_user_withdrawal_applications`.*FOR UPDATE").
|
||
WithArgs("lalu", uint(77), 1).
|
||
WillReturnRows(withdrawalAuditRowsWithOperationsContext(model.WithdrawalApplicationStatusPending, model.WithdrawalOperationsStatusRejecting, reviewerID, reviewerName, remark))
|
||
mock.ExpectCommit()
|
||
}
|
||
|
||
func expectWithdrawalRejectionFinalize(mock sqlmock.Sqlmock) {
|
||
expectWithdrawalRejectionFinalizeWithContext(mock, 8, "operations", "收款资料不符")
|
||
}
|
||
|
||
func expectWithdrawalRejectionFinalizeWithContext(mock sqlmock.Sqlmock, reviewerID uint, reviewerName string, remark string) {
|
||
mock.ExpectBegin()
|
||
mock.ExpectQuery("SELECT \\* FROM `admin_user_withdrawal_applications`.*FOR UPDATE").
|
||
WithArgs("lalu", uint(77), 1).
|
||
WillReturnRows(withdrawalAuditRowsWithOperationsContext(model.WithdrawalApplicationStatusPending, model.WithdrawalOperationsStatusRejecting, reviewerID, reviewerName, remark))
|
||
mock.ExpectExec("UPDATE `admin_user_withdrawal_applications` SET").WillReturnResult(sqlmock.NewResult(0, 1))
|
||
mock.ExpectCommit()
|
||
}
|
||
|
||
func expectWithdrawalMoneyAccess(mock sqlmock.Sqlmock, userID uint, appCodes ...string) {
|
||
mock.ExpectQuery("SELECT \\* FROM `admin_users` WHERE `admin_users`.`id` = \\? ORDER BY `admin_users`.`id` LIMIT \\?").
|
||
WillReturnRows(sqlmock.NewRows([]string{"id", "username", "name", "status"}).AddRow(userID, "reviewer", "Reviewer", "active"))
|
||
mock.ExpectQuery("SELECT \\* FROM `admin_user_roles` WHERE `admin_user_roles`.`user_id` = \\?").
|
||
WillReturnRows(sqlmock.NewRows([]string{"user_id", "role_id"}))
|
||
rows := sqlmock.NewRows([]string{"id", "user_id", "app_code", "region_id", "created_at_ms", "updated_at_ms"})
|
||
for index, appCode := range appCodes {
|
||
rows.AddRow(index+1, userID, appCode, 0, int64(1_700_000_000_000), int64(1_700_000_000_000))
|
||
}
|
||
mock.ExpectQuery("SELECT \\* FROM `admin_user_money_scopes` WHERE user_id = \\? ORDER BY app_code ASC, region_id ASC").
|
||
WillReturnRows(rows)
|
||
}
|
||
|
||
func withdrawalAuditRows(status string, operationsStatus string) *sqlmock.Rows {
|
||
return withdrawalAuditRowsWithOperationsContext(status, operationsStatus, 0, "", "")
|
||
}
|
||
|
||
func withdrawalAuditRowsWithOperationsContext(status string, operationsStatus string, reviewerID uint, reviewerName string, remark string) *sqlmock.Rows {
|
||
var storedReviewerID any
|
||
var reviewedAtMS any
|
||
operationsCommandID := ""
|
||
if reviewerID > 0 {
|
||
storedReviewerID = reviewerID
|
||
reviewedAtMS = int64(1_700_000_300_000)
|
||
operationsCommandID = "salary-withdrawal:77:operations"
|
||
}
|
||
return sqlmock.NewRows([]string{
|
||
"id", "app_code", "user_id", "salary_asset_type", "withdraw_amount", "withdraw_amount_minor",
|
||
"withdraw_method", "withdraw_address", "freeze_command_id", "freeze_transaction_id",
|
||
"audit_command_id", "audit_transaction_id", "status", "operations_status",
|
||
"approver_user_id", "approver_name", "audit_remark", "audit_image_url", "approved_at_ms",
|
||
"operations_reviewer_user_id", "operations_reviewer_name", "operations_audit_remark",
|
||
"operations_audit_command_id", "operations_audit_transaction_id", "operations_reviewed_at_ms",
|
||
"created_at_ms", "updated_at_ms",
|
||
}).AddRow(
|
||
77, "lalu", "42001", "HOST_SALARY_USD", "50.00", int64(5000),
|
||
"usdt_trc20", "TRON-address", "freeze-command", "freeze-tx",
|
||
"", "", status, operationsStatus, nil, "", "", "", nil,
|
||
storedReviewerID, reviewerName, remark, operationsCommandID, "", reviewedAtMS,
|
||
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),
|
||
)
|
||
}
|