Files
affiliate_dash/backend/internal/service/delivery.go
T
yml2213andCursor 8c43fb4569 fix(delivery): 将第一平台发货出站超时调到 30 秒
常态提交约 11 秒,15 秒上限会在上游偶发变慢时提前断开,可能把仍在处理的订单记成失败。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-05 19:57:07 +08:00

1163 lines
39 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package service
import (
"context"
"encoding/json"
"errors"
"net/http"
"strings"
"time"
"affiliate_dash/internal/model"
"affiliate_dash/internal/pkg/timeutil"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
const defaultDeliveryBFFBaseURL = "https://www.jxya.top/bff"
// 发货提交阶段,持久化在 result_data.provider_order_stage 中,供排障与补偿扫描区分处理。
const (
deliveryStageClaimed = "claimed" // 已锁定订单,尚未建 queue
deliveryStageQueueCreated = "queue_created" // 已在上游建 queue,尚未生成正式订单
deliveryStageSubmitted = "submitted" // 已在上游创建正式订单
deliveryStageBindVerify = "bind_verify" // 绑定账号校验失败
deliveryStageBindMismatch = "bind_mismatch" // 玩家编号与绑定账号不一致
deliveryStageCreateQueue = "create_queue" // 创建上游 queue 失败
deliveryStagePatchQueue = "patch_queue" // 补充 queue 账号信息失败
deliveryStageCreateOrder = "create_upstream" // 创建上游正式订单失败
deliverySubmissionStaleTimeout = 10 * time.Minute
// 第一平台创建正式订单常态约 11 秒,15 秒上限会在偶发变慢时提前断开。
deliveryUpstreamTimeout = 30 * time.Second
)
// deliverySubmittedUpstream 判断订单是否已成功提交到上游正式订单。
// 已提交上游的失败订单禁止自动重发,避免重复发货。
func deliverySubmittedUpstream(order *model.FulfillmentOrder) bool {
if order == nil {
return false
}
if strings.TrimSpace(order.ProviderOrderNo) != "" {
return true
}
m := resultDataMap(order.ResultData)
if _, ok := m["delivery"]; ok {
return true
}
if _, ok := m["upstream_order"]; ok { // 兼容历史数据
return true
}
return resultDataString(order.ResultData, "provider_order_stage") == deliveryStageSubmitted
}
type DeliveryService struct {
fulfillment *FulfillmentService
bffBaseURL string
channel string
linkBaseURL string
linkSecret string
linkTTL time.Duration
httpClient *http.Client
router *FulfillmentRouter
}
func NewDeliveryService(fulfillment *FulfillmentService, bffBaseURL, channel, linkBaseURL, linkSecret string, linkTTLMinutes int) *DeliveryService {
bffBaseURL = strings.TrimRight(strings.TrimSpace(bffBaseURL), "/")
if bffBaseURL == "" {
bffBaseURL = defaultDeliveryBFFBaseURL
}
channel = strings.TrimSpace(channel)
if channel == "" {
channel = "dlc"
}
linkBaseURL = strings.TrimRight(strings.TrimSpace(linkBaseURL), "/")
linkSecret = strings.TrimSpace(linkSecret)
if linkSecret == "" {
linkSecret = "affiliate-dash-delivery-link-secret"
}
// linkTTLMinutes <= 0 表示链接永久有效(0 为默认值)。
if linkTTLMinutes < 0 {
linkTTLMinutes = 0
}
client := &http.Client{Timeout: deliveryUpstreamTimeout}
dlc := NewDLCProvider(bffBaseURL, channel, client)
router := NewFulfillmentRouter(dlc)
return &DeliveryService{
fulfillment: fulfillment,
bffBaseURL: bffBaseURL,
channel: channel,
linkBaseURL: linkBaseURL,
linkSecret: linkSecret,
linkTTL: time.Duration(linkTTLMinutes) * time.Minute,
httpClient: client,
router: router,
}
}
func (s *DeliveryService) SetHTTPClient(client *http.Client) {
if client != nil {
s.httpClient = client
if dlc, ok := s.router.GetProvider(ProviderDLC).(*DLCProvider); ok && dlc != nil {
dlc.httpClient = client
}
}
}
func (s *DeliveryService) SetRouter(router *FulfillmentRouter) {
if router != nil {
s.router = router
}
}
func (s *DeliveryService) RegisterProvider(provider FulfillmentProvider) {
if s.router == nil {
s.router = NewFulfillmentRouter(provider)
} else if provider != nil {
s.router.providers[provider.Name()] = provider
}
}
// CancelUpstream 取消订单时同步取消已锁定/已建单的上游履约渠道。
func (s *DeliveryService) CancelUpstream(order *model.FulfillmentOrder) error {
if order == nil || s.router == nil {
return nil
}
if name := resultDataString(order.ResultData, "provider"); name != "" {
if p := s.router.GetProvider(name); p != nil {
return p.Cancel(context.Background(), order)
}
}
if resultDataString(order.ResultData, "lewan_order_id") != "" {
if p := s.router.GetProvider(ProviderLewan); p != nil {
return p.Cancel(context.Background(), order)
}
}
return nil
}
// SyncDeliveringOrder 主动向上游渠道查询处于 delivering 状态的订单结果,以补充 Webhook 丢失或延迟。
func (s *DeliveryService) SyncDeliveringOrder(ctx context.Context, order *model.FulfillmentOrder) (bool, error) {
if order == nil || s.router == nil || order.OrderStatus != model.OrderStatusDelivering {
return false, nil
}
if ctx == nil {
ctx = context.Background()
}
providerName := resultDataString(order.ResultData, "provider")
if providerName == "" && (resultDataString(order.ResultData, "lewan_order_id") != "" || resultDataString(order.ResultData, "lewan_order_no") != "" || strings.HasPrefix(order.ProviderOrderNo, "OMU")) {
providerName = ProviderLewan
}
providers := make([]FulfillmentProvider, 0, 2)
if providerName != "" {
if p := s.router.GetProvider(providerName); p != nil {
providers = append(providers, p)
}
}
if len(providers) == 0 {
resolved, _ := s.router.ResolveProviders(order)
providers = resolved
}
for _, p := range providers {
res, err := p.SyncStatus(ctx, order)
if err != nil || res == nil {
continue
}
if res.Status == "success" || res.Status == "failed" {
input := ShipNotifyInput{
OrderNo: order.OrderNo,
ShipStatus: res.Status,
ProviderOrderNo: res.ProviderOrderNo,
FailReason: res.FailReason,
RawPayload: res.RawPayload,
}
if res.GameUID != "" {
input.GameUID = &res.GameUID
}
if res.RoleName != "" {
input.RoleName = &res.RoleName
}
if res.RoleArea != "" {
input.GameChannel = &res.RoleArea
}
if _, err := s.fulfillment.HandleShipNotify(input); err != nil {
return false, err
}
return true, nil
}
// 如果仍在发货中,且获得了游戏UID/角色名/区服,补充写入 ResultData
patch := map[string]interface{}{}
if res.GameUID != "" && resultDataString(order.ResultData, "game_uid") == "" {
patch["game_uid"] = res.GameUID
}
if res.RoleName != "" && resultDataString(order.ResultData, "role_name") == "" {
patch["role_name"] = res.RoleName
}
if res.RoleArea != "" && resultDataString(order.ResultData, "game_channel") == "" {
patch["game_channel"] = res.RoleArea
}
if len(patch) > 0 {
_ = s.persistOrderResultPatch(order, patch)
}
return false, nil
}
return false, nil
}
// persistOrderResultPatch 合并写入订单 result_data,不改变发货 stage。
func (s *DeliveryService) persistOrderResultPatch(order *model.FulfillmentOrder, patch map[string]interface{}) error {
if order == nil || len(patch) == 0 {
return nil
}
resultData := mergeResultData(order.ResultData, patch)
if err := s.fulfillment.db.Model(&model.FulfillmentOrder{}).
Where("id = ?", order.ID).
Update("result_data", resultData).Error; err != nil {
return err
}
order.ResultData = resultData
return nil
}
func (s *DeliveryService) clearSelectedProvider(order *model.FulfillmentOrder) {
if order == nil || strings.TrimSpace(order.ResultData) == "" {
return
}
var raw map[string]interface{}
if err := json.Unmarshal([]byte(order.ResultData), &raw); err != nil {
return
}
if _, ok := raw["provider"]; !ok {
return
}
delete(raw, "provider")
b, err := json.Marshal(raw)
if err != nil {
return
}
resultData := string(b)
_ = s.fulfillment.db.Model(&model.FulfillmentOrder{}).
Where("id = ?", order.ID).
Update("result_data", resultData).Error
order.ResultData = resultData
}
type DeliveryOrderInfo struct {
OrderNo string `json:"order_no"`
Status string `json:"status"`
CanShip bool `json:"can_ship"`
CannotShipReason string `json:"cannot_ship_reason,omitempty"`
Product *DeliveryProduct `json:"product,omitempty"`
BuyerName string `json:"buyer_name"`
Amount int64 `json:"amount"`
ProviderOrderNo string `json:"provider_order_no,omitempty"`
ProviderName string `json:"provider_name,omitempty"`
CreatedAt time.Time `json:"created_at"`
ShippedAt *time.Time `json:"shipped_at"`
ShipFailReason string `json:"ship_fail_reason,omitempty"`
GameChannel string `json:"game_channel,omitempty"`
GameUID string `json:"game_uid,omitempty"`
RoleName string `json:"role_name,omitempty"`
PayScore int `json:"pay_score,omitempty"`
Data map[string]interface{} `json:"data,omitempty"`
Good map[string]interface{} `json:"good,omitempty"`
}
type DeliveryProduct struct {
Name string `json:"name"`
SKU string `json:"sku"`
Game string `json:"game"`
Image string `json:"image,omitempty"`
}
type DeliveryBindResult struct {
BindUUID string `json:"bind_uuid"`
BindURL string `json:"bind_url"`
QRURL string `json:"qr_url"`
Bound bool `json:"bound"`
GameAccount string `json:"game_account,omitempty"`
RoleName string `json:"role_name,omitempty"`
GameChannel string `json:"game_channel,omitempty"`
ExpectedGameAccount string `json:"expected_game_account,omitempty"`
Mismatch bool `json:"mismatch,omitempty"`
Message string `json:"message,omitempty"`
}
type DeliveryLinkResult struct {
OrderNo string `json:"order_no"`
DeliveryURL string `json:"delivery_url"`
ExpiresAt *time.Time `json:"expires_at,omitempty"` // null 表示永久有效
Exp int64 `json:"exp"`
Sign string `json:"sign"`
}
type DeliverySubmitResult struct {
OrderNo string `json:"order_no"`
Status string `json:"status"`
Message string `json:"message"`
ProviderOrderNo string `json:"provider_order_no,omitempty"`
GameAccount map[string]interface{} `json:"game_account,omitempty"`
UpstreamOrder interface{} `json:"delivery,omitempty"`
}
type DeliveryLinkAuth struct {
Exp int64
Sign string
}
type deliveryHTTPError struct {
status int
message string
}
func (e deliveryHTTPError) Error() string {
return e.message
}
func (e deliveryHTTPError) HTTPStatus() int {
return e.status
}
func newDeliveryHTTPError(status int, message string) error {
return deliveryHTTPError{status: status, message: message}
}
func (s *DeliveryService) GetOrder(orderNo string, auth DeliveryLinkAuth) (*DeliveryOrderInfo, error) {
info, _, _, err := s.prepareOrder(orderNo, false, &auth)
return info, err
}
func (s *DeliveryService) GetMerchantOrder(merchantID uint, orderNo string) (*DeliveryOrderInfo, error) {
openOrder, order, goodID, err := s.loadMerchantOrder(merchantID, orderNo)
if err != nil {
return nil, err
}
info, _, _, err := s.buildDeliveryState(openOrder, order, goodID, false)
return info, err
}
func (s *DeliveryService) Bind(orderNo, gameAccount string, auth DeliveryLinkAuth) (*DeliveryBindResult, error) {
return s.bindWithOrder(orderNo, gameAccount, &auth, 0)
}
func (s *DeliveryService) BindForMerchant(merchantID uint, orderNo, gameAccount string) (*DeliveryBindResult, error) {
return s.bindWithOrder(orderNo, gameAccount, nil, merchantID)
}
// GetBindResult 查询绑定结果(自建发货页轮询用)。
// 绑定未完成时返回 bound=false(不报错),由前端提示继续扫码。
func (s *DeliveryService) GetBindResult(merchantID uint, orderNo, bindUUID string) (*DeliveryBindResult, error) {
bindUUID = strings.TrimSpace(bindUUID)
if bindUUID == "" {
return nil, errors.New("bind_uuid 不能为空")
}
openOrder, order, goodID, err := s.loadMerchantOrder(merchantID, orderNo)
if err != nil {
return nil, err
}
if _, _, _, err := s.buildDeliveryState(openOrder, order, goodID, false); err != nil {
return nil, err
}
providers, _ := s.router.ResolveProviders(order)
if len(providers) == 0 {
return nil, errors.New("未配置发货渠道")
}
p := providers[0]
res, err := p.CheckBind(context.Background(), order, bindUUID)
if err != nil {
return nil, err
}
return &DeliveryBindResult{
BindUUID: res.BindUUID,
BindURL: res.BindURL,
QRURL: res.QRURL,
Bound: res.Bound,
GameAccount: res.GameAccount,
RoleName: res.RoleName,
GameChannel: res.GameChannel,
ExpectedGameAccount: res.ExpectedGameAccount,
Mismatch: res.Mismatch,
Message: res.Message,
}, nil
}
// GetBindResultLinked 为公开发货链接场景提供查询绑定结果的能力。
func (s *DeliveryService) GetBindResultLinked(orderNo, bindUUID string, auth DeliveryLinkAuth) (*DeliveryBindResult, error) {
bindUUID = strings.TrimSpace(bindUUID)
if bindUUID == "" {
return nil, errors.New("bind_uuid 不能为空")
}
openOrder, order, goodID, err := s.loadLinkedOrder(orderNo, &auth)
if err != nil {
return nil, err
}
if _, _, _, err := s.buildDeliveryState(openOrder, order, goodID, false); err != nil {
return nil, err
}
providers, _ := s.router.ResolveProviders(order)
if len(providers) == 0 {
return nil, errors.New("未配置发货渠道")
}
p := providers[0]
res, err := p.CheckBind(context.Background(), order, bindUUID)
if err != nil {
return nil, err
}
return &DeliveryBindResult{
BindUUID: res.BindUUID,
BindURL: res.BindURL,
QRURL: res.QRURL,
Bound: res.Bound,
GameAccount: res.GameAccount,
RoleName: res.RoleName,
GameChannel: res.GameChannel,
ExpectedGameAccount: res.ExpectedGameAccount,
Mismatch: res.Mismatch,
Message: res.Message,
}, nil
}
func (s *DeliveryService) bindWithOrder(orderNo, gameAccount string, auth *DeliveryLinkAuth, merchantID uint) (*DeliveryBindResult, error) {
gameAccount = strings.TrimSpace(gameAccount)
if gameAccount == "" {
return nil, errors.New("请输入玩家编号")
}
var (
openOrder *OpenOrderQuery
order *model.FulfillmentOrder
goodID string
err error
)
if merchantID > 0 {
openOrder, order, goodID, err = s.loadMerchantOrder(merchantID, orderNo)
} else {
openOrder, order, goodID, err = s.loadLinkedOrder(orderNo, auth)
}
if err != nil {
return nil, err
}
_, _, _, err = s.buildDeliveryState(openOrder, order, goodID, true)
if err != nil {
return nil, err
}
providers, _ := s.router.ResolveProviders(order)
if len(providers) == 0 {
return nil, errors.New("未配置发货渠道")
}
var lastErr error
var bindRes *ProviderBindResult
var usedProvider FulfillmentProvider
tried := map[string]bool{}
for _, p := range providers {
tried[p.Name()] = true
res, err := p.Bind(context.Background(), order, gameAccount)
if err == nil && res != nil {
bindRes = res
usedProvider = p
break
}
lastErr = err
}
// Prepare 可能因 GET 成功先锁住乐玩;随后写接口凭据/IP 校验失败时,清除锁定并尝试其余渠道(如 DLC)。
if bindRes == nil {
lockedName := resultDataString(order.ResultData, "provider")
if lockedName != "" || lastErr != nil {
s.clearSelectedProvider(order)
fallbackProviders, _ := s.router.ResolveProviders(order)
for _, p := range fallbackProviders {
if tried[p.Name()] {
continue
}
tried[p.Name()] = true
res, err := p.Bind(context.Background(), order, gameAccount)
if err == nil && res != nil {
bindRes = res
usedProvider = p
break
}
lastErr = err
}
}
}
if bindRes == nil {
if lastErr != nil {
return nil, lastErr
}
return nil, errors.New("发货渠道未就绪")
}
// 记录选定渠道与上游单号(如 lewan_order_id),不提前写入 submit claimed stage
if usedProvider != nil {
patch := map[string]interface{}{
"provider": usedProvider.Name(),
}
for k, v := range bindRes.PatchResultData {
patch[k] = v
}
_ = s.persistOrderResultPatch(order, patch)
}
return &DeliveryBindResult{
BindUUID: bindRes.BindUUID,
BindURL: bindRes.BindURL,
QRURL: bindRes.QRURL,
Bound: bindRes.Bound,
GameAccount: bindRes.GameAccount,
RoleName: bindRes.RoleName,
GameChannel: bindRes.GameChannel,
ExpectedGameAccount: bindRes.ExpectedGameAccount,
Mismatch: bindRes.Mismatch,
Message: bindRes.Message,
}, nil
}
func (s *DeliveryService) bindGood(gameAccount, goodID string) (*DeliveryBindResult, error) {
dlc, ok := s.router.GetProvider(ProviderDLC).(*DLCProvider)
if !ok || dlc == nil {
return nil, errors.New("DLC 渠道未就绪")
}
res, err := dlc.Bind(context.Background(), &model.FulfillmentOrder{
ProductSKU: "",
}, gameAccount)
if err != nil {
return nil, err
}
return &DeliveryBindResult{
BindUUID: res.BindUUID,
BindURL: res.BindURL,
QRURL: res.QRURL,
Bound: res.Bound,
}, nil
}
func (s *DeliveryService) Submit(orderNo, gameAccount, bindUUID string, auth DeliveryLinkAuth) (*DeliverySubmitResult, error) {
return s.submit(orderNo, gameAccount, bindUUID, 0, &auth, 0)
}
func (s *DeliveryService) SubmitForMerchant(merchantID, apiClientID uint, orderNo, gameAccount, bindUUID string) (*DeliverySubmitResult, error) {
return s.submit(orderNo, gameAccount, bindUUID, apiClientID, nil, merchantID)
}
func (s *DeliveryService) submit(orderNo, gameAccount, bindUUID string, apiClientID uint, auth *DeliveryLinkAuth, merchantID uint) (*DeliverySubmitResult, error) {
gameAccount = strings.TrimSpace(gameAccount)
bindUUID = strings.TrimSpace(bindUUID)
orderNo = strings.TrimSpace(orderNo)
if gameAccount == "" || bindUUID == "" {
return nil, errors.New("玩家编号和绑定凭证不能为空")
}
var (
openOrder *OpenOrderQuery
order *model.FulfillmentOrder
goodID string
err error
)
if merchantID > 0 {
openOrder, order, goodID, err = s.loadMerchantOrder(merchantID, orderNo)
} else {
openOrder, order, goodID, err = s.loadLinkedOrder(orderNo, auth)
}
if err != nil {
return nil, err
}
info, _, _, err := s.buildDeliveryState(openOrder, order, goodID, false)
if err != nil {
return nil, err
}
orderStatus := normalizeOrderStatus(order)
if orderStatus == model.OrderStatusDelivering ||
orderStatus == model.OrderStatusDelivered ||
strings.TrimSpace(order.ProviderOrderNo) != "" ||
strings.Contains(order.ResultData, "\"queue_order_id\"") ||
strings.Contains(order.ResultData, "\"lewan_order_id\"") {
if existing := buildExistingDeliverySubmitResult(order); existing != nil {
return existing, nil
}
}
// 已在上游创建正式订单的失败订单禁止自动重发,避免重复发货。
if orderStatus == model.OrderStatusShipFailed && deliverySubmittedUpstream(order) {
return nil, newDeliveryHTTPError(http.StatusConflict, "该订单已进入发货流程,为避免重复发货不能直接重试,请稍后查询订单状态或联系平台处理")
}
if !info.CanShip {
reason := info.CannotShipReason
if reason == "" {
reason = "订单暂不可发货"
}
return nil, newDeliveryHTTPError(http.StatusBadRequest, reason)
}
claimed, claimedNow, err := s.claimDeliverySubmission(order.MerchantID, apiClientID, order.OrderNo)
if err != nil {
return nil, err
}
if !claimedNow {
if existing := buildExistingDeliverySubmitResult(claimed); existing != nil {
return existing, nil
}
return nil, newDeliveryHTTPError(http.StatusConflict, "订单暂时正在发货中,请稍后查询")
}
providers, _ := s.router.ResolveProviders(claimed)
if len(providers) == 0 {
s.markDeliverySubmissionFailed(claimed, apiClientID, deliveryStageCreateOrder, "未配置发货渠道")
return nil, errors.New("未配置发货渠道")
}
activeProvider := providers[0]
submitRes, err := activeProvider.Submit(context.Background(), claimed, gameAccount, bindUUID)
if submitRes != nil && len(submitRes.PatchResultData) > 0 {
stage := deliveryStageCreateOrder
if v, ok := submitRes.PatchResultData["provider_order_stage"].(string); ok && v != "" {
stage = v
}
_ = s.recordDeliverySubmissionStage(claimed, stage, submitRes.PatchResultData)
}
if err != nil {
failStage := deliveryStageCreateOrder
if submitRes != nil {
if v, ok := submitRes.PatchResultData["provider_order_stage"].(string); ok && v != "" {
failStage = v
}
}
s.markDeliverySubmissionFailed(claimed, apiClientID, failStage, err.Error())
return nil, err
}
resultData := mergeResultData(claimed.ResultData, submitRes.PatchResultData)
updated, err := s.fulfillment.UpdateFulfillment(FulfillmentUpdateInput{
MerchantID: order.MerchantID,
APIClientID: apiClientID,
OrderNo: order.OrderNo,
Status: submitRes.NextStatus,
ProviderOrderNo: submitRes.ProviderOrderNo,
ResultData: resultData,
})
if err != nil {
// 上游已有身份时先固化 result_data,避免重试双发
_ = s.recordDeliverySubmissionStage(claimed, deliveryStageSubmitted, submitRes.PatchResultData)
return nil, err
}
return &DeliverySubmitResult{
OrderNo: updated.OrderNo,
Status: normalizeOrderStatus(updated),
Message: submitRes.Message,
ProviderOrderNo: submitRes.ProviderOrderNo,
GameAccount: submitRes.GameAccount,
UpstreamOrder: submitRes.UpstreamOrder,
}, nil
}
func (s *DeliveryService) claimDeliverySubmission(merchantID, apiClientID uint, orderNo string) (*model.FulfillmentOrder, bool, error) {
var updated model.FulfillmentOrder
claimedNow := false
err := s.fulfillment.db.Transaction(func(tx *gorm.DB) error {
var order model.FulfillmentOrder
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
Preload("MerchantProduct").
Where("merchant_id = ? AND order_no = ?", merchantID, orderNo).
First(&order).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return errors.New("订单不存在")
}
return err
}
orderStatus := normalizeOrderStatus(&order)
if orderStatus == model.OrderStatusDelivering || orderStatus == model.OrderStatusDelivered {
updated = order
return nil
}
if err := validateOrderStatusTransition(&order, model.OrderStatusDelivering, fulfillmentTransitionUpdate); err != nil {
return newDeliveryHTTPError(http.StatusConflict, "订单暂不可发货")
}
now := time.Now()
stage := resultDataString(order.ResultData, "provider_order_stage")
startedAtRaw := resultDataString(order.ResultData, "submit_started_at")
// 1. 如果已有协程正在活跃提交中(在 35 秒执行窗口内且处于 claimed/queue_created 阶段),
// 即使已被外部 Webhook 临时推回 ship_failed,也绝对不允许并发重入发货打架
if deliverySubmissionInProgress(stage) && startedAtRaw != "" {
if startedAt, parseErr := time.Parse(timeutil.APITimeLayout, startedAtRaw); parseErr == nil {
if now.Sub(startedAt) < 35*time.Second {
updated = order
return nil
}
}
}
// 2. 正常并发防双发:仅对 paid 等非最终失败态订单检查 10 分钟未超时
if orderStatus != model.OrderStatusShipFailed &&
deliverySubmissionInProgress(stage) &&
!deliverySubmissionStale(&order, now) {
updated = order
return nil
}
patch := map[string]interface{}{
"source": "delivery_proxy",
"submit_started_at": timeutil.FormatAPITime(now),
"provider_order_stage": deliveryStageClaimed,
"ship_attempts": resultDataNumber(order.ResultData, "ship_attempts") + 1,
}
if err := tx.Model(&order).Updates(map[string]interface{}{
"result_data": mergeResultData(order.ResultData, patch),
"failure_reason": "",
}).Error; err != nil {
return err
}
claimedNow = true
if err := tx.Preload("MerchantProduct").First(&updated, order.ID).Error; err != nil {
return err
}
apiClientIDPtr := optionalUint(apiClientID)
if err := writeAudit(tx, &merchantID, nil, apiClientIDPtr, "delivery.submit.claim", "fulfillment_order", order.OrderNo, nil); err != nil {
return err
}
return nil
})
if err != nil {
return nil, false, err
}
return &updated, claimedNow, nil
}
func (s *DeliveryService) recordDeliverySubmissionStage(order *model.FulfillmentOrder, stage string, patch map[string]interface{}) error {
if order == nil {
return errors.New("订单不存在")
}
if patch == nil {
patch = map[string]interface{}{}
}
patch["provider_order_stage"] = stage
resultData := mergeResultData(order.ResultData, patch)
if err := s.fulfillment.db.Model(&model.FulfillmentOrder{}).
Where("id = ?", order.ID).
Update("result_data", resultData).Error; err != nil {
return err
}
order.ResultData = resultData
return nil
}
func (s *DeliveryService) markDeliverySubmissionFailed(order *model.FulfillmentOrder, apiClientID uint, stage, reason string) {
if order == nil {
return
}
latest := order
if fresh, err := s.fulfillment.GetOrder(order.MerchantID, order.OrderNo); err == nil {
latest = fresh
}
// 确保失败时 stage 绝不能继续保持在 in_progress(claimed / queue_created),否则阻碍重试
releaseStage := stage
if deliverySubmissionInProgress(releaseStage) {
releaseStage = deliveryStageCreateOrder
}
resultData := mergeResultData(latest.ResultData, map[string]interface{}{
"provider_order_stage": releaseStage,
"fail_stage": stage,
"fail_reason": reason,
"fail_at": timeutil.FormatAPITime(time.Now()),
})
_, _ = s.fulfillment.UpdateFulfillment(FulfillmentUpdateInput{
MerchantID: order.MerchantID,
APIClientID: apiClientID,
OrderNo: order.OrderNo,
Status: model.OrderStatusShipFailed,
FailureReason: reason,
ResultData: resultData,
})
}
func (s *DeliveryService) loadLinkedOrder(orderNo string, auth *DeliveryLinkAuth) (*OpenOrderQuery, *model.FulfillmentOrder, string, error) {
openOrder, err := s.fulfillment.QueryOpenOrder(orderNo)
if err != nil {
return nil, nil, "", err
}
order, err := s.fulfillment.GetByOrderNo(orderNo)
if err != nil {
return nil, nil, "", err
}
if auth != nil {
if err := s.authorizeDeliveryLink(order, *auth); err != nil {
return nil, nil, "", err
}
}
goodID := ""
if openOrder.Product != nil {
goodID = deliveryGoodID(s.channel, openOrder.Product.SKU)
}
return openOrder, order, goodID, nil
}
func (s *DeliveryService) loadMerchantOrder(merchantID uint, orderNo string) (*OpenOrderQuery, *model.FulfillmentOrder, string, error) {
if merchantID == 0 {
return nil, nil, "", errors.New("无效的商户")
}
openOrder, err := s.fulfillment.QueryOpenOrder(orderNo)
if err != nil {
return nil, nil, "", err
}
order, err := s.fulfillment.GetOrder(merchantID, orderNo)
if err != nil {
return nil, nil, "", err
}
if order.OrderStatus == model.OrderStatusDelivering {
if synced, _ := s.SyncDeliveringOrder(context.Background(), order); synced {
openOrder, _ = s.fulfillment.QueryOpenOrder(orderNo)
order, _ = s.fulfillment.GetOrder(merchantID, orderNo)
}
}
goodID := ""
if openOrder != nil && openOrder.Product != nil {
goodID = deliveryGoodID(s.channel, openOrder.Product.SKU)
}
return openOrder, order, goodID, nil
}
func (s *DeliveryService) buildDeliveryState(openOrder *OpenOrderQuery, order *model.FulfillmentOrder, goodID string, requireCanShip bool) (*DeliveryOrderInfo, *model.FulfillmentOrder, string, error) {
if openOrder == nil || order == nil {
return nil, nil, "", errors.New("订单不存在")
}
canShip, reason := CanFulfill(order)
if !openOrder.CanShip {
reason = openOrder.CannotShipReason
canShip = false
}
if !canShip && reason == "" {
reason = "订单暂不可发货"
}
if openOrder.Product == nil || openOrder.Product.SKU == "" {
canShip = false
reason = "订单缺少商品 SKU"
}
var (
deliveryProd *DeliveryProduct
goodData map[string]interface{}
)
providers, _ := s.router.ResolveProviders(order)
if len(providers) > 0 {
locked := resultDataString(order.ResultData, "provider") != ""
matchedState, lastReason := s.tryPrepareProviders(order, openOrder, providers)
// 已锁定渠道不可发时,清除锁定并按完整优先级重新选择
if matchedState == nil && locked {
s.clearSelectedProvider(order)
providers, _ = s.router.ResolveProviders(order)
matchedState, lastReason = s.tryPrepareProviders(order, openOrder, providers)
}
if matchedState != nil {
deliveryProd = matchedState.Product
goodData = matchedState.Good
if matchedState.Provider != "" {
_ = s.persistOrderResultPatch(order, map[string]interface{}{
"provider": matchedState.Provider,
})
}
} else {
canShip = false
if reason == "" || reason == "订单暂不可发货" {
reason = lastReason
if reason == "" {
reason = "未找到适用的履约渠道配置"
}
}
}
} else if goodID != "" {
goodData, _ = s.goodsDetail(goodID)
}
if deliveryProd == nil && openOrder.Product != nil {
deliveryProd = buildDeliveryProduct(openOrder.Product, goodData)
}
if requireCanShip && !canShip {
return nil, nil, "", newDeliveryHTTPError(http.StatusBadRequest, reason)
}
providerOrderNo := strings.TrimSpace(openOrder.ProviderOrderNo)
if providerOrderNo == "" {
providerOrderNo = strings.TrimSpace(order.ProviderOrderNo)
}
if providerOrderNo == "" {
providerOrderNo = resultDataString(order.ResultData, "lewan_order_no")
}
if providerOrderNo == "" {
providerOrderNo = resultDataString(order.ResultData, "provider_order_no")
}
providerName := resultDataString(order.ResultData, "provider")
if providerName == "" {
if strings.TrimSpace(order.ProviderOrderNo) != "" || resultDataString(order.ResultData, "lewan_order_id") != "" {
providerName = "lewan"
}
}
roleName := openOrder.RoleName
if roleName == "" {
roleName = resultDataString(order.ResultData, "role_name")
}
gameChannel := openOrder.GameChannel
if gameChannel == "" {
gameChannel = resultDataString(order.ResultData, "game_channel")
}
// 若角色名或区服为空,尝试从关联的 rock_tokens 表反查并自动回填
if (roleName == "" || gameChannel == "") && s.fulfillment != nil && s.fulfillment.db != nil {
var rockToken model.RockToken
if err := s.fulfillment.db.Where("assigned_order_no = ?", order.OrderNo).First(&rockToken).Error; err == nil {
patches := map[string]interface{}{}
if roleName == "" && rockToken.ActualRoleName != "" {
roleName = rockToken.ActualRoleName
patches["role_name"] = roleName
}
if gameChannel == "" && rockToken.ActualChannel != "" {
gameChannel = rockToken.ActualChannel
patches["game_channel"] = gameChannel
}
if len(patches) > 0 {
_ = s.persistOrderResultPatch(order, patches)
}
}
}
info := &DeliveryOrderInfo{
OrderNo: openOrder.OrderNo,
Status: openOrder.Status,
CanShip: canShip,
CannotShipReason: reason,
Product: deliveryProd,
BuyerName: openOrder.BuyerName,
Amount: openOrder.Amount,
ProviderOrderNo: providerOrderNo,
ProviderName: providerName,
CreatedAt: openOrder.CreatedAt,
ShippedAt: openOrder.ShippedAt,
ShipFailReason: openOrder.ShipFailReason,
GameChannel: gameChannel,
GameUID: openOrder.GameUID,
RoleName: roleName,
PayScore: openOrder.PayScore,
Data: requestDataMap(order.RequestData),
Good: goodData,
}
return info, order, goodID, nil
}
// tryPrepareProviders 按优先级尝试 Prepare,返回第一个 CanShip 的渠道状态。
func (s *DeliveryService) tryPrepareProviders(order *model.FulfillmentOrder, openOrder *OpenOrderQuery, providers []FulfillmentProvider) (*DeliveryOrderState, string) {
lastReason := ""
for _, p := range providers {
state, err := p.PrepareDelivery(context.Background(), order, openOrder)
if err != nil || state == nil {
continue
}
if state.Provider == "" {
state.Provider = p.Name()
}
if state.CanShip {
return state, ""
}
if state.CannotShipReason != "" {
lastReason = state.CannotShipReason
}
}
return nil, lastReason
}
func (s *DeliveryService) GetOrCreateDeliveryLink(merchantID uint, orderNo, requestBaseURL string) (*DeliveryLinkResult, error) {
orderNo = strings.TrimSpace(orderNo)
if orderNo == "" {
return nil, errors.New("订单号不能为空")
}
order, err := s.fulfillment.GetOrder(merchantID, orderNo)
if err != nil {
return nil, err
}
// 已取消订单即使存在未过期链接也不再发放(买家侧授权同样拦截已取消订单)。
if normalizeOrderStatus(order) == model.OrderStatusCancelled {
return nil, newDeliveryHTTPError(http.StatusBadRequest, "订单已取消,无法获取发货链接")
}
now := time.Now().UTC()
if order.DeliveryLinkRevokedAt != nil {
return nil, newDeliveryHTTPError(http.StatusForbidden, "发货链接已作废")
}
// 复用未过期链接:已交付/发货中订单也允许查看兑换结果或进度,仅停止发放新链接。
if order.DeliveryLinkExpiresAt != nil && order.DeliveryLinkExpiresAt.After(now) {
expiresAt := order.DeliveryLinkExpiresAt.UTC().Truncate(time.Second)
return s.buildDeliveryLinkResult(order.OrderNo, &expiresAt, requestBaseURL), nil
}
// 永久有效链接(已生成、未作废、无有效期)直接复用,不重复生成。
if order.DeliveryLinkGeneratedAt != nil && order.DeliveryLinkExpiresAt == nil {
return s.buildDeliveryLinkResult(order.OrderNo, nil, requestBaseURL), nil
}
// 仅待发货(含可重试的发货失败)订单能生成或续期新链接。
canShip, reason := CanFulfill(order)
if !canShip {
if reason == "" {
reason = "订单暂不可发货"
}
return nil, newDeliveryHTTPError(http.StatusBadRequest, reason)
}
var expiresAt *time.Time
if s.linkTTL > 0 {
value := now.Add(s.linkTTL).UTC().Truncate(time.Second)
expiresAt = &value
}
if err := s.fulfillment.db.Model(&model.FulfillmentOrder{}).
Where("id = ?", order.ID).
Updates(map[string]interface{}{
"delivery_link_expires_at": expiresAt,
"delivery_link_revoked_at": nil,
"delivery_link_generated_at": now,
}).Error; err != nil {
return nil, err
}
return s.buildDeliveryLinkResult(order.OrderNo, expiresAt, requestBaseURL), nil
}
func (s *DeliveryService) RevokeDeliveryLink(merchantID, actorUserID uint, orderNo string) error {
orderNo = strings.TrimSpace(orderNo)
if orderNo == "" {
return errors.New("订单号不能为空")
}
order, err := s.fulfillment.GetOrder(merchantID, orderNo)
if err != nil {
return err
}
now := time.Now().UTC().Truncate(time.Second)
return s.fulfillment.db.Transaction(func(tx *gorm.DB) error {
if err := tx.Model(&model.FulfillmentOrder{}).
Where("id = ?", order.ID).
Updates(map[string]interface{}{
"delivery_link_revoked_at": now,
}).Error; err != nil {
return err
}
return writeAudit(tx, &merchantID, optionalUint(actorUserID), nil, "delivery_link.revoke", "fulfillment_order", order.OrderNo, map[string]interface{}{
"revoked_at": timeutil.FormatAPITime(now),
})
})
}
func (s *DeliveryService) RestoreDeliveryLink(merchantID, actorUserID uint, orderNo, requestBaseURL string) (*DeliveryLinkResult, error) {
orderNo = strings.TrimSpace(orderNo)
if orderNo == "" {
return nil, errors.New("订单号不能为空")
}
order, err := s.fulfillment.GetOrder(merchantID, orderNo)
if err != nil {
return nil, err
}
canShip, reason := CanFulfill(order)
if !canShip {
if reason == "" {
reason = "订单暂不可发货"
}
return nil, newDeliveryHTTPError(http.StatusBadRequest, reason)
}
now := time.Now().UTC()
var expiresAt *time.Time
if s.linkTTL > 0 {
value := now.Add(s.linkTTL).Truncate(time.Second)
if order.DeliveryLinkExpiresAt != nil && value.Equal(order.DeliveryLinkExpiresAt.UTC().Truncate(time.Second)) {
value = value.Add(time.Second)
}
expiresAt = &value
}
if err := s.fulfillment.db.Transaction(func(tx *gorm.DB) error {
if err := tx.Model(&model.FulfillmentOrder{}).
Where("id = ?", order.ID).
Updates(map[string]interface{}{
"delivery_link_expires_at": expiresAt,
"delivery_link_revoked_at": nil,
"delivery_link_generated_at": now,
}).Error; err != nil {
return err
}
metadata := map[string]interface{}{
"generated_at": timeutil.FormatAPITime(now),
}
if expiresAt != nil {
metadata["expires_at"] = timeutil.FormatAPITime(*expiresAt)
} else {
metadata["permanent"] = true
}
return writeAudit(tx, &merchantID, optionalUint(actorUserID), nil, "delivery_link.restore", "fulfillment_order", order.OrderNo, metadata)
}); err != nil {
return nil, err
}
return s.buildDeliveryLinkResult(order.OrderNo, expiresAt, requestBaseURL), nil
}
func (s *DeliveryService) prepareOrder(orderNo string, requireCanShip bool, auth *DeliveryLinkAuth) (*DeliveryOrderInfo, *model.FulfillmentOrder, string, error) {
orderNo = strings.TrimSpace(orderNo)
if orderNo == "" {
return nil, nil, "", errors.New("订单号不能为空")
}
openOrder, err := s.fulfillment.QueryOpenOrder(orderNo)
if err != nil {
return nil, nil, "", err
}
order, err := s.fulfillment.GetByOrderNo(orderNo)
if err != nil {
return nil, nil, "", err
}
if auth != nil {
if err := s.authorizeDeliveryLink(order, *auth); err != nil {
return nil, nil, "", err
}
}
if order.OrderStatus == model.OrderStatusDelivering {
if synced, _ := s.SyncDeliveringOrder(context.Background(), order); synced {
openOrder, _ = s.fulfillment.QueryOpenOrder(orderNo)
order, _ = s.fulfillment.GetByOrderNo(orderNo)
}
}
return s.buildDeliveryState(openOrder, order, "", requireCanShip)
}
func deliveryGoodID(channel, sku string) string {
if channel != "dlc" {
return ""
}
return map[string]string{
"suit_alan_walker": "682ef3c5a8f40c4234c59f47",
"suit_shadow_gothic": "682ef39ca8f40c4234c59f36",
"top_black_elite_trainer": "682ef39ca8f40c4234c59f37",
"m416_hamster_gray": "682ef3c5a8f40c4234c59f52",
"bag_cute_bear": "682ef3c5a8f40c4234c59f4a",
"suit_dual_fluffy": "682ef3c5a8f40c4234c59f49",
"suit_pink_sheep": "682ef3c5a8f40c4234c59f4d",
"suit_first_peach": "682ef3c5a8f40c4234c59f48",
"suit_romantic_destiny": "682ef39ca8f40c4234c59f38",
"pack_western_cowboy": "682ef3c5a8f40c4234c59f4c",
"smoke_pink_sheep": "682ef3c5a8f40c4234c59f50",
"frag_pink_sheep": "682ef3c5a8f40c4234c59f51",
"suit_hamster_gray": "682ef3c5a8f40c4234c59f53",
"suit_cute_bear": "682ef3c5a8f40c4234c59f4b",
"bag_pink_sheep": "682ef3c5a8f40c4234c59f4f",
"helmet_pink_sheep": "682ef3c5a8f40c4234c59f4e",
"bag_hamster_gray": "682ef3c5a8f40c4234c59f55",
"helmet_hamster_gray": "682ef3c5a8f40c4234c59f54",
"suit_western_mystery": "682ef39ca8f40c4234c59f35",
"helmet_panda_treasure": "682ef39ca8f40c4234c59f39",
"suit_panda_round": "682ef39ca8f40c4234c59f3a",
"suit_panda_tuan": "682ef39ca8f40c4234c59f3b",
"pack_lava_ranger": "682ef39ca8f40c4234c59f3c",
"suit_sand_dancer": "682ef39ca8f40c4234c59f3d",
"pack_star_roam_outfit": "682ef39ca8f40c4234c59f3e",
"pack_star_roam_weapon": "682ef39ca8f40c4234c59f3f",
"suit_cloud_bear": "682ef39ca8f40c4234c59f40",
"honor_medal_x2": "682ef39ca8f40c4234c59f45",
"honor_medal_x30": "682ef39ca8f40c4234c59f43",
"honor_medal_x90": "682ef39ca8f40c4234c59f41",
"lucky_coin_x2": "682ef39ca8f40c4234c59f46",
"lucky_coin_x30": "682ef39ca8f40c4234c59f44",
"lucky_coin_x90": "682ef39ca8f40c4234c59f42",
}[sku]
}