472 lines
18 KiB
Go
472 lines
18 KiB
Go
package service
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
"affiliate_dash/internal/model"
|
|
"affiliate_dash/internal/pkg/timeutil"
|
|
)
|
|
|
|
func TestDeliveryLinkGenerateAuthorizeAndRevoke(t *testing.T) {
|
|
db := newServiceTestDB(t)
|
|
merchantID, product := seedFulfillmentMerchant(t, db, "delivery-link", 5000, 5, 100)
|
|
fulfillmentSvc := NewFulfillmentService(db, nil)
|
|
deliverySvc := NewDeliveryService(fulfillmentSvc, "https://bff.example", "dlc", "https://shop.example", "link-secret", 60)
|
|
|
|
created, err := fulfillmentSvc.CreateOrder(CreateFulfillmentOrderInput{
|
|
MerchantID: merchantID,
|
|
APIClientID: 1,
|
|
ClientOrderNo: "delivery-link-001",
|
|
SKU: product.SKU,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("create order: %v", err)
|
|
}
|
|
|
|
link, err := deliverySvc.GetOrCreateDeliveryLink(merchantID, created.Order.OrderNo)
|
|
if err != nil {
|
|
t.Fatalf("get delivery link: %v", err)
|
|
}
|
|
if !strings.Contains(link.DeliveryURL, "exp=") || !strings.Contains(link.DeliveryURL, "sign=") {
|
|
t.Fatalf("delivery url should contain exp/sign, got %s", link.DeliveryURL)
|
|
}
|
|
|
|
if _, err := deliverySvc.GetOrder(created.Order.OrderNo, DeliveryLinkAuth{Exp: link.Exp, Sign: link.Sign}); err != nil {
|
|
t.Fatalf("authorize delivery link: %v", err)
|
|
}
|
|
|
|
if err := deliverySvc.RevokeDeliveryLink(merchantID, created.Order.OrderNo); err != nil {
|
|
t.Fatalf("revoke delivery link: %v", err)
|
|
}
|
|
if _, err := deliverySvc.GetOrder(created.Order.OrderNo, DeliveryLinkAuth{Exp: link.Exp, Sign: link.Sign}); err == nil {
|
|
t.Fatalf("revoked link should be rejected")
|
|
} else if sc, ok := err.(interface{ HTTPStatus() int }); !ok || sc.HTTPStatus() != 403 {
|
|
t.Fatalf("expected 403 after revoke, got %v", err)
|
|
}
|
|
|
|
var order model.FulfillmentOrder
|
|
if err := db.Where("order_no = ?", created.Order.OrderNo).First(&order).Error; err != nil {
|
|
t.Fatalf("reload order: %v", err)
|
|
}
|
|
if order.DeliveryLinkExpiresAt == nil {
|
|
t.Fatalf("delivery_link_expires_at should be stored")
|
|
}
|
|
if order.DeliveryLinkRevokedAt == nil {
|
|
t.Fatalf("delivery_link_revoked_at should be stored")
|
|
}
|
|
|
|
restored, err := deliverySvc.RestoreDeliveryLink(merchantID, created.Order.OrderNo)
|
|
if err != nil {
|
|
t.Fatalf("restore delivery link: %v", err)
|
|
}
|
|
if restored.Exp == link.Exp || restored.Sign == link.Sign || restored.DeliveryURL == link.DeliveryURL {
|
|
t.Fatalf("restored link should rotate exp/sign, old=%+v restored=%+v", link, restored)
|
|
}
|
|
if _, err := deliverySvc.GetOrder(created.Order.OrderNo, DeliveryLinkAuth{Exp: link.Exp, Sign: link.Sign}); err == nil {
|
|
t.Fatalf("old link should stay invalid after restore")
|
|
} else if sc, ok := err.(interface{ HTTPStatus() int }); !ok || sc.HTTPStatus() != 403 {
|
|
t.Fatalf("expected 403 for old link after restore, got %v", err)
|
|
}
|
|
if _, err := deliverySvc.GetOrder(created.Order.OrderNo, DeliveryLinkAuth{Exp: restored.Exp, Sign: restored.Sign}); err != nil {
|
|
t.Fatalf("restored link should be accepted: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestDeliveryGoodIDIncludesMedalAndCoinProducts(t *testing.T) {
|
|
cases := map[string]string{
|
|
"honor_medal_x2": "682ef39ca8f40c4234c59f45",
|
|
"honor_medal_x30": "682ef39ca8f40c4234c59f43",
|
|
"honor_medal_x90": "682ef39ca8f40c4234c59f41",
|
|
"lucky_coin_x2": "682ef39ca8f40c4234c59f46",
|
|
"lucky_coin_x30": "682ef39ca8f40c4234c59f44",
|
|
"lucky_coin_x90": "682ef39ca8f40c4234c59f42",
|
|
}
|
|
for sku, want := range cases {
|
|
if got := deliveryGoodID("dlc", sku); got != want {
|
|
t.Fatalf("unexpected good id for %s: got %q want %q", sku, got, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestDeliveryMerchantApiBindAndSubmit(t *testing.T) {
|
|
db := newServiceTestDB(t)
|
|
merchantID, _ := seedFulfillmentMerchant(t, db, "delivery-api", 5000, 5, 100)
|
|
product := model.Product{Code: "delivery-api-alt-product", Name: "测试发货商品", Status: model.ProductStatusActive}
|
|
if err := db.Create(&product).Error; err != nil {
|
|
t.Fatalf("create product: %v", err)
|
|
}
|
|
if err := db.Create(&model.MerchantProduct{
|
|
MerchantID: merchantID,
|
|
ProductID: product.ID,
|
|
SKU: "suit_alan_walker",
|
|
DisplayName: "测试发货商品",
|
|
PriceAmount: 100,
|
|
Currency: "POINT",
|
|
Stock: 5,
|
|
Status: model.ProductStatusActive,
|
|
}).Error; err != nil {
|
|
t.Fatalf("create merchant product: %v", err)
|
|
}
|
|
|
|
fulfillmentSvc := NewFulfillmentService(db, nil)
|
|
var bindCalls, boundCalls, queueCreateCalls, queuePatchCalls, upstreamCalls, upstreamSawCanShip int32
|
|
orderNoForUpstreamCheck := ""
|
|
var requestLog []string
|
|
mockBFF := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path != "/sign-proxy" {
|
|
w.WriteHeader(http.StatusNotFound)
|
|
return
|
|
}
|
|
var payload struct {
|
|
Path string `json:"path"`
|
|
Method string `json:"method"`
|
|
Data json.RawMessage `json:"data"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
return
|
|
}
|
|
requestLog = append(requestLog, payload.Method+" "+payload.Path)
|
|
w.Header().Set("Content-Type", "application/json")
|
|
switch payload.Path {
|
|
case "/public/goods/detail":
|
|
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
|
"code": 0,
|
|
"data": map[string]interface{}{
|
|
"good": map[string]interface{}{
|
|
"title": "测试发货商品",
|
|
"image": "https://img.example/good.png",
|
|
},
|
|
},
|
|
})
|
|
case "/public/games/bind-account":
|
|
atomic.AddInt32(&bindCalls, 1)
|
|
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
|
"code": 0,
|
|
"data": map[string]interface{}{
|
|
"bind_uuid": "bind-123",
|
|
"url": "https://bind.example/qrcode",
|
|
},
|
|
})
|
|
case "/public/games/account-bound":
|
|
atomic.AddInt32(&boundCalls, 1)
|
|
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
|
"code": 0,
|
|
"data": map[string]interface{}{
|
|
"gameAccount": map[string]interface{}{
|
|
"game_account": "4808146277",
|
|
"game_account_role_name": "巫师哈丁12",
|
|
"game_account_area": "安卓",
|
|
"game_account_plat": "QQ",
|
|
"game_account_channel": "安卓-QQ",
|
|
},
|
|
},
|
|
})
|
|
case "/public/users/orders-queue":
|
|
if payload.Method == http.MethodPost {
|
|
atomic.AddInt32(&queueCreateCalls, 1)
|
|
if orderNoForUpstreamCheck != "" {
|
|
openOrder, err := fulfillmentSvc.QueryOpenOrder(orderNoForUpstreamCheck)
|
|
if err == nil && openOrder.Status == model.OrderStatusPaid && openOrder.CanShip {
|
|
atomic.StoreInt32(&upstreamSawCanShip, 1)
|
|
} else {
|
|
atomic.StoreInt32(&upstreamSawCanShip, -1)
|
|
}
|
|
}
|
|
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
|
"code": 0,
|
|
"data": map[string]interface{}{
|
|
"orders": []map[string]interface{}{
|
|
{"_id": "queue-1"},
|
|
},
|
|
},
|
|
})
|
|
return
|
|
}
|
|
if payload.Method == http.MethodPatch {
|
|
atomic.AddInt32(&queuePatchCalls, 1)
|
|
_ = json.NewEncoder(w).Encode(map[string]interface{}{"code": 0, "data": map[string]interface{}{}})
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusMethodNotAllowed)
|
|
case "/public/users/orders":
|
|
atomic.AddInt32(&upstreamCalls, 1)
|
|
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
|
"code": 0,
|
|
"data": map[string]interface{}{
|
|
"order": map[string]interface{}{
|
|
"_id": "provider-1",
|
|
"order_sn": "local-order-sn",
|
|
"status": "FINISHED",
|
|
"send_status": "SUCCESS",
|
|
},
|
|
},
|
|
})
|
|
default:
|
|
w.WriteHeader(http.StatusNotFound)
|
|
}
|
|
}))
|
|
defer mockBFF.Close()
|
|
|
|
deliverySvc := NewDeliveryService(fulfillmentSvc, mockBFF.URL, "dlc", "https://shop.example", "link-secret", 60)
|
|
|
|
created, err := fulfillmentSvc.CreateOrder(CreateFulfillmentOrderInput{
|
|
MerchantID: merchantID,
|
|
APIClientID: 1,
|
|
ClientOrderNo: "delivery-api-001",
|
|
SKU: "suit_alan_walker",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("create order: %v", err)
|
|
}
|
|
orderNoForUpstreamCheck = created.Order.OrderNo
|
|
|
|
info, err := deliverySvc.GetMerchantOrder(merchantID, created.Order.OrderNo)
|
|
if err != nil {
|
|
t.Fatalf("get merchant order: %v", err)
|
|
}
|
|
if !info.CanShip || info.Product == nil || info.Product.Image == "" {
|
|
t.Fatalf("merchant order should include delivery data: %+v", info)
|
|
}
|
|
|
|
bind, err := deliverySvc.BindForMerchant(merchantID, created.Order.OrderNo, "4808146277")
|
|
if err != nil {
|
|
t.Fatalf("bind for merchant: %v", err)
|
|
}
|
|
if bind.BindUUID != "bind-123" || bind.BindURL == "" || bind.QRURL == "" {
|
|
t.Fatalf("unexpected bind result: %+v", bind)
|
|
}
|
|
|
|
submitted, err := deliverySvc.SubmitForMerchant(merchantID, 88, created.Order.OrderNo, "4808146277", bind.BindUUID)
|
|
if err != nil {
|
|
t.Fatalf("submit for merchant: %v", err)
|
|
}
|
|
if submitted.Status != model.OrderStatusDelivered || submitted.ProviderOrderNo != "provider-1" {
|
|
t.Fatalf("unexpected submit result: %+v", submitted)
|
|
}
|
|
var afterSubmit model.FulfillmentOrder
|
|
if err := db.Where("order_no = ?", created.Order.OrderNo).First(&afterSubmit).Error; err != nil {
|
|
t.Fatalf("reload after submit: %v", err)
|
|
}
|
|
if afterSubmit.OrderStatus != model.OrderStatusDelivered || afterSubmit.ProviderOrderNo != "provider-1" || afterSubmit.DeliveredAt == nil {
|
|
t.Fatalf("submit should persist delivered state: %+v", afterSubmit)
|
|
}
|
|
if atomic.LoadInt32(&queueCreateCalls) != 1 || atomic.LoadInt32(&upstreamCalls) != 1 {
|
|
t.Fatalf("first submit should only create one queue, queue=%d upstream=%d requests=%v", queueCreateCalls, upstreamCalls, requestLog)
|
|
}
|
|
if atomic.LoadInt32(&upstreamSawCanShip) != 1 {
|
|
t.Fatalf("upstream re-query during submit should still see paid/can_ship=true")
|
|
}
|
|
second, err := deliverySvc.SubmitForMerchant(merchantID, 88, created.Order.OrderNo, "4808146277", bind.BindUUID)
|
|
if err != nil {
|
|
t.Fatalf("repeat submit for merchant: %v", err)
|
|
}
|
|
if second.ProviderOrderNo != "provider-1" || second.Status != model.OrderStatusDelivered {
|
|
t.Fatalf("repeat submit should reuse existing result: %+v", second)
|
|
}
|
|
if atomic.LoadInt32(&queueCreateCalls) != 1 || atomic.LoadInt32(&upstreamCalls) != 1 {
|
|
t.Fatalf("submit should be idempotent, queue=%d upstream=%d requests=%v", queueCreateCalls, upstreamCalls, requestLog)
|
|
}
|
|
if atomic.LoadInt32(&bindCalls) != 1 || atomic.LoadInt32(&queuePatchCalls) != 1 || atomic.LoadInt32(&boundCalls) != 1 {
|
|
t.Fatalf("unexpected mock counts bind=%d bound=%d patch=%d", bindCalls, boundCalls, queuePatchCalls)
|
|
}
|
|
}
|
|
|
|
func TestDeliveryClaimBlocksRecentInProgressStage(t *testing.T) {
|
|
recentStartedAt := time.Now().Add(-time.Minute)
|
|
merchantID, deliverySvc, order := createDeliveryOrderWithSubmitStage(t, "delivery-claim-recent", deliveryStageClaimed, recentStartedAt, 1)
|
|
|
|
claimed, claimedNow, err := deliverySvc.claimDeliverySubmission(merchantID, 88, order.OrderNo)
|
|
if err != nil {
|
|
t.Fatalf("claim recent in-progress order: %v", err)
|
|
}
|
|
if claimedNow {
|
|
t.Fatalf("recent in-progress stage should still block duplicate claim")
|
|
}
|
|
if got := resultDataNumber(claimed.ResultData, "ship_attempts"); got != 1 {
|
|
t.Fatalf("blocked claim should keep attempts, got %d", got)
|
|
}
|
|
}
|
|
|
|
func TestDeliveryClaimRecoversStaleInProgressStage(t *testing.T) {
|
|
staleStartedAt := time.Now().Add(-deliverySubmissionStaleTimeout - time.Minute)
|
|
merchantID, deliverySvc, order := createDeliveryOrderWithSubmitStage(t, "delivery-claim-stale", deliveryStageQueueCreated, staleStartedAt, 1)
|
|
|
|
claimed, claimedNow, err := deliverySvc.claimDeliverySubmission(merchantID, 88, order.OrderNo)
|
|
if err != nil {
|
|
t.Fatalf("claim stale in-progress order: %v", err)
|
|
}
|
|
if !claimedNow {
|
|
t.Fatalf("stale in-progress stage should allow a fresh claim")
|
|
}
|
|
if got := resultDataString(claimed.ResultData, "provider_order_stage"); got != deliveryStageClaimed {
|
|
t.Fatalf("stale claim should reset stage to claimed, got %q", got)
|
|
}
|
|
if got := resultDataNumber(claimed.ResultData, "ship_attempts"); got != 2 {
|
|
t.Fatalf("stale claim should count a new attempt, got %d", got)
|
|
}
|
|
if got := resultDataString(claimed.ResultData, "submit_started_at"); got == "" || got == timeutil.FormatAPITime(staleStartedAt) {
|
|
t.Fatalf("stale claim should refresh submit_started_at, got %q", got)
|
|
}
|
|
}
|
|
|
|
func createDeliveryOrderWithSubmitStage(t *testing.T, merchantCode, stage string, startedAt time.Time, attempts int64) (uint, *DeliveryService, *model.FulfillmentOrder) {
|
|
t.Helper()
|
|
db := newServiceTestDB(t)
|
|
merchantID, product := seedFulfillmentMerchant(t, db, merchantCode, 5000, 5, 100)
|
|
fulfillmentSvc := NewFulfillmentService(db, nil)
|
|
created, err := fulfillmentSvc.CreateOrder(CreateFulfillmentOrderInput{
|
|
MerchantID: merchantID,
|
|
APIClientID: 1,
|
|
ClientOrderNo: merchantCode + "-001",
|
|
SKU: product.SKU,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("create order: %v", err)
|
|
}
|
|
resultData := mergeResultData(created.Order.ResultData, map[string]interface{}{
|
|
"provider_order_stage": stage,
|
|
"submit_started_at": timeutil.FormatAPITime(startedAt),
|
|
"ship_attempts": attempts,
|
|
})
|
|
if err := db.Model(&model.FulfillmentOrder{}).
|
|
Where("id = ?", created.Order.ID).
|
|
Update("result_data", resultData).Error; err != nil {
|
|
t.Fatalf("seed submit stage: %v", err)
|
|
}
|
|
var order model.FulfillmentOrder
|
|
if err := db.First(&order, created.Order.ID).Error; err != nil {
|
|
t.Fatalf("reload order: %v", err)
|
|
}
|
|
deliverySvc := NewDeliveryService(fulfillmentSvc, "https://bff.example", "dlc", "https://shop.example", "link-secret", 60)
|
|
return merchantID, deliverySvc, &order
|
|
}
|
|
|
|
func TestDeliveryResubmitBlockedAfterUpstreamSubmission(t *testing.T) {
|
|
db := newServiceTestDB(t)
|
|
merchantID, _ := seedFulfillmentMerchant(t, db, "delivery-guard", 5000, 5, 100)
|
|
product := model.Product{Code: "delivery-guard-catalog", Name: "测试发货商品", Status: model.ProductStatusActive}
|
|
if err := db.Create(&product).Error; err != nil {
|
|
t.Fatalf("create product: %v", err)
|
|
}
|
|
if err := db.Create(&model.MerchantProduct{
|
|
MerchantID: merchantID,
|
|
ProductID: product.ID,
|
|
SKU: "suit_alan_walker",
|
|
DisplayName: "测试发货商品",
|
|
PriceAmount: 100,
|
|
Currency: "POINT",
|
|
Stock: 5,
|
|
Status: model.ProductStatusActive,
|
|
}).Error; err != nil {
|
|
t.Fatalf("create merchant product: %v", err)
|
|
}
|
|
|
|
var queueCreateCalls, upstreamCalls int32
|
|
mockBFF := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path != "/sign-proxy" {
|
|
w.WriteHeader(http.StatusNotFound)
|
|
return
|
|
}
|
|
var payload struct {
|
|
Path string `json:"path"`
|
|
Method string `json:"method"`
|
|
Data json.RawMessage `json:"data"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
switch payload.Path {
|
|
case "/public/goods/detail":
|
|
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
|
"code": 0,
|
|
"data": map[string]interface{}{"good": map[string]interface{}{"title": "测试发货商品"}},
|
|
})
|
|
case "/public/games/bind-account":
|
|
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
|
"code": 0,
|
|
"data": map[string]interface{}{"bind_uuid": "bind-1", "url": "https://bind.example/qr"},
|
|
})
|
|
case "/public/games/account-bound":
|
|
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
|
"code": 0,
|
|
"data": map[string]interface{}{
|
|
"gameAccount": map[string]interface{}{
|
|
"game_account": "4808146277",
|
|
"game_account_role_name": "角色甲",
|
|
"game_account_area": "安卓",
|
|
"game_account_plat": "QQ",
|
|
},
|
|
},
|
|
})
|
|
case "/public/users/orders-queue":
|
|
if payload.Method == http.MethodPost {
|
|
atomic.AddInt32(&queueCreateCalls, 1)
|
|
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
|
"code": 0,
|
|
"data": map[string]interface{}{"orders": []map[string]interface{}{{"_id": "queue-g1"}}},
|
|
})
|
|
return
|
|
}
|
|
if payload.Method == http.MethodPatch {
|
|
_ = json.NewEncoder(w).Encode(map[string]interface{}{"code": 0, "data": map[string]interface{}{}})
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusMethodNotAllowed)
|
|
case "/public/users/orders":
|
|
atomic.AddInt32(&upstreamCalls, 1)
|
|
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
|
"code": 0,
|
|
"data": map[string]interface{}{"order": map[string]interface{}{"order_no": "provider-g1"}},
|
|
})
|
|
default:
|
|
w.WriteHeader(http.StatusNotFound)
|
|
}
|
|
}))
|
|
defer mockBFF.Close()
|
|
|
|
fulfillmentSvc := NewFulfillmentService(db, nil)
|
|
deliverySvc := NewDeliveryService(fulfillmentSvc, mockBFF.URL, "dlc", "https://shop.example", "link-secret", 60)
|
|
|
|
created, err := fulfillmentSvc.CreateOrder(CreateFulfillmentOrderInput{
|
|
MerchantID: merchantID,
|
|
APIClientID: 1,
|
|
ClientOrderNo: "delivery-guard-001",
|
|
SKU: "suit_alan_walker",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("create order: %v", err)
|
|
}
|
|
if _, err := deliverySvc.SubmitForMerchant(merchantID, 88, created.Order.OrderNo, "4808146277", "bind-1"); err != nil {
|
|
t.Fatalf("submit for merchant: %v", err)
|
|
}
|
|
if atomic.LoadInt32(&queueCreateCalls) != 1 || atomic.LoadInt32(&upstreamCalls) != 1 {
|
|
t.Fatalf("first submit should create upstream once, queue=%d upstream=%d", queueCreateCalls, upstreamCalls)
|
|
}
|
|
|
|
// 模拟超时巡检把已提交上游的订单标记为发货失败(保留 provider_order_no 与 result_data 上下文)。
|
|
if err := db.Model(&model.FulfillmentOrder{}).Where("id = ?", created.Order.ID).
|
|
Update("order_status", model.OrderStatusShipFailed).Error; err != nil {
|
|
t.Fatalf("mark ship_failed: %v", err)
|
|
}
|
|
|
|
_, err = deliverySvc.SubmitForMerchant(merchantID, 88, created.Order.OrderNo, "4808146277", "bind-1")
|
|
if err == nil {
|
|
t.Fatalf("resubmit after upstream submission should be rejected")
|
|
}
|
|
if sc, ok := err.(interface{ HTTPStatus() int }); !ok || sc.HTTPStatus() != http.StatusConflict {
|
|
t.Fatalf("expected 409 for resubmit guard, got %v", err)
|
|
}
|
|
if atomic.LoadInt32(&queueCreateCalls) != 1 || atomic.LoadInt32(&upstreamCalls) != 1 {
|
|
t.Fatalf("guard should prevent new upstream order, queue=%d upstream=%d", queueCreateCalls, upstreamCalls)
|
|
}
|
|
}
|