174 lines
5.2 KiB
Go
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())
|
|
}
|