优化发货接口对接流程
This commit is contained in:
@@ -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);
|
||||
@@ -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,
|
||||
|
||||
@@ -14,7 +14,7 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// OpenV1Handler 提供面向商户系统和履约器的通用开放接口。
|
||||
// OpenV1Handler 提供面向商户系统的客户侧开放接口。
|
||||
type OpenV1Handler struct {
|
||||
merchantSvc *service.MerchantService
|
||||
fulfillmentSvc *service.FulfillmentService
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user