From f57247b4eebe20d803a78fc4dba265c2649bd88d Mon Sep 17 00:00:00 2001 From: dujiao-next Date: Thu, 27 Aug 2026 20:46:42 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E5=A2=9E=E5=BC=BA=E8=AE=A2=E5=8D=95?= =?UTF-8?q?=E5=B1=A5=E7=BA=A6=E6=A0=A1=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/admin/src/i18n/index.ts | 6 + frontend/user/src/i18n/locales/en-US.json | 3 +- frontend/user/src/i18n/locales/zh-CN.json | 3 +- frontend/user/src/i18n/locales/zh-TW.json | 3 +- .../payment_callback_structure_test.go | 2 +- .../payment_service_structure_test.go | 2 +- internal/constants/constants.go | 3 + .../application/payment_service_callback.go | 72 +++- .../application/payment_service_create.go | 4 + .../application/payment_service_rules.go | 23 ++ .../payment_service_wallet_test.go | 316 ++++++++++++++++++ .../wallet/application/order_balance.go | 10 +- .../modules/wallet/application/service.go | 24 ++ internal/modules/wallet/contract/ports.go | 1 + .../wallet/infrastructure/gormstore/store.go | 16 + 15 files changed, 477 insertions(+), 11 deletions(-) diff --git a/frontend/admin/src/i18n/index.ts b/frontend/admin/src/i18n/index.ts index 01064890..87d5e12f 100644 --- a/frontend/admin/src/i18n/index.ts +++ b/frontend/admin/src/i18n/index.ts @@ -1079,6 +1079,7 @@ const messages = { superseded_payment_succeeded: '已替换的旧支付链接迟到成功', duplicate_payment_succeeded: '订单已支付后又收到另一笔成功款', closed_order_payment_succeeded: '已关闭订单收到成功款', + underpaid_payment_succeeded: '收款金额不足以完成订单,款项已转入用户余额', }, detailInteraction: '交互模式', detailCreatedAt: '创建时间', @@ -2408,6 +2409,7 @@ const messages = { gift_card_redeem: '礼品卡兑换', admin_adjust: '管理员调账', admin_refund: '管理员退款', + order_underpaid_credit: '订单收款不足转入', }, errors: { loadFailed: '钱包数据获取失败', @@ -5520,6 +5522,7 @@ const messages = { superseded_payment_succeeded: '已替換的舊支付連結遲到成功', duplicate_payment_succeeded: '訂單已支付後又收到另一筆成功款', closed_order_payment_succeeded: '已關閉訂單收到成功款', + underpaid_payment_succeeded: '收款金額不足以完成訂單,款項已轉入用戶餘額', }, detailInteraction: '互動模式', detailCreatedAt: '建立時間', @@ -6849,6 +6852,7 @@ const messages = { gift_card_redeem: '禮品卡兌換', admin_adjust: '管理員調帳', admin_refund: '管理員退款', + order_underpaid_credit: '訂單收款不足轉入', }, errors: { loadFailed: '錢包資料取得失敗', @@ -9961,6 +9965,7 @@ const messages = { superseded_payment_succeeded: 'A superseded payment link succeeded late', duplicate_payment_succeeded: 'Another payment succeeded after the order was already paid', closed_order_payment_succeeded: 'A closed order received a successful payment', + underpaid_payment_succeeded: 'The amount received was not enough to fulfill the order; it was credited to the customer wallet', }, detailInteraction: 'Interaction', detailCreatedAt: 'Created At', @@ -11290,6 +11295,7 @@ const messages = { gift_card_redeem: 'Gift card redeem', admin_adjust: 'Admin adjustment', admin_refund: 'Admin refund', + order_underpaid_credit: 'Underpaid order credit', }, errors: { loadFailed: 'Failed to load wallet data', diff --git a/frontend/user/src/i18n/locales/en-US.json b/frontend/user/src/i18n/locales/en-US.json index 07a6a3ea..1cd37d71 100644 --- a/frontend/user/src/i18n/locales/en-US.json +++ b/frontend/user/src/i18n/locales/en-US.json @@ -561,7 +561,8 @@ "order_refund": "Order refund", "gift_card_redeem": "Gift card redeem", "admin_adjust": "Admin adjustment", - "admin_refund": "Admin refund" + "admin_refund": "Admin refund", + "order_underpaid_credit": "Underpaid order credit" }, "errors": { "loadFailed": "Failed to load wallet data, please try again later", diff --git a/frontend/user/src/i18n/locales/zh-CN.json b/frontend/user/src/i18n/locales/zh-CN.json index f6572c93..502738d4 100644 --- a/frontend/user/src/i18n/locales/zh-CN.json +++ b/frontend/user/src/i18n/locales/zh-CN.json @@ -514,7 +514,8 @@ "order_refund": "订单退款", "gift_card_redeem": "礼品卡兑换", "admin_adjust": "管理员调账", - "admin_refund": "管理员退款" + "admin_refund": "管理员退款", + "order_underpaid_credit": "订单收款不足转入" }, "errors": { "loadFailed": "钱包数据加载失败,请稍后重试", diff --git a/frontend/user/src/i18n/locales/zh-TW.json b/frontend/user/src/i18n/locales/zh-TW.json index d9ef7107..5b4ec232 100644 --- a/frontend/user/src/i18n/locales/zh-TW.json +++ b/frontend/user/src/i18n/locales/zh-TW.json @@ -561,7 +561,8 @@ "order_refund": "訂單退款", "gift_card_redeem": "禮品卡兌換", "admin_adjust": "管理員調帳", - "admin_refund": "管理員退款" + "admin_refund": "管理員退款", + "order_underpaid_credit": "訂單收款不足轉入" }, "errors": { "loadFailed": "錢包資料載入失敗,請稍後重試", diff --git a/internal/architecture/payment_callback_structure_test.go b/internal/architecture/payment_callback_structure_test.go index 8d23ee2c..2c5b7b3f 100644 --- a/internal/architecture/payment_callback_structure_test.go +++ b/internal/architecture/payment_callback_structure_test.go @@ -16,7 +16,7 @@ func TestPaymentCallbackImplementationIsSplitByResponsibility(t *testing.T) { "HandleCallback", "updateCallbackMeta", "applyPaymentUpdate", "mergeProviderPayload", "markOrderPaid", "validateCallbackPaymentFacts", "updateCallbackMetaWithRepo", "canAdoptVerifiedLegacyDujiaoPayCurrency", - "adoptVerifiedLegacyDujiaoPayCurrency", + "adoptVerifiedLegacyDujiaoPayCurrency", "creditUnderpaidToWallet", }, "payment_service_callback_wallet.go": { "handleWalletRechargeCallback", "applyWalletRechargePaymentUpdate", "canApplyWalletRechargeCallback", diff --git a/internal/architecture/payment_service_structure_test.go b/internal/architecture/payment_service_structure_test.go index 74148d58..99f48125 100644 --- a/internal/architecture/payment_service_structure_test.go +++ b/internal/architecture/payment_service_structure_test.go @@ -30,7 +30,7 @@ func TestPaymentServiceImplementationIsSplitByResponsibility(t *testing.T) { "tenantReturnPath", "resolveTokenPayOrderUserKey", }, "payment_service_rules.go": { - "normalizeOrderAmount", "calculatePaymentAmounts", "pickFirstNonEmpty", + "normalizeOrderAmount", "calculatePaymentAmounts", "paymentCoveredOrderAmount", "pickFirstNonEmpty", "shouldMarkFulfilling", "shouldUseCNYPaymentCurrency", "validatePaymentAmountForChannel", "validatePaymentCurrencyForChannel", "resolveExpireMinutes", "normalizePaymentStatus", "isPaymentStatusValid", "shouldAutoFulfill", "isOrderFullyAutoFulfill", diff --git a/internal/constants/constants.go b/internal/constants/constants.go index f3cbad44..46bbdeb9 100644 --- a/internal/constants/constants.go +++ b/internal/constants/constants.go @@ -51,6 +51,7 @@ const ( PaymentExceptionSupersededSucceeded = "superseded_payment_succeeded" PaymentExceptionDuplicateSucceeded = "duplicate_payment_succeeded" PaymentExceptionClosedOrderSucceeded = "closed_order_payment_succeeded" + PaymentExceptionUnderpaidSucceeded = "underpaid_payment_succeeded" ) // 支付提供方常量 @@ -127,6 +128,8 @@ const ( WalletTxnTypeAdminAdjust = "admin_adjust" WalletTxnTypeAdminRefund = "admin_refund" WalletTxnTypeGiftCard = "gift_card_redeem" + // WalletTxnTypeOrderUnderpaidCredit 记录"支付成功但金额不足以履约订单"时转入用户余额的款项。 + WalletTxnTypeOrderUnderpaidCredit = "order_underpaid_credit" ) // 钱包交易方向常量 diff --git a/internal/modules/payment/application/payment_service_callback.go b/internal/modules/payment/application/payment_service_callback.go index e4ddd615..6cb609e5 100644 --- a/internal/modules/payment/application/payment_service_callback.go +++ b/internal/modules/payment/application/payment_service_callback.go @@ -1,6 +1,7 @@ package application import ( + "fmt" "strings" "time" @@ -9,10 +10,13 @@ import ( orderdomain "github.com/dujiao-next/internal/modules/order/domain" paymentcontract "github.com/dujiao-next/internal/modules/payment/contract" paymentdomain "github.com/dujiao-next/internal/modules/payment/domain" + walletcontract "github.com/dujiao-next/internal/modules/wallet/contract" "github.com/dujiao-next/internal/constants" "github.com/dujiao-next/internal/shared/jsonmap" "github.com/dujiao-next/internal/shared/money" + + "github.com/shopspring/decimal" ) // PaymentCallbackInput 支付回调输入 @@ -274,8 +278,17 @@ func (s *PaymentService) applyPaymentUpdate(payment *paymentdomain.Payment, orde return err } - canFulfillOrder := status == constants.PaymentStatusSuccess && - lockedOrder.Status == constants.OrderStatusPendingPayment && lockedOrder.PaidAt == nil + orderOpen := lockedOrder.Status == constants.OrderStatusPendingPayment && lockedOrder.PaidAt == nil + + // 金额守恒:一笔支付只有覆盖订单当前的在线应付额才允许履约。 + // 混合支付切换渠道会退回余额并抬高在线应付额,而旧链接在网关侧依然可付, + // 缺少这道校验就能用旧链接的小额付款换到整单商品。 + requiredOnlineAmount := normalizeOrderAmount(lockedOrder.TotalAmount.Decimal.Sub(lockedOrder.WalletPaidAmount.Decimal)) + coveredOnlineAmount := paymentCoveredOrderAmount(lockedPayment) + underpaid := status == constants.PaymentStatusSuccess && orderOpen && + coveredOnlineAmount.LessThan(requiredOnlineAmount) + + canFulfillOrder := status == constants.PaymentStatusSuccess && orderOpen && !underpaid switch status { case constants.PaymentStatusSuccess: paidAt := now @@ -283,9 +296,12 @@ func (s *PaymentService) applyPaymentUpdate(payment *paymentdomain.Payment, orde paidAt = *input.PaidAt } lockedPayment.PaidAt = &paidAt - if lockedPayment.SupersededAt != nil { + switch { + case underpaid: + lockedPayment.ExceptionCode = constants.PaymentExceptionUnderpaidSucceeded + case lockedPayment.SupersededAt != nil: lockedPayment.ExceptionCode = constants.PaymentExceptionSupersededSucceeded - } else if !canFulfillOrder { + case !canFulfillOrder: if lockedOrder.PaidAt != nil { lockedPayment.ExceptionCode = constants.PaymentExceptionDuplicateSucceeded } else { @@ -325,6 +341,12 @@ func (s *PaymentService) applyPaymentUpdate(payment *paymentdomain.Payment, orde } orderPaid = true } + if underpaid { + // 订单不履约,但用户的钱不能吞:转入余额,用户可用余额补齐后重新支付。 + if err := s.creditUnderpaidToWallet(tx, lockedOrder, lockedPayment, coveredOnlineAmount, requiredOnlineAmount); err != nil { + return err + } + } if (status == constants.PaymentStatusFailed || status == constants.PaymentStatusExpired) && lockedOrder.Status == constants.OrderStatusPendingPayment && s.walletSvc != nil { if _, err := orderapp.ReleaseWalletBalance(s.walletSvc, tx, lockedOrder, constants.WalletTxnTypeOrderRefund, "在线支付失败,退回余额"); err != nil { return err @@ -339,6 +361,48 @@ func (s *PaymentService) applyPaymentUpdate(payment *paymentdomain.Payment, orde return returnVal, processedOrder, orderPaid, nil } +// creditUnderpaidToWallet 把"支付成功但金额不足以履约订单"的款项转入用户余额。 +// +// 订单保持待支付,用户可以用这笔余额补齐后重新发起支付;商家既不发货也不吞款。 +// 以支付 ID 为幂等键,重复回调不会重复入账。游客订单没有钱包账户,只留异常码等待人工处理。 +func (s *PaymentService) creditUnderpaidToWallet( + tx paymentcontract.Transaction, + order *orderdomain.Order, + payment *paymentdomain.Payment, + coveredAmount decimal.Decimal, + requiredAmount decimal.Decimal, +) error { + if order == nil || payment == nil { + return nil + } + log := paymentLogger( + "payment_id", payment.ID, + "order_id", order.ID, + "order_no", order.OrderNo, + "covered_amount", coveredAmount.String(), + "required_amount", requiredAmount.String(), + ) + if s.walletSvc == nil || order.UserID == 0 || !coveredAmount.IsPositive() { + log.Warnw("payment_callback_underpaid_credit_skipped", "user_id", order.UserID) + return nil + } + orderID := order.ID + if _, _, err := s.walletSvc.CreditInTransaction(tx.Wallets(), walletcontract.CreditInput{ + UserID: order.UserID, + Amount: money.FromDecimal(coveredAmount), + Currency: order.Currency, + Type: constants.WalletTxnTypeOrderUnderpaidCredit, + Reference: fmt.Sprintf("payment:%d:underpaid_credit", payment.ID), + Remark: "支付金额不足以完成订单,款项已转入余额", + OrderID: &orderID, + }); err != nil { + log.Errorw("payment_callback_underpaid_credit_failed", "user_id", order.UserID, "error", err) + return err + } + log.Warnw("payment_callback_underpaid_credited_to_wallet", "user_id", order.UserID) + return nil +} + // mergeProviderPayload 合并第三方回调原文,同时保留创建支付阶段写入的展示快照等元数据。 // 回调字段优先覆盖同名旧字段,未出现在回调中的 display_channel_type 等字段不会丢失。 func mergeProviderPayload(existing jsonmap.JSON, incoming jsonmap.JSON) jsonmap.JSON { diff --git a/internal/modules/payment/application/payment_service_create.go b/internal/modules/payment/application/payment_service_create.go index f64549c1..e4dbb9aa 100644 --- a/internal/modules/payment/application/payment_service_create.go +++ b/internal/modules/payment/application/payment_service_create.go @@ -189,6 +189,10 @@ func (s *PaymentService) CreatePayment(input CreatePaymentInput) (*CreatePayment if err := paymentRepo.Create(payment); err != nil { return ErrPaymentCreateFailed } + // 余额已覆盖全额,订单遗留的在线链接必须同时作废,避免用户再付一次。 + if _, err := paymentRepo.SupersedePendingByOrderID(lockedOrder.ID, payment.ID, paidAt); err != nil { + return ErrPaymentCreateFailed + } if err := s.markOrderPaid(tx, &lockedOrder, paidAt); err != nil { return err } diff --git a/internal/modules/payment/application/payment_service_rules.go b/internal/modules/payment/application/payment_service_rules.go index 7390aa90..798b466f 100644 --- a/internal/modules/payment/application/payment_service_rules.go +++ b/internal/modules/payment/application/payment_service_rules.go @@ -31,6 +31,29 @@ func calculatePaymentAmounts(baseAmount, feeRate, fixedFee decimal.Decimal, cust return baseAmount, feeAmount, constants.PaymentFeePolicyMerchantAbsorbed } +// paymentCoveredOrderAmount 推算一笔支付创建时承诺覆盖的订单在线应付额。 +// +// Amount / FeeAmount / FeePolicy 均为创建时快照:用户承担手续费的策略下 Amount +// 含加收部分,必须扣除后才是订单侧的抵扣基数;商家承担或无手续费时 Amount 即基数。 +// 升级前未写入策略快照(FeePolicy 为空)且带正数手续费的历史记录,与 CreatePayment +// 的 legacy 判定保持一致,按用户承担解释。 +func paymentCoveredOrderAmount(payment *paymentdomain.Payment) decimal.Decimal { + if payment == nil { + return decimal.Zero + } + covered := payment.Amount.Decimal + fee := payment.FeeAmount.Decimal + switch payment.FeePolicy { + case constants.PaymentFeePolicyCustomerSurcharge, constants.PaymentFeePolicyLegacyCustomerSurcharge: + covered = covered.Sub(fee) + case "": + if fee.IsPositive() { + covered = covered.Sub(fee) + } + } + return normalizeOrderAmount(covered) +} + // normalizeOrderAmount 归一化金额精度与下限。 func normalizeOrderAmount(amount decimal.Decimal) decimal.Decimal { normalized := amount.Round(2) diff --git a/internal/modules/payment/application/payment_service_wallet_test.go b/internal/modules/payment/application/payment_service_wallet_test.go index a126135d..1cb4bb1d 100644 --- a/internal/modules/payment/application/payment_service_wallet_test.go +++ b/internal/modules/payment/application/payment_service_wallet_test.go @@ -980,3 +980,319 @@ func assertWalletRechargeSuccessState(t *testing.T, db *gorm.DB, paymentID uint, func ptrTime(v time.Time) *time.Time { return &v } + +func createUnderpaidChannel(t *testing.T, db *gorm.DB, svc *PaymentService, name, channelType string) *paymentdomain.PaymentChannel { + t.Helper() + now := time.Now() + channel := &paymentdomain.PaymentChannel{ + Name: name, ProviderType: constants.PaymentProviderOfficial, ChannelType: channelType, + InteractionMode: constants.PaymentInteractionQR, + FeeRate: money.FromDecimal(decimal.Zero), FixedFee: money.FromDecimal(decimal.Zero), + ConfigJSON: jsonmap.JSON{}, IsActive: true, CreatedAt: now, UpdatedAt: now, + } + if err := db.Create(channel).Error; err != nil { + t.Fatalf("create payment channel failed: %v", err) + } + registerTestGateway(t, svc, channel.ProviderType, channel.ChannelType, emptyProviderRefProvider{}) + return channel +} + +func createUnderpaidOrder(t *testing.T, db *gorm.DB, orderNo string, userID uint, total int64) *orderdomain.Order { + t.Helper() + now := time.Now() + order := &orderdomain.Order{ + OrderNo: orderNo, UserID: userID, Status: constants.OrderStatusPendingPayment, Currency: "CNY", + OriginalAmount: money.FromDecimal(decimal.NewFromInt(total)), TotalAmount: money.FromDecimal(decimal.NewFromInt(total)), + DiscountAmount: money.FromDecimal(decimal.Zero), PromotionDiscountAmount: money.FromDecimal(decimal.Zero), + WalletPaidAmount: money.FromDecimal(decimal.Zero), OnlinePaidAmount: money.FromDecimal(decimal.NewFromInt(total)), + RefundedAmount: money.FromDecimal(decimal.Zero), CreatedAt: now, UpdatedAt: now, + } + if err := db.Create(order).Error; err != nil { + t.Fatalf("create order failed: %v", err) + } + return order +} + +func walletBalance(t *testing.T, db *gorm.DB, userID uint) string { + t.Helper() + var account walletdomain.Account + if err := db.Where("user_id = ?", userID).First(&account).Error; err != nil { + t.Fatalf("load wallet account failed: %v", err) + } + return account.Balance.StringFixed(2) +} + +// 余额 5 + 渠道 A 在线 10 混合支付后切到渠道 B,余额被退回、在线应付额抬到 15。 +// 此时旧的 A 链接在网关侧仍可支付,只付 10 不得履约整单。 +func TestSupersededPaymentUnderpaidDoesNotFulfillOrder(t *testing.T) { + svc, db := setupPaymentServiceWalletTest(t) + now := time.Now() + + user := &userdomain.User{ + Email: "underpaid@example.com", PasswordHash: "hash", Status: constants.UserStatusActive, + CreatedAt: now, UpdatedAt: now, + } + if err := db.Create(user).Error; err != nil { + t.Fatalf("create user failed: %v", err) + } + if err := db.Create(&walletdomain.Account{ + UserID: user.ID, Balance: money.FromDecimal(decimal.NewFromInt(5)), CreatedAt: now, UpdatedAt: now, + }).Error; err != nil { + t.Fatalf("create wallet account failed: %v", err) + } + + channelA := createUnderpaidChannel(t, db, svc, "Underpaid Gateway A", constants.PaymentChannelTypeWechat) + channelB := createUnderpaidChannel(t, db, svc, "Underpaid Gateway B", constants.PaymentChannelTypeAlipay) + order := createUnderpaidOrder(t, db, "DJUNDERPAID001", user.ID, 15) + + resultA, err := svc.CreatePayment(CreatePaymentInput{ + OrderID: order.ID, ChannelID: channelA.ID, UseBalance: true, Context: context.Background(), + }) + if err != nil { + t.Fatalf("create payment on channel A failed: %v", err) + } + if got := resultA.Payment.Amount.StringFixed(2); got != "10.00" { + t.Fatalf("channel A payment amount want 10.00 got %s", got) + } + + if _, err := svc.CreatePayment(CreatePaymentInput{ + OrderID: order.ID, ChannelID: channelB.ID, UseBalance: false, Context: context.Background(), + }); err != nil { + t.Fatalf("switch to channel B failed: %v", err) + } + if got := walletBalance(t, db, user.ID); got != "5.00" { + t.Fatalf("balance want 5.00 after switching to online payment got %s", got) + } + + var supersededA paymentdomain.Payment + if err := db.First(&supersededA, resultA.Payment.ID).Error; err != nil { + t.Fatalf("reload channel A payment failed: %v", err) + } + if supersededA.SupersededAt == nil { + t.Fatalf("channel A payment should be superseded: %+v", supersededA) + } + + paid, err := svc.HandleCallback(PaymentCallbackInput{ + PaymentID: supersededA.ID, OrderNo: order.OrderNo, ChannelID: channelA.ID, + Status: constants.PaymentStatusSuccess, Amount: supersededA.Amount, Currency: "CNY", ProviderRef: "underpaid-ref", + }) + if err != nil { + t.Fatalf("handle underpaid callback failed: %v", err) + } + if paid.Status != constants.PaymentStatusSuccess { + t.Fatalf("payment status want success got %s", paid.Status) + } + if paid.ExceptionCode != constants.PaymentExceptionUnderpaidSucceeded { + t.Fatalf("exception code want %s got %s", constants.PaymentExceptionUnderpaidSucceeded, paid.ExceptionCode) + } + + var reloadedOrder orderdomain.Order + if err := db.First(&reloadedOrder, order.ID).Error; err != nil { + t.Fatalf("reload order failed: %v", err) + } + if reloadedOrder.Status != constants.OrderStatusPendingPayment || reloadedOrder.PaidAt != nil { + t.Fatalf("underpaid callback must not fulfill the order: %+v", reloadedOrder) + } + + // 用户实付的 10 元转入余额,加上退回的 5 元刚好可以补齐这一单。 + if got := walletBalance(t, db, user.ID); got != "15.00" { + t.Fatalf("balance want 15.00 after underpaid credit got %s", got) + } + var credits []walletdomain.Transaction + if err := db.Where("user_id = ? AND type = ?", user.ID, constants.WalletTxnTypeOrderUnderpaidCredit).Find(&credits).Error; err != nil { + t.Fatalf("load underpaid credit transactions failed: %v", err) + } + if len(credits) != 1 || credits[0].Amount.StringFixed(2) != "10.00" { + t.Fatalf("underpaid credit transactions unexpected: %+v", credits) + } + + // 重复回调不得重复入账。 + if _, err := svc.HandleCallback(PaymentCallbackInput{ + PaymentID: supersededA.ID, OrderNo: order.OrderNo, ChannelID: channelA.ID, + Status: constants.PaymentStatusSuccess, Amount: supersededA.Amount, Currency: "CNY", ProviderRef: "underpaid-ref", + }); err != nil { + t.Fatalf("replay underpaid callback failed: %v", err) + } + if got := walletBalance(t, db, user.ID); got != "15.00" { + t.Fatalf("balance want 15.00 after replayed callback got %s", got) + } + + // 余额补齐后用户可以正常完成订单。 + settled, err := svc.CreatePayment(CreatePaymentInput{ + OrderID: order.ID, UseBalance: true, Context: context.Background(), + }) + if err != nil { + t.Fatalf("settle order with wallet balance failed: %v", err) + } + if !settled.OrderPaid { + t.Fatalf("order should be paid by wallet balance: %+v", settled) + } + if got := walletBalance(t, db, user.ID); got != "0.00" { + t.Fatalf("balance want 0.00 after settling the order got %s", got) + } +} + +// 游客订单没有钱包账户:同样不得履约,只留异常码等待人工处理。 +func TestUnderpaidGuestOrderIsNotFulfilled(t *testing.T) { + svc, db := setupPaymentServiceWalletTest(t) + channel := createUnderpaidChannel(t, db, svc, "Guest Underpaid Gateway", constants.PaymentChannelTypeWechat) + order := createUnderpaidOrder(t, db, "DJUNDERPAID002", 0, 15) + + now := time.Now() + stale := &paymentdomain.Payment{ + OrderID: order.ID, ChannelID: channel.ID, ProviderType: channel.ProviderType, ChannelType: channel.ChannelType, + InteractionMode: channel.InteractionMode, Amount: money.FromDecimal(decimal.NewFromInt(10)), + FeeAmount: money.FromDecimal(decimal.Zero), FeePolicy: constants.PaymentFeePolicyNone, + Currency: "CNY", Status: constants.PaymentStatusPending, QRCode: "stale-link", + CreatedAt: now, UpdatedAt: now, + } + if err := db.Create(stale).Error; err != nil { + t.Fatalf("create stale payment failed: %v", err) + } + + paid, err := svc.HandleCallback(PaymentCallbackInput{ + PaymentID: stale.ID, OrderNo: order.OrderNo, ChannelID: channel.ID, + Status: constants.PaymentStatusSuccess, Amount: stale.Amount, Currency: "CNY", ProviderRef: "guest-underpaid-ref", + }) + if err != nil { + t.Fatalf("handle guest underpaid callback failed: %v", err) + } + if paid.ExceptionCode != constants.PaymentExceptionUnderpaidSucceeded { + t.Fatalf("exception code want %s got %s", constants.PaymentExceptionUnderpaidSucceeded, paid.ExceptionCode) + } + + var reloadedOrder orderdomain.Order + if err := db.First(&reloadedOrder, order.ID).Error; err != nil { + t.Fatalf("reload order failed: %v", err) + } + if reloadedOrder.Status != constants.OrderStatusPendingPayment || reloadedOrder.PaidAt != nil { + t.Fatalf("guest underpaid callback must not fulfill the order: %+v", reloadedOrder) + } +} + +// 足额支付不受影响:金额守恒校验只拦欠额,不改变正常履约路径。 +func TestFullyPaidCallbackStillFulfillsOrder(t *testing.T) { + svc, db := setupPaymentServiceWalletTest(t) + channel := createUnderpaidChannel(t, db, svc, "Full Amount Gateway", constants.PaymentChannelTypeWechat) + order := createUnderpaidOrder(t, db, "DJUNDERPAID003", 0, 15) + + created, err := svc.CreatePayment(CreatePaymentInput{ + OrderID: order.ID, ChannelID: channel.ID, Context: context.Background(), + }) + if err != nil { + t.Fatalf("create payment failed: %v", err) + } + paid, err := svc.HandleCallback(PaymentCallbackInput{ + PaymentID: created.Payment.ID, OrderNo: order.OrderNo, ChannelID: channel.ID, + Status: constants.PaymentStatusSuccess, Amount: created.Payment.Amount, Currency: "CNY", ProviderRef: "full-ref", + }) + if err != nil { + t.Fatalf("handle full amount callback failed: %v", err) + } + if paid.ExceptionCode != "" { + t.Fatalf("full amount payment should not be flagged, got %s", paid.ExceptionCode) + } + + var reloadedOrder orderdomain.Order + if err := db.First(&reloadedOrder, order.ID).Error; err != nil { + t.Fatalf("reload order failed: %v", err) + } + if reloadedOrder.Status != constants.OrderStatusPaid || reloadedOrder.PaidAt == nil { + t.Fatalf("full amount payment should fulfill the order: %+v", reloadedOrder) + } +} + +// 余额分配在"用余额 → 改在线 → 再用余额"之间来回切换时,每一轮都必须真实扣款。 +// 轮次幂等键缺失时第二轮会命中上一轮早已退回的流水,订单被标记为已用余额、钱包却没扣钱。 +func TestWalletBalanceReappliedAfterReleaseDebitsAgain(t *testing.T) { + svc, db := setupPaymentServiceWalletTest(t) + now := time.Now() + + user := &userdomain.User{ + Email: "reapply@example.com", PasswordHash: "hash", Status: constants.UserStatusActive, + CreatedAt: now, UpdatedAt: now, + } + if err := db.Create(user).Error; err != nil { + t.Fatalf("create user failed: %v", err) + } + if err := db.Create(&walletdomain.Account{ + UserID: user.ID, Balance: money.FromDecimal(decimal.NewFromInt(5)), CreatedAt: now, UpdatedAt: now, + }).Error; err != nil { + t.Fatalf("create wallet account failed: %v", err) + } + + channelA := createUnderpaidChannel(t, db, svc, "Reapply Gateway A", constants.PaymentChannelTypeWechat) + channelB := createUnderpaidChannel(t, db, svc, "Reapply Gateway B", constants.PaymentChannelTypeAlipay) + order := createUnderpaidOrder(t, db, "DJUNDERPAID004", user.ID, 15) + + if _, err := svc.CreatePayment(CreatePaymentInput{ + OrderID: order.ID, ChannelID: channelA.ID, UseBalance: true, Context: context.Background(), + }); err != nil { + t.Fatalf("first wallet allocation failed: %v", err) + } + if got := walletBalance(t, db, user.ID); got != "0.00" { + t.Fatalf("balance want 0.00 after first allocation got %s", got) + } + + if _, err := svc.CreatePayment(CreatePaymentInput{ + OrderID: order.ID, ChannelID: channelB.ID, UseBalance: false, Context: context.Background(), + }); err != nil { + t.Fatalf("switch to online payment failed: %v", err) + } + if got := walletBalance(t, db, user.ID); got != "5.00" { + t.Fatalf("balance want 5.00 after release got %s", got) + } + + result, err := svc.CreatePayment(CreatePaymentInput{ + OrderID: order.ID, ChannelID: channelA.ID, UseBalance: true, Context: context.Background(), + }) + if err != nil { + t.Fatalf("second wallet allocation failed: %v", err) + } + if got := walletBalance(t, db, user.ID); got != "0.00" { + t.Fatalf("balance want 0.00 after re-applying the balance got %s", got) + } + if got := result.WalletPaidAmount.StringFixed(2); got != "5.00" { + t.Fatalf("wallet_paid_amount want 5.00 got %s", got) + } + if got := result.Payment.Amount.StringFixed(2); got != "10.00" { + t.Fatalf("online payment amount want 10.00 got %s", got) + } + + var reloadedOrder orderdomain.Order + if err := db.First(&reloadedOrder, order.ID).Error; err != nil { + t.Fatalf("reload order failed: %v", err) + } + if got := reloadedOrder.WalletPaidAmount.StringFixed(2); got != "5.00" { + t.Fatalf("order wallet_paid_amount want 5.00 got %s", got) + } +} + +func TestPaymentCoveredOrderAmount(t *testing.T) { + tests := []struct { + name string + amount string + feeAmount string + feePolicy string + want string + }{ + {name: "no fee", amount: "15.00", feeAmount: "0", feePolicy: constants.PaymentFeePolicyNone, want: "15"}, + {name: "merchant absorbed", amount: "100.00", feeAmount: "3.00", feePolicy: constants.PaymentFeePolicyMerchantAbsorbed, want: "100"}, + {name: "customer surcharge", amount: "103.00", feeAmount: "3.00", feePolicy: constants.PaymentFeePolicyCustomerSurcharge, want: "100"}, + {name: "legacy customer surcharge", amount: "103.00", feeAmount: "3.00", feePolicy: constants.PaymentFeePolicyLegacyCustomerSurcharge, want: "100"}, + {name: "missing policy snapshot with fee", amount: "103.00", feeAmount: "3.00", feePolicy: "", want: "100"}, + {name: "missing policy snapshot without fee", amount: "100.00", feeAmount: "0", feePolicy: "", want: "100"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + payment := &paymentdomain.Payment{ + Amount: money.FromDecimal(decimal.RequireFromString(tt.amount)), + FeeAmount: money.FromDecimal(decimal.RequireFromString(tt.feeAmount)), + FeePolicy: tt.feePolicy, + } + if got := paymentCoveredOrderAmount(payment); got.String() != tt.want { + t.Fatalf("paymentCoveredOrderAmount() = %s, want %s", got.String(), tt.want) + } + }) + } +} diff --git a/internal/modules/wallet/application/order_balance.go b/internal/modules/wallet/application/order_balance.go index e579578a..c4015192 100644 --- a/internal/modules/wallet/application/order_balance.go +++ b/internal/modules/wallet/application/order_balance.go @@ -46,7 +46,10 @@ func (s *Service) ApplyOrderBalance(tx walletcontract.Transaction, input walletc if deduct.GreaterThan(total) { deduct = total } - reference := orderReference(input.OrderID, constants.WalletTxnTypeOrderPay) + reference, err := orderAllocationReference(repository, input.OrderID, constants.WalletTxnTypeOrderPay) + if err != nil { + return money.Amount{}, err + } existing, err := repository.GetTransactionByReference(reference) if err != nil { return money.Amount{}, err @@ -97,7 +100,10 @@ func (s *Service) ReleaseOrderBalance( return money.FromDecimal(decimal.Zero), nil } repository := tx.Wallets() - reference := orderReference(input.OrderID, input.TransactionType) + reference, err := orderAllocationReference(repository, input.OrderID, input.TransactionType) + if err != nil { + return money.Amount{}, err + } existing, err := repository.GetTransactionByReference(reference) if err != nil { return money.Amount{}, err diff --git a/internal/modules/wallet/application/service.go b/internal/modules/wallet/application/service.go index 8f53bc9d..020a358f 100644 --- a/internal/modules/wallet/application/service.go +++ b/internal/modules/wallet/application/service.go @@ -42,6 +42,30 @@ func cleanRemark(raw, fallback string) string { return remark } +// orderAllocationReference 生成订单余额分配的轮次幂等键。 +// +// 第 1 轮沿用历史格式(order::),升级前已经扣款的在途订单不会被重复扣款; +// 订单在"用余额 → 改在线(退回)→ 再用余额"之间来回切换时,后续轮次追加序号, +// 否则上一轮早已被退回的流水会把新一轮的扣款判成重复操作而跳过—— +// 订单会被标记为已用余额,钱包却一分钱都没扣。 +// +// 同一轮内的重复调用由订单侧的 wallet_paid_amount 与 Release 的原子占位拦截, +// 并发写入还有 wallet_transactions.reference 唯一索引兜底。 +func orderAllocationReference(repository walletcontract.Repository, orderID uint, action string) (string, error) { + base := orderReference(orderID, action) + if repository == nil || orderID == 0 { + return base, nil + } + count, err := repository.CountOrderTransactionsByType(orderID, action) + if err != nil { + return "", err + } + if count <= 0 { + return base, nil + } + return fmt.Sprintf("%s:%d", base, count+1), nil +} + func orderReference(orderID uint, action string) string { action = strings.TrimSpace(action) if action == "" { diff --git a/internal/modules/wallet/contract/ports.go b/internal/modules/wallet/contract/ports.go index c4d9dc40..a7b1fb19 100644 --- a/internal/modules/wallet/contract/ports.go +++ b/internal/modules/wallet/contract/ports.go @@ -19,6 +19,7 @@ type Repository interface { CreateTransaction(transaction *walletdomain.Transaction) error GetTransactionByReference(reference string) (*walletdomain.Transaction, error) + CountOrderTransactionsByType(orderID uint, transactionType string) (int64, error) ListTransactions(filter TransactionListFilter) ([]walletdomain.Transaction, int64, error) CreateRechargeOrder(order *walletdomain.RechargeOrder) error diff --git a/internal/modules/wallet/infrastructure/gormstore/store.go b/internal/modules/wallet/infrastructure/gormstore/store.go index 3a348c04..51f19dc3 100644 --- a/internal/modules/wallet/infrastructure/gormstore/store.go +++ b/internal/modules/wallet/infrastructure/gormstore/store.go @@ -117,6 +117,22 @@ func (s *Store) CreateTransaction(transaction *walletdomain.Transaction) error { return s.db.Create(transaction).Error } +// CountOrderTransactionsByType 统计订单在某一流水类型下已经发生的次数, +// 用于给订单余额分配生成轮次幂等键。 +func (s *Store) CountOrderTransactionsByType(orderID uint, transactionType string) (int64, error) { + transactionType = strings.TrimSpace(transactionType) + if orderID == 0 || transactionType == "" { + return 0, nil + } + var count int64 + if err := s.db.Model(&walletdomain.Transaction{}). + Where("order_id = ? AND type = ? AND deleted_at IS NULL", orderID, transactionType). + Count(&count).Error; err != nil { + return 0, err + } + return count, nil +} + func (s *Store) GetTransactionByReference(reference string) (*walletdomain.Transaction, error) { reference = strings.TrimSpace(reference) if reference == "" {