统一发货口径并保护结果数据
This commit is contained in:
@@ -112,8 +112,8 @@ make docker-down # 停止并移除
|
|||||||
| `OPEN_SIGN_SKEW` | 签名时间戳偏差(秒) | `300` |
|
| `OPEN_SIGN_SKEW` | 签名时间戳偏差(秒) | `300` |
|
||||||
| `OPEN_API_DEBUG` | 开放接口调试日志 | debug 模式默认开启 |
|
| `OPEN_API_DEBUG` | 开放接口调试日志 | debug 模式默认开启 |
|
||||||
| `LOG_FILE` | 日志文件路径 | `logs/app.log` |
|
| `LOG_FILE` | 日志文件路径 | `logs/app.log` |
|
||||||
| `FULFILLMENT_PROCESSING_TIMEOUT_MINUTES` | 履约中订单自动标记失败的超时分钟数,<=0 关闭 | `30` |
|
| `FULFILLMENT_PROCESSING_TIMEOUT_MINUTES` | 发货中订单自动标记失败的超时分钟数,<=0 关闭 | `30` |
|
||||||
| `FULFILLMENT_TIMEOUT_SCAN_INTERVAL_SECONDS` | 履约超时巡检间隔秒数 | `60` |
|
| `FULFILLMENT_TIMEOUT_SCAN_INTERVAL_SECONDS` | 发货超时巡检间隔秒数 | `60` |
|
||||||
|
|
||||||
## 开放接口(皮肤源头对接)
|
## 开放接口(皮肤源头对接)
|
||||||
|
|
||||||
|
|||||||
@@ -36,9 +36,9 @@ type Config struct {
|
|||||||
DeliveryLinkSecret string
|
DeliveryLinkSecret string
|
||||||
// DeliveryLinkTTLMinutes 发货链接默认有效分钟数。
|
// DeliveryLinkTTLMinutes 发货链接默认有效分钟数。
|
||||||
DeliveryLinkTTLMinutes int
|
DeliveryLinkTTLMinutes int
|
||||||
// FulfillmentProcessingTimeoutMinutes 履约中订单超过该分钟数自动标记失败;<=0 表示关闭。
|
// FulfillmentProcessingTimeoutMinutes 发货中订单超过该分钟数自动标记失败;<=0 表示关闭。
|
||||||
FulfillmentProcessingTimeoutMinutes int
|
FulfillmentProcessingTimeoutMinutes int
|
||||||
// FulfillmentTimeoutScanIntervalSeconds 履约超时巡检间隔秒数。
|
// FulfillmentTimeoutScanIntervalSeconds 发货超时巡检间隔秒数。
|
||||||
FulfillmentTimeoutScanIntervalSeconds int
|
FulfillmentTimeoutScanIntervalSeconds int
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -190,7 +190,7 @@ CREATE INDEX IF NOT EXISTS idx_wallet_ledger_entries_type ON wallet_ledger_entri
|
|||||||
CREATE INDEX IF NOT EXISTS idx_wallet_ledger_entries_reference_no ON wallet_ledger_entries (reference_no);
|
CREATE INDEX IF NOT EXISTS idx_wallet_ledger_entries_reference_no ON wallet_ledger_entries (reference_no);
|
||||||
|
|
||||||
-- ----------------------------------------------------------------------------
|
-- ----------------------------------------------------------------------------
|
||||||
-- 履约订单
|
-- 发货订单
|
||||||
-- ----------------------------------------------------------------------------
|
-- ----------------------------------------------------------------------------
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS fulfillment_orders (
|
CREATE TABLE IF NOT EXISTS fulfillment_orders (
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import (
|
|||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
// OpenHandler 皮肤源头开放接口(上游 SourceOpen),基于 FulfillmentOrder 履约模型。
|
// OpenHandler 皮肤源头开放接口(上游 SourceOpen),基于 FulfillmentOrder 发货模型。
|
||||||
type OpenHandler struct {
|
type OpenHandler struct {
|
||||||
fulfillmentSvc *service.FulfillmentService
|
fulfillmentSvc *service.FulfillmentService
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -255,13 +255,13 @@ func (h *OpenV1Handler) SubmitDeliveryOrder(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func buildOpenOrderResponse(order *model.FulfillmentOrder) gin.H {
|
func buildOpenOrderResponse(order *model.FulfillmentOrder) gin.H {
|
||||||
canFulfill, reason := service.CanFulfill(order)
|
canShip, reason := service.CanFulfill(order)
|
||||||
data := gin.H{
|
data := gin.H{
|
||||||
"order_no": order.OrderNo,
|
"order_no": order.OrderNo,
|
||||||
"client_order_no": order.ClientOrderNo,
|
"client_order_no": order.ClientOrderNo,
|
||||||
"order_status": order.OrderStatus,
|
"order_status": order.OrderStatus,
|
||||||
"can_fulfill": canFulfill,
|
"can_ship": canShip,
|
||||||
"cannot_fulfill_reason": reason,
|
"cannot_ship_reason": reason,
|
||||||
"product": gin.H{
|
"product": gin.H{
|
||||||
"sku": order.ProductSKU,
|
"sku": order.ProductSKU,
|
||||||
"name": order.ProductName,
|
"name": order.ProductName,
|
||||||
|
|||||||
@@ -136,7 +136,7 @@ type Product struct {
|
|||||||
Status string `gorm:"size:16;not null;default:active;index" json:"status"`
|
Status string `gorm:"size:16;not null;default:active;index" json:"status"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// MerchantProduct 是商户可售商品及其价格、库存和履约配置。
|
// MerchantProduct 是商户可售商品及其价格、库存和发货配置。
|
||||||
type MerchantProduct struct {
|
type MerchantProduct struct {
|
||||||
ID uint `gorm:"primarykey" json:"id"`
|
ID uint `gorm:"primarykey" json:"id"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
|||||||
@@ -266,7 +266,7 @@ func (s *DeliveryService) submit(orderNo, gameAccount, bindUUID string, apiClien
|
|||||||
if existing := buildExistingDeliverySubmitResult(claimed); existing != nil {
|
if existing := buildExistingDeliverySubmitResult(claimed); existing != nil {
|
||||||
return existing, nil
|
return existing, nil
|
||||||
}
|
}
|
||||||
return nil, newDeliveryHTTPError(http.StatusConflict, "订单暂时正在履约中,请稍后查询")
|
return nil, newDeliveryHTTPError(http.StatusConflict, "订单暂时正在发货中,请稍后查询")
|
||||||
}
|
}
|
||||||
boundAccount, err := s.accountBound(bindUUID, goodID)
|
boundAccount, err := s.accountBound(bindUUID, goodID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -507,10 +507,10 @@ func buildExistingDeliverySubmitResult(order *model.FulfillmentOrder) *DeliveryS
|
|||||||
order.ProviderOrderNo,
|
order.ProviderOrderNo,
|
||||||
stringFromMap(resultData, "provider_order_no"),
|
stringFromMap(resultData, "provider_order_no"),
|
||||||
)
|
)
|
||||||
message := "订单暂时正在履约中,请稍后查询"
|
message := "订单暂时正在发货中,请稍后查询"
|
||||||
status := normalizeOrderStatus(order)
|
status := normalizeOrderStatus(order)
|
||||||
if status == model.OrderStatusDelivered {
|
if status == model.OrderStatusDelivered {
|
||||||
message = "订单已履约成功"
|
message = "订单已交付"
|
||||||
}
|
}
|
||||||
return &DeliverySubmitResult{
|
return &DeliverySubmitResult{
|
||||||
OrderNo: order.OrderNo,
|
OrderNo: order.OrderNo,
|
||||||
|
|||||||
@@ -366,11 +366,12 @@ func (s *FulfillmentService) UpdateFulfillment(in FulfillmentUpdateInput) (*mode
|
|||||||
return nil, errors.New("无效的订单状态")
|
return nil, errors.New("无效的订单状态")
|
||||||
}
|
}
|
||||||
nextOrderStatus := in.Status
|
nextOrderStatus := in.Status
|
||||||
resultData := ""
|
var resultData string
|
||||||
|
hasResultData := in.ResultData != nil
|
||||||
if in.ResultData != nil {
|
if in.ResultData != nil {
|
||||||
raw, err := json.Marshal(in.ResultData)
|
raw, err := json.Marshal(in.ResultData)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, errors.New("履约结果无法序列化")
|
return nil, errors.New("发货结果无法序列化")
|
||||||
}
|
}
|
||||||
resultData = string(raw)
|
resultData = string(raw)
|
||||||
}
|
}
|
||||||
@@ -394,7 +395,9 @@ func (s *FulfillmentService) UpdateFulfillment(in FulfillmentUpdateInput) (*mode
|
|||||||
}
|
}
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
updates := map[string]interface{}{"order_status": nextOrderStatus}
|
updates := map[string]interface{}{"order_status": nextOrderStatus}
|
||||||
updates["result_data"] = resultData
|
if hasResultData {
|
||||||
|
updates["result_data"] = resultData
|
||||||
|
}
|
||||||
if in.ProviderOrderNo != "" {
|
if in.ProviderOrderNo != "" {
|
||||||
updates["provider_order_no"] = in.ProviderOrderNo
|
updates["provider_order_no"] = in.ProviderOrderNo
|
||||||
}
|
}
|
||||||
@@ -473,7 +476,7 @@ func (s *FulfillmentService) markProcessingTimeout(id uint, timeout time.Duratio
|
|||||||
if err := validateOrderStatusTransition(&order, model.OrderStatusShipFailed, fulfillmentTransitionTimeout); err != nil {
|
if err := validateOrderStatusTransition(&order, model.OrderStatusShipFailed, fulfillmentTransitionTimeout); err != nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
reason := fmt.Sprintf("履约超时:订单已处于 delivering 超过 %d 分钟", int(timeout.Minutes()))
|
reason := fmt.Sprintf("发货超时:订单已处于 delivering 超过 %d 分钟", int(timeout.Minutes()))
|
||||||
updates := map[string]interface{}{"order_status": model.OrderStatusShipFailed}
|
updates := map[string]interface{}{"order_status": model.OrderStatusShipFailed}
|
||||||
updates["failure_reason"] = reason
|
updates["failure_reason"] = reason
|
||||||
updates["result_data"] = buildProcessingTimeoutResultData(order.ResultData, timeout, now)
|
updates["result_data"] = buildProcessingTimeoutResultData(order.ResultData, timeout, now)
|
||||||
@@ -747,22 +750,22 @@ func CanFulfill(order *model.FulfillmentOrder) (bool, string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func orderCallbackData(order *model.FulfillmentOrder) map[string]interface{} {
|
func orderCallbackData(order *model.FulfillmentOrder) map[string]interface{} {
|
||||||
canFulfill, cannotFulfillReason := CanFulfill(order)
|
canShip, cannotShipReason := CanFulfill(order)
|
||||||
return map[string]interface{}{
|
return map[string]interface{}{
|
||||||
"order_no": order.OrderNo,
|
"order_no": order.OrderNo,
|
||||||
"client_order_no": order.ClientOrderNo,
|
"client_order_no": order.ClientOrderNo,
|
||||||
"product_sku": order.ProductSKU,
|
"product_sku": order.ProductSKU,
|
||||||
"quantity": order.Quantity,
|
"quantity": order.Quantity,
|
||||||
"base_amount": order.BaseAmount,
|
"base_amount": order.BaseAmount,
|
||||||
"fee_type": order.FeeType,
|
"fee_type": order.FeeType,
|
||||||
"service_fee_amount": order.ServiceFeeAmount,
|
"service_fee_amount": order.ServiceFeeAmount,
|
||||||
"amount": order.Amount,
|
"amount": order.Amount,
|
||||||
"currency": order.Currency,
|
"currency": order.Currency,
|
||||||
"order_status": normalizeOrderStatus(order),
|
"order_status": normalizeOrderStatus(order),
|
||||||
"can_fulfill": canFulfill,
|
"can_ship": canShip,
|
||||||
"cannot_fulfill_reason": cannotFulfillReason,
|
"cannot_ship_reason": cannotShipReason,
|
||||||
"provider_order_no": order.ProviderOrderNo,
|
"provider_order_no": order.ProviderOrderNo,
|
||||||
"failure_reason": order.FailureReason,
|
"failure_reason": order.FailureReason,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1097,7 +1100,7 @@ func (s *FulfillmentService) HandleShipNotify(in ShipNotifyInput) (*ShipNotifyRe
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// 已履约成功:success 推送幂等成功。状态读取和后续更新必须在同一把行锁内完成。
|
// 已交付:success 推送幂等成功。状态读取和后续更新必须在同一把行锁内完成。
|
||||||
if normalizeOrderStatus(&order) == model.OrderStatusDelivered && in.ShipStatus == "success" {
|
if normalizeOrderStatus(&order) == model.OrderStatusDelivered && in.ShipStatus == "success" {
|
||||||
result = ShipNotifyResult{
|
result = ShipNotifyResult{
|
||||||
OrderNo: order.OrderNo,
|
OrderNo: order.OrderNo,
|
||||||
@@ -1105,7 +1108,7 @@ func (s *FulfillmentService) HandleShipNotify(in ShipNotifyInput) (*ShipNotifyRe
|
|||||||
Message: "订单已交付,幂等成功",
|
Message: "订单已交付,幂等成功",
|
||||||
}
|
}
|
||||||
return writeAudit(tx, &order.MerchantID, nil, nil, "ship.notify", "fulfillment_order", order.OrderNo,
|
return writeAudit(tx, &order.MerchantID, nil, nil, "ship.notify", "fulfillment_order", order.OrderNo,
|
||||||
shipNotifyAuditMetadata(in, normalizeOrderStatus(&order), "订单已履约成功,幂等忽略"))
|
shipNotifyAuditMetadata(in, normalizeOrderStatus(&order), "订单已交付,幂等忽略"))
|
||||||
}
|
}
|
||||||
|
|
||||||
if normalizeOrderStatus(&order) == model.OrderStatusCancelled {
|
if normalizeOrderStatus(&order) == model.OrderStatusCancelled {
|
||||||
@@ -1265,7 +1268,7 @@ func buildProcessingTimeoutResultData(existing string, timeout time.Duration, no
|
|||||||
m["timeout_minutes"] = int(timeout.Minutes())
|
m["timeout_minutes"] = int(timeout.Minutes())
|
||||||
m["timeout_at"] = timeutil.FormatAPITime(now)
|
m["timeout_at"] = timeutil.FormatAPITime(now)
|
||||||
m["ship_status"] = "failed"
|
m["ship_status"] = "failed"
|
||||||
m["fail_reason"] = fmt.Sprintf("履约超时:订单已处于 delivering 超过 %d 分钟", int(timeout.Minutes()))
|
m["fail_reason"] = fmt.Sprintf("发货超时:订单已处于 delivering 超过 %d 分钟", int(timeout.Minutes()))
|
||||||
raw, err := json.Marshal(m)
|
raw, err := json.Marshal(m)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return existing
|
return existing
|
||||||
|
|||||||
@@ -86,11 +86,11 @@ func validateOrderStatusTransition(order *model.FulfillmentOrder, next string, k
|
|||||||
if next == model.OrderStatusPaid || next == model.OrderStatusCancelled {
|
if next == model.OrderStatusPaid || next == model.OrderStatusCancelled {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return errors.New("订单未支付,不能履约")
|
return errors.New("订单未支付,不能发货")
|
||||||
case model.OrderStatusCancelled:
|
case model.OrderStatusCancelled:
|
||||||
return errors.New("订单已取消,不能更新状态")
|
return errors.New("订单已取消,不能更新状态")
|
||||||
case model.OrderStatusDelivered:
|
case model.OrderStatusDelivered:
|
||||||
return errors.New("订单已履约成功,不能回退状态")
|
return errors.New("订单已交付,不能回退状态")
|
||||||
}
|
}
|
||||||
switch next {
|
switch next {
|
||||||
case model.OrderStatusDelivering:
|
case model.OrderStatusDelivering:
|
||||||
@@ -105,7 +105,7 @@ func validateOrderStatusTransition(order *model.FulfillmentOrder, next string, k
|
|||||||
}
|
}
|
||||||
case model.OrderStatusShipFailed:
|
case model.OrderStatusShipFailed:
|
||||||
if kind == fulfillmentTransitionTimeout && current != model.OrderStatusDelivering {
|
if kind == fulfillmentTransitionTimeout && current != model.OrderStatusDelivering {
|
||||||
return errors.New("只有履约中的订单可以标记超时")
|
return errors.New("只有发货中的订单可以标记超时")
|
||||||
}
|
}
|
||||||
if current == model.OrderStatusPaid ||
|
if current == model.OrderStatusPaid ||
|
||||||
current == model.OrderStatusShipFailed ||
|
current == model.OrderStatusShipFailed ||
|
||||||
@@ -134,7 +134,7 @@ func canCancelOrder(order *model.FulfillmentOrder) error {
|
|||||||
case model.OrderStatusPending:
|
case model.OrderStatusPending:
|
||||||
return errors.New("订单未支付,不能取消")
|
return errors.New("订单未支付,不能取消")
|
||||||
case model.OrderStatusDelivering, model.OrderStatusDelivered:
|
case model.OrderStatusDelivering, model.OrderStatusDelivered:
|
||||||
return errors.New("订单已进入履约流程,不能取消")
|
return errors.New("订单已进入发货流程,不能取消")
|
||||||
default:
|
default:
|
||||||
return errors.New("订单当前状态不能取消")
|
return errors.New("订单当前状态不能取消")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -393,6 +393,47 @@ func TestOrderStatusTransitions(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestUpdateFulfillmentKeepsResultDataWhenOmitted(t *testing.T) {
|
||||||
|
db := newServiceTestDB(t)
|
||||||
|
merchantID, product := seedFulfillmentMerchant(t, db, "merchant-result-data", 1000, -1, 100)
|
||||||
|
svc := NewFulfillmentService(db, nil)
|
||||||
|
created, err := svc.CreateOrder(CreateFulfillmentOrderInput{
|
||||||
|
MerchantID: merchantID,
|
||||||
|
APIClientID: 13,
|
||||||
|
ClientOrderNo: "client-result-data",
|
||||||
|
SKU: product.SKU,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create order: %v", err)
|
||||||
|
}
|
||||||
|
delivering, err := svc.UpdateFulfillment(FulfillmentUpdateInput{
|
||||||
|
MerchantID: merchantID,
|
||||||
|
APIClientID: 13,
|
||||||
|
OrderNo: created.Order.OrderNo,
|
||||||
|
Status: model.OrderStatusDelivering,
|
||||||
|
ResultData: map[string]string{"stage": "claimed"},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("mark delivering: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(delivering.ResultData, `"stage":"claimed"`) {
|
||||||
|
t.Fatalf("expected initial result_data, got %s", delivering.ResultData)
|
||||||
|
}
|
||||||
|
failed, err := svc.UpdateFulfillment(FulfillmentUpdateInput{
|
||||||
|
MerchantID: merchantID,
|
||||||
|
APIClientID: 13,
|
||||||
|
OrderNo: created.Order.OrderNo,
|
||||||
|
Status: model.OrderStatusShipFailed,
|
||||||
|
FailureReason: "上游暂不可用",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("mark failed: %v", err)
|
||||||
|
}
|
||||||
|
if failed.ResultData != delivering.ResultData {
|
||||||
|
t.Fatalf("result_data should be kept when omitted, before=%s after=%s", delivering.ResultData, failed.ResultData)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestMarkProcessingTimeoutsMarksStaleOrdersFailed(t *testing.T) {
|
func TestMarkProcessingTimeoutsMarksStaleOrdersFailed(t *testing.T) {
|
||||||
db := newServiceTestDB(t)
|
db := newServiceTestDB(t)
|
||||||
merchantID, product := seedFulfillmentMerchant(t, db, "merchant-timeout", 1000, -1, 100)
|
merchantID, product := seedFulfillmentMerchant(t, db, "merchant-timeout", 1000, -1, 100)
|
||||||
@@ -432,7 +473,7 @@ func TestMarkProcessingTimeoutsMarksStaleOrdersFailed(t *testing.T) {
|
|||||||
if err := db.First(&order, delivering.ID).Error; err != nil {
|
if err := db.First(&order, delivering.ID).Error; err != nil {
|
||||||
t.Fatalf("query order: %v", err)
|
t.Fatalf("query order: %v", err)
|
||||||
}
|
}
|
||||||
if order.OrderStatus != model.OrderStatusShipFailed || !strings.Contains(order.FailureReason, "履约超时") {
|
if order.OrderStatus != model.OrderStatusShipFailed || !strings.Contains(order.FailureReason, "发货超时") {
|
||||||
t.Fatalf("expected failed timeout order, got %+v", order)
|
t.Fatalf("expected failed timeout order, got %+v", order)
|
||||||
}
|
}
|
||||||
if !strings.Contains(order.ResultData, `"timeout":true`) {
|
if !strings.Contains(order.ResultData, `"timeout":true`) {
|
||||||
@@ -668,7 +709,7 @@ func TestHandleShipNotifyUpdatesOrderAndEnqueuesMerchantCallback(t *testing.T) {
|
|||||||
t.Fatalf("create order: %v", err)
|
t.Fatalf("create order: %v", err)
|
||||||
}
|
}
|
||||||
if _, err := callbackSvc.CreateSubscription(merchantID, CreateCallbackInput{
|
if _, err := callbackSvc.CreateSubscription(merchantID, CreateCallbackInput{
|
||||||
Name: "履约回调",
|
Name: "发货回调",
|
||||||
URL: "https://example.com/callback",
|
URL: "https://example.com/callback",
|
||||||
Events: "order.fulfillment.updated",
|
Events: "order.fulfillment.updated",
|
||||||
}, 7); err != nil {
|
}, 7); err != nil {
|
||||||
@@ -702,6 +743,9 @@ func TestHandleShipNotifyUpdatesOrderAndEnqueuesMerchantCallback(t *testing.T) {
|
|||||||
if !strings.Contains(delivery.Payload, created.Order.OrderNo) || !strings.Contains(delivery.Payload, "SRC-10001") {
|
if !strings.Contains(delivery.Payload, created.Order.OrderNo) || !strings.Contains(delivery.Payload, "SRC-10001") {
|
||||||
t.Fatalf("callback payload should contain updated order data, got %s", delivery.Payload)
|
t.Fatalf("callback payload should contain updated order data, got %s", delivery.Payload)
|
||||||
}
|
}
|
||||||
|
if !strings.Contains(delivery.Payload, `"can_ship":false`) || strings.Contains(delivery.Payload, "can_fulfill") {
|
||||||
|
t.Fatalf("callback payload should use can_ship fields, got %s", delivery.Payload)
|
||||||
|
}
|
||||||
var auditCount int64
|
var auditCount int64
|
||||||
db.Model(&model.AuditLog{}).Where("entity_id = ? AND action = ?", created.Order.OrderNo, "ship.notify").Count(&auditCount)
|
db.Model(&model.AuditLog{}).Where("entity_id = ? AND action = ?", created.Order.OrderNo, "ship.notify").Count(&auditCount)
|
||||||
if auditCount != 1 {
|
if auditCount != 1 {
|
||||||
|
|||||||
+1
-1
@@ -11,7 +11,7 @@
|
|||||||
|------|--------|--------|-------------|------|------|
|
|------|--------|--------|-------------|------|------|
|
||||||
| 商户侧开放 API | 商户自己的商城 / 系统 | 本平台 | `/api/client/v1` | `X-App-Key` + HMAC | 查商品、创建订单、查订单、取消订单、查钱包 |
|
| 商户侧开放 API | 商户自己的商城 / 系统 | 本平台 | `/api/client/v1` | `X-App-Key` + HMAC | 查商品、创建订单、查订单、取消订单、查钱包 |
|
||||||
| 源头侧发货 API | 上游发货平台 | 本平台 | `/api/open/v1` | `X-Api-Key` + HMAC | 按订单号查可发货信息,回传发货成功 / 失败 |
|
| 源头侧发货 API | 上游发货平台 | 本平台 | `/api/open/v1` | `X-Api-Key` + HMAC | 按订单号查可发货信息,回传发货成功 / 失败 |
|
||||||
| 商户回调 | 本平台 | 商户自己的回调 URL | 商户后台配置 URL | `X-Event-ID` + `X-Timestamp` + `X-Sign` | 把订单创建、履约变化、取消等事件推给商户 |
|
| 商户回调 | 本平台 | 商户自己的回调 URL | 商户后台配置 URL | `X-Event-ID` + `X-Timestamp` + `X-Sign` | 把订单创建、发货状态变化、取消等事件推给商户 |
|
||||||
|
|
||||||
## 2. 订单流
|
## 2. 订单流
|
||||||
|
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ function AppRoutes() {
|
|||||||
<Route index element={<Dashboard />} />
|
<Route index element={<Dashboard />} />
|
||||||
<Route path="merchant-center" element={<MerchantCenter />} />
|
<Route path="merchant-center" element={<MerchantCenter />} />
|
||||||
<Route path="merchant-products" element={<MerchantCenter fixedTab="products" title="商品" />} />
|
<Route path="merchant-products" element={<MerchantCenter fixedTab="products" title="商品" />} />
|
||||||
<Route path="merchant-orders" element={<MerchantCenter fixedTab="orders" title="履约订单" />} />
|
<Route path="merchant-orders" element={<MerchantCenter fixedTab="orders" title="发货订单" />} />
|
||||||
<Route path="merchant-wallet" element={<MerchantCenter fixedTab="wallet" title="钱包" />} />
|
<Route path="merchant-wallet" element={<MerchantCenter fixedTab="wallet" title="钱包" />} />
|
||||||
<Route path="merchant-members" element={<MerchantCenter fixedTab="members" title="成员" />} />
|
<Route path="merchant-members" element={<MerchantCenter fixedTab="members" title="成员" />} />
|
||||||
<Route path="merchant-callbacks" element={<MerchantCenter fixedTab="callbacks" title="回调" />} />
|
<Route path="merchant-callbacks" element={<MerchantCenter fixedTab="callbacks" title="回调" />} />
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ const adminSections: SidebarSection[] = [
|
|||||||
key: 'orders',
|
key: 'orders',
|
||||||
label: '订单管理',
|
label: '订单管理',
|
||||||
icon: <OrderedListOutlined />,
|
icon: <OrderedListOutlined />,
|
||||||
children: [{ key: 'fulfillment-orders', label: '履约订单', path: '/merchant-orders' }],
|
children: [{ key: 'fulfillment-orders', label: '发货订单', path: '/merchant-orders' }],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'staff',
|
key: 'staff',
|
||||||
@@ -110,7 +110,7 @@ const merchantSections: SidebarSection[] = [
|
|||||||
key: 'orders',
|
key: 'orders',
|
||||||
label: '订单管理',
|
label: '订单管理',
|
||||||
icon: <OrderedListOutlined />,
|
icon: <OrderedListOutlined />,
|
||||||
children: [{ key: 'fulfillment-orders', label: '履约订单', path: '/merchant-orders' }],
|
children: [{ key: 'fulfillment-orders', label: '发货订单', path: '/merchant-orders' }],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'funds',
|
key: 'funds',
|
||||||
|
|||||||
@@ -16,14 +16,14 @@ export const errorCodes: ParamSpec[] = [
|
|||||||
{ name: '500', type: 'int', desc: '服务端内部错误' },
|
{ name: '500', type: 'int', desc: '服务端内部错误' },
|
||||||
]
|
]
|
||||||
|
|
||||||
// 订单状态与可履约说明。
|
// 订单状态与可发货说明。
|
||||||
export const orderStatusTable: ParamSpec[] = [
|
export const orderStatusTable: ParamSpec[] = [
|
||||||
{ name: 'pending', type: 'order_status', desc: '待支付(当前无真实支付流程,仅预留)', example: 'can_fulfill=false' },
|
{ name: 'pending', type: 'order_status', desc: '待支付(当前无真实支付流程,仅预留)', example: 'can_ship=false' },
|
||||||
{ name: 'paid', type: 'order_status', desc: '已支付/已扣款,可发货', example: 'can_fulfill=true' },
|
{ name: 'paid', type: 'order_status', desc: '已支付/已扣款,可发货', example: 'can_ship=true' },
|
||||||
{ name: 'delivering', type: 'order_status', desc: '发货中', example: 'can_fulfill=false' },
|
{ name: 'delivering', type: 'order_status', desc: '发货中', example: 'can_ship=false' },
|
||||||
{ name: 'delivered', type: 'order_status', desc: '已交付', example: 'can_fulfill=false' },
|
{ name: 'delivered', type: 'order_status', desc: '已交付', example: 'can_ship=false' },
|
||||||
{ name: 'ship_failed', type: 'order_status', desc: '发货失败,可重试', example: 'can_fulfill=true' },
|
{ name: 'ship_failed', type: 'order_status', desc: '发货失败,可重试', example: 'can_ship=true' },
|
||||||
{ name: 'cancelled', type: 'order_status', desc: '已取消', example: 'can_fulfill=false' },
|
{ name: 'cancelled', type: 'order_status', desc: '已取消', example: 'can_ship=false' },
|
||||||
]
|
]
|
||||||
|
|
||||||
// 订单对象公共字段说明,下单/查询/取消响应共用。
|
// 订单对象公共字段说明,下单/查询/取消响应共用。
|
||||||
@@ -31,9 +31,9 @@ const orderFields: ParamSpec[] = [
|
|||||||
{ name: 'order_no', type: 'string', required: true, desc: '平台订单号' },
|
{ name: 'order_no', type: 'string', required: true, desc: '平台订单号' },
|
||||||
{ name: 'client_order_no', type: 'string', required: true, desc: '调用方传入的幂等单号' },
|
{ name: 'client_order_no', type: 'string', required: true, desc: '调用方传入的幂等单号' },
|
||||||
{ name: 'order_status', type: 'enum', desc: '订单状态,见「状态说明」' },
|
{ name: 'order_status', type: 'enum', desc: '订单状态,见「状态说明」' },
|
||||||
{ name: 'can_fulfill', type: 'bool', desc: '是否允许履约器接单/发货' },
|
{ name: 'can_ship', type: 'bool', desc: '是否允许发货' },
|
||||||
{ name: 'cannot_fulfill_reason', type: 'string', desc: 'can_fulfill=false 时的原因' },
|
{ name: 'cannot_ship_reason', type: 'string', desc: 'can_ship=false 时的原因' },
|
||||||
{ name: 'product.sku', type: 'string', desc: '商品标识,履约以此为准' },
|
{ name: 'product.sku', type: 'string', desc: '商品标识,发货以此为准' },
|
||||||
{ name: 'product.name', type: 'string', desc: '商品展示名' },
|
{ name: 'product.name', type: 'string', desc: '商品展示名' },
|
||||||
{ name: 'quantity', type: 'int', desc: '数量' },
|
{ name: 'quantity', type: 'int', desc: '数量' },
|
||||||
{ name: 'base_amount', type: 'int', desc: '商品基础金额(最小货币单位)' },
|
{ name: 'base_amount', type: 'int', desc: '商品基础金额(最小货币单位)' },
|
||||||
@@ -47,7 +47,7 @@ const orderFields: ParamSpec[] = [
|
|||||||
{ name: 'provider_order_no', type: 'string', desc: '发货平台侧单号' },
|
{ name: 'provider_order_no', type: 'string', desc: '发货平台侧单号' },
|
||||||
{ name: 'failure_reason', type: 'string', desc: '最近一次失败原因' },
|
{ name: 'failure_reason', type: 'string', desc: '最近一次失败原因' },
|
||||||
{ name: 'created_at', type: 'datetime', desc: '创建时间' },
|
{ name: 'created_at', type: 'datetime', desc: '创建时间' },
|
||||||
{ name: 'delivered_at', type: 'datetime', desc: '履约成功时间' },
|
{ name: 'delivered_at', type: 'datetime', desc: '交付时间' },
|
||||||
{ name: 'cancelled_at', type: 'datetime', desc: '取消时间' },
|
{ name: 'cancelled_at', type: 'datetime', desc: '取消时间' },
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -58,8 +58,8 @@ const orderResponseExample = `{
|
|||||||
"order_no": "FO20260730000123",
|
"order_no": "FO20260730000123",
|
||||||
"client_order_no": "shop-10001",
|
"client_order_no": "shop-10001",
|
||||||
"order_status": "paid",
|
"order_status": "paid",
|
||||||
"can_fulfill": true,
|
"can_ship": true,
|
||||||
"cannot_fulfill_reason": "",
|
"cannot_ship_reason": "",
|
||||||
"product": { "sku": "suit_pink_sheep", "name": "套装-糯粉咩咩" },
|
"product": { "sku": "suit_pink_sheep", "name": "套装-糯粉咩咩" },
|
||||||
"quantity": 1,
|
"quantity": 1,
|
||||||
"base_amount": 100,
|
"base_amount": 100,
|
||||||
@@ -197,7 +197,7 @@ export const endpoints: EndpointSpec[] = [
|
|||||||
{ name: 'sku', type: 'string', required: true, desc: '商品标识,取自商品列表', example: 'suit_pink_sheep' },
|
{ name: 'sku', type: 'string', required: true, desc: '商品标识,取自商品列表', example: 'suit_pink_sheep' },
|
||||||
{ name: 'quantity', type: 'int', desc: '数量,默认 1', example: '1' },
|
{ name: 'quantity', type: 'int', desc: '数量,默认 1', example: '1' },
|
||||||
{ name: 'buyer_reference', type: 'string', desc: '买家标识/备注', example: 'buyer-001' },
|
{ name: 'buyer_reference', type: 'string', desc: '买家标识/备注', example: 'buyer-001' },
|
||||||
{ name: 'data', type: 'object', desc: '透传给履约器的业务数据(区服、账号等),原样存储并回显' },
|
{ name: 'data', type: 'object', desc: '透传给发货流程的业务数据(区服、账号等),原样存储并回显' },
|
||||||
],
|
],
|
||||||
requestExample: `{
|
requestExample: `{
|
||||||
"client_order_no": "shop-10001",
|
"client_order_no": "shop-10001",
|
||||||
@@ -215,7 +215,7 @@ export const endpoints: EndpointSpec[] = [
|
|||||||
"order_no": "FO20260730000123",
|
"order_no": "FO20260730000123",
|
||||||
"client_order_no": "shop-10001",
|
"client_order_no": "shop-10001",
|
||||||
"order_status": "paid",
|
"order_status": "paid",
|
||||||
"can_fulfill": true
|
"can_ship": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}`,
|
}`,
|
||||||
@@ -339,7 +339,7 @@ export const endpoints: EndpointSpec[] = [
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
path: '/api/client/v1/orders/{order_no}/delivery/submit',
|
path: '/api/client/v1/orders/{order_no}/delivery/submit',
|
||||||
title: '提交发货',
|
title: '提交发货',
|
||||||
summary: '提交发货到上游并进入履约中状态',
|
summary: '提交发货到上游并进入发货中状态',
|
||||||
scope: 'orders:write',
|
scope: 'orders:write',
|
||||||
pathParams: [{ name: 'order_no', type: 'string', required: true, desc: '平台订单号', example: 'FO20260730000123' }],
|
pathParams: [{ name: 'order_no', type: 'string', required: true, desc: '平台订单号', example: 'FO20260730000123' }],
|
||||||
bodyParams: [
|
bodyParams: [
|
||||||
@@ -353,7 +353,7 @@ export const endpoints: EndpointSpec[] = [
|
|||||||
responseExample: deliverySubmitResponseExample,
|
responseExample: deliverySubmitResponseExample,
|
||||||
responseFields: [
|
responseFields: [
|
||||||
{ name: 'order_no', type: 'string', desc: '平台订单号' },
|
{ name: 'order_no', type: 'string', desc: '平台订单号' },
|
||||||
{ name: 'status', type: 'string', desc: '提交后的履约状态' },
|
{ name: 'status', type: 'string', desc: '提交后的订单状态' },
|
||||||
{ name: 'message', type: 'string', desc: '处理结果说明' },
|
{ name: 'message', type: 'string', desc: '处理结果说明' },
|
||||||
{ name: 'provider_order_no', type: 'string', desc: '上游订单号' },
|
{ name: 'provider_order_no', type: 'string', desc: '上游订单号' },
|
||||||
{ name: 'game_account', type: 'object', desc: '上游绑定结果' },
|
{ name: 'game_account', type: 'object', desc: '上游绑定结果' },
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ import { formatDateTime } from '../utils/time'
|
|||||||
|
|
||||||
const statusMap: Record<string, { color: string; text: string }> = {
|
const statusMap: Record<string, { color: string; text: string }> = {
|
||||||
paid: { color: 'orange', text: '待发货' },
|
paid: { color: 'orange', text: '待发货' },
|
||||||
delivering: { color: 'blue', text: '履约中' },
|
delivering: { color: 'blue', text: '发货中' },
|
||||||
delivered: { color: 'green', text: '已交付' },
|
delivered: { color: 'green', text: '已交付' },
|
||||||
ship_failed: { color: 'red', text: '发货失败' },
|
ship_failed: { color: 'red', text: '发货失败' },
|
||||||
cancelled: { color: 'default', text: '已取消' },
|
cancelled: { color: 'default', text: '已取消' },
|
||||||
|
|||||||
@@ -61,13 +61,13 @@ const scopeOptions = [
|
|||||||
{ value: 'products:read', label: '商品读取' },
|
{ value: 'products:read', label: '商品读取' },
|
||||||
{ value: 'orders:read', label: '订单读取' },
|
{ value: 'orders:read', label: '订单读取' },
|
||||||
{ value: 'orders:write', label: '订单写入' },
|
{ value: 'orders:write', label: '订单写入' },
|
||||||
{ value: 'fulfillment:read', label: '履约读取' },
|
{ value: 'fulfillment:read', label: '发货读取' },
|
||||||
{ value: 'wallet:read', label: '钱包读取' },
|
{ value: 'wallet:read', label: '钱包读取' },
|
||||||
]
|
]
|
||||||
|
|
||||||
const eventOptions = [
|
const eventOptions = [
|
||||||
{ value: 'order.created', label: '订单创建' },
|
{ value: 'order.created', label: '订单创建' },
|
||||||
{ value: 'order.fulfillment.updated', label: '履约更新' },
|
{ value: 'order.fulfillment.updated', label: '发货更新' },
|
||||||
{ value: 'order.cancelled', label: '订单取消' },
|
{ value: 'order.cancelled', label: '订单取消' },
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -689,7 +689,7 @@ export default function MerchantCenter({ fixedTab, title = '商户中心' }: Mer
|
|||||||
|
|
||||||
const tabItems = [
|
const tabItems = [
|
||||||
{ key: 'products', label: '商品', disabled: !hasFeature('products'), children: productContent },
|
{ key: 'products', label: '商品', disabled: !hasFeature('products'), children: productContent },
|
||||||
{ key: 'orders', label: '履约订单', disabled: !hasFeature('orders'), children: orderContent },
|
{ key: 'orders', label: '发货订单', disabled: !hasFeature('orders'), children: orderContent },
|
||||||
{ key: 'wallet', label: '钱包', disabled: !hasFeature('wallet'), children: walletContent },
|
{ key: 'wallet', label: '钱包', disabled: !hasFeature('wallet'), children: walletContent },
|
||||||
{ key: 'api', label: 'API 密钥', disabled: !hasFeature('api'), children: apiKeyContent },
|
{ key: 'api', label: 'API 密钥', disabled: !hasFeature('api'), children: apiKeyContent },
|
||||||
{ key: 'callbacks', label: '回调', disabled: !hasFeature('callbacks'), children: callbackContent },
|
{ key: 'callbacks', label: '回调', disabled: !hasFeature('callbacks'), children: callbackContent },
|
||||||
@@ -765,7 +765,7 @@ export default function MerchantCenter({ fixedTab, title = '商户中心' }: Mer
|
|||||||
</Form.Item>
|
</Form.Item>
|
||||||
)}
|
)}
|
||||||
</Space>
|
</Space>
|
||||||
<Form.Item name="fulfillment_config" label="履约配置">
|
<Form.Item name="fulfillment_config" label="发货配置">
|
||||||
<Input.TextArea rows={3} placeholder='{"provider":"manual"}' />
|
<Input.TextArea rows={3} placeholder='{"provider":"manual"}' />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Form>
|
</Form>
|
||||||
|
|||||||
@@ -452,7 +452,7 @@ function EndpointSection({ spec }: { spec: EndpointSpec }) {
|
|||||||
function StatusSection() {
|
function StatusSection() {
|
||||||
return (
|
return (
|
||||||
<SectionWrap id="status" title="状态说明">
|
<SectionWrap id="status" title="状态说明">
|
||||||
<Card className="api-docs__card" size="small" title="订单状态 order_status 与 can_fulfill">
|
<Card className="api-docs__card" size="small" title="订单状态 order_status 与 can_ship">
|
||||||
<Table
|
<Table
|
||||||
className="api-docs__table"
|
className="api-docs__table"
|
||||||
size="small"
|
size="small"
|
||||||
@@ -462,11 +462,11 @@ function StatusSection() {
|
|||||||
columns={[
|
columns={[
|
||||||
{ title: '状态', dataIndex: 'name', width: 140, render: (v) => <Text code>{v}</Text> },
|
{ title: '状态', dataIndex: 'name', width: 140, render: (v) => <Text code>{v}</Text> },
|
||||||
{
|
{
|
||||||
title: 'can_fulfill',
|
title: 'can_ship',
|
||||||
dataIndex: 'example',
|
dataIndex: 'example',
|
||||||
width: 160,
|
width: 160,
|
||||||
render: (v: string) =>
|
render: (v: string) =>
|
||||||
v === 'can_fulfill=true' ? <Tag color="green">true</Tag> : <Tag>false</Tag>,
|
v === 'can_ship=true' ? <Tag color="green">true</Tag> : <Tag>false</Tag>,
|
||||||
},
|
},
|
||||||
{ title: '说明', dataIndex: 'desc' },
|
{ title: '说明', dataIndex: 'desc' },
|
||||||
]}
|
]}
|
||||||
|
|||||||
Reference in New Issue
Block a user