feat(通道流量阈值): AUG26-011 运营商通道流量阈值达量停机与周期复机
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 12m59s

This commit is contained in:
2026-09-16 15:54:49 +08:00
parent 41722760b1
commit 59b3df868a
36 changed files with 2431 additions and 93 deletions

View File

@@ -0,0 +1,258 @@
package carrierthreshold
import (
"context"
"time"
"go.uber.org/zap"
"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/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// cycleBatchSize 是单次周期处理扫描的锁行上限。
const cycleBatchSize = 200
// CycleStats 是一次周期处理的可观察结果。
//
// Scanned 为扫到的跨期持锁数Unlocked 为本次认领解锁成功的锁数Resumed 为同时写出复机事件的锁数;
// Skipped 为本地事实缺失、被并发推进或判定失败而未改动的锁数。
type CycleStats struct{ Scanned, Unlocked, Resumed, Skipped int }
// 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)
if err != nil {
return stats, err
}
stats.Scanned = len(locks)
if len(locks) == 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 {
stats.Skipped++
s.logger.Warn("通道阈值跨期处理单条失败",
zap.Uint("lock_id", lock.ID), zap.Uint("card_id", lock.CardID), zap.Error(err))
if firstErr == nil {
firstErr = err
}
}
}
return stats, firstErr
}
// processDueLock 处理单条跨期锁:认领解锁并在条件满足时同事务写复机事件。
func (s *Service) processDueLock(ctx context.Context, lock *model.CarrierTrafficThresholdLock, stats *CycleStats) error {
card, err := s.loadCard(ctx, lock.CardID)
if err != nil {
return err
}
if card == nil {
stats.Skipped++
s.logger.Warn("通道阈值跨期锁对应卡不存在,本次不处理",
zap.Uint("lock_id", lock.ID), zap.Uint("card_id", lock.CardID))
return nil
}
// 复机条件复用既有单一事实源(有效主套餐 + 流量未耗尽 + 实名满足 + 非风险扩展 + 无其他停因)。
ready, reason, err := s.commander.ResumeReady(ctx, lock.CardID)
if err != nil {
// 判定失败时不解锁:保留锁与拒绝复机的语义,下一分钟重试。
return err
}
unlocked := false
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
claimed, unlockErr := s.unlockInTx(ctx, tx, lock.ID, reason)
if unlockErr != nil {
return unlockErr
}
if !claimed {
return nil
}
unlocked = true
if !ready {
return nil
}
return AppendResumeRequested(ctx, tx, s.repository, lock)
})
if err != nil {
return err
}
if !unlocked {
stats.Skipped++
s.logger.Info("通道阈值跨期锁已被并发处理,跳过", zap.Uint("lock_id", lock.ID))
return nil
}
stats.Unlocked++
if !ready {
s.logger.Info("通道阈值新周期仅解锁,不调用运营商复机",
zap.Uint("lock_id", lock.ID), zap.Uint("card_id", lock.CardID), zap.String("reason", reason))
return nil
}
stats.Resumed++
s.logger.Info("通道阈值新周期条件满足,已写复机事件",
zap.Uint("lock_id", lock.ID), zap.Uint("card_id", lock.CardID))
return nil
}
// recoveryBatchSize 是单次恢复扫描的锁行上限。
const recoveryBatchSize = 200
// RecoveryStats 是一次恢复扫描的可观察结果。
//
// Scanned 为扫到的待确认锁数Confirmed 为本次回填为运营商已确认的锁数Pending 为结果仍未知、
// 等待下次扫描的锁数Anomaly 为超过查询窗口仍不可确认、本次标记转人工的锁数;
// Skipped 为本地事实缺失或已被并发推进而未改动状态的锁数。
type RecoveryStats struct{ Scanned, Confirmed, Pending, Anomaly, Skipped int }
// RecoverSubmitted 扫描存在已提交子任务的锁:只查询运营商状态回填,绝不重复发起停复机。
//
// 每个子任务独立判断:查询确认到达目标状态即回填 confirmed 并补写卡状态(覆盖运营商调用成功但
// 本地回写失败的场景);仍不可确认则等到下一次扫描;自提交起超过查询窗口仍不可确认时标记异常
// 并退出自动扫描转人工,锁行与历史结果一律保留,绝不自动删除。
func (s *Service) RecoverSubmitted(ctx context.Context, now time.Time) (RecoveryStats, error) {
stats := RecoveryStats{}
if s == nil || s.db == nil {
return stats, errors.New(errors.CodeServiceUnavailable, "通道流量阈值恢复扫描能力未配置")
}
locks, err := s.ScanSubmittedLocks(ctx, recoveryBatchSize)
if err != nil {
return stats, err
}
stats.Scanned = len(locks)
if len(locks) == 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.recoverSubmittedLock(ctx, &lock, now, &stats); err != nil {
stats.Skipped++
s.logger.Warn("通道阈值恢复扫描单条失败",
zap.Uint("lock_id", lock.ID), zap.Uint("card_id", lock.CardID), zap.Error(err))
if firstErr == nil {
firstErr = err
}
}
}
return stats, firstErr
}
// recoverSubmittedLock 处理单条待确认锁:只查询状态回填,不发起任何停复机调用。
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 {
return nil
}
card, err := s.loadCard(ctx, lock.CardID)
if err != nil {
return err
}
status, known := constants.NetworkStatusOffline, false
if card != nil {
// 查询失败按「仍未确认」处理,绝不误判为失败终态。
status, known, _, err = s.commander.CardNetworkStatus(ctx, lock.CardID)
if err != nil {
s.logger.Warn("查询运营商卡状态失败,按仍未确认处理",
zap.Uint("lock_id", lock.ID), zap.Uint("card_id", lock.CardID), zap.Error(err))
known = false
}
}
confirmed := 0
unconfirmed := 0
if lock.StopStatus == domain.TaskStatusSubmitted {
switch {
case known && status == constants.NetworkStatusOffline:
confirmed++
if err := s.confirmStop(ctx, lock, card); err != nil {
return err
}
default:
unconfirmed++
}
}
if lock.ResumeStatus == domain.TaskStatusSubmitted {
switch {
case known && status == constants.NetworkStatusOnline:
confirmed++
if err := s.confirmResume(ctx, lock, card); err != nil {
return err
}
default:
unconfirmed++
}
}
if confirmed == 0 {
stats.Pending++
} else {
stats.Confirmed++
}
if unconfirmed == 0 {
return nil
}
// 仍有子任务不可确认:窗口内继续等待,超期标记异常并退出自动扫描。
if !domain.SubmissionExpired(lock.StopSubmittedAt, now) && !domain.SubmissionExpired(lock.ResumeSubmittedAt, now) {
return nil
}
marked, err := s.markAnomaly(ctx, lock.ID, "运营商停复机结果超过确认窗口仍不可查,请人工核对")
if err != nil {
return err
}
if marked {
stats.Anomaly++
s.logger.Warn("通道阈值停复机结果超期不可确认,已标记异常转人工",
zap.Uint("lock_id", lock.ID), zap.Uint("card_id", lock.CardID),
zap.Time("stop_submitted_at", valueOrZero(lock.StopSubmittedAt)),
zap.Time("resume_submitted_at", valueOrZero(lock.ResumeSubmittedAt)))
}
return nil
}
// confirmStop 停机已被运营商确认:先补写卡停机状态,再把子任务回填为已确认。
// 顺序不可颠倒:先写卡状态才能保证「已确认」的锁不会掩盖未回写的卡事实。
func (s *Service) confirmStop(ctx context.Context, lock *model.CarrierTrafficThresholdLock, card *model.IotCard) error {
if card != nil && card.NetworkStatus != constants.NetworkStatusOffline {
if err := s.commander.ConfirmCardState(ctx, lock.CardID, true); err != nil {
return err
}
}
_, err := s.markTaskConfirmed(ctx, lock.ID, stopTask, lock.StopIntegrationID)
return err
}
// confirmResume 复机已被运营商确认:先补写卡在线状态,再把子任务回填为已确认。
func (s *Service) confirmResume(ctx context.Context, lock *model.CarrierTrafficThresholdLock, card *model.IotCard) error {
if card != nil && card.NetworkStatus != constants.NetworkStatusOnline {
if err := s.commander.ConfirmCardState(ctx, lock.CardID, false); err != nil {
return err
}
}
_, err := s.markTaskConfirmed(ctx, lock.ID, resumeTask, lock.ResumeIntegrationID)
return err
}
// valueOrZero 在日志中安全展开可空的提交时刻。
func valueOrZero(value *time.Time) time.Time {
if value == nil {
return time.Time{}
}
return *value
}

View File

@@ -0,0 +1,107 @@
package carrierthreshold
import (
"context"
"strconv"
"go.uber.org/zap"
"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"
)
// EvaluateInTx 在流量观测事务内判定通道阈值,达量时写周期锁并同事务写停机事件。
//
// 判定前提由调用方保证只有「读数被接受」ReadingAccepted的观测才进入本方法
// 异常下降保护命中的观测不参与判定。重复达量观测由部分唯一索引的 23505 识别为
// 「该周期已处理」并幂等跳过:不重复建锁、不重复写停机事件,该冲突与流量基线 CAS 的
// CodeConflict 语义不同,绝不能混流。返回错误表示必须整体回滚(与既有流量事实同事务)。
func (s *Service) EvaluateInTx(ctx context.Context, tx *gorm.DB, evaluation domain.Evaluation) error {
if s == nil || s.db == nil || s.repository == nil {
return errors.New(errors.CodeInternalError, "通道流量阈值判定能力未完整配置")
}
if tx == nil {
return errors.New(errors.CodeInvalidStatus, "通道流量阈值判定必须传入事务句柄")
}
if evaluation.CardID == 0 || evaluation.CarrierID == 0 {
return nil
}
var carrier model.Carrier
// 只取判定所需列:周期起点必须来自该运营商的 data_reset_day漏取会让周期判定失去依据。
if err := tx.WithContext(ctx).Select("id", "data_reset_day", "traffic_threshold_enabled", "traffic_threshold_value", "traffic_threshold_unit").
Where("id = ?", evaluation.CarrierID).First(&carrier).Error; err != nil {
if err == gorm.ErrRecordNotFound {
// 卡引用的运营商已不存在:不判定、不建锁,保持与既有卡事实不一致的现状。
return nil
}
return errors.Wrap(errors.CodeDatabaseError, err, "查询运营商通道流量阈值配置失败")
}
threshold := thresholdOf(carrier)
if !threshold.Enabled {
return nil
}
if !threshold.Valid() {
// 配置半残(启用但无数值/单位,或单位未知)时绝不按 0 判定,跳过并留可观测日志。
s.logger.Warn("运营商通道流量阈值配置不完整,跳过达量判定",
zap.Uint("carrier_id", carrier.ID), zap.String("unit", threshold.Unit), zap.Float64("value", threshold.Value))
return nil
}
reached, err := threshold.Reached(evaluation.ReadingMB)
if err != nil {
return err
}
if !reached {
return nil
}
periodStart, err := domain.PeriodStart(evaluation.ObservedAt, carrier.DataResetDay)
if err != nil {
s.logger.Warn("运营商上游流量重置日非法,跳过达量判定",
zap.Uint("carrier_id", carrier.ID), zap.Int("data_reset_day", carrier.DataResetDay))
return nil
}
lock := &model.CarrierTrafficThresholdLock{
CarrierID: evaluation.CarrierID,
CardID: evaluation.CardID,
PeriodStart: periodStart,
Status: domain.LockStatusLocked,
StopStatus: domain.TaskStatusPending,
ResumeStatus: domain.TaskStatusPending,
}
// 唯一冲突(该周期已处理)在 createLockInTx 内以保存点隔离:不重复建锁、不重复写停机事件。
created, err := s.createLockInTx(ctx, tx, lock)
if err != nil {
return err
}
if !created {
s.logger.Info("该计费周期已存在通道阈值停机锁,跳过重复判定",
zap.Uint("carrier_id", evaluation.CarrierID), zap.Uint("card_id", evaluation.CardID))
return nil
}
if err := AppendStopRequested(ctx, tx, s.repository, lock); err != nil {
return err
}
s.logger.Info("卡流量达到运营商通道阈值,已写周期锁与停机事件",
zap.Uint("carrier_id", evaluation.CarrierID), zap.Uint("card_id", evaluation.CardID),
zap.Uint("lock_id", lock.ID), zap.Float64("reading_mb", evaluation.ReadingMB),
zap.Time("period_start", periodStart))
return nil
}
// thresholdOf 把运营商持久化列转换为领域阈值配置。
func thresholdOf(carrier model.Carrier) domain.Threshold {
threshold := domain.Threshold{
Enabled: carrier.TrafficThresholdEnabled == 1,
Unit: carrier.TrafficThresholdUnit,
}
if carrier.TrafficThresholdValue != nil {
threshold.Value = *carrier.TrafficThresholdValue
}
return threshold
}
// lockKeyValue 返回周期锁在 Outbox 事件中的稳定字符串标识。
func lockKeyValue(lockID uint) string {
return strconv.FormatUint(uint64(lockID), 10)
}

View File

@@ -0,0 +1,159 @@
package carrierthreshold
import (
"context"
stderrors "errors"
"github.com/bytedance/sonic"
"github.com/jackc/pgx/v5/pgconn"
"gorm.io/gorm"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/messaging/outbox"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/auditcontext"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/outboxid"
)
// EventCarrierThresholdStop 是卡流量达到通道阈值后的可靠停机事件。
const EventCarrierThresholdStop = "carrier.threshold.stop.requested"
// EventCarrierThresholdResume 是通道阈值跨期解锁且条件满足后的可靠复机事件。
const EventCarrierThresholdResume = "carrier.threshold.resume.requested"
// carrierThresholdPayloadVersion 是通道阈值事件的载荷版本。
const carrierThresholdPayloadVersion = 1
// periodLockConstraint 是周期锁部分唯一索引名,用于把 23505 精确识别为该周期已处理。
const periodLockConstraint = "uq_carrier_traffic_threshold_lock_key"
// StopPayload 是通道阈值停机事件的载荷,只携带锁与卡标识,消费者按锁 ID 认领提交权。
type StopPayload struct {
LockID uint `json:"lock_id"`
CardID uint `json:"card_id"`
CarrierID uint `json:"carrier_id"`
}
// AppendStopRequested 在达量判定事务内幂等写入通道阈值停机事件。
// 事件 ID 由锁 ID 派生同一周期锁重复投递不会创建第二个事件ENG-OUTBOX-001
func AppendStopRequested(ctx context.Context, tx *gorm.DB, repository *outbox.Repository, lock *model.CarrierTrafficThresholdLock) error {
if repository == nil {
return gorm.ErrInvalidDB
}
if lock == nil || lock.ID == 0 {
return gorm.ErrInvalidData
}
value := lockKeyValue(lock.ID)
_, err := repository.AppendIdempotent(ctx, tx, outbox.Envelope{
EventID: outboxid.Stable(EventCarrierThresholdStop+":", value),
EventType: EventCarrierThresholdStop,
PayloadVersion: carrierThresholdPayloadVersion,
AggregateType: "carrier_traffic_threshold_lock",
AggregateID: value,
ResourceType: "iot_card",
ResourceID: lockKeyValue(lock.CardID),
BusinessKey: EventCarrierThresholdStop + ":" + value,
Payload: StopPayload{
LockID: lock.ID,
CardID: lock.CardID,
CarrierID: lock.CarrierID,
},
})
return err
}
// ResumePayload 是通道阈值复机事件的载荷,只携带锁与卡标识,消费者按锁 ID 认领提交权。
type ResumePayload struct {
LockID uint `json:"lock_id"`
CardID uint `json:"card_id"`
CarrierID uint `json:"carrier_id"`
}
// AppendResumeRequested 在周期处理事务内幂等写入通道阈值复机事件。
// 事件 ID 由锁 ID 派生解锁认领与复机事件同事务写入重复投递不会创建第二个事件ENG-OUTBOX-001
func AppendResumeRequested(ctx context.Context, tx *gorm.DB, repository *outbox.Repository, lock *model.CarrierTrafficThresholdLock) error {
if repository == nil {
return gorm.ErrInvalidDB
}
if lock == nil || lock.ID == 0 {
return gorm.ErrInvalidData
}
value := lockKeyValue(lock.ID)
_, err := repository.AppendIdempotent(ctx, tx, outbox.Envelope{
EventID: outboxid.Stable(EventCarrierThresholdResume+":", value),
EventType: EventCarrierThresholdResume,
PayloadVersion: carrierThresholdPayloadVersion,
AggregateType: "carrier_traffic_threshold_lock",
AggregateID: value,
ResourceType: "iot_card",
ResourceID: lockKeyValue(lock.CardID),
BusinessKey: EventCarrierThresholdResume + ":" + value,
Payload: ResumePayload{
LockID: lock.ID,
CardID: lock.CardID,
CarrierID: lock.CarrierID,
},
})
return err
}
// isPeriodLockConflict 判断错误是否为周期锁唯一键冲突,即「该周期已处理」。
// 该冲突必须与流量基线 CAS 冲突CodeConflict区分前者幂等跳过后者由调用方重放重试。
func isPeriodLockConflict(err error) bool {
var pgErr *pgconn.PgError
if !stderrors.As(err, &pgErr) {
return false
}
return pgErr.Code == "23505" && pgErr.ConstraintName == periodLockConstraint
}
// Consumer 把通道阈值停复机事件转成一次停复机动作。
type Consumer struct {
service *Service
}
// NewConsumer 创建通道阈值停复机事件消费者。
func NewConsumer(service *Service) *Consumer {
return &Consumer{service: service}
}
// Consume 按事件类型幂等执行停机或复机;重复投递由锁行认领字段兜住。
func (c *Consumer) Consume(ctx context.Context, envelope outbox.DeliveryEnvelope) error {
if c == nil || c.service == nil {
return errors.New(errors.CodeServiceUnavailable, "通道流量阈值停复机执行能力未配置")
}
lockID, validationErr := decodeThresholdPayload(envelope)
if validationErr != nil {
return validationErr
}
ctx = auditcontext.With(ctx, auditcontext.Context{CorrelationID: envelope.CorrelationID, ParentEventID: envelope.EventID})
switch envelope.EventType {
case EventCarrierThresholdStop:
return c.service.ExecuteStop(ctx, lockID)
case EventCarrierThresholdResume:
return c.service.ExecuteResume(ctx, lockID)
default:
return outbox.Permanent(gorm.ErrInvalidData)
}
}
// decodeThresholdPayload 校验事件类型与载荷版本并取出锁 ID。
// 载荷不合法属永久失败:重复投递不会改变结果,必须直接终结而不是重试。
func decodeThresholdPayload(envelope outbox.DeliveryEnvelope) (uint, error) {
if envelope.PayloadVersion != carrierThresholdPayloadVersion {
return 0, outbox.Permanent(gorm.ErrInvalidData)
}
var payload struct {
LockID uint `json:"lock_id"`
}
if err := sonic.Unmarshal(envelope.Payload, &payload); err != nil {
return 0, outbox.Permanent(err)
}
if payload.LockID == 0 {
return 0, outbox.Permanent(gorm.ErrInvalidData)
}
return payload.LockID, nil
}
// 编译期断言:通道阈值停复机消费者满足公共 Outbox 的消费边界。
var _ outbox.EventConsumer = (*Consumer)(nil)

View File

@@ -0,0 +1,199 @@
package carrierthreshold
import (
"context"
"go.uber.org/zap"
domain "github.com/break/junhong_cmp_fiber/internal/domain/carrierthreshold"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// 无法从运营商确认结果时的可安全展示原因(不含内部细节与渠道报文)。
const (
failedStopReason = "运营商停机调用失败,等待状态查询确认"
unknownStopReason = "运营商停机结果未知,等待状态查询确认"
failedResumeReason = "运营商复机调用失败,等待状态查询确认"
unknownResumeReason = "运营商复机结果未知,等待状态查询确认"
)
// ExecuteStop 执行一次通道阈值达量停机,保证至多一次外部调用。
//
// 流程提交认领stop_submitted_at IS NULL 且仍持锁)→ 卡已停机则直接确认成功,不调运营商 →
// 否则复用既有停机重试、Integration Log 与统一审计执行停机。认领失败表示该锁已提交过
// (事件重复投递或人工重放),本次只结束,绝不重复调用;结果由恢复扫描查询收敛。
// 任何分支都不删除锁行、不解锁。
func (s *Service) ExecuteStop(ctx context.Context, lockID uint) error {
if err := s.requireExecution(); err != nil {
return err
}
lock, err := s.loadLock(ctx, lockID)
if err != nil {
return err
}
if lock == nil {
s.logger.Info("通道阈值停机事件对应锁不存在,幂等跳过", zap.Uint("lock_id", lockID))
return nil
}
claimed, err := s.ClaimStopSubmission(ctx, lockID, s.now())
if err != nil {
return err
}
if !claimed {
s.logger.Info("通道阈值停机已提交过,只等待恢复扫描确认",
zap.Uint("lock_id", lockID), zap.String("stop_status", lock.StopStatus))
return nil
}
card, err := s.loadCard(ctx, lock.CardID)
if err != nil {
return err
}
if card == nil {
// 卡事实不存在时无法调运营商,保留 submitted 由恢复扫描按窗口标记异常转人工。
s.logger.Warn("通道阈值停机锁对应卡不存在,等待恢复扫描处理",
zap.Uint("lock_id", lockID), zap.Uint("card_id", lock.CardID))
return nil
}
if card.NetworkStatus == constants.NetworkStatusOffline {
// 其他停因已先行停机:不重复调用运营商,直接确认本次停机目标已达成。
if _, err := s.markTaskConfirmed(ctx, lockID, stopTask, ""); err != nil {
return err
}
s.logger.Info("卡已停机,通道阈值停机直接确认成功",
zap.Uint("lock_id", lockID), zap.Uint("card_id", lock.CardID), zap.String("stop_reason", card.StopReason))
return nil
}
outcome, callErr := s.commander.StopCardForThreshold(ctx, lock.CardID)
if callErr != nil && outcome.Result == "" {
// 基础设施故障(读卡、写审计或写卡状态失败)导致结果无法判定:保留 submitted
// 交由恢复扫描查询确认,本次不判定终态、不重复调用。
s.logger.Error("通道阈值停机执行失败,等待恢复扫描确认",
zap.Uint("lock_id", lockID), zap.Uint("card_id", lock.CardID), zap.Error(callErr))
return nil
}
if callErr != nil {
s.logger.Warn("通道阈值停机运营商调用未成功,已按结果分类回填",
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,
taskResultOf(outcome), outcome.IntegrationID, stopFailureReason(outcome))
if err != nil {
return err
}
s.logger.Info("通道阈值停机任务已回填结果",
zap.Uint("lock_id", lockID), zap.Uint("card_id", lock.CardID),
zap.String("result", outcome.Result), zap.String("integration_id", outcome.IntegrationID))
return nil
}
// ExecuteResume 执行一次通道阈值新周期复机,保证至多一次外部调用。
//
// 流程提交认领resume_submitted_at IS NULL→ 卡已在线则直接确认成功,不调运营商 →
// 否则复用既有复机重试、Integration Log 与统一审计执行复机(成功时同一事务写回卡状态,
// 且只在停因为通道阈值时清除停因)。认领失败表示已提交过,绝不重复调用。
func (s *Service) ExecuteResume(ctx context.Context, lockID uint) error {
if err := s.requireExecution(); err != nil {
return err
}
lock, err := s.loadLock(ctx, lockID)
if err != nil {
return err
}
if lock == nil {
s.logger.Info("通道阈值复机事件对应锁不存在,幂等跳过", zap.Uint("lock_id", lockID))
return nil
}
claimed, err := s.ClaimResumeSubmission(ctx, lockID, s.now())
if err != nil {
return err
}
if !claimed {
s.logger.Info("通道阈值复机已提交过,只等待恢复扫描确认",
zap.Uint("lock_id", lockID), zap.String("resume_status", lock.ResumeStatus))
return nil
}
card, err := s.loadCard(ctx, lock.CardID)
if err != nil {
return err
}
if card == nil {
s.logger.Warn("通道阈值复机锁对应卡不存在,等待恢复扫描处理",
zap.Uint("lock_id", lockID), zap.Uint("card_id", lock.CardID))
return nil
}
if card.NetworkStatus == constants.NetworkStatusOnline {
if _, err := s.markTaskConfirmed(ctx, lockID, resumeTask, ""); err != nil {
return err
}
s.logger.Info("卡已在线,通道阈值复机直接确认成功",
zap.Uint("lock_id", lockID), zap.Uint("card_id", lock.CardID))
return nil
}
outcome, callErr := s.commander.ResumeCardForThreshold(ctx, lock.CardID)
if callErr != nil && outcome.Result == "" {
s.logger.Error("通道阈值复机执行失败,等待恢复扫描确认",
zap.Uint("lock_id", lockID), zap.Uint("card_id", lock.CardID), zap.Error(callErr))
return nil
}
if callErr != nil {
s.logger.Warn("通道阈值复机运营商调用未成功,已按结果分类回填",
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,
taskResultOf(outcome), outcome.IntegrationID, resumeFailureReason(outcome))
if err != nil {
return err
}
s.logger.Info("通道阈值复机任务已回填结果",
zap.Uint("lock_id", lockID), zap.Uint("card_id", lock.CardID),
zap.String("result", outcome.Result), zap.String("integration_id", outcome.IntegrationID))
return nil
}
// requireExecution 校验消费者与周期处理所需的端口已配置。
func (s *Service) requireExecution() error {
if s == nil || s.db == nil || s.repository == nil {
return errors.New(errors.CodeServiceUnavailable, "通道流量阈值执行能力未完整配置")
}
if s.commander == nil {
return errors.New(errors.CodeServiceUnavailable, "通道流量阈值停复机执行端口未配置")
}
return nil
}
// taskResultOf 把运营商调用结果映射为子任务终态。
func taskResultOf(outcome domain.CommandOutcome) string {
switch outcome.Result {
case constants.AuditResultSuccess:
return domain.TaskStatusConfirmed
case constants.AuditResultUnknown:
return domain.TaskStatusUnknown
default:
return domain.TaskStatusFailed
}
}
// stopFailureReason 生成停机子任务的可安全失败原因,成功时为空。
func stopFailureReason(outcome domain.CommandOutcome) string {
if outcome.Confirmed() {
return ""
}
if outcome.Unresolved() {
return unknownStopReason
}
return failedStopReason
}
// resumeFailureReason 生成复机子任务的可安全失败原因,成功时为空。
func resumeFailureReason(outcome domain.CommandOutcome) string {
if outcome.Confirmed() {
return ""
}
if outcome.Unresolved() {
return unknownResumeReason
}
return failedResumeReason
}

View File

@@ -0,0 +1,317 @@
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
}

View File

@@ -0,0 +1,107 @@
// Package carrierthreshold 编排运营商通道流量阈值的达量判定与周期锁事实。
//
// 本包拥有 tb_carrier_traffic_threshold_lock 的全部读写:达量判定在流量观测事务内写入周期锁
// 与可靠停机事件,消费者以提交认领字段取得至多一次的外部调用权,周期处理与恢复扫描只查询锁行。
// 本包不调用任何运营商接口,也不在数据库事务内发起外部 I/O停复机执行通过 CardCommander
// 端口复用既有停复机单一事实源。
package carrierthreshold
import (
"context"
"time"
"go.uber.org/zap"
"gorm.io/gorm"
domain "github.com/break/junhong_cmp_fiber/internal/domain/carrierthreshold"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/messaging/outbox"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// CardCommander 是通道阈值停复机对卡与运营商接口的能力边界。
//
// 实现必须复用既有停复机单一事实源重试、Integration Log、统一审计、卡状态观测序列
// 既有套餐/流量/实名/风险判定;本包绝不复制这些规则,也绝不直接调用运营商接口。
type CardCommander interface {
// StopCardForThreshold 执行通道阈值达量停机,成功时写回卡停机状态与停因。
StopCardForThreshold(ctx context.Context, cardID uint) (domain.CommandOutcome, error)
// ResumeCardForThreshold 执行新周期复机,成功时写回卡在线状态(只清除通道阈值停因)。
ResumeCardForThreshold(ctx context.Context, cardID uint) (domain.CommandOutcome, error)
// ResumeReady 判断解锁后的卡是否满足自动复机条件;不满足时返回可安全记录的原因。
ResumeReady(ctx context.Context, cardID uint) (ready bool, reason string, err error)
// CardNetworkStatus 只查询运营商卡状态并映射为本地网络状态known 为 false 表示状态不可判定。
CardNetworkStatus(ctx context.Context, cardID uint) (status int, known bool, integrationID string, err error)
// ConfirmCardState 按已确认的运营商结果补写卡状态,覆盖 Gateway 成功但 DB 更新失败的场景。
ConfirmCardState(ctx context.Context, cardID uint, offline bool) error
}
// Service 执行通道阈值达量判定并维护周期锁事实。
type Service struct {
db *gorm.DB
// repository 是公共 Outbox 仓储达量停机事件与周期复机事件必须与锁事实在同一事务写入ENG-OUTBOX-001
repository *outbox.Repository
logger *zap.Logger
// commander 是停复机执行端口;未注入时消费者与 cron 拒绝执行外部调用。
commander CardCommander
now func() time.Time
}
// NewService 创建通道阈值用例repository 决定达量判定能否写出可靠停复机事件。
func NewService(db *gorm.DB, repository *outbox.Repository) *Service {
return &Service{db: db, repository: repository, logger: zap.NewNop(), now: time.Now}
}
// SetLogger 注入通道阈值运行日志。
func (s *Service) SetLogger(logger *zap.Logger) *Service {
if s == nil {
return s
}
if logger == nil {
s.logger = zap.NewNop()
return s
}
s.logger = logger
return s
}
// SetCommander 注入停复机执行端口(消费者与周期处理必需)。
func (s *Service) SetCommander(commander CardCommander) *Service {
if s == nil {
return s
}
s.commander = commander
return s
}
// ChannelThresholdLocked 判断该卡当前计费周期是否持有通道阈值停机锁。
//
// 供复机入口前置拒绝复用:持锁即拒绝,判定口径与锁生效口径完全一致(按锁行自身运营商的
// data_reset_day 判断周期归属),不另立一套判定。
func (s *Service) ChannelThresholdLocked(ctx context.Context, cardID uint) (bool, error) {
lock, err := s.ActiveLock(ctx, cardID, s.now())
if err != nil {
return false, err
}
return lock != nil, nil
}
// carrierResetDays 按运营商 ID 集合显式查询上游流量重置日ENG-MODEL-001不使用关联标签
// 返回结果只包含仍然存在的运营商,缺失 ID 由调用方按各自语义处理。
func (s *Service) carrierResetDays(ctx context.Context, carrierIDs []uint) (map[uint]int, error) {
resetDays := make(map[uint]int, len(carrierIDs))
if len(carrierIDs) == 0 {
return resetDays, nil
}
var carriers []model.Carrier
if err := s.db.WithContext(ctx).
Select("id", "data_reset_day").
Where("id IN ?", carrierIDs).
Find(&carriers).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询运营商上游流量重置日失败")
}
for index := range carriers {
resetDays[carriers[index].ID] = carriers[index].DataResetDay
}
return resetDays, nil
}