feat: add manual fulfillment order creation

This commit is contained in:
yml2213
2026-08-11 11:49:58 +08:00
parent 2264851d5d
commit b686a82e60
12 changed files with 278 additions and 121 deletions
+50 -4
View File
@@ -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)