Files
affiliate_dash/backend/internal/service/rock_assign.go
T
zhaoxing f0dea6a583 feat: Rock 兑换支持上游异步 pending 并记录发货全链路报文
问题:上游 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 与
前端构建均通过。
2026-11-14 12:09:16 +08:00

187 lines
7.0 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"
"fmt"
"log"
"strings"
"time"
"affiliate_dash/internal/model"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// BindingSnapshot 保存分配 Token 时上游的绑定快照
type BindingSnapshot struct {
Bound bool `json:"bound"`
RoleName string `json:"role_name"`
RoleID string `json:"role_id"`
Channel string `json:"channel"`
BindChangedAt string `json:"bind_changed_at"`
}
// rockOrderTokenLockedMessage 一单只能占用一个提货号。绑定失败后不再发新号。
const rockOrderTokenLockedMessage = "该订单提货链接已绑定失败,不能更换新的提货号,请联系客服处理"
// AssignTokenForOrder 为指定订单锁定分配一个可用 Token。
// 同一个订单号无论怎么更新 UID,都只能占用一个提货号:using/bound 复用原号并改期望 UID;
// mismatch、已发货或异常则拒绝再分配,需客服人工重置后才能重新领取。
func (s *RockService) AssignTokenForOrder(ctx context.Context, orderNo, expectedUID string) (*model.RockToken, *RockRedeemDetailResponse, error) {
cfg, err := s.GetConfig(ctx)
if err != nil {
return nil, nil, err
}
if !cfg.AutoDeliveryEnabled {
return nil, nil, fmt.Errorf("Rock 自动发货通道已维护关闭")
}
var token model.RockToken
// 1. 该订单已经占用过提货号时,禁止再领新号。
err = s.db.WithContext(ctx).Unscoped().
Where("assigned_order_no = ? AND status IN ?", orderNo, []string{
model.RockStatusUsing,
model.RockStatusBound,
model.RockStatusRedeeming,
model.RockStatusMismatch,
model.RockStatusRedeemed,
model.RockStatusAbnormal,
}).
Order("id DESC").
First(&token).Error
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil, err
}
if err == nil {
switch token.Status {
case model.RockStatusAbnormal:
return nil, nil, errors.New(rockOrderTokenLockedMessage)
case model.RockStatusRedeeming:
return nil, nil, errors.New("该订单已提交发货,上游官方处理中,不能再次领取提货号")
case model.RockStatusRedeemed:
return nil, nil, errors.New("该订单已发货,不能再次领取提货号")
case model.RockStatusMismatch:
trimmedUID := strings.TrimSpace(expectedUID)
actualUID := strings.TrimSpace(token.ActualRoleID)
// 方案 B:买家确认纠正为当前扫码角色的 UID
if trimmedUID != "" && actualUID != "" && trimmedUID == actualUID {
now := time.Now()
token.ExpectedUID = trimmedUID
token.Status = model.RockStatusBound
token.MismatchReason = ""
token.MismatchAt = nil
token.BoundAt = &now
if updateErr := s.db.WithContext(ctx).Unscoped().Model(&token).Updates(map[string]interface{}{
"expected_uid": trimmedUID,
"status": model.RockStatusBound,
"mismatch_reason": "",
"mismatch_at": nil,
"bound_at": now,
"updated_at": now,
}).Error; updateErr != nil {
log.Printf("[RockService] 买家确认更换 UID 失败: order=%s uid=%s err=%v", orderNo, trimmedUID, updateErr)
} else {
writeRockTokenStatusChange(s.db.WithContext(ctx), &token, model.RockStatusMismatch, model.RockStatusBound,
rockTokenSourceAssign, "订单 "+orderNo+" 确认扫码 UID 后解除绑定错误", false, nil)
}
log.Printf("[RockService] 买家确认更换 UID 为扫码角色 UID 成功,解除阻断: order=%s uid=%s role=%s", orderNo, trimmedUID, token.ActualRoleName)
} else {
roleDesc := token.ActualRoleName
if roleDesc == "" {
roleDesc = actualUID
}
if actualUID != "" {
return nil, nil, fmt.Errorf("该订单提货链接已扫码绑定角色「%s」(%s)。如确系本人账号,请确认将填写 UID 修改为 %s;不能更换为其他账号,如需更换请联系客服处理", roleDesc, actualUID, actualUID)
}
return nil, nil, errors.New(rockOrderTokenLockedMessage)
}
default:
trimmedUID := strings.TrimSpace(expectedUID)
if token.Status == model.RockStatusBound && trimmedUID != "" && token.ExpectedUID != trimmedUID {
return nil, nil, errors.New("该订单提货号已绑定,不能更换 UID,请联系客服处理")
}
if trimmedUID != "" && token.ExpectedUID != trimmedUID {
token.ExpectedUID = trimmedUID
_ = s.db.WithContext(ctx).Unscoped().Model(&token).Update("expected_uid", trimmedUID).Error
}
}
}
// 2. 若尚未分配,锁定一个可用 Token (行锁防并发超卖)
if errors.Is(err, gorm.ErrRecordNotFound) {
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Clauses(clause.Locking{Strength: "UPDATE", Options: "SKIP LOCKED"}).
Where("status = ? AND color = ?", model.RockStatusAvailable, model.RockColorBlack).
// 人工释放回库存的 Token 优先出库(最早回库优先),其余按入库先后。
Order("restocked_at ASC NULLS LAST").
Order("id ASC").
First(&token).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return fmt.Errorf("Rock平台库存不足,请联系管理员补货")
}
return err
}
now := time.Now()
// 新一轮分配前清掉上一轮可能残留的绑定信息,避免“未使用”的链接带上旧角色/旧时间。
token.ActualRoleName = ""
token.ActualRoleID = ""
token.ActualChannel = ""
token.BoundAt = nil
token.RedeemedAt = nil
token.UpstreamFulfillID = 0
token.MismatchAt = nil
token.MismatchReason = ""
token.InitialBoundSnapshot = ""
token.RestockedAt = nil // 释放带来的优先权在分配时消耗掉
token.Status = model.RockStatusUsing
token.AssignedOrderNo = orderNo
token.ExpectedUID = strings.TrimSpace(expectedUID)
token.UsingAt = &now
if err := tx.Save(&token).Error; err != nil {
return err
}
writeRockTokenStatusChange(tx, &token, model.RockStatusAvailable, model.RockStatusUsing,
rockTokenSourceAssign, orderNo, false, nil)
return nil
})
if err != nil {
return nil, nil, err
}
// 检查水位触发异步自动补货
go s.CheckStockAndAutoPurchase(context.Background())
}
// 3. 调上游获取绑定活动链接
detail, err := s.client.GetRedeemDetail(ctx, token.RedeemCode)
if err != nil {
return nil, nil, fmt.Errorf("获取上游活动绑定链接失败: %w", err)
}
s.rememberBindLink(token.ID, detail.BindURL, detail.QRURL)
// 4. 首次分配记录上游初始绑定快照(防范 Ghost-Binding 历史残留数据)
if token.InitialBoundSnapshot == "" && cfg.BearerToken != "" {
ref, refErr := s.client.RefreshToken(ctx, cfg.BearerToken, token.UpstreamTokenID)
if refErr == nil && ref != nil {
snap := BindingSnapshot{
Bound: ref.IsBound(),
RoleName: ref.GetRoleName(),
RoleID: ref.GetRoleID(),
Channel: ref.GetChannel(),
BindChangedAt: ref.Token.BindChangedAt,
}
snapBytes, _ := json.Marshal(snap)
token.InitialBoundSnapshot = string(snapBytes)
_ = s.db.WithContext(ctx).Unscoped().Model(&token).Update("initial_bound_snapshot", token.InitialBoundSnapshot).Error
}
}
return &token, detail, nil
}