All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 12m59s
318 lines
13 KiB
Go
318 lines
13 KiB
Go
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
|
||
}
|
||
|
||
// ScanExpiredLocks 扫描已跨期但仍持锁的锁行,供周期处理解锁与条件复机。
|
||
//
|
||
// 过期判断按锁行自身运营商的 data_reset_day 计算其当前周期起点,与锁行 period_start 不一致即已跨期,
|
||
// 因此换运营商后的旧锁仍按其旧 carrier 的归属被正确识别。锁行引用的运营商已不存在时无法计算周期,
|
||
// 本次不返回该锁(不猜测),交由周期处理标记异常转人工。
|
||
func (s *Service) ScanExpiredLocks(ctx context.Context, now time.Time, limit int) ([]model.CarrierTrafficThresholdLock, 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
|
||
}
|
||
expired := make([]model.CarrierTrafficThresholdLock, 0, len(locks))
|
||
for index := range locks {
|
||
resetDay, ok := resetDays[locks[index].CarrierID]
|
||
if !ok {
|
||
continue
|
||
}
|
||
periodStart, err := domain.PeriodStart(now, resetDay)
|
||
if err != nil {
|
||
continue
|
||
}
|
||
if locks[index].PeriodStart.Before(periodStart) {
|
||
expired = append(expired, locks[index])
|
||
}
|
||
}
|
||
return expired, nil
|
||
}
|
||
|
||
// ScanSubmittedLocks 扫描存在已提交子任务的锁行,供恢复扫描查询运营商状态回填。
|
||
//
|
||
// 已标记异常(转人工)的锁必须退出扫描,否则每次扫描都会重复查询同一笔无法收敛的结果。
|
||
func (s *Service) ScanSubmittedLocks(ctx context.Context, limit int) ([]model.CarrierTrafficThresholdLock, error) {
|
||
if s == nil || s.db == nil {
|
||
return nil, nil
|
||
}
|
||
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).
|
||
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, result, integrationID, failureReason string) (bool, error) {
|
||
if s == nil || s.db == nil || lockID == 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+" = ?", lockID, expected).
|
||
Updates(updates)
|
||
if result_.Error != nil {
|
||
return false, errors.Wrap(errors.CodeDatabaseError, result_.Error, "回填通道阈值子任务状态失败")
|
||
}
|
||
return result_.RowsAffected == 1, nil
|
||
}
|
||
|
||
// markTaskConfirmed 由恢复扫描在运营商状态确认成功后把子任务回填为已确认。
|
||
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, "")
|
||
}
|
||
|
||
// markAnomaly 把锁标记为需人工核对并退出自动扫描。
|
||
// 只应在窗口超期且结果无法确认时调用;已标记的锁不再重复查询。
|
||
func (s *Service) markAnomaly(ctx context.Context, lockID uint, reason string) (bool, error) {
|
||
if s == nil || s.db == nil || lockID == 0 {
|
||
return false, nil
|
||
}
|
||
result := s.db.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
|
||
}
|