package service import ( "encoding/json" "net/http" "net/http/httptest" "strings" "sync/atomic" "testing" "affiliate_dash/internal/model" ) 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 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) } var bindCalls, boundCalls, queueCreateCalls, queuePatchCalls, upstreamCalls int32 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) _ = 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{}{ "order_no": "provider-1", }, }, }) 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-api-001", SKU: "suit_alan_walker", }) if err != nil { t.Fatalf("create order: %v", err) } 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.OrderStatusDelivering || 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.OrderStatusDelivering || afterSubmit.ProviderOrderNo != "provider-1" { t.Fatalf("submit should persist delivering 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) } 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.OrderStatusDelivering { 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 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) } }