refactor: 移除 Rock 发货处理中状态,新增异常派生筛选标记

- 新增迁移 028:历史 redeeming 回退为 bound,重建 pending 部分索引
- pending 语义改为 bound + redeem_requested_at 标记,不再写状态与状态日志
- 新增 anomaly 虚拟筛选项:基于状态日志派生异常标记,不改状态、不落库
- 状态列追加「⚠️ 异常」标签,统计新增 anomaly_stock
- 清理前后端 redeeming 相关分支与文案
This commit is contained in:
2026-11-14 15:40:19 +08:00
parent f0dea6a583
commit a9473990be
25 changed files with 620 additions and 219 deletions
@@ -0,0 +1,22 @@
-- 028: 移除 redeeming 状态,pending 改为纯标记字段;恢复 redeeming 历史数据
-- 背景:上游异步兑奖不再占用独立状态,Token 保持 bound,
-- 用 redeem_requested_at / redeemed_at 判定「发货确认中」。
-- 1) 回退历史 redeeming 数据:只回状态,不写状态日志,避免伪造流转。
UPDATE rock_tokens
SET status = 'bound',
updated_at = NOW()
WHERE status = 'redeeming'
AND redeemed_at IS NULL;
-- 2) 旧的 pending 索引依赖 status='redeeming',必须替换为标记口径。
DROP INDEX IF EXISTS idx_rock_tokens_redeem_next_retry_at;
CREATE INDEX IF NOT EXISTS idx_rock_tokens_redeem_pending
ON rock_tokens (redeem_next_retry_at)
WHERE redeem_requested_at IS NOT NULL
AND redeemed_at IS NULL;
COMMENT ON COLUMN rock_tokens.redeem_requested_at IS '提交上游兑换的时间(发货确认中标记,非状态)';
COMMENT ON COLUMN rock_tokens.redeem_next_retry_at IS '下一次允许轮询上游的时间(指数退避)';
COMMENT ON COLUMN rock_tokens.redeem_timeout_warned_at IS '超过等待上限仍未确认时写入的告警时间(仅标记)';
+15 -3
View File
@@ -23,11 +23,14 @@ const (
RockStatusAvailable = "available" // 可用库存
RockStatusUsing = "using" // 已分配给订单,正在提货扫码
RockStatusBound = "bound" // 扫码绑定成功
RockStatusRedeeming = "redeeming" // 上游官方异步处理中,等待确认发货结果
RockStatusRedeemed = "redeemed" // 发货完成
RockStatusMismatch = "mismatch" // UID 校验不符阻断(绑错)
RockStatusAbnormal = "abnormal" // 异常/作废
// 虚拟筛选状态:不落库,仅用于列表查询换算。
// RockStatusAnomaly 表示「历史流转中出现过异常」的 Token,不改变其主状态。
RockStatusAnomaly = "anomaly"
RockSKUBlackTshirt = "rock_black_tshirt"
RockSKUBlueSportswear = "rock_blue_sportswear"
@@ -106,8 +109,10 @@ type RockToken struct {
BoundAt *time.Time `json:"bound_at"`
RedeemedAt *time.Time `json:"redeemed_at"`
// 异步兑换(上游 pending)跟踪字段。
RedeemRequestedAt *time.Time `gorm:"index" json:"redeem_requested_at,omitempty"` // 首次提交兑换的时间,用于 15 分钟超时告警
// 发货确认中(上游异步兑奖)标记字段。
// 注意:这不是状态。Token 主状态保持 bound,pending 期间只写这些标记,
// 用 redeem_requested_at 非空且 redeemed_at 为空来判定「发货确认中」。
RedeemRequestedAt *time.Time `gorm:"index" json:"redeem_requested_at,omitempty"` // 提交兑换的时间,pending 起点
RedeemCheckedAt *time.Time `json:"redeem_checked_at,omitempty"` // 最近一次查询上游结果的时间
RedeemAttempts int `gorm:"not null;default:0" json:"redeem_attempts"`
RedeemNextRetryAt *time.Time `gorm:"index" json:"redeem_next_retry_at,omitempty"`
@@ -125,6 +130,13 @@ type RockToken struct {
UsingDanger bool `gorm:"-" json:"using_danger"`
BoundDays float64 `gorm:"-" json:"bound_days"`
UsingDays float64 `gorm:"-" json:"using_days"`
// 异常标记(派生,不落库):来自状态流转历史,不改变主状态。
HasAnomaly bool `gorm:"-" json:"has_anomaly"`
AnomalyType string `gorm:"-" json:"anomaly_type,omitempty"`
AnomalyReason string `gorm:"-" json:"anomaly_reason,omitempty"`
AnomalyAt *time.Time `gorm:"-" json:"anomaly_at,omitempty"`
AnomalyCount int `gorm:"-" json:"anomaly_count"`
}
func (RockToken) TableName() string {
@@ -61,7 +61,6 @@ func (p *RockProvider) PrepareDelivery(ctx context.Context, order *model.Fulfill
Where("assigned_order_no = ? AND status IN ?", orderNo, []string{
model.RockStatusUsing,
model.RockStatusBound,
model.RockStatusRedeeming,
model.RockStatusMismatch,
model.RockStatusAbnormal,
}).
@@ -0,0 +1,161 @@
package service
import (
"context"
"strings"
"time"
"affiliate_dash/internal/model"
"gorm.io/gorm"
)
// 异常判定口径(与产品确认一致):
// 1. 状态流转历史中出现过 mismatch / abnormal(绑定错误、上游失效);
// 2. 状态流转原因或备注中出现失败类关键字(兑换失败、刷新失败、卡密不存在、超时等);
// 3. 只要命中任意一条即为「异常」,不改变 Token 主状态。
//
// 采用子查询而不是落库字段,保证历史数据无需迁移即可被筛选到。
func rockTokenAnomalyExistsSQL() string {
return `EXISTS (
SELECT 1 FROM rock_token_status_logs l
WHERE l.token_id = rock_tokens.id
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 '%不符%'
)
)`
}
// rockAnomalyRecentSQL 取每个 Token 最近一条异常日志,供批量补齐派生字段。
const rockAnomalyRecentSQL = `
SELECT DISTINCT ON (l.token_id)
l.token_id,
l.to_status,
l.reason,
l.created_at
FROM rock_token_status_logs l
WHERE l.token_id IN (?)
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 '%不符%'
)
ORDER BY l.token_id, l.id DESC
`
const rockAnomalyCountSQL = `
SELECT l.token_id, COUNT(*) AS total
FROM rock_token_status_logs l
WHERE l.token_id IN (?)
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 '%不符%'
)
GROUP BY l.token_id
`
type rockAnomalyRecentRow struct {
TokenID uint `gorm:"column:token_id"`
ToStatus string `gorm:"column:to_status"`
Reason string `gorm:"column:reason"`
CreatedAt time.Time `gorm:"column:created_at"`
}
type rockAnomalyCountRow struct {
TokenID uint `gorm:"column:token_id"`
Total int64 `gorm:"column:total"`
}
// fillRockTokenAnomalyFlags 为一页 Token 批量补齐异常标记,避免 N+1 查询。
func (s *RockService) fillRockTokenAnomalyFlags(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
}
var recents []rockAnomalyRecentRow
if err := s.db.WithContext(ctx).Raw(rockAnomalyRecentSQL, ids).Scan(&recents).Error; err != nil {
return
}
var counts []rockAnomalyCountRow
if err := s.db.WithContext(ctx).Raw(rockAnomalyCountSQL, ids).Scan(&counts).Error; err != nil {
return
}
recentMap := make(map[uint]rockAnomalyRecentRow, len(recents))
for _, row := range recents {
recentMap[row.TokenID] = row
}
countMap := make(map[uint]int64, len(counts))
for _, row := range counts {
countMap[row.TokenID] = row.Total
}
for i := range tokens {
id := tokens[i].ID
row, ok := recentMap[id]
if !ok {
continue
}
at := row.CreatedAt
tokens[i].HasAnomaly = true
tokens[i].AnomalyType = rockAnomalyTypeFromLog(row.ToStatus, row.Reason)
tokens[i].AnomalyReason = strings.TrimSpace(row.Reason)
tokens[i].AnomalyAt = &at
tokens[i].AnomalyCount = int(countMap[id])
}
}
// rockAnomalyTypeFromLog 把状态流转记录归成给用户看的异常分类。
func rockAnomalyTypeFromLog(toStatus, reason string) string {
switch strings.TrimSpace(toStatus) {
case model.RockStatusMismatch:
return "UID 不一致"
case model.RockStatusAbnormal:
return "上游失效"
}
reason = strings.TrimSpace(reason)
switch {
case strings.Contains(reason, "不存在"):
return "卡密不存在"
case strings.Contains(reason, "超时"):
return "确认超时"
case strings.Contains(reason, "兑换") || strings.Contains(reason, "发货"):
return "兑换失败"
case strings.Contains(reason, "刷新"):
return "刷新失败"
case strings.Contains(reason, "拦截") || strings.Contains(reason, "不符"):
return "UID 不一致"
case reason != "":
return "异常"
default:
return "异常"
}
}
// rockTokenAnomalyFilter 让 applyRockTokenListFilters 复用同一套口径。
func rockTokenAnomalyFilter(db *gorm.DB) *gorm.DB {
return db.Where(rockTokenAnomalyExistsSQL())
}
@@ -0,0 +1,179 @@
package service
import (
"context"
"testing"
"time"
"affiliate_dash/internal/model"
)
// 异常是派生标记:只依据历史流转,不改变 Token 主状态。
func TestListTokens_AnomalyFilterIsDerivedAndKeepsStatus(t *testing.T) {
db := newRockTestDB(t)
svc := NewRockService(db, NewRockClient("https://example.com"))
now := time.Now()
anomalyToken := model.RockToken{
UpstreamTokenID: "ANOMALY-1",
Token: "jwt-anomaly-1",
RedeemCode: "CODE-ANOMALY-1",
Color: model.RockColorBlack,
Status: model.RockStatusBound,
AssignedOrderNo: "FO-ANOMALY-1",
ExpectedUID: "3829515948",
BoundAt: &now,
}
normalToken := model.RockToken{
UpstreamTokenID: "ANOMALY-2",
Token: "jwt-anomaly-2",
RedeemCode: "CODE-ANOMALY-2",
Color: model.RockColorBlack,
Status: model.RockStatusBound,
AssignedOrderNo: "FO-ANOMALY-2",
ExpectedUID: "3829515949",
BoundAt: &now,
}
if err := db.Create(&anomalyToken).Error; err != nil {
t.Fatalf("创建 Token 失败: %v", err)
}
if err := db.Create(&normalToken).Error; err != nil {
t.Fatalf("创建 Token 失败: %v", err)
}
// 只给第一条写入 mismatch 流转历史。
if err := db.Create(&model.RockTokenStatusLog{
TokenID: anomalyToken.ID,
UpstreamTokenID: anomalyToken.UpstreamTokenID,
EventType: rockTokenEventStatusChanged,
FromStatus: model.RockStatusUsing,
ToStatus: model.RockStatusMismatch,
Source: rockTokenSourceBindCheck,
Reason: "预期UID(3829515948) 与 扫码UID(舱y/3829515950) 不一致",
}).Error; err != nil {
t.Fatalf("创建状态日志失败: %v", err)
}
list, total, err := svc.ListTokens(context.Background(), 1, 20, model.RockStatusAnomaly, "")
if err != nil {
t.Fatalf("查询异常列表失败: %v", err)
}
if total != 1 || len(list) != 1 {
t.Fatalf("异常筛选应只命中 1 条,实际 total=%d len=%d", total, len(list))
}
got := list[0]
if got.ID != anomalyToken.ID {
t.Fatalf("命中的 Token 不正确: %d", got.ID)
}
// 关键:筛选不改变主状态,也不写派生状态。
if got.Status != model.RockStatusBound {
t.Fatalf("异常筛选不应改变主状态,实际 %s", got.Status)
}
if !got.HasAnomaly || got.AnomalyType != "UID 不一致" || got.AnomalyCount != 1 {
t.Fatalf("异常派生字段不正确: %+v", got)
}
if got.AnomalyReason == "" || got.AnomalyAt == nil {
t.Fatalf("异常原因与时间应补齐: %+v", got)
}
// 普通列表也应带上异常标记,且正常 Token 不带。
all, _, err := svc.ListTokens(context.Background(), 1, 20, "", "")
if err != nil {
t.Fatalf("查询全部失败: %v", err)
}
var flagged, clean int
for i := range all {
if all[i].HasAnomaly {
flagged++
} else {
clean++
}
}
if flagged != 1 || clean != 1 {
t.Fatalf("异常标记数量不正确: flagged=%d clean=%d", flagged, clean)
}
}
// 正常 Token 不应被异常筛选命中。
func TestListTokens_AnomalyFilterExcludesCleanTokens(t *testing.T) {
db := newRockTestDB(t)
svc := NewRockService(db, NewRockClient("https://example.com"))
now := time.Now()
token := model.RockToken{
UpstreamTokenID: "CLEAN-1",
Token: "jwt-clean-1",
RedeemCode: "CODE-CLEAN-1",
Color: model.RockColorBlack,
Status: model.RockStatusAvailable,
}
if err := db.Create(&token).Error; err != nil {
t.Fatalf("创建 Token 失败: %v", err)
}
if err := db.Create(&model.RockTokenStatusLog{
TokenID: token.ID,
UpstreamTokenID: token.UpstreamTokenID,
EventType: rockTokenEventCreated,
ToStatus: model.RockStatusAvailable,
Source: rockTokenSourcePurchase,
}).Error; err != nil {
t.Fatalf("创建状态日志失败: %v", err)
}
_ = now
list, total, err := svc.ListTokens(context.Background(), 1, 20, model.RockStatusAnomaly, "")
if err != nil {
t.Fatalf("查询失败: %v", err)
}
if total != 0 || len(list) != 0 {
t.Fatalf("正常 Token 不应命中异常筛选,实际 total=%d", total)
}
}
// 统计徽标按同一口径计数。
func TestGetStats_AnomalyStockCountsDerivedAnomalies(t *testing.T) {
db := newRockTestDB(t)
svc := NewRockService(db, NewRockClient("https://example.com"))
if _, err := svc.GetConfig(context.Background()); err != nil {
t.Fatalf("初始化配置失败: %v", err)
}
now := time.Now()
token := model.RockToken{
UpstreamTokenID: "STAT-1",
Token: "jwt-stat-1",
RedeemCode: "CODE-STAT-1",
Color: model.RockColorBlack,
Status: model.RockStatusBound,
BoundAt: &now,
}
if err := db.Create(&token).Error; err != nil {
t.Fatalf("创建 Token 失败: %v", err)
}
if err := db.Create(&model.RockTokenStatusLog{
TokenID: token.ID,
UpstreamTokenID: token.UpstreamTokenID,
EventType: rockTokenEventStatusChanged,
FromStatus: model.RockStatusUsing,
ToStatus: model.RockStatusAbnormal,
Source: rockTokenSourceRefresh,
Reason: "上游卡密不存在(刷新自动同步)",
}).Error; err != nil {
t.Fatalf("创建状态日志失败: %v", err)
}
stats, err := svc.GetStats(context.Background())
if err != nil {
t.Fatalf("获取统计失败: %v", err)
}
if stats.AnomalyStock != 1 {
t.Fatalf("异常徽标应统计 1 条,实际 %d", stats.AnomalyStock)
}
// 异常只做标记:Token 主状态仍是 bound,因此正常计入已绑定库存。
if stats.BoundStock != 1 {
t.Fatalf("异常不应改变主状态统计,bound 应为 1,实际 %d", stats.BoundStock)
}
if stats.AbnormalStock != 0 {
t.Fatalf("异常标记不应把 Token 计入已上报失效,实际 %d", stats.AbnormalStock)
}
}
-2
View File
@@ -45,7 +45,6 @@ func (s *RockService) AssignTokenForOrder(ctx context.Context, orderNo, expected
Where("assigned_order_no = ? AND status IN ?", orderNo, []string{
model.RockStatusUsing,
model.RockStatusBound,
model.RockStatusRedeeming,
model.RockStatusMismatch,
model.RockStatusRedeemed,
model.RockStatusAbnormal,
@@ -61,7 +60,6 @@ func (s *RockService) AssignTokenForOrder(ctx context.Context, orderNo, expected
switch token.Status {
case model.RockStatusAbnormal:
return nil, nil, errors.New(rockOrderTokenLockedMessage)
case model.RockStatusRedeeming:
return nil, nil, errors.New("该订单已提交发货,上游官方处理中,不能再次领取提货号")
case model.RockStatusRedeemed:
return nil, nil, errors.New("该订单已发货,不能再次领取提货号")
+2 -1
View File
@@ -84,7 +84,8 @@ func (s *RockService) CheckBindingAndVerifyUID(ctx context.Context, tokenID uint
}
// 已提交发货(上游异步处理中)的 Token 不再参与扫码绑定轮询。
if token.Status == model.RockStatusRedeeming {
// 判定依据是标记而不是状态:主状态仍是 bound,但已提交兑换且未拿到履约结果。
if rockTokenRedeemPending(&token) {
return &CheckBindingResult{
Bound: true,
RoleName: token.ActualRoleName,
@@ -27,7 +27,7 @@ type TokenBindingRefreshResult struct {
func canRefreshRockBinding(status string) bool {
switch status {
case model.RockStatusUsing, model.RockStatusBound, model.RockStatusRedeeming, model.RockStatusMismatch:
case model.RockStatusUsing, model.RockStatusBound, model.RockStatusMismatch:
return true
default:
return false
@@ -169,15 +169,14 @@ func (s *RockService) refreshTokenBinding(ctx context.Context, tokenID uint, act
case upstreamBound:
// 上游已绑定即同步为 bound:不以本地预期 UID 做拦截,
// 否则历史 UID 不一致的 Token 永远无法把真实状态刷新回本地。
// 但本地已进入 redeeming 时不能降级回 bound,必须保持发货处理中。
if token.Status == model.RockStatusRedeeming {
updates["status"] = model.RockStatusRedeeming
// 发货确认中(有 redeem 标记未完成)时只刷新标记,状态保持 bound。
if rockTokenRedeemPending(&token) {
updates["redeem_checked_at"] = now
if msg := strings.TrimSpace(ref.Message); msg != "" {
updates["redeem_last_message"] = msg
}
result.Status = model.RockStatusRedeeming
result.Message = "上游仍未确认发货,保持发货处理中"
result.Status = model.RockStatusBound
result.Message = "上游仍未确认发货,保持发货确认中"
break
}
updates["status"] = model.RockStatusBound
@@ -198,9 +197,10 @@ func (s *RockService) refreshTokenBinding(ctx context.Context, tokenID uint, act
}
default:
if token.Status == model.RockStatusRedeeming {
if rockTokenRedeemPending(&token) {
updates["redeem_checked_at"] = now
result.Message = "上游仍未确认发货,保持发货处理中"
result.Status = model.RockStatusBound
result.Message = "上游仍未确认发货,保持发货确认中"
} else {
result.Message = "上游仍未绑定,本地状态保持不变"
}
+1 -3
View File
@@ -124,7 +124,7 @@ func (s *RockService) importPackageDetail(ctx context.Context, detail *RockPacka
canRefreshUpstream := existing.Source == "package" &&
strings.TrimSpace(existing.AssignedOrderNo) == "" &&
existing.Status != model.RockStatusAvailable &&
existing.Status != model.RockStatusRedeeming
!rockTokenRedeemPending(&existing)
if canRefreshUpstream {
updates["status"] = status
updates["restocked_at"] = nil // 上游包同步回库不属于「释放回库存」,不产生优先权
@@ -182,8 +182,6 @@ func normalizeRockPackageStatus(status string) string {
return model.RockStatusUsing
case model.RockStatusBound:
return model.RockStatusBound
case model.RockStatusRedeeming:
return model.RockStatusRedeeming
case model.RockStatusRedeemed:
return model.RockStatusRedeemed
case model.RockStatusMismatch:
+2 -2
View File
@@ -160,8 +160,8 @@ func (s *RockService) UpdateTokenRemark(ctx context.Context, tokenID uint, remar
trimmedStatus := strings.TrimSpace(nextStatus)
if trimmedStatus != "" {
if token.Status == model.RockStatusRedeeming && trimmedStatus != model.RockStatusAbnormal {
return nil, fmt.Errorf("发货处理中的 Token 只能标记为已上报失效,不能改回其他状态")
if rockTokenRedeemPending(&token) && trimmedStatus != model.RockStatusAbnormal {
return nil, fmt.Errorf("发货确认中的 Token 只能标记为已上报失效,不能改回其他状态")
}
switch trimmedStatus {
case model.RockStatusAbnormal, model.RockStatusAvailable, model.RockStatusMismatch:
+25 -18
View File
@@ -25,8 +25,9 @@ type RockRedeemResult struct {
// RedeemTokenForOrder 执行最终发货兑换(动态判定皮肤)。
//
// 上游 0666.pro 是异步兑奖:HTTP 200 + status=pending 表示「官方处理中」,
// 此时绝不能写 redeemed 终态,而是进入 redeeming 并交给后台任务轮询确认。
// 上游 0666.pro 是异步兑奖:HTTP 200 + status=pending 表示「官方处理中」。
// 此时 Token 主状态保持 bound,只写 redeem_* 标记字段等待后台确认,
// 绝不写 redeemed 终态,也不新增中间状态。
func (s *RockService) RedeemTokenForOrder(ctx context.Context, orderNo, sku, expectedUID string, recorder *RockDeliveryRecorder) (*RockRedeemResult, error) {
cfg, err := s.GetConfig(ctx)
if err != nil {
@@ -36,10 +37,10 @@ func (s *RockService) RedeemTokenForOrder(ctx context.Context, orderNo, sku, exp
return nil, fmt.Errorf("Rock 自动发货通道维护中,已关闭自动兑换")
}
// 已进入 redeeming 的 Token 不重复提交兑换,直接返回处理中,避免重复发放。
// 发货确认中(待确认)的 Token 不重复提交兑换,直接返回处理中,避免重复发放。
var token model.RockToken
err = s.db.WithContext(ctx).Unscoped().
Where("assigned_order_no = ? AND status IN ?", orderNo, []string{model.RockStatusBound, model.RockStatusRedeeming}).
Where("assigned_order_no = ? AND status = ?", orderNo, model.RockStatusBound).
First(&token).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
@@ -52,7 +53,7 @@ func (s *RockService) RedeemTokenForOrder(ctx context.Context, orderNo, sku, exp
}
ctx = withRockDeliveryRecorder(ctx, recorder)
if token.Status == model.RockStatusRedeeming {
if rockTokenRedeemPending(&token) {
msg := strings.TrimSpace(token.RedeemLastMessage)
if msg == "" {
msg = "上游官方处理中,请稍后刷新查看结果"
@@ -132,7 +133,7 @@ func (s *RockService) RedeemTokenForOrder(ctx context.Context, orderNo, sku, exp
if redeemResp.IsPending() {
msg := redeemResp.FailureMessage("官方处理中,请稍后刷新查看结果")
now := time.Now()
token = s.markTokenRedeeming(ctx, token, targetSkin, msg, recorder)
token = s.markTokenRedeemPending(ctx, token, targetSkin, msg, recorder)
if recorder != nil {
recorder.Internal(ctx, "redeem_result", "pending", msg, map[string]interface{}{
"upstream_status": redeemResp.Status,
@@ -154,7 +155,7 @@ func (s *RockService) RedeemTokenForOrder(ctx context.Context, orderNo, sku, exp
}
// 刷新失败不直接否认可信的成功响应,但也不立即写终态;交给后台轮询确认。
msg := "上游已受理,正在等待发货结果确认"
token = s.markTokenRedeeming(ctx, token, targetSkin, msg, recorder)
token = s.markTokenRedeemPending(ctx, token, targetSkin, msg, recorder)
return &RockRedeemResult{Token: &token, Response: redeemResp, Pending: true, Message: msg, TraceID: recorderTraceID(recorder)}, nil
}
if ref != nil && !rockUpstreamRedeemed(ref) {
@@ -165,7 +166,7 @@ func (s *RockService) RedeemTokenForOrder(ctx context.Context, orderNo, sku, exp
"redeemed_at": ref.Token.RedeemedAt,
})
}
token = s.markTokenRedeeming(ctx, token, targetSkin, msg, recorder)
token = s.markTokenRedeemPending(ctx, token, targetSkin, msg, recorder)
return &RockRedeemResult{Token: &token, Response: redeemResp, Pending: true, Message: msg, TraceID: recorderTraceID(recorder)}, nil
}
@@ -214,17 +215,15 @@ func (s *RockService) RedeemTokenForOrder(ctx context.Context, orderNo, sku, exp
return &RockRedeemResult{Token: &token, Response: redeemResp, Pending: false, Message: "发货成功", TraceID: recorderTraceID(recorder)}, nil
}
// markTokenRedeeming 把 Token 置为「发货处理中」,记录 pending 起点与首次轮询时间。
func (s *RockService) markTokenRedeeming(ctx context.Context, token model.RockToken, targetSkin, message string, recorder *RockDeliveryRecorder) model.RockToken {
// markTokenRedeemPending 记录「发货确认中」标记(不改变 Token 状态)。
func (s *RockService) markTokenRedeemPending(ctx context.Context, token model.RockToken, targetSkin, message string, recorder *RockDeliveryRecorder) model.RockToken {
now := time.Now()
requestedAt := token.RedeemRequestedAt
if requestedAt == nil {
requestedAt = &now
}
nextRetry := now.Add(30 * time.Second)
fromStatus := token.Status
_ = s.db.WithContext(ctx).Unscoped().Model(&token).Updates(map[string]interface{}{
"status": model.RockStatusRedeeming,
"target_skin": targetSkin,
"target_currency": "幸运币",
"redeem_requested_at": requestedAt,
@@ -235,17 +234,13 @@ func (s *RockService) markTokenRedeeming(ctx context.Context, token model.RockTo
"redeem_last_error": "",
"updated_at": now,
}).Error
writeRockTokenStatusChange(s.db.WithContext(ctx), &token, fromStatus, model.RockStatusRedeeming,
rockTokenSourceRedeem, strings.TrimSpace(message), false, nil)
if recorder != nil {
recorder.Internal(ctx, "state_change", "pending", strings.TrimSpace(message), map[string]interface{}{
"from_status": fromStatus,
"to_status": model.RockStatusRedeeming,
recorder.Internal(ctx, "redeem_pending", "pending", strings.TrimSpace(message), map[string]interface{}{
"status": token.Status,
"redeem_requested_at": requestedAt.Format("2006-01-02 15:04:05"),
"next_retry_at": nextRetry.Format("2006-01-02 15:04:05"),
})
}
token.Status = model.RockStatusRedeeming
token.TargetSkin = targetSkin
token.TargetCurrency = "幸运币"
token.RedeemRequestedAt = requestedAt
@@ -257,6 +252,18 @@ func (s *RockService) markTokenRedeeming(ctx context.Context, token model.RockTo
return token
}
// rockTokenRedeemPending 判断 Token 是否处于「发货确认中」:
// 已提交兑换、还没拿到履约结果、主状态仍是 bound。
func rockTokenRedeemPending(token *model.RockToken) bool {
if token == nil {
return false
}
if token.RedeemRequestedAt == nil || token.RedeemedAt != nil {
return false
}
return token.Status == model.RockStatusBound
}
func recorderTraceID(recorder *RockDeliveryRecorder) string {
if recorder == nil {
return ""
@@ -37,7 +37,7 @@ func (s *RockService) SetRedeemConfirmHook(hook func(ctx context.Context, order
s.redeemConfirmHook = hook
}
// RunRedeemConfirmWorker 启动 pending 兑换确认任务:每 20 秒扫描一次 redeeming Token。
// RunRedeemConfirmWorker 启动发货结果确认任务:每 20 秒扫描一次「发货确认中」标记。
//
// 频控保护:上游 110001 时全局冷却 60 秒,且每个 Token 使用指数退避,
// 避免把 0666.pro 的公开刷新接口打成持续频控。
@@ -66,7 +66,10 @@ func (s *RockService) RunRedeemConfirmWorker(ctx context.Context) {
var errRockRedeemBusy = errors.New("发货结果确认任务正在执行")
// ProcessRedeemingTokens 扫描并确认一批 pending 兑换。可被后台任务或测试直接调用。
// ProcessRedeemingTokens 扫描并确认一批「发货确认中」的 Token。
//
// 判定口径是标记而非状态:status=bound + 已提交兑换 + 尚未拿到履约结果。
// 可被后台任务或测试直接调用。
func (s *RockService) ProcessRedeemingTokens(ctx context.Context) (*RockRedeemConfirmSummary, error) {
if !rockRedeemWorkerRunning.CompareAndSwap(false, true) {
return nil, errRockRedeemBusy
@@ -76,7 +79,9 @@ func (s *RockService) ProcessRedeemingTokens(ctx context.Context) (*RockRedeemCo
now := time.Now()
var tokens []model.RockToken
if err := s.db.WithContext(ctx).
Where("status = ?", model.RockStatusRedeeming).
Where("status = ?", model.RockStatusBound).
Where("redeem_requested_at IS NOT NULL").
Where("redeemed_at IS NULL").
Where("(redeem_next_retry_at IS NULL OR redeem_next_retry_at <= ?)", now).
Order("redeem_next_retry_at ASC").
Limit(rockRedeemBatchSize).
@@ -187,7 +192,7 @@ func (s *RockService) confirmRedeemingToken(ctx context.Context, token *model.Ro
return true, false, nil
}
// 仍在官方处理中:保持 redeeming,指数退避。
// 仍在官方处理中:保持 bound + 标记,指数退避。
waitingMessage := strings.TrimSpace(ref.Message)
if waitingMessage == "" {
waitingMessage = "官方处理中,请稍后刷新查看结果"
@@ -210,16 +215,13 @@ func (s *RockService) confirmRedeemingToken(ctx context.Context, token *model.Ro
})
}
// 超过 15 分钟仍未确认:保持处理中,仅告警一次。
// 超过等待上限仍未确认:保持处理中,仅告警一次(不做状态流转)。
if token.RedeemRequestedAt != nil && now.Sub(*token.RedeemRequestedAt) >= rockRedeemTimeout && token.RedeemTimeoutWarnedAt == nil {
warnAt := now
_ = s.db.WithContext(ctx).Unscoped().Model(token).Updates(map[string]interface{}{
"redeem_timeout_warned_at": warnAt,
"redeem_last_message": waitingMessage + "(已超过 15 分钟未确认,已告警)",
}).Error
writeRockTokenStatusLog(s.db.WithContext(ctx), token, rockTokenEventStatusChanged,
model.RockStatusRedeeming, model.RockStatusRedeeming, rockTokenSourceRedeem,
"上游超过 15 分钟未确认发货,已触发告警", false, nil)
if recorder != nil {
recorder.Internal(ctx, "redeem_timeout", "pending", "上游超过 15 分钟未确认发货", nil)
}
@@ -25,8 +25,8 @@ func setRockTestBearerToken(t *testing.T, svc *RockService, bearer string) {
}
// 上游返回 pending(HTTP 200 + status=pending + token.status=bound)时:
// 本地必须进入 redeeming,绝不能写 redeemed 终态或履约流水。
func TestRedeemTokenForOrder_PendingKeepsRedeeming(t *testing.T) {
// Token 主状态必须保持 bound,只写发货确认标记,绝不写 redeemed 终态或履约流水。
func TestRedeemTokenForOrder_PendingKeepsBoundWithMarkers(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")
@@ -104,8 +104,8 @@ func TestRedeemTokenForOrder_PendingKeepsRedeeming(t *testing.T) {
if err := db.First(&saved, token.ID).Error; err != nil {
t.Fatalf("读取 Token 失败: %v", err)
}
if saved.Status != model.RockStatusRedeeming {
t.Fatalf("pending 后状态应为 redeeming,实际为 %s", saved.Status)
if saved.Status != model.RockStatusBound {
t.Fatalf("pending 后状态应保持 bound,实际为 %s", saved.Status)
}
if saved.RedeemedAt != nil {
t.Fatalf("pending 不能写 redeemed_at,实际为 %v", saved.RedeemedAt)
@@ -196,8 +196,8 @@ func TestRedeemTokenForOrder_UnexpectedPayloadDoesNotMarkRedeemed(t *testing.T)
}
}
// 已进入 redeeming 的 Token 再次提交必须直接返回处理中,不能重复调用上游兑换。
func TestRedeemTokenForOrder_RedeemingDoesNotResubmit(t *testing.T) {
// 发货确认中(已提交兑换未确认)的 Token 再次提交必须直接返回处理中,不能重复调用上游兑换。
func TestRedeemTokenForOrder_PendingDoesNotResubmit(t *testing.T) {
db := newRockTestDB(t)
var redeemCalls int
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -225,7 +225,7 @@ func TestRedeemTokenForOrder_RedeemingDoesNotResubmit(t *testing.T) {
Token: "jwt-179386",
RedeemCode: "CODE-REDEEMING",
Color: model.RockColorBlack,
Status: model.RockStatusRedeeming,
Status: model.RockStatusBound,
AssignedOrderNo: "FO-REDEEMING-1",
ExpectedUID: "3829515950",
ActualRoleName: "舱x",
@@ -247,7 +247,7 @@ func TestRedeemTokenForOrder_RedeemingDoesNotResubmit(t *testing.T) {
t.Fatalf("重复提交应返回 Pending=true: %+v", result)
}
if redeemCalls != 0 {
t.Fatalf("redeeming 状态不应再次调用上游兑换,实际调用 %d 次", redeemCalls)
t.Fatalf("发货确认中不应再次调用上游兑换,实际调用 %d 次", redeemCalls)
}
}
@@ -296,7 +296,7 @@ func TestProcessRedeemingTokens_ConfirmsAndSyncsOrder(t *testing.T) {
Token: "jwt-179384",
RedeemCode: "CODE-CONFIRM",
Color: model.RockColorBlack,
Status: model.RockStatusRedeeming,
Status: model.RockStatusBound,
AssignedOrderNo: "FO-CONFIRM-1",
ExpectedUID: "3829515948",
ActualRoleName: "舱z",
@@ -343,7 +343,7 @@ func TestProcessRedeemingTokens_ConfirmsAndSyncsOrder(t *testing.T) {
}
}
// 上游仍为 bound 时应保持 redeeming,并按指数退避延后下一次查询。
// 上游仍为 bound 时应保持发货确认标记,并按指数退避延后下一次查询。
func TestProcessRedeemingTokens_StaysPendingAndBacksOff(t *testing.T) {
db := newRockTestDB(t)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -376,7 +376,7 @@ func TestProcessRedeemingTokens_StaysPendingAndBacksOff(t *testing.T) {
Token: "jwt-179387",
RedeemCode: "CODE-STILL-PENDING",
Color: model.RockColorBlack,
Status: model.RockStatusRedeeming,
Status: model.RockStatusBound,
AssignedOrderNo: "FO-STILL-PENDING",
ExpectedUID: "3829515948",
RedeemRequestedAt: &requested,
@@ -398,8 +398,8 @@ func TestProcessRedeemingTokens_StaysPendingAndBacksOff(t *testing.T) {
if err := db.First(&saved, token.ID).Error; err != nil {
t.Fatalf("读取 Token 失败: %v", err)
}
if saved.Status != model.RockStatusRedeeming {
t.Fatalf("状态应保持 redeeming,实际 %s", saved.Status)
if saved.Status != model.RockStatusBound {
t.Fatalf("状态应保持 bound(发货确认中只写标记),实际 %s", saved.Status)
}
if saved.RedeemAttempts != 1 {
t.Fatalf("轮询次数应为 1,实际 %d", saved.RedeemAttempts)
+12 -14
View File
@@ -44,20 +44,20 @@ type RockStatsResponse struct {
AvailableStock int64 `json:"available_stock"`
UsingStock int64 `json:"using_stock"`
BoundStock int64 `json:"bound_stock"`
RedeemingStock int64 `json:"redeeming_stock"` // 上游官方处理中
RedeemedStock int64 `json:"redeemed_stock"`
MismatchStock int64 `json:"mismatch_stock"` // 绑定错误(待排查)
AbnormalStock int64 `json:"abnormal_stock"`
DeletedStock int64 `json:"deleted_stock"` // 回收站中已软删除的数量
// 过期预警计数:仅统计对应主状态(使用中 / 已绑定 / 发货处理中),用于筛选栏徽标。
UsingOverdueStock int64 `json:"using_overdue_stock"`
UsingDangerStock int64 `json:"using_danger_stock"`
BoundOverdueStock int64 `json:"bound_overdue_stock"`
RedeemingTimeoutStock int64 `json:"redeeming_timeout_stock"`
UpstreamPoints float64 `json:"upstream_points"`
AutoDeliveryEnabled bool `json:"auto_delivery_enabled"`
TokenWarningStock int `json:"token_warning_stock"`
AutoPurchaseEnabled bool `json:"auto_purchase_enabled"`
// 过期预警计数:仅统计对应主状态(使用中 / 已绑定),用于筛选栏徽标。
UsingOverdueStock int64 `json:"using_overdue_stock"`
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"`
// 自动刷新绑定错误开关状态,供筛选栏 Switch 回显
AutoRefreshMismatchEnabled bool `json:"auto_refresh_mismatch_enabled"`
// 自动补货冷却状态:供页面提示“下次可补货时间”
@@ -365,8 +365,6 @@ func (s *RockService) GetStats(ctx context.Context) (*RockStatsResponse, error)
stats.UsingStock = r.Total
case model.RockStatusBound:
stats.BoundStock = r.Total
case model.RockStatusRedeeming:
stats.RedeemingStock = r.Total
case model.RockStatusRedeemed:
stats.RedeemedStock = r.Total
case model.RockStatusMismatch:
@@ -404,8 +402,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 redeem_requested_at IS NOT NULL AND redeem_requested_at <= ?", model.RockStatusRedeeming, now.Add(-rockRedeemTimeout)).
Count(&stats.RedeemingTimeoutStock).Error; err != nil {
Where(rockTokenAnomalyExistsSQL()).
Count(&stats.AnomalyStock).Error; err != nil {
return nil, err
}
+1
View File
@@ -592,6 +592,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)
return tokens, total, nil
}
@@ -23,10 +23,9 @@ const (
)
var rockTokenDangerousStatuses = map[string]bool{
model.RockStatusUsing: true,
model.RockStatusBound: true,
model.RockStatusRedeeming: true,
model.RockStatusMismatch: true,
model.RockStatusUsing: true,
model.RockStatusBound: true,
model.RockStatusMismatch: true,
}
// RockTokenDeleteStatusCount 是删除预览中的状态分布。
@@ -68,7 +67,7 @@ type RockTokenForceRequiredError struct {
}
func (e *RockTokenForceRequiredError) Error() string {
return fmt.Sprintf("本次操作包含 %d 条使用中/已绑定/发货处理中/绑定错误的 Token,必须二次确认后强制删除", e.Dangerous)
return fmt.Sprintf("本次操作包含 %d 条使用中/已绑定/绑定错误的 Token,必须二次确认后强制删除", e.Dangerous)
}
// RockTokenDeleteConflictError 表示预览后数据已变化。
@@ -85,9 +84,9 @@ func (e *RockTokenDeleteConflictError) Error() string {
func isKnownRockTokenStatus(status string) bool {
switch status {
case "", model.RockStatusAvailable, model.RockStatusUsing, model.RockStatusBound, model.RockStatusRedeeming,
case "", model.RockStatusAvailable, model.RockStatusUsing, model.RockStatusBound,
model.RockStatusRedeemed, model.RockStatusMismatch, model.RockStatusAbnormal,
rockTokenStatusUsingOverdue, rockTokenStatusBoundOverdue:
rockTokenStatusUsingOverdue, rockTokenStatusBoundOverdue, model.RockStatusAnomaly:
return true
default:
return false
@@ -165,6 +164,9 @@ func applyRockTokenListFilters(db *gorm.DB, status, search string, now time.Time
case rockTokenStatusBoundOverdue:
db = db.Where("status = ? AND bound_at IS NOT NULL AND bound_at <= ?",
model.RockStatusBound, rockOverdueCutoff(now, boundWarnDays))
case model.RockStatusAnomaly:
// 异常是派生筛选:只看历史流转,不限定主状态,也不改状态。
db = rockTokenAnomalyFilter(db)
default:
if value := strings.TrimSpace(status); value != "" {
db = db.Where("status = ?", value)
@@ -187,7 +189,6 @@ func rockTokenStatusCountsFromRows(rows []model.RockToken) []RockTokenDeleteStat
model.RockStatusAvailable,
model.RockStatusUsing,
model.RockStatusBound,
model.RockStatusRedeeming,
model.RockStatusRedeemed,
model.RockStatusMismatch,
model.RockStatusAbnormal,
@@ -81,6 +81,7 @@ func (s *RockService) ListDeletedRockTokens(ctx context.Context, page, pageSize
}
}
applyRockTokenOverdueFlags(tokens, now, warnDays, dangerDays, boundWarnDays)
s.fillRockTokenAnomalyFlags(ctx, tokens)
return tokens, total, nil
}
+8 -3
View File
@@ -597,7 +597,6 @@ export interface RockStats {
available_stock: number;
using_stock: number;
bound_stock: number;
redeeming_stock?: number;
redeemed_stock: number;
mismatch_stock: number;
abnormal_stock: number;
@@ -605,7 +604,7 @@ export interface RockStats {
using_overdue_stock?: number;
using_danger_stock?: number;
bound_overdue_stock?: number;
redeeming_timeout_stock?: number;
anomaly_stock?: number;
auto_refresh_mismatch_enabled?: boolean;
upstream_points: number;
auto_delivery_enabled: boolean;
@@ -674,7 +673,7 @@ export interface RockTokenItem {
using_at?: string;
bound_at?: string;
redeemed_at?: string;
// 上游异步兑奖(pending)跟踪字段
// 上游异步兑奖(发货确认中)标记字段,不是状态
redeem_requested_at?: string;
redeem_checked_at?: string;
redeem_attempts?: number;
@@ -682,6 +681,12 @@ export interface RockTokenItem {
redeem_last_message?: string;
redeem_last_error?: string;
redeem_timeout_warned_at?: string;
// 异常标记(派生字段,来自历史流转,不改变主状态)
has_anomaly?: boolean;
anomaly_type?: string;
anomaly_reason?: string;
anomaly_at?: string;
anomaly_count?: number;
created_at: string;
updated_at?: string;
deleted_at?: string | null;
@@ -64,16 +64,6 @@ export function RockTokenFilterBar({
<Radio.Button value="available">可用</Radio.Button>
<Radio.Button value="using">使用中</Radio.Button>
<Radio.Button value="bound">已绑定</Radio.Button>
<Radio.Button value="redeeming">
<Badge
count={(stats?.redeeming_stock ?? 0) + (stats?.redeeming_timeout_stock ?? 0)}
offset={[6, -2]}
size="small"
style={{ backgroundColor: (stats?.redeeming_timeout_stock ?? 0) > 0 ? '#ef4444' : '#6366f1' }}
>
<span>发货处理中</span>
</Badge>
</Radio.Button>
<Radio.Button value="redeemed">已发货</Radio.Button>
<Radio.Button value="mismatch">
<Badge count={stats?.mismatch_stock ?? 0} offset={[6, -2]} size="small">
@@ -110,6 +100,17 @@ export function RockTokenFilterBar({
<span>绑定超时</span>
</Badge>
</Radio.Button>
{/* 异常放在最后:派生筛选,只看历史流转,不改变主状态 */}
<Radio.Button value="anomaly">
<Badge
count={stats?.anomaly_stock ?? 0}
offset={[6, -2]}
size="small"
style={{ backgroundColor: '#b91c1c' }}
>
<span>异常</span>
</Badge>
</Radio.Button>
</Radio.Group>
</div>
<Space size={10} style={{ flex: '0 0 auto', flexWrap: 'wrap' }}>
@@ -99,8 +99,6 @@ export function RockTokenTable({
message.warning(res.message || '上游绑定状态需要人工核对')
} else if (res.status === 'redeemed') {
message.success(res.message || '上游已发货,本地状态已同步为已交付')
} else if (res.status === 'redeeming') {
message.info(res.message || '上游仍未确认发货,保持发货处理中')
} else if (res.bound) {
const role = res.role_name || res.role_id || '-'
message.success(res.message || `刷新成功:上游已绑定「${role}」`)
@@ -28,7 +28,6 @@ const STATUS_LABELS: Record<string, string> = {
available: '可用库存',
using: '使用中',
bound: '已绑定',
redeeming: '发货处理中',
redeemed: '已发货',
mismatch: '绑定错误',
abnormal: '已上报失效',
@@ -36,7 +35,7 @@ const STATUS_LABELS: Record<string, string> = {
bound_overdue: '绑定超时',
}
const DANGEROUS_STATUSES = new Set(['using', 'bound', 'redeeming', 'mismatch'])
const DANGEROUS_STATUSES = new Set(['using', 'bound', 'mismatch'])
function statusLabel(status: string) {
return STATUS_LABELS[status] || status || '-'
@@ -50,8 +49,6 @@ function statusTagColor(status: string) {
return 'processing'
case 'bound':
return 'cyan'
case 'redeeming':
return 'geekblue'
case 'redeemed':
return 'purple'
case 'mismatch':
@@ -225,7 +222,7 @@ export function TokenDeleteModal({
<Text type="secondary"> · 其中可用库存 {available} 条</Text>
)}
{dangerous > 0 && (
<Text type="danger"> · 使用中/已绑定/发货处理中/绑定错误 {dangerous} 条</Text>
<Text type="danger"> · 使用中/已绑定/绑定错误 {dangerous} 条</Text>
)}
</div>
<div>
@@ -250,7 +247,7 @@ export function TokenDeleteModal({
showIcon
icon={<ExclamationCircleOutlined />}
message={`包含 ${dangerous} 条进行中的 Token`}
description="使用中/已绑定/发货处理中/绑定错误的 Token 与订单履约相关。删除后它们会从列表和统计中消失,可能导致客服排查困难;请确认这些订单已无需处理,或已经人工接管。"
description="使用中/已绑定/绑定错误的 Token 与订单履约相关。删除后它们会从列表和统计中消失,可能导致客服排查困难;请确认这些订单已无需处理,或已经人工接管。"
/>
)}
@@ -31,7 +31,6 @@ const STATUS_LABELS: Record<string, string> = {
available: '可用库存',
using: '使用中',
bound: '已绑定',
redeeming: '发货处理中',
redeemed: '已发货',
mismatch: '绑定错误',
abnormal: '已上报失效',
@@ -44,7 +43,6 @@ const STATUS_OPTIONS = [
{ value: 'available', label: '可用库存' },
{ value: 'using', label: '使用中' },
{ value: 'bound', label: '已绑定' },
{ value: 'redeeming', label: '发货处理中' },
{ value: 'redeemed', label: '已发货' },
{ value: 'mismatch', label: '绑定错误' },
{ value: 'abnormal', label: '已上报失效' },
@@ -52,7 +50,7 @@ const STATUS_OPTIONS = [
{ value: 'bound_overdue', label: '绑定超时' },
]
const DANGEROUS_STATUSES = new Set(['using', 'bound', 'redeeming', 'mismatch'])
const DANGEROUS_STATUSES = new Set(['using', 'bound', 'mismatch'])
const SKIP_REASONS: Record<string, string> = {
not_deleted: '已不在回收站',
@@ -72,8 +70,6 @@ function statusTagColor(status: string) {
return 'processing'
case 'bound':
return 'cyan'
case 'redeeming':
return 'geekblue'
case 'redeemed':
return 'purple'
case 'mismatch':
@@ -435,12 +435,11 @@ export function TokenStatusLogDrawer({
当前状态:{rockTokenStatusLabel(token.status)}
{token.assigned_order_no ? ` · 工单 ${token.assigned_order_no}` : ''}
</Text>
{token.status === 'redeeming' && (
<Text type="secondary" style={{ fontSize: 12 }}>
{token.redeem_last_message || '上游官方处理中,系统会自动确认发货结果'}
{token.redeem_requested_at
? ` · 提交于 ${formatDateTime(token.redeem_requested_at)}`
: ''}
{token.has_anomaly && (
<Text style={{ fontSize: 12, color: '#b91c1c' }}>
⚠️ 历史异常:{token.anomaly_type || '异常'}
{token.anomaly_count ? ` · 共 ${token.anomaly_count} 次` : ''}
{token.anomaly_at ? ` · 最近 ${formatDateTime(token.anomaly_at)}` : ''}
</Text>
)}
</Space>
+131 -105
View File
@@ -40,6 +40,45 @@ function serverElapsedDays(serverDays: number | undefined, at?: string): number
return elapsedDaysSince(at)
}
// 异常标记:独立小标签,不改变主状态。hover 展示最近一次异常原因与时间。
function AnomalyBadge({ record }: { record: RockTokenItem }) {
const reason = (record.anomaly_reason || '').trim()
const type = (record.anomaly_type || '').trim()
return (
<Tooltip
title={
<div style={{ maxWidth: 320, whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>
<div style={{ fontWeight: 600, marginBottom: 4 }}>
{type || '历史异常'}
{record.anomaly_count ? ` · 共 ${record.anomaly_count} 次` : ''}
</div>
{reason || '历史流转中出现过异常'}
{record.anomaly_at ? (
<div style={{ marginTop: 4, opacity: 0.8 }}>
最近:{formatDateTime(record.anomaly_at)}
</div>
) : null}
</div>
}
>
<Tag
style={{
margin: 0,
fontWeight: 600,
fontSize: 11,
padding: '0 6px',
color: '#b91c1c',
background: '#fff1f0',
border: '1px solid #ffa39e',
cursor: 'default',
}}
>
⚠️ 异常
</Tag>
</Tooltip>
)
}
// Token 主信息:编号/兑换码 与 包ID/提取码 上下两行展示,
// 两个复制按钮各自只复制兑换码 / 提取码,编号与包 ID 不参与复制。
export function TokenInfoCell({ record }: { record: RockTokenItem }) {
@@ -153,114 +192,106 @@ export function buildTokenColumns({
dataIndex: 'status',
width: 165,
render: (status, record) => {
switch (status) {
case 'available':
return (
<Tag color="success" style={{ fontWeight: 600, padding: '2px 8px' }}>
● 可用库存
</Tag>
)
case 'using': {
const days = serverElapsedDays(record.using_days, record.using_at)
const usingOverdue = record.using_overdue ?? (days !== null && days >= usingWarnDays)
const usingDanger = record.using_danger ?? (days !== null && days >= usingDangerDays)
const displayDays = days === null ? 0 : floorDays(days)
if (usingDanger) {
const statusTag = (() => {
switch (status) {
case 'available':
return (
<Tag color="success" style={{ fontWeight: 600, padding: '2px 8px' }}>
● 可用库存
</Tag>
)
case 'using': {
const days = serverElapsedDays(record.using_days, record.using_at)
const usingOverdue = record.using_overdue ?? (days !== null && days >= usingWarnDays)
const usingDanger = record.using_danger ?? (days !== null && days >= usingDangerDays)
const displayDays = days === null ? 0 : floorDays(days)
if (usingDanger) {
return (
<Tag
color="error"
style={{ fontWeight: 600, padding: '2px 8px', border: '1px solid #ff4d4f' }}
>
⚠️ 使用中超时 {displayDays.toFixed(1)} 天
</Tag>
)
}
if (usingOverdue) {
return (
<Tag color="warning" style={{ fontWeight: 600, padding: '2px 8px' }}>
● 扫码使用中 · {displayDays.toFixed(1)} 天
</Tag>
)
}
return (
<Tag color="processing" style={{ fontWeight: 600, padding: '2px 8px' }}>
● 扫码使用中
</Tag>
)
}
case 'bound': {
const boundDays = serverElapsedDays(record.bound_days, record.bound_at)
const boundOverdue =
record.bound_overdue ?? (boundDays !== null && boundDays >= boundWarnDays)
if (boundOverdue) {
const displayDays = boundDays === null ? 0 : floorDays(boundDays)
return (
<Tag
color="volcano"
style={{ fontWeight: 600, padding: '2px 8px', border: '1px solid #ff7a45' }}
>
⏱ 绑定超时 {displayDays.toFixed(1)} 天
</Tag>
)
}
return (
<Tag color="cyan" style={{ fontWeight: 600, padding: '2px 8px' }}>
● 已授权绑定
</Tag>
)
}
case 'redeemed':
return (
<Tag color="purple" style={{ fontWeight: 600, padding: '2px 8px' }}>
● 已交付发货
</Tag>
)
case 'mismatch':
return (
<Tag
color="error"
style={{ fontWeight: 600, padding: '2px 8px', border: '1px solid #ff4d4f' }}
>
⚠️ 使用中超时 {displayDays.toFixed(1)} 天
⚠️ 绑定错误 (阻断)
</Tag>
)
}
if (usingOverdue) {
return (
<Tag color="warning" style={{ fontWeight: 600, padding: '2px 8px' }}>
● 扫码使用中 · {displayDays.toFixed(1)} 天
</Tag>
)
}
return (
<Tag color="processing" style={{ fontWeight: 600, padding: '2px 8px' }}>
● 扫码使用中
</Tag>
)
}
case 'bound': {
const boundDays = serverElapsedDays(record.bound_days, record.bound_at)
const boundOverdue = record.bound_overdue ?? (boundDays !== null && boundDays >= boundWarnDays)
if (boundOverdue) {
const displayDays = boundDays === null ? 0 : floorDays(boundDays)
case 'abnormal':
return (
<Tag
color="volcano"
style={{ fontWeight: 600, padding: '2px 8px', border: '1px solid #ff7a45' }}
color="default"
style={{
fontWeight: 600,
padding: '2px 8px',
color: '#b91c1c',
background: '#fef2f2',
border: '1px solid #fca5a5',
}}
>
⏱ 绑定超时 {displayDays.toFixed(1)} 天
🚫 已上报失效
</Tag>
)
}
return (
<Tag color="cyan" style={{ fontWeight: 600, padding: '2px 8px' }}>
● 已授权绑定
</Tag>
)
default:
return <Tag>{status}</Tag>
}
case 'redeeming': {
// pending 起点以 redeem_requested_at 为准,按分钟展示等待时长。
const pendingDays = elapsedDaysSince(record.redeem_requested_at)
const pendingMinutes = pendingDays === null ? null : Math.floor(pendingDays * 24 * 60)
const timeoutWarned = Boolean(record.redeem_timeout_warned_at)
return (
<Tooltip
title={
record.redeem_last_message ||
'上游官方处理中,系统会自动轮询确认发货结果'
}
>
<Tag
color={timeoutWarned ? 'volcano' : 'geekblue'}
style={{ fontWeight: 600, padding: '2px 8px' }}
>
{timeoutWarned
? `⏱ 发货确认超时${pendingMinutes !== null ? ` ${pendingMinutes} 分钟` : ''}`
: '⏳ 发货处理中'}
</Tag>
</Tooltip>
)
}
case 'redeemed':
return (
<Tag color="purple" style={{ fontWeight: 600, padding: '2px 8px' }}>
● 已交付发货
</Tag>
)
case 'mismatch':
return (
<Tag color="error" style={{ fontWeight: 600, padding: '2px 8px', border: '1px solid #ff4d4f' }}>
⚠️ 绑定错误 (阻断)
</Tag>
)
case 'abnormal':
return (
<Tag
color="default"
style={{
fontWeight: 600,
padding: '2px 8px',
color: '#b91c1c',
background: '#fef2f2',
border: '1px solid #fca5a5',
}}
>
🚫 已上报失效
</Tag>
)
default:
return <Tag>{status}</Tag>
}
})()
// 异常是独立标记:不替换主状态,只在后面追加一个小标签。
if (!record.has_anomaly) return statusTag
return (
<Space direction="vertical" size={4}>
{statusTag}
<AnomalyBadge record={record} />
</Space>
)
},
},
{
@@ -293,16 +324,11 @@ export function buildTokenColumns({
{usingDays !== null ? `(${floorDays(usingDays).toFixed(1)} 天)` : ''}
</div>
)}
{record.status === 'redeeming' && (
<div
style={{
fontSize: 11,
color: record.redeem_timeout_warned_at ? '#dc2626' : '#4338ca',
}}
>
<span>⏳ 提交发货: </span>
{formatDateTime(record.redeem_requested_at)}
{record.redeem_attempts ? `(已查询 ${record.redeem_attempts} 次)` : ''}
{record.anomaly_at && (
<div style={{ fontSize: 11, color: '#b91c1c' }}>
<span>⚠️ 异常: </span>
{formatDateTime(record.anomaly_at)}
{record.anomaly_type ? `(${record.anomaly_type})` : ''}
</div>
)}
{record.mismatch_at && (
-1
View File
@@ -10,7 +10,6 @@ export const ROCK_TOKEN_STATUS_LABELS: Record<string, string> = {
available: '可用库存',
using: '扫码使用中',
bound: '已授权绑定',
redeeming: '发货处理中',
redeemed: '已交付发货',
mismatch: '绑定错误',
abnormal: '已上报失效',