补商户发货API

This commit is contained in:
yml2213
2026-07-31 12:24:54 +08:00
parent e4c1306216
commit 761522e808
7 changed files with 580 additions and 8 deletions
+1 -1
View File
@@ -74,7 +74,7 @@ func main() {
Dashboard: handler.NewDashboardHandler(fulfillmentSvc), Dashboard: handler.NewDashboardHandler(fulfillmentSvc),
Delivery: handler.NewDeliveryHandler(deliverySvc), Delivery: handler.NewDeliveryHandler(deliverySvc),
User: handler.NewUserHandler(userSvc), User: handler.NewUserHandler(userSvc),
Open: handler.NewOpenV1Handler(merchantSvc, fulfillmentSvc), Open: handler.NewOpenV1Handler(merchantSvc, fulfillmentSvc, deliverySvc),
SourceOpen: handler.NewOpenHandler(fulfillmentSvc), SourceOpen: handler.NewOpenHandler(fulfillmentSvc),
Merchant: handler.NewMerchantHandler(merchantSvc, fulfillmentSvc, callbackSvc, deliverySvc), Merchant: handler.NewMerchantHandler(merchantSvc, fulfillmentSvc, callbackSvc, deliverySvc),
JWT: jm, JWT: jm,
+103 -2
View File
@@ -18,10 +18,11 @@ import (
type OpenV1Handler struct { type OpenV1Handler struct {
merchantSvc *service.MerchantService merchantSvc *service.MerchantService
fulfillmentSvc *service.FulfillmentService fulfillmentSvc *service.FulfillmentService
deliverySvc *service.DeliveryService
} }
func NewOpenV1Handler(merchantSvc *service.MerchantService, fulfillmentSvc *service.FulfillmentService) *OpenV1Handler { func NewOpenV1Handler(merchantSvc *service.MerchantService, fulfillmentSvc *service.FulfillmentService, deliverySvc *service.DeliveryService) *OpenV1Handler {
return &OpenV1Handler{merchantSvc: merchantSvc, fulfillmentSvc: fulfillmentSvc} return &OpenV1Handler{merchantSvc: merchantSvc, fulfillmentSvc: fulfillmentSvc, deliverySvc: deliverySvc}
} }
func (h *OpenV1Handler) ListProducts(c *gin.Context) { func (h *OpenV1Handler) ListProducts(c *gin.Context) {
@@ -162,6 +163,106 @@ func (h *OpenV1Handler) GetWallet(c *gin.Context) {
response.OK(c, wallet) 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 { func buildOpenOrderResponse(order *model.FulfillmentOrder) gin.H {
canFulfill, reason := service.CanFulfill(order) canFulfill, reason := service.CanFulfill(order)
data := gin.H{ data := gin.H{
+6
View File
@@ -202,6 +202,12 @@ func clientAction(method, path string) string {
return ActionList return ActionList
case method == "POST" && path == "/api/client/v1/orders": case method == "POST" && path == "/api/client/v1/orders":
return ActionCreate 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/"): case method == "GET" && strings.HasPrefix(path, "/api/client/v1/orders/"):
return ActionQuery return ActionQuery
case method == "POST" && strings.HasPrefix(path, "/api/client/v1/orders/") && strings.HasSuffix(path, "/cancel"): case method == "POST" && strings.HasPrefix(path, "/api/client/v1/orders/") && strings.HasSuffix(path, "/cancel"):
+4
View File
@@ -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.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.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.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) clientOpen.GET("/wallet", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureWallet), middleware.RequireAPIScope("wallet:read"), h.Open.GetWallet)
} }
+1 -1
View File
@@ -35,7 +35,7 @@ func TestSetupDoesNotPanic(t *testing.T) {
Auth: handler.NewAuthHandler(authSvc), Auth: handler.NewAuthHandler(authSvc),
Dashboard: handler.NewDashboardHandler(fulfillmentSvc), Dashboard: handler.NewDashboardHandler(fulfillmentSvc),
User: handler.NewUserHandler(userSvc), User: handler.NewUserHandler(userSvc),
Open: handler.NewOpenV1Handler(merchantSvc, fulfillmentSvc), Open: handler.NewOpenV1Handler(merchantSvc, fulfillmentSvc, deliverySvc),
SourceOpen: handler.NewOpenHandler(fulfillmentSvc), SourceOpen: handler.NewOpenHandler(fulfillmentSvc),
Merchant: handler.NewMerchantHandler(merchantSvc, fulfillmentSvc, callbackSvc, deliverySvc), Merchant: handler.NewMerchantHandler(merchantSvc, fulfillmentSvc, callbackSvc, deliverySvc),
JWT: jwt.NewManager("test-jwt"), JWT: jwt.NewManager("test-jwt"),
+293 -4
View File
@@ -16,6 +16,9 @@ import (
"time" "time"
"affiliate_dash/internal/model" "affiliate_dash/internal/model"
"gorm.io/gorm"
"gorm.io/gorm/clause"
) )
const defaultDeliveryBFFBaseURL = "https://www.jxya.top/bff-stg" const defaultDeliveryBFFBaseURL = "https://www.jxya.top/bff-stg"
@@ -139,15 +142,50 @@ func (s *DeliveryService) GetOrder(orderNo string, auth DeliveryLinkAuth) (*Deli
return info, err 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) { 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) gameAccount = strings.TrimSpace(gameAccount)
if gameAccount == "" { if gameAccount == "" {
return nil, errors.New("请输入玩家编号") 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 { if err != nil {
return nil, err 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 { var out struct {
BindUUID string `json:"bind_uuid"` BindUUID string `json:"bind_uuid"`
URL string `json:"url"` 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) { 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) gameAccount = strings.TrimSpace(gameAccount)
bindUUID = strings.TrimSpace(bindUUID) bindUUID = strings.TrimSpace(bindUUID)
orderNo = strings.TrimSpace(orderNo) orderNo = strings.TrimSpace(orderNo)
if gameAccount == "" || bindUUID == "" { if gameAccount == "" || bindUUID == "" {
return nil, errors.New("玩家编号和绑定凭证不能为空") 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 { if err != nil {
return nil, err 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) boundAccount, err := s.accountBound(bindUUID, goodID)
if err != nil { if err != nil {
s.markDeliverySubmissionFailed(claimed, apiClientID, err.Error())
return nil, err return nil, err
} }
if boundGameAccount := stringFromMap(boundAccount, "game_account"); boundGameAccount != "" && boundGameAccount != gameAccount { 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) queueOrderID, err := s.createOrderQueue(goodID, order.OrderNo)
if err != nil { if err != nil {
s.markDeliverySubmissionFailed(claimed, apiClientID, err.Error())
return nil, err return nil, err
} }
if err := s.patchOrderQueue(queueOrderID, gameAccount, bindUUID); err != nil { if err := s.patchOrderQueue(queueOrderID, gameAccount, bindUUID); err != nil {
s.markDeliverySubmissionFailed(claimed, apiClientID, err.Error())
return nil, err return nil, err
} }
upstreamOrder, err := s.createUpstreamOrder(queueOrderID, gameAccount, goodID, order.OrderNo) upstreamOrder, err := s.createUpstreamOrder(queueOrderID, gameAccount, goodID, order.OrderNo)
if err != nil { if err != nil {
s.markDeliverySubmissionFailed(claimed, apiClientID, err.Error())
return nil, err return nil, err
} }
providerOrderNo := firstNonEmpty( providerOrderNo := firstNonEmpty(
@@ -217,7 +308,7 @@ func (s *DeliveryService) Submit(orderNo, gameAccount, bindUUID string, auth Del
} }
updated, err := s.fulfillment.UpdateFulfillment(FulfillmentUpdateInput{ updated, err := s.fulfillment.UpdateFulfillment(FulfillmentUpdateInput{
MerchantID: order.MerchantID, MerchantID: order.MerchantID,
APIClientID: 0, APIClientID: apiClientID,
OrderNo: order.OrderNo, OrderNo: order.OrderNo,
Status: "processing", Status: "processing",
ProviderOrderNo: providerOrderNo, ProviderOrderNo: providerOrderNo,
@@ -236,6 +327,204 @@ func (s *DeliveryService) Submit(orderNo, gameAccount, bindUUID string, auth Del
}, nil }, 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) { func (s *DeliveryService) GetOrCreateDeliveryLink(merchantID uint, orderNo string) (*DeliveryLinkResult, error) {
orderNo = strings.TrimSpace(orderNo) orderNo = strings.TrimSpace(orderNo)
if orderNo == "" { if orderNo == "" {
+172
View File
@@ -1,7 +1,11 @@
package service package service
import ( import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings" "strings"
"sync/atomic"
"testing" "testing"
"affiliate_dash/internal/model" "affiliate_dash/internal/model"
@@ -71,3 +75,171 @@ func TestDeliveryLinkGenerateAuthorizeAndRevoke(t *testing.T) {
t.Fatalf("restored link should be accepted: %v", err) 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)
}
}