diff --git a/backend/internal/service/rock_anomaly_marker.go b/backend/internal/service/rock_anomaly_marker.go index d576674..454d5b4 100644 --- a/backend/internal/service/rock_anomaly_marker.go +++ b/backend/internal/service/rock_anomaly_marker.go @@ -12,14 +12,12 @@ import ( // 异常判定口径(与产品确认一致): // 1. 状态流转历史中出现过 mismatch / abnormal(绑定错误、上游失效); // 2. 状态流转原因或备注中出现失败类关键字(兑换失败、刷新失败、卡密不存在、超时等); -// 3. 只要命中任意一条即为「异常」,不改变 Token 主状态。 +// 3. 已最终发货的 Token(status=redeemed 或 redeemed_at 非空)不再算异常: +// 发货前的 mismatch / 失败属于可恢复阻断,最终已履约成功,不需要人工处理; +// 4. 只要命中任意一条即为「异常」,不改变 Token 主状态。 // // 采用子查询而不是落库字段,保证历史数据无需迁移即可被筛选到。 -func rockTokenAnomalyExistsSQL() string { - return `EXISTS ( - SELECT 1 FROM rock_token_status_logs l - WHERE l.token_id = rock_tokens.id - AND ( +const rockAnomalyLogConditionSQL = `( l.to_status IN ('mismatch', 'abnormal') OR l.reason LIKE '%失败%' OR l.reason LIKE '%不存在%' @@ -27,7 +25,18 @@ func rockTokenAnomalyExistsSQL() string { OR l.reason LIKE '%超时%' OR l.reason LIKE '%拦截%' OR l.reason LIKE '%不符%' - ) + )` + +// rockAnomalyTokenScopeSQL 排除已最终发货的 Token(别名固定为 t)。 +const rockAnomalyTokenScopeSQL = `t.status <> 'redeemed' 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 + ` )` } @@ -39,32 +48,20 @@ const rockAnomalyRecentSQL = ` l.reason, l.created_at FROM rock_token_status_logs l + JOIN rock_tokens t ON t.id = l.token_id 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 '%不符%' - ) + AND ` + rockAnomalyTokenScopeSQL + ` + AND ` + rockAnomalyLogConditionSQL + ` ORDER BY l.token_id, l.id DESC ` const rockAnomalyCountSQL = ` SELECT l.token_id, COUNT(*) AS total FROM rock_token_status_logs l + JOIN rock_tokens t ON t.id = l.token_id 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 '%不符%' - ) + AND ` + rockAnomalyTokenScopeSQL + ` + AND ` + rockAnomalyLogConditionSQL + ` GROUP BY l.token_id ` @@ -81,6 +78,7 @@ type rockAnomalyCountRow struct { } // fillRockTokenAnomalyFlags 为一页 Token 批量补齐异常标记,避免 N+1 查询。 +// 已最终发货的 Token 不会命中,因此不会带上异常标记。 func (s *RockService) fillRockTokenAnomalyFlags(ctx context.Context, tokens []model.RockToken) { if len(tokens) == 0 { return diff --git a/backend/internal/service/rock_anomaly_marker_test.go b/backend/internal/service/rock_anomaly_marker_test.go index 234a321..a1f8810 100644 --- a/backend/internal/service/rock_anomaly_marker_test.go +++ b/backend/internal/service/rock_anomaly_marker_test.go @@ -177,3 +177,87 @@ func TestGetStats_AnomalyStockCountsDerivedAnomalies(t *testing.T) { t.Fatalf("异常标记不应把 Token 计入已上报失效,实际 %d", stats.AbnormalStock) } } + +// 已最终发货的 Token 不再算异常:发货前的 mismatch 属于可恢复阻断,最终已履约成功。 +func TestListTokens_AnomalyFilterExcludesDeliveredTokens(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() + deliveredByStatus := model.RockToken{ + UpstreamTokenID: "DELIVERED-1", + Token: "jwt-delivered-1", + RedeemCode: "CODE-DELIVERED-1", + Color: model.RockColorBlack, + Status: model.RockStatusRedeemed, + BoundAt: &now, + } + deliveredByTime := model.RockToken{ + UpstreamTokenID: "DELIVERED-2", + Token: "jwt-delivered-2", + RedeemCode: "CODE-DELIVERED-2", + Color: model.RockColorBlack, + Status: model.RockStatusBound, + BoundAt: &now, + RedeemedAt: &now, + } + pendingToken := model.RockToken{ + UpstreamTokenID: "BOUND-1", + Token: "jwt-bound-1", + RedeemCode: "CODE-BOUND-1", + Color: model.RockColorBlack, + Status: model.RockStatusBound, + BoundAt: &now, + } + + // 三个 Token 都有 mismatch 历史,只有未发货的那条应算异常。 + for _, token := range []*model.RockToken{&deliveredByStatus, &deliveredByTime, &pendingToken} { + 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.RockStatusMismatch, + Source: rockTokenSourceBindCheck, + Reason: "预期UID(3829515948) 与 扫码UID(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 || list[0].ID != pendingToken.ID { + t.Fatalf("已发货 Token 不应命中异常筛选,实际 total=%d len=%d", total, len(list)) + } + + all, _, err := svc.ListTokens(context.Background(), 1, 20, "", "") + if err != nil { + t.Fatalf("查询全部失败: %v", err) + } + flagged := 0 + for i := range all { + if all[i].HasAnomaly { + flagged++ + } + } + if flagged != 1 { + t.Fatalf("已发货 Token 不应带异常标记,实际 flagged=%d", flagged) + } + + stats, err := svc.GetStats(context.Background()) + if err != nil { + t.Fatalf("获取统计失败: %v", err) + } + if stats.AnomalyStock != 1 { + t.Fatalf("异常徽标应只统计未发货的 1 条,实际 %d", stats.AnomalyStock) + } +} diff --git a/backend/internal/service/rock_assign.go b/backend/internal/service/rock_assign.go index b322b9f..d61c97d 100644 --- a/backend/internal/service/rock_assign.go +++ b/backend/internal/service/rock_assign.go @@ -60,7 +60,6 @@ func (s *RockService) AssignTokenForOrder(ctx context.Context, orderNo, expected switch token.Status { case model.RockStatusAbnormal: return nil, nil, errors.New(rockOrderTokenLockedMessage) - return nil, nil, errors.New("该订单已提交发货,上游官方处理中,不能再次领取提货号") case model.RockStatusRedeemed: return nil, nil, errors.New("该订单已发货,不能再次领取提货号") case model.RockStatusMismatch: