Files
affiliate_dash/backend/internal/service/fulfillment_shipnotify.go
T
yml2213andCursor c2d8228512 fix(delivery): 硬化自动发货可靠性,避免双发与无限 Webhook 重试
恢复 ship_failed 可重试 UI;DLC 断点续传 queue;乐玩未绑定禁止进 delivering;Webhook 原子 nonce 与业务拒绝回 200。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-14 20:13:29 +08:00

206 lines
6.5 KiB
Go

package service
import (
"errors"
"strings"
"time"
"unicode/utf8"
"affiliate_dash/internal/model"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// ShipNotifyInput 上游发货结果推送。
type ShipNotifyInput struct {
OrderNo string
ShipStatus string // success / failed
ProviderOrderNo string
ShippedAt *time.Time
FailReason string
RawPayload string
GameChannel *string
GameUID *string
RoleName *string
PayScore *int
}
// ShipNotifyResult 上游推送处理结果。
type ShipNotifyResult struct {
OrderNo string `json:"order_no"`
Status string `json:"status"`
Message string `json:"message"`
}
// ErrShipNotifyRejected 表示业务永久拒绝(如已取消/已交付拒失败),Webhook 应回 200 避免无限重试。
var ErrShipNotifyRejected = errors.New("ship notify rejected")
// wrapShipNotifyRejected 将永久拒绝包装为可识别错误。
func wrapShipNotifyRejected(err error) error {
if err == nil {
return ErrShipNotifyRejected
}
return errors.Join(ErrShipNotifyRejected, err)
}
// 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.OrderStatusDelivered
case "failed":
nextStatus = model.OrderStatusShipFailed
}
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
}
now := time.Now()
shippedAt := in.ShippedAt
if shippedAt == nil && in.ShipStatus == "success" {
shippedAt = &now
}
// 已交付:success 推送幂等成功。状态读取和后续更新必须在同一把行锁内完成。
if normalizeOrderStatus(&order) == model.OrderStatusDelivered && in.ShipStatus == "success" {
newResultData := buildShipNotifyResultData(order.ResultData, in, shippedAt)
if newResultData != order.ResultData {
_ = tx.Model(&model.FulfillmentOrder{}).Where("id = ?", order.ID).Update("result_data", newResultData).Error
}
result = ShipNotifyResult{
OrderNo: order.OrderNo,
Status: normalizeOrderStatus(&order),
Message: "订单已交付,幂等成功",
}
return writeAudit(tx, &order.MerchantID, nil, nil, "ship.notify", "fulfillment_order", order.OrderNo,
shipNotifyAuditMetadata(in, normalizeOrderStatus(&order), "订单已交付,幂等忽略"))
}
if normalizeOrderStatus(&order) == model.OrderStatusCancelled {
if err := writeShipNotifyRejectedAudit(tx, &order, in, "订单已取消,拒绝更新"); err != nil {
return err
}
rejectionErr = wrapShipNotifyRejected(errors.New("订单已取消,无法更新发货状态"))
return nil
}
updates := map[string]interface{}{
"order_status": nextStatus,
}
var message string
switch in.ShipStatus {
case "success":
if err := validateOrderStatusTransition(&order, nextStatus, fulfillmentTransitionShipNotify); err != nil {
message := "当前状态不允许标记发货成功"
if err := writeShipNotifyRejectedAudit(tx, &order, in, message); err != nil {
return err
}
rejectionErr = wrapShipNotifyRejected(err)
return nil
}
updates["delivered_at"] = shippedAt
updates["failure_reason"] = ""
if in.ProviderOrderNo != "" {
updates["provider_order_no"] = in.ProviderOrderNo
}
message = "发货成功,订单已交付"
case "failed":
if err := validateOrderStatusTransition(&order, nextStatus, fulfillmentTransitionShipNotify); err != nil {
message := "当前状态不允许标记发货失败"
if normalizeOrderStatus(&order) == model.OrderStatusDelivered {
message = "订单已交付,拒绝失败推送"
}
if err := writeShipNotifyRejectedAudit(tx, &order, in, message); err != nil {
return err
}
rejectionErr = wrapShipNotifyRejected(err)
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
}
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
}
if s.callbacks != nil {
if err := s.callbacks.Enqueue(tx, order.MerchantID, "order.shipping.updated", orderCallbackData(&updated)); err != nil {
return err
}
}
result = ShipNotifyResult{
OrderNo: order.OrderNo,
Status: nextStatus,
Message: message,
}
return nil
}); err != nil {
return nil, err
}
if rejectionErr != nil {
return nil, rejectionErr
}
return &result, nil
}
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, normalizeOrderStatus(order), message))
}
func shipNotifyAuditMetadata(in ShipNotifyInput, resultStatus, message string) map[string]interface{} {
metadata := map[string]interface{}{
"ship_status": in.ShipStatus,
"provider_order_no": in.ProviderOrderNo,
"fail_reason": in.FailReason,
"result_status": resultStatus,
"message": message,
"payload": in.RawPayload,
}
return metadata
}