fix(通道流量阈值): AUG26-011 修复失败/未知结果收敛、锁定 carrier 缺失出路与审计回归
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 13m29s
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 13m29s
This commit is contained in:
@@ -18,38 +18,42 @@ const cycleBatchSize = 200
|
||||
|
||||
// CycleStats 是一次周期处理的可观察结果。
|
||||
//
|
||||
// Scanned 为扫到的跨期持锁数;Unlocked 为本次认领解锁成功的锁数;Resumed 为同时写出复机事件的锁数;
|
||||
// Skipped 为本地事实缺失、被并发推进或判定失败而未改动的锁数。
|
||||
type CycleStats struct{ Scanned, Unlocked, Resumed, Skipped int }
|
||||
// Scanned 为扫到的待处理持锁数;Unlocked 为本次认领解锁成功的锁数;Resumed 为同时写出复机事件的锁数;
|
||||
// Anomaly 为因运营商配置缺失或重置日非法而解锁并转人工的锁数;Skipped 为本地事实缺失、被并发推进
|
||||
// 或判定失败而未改动的锁数。
|
||||
type CycleStats struct{ Scanned, Unlocked, Resumed, Anomaly, Skipped int }
|
||||
|
||||
// ProcessDueLocks 扫描已跨期的持锁锁行:认领解锁,并在新周期条件满足时同事务写复机事件。
|
||||
// ProcessDueLocks 扫描待处理的持锁锁行:认领解锁,并在新周期条件满足时同事务写复机事件。
|
||||
//
|
||||
// 过期判断按锁行自身运营商的 data_reset_day(支持换运营商后旧锁仍按其归属处理)。解锁与复机事件
|
||||
// 在同一事务提交:事务失败则解锁一起回滚,下一分钟重新处理,不会出现「已解锁但没有复机任务」的中间态。
|
||||
// 任一复机条件不满足时只解锁、不调运营商,并把可观察原因写入锁行。
|
||||
//
|
||||
// 锁行引用的运营商已不存在或重置日非法时无法计算周期归属:这类行按「新周期对仍持锁卡解除通道锁」
|
||||
// 的语义解锁并标记异常转人工,绝不写复机事件、不调运营商,也绝不静默跳过(否则持锁卡会永久禁止复机)。
|
||||
func (s *Service) ProcessDueLocks(ctx context.Context, now time.Time) (CycleStats, error) {
|
||||
stats := CycleStats{}
|
||||
if s == nil || s.db == nil || s.repository == nil {
|
||||
return stats, errors.New(errors.CodeServiceUnavailable, "通道流量阈值周期处理能力未配置")
|
||||
}
|
||||
locks, err := s.ScanExpiredLocks(ctx, now, cycleBatchSize)
|
||||
due, err := s.ScanDueLocks(ctx, now, cycleBatchSize)
|
||||
if err != nil {
|
||||
return stats, err
|
||||
}
|
||||
stats.Scanned = len(locks)
|
||||
if len(locks) == 0 {
|
||||
stats.Scanned = len(due)
|
||||
if len(due) == 0 {
|
||||
return stats, nil
|
||||
}
|
||||
if s.commander == nil {
|
||||
return stats, errors.New(errors.CodeServiceUnavailable, "通道流量阈值停复机执行端口未配置")
|
||||
}
|
||||
var firstErr error
|
||||
for index := range locks {
|
||||
lock := locks[index]
|
||||
if err := s.processDueLock(ctx, &lock, &stats); err != nil {
|
||||
for index := range due {
|
||||
item := due[index]
|
||||
if err := s.processDueLock(ctx, &item, &stats); err != nil {
|
||||
stats.Skipped++
|
||||
s.logger.Warn("通道阈值跨期处理单条失败",
|
||||
zap.Uint("lock_id", lock.ID), zap.Uint("card_id", lock.CardID), zap.Error(err))
|
||||
zap.Uint("lock_id", item.Lock.ID), zap.Uint("card_id", item.Lock.CardID), zap.Error(err))
|
||||
if firstErr == nil {
|
||||
firstErr = err
|
||||
}
|
||||
@@ -58,8 +62,12 @@ func (s *Service) ProcessDueLocks(ctx context.Context, now time.Time) (CycleStat
|
||||
return stats, firstErr
|
||||
}
|
||||
|
||||
// processDueLock 处理单条跨期锁:认领解锁并在条件满足时同事务写复机事件。
|
||||
func (s *Service) processDueLock(ctx context.Context, lock *model.CarrierTrafficThresholdLock, stats *CycleStats) error {
|
||||
// processDueLock 处理单条待处理锁行:周期归属不可判定时解锁并转人工,已跨期时认领解锁并条件复机。
|
||||
func (s *Service) processDueLock(ctx context.Context, item *DueLock, stats *CycleStats) error {
|
||||
if item.Kind == DueLockUnresolvable {
|
||||
return s.processUnresolvableLock(ctx, item, stats)
|
||||
}
|
||||
lock := &item.Lock
|
||||
card, err := s.loadCard(ctx, lock.CardID)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -111,6 +119,42 @@ func (s *Service) processDueLock(ctx context.Context, lock *model.CarrierTraffic
|
||||
return nil
|
||||
}
|
||||
|
||||
// processUnresolvableLock 处理周期归属不可判定的锁行:同事务认领解锁并标记异常转人工。
|
||||
//
|
||||
// 只解锁不写复机事件、不调运营商:配置缺失时无法判断是否已跨期,解除通道锁交由既有复机链路
|
||||
// 与人工决定;anomaly_flag 与失败原因使运维可见并转人工核对。解锁与异常标记都是条件更新,
|
||||
// 重复执行不会产生第二次副作用。
|
||||
func (s *Service) processUnresolvableLock(ctx context.Context, item *DueLock, stats *CycleStats) error {
|
||||
lock := &item.Lock
|
||||
s.logger.Warn("通道阈值锁行周期归属不可判定,解锁并转人工核对",
|
||||
zap.Uint("lock_id", lock.ID), zap.Uint("card_id", lock.CardID), zap.Uint("carrier_id", lock.CarrierID),
|
||||
zap.String("reason", item.Reason))
|
||||
unlocked := false
|
||||
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
claimed, unlockErr := s.unlockInTx(ctx, tx, lock.ID, item.Reason)
|
||||
if unlockErr != nil {
|
||||
return unlockErr
|
||||
}
|
||||
if !claimed {
|
||||
return nil
|
||||
}
|
||||
unlocked = true
|
||||
_, anomalyErr := s.markAnomaly(ctx, lock.ID, item.Reason)
|
||||
return anomalyErr
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !unlocked {
|
||||
stats.Skipped++
|
||||
s.logger.Info("通道阈值不可判定锁已被并发处理,跳过", zap.Uint("lock_id", lock.ID))
|
||||
return nil
|
||||
}
|
||||
stats.Unlocked++
|
||||
stats.Anomaly++
|
||||
return nil
|
||||
}
|
||||
|
||||
// recoveryBatchSize 是单次恢复扫描的锁行上限。
|
||||
const recoveryBatchSize = 200
|
||||
|
||||
@@ -158,8 +202,11 @@ func (s *Service) RecoverSubmitted(ctx context.Context, now time.Time) (Recovery
|
||||
}
|
||||
|
||||
// recoverSubmittedLock 处理单条待确认锁:只查询状态回填,不发起任何停复机调用。
|
||||
//
|
||||
// 入口只处理未决子任务(submitted/unknown/failed):调用失败或结果未知同样可能已在运营商侧生效,
|
||||
// 必须继续收敛;confirmed 是终态,pending 表示从未对外调用,都不在本扫描范围。
|
||||
func (s *Service) recoverSubmittedLock(ctx context.Context, lock *model.CarrierTrafficThresholdLock, now time.Time, stats *RecoveryStats) error {
|
||||
if lock.StopStatus != domain.TaskStatusSubmitted && lock.ResumeStatus != domain.TaskStatusSubmitted {
|
||||
if !domain.IsUnresolvedTaskStatus(lock.StopStatus) && !domain.IsUnresolvedTaskStatus(lock.ResumeStatus) {
|
||||
return nil
|
||||
}
|
||||
card, err := s.loadCard(ctx, lock.CardID)
|
||||
@@ -178,7 +225,7 @@ func (s *Service) recoverSubmittedLock(ctx context.Context, lock *model.CarrierT
|
||||
}
|
||||
confirmed := 0
|
||||
unconfirmed := 0
|
||||
if lock.StopStatus == domain.TaskStatusSubmitted {
|
||||
if domain.IsUnresolvedTaskStatus(lock.StopStatus) {
|
||||
switch {
|
||||
case known && status == constants.NetworkStatusOffline:
|
||||
confirmed++
|
||||
@@ -189,7 +236,7 @@ func (s *Service) recoverSubmittedLock(ctx context.Context, lock *model.CarrierT
|
||||
unconfirmed++
|
||||
}
|
||||
}
|
||||
if lock.ResumeStatus == domain.TaskStatusSubmitted {
|
||||
if domain.IsUnresolvedTaskStatus(lock.ResumeStatus) {
|
||||
switch {
|
||||
case known && status == constants.NetworkStatusOnline:
|
||||
confirmed++
|
||||
|
||||
@@ -77,7 +77,7 @@ func (s *Service) ExecuteStop(ctx context.Context, lockID uint) error {
|
||||
zap.Uint("lock_id", lockID), zap.Uint("card_id", lock.CardID),
|
||||
zap.String("result", outcome.Result), zap.Error(callErr))
|
||||
}
|
||||
_, err = s.markTaskOutcome(ctx, lockID, stopTask, domain.TaskStatusSubmitted,
|
||||
_, err = s.markTaskOutcome(ctx, lockID, stopTask, []string{domain.TaskStatusSubmitted},
|
||||
taskResultOf(outcome), outcome.IntegrationID, stopFailureReason(outcome))
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -142,7 +142,7 @@ func (s *Service) ExecuteResume(ctx context.Context, lockID uint) error {
|
||||
zap.Uint("lock_id", lockID), zap.Uint("card_id", lock.CardID),
|
||||
zap.String("result", outcome.Result), zap.Error(callErr))
|
||||
}
|
||||
_, err = s.markTaskOutcome(ctx, lockID, resumeTask, domain.TaskStatusSubmitted,
|
||||
_, err = s.markTaskOutcome(ctx, lockID, resumeTask, []string{domain.TaskStatusSubmitted},
|
||||
taskResultOf(outcome), outcome.IntegrationID, resumeFailureReason(outcome))
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -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 把锁标记为需人工核对并退出自动扫描。
|
||||
|
||||
@@ -29,3 +29,22 @@ const (
|
||||
// AnomalyFlagManual 表示查询窗口超期或失败无法自动确认,已转人工核对并退出自动扫描。
|
||||
AnomalyFlagManual = 1
|
||||
)
|
||||
|
||||
// UnresolvedTaskStatuses 返回仍需由恢复扫描查询运营商状态收敛的子任务状态集合。
|
||||
//
|
||||
// 语义:submitted 表示已提交待确认,unknown 表示结果未知,failed 表示调用明确失败但运营商侧
|
||||
// 状态仍可能已生效(例如请求已到达而响应超时/异常)——三者都必须继续用只读状态查询确认,
|
||||
// 因此恢复扫描的查询谓词与超期判定共用本集合,避免「失败或结果未知」的锁行退出收敛链路。
|
||||
// pending 表示尚未对运营商发起过调用,confirmed 是终态,都不属于本集合。
|
||||
func UnresolvedTaskStatuses() []string {
|
||||
return []string{TaskStatusSubmitted, TaskStatusUnknown, TaskStatusFailed}
|
||||
}
|
||||
|
||||
// IsUnresolvedTaskStatus 判断子任务状态是否仍需恢复扫描收敛。
|
||||
func IsUnresolvedTaskStatus(status string) bool {
|
||||
switch status {
|
||||
case TaskStatusSubmitted, TaskStatusUnknown, TaskStatusFailed:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -638,6 +638,10 @@ func (s *StopResumeService) stopCardWithRetry(ctx context.Context, card *model.I
|
||||
if lastErr == nil {
|
||||
lastErr = attemptObserver.recordingErr
|
||||
}
|
||||
// Integration Log 无法终结时仍必须留下本次停机的审计事实(与重试耗尽路径同一写入)。
|
||||
s.recordCardCommandAudit(ctx, card, actionCode, summary+"未完成", attemptObserver.auditResult(lastErr), lastIntegrationID,
|
||||
map[string]any{"network_status": card.NetworkStatus, "stop_reason": card.StopReason},
|
||||
map[string]any{"requested_network_status": constants.NetworkStatusOffline, "stop_reason": stopReason}, lastErr)
|
||||
return carrierthresholddomain.CommandOutcome{
|
||||
IntegrationID: lastIntegrationID, Result: attemptObserver.auditResult(lastErr),
|
||||
}, lastErr
|
||||
@@ -739,6 +743,10 @@ func (s *StopResumeService) resumeCardWithRetry(ctx context.Context, card *model
|
||||
if lastErr == nil {
|
||||
lastErr = attemptObserver.recordingErr
|
||||
}
|
||||
// Integration Log 无法终结时仍必须留下本次复机的审计事实(与重试耗尽路径同一写入)。
|
||||
s.recordCardCommandAudit(ctx, card, actionCode, summary+"未完成", attemptObserver.auditResult(lastErr), lastIntegrationID,
|
||||
map[string]any{"network_status": card.NetworkStatus, "stop_reason": card.StopReason, "gateway_extend": card.GatewayExtend},
|
||||
map[string]any{"requested_network_status": constants.NetworkStatusOnline}, lastErr)
|
||||
return nil, lastIntegrationID, lastErr
|
||||
}
|
||||
if callErr == nil {
|
||||
|
||||
Reference in New Issue
Block a user