优化发货接口对接流程

This commit is contained in:
yml2213
2026-07-30 23:44:50 +08:00
parent a16e518b6c
commit ee956e2e01
14 changed files with 428 additions and 141 deletions
+10 -3
View File
@@ -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` | 否 | 角色名 |
@@ -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);
+9 -3
View File
@@ -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,
+1 -1
View File
@@ -14,7 +14,7 @@ import (
"github.com/gin-gonic/gin"
)
// OpenV1Handler 提供面向商户系统和履约器的通用开放接口。
// OpenV1Handler 提供面向商户系统的客户侧开放接口。
type OpenV1Handler struct {
merchantSvc *service.MerchantService
fulfillmentSvc *service.FulfillmentService
+1
View File
@@ -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 {
@@ -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,
+31 -32
View File
@@ -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()
+13
View File
@@ -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"`
+3 -2
View File
@@ -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,
+71 -58
View File
@@ -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,40 +837,60 @@ 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
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
}
// 已履约成功:success 推送幂等成功
// 已履约成功:success 推送幂等成功。状态读取和后续更新必须在同一把行锁内完成。
if order.FulfillmentStatus == model.FulfillmentStatusSucceeded && in.ShipStatus == "success" {
_ = s.writeShipNotifyAudit(order, in, order.FulfillmentStatus, "订单已履约成功,幂等忽略")
return &ShipNotifyResult{
result = ShipNotifyResult{
OrderNo: order.OrderNo,
Status: fulfillmentStatusToLegacyStatus(order.FulfillmentStatus),
Message: "订单已交付,幂等成功",
}, nil
}
return writeAudit(tx, &order.MerchantID, nil, nil, "ship.notify", "fulfillment_order", order.OrderNo,
shipNotifyAuditMetadata(in, order.FulfillmentStatus, "订单已履约成功,幂等忽略"))
}
// 已取消不允许再推
if order.FulfillmentStatus == model.FulfillmentStatusCancelled {
_ = s.writeShipNotifyAudit(order, in, order.FulfillmentStatus, "订单已取消,拒绝更新")
return nil, errors.New("订单已取消,无法更新发货状态")
if err := writeShipNotifyRejectedAudit(tx, &order, in, "订单已取消,拒绝更新"); err != nil {
return err
}
rejectionErr = errors.New("订单已取消,无法更新发货状态")
return nil
}
now := time.Now()
@@ -881,65 +902,48 @@ func (s *FulfillmentService) HandleShipNotify(in ShipNotifyInput) (*ShipNotifyRe
updates := map[string]interface{}{
"fulfillment_status": nextStatus,
}
var msg string
var message 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)
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
}
msg = "发货成功,订单已交付"
message = "发货成功,订单已交付"
case "failed":
if order.FulfillmentStatus == model.FulfillmentStatusSucceeded {
_ = s.writeShipNotifyAudit(order, in, order.FulfillmentStatus, "订单已交付,忽略失败推送")
return nil, errors.New("订单已交付,不能标记发货失败")
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
}
msg = "已记录发货失败"
case "processing":
if order.FulfillmentStatus == model.FulfillmentStatusSucceeded {
_ = s.writeShipNotifyAudit(order, in, order.FulfillmentStatus, "订单已交付,忽略发货中推送")
return &ShipNotifyResult{
OrderNo: order.OrderNo,
Status: fulfillmentStatusToLegacyStatus(order.FulfillmentStatus),
Message: "订单已交付,忽略 processing",
}, nil
}
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 = "订单已标记为发货中"
message = "已记录发货失败"
}
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
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 {
@@ -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)
}
}
+71
View File
@@ -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)
+72
View File
@@ -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` 推送请求,说明当次联调只做了查询。
+4 -5
View File
@@ -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` | 否 | RFC3339success 未传则用服务端时间 |
| `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