feat: add manual fulfillment order creation
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
-- 区分开放 API、商户后台人工和联调测试创建的订单,供后台按来源管理。
|
||||
ALTER TABLE fulfillment_orders
|
||||
ADD COLUMN IF NOT EXISTS order_source VARCHAR(16) NOT NULL DEFAULT 'api';
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_fulfillment_orders_merchant_source
|
||||
ON fulfillment_orders (merchant_id, order_source);
|
||||
@@ -2,6 +2,7 @@ package handler
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"affiliate_dash/internal/middleware"
|
||||
@@ -76,14 +77,64 @@ func (h *MerchantHandler) UpdateProductStatus(c *gin.Context) {
|
||||
|
||||
func (h *MerchantHandler) ListOrders(c *gin.Context) {
|
||||
page, size := pageParams(c)
|
||||
list, total, err := h.fulfillmentSvc.ListOrders(middleware.GetMerchantID(c), page, size, c.Query("order_status"))
|
||||
orderSource := strings.TrimSpace(c.Query("order_source"))
|
||||
list, total, err := h.fulfillmentSvc.ListOrders(middleware.GetMerchantID(c), page, size, c.Query("order_status"), orderSource)
|
||||
if err != nil {
|
||||
if orderSource != "" && err.Error() == "无效的订单来源" {
|
||||
response.BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
response.ServerError(c, err.Error())
|
||||
return
|
||||
}
|
||||
response.Page(c, list, total, page, size)
|
||||
}
|
||||
|
||||
type merchantManualOrderReq struct {
|
||||
ClientOrderNo string `json:"client_order_no" binding:"required,max=96"`
|
||||
SKU string `json:"sku" binding:"required,max=96"`
|
||||
Quantity int64 `json:"quantity"`
|
||||
BuyerReference string `json:"buyer_reference" binding:"max=128"`
|
||||
GameAccount string `json:"game_account" binding:"max=128"`
|
||||
Note string `json:"note" binding:"max=512"`
|
||||
}
|
||||
|
||||
// CreateManualOrder 在商户后台创建真实订单;扣款、库存和回调与开放 API 下单保持一致。
|
||||
func (h *MerchantHandler) CreateManualOrder(c *gin.Context) {
|
||||
var req merchantManualOrderReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "参数错误:商户单号与商品必填")
|
||||
return
|
||||
}
|
||||
data := map[string]string{}
|
||||
if gameAccount := strings.TrimSpace(req.GameAccount); gameAccount != "" {
|
||||
data["game_account"] = gameAccount
|
||||
}
|
||||
if note := strings.TrimSpace(req.Note); note != "" {
|
||||
data["manual_note"] = note
|
||||
}
|
||||
result, err := h.fulfillmentSvc.CreateManualOrder(service.CreateManualOrderInput{
|
||||
MerchantID: middleware.GetMerchantID(c),
|
||||
ActorUserID: middleware.GetUserID(c),
|
||||
ClientOrderNo: req.ClientOrderNo,
|
||||
SKU: req.SKU,
|
||||
Quantity: req.Quantity,
|
||||
BuyerReference: req.BuyerReference,
|
||||
RequestData: data,
|
||||
})
|
||||
if err != nil {
|
||||
response.BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
canShip, cannotShipReason := service.CanFulfill(result.Order)
|
||||
response.OK(c, gin.H{
|
||||
"order": result.Order,
|
||||
"idempotent": result.Idempotent,
|
||||
"can_ship": canShip,
|
||||
"cannot_ship_reason": cannotShipReason,
|
||||
})
|
||||
}
|
||||
|
||||
type merchantTestOrderReq struct {
|
||||
SKU string `json:"sku" binding:"required"`
|
||||
BuyerReference string `json:"buyer_reference"`
|
||||
|
||||
@@ -281,6 +281,7 @@ func buildOpenOrderResponse(order *model.FulfillmentOrder) gin.H {
|
||||
"order_no": order.OrderNo,
|
||||
"client_order_no": order.ClientOrderNo,
|
||||
"order_status": order.OrderStatus,
|
||||
"order_source": order.OrderSource,
|
||||
"can_ship": canShip,
|
||||
"cannot_ship_reason": reason,
|
||||
"product": gin.H{
|
||||
|
||||
@@ -40,6 +40,10 @@ const (
|
||||
OrderStatusShipFailed = "ship_failed" // 发货失败(可重试)
|
||||
OrderStatusCancelled = "cancelled" // 已取消并退款
|
||||
|
||||
OrderSourceAPI = "api" // 商户开放接口创建
|
||||
OrderSourceManual = "manual" // 商户后台人工创建
|
||||
OrderSourceTest = "test" // 后台联调测试订单
|
||||
|
||||
WalletLedgerCredit = "credit"
|
||||
WalletLedgerDebit = "debit"
|
||||
WalletLedgerRefund = "refund"
|
||||
@@ -206,6 +210,7 @@ type FulfillmentOrder struct {
|
||||
Amount int64 `gorm:"not null" json:"amount"`
|
||||
Currency string `gorm:"size:12;not null;default:POINT" json:"currency"`
|
||||
OrderStatus string `gorm:"size:16;not null;default:paid;index" json:"order_status"`
|
||||
OrderSource string `gorm:"size:16;not null;default:api;index" json:"order_source"`
|
||||
BuyerReference string `gorm:"size:128" json:"buyer_reference"`
|
||||
RequestFingerprint string `gorm:"size:64;not null;default:'';index" json:"-"`
|
||||
RequestData string `gorm:"type:text" json:"request_data"`
|
||||
|
||||
@@ -155,6 +155,7 @@ func Setup(h *Handlers) *gin.Engine {
|
||||
merchant.GET("/products", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureProducts), h.Merchant.ListProducts)
|
||||
merchant.PATCH("/products/:id/status", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureProducts), middleware.RequireMerchantRole(model.MemberRoleOwner, model.MemberRoleOperator), h.Merchant.UpdateProductStatus)
|
||||
merchant.GET("/orders", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureOrders), h.Merchant.ListOrders)
|
||||
merchant.POST("/orders/manual", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureOrders), middleware.RequireMerchantRole(model.MemberRoleOwner, model.MemberRoleOperator), h.Merchant.CreateManualOrder)
|
||||
merchant.POST("/orders/test", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureOrders), middleware.RequireMerchantRole(model.MemberRoleOwner, model.MemberRoleOperator), h.Merchant.CreateTestOrder)
|
||||
merchant.GET("/orders/:order_no/delivery-link", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureOrders), middleware.RequireMerchantRole(model.MemberRoleOwner, model.MemberRoleOperator), h.Merchant.GetDeliveryLink)
|
||||
merchant.POST("/orders/:order_no/delivery-link/revoke", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureOrders), middleware.RequireMerchantRole(model.MemberRoleOwner, model.MemberRoleOperator), h.Merchant.RevokeDeliveryLink)
|
||||
|
||||
@@ -26,11 +26,13 @@ func NewFulfillmentService(db *gorm.DB, callbacks *CallbackService) *Fulfillment
|
||||
type CreateFulfillmentOrderInput struct {
|
||||
MerchantID uint
|
||||
APIClientID uint
|
||||
ActorUserID uint
|
||||
ClientOrderNo string
|
||||
SKU string
|
||||
Quantity int64
|
||||
BuyerReference string
|
||||
RequestData interface{}
|
||||
OrderSource string
|
||||
}
|
||||
|
||||
type CreateFulfillmentOrderResult struct {
|
||||
@@ -47,11 +49,35 @@ type CreateTestOrderInput struct {
|
||||
OrderStatus string
|
||||
}
|
||||
|
||||
// CreateManualOrderInput 是商户后台创建真实订单的输入。其账务、库存与回调规则与开放 API 下单一致。
|
||||
type CreateManualOrderInput struct {
|
||||
MerchantID uint
|
||||
ActorUserID uint
|
||||
ClientOrderNo string
|
||||
SKU string
|
||||
Quantity int64
|
||||
BuyerReference string
|
||||
RequestData interface{}
|
||||
}
|
||||
|
||||
func (s *FulfillmentService) CreateManualOrder(in CreateManualOrderInput) (*CreateFulfillmentOrderResult, error) {
|
||||
return s.CreateOrder(CreateFulfillmentOrderInput{
|
||||
MerchantID: in.MerchantID,
|
||||
ActorUserID: in.ActorUserID,
|
||||
ClientOrderNo: in.ClientOrderNo,
|
||||
SKU: in.SKU,
|
||||
Quantity: in.Quantity,
|
||||
BuyerReference: in.BuyerReference,
|
||||
RequestData: in.RequestData,
|
||||
OrderSource: model.OrderSourceManual,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *FulfillmentService) CreateOrder(in CreateFulfillmentOrderInput) (*CreateFulfillmentOrderResult, error) {
|
||||
in.ClientOrderNo = strings.TrimSpace(in.ClientOrderNo)
|
||||
in.SKU = strings.TrimSpace(in.SKU)
|
||||
if in.MerchantID == 0 || in.APIClientID == 0 {
|
||||
return nil, errors.New("无效的商户或 API 客户端")
|
||||
if in.MerchantID == 0 || (in.APIClientID == 0 && in.ActorUserID == 0) {
|
||||
return nil, errors.New("无效的商户或下单身份")
|
||||
}
|
||||
if in.ClientOrderNo == "" || len(in.ClientOrderNo) > 96 {
|
||||
return nil, errors.New("client_order_no 不能为空且最长 96 位")
|
||||
@@ -65,6 +91,15 @@ func (s *FulfillmentService) CreateOrder(in CreateFulfillmentOrderInput) (*Creat
|
||||
if in.Quantity < 1 {
|
||||
return nil, errors.New("quantity 必须大于零")
|
||||
}
|
||||
orderSource := strings.TrimSpace(in.OrderSource)
|
||||
if orderSource == "" {
|
||||
orderSource = model.OrderSourceAPI
|
||||
}
|
||||
switch orderSource {
|
||||
case model.OrderSourceAPI, model.OrderSourceManual:
|
||||
default:
|
||||
return nil, errors.New("无效的订单来源")
|
||||
}
|
||||
requestData := ""
|
||||
if in.RequestData != nil {
|
||||
raw, err := json.Marshal(in.RequestData)
|
||||
@@ -155,6 +190,7 @@ func (s *FulfillmentService) CreateOrder(in CreateFulfillmentOrderInput) (*Creat
|
||||
Amount: totalAmount,
|
||||
Currency: product.Currency,
|
||||
OrderStatus: model.OrderStatusPaid,
|
||||
OrderSource: orderSource,
|
||||
BuyerReference: in.BuyerReference,
|
||||
RequestFingerprint: fingerprint,
|
||||
RequestData: requestData,
|
||||
@@ -163,6 +199,10 @@ func (s *FulfillmentService) CreateOrder(in CreateFulfillmentOrderInput) (*Creat
|
||||
return err
|
||||
}
|
||||
idempotencyKey := in.ClientOrderNo
|
||||
ledgerNote := "开放接口下单扣款(含平台手续费)"
|
||||
if orderSource == model.OrderSourceManual {
|
||||
ledgerNote = "商户后台手动下单扣款(含平台手续费)"
|
||||
}
|
||||
if err := tx.Create(&model.WalletLedgerEntry{
|
||||
MerchantID: in.MerchantID,
|
||||
WalletAccountID: wallet.ID,
|
||||
@@ -173,7 +213,7 @@ func (s *FulfillmentService) CreateOrder(in CreateFulfillmentOrderInput) (*Creat
|
||||
ReferenceType: "fulfillment_order",
|
||||
ReferenceNo: order.OrderNo,
|
||||
IdempotencyKey: &idempotencyKey,
|
||||
Note: "开放接口下单扣款(含平台手续费)",
|
||||
Note: ledgerNote,
|
||||
}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -183,7 +223,11 @@ func (s *FulfillmentService) CreateOrder(in CreateFulfillmentOrderInput) (*Creat
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := writeAudit(tx, &in.MerchantID, nil, &in.APIClientID, "open_order.create", "fulfillment_order", order.OrderNo, map[string]interface{}{"client_order_no": in.ClientOrderNo, "sku": in.SKU}); err != nil {
|
||||
action := "open_order.create"
|
||||
if orderSource == model.OrderSourceManual {
|
||||
action = "merchant_order.manual_create"
|
||||
}
|
||||
if err := writeAudit(tx, &in.MerchantID, optionalUint(in.ActorUserID), optionalUint(in.APIClientID), action, "fulfillment_order", order.OrderNo, map[string]interface{}{"client_order_no": in.ClientOrderNo, "sku": in.SKU, "source": orderSource}); err != nil {
|
||||
return err
|
||||
}
|
||||
if s.callbacks != nil {
|
||||
@@ -289,6 +333,7 @@ func (s *FulfillmentService) CreateTestOrder(in CreateTestOrderInput) (*model.Fu
|
||||
Amount: 0,
|
||||
Currency: product.Currency,
|
||||
OrderStatus: in.OrderStatus,
|
||||
OrderSource: model.OrderSourceTest,
|
||||
BuyerReference: in.BuyerReference,
|
||||
RequestData: string(rawRequestData),
|
||||
}
|
||||
@@ -511,6 +556,7 @@ func orderCallbackData(order *model.FulfillmentOrder) map[string]interface{} {
|
||||
"amount": order.Amount,
|
||||
"currency": order.Currency,
|
||||
"order_status": normalizeOrderStatus(order),
|
||||
"order_source": order.OrderSource,
|
||||
"can_ship": canShip,
|
||||
"cannot_ship_reason": cannotShipReason,
|
||||
"provider_order_no": order.ProviderOrderNo,
|
||||
|
||||
@@ -23,12 +23,20 @@ func (s *FulfillmentService) GetOrder(merchantID uint, orderNo string) (*model.F
|
||||
return &order, nil
|
||||
}
|
||||
|
||||
func (s *FulfillmentService) ListOrders(merchantID uint, page, size int, orderStatus string) ([]model.FulfillmentOrder, int64, error) {
|
||||
func (s *FulfillmentService) ListOrders(merchantID uint, page, size int, orderStatus, orderSource string) ([]model.FulfillmentOrder, int64, error) {
|
||||
page, size = normalizePage(page, size)
|
||||
tx := s.db.Model(&model.FulfillmentOrder{}).Where("merchant_id = ?", merchantID)
|
||||
if orderStatus != "" {
|
||||
tx = tx.Where("order_status = ?", orderStatus)
|
||||
}
|
||||
if orderSource != "" {
|
||||
switch orderSource {
|
||||
case model.OrderSourceAPI, model.OrderSourceManual, model.OrderSourceTest:
|
||||
tx = tx.Where("order_source = ?", orderSource)
|
||||
default:
|
||||
return nil, 0, errors.New("无效的订单来源")
|
||||
}
|
||||
}
|
||||
var total int64
|
||||
if err := tx.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
|
||||
@@ -123,6 +123,66 @@ func TestFulfillmentCreateOrderDebitsWalletAndIsIdempotent(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFulfillmentCreateManualOrderDebitsWalletAndStock(t *testing.T) {
|
||||
db := newServiceTestDB(t)
|
||||
merchantID, product := seedFulfillmentMerchant(t, db, "merchant-manual-order", 1000, 5, 200)
|
||||
svc := NewFulfillmentService(db, nil)
|
||||
|
||||
created, err := svc.CreateManualOrder(CreateManualOrderInput{
|
||||
MerchantID: merchantID,
|
||||
ActorUserID: 42,
|
||||
ClientOrderNo: "manual-001",
|
||||
SKU: product.SKU,
|
||||
Quantity: 2,
|
||||
BuyerReference: "manual-buyer",
|
||||
RequestData: map[string]string{"game_account": "123456"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create manual order: %v", err)
|
||||
}
|
||||
if created.Idempotent || created.Order.OrderSource != model.OrderSourceManual || created.Order.Amount != 400 {
|
||||
t.Fatalf("unexpected manual order: %+v", created)
|
||||
}
|
||||
|
||||
again, err := svc.CreateManualOrder(CreateManualOrderInput{
|
||||
MerchantID: merchantID,
|
||||
ActorUserID: 42,
|
||||
ClientOrderNo: "manual-001",
|
||||
SKU: product.SKU,
|
||||
Quantity: 2,
|
||||
BuyerReference: "manual-buyer",
|
||||
RequestData: map[string]string{"game_account": "123456"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("idempotent manual order: %v", err)
|
||||
}
|
||||
if !again.Idempotent || again.Order.OrderNo != created.Order.OrderNo {
|
||||
t.Fatalf("manual order should be idempotent, got %+v", again)
|
||||
}
|
||||
|
||||
var wallet model.WalletAccount
|
||||
if err := db.Where("merchant_id = ?", merchantID).First(&wallet).Error; err != nil {
|
||||
t.Fatalf("query wallet: %v", err)
|
||||
}
|
||||
if wallet.AvailableBalance != 600 {
|
||||
t.Fatalf("wallet should debit once, got %d", wallet.AvailableBalance)
|
||||
}
|
||||
var refreshed model.MerchantProduct
|
||||
if err := db.First(&refreshed, product.ID).Error; err != nil {
|
||||
t.Fatalf("query product: %v", err)
|
||||
}
|
||||
if refreshed.Stock != 3 {
|
||||
t.Fatalf("stock should decrease once, got %d", refreshed.Stock)
|
||||
}
|
||||
var audit model.AuditLog
|
||||
if err := db.Where("entity_id = ? AND action = ?", created.Order.OrderNo, "merchant_order.manual_create").First(&audit).Error; err != nil {
|
||||
t.Fatalf("query manual creation audit: %v", err)
|
||||
}
|
||||
if audit.ActorUserID == nil || *audit.ActorUserID != 42 || audit.APIClientID != nil {
|
||||
t.Fatalf("unexpected manual creation audit: %+v", audit)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFulfillmentCreateOrderRejectsIdempotencyMismatch(t *testing.T) {
|
||||
db := newServiceTestDB(t)
|
||||
merchantID, product := seedFulfillmentMerchant(t, db, "merchant-idempotency-mismatch", 1000, 5, 200)
|
||||
|
||||
Reference in New Issue
Block a user