Files
affiliate_dash/backend/internal/service/rock_anomaly_marker.go
T

174 lines
5.2 KiB
Go

package service
import (
"context"
"strings"
"time"
"affiliate_dash/internal/model"
"gorm.io/gorm"
)
// 异常判定口径(与产品确认一致):
// 1. 状态流转历史中出现过 mismatch / abnormal(绑定错误、上游失效);
// 2. 状态流转原因或备注中出现失败类关键字(兑换失败、刷新失败、卡密不存在、超时等);
// 3. 已有明确结论的 Token 不再算异常:
// - 已最终发货(status=redeemed 或 redeemed_at 非空):发货前的 mismatch / 失败属于可恢复
// 阻断,最终已履约成功,不需要人工处理;
// - 已上报失效(status=abnormal):库存状态列已明确标识,无需重复标记;
// 4. 只要命中任意一条即为「异常」,不改变 Token 主状态。
//
// 采用子查询而不是落库字段,保证历史数据无需迁移即可被筛选到。
const rockAnomalyLogConditionSQL = `(
l.event_type <> 'incident'
AND (
l.to_status IN ('mismatch', 'abnormal')
OR l.reason LIKE '%失败%'
OR l.reason LIKE '%不存在%'
OR l.reason LIKE '%异常%'
OR l.reason LIKE '%超时%'
OR l.reason LIKE '%拦截%'
OR l.reason LIKE '%不符%'
)
)`
// rockAnomalyTokenScopeSQL 排除已有明确结论的 Token(别名固定为 t):
// 已最终发货(redeemed 或 redeemed_at 非空)与已上报失效(abnormal)。
const rockAnomalyTokenScopeSQL = `t.status NOT IN ('redeemed', 'abnormal') AND t.redeemed_at IS NULL`
func rockTokenAnomalyExistsSQL() string {
return `(
EXISTS (
SELECT 1 FROM rock_token_status_logs l
JOIN rock_tokens t ON t.id = l.token_id
WHERE l.token_id = rock_tokens.id
AND ` + rockAnomalyTokenScopeSQL + `
AND ` + rockAnomalyLogConditionSQL + `
)
OR EXISTS (
SELECT 1 FROM rock_delivery_incidents i
WHERE i.token_id = rock_tokens.id
AND i.status = 'open'
AND i.category <> 'no_entitlement'
)
)`
}
// 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())
}