diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index ff9011e..0fae64b 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -74,7 +74,7 @@ func main() { Dashboard: handler.NewDashboardHandler(fulfillmentSvc), Delivery: handler.NewDeliveryHandler(deliverySvc), User: handler.NewUserHandler(userSvc), - Open: handler.NewOpenV1Handler(merchantSvc, fulfillmentSvc), + Open: handler.NewOpenV1Handler(merchantSvc, fulfillmentSvc, deliverySvc), SourceOpen: handler.NewOpenHandler(fulfillmentSvc), Merchant: handler.NewMerchantHandler(merchantSvc, fulfillmentSvc, callbackSvc, deliverySvc), JWT: jm, diff --git a/backend/internal/handler/open_v1.go b/backend/internal/handler/open_v1.go index f6267ef..22735ca 100644 --- a/backend/internal/handler/open_v1.go +++ b/backend/internal/handler/open_v1.go @@ -18,10 +18,11 @@ import ( type OpenV1Handler struct { merchantSvc *service.MerchantService fulfillmentSvc *service.FulfillmentService + deliverySvc *service.DeliveryService } -func NewOpenV1Handler(merchantSvc *service.MerchantService, fulfillmentSvc *service.FulfillmentService) *OpenV1Handler { - return &OpenV1Handler{merchantSvc: merchantSvc, fulfillmentSvc: fulfillmentSvc} +func NewOpenV1Handler(merchantSvc *service.MerchantService, fulfillmentSvc *service.FulfillmentService, deliverySvc *service.DeliveryService) *OpenV1Handler { + return &OpenV1Handler{merchantSvc: merchantSvc, fulfillmentSvc: fulfillmentSvc, deliverySvc: deliverySvc} } func (h *OpenV1Handler) ListProducts(c *gin.Context) { @@ -162,6 +163,106 @@ func (h *OpenV1Handler) GetWallet(c *gin.Context) { response.OK(c, wallet) } +func (h *OpenV1Handler) GetDeliveryLink(c *gin.Context) { + openlog.SetAction(c, openlog.ActionQuery) + orderNo := c.Param("order_no") + openlog.Info(c, "delivery_link start order_no=%s", orderNo) + link, err := h.deliverySvc.GetOrCreateDeliveryLink(middleware.GetMerchantID(c), orderNo) + if err != nil { + openlog.Warn(c, "delivery_link fail order_no=%s err=%v", orderNo, err) + if sc, ok := err.(interface{ HTTPStatus() int }); ok { + response.Fail(c, sc.HTTPStatus(), sc.HTTPStatus(), err.Error()) + return + } + if err.Error() == "订单不存在" { + response.NotFound(c, err.Error()) + return + } + response.BadRequest(c, err.Error()) + return + } + response.OK(c, link) +} + +func (h *OpenV1Handler) GetDeliveryOrder(c *gin.Context) { + openlog.SetAction(c, openlog.ActionQuery) + orderNo := c.Param("order_no") + openlog.Info(c, "delivery_get start order_no=%s", orderNo) + order, err := h.deliverySvc.GetMerchantOrder(middleware.GetMerchantID(c), orderNo) + if err != nil { + openlog.Warn(c, "delivery_get fail order_no=%s err=%v", orderNo, err) + if err.Error() == "订单不存在" { + response.NotFound(c, err.Error()) + return + } + if sc, ok := err.(interface{ HTTPStatus() int }); ok { + response.Fail(c, sc.HTTPStatus(), sc.HTTPStatus(), err.Error()) + return + } + response.BadRequest(c, err.Error()) + return + } + response.OK(c, order) +} + +type openDeliveryBindReq struct { + GameAccount string `json:"game_account" binding:"required"` +} + +func (h *OpenV1Handler) BindDeliveryOrder(c *gin.Context) { + openlog.SetAction(c, openlog.ActionCreate) + var req openDeliveryBindReq + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "请输入玩家编号") + return + } + orderNo := c.Param("order_no") + openlog.Info(c, "delivery_bind start order_no=%s", orderNo) + result, err := h.deliverySvc.BindForMerchant(middleware.GetMerchantID(c), orderNo, req.GameAccount) + if err != nil { + openlog.Warn(c, "delivery_bind fail order_no=%s err=%v", orderNo, err) + if sc, ok := err.(interface{ HTTPStatus() int }); ok { + response.Fail(c, sc.HTTPStatus(), sc.HTTPStatus(), err.Error()) + return + } + response.BadRequest(c, err.Error()) + return + } + response.OK(c, result) +} + +type openDeliverySubmitReq struct { + GameAccount string `json:"game_account" binding:"required"` + BindUUID string `json:"bind_uuid" binding:"required"` +} + +func (h *OpenV1Handler) SubmitDeliveryOrder(c *gin.Context) { + openlog.SetAction(c, openlog.ActionCreate) + var req openDeliverySubmitReq + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "玩家编号和绑定凭证不能为空") + return + } + orderNo := c.Param("order_no") + openlog.Info(c, "delivery_submit start order_no=%s", orderNo) + client := middleware.GetAPIClient(c) + result, err := h.deliverySvc.SubmitForMerchant(middleware.GetMerchantID(c), client.ID, orderNo, req.GameAccount, req.BindUUID) + if err != nil { + openlog.Warn(c, "delivery_submit fail order_no=%s err=%v", orderNo, err) + if sc, ok := err.(interface{ HTTPStatus() int }); ok { + response.Fail(c, sc.HTTPStatus(), sc.HTTPStatus(), err.Error()) + return + } + if err.Error() == "订单不存在" { + response.NotFound(c, err.Error()) + return + } + response.BadRequest(c, err.Error()) + return + } + response.OK(c, result) +} + func buildOpenOrderResponse(order *model.FulfillmentOrder) gin.H { canFulfill, reason := service.CanFulfill(order) data := gin.H{ diff --git a/backend/internal/pkg/openlog/openlog.go b/backend/internal/pkg/openlog/openlog.go index 7a7dcaf..763d403 100644 --- a/backend/internal/pkg/openlog/openlog.go +++ b/backend/internal/pkg/openlog/openlog.go @@ -202,6 +202,12 @@ func clientAction(method, path string) string { return ActionList case method == "POST" && path == "/api/client/v1/orders": return ActionCreate + case method == "GET" && strings.HasPrefix(path, "/api/client/v1/orders/") && strings.Contains(path, "/delivery"): + return ActionQuery + case method == "POST" && strings.HasPrefix(path, "/api/client/v1/orders/") && strings.Contains(path, "/delivery/bind"): + return ActionCreate + case method == "POST" && strings.HasPrefix(path, "/api/client/v1/orders/") && strings.Contains(path, "/delivery/submit"): + return ActionCreate case method == "GET" && strings.HasPrefix(path, "/api/client/v1/orders/"): return ActionQuery case method == "POST" && strings.HasPrefix(path, "/api/client/v1/orders/") && strings.HasSuffix(path, "/cancel"): diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index 9829275..d176caa 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -84,6 +84,10 @@ func Setup(h *Handlers) *gin.Engine { clientOpen.POST("/orders", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureOrders), middleware.RequireAPIScope("orders:write"), h.Open.CreateOrder) clientOpen.GET("/orders/:order_no", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureOrders), middleware.RequireAPIScope("orders:read", "fulfillment:read"), h.Open.QueryOrder) clientOpen.POST("/orders/:order_no/cancel", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureOrders), middleware.RequireAPIScope("orders:write"), h.Open.CancelOrder) + clientOpen.GET("/orders/:order_no/delivery-link", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureOrders), middleware.RequireAPIScope("orders:read", "fulfillment:read"), h.Open.GetDeliveryLink) + clientOpen.GET("/orders/:order_no/delivery", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureOrders), middleware.RequireAPIScope("orders:read", "fulfillment:read"), h.Open.GetDeliveryOrder) + clientOpen.POST("/orders/:order_no/delivery/bind", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureOrders), middleware.RequireAPIScope("orders:write"), h.Open.BindDeliveryOrder) + clientOpen.POST("/orders/:order_no/delivery/submit", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureOrders), middleware.RequireAPIScope("orders:write"), h.Open.SubmitDeliveryOrder) clientOpen.GET("/wallet", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureWallet), middleware.RequireAPIScope("wallet:read"), h.Open.GetWallet) } diff --git a/backend/internal/router/router_test.go b/backend/internal/router/router_test.go index 292b57b..4f6f706 100644 --- a/backend/internal/router/router_test.go +++ b/backend/internal/router/router_test.go @@ -35,7 +35,7 @@ func TestSetupDoesNotPanic(t *testing.T) { Auth: handler.NewAuthHandler(authSvc), Dashboard: handler.NewDashboardHandler(fulfillmentSvc), User: handler.NewUserHandler(userSvc), - Open: handler.NewOpenV1Handler(merchantSvc, fulfillmentSvc), + Open: handler.NewOpenV1Handler(merchantSvc, fulfillmentSvc, deliverySvc), SourceOpen: handler.NewOpenHandler(fulfillmentSvc), Merchant: handler.NewMerchantHandler(merchantSvc, fulfillmentSvc, callbackSvc, deliverySvc), JWT: jwt.NewManager("test-jwt"), diff --git a/backend/internal/service/delivery.go b/backend/internal/service/delivery.go index 9914408..a5b67c2 100644 --- a/backend/internal/service/delivery.go +++ b/backend/internal/service/delivery.go @@ -16,6 +16,9 @@ import ( "time" "affiliate_dash/internal/model" + + "gorm.io/gorm" + "gorm.io/gorm/clause" ) const defaultDeliveryBFFBaseURL = "https://www.jxya.top/bff-stg" @@ -139,15 +142,50 @@ func (s *DeliveryService) GetOrder(orderNo string, auth DeliveryLinkAuth) (*Deli return info, err } +func (s *DeliveryService) GetMerchantOrder(merchantID uint, orderNo string) (*DeliveryOrderInfo, error) { + openOrder, order, goodID, err := s.loadMerchantOrder(merchantID, orderNo) + if err != nil { + return nil, err + } + info, _, _, err := s.buildDeliveryState(openOrder, order, goodID, false) + return info, err +} + func (s *DeliveryService) Bind(orderNo, gameAccount string, auth DeliveryLinkAuth) (*DeliveryBindResult, error) { + return s.bindWithOrder(orderNo, gameAccount, &auth, 0) +} + +func (s *DeliveryService) BindForMerchant(merchantID uint, orderNo, gameAccount string) (*DeliveryBindResult, error) { + return s.bindWithOrder(orderNo, gameAccount, nil, merchantID) +} + +func (s *DeliveryService) bindWithOrder(orderNo, gameAccount string, auth *DeliveryLinkAuth, merchantID uint) (*DeliveryBindResult, error) { gameAccount = strings.TrimSpace(gameAccount) if gameAccount == "" { return nil, errors.New("请输入玩家编号") } - _, _, goodID, err := s.prepareOrder(orderNo, true, &auth) + var ( + openOrder *OpenOrderQuery + order *model.FulfillmentOrder + goodID string + err error + ) + if merchantID > 0 { + openOrder, order, goodID, err = s.loadMerchantOrder(merchantID, orderNo) + } else { + openOrder, order, goodID, err = s.loadLinkedOrder(orderNo, auth) + } if err != nil { return nil, err } + _, _, _, err = s.buildDeliveryState(openOrder, order, goodID, true) + if err != nil { + return nil, err + } + return s.bindGood(gameAccount, goodID) +} + +func (s *DeliveryService) bindGood(gameAccount, goodID string) (*DeliveryBindResult, error) { var out struct { BindUUID string `json:"bind_uuid"` URL string `json:"url"` @@ -171,32 +209,85 @@ func (s *DeliveryService) Bind(orderNo, gameAccount string, auth DeliveryLinkAut } func (s *DeliveryService) Submit(orderNo, gameAccount, bindUUID string, auth DeliveryLinkAuth) (*DeliverySubmitResult, error) { + return s.submit(orderNo, gameAccount, bindUUID, 0, &auth, 0) +} + +func (s *DeliveryService) SubmitForMerchant(merchantID, apiClientID uint, orderNo, gameAccount, bindUUID string) (*DeliverySubmitResult, error) { + return s.submit(orderNo, gameAccount, bindUUID, apiClientID, nil, merchantID) +} + +func (s *DeliveryService) submit(orderNo, gameAccount, bindUUID string, apiClientID uint, auth *DeliveryLinkAuth, merchantID uint) (*DeliverySubmitResult, error) { gameAccount = strings.TrimSpace(gameAccount) bindUUID = strings.TrimSpace(bindUUID) orderNo = strings.TrimSpace(orderNo) if gameAccount == "" || bindUUID == "" { return nil, errors.New("玩家编号和绑定凭证不能为空") } - _, order, goodID, err := s.prepareOrder(orderNo, true, &auth) + var ( + openOrder *OpenOrderQuery + order *model.FulfillmentOrder + goodID string + err error + ) + if merchantID > 0 { + openOrder, order, goodID, err = s.loadMerchantOrder(merchantID, orderNo) + } else { + openOrder, order, goodID, err = s.loadLinkedOrder(orderNo, auth) + } if err != nil { return nil, err } + info, _, _, err := s.buildDeliveryState(openOrder, order, goodID, false) + if err != nil { + return nil, err + } + if order.FulfillmentStatus == model.FulfillmentStatusProcessing || + order.FulfillmentStatus == model.FulfillmentStatusSucceeded || + strings.TrimSpace(order.ProviderOrderNo) != "" || + strings.Contains(order.ResultData, "\"queue_order_id\"") { + if existing := buildExistingDeliverySubmitResult(order); existing != nil { + return existing, nil + } + } + if !info.CanShip { + reason := info.CannotShipReason + if reason == "" { + reason = "订单暂不可发货" + } + return nil, newDeliveryHTTPError(http.StatusBadRequest, reason) + } + claimed, claimedNow, err := s.claimDeliverySubmission(order.MerchantID, apiClientID, order.OrderNo) + if err != nil { + return nil, err + } + if !claimedNow { + if existing := buildExistingDeliverySubmitResult(claimed); existing != nil { + return existing, nil + } + return nil, newDeliveryHTTPError(http.StatusConflict, "订单暂时正在履约中,请稍后查询") + } boundAccount, err := s.accountBound(bindUUID, goodID) if err != nil { + s.markDeliverySubmissionFailed(claimed, apiClientID, err.Error()) return nil, err } if boundGameAccount := stringFromMap(boundAccount, "game_account"); boundGameAccount != "" && boundGameAccount != gameAccount { - return nil, errors.New("玩家编号与绑定的游戏账号不一致,请重新绑定") + err := errors.New("玩家编号与绑定的游戏账号不一致,请重新绑定") + s.markDeliverySubmissionFailed(claimed, apiClientID, err.Error()) + return nil, err } queueOrderID, err := s.createOrderQueue(goodID, order.OrderNo) if err != nil { + s.markDeliverySubmissionFailed(claimed, apiClientID, err.Error()) return nil, err } if err := s.patchOrderQueue(queueOrderID, gameAccount, bindUUID); err != nil { + s.markDeliverySubmissionFailed(claimed, apiClientID, err.Error()) return nil, err } upstreamOrder, err := s.createUpstreamOrder(queueOrderID, gameAccount, goodID, order.OrderNo) if err != nil { + s.markDeliverySubmissionFailed(claimed, apiClientID, err.Error()) return nil, err } providerOrderNo := firstNonEmpty( @@ -217,7 +308,7 @@ func (s *DeliveryService) Submit(orderNo, gameAccount, bindUUID string, auth Del } updated, err := s.fulfillment.UpdateFulfillment(FulfillmentUpdateInput{ MerchantID: order.MerchantID, - APIClientID: 0, + APIClientID: apiClientID, OrderNo: order.OrderNo, Status: "processing", ProviderOrderNo: providerOrderNo, @@ -236,6 +327,204 @@ func (s *DeliveryService) Submit(orderNo, gameAccount, bindUUID string, auth Del }, nil } +func (s *DeliveryService) claimDeliverySubmission(merchantID, apiClientID uint, orderNo string) (*model.FulfillmentOrder, bool, error) { + var updated model.FulfillmentOrder + claimedNow := false + err := s.fulfillment.db.Transaction(func(tx *gorm.DB) error { + var order model.FulfillmentOrder + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}). + Where("merchant_id = ? AND order_no = ?", merchantID, orderNo). + First(&order).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return errors.New("订单不存在") + } + return err + } + if order.FulfillmentStatus == model.FulfillmentStatusProcessing || order.FulfillmentStatus == model.FulfillmentStatusSucceeded { + updated = order + return nil + } + if order.FulfillmentStatus != model.FulfillmentStatusPending && order.FulfillmentStatus != model.FulfillmentStatusFailed { + return newDeliveryHTTPError(http.StatusConflict, "订单暂不可发货") + } + raw, _ := json.Marshal(map[string]interface{}{ + "source": "delivery_proxy", + "submit_started_at": time.Now().UTC().Format(time.RFC3339), + "provider_order_stage": "claimed", + }) + if err := tx.Model(&order).Updates(map[string]interface{}{ + "fulfillment_status": model.FulfillmentStatusProcessing, + "result_data": string(raw), + "failure_reason": "", + }).Error; err != nil { + return err + } + claimedNow = true + if err := tx.First(&updated, order.ID).Error; err != nil { + return err + } + apiClientIDPtr := optionalUint(apiClientID) + if err := writeAudit(tx, &merchantID, nil, apiClientIDPtr, "delivery.submit.claim", "fulfillment_order", order.OrderNo, nil); err != nil { + return err + } + if s.fulfillment.callbacks != nil { + if err := s.fulfillment.callbacks.Enqueue(tx, merchantID, "order.fulfillment.updated", orderCallbackData(&updated)); err != nil { + return err + } + } + return nil + }) + if err != nil { + return nil, false, err + } + return &updated, claimedNow, nil +} + +func (s *DeliveryService) markDeliverySubmissionFailed(order *model.FulfillmentOrder, apiClientID uint, reason string) { + if order == nil { + return + } + _, _ = s.fulfillment.UpdateFulfillment(FulfillmentUpdateInput{ + MerchantID: order.MerchantID, + APIClientID: apiClientID, + OrderNo: order.OrderNo, + Status: model.FulfillmentStatusFailed, + FailureReason: reason, + ResultData: map[string]interface{}{ + "source": "delivery_proxy", + "fail_reason": reason, + }, + }) +} + +func (s *DeliveryService) loadLinkedOrder(orderNo string, auth *DeliveryLinkAuth) (*OpenOrderQuery, *model.FulfillmentOrder, string, error) { + openOrder, err := s.fulfillment.QueryOpenOrder(orderNo) + if err != nil { + return nil, nil, "", err + } + order, err := s.fulfillment.GetByOrderNo(orderNo) + if err != nil { + return nil, nil, "", err + } + if auth != nil { + if err := s.authorizeDeliveryLink(order, *auth); err != nil { + return nil, nil, "", err + } + } + goodID := "" + if openOrder.Product != nil { + goodID = deliveryGoodID(s.channel, openOrder.Product.SKU) + } + return openOrder, order, goodID, nil +} + +func (s *DeliveryService) loadMerchantOrder(merchantID uint, orderNo string) (*OpenOrderQuery, *model.FulfillmentOrder, string, error) { + if merchantID == 0 { + return nil, nil, "", errors.New("无效的商户") + } + openOrder, err := s.fulfillment.QueryOpenOrder(orderNo) + if err != nil { + return nil, nil, "", err + } + order, err := s.fulfillment.GetOrder(merchantID, orderNo) + if err != nil { + return nil, nil, "", err + } + goodID := "" + if openOrder.Product != nil { + goodID = deliveryGoodID(s.channel, openOrder.Product.SKU) + } + return openOrder, order, goodID, nil +} + +func (s *DeliveryService) buildDeliveryState(openOrder *OpenOrderQuery, order *model.FulfillmentOrder, goodID string, requireCanShip bool) (*DeliveryOrderInfo, *model.FulfillmentOrder, string, error) { + if openOrder == nil || order == nil { + return nil, nil, "", errors.New("订单不存在") + } + canShip, reason := CanFulfill(order) + if !openOrder.CanShip { + reason = openOrder.CannotShipReason + canShip = false + } + if !canShip && reason == "" { + reason = "订单暂不可发货" + } + if openOrder.Product == nil || openOrder.Product.SKU == "" { + canShip = false + reason = "订单缺少商品 SKU" + } else if goodID == "" { + goodID = deliveryGoodID(s.channel, openOrder.Product.SKU) + if goodID == "" { + canShip = false + reason = "未找到对应的上游商品配置" + } + } + if requireCanShip && !canShip { + return nil, nil, "", newDeliveryHTTPError(http.StatusBadRequest, reason) + } + var good map[string]interface{} + if goodID != "" { + good, _ = s.goodsDetail(goodID) + } + product := buildDeliveryProduct(openOrder.Product, good) + info := &DeliveryOrderInfo{ + OrderNo: openOrder.OrderNo, + Status: openOrder.Status, + CanShip: canShip, + CannotShipReason: reason, + Product: product, + BuyerName: openOrder.BuyerName, + Amount: openOrder.Amount, + CreatedAt: openOrder.CreatedAt, + ShippedAt: openOrder.ShippedAt, + ShipFailReason: openOrder.ShipFailReason, + GameChannel: openOrder.GameChannel, + GameUID: openOrder.GameUID, + RoleName: openOrder.RoleName, + PayScore: openOrder.PayScore, + Good: good, + } + return info, order, goodID, nil +} + +func buildExistingDeliverySubmitResult(order *model.FulfillmentOrder) *DeliverySubmitResult { + if order == nil { + return nil + } + switch order.FulfillmentStatus { + case model.FulfillmentStatusProcessing, model.FulfillmentStatusSucceeded: + default: + return nil + } + var resultData map[string]interface{} + if json.Valid([]byte(order.ResultData)) { + _ = json.Unmarshal([]byte(order.ResultData), &resultData) + } + providerOrderNo := firstNonEmpty( + order.ProviderOrderNo, + stringFromMap(resultData, "provider_order_no"), + ) + message := "订单暂时正在履约中,请稍后查询" + if order.FulfillmentStatus == model.FulfillmentStatusSucceeded { + message = "订单已履约成功" + } + return &DeliverySubmitResult{ + OrderNo: order.OrderNo, + Status: order.FulfillmentStatus, + Message: message, + ProviderOrderNo: providerOrderNo, + UpstreamOrder: resultData, + } +} + +func optionalUint(value uint) *uint { + if value == 0 { + return nil + } + out := value + return &out +} + func (s *DeliveryService) GetOrCreateDeliveryLink(merchantID uint, orderNo string) (*DeliveryLinkResult, error) { orderNo = strings.TrimSpace(orderNo) if orderNo == "" { diff --git a/backend/internal/service/delivery_test.go b/backend/internal/service/delivery_test.go index 60df93f..a71f8c2 100644 --- a/backend/internal/service/delivery_test.go +++ b/backend/internal/service/delivery_test.go @@ -1,7 +1,11 @@ package service import ( + "encoding/json" + "net/http" + "net/http/httptest" "strings" + "sync/atomic" "testing" "affiliate_dash/internal/model" @@ -71,3 +75,171 @@ func TestDeliveryLinkGenerateAuthorizeAndRevoke(t *testing.T) { 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.FulfillmentStatusProcessing || 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.FulfillmentStatus != model.FulfillmentStatusProcessing || afterSubmit.ProviderOrderNo != "provider-1" { + t.Fatalf("submit should persist processing 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.FulfillmentStatusProcessing { + 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) + } +}