diff --git a/README.md b/README.md index ac5d40d..5898baa 100644 --- a/README.md +++ b/README.md @@ -107,7 +107,14 @@ make docker-down # 停止并移除 ## 开放接口(皮肤源头对接) -上游皮肤源头系统通过以下接口对接,需携带签名头 `X-Api-Key`、`X-Timestamp`、`X-Nonce`、`X-Sign`。 +项目有两套外部 API,请先区分调用方: + +- 商户侧 `/api/client/v1`:商户系统创建订单、查询订单和钱包,使用商户专属 `X-App-Key`。 +- 源头侧 `/api/open/v1`:上游发货平台查询订单并回传发货结果,使用平台配置的 `X-Api-Key`。 + +源头侧 `ship_notify` 当前只使用 `success` / `failed`,速查见 [`docs/发货通知约定.md`](docs/发货通知约定.md),完整关系图见 [`docs/API对接关系.md`](docs/API对接关系.md)。两套 API 的签名算法不同,不能混用鉴权头或签名串。 + +上游皮肤源头系统调用源头侧接口时,需携带签名头 `X-Api-Key`、`X-Timestamp`、`X-Nonce`、`X-Sign`。 ### 1. 查询订单(发货前置) @@ -142,10 +149,10 @@ POST /api/open/v1/orders/ship-notify | 字段 | 必填 | 说明 | |------|------|------| | `order_no` | 是 | 系统订单号 | -| `ship_status` | 是 | `success` / `failed` / `processing` | +| `ship_status` | 是 | 仅 `success` / `failed` | | `provider_order_no` | 否 | 上游单号 | | `shipped_at` | 否 | 发货时间,RFC3339 格式 | -| `fail_reason` | 否 | 失败原因 | +| `fail_reason` | 失败时是 | `failed` 时必填,填写详细失败原因 | | `game_channel` | 否 | 账号区服(安卓/IOS-微信/QQ) | | `game_uid` | 否 | 游戏角色 UUID | | `role_name` | 否 | 角色名 | diff --git a/backend/internal/database/migrations/002_source_api_nonces.sql b/backend/internal/database/migrations/002_source_api_nonces.sql new file mode 100644 index 0000000..f699550 --- /dev/null +++ b/backend/internal/database/migrations/002_source_api_nonces.sql @@ -0,0 +1,13 @@ +-- 源头开放接口 nonce 防重,跨进程、跨重启生效。 +CREATE TABLE IF NOT EXISTS source_api_nonces ( + id BIGSERIAL PRIMARY KEY, + created_at TIMESTAMPTZ, + api_key VARCHAR(96) NOT NULL, + nonce VARCHAR(64) NOT NULL, + expires_at TIMESTAMPTZ NOT NULL +); + +CREATE UNIQUE INDEX IF NOT EXISTS idx_source_api_nonce + ON source_api_nonces (api_key, nonce); +CREATE INDEX IF NOT EXISTS idx_source_api_nonces_expires_at + ON source_api_nonces (expires_at); diff --git a/backend/internal/handler/open.go b/backend/internal/handler/open.go index 9a9d8cc..b2b6ba2 100644 --- a/backend/internal/handler/open.go +++ b/backend/internal/handler/open.go @@ -55,7 +55,7 @@ func (h *OpenHandler) QueryOrder(c *gin.Context) { type shipNotifyReq struct { OrderNo string `json:"order_no" binding:"required"` - ShipStatus string `json:"ship_status" binding:"required"` // success/failed/processing + ShipStatus string `json:"ship_status" binding:"required"` // success/failed ProviderOrderNo string `json:"provider_order_no"` ShippedAt string `json:"shipped_at"` // RFC3339 可选 FailReason string `json:"fail_reason"` @@ -77,8 +77,14 @@ func (h *OpenHandler) ShipNotify(c *gin.Context) { } raw, _ := json.Marshal(req) + rawPayload := string(raw) + if body, ok := c.Get(openlog.CtxBody); ok { + if bodyText, ok := body.(string); ok && bodyText != "" { + rawPayload = bodyText + } + } openlog.Info(c, "ship_notify start order_no=%s ship_status=%s provider_no=%s shipped_at=%s fail_reason=%q payload=%s", - req.OrderNo, req.ShipStatus, req.ProviderOrderNo, req.ShippedAt, req.FailReason, openlog.Truncate(string(raw), 800), + req.OrderNo, req.ShipStatus, req.ProviderOrderNo, req.ShippedAt, req.FailReason, openlog.Truncate(rawPayload, 800), ) var shippedAt *time.Time @@ -98,7 +104,7 @@ func (h *OpenHandler) ShipNotify(c *gin.Context) { ProviderOrderNo: req.ProviderOrderNo, ShippedAt: shippedAt, FailReason: req.FailReason, - RawPayload: string(raw), + RawPayload: rawPayload, GameChannel: req.GameChannel, GameUID: req.GameUID, RoleName: req.RoleName, diff --git a/backend/internal/handler/open_v1.go b/backend/internal/handler/open_v1.go index f803ab1..f6267ef 100644 --- a/backend/internal/handler/open_v1.go +++ b/backend/internal/handler/open_v1.go @@ -14,7 +14,7 @@ import ( "github.com/gin-gonic/gin" ) -// OpenV1Handler 提供面向商户系统和履约器的通用开放接口。 +// OpenV1Handler 提供面向商户系统的客户侧开放接口。 type OpenV1Handler struct { merchantSvc *service.MerchantService fulfillmentSvc *service.FulfillmentService diff --git a/backend/internal/middleware/open_auth.go b/backend/internal/middleware/open_auth.go index 84ec777..e237383 100644 --- a/backend/internal/middleware/open_auth.go +++ b/backend/internal/middleware/open_auth.go @@ -87,6 +87,7 @@ func OpenAuth(cfg OpenAuthConfig) gin.HandlerFunc { return } c.Request.Body = io.NopCloser(bytes.NewBuffer(bodyBytes)) + c.Set(openlog.CtxBody, string(bodyBytes)) var client model.APIClient if err := cfg.DB.Where("app_key = ? AND status = ?", appKey, model.APIClientStatusActive).First(&client).Error; err != nil { diff --git a/backend/internal/middleware/open_auth_test.go b/backend/internal/middleware/open_auth_test.go index 9f58562..0b20e8b 100644 --- a/backend/internal/middleware/open_auth_test.go +++ b/backend/internal/middleware/open_auth_test.go @@ -104,10 +104,12 @@ func TestOpenAuthRejectsSignatureMismatch(t *testing.T) { func TestSourceOpenAuthKeepsLegacyUpstreamSignature(t *testing.T) { gin.SetMode(gin.TestMode) + db := testdb.New(t) appKey := "source-key" secret := "source-secret" r := gin.New() r.Use(SourceOpenAuth(SourceOpenAuthConfig{ + DB: db, APIKey: appKey, APISecret: secret, SkewSeconds: 300, @@ -130,14 +132,24 @@ func TestSourceOpenAuthKeepsLegacyUpstreamSignature(t *testing.T) { if w.Code != http.StatusOK { t.Fatalf("legacy signed request should pass, code=%d body=%s", w.Code, w.Body.String()) } + + replay := httptest.NewRequest(http.MethodPost, path, strings.NewReader(body)) + replay.Header = req.Header.Clone() + w = httptest.NewRecorder() + r.ServeHTTP(w, replay) + if w.Code != http.StatusUnauthorized { + t.Fatalf("legacy replay should be rejected, code=%d body=%s", w.Code, w.Body.String()) + } } func TestSourceOpenAuthAcceptsUppercaseLegacySignature(t *testing.T) { gin.SetMode(gin.TestMode) + db := testdb.New(t) appKey := "source-key" secret := "source-secret" r := gin.New() r.Use(SourceOpenAuth(SourceOpenAuthConfig{ + DB: db, APIKey: appKey, APISecret: secret, SkewSeconds: 300, diff --git a/backend/internal/middleware/source_open_auth.go b/backend/internal/middleware/source_open_auth.go index 48ac5dc..9a5e584 100644 --- a/backend/internal/middleware/source_open_auth.go +++ b/backend/internal/middleware/source_open_auth.go @@ -6,53 +6,31 @@ import ( "io" "strconv" "strings" - "sync" "time" + "affiliate_dash/internal/model" "affiliate_dash/internal/pkg/openlog" "affiliate_dash/internal/pkg/response" "github.com/gin-gonic/gin" + "gorm.io/gorm" + "gorm.io/gorm/clause" ) // SourceOpenAuthConfig 是原上游发货接口鉴权配置,保持 X-Api-Key 兼容。 type SourceOpenAuthConfig struct { + DB *gorm.DB APIKey string APISecret string SkewSeconds int64 Debug bool } -type sourceNonceStore struct { - mu sync.Mutex - data map[string]int64 -} - -func newSourceNonceStore() *sourceNonceStore { - return &sourceNonceStore{data: make(map[string]int64)} -} - -func (s *sourceNonceStore) seen(nonce string, now, ttl int64) bool { - s.mu.Lock() - defer s.mu.Unlock() - for key, expiresAt := range s.data { - if expiresAt < now { - delete(s.data, key) - } - } - if expiresAt, ok := s.data[nonce]; ok && expiresAt >= now { - return true - } - s.data[nonce] = now + ttl - return false -} - // SourceOpenAuth 保持现有上游对接签名算法不变:X-Api-Key + 字典序 HMAC。 func SourceOpenAuth(cfg SourceOpenAuthConfig) gin.HandlerFunc { if cfg.SkewSeconds <= 0 { cfg.SkewSeconds = 300 } - store := newSourceNonceStore() return func(c *gin.Context) { reqID := openlog.EnsureReqID(c) side, action := openlog.ScopeFromPath(openlog.SideSource, c.Request.Method, c.Request.URL.Path) @@ -61,6 +39,12 @@ func SourceOpenAuth(cfg SourceOpenAuthConfig) gin.HandlerFunc { c.Set(openlog.CtxStart, time.Now()) c.Header("X-Request-Id", reqID) + if cfg.DB == nil { + openlog.Warn(c, "source_open_auth uninitialized_db") + response.ServerError(c, "源头开放接口认证服务未初始化") + c.Abort() + return + } if cfg.APIKey == "" || cfg.APISecret == "" { openlog.Warn(c, "source_open_auth uninitialized") response.ServerError(c, "服务端未配置 OPEN_API_KEY / OPEN_API_SECRET") @@ -105,13 +89,8 @@ func SourceOpenAuth(cfg SourceOpenAuthConfig) gin.HandlerFunc { return } c.Request.Body = io.NopCloser(bytes.NewBuffer(bodyBytes)) + c.Set(openlog.CtxBody, string(bodyBytes)) - if store.seen(apiKey+":"+nonce, time.Now().Unix(), cfg.SkewSeconds) { - openlog.Warn(c, "source_open_auth nonce_replay nonce=%s", nonce) - response.Unauthorized(c, "重复的 X-Nonce(请勿重放请求)") - c.Abort() - return - } expected := BuildOpenSign(apiKey, cfg.APISecret, timestamp, nonce, c.Request.Method, c.Request.URL.Path, string(bodyBytes)) if !hmac.Equal([]byte(strings.ToLower(sign)), []byte(expected)) { openlog.Warn(c, "source_open_auth sign_mismatch method=%s path=%s body=%s sign=%s expected=%s", @@ -122,6 +101,26 @@ func SourceOpenAuth(cfg SourceOpenAuthConfig) gin.HandlerFunc { return } + now := time.Now() + _ = cfg.DB.Where("expires_at < ?", now).Delete(&model.SourceAPINonce{}).Error + created := cfg.DB.Clauses(clause.OnConflict{DoNothing: true}).Create(&model.SourceAPINonce{ + APIKey: apiKey, + Nonce: nonce, + ExpiresAt: now.Add(time.Duration(cfg.SkewSeconds) * time.Second), + }) + if created.Error != nil { + openlog.Warn(c, "source_open_auth nonce_db_fail err=%v", created.Error) + response.ServerError(c, "记录请求 nonce 失败") + c.Abort() + return + } + if created.RowsAffected == 0 { + openlog.Warn(c, "source_open_auth nonce_replay nonce=%s", nonce) + response.Unauthorized(c, "重复的 X-Nonce(请勿重放请求)") + c.Abort() + return + } + openlog.Info(c, "source_open_auth ok method=%s path=%s body_size=%d", c.Request.Method, c.Request.URL.Path, len(bodyBytes)) c.Next() diff --git a/backend/internal/model/merchant.go b/backend/internal/model/merchant.go index 908c06c..30f87f0 100644 --- a/backend/internal/model/merchant.go +++ b/backend/internal/model/merchant.go @@ -289,6 +289,19 @@ type APIRequestNonce struct { ExpiresAt time.Time `gorm:"not null;index" json:"expires_at"` } +// SourceAPINonce 记录源头接口的请求 nonce,保证重放保护跨进程、跨重启生效。 +type SourceAPINonce struct { + ID uint `gorm:"primarykey" json:"id"` + CreatedAt time.Time `json:"created_at"` + APIKey string `gorm:"size:96;not null;uniqueIndex:idx_source_api_nonce" json:"-"` + Nonce string `gorm:"size:64;not null;uniqueIndex:idx_source_api_nonce" json:"-"` + ExpiresAt time.Time `gorm:"not null;index" json:"-"` +} + +func (SourceAPINonce) TableName() string { + return "source_api_nonces" +} + // AuditLog 留存后台和开放接口的重要业务操作。 type AuditLog struct { ID uint `gorm:"primarykey" json:"id"` diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index 6be4672..13e579b 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -49,9 +49,10 @@ func Setup(h *Handlers) *gin.Engine { api.POST("/auth/login", h.Auth.Login) api.POST("/auth/register", h.Auth.Register) - // 原上游发货接口:路径、鉴权和字段保持不变。 + // 源头侧接口:上游发货平台查询订单并回传发货结果。 sourceOpen := api.Group("/open/v1") sourceOpen.Use(middleware.SourceOpenAuth(middleware.SourceOpenAuthConfig{ + DB: h.OpenDB, APIKey: h.OpenAPIKey, APISecret: h.OpenAPISecret, SkewSeconds: h.OpenSignSkew, @@ -62,7 +63,7 @@ func Setup(h *Handlers) *gin.Engine { sourceOpen.POST("/orders/ship-notify", h.SourceOpen.ShipNotify) } - // 客户侧通用开放接口:独立 API 客户端 + HMAC 签名。 + // 商户侧接口:每个商户独立 API 客户端 + HMAC 签名。 clientOpen := api.Group("/client/v1") clientOpen.Use(middleware.OpenAuth(middleware.OpenAuthConfig{ DB: h.OpenDB, diff --git a/backend/internal/service/fulfillment.go b/backend/internal/service/fulfillment.go index 850a1f5..ad5b767 100644 --- a/backend/internal/service/fulfillment.go +++ b/backend/internal/service/fulfillment.go @@ -7,6 +7,7 @@ import ( "math" "strings" "time" + "unicode/utf8" "affiliate_dash/internal/model" @@ -764,7 +765,7 @@ type OpenOrderProduct struct { // ShipNotifyInput 上游发货结果推送。 type ShipNotifyInput struct { OrderNo string - ShipStatus string // success / failed / processing + ShipStatus string // success / failed ProviderOrderNo string ShippedAt *time.Time FailReason string @@ -836,110 +837,113 @@ func (s *FulfillmentService) QueryOpenOrder(orderNo string) (*OpenOrderQuery, er // HandleShipNotify 处理上游发货结果推送(幂等),基于 FulfillmentOrder。 func (s *FulfillmentService) HandleShipNotify(in ShipNotifyInput) (*ShipNotifyResult, error) { + in.ShipStatus = strings.TrimSpace(in.ShipStatus) + in.FailReason = strings.TrimSpace(in.FailReason) if in.OrderNo == "" { return nil, errors.New("订单号不能为空") } + if in.ShipStatus != "success" && in.ShipStatus != "failed" { + return nil, errors.New("无效的 ship_status,仅支持 success/failed") + } + if in.ShipStatus == "failed" && in.FailReason == "" { + return nil, errors.New("发货失败时 fail_reason 必填") + } + if utf8.RuneCountInString(in.FailReason) > 512 { + return nil, errors.New("fail_reason 最长 512 个字符") + } + var nextStatus string switch in.ShipStatus { case "success": nextStatus = model.FulfillmentStatusSucceeded case "failed": nextStatus = model.FulfillmentStatusFailed - case "processing": - nextStatus = model.FulfillmentStatusProcessing - default: - return nil, errors.New("无效的 ship_status,仅支持 success/failed/processing") } - order, err := s.GetByOrderNo(in.OrderNo) - if err != nil { - return nil, err - } - - // 已履约成功:success 推送幂等成功 - if order.FulfillmentStatus == model.FulfillmentStatusSucceeded && in.ShipStatus == "success" { - _ = s.writeShipNotifyAudit(order, in, order.FulfillmentStatus, "订单已履约成功,幂等忽略") - return &ShipNotifyResult{ - OrderNo: order.OrderNo, - Status: fulfillmentStatusToLegacyStatus(order.FulfillmentStatus), - Message: "订单已交付,幂等成功", - }, nil - } - - // 已取消不允许再推 - if order.FulfillmentStatus == model.FulfillmentStatusCancelled { - _ = s.writeShipNotifyAudit(order, in, order.FulfillmentStatus, "订单已取消,拒绝更新") - return nil, errors.New("订单已取消,无法更新发货状态") - } - - now := time.Now() - shippedAt := in.ShippedAt - if shippedAt == nil && in.ShipStatus == "success" { - shippedAt = &now - } - - updates := map[string]interface{}{ - "fulfillment_status": nextStatus, - } - var msg string - switch in.ShipStatus { - case "success": - // 仅 pending / failed / processing 可转为 succeeded - if order.FulfillmentStatus != model.FulfillmentStatusPending && - order.FulfillmentStatus != model.FulfillmentStatusFailed && - order.FulfillmentStatus != model.FulfillmentStatusProcessing { - _ = s.writeShipNotifyAudit(order, in, order.FulfillmentStatus, "当前状态不允许标记发货成功") - return nil, fmt.Errorf("当前状态 %s 不允许标记发货成功", order.FulfillmentStatus) + var result ShipNotifyResult + var rejectionErr error + if err := s.db.Transaction(func(tx *gorm.DB) error { + var order model.FulfillmentOrder + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}). + Preload("MerchantProduct.Product"). + Where("order_no = ?", in.OrderNo). + First(&order).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return errors.New("订单不存在") + } + return err } - updates["delivered_at"] = shippedAt - updates["failure_reason"] = "" - if in.ProviderOrderNo != "" { - updates["provider_order_no"] = in.ProviderOrderNo - } - msg = "发货成功,订单已交付" - case "failed": - if order.FulfillmentStatus == model.FulfillmentStatusSucceeded { - _ = s.writeShipNotifyAudit(order, in, order.FulfillmentStatus, "订单已交付,忽略失败推送") - return nil, errors.New("订单已交付,不能标记发货失败") - } - updates["failure_reason"] = in.FailReason - if in.ProviderOrderNo != "" { - updates["provider_order_no"] = in.ProviderOrderNo - } - msg = "已记录发货失败" - case "processing": - if order.FulfillmentStatus == model.FulfillmentStatusSucceeded { - _ = s.writeShipNotifyAudit(order, in, order.FulfillmentStatus, "订单已交付,忽略发货中推送") - return &ShipNotifyResult{ + + // 已履约成功:success 推送幂等成功。状态读取和后续更新必须在同一把行锁内完成。 + if order.FulfillmentStatus == model.FulfillmentStatusSucceeded && in.ShipStatus == "success" { + result = ShipNotifyResult{ OrderNo: order.OrderNo, Status: fulfillmentStatusToLegacyStatus(order.FulfillmentStatus), - Message: "订单已交付,忽略 processing", - }, nil + Message: "订单已交付,幂等成功", + } + return writeAudit(tx, &order.MerchantID, nil, nil, "ship.notify", "fulfillment_order", order.OrderNo, + shipNotifyAuditMetadata(in, order.FulfillmentStatus, "订单已履约成功,幂等忽略")) } - if order.FulfillmentStatus != model.FulfillmentStatusPending && - order.FulfillmentStatus != model.FulfillmentStatusFailed && - order.FulfillmentStatus != model.FulfillmentStatusProcessing { - _ = s.writeShipNotifyAudit(order, in, order.FulfillmentStatus, "当前状态不允许进入发货中") - return nil, fmt.Errorf("当前状态 %s 不允许进入发货中", order.FulfillmentStatus) - } - if in.ProviderOrderNo != "" { - updates["provider_order_no"] = in.ProviderOrderNo - } - msg = "订单已标记为发货中" - } - 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 order.FulfillmentStatus == model.FulfillmentStatusCancelled { + if err := writeShipNotifyRejectedAudit(tx, &order, in, "订单已取消,拒绝更新"); err != nil { + return err + } + rejectionErr = errors.New("订单已取消,无法更新发货状态") + return nil + } + + now := time.Now() + shippedAt := in.ShippedAt + if shippedAt == nil && in.ShipStatus == "success" { + shippedAt = &now + } + + updates := map[string]interface{}{ + "fulfillment_status": nextStatus, + } + var message string + switch in.ShipStatus { + case "success": + if order.FulfillmentStatus != model.FulfillmentStatusPending && + order.FulfillmentStatus != model.FulfillmentStatusFailed && + order.FulfillmentStatus != model.FulfillmentStatusProcessing { + if err := writeShipNotifyRejectedAudit(tx, &order, in, "当前状态不允许标记发货成功"); err != nil { + return err + } + rejectionErr = fmt.Errorf("当前状态 %s 不允许标记发货成功", order.FulfillmentStatus) + return nil + } + updates["delivered_at"] = shippedAt + updates["failure_reason"] = "" + if in.ProviderOrderNo != "" { + updates["provider_order_no"] = in.ProviderOrderNo + } + message = "发货成功,订单已交付" + case "failed": + if order.FulfillmentStatus == model.FulfillmentStatusSucceeded { + if err := writeShipNotifyRejectedAudit(tx, &order, in, "订单已交付,拒绝失败推送"); err != nil { + return err + } + rejectionErr = errors.New("订单已交付,不能标记发货失败") + return nil + } + updates["failure_reason"] = in.FailReason + if in.ProviderOrderNo != "" { + updates["provider_order_no"] = in.ProviderOrderNo + } + message = "已记录发货失败" + } + + resultData := buildShipNotifyResultData(order.ResultData, in, shippedAt) + updates["result_data"] = resultData if err := tx.Model(&model.FulfillmentOrder{}).Where("id = ?", order.ID).Updates(updates).Error; err != nil { return err } - jobStatus := model.FulfillmentJobStatusProcessing + + jobStatus := model.FulfillmentJobStatusFailed if nextStatus == model.FulfillmentStatusSucceeded { jobStatus = model.FulfillmentJobStatusSucceeded - } else if nextStatus == model.FulfillmentStatusFailed { - jobStatus = model.FulfillmentJobStatusFailed } jobUpdates := map[string]interface{}{ "status": jobStatus, @@ -952,10 +956,13 @@ func (s *FulfillmentService) HandleShipNotify(in ShipNotifyInput) (*ShipNotifyRe 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 { + + if err := writeAudit(tx, &order.MerchantID, nil, nil, "ship.notify", "fulfillment_order", order.OrderNo, + shipNotifyAuditMetadata(in, nextStatus, message)); err != nil { return err } + + var updated model.FulfillmentOrder if err := tx.Preload("MerchantProduct.Product").First(&updated, order.ID).Error; err != nil { return err } @@ -964,21 +971,25 @@ func (s *FulfillmentService) HandleShipNotify(in ShipNotifyInput) (*ShipNotifyRe return err } } + result = ShipNotifyResult{ + OrderNo: order.OrderNo, + Status: fulfillmentStatusToLegacyStatus(nextStatus), + Message: message, + } return nil }); err != nil { return nil, err } + if rejectionErr != nil { + return nil, rejectionErr + } - return &ShipNotifyResult{ - OrderNo: order.OrderNo, - Status: fulfillmentStatusToLegacyStatus(nextStatus), - Message: msg, - }, nil + return &result, nil } -// 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 writeShipNotifyRejectedAudit(tx *gorm.DB, order *model.FulfillmentOrder, in ShipNotifyInput, message string) error { + return writeAudit(tx, &order.MerchantID, nil, nil, "ship.notify", "fulfillment_order", order.OrderNo, + shipNotifyAuditMetadata(in, order.FulfillmentStatus, message)) } func shipNotifyAuditMetadata(in ShipNotifyInput, resultStatus, message string) map[string]interface{} { @@ -1003,7 +1014,9 @@ func buildShipNotifyResultData(existing string, in ShipNotifyInput, shippedAt *t if in.ProviderOrderNo != "" { m["provider_order_no"] = in.ProviderOrderNo } - if in.FailReason != "" { + if in.ShipStatus == "success" { + delete(m, "fail_reason") + } else if in.FailReason != "" { m["fail_reason"] = in.FailReason } if shippedAt != nil { diff --git a/backend/internal/service/fulfillment_test.go b/backend/internal/service/fulfillment_test.go index 30ce8dc..6f2b4ba 100644 --- a/backend/internal/service/fulfillment_test.go +++ b/backend/internal/service/fulfillment_test.go @@ -524,3 +524,83 @@ func TestHandleShipNotifyUpdatesOrderJobAndEnqueuesMerchantCallback(t *testing.T t.Fatalf("expected one ship notify audit, got %d", auditCount) } } + +func TestHandleShipNotifyRequiresFinalStatusAndFailureReason(t *testing.T) { + db := newServiceTestDB(t) + merchantID, product := seedFulfillmentMerchant(t, db, "merchant-source-contract", 1000, 2, 100) + svc := NewFulfillmentService(db, nil) + created, err := svc.CreateOrder(CreateFulfillmentOrderInput{ + MerchantID: merchantID, + APIClientID: 31, + ClientOrderNo: "client-source-contract", + SKU: product.SKU, + }) + if err != nil { + t.Fatalf("create order: %v", err) + } + + if _, err := svc.HandleShipNotify(ShipNotifyInput{ + OrderNo: created.Order.OrderNo, + ShipStatus: "processing", + }); err == nil || !strings.Contains(err.Error(), "success/failed") { + t.Fatalf("processing should be rejected, got %v", err) + } + if _, err := svc.HandleShipNotify(ShipNotifyInput{ + OrderNo: created.Order.OrderNo, + ShipStatus: "failed", + }); err == nil || !strings.Contains(err.Error(), "fail_reason") { + t.Fatalf("failed without reason should be rejected, got %v", err) + } +} + +func TestHandleShipNotifySuccessClearsPreviousResultFailureReason(t *testing.T) { + db := newServiceTestDB(t) + merchantID, product := seedFulfillmentMerchant(t, db, "merchant-source-clear-reason", 1000, 2, 100) + svc := NewFulfillmentService(db, nil) + created, err := svc.CreateOrder(CreateFulfillmentOrderInput{ + MerchantID: merchantID, + APIClientID: 31, + ClientOrderNo: "client-source-clear-reason", + SKU: product.SKU, + }) + if err != nil { + t.Fatalf("create order: %v", err) + } + + if _, err := svc.HandleShipNotify(ShipNotifyInput{ + OrderNo: created.Order.OrderNo, + ShipStatus: "failed", + FailReason: "渠道服校验失败", + }); err != nil { + t.Fatalf("mark failed: %v", err) + } + if _, err := svc.HandleShipNotify(ShipNotifyInput{ + OrderNo: created.Order.OrderNo, + ShipStatus: "success", + }); err != nil { + t.Fatalf("mark success: %v", err) + } + + 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.FailureReason != "" { + t.Fatalf("success should clear order failure reason, got %+v", order) + } + if strings.Contains(order.ResultData, "fail_reason") { + t.Fatalf("success should clear result_data fail_reason, got %s", order.ResultData) + } +} + +func TestBuildShipNotifyResultDataSuccessRemovesFailureReason(t *testing.T) { + result := buildShipNotifyResultData(`{"ship_status":"failed","fail_reason":"旧失败原因"}`, ShipNotifyInput{ + ShipStatus: "success", + }, nil) + if strings.Contains(result, "fail_reason") { + t.Fatalf("success result data should remove fail_reason, got %s", result) + } + if !strings.Contains(result, `"ship_status":"success"`) { + t.Fatalf("success result data should keep ship_status, got %s", result) + } +} diff --git a/docs/API对接关系.md b/docs/API对接关系.md new file mode 100644 index 0000000..e61824b --- /dev/null +++ b/docs/API对接关系.md @@ -0,0 +1,71 @@ +# API 对接关系总览 + +> 更新日期:2026-07-30 +> 用途:快速判断接口调用方、鉴权方式、状态流和回调方向 + +项目里有三条外部通信链路,容易混在一起。排查问题时先判断请求属于哪一条。 + +## 1. 三条链路 + +| 链路 | 调用方 | 接收方 | 路径 / 入口 | 鉴权 | 作用 | +|------|--------|--------|-------------|------|------| +| 商户侧开放 API | 商户自己的商城 / 系统 | 本平台 | `/api/client/v1` | `X-App-Key` + HMAC | 查商品、创建订单、查订单、取消订单、查钱包 | +| 源头侧发货 API | 上游发货平台 | 本平台 | `/api/open/v1` | `X-Api-Key` + HMAC | 按订单号查可发货信息,回传发货成功 / 失败 | +| 商户回调 | 本平台 | 商户自己的回调 URL | 商户后台配置 URL | `X-Event-ID` + `X-Timestamp` + `X-Sign` | 把订单创建、履约变化、取消等事件推给商户 | + +## 2. 订单流 + +```text +商户系统 + ↓ POST /api/client/v1/orders +本平台创建订单、扣商户钱包、生成 order_no + ↓ +上游发货平台 + ↓ GET /api/open/v1/orders/{order_no} +查询商品 sku、买家信息、can_ship + ↓ +上游完成发货 + ↓ POST /api/open/v1/orders/ship-notify +只回传 success 或 failed + ↓ +本平台更新履约状态 + ↓ +商户回调 / 商户查询订单 +``` + +## 3. 鉴权不要混用 + +| 项 | 商户侧 `/api/client/v1` | 源头侧 `/api/open/v1` | +|----|--------------------------|------------------------| +| Key Header | `X-App-Key` | `X-Api-Key` | +| Secret 来源 | 商户后台创建 API 客户端后展示一次 | 服务端环境变量 `OPEN_API_SECRET` | +| body 参与签名 | `body_sha256` | 原始 body 字符串 | +| nonce 防重 | 数据库持久化 | 数据库持久化 | +| 多租户 | 按 API 客户端绑定商户 | 全局源头 Key,按 `order_no` 找订单 | + +两套签名串不同,即使 Header 名相似也不能互相套用。 + +## 4. 状态语言 + +内部订单使用 `payment_status` + `fulfillment_status` 两组状态;源头侧为了兼容对接,返回的是更贴近发货系统的旧状态名。 + +| 内部 payment_status | 内部 fulfillment_status | 源头侧 `status` | 含义 | 是否可发货 | +|---------------------|-------------------------|-----------------|------|------------| +| `paid` | `pending` | `paid` | 已付款,等待发货 | 是 | +| `paid` | `processing` | `delivering` | 履约中 | 否 | +| `paid` | `succeeded` | `delivered` | 已交付 | 否 | +| `paid` | `failed` | `ship_failed` | 发货失败,可重试 | 是 | +| `refunded` | `cancelled` | `cancelled` | 已取消 / 已退款 | 否 | + +注意:源头侧返回的 `status=paid` 不是单独的支付状态,而是“这个订单已支付且处于待发货履约状态”的兼容表达。 + +## 5. ship_notify 当前契约 + +- 路径:`POST /api/open/v1/orders/ship-notify` +- `ship_status` 只接受 `success` / `failed` +- `failed` 时 `fail_reason` 必填,必须写详细原因 +- `success` 会清空订单失败原因,并清掉结果 JSON 中历史 `fail_reason` +- 已交付订单重复推 `success` 按幂等成功处理 +- 已交付订单再推 `failed` 会被拒绝,并保留审计记录 + +详细请求体见:[发货通知约定.md](发货通知约定.md) diff --git a/docs/发货通知约定.md b/docs/发货通知约定.md new file mode 100644 index 0000000..3abe715 --- /dev/null +++ b/docs/发货通知约定.md @@ -0,0 +1,72 @@ +# 发货通知约定(速查) + +> 更新日期:2026-07-30 +> 适用对象:上游发货系统、联调排查、后续快速检索 + +本文是 `ship_notify` 的当前对接口径速查版。完整鉴权与接口说明见:[开放接口-皮肤源头对接.md](开放接口-皮肤源头对接.md) + +## 1. 当前口径 + +- `ship_notify` 只推送两种状态:`success` / `failed` +- `success` 表示发货成功 +- `failed` 表示发货失败 +- `failed` 时,`fail_reason` 必填,且要写详细失败原因 +- `processing` 是旧口径里曾出现过的中间状态,当前 `ship_notify` 不再接受 + +## 2. 推荐推送格式 + +### 发货成功 + +```json +{ + "order_no": "O202607240733306742", + "ship_status": "success", + "provider_order_no": "6a6322a81b3b421994137260", + "shipped_at": "2026-07-24T08:30:33.000Z", + "fail_reason": "", + "game_uid": "4808146277", + "role_name": "巫师哈丁12", + "game_channel": "安卓-QQ", + "pay_score": 360 +} +``` + +### 发货失败 + +```json +{ + "order_no": "O202607240733306742", + "ship_status": "failed", + "provider_order_no": "6a6322a81b3b421994137260", + "shipped_at": "2026-07-24T08:30:33.000Z", + "fail_reason": "角色名不存在,渠道服校验失败", + "game_uid": "4808146277", + "role_name": "巫师哈丁12", + "game_channel": "安卓-QQ", + "pay_score": 360 +} +``` + +## 3. 字段要求 + +| 字段 | 必填 | 说明 | +|------|------|------| +| `order_no` | 是 | 店铺订单号 | +| `ship_status` | 是 | 仅使用 `success` / `failed` | +| `provider_order_no` | 否 | 上游发货单号 | +| `shipped_at` | 否 | RFC3339 时间;`success` 未传时可由服务端补时间 | +| `fail_reason` | 失败时是 | `failed` 时必须填详细原因 | +| `game_channel` | 否 | 账号区服 | +| `game_uid` | 否 | 游戏角色 UUID | +| `role_name` | 否 | 角色名 | +| `pay_score` | 否 | 消耗积分 | + +## 4. 快速排查 + +- 只有订单查询日志,没有 `ship_notify` 日志,通常表示上游只查了单,没有回调发货结果 +- `failed` 但 `fail_reason` 为空,不符合当前约定 +- `success` 后订单应进入已交付状态,重复 `success` 一般按幂等处理 + +## 5. 备注 + +2026-07-30 的测试环境日志里,只看到 `/api/open/v1/orders/{order_no}` 的查询请求,没有看到 `ship_notify` 推送请求,说明当次联调只做了查询。 diff --git a/docs/开放接口-皮肤源头对接.md b/docs/开放接口-皮肤源头对接.md index c3fb846..fd96bf8 100644 --- a/docs/开放接口-皮肤源头对接.md +++ b/docs/开放接口-皮肤源头对接.md @@ -215,6 +215,8 @@ GET /api/open/v1/orders/{order_no} ## 4. 接口二:发货结果推送 +> 当前接口只接受 `success` / `failed` 两种最终结果。`processing` 是旧口径里曾出现过的中间状态,当前 `ship_notify` 不再接受;当 `ship_status=failed` 时,`fail_reason` 必须填写详细失败原因。 + ### 4.1 请求 ```http @@ -235,16 +237,15 @@ Content-Type: application/json | 字段 | 必填 | 说明 | |------|------|------| | `order_no` | 是 | 店铺订单号 | -| `ship_status` | 是 | `success` / `failed` / `processing` | +| `ship_status` | 是 | 仅 `success` / `failed` | | `provider_order_no` | 否 | 你们系统的发货单号 | | `shipped_at` | 否 | RFC3339;success 未传则用服务端时间 | -| `fail_reason` | 否 | 失败原因(failed 时建议填) | +| `fail_reason` | 失败时是 | `failed` 时必填,填写详细失败原因 | ### 4.2 ship_status → 我们订单状态 | ship_status | 订单变为 | 说明 | |-------------|---------|------| -| `processing` | `delivering` | 已接单 / 发货中 | | `success` | `delivered` | 发货成功 | | `failed` | `ship_failed` | 失败,允许之后再次查询并重试 | @@ -279,8 +280,6 @@ GET 订单查询 ↓ can_ship == false ? → 停止,展示 cannot_ship_reason ↓ true -(可选)POST ship_status=processing - ↓ 按 product.sku 发货 ↓ POST ship_status=success 或 failed