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 与
前端构建均通过。
This commit is contained in:
2026-11-14 12:09:16 +08:00
parent 227d4b93ad
commit f0dea6a583
33 changed files with 2241 additions and 170 deletions
+76 -35
View File
@@ -23,6 +23,7 @@ const (
RockStatusAvailable = "available" // 可用库存
RockStatusUsing = "using" // 已分配给订单,正在提货扫码
RockStatusBound = "bound" // 扫码绑定成功
RockStatusRedeeming = "redeeming" // 上游官方异步处理中,等待确认发货结果
RockStatusRedeemed = "redeemed" // 发货完成
RockStatusMismatch = "mismatch" // UID 校验不符阻断(绑错)
RockStatusAbnormal = "abnormal" // 异常/作废
@@ -73,41 +74,50 @@ func (RockConfig) TableName() string {
// RockToken 通用 Token 本地库存池记录
type RockToken struct {
ID uint `gorm:"primaryKey" json:"id"`
UpstreamTokenID string `gorm:"size:64;not null;default:''" json:"upstream_token_id"`
Token string `gorm:"type:text;not null" json:"-"`
RedeemCode string `gorm:"size:64;not null;index" json:"redeem_code"`
Color string `gorm:"size:32;not null;default:'black';index" json:"color"`
Region string `gorm:"size:64;not null;default:'Q/V可用'" json:"region"`
Status string `gorm:"size:32;not null;default:'available';index" json:"status"`
PackageID *int64 `gorm:"index" json:"package_id,omitempty"`
PackageShareCode string `gorm:"size:64;not null;default:''" json:"package_share_code"`
AssignedOrderNo string `gorm:"size:128;index" json:"assigned_order_no"`
ClientOrderNo string `gorm:"-" json:"client_order_no,omitempty"` // 关联的创建订单商户单号
TokenCode string `gorm:"-" json:"token,omitempty"` // 提取码,仅管理端响应展示,不持久化
ExpectedUID string `gorm:"size:64" json:"expected_uid"`
ActualRoleName string `gorm:"size:128" json:"actual_role_name"`
ActualRoleID string `gorm:"size:64" json:"actual_role_id"`
ActualChannel string `gorm:"size:64" json:"actual_channel"`
InitialBoundSnapshot string `gorm:"type:text" json:"-"` // 分配时初始旧数据快照 JSON
UpstreamFulfillID int64 `json:"upstream_fulfill_id"`
TargetSkin string `gorm:"size:128" json:"target_skin"`
TargetCurrency string `gorm:"size:64;not null;default:'幸运币'" json:"target_currency"`
TargetCurrencyQuantity int `gorm:"not null;default:0" json:"target_currency_quantity"`
MismatchAt *time.Time `gorm:"index" json:"mismatch_at"`
MismatchReason string `gorm:"size:255" json:"mismatch_reason"`
Remark string `gorm:"type:text;not null;default:''" json:"remark"`
RawData string `gorm:"type:text;not null;default:''" json:"raw_data,omitempty"`
Source string `gorm:"size:32;not null;default:'purchase';index" json:"source"`
ClaimedAt *time.Time `json:"claimed_at"`
UsingAt *time.Time `gorm:"index" json:"using_at"` // 变为 using 的时间,超时预警起点
RestockedAt *time.Time `gorm:"index" json:"restocked_at,omitempty"` // 人工释放回库存的时间,分配时优先(FIFO)
BoundAt *time.Time `json:"bound_at"`
RedeemedAt *time.Time `json:"redeemed_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
DeletedAtText string `gorm:"-" json:"deleted_at,omitempty"` // 回收站展示用,仅管理端响应返回
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
ID uint `gorm:"primaryKey" json:"id"`
UpstreamTokenID string `gorm:"size:64;not null;default:''" json:"upstream_token_id"`
Token string `gorm:"type:text;not null" json:"-"`
RedeemCode string `gorm:"size:64;not null;index" json:"redeem_code"`
Color string `gorm:"size:32;not null;default:'black';index" json:"color"`
Region string `gorm:"size:64;not null;default:'Q/V可用'" json:"region"`
Status string `gorm:"size:32;not null;default:'available';index" json:"status"`
PackageID *int64 `gorm:"index" json:"package_id,omitempty"`
PackageShareCode string `gorm:"size:64;not null;default:''" json:"package_share_code"`
AssignedOrderNo string `gorm:"size:128;index" json:"assigned_order_no"`
ClientOrderNo string `gorm:"-" json:"client_order_no,omitempty"` // 关联的创建订单商户单号
TokenCode string `gorm:"-" json:"token,omitempty"` // 提取码,仅管理端响应展示,不持久化
ExpectedUID string `gorm:"size:64" json:"expected_uid"`
ActualRoleName string `gorm:"size:128" json:"actual_role_name"`
ActualRoleID string `gorm:"size:64" json:"actual_role_id"`
ActualChannel string `gorm:"size:64" json:"actual_channel"`
InitialBoundSnapshot string `gorm:"type:text" json:"-"` // 分配时初始旧数据快照 JSON
UpstreamFulfillID int64 `json:"upstream_fulfill_id"`
TargetSkin string `gorm:"size:128" json:"target_skin"`
TargetCurrency string `gorm:"size:64;not null;default:'幸运币'" json:"target_currency"`
TargetCurrencyQuantity int `gorm:"not null;default:0" json:"target_currency_quantity"`
MismatchAt *time.Time `gorm:"index" json:"mismatch_at"`
MismatchReason string `gorm:"size:255" json:"mismatch_reason"`
Remark string `gorm:"type:text;not null;default:''" json:"remark"`
RawData string `gorm:"type:text;not null;default:''" json:"raw_data,omitempty"`
Source string `gorm:"size:32;not null;default:'purchase';index" json:"source"`
ClaimedAt *time.Time `json:"claimed_at"`
UsingAt *time.Time `gorm:"index" json:"using_at"` // 变为 using 的时间,超时预警起点
RestockedAt *time.Time `gorm:"index" json:"restocked_at,omitempty"` // 人工释放回库存的时间,分配时优先(FIFO)
BoundAt *time.Time `json:"bound_at"`
RedeemedAt *time.Time `json:"redeemed_at"`
// 异步兑换(上游 pending)跟踪字段。
RedeemRequestedAt *time.Time `gorm:"index" json:"redeem_requested_at,omitempty"` // 首次提交兑换的时间,用于 15 分钟超时告警
RedeemCheckedAt *time.Time `json:"redeem_checked_at,omitempty"` // 最近一次查询上游结果的时间
RedeemAttempts int `gorm:"not null;default:0" json:"redeem_attempts"`
RedeemNextRetryAt *time.Time `gorm:"index" json:"redeem_next_retry_at,omitempty"`
RedeemLastMessage string `gorm:"type:text;not null;default:''" json:"redeem_last_message,omitempty"`
RedeemLastError string `gorm:"type:text;not null;default:''" json:"redeem_last_error,omitempty"`
RedeemTimeoutWarnedAt *time.Time `json:"redeem_timeout_warned_at,omitempty"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
DeletedAtText string `gorm:"-" json:"deleted_at,omitempty"` // 回收站展示用,仅管理端响应返回
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
// 以下为列表派生字段,不落库:由后端用与筛选/统计完全相同的 now 计算。
BoundOverdue bool `gorm:"-" json:"bound_overdue"`
@@ -140,6 +150,37 @@ func (RockTokenStatusLog) TableName() string {
return "rock_token_status_logs"
}
// RockDeliveryLog 记录一次发货链路的完整报文(本系统入站 / 上游请求 / 上游响应 / 内部状态)。
// 用于订单排障与对账;按 180 天保留,由后台任务定期清理。
type RockDeliveryLog struct {
ID uint `gorm:"primaryKey" json:"id"`
TraceID string `gorm:"size:64;not null;default:'';index:idx_rock_delivery_logs_trace" json:"trace_id"`
TokenID uint `gorm:"not null;default:0;index:idx_rock_delivery_logs_token" json:"token_id"`
UpstreamTokenID string `gorm:"size:64;not null;default:''" json:"upstream_token_id"`
OrderNo string `gorm:"size:128;not null;default:'';index:idx_rock_delivery_logs_order" json:"order_no"`
Step string `gorm:"size:40;not null;default:'';index" json:"step"`
Direction string `gorm:"size:16;not null;default:''" json:"direction"` // inbound / outbound / internal
Status string `gorm:"size:16;not null;default:'';index" json:"status"` // started / success / pending / failed / skipped
HTTPMethod string `gorm:"size:8;not null;default:''" json:"http_method"`
HTTPURL string `gorm:"type:text;not null;default:''" json:"http_url"`
HTTPStatus int `gorm:"not null;default:0" json:"http_status"`
RequestHeaders string `gorm:"type:text;not null;default:''" json:"request_headers"`
RequestBody string `gorm:"type:text;not null;default:''" json:"request_body"`
ResponseHeaders string `gorm:"type:text;not null;default:''" json:"response_headers"`
ResponseBody string `gorm:"type:text;not null;default:''" json:"response_body"`
DurationMs int `gorm:"not null;default:0" json:"duration_ms"`
ErrorMessage string `gorm:"type:text;not null;default:''" json:"error_message"`
ActorUserID *uint `gorm:"index" json:"actor_user_id,omitempty"`
ActorIP string `gorm:"size:64;not null;default:''" json:"actor_ip"`
UserAgent string `gorm:"size:512;not null;default:''" json:"user_agent"`
CreatedAt time.Time `json:"created_at"`
FinishedAt *time.Time `json:"finished_at,omitempty"`
}
func (RockDeliveryLog) TableName() string {
return "rock_delivery_logs"
}
// RockPurchaseLog 是一次采购尝试的主记录。
// 每一次真正进入采购流程的尝试都会落一条,包含降级原因与各阶段汇总。
type RockPurchaseLog struct {