Files
affiliate_dash/backend/internal/service/rock_anomaly_marker.go
T
zhaoxing ad8d1704db fix: 已发货 Token 不再计为异常,清理遗留不可达代码
- 异常判定排除 status=redeemed 或 redeemed_at 非空的 Token,发货前的 mismatch/失败不再计入
- 筛选、统计徽标、列表派生标记统一复用同一口径
- 新增已发货 Token 排除的单元测试
- 删除 rock_assign.go 中 redeeming 分支替换后残留的不可达 return
2026-11-14 16:05:29 +08:00

160 lines
4.8 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package service
import (
"context"
"strings"
"time"
"affiliate_dash/internal/model"
"gorm.io/gorm"
)
// 异常判定口径(与产品确认一致):
// 1. 状态流转历史中出现过 mismatch / abnormal(绑定错误、上游失效);
// 2. 状态流转原因或备注中出现失败类关键字(兑换失败、刷新失败、卡密不存在、超时等);
// 3. 已最终发货的 Token(status=redeemed 或 redeemed_at 非空)不再算异常:
// 发货前的 mismatch / 失败属于可恢复阻断,最终已履约成功,不需要人工处理;
// 4. 只要命中任意一条即为「异常」,不改变 Token 主状态。
//
// 采用子查询而不是落库字段,保证历史数据无需迁移即可被筛选到。
const rockAnomalyLogConditionSQL = `(
l.to_status IN ('mismatch', 'abnormal')
OR l.reason LIKE '%失败%'
OR l.reason LIKE '%不存在%'
OR l.reason LIKE '%异常%'
OR l.reason LIKE '%超时%'
OR l.reason LIKE '%拦截%'
OR l.reason LIKE '%不符%'
)`
// 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 + `
)`
}
// 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
JOIN rock_tokens t ON t.id = l.token_id
WHERE l.token_id IN (?)
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 ` + rockAnomalyTokenScopeSQL + `
AND ` + rockAnomalyLogConditionSQL + `
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 查询。
// 已最终发货的 Token 不会命中,因此不会带上异常标记。
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())
}