补商户发货API
This commit is contained in:
@@ -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,
|
||||||
|
|||||||
@@ -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{
|
||||||
|
|||||||
@@ -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"):
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
|||||||
@@ -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 == "" {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user