diff --git a/backend/internal/database/migrations/028_rock_drop_redeeming_status.sql b/backend/internal/database/migrations/028_rock_drop_redeeming_status.sql new file mode 100644 index 0000000..920e0db --- /dev/null +++ b/backend/internal/database/migrations/028_rock_drop_redeeming_status.sql @@ -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 '超过等待上限仍未确认时写入的告警时间(仅标记)'; \ No newline at end of file diff --git a/backend/internal/model/rock.go b/backend/internal/model/rock.go index 8e99ce0..2877e6d 100644 --- a/backend/internal/model/rock.go +++ b/backend/internal/model/rock.go @@ -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 { diff --git a/backend/internal/service/provider_rock.go b/backend/internal/service/provider_rock.go index fbf0610..a37bb74 100644 --- a/backend/internal/service/provider_rock.go +++ b/backend/internal/service/provider_rock.go @@ -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, }). diff --git a/backend/internal/service/rock_anomaly_marker.go b/backend/internal/service/rock_anomaly_marker.go new file mode 100644 index 0000000..d576674 --- /dev/null +++ b/backend/internal/service/rock_anomaly_marker.go @@ -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()) +} diff --git a/backend/internal/service/rock_anomaly_marker_test.go b/backend/internal/service/rock_anomaly_marker_test.go new file mode 100644 index 0000000..234a321 --- /dev/null +++ b/backend/internal/service/rock_anomaly_marker_test.go @@ -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) + } +} diff --git a/backend/internal/service/rock_assign.go b/backend/internal/service/rock_assign.go index 6017aea..b322b9f 100644 --- a/backend/internal/service/rock_assign.go +++ b/backend/internal/service/rock_assign.go @@ -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("该订单已发货,不能再次领取提货号") diff --git a/backend/internal/service/rock_bindcheck.go b/backend/internal/service/rock_bindcheck.go index 4e8296a..3f52ac7 100644 --- a/backend/internal/service/rock_bindcheck.go +++ b/backend/internal/service/rock_bindcheck.go @@ -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, diff --git a/backend/internal/service/rock_binding_refresh.go b/backend/internal/service/rock_binding_refresh.go index 33d98d7..28a7d95 100644 --- a/backend/internal/service/rock_binding_refresh.go +++ b/backend/internal/service/rock_binding_refresh.go @@ -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 = "上游仍未绑定,本地状态保持不变" } diff --git a/backend/internal/service/rock_package.go b/backend/internal/service/rock_package.go index b7e90de..dbb5f0e 100644 --- a/backend/internal/service/rock_package.go +++ b/backend/internal/service/rock_package.go @@ -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: diff --git a/backend/internal/service/rock_recover.go b/backend/internal/service/rock_recover.go index 3605a7f..5d068d1 100644 --- a/backend/internal/service/rock_recover.go +++ b/backend/internal/service/rock_recover.go @@ -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: diff --git a/backend/internal/service/rock_redeem.go b/backend/internal/service/rock_redeem.go index 45ea4f1..cb3f263 100644 --- a/backend/internal/service/rock_redeem.go +++ b/backend/internal/service/rock_redeem.go @@ -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 "" diff --git a/backend/internal/service/rock_redeem_confirm.go b/backend/internal/service/rock_redeem_confirm.go index b2a1afb..f602610 100644 --- a/backend/internal/service/rock_redeem_confirm.go +++ b/backend/internal/service/rock_redeem_confirm.go @@ -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) } diff --git a/backend/internal/service/rock_redeem_pending_test.go b/backend/internal/service/rock_redeem_pending_test.go index 1023c37..479aaa9 100644 --- a/backend/internal/service/rock_redeem_pending_test.go +++ b/backend/internal/service/rock_redeem_pending_test.go @@ -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) diff --git a/backend/internal/service/rock_service.go b/backend/internal/service/rock_service.go index c322e95..5c8f0b2 100644 --- a/backend/internal/service/rock_service.go +++ b/backend/internal/service/rock_service.go @@ -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 } diff --git a/backend/internal/service/rock_stock.go b/backend/internal/service/rock_stock.go index 27a8c48..b2d2ec9 100644 --- a/backend/internal/service/rock_stock.go +++ b/backend/internal/service/rock_stock.go @@ -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 } diff --git a/backend/internal/service/rock_token_delete.go b/backend/internal/service/rock_token_delete.go index bb84f75..522127a 100644 --- a/backend/internal/service/rock_token_delete.go +++ b/backend/internal/service/rock_token_delete.go @@ -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, diff --git a/backend/internal/service/rock_token_restore.go b/backend/internal/service/rock_token_restore.go index d6bc387..48836cc 100644 --- a/backend/internal/service/rock_token_restore.go +++ b/backend/internal/service/rock_token_restore.go @@ -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 } diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index a7cc985..70515d9 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -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; diff --git a/frontend/src/pages/platformRock/RockTokenFilterBar.tsx b/frontend/src/pages/platformRock/RockTokenFilterBar.tsx index c4de8ec..f837cd5 100644 --- a/frontend/src/pages/platformRock/RockTokenFilterBar.tsx +++ b/frontend/src/pages/platformRock/RockTokenFilterBar.tsx @@ -64,16 +64,6 @@ export function RockTokenFilterBar({ 可用 使用中 已绑定 - - 0 ? '#ef4444' : '#6366f1' }} - > - 发货处理中 - - 已发货 @@ -110,6 +100,17 @@ export function RockTokenFilterBar({ 绑定超时 + {/* 异常放在最后:派生筛选,只看历史流转,不改变主状态 */} + + + 异常 + + diff --git a/frontend/src/pages/platformRock/RockTokenTable.tsx b/frontend/src/pages/platformRock/RockTokenTable.tsx index 1bf6c28..2e7fed6 100644 --- a/frontend/src/pages/platformRock/RockTokenTable.tsx +++ b/frontend/src/pages/platformRock/RockTokenTable.tsx @@ -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}」`) diff --git a/frontend/src/pages/platformRock/TokenDeleteModal.tsx b/frontend/src/pages/platformRock/TokenDeleteModal.tsx index 1a424ab..50f4b49 100644 --- a/frontend/src/pages/platformRock/TokenDeleteModal.tsx +++ b/frontend/src/pages/platformRock/TokenDeleteModal.tsx @@ -28,7 +28,6 @@ const STATUS_LABELS: Record = { available: '可用库存', using: '使用中', bound: '已绑定', - redeeming: '发货处理中', redeemed: '已发货', mismatch: '绑定错误', abnormal: '已上报失效', @@ -36,7 +35,7 @@ const STATUS_LABELS: Record = { 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({ · 其中可用库存 {available} 条 )} {dangerous > 0 && ( - · 使用中/已绑定/发货处理中/绑定错误 {dangerous} 条 + · 使用中/已绑定/绑定错误 {dangerous} 条 )}
@@ -250,7 +247,7 @@ export function TokenDeleteModal({ showIcon icon={} message={`包含 ${dangerous} 条进行中的 Token`} - description="使用中/已绑定/发货处理中/绑定错误的 Token 与订单履约相关。删除后它们会从列表和统计中消失,可能导致客服排查困难;请确认这些订单已无需处理,或已经人工接管。" + description="使用中/已绑定/绑定错误的 Token 与订单履约相关。删除后它们会从列表和统计中消失,可能导致客服排查困难;请确认这些订单已无需处理,或已经人工接管。" /> )} diff --git a/frontend/src/pages/platformRock/TokenRecycleBinDrawer.tsx b/frontend/src/pages/platformRock/TokenRecycleBinDrawer.tsx index 8238b32..4768a1f 100644 --- a/frontend/src/pages/platformRock/TokenRecycleBinDrawer.tsx +++ b/frontend/src/pages/platformRock/TokenRecycleBinDrawer.tsx @@ -31,7 +31,6 @@ const STATUS_LABELS: Record = { 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 = { 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': diff --git a/frontend/src/pages/platformRock/TokenStatusLogDrawer.tsx b/frontend/src/pages/platformRock/TokenStatusLogDrawer.tsx index 16f7307..d24636f 100644 --- a/frontend/src/pages/platformRock/TokenStatusLogDrawer.tsx +++ b/frontend/src/pages/platformRock/TokenStatusLogDrawer.tsx @@ -435,12 +435,11 @@ export function TokenStatusLogDrawer({ 当前状态:{rockTokenStatusLabel(token.status)} {token.assigned_order_no ? ` · 工单 ${token.assigned_order_no}` : ''} - {token.status === 'redeeming' && ( - - {token.redeem_last_message || '上游官方处理中,系统会自动确认发货结果'} - {token.redeem_requested_at - ? ` · 提交于 ${formatDateTime(token.redeem_requested_at)}` - : ''} + {token.has_anomaly && ( + + ⚠️ 历史异常:{token.anomaly_type || '异常'} + {token.anomaly_count ? ` · 共 ${token.anomaly_count} 次` : ''} + {token.anomaly_at ? ` · 最近 ${formatDateTime(token.anomaly_at)}` : ''} )} diff --git a/frontend/src/pages/platformRock/tokenColumns.tsx b/frontend/src/pages/platformRock/tokenColumns.tsx index 677820c..2fbcb6b 100644 --- a/frontend/src/pages/platformRock/tokenColumns.tsx +++ b/frontend/src/pages/platformRock/tokenColumns.tsx @@ -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 ( + +
+ {type || '历史异常'} + {record.anomaly_count ? ` · 共 ${record.anomaly_count} 次` : ''} +
+ {reason || '历史流转中出现过异常'} + {record.anomaly_at ? ( +
+ 最近:{formatDateTime(record.anomaly_at)} +
+ ) : null} +
+ } + > + + ⚠️ 异常 + + + ) +} + // 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 ( - - ● 可用库存 - - ) - 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 ( + + ● 可用库存 + + ) + 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 ( + + ⚠️ 使用中超时 {displayDays.toFixed(1)} 天 + + ) + } + if (usingOverdue) { + return ( + + ● 扫码使用中 · {displayDays.toFixed(1)} 天 + + ) + } + return ( + + ● 扫码使用中 + + ) + } + 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 ( + + ⏱ 绑定超时 {displayDays.toFixed(1)} 天 + + ) + } + return ( + + ● 已授权绑定 + + ) + } + case 'redeemed': + return ( + + ● 已交付发货 + + ) + case 'mismatch': return ( - ⚠️ 使用中超时 {displayDays.toFixed(1)} 天 + ⚠️ 绑定错误 (阻断) ) - } - if (usingOverdue) { - return ( - - ● 扫码使用中 · {displayDays.toFixed(1)} 天 - - ) - } - return ( - - ● 扫码使用中 - - ) - } - 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 ( - ⏱ 绑定超时 {displayDays.toFixed(1)} 天 + 🚫 已上报失效 ) - } - return ( - - ● 已授权绑定 - - ) + default: + return {status} } - 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 ( - - - {timeoutWarned - ? `⏱ 发货确认超时${pendingMinutes !== null ? ` ${pendingMinutes} 分钟` : ''}` - : '⏳ 发货处理中'} - - - ) - } - case 'redeemed': - return ( - - ● 已交付发货 - - ) - case 'mismatch': - return ( - - ⚠️ 绑定错误 (阻断) - - ) - case 'abnormal': - return ( - - 🚫 已上报失效 - - ) - default: - return {status} - } + })() + + // 异常是独立标记:不替换主状态,只在后面追加一个小标签。 + if (!record.has_anomaly) return statusTag + return ( + + {statusTag} + + + ) }, }, { @@ -293,16 +324,11 @@ export function buildTokenColumns({ {usingDays !== null ? `(${floorDays(usingDays).toFixed(1)} 天)` : ''} )} - {record.status === 'redeeming' && ( -
- ⏳ 提交发货: - {formatDateTime(record.redeem_requested_at)} - {record.redeem_attempts ? `(已查询 ${record.redeem_attempts} 次)` : ''} + {record.anomaly_at && ( +
+ ⚠️ 异常: + {formatDateTime(record.anomaly_at)} + {record.anomaly_type ? `(${record.anomaly_type})` : ''}
)} {record.mismatch_at && ( diff --git a/frontend/src/pages/platformRock/types.ts b/frontend/src/pages/platformRock/types.ts index f88b206..3b4d5b8 100644 --- a/frontend/src/pages/platformRock/types.ts +++ b/frontend/src/pages/platformRock/types.ts @@ -10,7 +10,6 @@ export const ROCK_TOKEN_STATUS_LABELS: Record = { available: '可用库存', using: '扫码使用中', bound: '已授权绑定', - redeeming: '发货处理中', redeemed: '已交付发货', mismatch: '绑定错误', abnormal: '已上报失效',