- 修复 FindOrder 在收号站模式(two_party_distributor_receive / three_party)下的 403 权限判定,放行参与方站点成员并精准计算收益 - 串联 distribution_site_payment_channels 至 payment.Start 核心支付发起流程,分销订单优先使用绑定的专属商户通道 - 修复数据大盘 GetDashboardAnalytics 趋势中 ReceivedCount 恒为 0 的问题,并对齐今日订单与 GMV 统计 - 修复 dispute/arbitration.go 仲裁完成时向分销租客发放成长积分的泄漏风险 - 提现管理新增 distribution_site_name/distribution_site_code 字段并在后台列表与详情弹窗中区分展示分销站 - GetPublicSiteInfo 增加 X-Distribution-Site-Code 请求头兜底支持
931 lines
28 KiB
Go
931 lines
28 KiB
Go
package payment
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"hfb_sys/backend/internal/database"
|
|
"hfb_sys/backend/internal/model"
|
|
ordermodule "hfb_sys/backend/internal/modules/order"
|
|
"hfb_sys/backend/internal/modules/paymentconfig"
|
|
"hfb_sys/backend/internal/modules/rentergrowth"
|
|
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
// setupPaymentTestDB 创建测试数据库
|
|
func setupPaymentTestDB(t *testing.T) *gorm.DB {
|
|
t.Helper()
|
|
db := database.NewTestDB()
|
|
if err := database.MigrateRentalTransactionTestSchema(db); err != nil {
|
|
t.Fatalf("数据库迁移失败: %v", err)
|
|
}
|
|
|
|
return db
|
|
}
|
|
|
|
type payableOrderFixture struct {
|
|
Owner model.User
|
|
Renter model.User
|
|
Account model.GameAccount
|
|
Listing model.RentalListing
|
|
Order model.RentalOrder
|
|
Payment model.PaymentOrder
|
|
}
|
|
|
|
func createPayableOrderFixture(t *testing.T, db *gorm.DB, suffix string, createPayment bool) payableOrderFixture {
|
|
t.Helper()
|
|
f := payableOrderFixture{
|
|
Owner: model.User{Phone: "13800001" + suffix},
|
|
Renter: model.User{Phone: "13900001" + suffix},
|
|
}
|
|
if err := db.Create(&f.Owner).Error; err != nil {
|
|
t.Fatalf("create owner failed: %v", err)
|
|
}
|
|
if err := db.Create(&f.Renter).Error; err != nil {
|
|
t.Fatalf("create renter failed: %v", err)
|
|
}
|
|
f.Account = model.GameAccount{
|
|
OwnerID: f.Owner.ID,
|
|
ServerRegion: "国服",
|
|
LoginPlatform: "steam",
|
|
Title: "测试账号",
|
|
Status: "published",
|
|
}
|
|
if err := db.Create(&f.Account).Error; err != nil {
|
|
t.Fatalf("create account failed: %v", err)
|
|
}
|
|
f.Listing = model.RentalListing{
|
|
ListingNo: "LST20260614" + suffix,
|
|
OwnerID: f.Owner.ID,
|
|
AccountID: f.Account.ID,
|
|
Status: "published",
|
|
ReviewStatus: "approved",
|
|
InTransaction: true,
|
|
PriceCent: 1000,
|
|
}
|
|
if err := db.Create(&f.Listing).Error; err != nil {
|
|
t.Fatalf("create listing failed: %v", err)
|
|
}
|
|
f.Order = model.RentalOrder{
|
|
OrderNo: "ORD20260614" + suffix,
|
|
ListingID: f.Listing.ID,
|
|
AccountID: f.Account.ID,
|
|
OwnerID: f.Owner.ID,
|
|
RenterID: f.Renter.ID,
|
|
RentAmountCent: 1000,
|
|
Status: "pending_payment",
|
|
HandoffStatus: "none",
|
|
}
|
|
if err := db.Create(&f.Order).Error; err != nil {
|
|
t.Fatalf("create order failed: %v", err)
|
|
}
|
|
f.Payment = model.PaymentOrder{
|
|
PaymentNo: "PAY20260614" + suffix,
|
|
OrderID: f.Order.ID,
|
|
OrderNo: f.Order.OrderNo,
|
|
UserID: f.Renter.ID,
|
|
Provider: "mock",
|
|
ThirdOrderID: "PAY20260614" + suffix,
|
|
ProviderOrderID: "MOCKPAY20260614" + suffix,
|
|
AmountCent: 1000,
|
|
BizType: "order_pay",
|
|
Status: "paying",
|
|
}
|
|
if createPayment {
|
|
if err := db.Create(&f.Payment).Error; err != nil {
|
|
t.Fatalf("create payment failed: %v", err)
|
|
}
|
|
}
|
|
return f
|
|
}
|
|
|
|
func TestMarkRefundFailedPersistsRawRequest(t *testing.T) {
|
|
db := setupPaymentTestDB(t)
|
|
f := createPayableOrderFixture(t, db, "RAWREQ", true)
|
|
refund := model.PaymentOrder{
|
|
PaymentNo: "PAYREFRAWREQ",
|
|
OrderID: f.Order.ID,
|
|
OrderNo: f.Order.OrderNo,
|
|
UserID: f.Renter.ID,
|
|
Provider: "lakala",
|
|
MerchantID: "merchant-1",
|
|
ThirdOrderID: "REFRAWREQ",
|
|
ProviderOrderID: "",
|
|
AmountCent: 99,
|
|
BizType: "checkout_refund",
|
|
Status: "refunding",
|
|
}
|
|
if err := db.Create(&refund).Error; err != nil {
|
|
t.Fatalf("create refund payment failed: %v", err)
|
|
}
|
|
|
|
repo := NewRepository(db, nil, nil)
|
|
rawRequest := map[string]string{
|
|
"merchant_no": "merchant-1",
|
|
"out_trade_no": "REFRAWREQ",
|
|
"refund_amount": "99",
|
|
"origin_trade_no": "TRADE123",
|
|
}
|
|
err := repo.markRefundFailed(context.Background(), refund.ID, f.Order.ID, refund.AmountCent, map[string]string{"error": "lakala http status 400"}, rawRequest)
|
|
if err != nil {
|
|
t.Fatalf("mark refund failed: %v", err)
|
|
}
|
|
|
|
var saved model.PaymentOrder
|
|
if err := db.First(&saved, refund.ID).Error; err != nil {
|
|
t.Fatalf("find refund payment failed: %v", err)
|
|
}
|
|
var got map[string]string
|
|
if err := json.Unmarshal(saved.RawRequest, &got); err != nil {
|
|
t.Fatalf("decode raw_request: %v", err)
|
|
}
|
|
for key, want := range rawRequest {
|
|
if got[key] != want {
|
|
t.Fatalf("raw_request[%s] = %q, want %q; full=%v", key, got[key], want, got)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestCanReusePaymentWithSameProvider 测试相同渠道可复用
|
|
func TestCanReusePaymentWithSameProvider(t *testing.T) {
|
|
payment := model.PaymentOrder{
|
|
Status: "paying",
|
|
Provider: "lakala",
|
|
MerchantID: "M123",
|
|
}
|
|
|
|
config := runtimePaymentConfig{
|
|
Provider: "lakala",
|
|
MerchantID: "M123",
|
|
}
|
|
|
|
if !canReuseOrderPayment(payment, config) {
|
|
t.Fatal("should reuse payment with same provider and merchant")
|
|
}
|
|
}
|
|
|
|
// TestCannotReusePaymentWithDifferentProvider 测试不同渠道不可复用
|
|
func TestCannotReusePaymentWithDifferentProvider(t *testing.T) {
|
|
payment := model.PaymentOrder{
|
|
Status: "paying",
|
|
Provider: "lakala",
|
|
MerchantID: "M123",
|
|
}
|
|
|
|
config := runtimePaymentConfig{
|
|
Provider: "leshua", // 不同渠道
|
|
MerchantID: "M456",
|
|
}
|
|
|
|
if canReuseOrderPayment(payment, config) {
|
|
t.Fatal("should not reuse payment with different provider")
|
|
}
|
|
}
|
|
|
|
// TestPaymentStatusTransitions 测试支付单状态转换
|
|
func TestPaymentStatusTransitionsValid(t *testing.T) {
|
|
validTransitions := map[string][]string{
|
|
"pending": {"paying", "closed"},
|
|
"paying": {"paid", "failed", "closed"},
|
|
"paid": {"refunding", "refunded"},
|
|
"failed": {}, // 终态
|
|
"closed": {}, // 终态
|
|
"refunded": {}, // 终态
|
|
}
|
|
|
|
for from, toList := range validTransitions {
|
|
if from == "" {
|
|
t.Fatal("payment status should not be empty")
|
|
}
|
|
for _, to := range toList {
|
|
if to == "" {
|
|
t.Fatal("transition target should not be empty")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestRefundBizTypeCoverage 测试退款业务类型覆盖
|
|
func TestRefundBizTypeCoverage(t *testing.T) {
|
|
expected := []string{
|
|
"cancel_refund",
|
|
"admin_close_refund",
|
|
"admin_refund",
|
|
"checkout_refund",
|
|
"deposit_refund",
|
|
"rent_refund",
|
|
"arbitration_refund",
|
|
}
|
|
|
|
if len(refundBizTypes) != len(expected) {
|
|
t.Fatalf("refundBizTypes count = %d, want %d", len(refundBizTypes), len(expected))
|
|
}
|
|
|
|
// 验证每个预期类型都存在
|
|
typeMap := make(map[string]bool)
|
|
for _, bizType := range refundBizTypes {
|
|
typeMap[bizType] = true
|
|
}
|
|
|
|
for _, exp := range expected {
|
|
if !typeMap[exp] {
|
|
t.Fatalf("missing refund biz type: %s", exp)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestPaymentAmountValidation 测试支付金额验证
|
|
func TestPaymentAmountMustBePositive(t *testing.T) {
|
|
testCases := []struct {
|
|
name string
|
|
amount int64
|
|
wantValid bool
|
|
}{
|
|
{"正数金额", 10000, true},
|
|
{"零金额", 0, false},
|
|
{"负数金额", -1000, false},
|
|
{"最小金额", 1, true},
|
|
}
|
|
|
|
for _, tc := range testCases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
isValid := tc.amount > 0
|
|
if isValid != tc.wantValid {
|
|
t.Fatalf("amount %d validation = %v, want %v", tc.amount, isValid, tc.wantValid)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestPaymentProviderValidation 测试支付渠道验证
|
|
func TestPaymentProviderMustNotBeEmpty(t *testing.T) {
|
|
testCases := []struct {
|
|
name string
|
|
provider string
|
|
wantValid bool
|
|
}{
|
|
{"lakala 渠道", "lakala", true},
|
|
{"leshua 渠道", "leshua", true},
|
|
{"mock 渠道", "mock", true},
|
|
{"空渠道", "", false},
|
|
}
|
|
|
|
for _, tc := range testCases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
isValid := tc.provider != ""
|
|
if isValid != tc.wantValid {
|
|
t.Fatalf("provider %q validation = %v, want %v", tc.provider, isValid, tc.wantValid)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestRuntimeConfigIsMockMode 测试运行时配置 mock 模式
|
|
func TestRuntimeConfigIsMockModeTrue(t *testing.T) {
|
|
config := runtimePaymentConfig{
|
|
Provider: "mock",
|
|
}
|
|
|
|
if !config.isMockMode() {
|
|
t.Fatal("should be mock mode when provider is 'mock'")
|
|
}
|
|
}
|
|
|
|
func TestRuntimeConfigIsMockModeFalse(t *testing.T) {
|
|
providers := []string{"lakala", "leshua", "alipay", "wechat"}
|
|
|
|
for _, provider := range providers {
|
|
config := runtimePaymentConfig{
|
|
Provider: provider,
|
|
}
|
|
|
|
if config.isMockMode() {
|
|
t.Fatalf("should not be mock mode when provider is %q", provider)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestChannelSourceValidation 测试渠道来源验证
|
|
func TestChannelSourceMustBeValid(t *testing.T) {
|
|
validSources := []string{
|
|
channelSourceCreate,
|
|
channelSourceQuery,
|
|
channelSourceNotify,
|
|
channelSourceMock,
|
|
}
|
|
|
|
expectedValues := []string{"create", "query", "notify", "mock"}
|
|
|
|
for i, source := range validSources {
|
|
if source != expectedValues[i] {
|
|
t.Fatalf("channelSource[%d] = %q, want %q", i, source, expectedValues[i])
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestPaymentOrderFields 测试支付单字段完整性
|
|
func TestPaymentOrderRequiredFields(t *testing.T) {
|
|
payment := model.PaymentOrder{
|
|
PaymentNo: "PAY123456",
|
|
OrderNo: "ORD123456",
|
|
OrderID: 100,
|
|
UserID: 1,
|
|
AmountCent: 10000,
|
|
Status: "paying",
|
|
Provider: "lakala",
|
|
MerchantID: "M123",
|
|
BizType: "order_pay",
|
|
}
|
|
|
|
// 验证必填字段
|
|
if payment.PaymentNo == "" {
|
|
t.Fatal("PaymentNo should not be empty")
|
|
}
|
|
if payment.OrderNo == "" {
|
|
t.Fatal("OrderNo should not be empty")
|
|
}
|
|
if payment.OrderID == 0 {
|
|
t.Fatal("OrderID should not be zero")
|
|
}
|
|
if payment.UserID == 0 {
|
|
t.Fatal("UserID should not be zero")
|
|
}
|
|
if payment.AmountCent <= 0 {
|
|
t.Fatal("AmountCent should be positive")
|
|
}
|
|
if payment.Status == "" {
|
|
t.Fatal("Status should not be empty")
|
|
}
|
|
if payment.Provider == "" {
|
|
t.Fatal("Provider should not be empty")
|
|
}
|
|
}
|
|
|
|
// TestRefundAmountValidation 测试退款金额验证
|
|
func TestRefundAmountMustNotExceedOriginal(t *testing.T) {
|
|
testCases := []struct {
|
|
name string
|
|
originalAmount int64
|
|
refundAmount int64
|
|
wantValid bool
|
|
}{
|
|
{"部分退款", 10000, 5000, true},
|
|
{"全额退款", 10000, 10000, true},
|
|
{"超额退款", 10000, 15000, false},
|
|
{"零退款", 10000, 0, false},
|
|
{"负数退款", 10000, -1000, false},
|
|
}
|
|
|
|
for _, tc := range testCases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
isValid := tc.refundAmount > 0 && tc.refundAmount <= tc.originalAmount
|
|
if isValid != tc.wantValid {
|
|
t.Fatalf("refund %d from %d validation = %v, want %v",
|
|
tc.refundAmount, tc.originalAmount, isValid, tc.wantValid)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestSuccessfulAdminFullRefundRevokesCompletedOrderGrowthPoints(t *testing.T) {
|
|
db := setupPaymentTestDB(t)
|
|
repo := NewRepository(db, nil, nil)
|
|
renter := model.User{Phone: "13900001991", RenterGrowthLevel: "normal"}
|
|
if err := db.Create(&renter).Error; err != nil {
|
|
t.Fatalf("create renter failed: %v", err)
|
|
}
|
|
order := model.RentalOrder{
|
|
OrderNo: "ORD202607260991",
|
|
RenterID: renter.ID,
|
|
RentAmountCent: 1000,
|
|
Status: "completed",
|
|
GrowthPointsBasisCent: 1000,
|
|
GrowthPointsPerYuan: 1,
|
|
}
|
|
if err := db.Create(&order).Error; err != nil {
|
|
t.Fatalf("create order failed: %v", err)
|
|
}
|
|
if err := rentergrowth.AwardOrderCompleted(db, &order, 1000, rentergrowth.SourceOrderCompleted); err != nil {
|
|
t.Fatalf("award growth points failed: %v", err)
|
|
}
|
|
if err := db.Save(&order).Error; err != nil {
|
|
t.Fatalf("save awarded order failed: %v", err)
|
|
}
|
|
|
|
if err := repo.updateOrderRefundStatus(t.Context(), order.ID, 1000, "admin_refund"); err != nil {
|
|
t.Fatalf("update admin refund status failed: %v", err)
|
|
}
|
|
if err := repo.updateOrderRefundStatus(t.Context(), order.ID, 1000, "admin_refund"); err != nil {
|
|
t.Fatalf("repeat admin refund status failed: %v", err)
|
|
}
|
|
var savedUser model.User
|
|
if err := db.First(&savedUser, renter.ID).Error; err != nil {
|
|
t.Fatalf("find renter failed: %v", err)
|
|
}
|
|
if savedUser.RenterGrowthPoints != 0 || savedUser.RenterGrowthLevel != rentergrowth.DefaultLevelCode {
|
|
t.Fatalf("growth after refund = %d/%s, want 0/%s", savedUser.RenterGrowthPoints, savedUser.RenterGrowthLevel, rentergrowth.DefaultLevelCode)
|
|
}
|
|
var revokeCount int64
|
|
if err := db.Model(&model.RenterGrowthLedger{}).
|
|
Where("order_id = ? AND source = ?", order.ID, rentergrowth.SourceOrderRefunded).
|
|
Count(&revokeCount).Error; err != nil {
|
|
t.Fatalf("count revoke ledger failed: %v", err)
|
|
}
|
|
if revokeCount != 1 {
|
|
t.Fatalf("revoke ledger count = %d, want 1", revokeCount)
|
|
}
|
|
}
|
|
|
|
// TestPaymentNotifyResultStructure 测试支付回调结果结构
|
|
func TestNotifyResultHasRequiredFields(t *testing.T) {
|
|
result := NotifyResult{
|
|
OK: true,
|
|
Message: "payment success",
|
|
}
|
|
|
|
if result.Message == "" {
|
|
t.Fatal("NotifyResult.Message should not be empty")
|
|
}
|
|
}
|
|
|
|
func TestStartRefundDoesNotCreateNewOrderWhenFailedRefundExists(t *testing.T) {
|
|
db := setupPaymentTestDB(t)
|
|
repo := NewRepository(db, nil, nil)
|
|
order := model.RentalOrder{
|
|
ID: 1001,
|
|
OrderNo: "ORD202606140001",
|
|
RenterID: 11,
|
|
RentAmountCent: 800,
|
|
DepositAmountCent: 200,
|
|
RefundStatus: "failed",
|
|
RefundAmountCent: 1000,
|
|
Status: "closed",
|
|
}
|
|
if err := db.Create(&order).Error; err != nil {
|
|
t.Fatalf("create order failed: %v", err)
|
|
}
|
|
original := model.PaymentOrder{
|
|
PaymentNo: "PAY202606140001",
|
|
OrderID: order.ID,
|
|
OrderNo: order.OrderNo,
|
|
UserID: order.RenterID,
|
|
Provider: "mock",
|
|
ThirdOrderID: "PAY202606140001",
|
|
ProviderOrderID: "MOCKPAY202606140001",
|
|
AmountCent: 1000,
|
|
BizType: "order_pay",
|
|
Status: "paid",
|
|
}
|
|
if err := db.Create(&original).Error; err != nil {
|
|
t.Fatalf("create original payment failed: %v", err)
|
|
}
|
|
existingRefund := model.PaymentOrder{
|
|
PaymentNo: "PAY202606140002",
|
|
OrderID: order.ID,
|
|
OrderNo: order.OrderNo,
|
|
UserID: order.RenterID,
|
|
Provider: "mock",
|
|
ThirdOrderID: "REF202606140002",
|
|
AmountCent: 1000,
|
|
BizType: "admin_refund",
|
|
Status: "failed",
|
|
}
|
|
if err := db.Create(&existingRefund).Error; err != nil {
|
|
t.Fatalf("create existing refund failed: %v", err)
|
|
}
|
|
|
|
dto, err := repo.StartRefund(t.Context(), order.ID, 1000, "admin_refund", "后台人工退款")
|
|
if err != nil {
|
|
t.Fatalf("StartRefund() error = %v", err)
|
|
}
|
|
if dto.ID != existingRefund.ID {
|
|
t.Fatalf("StartRefund() returned payment id %d, want existing %d", dto.ID, existingRefund.ID)
|
|
}
|
|
var count int64
|
|
if err := db.Model(&model.PaymentOrder{}).
|
|
Where("order_id = ? AND biz_type = ?", order.ID, "admin_refund").
|
|
Count(&count).Error; err != nil {
|
|
t.Fatalf("count refund orders failed: %v", err)
|
|
}
|
|
if count != 1 {
|
|
t.Fatalf("refund order count = %d, want 1", count)
|
|
}
|
|
}
|
|
|
|
func TestStartMohongRefundDefaultsFullAmountForCancelledPaidOrder(t *testing.T) {
|
|
db := setupPaymentTestDB(t)
|
|
if err := database.AutoMigrateIfMissing(db, &model.MohongOrder{}); err != nil {
|
|
t.Fatalf("migrate mohong order failed: %v", err)
|
|
}
|
|
repo := NewRepository(db, nil, nil)
|
|
order := model.MohongOrder{
|
|
OrderNo: "MH202607310001",
|
|
UserID: 42,
|
|
ProductID: 8,
|
|
Quantity: 1,
|
|
UnitPriceCent: 13300,
|
|
AmountCent: 13300,
|
|
Status: model.MohongOrderStatusCancelled,
|
|
}
|
|
if err := db.Create(&order).Error; err != nil {
|
|
t.Fatalf("create mohong order failed: %v", err)
|
|
}
|
|
original := model.PaymentOrder{
|
|
PaymentNo: "PAYMOHONG0001",
|
|
OrderID: order.ID,
|
|
OrderNo: order.OrderNo,
|
|
UserID: order.UserID,
|
|
Provider: "mock",
|
|
ThirdOrderID: "PAYMOHONG0001",
|
|
ProviderOrderID: "MOCKPAYMOHONG0001",
|
|
AmountCent: order.AmountCent,
|
|
BizType: bizTypeMohongPay,
|
|
Status: "paid",
|
|
}
|
|
if err := db.Create(&original).Error; err != nil {
|
|
t.Fatalf("create mohong payment failed: %v", err)
|
|
}
|
|
|
|
dto, err := repo.StartMohongRefund(t.Context(), order.ID, 0, "后台撞车订单人工退款")
|
|
if err != nil {
|
|
t.Fatalf("StartMohongRefund() error = %v", err)
|
|
}
|
|
if dto.AmountCent != order.AmountCent {
|
|
t.Fatalf("refund amount = %d, want %d", dto.AmountCent, order.AmountCent)
|
|
}
|
|
if dto.BizType != bizTypeMohongRefund {
|
|
t.Fatalf("refund biz_type = %q, want %q", dto.BizType, bizTypeMohongRefund)
|
|
}
|
|
if dto.Status != "refunded" {
|
|
t.Fatalf("refund status = %q, want refunded", dto.Status)
|
|
}
|
|
|
|
var latest model.MohongOrder
|
|
if err := db.First(&latest, order.ID).Error; err != nil {
|
|
t.Fatalf("find mohong order failed: %v", err)
|
|
}
|
|
if latest.Status != model.MohongOrderStatusRefunded {
|
|
t.Fatalf("mohong order status = %q, want refunded", latest.Status)
|
|
}
|
|
}
|
|
|
|
func TestStartMohongRefundRequiresCancelledOrder(t *testing.T) {
|
|
db := setupPaymentTestDB(t)
|
|
if err := database.AutoMigrateIfMissing(db, &model.MohongOrder{}); err != nil {
|
|
t.Fatalf("migrate mohong order failed: %v", err)
|
|
}
|
|
repo := NewRepository(db, nil, nil)
|
|
order := model.MohongOrder{
|
|
OrderNo: "MH202607310002",
|
|
UserID: 42,
|
|
ProductID: 8,
|
|
Quantity: 1,
|
|
UnitPriceCent: 13300,
|
|
AmountCent: 13300,
|
|
Status: model.MohongOrderStatusPaid,
|
|
}
|
|
if err := db.Create(&order).Error; err != nil {
|
|
t.Fatalf("create mohong order failed: %v", err)
|
|
}
|
|
original := model.PaymentOrder{
|
|
PaymentNo: "PAYMOHONG0002",
|
|
OrderID: order.ID,
|
|
OrderNo: order.OrderNo,
|
|
UserID: order.UserID,
|
|
Provider: "mock",
|
|
ThirdOrderID: "PAYMOHONG0002",
|
|
ProviderOrderID: "MOCKPAYMOHONG0002",
|
|
AmountCent: order.AmountCent,
|
|
BizType: bizTypeMohongPay,
|
|
Status: "paid",
|
|
}
|
|
if err := db.Create(&original).Error; err != nil {
|
|
t.Fatalf("create mohong payment failed: %v", err)
|
|
}
|
|
|
|
_, err := repo.StartMohongRefund(t.Context(), order.ID, 0, "后台撞车订单人工退款")
|
|
if !errors.Is(err, ErrRefundCannotStart) {
|
|
t.Fatalf("StartMohongRefund() error = %v, want ErrRefundCannotStart", err)
|
|
}
|
|
}
|
|
|
|
func TestConfirmPaidUpdatesOrderAndPaymentInOnePath(t *testing.T) {
|
|
db := setupPaymentTestDB(t)
|
|
orderRepo := ordermodule.NewRepository(db)
|
|
repo := NewRepository(db, nil, orderRepo)
|
|
fixture := createPayableOrderFixture(t, db, "0010", true)
|
|
|
|
paidAt := time.Date(2026, 6, 14, 12, 0, 0, 0, time.UTC)
|
|
err := repo.confirmPaid(t.Context(), &fixture.Payment, "paid", paidAt, map[string]string{
|
|
"provider_order_id": "PROVIDER202606140010",
|
|
}, channelSourceNotify)
|
|
if err != nil {
|
|
t.Fatalf("confirmPaid() error = %v", err)
|
|
}
|
|
|
|
var latestPayment model.PaymentOrder
|
|
if err := db.First(&latestPayment, fixture.Payment.ID).Error; err != nil {
|
|
t.Fatalf("find payment failed: %v", err)
|
|
}
|
|
if latestPayment.Status != "paid" {
|
|
t.Fatalf("payment status = %q, want paid", latestPayment.Status)
|
|
}
|
|
if latestPayment.ProviderOrderID != "PROVIDER202606140010" {
|
|
t.Fatalf("provider order id = %q, want provider response", latestPayment.ProviderOrderID)
|
|
}
|
|
|
|
var latestOrder model.RentalOrder
|
|
if err := db.First(&latestOrder, fixture.Order.ID).Error; err != nil {
|
|
t.Fatalf("find order failed: %v", err)
|
|
}
|
|
if latestOrder.Status != "pending_handoff" || latestOrder.HandoffStatus != "pending_owner" {
|
|
t.Fatalf("order status = %q/%q, want pending_handoff/pending_owner", latestOrder.Status, latestOrder.HandoffStatus)
|
|
}
|
|
|
|
var latestListing model.RentalListing
|
|
if err := db.First(&latestListing, fixture.Listing.ID).Error; err != nil {
|
|
t.Fatalf("find listing failed: %v", err)
|
|
}
|
|
var latestAccount model.GameAccount
|
|
if err := db.First(&latestAccount, fixture.Account.ID).Error; err != nil {
|
|
t.Fatalf("find account failed: %v", err)
|
|
}
|
|
if latestListing.Status != "rented" || latestAccount.Status != "rented" {
|
|
t.Fatalf("asset status = %q/%q, want rented/rented", latestListing.Status, latestAccount.Status)
|
|
}
|
|
}
|
|
|
|
func TestConfirmPaidRollsBackOrderWhenPaymentUpdateFails(t *testing.T) {
|
|
db := setupPaymentTestDB(t)
|
|
orderRepo := ordermodule.NewRepository(db)
|
|
repo := NewRepository(db, nil, orderRepo)
|
|
fixture := createPayableOrderFixture(t, db, "0011", false)
|
|
fixture.Payment.ID = 999999
|
|
|
|
paidAt := time.Date(2026, 6, 14, 12, 0, 0, 0, time.UTC)
|
|
err := repo.confirmPaid(t.Context(), &fixture.Payment, "paid", paidAt, map[string]string{
|
|
"provider_order_id": "PROVIDER202606140011",
|
|
}, channelSourceNotify)
|
|
if err == nil {
|
|
t.Fatal("confirmPaid() error = nil, want payment lookup failure")
|
|
}
|
|
|
|
var latestOrder model.RentalOrder
|
|
if findErr := db.First(&latestOrder, fixture.Order.ID).Error; findErr != nil {
|
|
t.Fatalf("find order failed: %v", findErr)
|
|
}
|
|
if latestOrder.Status != "pending_payment" || latestOrder.HandoffStatus != "none" {
|
|
t.Fatalf("order status = %q/%q, want rollback to pending_payment/none", latestOrder.Status, latestOrder.HandoffStatus)
|
|
}
|
|
|
|
var latestListing model.RentalListing
|
|
if findErr := db.First(&latestListing, fixture.Listing.ID).Error; findErr != nil {
|
|
t.Fatalf("find listing failed: %v", findErr)
|
|
}
|
|
var latestAccount model.GameAccount
|
|
if findErr := db.First(&latestAccount, fixture.Account.ID).Error; findErr != nil {
|
|
t.Fatalf("find account failed: %v", findErr)
|
|
}
|
|
if latestListing.Status != "published" || latestAccount.Status != "published" {
|
|
t.Fatalf("asset status = %q/%q, want rollback to published/published", latestListing.Status, latestAccount.Status)
|
|
}
|
|
}
|
|
|
|
func TestConcurrentPaidNotificationsAreIdempotent(t *testing.T) {
|
|
db := database.NewTestDB()
|
|
if err := database.MigrateRentalTransactionTestSchema(db); err != nil {
|
|
t.Fatalf("数据库迁移失败: %v", err)
|
|
}
|
|
sqlDB, err := db.DB()
|
|
if err != nil {
|
|
t.Fatalf("获取底层连接失败: %v", err)
|
|
}
|
|
sqlDB.SetMaxOpenConns(8)
|
|
orderRepo := ordermodule.NewRepository(db)
|
|
repo := NewRepository(db, nil, orderRepo)
|
|
fixture := createPayableOrderFixture(t, db, "CONC", true)
|
|
params := map[string]string{
|
|
"third_order_id": fixture.Payment.ThirdOrderID,
|
|
"status": "paid",
|
|
"amount": "1000",
|
|
"provider_order_id": "PROVIDER-CONCURRENT",
|
|
}
|
|
|
|
const callers = 8
|
|
results := make(chan error, callers)
|
|
var group sync.WaitGroup
|
|
for i := 0; i < callers; i++ {
|
|
group.Add(1)
|
|
go func() {
|
|
defer group.Done()
|
|
var callErr error
|
|
for attempt := 0; attempt < 50; attempt++ {
|
|
_, callErr = repo.HandleNotify(t.Context(), "mock", params, "status=paid", "application/x-www-form-urlencoded", "")
|
|
if callErr == nil || !strings.Contains(callErr.Error(), "locked") {
|
|
break
|
|
}
|
|
time.Sleep(5 * time.Millisecond)
|
|
}
|
|
results <- callErr
|
|
}()
|
|
}
|
|
group.Wait()
|
|
close(results)
|
|
for callErr := range results {
|
|
if callErr != nil {
|
|
t.Fatalf("concurrent HandleNotify() error = %v", callErr)
|
|
}
|
|
}
|
|
|
|
var latestPayment model.PaymentOrder
|
|
if err := db.First(&latestPayment, fixture.Payment.ID).Error; err != nil {
|
|
t.Fatalf("find payment: %v", err)
|
|
}
|
|
if latestPayment.Status != "paid" {
|
|
t.Fatalf("payment status = %q, want paid", latestPayment.Status)
|
|
}
|
|
var latestOrder model.RentalOrder
|
|
if err := db.First(&latestOrder, fixture.Order.ID).Error; err != nil {
|
|
t.Fatalf("find order: %v", err)
|
|
}
|
|
if latestOrder.Status != "pending_handoff" || latestOrder.HandoffStatus != "pending_owner" {
|
|
t.Fatalf("order status = %q/%q, want pending_handoff/pending_owner", latestOrder.Status, latestOrder.HandoffStatus)
|
|
}
|
|
var paidCount int64
|
|
if err := db.Model(&model.PaymentOrder{}).Where("id = ? AND status = 'paid'", fixture.Payment.ID).Count(&paidCount).Error; err != nil {
|
|
t.Fatalf("count paid payment: %v", err)
|
|
}
|
|
if paidCount != 1 {
|
|
t.Fatalf("paid payment rows = %d, want 1", paidCount)
|
|
}
|
|
}
|
|
|
|
// TestQueryClosedPaymentReturnsLocalStatus 验证关闭状态不再依赖渠道配置或继续轮询渠道。
|
|
func TestQueryClosedPaymentReturnsLocalStatus(t *testing.T) {
|
|
db := setupPaymentTestDB(t)
|
|
repo := NewRepository(db, nil, nil)
|
|
payment := model.PaymentOrder{
|
|
PaymentNo: "PAY20260614CLOSED",
|
|
OrderID: 9001,
|
|
OrderNo: "ORD20260614CLOSED",
|
|
UserID: 8001,
|
|
Provider: "lakala",
|
|
ThirdOrderID: "PAY20260614CLOSED",
|
|
AmountCent: 1000,
|
|
BizType: "order_pay",
|
|
Status: "closed",
|
|
}
|
|
if err := db.Create(&payment).Error; err != nil {
|
|
t.Fatalf("create payment failed: %v", err)
|
|
}
|
|
|
|
dto, err := repo.Query(t.Context(), payment.UserID, payment.OrderID)
|
|
if err != nil {
|
|
t.Fatalf("Query() error = %v", err)
|
|
}
|
|
if dto.Status != "closed" {
|
|
t.Fatalf("status = %q, want closed", dto.Status)
|
|
}
|
|
}
|
|
|
|
// TestApplyChannelStatusKeepsTerminalPayment 验证已关闭支付单不会被渠道普通状态重新刷回支付中。
|
|
func TestApplyChannelStatusKeepsTerminalPayment(t *testing.T) {
|
|
db := setupPaymentTestDB(t)
|
|
repo := NewRepository(db, nil, nil)
|
|
payment := model.PaymentOrder{
|
|
PaymentNo: "PAY20260614TERMINAL",
|
|
OrderID: 9002,
|
|
OrderNo: "ORD20260614TERMINAL",
|
|
UserID: 8002,
|
|
Provider: "lakala",
|
|
ThirdOrderID: "PAY20260614TERMINAL",
|
|
AmountCent: 1000,
|
|
BizType: "order_pay",
|
|
Status: "closed",
|
|
}
|
|
if err := db.Create(&payment).Error; err != nil {
|
|
t.Fatalf("create payment failed: %v", err)
|
|
}
|
|
|
|
if err := repo.applyChannelStatus(t.Context(), &payment, "paying", "", map[string]string{"status": "paying"}, channelSourceNotify); err != nil {
|
|
t.Fatalf("applyChannelStatus() error = %v", err)
|
|
}
|
|
|
|
var latest model.PaymentOrder
|
|
if err := db.First(&latest, payment.ID).Error; err != nil {
|
|
t.Fatalf("find payment failed: %v", err)
|
|
}
|
|
if latest.Status != "closed" {
|
|
t.Fatalf("status = %q, want closed", latest.Status)
|
|
}
|
|
}
|
|
|
|
func TestStartPaymentRoutesToDistributionSitePaymentChannel(t *testing.T) {
|
|
db := setupPaymentTestDB(t)
|
|
if err := db.AutoMigrate(
|
|
&model.DistributionSite{},
|
|
&model.DistributionSitePaymentChannel{},
|
|
&model.PaymentMerchantConfig{},
|
|
); err != nil {
|
|
t.Fatalf("migrate distribution payment schema failed: %v", err)
|
|
}
|
|
|
|
configRepo := paymentconfig.NewRepository(db, &paymentconfig.MockEncryptor{})
|
|
orderRepo := ordermodule.NewRepository(db)
|
|
repo := NewRepository(db, configRepo, orderRepo)
|
|
|
|
defaultConfig := model.PaymentMerchantConfig{
|
|
Name: "平台默认微信",
|
|
Provider: "mock",
|
|
MerchantID: "PLATFORM_MOCK",
|
|
PayWay: "WX",
|
|
IsDefault: true,
|
|
Status: "active",
|
|
Environment: "sandbox",
|
|
}
|
|
if err := db.Create(&defaultConfig).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
siteConfig := model.PaymentMerchantConfig{
|
|
Name: "分销站专属微信",
|
|
Provider: "mock",
|
|
MerchantID: "SITE_MOCK_01",
|
|
PayWay: "WX",
|
|
IsDefault: false,
|
|
Status: "active",
|
|
Environment: "sandbox",
|
|
}
|
|
if err := db.Create(&siteConfig).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
site := model.DistributionSite{SiteCode: "pay_site", SiteName: "支付分站", Domain: "pay.example.com", Status: "active"}
|
|
if err := db.Create(&site).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
channel := model.DistributionSitePaymentChannel{
|
|
SiteID: site.ID,
|
|
ConfigID: siteConfig.ID,
|
|
Priority: 1,
|
|
Status: "active",
|
|
}
|
|
if err := db.Create(&channel).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Order A: Belongs to distribution site
|
|
fixA := createPayableOrderFixture(t, db, "siteA", false)
|
|
fixA.Order.DistributionSiteID = &site.ID
|
|
if err := db.Save(&fixA.Order).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Order B: Normal platform order
|
|
fixB := createPayableOrderFixture(t, db, "siteB", false)
|
|
fixB.Order.DistributionSiteID = nil
|
|
if err := db.Save(&fixB.Order).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Start payment for Order A -> should route to siteConfig
|
|
dtoA, err := repo.Start(t.Context(), fixA.Renter.ID, fixA.Order.ID, StartPaymentRequest{PayWay: "WX"}, "127.0.0.1")
|
|
if err != nil {
|
|
t.Fatalf("Start Order A failed: %v", err)
|
|
}
|
|
if dtoA.PaymentConfigID != siteConfig.ID {
|
|
t.Fatalf("Order A expected PaymentConfigID=%d, got %d", siteConfig.ID, dtoA.PaymentConfigID)
|
|
}
|
|
var savedA model.PaymentOrder
|
|
if err := db.First(&savedA, dtoA.ID).Error; err != nil {
|
|
t.Fatalf("find saved payment A failed: %v", err)
|
|
}
|
|
if savedA.MerchantID != siteConfig.MerchantID {
|
|
t.Fatalf("Order A expected MerchantID=%s, got %s", siteConfig.MerchantID, savedA.MerchantID)
|
|
}
|
|
|
|
// Start payment for Order B -> should route to defaultConfig
|
|
dtoB, err := repo.Start(t.Context(), fixB.Renter.ID, fixB.Order.ID, StartPaymentRequest{PayWay: "WX"}, "127.0.0.1")
|
|
if err != nil {
|
|
t.Fatalf("Start Order B failed: %v", err)
|
|
}
|
|
if dtoB.PaymentConfigID != defaultConfig.ID {
|
|
t.Fatalf("Order B expected PaymentConfigID=%d, got %d", defaultConfig.ID, dtoB.PaymentConfigID)
|
|
}
|
|
var savedB model.PaymentOrder
|
|
if err := db.First(&savedB, dtoB.ID).Error; err != nil {
|
|
t.Fatalf("find saved payment B failed: %v", err)
|
|
}
|
|
if savedB.MerchantID != defaultConfig.MerchantID {
|
|
t.Fatalf("Order B expected MerchantID=%s, got %s", defaultConfig.MerchantID, savedB.MerchantID)
|
|
}
|
|
}
|
|
|