- 异常判定排除 status=redeemed 或 redeemed_at 非空的 Token,发货前的 mismatch/失败不再计入 - 筛选、统计徽标、列表派生标记统一复用同一口径 - 新增已发货 Token 排除的单元测试 - 删除 rock_assign.go 中 redeeming 分支替换后残留的不可达 return
160 lines
4.8 KiB
Go
160 lines
4.8 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 / 失败属于可恢复阻断,最终已履约成功,不需要人工处理;
|
||
// 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())
|
||
}
|