package service import ( "encoding/json" "strings" "testing" "time" "affiliate_dash/internal/model" "affiliate_dash/internal/testdb" "gorm.io/gorm" ) func newServiceTestDB(t *testing.T) *gorm.DB { t.Helper() return testdb.New(t, &model.Merchant{}, &model.Product{}, &model.MerchantProduct{}, &model.WalletAccount{}, &model.WalletLedgerEntry{}, &model.FulfillmentOrder{}, &model.CallbackSubscription{}, &model.CallbackDelivery{}, &model.AuditLog{}, ) } func seedFulfillmentMerchant(t *testing.T, db *gorm.DB, code string, balance, stock, price int64) (uint, model.MerchantProduct) { t.Helper() merchant := model.Merchant{Code: code, Name: code, Status: model.MerchantStatusActive} if err := db.Create(&merchant).Error; err != nil { t.Fatalf("create merchant: %v", err) } if err := db.Create(&model.WalletAccount{ MerchantID: merchant.ID, Currency: "POINT", AvailableBalance: balance, }).Error; err != nil { t.Fatalf("create wallet: %v", err) } product := model.Product{Code: code + "-product", Name: "测试商品", Status: model.ProductStatusActive} if err := db.Create(&product).Error; err != nil { t.Fatalf("create product: %v", err) } merchantProduct := model.MerchantProduct{ MerchantID: merchant.ID, ProductID: product.ID, SKU: "sku-basic", DisplayName: "测试商品", PriceAmount: price, Currency: "POINT", Stock: stock, Status: model.ProductStatusActive, } if err := db.Create(&merchantProduct).Error; err != nil { t.Fatalf("create merchant product: %v", err) } return merchant.ID, merchantProduct } func TestFulfillmentCreateOrderDebitsWalletAndIsIdempotent(t *testing.T) { db := newServiceTestDB(t) merchantID, product := seedFulfillmentMerchant(t, db, "merchant-a", 1000, 5, 200) svc := NewFulfillmentService(db, nil) first, err := svc.CreateOrder(CreateFulfillmentOrderInput{ MerchantID: merchantID, APIClientID: 11, ClientOrderNo: "client-001", SKU: product.SKU, Quantity: 2, RequestData: map[string]string{ "account": "player-1", }, }) if err != nil { t.Fatalf("create order: %v", err) } if first.Idempotent { t.Fatalf("first create should not be idempotent") } if first.Order.Amount != 400 || first.Order.OrderStatus != model.OrderStatusPaid { t.Fatalf("unexpected order: %+v", first.Order) } second, err := svc.CreateOrder(CreateFulfillmentOrderInput{ MerchantID: merchantID, APIClientID: 11, ClientOrderNo: "client-001", SKU: product.SKU, Quantity: 2, RequestData: map[string]string{ "account": "player-1", }, }) if err != nil { t.Fatalf("idempotent create: %v", err) } if !second.Idempotent || second.Order.OrderNo != first.Order.OrderNo { t.Fatalf("expected existing order, got %+v", second) } var wallet model.WalletAccount if err := db.Where("merchant_id = ?", merchantID).First(&wallet).Error; err != nil { t.Fatalf("query wallet: %v", err) } if wallet.AvailableBalance != 600 { t.Fatalf("wallet should debit once, got %d", wallet.AvailableBalance) } var refreshed model.MerchantProduct if err := db.First(&refreshed, product.ID).Error; err != nil { t.Fatalf("query product: %v", err) } if refreshed.Stock != 3 { t.Fatalf("stock should decrease once, got %d", refreshed.Stock) } var ledgerCount int64 db.Model(&model.WalletLedgerEntry{}).Where("merchant_id = ?", merchantID).Count(&ledgerCount) if ledgerCount != 1 { t.Fatalf("ledger should have one debit entry, got %d", ledgerCount) } } func TestFulfillmentCreateOrderRejectsIdempotencyMismatch(t *testing.T) { db := newServiceTestDB(t) merchantID, product := seedFulfillmentMerchant(t, db, "merchant-idempotency-mismatch", 1000, 5, 200) svc := NewFulfillmentService(db, nil) first, err := svc.CreateOrder(CreateFulfillmentOrderInput{ MerchantID: merchantID, APIClientID: 11, ClientOrderNo: "client-mismatch", SKU: product.SKU, Quantity: 1, BuyerReference: "buyer-a", }) if err != nil { t.Fatalf("create order: %v", err) } _, err = svc.CreateOrder(CreateFulfillmentOrderInput{ MerchantID: merchantID, APIClientID: 11, ClientOrderNo: "client-mismatch", SKU: product.SKU, Quantity: 2, BuyerReference: "buyer-a", }) if err == nil || !strings.Contains(err.Error(), "请求参数与原订单不一致") { t.Fatalf("expected idempotency mismatch, got %v", err) } var wallet model.WalletAccount if err := db.Where("merchant_id = ?", merchantID).First(&wallet).Error; err != nil { t.Fatalf("query wallet: %v", err) } if wallet.AvailableBalance != 800 { t.Fatalf("wallet should debit first order only, got %d", wallet.AvailableBalance) } var refreshed model.MerchantProduct if err := db.First(&refreshed, product.ID).Error; err != nil { t.Fatalf("query product: %v", err) } if refreshed.Stock != 4 { t.Fatalf("stock should decrease once, got %d", refreshed.Stock) } if first.Order.RequestFingerprint == "" { t.Fatalf("request fingerprint should be stored") } } func TestDashboardScopesPlatformAndMerchantData(t *testing.T) { db := newServiceTestDB(t) merchantA, productA := seedFulfillmentMerchant(t, db, "dashboard-a", 1000, 5, 100) merchantB, productB := seedFulfillmentMerchant(t, db, "dashboard-b", 2000, 5, 300) svc := NewFulfillmentService(db, nil) if _, err := svc.CreateOrder(CreateFulfillmentOrderInput{ MerchantID: merchantA, APIClientID: 31, ClientOrderNo: "dashboard-a-001", SKU: productA.SKU, }); err != nil { t.Fatalf("create order a: %v", err) } if _, err := svc.CreateOrder(CreateFulfillmentOrderInput{ MerchantID: merchantB, APIClientID: 32, ClientOrderNo: "dashboard-b-001", SKU: productB.SKU, }); err != nil { t.Fatalf("create order b: %v", err) } merchantStats, err := svc.Dashboard(merchantA, false) if err != nil { t.Fatalf("merchant dashboard: %v", err) } if merchantStats.Scope != "merchant" { t.Fatalf("expected merchant scope, got %s", merchantStats.Scope) } if merchantStats.ProductCount != 1 || merchantStats.ActiveProductCount != 1 { t.Fatalf("merchant product stats should only include current merchant, got %+v", merchantStats) } if merchantStats.OrderCount != 1 || merchantStats.PaidOrderCount != 1 || merchantStats.TotalSales != 100 { t.Fatalf("merchant order stats should only include current merchant, got %+v", merchantStats) } if merchantStats.WalletAvailableBalance != 900 { t.Fatalf("merchant wallet should only include current merchant, got %d", merchantStats.WalletAvailableBalance) } platformStats, err := svc.Dashboard(merchantA, true) if err != nil { t.Fatalf("platform dashboard: %v", err) } if platformStats.Scope != "platform" { t.Fatalf("expected platform scope, got %s", platformStats.Scope) } if platformStats.OrderCount != 2 || platformStats.PaidOrderCount != 2 || platformStats.TotalSales != 400 { t.Fatalf("platform order stats should include all merchants, got %+v", platformStats) } if platformStats.WalletAvailableBalance < 2600 { t.Fatalf("platform wallet should include all merchant wallets, got %d", platformStats.WalletAvailableBalance) } if platformStats.ProductCount <= merchantStats.ProductCount { t.Fatalf("platform product stats should be broader than merchant stats, got platform=%d merchant=%d", platformStats.ProductCount, merchantStats.ProductCount) } } func TestFulfillmentCreateOrderAppliesMerchantFeeRate(t *testing.T) { db := newServiceTestDB(t) merchantID, product := seedFulfillmentMerchant(t, db, "merchant-fee-rate", 1000, 5, 200) if err := db.Model(&model.Merchant{}).Where("id = ?", merchantID).Updates(map[string]interface{}{ "fee_type": model.FeeTypeRate, "fee_rate_bp": int64(250), }).Error; err != nil { t.Fatalf("update merchant fee: %v", err) } svc := NewFulfillmentService(db, nil) created, err := svc.CreateOrder(CreateFulfillmentOrderInput{ MerchantID: merchantID, APIClientID: 21, ClientOrderNo: "client-fee-rate", SKU: product.SKU, Quantity: 2, }) if err != nil { t.Fatalf("create order: %v", err) } // baseAmount = 200 * 2 = 400; rate 250BP = 400 * 250 / 10000 = 10; total = 410 if created.Order.BaseAmount != 400 || created.Order.ServiceFeeAmount != 10 || created.Order.Amount != 410 { t.Fatalf("unexpected rate fee snapshot: %+v", created.Order) } if created.Order.FeeType != model.FeeTypeRate || created.Order.FeeRateBP != 250 { t.Fatalf("unexpected rate fee config snapshot: %+v", created.Order) } var wallet model.WalletAccount if err := db.Where("merchant_id = ?", merchantID).First(&wallet).Error; err != nil { t.Fatalf("query wallet: %v", err) } if wallet.AvailableBalance != 590 { t.Fatalf("wallet should debit total amount, got %d", wallet.AvailableBalance) } } func TestFulfillmentCreateOrderAppliesMerchantFeeFixed(t *testing.T) { db := newServiceTestDB(t) merchantID, product := seedFulfillmentMerchant(t, db, "merchant-fee-fixed", 1000, 5, 200) if err := db.Model(&model.Merchant{}).Where("id = ?", merchantID).Updates(map[string]interface{}{ "fee_type": model.FeeTypeFixed, "fee_fixed_amount": int64(30), }).Error; err != nil { t.Fatalf("update merchant fee: %v", err) } svc := NewFulfillmentService(db, nil) created, err := svc.CreateOrder(CreateFulfillmentOrderInput{ MerchantID: merchantID, APIClientID: 22, ClientOrderNo: "client-fee-fixed", SKU: product.SKU, Quantity: 2, }) if err != nil { t.Fatalf("create order: %v", err) } // baseAmount = 400; fixed fee = 30; total = 430 if created.Order.BaseAmount != 400 || created.Order.ServiceFeeAmount != 30 || created.Order.Amount != 430 { t.Fatalf("unexpected fixed fee snapshot: %+v", created.Order) } if created.Order.FeeType != model.FeeTypeFixed || created.Order.FeeFixedAmount != 30 { t.Fatalf("unexpected fixed fee config snapshot: %+v", created.Order) } var wallet model.WalletAccount if err := db.Where("merchant_id = ?", merchantID).First(&wallet).Error; err != nil { t.Fatalf("query wallet: %v", err) } if wallet.AvailableBalance != 570 { t.Fatalf("wallet should debit total amount, got %d", wallet.AvailableBalance) } } func TestFulfillmentCancelRefundsOnceAndRestoresStock(t *testing.T) { db := newServiceTestDB(t) merchantID, product := seedFulfillmentMerchant(t, db, "merchant-b", 1000, 2, 300) svc := NewFulfillmentService(db, nil) created, err := svc.CreateOrder(CreateFulfillmentOrderInput{ MerchantID: merchantID, APIClientID: 12, ClientOrderNo: "client-cancel", SKU: product.SKU, Quantity: 1, }) if err != nil { t.Fatalf("create order: %v", err) } cancelled, err := svc.CancelOrder(merchantID, 12, created.Order.OrderNo, "用户取消") if err != nil { t.Fatalf("cancel order: %v", err) } if cancelled.OrderStatus != model.OrderStatusCancelled { t.Fatalf("unexpected cancelled order: %+v", cancelled) } if _, err := svc.CancelOrder(merchantID, 12, created.Order.OrderNo, "重复取消"); err != nil { t.Fatalf("repeat cancel should be idempotent: %v", err) } var wallet model.WalletAccount _ = db.Where("merchant_id = ?", merchantID).First(&wallet).Error if wallet.AvailableBalance != 1000 { t.Fatalf("wallet should refund once, got %d", wallet.AvailableBalance) } var refreshed model.MerchantProduct _ = db.First(&refreshed, product.ID).Error if refreshed.Stock != 2 { t.Fatalf("stock should restore once, got %d", refreshed.Stock) } var ledgerCount int64 db.Model(&model.WalletLedgerEntry{}).Where("merchant_id = ?", merchantID).Count(&ledgerCount) if ledgerCount != 2 { t.Fatalf("ledger should have debit and refund, got %d", ledgerCount) } } func TestOrderStatusTransitions(t *testing.T) { db := newServiceTestDB(t) merchantID, product := seedFulfillmentMerchant(t, db, "merchant-c", 1000, -1, 100) svc := NewFulfillmentService(db, nil) created, err := svc.CreateOrder(CreateFulfillmentOrderInput{ MerchantID: merchantID, APIClientID: 13, ClientOrderNo: "client-status", SKU: product.SKU, }) if err != nil { t.Fatalf("create order: %v", err) } delivering, err := svc.UpdateFulfillment(FulfillmentUpdateInput{ MerchantID: merchantID, APIClientID: 13, OrderNo: created.Order.OrderNo, Status: model.OrderStatusDelivering, }) if err != nil { t.Fatalf("mark delivering: %v", err) } if delivering.OrderStatus != model.OrderStatusDelivering { t.Fatalf("expected delivering, got %s", delivering.OrderStatus) } delivered, err := svc.UpdateFulfillment(FulfillmentUpdateInput{ MerchantID: merchantID, APIClientID: 13, OrderNo: created.Order.OrderNo, Status: model.OrderStatusDelivered, ProviderOrderNo: "provider-1", ResultData: map[string]string{"ok": "true"}, }) if err != nil { t.Fatalf("mark delivered: %v", err) } if delivered.OrderStatus != model.OrderStatusDelivered || delivered.DeliveredAt == nil { t.Fatalf("unexpected delivered order: %+v", delivered) } _, err = svc.UpdateFulfillment(FulfillmentUpdateInput{ MerchantID: merchantID, APIClientID: 13, OrderNo: created.Order.OrderNo, Status: model.OrderStatusShipFailed, }) if err == nil { t.Fatalf("should reject rollback after success") } } func TestUpdateFulfillmentKeepsResultDataWhenOmitted(t *testing.T) { db := newServiceTestDB(t) merchantID, product := seedFulfillmentMerchant(t, db, "merchant-result-data", 1000, -1, 100) svc := NewFulfillmentService(db, nil) created, err := svc.CreateOrder(CreateFulfillmentOrderInput{ MerchantID: merchantID, APIClientID: 13, ClientOrderNo: "client-result-data", SKU: product.SKU, }) if err != nil { t.Fatalf("create order: %v", err) } delivering, err := svc.UpdateFulfillment(FulfillmentUpdateInput{ MerchantID: merchantID, APIClientID: 13, OrderNo: created.Order.OrderNo, Status: model.OrderStatusDelivering, ResultData: map[string]string{"stage": "claimed"}, }) if err != nil { t.Fatalf("mark delivering: %v", err) } if !strings.Contains(delivering.ResultData, `"stage":"claimed"`) { t.Fatalf("expected initial result_data, got %s", delivering.ResultData) } failed, err := svc.UpdateFulfillment(FulfillmentUpdateInput{ MerchantID: merchantID, APIClientID: 13, OrderNo: created.Order.OrderNo, Status: model.OrderStatusShipFailed, FailureReason: "上游暂不可用", }) if err != nil { t.Fatalf("mark failed: %v", err) } if failed.ResultData != delivering.ResultData { t.Fatalf("result_data should be kept when omitted, before=%s after=%s", delivering.ResultData, failed.ResultData) } } func TestMarkProcessingTimeoutsMarksStaleOrdersFailed(t *testing.T) { db := newServiceTestDB(t) merchantID, product := seedFulfillmentMerchant(t, db, "merchant-timeout", 1000, -1, 100) svc := NewFulfillmentService(db, nil) created, err := svc.CreateOrder(CreateFulfillmentOrderInput{ MerchantID: merchantID, APIClientID: 13, ClientOrderNo: "client-timeout", SKU: product.SKU, }) if err != nil { t.Fatalf("create order: %v", err) } delivering, err := svc.UpdateFulfillment(FulfillmentUpdateInput{ MerchantID: merchantID, APIClientID: 13, OrderNo: created.Order.OrderNo, Status: model.OrderStatusDelivering, }) if err != nil { t.Fatalf("mark delivering: %v", err) } if err := db.Model(&model.FulfillmentOrder{}). Where("id = ?", delivering.ID). Update("updated_at", time.Now().Add(-time.Hour)).Error; err != nil { t.Fatalf("age delivering order: %v", err) } changed, err := svc.MarkProcessingTimeouts(30*time.Minute, 10) if err != nil { t.Fatalf("mark timeouts: %v", err) } if changed != 1 { t.Fatalf("expected one timed out order, got %d", changed) } var order model.FulfillmentOrder if err := db.First(&order, delivering.ID).Error; err != nil { t.Fatalf("query order: %v", err) } if order.OrderStatus != model.OrderStatusShipFailed || !strings.Contains(order.FailureReason, "发货超时") { t.Fatalf("expected failed timeout order, got %+v", order) } if !strings.Contains(order.ResultData, `"timeout":true`) { t.Fatalf("timeout result_data should be recorded, got %s", order.ResultData) } } func TestMarkProcessingTimeoutsSkipsRecentOrders(t *testing.T) { db := newServiceTestDB(t) merchantID, product := seedFulfillmentMerchant(t, db, "merchant-timeout-skip", 1000, -1, 100) svc := NewFulfillmentService(db, nil) created, err := svc.CreateOrder(CreateFulfillmentOrderInput{ MerchantID: merchantID, APIClientID: 13, ClientOrderNo: "client-timeout-skip", SKU: product.SKU, }) if err != nil { t.Fatalf("create order: %v", err) } delivering, err := svc.UpdateFulfillment(FulfillmentUpdateInput{ MerchantID: merchantID, APIClientID: 13, OrderNo: created.Order.OrderNo, Status: model.OrderStatusDelivering, }) if err != nil { t.Fatalf("mark delivering: %v", err) } changed, err := svc.MarkProcessingTimeouts(30*time.Minute, 10) if err != nil { t.Fatalf("mark timeouts: %v", err) } if changed != 0 { t.Fatalf("recent delivering order should not time out, got %d", changed) } var order model.FulfillmentOrder if err := db.First(&order, delivering.ID).Error; err != nil { t.Fatalf("query order: %v", err) } if order.OrderStatus != model.OrderStatusDelivering { t.Fatalf("expected delivering order, got %+v", order) } } func TestMergeResultDataKeepsHistoricalFields(t *testing.T) { merged := mergeResultData(`{"a":1,"stage":"claimed","nested":{"x":true}}`, map[string]interface{}{ "stage": "submitted", "b": 2, }) var m map[string]interface{} if err := json.Unmarshal([]byte(merged), &m); err != nil { t.Fatalf("merge result should be valid json: %v", err) } if m["stage"] != "submitted" || m["a"] != float64(1) || m["b"] != float64(2) { t.Fatalf("merge should override patched keys and keep others, got %s", merged) } nested, ok := m["nested"].(map[string]interface{}) if !ok || nested["x"] != true { t.Fatalf("merge should keep nested objects, got %s", merged) } if got := mergeResultData("not-json", map[string]interface{}{"k": "v"}); got == "not-json" { t.Fatalf("invalid existing json should be replaced, got %s", got) } if got := mergeResultData(`{"k":"v"}`, nil); got != `{"k":"v"}` { t.Fatalf("empty patch should keep existing, got %s", got) } } func TestMarkProcessingTimeoutDistinguishesUpstreamSubmission(t *testing.T) { db := newServiceTestDB(t) merchantID, product := seedFulfillmentMerchant(t, db, "merchant-timeout-kind", 1000, -1, 100) svc := NewFulfillmentService(db, nil) submittedOrder, err := svc.CreateOrder(CreateFulfillmentOrderInput{ MerchantID: merchantID, APIClientID: 13, ClientOrderNo: "client-timeout-submitted", SKU: product.SKU, }) if err != nil { t.Fatalf("create submitted order: %v", err) } if _, err := svc.UpdateFulfillment(FulfillmentUpdateInput{ MerchantID: merchantID, APIClientID: 13, OrderNo: submittedOrder.Order.OrderNo, Status: model.OrderStatusDelivering, ResultData: map[string]interface{}{ "provider_order_stage": "submitted", "provider_order_no": "provider-1", "upstream_order": map[string]interface{}{"order_id": "up-1"}, }, }); err != nil { t.Fatalf("mark submitted delivering: %v", err) } interruptedOrder, err := svc.CreateOrder(CreateFulfillmentOrderInput{ MerchantID: merchantID, APIClientID: 13, ClientOrderNo: "client-timeout-interrupted", SKU: product.SKU, }) if err != nil { t.Fatalf("create interrupted order: %v", err) } if _, err := svc.UpdateFulfillment(FulfillmentUpdateInput{ MerchantID: merchantID, APIClientID: 13, OrderNo: interruptedOrder.Order.OrderNo, Status: model.OrderStatusDelivering, ResultData: map[string]interface{}{ "provider_order_stage": "claimed", }, }); err != nil { t.Fatalf("mark claimed delivering: %v", err) } cutoff := time.Now().Add(-time.Hour) for _, id := range []uint{submittedOrder.Order.ID, interruptedOrder.Order.ID} { if err := db.Model(&model.FulfillmentOrder{}).Where("id = ?", id).Update("updated_at", cutoff).Error; err != nil { t.Fatalf("age order %d: %v", id, err) } } changed, err := svc.MarkProcessingTimeouts(30*time.Minute, 10) if err != nil { t.Fatalf("mark timeouts: %v", err) } if changed != 2 { t.Fatalf("expected both orders timed out, got %d", changed) } var submitted model.FulfillmentOrder if err := db.First(&submitted, submittedOrder.Order.ID).Error; err != nil { t.Fatalf("query submitted order: %v", err) } if submitted.OrderStatus != model.OrderStatusShipFailed || !strings.Contains(submitted.FailureReason, "已提交上游") { t.Fatalf("submitted-upstream order should warn about upstream, got %+v", submitted) } if !strings.Contains(submitted.ResultData, `"provider_order_no":"provider-1"`) { t.Fatalf("submitted-upstream order should keep context, got %s", submitted.ResultData) } var interrupted model.FulfillmentOrder if err := db.First(&interrupted, interruptedOrder.Order.ID).Error; err != nil { t.Fatalf("query interrupted order: %v", err) } if interrupted.OrderStatus != model.OrderStatusShipFailed || !strings.Contains(interrupted.FailureReason, "提交中断") { t.Fatalf("interrupted order should say retryable, got %+v", interrupted) } } func TestFulfillmentMerchantIsolation(t *testing.T) { db := newServiceTestDB(t) merchantA, productA := seedFulfillmentMerchant(t, db, "merchant-d", 1000, 1, 100) merchantB, _ := seedFulfillmentMerchant(t, db, "merchant-e", 1000, 1, 100) svc := NewFulfillmentService(db, nil) created, err := svc.CreateOrder(CreateFulfillmentOrderInput{ MerchantID: merchantA, APIClientID: 14, ClientOrderNo: "client-isolation", SKU: productA.SKU, }) if err != nil { t.Fatalf("create order: %v", err) } if _, err := svc.GetOrder(merchantB, created.Order.OrderNo); err == nil { t.Fatalf("other merchant should not read the order") } } func TestWalletAdjustIsIdempotentPerMerchant(t *testing.T) { db := newServiceTestDB(t) merchantA, _ := seedFulfillmentMerchant(t, db, "merchant-f", 0, -1, 100) merchantB, _ := seedFulfillmentMerchant(t, db, "merchant-g", 0, -1, 100) svc := NewFulfillmentService(db, nil) for _, merchantID := range []uint{merchantA, merchantB} { wallet, err := svc.AdjustWallet(WalletAdjustInput{ MerchantID: merchantID, ActorUserID: 1, Amount: 500, IdempotencyKey: "same-key", Note: "充值", }) if err != nil { t.Fatalf("adjust wallet merchant %d: %v", merchantID, err) } if wallet.AvailableBalance != 500 { t.Fatalf("unexpected balance for merchant %d: %d", merchantID, wallet.AvailableBalance) } } wallet, err := svc.AdjustWallet(WalletAdjustInput{ MerchantID: merchantA, ActorUserID: 1, Amount: 500, IdempotencyKey: "same-key", Note: "重复充值", }) if err != nil { t.Fatalf("repeat adjust: %v", err) } if wallet.AvailableBalance != 500 { t.Fatalf("repeat adjust should not change balance, got %d", wallet.AvailableBalance) } } func TestCanFulfill(t *testing.T) { ok, reason := CanFulfill(&model.FulfillmentOrder{ OrderStatus: model.OrderStatusShipFailed, }) if !ok || reason != "" { t.Fatalf("failed paid order should be fulfillable") } ok, reason = CanFulfill(&model.FulfillmentOrder{ OrderStatus: model.OrderStatusShipFailed, ProviderOrderNo: "provider-1", }) if ok || !strings.Contains(reason, "已提交上游") { t.Fatalf("failed order already submitted upstream should not be re-fulfillable, ok=%v reason=%q", ok, reason) } ok, _ = CanFulfill(&model.FulfillmentOrder{ OrderStatus: model.OrderStatusCancelled, }) if ok { t.Fatalf("cancelled order should not be fulfillable") } } func TestCreateOrderRejectsInsufficientBalance(t *testing.T) { db := newServiceTestDB(t) merchantID, product := seedFulfillmentMerchant(t, db, "merchant-h", 50, 1, 100) svc := NewFulfillmentService(db, nil) _, err := svc.CreateOrder(CreateFulfillmentOrderInput{ MerchantID: merchantID, APIClientID: 15, ClientOrderNo: "client-low-balance", SKU: product.SKU, }) if err == nil || !strings.Contains(err.Error(), "余额不足") { t.Fatalf("expected insufficient balance error, got %v", err) } var wallet model.WalletAccount _ = db.Where("merchant_id = ?", merchantID).First(&wallet).Error if wallet.AvailableBalance != 50 { t.Fatalf("balance should remain unchanged, got %d", wallet.AvailableBalance) } } func TestCreateOrderRejectsInactiveProduct(t *testing.T) { db := newServiceTestDB(t) merchantID, product := seedFulfillmentMerchant(t, db, "merchant-inactive-product", 5000, 1, 100) svc := NewFulfillmentService(db, nil) // 商户下架商品后:开放接口下单与商户测试订单都应被拒绝 if err := db.Model(&model.MerchantProduct{}).Where("id = ?", product.ID).Update("status", model.ProductStatusInactive).Error; err != nil { t.Fatalf("deactivate product: %v", err) } if _, err := svc.CreateOrder(CreateFulfillmentOrderInput{ MerchantID: merchantID, APIClientID: 16, ClientOrderNo: "client-inactive-product", SKU: product.SKU, }); err == nil || !strings.Contains(err.Error(), "已下架") { t.Fatalf("inactive product should reject order, got %v", err) } if _, err := svc.CreateTestOrder(CreateTestOrderInput{ MerchantID: merchantID, ActorUserID: 99, SKU: product.SKU, }); err == nil || !strings.Contains(err.Error(), "已下架") { t.Fatalf("inactive product should reject test order, got %v", err) } } func TestCreateTestOrderCreatesFulfillableOrderWithoutBilling(t *testing.T) { db := newServiceTestDB(t) merchantID, product := seedFulfillmentMerchant(t, db, "merchant-test-order", 0, 0, 100) originalStock := product.Stock svc := NewFulfillmentService(db, nil) order, err := svc.CreateTestOrder(CreateTestOrderInput{ MerchantID: merchantID, ActorUserID: 99, SKU: product.SKU, BuyerReference: "测试买家 A", Note: "联调测试", }) if err != nil { t.Fatalf("create test order: %v", err) } if !strings.HasPrefix(order.OrderNo, "O") || order.ClientOrderNo != "TEST-"+order.OrderNo { t.Fatalf("unexpected test order numbers: %+v", order) } if order.OrderStatus != model.OrderStatusPaid { t.Fatalf("unexpected test order status: %+v", order) } canShip, reason := CanFulfill(order) if !canShip || reason != "" { t.Fatalf("test order should be fulfillable, canShip=%v reason=%q", canShip, reason) } openOrder, err := svc.QueryOpenOrder(order.OrderNo) if err != nil { t.Fatalf("query open order: %v", err) } if !openOrder.CanShip || openOrder.Product == nil || openOrder.Product.SKU != product.SKU { t.Fatalf("unexpected open order: %+v", openOrder) } var wallet model.WalletAccount if err := db.Where("merchant_id = ?", merchantID).First(&wallet).Error; err != nil { t.Fatalf("query wallet: %v", err) } if wallet.AvailableBalance != 0 { t.Fatalf("test order should not debit wallet, got %d", wallet.AvailableBalance) } var refreshed model.MerchantProduct if err := db.First(&refreshed, product.ID).Error; err != nil { t.Fatalf("query product: %v", err) } if refreshed.Stock != originalStock { t.Fatalf("test order should not decrease stock, got %d", refreshed.Stock) } var ledgerCount int64 db.Model(&model.WalletLedgerEntry{}).Where("merchant_id = ?", merchantID).Count(&ledgerCount) if ledgerCount != 0 { t.Fatalf("test order should not create wallet ledger, got %d", ledgerCount) } } func TestCreateTestOrderSupportsFailedFulfillableStatus(t *testing.T) { db := newServiceTestDB(t) merchantID, product := seedFulfillmentMerchant(t, db, "merchant-test-failed", 0, -1, 100) svc := NewFulfillmentService(db, nil) order, err := svc.CreateTestOrder(CreateTestOrderInput{ MerchantID: merchantID, SKU: product.SKU, OrderStatus: model.OrderStatusShipFailed, Note: "等待重新发货", }) if err != nil { t.Fatalf("create failed test order: %v", err) } if order.OrderStatus != model.OrderStatusShipFailed || order.FailureReason != "等待重新发货" { t.Fatalf("unexpected failed test order: %+v", order) } openOrder, err := svc.QueryOpenOrder(order.OrderNo) if err != nil { t.Fatalf("query open failed order: %v", err) } if !openOrder.CanShip || openOrder.Status != "ship_failed" { t.Fatalf("failed test order should be re-fulfillable, got %+v", openOrder) } } func TestCreateTestOrderSupportsCancelledUnfulfillableStatus(t *testing.T) { db := newServiceTestDB(t) merchantID, product := seedFulfillmentMerchant(t, db, "merchant-test-cancelled", 0, -1, 100) svc := NewFulfillmentService(db, nil) order, err := svc.CreateTestOrder(CreateTestOrderInput{ MerchantID: merchantID, SKU: product.SKU, OrderStatus: model.OrderStatusCancelled, }) if err != nil { t.Fatalf("create cancelled test order: %v", err) } if order.OrderStatus != model.OrderStatusCancelled || order.CancelledAt == nil { t.Fatalf("unexpected cancelled test order: %+v", order) } openOrder, err := svc.QueryOpenOrder(order.OrderNo) if err != nil { t.Fatalf("query open cancelled order: %v", err) } if openOrder.CanShip || openOrder.Status != "cancelled" || !strings.Contains(openOrder.CannotShipReason, "已取消") { t.Fatalf("cancelled test order should be unfulfillable, got %+v", openOrder) } } func TestHandleShipNotifyUpdatesOrderAndEnqueuesMerchantCallback(t *testing.T) { db := newServiceTestDB(t) merchantID, product := seedFulfillmentMerchant(t, db, "merchant-source-notify", 1000, 2, 100) codec, err := NewSecretCodec("test-master-key") if err != nil { t.Fatalf("codec: %v", err) } callbackSvc := NewCallbackService(db, codec) svc := NewFulfillmentService(db, callbackSvc) created, err := svc.CreateOrder(CreateFulfillmentOrderInput{ MerchantID: merchantID, APIClientID: 31, ClientOrderNo: "client-source-notify", SKU: product.SKU, }) if err != nil { t.Fatalf("create order: %v", err) } if _, err := callbackSvc.CreateSubscription(merchantID, CreateCallbackInput{ Name: "发货回调", URL: "https://example.com/callback", Events: "order.shipping.updated", }, 7); err != nil { t.Fatalf("create callback subscription: %v", err) } result, err := svc.HandleShipNotify(ShipNotifyInput{ OrderNo: created.Order.OrderNo, ShipStatus: "success", ProviderOrderNo: "SRC-10001", FailReason: "ignored", }) if err != nil { t.Fatalf("handle ship notify: %v", err) } if result.Status != "delivered" { t.Fatalf("unexpected notify result: %+v", result) } var order model.FulfillmentOrder if err := db.First(&order, created.Order.ID).Error; err != nil { t.Fatalf("query order: %v", err) } if order.OrderStatus != model.OrderStatusDelivered || order.ProviderOrderNo != "SRC-10001" || order.DeliveredAt == nil { t.Fatalf("unexpected order after source notify: %+v", order) } var delivery model.CallbackDelivery if err := db.Where("merchant_id = ? AND event = ?", merchantID, "order.shipping.updated").First(&delivery).Error; err != nil { t.Fatalf("query callback delivery: %v", err) } if !strings.Contains(delivery.Payload, created.Order.OrderNo) || !strings.Contains(delivery.Payload, "SRC-10001") { t.Fatalf("callback payload should contain updated order data, got %s", delivery.Payload) } if !strings.Contains(delivery.Payload, `"can_ship":false`) || strings.Contains(delivery.Payload, "can_fulfill") { t.Fatalf("callback payload should use can_ship fields, got %s", delivery.Payload) } var auditCount int64 db.Model(&model.AuditLog{}).Where("entity_id = ? AND action = ?", created.Order.OrderNo, "ship.notify").Count(&auditCount) if auditCount != 1 { t.Fatalf("expected one ship notify audit, got %d", auditCount) } } func TestHandleShipNotifyRequiresFinalStatusAndFailureReason(t *testing.T) { db := newServiceTestDB(t) merchantID, product := seedFulfillmentMerchant(t, db, "merchant-source-contract", 1000, 2, 100) svc := NewFulfillmentService(db, nil) created, err := svc.CreateOrder(CreateFulfillmentOrderInput{ MerchantID: merchantID, APIClientID: 31, ClientOrderNo: "client-source-contract", SKU: product.SKU, }) if err != nil { t.Fatalf("create order: %v", err) } if _, err := svc.HandleShipNotify(ShipNotifyInput{ OrderNo: created.Order.OrderNo, ShipStatus: "processing", }); err == nil || !strings.Contains(err.Error(), "success/failed") { t.Fatalf("processing should be rejected, got %v", err) } if _, err := svc.HandleShipNotify(ShipNotifyInput{ OrderNo: created.Order.OrderNo, ShipStatus: "failed", }); err == nil || !strings.Contains(err.Error(), "fail_reason") { t.Fatalf("failed without reason should be rejected, got %v", err) } } func TestHandleShipNotifySuccessClearsPreviousResultFailureReason(t *testing.T) { db := newServiceTestDB(t) merchantID, product := seedFulfillmentMerchant(t, db, "merchant-source-clear-reason", 1000, 2, 100) svc := NewFulfillmentService(db, nil) created, err := svc.CreateOrder(CreateFulfillmentOrderInput{ MerchantID: merchantID, APIClientID: 31, ClientOrderNo: "client-source-clear-reason", SKU: product.SKU, }) if err != nil { t.Fatalf("create order: %v", err) } if _, err := svc.HandleShipNotify(ShipNotifyInput{ OrderNo: created.Order.OrderNo, ShipStatus: "failed", FailReason: "渠道服校验失败", }); err != nil { t.Fatalf("mark failed: %v", err) } if _, err := svc.HandleShipNotify(ShipNotifyInput{ OrderNo: created.Order.OrderNo, ShipStatus: "success", }); err != nil { t.Fatalf("mark success: %v", err) } var order model.FulfillmentOrder if err := db.First(&order, created.Order.ID).Error; err != nil { t.Fatalf("query order: %v", err) } if order.OrderStatus != model.OrderStatusDelivered || order.FailureReason != "" { t.Fatalf("success should clear order failure reason, got %+v", order) } if strings.Contains(order.ResultData, "fail_reason") { t.Fatalf("success should clear result_data fail_reason, got %s", order.ResultData) } } func TestBuildShipNotifyResultDataSuccessRemovesFailureReason(t *testing.T) { result := buildShipNotifyResultData(`{"ship_status":"failed","fail_reason":"旧失败原因"}`, ShipNotifyInput{ ShipStatus: "success", }, nil) if strings.Contains(result, "fail_reason") { t.Fatalf("success result data should remove fail_reason, got %s", result) } if !strings.Contains(result, `"ship_status":"success"`) { t.Fatalf("success result data should keep ship_status, got %s", result) } }