From 5858ff59ae5fdce4f5f3d562b22d4a1063411694 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Thu, 30 Jul 2026 19:35:34 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E6=AD=A3=E5=8F=91=E8=B4=A7=E5=9B=9E?= =?UTF-8?q?=E8=B0=83=E6=B5=81=E7=A8=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/handler/open_v1.go | 68 ------------------- backend/internal/router/router.go | 1 - backend/internal/service/fulfillment.go | 49 ++++++++++++-- backend/internal/service/fulfillment_test.go | 69 ++++++++++++++++++++ frontend/src/openapi/endpoints.ts | 32 +-------- frontend/src/pages/MerchantCenter.tsx | 1 - frontend/src/pages/OpenApiDocs.tsx | 14 ++-- 7 files changed, 122 insertions(+), 112 deletions(-) diff --git a/backend/internal/handler/open_v1.go b/backend/internal/handler/open_v1.go index 7f91384..c993cf1 100644 --- a/backend/internal/handler/open_v1.go +++ b/backend/internal/handler/open_v1.go @@ -144,74 +144,6 @@ func (h *OpenV1Handler) CancelOrder(c *gin.Context) { response.OK(c, buildOpenOrderResponse(order)) } -type openShipNotifyReq struct { - OrderNo string `json:"order_no"` - ShipStatus string `json:"ship_status" binding:"required"` - ProviderOrderNo string `json:"provider_order_no"` - FailReason string `json:"fail_reason"` - Result json.RawMessage `json:"result"` -} - -func (h *OpenV1Handler) ShipNotify(c *gin.Context) { - var req openShipNotifyReq - if err := c.ShouldBindJSON(&req); err != nil { - openlog.Warn(c, "client_ship_notify bind_fail err=%v", err) - response.BadRequest(c, "参数错误:ship_status 必填") - return - } - status := "" - switch req.ShipStatus { - case "processing": - status = model.FulfillmentStatusProcessing - case "success": - status = model.FulfillmentStatusSucceeded - case "failed": - status = model.FulfillmentStatusFailed - default: - openlog.Warn(c, "client_ship_notify bad_status=%s", req.ShipStatus) - response.BadRequest(c, "ship_status 仅支持 processing、success、failed") - return - } - var result interface{} - if len(req.Result) > 0 { - if err := json.Unmarshal(req.Result, &result); err != nil { - openlog.Warn(c, "client_ship_notify bad_result err=%v", err) - response.BadRequest(c, "result 必须是有效 JSON") - return - } - } - client := middleware.GetAPIClient(c) - orderNo := c.Param("order_no") - if orderNo == "" { - orderNo = req.OrderNo - } - if orderNo == "" { - openlog.Warn(c, "client_ship_notify missing_order_no") - response.BadRequest(c, "order_no 必填") - return - } - openlog.Info(c, "client_ship_notify start order_no=%s ship_status=%s provider_no=%s", - orderNo, req.ShipStatus, req.ProviderOrderNo) - order, err := h.fulfillmentSvc.UpdateFulfillment(service.FulfillmentUpdateInput{ - MerchantID: middleware.GetMerchantID(c), - APIClientID: client.ID, - OrderNo: orderNo, - Status: status, - ProviderOrderNo: req.ProviderOrderNo, - FailureReason: req.FailReason, - ResultData: result, - }) - if err != nil { - openlog.Warn(c, "client_ship_notify fail order_no=%s ship_status=%s err=%v", - orderNo, req.ShipStatus, err) - response.BadRequest(c, err.Error()) - return - } - openlog.Info(c, "client_ship_notify ok order_no=%s status=%s", - order.OrderNo, order.FulfillmentStatus) - response.OK(c, buildOpenOrderResponse(order)) -} - func (h *OpenV1Handler) GetWallet(c *gin.Context) { openlog.Info(c, "get_wallet start") wallet, err := h.fulfillmentSvc.GetWallet(middleware.GetMerchantID(c)) diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index 2a99b02..6be4672 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -75,7 +75,6 @@ func Setup(h *Handlers) *gin.Engine { 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.POST("/orders/:order_no/cancel", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureOrders), middleware.RequireAPIScope("orders:write"), h.Open.CancelOrder) - clientOpen.POST("/orders/:order_no/ship-notify", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureOrders), middleware.RequireAPIScope("fulfillment:write"), h.Open.ShipNotify) clientOpen.GET("/wallet", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureWallet), middleware.RequireAPIScope("wallet:read"), h.Open.GetWallet) } diff --git a/backend/internal/service/fulfillment.go b/backend/internal/service/fulfillment.go index 0f25e35..850a1f5 100644 --- a/backend/internal/service/fulfillment.go +++ b/backend/internal/service/fulfillment.go @@ -928,13 +928,46 @@ func (s *FulfillmentService) HandleShipNotify(in ShipNotifyInput) (*ShipNotifyRe msg = "订单已标记为发货中" } - // 游戏相关字段与推送结果合并写入 ResultData,便于后续查询还原(新模型无独立列)。 - updates["result_data"] = buildShipNotifyResultData(order.ResultData, in, shippedAt) - - if err := s.db.Model(&model.FulfillmentOrder{}).Where("id = ?", order.ID).Updates(updates).Error; err != nil { + resultData := buildShipNotifyResultData(order.ResultData, in, shippedAt) + updates["result_data"] = resultData + var updated model.FulfillmentOrder + if err := s.db.Transaction(func(tx *gorm.DB) error { + if err := tx.Model(&model.FulfillmentOrder{}).Where("id = ?", order.ID).Updates(updates).Error; err != nil { + return err + } + jobStatus := model.FulfillmentJobStatusProcessing + if nextStatus == model.FulfillmentStatusSucceeded { + jobStatus = model.FulfillmentJobStatusSucceeded + } else if nextStatus == model.FulfillmentStatusFailed { + jobStatus = model.FulfillmentJobStatusFailed + } + jobUpdates := map[string]interface{}{ + "status": jobStatus, + "result_payload": resultData, + "last_error": in.FailReason, + } + if in.ProviderOrderNo != "" { + jobUpdates["provider_order_no"] = in.ProviderOrderNo + } + if err := tx.Model(&model.FulfillmentJob{}).Where("order_id = ?", order.ID).Updates(jobUpdates).Error; err != nil { + return err + } + metadata := shipNotifyAuditMetadata(in, nextStatus, msg) + if err := writeAudit(tx, &order.MerchantID, nil, nil, "ship.notify", "fulfillment_order", order.OrderNo, metadata); err != nil { + return err + } + if err := tx.Preload("MerchantProduct.Product").First(&updated, order.ID).Error; err != nil { + return err + } + if s.callbacks != nil { + if err := s.callbacks.Enqueue(tx, order.MerchantID, "order.fulfillment.updated", orderCallbackData(&updated)); err != nil { + return err + } + } + return nil + }); err != nil { return nil, err } - _ = s.writeShipNotifyAudit(order, in, nextStatus, msg) return &ShipNotifyResult{ OrderNo: order.OrderNo, @@ -945,6 +978,10 @@ func (s *FulfillmentService) HandleShipNotify(in ShipNotifyInput) (*ShipNotifyRe // writeShipNotifyAudit 将上游推送留痕写入 AuditLog(替代旧的 ShipLog 表)。 func (s *FulfillmentService) writeShipNotifyAudit(order *model.FulfillmentOrder, in ShipNotifyInput, resultStatus, message string) error { + return writeAudit(s.db, &order.MerchantID, nil, nil, "ship.notify", "fulfillment_order", order.OrderNo, shipNotifyAuditMetadata(in, resultStatus, message)) +} + +func shipNotifyAuditMetadata(in ShipNotifyInput, resultStatus, message string) map[string]interface{} { metadata := map[string]interface{}{ "ship_status": in.ShipStatus, "provider_order_no": in.ProviderOrderNo, @@ -953,7 +990,7 @@ func (s *FulfillmentService) writeShipNotifyAudit(order *model.FulfillmentOrder, "message": message, "payload": in.RawPayload, } - return writeAudit(s.db, &order.MerchantID, nil, nil, "ship.notify", "fulfillment_order", order.OrderNo, metadata) + return metadata } // buildShipNotifyResultData 把推送结果与游戏字段合并进 ResultData JSON。 diff --git a/backend/internal/service/fulfillment_test.go b/backend/internal/service/fulfillment_test.go index 24265c9..30ce8dc 100644 --- a/backend/internal/service/fulfillment_test.go +++ b/backend/internal/service/fulfillment_test.go @@ -20,6 +20,8 @@ func newServiceTestDB(t *testing.T) *gorm.DB { &model.WalletLedgerEntry{}, &model.FulfillmentOrder{}, &model.FulfillmentJob{}, + &model.CallbackSubscription{}, + &model.CallbackDelivery{}, &model.AuditLog{}, ) } @@ -455,3 +457,70 @@ func TestCreateTestOrderSupportsFailedFulfillableStatus(t *testing.T) { t.Fatalf("failed test order should be re-fulfillable, got %+v", openOrder) } } + +func TestHandleShipNotifyUpdatesOrderJobAndEnqueuesMerchantCallback(t *testing.T) { + db := newServiceTestDB(t) + merchantID, product := seedFulfillmentMerchant(t, db, "merchant-source-notify", 1000, 2, 100) + codec, err := NewSecretCodec("test-master-key") + if err != nil { + t.Fatalf("codec: %v", err) + } + callbackSvc := NewCallbackService(db, codec) + svc := NewFulfillmentService(db, callbackSvc) + created, err := svc.CreateOrder(CreateFulfillmentOrderInput{ + MerchantID: merchantID, + APIClientID: 31, + ClientOrderNo: "client-source-notify", + SKU: product.SKU, + }) + if err != nil { + t.Fatalf("create order: %v", err) + } + if _, err := callbackSvc.CreateSubscription(merchantID, CreateCallbackInput{ + Name: "履约回调", + URL: "https://example.com/callback", + Events: "order.fulfillment.updated", + }, 7); err != nil { + t.Fatalf("create callback subscription: %v", err) + } + + result, err := svc.HandleShipNotify(ShipNotifyInput{ + OrderNo: created.Order.OrderNo, + ShipStatus: "success", + ProviderOrderNo: "SRC-10001", + FailReason: "ignored", + }) + if err != nil { + t.Fatalf("handle ship notify: %v", err) + } + if result.Status != "delivered" { + t.Fatalf("unexpected notify result: %+v", result) + } + + var order model.FulfillmentOrder + if err := db.First(&order, created.Order.ID).Error; err != nil { + t.Fatalf("query order: %v", err) + } + if order.FulfillmentStatus != model.FulfillmentStatusSucceeded || order.ProviderOrderNo != "SRC-10001" || order.DeliveredAt == nil { + t.Fatalf("unexpected order after source notify: %+v", order) + } + var job model.FulfillmentJob + if err := db.Where("order_id = ?", created.Order.ID).First(&job).Error; err != nil { + t.Fatalf("query fulfillment job: %v", err) + } + if job.Status != model.FulfillmentJobStatusSucceeded || job.ProviderOrderNo != "SRC-10001" { + t.Fatalf("unexpected job after source notify: %+v", job) + } + var delivery model.CallbackDelivery + if err := db.Where("merchant_id = ? AND event = ?", merchantID, "order.fulfillment.updated").First(&delivery).Error; err != nil { + t.Fatalf("query callback delivery: %v", err) + } + 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) + } + var auditCount int64 + db.Model(&model.AuditLog{}).Where("entity_id = ? AND action = ?", created.Order.OrderNo, "ship.notify").Count(&auditCount) + if auditCount != 1 { + t.Fatalf("expected one ship notify audit, got %d", auditCount) + } +} diff --git a/frontend/src/openapi/endpoints.ts b/frontend/src/openapi/endpoints.ts index bfb7bd9..5de53a6 100644 --- a/frontend/src/openapi/endpoints.ts +++ b/frontend/src/openapi/endpoints.ts @@ -32,7 +32,7 @@ export const paymentStatusTable: ParamSpec[] = [ { name: 'cancelled', type: 'payment_status', desc: '已取消' }, ] -// 订单对象公共字段说明,下单/查询/取消/回传响应共用。 +// 订单对象公共字段说明,下单/查询/取消响应共用。 const orderFields: ParamSpec[] = [ { name: 'order_no', type: 'string', required: true, desc: '平台订单号' }, { name: 'client_order_no', type: 'string', required: true, desc: '调用方传入的幂等单号' }, @@ -50,8 +50,8 @@ const orderFields: ParamSpec[] = [ { name: 'currency', type: 'string', desc: '货币,默认 POINT' }, { name: 'buyer_reference', type: 'string', desc: '买家标识/备注' }, { name: 'data', type: 'object', desc: '下单时透传的请求数据(原样回显)' }, - { name: 'result', type: 'object', desc: '履约器回传的结果数据(原样回显)' }, - { name: 'provider_order_no', type: 'string', desc: '履约器侧单号' }, + { name: 'result', type: 'object', desc: '发货平台回调的结果数据(原样回显)' }, + { name: 'provider_order_no', type: 'string', desc: '发货平台侧单号' }, { name: 'failure_reason', type: 'string', desc: '最近一次失败原因' }, { name: 'created_at', type: 'datetime', desc: '创建时间' }, { name: 'delivered_at', type: 'datetime', desc: '履约成功时间' }, @@ -200,32 +200,6 @@ export const endpoints: EndpointSpec[] = [ '已进入 processing/succeeded 的订单不可取消,返回 400。', ], }, - { - key: 'ship-notify', - method: 'POST', - path: '/api/client/v1/orders/{order_no}/ship-notify', - summary: '履约器回传履约结果(processing/success/failed)', - scope: 'fulfillment:write', - pathParams: [{ name: 'order_no', type: 'string', required: true, desc: '平台订单号', example: 'FO20260730000123' }], - bodyParams: [ - { name: 'ship_status', type: 'enum(processing|success|failed)', required: true, desc: '履约结果', example: 'success' }, - { name: 'provider_order_no', type: 'string', desc: '履约器侧单号', example: 'SRC20260720001' }, - { name: 'fail_reason', type: 'string', desc: '失败原因(failed 时建议填)' }, - { name: 'result', type: 'object', desc: '履约结果数据,原样存储并回显' }, - ], - requestExample: `{ - "ship_status": "success", - "provider_order_no": "SRC20260720001", - "result": { "delivered_at": "2026-07-30T10:05:00+08:00" } -}`, - responseExample: orderResponseExample, - responseFields: orderFields, - notes: [ - 'processing → fulfillment_status=processing;success → succeeded;failed → failed(可重试)。', - '已 succeeded 的订单再推 success 仍返回成功,不重复处理(幂等)。', - '已 succeeded 推 failed、或 cancelled 订单推送 → 返回 400 拒绝。', - ], - }, { key: 'get-wallet', method: 'GET', diff --git a/frontend/src/pages/MerchantCenter.tsx b/frontend/src/pages/MerchantCenter.tsx index 12a27f7..08e48bb 100644 --- a/frontend/src/pages/MerchantCenter.tsx +++ b/frontend/src/pages/MerchantCenter.tsx @@ -67,7 +67,6 @@ const scopeOptions = [ { value: 'orders:read', label: '订单读取' }, { value: 'orders:write', label: '订单写入' }, { value: 'fulfillment:read', label: '履约读取' }, - { value: 'fulfillment:write', label: '履约写入' }, { value: 'wallet:read', label: '钱包读取' }, ] diff --git a/frontend/src/pages/OpenApiDocs.tsx b/frontend/src/pages/OpenApiDocs.tsx index ab9253e..ec3b13d 100644 --- a/frontend/src/pages/OpenApiDocs.tsx +++ b/frontend/src/pages/OpenApiDocs.tsx @@ -35,9 +35,9 @@ export default function OpenApiDocs() { 开放接口 - 面向商户系统、履约器和外部平台调用的通用开放 API。凭证在「商户中心 / API 客户端」创建, + 面向商户系统调用的开放 API。凭证在「商户中心 / API 客户端」创建, 采用 X-App-Key + HMAC-SHA256 签名鉴权。 - 原 /api/open/v1 保留给上游发货对接,不在本页展示。 + /api/open/v1 仅保留给发货平台查询与回传,不在本页展示。 在「商户中心 / API 客户端」创建凭证,获得 AppKeyAppSecret
  • 每次请求携带 X-App-Key / X-Timestamp / X-Nonce / X-Sign 四个鉴权头。
  • 调用「商品列表」拿到可售 sku,调用「下单」创建订单并扣款。
  • -
  • 履约器通过「发货回传」回写 processing/success/failed,状态可由「查询订单」轮询。
  • +
  • 拿到 order_no 后进入发货链接;订单状态由发货平台回调更新,可通过「查询订单」轮询。
  • } /> @@ -234,13 +234,13 @@ function StatusTab() { ↓ POST /orders 下单(幂等,Idempotency-Key = client_order_no) ↓ -(履约器)POST /orders/{order_no}/ship-notify ship_status=processing +拿响应里的 order_no 进入发货平台链接 ↓ -按 product.sku 发货 +发货平台查询订单并执行发货 ↓ -POST /orders/{order_no}/ship-notify ship_status=success 或 failed +发货平台通过上游回调更新 fulfillment_status ↓ -(可选)GET /orders/{order_no} 轮询确认 fulfillment_status=succeeded`} +GET /orders/{order_no} 轮询,或接收 order.fulfillment.updated 回调`} } />