package carrierthreshold import ( "context" "strings" "time" "gorm.io/gorm" domain "github.com/break/junhong_cmp_fiber/internal/domain/carrierthreshold" "github.com/break/junhong_cmp_fiber/internal/model" "github.com/break/junhong_cmp_fiber/pkg/errors" ) // activeLockScanLimit 是「按卡取当前周期锁」的候选扫描上限。 // 一张卡正常情况下只有当前周期的锁与历史周期锁,取最近若干条足以覆盖换运营商后的场景。 const activeLockScanLimit = 8 // createLockInTx 在调用方事务内插入周期锁,返回 created=false 表示该周期已处理(唯一键冲突)。 func (s *Service) createLockInTx(ctx context.Context, tx *gorm.DB, lock *model.CarrierTrafficThresholdLock) (bool, error) { // 唯一冲突在 PostgreSQL 中会中止整个事务,因此插入必须隔离在保存点内: // GORM 对已开启事务的嵌套 Transaction 使用 SAVEPOINT,冲突只回滚本次插入。 insertErr := tx.WithContext(ctx).Transaction(func(inner *gorm.DB) error { return inner.Create(lock).Error }) if insertErr == nil { return true, nil } if isPeriodLockConflict(insertErr) { return false, nil } return false, errors.Wrap(errors.CodeDatabaseError, insertErr, "写入通道流量阈值周期锁失败") } // FindLock 按唯一键读取某卡在某运营商某计费周期内的锁;不存在返回 nil。 func (s *Service) FindLock(ctx context.Context, carrierID, cardID uint, periodStart time.Time) (*model.CarrierTrafficThresholdLock, error) { if s == nil || s.db == nil || carrierID == 0 || cardID == 0 { return nil, nil } var lock model.CarrierTrafficThresholdLock err := s.db.WithContext(ctx). Where("carrier_id = ? AND card_id = ? AND period_start = ?", carrierID, cardID, periodStart). First(&lock).Error if err == gorm.ErrRecordNotFound { return nil, nil } if err != nil { return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询通道流量阈值周期锁失败") } return &lock, nil } // ActiveLock 返回该卡在 now 所属计费周期内仍然生效的通道阈值锁;无锁返回 nil。 // // 周期归属按锁行自身运营商的 data_reset_day 判断,因此换运营商后旧周期锁不会误判为当前周期。 // 锁行引用的运营商已不存在时无法计算周期归属,此时按「仍可能属于当前周期」处理并返回该锁: // 持有通道阈值锁的卡在周期内必须拒绝一切复机,不能因为配置缺失放开复机,只能由周期处理/人工核销。 func (s *Service) ActiveLock(ctx context.Context, cardID uint, now time.Time) (*model.CarrierTrafficThresholdLock, error) { if s == nil || s.db == nil || cardID == 0 { return nil, nil } var locks []model.CarrierTrafficThresholdLock if err := s.db.WithContext(ctx). Where("card_id = ? AND status = ?", cardID, domain.LockStatusLocked). Order("period_start DESC").Limit(activeLockScanLimit).Find(&locks).Error; err != nil { return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询卡当前周期通道流量阈值锁失败") } if len(locks) == 0 { return nil, nil } resetDays, err := s.carrierResetDays(ctx, lockCarrierIDs(locks)) if err != nil { return nil, err } for index := range locks { resetDay, ok := resetDays[locks[index].CarrierID] if !ok { return &locks[index], nil } periodStart, err := domain.PeriodStart(now, resetDay) if err != nil { // 重置日非法时同样无法判定周期归属,按仍生效处理,避免放开复机。 return &locks[index], nil } if locks[index].PeriodStart.Equal(periodStart) { return &locks[index], nil } } return nil, nil } // ClaimStopSubmission 以 stop_submitted_at IS NULL 条件更新认领停机提交权。 // // 返回 true 表示调用方获得提交权、可以调用停机接口;false 表示该锁已提交过(事件重复投递或 // 人工重放),调用方只能查询结果。谓词同时要求锁仍处于 locked:已跨期解锁的锁不再停机。 func (s *Service) ClaimStopSubmission(ctx context.Context, lockID uint, now time.Time) (bool, error) { if s == nil || s.db == nil || lockID == 0 { return false, nil } claimed := s.db.WithContext(ctx).Model(&model.CarrierTrafficThresholdLock{}). Where("id = ? AND stop_submitted_at IS NULL AND status = ?", lockID, domain.LockStatusLocked). Updates(map[string]any{"stop_submitted_at": now, "stop_status": domain.TaskStatusSubmitted}) if claimed.Error != nil { return false, errors.Wrap(errors.CodeDatabaseError, claimed.Error, "认领通道阈值停机提交权失败") } return claimed.RowsAffected == 1, nil } // ClaimResumeSubmission 以 resume_submitted_at IS NULL 条件更新认领复机提交权。 // // 复机发生在周期处理解锁之后,此时锁已是 unlocked,因此谓词只要求未提交过。 func (s *Service) ClaimResumeSubmission(ctx context.Context, lockID uint, now time.Time) (bool, error) { if s == nil || s.db == nil || lockID == 0 { return false, nil } claimed := s.db.WithContext(ctx).Model(&model.CarrierTrafficThresholdLock{}). Where("id = ? AND resume_submitted_at IS NULL", lockID). Updates(map[string]any{"resume_submitted_at": now, "resume_status": domain.TaskStatusSubmitted}) if claimed.Error != nil { return false, errors.Wrap(errors.CodeDatabaseError, claimed.Error, "认领通道阈值复机提交权失败") } return claimed.RowsAffected == 1, nil } // 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 的归属被正确识别。锁行引用的运营商已不存在或重置日非法时 // 无法计算周期归属,这类行以 DueLockUnresolvable 返回:周期处理必须给出出路(按跨期语义解锁并转人工), // 绝不能让持锁卡因配置缺失而永久禁止复机。 func (s *Service) ScanDueLocks(ctx context.Context, now time.Time, limit int) ([]DueLock, error) { if s == nil || s.db == nil { return nil, nil } var locks []model.CarrierTrafficThresholdLock if err := s.db.WithContext(ctx). Where("status = ?", domain.LockStatusLocked). Order("id ASC").Limit(limit).Find(&locks).Error; err != nil { return nil, errors.Wrap(errors.CodeDatabaseError, err, "扫描跨期通道流量阈值锁失败") } if len(locks) == 0 { return nil, nil } resetDays, err := s.carrierResetDays(ctx, lockCarrierIDs(locks)) if err != nil { return nil, err } due := make([]DueLock, 0, len(locks)) for index := range locks { lock := locks[index] resetDay, ok := resetDays[lock.CarrierID] if !ok { due = append(due, DueLock{Lock: lock, Kind: DueLockUnresolvable, Reason: unresolvableCarrierReason}) continue } periodStart, periodErr := domain.PeriodStart(now, resetDay) if periodErr != nil { due = append(due, DueLock{Lock: lock, Kind: DueLockUnresolvable, Reason: unresolvableCarrierReason}) continue } if lock.PeriodStart.Before(periodStart) { due = append(due, DueLock{Lock: lock, Kind: DueLockExpired}) } } return due, nil } // 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 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, "扫描待确认通道流量阈值任务失败") } return locks, nil } // lockTask 标识锁行上的停复机子任务。 type lockTask struct { // statusColumn 是子任务状态列名。 statusColumn string // integrationColumn 是该子任务对应的 Integration Log 标识列名。 integrationColumn string } var ( // stopTask 是停机子任务。 stopTask = lockTask{statusColumn: "stop_status", integrationColumn: "stop_integration_id"} // resumeTask 是复机子任务。 resumeTask = lockTask{statusColumn: "resume_status", integrationColumn: "resume_integration_id"} ) // failureReasonMaxRunes 与 tb_carrier_traffic_threshold_lock.failure_reason 的长度上限一致。 const failureReasonMaxRunes = 500 // safeFailureReason 裁剪可安全展示的失败原因,超长截断,绝不写入内部细节。 func safeFailureReason(reason string) string { trimmed := strings.TrimSpace(reason) runes := []rune(trimmed) if len(runes) <= failureReasonMaxRunes { return trimmed } return string(runes[:failureReasonMaxRunes]) } // loadLock 按 ID 读取锁行;不存在返回 nil(软删行不可见)。 func (s *Service) loadLock(ctx context.Context, lockID uint) (*model.CarrierTrafficThresholdLock, error) { if s == nil || s.db == nil || lockID == 0 { return nil, nil } var lock model.CarrierTrafficThresholdLock err := s.db.WithContext(ctx).Where("id = ?", lockID).First(&lock).Error if err == gorm.ErrRecordNotFound { return nil, nil } if err != nil { return nil, errors.Wrap(errors.CodeDatabaseError, err, "读取通道流量阈值周期锁失败") } return &lock, nil } // loadCard 按 ID 读取卡事实;不存在返回 nil。 func (s *Service) loadCard(ctx context.Context, cardID uint) (*model.IotCard, error) { if s == nil || s.db == nil || cardID == 0 { return nil, nil } var card model.IotCard err := s.db.WithContext(ctx).Where("id = ?", cardID).First(&card).Error if err == gorm.ErrRecordNotFound { return nil, nil } if err != nil { return nil, errors.Wrap(errors.CodeDatabaseError, err, "读取通道阈值卡事实失败") } return &card, nil } // 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} if integrationID != "" { updates[task.integrationColumn] = integrationID } if failureReason != "" { updates["failure_reason"] = safeFailureReason(failureReason) } result_ := s.db.WithContext(ctx).Model(&model.CarrierTrafficThresholdLock{}). Where("id = ? AND "+task.statusColumn+" IN ?", lockID, expected). Updates(updates) if result_.Error != nil { return false, errors.Wrap(errors.CodeDatabaseError, result_.Error, "回填通道阈值子任务状态失败") } return result_.RowsAffected == 1, nil } // 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.UnresolvedTaskStatuses(), domain.TaskStatusConfirmed, integrationID, "") } // markAnomaly 把锁标记为需人工核对并退出自动扫描(使用服务自身连接池,调用方不得已持有该行锁)。 // 只应在窗口超期且结果无法确认时调用;已标记的锁不再重复查询。 func (s *Service) markAnomaly(ctx context.Context, lockID uint, reason string) (bool, error) { if s == nil || s.db == nil { return false, nil } return s.markAnomalyInTx(ctx, s.db, lockID, reason) } // markAnomalyInTx 在调用方事务内把锁标记为需人工核对(anomaly_flag=0 → 1 条件更新)。 // // 调用方已在同一事务内写过该行时 MUST 使用本方法:改用服务自身连接池会让另一条连接 // 等待本事务持有的行锁(自锁),表现为处理挂死到语句超时。返回 false 表示已被并发标记, // 调用方按幂等处理。 func (s *Service) markAnomalyInTx(ctx context.Context, tx *gorm.DB, lockID uint, reason string) (bool, error) { if tx == nil || lockID == 0 { return false, nil } result := tx.WithContext(ctx).Model(&model.CarrierTrafficThresholdLock{}). Where("id = ? AND anomaly_flag = ?", lockID, domain.AnomalyFlagNone). Updates(map[string]any{ "anomaly_flag": domain.AnomalyFlagManual, "failure_reason": safeFailureReason(reason), }) if result.Error != nil { return false, errors.Wrap(errors.CodeDatabaseError, result.Error, "标记通道阈值锁异常失败") } return result.RowsAffected == 1, nil } // unlockInTx 在调用方事务内按 status=locked 条件更新认领解锁,返回 false 表示已被并发推进。 // 未满足复机条件时同事务写入可观察原因;锁行与历史任务结果一律保留,绝不删除。 func (s *Service) unlockInTx(ctx context.Context, tx *gorm.DB, lockID uint, failureReason string) (bool, error) { if tx == nil { return false, errors.New(errors.CodeInvalidStatus, "通道阈值解锁必须传入事务句柄") } updates := map[string]any{"status": domain.LockStatusUnlocked} if failureReason != "" { updates["failure_reason"] = safeFailureReason(failureReason) } result := tx.WithContext(ctx).Model(&model.CarrierTrafficThresholdLock{}). Where("id = ? AND status = ?", lockID, domain.LockStatusLocked). Updates(updates) if result.Error != nil { return false, errors.Wrap(errors.CodeDatabaseError, result.Error, "解除通道流量阈值周期锁失败") } return result.RowsAffected == 1, nil } // lockCarrierIDs 收集锁行引用的运营商 ID 去重集合,用于显式批量查询(ENG-MODEL-001)。 func lockCarrierIDs(locks []model.CarrierTrafficThresholdLock) []uint { seen := make(map[uint]struct{}, len(locks)) ids := make([]uint, 0, len(locks)) for index := range locks { if _, ok := seen[locks[index].CarrierID]; ok { continue } seen[locks[index].CarrierID] = struct{}{} ids = append(ids, locks[index].CarrierID) } return ids }