问题:上游 0666.pro 兑换返回 HTTP 200 + status=pending(官方处理中), 旧逻辑仅凭「没有 error 字段」就判定成功并写 redeemed 终态,导致本地显示 已发货而上游仍是 bound,且 redeemed 为硬终态无法自愈。 后端: - RedeemSkin 改为三态判定(success / pending / failed),不再无 error 即成功 - 新增 redeeming「发货处理中」状态;pending 不写 redeemed_at/fulfill_id - 成功落库前二次 refresh 确认上游 redeemed 或 redeemed_at 才置终态 - 新增确认 worker:20s 轮询、30s→5m 指数退避、110001 全局冷却 60s、 15 分钟未确认仅告警不回退,确认后同步订单为 delivered 并触发回调 - pending 期间禁止重复兑换,订单保持 delivering,不下发发货成功回调 - 新增 rock_delivery_logs 表与记录器:入站参数、内部状态、上游请求/响应 原文全部留档,保留 180 天 - 分配/绑定校验/刷新/导入/删除/回收站/统计/客服查询全部适配 redeeming - 迁移 026:redeeming 跟踪字段、部分索引与报文日志表 前端: - Token 列表新增「发货处理中」筛选项、标签与超时提示 - 删除/回收站纳入 redeeming 危险状态 - 历史抽屉拆为「状态历史 / 发货报文」双页签,按 trace 展示报文详情 - 买家发货页在 pending 时提示官方处理中,无需重复提交 测试:新增 pending 不落终态、异常响应不误判、重复提交不重复兑换、 确认后同步订单、保持 pending 并退避 5 个用例;go build/vet/test 与 前端构建均通过。
292 lines
10 KiB
Go
292 lines
10 KiB
Go
package service
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"fmt"
|
||
"net/http"
|
||
"strings"
|
||
"time"
|
||
|
||
"affiliate_dash/internal/model"
|
||
"affiliate_dash/internal/pkg/timeutil"
|
||
"gorm.io/gorm"
|
||
"gorm.io/gorm/clause"
|
||
)
|
||
|
||
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),
|
||
Pending: submitRes.Pending,
|
||
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
|
||
}
|
||
|
||
// ConfirmRockDelivery 在 Rock 后台确认上游已发货后,把订单同步为已交付并触发回调。
|
||
func (s *DeliveryService) ConfirmRockDelivery(ctx context.Context, order *model.FulfillmentOrder, token *model.RockToken) error {
|
||
if order == nil || token == nil {
|
||
return errors.New("订单或 Token 不存在")
|
||
}
|
||
fulfillIDStr := ""
|
||
if token.UpstreamFulfillID > 0 {
|
||
fulfillIDStr = fmt.Sprintf("%d", token.UpstreamFulfillID)
|
||
}
|
||
skinName := token.TargetSkin
|
||
if strings.TrimSpace(skinName) == "" {
|
||
skinName = model.RockSkinBlackTshirt
|
||
}
|
||
currencyName := token.TargetCurrency
|
||
if strings.TrimSpace(currencyName) == "" {
|
||
currencyName = "幸运币"
|
||
}
|
||
patch := map[string]interface{}{
|
||
"provider": ProviderRock,
|
||
"provider_order_no": fulfillIDStr,
|
||
"fulfill_id": token.UpstreamFulfillID,
|
||
"rock_redeem_pending": false,
|
||
"rock_token_id": token.ID,
|
||
"rock_redeem_code": token.RedeemCode,
|
||
"skin": skinName,
|
||
"currency": currencyName,
|
||
"currency_quantity": token.TargetCurrencyQuantity,
|
||
"role_name": token.ActualRoleName,
|
||
"game_channel": token.ActualChannel,
|
||
"game_uid": token.ActualRoleID,
|
||
}
|
||
_, err := s.fulfillment.UpdateFulfillment(FulfillmentUpdateInput{
|
||
MerchantID: order.MerchantID,
|
||
OrderNo: order.OrderNo,
|
||
Status: model.OrderStatusDelivered,
|
||
ProviderOrderNo: fulfillIDStr,
|
||
ResultData: mergeResultData(order.ResultData, patch),
|
||
})
|
||
return err
|
||
}
|
||
|
||
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,
|
||
})
|
||
}
|