Files
affiliate_dash/backend/internal/service/rock_delivery_log.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

281 lines
8.5 KiB
Go

package service
import (
"context"
"encoding/json"
"fmt"
"log"
"net/http"
"strings"
"sync/atomic"
"time"
"affiliate_dash/internal/model"
"gorm.io/gorm"
)
const (
// 发货报文日志保留天数:按业务要求原样明文保存 180 天。
rockDeliveryLogRetentionDays = 180
// 单个报文字段最大长度,避免异常大响应拖垮数据库;超出部分截断并标注。
rockDeliveryLogMaxBodyBytes = 512 * 1024
// 上游 110001 频控后的全局冷却时间。
rockRateLimitCooldown = 60 * time.Second
)
// rockUpstreamRateLimitedUntil 记录上游频控解禁时间(UnixNano)。
// 买家扫码轮询与后台确认任务共用,避免把 0666.pro 打成持续 110001。
var rockUpstreamRateLimitedUntil atomic.Int64
func markRockUpstreamRateLimited() {
rockUpstreamRateLimitedUntil.Store(time.Now().Add(rockRateLimitCooldown).UnixNano())
}
func rockUpstreamRateLimitActive() bool {
until := rockUpstreamRateLimitedUntil.Load()
return until > 0 && time.Now().UnixNano() < until
}
func isRockRateLimitError(err error) bool {
if err == nil {
return false
}
msg := err.Error()
return strings.Contains(msg, "110001") || strings.Contains(msg, "请求过于频繁")
}
// RockDeliveryRecorder 负责把一次发货链路的每一步写入 rock_delivery_logs。
//
// 设计要点:
// - 上游调用采用「预写日志」:请求前先落 started,拿到响应后更新为最终状态,
// 即使进程中途重启也能看到未完成的调用;
// - 入站请求(本系统收到的参数)与内部状态迁移也各落一条,前端按 trace_id 串起来;
// - 写入为 best-effort,失败只打日志,绝不阻断发货主流程。
type RockDeliveryRecorder struct {
db *gorm.DB
TraceID string
TokenID uint
UpstreamTokenID string
OrderNo string
ActorUserID *uint
ActorIP string
UserAgent string
}
// NewDeliveryRecorder 创建一次发货链路的记录器。
func (s *RockService) NewDeliveryRecorder(orderNo string, actorUserID *uint, actorIP, userAgent string) *RockDeliveryRecorder {
return &RockDeliveryRecorder{
db: s.db,
TraceID: fmt.Sprintf("rd-%d-%d", time.Now().UnixNano(), time.Now().UnixNano()%100000),
OrderNo: strings.TrimSpace(orderNo),
ActorUserID: actorUserID,
ActorIP: strings.TrimSpace(actorIP),
UserAgent: strings.TrimSpace(userAgent),
}
}
// AttachToken 把 Token 身份补到后续日志上(Token 在订单内查询后才能确定)。
func (r *RockDeliveryRecorder) AttachToken(token *model.RockToken) {
if r == nil || token == nil {
return
}
r.TokenID = token.ID
r.UpstreamTokenID = strings.TrimSpace(token.UpstreamTokenID)
}
// Inbound 记录本系统收到的一次业务请求(买家提交发货等)。
func (r *RockDeliveryRecorder) Inbound(ctx context.Context, step string, payload interface{}) {
r.write(ctx, &model.RockDeliveryLog{
TraceID: r.traceID(),
TokenID: r.TokenID,
UpstreamTokenID: r.UpstreamTokenID,
OrderNo: r.OrderNo,
Step: step,
Direction: "inbound",
Status: "success",
RequestHeaders: "",
RequestBody: rockJSONText(payload),
ResponseBody: "",
ActorUserID: r.ActorUserID,
ActorIP: r.ActorIP,
UserAgent: r.UserAgent,
})
}
// Internal 记录一个内部步骤(状态迁移、订单更新、回调等)。
func (r *RockDeliveryRecorder) Internal(ctx context.Context, step, status, message string, payload interface{}) {
if r == nil {
return
}
entry := &model.RockDeliveryLog{
TraceID: r.traceID(),
TokenID: r.TokenID,
UpstreamTokenID: r.UpstreamTokenID,
OrderNo: r.OrderNo,
Step: step,
Direction: "internal",
Status: status,
RequestBody: rockJSONText(payload),
ErrorMessage: strings.TrimSpace(message),
ActorUserID: r.ActorUserID,
ActorIP: r.ActorIP,
UserAgent: r.UserAgent,
}
now := time.Now()
entry.FinishedAt = &now
r.write(ctx, entry)
}
// StartUpstream 在调用上游前预写一条 started 日志,返回的 entry 用于回填响应。
func (r *RockDeliveryRecorder) StartUpstream(ctx context.Context, step, method, reqURL string, headers http.Header, reqBody []byte) *model.RockDeliveryLog {
if r == nil {
return nil
}
entry := &model.RockDeliveryLog{
TraceID: r.traceID(),
TokenID: r.TokenID,
UpstreamTokenID: r.UpstreamTokenID,
OrderNo: r.OrderNo,
Step: step,
Direction: "outbound",
Status: "started",
HTTPMethod: method,
HTTPURL: reqURL,
RequestHeaders: rockHeadersText(headers),
RequestBody: truncateRockDeliveryBody(reqBody),
ActorUserID: r.ActorUserID,
ActorIP: r.ActorIP,
UserAgent: r.UserAgent,
}
if err := r.db.WithContext(ctx).Create(entry).Error; err != nil {
log.Printf("[Rock] 写入发货请求日志失败 trace=%s step=%s: %v", r.TraceID, step, err)
return nil
}
return entry
}
// FinishUpstream 回填上游响应报文与耗时。
func (r *RockDeliveryRecorder) FinishUpstream(ctx context.Context, entry *model.RockDeliveryLog, status string, resp *http.Response, respBody []byte, callErr error, duration time.Duration) {
if r == nil || entry == nil {
return
}
updates := map[string]interface{}{
"status": status,
"duration_ms": int(duration.Milliseconds()),
"response_body": truncateRockDeliveryBody(respBody),
"finished_at": time.Now(),
}
if resp != nil {
updates["http_status"] = resp.StatusCode
updates["response_headers"] = rockHeadersText(resp.Header)
}
if callErr != nil {
updates["error_message"] = callErr.Error()
}
if err := r.db.WithContext(ctx).Model(&model.RockDeliveryLog{}).
Where("id = ?", entry.ID).
Updates(updates).Error; err != nil {
log.Printf("[Rock] 回填发货响应日志失败 trace=%s id=%d: %v", r.TraceID, entry.ID, err)
}
}
func (r *RockDeliveryRecorder) traceID() string {
if r == nil || strings.TrimSpace(r.TraceID) == "" {
return "rd-unknown"
}
return r.TraceID
}
func (r *RockDeliveryRecorder) write(ctx context.Context, entry *model.RockDeliveryLog) {
if r == nil || r.db == nil || entry == nil {
return
}
entry.TraceID = r.traceID()
if entry.TokenID == 0 {
entry.TokenID = r.TokenID
}
if entry.UpstreamTokenID == "" {
entry.UpstreamTokenID = r.UpstreamTokenID
}
if entry.OrderNo == "" {
entry.OrderNo = r.OrderNo
}
if err := r.db.WithContext(ctx).Create(entry).Error; err != nil {
log.Printf("[Rock] 写入发货链路日志失败 trace=%s step=%s: %v", entry.TraceID, entry.Step, err)
}
}
type rockDeliveryRecorderCtxKey struct{}
func withRockDeliveryRecorder(ctx context.Context, recorder *RockDeliveryRecorder) context.Context {
if recorder == nil {
return ctx
}
return context.WithValue(ctx, rockDeliveryRecorderCtxKey{}, recorder)
}
func rockDeliveryRecorderFrom(ctx context.Context) *RockDeliveryRecorder {
if ctx == nil {
return nil
}
if recorder, ok := ctx.Value(rockDeliveryRecorderCtxKey{}).(*RockDeliveryRecorder); ok {
return recorder
}
return nil
}
// ListRockDeliveryLogs 返回指定 Token 的完整发货链路(按时间正序,便于前端时间线展示)。
func (s *RockService) ListRockDeliveryLogs(ctx context.Context, tokenID uint) ([]model.RockDeliveryLog, error) {
var logs []model.RockDeliveryLog
if err := s.db.WithContext(ctx).
Where("token_id = ?", tokenID).
Order("id ASC").
Limit(500).
Find(&logs).Error; err != nil {
return nil, err
}
return logs, nil
}
// CleanupRockDeliveryLogs 清理超过保留期的发货报文日志。
func (s *RockService) CleanupRockDeliveryLogs(ctx context.Context) {
cutoff := time.Now().AddDate(0, 0, -rockDeliveryLogRetentionDays)
if err := s.db.WithContext(ctx).
Where("created_at < ?", cutoff).
Delete(&model.RockDeliveryLog{}).Error; err != nil {
log.Printf("[Rock] 清理发货报文日志失败: %v", err)
}
}
func rockHeadersText(headers http.Header) string {
if len(headers) == 0 {
return ""
}
encoded, err := json.Marshal(headers)
if err != nil {
return ""
}
return string(encoded)
}
func rockJSONText(value interface{}) string {
if value == nil {
return ""
}
encoded, err := json.Marshal(value)
if err != nil {
return fmt.Sprintf("%v", value)
}
return string(encoded)
}
func truncateRockDeliveryBody(body []byte) string {
if len(body) == 0 {
return ""
}
if len(body) <= rockDeliveryLogMaxBodyBytes {
return string(body)
}
return string(body[:rockDeliveryLogMaxBodyBytes]) + "\n...[truncated]"
}