fix(通道流量阈值): AUG26-011 修复失败/未知结果收敛、锁定 carrier 缺失出路与审计回归
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 13m29s

This commit is contained in:
2026-09-16 16:41:25 +08:00
parent 59b3df868a
commit 15bbb953db
11 changed files with 250 additions and 58 deletions

View File

@@ -122,12 +122,36 @@ func (s *Service) ClaimResumeSubmission(ctx context.Context, lockID uint, now ti
return claimed.RowsAffected == 1, nil
}
// ScanExpiredLocks 扫描已跨期但仍持锁的锁行,供周期处理解锁与条件复机
// DueLockKind 描述一条仍在持锁的锁行的跨期判定结果
type DueLockKind string
const (
// DueLockExpired 表示已跨期:锁行 period_start 早于该锁行自身运营商按 data_reset_day 算出的当前周期起点。
DueLockExpired DueLockKind = "expired"
// DueLockUnresolvable 表示周期归属不可判定:锁行引用的运营商已不存在(含软删)或重置日非法。
DueLockUnresolvable DueLockKind = "unresolvable"
)
// unresolvableCarrierReason 是周期归属不可判定时写入锁行的可安全原因。
const unresolvableCarrierReason = "锁行引用的运营商已不存在或上游流量重置日非法,已按跨期解除通道锁并转人工核对"
// DueLock 是周期处理扫描到的一条待处理锁行。
type DueLock struct {
// Lock 是持锁锁行本身。
Lock model.CarrierTrafficThresholdLock
// Kind 是跨期判定结果:已跨期或周期归属不可判定。
Kind DueLockKind
// Reason 是周期归属不可判定时的可安全原因Kind 为 DueLockExpired 时为空)。
Reason string
}
// ScanDueLocks 扫描需要周期处理的持锁锁行,供周期处理解锁与条件复机。
//
// 过期判断按锁行自身运营商的 data_reset_day 计算其当前周期起点,与锁行 period_start 不一致即已跨期,
// 因此换运营商后的旧锁仍按其旧 carrier 的归属被正确识别。锁行引用的运营商已不存在时无法计算周期,
// 本次不返回该锁(不猜测),交由周期处理标记异常转人工
func (s *Service) ScanExpiredLocks(ctx context.Context, now time.Time, limit int) ([]model.CarrierTrafficThresholdLock, error) {
// 因此换运营商后的旧锁仍按其旧 carrier 的归属被正确识别。锁行引用的运营商已不存在或重置日非法时
// 无法计算周期归属,这类行以 DueLockUnresolvable 返回:周期处理必须给出出路(按跨期语义解锁并转人工
// 绝不能让持锁卡因配置缺失而永久禁止复机。
func (s *Service) ScanDueLocks(ctx context.Context, now time.Time, limit int) ([]DueLock, error) {
if s == nil || s.db == nil {
return nil, nil
}
@@ -144,34 +168,40 @@ func (s *Service) ScanExpiredLocks(ctx context.Context, now time.Time, limit int
if err != nil {
return nil, err
}
expired := make([]model.CarrierTrafficThresholdLock, 0, len(locks))
due := make([]DueLock, 0, len(locks))
for index := range locks {
resetDay, ok := resetDays[locks[index].CarrierID]
lock := locks[index]
resetDay, ok := resetDays[lock.CarrierID]
if !ok {
due = append(due, DueLock{Lock: lock, Kind: DueLockUnresolvable, Reason: unresolvableCarrierReason})
continue
}
periodStart, err := domain.PeriodStart(now, resetDay)
if err != nil {
periodStart, periodErr := domain.PeriodStart(now, resetDay)
if periodErr != nil {
due = append(due, DueLock{Lock: lock, Kind: DueLockUnresolvable, Reason: unresolvableCarrierReason})
continue
}
if locks[index].PeriodStart.Before(periodStart) {
expired = append(expired, locks[index])
if lock.PeriodStart.Before(periodStart) {
due = append(due, DueLock{Lock: lock, Kind: DueLockExpired})
}
}
return expired, nil
return due, nil
}
// ScanSubmittedLocks 扫描存在已提交子任务的锁行,供恢复扫描查询运营商状态回填。
// ScanSubmittedLocks 扫描存在未决子任务的锁行,供恢复扫描查询运营商状态回填。
//
// 未决集合为 {submitted, unknown, failed}domain.UnresolvedTaskStatuses调用失败或结果未知的行
// 仍可能已在运营商侧生效,因此必须继续用只读状态查询收敛,不得退出链路。
// 已标记异常(转人工)的锁必须退出扫描,否则每次扫描都会重复查询同一笔无法收敛的结果。
func (s *Service) ScanSubmittedLocks(ctx context.Context, limit int) ([]model.CarrierTrafficThresholdLock, error) {
if s == nil || s.db == nil {
return nil, nil
}
unresolved := domain.UnresolvedTaskStatuses()
var locks []model.CarrierTrafficThresholdLock
if err := s.db.WithContext(ctx).
Where("anomaly_flag = ? AND (stop_status = ? OR resume_status = ?)",
domain.AnomalyFlagNone, domain.TaskStatusSubmitted, domain.TaskStatusSubmitted).
Where("anomaly_flag = ? AND (stop_status IN ? OR resume_status IN ?)",
domain.AnomalyFlagNone, unresolved, unresolved).
Order("id ASC").Limit(limit).Find(&locks).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "扫描待确认通道流量阈值任务失败")
}
@@ -238,10 +268,10 @@ func (s *Service) loadCard(ctx context.Context, cardID uint) (*model.IotCard, er
return &card, nil
}
// markTaskOutcome 按 expected 状态条件更新把子任务推进到终态ENG-CONC-001
// 返回 false 表示记录已被并发推进,调用方必须按幂等处理,不再重复执行外部动作。
func (s *Service) markTaskOutcome(ctx context.Context, lockID uint, task lockTask, expected, result, integrationID, failureReason string) (bool, error) {
if s == nil || s.db == nil || lockID == 0 {
// markTaskOutcome 按 expected 状态集合条件更新把子任务推进到终态ENG-CONC-001
// 返回 false 表示记录已被并发推进或已处于终态,调用方必须按幂等处理,不再重复执行外部动作。
func (s *Service) markTaskOutcome(ctx context.Context, lockID uint, task lockTask, expected []string, result, integrationID, failureReason string) (bool, error) {
if s == nil || s.db == nil || lockID == 0 || len(expected) == 0 {
return false, nil
}
updates := map[string]any{task.statusColumn: result}
@@ -252,7 +282,7 @@ func (s *Service) markTaskOutcome(ctx context.Context, lockID uint, task lockTas
updates["failure_reason"] = safeFailureReason(failureReason)
}
result_ := s.db.WithContext(ctx).Model(&model.CarrierTrafficThresholdLock{}).
Where("id = ? AND "+task.statusColumn+" = ?", lockID, expected).
Where("id = ? AND "+task.statusColumn+" IN ?", lockID, expected).
Updates(updates)
if result_.Error != nil {
return false, errors.Wrap(errors.CodeDatabaseError, result_.Error, "回填通道阈值子任务状态失败")
@@ -261,8 +291,10 @@ func (s *Service) markTaskOutcome(ctx context.Context, lockID uint, task lockTas
}
// markTaskConfirmed 由恢复扫描在运营商状态确认成功后把子任务回填为已确认。
// expected 取未决集合 {submitted, unknown, failed}:失败与结果未知同样可能已在运营商侧生效,
// 必须允许收敛为已确认confirmed 不在集合内,因此确认只会写入一次。
func (s *Service) markTaskConfirmed(ctx context.Context, lockID uint, task lockTask, integrationID string) (bool, error) {
return s.markTaskOutcome(ctx, lockID, task, domain.TaskStatusSubmitted, domain.TaskStatusConfirmed, integrationID, "")
return s.markTaskOutcome(ctx, lockID, task, domain.UnresolvedTaskStatuses(), domain.TaskStatusConfirmed, integrationID, "")
}
// markAnomaly 把锁标记为需人工核对并退出自动扫描。