feat: 完善 Rock 发货异常闭环与绑定判定

This commit is contained in:
yml
2026-11-14 23:58:57 +08:00
parent 9353b439a7
commit 6a87fe5228
45 changed files with 3497 additions and 389 deletions
+1 -1
View File
@@ -161,7 +161,7 @@ func main() {
CORSAllowedOrigins: cfg.CORSAllowedOrigins,
LewanWebhook: handler.NewLewanWebhookHandler(fulfillmentSvc, db, cfg.LewanWebhookSecret, cfg.LewanApplicationID),
PlatformLewan: handler.NewPlatformLewanHandler(merchantSvc, lewanClient),
Rock: handler.NewRockHandler(rockSvc),
Rock: handler.NewRockHandler(rockSvc, deliverySvc),
}
go callbackSvc.Run(context.Background())
@@ -0,0 +1,34 @@
-- 029: Rock 发货异常事件表。
-- 原始请求/响应继续保留在 rock_delivery_logs;本表用于分类、筛选和人工处理。
CREATE TABLE IF NOT EXISTS rock_delivery_incidents (
id BIGSERIAL PRIMARY KEY,
token_id BIGINT NOT NULL,
order_no VARCHAR(128) NOT NULL DEFAULT '',
upstream_token_id VARCHAR(64) NOT NULL DEFAULT '',
category VARCHAR(40) NOT NULL,
status VARCHAR(16) NOT NULL DEFAULT 'open',
blocking_retry BOOLEAN NOT NULL DEFAULT TRUE,
message TEXT NOT NULL DEFAULT '',
upstream_http_status INTEGER NOT NULL DEFAULT 0,
upstream_response TEXT NOT NULL DEFAULT '',
occurrences INTEGER NOT NULL DEFAULT 1,
first_seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
last_seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
resolved_at TIMESTAMPTZ,
resolved_by BIGINT,
resolution_action VARCHAR(32) NOT NULL DEFAULT '',
resolution_note TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_rock_delivery_incidents_filter
ON rock_delivery_incidents (status, category, last_seen_at DESC);
CREATE INDEX IF NOT EXISTS idx_rock_delivery_incidents_token
ON rock_delivery_incidents (token_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_rock_delivery_incidents_order
ON rock_delivery_incidents (order_no, created_at DESC);
CREATE UNIQUE INDEX IF NOT EXISTS idx_rock_delivery_incidents_open_unique
ON rock_delivery_incidents (token_id, order_no, category)
WHERE status = 'open';
+123 -4
View File
@@ -8,6 +8,7 @@ import (
"strings"
"affiliate_dash/internal/middleware"
"affiliate_dash/internal/model"
"affiliate_dash/internal/pkg/response"
"affiliate_dash/internal/service"
@@ -15,11 +16,12 @@ import (
)
type RockHandler struct {
rockSvc *service.RockService
rockSvc *service.RockService
deliverySvc *service.DeliveryService
}
func NewRockHandler(rockSvc *service.RockService) *RockHandler {
return &RockHandler{rockSvc: rockSvc}
func NewRockHandler(rockSvc *service.RockService, deliverySvc *service.DeliveryService) *RockHandler {
return &RockHandler{rockSvc: rockSvc, deliverySvc: deliverySvc}
}
// actorPtr 把 0 值用户 ID 转成 nil,便于审计字段留空。
@@ -493,6 +495,123 @@ func (h *RockHandler) ListDeliveryLogs(c *gin.Context) {
response.OK(c, gin.H{"list": logs})
}
func (h *RockHandler) ListTokenIncidents(c *gin.Context) {
idUint, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
response.BadRequest(c, "无效的 Token ID")
return
}
list, err := h.rockSvc.ListRockTokenIncidents(c.Request.Context(), uint(idUint))
if err != nil {
response.BadRequest(c, err.Error())
return
}
response.OK(c, gin.H{"list": list})
}
func (h *RockHandler) ListIncidents(c *gin.Context) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
result, err := h.rockSvc.ListRockDeliveryIncidents(
c.Request.Context(),
strings.TrimSpace(c.Query("status")),
strings.TrimSpace(c.Query("category")),
page,
pageSize,
)
if err != nil {
response.ServerError(c, err.Error())
return
}
response.OK(c, gin.H{
"list": result.List,
"total": result.Total,
"page": page,
"page_size": pageSize,
})
}
type resolveIncidentReq struct {
Note string `json:"note"`
}
func (h *RockHandler) ResolveIncident(c *gin.Context) {
idUint, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil || idUint == 0 {
response.BadRequest(c, "无效的异常记录 ID")
return
}
var req resolveIncidentReq
_ = c.ShouldBindJSON(&req)
if err := h.rockSvc.ResolveRockDeliveryIncident(
c.Request.Context(),
uint(idUint),
req.Note,
actorPtr(middleware.GetUserID(c)),
); err != nil {
response.BadRequest(c, err.Error())
return
}
response.OK(c, gin.H{"message": "异常记录已标记处理"})
}
type retryIncidentReq struct {
Note string `json:"note"`
}
func (h *RockHandler) RetryIncident(c *gin.Context) {
idUint, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil || idUint == 0 {
response.BadRequest(c, "无效的异常记录 ID")
return
}
if h.deliverySvc == nil {
response.ServerError(c, "发货服务未初始化")
return
}
var req retryIncidentReq
_ = c.ShouldBindJSON(&req)
actor := actorPtr(middleware.GetUserID(c))
plan, err := h.rockSvc.ApproveRockIncidentRetry(c.Request.Context(), uint(idUint), req.Note, actor)
if err != nil {
response.BadRequest(c, err.Error())
return
}
result, retryErr := h.deliverySvc.SubmitForMerchant(
plan.Order.MerchantID,
0,
plan.Order.OrderNo,
plan.Token.ExpectedUID,
strconv.FormatUint(uint64(plan.Token.ID), 10),
)
if retryErr != nil {
if blocking, findErr := h.rockSvc.HasBlockingRockIncident(c.Request.Context(), plan.Order.OrderNo); findErr == nil && blocking == nil {
_, _ = h.rockSvc.RecordRockIncident(
c.Request.Context(),
&plan.Token,
plan.Order.OrderNo,
model.RockIncidentCategoryOther,
"人工重试失败: "+retryErr.Error(),
0,
"",
)
}
response.BadRequest(c, "人工重试失败: "+retryErr.Error())
return
}
if err := h.rockSvc.CompleteRockIncidentRetry(
c.Request.Context(),
plan.Token.ID,
plan.Order.OrderNo,
req.Note,
actor,
); err != nil {
response.ServerError(c, "发货已提交,但关闭异常记录失败: "+err.Error())
return
}
response.OK(c, result)
}
// ReleaseToken 把使用中的提货链接释放回可用库存;上游异常时需人工强制确认。
func (h *RockHandler) ReleaseToken(c *gin.Context) {
idUint, err := strconv.ParseUint(c.Param("id"), 10, 64)
@@ -522,7 +641,7 @@ func (h *RockHandler) ReleaseToken(c *gin.Context) {
response.OK(c, gin.H{"message": message})
}
// RefreshStaleTokens 一键刷新异常 Token(绑定错误 + 使用超时 + 绑定超时,同步执行,单次最多 20 条)。
// RefreshStaleTokens 一键刷新异常 Token(绑定错误 + 待绑定超时 + 绑定后未发货,同步执行,单次最多 20 条)。
func (h *RockHandler) RefreshStaleTokens(c *gin.Context) {
result, err := h.rockSvc.RefreshAllStaleTokens(c.Request.Context())
if err != nil {
+53
View File
@@ -31,6 +31,24 @@ const (
// RockStatusAnomaly 表示「历史流转中出现过异常」的 Token,不改变其主状态。
RockStatusAnomaly = "anomaly"
// Rock 发货异常事件。
RockIncidentStatusOpen = "open"
RockIncidentStatusResolved = "resolved"
RockIncidentStatusIgnored = "ignored"
RockIncidentCategoryNoEntitlement = "no_entitlement"
RockIncidentCategoryDuplicateItem = "duplicate_item"
RockIncidentCategoryGiftExhausted = "gift_exhausted"
RockIncidentCategoryTokenRedeemed = "token_already_redeemed"
RockIncidentCategoryTokenNotRedeemable = "token_not_redeemable"
RockIncidentCategoryTokenNotFound = "token_not_found"
RockIncidentCategoryUpstreamTimeout = "upstream_timeout"
RockIncidentCategoryRateLimited = "upstream_rate_limited"
RockIncidentCategoryShipTimeout = "ship_timeout"
RockIncidentCategoryBindMismatch = "bind_mismatch"
RockIncidentCategoryOrderCancelled = "order_cancelled"
RockIncidentCategoryOther = "other"
RockSKUBlackTshirt = "rock_black_tshirt"
RockSKUBlueSportswear = "rock_blue_sportswear"
@@ -137,6 +155,13 @@ type RockToken struct {
AnomalyReason string `gorm:"-" json:"anomaly_reason,omitempty"`
AnomalyAt *time.Time `gorm:"-" json:"anomaly_at,omitempty"`
AnomalyCount int `gorm:"-" json:"anomaly_count"`
// 当前开放异常,供列表筛选和人工处理入口使用。
HasOpenIncident bool `gorm:"-" json:"has_open_incident"`
IncidentCategory string `gorm:"-" json:"incident_category,omitempty"`
IncidentMessage string `gorm:"-" json:"incident_message,omitempty"`
IncidentAt *time.Time `gorm:"-" json:"incident_at,omitempty"`
IncidentCount int64 `gorm:"-" json:"incident_count,omitempty"`
}
func (RockToken) TableName() string {
@@ -193,6 +218,34 @@ func (RockDeliveryLog) TableName() string {
return "rock_delivery_logs"
}
// RockDeliveryIncident 是需要人工处理的发货异常事件。
// 原始报文仍保存在 rock_delivery_logs,本表只保存分类和处理状态。
type RockDeliveryIncident struct {
ID uint `gorm:"primaryKey" json:"id"`
TokenID uint `gorm:"not null;index:idx_rock_delivery_incidents_token" json:"token_id"`
OrderNo string `gorm:"size:128;not null;default:'';index:idx_rock_delivery_incidents_order" json:"order_no"`
UpstreamTokenID string `gorm:"size:64;not null;default:''" json:"upstream_token_id"`
Category string `gorm:"size:40;not null;index:idx_rock_delivery_incidents_filter" json:"category"`
Status string `gorm:"size:16;not null;default:'open';index:idx_rock_delivery_incidents_filter" json:"status"`
BlockingRetry bool `gorm:"not null;default:true" json:"blocking_retry"`
Message string `gorm:"type:text;not null;default:''" json:"message"`
UpstreamHTTPStatus int `gorm:"not null;default:0" json:"upstream_http_status"`
UpstreamResponse string `gorm:"type:text;not null;default:''" json:"upstream_response"`
Occurrences int `gorm:"not null;default:1" json:"occurrences"`
FirstSeenAt time.Time `json:"first_seen_at"`
LastSeenAt time.Time `json:"last_seen_at"`
ResolvedAt *time.Time `json:"resolved_at,omitempty"`
ResolvedBy *uint `gorm:"index" json:"resolved_by,omitempty"`
ResolutionAction string `gorm:"size:32;not null;default:''" json:"resolution_action"`
ResolutionNote string `gorm:"type:text;not null;default:''" json:"resolution_note"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func (RockDeliveryIncident) TableName() string {
return "rock_delivery_incidents"
}
// RockPurchaseLog 是一次采购尝试的主记录。
// 每一次真正进入采购流程的尝试都会落一条,包含降级原因与各阶段汇总。
type RockPurchaseLog struct {
+4
View File
@@ -301,6 +301,10 @@ func Setup(h *Handlers) *gin.Engine {
platform.POST("/platform/rock/tokens/:id/release-check", h.Rock.CheckReleaseToken)
platform.GET("/platform/rock/tokens/:id/status-logs", h.Rock.ListTokenStatusLogs)
platform.GET("/platform/rock/tokens/:id/delivery-logs", h.Rock.ListDeliveryLogs)
platform.GET("/platform/rock/tokens/:id/incidents", h.Rock.ListTokenIncidents)
platform.GET("/platform/rock/incidents", h.Rock.ListIncidents)
platform.PATCH("/platform/rock/incidents/:id", h.Rock.ResolveIncident)
platform.POST("/platform/rock/incidents/:id/retry", h.Rock.RetryIncident)
platform.POST("/platform/rock/tokens/:id/refresh-binding", h.Rock.RefreshBinding)
platform.POST("/platform/rock/token-batches/refresh-stale", h.Rock.RefreshStaleTokens)
platform.GET("/platform/rock/token-batches/preview", h.Rock.PreviewTokenDelete)
+1 -1
View File
@@ -54,7 +54,7 @@ func newTestRouterWithDB(t *testing.T) (*gin.Engine, *jwt.Manager, *gorm.DB) {
SourceOpen: handler.NewOpenHandler(fulfillmentSvc),
Merchant: handler.NewMerchantHandler(merchantSvc, fulfillmentSvc, callbackSvc, deliverySvc, rechargeSvc),
Recharge: handler.NewRechargeHandler(rechargeSvc),
Rock: handler.NewRockHandler(rockSvc),
Rock: handler.NewRockHandler(rockSvc, deliverySvc),
JWT: jm,
Tenant: tenantSvc,
OpenDB: db,
+13
View File
@@ -32,6 +32,9 @@ func deliverySubmittedUpstream(order *model.FulfillmentOrder) bool {
if order == nil {
return false
}
if deliveryPendingUpstream(order) {
return true
}
if strings.TrimSpace(order.ProviderOrderNo) != "" {
return true
}
@@ -45,6 +48,16 @@ func deliverySubmittedUpstream(order *model.FulfillmentOrder) bool {
return resultDataString(order.ResultData, "provider_order_stage") == deliveryStageSubmitted
}
// deliveryPendingUpstream 表示 Rock 已受理兑换,正在等待上游确认。
// pending 不等于“提交中断”,订单超时扫描必须跳过。
func deliveryPendingUpstream(order *model.FulfillmentOrder) bool {
if order == nil {
return false
}
stage := resultDataString(order.ResultData, "provider_order_stage")
return stage == "redeem_pending" || boolFromMap(resultDataMap(order.ResultData), "rock_redeem_pending")
}
type DeliveryService struct {
fulfillment *FulfillmentService
bffBaseURL string
@@ -65,6 +65,10 @@ func (s *FulfillmentService) markProcessingTimeout(id uint, timeout time.Duratio
if !processingTimedOut(&order, timeout, now) {
return nil
}
// Rock pending 已经提交上游,由 Rock 确认任务继续轮询,不能按“提交中断”标记失败。
if deliveryPendingUpstream(&order) {
return nil
}
if err := validateOrderStatusTransition(&order, model.OrderStatusShipFailed, fulfillmentTransitionTimeout); err != nil {
return nil
}
+105 -23
View File
@@ -6,8 +6,11 @@ import (
"fmt"
"strconv"
"strings"
"time"
"affiliate_dash/internal/model"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// RockProvider 实现 Rock 发货平台履约逻辑
@@ -56,6 +59,17 @@ func (p *RockProvider) PrepareDelivery(ctx context.Context, order *model.Fulfill
}
if orderNo != "" {
incident, incidentErr := p.rockService.HasBlockingRockIncident(ctx, orderNo)
if incidentErr != nil {
return nil, incidentErr
}
if incident != nil {
return &DeliveryOrderState{
CanShip: false,
CannotShipReason: "该订单存在未处理发货异常,请先在 Rock 发货记录中处理",
Provider: ProviderRock,
}, nil
}
var assigned model.RockToken
findErr := p.rockService.db.WithContext(ctx).Unscoped().
Where("assigned_order_no = ? AND status IN ?", orderNo, []string{
@@ -256,6 +270,12 @@ func (p *RockProvider) Submit(ctx context.Context, order *model.FulfillmentOrder
"provider": ProviderRock,
})
if incident, incidentErr := p.rockService.HasBlockingRockIncident(ctx, order.OrderNo); incidentErr != nil {
return nil, incidentErr
} else if incident != nil {
return nil, fmt.Errorf("该订单存在未处理发货异常(%s),请人工处理后再重试", incident.Category)
}
result, err := p.rockService.RedeemTokenForOrder(ctx, order.OrderNo, sku, expectedUID, recorder)
if err != nil {
recorder.Internal(ctx, "order_update", "failed", err.Error(), map[string]interface{}{
@@ -404,32 +424,94 @@ func (p *RockProvider) Cancel(ctx context.Context, order *model.FulfillmentOrder
return nil
}
// 若订单尚未扫码绑定成功,将处于 using 状态的 Token 安全重置回 available
var token model.RockToken
err := p.rockService.db.WithContext(ctx).Unscoped().
Where("assigned_order_no = ? AND status = ?", order.OrderNo, model.RockStatusUsing).
err := p.rockService.db.WithContext(ctx).
Where("assigned_order_no = ? AND status IN ?", order.OrderNo,
[]string{model.RockStatusUsing, model.RockStatusBound, model.RockStatusRedeemed}).
Order("id DESC").
First(&token).Error
if err == nil {
fromStatus := token.Status
if updateErr := p.rockService.db.WithContext(ctx).Unscoped().Model(&token).Updates(map[string]interface{}{
"status": model.RockStatusAvailable,
"assigned_order_no": "",
"expected_uid": "",
"actual_role_name": "",
"actual_role_id": "",
"actual_channel": "",
"initial_bound_snapshot": "",
"mismatch_at": nil,
"mismatch_reason": "",
"bound_at": nil,
"using_at": nil,
"redeemed_at": nil,
"upstream_fulfill_id": 0,
"restocked_at": nil, // 订单取消回滚不属于「释放回库存」,不产生优先权
}).Error; updateErr == nil {
writeRockTokenStatusChange(p.rockService.db.WithContext(ctx), &token, fromStatus, model.RockStatusAvailable,
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil
}
if err != nil {
return err
}
switch token.Status {
case model.RockStatusUsing:
// 尚未扫码绑定,可以安全撤销本轮领号并回库存。
retryAsBound := false
err := p.rockService.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&token, token.ID).Error; err != nil {
return err
}
if token.Status != model.RockStatusUsing {
retryAsBound = token.Status == model.RockStatusBound ||
token.Status == model.RockStatusRedeemed
return nil
}
fromStatus := token.Status
now := time.Now()
if err := tx.Unscoped().Model(&token).Updates(map[string]interface{}{
"status": model.RockStatusAvailable,
"assigned_order_no": "",
"expected_uid": "",
"actual_role_name": "",
"actual_role_id": "",
"actual_channel": "",
"initial_bound_snapshot": "",
"mismatch_at": nil,
"mismatch_reason": "",
"bound_at": nil,
"using_at": nil,
"redeemed_at": nil,
"upstream_fulfill_id": 0,
"redeem_requested_at": nil,
"redeem_checked_at": nil,
"redeem_attempts": 0,
"redeem_next_retry_at": nil,
"redeem_last_message": "",
"redeem_last_error": "",
"redeem_timeout_warned_at": nil,
"restocked_at": nil, // 订单取消回滚不属于「释放回库存」,不产生优先权
"updated_at": now,
}).Error; err != nil {
return err
}
writeRockTokenStatusChange(tx, &token, fromStatus, model.RockStatusAvailable,
rockTokenSourceOrderCancel, order.OrderNo, false, nil)
return resolveOpenRockIncidents(tx, token.ID, "order_cancel", order.OrderNo, nil)
})
if err != nil {
return err
}
if retryAsBound {
return p.Cancel(ctx, order)
}
return nil
case model.RockStatusBound:
_, err = p.rockService.RecordRockIncident(
ctx,
&token,
order.OrderNo,
model.RockIncidentCategoryOrderCancelled,
"订单已取消,但 Token 已扫码绑定;禁止自动释放,请人工核对后释放或上报失效",
0,
"",
)
return err
case model.RockStatusRedeemed:
_, err = p.rockService.RecordRockIncident(
ctx,
&token,
order.OrderNo,
model.RockIncidentCategoryOrderCancelled,
"订单已取消,但上游已发货;需要人工核对退款与履约",
0,
"",
)
return err
default:
return nil
}
return nil
}
+13 -5
View File
@@ -215,7 +215,8 @@ func TestRockProvider_AntiGhostAndUIDMismatch(t *testing.T) {
// 3. 买家扫错了号!拿了微信小号扫码,实际角色变成了“错误小号”(9999999999)
currentBoundAccount = "错误小号"
currentBoundRoleID = "9999999999"
currentBindChangedAt = "2026-10-05 14:00:00"
currentBindChangedAt = time.Now().In(timeutil.Location()).Add(time.Minute).Format("2006-01-02 15:04:05")
rockSvc.refreshCache.Delete("151153")
check2, err := provider.CheckBind(context.Background(), order, bindRes.BindUUID)
if err != nil {
@@ -275,12 +276,14 @@ func TestRockProvider_AntiGhostAndUIDMismatch(t *testing.T) {
// 5. 重新绑定正确账号“感性的洁少”(3688825401)
currentBoundAccount = "感性的洁少"
currentBoundRoleID = "3688825401"
currentBindChangedAt = "2026-10-05 14:05:00"
currentBindChangedAt = ""
bindRes2, err := provider.Bind(context.Background(), order, expectedUID)
if err != nil {
t.Fatalf("rebind error: %v", err)
}
currentBindChangedAt = time.Now().In(timeutil.Location()).Add(time.Minute).Format("2006-01-02 15:04:05")
rockSvc.refreshCache.Delete("151153")
check3, err := provider.CheckBind(context.Background(), order, bindRes2.BindUUID)
if err != nil {
t.Fatalf("check bind match: %v", err)
@@ -638,6 +641,7 @@ func TestReleaseUnboundUsingToken(t *testing.T) {
}
// 上游已经出现新扫码时拒绝释放。
releaseBaseline := time.Now().Add(-time.Hour)
using := model.RockToken{
UpstreamTokenID: "88002",
Token: "jwt2",
@@ -646,12 +650,13 @@ func TestReleaseUnboundUsingToken(t *testing.T) {
Status: model.RockStatusUsing,
AssignedOrderNo: "FO-RELEASE-2",
ExpectedUID: "3688825401",
ClaimedAt: &releaseBaseline,
}
if err := db.Create(&using).Error; err != nil {
t.Fatalf("create using token: %v", err)
}
boundRoleID = "3688825401"
bindChangedAt = "2026-10-05 21:10:00"
bindChangedAt = time.Now().In(timeutil.Location()).Add(-30 * time.Minute).Format("2006-01-02 15:04:05")
if err := rockSvc.ReleaseUnboundUsingToken(context.Background(), using.ID, false, nil); err == nil || !strings.Contains(err.Error(), "已扫码绑定") {
t.Fatalf("已绑定链接必须拒绝释放,实际: %v", err)
}
@@ -705,13 +710,15 @@ func TestReleaseUnboundUsingToken(t *testing.T) {
boundRoleID = "4744920014"
bindChangedAt = "2026-10-05 21:52:30"
deliveryTime = "2026-10-05T21:52:40.61"
if err := rockSvc.ReleaseUnboundUsingToken(context.Background(), fresh.ID, false, nil); err == nil || !strings.Contains(err.Error(), "已扫码绑定") {
if err := rockSvc.ReleaseUnboundUsingToken(context.Background(), fresh.ID, false, nil); err == nil ||
(!strings.Contains(err.Error(), "已扫码绑定") && !strings.Contains(err.Error(), "上游已发货")) {
t.Fatalf("领取后的新发货必须拒绝释放,实际: %v", err)
}
}
func TestRockProvider_SchemeB_UIDSelfHealing(t *testing.T) {
db := newRockTestDB(t)
currentBindChangedAt := ""
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
@@ -728,7 +735,7 @@ func TestRockProvider_SchemeB_UIDSelfHealing(t *testing.T) {
"token": map[string]interface{}{
"id": 152001,
"status": "bound",
"bind_changed_at": "2026-10-06 10:00:00",
"bind_changed_at": currentBindChangedAt,
"bound_account": "少年有点忧郁",
"bound_account_id": "3454648872",
"region": "安卓-微信",
@@ -771,6 +778,7 @@ func TestRockProvider_SchemeB_UIDSelfHealing(t *testing.T) {
if bindRes1.Bound {
t.Fatalf("尚未扫码,bound 应为 false")
}
currentBindChangedAt = time.Now().In(timeutil.Location()).Add(time.Minute).Format("2006-01-02 15:04:05")
// 2. 扫码反查,真实角色是 3454648872,发生 mismatch
check1, err := provider.CheckBind(ctx, order, bindRes1.BindUUID)
+24 -13
View File
@@ -20,13 +20,16 @@ import (
//
// 采用子查询而不是落库字段,保证历史数据无需迁移即可被筛选到。
const rockAnomalyLogConditionSQL = `(
l.to_status IN ('mismatch', 'abnormal')
OR l.reason LIKE '%失败%'
OR l.reason LIKE '%不存在%'
OR l.reason LIKE '%异常%'
OR l.reason LIKE '%超时%'
OR l.reason LIKE '%拦截%'
OR l.reason LIKE '%不符%'
l.event_type <> 'incident'
AND (
l.to_status IN ('mismatch', 'abnormal')
OR l.reason LIKE '%失败%'
OR l.reason LIKE '%不存在%'
OR l.reason LIKE '%异常%'
OR l.reason LIKE '%超时%'
OR l.reason LIKE '%拦截%'
OR l.reason LIKE '%不符%'
)
)`
// rockAnomalyTokenScopeSQL 排除已有明确结论的 Token(别名固定为 t):
@@ -34,12 +37,20 @@ const rockAnomalyLogConditionSQL = `(
const rockAnomalyTokenScopeSQL = `t.status NOT IN ('redeemed', 'abnormal') AND t.redeemed_at IS NULL`
func rockTokenAnomalyExistsSQL() string {
return `EXISTS (
SELECT 1 FROM rock_token_status_logs l
JOIN rock_tokens t ON t.id = l.token_id
WHERE l.token_id = rock_tokens.id
AND ` + rockAnomalyTokenScopeSQL + `
AND ` + rockAnomalyLogConditionSQL + `
return `(
EXISTS (
SELECT 1 FROM rock_token_status_logs l
JOIN rock_tokens t ON t.id = l.token_id
WHERE l.token_id = rock_tokens.id
AND ` + rockAnomalyTokenScopeSQL + `
AND ` + rockAnomalyLogConditionSQL + `
)
OR EXISTS (
SELECT 1 FROM rock_delivery_incidents i
WHERE i.token_id = rock_tokens.id
AND i.status = 'open'
AND i.category <> 'no_entitlement'
)
)`
}
+15 -10
View File
@@ -16,11 +16,13 @@ import (
// 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"`
Bound bool `json:"bound"`
RoleName string `json:"role_name"`
RoleID string `json:"role_id"`
Channel string `json:"channel"`
BindChangedAt string `json:"bind_changed_at"`
BindChangeType string `json:"bind_change_type,omitempty"`
Status string `json:"status,omitempty"`
}
// rockOrderTokenLockedMessage 一单只能占用一个提货号。绑定失败后不再发新号。
@@ -166,12 +168,15 @@ func (s *RockService) AssignTokenForOrder(ctx context.Context, orderNo, expected
if token.InitialBoundSnapshot == "" && cfg.BearerToken != "" {
ref, refErr := s.client.RefreshToken(ctx, cfg.BearerToken, token.UpstreamTokenID)
if refErr == nil && ref != nil {
currentBound, _ := rockCurrentCycleBinding(&token, ref)
snap := BindingSnapshot{
Bound: ref.IsBound(),
RoleName: ref.GetRoleName(),
RoleID: ref.GetRoleID(),
Channel: ref.GetChannel(),
BindChangedAt: ref.Token.BindChangedAt,
Bound: currentBound,
RoleName: ref.GetRoleName(),
RoleID: ref.GetRoleID(),
Channel: ref.GetChannel(),
BindChangedAt: ref.Token.BindChangedAt,
BindChangeType: ref.Token.BindChangeType,
Status: ref.Token.Status,
}
snapBytes, _ := json.Marshal(snap)
token.InitialBoundSnapshot = string(snapBytes)
+76 -32
View File
@@ -7,6 +7,7 @@ import (
"time"
"affiliate_dash/internal/model"
"gorm.io/gorm"
)
type CheckBindingResult struct {
@@ -46,16 +47,27 @@ func (s *RockService) CheckBindingAndVerifyUID(ctx context.Context, tokenID uint
token.MismatchReason = ""
token.MismatchAt = nil
token.BoundAt = &now
_ = s.db.WithContext(ctx).Unscoped().Model(&token).Updates(map[string]interface{}{
"expected_uid": trimmedExpected,
"status": model.RockStatusBound,
"mismatch_reason": "",
"mismatch_at": nil,
"bound_at": now,
"updated_at": now,
}).Error
writeRockTokenStatusChange(s.db.WithContext(ctx), &token, model.RockStatusMismatch, model.RockStatusBound,
rockTokenSourceBindCheck, "买家确认正确 UID 后解除绑定错误", false, nil)
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Unscoped().Model(&token).Updates(map[string]interface{}{
"expected_uid": trimmedExpected,
"status": model.RockStatusBound,
"mismatch_reason": "",
"mismatch_at": nil,
"bound_at": now,
"updated_at": now,
}).Error; err != nil {
return err
}
writeRockTokenStatusChange(tx, &token, model.RockStatusMismatch, model.RockStatusBound,
rockTokenSourceBindCheck, "买家确认正确 UID 后解除绑定错误", false, nil)
return writeAudit(tx, nil, nil, nil, "rock_token.bind_confirm", "rock_token",
fmt.Sprintf("%d", token.ID), map[string]interface{}{
"expected_uid": trimmedExpected,
"actual_uid": actualUID,
})
}); err != nil {
return nil, fmt.Errorf("更新扫码绑定状态失败: %w", err)
}
return &CheckBindingResult{
Bound: true,
RoleName: token.ActualRoleName,
@@ -155,7 +167,7 @@ func (s *RockService) CheckBindingAndVerifyUID(ctx context.Context, tokenID uint
}
// 1. 检查上游是否已绑定 (上游状态 bound 或 bound_account_id 有值)
if !ref.IsBound() || strings.TrimSpace(ref.GetRoleID()) == "" {
if !ref.HasBindingSignal() || strings.TrimSpace(ref.GetRoleID()) == "" {
return &CheckBindingResult{
Bound: false,
Message: "等待买家微信/QQ扫码授权",
@@ -165,9 +177,10 @@ func (s *RockService) CheckBindingAndVerifyUID(ctx context.Context, tokenID uint
actualRoleName := ref.GetRoleName()
actualRoleID := ref.GetRoleID()
actualChannel := ref.GetChannel()
currentBound, bindChangedAt := rockCurrentCycleBinding(&token, ref)
// 2. 历史残留不算本次扫码:角色和绑定时间与分配快照一致,或发货记录都早于本次领取。
if !rockBuyerNewlyBound(&token, ref) {
// 2. 历史残留不算本次扫码:只认晚于本轮 using_at 的 bind_changed_at。
if !currentBound {
return &CheckBindingResult{
Bound: false,
Message: "等待买家扫码(检测到历史绑定残留,已自动忽略)",
@@ -185,16 +198,30 @@ func (s *RockService) CheckBindingAndVerifyUID(ctx context.Context, tokenID uint
// 严禁发货!跃迁为 mismatch
fromStatus := token.Status
mismatchReason := fmt.Sprintf("预期UID(%s) 与 扫码UID(%s/%s) 不一致", trimmedExpected, actualRoleName, actualRoleID)
_ = s.db.WithContext(ctx).Unscoped().Model(&token).Updates(map[string]interface{}{
"status": model.RockStatusMismatch,
"actual_role_name": actualRoleName,
"actual_role_id": actualRoleID,
"actual_channel": actualChannel,
"mismatch_at": now,
"mismatch_reason": mismatchReason,
}).Error
writeRockTokenStatusChange(s.db.WithContext(ctx), &token, fromStatus, model.RockStatusMismatch,
rockTokenSourceBindCheck, mismatchReason, false, nil)
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Unscoped().Model(&token).Updates(map[string]interface{}{
"status": model.RockStatusMismatch,
"actual_role_name": actualRoleName,
"actual_role_id": actualRoleID,
"actual_channel": actualChannel,
"mismatch_at": now,
"mismatch_reason": mismatchReason,
"bound_at": bindChangedAt,
"updated_at": now,
}).Error; err != nil {
return err
}
writeRockTokenStatusChange(tx, &token, fromStatus, model.RockStatusMismatch,
rockTokenSourceBindCheck, mismatchReason, false, nil)
return writeAudit(tx, nil, nil, nil, "rock_token.bind_mismatch", "rock_token",
fmt.Sprintf("%d", token.ID), map[string]interface{}{
"expected_uid": trimmedExpected,
"actual_uid": actualRoleID,
"reason": mismatchReason,
})
}); err != nil {
return nil, fmt.Errorf("记录绑定 UID 不一致失败: %w", err)
}
roleDesc := actualRoleName
if roleDesc == "" {
@@ -213,15 +240,32 @@ func (s *RockService) CheckBindingAndVerifyUID(ctx context.Context, tokenID uint
// 4. UID 一致,更新为 bound 成功状态并保存玩家角色名称
fromStatus := token.Status
_ = s.db.WithContext(ctx).Unscoped().Model(&token).Updates(map[string]interface{}{
"status": model.RockStatusBound,
"actual_role_name": actualRoleName,
"actual_role_id": actualRoleID,
"actual_channel": actualChannel,
"bound_at": now,
}).Error
writeRockTokenStatusChange(s.db.WithContext(ctx), &token, fromStatus, model.RockStatusBound,
rockTokenSourceBindCheck, fmt.Sprintf("扫码绑定成功 UID %s", actualRoleID), false, nil)
boundAt := bindChangedAt
if boundAt.IsZero() {
boundAt = now
}
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Unscoped().Model(&token).Updates(map[string]interface{}{
"status": model.RockStatusBound,
"actual_role_name": actualRoleName,
"actual_role_id": actualRoleID,
"actual_channel": actualChannel,
"bound_at": boundAt,
"updated_at": now,
}).Error; err != nil {
return err
}
writeRockTokenStatusChange(tx, &token, fromStatus, model.RockStatusBound,
rockTokenSourceBindCheck, fmt.Sprintf("扫码绑定成功 UID %s", actualRoleID), false, nil)
return writeAudit(tx, nil, nil, nil, "rock_token.bind_confirm", "rock_token",
fmt.Sprintf("%d", token.ID), map[string]interface{}{
"expected_uid": trimmedExpected,
"actual_uid": actualRoleID,
"bound_at": boundAt.Format(time.RFC3339),
})
}); err != nil {
return nil, fmt.Errorf("保存扫码绑定状态失败: %w", err)
}
return &CheckBindingResult{
Bound: true,
@@ -36,12 +36,12 @@ func canRefreshRockBinding(status string) bool {
// RefreshTokenBinding 通过兑换码调用上游 refresh,人工同步一次绑定状态。
//
// 规则(只同步上游实时状态,不再用本地预期 UID 拦截):
// 规则(只同步本轮真实事件,并与本地预期 UID 交叉校验):
// - 上游状态异常(abnormal)→ 本地同步 abnormal(已上报失效);
// - 上游明确返回「Token 不存在」(404) → 本地同步 abnormal(已上报失效);
// - 上游已发货 → 本地同步 redeemed;
// - 上游已绑定 → 本地直接同步 bound,并清空绑定错误标记;
// - 上游未绑定 → 本地状态保持不变(不自动把 bound 退回 using)。
// - 本轮 redeemed_at / deliveryList → 本地同步 redeemed;
// - 本轮 bind_changed_at → 本地同步 bound/mismatch;
// - 只有历史残留事件 → 不推进本轮状态,错误的 bound 修复回 using。
func (s *RockService) RefreshTokenBinding(ctx context.Context, tokenID uint) (*TokenBindingRefreshResult, error) {
return s.refreshTokenBinding(ctx, tokenID, nil)
}
@@ -119,13 +119,13 @@ func (s *RockService) refreshTokenBinding(ctx context.Context, tokenID uint, act
actualRoleName := ref.GetRoleName()
actualRoleID := ref.GetRoleID()
actualChannel := ref.GetChannel()
upstreamBound := rockUpstreamBound(ref)
currentBound, bindChangedAt := rockCurrentCycleBinding(&token, ref)
updates := map[string]interface{}{
"updated_at": now,
}
// 只有上游确实绑定时才同步角色字段;未绑定时 Region 可能只是领取区域,不能当成绑定渠道。
if upstreamBound {
if currentBound {
if actualRoleName != "" {
updates["actual_role_name"] = actualRoleName
}
@@ -139,37 +139,42 @@ func (s *RockService) refreshTokenBinding(ctx context.Context, tokenID uint, act
result := &TokenBindingRefreshResult{
Status: token.Status,
Bound: upstreamBound,
Bound: currentBound,
}
if upstreamBound {
if currentBound {
result.RoleName = actualRoleName
result.RoleID = actualRoleID
result.Channel = actualChannel
}
redeemedAtRaw := strings.TrimSpace(ref.Token.RedeemedAt)
deliveryEvidence := rockCurrentCycleDelivery(&token, ref)
baselineUnknown := rockClaimBaseline(&token).IsZero()
matchesInitialSnapshot := rockBindingMatchesInitialSnapshot(&token, ref)
switch {
case upstreamStatus == model.RockStatusRedeemed || redeemedAtRaw != "":
redeemedAt, ok := parseRockTime(redeemedAtRaw)
if !ok {
case deliveryEvidence.Ambiguous:
result.Warning = true
result.Message = "上游显示已发货,但无法确认是否属于本轮,禁止释放,请人工核对"
case deliveryEvidence.Current:
redeemedAt := deliveryEvidence.At
if redeemedAt.IsZero() {
redeemedAt = now
}
updates["status"] = model.RockStatusRedeemed
updates["redeemed_at"] = redeemedAt
if len(ref.DeliveryList) > 0 {
if fulfillID := ref.DeliveryList[len(ref.DeliveryList)-1].FulfillID; fulfillID > 0 {
updates["upstream_fulfill_id"] = fulfillID
}
if deliveryEvidence.FulfillID > 0 {
updates["upstream_fulfill_id"] = deliveryEvidence.FulfillID
}
result.Status = model.RockStatusRedeemed
result.RedeemedAt = redeemedAt.Format("2006-01-02 15:04:05")
result.Message = "上游已发货,本地状态已同步为已交付"
case upstreamBound:
// 上游已绑定即同步为 bound:不以本地预期 UID 做拦截,
// 否则历史 UID 不一致的 Token 永远无法把真实状态刷新回本地。
// 发货确认中(有 redeem 标记未完成)时只刷新标记,状态保持 bound。
case baselineUnknown && !matchesInitialSnapshot &&
(ref.HasBindingSignal() || strings.TrimSpace(rockBindingChangedAt(ref)) != ""):
result.Warning = true
result.Message = "缺少本轮 using_at,无法判断该绑定是否属于本轮,请人工核对"
case currentBound:
if rockTokenRedeemPending(&token) {
updates["redeem_checked_at"] = now
if msg := strings.TrimSpace(ref.Message); msg != "" {
@@ -179,12 +184,21 @@ func (s *RockService) refreshTokenBinding(ctx context.Context, tokenID uint, act
result.Message = "上游仍未确认发货,保持发货确认中"
break
}
updates["bound_at"] = bindChangedAt
expectedUID := strings.TrimSpace(token.ExpectedUID)
if expectedUID != "" && actualRoleID != "" && expectedUID != actualRoleID {
reason := fmt.Sprintf("预期UID(%s) 与 扫码UID(%s/%s) 不一致", expectedUID, actualRoleName, actualRoleID)
updates["status"] = model.RockStatusMismatch
updates["mismatch_at"] = now
updates["mismatch_reason"] = reason
result.Status = model.RockStatusMismatch
result.Warning = true
result.Message = reason
break
}
updates["status"] = model.RockStatusBound
updates["mismatch_at"] = nil
updates["mismatch_reason"] = ""
if token.BoundAt == nil {
updates["bound_at"] = resolveRockBoundAt(ref, now)
}
result.Status = model.RockStatusBound
if actualRoleID == "" {
result.Warning = true
@@ -201,18 +215,38 @@ func (s *RockService) refreshTokenBinding(ctx context.Context, tokenID uint, act
updates["redeem_checked_at"] = now
result.Status = model.RockStatusBound
result.Message = "上游仍未确认发货,保持发货确认中"
} else if token.Status == model.RockStatusBound {
updates["status"] = model.RockStatusUsing
updates["bound_at"] = nil
updates["actual_role_name"] = ""
updates["actual_role_id"] = ""
updates["actual_channel"] = ""
updates["mismatch_at"] = nil
updates["mismatch_reason"] = ""
result.Status = model.RockStatusUsing
result.Warning = true
result.Message = "未检测到本轮真实绑定事件,已修复为使用中"
} else {
result.Message = "上游仍未绑定,本地状态保持不变"
}
}
result.Changed = result.Status != originalStatus ||
(upstreamBound && actualRoleName != "" && actualRoleName != originalRoleName) ||
(upstreamBound && actualRoleID != "" && actualRoleID != originalRoleID) ||
(upstreamBound && actualChannel != "" && actualChannel != originalChannel)
(currentBound && actualRoleName != "" && actualRoleName != originalRoleName) ||
(currentBound && actualRoleID != "" && actualRoleID != originalRoleID) ||
(currentBound && actualChannel != "" && actualChannel != originalChannel)
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Model(&token).Updates(updates).Error; err != nil {
updateResult := tx.Model(&model.RockToken{}).
Where("id = ? AND updated_at = ?", token.ID, token.UpdatedAt).
Updates(updates)
if updateResult.Error != nil {
return updateResult.Error
}
if updateResult.RowsAffected == 0 {
return errors.New("Token 状态已被并发更新,请刷新后重试")
}
if err := tx.First(&token, token.ID).Error; err != nil {
return err
}
writeRockTokenStatusChange(tx, &token, originalStatus, result.Status,
@@ -308,12 +342,3 @@ func (s *RockService) markTokenAbnormalByRefresh(ctx context.Context, token *mod
Message: message,
}, nil
}
func resolveRockBoundAt(ref *RockRefreshResponse, fallback time.Time) time.Time {
if ref != nil {
if t, ok := parseRockTime(ref.Token.BindChangedAt); ok {
return t
}
}
return fallback
}
@@ -10,8 +10,13 @@ import (
"time"
"affiliate_dash/internal/model"
"affiliate_dash/internal/pkg/timeutil"
)
func recentRockBindTime(base time.Time) string {
return base.Add(time.Minute).In(timeutil.Location()).Format("2006-01-02 15:04:05")
}
func TestRefreshTokenBinding_BoundMatchesExpectedUID(t *testing.T) {
db := newRockTestDB(t)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -25,6 +30,7 @@ func TestRefreshTokenBinding_BoundMatchesExpectedUID(t *testing.T) {
"status": "bound",
"bound_account": "痛写心扉",
"bound_account_id": "3132710334",
"bind_changed_at": recentRockBindTime(time.Now()),
"region": "安卓-微信",
},
"binding": map[string]interface{}{
@@ -79,8 +85,8 @@ func TestRefreshTokenBinding_BoundMatchesExpectedUID(t *testing.T) {
}
}
// 刷新链路只同步上游实时状态:即使本地预期 UID 与扫码 UID 不一致,也应直接标记为已绑定。
func TestRefreshTokenBinding_IgnoresUIDMismatchAndMarksBound(t *testing.T) {
// 本轮真实绑定与预期 UID 不一致时必须进入 mismatch。
func TestRefreshTokenBinding_UIDMismatchMarksMismatch(t *testing.T) {
db := newRockTestDB(t)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
@@ -89,6 +95,7 @@ func TestRefreshTokenBinding_IgnoresUIDMismatchAndMarksBound(t *testing.T) {
"status": "bound",
"bound_account": "痛写心扉",
"bound_account_id": "3132710334",
"bind_changed_at": recentRockBindTime(time.Now()),
"region": "安卓-微信",
},
})
@@ -116,19 +123,19 @@ func TestRefreshTokenBinding_IgnoresUIDMismatchAndMarksBound(t *testing.T) {
if err != nil {
t.Fatalf("刷新绑定状态失败: %v", err)
}
if res.Status != model.RockStatusBound || !res.Bound {
t.Fatalf("UID 不一致时也应同步为 bound,实际 %+v", res)
if res.Status != model.RockStatusMismatch || !res.Bound {
t.Fatalf("UID 不一致时应同步为 mismatch,实际 %+v", res)
}
var saved model.RockToken
if err := db.First(&saved, token.ID).Error; err != nil {
t.Fatalf("读取 Token 失败: %v", err)
}
if saved.Status != model.RockStatusBound || saved.ActualRoleID != "3132710334" {
t.Fatalf("应同步为 bound 并写入实际 UID,实际 %+v", saved)
if saved.Status != model.RockStatusMismatch || saved.ActualRoleID != "3132710334" {
t.Fatalf("应同步为 mismatch 并写入实际 UID,实际 %+v", saved)
}
if saved.MismatchAt != nil || saved.MismatchReason != "" {
t.Fatalf("同步为 bound 后应清空绑定错误标记,实际 %+v", saved)
if saved.MismatchAt == nil || saved.MismatchReason == "" {
t.Fatalf("UID 不一致必须写入绑定错误标记,实际 %+v", saved)
}
if saved.BoundAt == nil {
t.Fatalf("bound_at 未写入")
@@ -141,6 +148,9 @@ func TestRefreshTokenBinding_EmptyExpectedUIDMarksBound(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"token": map[string]interface{}{
"bind_changed_at": recentRockBindTime(time.Now()),
},
"binding": map[string]interface{}{
"bound": true,
"role": "痛写心扉",
@@ -154,11 +164,13 @@ func TestRefreshTokenBinding_EmptyExpectedUIDMarksBound(t *testing.T) {
svc := NewRockService(db, NewRockClient(server.URL))
setRockTestBearer(t, svc, "bearer-test")
now := time.Now()
token := model.RockToken{
UpstreamTokenID: "9003",
RedeemCode: "CODE-NOUID",
Color: model.RockColorBlack,
Status: model.RockStatusUsing,
ClaimedAt: &now,
}
if err := db.Create(&token).Error; err != nil {
t.Fatalf("创建 Token 失败: %v", err)
@@ -186,6 +198,7 @@ func TestRefreshTokenBinding_EmptyExpectedUIDMarksBound(t *testing.T) {
func TestRefreshTokenBinding_SyncsRedeemed(t *testing.T) {
db := newRockTestDB(t)
now := time.Now()
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]interface{}{
@@ -193,10 +206,10 @@ func TestRefreshTokenBinding_SyncsRedeemed(t *testing.T) {
"status": "redeemed",
"bound_account": "痛写心扉",
"bound_account_id": "3132710334",
"redeemed_at": "2026-10-29 12:00:00",
"redeemed_at": now.Add(-20 * time.Second).In(timeutil.Location()).Format("2006-01-02 15:04:05"),
},
"deliveryList": []map[string]interface{}{
{"fulfillId": 4519960, "time": "2026-10-29T12:00:00.1", "role": "安卓-微信-痛写心扉"},
{"fulfillId": 4519960, "time": now.Add(-10 * time.Second).Format("2006-01-02T15:04:05.1"), "role": "安卓-微信-痛写心扉"},
},
})
}))
@@ -212,6 +225,7 @@ func TestRefreshTokenBinding_SyncsRedeemed(t *testing.T) {
Status: model.RockStatusUsing,
AssignedOrderNo: "FO-REFRESH-4",
ExpectedUID: "3132710334",
UsingAt: &now,
}
if err := db.Create(&token).Error; err != nil {
t.Fatalf("创建 Token 失败: %v", err)
@@ -234,6 +248,140 @@ func TestRefreshTokenBinding_SyncsRedeemed(t *testing.T) {
}
}
func TestRefreshTokenBinding_IgnoresHistoricalRedeemedAt(t *testing.T) {
db := newRockTestDB(t)
now := time.Now()
historical := now.Add(-48 * time.Hour)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"token": map[string]interface{}{
"status": "redeemed",
"redeemed_at": historical.In(timeutil.Location()).Format("2006-01-02 15:04:05"),
},
"deliveryList": []map[string]interface{}{
{"fulfillId": 100, "time": historical.Format("2006-01-02T15:04:05.1")},
},
})
}))
defer server.Close()
svc := NewRockService(db, NewRockClient(server.URL))
setRockTestBearer(t, svc, "bearer-test")
token := model.RockToken{
UpstreamTokenID: "9005",
RedeemCode: "CODE-HISTORICAL-REDEEMED",
Color: model.RockColorBlack,
Status: model.RockStatusUsing,
AssignedOrderNo: "FO-REFRESH-5",
UsingAt: &now,
}
if err := db.Create(&token).Error; err != nil {
t.Fatalf("创建 Token 失败: %v", err)
}
res, err := svc.RefreshTokenBinding(context.Background(), token.ID)
if err != nil {
t.Fatalf("刷新绑定状态失败: %v", err)
}
if res.Status != model.RockStatusUsing || res.Bound {
t.Fatalf("历史兑换记录不能推进本轮状态,实际 %+v", res)
}
var saved model.RockToken
if err := db.First(&saved, token.ID).Error; err != nil {
t.Fatalf("读取 Token 失败: %v", err)
}
if saved.Status != model.RockStatusUsing || saved.RedeemedAt != nil {
t.Fatalf("Token 不应被历史兑换记录改成已发货: %+v", saved)
}
}
func TestRefreshTokenBinding_SnapshotEventAcceptsDifferentTimeFormat(t *testing.T) {
db := newRockTestDB(t)
now := time.Now()
boundAt := now.Add(time.Minute)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"token": map[string]interface{}{
"status": "bound",
"bound_account": "上一任角色",
"bound_account_id": "99001",
"bind_changed_at": boundAt.Format(time.RFC3339Nano),
},
})
}))
defer server.Close()
svc := NewRockService(db, NewRockClient(server.URL))
setRockTestBearer(t, svc, "bearer-test")
token := model.RockToken{
UpstreamTokenID: "9006",
RedeemCode: "CODE-SNAPSHOT-FORMAT",
Color: model.RockColorBlack,
Status: model.RockStatusUsing,
AssignedOrderNo: "FO-REFRESH-6",
UsingAt: &now,
InitialBoundSnapshot: `{"bound":true,"role_id":"99001","bind_changed_at":"` + boundAt.In(timeutil.Location()).Format("2006-01-02 15:04:05") + `"}`,
}
if err := db.Create(&token).Error; err != nil {
t.Fatalf("创建 Token 失败: %v", err)
}
res, err := svc.RefreshTokenBinding(context.Background(), token.ID)
if err != nil {
t.Fatalf("刷新绑定状态失败: %v", err)
}
if res.Status != model.RockStatusUsing || res.Bound {
t.Fatalf("与初始快照相同的绑定事件必须按残留处理,实际 %+v", res)
}
}
func TestRefreshTokenBinding_MissingBaselineDoesNotDowngradeBound(t *testing.T) {
db := newRockTestDB(t)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"token": map[string]interface{}{
"status": "bound",
"bound_account": "历史角色",
"bound_account_id": "88001",
"bind_changed_at": time.Now().In(timeutil.Location()).Add(-time.Hour).Format("2006-01-02 15:04:05"),
},
})
}))
defer server.Close()
svc := NewRockService(db, NewRockClient(server.URL))
setRockTestBearer(t, svc, "bearer-test")
token := model.RockToken{
UpstreamTokenID: "9007",
RedeemCode: "CODE-MISSING-BASELINE",
Color: model.RockColorBlack,
Status: model.RockStatusBound,
AssignedOrderNo: "FO-REFRESH-7",
ExpectedUID: "88001",
}
if err := db.Create(&token).Error; err != nil {
t.Fatalf("创建 Token 失败: %v", err)
}
res, err := svc.RefreshTokenBinding(context.Background(), token.ID)
if err != nil {
t.Fatalf("刷新绑定状态失败: %v", err)
}
if res.Status != model.RockStatusBound || !res.Warning {
t.Fatalf("缺少时间基线时只能告警,不能自动降级,实际 %+v", res)
}
var saved model.RockToken
if err := db.First(&saved, token.ID).Error; err != nil {
t.Fatalf("读取 Token 失败: %v", err)
}
if saved.Status != model.RockStatusBound {
t.Fatalf("缺少时间基线时不得自动改写 Token 状态,实际 %s", saved.Status)
}
}
func TestRefreshTokenBinding_UpstreamErrorKeepsState(t *testing.T) {
db := newRockTestDB(t)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -281,6 +429,7 @@ func TestRefreshTokenBinding_FallsBackToAdminRefresh(t *testing.T) {
"status": "bound",
"bound_account": "痛写心扉",
"bound_account_id": "3132710334",
"bind_changed_at": recentRockBindTime(time.Now()),
},
})
default:
+9 -9
View File
@@ -153,13 +153,6 @@ type RockTokenData struct {
RedeemedAt string `json:"redeemed_at"`
}
func (t *RockTokenData) IsBound() bool {
if t.Status == "bound" || t.Status == "redeemed" {
return true
}
return strings.TrimSpace(t.BoundAccountID) != ""
}
func (t *RockTokenData) GetRoleName() string {
return strings.TrimSpace(t.BoundAccount)
}
@@ -214,11 +207,18 @@ type RockRefreshResponse struct {
Error string `json:"error,omitempty"`
}
func (r *RockRefreshResponse) IsBound() bool {
// HasBindingSignal 表示上游存在任何绑定痕迹,不代表该绑定属于本轮。
// 本轮判定必须使用 rockCurrentCycleBinding。
func (r *RockRefreshResponse) HasBindingSignal() bool {
if r == nil {
return false
}
if r.Token.IsBound() {
switch strings.ToLower(strings.TrimSpace(r.Token.Status)) {
case "bound", "redeemed":
return true
}
if strings.TrimSpace(r.Token.BoundAccountID) != "" ||
strings.TrimSpace(r.Token.BindChangedAt) != "" {
return true
}
return r.Binding.Bound || strings.TrimSpace(r.Binding.RoleID) != ""
+464
View File
@@ -0,0 +1,464 @@
package service
import (
"context"
"errors"
"fmt"
"strings"
"time"
"affiliate_dash/internal/model"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
type RockIncidentListResult struct {
List []model.RockDeliveryIncident `json:"list"`
Total int64 `json:"total"`
}
type RockIncidentRetryPlan struct {
Incident model.RockDeliveryIncident
Token model.RockToken
Order model.FulfillmentOrder
}
func rockIncidentRetryable(category string) bool {
switch category {
case model.RockIncidentCategoryUpstreamTimeout, model.RockIncidentCategoryRateLimited:
return true
default:
return false
}
}
func classifyRockDeliveryError(err error) (category string, httpStatus int, response string) {
if err == nil {
return model.RockIncidentCategoryOther, 0, ""
}
var statusErr *RockUpstreamStatusError
if errors.As(err, &statusErr) {
httpStatus = statusErr.StatusCode
response = statusErr.Body
}
body := strings.ToLower(strings.TrimSpace(response + " " + err.Error()))
compactBody := strings.NewReplacer(" ", "", "\n", "", "\r", "", "\t", "").Replace(body)
switch {
case strings.Contains(body, "用户不存在可以兑换的可用权益"):
return model.RockIncidentCategoryNoEntitlement, httpStatus, response
case strings.Contains(body, "同一角色无法重复兑换已兑换过的道具"):
return model.RockIncidentCategoryDuplicateItem, httpStatus, response
case strings.Contains(body, "礼包已发放完毕"):
return model.RockIncidentCategoryGiftExhausted, httpStatus, response
case strings.Contains(compactBody, "该token已兑换"):
return model.RockIncidentCategoryTokenRedeemed, httpStatus, response
case strings.Contains(compactBody, "该token暂不可兑换"):
return model.RockIncidentCategoryTokenNotRedeemable, httpStatus, response
case strings.Contains(compactBody, "token不存在"):
return model.RockIncidentCategoryTokenNotFound, httpStatus, response
case strings.Contains(body, "请求过于频繁") || strings.Contains(body, "110001"):
return model.RockIncidentCategoryRateLimited, httpStatus, response
case strings.Contains(body, "context deadline exceeded") || strings.Contains(body, "timeout"):
return model.RockIncidentCategoryUpstreamTimeout, httpStatus, response
case strings.Contains(body, "发货超时"):
return model.RockIncidentCategoryShipTimeout, httpStatus, response
case strings.Contains(body, "uid") || strings.Contains(body, "玩家编号") || strings.Contains(body, "绑定账号"):
return model.RockIncidentCategoryBindMismatch, httpStatus, response
default:
return model.RockIncidentCategoryOther, httpStatus, response
}
}
// RecordRockDeliveryIncident 持久化一次需要人工处理的发货异常,并同步 Token 错误摘要。
func (s *RockService) RecordRockDeliveryIncident(ctx context.Context, token *model.RockToken, orderNo string, cause error) (*model.RockDeliveryIncident, error) {
if token == nil || token.ID == 0 {
return nil, errors.New("Token 不存在")
}
category, httpStatus, response := classifyRockDeliveryError(cause)
message := ""
if cause != nil {
message = strings.TrimSpace(cause.Error())
}
if message == "" {
message = "上游发货失败"
}
return s.RecordRockIncident(ctx, token, orderNo, category, message, httpStatus, response)
}
func (s *RockService) RecordRockIncident(
ctx context.Context,
token *model.RockToken,
orderNo, category, message string,
httpStatus int,
upstreamResponse string,
) (*model.RockDeliveryIncident, error) {
if token == nil || token.ID == 0 {
return nil, errors.New("Token 不存在")
}
category = strings.TrimSpace(category)
if category == "" {
category = model.RockIncidentCategoryOther
}
orderNo = strings.TrimSpace(orderNo)
message = strings.TrimSpace(message)
upstreamResponse = strings.TrimSpace(upstreamResponse)
now := time.Now()
var incident model.RockDeliveryIncident
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var current model.RockToken
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&current, token.ID).Error; err != nil {
return err
}
err := tx.Where(
"token_id = ? AND order_no = ? AND category = ? AND status = ?",
token.ID, orderNo, category, model.RockIncidentStatusOpen,
).First(&incident).Error
switch {
case err == nil:
if err := tx.Model(&incident).Updates(map[string]interface{}{
"occurrences": incident.Occurrences + 1,
"blocking_retry": true,
"message": message,
"upstream_http_status": httpStatus,
"upstream_response": upstreamResponse,
"last_seen_at": now,
"updated_at": now,
}).Error; err != nil {
return err
}
incident.Occurrences++
incident.BlockingRetry = true
incident.Message = message
incident.UpstreamHTTPStatus = httpStatus
incident.UpstreamResponse = upstreamResponse
incident.LastSeenAt = now
case errors.Is(err, gorm.ErrRecordNotFound):
incident = model.RockDeliveryIncident{
TokenID: current.ID,
OrderNo: orderNo,
UpstreamTokenID: strings.TrimSpace(current.UpstreamTokenID),
Category: category,
Status: model.RockIncidentStatusOpen,
BlockingRetry: true,
Message: message,
UpstreamHTTPStatus: httpStatus,
UpstreamResponse: upstreamResponse,
Occurrences: 1,
FirstSeenAt: now,
LastSeenAt: now,
}
if err := tx.Create(&incident).Error; err != nil {
return err
}
default:
return err
}
if err := tx.Model(&current).Updates(map[string]interface{}{
"redeem_last_error": message,
"remark": appendRockRemark(current.Remark, message),
"updated_at": now,
}).Error; err != nil {
return err
}
writeRockTokenStatusLog(tx, &current, rockTokenEventIncident, current.Status, current.Status,
rockTokenSourceIncident, fmt.Sprintf("%s: %s", category, message), false, nil)
return writeAudit(tx, nil, nil, nil, "rock_delivery_incident.open", "rock_token",
fmt.Sprintf("%d", current.ID), map[string]interface{}{
"incident_id": incident.ID,
"order_no": orderNo,
"category": category,
"http_status": httpStatus,
})
})
if err != nil {
return nil, err
}
return &incident, nil
}
func (s *RockService) HasBlockingRockIncident(ctx context.Context, orderNo string) (*model.RockDeliveryIncident, error) {
var incident model.RockDeliveryIncident
err := s.db.WithContext(ctx).
Where("order_no = ? AND status = ? AND blocking_retry = TRUE", strings.TrimSpace(orderNo), model.RockIncidentStatusOpen).
Order("last_seen_at DESC").
First(&incident).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
if err != nil {
return nil, err
}
return &incident, nil
}
func (s *RockService) ListRockTokenIncidents(ctx context.Context, tokenID uint) ([]model.RockDeliveryIncident, error) {
var list []model.RockDeliveryIncident
err := s.db.WithContext(ctx).
Where("token_id = ?", tokenID).
Order("id DESC").
Limit(200).
Find(&list).Error
return list, err
}
func (s *RockService) ListRockDeliveryIncidents(ctx context.Context, status, category string, page, pageSize int) (*RockIncidentListResult, error) {
if page <= 0 {
page = 1
}
if pageSize <= 0 || pageSize > 100 {
pageSize = 20
}
db := s.db.WithContext(ctx).Model(&model.RockDeliveryIncident{})
if v := strings.TrimSpace(status); v != "" {
db = db.Where("status = ?", v)
}
if v := strings.TrimSpace(category); v != "" {
db = db.Where("category = ?", v)
}
var total int64
if err := db.Count(&total).Error; err != nil {
return nil, err
}
var list []model.RockDeliveryIncident
err := db.Order("last_seen_at DESC, id DESC").
Offset((page - 1) * pageSize).
Limit(pageSize).
Find(&list).Error
if err != nil {
return nil, err
}
return &RockIncidentListResult{List: list, Total: total}, nil
}
// ResolveRockDeliveryIncident 只关闭异常事件,不改变 Token 或订单主状态。
func (s *RockService) ResolveRockDeliveryIncident(ctx context.Context, id uint, note string, actorUserID *uint) error {
if id == 0 {
return errors.New("异常记录不存在")
}
now := time.Now()
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var incident model.RockDeliveryIncident
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
Where("id = ? AND status = ?", id, model.RockIncidentStatusOpen).
First(&incident).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return errors.New("异常记录不存在或已处理")
}
return err
}
if err := tx.Model(&incident).Updates(map[string]interface{}{
"status": model.RockIncidentStatusResolved,
"blocking_retry": false,
"resolved_at": now,
"resolved_by": actorUserID,
"resolution_action": "resolve",
"resolution_note": strings.TrimSpace(note),
"updated_at": now,
}).Error; err != nil {
return err
}
return writeAudit(tx, nil, actorUserID, nil, "rock_delivery_incident.resolve", "rock_delivery_incident",
fmt.Sprintf("%d", id), map[string]interface{}{
"token_id": incident.TokenID,
"order_no": incident.OrderNo,
"category": incident.Category,
"note": strings.TrimSpace(note),
})
})
}
// ApproveRockIncidentRetry 只对超时/频控异常临时解除重试阻塞。
// 实际发货结束后必须调用 Complete 或重新记录异常,恢复最终状态。
func (s *RockService) ApproveRockIncidentRetry(
ctx context.Context,
id uint,
note string,
actorUserID *uint,
) (*RockIncidentRetryPlan, error) {
note = strings.TrimSpace(note)
if note == "" {
return nil, errors.New("请填写人工重试说明")
}
now := time.Now()
var plan RockIncidentRetryPlan
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
Where("id = ? AND status = ?", id, model.RockIncidentStatusOpen).
First(&plan.Incident).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return errors.New("异常记录不存在或已处理")
}
return err
}
if !rockIncidentRetryable(plan.Incident.Category) {
return fmt.Errorf("%s 类型异常不允许直接重试,请先核对上游状态", plan.Incident.Category)
}
if err := tx.First(&plan.Token, plan.Incident.TokenID).Error; err != nil {
return errors.New("异常关联的 Token 不存在")
}
if plan.Token.Status != model.RockStatusBound ||
plan.Token.AssignedOrderNo != plan.Incident.OrderNo ||
plan.Token.RedeemedAt != nil ||
rockTokenRedeemPending(&plan.Token) {
return errors.New("Token 当前状态不允许人工重试,请刷新后核对")
}
if strings.TrimSpace(plan.Token.ExpectedUID) == "" {
return errors.New("Token 缺少预期 UID,不能人工重试")
}
if err := tx.Where("order_no = ?", plan.Incident.OrderNo).First(&plan.Order).Error; err != nil {
return errors.New("异常关联的订单不存在")
}
if plan.Order.OrderStatus != model.OrderStatusShipFailed {
return fmt.Errorf("订单当前状态为 %s,不允许人工重试", plan.Order.OrderStatus)
}
var blockingNonRetryable model.RockDeliveryIncident
if err := tx.Where(
"token_id = ? AND order_no = ? AND status = ? AND category NOT IN ?",
plan.Token.ID,
plan.Incident.OrderNo,
model.RockIncidentStatusOpen,
[]string{model.RockIncidentCategoryUpstreamTimeout, model.RockIncidentCategoryRateLimited},
).First(&blockingNonRetryable).Error; err == nil {
return fmt.Errorf("该订单同时存在不可重试异常(%s),请先人工核对", blockingNonRetryable.Category)
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
if err := tx.Model(&model.RockDeliveryIncident{}).
Where("token_id = ? AND order_no = ? AND status = ?",
plan.Token.ID, plan.Incident.OrderNo, model.RockIncidentStatusOpen).
Updates(map[string]interface{}{
"blocking_retry": false,
"updated_at": now,
}).Error; err != nil {
return err
}
return writeAudit(tx, nil, actorUserID, nil, "rock_delivery_incident.approve_retry",
"rock_delivery_incident", fmt.Sprintf("%d", plan.Incident.ID), map[string]interface{}{
"token_id": plan.Token.ID,
"order_no": plan.Incident.OrderNo,
"category": plan.Incident.Category,
"note": note,
})
})
if err != nil {
return nil, err
}
return &plan, nil
}
// CompleteRockIncidentRetry 在上游已受理或已确认发货后,关闭同一订单的开放异常。
func (s *RockService) CompleteRockIncidentRetry(
ctx context.Context,
tokenID uint,
orderNo, note string,
actorUserID *uint,
) error {
now := time.Now()
note = strings.TrimSpace(note)
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
result := tx.Model(&model.RockDeliveryIncident{}).
Where("token_id = ? AND order_no = ? AND status = ?",
tokenID, strings.TrimSpace(orderNo), model.RockIncidentStatusOpen).
Updates(map[string]interface{}{
"status": model.RockIncidentStatusResolved,
"blocking_retry": false,
"resolved_at": now,
"resolved_by": actorUserID,
"resolution_action": "approve_retry",
"resolution_note": note,
"updated_at": now,
})
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return errors.New("没有可关闭的开放异常记录")
}
return writeAudit(tx, nil, actorUserID, nil, "rock_delivery_incident.retry_complete",
"rock_token", fmt.Sprintf("%d", tokenID), map[string]interface{}{
"order_no": orderNo,
"note": note,
})
})
}
func resolveOpenRockIncidents(tx *gorm.DB, tokenID uint, action, note string, actorUserID *uint) error {
now := time.Now()
return tx.Model(&model.RockDeliveryIncident{}).
Where("token_id = ? AND status = ?", tokenID, model.RockIncidentStatusOpen).
Updates(map[string]interface{}{
"status": model.RockIncidentStatusResolved,
"blocking_retry": false,
"resolved_at": now,
"resolved_by": actorUserID,
"resolution_action": action,
"resolution_note": strings.TrimSpace(note),
"updated_at": now,
}).Error
}
func rockTokenIncidentFilter(db *gorm.DB, category string) *gorm.DB {
if category == model.RockIncidentCategoryNoEntitlement {
return db.Where(
"EXISTS (SELECT 1 FROM rock_delivery_incidents i WHERE i.token_id = rock_tokens.id AND i.status = 'open' AND i.category = ?)",
model.RockIncidentCategoryNoEntitlement,
)
}
return db.Where(
"EXISTS (SELECT 1 FROM rock_delivery_incidents i WHERE i.token_id = rock_tokens.id AND i.status = 'open' AND i.category <> ?)",
model.RockIncidentCategoryNoEntitlement,
)
}
func (s *RockService) fillRockTokenIncidentFlags(ctx context.Context, tokens []model.RockToken) {
if len(tokens) == 0 {
return
}
ids := make([]uint, 0, len(tokens))
for i := range tokens {
if tokens[i].ID != 0 {
ids = append(ids, tokens[i].ID)
}
}
if len(ids) == 0 {
return
}
type row struct {
TokenID uint
Category string
Message string
LastSeenAt time.Time
IncidentCount int64
}
var rows []row
err := s.db.WithContext(ctx).Raw(`
SELECT DISTINCT ON (token_id)
token_id, category, message, last_seen_at,
COUNT(*) OVER (PARTITION BY token_id) AS incident_count
FROM rock_delivery_incidents
WHERE token_id IN ? AND status = 'open'
ORDER BY token_id, (category = 'no_entitlement') DESC, last_seen_at DESC, id DESC
`, ids).Scan(&rows).Error
if err != nil {
return
}
byID := make(map[uint]row, len(rows))
for _, item := range rows {
byID[item.TokenID] = item
}
for i := range tokens {
item, ok := byID[tokens[i].ID]
if !ok {
continue
}
at := item.LastSeenAt
tokens[i].HasOpenIncident = true
tokens[i].IncidentCategory = item.Category
tokens[i].IncidentMessage = item.Message
tokens[i].IncidentAt = &at
tokens[i].IncidentCount = item.IncidentCount
}
}
@@ -0,0 +1,523 @@
package service
import (
"context"
"errors"
"strings"
"testing"
"time"
"affiliate_dash/internal/model"
)
func TestClassifyRockDeliveryError_TokenMessageWithoutSpaces(t *testing.T) {
category, _, _ := classifyRockDeliveryError(errors.New("上游返回:该Token已兑换"))
if category != model.RockIncidentCategoryTokenRedeemed {
t.Fatalf("应识别无空格 Token 已兑换错误,实际 %s", category)
}
if !strings.Contains(category, "token") {
t.Fatalf("分类内部值应保持稳定,实际 %s", category)
}
}
func TestRockDeliveryIncident_NoEntitlementLifecycle(t *testing.T) {
db := newRockTestDB(t)
svc := NewRockService(db, NewRockClient("https://example.com"))
ctx := context.Background()
token := model.RockToken{
UpstreamTokenID: "incident-1",
Token: "jwt-incident-1",
RedeemCode: "CODE-INCIDENT-1",
Status: model.RockStatusBound,
AssignedOrderNo: "ORDER-INCIDENT-1",
}
if err := db.Create(&token).Error; err != nil {
t.Fatalf("创建 Token 失败: %v", err)
}
cause := &RockUpstreamStatusError{
StatusCode: 400,
Body: `{"error":"用户不存在可以兑换的可用权益"}`,
}
incident, err := svc.RecordRockDeliveryIncident(ctx, &token, token.AssignedOrderNo, cause)
if err != nil {
t.Fatalf("记录异常失败: %v", err)
}
if incident.Category != model.RockIncidentCategoryNoEntitlement || incident.Occurrences != 1 {
t.Fatalf("异常分类或次数错误: %+v", incident)
}
if _, err := svc.RecordRockDeliveryIncident(ctx, &token, token.AssignedOrderNo, cause); err != nil {
t.Fatalf("重复记录异常失败: %v", err)
}
var stored model.RockToken
if err := db.First(&stored, token.ID).Error; err != nil {
t.Fatalf("读取 Token 失败: %v", err)
}
if stored.RedeemLastError == "" {
t.Fatal("Token 应记录最近一次发货错误")
}
blocking, err := svc.HasBlockingRockIncident(ctx, token.AssignedOrderNo)
if err != nil || blocking == nil || blocking.Category != model.RockIncidentCategoryNoEntitlement {
t.Fatalf("应查询到阻塞重试的异常: incident=%+v err=%v", blocking, err)
}
var open model.RockDeliveryIncident
if err := db.Where("token_id = ? AND status = ?", token.ID, model.RockIncidentStatusOpen).
First(&open).Error; err != nil {
t.Fatalf("读取开放异常失败: %v", err)
}
if open.Occurrences != 2 {
t.Fatalf("重复错误应累加次数,实际 %d", open.Occurrences)
}
list, total, err := svc.ListTokens(ctx, 1, 20, "no_entitlement", "")
if err != nil {
t.Fatalf("筛选无权益失败: %v", err)
}
if total != 1 || len(list) != 1 || !list[0].HasOpenIncident {
t.Fatalf("无权益筛选结果错误: total=%d list=%+v", total, list)
}
anomalyList, anomalyTotal, err := svc.ListTokens(ctx, 1, 20, model.RockStatusAnomaly, "")
if err != nil {
t.Fatalf("筛选异常失败: %v", err)
}
if anomalyTotal != 0 || len(anomalyList) != 0 {
t.Fatalf("无权益不能重复归入异常: total=%d list=%+v", anomalyTotal, anomalyList)
}
stats, err := svc.GetStats(ctx)
if err != nil {
t.Fatalf("读取统计失败: %v", err)
}
if stats.NoEntitlementStock != 1 {
t.Fatalf("无权益统计错误: %d", stats.NoEntitlementStock)
}
if stats.AnomalyStock != 0 || stats.UnresolvedAnomalyStock != 0 {
t.Fatalf("无权益不应计入异常统计: anomaly=%d unresolved=%d",
stats.AnomalyStock, stats.UnresolvedAnomalyStock)
}
actor := uint(9)
if err := svc.ResolveRockDeliveryIncident(ctx, open.ID, "已人工核对", &actor); err != nil {
t.Fatalf("处理异常失败: %v", err)
}
blocking, err = svc.HasBlockingRockIncident(ctx, token.AssignedOrderNo)
if err != nil {
t.Fatalf("处理后查询异常失败: %v", err)
}
if blocking != nil {
t.Fatalf("处理后不应继续阻塞重试: %+v", blocking)
}
}
func TestRockDeliveryIncident_OtherErrorsUseAnomalyFilter(t *testing.T) {
db := newRockTestDB(t)
svc := NewRockService(db, NewRockClient("https://example.com"))
ctx := context.Background()
token := model.RockToken{
UpstreamTokenID: "incident-2",
Token: "jwt-incident-2",
RedeemCode: "CODE-INCIDENT-2",
Status: model.RockStatusBound,
AssignedOrderNo: "ORDER-INCIDENT-2",
}
if err := db.Create(&token).Error; err != nil {
t.Fatalf("创建 Token 失败: %v", err)
}
if _, err := svc.RecordRockDeliveryIncident(ctx, &token, token.AssignedOrderNo,
errors.New("上游兑换发货接口返回错误码 400: 同一角色无法重复兑换已兑换过的道具")); err != nil {
t.Fatalf("记录异常失败: %v", err)
}
noEntitlement, totalNoEntitlement, err := svc.ListTokens(ctx, 1, 20, "no_entitlement", "")
if err != nil {
t.Fatalf("筛选无权益失败: %v", err)
}
if totalNoEntitlement != 0 || len(noEntitlement) != 0 {
t.Fatalf("其他错误不应进入无权益: %+v", noEntitlement)
}
anomaly, totalAnomaly, err := svc.ListTokens(ctx, 1, 20, model.RockStatusAnomaly, "")
if err != nil {
t.Fatalf("筛选异常失败: %v", err)
}
if totalAnomaly != 1 || len(anomaly) != 1 || anomaly[0].IncidentCategory != model.RockIncidentCategoryDuplicateItem {
t.Fatalf("其他错误应进入异常: total=%d list=%+v", totalAnomaly, anomaly)
}
}
func TestRockIncidentRetry_RequiresRetryableCategoryAndResolvesAfterApproval(t *testing.T) {
db := newRockTestDB(t)
svc := NewRockService(db, NewRockClient("https://example.com"))
ctx := context.Background()
token := model.RockToken{
UpstreamTokenID: "incident-retry-1",
Token: "jwt-incident-retry-1",
RedeemCode: "CODE-INCIDENT-RETRY-1",
Status: model.RockStatusBound,
AssignedOrderNo: "ORDER-INCIDENT-RETRY-1",
ExpectedUID: "5550001",
ActualRoleID: "5550001",
ActualRoleName: "测试角色",
}
order := model.FulfillmentOrder{
OrderNo: token.AssignedOrderNo,
MerchantID: 1,
OrderStatus: model.OrderStatusShipFailed,
ProductSKU: model.RockSKUBlackTshirt,
}
if err := db.Create(&token).Error; err != nil {
t.Fatalf("创建 Token 失败: %v", err)
}
if err := db.Create(&order).Error; err != nil {
t.Fatalf("创建订单失败: %v", err)
}
incident, err := svc.RecordRockIncident(ctx, &token, order.OrderNo,
model.RockIncidentCategoryUpstreamTimeout, "上游超时", 504, "")
if err != nil {
t.Fatalf("记录异常失败: %v", err)
}
actor := uint(9)
plan, err := svc.ApproveRockIncidentRetry(ctx, incident.ID, "已核对上游,允许一次重试", &actor)
if err != nil {
t.Fatalf("批准人工重试失败: %v", err)
}
if plan.Token.ID != token.ID || plan.Order.OrderNo != order.OrderNo {
t.Fatalf("重试计划关联错误: %+v", plan)
}
var unblocked model.RockDeliveryIncident
if err := db.First(&unblocked, incident.ID).Error; err != nil {
t.Fatalf("读取异常失败: %v", err)
}
if unblocked.Status != model.RockIncidentStatusOpen || unblocked.BlockingRetry {
t.Fatalf("批准重试后应暂时解除阻塞: %+v", unblocked)
}
if err := svc.CompleteRockIncidentRetry(ctx, token.ID, order.OrderNo, "重试成功", &actor); err != nil {
t.Fatalf("完成人工重试失败: %v", err)
}
var resolved model.RockDeliveryIncident
if err := db.First(&resolved, incident.ID).Error; err != nil {
t.Fatalf("读取已处理异常失败: %v", err)
}
if resolved.Status != model.RockIncidentStatusResolved ||
resolved.ResolutionAction != "approve_retry" ||
resolved.ResolvedBy == nil || *resolved.ResolvedBy != actor {
t.Fatalf("异常关闭结果错误: %+v", resolved)
}
}
func TestRockIncidentRetry_RejectsNoEntitlement(t *testing.T) {
db := newRockTestDB(t)
svc := NewRockService(db, NewRockClient("https://example.com"))
ctx := context.Background()
token := model.RockToken{
UpstreamTokenID: "incident-retry-2",
Token: "jwt-incident-retry-2",
Status: model.RockStatusBound,
AssignedOrderNo: "ORDER-INCIDENT-RETRY-2",
ExpectedUID: "5550002",
}
order := model.FulfillmentOrder{
OrderNo: token.AssignedOrderNo,
MerchantID: 1,
OrderStatus: model.OrderStatusShipFailed,
ProductSKU: model.RockSKUBlackTshirt,
}
if err := db.Create(&token).Error; err != nil {
t.Fatalf("创建 Token 失败: %v", err)
}
if err := db.Create(&order).Error; err != nil {
t.Fatalf("创建订单失败: %v", err)
}
incident, err := svc.RecordRockIncident(ctx, &token, order.OrderNo,
model.RockIncidentCategoryNoEntitlement, "用户不存在可以兑换的可用权益", 400, "")
if err != nil {
t.Fatalf("记录异常失败: %v", err)
}
if _, err := svc.ApproveRockIncidentRetry(ctx, incident.ID, "尝试重试", nil); err == nil {
t.Fatal("无权益异常不允许直接重试")
}
}
func TestRockProviderCancel_UsingTokenReturnsToStock(t *testing.T) {
db := newRockTestDB(t)
svc := NewRockService(db, NewRockClient("https://example.com"))
provider := NewRockProvider(svc)
ctx := context.Background()
usingAt := time.Now().Add(-10 * time.Minute)
token := model.RockToken{
UpstreamTokenID: "cancel-using",
Token: "jwt-cancel-using",
Status: model.RockStatusUsing,
AssignedOrderNo: "ORDER-CANCEL-USING",
ExpectedUID: "5550003",
UsingAt: &usingAt,
}
if err := db.Create(&token).Error; err != nil {
t.Fatalf("创建 Token 失败: %v", err)
}
if err := provider.Cancel(ctx, &model.FulfillmentOrder{OrderNo: token.AssignedOrderNo}); err != nil {
t.Fatalf("取消未绑定订单失败: %v", err)
}
var saved model.RockToken
if err := db.First(&saved, token.ID).Error; err != nil {
t.Fatalf("读取 Token 失败: %v", err)
}
if saved.Status != model.RockStatusAvailable || saved.AssignedOrderNo != "" ||
saved.ExpectedUID != "" || saved.UsingAt != nil {
t.Fatalf("未绑定 Token 应安全回库存: %+v", saved)
}
var incidentCount int64
if err := db.Model(&model.RockDeliveryIncident{}).
Where("token_id = ? AND status = ?", token.ID, model.RockIncidentStatusOpen).
Count(&incidentCount).Error; err != nil {
t.Fatalf("统计异常失败: %v", err)
}
if incidentCount != 0 {
t.Fatalf("未绑定订单取消不应生成异常,实际 %d", incidentCount)
}
}
func TestRockProviderCancel_BoundAndRedeemedCreateOrderCancelledIncident(t *testing.T) {
cases := []struct {
name string
status string
}{
{name: "bound", status: model.RockStatusBound},
{name: "redeemed", status: model.RockStatusRedeemed},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
db := newRockTestDB(t)
svc := NewRockService(db, NewRockClient("https://example.com"))
provider := NewRockProvider(svc)
token := model.RockToken{
UpstreamTokenID: "cancel-" + tc.name,
Token: "jwt-cancel-" + tc.name,
Status: tc.status,
AssignedOrderNo: "ORDER-CANCEL-" + tc.name,
ExpectedUID: "5550004",
}
if err := db.Create(&token).Error; err != nil {
t.Fatalf("创建 Token 失败: %v", err)
}
if err := provider.Cancel(context.Background(),
&model.FulfillmentOrder{OrderNo: token.AssignedOrderNo}); err != nil {
t.Fatalf("取消订单失败: %v", err)
}
var stored model.RockToken
if err := db.First(&stored, token.ID).Error; err != nil {
t.Fatalf("读取 Token 失败: %v", err)
}
if stored.Status != tc.status || stored.AssignedOrderNo != token.AssignedOrderNo {
t.Fatalf("已绑定或已发货 Token 不应自动释放: %+v", stored)
}
var incident model.RockDeliveryIncident
if err := db.Where(
"token_id = ? AND order_no = ? AND status = ? AND category = ?",
token.ID,
token.AssignedOrderNo,
model.RockIncidentStatusOpen,
model.RockIncidentCategoryOrderCancelled,
).First(&incident).Error; err != nil {
t.Fatalf("应生成订单取消冲突异常: %v", err)
}
if !incident.BlockingRetry || incident.Message == "" {
t.Fatalf("订单取消冲突异常应阻塞重试并带说明: %+v", incident)
}
})
}
}
func TestReconcileRockOrders_DoesNotBackfillHistoricalIncidents(t *testing.T) {
db := newRockTestDB(t)
svc := NewRockService(db, NewRockClient("https://example.com"))
ctx := context.Background()
tokens := []model.RockToken{
{
UpstreamTokenID: "reconcile-old-failed",
Token: "jwt-reconcile-old-failed",
Status: model.RockStatusBound,
AssignedOrderNo: "ORDER-RECONCILE-OLD-FAILED",
},
{
UpstreamTokenID: "reconcile-old-cancel",
Token: "jwt-reconcile-old-cancel",
Status: model.RockStatusRedeemed,
AssignedOrderNo: "ORDER-RECONCILE-OLD-CANCEL",
},
}
orders := []model.FulfillmentOrder{
{
OrderNo: tokens[0].AssignedOrderNo,
ClientOrderNo: "CLIENT-RECONCILE-OLD-FAILED",
MerchantID: 1,
OrderStatus: model.OrderStatusShipFailed,
FailureReason: "用户不存在可以兑换的可用权益",
ProductSKU: model.RockSKUBlackTshirt,
},
{
OrderNo: tokens[1].AssignedOrderNo,
ClientOrderNo: "CLIENT-RECONCILE-OLD-CANCEL",
MerchantID: 1,
OrderStatus: model.OrderStatusCancelled,
ProductSKU: model.RockSKUBlackTshirt,
},
}
for i := range tokens {
if err := db.Create(&tokens[i]).Error; err != nil {
t.Fatalf("创建历史 Token 失败: %v", err)
}
if err := db.Create(&orders[i]).Error; err != nil {
t.Fatalf("创建历史订单失败: %v", err)
}
}
svc.SetRedeemConfirmHook(func(context.Context, *model.FulfillmentOrder, *model.RockToken) error {
return nil
})
if err := svc.ReconcileRockOrders(ctx); err != nil {
t.Fatalf("执行对账失败: %v", err)
}
var count int64
if err := db.Model(&model.RockDeliveryIncident{}).Count(&count).Error; err != nil {
t.Fatalf("统计异常失败: %v", err)
}
if count != 0 {
t.Fatalf("历史失败/取消记录不应自动回填异常,实际 %d", count)
}
}
func TestPendingOrderDoesNotEnterShipTimeout(t *testing.T) {
db := newRockTestDB(t)
svc := NewFulfillmentService(db, nil)
old := time.Now().Add(-time.Hour)
order := model.FulfillmentOrder{
OrderNo: "ORDER-PENDING-TIMEOUT",
MerchantID: 1,
OrderStatus: model.OrderStatusDelivering,
ResultData: `{"provider_order_stage":"redeem_pending","rock_redeem_pending":true}`,
}
if err := db.Create(&order).Error; err != nil {
t.Fatalf("创建订单失败: %v", err)
}
if err := db.Model(&model.FulfillmentOrder{}).Where("id = ?", order.ID).
UpdateColumn("updated_at", old).Error; err != nil {
t.Fatalf("调整订单时间失败: %v", err)
}
changed, err := svc.MarkProcessingTimeouts(30*time.Minute, 10)
if err != nil {
t.Fatalf("执行超时扫描失败: %v", err)
}
if changed != 0 {
t.Fatalf("pending 订单不应被超时任务标记失败,实际变更 %d", changed)
}
var stored model.FulfillmentOrder
if err := db.First(&stored, order.ID).Error; err != nil {
t.Fatalf("读取订单失败: %v", err)
}
if stored.OrderStatus != model.OrderStatusDelivering {
t.Fatalf("pending 订单应保持 delivering,实际 %s", stored.OrderStatus)
}
}
func TestBlockingIncidentPreventsDeliveryRetry(t *testing.T) {
db := newRockTestDB(t)
svc := NewRockService(db, NewRockClient("https://example.com"))
provider := NewRockProvider(svc)
ctx := context.Background()
token := model.RockToken{
UpstreamTokenID: "incident-block",
Token: "jwt-incident-block",
RedeemCode: "CODE-INCIDENT-BLOCK",
Status: model.RockStatusBound,
AssignedOrderNo: "ORDER-INCIDENT-BLOCK",
}
if err := db.Create(&token).Error; err != nil {
t.Fatalf("创建 Token 失败: %v", err)
}
if _, err := svc.RecordRockDeliveryIncident(ctx, &token, token.AssignedOrderNo,
errors.New("用户不存在可以兑换的可用权益")); err != nil {
t.Fatalf("记录异常失败: %v", err)
}
state, err := provider.PrepareDelivery(ctx, &model.FulfillmentOrder{
OrderNo: token.AssignedOrderNo,
ProductSKU: model.RockSKUBlackTshirt,
}, nil)
if err != nil {
t.Fatalf("准备发货失败: %v", err)
}
if state.CanShip {
t.Fatal("存在开放异常时不得再次调用上游发货")
}
}
func TestReconcileRockOrdersRepairsTokenAndOrderStatus(t *testing.T) {
db := newRockTestDB(t)
svc := NewRockService(db, NewRockClient("https://example.com"))
ctx := context.Background()
redeemedAt := time.Now().Add(-time.Hour)
token := model.RockToken{
UpstreamTokenID: "reconcile-1",
Token: "jwt-reconcile-1",
RedeemCode: "CODE-RECONCILE-1",
Status: model.RockStatusRedeemed,
AssignedOrderNo: "ORDER-RECONCILE-1",
RedeemedAt: &redeemedAt,
}
order := model.FulfillmentOrder{
OrderNo: token.AssignedOrderNo,
ClientOrderNo: "CLIENT-RECONCILE-1",
MerchantID: 1,
OrderStatus: model.OrderStatusPaid,
ProductSKU: model.RockSKUBlackTshirt,
}
if err := db.Create(&token).Error; err != nil {
t.Fatalf("创建 Token 失败: %v", err)
}
if err := db.Create(&order).Error; err != nil {
t.Fatalf("创建订单失败: %v", err)
}
svc.SetRedeemConfirmHook(func(ctx context.Context, order *model.FulfillmentOrder, token *model.RockToken) error {
return db.Model(order).Update("order_status", model.OrderStatusDelivered).Error
})
if err := svc.ReconcileRockOrders(ctx); err != nil {
t.Fatalf("订单对账失败: %v", err)
}
var repairedOrder model.FulfillmentOrder
if err := db.First(&repairedOrder, order.ID).Error; err != nil {
t.Fatalf("读取订单失败: %v", err)
}
if repairedOrder.OrderStatus != model.OrderStatusDelivered {
t.Fatalf("redeemed Token 应修复订单为 delivered,实际 %s", repairedOrder.OrderStatus)
}
deliveredAt := time.Now()
legacyToken := model.RockToken{
UpstreamTokenID: "reconcile-2",
Token: "jwt-reconcile-2",
RedeemCode: "CODE-RECONCILE-2",
Status: model.RockStatusBound,
AssignedOrderNo: "ORDER-RECONCILE-2",
RedeemedAt: &redeemedAt,
}
deliveredOrder := model.FulfillmentOrder{
OrderNo: legacyToken.AssignedOrderNo,
ClientOrderNo: "CLIENT-RECONCILE-2",
MerchantID: 1,
OrderStatus: model.OrderStatusDelivered,
DeliveredAt: &deliveredAt,
ProductSKU: model.RockSKUBlackTshirt,
}
if err := db.Create(&legacyToken).Error; err != nil {
t.Fatalf("创建历史 Token 失败: %v", err)
}
if err := db.Create(&deliveredOrder).Error; err != nil {
t.Fatalf("创建已交付订单失败: %v", err)
}
if err := svc.ReconcileRockOrders(ctx); err != nil {
t.Fatalf("Token 对账失败: %v", err)
}
var repairedToken model.RockToken
if err := db.First(&repairedToken, legacyToken.ID).Error; err != nil {
t.Fatalf("读取 Token 失败: %v", err)
}
if repairedToken.Status != model.RockStatusRedeemed {
t.Fatalf("已交付订单的 bound Token 应修复为 redeemed,实际 %s", repairedToken.Status)
}
}
+30 -5
View File
@@ -10,6 +10,7 @@ import (
"time"
"affiliate_dash/internal/model"
"affiliate_dash/internal/pkg/timeutil"
)
func TestUpdateConfig_BoundWarnDaysAndAutoRefresh(t *testing.T) {
@@ -65,7 +66,15 @@ func TestListTokens_OverdueFilters(t *testing.T) {
boundOld := model.RockToken{UpstreamTokenID: "overdue-bound", RedeemCode: "C3", Status: model.RockStatusBound, BoundAt: &old}
boundFresh := model.RockToken{UpstreamTokenID: "fresh-bound", RedeemCode: "C4", Status: model.RockStatusBound, BoundAt: &fresh}
redeemedOld := model.RockToken{UpstreamTokenID: "old-redeemed", RedeemCode: "C5", Status: model.RockStatusRedeemed, BoundAt: &old}
for _, token := range []*model.RockToken{&usingOld, &usingFresh, &boundOld, &boundFresh, &redeemedOld} {
requestedAt := now.Add(-time.Minute)
boundPending := model.RockToken{
UpstreamTokenID: "overdue-bound-pending",
RedeemCode: "C6",
Status: model.RockStatusBound,
BoundAt: &old,
RedeemRequestedAt: &requestedAt,
}
for _, token := range []*model.RockToken{&usingOld, &usingFresh, &boundOld, &boundFresh, &redeemedOld, &boundPending} {
if err := db.Create(token).Error; err != nil {
t.Fatalf("创建 Token 失败: %v", err)
}
@@ -96,6 +105,15 @@ func TestListTokens_OverdueFilters(t *testing.T) {
t.Fatalf("过期预警统计不符: using_overdue=%d using_danger=%d bound_overdue=%d",
stats.UsingOverdueStock, stats.UsingDangerStock, stats.BoundOverdueStock)
}
all, _, err := svc.ListTokens(ctx, 1, 20, "", "")
if err != nil {
t.Fatalf("查询全部 Token 失败: %v", err)
}
for _, token := range all {
if token.ID == boundPending.ID && token.BoundOverdue {
t.Fatal("发货确认中的 Token 不应进入绑定后未发货")
}
}
}
// TestListTokens_OverdueBoundaryInclusive 锁定「满 2 天 / 满 3 天」的含边界口径:
@@ -211,15 +229,17 @@ func TestRefreshStaleTokens_SkipsWhenAutoRefreshDisabled(t *testing.T) {
}
}
func TestRefreshAllStaleTokens_FixesMismatch(t *testing.T) {
func TestRefreshAllStaleTokens_KeepsUnresolvedMismatch(t *testing.T) {
db := newRockTestDB(t)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
bindingAt := time.Now().In(timeutil.Location()).Add(-30 * time.Minute).Format("2006-01-02 15:04:05")
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"token": map[string]interface{}{
"status": "bound",
"bound_account": "痛写心扉",
"bound_account_id": "3132710334",
"bind_changed_at": bindingAt,
},
})
}))
@@ -228,11 +248,13 @@ func TestRefreshAllStaleTokens_FixesMismatch(t *testing.T) {
svc := NewRockService(db, NewRockClient(server.URL))
setRockTestBearer(t, svc, "bearer-test")
usingAt := time.Now().Add(-time.Hour)
token := model.RockToken{
UpstreamTokenID: "scan-on",
RedeemCode: "CODE-SCAN-ON",
Status: model.RockStatusMismatch,
ExpectedUID: "1111111111",
UsingAt: &usingAt,
}
if err := db.Create(&token).Error; err != nil {
t.Fatalf("创建 Token 失败: %v", err)
@@ -246,7 +268,7 @@ func TestRefreshAllStaleTokens_FixesMismatch(t *testing.T) {
if err != nil {
t.Fatalf("一键刷新失败: %v", err)
}
if result.Scanned != 1 || result.Fixed != 1 || result.Remaining != 0 || result.Failed != 0 {
if result.Scanned != 1 || result.Fixed != 0 || result.Remaining != 1 || result.Failed != 0 {
t.Fatalf("一键刷新结果不符: %+v", result)
}
@@ -254,8 +276,8 @@ func TestRefreshAllStaleTokens_FixesMismatch(t *testing.T) {
if err := db.First(&saved, token.ID).Error; err != nil {
t.Fatalf("读取 Token 失败: %v", err)
}
if saved.Status != model.RockStatusBound || saved.MismatchAt != nil {
t.Fatalf("应修复为 bound 并清空 mismatch 标记,实际 %+v", saved)
if saved.Status != model.RockStatusMismatch || saved.MismatchAt == nil {
t.Fatalf("UID 不一致时应保持 mismatch,实际 %+v", saved)
}
}
@@ -271,6 +293,7 @@ func TestRefreshAllStaleTokens_IncludesOverdueTokens(t *testing.T) {
"status": "bound",
"bound_account": "新角色",
"bound_account_id": "10001",
"bind_changed_at": time.Now().AddDate(0, 0, -10).Add(time.Hour).In(timeutil.Location()).Format("2006-01-02 15:04:05"),
},
})
case "/api/redeem/CODE-BOUND-OLD/refresh":
@@ -302,6 +325,7 @@ func TestRefreshAllStaleTokens_IncludesOverdueTokens(t *testing.T) {
UpstreamTokenID: "stale-bound",
RedeemCode: "CODE-BOUND-OLD",
Status: model.RockStatusBound,
UsingAt: &old,
BoundAt: &old,
}
for _, token := range []*model.RockToken{&usingToken, &boundToken} {
@@ -347,6 +371,7 @@ func TestRefreshStaleTokens_IncludesOverdueWhenEnabled(t *testing.T) {
"status": "bound",
"bound_account": "自动刷新角色",
"bound_account_id": "20001",
"bind_changed_at": time.Now().AddDate(0, 0, -10).Add(time.Hour).In(timeutil.Location()).Format("2006-01-02 15:04:05"),
},
})
}))
+11 -14
View File
@@ -30,7 +30,7 @@ func (s *RockService) importPackageDetail(ctx context.Context, detail *RockPacka
continue
}
upstreamID := strconv.FormatInt(pt.ID, 10)
status := normalizeRockPackageStatus(pt.Status)
status := rockStatusFromPackageToken(pt)
raw, marshalErr := json.Marshal(pt)
if marshalErr != nil {
raw = []byte(`{}`)
@@ -174,22 +174,19 @@ func (s *RockService) importPackageDetail(ctx context.Context, detail *RockPacka
return inserted, updated, skipped, err
}
func normalizeRockPackageStatus(status string) string {
switch strings.TrimSpace(status) {
case model.RockStatusAvailable:
return model.RockStatusAvailable
case model.RockStatusUsing:
return model.RockStatusUsing
case model.RockStatusBound:
return model.RockStatusBound
case model.RockStatusRedeemed:
// rockStatusFromPackageToken 以真实事件字段推导状态,避免 status=bound 的历史残留污染库存。
func rockStatusFromPackageToken(token RockPackageToken) string {
if strings.TrimSpace(token.RedeemedAt) != "" {
return model.RockStatusRedeemed
case model.RockStatusMismatch:
return model.RockStatusMismatch
default:
// 未知上游状态默认落 abnormal,避免被错误分配为可用库存。
}
if strings.TrimSpace(token.BindChangedAt) != "" {
return model.RockStatusBound
}
if strings.TrimSpace(token.BindChangeType) != "" {
// 只有事件类型没有时间,无法判定真实状态,保守落异常等待人工核对。
return model.RockStatusAbnormal
}
return model.RockStatusAvailable
}
func parseRockPackageTime(raw string) *time.Time {
+13 -7
View File
@@ -135,6 +135,9 @@ func (s *RockService) ResetMismatchToken(ctx context.Context, tokenID uint, acto
}
writeRockTokenStatusChange(tx, &token, oldStatus, model.RockStatusAvailable,
rockTokenSourceReset, "人工解冻重置回库存", false, actorUserID)
if err := resolveOpenRockIncidents(tx, token.ID, "reset", "人工解冻重置回库存", actorUserID); err != nil {
return err
}
return writeAudit(tx, nil, actorUserID, nil, "rock_token.reset", "rock_token",
fmt.Sprintf("%d", token.ID), map[string]interface{}{
"from_status": oldStatus,
@@ -164,15 +167,12 @@ func (s *RockService) UpdateTokenRemark(ctx context.Context, tokenID uint, remar
return nil, fmt.Errorf("发货确认中的 Token 只能标记为已上报失效,不能改回其他状态")
}
switch trimmedStatus {
case model.RockStatusAbnormal, model.RockStatusAvailable, model.RockStatusMismatch:
case model.RockStatusAbnormal:
updates["status"] = trimmedStatus
token.Status = trimmedStatus
if trimmedStatus != oldStatus {
updates["restocked_at"] = nil // 人工改状态不属于「释放回库存」,不产生优先权
}
if trimmedStatus == model.RockStatusAvailable {
updates["using_at"] = nil
}
if trimmedStatus == model.RockStatusAbnormal {
s.refreshCache.Delete(token.UpstreamTokenID)
}
@@ -187,12 +187,11 @@ func (s *RockService) UpdateTokenRemark(ctx context.Context, tokenID uint, remar
}
writeRockTokenStatusChange(tx, &token, oldStatus, token.Status,
rockTokenSourceManual, "人工更新备注: "+trimmedRemark, false, actorUserID)
_ = writeAudit(tx, nil, actorUserID, nil, "rock_token.update_remark", "rock_token", fmt.Sprintf("%d", token.ID), map[string]interface{}{
return writeAudit(tx, nil, actorUserID, nil, "rock_token.update_remark", "rock_token", fmt.Sprintf("%d", token.ID), map[string]interface{}{
"remark": token.Remark,
"from_status": oldStatus,
"to_status": token.Status,
})
return nil
})
if err != nil {
return nil, err
@@ -207,5 +206,12 @@ func (s *RockService) MarkTokenAbnormal(ctx context.Context, tokenID uint, reaso
if remark == "" {
remark = "已上报上游 (失效作废)"
}
return s.UpdateTokenRemark(ctx, tokenID, remark, model.RockStatusAbnormal, actorUserID)
token, err := s.UpdateTokenRemark(ctx, tokenID, remark, model.RockStatusAbnormal, actorUserID)
if err != nil {
return nil, err
}
if err := resolveOpenRockIncidents(s.db.WithContext(ctx), tokenID, "mark_abnormal", remark, actorUserID); err != nil {
return nil, err
}
return token, nil
}
+103 -53
View File
@@ -126,21 +126,27 @@ func (s *RockService) RedeemTokenForOrder(ctx context.Context, orderNo, sku, exp
if isRockRateLimitError(err) {
markRockUpstreamRateLimited()
}
return nil, fmt.Errorf("调用上游兑换失败: %w", err)
wrapped := fmt.Errorf("调用上游兑换失败: %w", err)
if _, incidentErr := s.RecordRockDeliveryIncident(ctx, &token, orderNo, wrapped); incidentErr != nil {
return nil, fmt.Errorf("%w;记录发货异常失败: %v", wrapped, incidentErr)
}
return nil, wrapped
}
// 上游已受理但官方仍在异步处理:进入 redeeming,等待后台轮询确认。
if redeemResp.IsPending() {
msg := redeemResp.FailureMessage("官方处理中,请稍后刷新查看结果")
now := time.Now()
token = s.markTokenRedeemPending(ctx, token, targetSkin, msg, recorder)
var pendingErr error
token, pendingErr = s.markTokenRedeemPending(ctx, token, targetSkin, msg, recorder)
if pendingErr != nil {
return nil, fmt.Errorf("上游已受理兑换,但记录发货确认状态失败: %w", pendingErr)
}
if recorder != nil {
recorder.Internal(ctx, "redeem_result", "pending", msg, map[string]interface{}{
"upstream_status": redeemResp.Status,
"pending": true,
})
}
_ = now
return &RockRedeemResult{Token: &token, Response: redeemResp, Pending: true, Message: msg, TraceID: recorderTraceID(recorder)}, nil
}
@@ -155,10 +161,14 @@ func (s *RockService) RedeemTokenForOrder(ctx context.Context, orderNo, sku, exp
}
// 刷新失败不直接否认可信的成功响应,但也不立即写终态;交给后台轮询确认。
msg := "上游已受理,正在等待发货结果确认"
token = s.markTokenRedeemPending(ctx, token, targetSkin, msg, recorder)
token, pendingErr := s.markTokenRedeemPending(ctx, token, targetSkin, msg, recorder)
if pendingErr != nil {
return nil, fmt.Errorf("上游已受理兑换,但记录发货确认状态失败: %w", pendingErr)
}
return &RockRedeemResult{Token: &token, Response: redeemResp, Pending: true, Message: msg, TraceID: recorderTraceID(recorder)}, nil
}
if ref != nil && !rockUpstreamRedeemed(ref) {
deliveryEvidence := rockCurrentCycleDelivery(&token, ref)
if ref != nil && !deliveryEvidence.Current && !deliveryEvidence.Ambiguous {
msg := "上游返回成功但尚未确认发货,正在等待官方结果"
if recorder != nil {
recorder.Internal(ctx, "redeem_confirm", "pending", msg, map[string]interface{}{
@@ -166,22 +176,23 @@ func (s *RockService) RedeemTokenForOrder(ctx context.Context, orderNo, sku, exp
"redeemed_at": ref.Token.RedeemedAt,
})
}
token = s.markTokenRedeemPending(ctx, token, targetSkin, msg, recorder)
token, pendingErr := s.markTokenRedeemPending(ctx, token, targetSkin, msg, recorder)
if pendingErr != nil {
return nil, fmt.Errorf("上游已受理兑换,但记录发货确认状态失败: %w", pendingErr)
}
return &RockRedeemResult{Token: &token, Response: redeemResp, Pending: true, Message: msg, TraceID: recorderTraceID(recorder)}, nil
}
fulfillID := extractRockFulfillID(ref, rockClaimBaseline(&token))
fulfillID := deliveryEvidence.FulfillID
redeemedAt := time.Now()
if ref != nil {
if parsed, ok := parseRockTime(ref.Token.RedeemedAt); ok {
redeemedAt = parsed
}
if !deliveryEvidence.At.IsZero() {
redeemedAt = deliveryEvidence.At
}
// 状态跃迁至 redeemed(只有拿到明确成功/明显履约结果才执行)
fromStatus := token.Status
now := time.Now()
_ = s.db.WithContext(ctx).Unscoped().Model(&token).Updates(map[string]interface{}{
if err := s.db.WithContext(ctx).Unscoped().Model(&token).Updates(map[string]interface{}{
"status": model.RockStatusRedeemed,
"upstream_fulfill_id": fulfillID,
"target_skin": targetSkin,
@@ -194,7 +205,9 @@ func (s *RockService) RedeemTokenForOrder(ctx context.Context, orderNo, sku, exp
"redeem_last_message": "",
"redeem_last_error": "",
"redeem_timeout_warned_at": nil,
}).Error
}).Error; err != nil {
return nil, fmt.Errorf("上游已发货,但更新本地 Token 状态失败: %w", err)
}
writeRockTokenStatusChange(s.db.WithContext(ctx), &token, fromStatus, model.RockStatusRedeemed,
rockTokenSourceRedeem, "自动发货: "+targetSkin, false, nil)
if recorder != nil {
@@ -216,14 +229,14 @@ func (s *RockService) RedeemTokenForOrder(ctx context.Context, orderNo, sku, exp
}
// markTokenRedeemPending 记录「发货确认中」标记(不改变 Token 状态)。
func (s *RockService) markTokenRedeemPending(ctx context.Context, token model.RockToken, targetSkin, message string, recorder *RockDeliveryRecorder) model.RockToken {
func (s *RockService) markTokenRedeemPending(ctx context.Context, token model.RockToken, targetSkin, message string, recorder *RockDeliveryRecorder) (model.RockToken, error) {
now := time.Now()
requestedAt := token.RedeemRequestedAt
if requestedAt == nil {
requestedAt = &now
}
nextRetry := now.Add(30 * time.Second)
_ = s.db.WithContext(ctx).Unscoped().Model(&token).Updates(map[string]interface{}{
if err := s.db.WithContext(ctx).Unscoped().Model(&token).Updates(map[string]interface{}{
"target_skin": targetSkin,
"target_currency": "幸运币",
"redeem_requested_at": requestedAt,
@@ -233,7 +246,9 @@ func (s *RockService) markTokenRedeemPending(ctx context.Context, token model.Ro
"redeem_last_message": strings.TrimSpace(message),
"redeem_last_error": "",
"updated_at": now,
}).Error
}).Error; err != nil {
return token, err
}
if recorder != nil {
recorder.Internal(ctx, "redeem_pending", "pending", strings.TrimSpace(message), map[string]interface{}{
"status": token.Status,
@@ -249,7 +264,7 @@ func (s *RockService) markTokenRedeemPending(ctx context.Context, token model.Ro
token.RedeemNextRetryAt = &nextRetry
token.RedeemLastMessage = strings.TrimSpace(message)
token.RedeemLastError = ""
return token
return token, nil
}
// rockTokenRedeemPending 判断 Token 是否处于「发货确认中」:
@@ -271,19 +286,8 @@ func recorderTraceID(recorder *RockDeliveryRecorder) string {
return recorder.TraceID
}
// rockUpstreamRedeemed 判断上游是否已明确完成发货。
func rockUpstreamRedeemed(ref *RockRefreshResponse) bool {
if ref == nil {
return false
}
if strings.EqualFold(strings.TrimSpace(ref.Token.Status), model.RockStatusRedeemed) {
return true
}
return strings.TrimSpace(ref.Token.RedeemedAt) != ""
}
// extractRockFulfillID 从上游客服端返回的 deliveryList 中取本次履约流水。
// 优先取本次领取之后产生的记录;都为历史记录时退回最大流水号。
// 只取本次领取之后产生的记录;历史流水不能作为本轮发货依据。
func extractRockFulfillID(ref *RockRefreshResponse, baseline time.Time) int64 {
if ref == nil || len(ref.DeliveryList) == 0 {
return 0
@@ -306,6 +310,9 @@ func extractRockFulfillID(ref *RockRefreshResponse, baseline time.Time) int64 {
if best > 0 {
return best
}
if !baseline.IsZero() {
return 0
}
for _, item := range ref.DeliveryList {
if item.FulfillID > best {
best = item.FulfillID
@@ -347,8 +354,14 @@ func (s *RockService) checkReleaseToken(ctx context.Context, tokenID uint) (*mod
if err := s.db.WithContext(ctx).First(&token, tokenID).Error; err != nil {
return nil, nil, err
}
if token.Status != model.RockStatusUsing {
return nil, nil, fmt.Errorf("仅扫码使用中的链接可以释放回库存")
if token.Status != model.RockStatusUsing && token.Status != model.RockStatusBound {
return nil, nil, fmt.Errorf("仅使用中或已绑定的链接可以释放回库存")
}
if rockTokenRedeemPending(&token) {
return nil, nil, fmt.Errorf("发货确认中的 Token 禁止释放回库存")
}
if token.RedeemedAt != nil {
return nil, nil, fmt.Errorf("本地已有发货记录的 Token 禁止释放回库存")
}
cfg, err := s.GetConfig(ctx)
@@ -385,6 +398,10 @@ func (s *RockService) checkReleaseToken(ctx context.Context, tokenID uint) (*mod
upstreamStatus := strings.ToLower(strings.TrimSpace(ref.Token.Status))
roleName := ref.GetRoleName()
roleID := ref.GetRoleID()
deliveryEvidence := rockCurrentCycleDelivery(&token, ref)
currentBound, _ := rockCurrentCycleBinding(&token, ref)
baselineUnknown := rockClaimBaseline(&token).IsZero()
matchesInitialSnapshot := rockBindingMatchesInitialSnapshot(&token, ref)
switch {
case upstreamStatus == model.RockStatusAbnormal:
return &token, &RockReleaseCheckResult{
@@ -393,20 +410,32 @@ func (s *RockService) checkReleaseToken(ctx context.Context, tokenID uint) (*mod
RoleID: roleID,
Message: "上游状态异常(已上报失效)",
}, nil
case upstreamStatus == model.RockStatusRedeemed || strings.TrimSpace(ref.Token.RedeemedAt) != "":
case deliveryEvidence.Current || deliveryEvidence.Ambiguous:
message := "上游已发货"
if deliveryEvidence.Ambiguous {
message = "上游显示已发货,但无法确认是否属于本轮,禁止释放"
}
return &token, &RockReleaseCheckResult{
UpstreamStatus: "redeemed",
RoleName: roleName,
RoleID: roleID,
Message: "上游已发货",
Message: message,
}, nil
case rockBuyerNewlyBound(&token, ref):
case currentBound:
return &token, &RockReleaseCheckResult{
UpstreamStatus: "bound",
RoleName: roleName,
RoleID: roleID,
Message: fmt.Sprintf("上游已扫码绑定角色「%s」(%s)", displayRockRole(roleName, roleID), roleID),
}, nil
case baselineUnknown && !matchesInitialSnapshot &&
(ref.HasBindingSignal() || strings.TrimSpace(rockBindingChangedAt(ref)) != ""):
return &token, &RockReleaseCheckResult{
UpstreamStatus: "bound",
RoleName: roleName,
RoleID: roleID,
Message: "上游存在绑定信息,但缺少本轮 using_at,无法自动判断,请人工核对后强制释放",
}, nil
default:
return &token, &RockReleaseCheckResult{
CanRelease: true,
@@ -427,6 +456,10 @@ func (s *RockService) ReleaseUnboundUsingToken(ctx context.Context, tokenID uint
if !check.CanRelease && !force {
return &RockReleaseRequiresForceError{Check: *check}
}
switch check.UpstreamStatus {
case "redeemed", "abnormal", "not_found", "check_failed":
return fmt.Errorf("上游状态为 %s,禁止释放回库存", check.UpstreamStatus)
}
reason := "人工释放(上游未绑定)"
if force {
@@ -440,33 +473,50 @@ func (s *RockService) ReleaseUnboundUsingToken(ctx context.Context, tokenID uint
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&token, tokenID).Error; err != nil {
return err
}
if token.Status != model.RockStatusUsing {
return fmt.Errorf("仅扫码使用中的链接可以释放回库存")
if token.Status != model.RockStatusUsing && token.Status != model.RockStatusBound {
return fmt.Errorf("仅使用中或已绑定的链接可以释放回库存")
}
if rockTokenRedeemPending(&token) {
return fmt.Errorf("发货确认中的 Token 禁止释放回库存")
}
if token.RedeemedAt != nil {
return fmt.Errorf("本地已有发货记录的 Token 禁止释放回库存")
}
fromStatus := token.Status
orderNo = token.AssignedOrderNo
now := time.Now()
if err := tx.Model(&token).Updates(map[string]interface{}{
"status": model.RockStatusAvailable,
"assigned_order_no": "",
"expected_uid": "",
"actual_role_name": "",
"actual_role_id": "",
"actual_channel": "",
"initial_bound_snapshot": "",
"mismatch_at": nil,
"mismatch_reason": "",
"bound_at": nil,
"using_at": nil,
"redeemed_at": nil,
"upstream_fulfill_id": 0,
"restocked_at": now, // 人工释放回库存:下次分配优先使用
"updated_at": now,
"status": model.RockStatusAvailable,
"assigned_order_no": "",
"expected_uid": "",
"actual_role_name": "",
"actual_role_id": "",
"actual_channel": "",
"initial_bound_snapshot": "",
"mismatch_at": nil,
"mismatch_reason": "",
"bound_at": nil,
"using_at": nil,
"redeemed_at": nil,
"upstream_fulfill_id": 0,
"restocked_at": now, // 人工释放回库存:下次分配优先使用
"redeem_requested_at": nil,
"redeem_checked_at": nil,
"redeem_attempts": 0,
"redeem_next_retry_at": nil,
"redeem_last_message": "",
"redeem_last_error": "",
"redeem_timeout_warned_at": nil,
"updated_at": now,
}).Error; err != nil {
return err
}
released = token
writeRockTokenStatusChange(tx, &token, model.RockStatusUsing, model.RockStatusAvailable,
writeRockTokenStatusChange(tx, &token, fromStatus, model.RockStatusAvailable,
rockTokenSourceRelease, reason, force, actorUserID)
if err := resolveOpenRockIncidents(tx, tokenID, "release", reason, actorUserID); err != nil {
return err
}
return writeAudit(tx, nil, actorUserID, nil, "rock_token.release", "rock_token",
fmt.Sprintf("%d", token.ID), map[string]interface{}{
"order_no": orderNo,
+142 -21
View File
@@ -43,6 +43,7 @@ func (s *RockService) SetRedeemConfirmHook(hook func(ctx context.Context, order
// 避免把 0666.pro 的公开刷新接口打成持续频控。
func (s *RockService) RunRedeemConfirmWorker(ctx context.Context) {
lastCleanup := time.Now()
lastReconcile := time.Time{}
ticker := time.NewTicker(rockRedeemPollInterval)
defer ticker.Stop()
for {
@@ -56,6 +57,12 @@ func (s *RockService) RunRedeemConfirmWorker(ctx context.Context) {
if _, err := s.ProcessRedeemingTokens(ctx); err != nil && !errors.Is(err, errRockRedeemBusy) {
log.Printf("[Rock] 发货结果确认任务失败: %v", err)
}
if time.Since(lastReconcile) >= 2*time.Minute {
if err := s.ReconcileRockOrders(ctx); err != nil {
log.Printf("[Rock] 订单对账失败: %v", err)
}
lastReconcile = time.Now()
}
if time.Since(lastCleanup) >= 24*time.Hour {
s.CleanupRockDeliveryLogs(ctx)
lastCleanup = time.Now()
@@ -64,6 +71,93 @@ func (s *RockService) RunRedeemConfirmWorker(ctx context.Context) {
}
}
// ReconcileRockOrders 修复 Token 与订单状态之间的已知不一致:
// 1. Token 已 redeemed,但订单仍 paid/delivering/ship_failed,重新同步订单;
// 2. 订单已 delivered,Token 仍 bound 且已有 redeemed_at,修复 Token 终态;
//
// 历史失败订单和取消冲突不在这里回填;新异常由实际发货/取消流程直接记录。
func (s *RockService) ReconcileRockOrders(ctx context.Context) error {
if s.redeemConfirmHook == nil {
return nil
}
type row struct {
TokenID uint
}
var redeemRows []row
if err := s.db.WithContext(ctx).Raw(`
SELECT t.id AS token_id
FROM rock_tokens t
JOIN fulfillment_orders o ON o.order_no = t.assigned_order_no
WHERE t.deleted_at IS NULL
AND t.status = 'redeemed'
AND o.deleted_at IS NULL
AND o.order_status IN ('paid', 'delivering', 'ship_failed')
ORDER BY t.redeemed_at ASC, t.id ASC
LIMIT 50
`).Scan(&redeemRows).Error; err != nil {
return err
}
for _, item := range redeemRows {
if err := s.reconcileRedeemedOrder(ctx, item.TokenID); err != nil {
log.Printf("[Rock] 对账 redeemed Token %d 失败: %v", item.TokenID, err)
}
}
// 修复历史“订单已交付但 Token 仍 bound”的记录。
var boundRows []row
if err := s.db.WithContext(ctx).Raw(`
SELECT t.id AS token_id
FROM rock_tokens t
JOIN fulfillment_orders o ON o.order_no = t.assigned_order_no
WHERE t.deleted_at IS NULL
AND t.status = 'bound'
AND t.redeemed_at IS NOT NULL
AND o.deleted_at IS NULL
AND o.order_status = 'delivered'
ORDER BY t.id ASC
LIMIT 50
`).Scan(&boundRows).Error; err != nil {
return err
}
for _, item := range boundRows {
var token model.RockToken
if err := s.db.WithContext(ctx).First(&token, item.TokenID).Error; err != nil {
continue
}
oldStatus := token.Status
if err := s.db.WithContext(ctx).Model(&token).Updates(map[string]interface{}{
"status": model.RockStatusRedeemed,
"updated_at": time.Now(),
}).Error; err != nil {
continue
}
writeRockTokenStatusChange(s.db.WithContext(ctx), &token, oldStatus, model.RockStatusRedeemed,
rockTokenSourceRedeem, "订单对账修复:订单已交付", false, nil)
}
return nil
}
func (s *RockService) reconcileRedeemedOrder(ctx context.Context, tokenID uint) error {
var token model.RockToken
if err := s.db.WithContext(ctx).First(&token, tokenID).Error; err != nil {
return err
}
var order model.FulfillmentOrder
if err := s.db.WithContext(ctx).Where("order_no = ?", token.AssignedOrderNo).First(&order).Error; err != nil {
return err
}
if s.redeemConfirmHook == nil {
return nil
}
if err := s.redeemConfirmHook(ctx, &order, &token); err != nil {
_, _ = s.RecordRockIncident(ctx, &token, token.AssignedOrderNo,
model.RockIncidentCategoryOther,
"Token 已发货但订单同步失败: "+err.Error(), 0, "")
return err
}
return nil
}
var errRockRedeemBusy = errors.New("发货结果确认任务正在执行")
// ProcessRedeemingTokens 扫描并确认一批「发货确认中」的 Token。
@@ -138,14 +232,18 @@ func (s *RockService) confirmRedeemingToken(ctx context.Context, token *model.Ro
if isRockRateLimitError(err) {
markRockUpstreamRateLimited()
}
s.bumpRedeemRetry(ctx, token, err.Error(), false)
if retryErr := s.bumpRedeemRetry(ctx, token, err.Error(), false); retryErr != nil {
return false, false, fmt.Errorf("刷新失败且记录重试状态失败: %w", retryErr)
}
if recorder != nil {
recorder.Internal(ctx, "redeem_confirm", "failed", err.Error(), nil)
}
return false, false, err
}
if ref == nil {
s.bumpRedeemRetry(ctx, token, "上游未返回刷新结果", false)
if retryErr := s.bumpRedeemRetry(ctx, token, "上游未返回刷新结果", false); retryErr != nil {
return false, false, retryErr
}
return false, false, errors.New("上游未返回刷新结果")
}
@@ -154,18 +252,24 @@ func (s *RockService) confirmRedeemingToken(ctx context.Context, token *model.Ro
// 上游明确失效:终止处理并标记异常。
if upstreamStatus == model.RockStatusAbnormal {
s.markRedeemingAbnormal(ctx, token, "上游状态异常(发货确认)", recorder)
if err := s.markRedeemingAbnormal(ctx, token, "上游状态异常(发货确认)", recorder); err != nil {
return false, false, err
}
return false, false, nil
}
if rockUpstreamRedeemed(ref) {
fulfillID := extractRockFulfillID(ref, rockClaimBaseline(token))
redeemedAt := now
if parsed, ok := parseRockTime(ref.Token.RedeemedAt); ok {
redeemedAt = parsed
deliveryEvidence := rockCurrentCycleDelivery(token, ref)
if deliveryEvidence.Current || deliveryEvidence.Ambiguous {
fulfillID := deliveryEvidence.FulfillID
if fulfillID == 0 {
fulfillID = extractRockFulfillID(ref, rockClaimBaseline(token))
}
redeemedAt := deliveryEvidence.At
if redeemedAt.IsZero() {
redeemedAt = now
}
fromStatus := token.Status
_ = s.db.WithContext(ctx).Unscoped().Model(token).Updates(map[string]interface{}{
if err := s.db.WithContext(ctx).Unscoped().Model(token).Updates(map[string]interface{}{
"status": model.RockStatusRedeemed,
"upstream_fulfill_id": fulfillID,
"redeemed_at": redeemedAt,
@@ -175,17 +279,24 @@ func (s *RockService) confirmRedeemingToken(ctx context.Context, token *model.Ro
"redeem_last_error": "",
"redeem_timeout_warned_at": nil,
"updated_at": now,
}).Error
}).Error; err != nil {
return false, false, fmt.Errorf("上游确认发货,但更新 Token 终态失败: %w", err)
}
message := "上游确认发货"
if deliveryEvidence.Ambiguous {
message = "上游确认发货,但兑换时间无法核对,已按已发货处理"
}
writeRockTokenStatusChange(s.db.WithContext(ctx), token, fromStatus, model.RockStatusRedeemed,
rockTokenSourceRedeem, "上游确认发货", false, nil)
rockTokenSourceRedeem, message, false, nil)
token.Status = model.RockStatusRedeemed
token.UpstreamFulfillID = fulfillID
token.RedeemedAt = &redeemedAt
if recorder != nil {
recorder.Internal(ctx, "state_change", "success", "上游确认发货", map[string]interface{}{
recorder.Internal(ctx, "state_change", "success", message, map[string]interface{}{
"upstream_status": ref.Token.Status,
"upstream_redeemed_at": ref.Token.RedeemedAt,
"upstream_fulfill_id": fulfillID,
"ambiguous": deliveryEvidence.Ambiguous,
})
}
s.syncDeliveredOrder(ctx, token, recorder)
@@ -197,17 +308,21 @@ func (s *RockService) confirmRedeemingToken(ctx context.Context, token *model.Ro
if waitingMessage == "" {
waitingMessage = "官方处理中,请稍后刷新查看结果"
}
_ = s.db.WithContext(ctx).Unscoped().Model(token).Updates(map[string]interface{}{
if err := s.db.WithContext(ctx).Unscoped().Model(token).Updates(map[string]interface{}{
"redeem_checked_at": now,
"redeem_last_message": waitingMessage,
"redeem_last_error": "",
"updated_at": now,
}).Error
}).Error; err != nil {
return false, false, fmt.Errorf("更新发货确认状态失败: %w", err)
}
token.RedeemCheckedAt = &now
token.RedeemLastMessage = waitingMessage
attempts := token.RedeemAttempts
s.bumpRedeemRetry(ctx, token, "", true)
if err := s.bumpRedeemRetry(ctx, token, "", true); err != nil {
return false, false, err
}
if recorder != nil {
recorder.Internal(ctx, "redeem_confirm", "pending", waitingMessage, map[string]interface{}{
"upstream_status": ref.Token.Status,
@@ -259,9 +374,9 @@ func (s *RockService) refreshRedeemStatus(ctx context.Context, token *model.Rock
return nil, errors.New("缺少兑换码与上游 Token ID,无法确认发货结果")
}
func (s *RockService) bumpRedeemRetry(ctx context.Context, token *model.RockToken, lastError string, countAttempt bool) {
func (s *RockService) bumpRedeemRetry(ctx context.Context, token *model.RockToken, lastError string, countAttempt bool) error {
if token == nil {
return
return errors.New("Token 不存在")
}
attempts := token.RedeemAttempts
if countAttempt {
@@ -290,21 +405,26 @@ func (s *RockService) bumpRedeemRetry(ctx context.Context, token *model.RockToke
if strings.TrimSpace(lastError) != "" {
updates["redeem_last_error"] = lastError
}
_ = s.db.WithContext(ctx).Unscoped().Model(token).Updates(updates).Error
if err := s.db.WithContext(ctx).Unscoped().Model(token).Updates(updates).Error; err != nil {
return err
}
token.RedeemAttempts = attempts
token.RedeemNextRetryAt = &next
return nil
}
func (s *RockService) markRedeemingAbnormal(ctx context.Context, token *model.RockToken, reason string, recorder *RockDeliveryRecorder) {
func (s *RockService) markRedeemingAbnormal(ctx context.Context, token *model.RockToken, reason string, recorder *RockDeliveryRecorder) error {
fromStatus := token.Status
_ = s.db.WithContext(ctx).Unscoped().Model(token).Updates(map[string]interface{}{
if err := s.db.WithContext(ctx).Unscoped().Model(token).Updates(map[string]interface{}{
"status": model.RockStatusAbnormal,
"redeem_last_error": reason,
"redeem_next_retry_at": nil,
"redeem_timeout_warned_at": nil,
"remark": appendRockRemark(token.Remark, reason),
"updated_at": time.Now(),
}).Error
}).Error; err != nil {
return err
}
writeRockTokenStatusChange(s.db.WithContext(ctx), token, fromStatus, model.RockStatusAbnormal,
rockTokenSourceRedeem, reason, false, nil)
token.Status = model.RockStatusAbnormal
@@ -314,6 +434,7 @@ func (s *RockService) markRedeemingAbnormal(ctx context.Context, token *model.Ro
"to_status": model.RockStatusAbnormal,
})
}
return nil
}
func (s *RockService) syncDeliveredOrder(ctx context.Context, token *model.RockToken, recorder *RockDeliveryRecorder) {
+42 -3
View File
@@ -36,7 +36,7 @@ func TestCheckReleaseToken_UpstreamStatuses(t *testing.T) {
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"token": map[string]interface{}{
"status": "redeemed",
"redeemed_at": "2026-11-07 22:44:00",
"redeemed_at": time.Now().In(timeutil.Location()).Add(-10 * time.Second).Format("2006-01-02 15:04:05"),
},
})
case "abnormal":
@@ -179,9 +179,9 @@ func TestReleaseUnboundUsingToken_ForceClearsFieldsAndWritesLogs(t *testing.T) {
MismatchAt: &now,
MismatchReason: "历史不一致",
ClaimedAt: &claimedAt,
UsingAt: &now,
UsingAt: &claimedAt,
BoundAt: &now,
RedeemedAt: &now,
RedeemedAt: nil,
}
if err := db.Create(&token).Error; err != nil {
t.Fatalf("创建 Token 失败: %v", err)
@@ -251,6 +251,45 @@ func TestReleaseUnboundUsingToken_ForceClearsFieldsAndWritesLogs(t *testing.T) {
}
}
func TestReleaseUnboundUsingToken_ForceStillRejectsRedeemed(t *testing.T) {
db := newRockTestDB(t)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"token": map[string]interface{}{
"status": "redeemed",
"redeemed_at": time.Now().In(timeutil.Location()).Add(-time.Minute).Format("2006-01-02 15:04:05"),
},
})
}))
defer server.Close()
svc := NewRockService(db, NewRockClient(server.URL))
setRockTestBearer(t, svc, "bearer-test")
now := time.Now()
usingAt := now.Add(-time.Hour)
token := model.RockToken{
UpstreamTokenID: "release-redeemed",
Token: "jwt",
RedeemCode: "CODE-RELEASED",
Color: model.RockColorBlack,
Status: model.RockStatusUsing,
UsingAt: &usingAt,
}
if err := db.Create(&token).Error; err != nil {
t.Fatalf("创建 Token 失败: %v", err)
}
if err := svc.ReleaseUnboundUsingToken(context.Background(), token.ID, true, nil); err == nil {
t.Fatal("上游已发货时强制释放也必须被拒绝")
}
var stored model.RockToken
if err := db.First(&stored, token.ID).Error; err != nil {
t.Fatalf("读取 Token 失败: %v", err)
}
if stored.Status != model.RockStatusUsing {
t.Fatalf("拒绝释放后状态不应变化,实际 %s", stored.Status)
}
}
// newBoundReleaseServer 返回一个「上游已扫码绑定」的 mock,用于强制释放场景。
func newBoundReleaseServer(t *testing.T) *httptest.Server {
t.Helper()
@@ -229,8 +229,8 @@ func TestResetMismatchToken_ClearsRestockPriority(t *testing.T) {
}
}
// TestUpdateTokenRemark_ManualAvailableClearsRestockPriority 验证人工备注改回可用不产生优先权。
func TestUpdateTokenRemark_ManualAvailableClearsRestockPriority(t *testing.T) {
// TestUpdateTokenRemark_RejectsManualAvailable 防止备注接口绕过释放校验。
func TestUpdateTokenRemark_RejectsManualAvailable(t *testing.T) {
db := newRockTestDB(t)
svc := NewRockService(db, NewRockClient("https://example.com"))
ctx := context.Background()
@@ -248,19 +248,19 @@ func TestUpdateTokenRemark_ManualAvailableClearsRestockPriority(t *testing.T) {
t.Fatalf("创建 abnormal Token 失败: %v", err)
}
if _, err := svc.UpdateTokenRemark(ctx, token.ID, "人工改回可用", model.RockStatusAvailable, nil); err != nil {
t.Fatalf("人工改回可用失败: %v", err)
if _, err := svc.UpdateTokenRemark(ctx, token.ID, "人工改回可用", model.RockStatusAvailable, nil); err == nil {
t.Fatal("备注接口不应允许直接改回 available")
}
var updated model.RockToken
if err := db.First(&updated, token.ID).Error; err != nil {
t.Fatalf("读取更新结果失败: %v", err)
}
if updated.Status != model.RockStatusAvailable {
t.Fatalf("人工更新后应为 available,实际 %s", updated.Status)
if updated.Status != model.RockStatusAbnormal {
t.Fatalf("拒绝后应保持 abnormal,实际 %s", updated.Status)
}
if updated.RestockedAt != nil {
t.Fatalf("人工改回可用不应产生优先权,restocked_at=%v", updated.RestockedAt)
if updated.RestockedAt == nil {
t.Fatalf("拒绝后不应清空原有 restocked_at")
}
}
+22 -9
View File
@@ -53,11 +53,13 @@ type RockStatsResponse struct {
UsingDangerStock int64 `json:"using_danger_stock"`
BoundOverdueStock int64 `json:"bound_overdue_stock"`
// 异常标记计数(派生筛选,不是状态):历史流转中出现过异常的 Token 数。
AnomalyStock int64 `json:"anomaly_stock"`
UpstreamPoints float64 `json:"upstream_points"`
AutoDeliveryEnabled bool `json:"auto_delivery_enabled"`
TokenWarningStock int `json:"token_warning_stock"`
AutoPurchaseEnabled bool `json:"auto_purchase_enabled"`
AnomalyStock int64 `json:"anomaly_stock"`
NoEntitlementStock int64 `json:"no_entitlement_stock"`
UnresolvedAnomalyStock int64 `json:"unresolved_anomaly_stock"`
UpstreamPoints float64 `json:"upstream_points"`
AutoDeliveryEnabled bool `json:"auto_delivery_enabled"`
TokenWarningStock int `json:"token_warning_stock"`
AutoPurchaseEnabled bool `json:"auto_purchase_enabled"`
// 自动刷新绑定错误开关状态,供筛选栏 Switch 回显
AutoRefreshMismatchEnabled bool `json:"auto_refresh_mismatch_enabled"`
// 自动补货冷却状态:供页面提示“下次可补货时间”
@@ -223,7 +225,7 @@ func (s *RockService) UpdateConfig(ctx context.Context, req UpdateRockConfigReq)
return nil, &RockConfigValidationError{Message: "使用中红色预警天数必须大于橙色预警天数"}
}
// 绑定过期预警:已绑定超过 N 天视为绑定超时,独立于使用中两级预警。
// 绑定后未发货预警:本轮真实绑定后超过 N 天仍未发货。
boundWarnDays := cfg.BoundWarnDays
if req.BoundWarnDays != nil {
boundWarnDays = *req.BoundWarnDays
@@ -359,8 +361,6 @@ func (s *RockService) GetStats(ctx context.Context) (*RockStatsResponse, error)
for _, r := range results {
switch r.Status {
case model.RockStatusAvailable:
stats.AvailableStock = r.Total
case model.RockStatusUsing:
stats.UsingStock = r.Total
case model.RockStatusBound:
@@ -373,6 +373,12 @@ func (s *RockService) GetStats(ctx context.Context) (*RockStatsResponse, error)
stats.AbnormalStock = r.Total
}
}
// 实际分配只取黑色通用 Token,看板可用库存也使用同一口径。
if err := s.db.WithContext(ctx).Model(&model.RockToken{}).
Where("status = ? AND color = ?", model.RockStatusAvailable, model.RockColorBlack).
Count(&stats.AvailableStock).Error; err != nil {
return nil, err
}
var deletedCount int64
if err := s.db.WithContext(ctx).Unscoped().Model(&model.RockToken{}).
@@ -397,7 +403,8 @@ func (s *RockService) GetStats(ctx context.Context) (*RockStatsResponse, error)
return nil, err
}
if err := s.db.WithContext(ctx).Model(&model.RockToken{}).
Where("status = ? AND bound_at IS NOT NULL AND bound_at <= ?", model.RockStatusBound, rockOverdueCutoff(now, boundWarnDays)).
Where("status = ? AND bound_at IS NOT NULL AND bound_at <= ? AND redeem_requested_at IS NULL",
model.RockStatusBound, rockOverdueCutoff(now, boundWarnDays)).
Count(&stats.BoundOverdueStock).Error; err != nil {
return nil, err
}
@@ -406,6 +413,12 @@ func (s *RockService) GetStats(ctx context.Context) (*RockStatsResponse, error)
Count(&stats.AnomalyStock).Error; err != nil {
return nil, err
}
stats.UnresolvedAnomalyStock = stats.AnomalyStock
if err := rockTokenIncidentFilter(s.db.WithContext(ctx).Model(&model.RockToken{}),
model.RockIncidentCategoryNoEntitlement).
Count(&stats.NoEntitlementStock).Error; err != nil {
return nil, err
}
if cfg.AutoPurchaseEnabled {
if remain := rockAutoPurchaseCooldownRemaining(cfg, time.Now()); remain > 0 {
+133 -64
View File
@@ -9,56 +9,74 @@ import (
"affiliate_dash/internal/pkg/timeutil"
)
// rockBuyerNewlyBound 判断上游展示的绑定是不是本次领取之后新扫上的。
// 和分配快照一致,或者绑定时间仍为空且发货记录都早于本次领取,都是历史残留,链接可以再次使用。
// 没有快照、也没有更早的发货记录时,上游已有角色按新绑定处理,避免把刚扫上的号放回库存。
func rockBuyerNewlyBound(token *model.RockToken, ref *RockRefreshResponse) bool {
if ref == nil || !ref.IsBound() || strings.TrimSpace(ref.GetRoleID()) == "" {
return false
}
snap, hasSnap := bindingSnapshot(token)
if hasSnap && snap.Bound && snap.RoleID == ref.GetRoleID() && snap.BindChangedAt == strings.TrimSpace(ref.Token.BindChangedAt) {
return false
}
if deliveryAfterClaim(token, ref) || bindChangedAfterClaim(token, ref.Token.BindChangedAt) {
return true
}
if hasSnap && snap.RoleID != "" && snap.RoleID != ref.GetRoleID() {
return true
}
if strings.TrimSpace(ref.Token.BindChangedAt) == "" && historicalDeliveriesOnly(token, ref) {
return false
}
if hasSnap {
return false
}
return true
}
// rockUpstreamBound 判断上游「当前」是否已绑定。
//
// 只看本次实时返回的绑定信号(status / bind_changed_at / binding),
// 不用 bound_account_id 判断:上游可能保留上一次绑定的账号残留,
// 用它会导致刷新按钮把「未扫码」误判成已绑定,也无法把真实绑定同步回本地。
func rockUpstreamBound(ref *RockRefreshResponse) bool {
func rockBindingChangedAt(ref *RockRefreshResponse) string {
if ref == nil {
return false
return ""
}
switch strings.ToLower(strings.TrimSpace(ref.Token.Status)) {
case model.RockStatusBound, model.RockStatusRedeemed:
return true
if value := strings.TrimSpace(ref.Token.BindChangedAt); value != "" {
return value
}
if strings.TrimSpace(ref.Token.BindChangedAt) != "" {
return true
}
return ref.Binding.Bound || strings.TrimSpace(ref.Binding.RoleID) != ""
return strings.TrimSpace(ref.Binding.BindTime)
}
func historicalDeliveriesOnly(token *model.RockToken, ref *RockRefreshResponse) bool {
if ref == nil || len(ref.DeliveryList) == 0 {
// rockRawBindingAfterBaseline 只判断上游是否返回了本轮绑定事件,不参考残留账号。
func rockRawBindingAfterBaseline(token *model.RockToken, ref *RockRefreshResponse) (time.Time, bool) {
raw := rockBindingChangedAt(ref)
if raw == "" {
return time.Time{}, false
}
changedAt, ok := parseRockTime(raw)
if !ok {
return time.Time{}, false
}
baseline := rockClaimBaseline(token)
if baseline.IsZero() {
return time.Time{}, false
}
return changedAt, !changedAt.Before(baseline.Add(-time.Minute))
}
// rockCurrentCycleBinding 判断上游绑定事件是否属于当前分配周期。
func rockCurrentCycleBinding(token *model.RockToken, ref *RockRefreshResponse) (bool, time.Time) {
if token == nil || ref == nil {
return false, time.Time{}
}
changedAt, current := rockRawBindingAfterBaseline(token, ref)
if !current {
return false, time.Time{}
}
if rockBindingMatchesInitialSnapshot(token, ref) {
return false, time.Time{}
}
return true, changedAt
}
func rockBindingMatchesInitialSnapshot(token *model.RockToken, ref *RockRefreshResponse) bool {
snap, ok := bindingSnapshot(token)
if !ok {
return false
}
return !deliveryAfterClaim(token, ref)
return sameRockBindingEvent(snap.BindChangedAt, rockBindingChangedAt(ref))
}
// sameRockBindingEvent 兼容上游同一事件的不同时间格式,避免仅因字符串格式差异
// 把分配时保存的历史绑定重新识别成本轮绑定。
func sameRockBindingEvent(snapshotAt, upstreamAt string) bool {
snapshotAt = strings.TrimSpace(snapshotAt)
upstreamAt = strings.TrimSpace(upstreamAt)
if snapshotAt == "" || upstreamAt == "" {
return false
}
snapshotTime, snapshotOK := parseRockTime(snapshotAt)
upstreamTime, upstreamOK := parseRockTime(upstreamAt)
if snapshotOK && upstreamOK {
diff := snapshotTime.Sub(upstreamTime)
if diff < 0 {
diff = -diff
}
return diff <= time.Second
}
return snapshotAt == upstreamAt
}
func bindingSnapshot(token *model.RockToken) (BindingSnapshot, bool) {
@@ -78,10 +96,13 @@ func rockClaimBaseline(token *model.RockToken) time.Time {
if token == nil {
return time.Time{}
}
if token.UsingAt != nil && !token.UsingAt.IsZero() {
return token.UsingAt.UTC()
}
if token.ClaimedAt != nil && !token.ClaimedAt.IsZero() {
return token.ClaimedAt.UTC()
}
return token.CreatedAt.UTC()
return time.Time{}
}
// parseRockTime 解析上游返回的时间串。
@@ -106,34 +127,82 @@ func parseRockTime(raw string) (time.Time, bool) {
return time.Time{}, false
}
func bindChangedAfterClaim(token *model.RockToken, changedAt string) bool {
changed, ok := parseRockTime(changedAt)
if !ok {
return false
}
baseline := rockClaimBaseline(token)
if baseline.IsZero() {
return true
}
return !changed.Before(baseline.Add(-time.Minute))
type rockDeliveryEvidence struct {
Current bool
Ambiguous bool
At time.Time
FulfillID int64
Source string
}
func deliveryAfterClaim(token *model.RockToken, ref *RockRefreshResponse) bool {
if ref == nil {
return false
// rockCurrentCycleDelivery 只认本轮 using_at 之后产生的兑换时间或发货流水。
// 明确早于本轮的历史记录忽略;状态为 redeemed 却没有可解析时间时按
// “已发货、数据待核对”保守处理,禁止释放。
func rockCurrentCycleDelivery(token *model.RockToken, ref *RockRefreshResponse) rockDeliveryEvidence {
if token == nil || ref == nil {
return rockDeliveryEvidence{}
}
baseline := rockClaimBaseline(token)
if baseline.IsZero() {
return false
}
cutoff := baseline.Add(-time.Minute)
evidence := rockDeliveryEvidence{}
for _, item := range ref.DeliveryList {
deliveredAt, ok := parseRockTime(item.Time)
at, ok := parseRockTime(item.Time)
if !ok {
continue
}
if !deliveredAt.Before(baseline.Add(-time.Minute)) {
return true
if baseline.IsZero() {
if !evidence.Ambiguous || at.After(evidence.At) {
evidence.Ambiguous = true
evidence.At = at
evidence.FulfillID = item.FulfillID
evidence.Source = "delivery_list"
}
continue
}
if at.Before(cutoff) {
continue
}
if !evidence.Current || at.After(evidence.At) ||
(at.Equal(evidence.At) && item.FulfillID > evidence.FulfillID) {
evidence.Current = true
evidence.Ambiguous = false
evidence.At = at
evidence.FulfillID = item.FulfillID
evidence.Source = "delivery_list"
}
}
return false
rawRedeemedAt := strings.TrimSpace(ref.Token.RedeemedAt)
if rawRedeemedAt != "" {
if at, ok := parseRockTime(rawRedeemedAt); ok {
if baseline.IsZero() {
evidence.Ambiguous = true
if evidence.At.IsZero() || at.After(evidence.At) {
evidence.At = at
evidence.Source = "redeemed_at"
}
} else if !at.Before(cutoff) &&
(!evidence.Current || at.After(evidence.At)) {
evidence.Current = true
evidence.Ambiguous = false
evidence.At = at
evidence.Source = "redeemed_at"
}
} else {
evidence.Ambiguous = true
if evidence.Source == "" {
evidence.Source = "redeemed_at"
}
}
}
if strings.EqualFold(strings.TrimSpace(ref.Token.Status), model.RockStatusRedeemed) &&
rawRedeemedAt == "" && !evidence.Current {
evidence.Ambiguous = true
if evidence.Source == "" {
evidence.Source = "status"
}
}
return evidence
}
+2 -2
View File
@@ -34,7 +34,7 @@ type RockStaleRefreshResult struct {
// RefreshStaleTokens 定时巡检入口:受「自动刷新」开关控制,关闭时直接跳过。
//
// 与手动「一键刷新」使用同一套扫描规则:绑定错误 + 使用超时 + 绑定超时。
// 与手动「一键刷新」使用同一套扫描规则:绑定错误 + 待绑定超时 + 绑定后未发货。
func (s *RockService) RefreshStaleTokens(ctx context.Context) {
cfg, err := s.GetConfig(ctx)
if err != nil {
@@ -59,7 +59,7 @@ func (s *RockService) RefreshStaleTokens(ctx context.Context) {
result.Scanned, result.Fixed, result.Remaining, result.Failed)
}
// RefreshAllStaleTokens 手动「一键刷新」:绑定错误 + 使用超时 + 绑定超时,
// RefreshAllStaleTokens 手动「一键刷新」:绑定错误 + 待绑定超时 + 绑定后未发货,
// 忽略自动开关,同步执行并返回结果。
func (s *RockService) RefreshAllStaleTokens(ctx context.Context) (*RockStaleRefreshResult, error) {
return s.refreshStaleTokens(ctx)
+4
View File
@@ -567,6 +567,9 @@ func (s *RockService) ListTokens(ctx context.Context, page, pageSize int, status
if pageSize <= 0 {
pageSize = 20
}
if pageSize > 100 {
pageSize = 100
}
cfg, err := s.GetConfig(ctx)
if err != nil {
@@ -593,6 +596,7 @@ func (s *RockService) ListTokens(ctx context.Context, page, pageSize int, status
applyRockTokenOverdueFlags(tokens, now, warnDays, dangerDays, boundWarnDays)
s.prepareRockTokenList(ctx, tokens)
s.fillRockTokenAnomalyFlags(ctx, tokens)
s.fillRockTokenIncidentFlags(ctx, tokens)
return tokens, total, nil
}
+2
View File
@@ -55,6 +55,8 @@ func (s *RockService) ListSupportBoundTokens(ctx context.Context, page, pageSize
}
applyRockTokenOverdueFlags(tokens, now, warnDays, dangerDays, boundWarnDays)
s.prepareRockTokenList(ctx, tokens)
s.fillRockTokenAnomalyFlags(ctx, tokens)
s.fillRockTokenIncidentFlags(ctx, tokens)
return tokens, total, nil
}
+12 -5
View File
@@ -18,8 +18,9 @@ const (
rockTokenDeleteAllMax = 5000
// 虚拟筛选状态:不落库,只在查询时换算成「主状态 + 时间阈值」。
rockTokenStatusUsingOverdue = "using_overdue"
rockTokenStatusBoundOverdue = "bound_overdue"
rockTokenStatusUsingOverdue = "using_overdue"
rockTokenStatusBoundOverdue = "bound_overdue"
rockTokenStatusNoEntitlement = "no_entitlement"
)
var rockTokenDangerousStatuses = map[string]bool{
@@ -86,7 +87,8 @@ func isKnownRockTokenStatus(status string) bool {
switch status {
case "", model.RockStatusAvailable, model.RockStatusUsing, model.RockStatusBound,
model.RockStatusRedeemed, model.RockStatusMismatch, model.RockStatusAbnormal,
rockTokenStatusUsingOverdue, rockTokenStatusBoundOverdue, model.RockStatusAnomaly:
rockTokenStatusUsingOverdue, rockTokenStatusBoundOverdue, rockTokenStatusNoEntitlement,
model.RockStatusAnomaly:
return true
default:
return false
@@ -144,6 +146,9 @@ func applyRockTokenOverdueFlags(tokens []model.RockToken, now time.Time, warnDay
if tokens[i].BoundAt == nil {
continue
}
if rockTokenRedeemPending(&tokens[i]) {
continue
}
days := now.Sub(*tokens[i].BoundAt).Hours() / 24
if days < 0 {
days = 0
@@ -162,10 +167,12 @@ func applyRockTokenListFilters(db *gorm.DB, status, search string, now time.Time
db = db.Where("status = ? AND using_at IS NOT NULL AND using_at <= ?",
model.RockStatusUsing, rockOverdueCutoff(now, warnDays))
case rockTokenStatusBoundOverdue:
db = db.Where("status = ? AND bound_at IS NOT NULL AND bound_at <= ?",
db = db.Where("status = ? AND bound_at IS NOT NULL AND bound_at <= ? AND redeem_requested_at IS NULL",
model.RockStatusBound, rockOverdueCutoff(now, boundWarnDays))
case rockTokenStatusNoEntitlement:
db = rockTokenIncidentFilter(db, model.RockIncidentCategoryNoEntitlement)
case model.RockStatusAnomaly:
// 异常是派生筛选:只看历史流转,不限定主状态,也不改状态。
// 异常是派生筛选:包含历史异常和除“无权益”外的开放发货错误。
db = rockTokenAnomalyFilter(db)
default:
if value := strings.TrimSpace(status); value != "" {
@@ -82,6 +82,7 @@ func (s *RockService) ListDeletedRockTokens(ctx context.Context, page, pageSize
}
applyRockTokenOverdueFlags(tokens, now, warnDays, dangerDays, boundWarnDays)
s.fillRockTokenAnomalyFlags(ctx, tokens)
s.fillRockTokenIncidentFlags(ctx, tokens)
return tokens, total, nil
}
@@ -15,6 +15,7 @@ const (
rockTokenEventStatusChanged = "status_changed"
rockTokenEventDeleted = "deleted"
rockTokenEventRestored = "restored"
rockTokenEventIncident = "incident"
)
// Token 状态来源,用于前端展示流转原因。
@@ -31,6 +32,7 @@ const (
rockTokenSourcePackageSync = "package_sync"
rockTokenSourceDelete = "delete"
rockTokenSourceRestore = "restore"
rockTokenSourceIncident = "incident"
)
// writeRockTokenStatusLog 记录 Token 生命周期/状态变化。
+5 -11
View File
@@ -7,10 +7,8 @@ import (
"net/http/httptest"
"strings"
"testing"
"time"
"affiliate_dash/internal/model"
"affiliate_dash/internal/pkg/timeutil"
)
func TestAssignTokenForOrder_SetsUsingAtAndReleaseClears(t *testing.T) {
@@ -89,7 +87,7 @@ func TestAssignTokenForOrder_SetsUsingAtAndReleaseClears(t *testing.T) {
}
}
func TestImportPackageDetail_SetsUsingAtFromClaimedAt(t *testing.T) {
func TestImportPackageDetail_ResidualUsingStatusBecomesAvailable(t *testing.T) {
db := newRockTestDB(t)
svc := NewRockService(db, NewRockClient("https://example.com"))
@@ -116,15 +114,11 @@ func TestImportPackageDetail_SetsUsingAtFromClaimedAt(t *testing.T) {
if err := db.Where("upstream_token_id = ?", "30001").First(&row).Error; err != nil {
t.Fatalf("查询导入 Token 失败: %v", err)
}
if row.Status != model.RockStatusUsing {
t.Fatalf("期望状态 using,实际 %s", row.Status)
if row.Status != model.RockStatusAvailable {
t.Fatalf("无真实绑定事件时应导入为 available,实际 %s", row.Status)
}
if row.UsingAt == nil {
t.Fatalf("上游状态为 using 时必须写入 using_at")
}
expected := time.Date(2026, 10, 27, 23, 31, 59, 0, timeutil.Location())
if !row.UsingAt.Equal(expected) {
t.Fatalf("using_at 应取上游 claimed_at,实际 %v", row.UsingAt)
if row.UsingAt != nil {
t.Fatalf("available 记录不应写入 using_at,实际 %v", row.UsingAt)
}
}
@@ -0,0 +1,872 @@
# Rock 绑定锚点与超时语义优化方案
> 背景:Rock(0666.pro)的 Token 会被循环使用。上游包详情里的
> `bound_account` / `bound_account_id` / `status` 可能保留上一任买家的绑定信息,
> 不能单独作为本轮绑定依据。真正能证明本轮发生绑定的是 `bind_changed_at`。
>
> 本文定义优化方案,并记录本次代码、数据库与页面落地的实际状态。
---
## 一、目标
1. 统一 Rock Token 的绑定判定口径,消除多套互相矛盾的 `IsBound` 判断。
2. 明确“待绑定超时”和“绑定后未发货”的业务语义。
3. 阻止历史绑定残留把 `using` 错误推进到 `bound`。
4. 为“绑定超时”记录提供安全、可追溯的人工释放回库存入口。
5. 保持现有状态值、筛选参数和 API 路径兼容,降低上线风险。
6. 持久化自动发货错误,支持“无权益”独立筛选和统一人工处理。
7. 阻止确定性上游业务错误触发无效自动重试。
---
## 二、业务语义
### 2.1 主链路
```text
available
-> using 提货链接已分配给订单,等待买家扫码
-> bound 本轮真实扫码绑定成功,等待完成兑换发货
-> redeemed 上游确认发货完成
```
异常终态:
```text
using / bound
-> mismatch 本轮真实扫码,但 UID 与预期不一致
-> abnormal 已确认失效或上游异常
```
`bound` 不是最终状态。买家已扫码绑定但兑换仍在异步确认时,主状态继续保持
`bound`,同时通过 `redeem_requested_at` / `redeem_checked_at` 等字段表示
“发货确认中”。
### 2.2 两种超时
| 当前代码值 | 当前展示名 | 建议展示名 | 业务含义 |
|---|---|---|---|
| `using_overdue` | 使用超时 | 待绑定超时 | 链接已经发出,但本轮一直没有真实扫码绑定 |
| `bound_overdue` | 绑定超时 | 绑定后未发货 | 本轮已经真实扫码绑定,但长时间没有完成兑换发货 |
判定公式:
```text
待绑定超时:
status = using
AND using_at IS NOT NULL
AND now - using_at >= using_warn_days
绑定后未发货:
status = bound
AND bound_at IS NOT NULL
AND now - bound_at >= bound_warn_days
AND 发货确认中标记不存在
```
默认阈值:
- `using_warn_days = 3`
- `using_danger_days = 5`
- `bound_warn_days = 2`
“使用超时”不是指买家已经开始使用 Token,而是指提货链接发出后一直没有完成
扫码绑定。建议保留 `using_overdue` 作为 API 和数据库兼容值,只修改前端展示名。
“绑定超时”不是指绑定动作超时,而是指绑定已经成功,但后续发货没有走完。
建议保留 `bound_overdue` 作为兼容值,前端展示为“绑定后未发货”。
### 2.3 发货确认中
以下条件表示 Token 已提交兑换、等待上游异步确认:
```text
status = bound
AND redeem_requested_at IS NOT NULL
AND redeemed_at IS NULL
```
这类记录不应继续计入普通“绑定后未发货”:
- 不应因为等待上游确认而触发人工释放。
- 如未来需要告警,应新增独立的“发货确认超时”筛选。
- 当前阶段先在 `bound_overdue` 判定中排除,避免和“绑定后未发货”重叠。
---
## 三、上游字段锚点
### 3.1 字段优先级
| 字段 | 含义 | 是否可作为本轮绑定依据 | 使用方式 |
|---|---|---|---|
| `bind_changed_at` | 绑定/换绑时间,上游页面红色“绑定”时间 | 是,主锚点 | 与本轮 `using_at` 比较 |
| `bind_change_type` | `bind` / `rebind` 事件类型 | 辅助 | 只用于说明事件类型 |
| `redeemed_at` | 上游兑换时间 | 是,发货锚点 | 与本轮 `using_at` 比较 |
| `bound_account` | 最后绑定角色名 | 否 | 只在存在真实绑定事件时作为身份展示 |
| `bound_account_id` | 最后绑定角色 UID | 否 | 只在存在真实绑定事件时作为身份展示 |
| `previous_bound_account` | 上一次绑定角色 | 否 | 仅用于换绑追溯 |
| `previous_bound_account_id` | 上一次绑定 UID | 否 | 仅用于换绑追溯 |
| `status` | 上游汇总状态 | 否,不能单独判断 | `redeemed` 可用于交叉确认 |
| `region` / `phone` | 区服、领取信息 | 否 | 不作为绑定布尔值 |
| `updated_at` | 上游数据更新时间 | 否 | 不参与绑定判定 |
### 3.2 绑定事件
以下字段非空才代表存在绑定事件:
```text
bind_changed_at
```
`bind_change_type` 非空但 `bind_changed_at` 为空时属于上游数据不一致:
- 不自动判定为本轮绑定。
- 不自动释放。
- 释放前要求人工确认,并在操作日志中记录 `ambiguous_bind_event`。
如果当前刷新接口额外返回 `binding.bindTime`,只有在以下条件同时满足时才可作为
`bind_changed_at` 的兼容回退:
```text
binding.bindTime 非空
AND binding.bindTime 可解析
AND binding.bindTime 晚于本轮 using_at
```
单纯 `binding.bound = true` 或只有 `binding.roleId` 不足以判定本轮绑定。
### 3.3 兑换事件
以下任一条件可以判定当前周期已发货:
```text
redeemed_at 晚于本轮 using_at
OR deliveryList 中存在晚于本轮 using_at 的履约记录
```
如果 `status = redeemed` 但 `redeemed_at` 为空,按“已发货、数据待核对”处理,
禁止释放回库存,并在备注或日志中标明 `redeemed_at_missing`。
---
## 四、本地字段语义
| 字段 | 统一语义 |
|---|---|
| `claimed_at` | 上游 Token 领取/创建时间,保留为业务来源信息 |
| `using_at` | 本轮本地分配时间,作为绑定新鲜度的唯一时间基线 |
| `bound_at` | 本轮真实绑定时间,只能来自本轮 `bind_changed_at` |
| `redeemed_at` | 本轮真实发货完成时间 |
| `initial_bound_snapshot` | 分配瞬间保存的上游残留绑定快照 |
| `actual_role_name` / `actual_role_id` | 本轮真实绑定角色;无本轮绑定事件时不应视为当前角色 |
| `redeem_requested_at` | 本地提交兑换时间,表示发货确认中 |
### 4.1 时间基线
本轮判定的唯一基线是 `using_at`:
```text
cycleBaseline = using_at
```
不要再使用 `created_at` 作为绑定新鲜度基线。`created_at` 是本地记录创建时间,
与订单分配轮次无关。
`claimed_at` 可以作为旧数据兼容回退,但只允许在 `using_at` 为空时使用:
```text
cycleBaseline = using_at
fallback = claimed_at
禁止 fallback = created_at
```
如果两者都为空,本轮绑定状态为“不可判定”,必须人工确认,不能自动写 `bound_at`。
### 4.2 初始绑定快照
每次 `available -> using` 分配时,重新抓取并保存上游原始绑定快照:
```json
{
"status": "bound",
"bound_account": "那若水",
"bound_account_id": "4613930835",
"bind_changed_at": "",
"bind_change_type": "",
"redeemed_at": "",
"captured_at": "2026-11-14T21:10:22+08:00"
}
```
快照中的 `Bound` 不再使用“状态为 bound 或存在账号 ID”作为布尔值,而应改为:
```text
bound = bind_changed_at 非空
```
`bound_account` / `bound_account_id` 只作为残留身份保存,用于后续比对和审计。
---
## 五、唯一判定函数
新增统一分类入口,所有业务路径共用:
```go
type RockBindingEvidence struct {
Kind string // delivered / current_bound / residual / pending / ambiguous
BindChangedAt time.Time
RedeemedAt time.Time
RoleName string
RoleID string
Reason string
}
func classifyRockBinding(
token *model.RockToken,
ref *RockRefreshResponse,
) RockBindingEvidence
```
### 5.1 分类顺序
```text
1. 本地发货确认中
-> pending
2. 本轮 redeemed_at 或本轮 deliveryList
-> delivered
3. bind_changed_at 晚于 cycleBaseline
-> current_bound
4. bind_changed_at 早于 cycleBaseline
-> residual
5. bind_changed_at 与 initial_bound_snapshot 相同
-> residual
6. bind_changed_at 为空,但存在 bound_account / bound_account_id
-> residual
7. bind_change_type 非空但时间缺失
-> ambiguous
8. 其他
-> unbound
```
时间比较允许 1 分钟误差,用于吸收 Token 领取和分配之间的时钟差。
### 5.2 分类后的状态动作
| 分类 | 本地状态 | 时间字段 | 是否允许释放 |
|---|---|---|---|
| `unbound` | `using` | 清空 `bound_at` | 允许,仍需确认订单不会履约 |
| `residual` | `using` | 清空 `bound_at` | 允许,走普通释放校验 |
| `current_bound` | `bound` | 写入上游 `bind_changed_at` | 允许人工强制释放 |
| `pending` | `bound` + redeem 标记 | 保持原值 | 禁止 |
| `delivered` | `redeemed` | 写入本轮 `redeemed_at` | 禁止 |
| `ambiguous` | 保持原状态并告警 | 不自动写入 | 仅人工强制释放 |
---
## 六、当前代码问题
### 6.1 绑定布尔值口径分裂
- `RockTokenData.IsBound()` 将 `status=bound`、`status=redeemed` 或
`bound_account_id` 非空都视为绑定。
- `rockUpstreamBound()` 将 `status=bound` 视为当前绑定。
- `isUnboundRockToken()` 已使用 `bind_changed_at` / `bind_change_type`,口径与其他路径不一致。
- `rockBuyerNewlyBound()` 有快照判断,但入口仍依赖 `ref.IsBound()`。
### 6.2 刷新会制造错误的绑定时间
`RefreshTokenBinding` 遇到 `status=bound` 后直接写本地 `bound`:
- 清除已有 `mismatch`。
- 当 `bound_at` 为空时,使用 `bind_changed_at`;缺失时回退到当前时间。
这会把没有本轮绑定事件的残留账号写成“本轮已绑定”,随后进入错误的
“绑定后未发货”筛选。
### 6.3 时间基线可能错误
`rockClaimBaseline()` 会使用:
```text
claimed_at -> created_at
```
其中 `created_at` 与订单分配轮次无关。旧 Token 重新分配后,历史绑定时间可能被
错误判断为晚于基线。
### 6.4 释放状态门禁不完整
- 普通释放只允许 `using`,`bound / 绑定后未发货` 没有入口。
- force 模式当前可能允许上游已发货、异常或校验失败的 Token 回库存。
- 如果只放开 `bound` 状态而不先增加 pending / delivered 硬门禁,会产生重复发货风险。
---
## 七、目标行为
### 7.1 刷新绑定
刷新时只同步真实绑定事件:
```text
本轮 bind_changed_at 存在
-> status=bound
-> bound_at=上游 bind_changed_at
-> 按预期 UID 判断 bound 或 mismatch
本轮 bind_changed_at 不存在
-> 保持 using
-> 不写 bound_at
-> 不把 bound_account 当成本轮角色
```
### 7.2 待绑定超时
```text
status = using
AND using_at <= now - using_warn_days
```
前端展示“待绑定超时”。操作列继续显示释放入口。
### 7.3 绑定后未发货
```text
status = bound
AND bound_at <= now - bound_warn_days
AND 非发货确认中
```
前端展示“绑定后未发货”。操作列增加“释放回库存”按钮。
释放前必须调用 `release-check`:
| 上游校验结果 | 行为 |
|---|---|
| 本轮无绑定事件 | 普通释放 |
| 本轮已绑定 | 强制确认释放 |
| 发货确认中 | 硬阻断 |
| 已发货 | 硬阻断 |
| 上游异常 / Token 不存在 | 硬阻断,引导“上报失效” |
| 校验失败 | 硬阻断,不允许盲放 |
### 7.4 释放事务
允许来源状态:
```text
using
bound
```
释放时统一执行:
```text
status -> available
清空 assigned_order_no / expected_uid
清空 actual_role_name / actual_role_id / actual_channel
清空 mismatch / bound_at / using_at / redeemed_at
清空 upstream_fulfill_id
清空 redeem_* 标记
清空 initial_bound_snapshot
写入 restocked_at
解除订单 result_data 中的 rock_token 关联
写状态历史和审计日志
```
状态历史中的 `from_status` 必须保存真实的 `using` 或 `bound`。
### 7.5 下一次分配
Token 释放后再次进入 `available`,下一次分配时:
1. 清除上一轮本地绑定字段。
2. 设置新的 `using_at`。
3. 重新抓取上游绑定快照。
4. 以新 `using_at` 作为本轮唯一时间基线。
即使上游仍保留上一任账号,也只能作为快照残留,不能直接判定为新绑定。
---
## 八、兼容性与数据修复
### 8.1 保持兼容
- 数据库状态值保持不变:继续使用 `using`、`bound`、`redeemed`。
- API 筛选值保持不变:继续使用 `using_overdue`、`bound_overdue`。
- 前端只调整中文展示名和按钮条件。
- 第一阶段不新增数据库字段,优先使用现有字段完成口径统一。
### 8.2 历史异常数据
重点排查:
```text
status = bound
AND bound_at IS NOT NULL
AND redeem_requested_at IS NULL
AND raw_data.bind_changed_at 为空
```
处理流程:
1. 先补充只读审计脚本,统计数量和抽样明细。
2. 人工确认这些记录没有正在进行的发货。
3. 刷新上游状态后,把无本轮绑定事件的记录修复为 `using`。
4. 清理错误的 `bound_at` 和本轮角色字段。
5. 写状态日志,来源标记为 `binding_anchor_repair`。
禁止直接批量把所有 `bound` 改成 `using`。发货确认中、已发货和人工锁定记录必须排除。
---
## 九、前端调整
### 9.1 文案
| 当前文案 | 新文案 |
|---|---|
| 使用超时 | 待绑定超时 |
| 绑定超时 2.1 天 | 绑定后未发货 2.1 天 |
| 已授权绑定 | 已绑定待发货 |
### 9.2 操作列
- `using`:显示“释放回库存”。
- `bound` 且非发货确认中:显示“释放回库存”。
- `bound` 且发货确认中:显示“发货确认中”,不显示释放按钮。
- `redeemed` / `abnormal`:不显示释放按钮。
### 9.3 释放弹窗
弹窗需要明确展示:
- 本地状态。
- 本轮绑定时间。
- 上游绑定角色和 UID。
- 是否为本轮真实绑定。
- 上游是否已发货。
- 是否处于发货确认中。
强制释放必须输入固定确认文案,并记录操作人、上游状态和判定原因。
### 9.4 错误分类筛选
筛选栏最终顺序:
```text
全部 | 可用 | 使用中 | 已绑定 | 已发货 |
绑定错误 | 已上报失效 | 待绑定超时 | 绑定后未发货 | 无权益 | 异常
```
- “无权益”独立放在“异常”之前。
- 除“无权益”外,其他未处理的发货错误全部归入最后的“异常”。
- “无权益”和“异常”都是错误事件筛选,不替代 Token 主状态。
- 同一个 Token 可以同时出现在“已绑定”和“无权益/异常”中,这是预期行为。
- 只有完成人工处理动作后,对应错误事件才从筛选计数中消失。
---
## 十、测试计划
### 10.1 分类函数
- `bind_changed_at` 晚于 `using_at`:`current_bound`。
- `bind_changed_at` 早于 `using_at`:`residual`。
- `bind_changed_at` 为空但 `bound_account_id` 非空:`residual`。
- `bind_change_type` 非空但时间缺失:`ambiguous`。
- 本轮 `redeemed_at` 非空:`delivered`。
- 历史 `redeemed_at` 早于 `using_at`:不能错误判定本轮已发货。
- `redeem_requested_at` 非空且 `redeemed_at` 为空:`pending`。
### 10.2 刷新
- 残留绑定刷新后保持 `using`,不写 `bound_at`。
- 真实绑定刷新后写 `bound`,`bound_at` 使用上游时间。
- 真实绑定且 UID 不一致时进入 `mismatch`。
- 历史已兑换状态不能被当成当前轮次发货。
### 10.3 超时
- `using` 超过阈值进入“待绑定超时”。
- `bound` 超过阈值且非 pending 进入“绑定后未发货”。
- pending 不计入“绑定后未发货”。
- `redeemed` 不进入任何绑定超时。
### 10.4 释放
- `using + residual` 可普通释放。
- `bound + current_bound` 必须强制确认。
- pending 禁止释放。
- delivered 禁止释放。
- abnormal / not_found 禁止释放。
- 释放后所有绑定和履约字段清空,订单关联解除。
- 状态历史记录真实来源状态和判定原因。
### 10.5 回归用例
- HAR 中的 Token `176369` 不再进入“绑定后未发货”。
- `176369` 刷新后按本轮无绑定事件处理,进入待绑定/历史残留分支。
- 其余具有本轮 `bind_changed_at` 和 `redeemed_at` 的 Token 仍正常显示已发货。
---
## 十一、实施顺序
1. 新增统一 `classifyRockBinding` 和单元测试。
2. 用统一分类替换 `RockTokenData.IsBound()`、`rockUpstreamBound()`、
`rockBuyerNewlyBound()` 的业务调用。
3. 修正 `RefreshTokenBinding`,禁止残留状态写 `bound_at`。
4. 修正时间基线,使用 `using_at`,禁止回退 `created_at`。
5. 调整超时筛选,pending 排除出 `bound_overdue`。
6. 扩展 `release-check` 和释放事务,先补硬门禁再加前端按钮。
7. 更新前端文案和操作列。
8. 运行历史异常数据审计,不直接批量修复。
9. 小范围验证后再执行历史数据修复。
---
## 十二、验收标准
- `status=bound` 但 `bind_changed_at` 为空的旧账号不再被识别为本轮绑定。
- “待绑定超时”表示链接发出后未发生本轮真实绑定。
- “绑定后未发货”表示本轮真实绑定成功,但未完成兑换发货。
- 发货确认中的 Token 不进入“绑定后未发货”。
- 绑定后未发货支持人工释放,但必须经过上游复核和强制确认。
- 已发货、pending、异常和校验失败的 Token 无法被错误释放。
- `176369` 这类 Token 不再因历史残留账号被误判为绑定后未发货。
---
## 十三、自动发货错误记录与人工处理计划
### 13.1 生产排查结论
2026-11-14 对生产日志和数据库进行了只读排查,确认上游实际错误为:
```text
用户不存在可以兑换的可用权益
```
排查结果:
- 累计发现 16 笔该错误订单,全部仍为 `ship_failed`。
- 其中 9 个 Token 仍为 `bound`。
- 7 个 Token 后续因上游卡密不存在或状态异常转为 `abnormal`。
- 当前全库有 27 个 `bound` Token 关联在 `ship_failed` 订单上。
- 错误报文已写入 `rock_delivery_logs`,但 Token 的
`redeem_last_error` 和 `remark` 为空。
- 系统未对确定性 400 业务错误做分类,同一订单可能被重复提交上游。
- 管理端可从“历史 -> 发货报文”查看原始记录,但没有可处理的错误事件。
### 13.2 错误分类
内部错误码与前端归类:
| 内部错误码 | 上游特征 | 前端归类 | 是否允许自动重试 |
|---|---|---|---|
| `no_entitlement` | 用户不存在可以兑换的可用权益 | 无权益 | 否 |
| `duplicate_item` | 同一角色无法重复兑换已兑换过的道具 | 异常 | 否 |
| `gift_exhausted` | 礼包已发放完毕 | 异常 | 否 |
| `token_already_redeemed` | 该 Token 已兑换 | 异常 | 否 |
| `token_not_redeemable` | 该 Token 暂不可兑换 | 异常 | 否 |
| `token_not_found` | Token 不存在 | 异常 | 否 |
| `upstream_timeout` | HTTP 超时、连接失败 | 异常 | 否,人工确认后重试 |
| `upstream_rate_limited` | 请求过于频繁或频控错误 | 异常 | 否,人工确认后重试 |
| `ship_timeout` | 已提交但长时间未回传 | 异常 | 否,先对上游客服端 |
| `bind_mismatch` | 绑定 UID 与预期 UID 不一致 | 异常 | 否 |
| `order_cancelled` | 订单取消时 Token 已绑定或已发货 | 异常 | 否 |
| `other` | 未识别错误 | 异常 | 否 |
“无权益”表示当前绑定角色在上游没有可兑换权益。它不能证明 Token 已失效,
因此不能直接自动标记为 `abnormal`。
### 13.3 错误持久化模型
新增表 `rock_delivery_incidents`,每条记录代表一次需要人工处理的发货异常:
```sql
CREATE TABLE rock_delivery_incidents (
id BIGSERIAL PRIMARY KEY,
token_id BIGINT NOT NULL,
order_no VARCHAR(128) NOT NULL DEFAULT '',
upstream_token_id VARCHAR(64) NOT NULL DEFAULT '',
category VARCHAR(40) NOT NULL,
status VARCHAR(16) NOT NULL DEFAULT 'open',
blocking_retry BOOLEAN NOT NULL DEFAULT TRUE,
message TEXT NOT NULL DEFAULT '',
upstream_http_status INT NOT NULL DEFAULT 0,
upstream_response TEXT NOT NULL DEFAULT '',
occurrences INT NOT NULL DEFAULT 1,
first_seen_at TIMESTAMPTZ NOT NULL,
last_seen_at TIMESTAMPTZ NOT NULL,
resolved_at TIMESTAMPTZ,
resolved_by BIGINT,
resolution_action VARCHAR(32) NOT NULL DEFAULT '',
resolution_note TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
```
建议索引:
```text
(status, category, last_seen_at DESC)
(token_id, created_at DESC)
(order_no, created_at DESC)
唯一开放事件:(token_id, order_no, category) WHERE status = 'open'
```
说明:
- 原始请求和响应继续保存在 `rock_delivery_logs`。
- `rock_delivery_incidents` 只保存分类、处理状态和人工处置结果。
- 禁止把 Bearer Token、JWT、请求签名等敏感头写入异常表。
- 同一 Token、订单、错误类型重复发生时应累加 `occurrences`,不能无限新增重复事件。
同时在 `rock_tokens` 上保留便于列表快速展示的摘要字段:
- `redeem_last_error`
- `remark`
- `updated_at`
错误写入后,Token 历史和备注中必须能看到最近一次失败原因。
### 13.4 发货失败写入流程
Rock 兑换失败时按顺序执行:
1. 解析 HTTP 状态和上游错误文本。
2. 归类为内部错误码。
3. Upsert 对应 `rock_delivery_incidents`。
4. 写入 Token 的 `redeem_last_error` 和系统备注。
5. 记录 `redeem_result failed` 和 `incident_opened` 日志。
6. 将订单更新为 `ship_failed`,保留 `fail_stage`、`fail_reason`、`fail_at`。
7. 设置 `blocking_retry=true`,阻止相同订单再次直接请求上游。
不能在只写订单错误后直接返回。否则订单失败、Token 状态和错误列表会彼此脱节。
### 13.5 自动重试策略
以下情况不属于异常,系统继续正常轮询:
- 上游返回 `pending`。
- 本地处于“发货确认中”。
- 上游确认仍在异步处理。
以下情况属于开放异常,禁止自动重复提交兑换:
- 无权益。
- 重复道具。
- 礼包耗尽。
- Token 已兑换或不可兑换。
- 绑定 UID 不一致。
- 未识别的确定性 400 业务错误。
对于超时和频控错误:
- 不立即重复兑换。
- 先记录异常并人工确认上游状态。
- 人工确认后只允许一次受控重试。
公共发货接口重复调用时:
- 如果存在 `blocking_retry=true` 的开放异常,直接返回已缓存的失败结果。
- 不再重复请求 0666.pro。
- 不通过隐式重试清理异常,必须由管理员执行人工动作。
### 13.6 人工处理闭环
管理端的“处理异常”弹窗展示:
- Token、订单号、上游 Token ID。
- 当前 Token 状态和订单状态。
- 错误分类、错误文本、HTTP 状态。
- 首次发生时间、最近发生时间、累计次数。
- 预期 UID、实际角色、绑定时间。
- 最近一次上游请求和响应的关键内容。
允许的人工动作:
| 动作 | 结果 |
|---|---|
| 人工重试发货 | 仅对可重试错误开放,只允许一次受控重试 |
| 释放回库存 | 先走 `release-check`,成功后关闭异常并释放 Token |
| 上报失效 | Token 转为 `abnormal`,关闭异常并记录备注 |
| 标记已处理 | 仅关闭异常事件,不改变 Token 和订单状态 |
所有动作必须记录:
- 操作人。
- 操作时间。
- 动作类型。
- 原错误分类。
- 处理后 Token 状态。
- 处理备注。
“标记已处理”不能用于绕过 Token 状态限制。例如 `bound` Token 仍需通过释放校验
才能回到库存池。
### 13.7 无权益订单的当前处理建议
对已存在的 16 笔“无权益”订单:
1. 不自动重新提交兑换。
2. 在 Token 列表的“无权益”筛选中统一展示。
3. 人工确认订单是否已退款、是否允许更换绑定的游戏角色。
4. 如果确认 Token 未被消耗,可人工释放回库存。
5. 如果上游已作废 Token,则执行“上报失效”。
6. 订单保持 `ship_failed`,直到业务侧完成退款、补发或人工重试。
不能把所有“无权益”Token 直接重置为 `available`。当前有 9 个 Token 仍是 `bound`,
必须先确认它们是否仍绑定在下一任用户不可使用的角色上。
### 13.8 API 与统计
Token 列表新增筛选值:
```text
no_entitlement
anomaly
```
语义:
- `no_entitlement`:存在开放的 `no_entitlement` 异常事件。
- `anomaly`:存在除 `no_entitlement` 以外的任意开放异常事件。
统计接口新增:
```text
no_entitlement_stock
unresolved_anomaly_stock
```
已实现接口:
```text
GET /api/platform/rock/tokens/:id/incidents
GET /api/platform/rock/incidents
PATCH /api/platform/rock/incidents/:id
POST /api/platform/rock/incidents/:id/retry
```
接口动作:
```text
PATCH :id 标记异常已处理,仅关闭事件
POST :id/retry 超时/频控异常的一次受控人工重试
Token release API 释放前复核,成功后关闭同 Token 的开放异常
Token mark-abnormal API 上报失效,成功后关闭同 Token 的开放异常
```
### 13.9 前端调整
筛选栏在“绑定后未发货”和“异常”之间增加“无权益”:
```text
待绑定超时 | 绑定后未发货 | 无权益 | 异常
```
- “无权益”使用独立红色计数。
- 其他所有开放错误统一计入最后的“异常”。
- Token 状态列增加独立错误标签:
- `无权益`
- `异常`
- 有开放异常时,操作列增加“处理异常”。
- “历史”抽屉增加“异常记录”标签页,和“状态历史”“发货报文”并列。
### 13.10 历史数据策略
- `029` 迁移只创建异常事件表与索引,不回填历史订单。
- 历史 `ship_failed`、取消订单和人工已处理记录不自动生成开放异常。
- 部署后新发生的发货错误、取消冲突正常写入异常事件。
- 历史 Token 主状态不由迁移自动修改。
### 13.11 测试补充
- “用户不存在可以兑换的可用权益”必须分类为 `no_entitlement`。
- 其他上游错误必须进入“异常”,不能进入“无权益”。
- 同一 Token 连续 3 次相同错误只保留 1 个开放事件,`occurrences=3`。
- 存在开放异常时,重复发货请求不得再次调用上游兑换接口。
- 人工释放成功后异常关闭、Token 回 `available`。
- 上报失效后异常关闭、Token 转 `abnormal`。
- 人工重试只允许一次,并写完整审计记录。
- 筛选计数与列表结果一致。
- 订单取消时 `using` Token 自动回库存。
- 订单取消时 `bound/redeemed` Token 生成 `order_cancelled` 异常。
### 13.12 实施顺序
1. 新增错误分类器及单元测试。
2. 新增 `rock_delivery_incidents` 迁移和索引。
3. 在 `RedeemTokenForOrder` 错误分支写入异常事件。
4. 在重复发货入口增加开放异常短路,禁止无效上游请求。
5. 新增异常列表、详情和人工动作 API。
6. 增加 `no_entitlement` / `anomaly` 筛选与统计。
7. 更新前端筛选栏、状态标签和“处理异常”弹窗。
8. 不自动回填历史订单,只处理部署后的新异常。
### 13.13 验收标准
- “无权益”作为独立筛选项出现在“异常”之前。
- 其他所有未处理发货错误统一进入“异常”。
- `no_entitlement` 订单不再自动重复请求上游。
- 错误在订单、Token、发货报文和异常记录中口径一致。
- Token 历史可看到失败事件和人工处理结果。
- 所有开放异常都有明确处理状态和操作人。
- 现有 16 笔无权益订单全部进入待处理列表。
- 9 个 `bound` Token 可通过安全的人工释放或上报失效流程完成处理。
---
## 十四、本次实现状态
已完成:
- 绑定判定统一以 `bind_changed_at` / `binding.bindTime` 和本轮 `using_at` 为锚点。
- 绑定快照与上游时间格式不同但指向同一事件时,仍按历史残留处理。
- `status=bound` 和 `bound_account_id` 不再单独推进本轮绑定状态。
- `redeemed_at` / `deliveryList` 同样必须晚于本轮 `using_at`,历史发货记录不再误判本轮已发货。
- “待绑定超时”和“绑定后未发货”文案与筛选语义已更新。
- 发货确认中的 Token 不再计入“绑定后未发货”,操作列显示“发货确认中”。
- pending 订单不会再被普通订单超时任务标记为 `ship_failed`。
- 新增 `rock_delivery_incidents` 异常事件表和 `029` 迁移。
- 新增 `no_entitlement` 独立错误分类和筛选项。
- 其他发货错误统一进入“异常”筛选,内部保留细分错误码。
- 重复发货时存在开放异常会阻止再次请求上游。
- 新增人工标记已处理、释放回库存和上报失效闭环。
- 新增超时/频控异常的一次受控人工重试;其他确定性强错误不允许直接重试。
- 新增订单取消冲突处理:`using` 自动回库,`bound/redeemed` 生成人工异常。
- 新增 Token 异常历史、处理状态、操作人和处理备注。
- 新增 Rock 订单与 Token 状态后台对账。
- 关键履约状态更新不再忽略数据库错误。
- `bound` Token 在确认未发货后支持人工释放。
- 上游已发货、发货确认中、异常或校验失败的 Token 禁止释放。
- 备注接口不能再绕过释放流程直接改成 `available`。
- 可用库存统计改为与实际分配一致的黑色 Token 口径。
- Token 列表分页大小增加 100 条上限。
按当前要求暂不处理:
- Rock Bearer Token 明文存储。
- `rock_delivery_logs` 请求头中的 Authorization 脱敏。
- 配置接口返回原始 Bearer Token。
部署说明:
- `029_rock_delivery_incidents.sql` 只创建异常事件表与索引,不回填历史订单。
- 部署后新发生的订单取消冲突和发货异常会进入人工处理列表。
- 历史记录保持现状,不自动生成异常,也不自动修改 Token 主状态。
+61
View File
@@ -605,6 +605,8 @@ export interface RockStats {
using_danger_stock?: number;
bound_overdue_stock?: number;
anomaly_stock?: number;
no_entitlement_stock?: number;
unresolved_anomaly_stock?: number;
auto_refresh_mismatch_enabled?: boolean;
upstream_points: number;
auto_delivery_enabled: boolean;
@@ -687,6 +689,11 @@ export interface RockTokenItem {
anomaly_reason?: string;
anomaly_at?: string;
anomaly_count?: number;
has_open_incident?: boolean;
incident_category?: string;
incident_message?: string;
incident_at?: string;
incident_count?: number;
created_at: string;
updated_at?: string;
deleted_at?: string | null;
@@ -865,6 +872,28 @@ export interface RockStaleRefreshResult {
message: string;
}
export interface RockDeliveryIncident {
id: number;
token_id: number;
order_no: string;
upstream_token_id: string;
category: string;
status: "open" | "resolved" | "ignored";
blocking_retry: boolean;
message: string;
upstream_http_status: number;
upstream_response: string;
occurrences: number;
first_seen_at: string;
last_seen_at: string;
resolved_at?: string;
resolved_by?: number;
resolution_action: string;
resolution_note: string;
created_at: string;
updated_at: string;
}
export const rockApi = {
getConfig: () =>
request.get("/platform/rock/config").then((r) => r.data.data as RockConfig),
@@ -1028,6 +1057,38 @@ export const rockApi = {
request
.get(`/platform/rock/tokens/${id}/delivery-logs`)
.then((r) => r.data.data as { list: RockDeliveryLog[] }),
listTokenIncidents: (id: number) =>
request
.get(`/platform/rock/tokens/${id}/incidents`)
.then((r) => r.data.data as { list: RockDeliveryIncident[] }),
listIncidents: (params?: { page?: number; page_size?: number; status?: string; category?: string }) =>
request
.get("/platform/rock/incidents", { params })
.then(
(r) =>
r.data.data as {
list: RockDeliveryIncident[];
total: number;
page: number;
page_size: number;
},
),
resolveIncident: (id: number, note?: string) =>
request
.patch(`/platform/rock/incidents/${id}`, { note })
.then((r) => r.data.data as { message: string }),
retryIncident: (id: number, note: string) =>
request
.post(`/platform/rock/incidents/${id}/retry`, { note })
.then(
(r) =>
r.data.data as {
order_no: string
status: string
pending?: boolean
message?: string
},
),
refreshTokenBinding: (id: number) =>
request
.post(`/platform/rock/tokens/${id}/refresh-binding`)
+2 -2
View File
@@ -197,7 +197,7 @@ export default function PlatformRock() {
}
}
// 一键刷新异常 Token:绑定错误 + 使用超时 + 绑定超时,同步执行,单次最多 20 条
// 一键刷新异常 Token:绑定错误 + 待绑定超时 + 绑定后未发货,同步执行,单次最多 20 条
const handleRefreshStale = async () => {
setRefreshingStale(true)
try {
@@ -218,7 +218,7 @@ export default function PlatformRock() {
}
}
// 自动刷新开关:与「一键刷新」同规则(绑定错误 + 使用超时 + 绑定超时),切换即保存
// 自动刷新开关:与「一键刷新」同规则(绑定错误 + 待绑定超时 + 绑定后未发货),切换即保存
const handleToggleAutoRefreshMismatch = async (checked: boolean) => {
const previous = autoRefreshMismatchEnabled
setAutoRefreshMismatchEnabled(checked)
@@ -350,8 +350,8 @@ export function RockStrategyCard({
<Col span={12}>
<Form.Item
name="using_warn_days"
label="使用中橙色预警"
tooltip="扫码使用中的 Token 超过该天数后,状态标签变为橙色"
label="待绑定橙色预警"
tooltip="链接已发出但尚未扫码绑定的 Token 超过该天数后,状态标签变为橙色"
>
<InputNumber min={1} max={365} style={{ width: '100%' }} addonAfter="天" />
</Form.Item>
@@ -359,7 +359,7 @@ export function RockStrategyCard({
<Col span={12}>
<Form.Item
name="using_danger_days"
label="使用中红色预警"
label="待绑定红色预警"
tooltip="必须大于橙色预警天数;超过后状态标签变为红色"
>
<InputNumber min={1} max={365} style={{ width: '100%' }} addonAfter="天" />
@@ -368,8 +368,8 @@ export function RockStrategyCard({
<Col span={12}>
<Form.Item
name="bound_warn_days"
label="绑定过期预警"
tooltip="已绑定 Token 超过该天数后标记为绑定超时,并计入「绑定超时」筛选"
label="绑定后未发货预警"
tooltip="本轮真实绑定后超过该天数仍未完成发货,标记为「绑定后未发货」"
>
<InputNumber min={1} max={365} style={{ width: '100%' }} addonAfter="天" />
</Form.Item>
@@ -62,7 +62,7 @@ export function RockTokenFilterBar({
>
<Radio.Button value="all">全部</Radio.Button>
<Radio.Button value="available">可用</Radio.Button>
<Radio.Button value="using">使用中</Radio.Button>
<Radio.Button value="using">待绑定</Radio.Button>
<Radio.Button value="bound">已绑定</Radio.Button>
<Radio.Button value="redeemed">已发货</Radio.Button>
<Radio.Button value="mismatch">
@@ -87,7 +87,7 @@ export function RockTokenFilterBar({
size="small"
style={{ backgroundColor: '#f59e0b' }}
>
<span>使用超时</span>
<span>待绑定超时</span>
</Badge>
</Radio.Button>
<Radio.Button value="bound_overdue">
@@ -97,13 +97,23 @@ export function RockTokenFilterBar({
size="small"
style={{ backgroundColor: '#ef4444' }}
>
<span>绑定超时</span>
<span>绑定后未发货</span>
</Badge>
</Radio.Button>
{/* 异常放在最后:派生筛选,只看历史流转,不改变主状态 */}
<Radio.Button value="no_entitlement">
<Badge
count={stats?.no_entitlement_stock ?? 0}
offset={[6, -2]}
size="small"
style={{ backgroundColor: '#dc2626' }}
>
<span>无权益</span>
</Badge>
</Radio.Button>
{/* 其余未处理错误统一进入异常 */}
<Radio.Button value="anomaly">
<Badge
count={stats?.anomaly_stock ?? 0}
count={stats?.unresolved_anomaly_stock ?? stats?.anomaly_stock ?? 0}
offset={[6, -2]}
size="small"
style={{ backgroundColor: '#b91c1c' }}
@@ -127,7 +137,7 @@ export function RockTokenFilterBar({
<Tooltip
title={
<div style={{ fontSize: 12, lineHeight: 1.7 }}>
同时刷新三类异常 Token:绑定错误、使用超时、绑定超时。
同时刷新三类 Token:绑定错误、待绑定超时、绑定后未发货。
<br />
单次最多 20 条,逐条间隔 1.5 秒,同一 Token 10 分钟内不重复刷新。
<br />
@@ -70,6 +70,7 @@ export function RockTokenTable({
const [openingUpstreamId, setOpeningUpstreamId] = useState<number | null>(null)
const [releaseTarget, setReleaseTarget] = useState<RockTokenItem | null>(null)
const [statusLogTarget, setStatusLogTarget] = useState<RockTokenItem | null>(null)
const [statusLogTab, setStatusLogTab] = useState<'status' | 'incidents'>('status')
const handleOpenUpstream = async (id: number) => {
// 先同步打开空白页,避免异步请求后 window.open 被浏览器拦截。
@@ -227,7 +228,10 @@ export function RockTokenTable({
onViewRawData,
onResetToken: handleResetToken,
onReleaseToken: setReleaseTarget,
onViewStatusLogs: setStatusLogTarget,
onViewStatusLogs: (record, tab = 'status') => {
setStatusLogTab(tab)
setStatusLogTarget(record)
},
onUpdateRemark: handleUpdateRemark,
onMarkAbnormal: handleMarkAbnormal,
})
@@ -306,7 +310,16 @@ export function RockTokenTable({
<TokenStatusLogDrawer
open={Boolean(statusLogTarget)}
token={statusLogTarget}
initialTab={statusLogTab}
onClose={() => setStatusLogTarget(null)}
onRequestRelease={(token) => {
setStatusLogTarget(null)
setReleaseTarget(token)
}}
onIncidentChanged={() => {
onReloadData()
onReloadTokens()
}}
/>
</div>
)
@@ -26,13 +26,14 @@ export interface TokenDeleteModalProps {
const STATUS_LABELS: Record<string, string> = {
available: '可用库存',
using: '使用中',
bound: '已绑定',
using: '待扫码绑定',
bound: '已绑定待发货',
redeemed: '已发货',
mismatch: '绑定错误',
abnormal: '已上报失效',
using_overdue: '使用超时',
bound_overdue: '绑定超时',
using_overdue: '待绑定超时',
bound_overdue: '绑定后未发货',
no_entitlement: '无权益',
}
const DANGEROUS_STATUSES = new Set(['using', 'bound', 'mismatch'])
@@ -29,25 +29,27 @@ export interface TokenRecycleBinDrawerProps {
const STATUS_LABELS: Record<string, string> = {
available: '可用库存',
using: '使用中',
bound: '已绑定',
using: '待扫码绑定',
bound: '已绑定待发货',
redeemed: '已发货',
mismatch: '绑定错误',
abnormal: '已上报失效',
using_overdue: '使用超时',
bound_overdue: '绑定超时',
using_overdue: '待绑定超时',
bound_overdue: '绑定后未发货',
no_entitlement: '无权益',
}
const STATUS_OPTIONS = [
{ value: 'all', label: '全部状态' },
{ value: 'available', label: '可用库存' },
{ value: 'using', label: '使用中' },
{ value: 'bound', label: '已绑定' },
{ value: 'using', label: '待扫码绑定' },
{ value: 'bound', label: '已绑定待发货' },
{ value: 'redeemed', label: '已发货' },
{ value: 'mismatch', label: '绑定错误' },
{ value: 'abnormal', label: '已上报失效' },
{ value: 'using_overdue', label: '使用超时' },
{ value: 'bound_overdue', label: '绑定超时' },
{ value: 'using_overdue', label: '待绑定超时' },
{ value: 'bound_overdue', label: '绑定后未发货' },
{ value: 'no_entitlement', label: '无权益' },
]
const DANGEROUS_STATUSES = new Set(['using', 'bound', 'mismatch'])
@@ -6,6 +6,9 @@ import {
Drawer,
Empty,
Grid,
Input,
message,
Modal,
Space,
Spin,
Tabs,
@@ -17,6 +20,7 @@ import { ReloadOutlined } from '@ant-design/icons'
import {
rockApi,
type RockDeliveryLog,
type RockDeliveryIncident,
type RockTokenItem,
type RockTokenStatusLog,
} from '../../api'
@@ -28,8 +32,11 @@ const { Text, Paragraph } = Typography
export interface TokenStatusLogDrawerProps {
open: boolean
token: RockTokenItem | null
initialTab?: 'status' | 'delivery' | 'incidents'
onClose: () => void
loadLogs?: (tokenId: number) => Promise<RockTokenStatusLog[]>
onIncidentChanged?: () => void
onRequestRelease?: (token: RockTokenItem) => void
}
const EVENT_META: Record<string, { label: string; color: string }> = {
@@ -37,6 +44,7 @@ const EVENT_META: Record<string, { label: string; color: string }> = {
status_changed: { label: '状态变更', color: 'blue' },
deleted: { label: '删除', color: 'red' },
restored: { label: '恢复', color: 'orange' },
incident: { label: '发货异常', color: 'red' },
}
const SOURCE_LABELS: Record<string, string> = {
@@ -52,8 +60,26 @@ const SOURCE_LABELS: Record<string, string> = {
package_sync: '上游包同步',
delete: '删除',
restore: '回收站恢复',
incident: '发货异常',
}
const INCIDENT_CATEGORY_LABELS: Record<string, string> = {
no_entitlement: '无权益',
duplicate_item: '重复道具',
gift_exhausted: '礼包耗尽',
token_already_redeemed: 'Token 已兑换',
token_not_redeemable: 'Token 不可兑换',
token_not_found: 'Token 不存在',
upstream_timeout: '上游超时',
upstream_rate_limited: '上游频控',
ship_timeout: '发货超时',
bind_mismatch: 'UID 不一致',
order_cancelled: '订单取消冲突',
other: '其他异常',
}
const RETRYABLE_INCIDENTS = new Set(['upstream_timeout', 'upstream_rate_limited'])
// 发货链路的步骤中文名,用于报文时间线。
const DELIVERY_STEP_LABELS: Record<string, string> = {
submit_request: '提交发货',
@@ -252,18 +278,30 @@ const preStyle: CSSProperties = {
export function TokenStatusLogDrawer({
open,
token,
initialTab = 'status',
onClose,
loadLogs,
onIncidentChanged,
onRequestRelease,
}: TokenStatusLogDrawerProps) {
const screens = Grid.useBreakpoint()
const isMobile = !screens.md
const [logs, setLogs] = useState<RockTokenStatusLog[]>([])
const [deliveryLogs, setDeliveryLogs] = useState<RockDeliveryLog[]>([])
const [incidents, setIncidents] = useState<RockDeliveryIncident[]>([])
const [loading, setLoading] = useState(false)
const [loadingDelivery, setLoadingDelivery] = useState(false)
const [error, setError] = useState('')
const [deliveryError, setDeliveryError] = useState('')
const [incidentError, setIncidentError] = useState('')
const [loadingIncidents, setLoadingIncidents] = useState(false)
const [activeTab, setActiveTab] = useState('status')
const [incidentAction, setIncidentAction] = useState<{
type: 'resolve' | 'retry' | 'abnormal'
incident: RockDeliveryIncident
} | null>(null)
const [incidentNote, setIncidentNote] = useState('')
const [submittingIncident, setSubmittingIncident] = useState(false)
const load = useCallback(async () => {
if (!token) return
@@ -295,14 +333,69 @@ export function TokenStatusLogDrawer({
}
}, [token])
const loadIncidents = useCallback(async () => {
if (!token) return
setLoadingIncidents(true)
setIncidentError('')
try {
const res = await rockApi.listTokenIncidents(token.id)
setIncidents(res.list || [])
} catch (e) {
setIncidentError(e instanceof Error ? e.message : '加载异常记录失败')
} finally {
setLoadingIncidents(false)
}
}, [token])
useEffect(() => {
if (!open || !token) return
setLogs([])
setDeliveryLogs([])
setActiveTab('status')
setIncidents([])
setActiveTab(initialTab)
void load()
void loadDelivery()
}, [open, token, load, loadDelivery])
void loadIncidents()
}, [open, token, initialTab, load, loadDelivery, loadIncidents])
const openIncidentAction = (
type: 'resolve' | 'retry' | 'abnormal',
incident: RockDeliveryIncident,
) => {
setIncidentNote('')
setIncidentAction({ type, incident })
}
const closeIncidentAction = () => {
if (submittingIncident) return
setIncidentAction(null)
setIncidentNote('')
}
const handleIncidentAction = async () => {
if (!token || !incidentAction || !incidentNote.trim()) return
setSubmittingIncident(true)
try {
if (incidentAction.type === 'resolve') {
await rockApi.resolveIncident(incidentAction.incident.id, incidentNote.trim())
message.success('异常记录已标记处理')
} else if (incidentAction.type === 'retry') {
const result = await rockApi.retryIncident(incidentAction.incident.id, incidentNote.trim())
message.success(result.message || (result.pending ? '上游已受理,等待发货确认' : '人工重试成功'))
} else {
await rockApi.markAbnormal(token.id, incidentNote.trim())
message.success('Token 已标记为已上报失效')
}
setIncidentAction(null)
setIncidentNote('')
await loadIncidents()
onIncidentChanged?.()
} catch (e) {
message.error(e instanceof Error ? e.message : '处理异常失败')
} finally {
setSubmittingIncident(false)
}
}
const statusContent = (
<>
@@ -397,6 +490,83 @@ export function TokenStatusLogDrawer({
</>
)
const incidentContent = (
<>
{incidentError && (
<Alert
type="error"
showIcon
message={incidentError}
style={{ marginBottom: 12 }}
action={<Button size="small" onClick={loadIncidents}>重试</Button>}
/>
)}
{loadingIncidents ? (
<div style={{ padding: '48px 0', textAlign: 'center' }}><Spin /></div>
) : incidents.length === 0 ? (
<Empty description="暂无发货异常记录" />
) : (
<Timeline
items={incidents.map((incident) => ({
key: incident.id,
color: incident.status === 'open' ? 'red' : 'green',
children: (
<Space direction="vertical" size={5} style={{ width: '100%' }}>
<Space wrap>
<Tag color={incident.category === 'no_entitlement' ? 'red' : 'orange'}>
{INCIDENT_CATEGORY_LABELS[incident.category] || incident.category}
</Tag>
<Tag color={incident.status === 'open' ? 'error' : 'success'}>
{incident.status === 'open' ? '待处理' : '已处理'}
</Tag>
<Text type="secondary" style={{ fontSize: 11 }}>
发生 {incident.occurrences} 次
</Text>
</Space>
<Text style={{ fontSize: 12, whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>
{incident.message || '发货异常'}
</Text>
<Text type="secondary" style={{ fontSize: 11 }}>
{formatDateTime(incident.first_seen_at)} · 最近 {formatDateTime(incident.last_seen_at)}
</Text>
{incident.status === 'open' && (
<Space size={6} wrap>
{RETRYABLE_INCIDENTS.has(incident.category) && (
<Button
size="small"
type="primary"
onClick={() => openIncidentAction('retry', incident)}
>
人工重试
</Button>
)}
{token && (token.status === 'using' || token.status === 'bound') && onRequestRelease && (
<Button size="small" onClick={() => onRequestRelease(token)}>
释放回库存
</Button>
)}
{token && token.status !== 'redeemed' && token.status !== 'abnormal' && (
<Button
size="small"
danger
onClick={() => openIncidentAction('abnormal', incident)}
>
上报失效
</Button>
)}
<Button size="small" onClick={() => openIncidentAction('resolve', incident)}>
标记已处理
</Button>
</Space>
)}
</Space>
),
}))}
/>
)}
</>
)
return (
<Drawer
open={open}
@@ -407,10 +577,11 @@ export function TokenStatusLogDrawer({
<Button
size="small"
icon={<ReloadOutlined />}
loading={loading || loadingDelivery}
loading={loading || loadingDelivery || loadingIncidents}
onClick={() => {
void load()
void loadDelivery()
void loadIncidents()
}}
>
刷新
@@ -460,8 +631,64 @@ export function TokenStatusLogDrawer({
label: `发货报文${deliveryLogs.length ? ` (${deliveryLogs.length})` : ''}`,
children: deliveryContent,
},
{
key: 'incidents',
label: `异常记录${incidents.length ? ` (${incidents.length})` : ''}`,
children: incidentContent,
},
]}
/>
<Modal
open={Boolean(incidentAction)}
title={
incidentAction?.type === 'retry'
? '人工重试发货'
: incidentAction?.type === 'abnormal'
? '上报 Token 失效'
: '标记异常已处理'
}
okText={
incidentAction?.type === 'retry'
? '确认重试'
: incidentAction?.type === 'abnormal'
? '确认上报失效'
: '确认已处理'
}
cancelText="取消"
confirmLoading={submittingIncident}
okButtonProps={{
danger: incidentAction?.type === 'abnormal',
disabled: !incidentNote.trim(),
}}
onOk={() => void handleIncidentAction()}
onCancel={closeIncidentAction}
>
<Space direction="vertical" size={10} style={{ width: '100%', marginTop: 8 }}>
{incidentAction && (
<Alert
type={incidentAction.type === 'retry' ? 'warning' : 'info'}
showIcon
message={`${INCIDENT_CATEGORY_LABELS[incidentAction.incident.category] || incidentAction.incident.category} · ${incidentAction.incident.message}`}
/>
)}
<Text type="secondary" style={{ fontSize: 12 }}>
{incidentAction?.type === 'retry'
? '仅在上游状态已人工核对后使用;系统只允许本次受控重试,失败会重新阻塞。'
: incidentAction?.type === 'abnormal'
? '该操作会将 Token 标记为已上报失效,请确认卡密已经作废。'
: '仅关闭异常事件,不改变 Token 和订单状态。'}
</Text>
<Input.TextArea
rows={3}
value={incidentNote}
maxLength={500}
showCount
placeholder="请填写人工核对结果和处理说明"
onChange={(event) => setIncidentNote(event.target.value)}
/>
</Space>
</Modal>
</Drawer>
)
}
@@ -1,3 +1,4 @@
/* oxlint-disable react/only-export-components -- 本文件同时导出单元格组件和共享列构建器。 */
import { Button, Dropdown, Popconfirm, Space, Tag, Tooltip, Typography } from 'antd'
import {
CopyOutlined,
@@ -137,7 +138,7 @@ export interface TokenColumnHandlers {
onViewRawData: (record: RockTokenItem) => void
onResetToken: (id: number) => void
onReleaseToken: (record: RockTokenItem) => void
onViewStatusLogs: (record: RockTokenItem) => void
onViewStatusLogs: (record: RockTokenItem, tab?: 'status' | 'incidents') => void
onUpdateRemark: (id: number, remark: string, status?: string) => void
onMarkAbnormal: (id: number, remark?: string) => void
}
@@ -211,24 +212,32 @@ export function buildTokenColumns({
color="error"
style={{ fontWeight: 600, padding: '2px 8px', border: '1px solid #ff4d4f' }}
>
⚠️ 使用中超时 {displayDays.toFixed(1)} 天
⚠️ 待绑定超时 {displayDays.toFixed(1)} 天
</Tag>
)
}
if (usingOverdue) {
return (
<Tag color="warning" style={{ fontWeight: 600, padding: '2px 8px' }}>
● 扫码使用中 · {displayDays.toFixed(1)} 天
● 待绑定 · {displayDays.toFixed(1)} 天
</Tag>
)
}
return (
<Tag color="processing" style={{ fontWeight: 600, padding: '2px 8px' }}>
● 扫码使用中
● 待扫码绑定
</Tag>
)
}
case 'bound': {
const redeemPending = Boolean(record.redeem_requested_at && !record.redeemed_at)
if (redeemPending) {
return (
<Tag color="processing" style={{ fontWeight: 600, padding: '2px 8px' }}>
● 发货确认中
</Tag>
)
}
const boundDays = serverElapsedDays(record.bound_days, record.bound_at)
const boundOverdue =
record.bound_overdue ?? (boundDays !== null && boundDays >= boundWarnDays)
@@ -239,13 +248,13 @@ export function buildTokenColumns({
color="volcano"
style={{ fontWeight: 600, padding: '2px 8px', border: '1px solid #ff7a45' }}
>
⏱ 绑定超时 {displayDays.toFixed(1)} 天
⏱ 绑定后未发货 {displayDays.toFixed(1)} 天
</Tag>
)
}
return (
<Tag color="cyan" style={{ fontWeight: 600, padding: '2px 8px' }}>
● 已授权绑定
● 已绑定待发货
</Tag>
)
}
@@ -285,11 +294,35 @@ export function buildTokenColumns({
})()
// 异常是独立标记:不替换主状态,只在后面追加一个小标签。
if (!record.has_anomaly) return statusTag
if (!record.has_anomaly && !record.has_open_incident) return statusTag
return (
<Space direction="vertical" size={4}>
{statusTag}
<AnomalyBadge record={record} />
{record.has_open_incident && (
<Tooltip
title={
<div style={{ maxWidth: 320, whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>
{record.incident_message || '该 Token 存在需要人工处理的发货异常'}
{record.incident_count ? ` · 共 ${record.incident_count} 次` : ''}
</div>
}
>
<Tag
style={{
margin: 0,
fontWeight: 600,
fontSize: 11,
padding: '0 6px',
color: record.incident_category === 'no_entitlement' ? '#b91c1c' : '#c2410c',
background: record.incident_category === 'no_entitlement' ? '#fef2f2' : '#fff7ed',
border: record.incident_category === 'no_entitlement' ? '1px solid #fca5a5' : '1px solid #fdba74',
}}
>
{record.incident_category === 'no_entitlement' ? '无权益' : '异常'}
</Tag>
</Tooltip>
)}
{record.has_anomaly && <AnomalyBadge record={record} />}
</Space>
)
},
@@ -319,7 +352,7 @@ export function buildTokenColumns({
</div>
{record.status === 'using' && record.using_at && (
<div style={{ fontSize: 11, color: usingColor }}>
<span>⏳ 使用中: </span>
<span>⏳ 待绑定: </span>
{formatDateTime(record.using_at)}
{usingDays !== null ? `(${floorDays(usingDays).toFixed(1)} 天)` : ''}
</div>
@@ -607,11 +640,16 @@ export function buildTokenColumns({
size="small"
type="text"
icon={<HistoryOutlined />}
onClick={() => onViewStatusLogs(record)}
onClick={() => onViewStatusLogs(record, 'status')}
>
历史
</Button>
)
const incidentButton = record.has_open_incident ? (
<Button size="small" danger onClick={() => onViewStatusLogs(record, 'incidents')}>
处理异常
</Button>
) : null
if (readOnly) {
return (
<Space size={4} wrap>
@@ -625,6 +663,7 @@ export function buildTokenColumns({
<Space size={4} wrap>
{refreshButton}
{upstreamButton}
{incidentButton}
<Button
size="small"
type="primary"
@@ -671,6 +710,7 @@ export function buildTokenColumns({
<Space size={4} wrap>
<Text type="secondary" style={{ fontSize: 11 }}>已失效作废</Text>
{upstreamButton}
{incidentButton}
<Popconfirm
title="确定强制恢复为可用库存?"
description="警告:该链接曾标记为已上报失效。若上游已作废该卡密,重新放回库存将导致下一单提货失败!请确保卡密真实可用。"
@@ -692,6 +732,7 @@ export function buildTokenColumns({
<Space size={4} wrap>
{refreshButton}
{upstreamButton}
{incidentButton}
<Button size="small" icon={<UndoOutlined />} onClick={() => onReleaseToken(record)}>
释放回库存
</Button>
@@ -700,10 +741,21 @@ export function buildTokenColumns({
)
}
if (record.status === 'bound') {
const redeemPending = Boolean(record.redeem_requested_at && !record.redeemed_at)
return (
<Space size={4} wrap>
{refreshButton}
{upstreamButton}
{incidentButton}
{redeemPending ? (
<Tag color="processing" style={{ margin: 0 }}>
发货确认中
</Tag>
) : (
<Button size="small" icon={<UndoOutlined />} onClick={() => onReleaseToken(record)}>
释放回库存
</Button>
)}
{historyButton}
</Space>
)
@@ -711,6 +763,7 @@ export function buildTokenColumns({
return (
<Space size={4} wrap>
{upstreamButton}
{incidentButton}
{historyButton}
</Space>
)
+5 -4
View File
@@ -8,13 +8,14 @@ export interface UidEditState {
// Token 主状态中文名,状态历史与释放弹窗共用。
export const ROCK_TOKEN_STATUS_LABELS: Record<string, string> = {
available: '可用库存',
using: '扫码使用中',
bound: '已授权绑定',
using: '待扫码绑定',
bound: '已绑定待发货',
redeemed: '已交付发货',
mismatch: '绑定错误',
abnormal: '已上报失效',
using_overdue: '使用超时',
bound_overdue: '绑定超时',
using_overdue: '待绑定超时',
bound_overdue: '绑定后未发货',
no_entitlement: '无权益',
}
export function rockTokenStatusLabel(status?: string): string {