优化发货接口对接流程

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
@@ -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,
+108 -95
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,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 {
@@ -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)
}
}