Files
junhong_cmp_fiber/internal/application/cardobservation/apply.go
邹进 9c3c258f16
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 9m27s
feat: 卡流量读数先过采集队列再入账,扣减按采集时刻确定套餐
- 每卡 Redis 采集队列(默认 N=3、跨天清空、回退容差 1 MB),攒满且不下降才写最后一个点
- 测试环境全量切换日快照计量,下线灰度名单、切换水位与卡表基线扣费;全部卡置待建起点
- 读数区分没给值与 0;有套餐的卡读数 0 也写快照与日用量 0 行;重置日比较基准为 0 照常防旧值
- 新增采集记录(按月分区保留 90 天)、人工冲正流水与后台接口、采集告警
- 扣减按采集时刻有效套餐查找,只对生效中套餐做状态迁移,查不到套餐按成功跳过
- 换货新卡(新设备下的卡)打待建起点;采集间隔上限只校验启用中的轮询配置

Change: add-card-traffic-queue-and-observed-deduction

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 12:18:47 +09:00

311 lines
13 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Package cardobservation 提供卡实名观测的复杂写用例。
package cardobservation
import (
"context"
"strconv"
"time"
domain "github.com/break/junhong_cmp_fiber/internal/domain/cardobservation"
carrierthresholddomain "github.com/break/junhong_cmp_fiber/internal/domain/carrierthreshold"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/auditcontext"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/outboxid"
"go.uber.org/zap"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// RealnameChangedEvent 是卡实名状态变化的可靠领域事实。
type RealnameChangedEvent struct {
EventID string `json:"event_id"`
CardID uint `json:"card_id"`
BeforeStatus int `json:"before_status"`
AfterStatus int `json:"after_status"`
FirstVerified bool `json:"first_verified"`
ObservedAt time.Time `json:"observed_at"`
Source string `json:"source"`
Scene string `json:"scene"`
RequestID string `json:"request_id,omitempty"`
CorrelationID string `json:"correlation_id,omitempty"`
}
// EventWriter 在卡状态事务中追加领域 Outbox 事件。
type EventWriter interface {
AppendRealname(ctx context.Context, tx *gorm.DB, event RealnameChangedEvent) error
AppendTraffic(ctx context.Context, tx *gorm.DB, event TrafficIncrementedEvent) error
AppendNetwork(ctx context.Context, tx *gorm.DB, event NetworkChangedEvent) error
}
// CacheInvalidator 在业务事务提交后失效卡缓存。
type CacheInvalidator interface {
Invalidate(ctx context.Context, cardID uint)
}
// ChannelThresholdEvaluator 在卡流量事务内判定运营商通道流量阈值。
// 实现必须在调用方事务内写周期锁与停机事件,使锁事实与流量事实同事务提交。
type ChannelThresholdEvaluator interface {
EvaluateInTx(ctx context.Context, tx *gorm.DB, evaluation carrierthresholddomain.Evaluation) error
}
// StateAudit 描述一次需要与卡事实关联保存的状态操作。
type StateAudit struct {
ActionCode string
Summary string
Card *model.IotCard
IntegrationID string
BeforeData map[string]any
AfterData map[string]any
}
// StateAuditWriter 在卡状态事务中追加统一 Audit Event。
type StateAuditWriter interface {
WriteCardStateAudit(ctx context.Context, tx *gorm.DB, input StateAudit) error
WriteCardStateFailure(ctx context.Context, input StateAudit, businessErr error)
}
// Service 负责卡实名观测的锁定、规则应用和可靠事件写入。
type Service struct {
db *gorm.DB
eventWriter EventWriter
cache CacheInvalidator
auditWriter StateAuditWriter
// channelThreshold 为可选的通道阈值判定能力;未注入时流量观测不做阈值判定。
channelThreshold ChannelThresholdEvaluator
// trafficQueue 是卡流量采集队列;未注入时流量观测报错。
trafficQueue TrafficQueuePort
// trafficQueueSettings 返回采集队列配置;未注入时使用代码默认值。
trafficQueueSettings TrafficQueueSettingsSource
// logger 记录采集记录写入失败等不影响观测结果的告警;未注入时不输出。
logger *zap.Logger
// commandGrace 返回停复机成功后的状态保护窗口;未注入时不保护。
commandGrace func(ctx context.Context) time.Duration
}
// SetCommandGrace 注入停复机成功后的状态保护窗口(受控系统配置 polling.stop_resume.status_grace_seconds)。
func (s *Service) SetCommandGrace(grace func(ctx context.Context) time.Duration) {
s.commandGrace = grace
}
func (s *Service) currentCommandGrace(ctx context.Context) time.Duration {
if s == nil || s.commandGrace == nil {
return 0
}
return s.commandGrace(ctx)
}
// SetTrafficQueue 注入卡流量采集队列、队列配置与告警日志。
func (s *Service) SetTrafficQueue(queue TrafficQueuePort, settings TrafficQueueSettingsSource, logger *zap.Logger) {
s.trafficQueue = queue
s.trafficQueueSettings = settings
s.logger = logger
}
// warnLogger 返回可用的告警日志器。
func (s *Service) warnLogger() *zap.Logger {
if s.logger == nil {
return zap.NewNop()
}
return s.logger
}
// NewService 创建卡实名观测应用服务。
func NewService(db *gorm.DB, eventWriter EventWriter, cache CacheInvalidator) *Service {
return &Service{db: db, eventWriter: eventWriter, cache: cache}
}
// SetChannelThresholdEvaluator 注入运营商通道流量阈值达量判定能力。
func (s *Service) SetChannelThresholdEvaluator(evaluator ChannelThresholdEvaluator) {
s.channelThreshold = evaluator
}
// SetStateAuditWriter 注入卡状态统一审计 Writer。
func (s *Service) SetStateAuditWriter(writer StateAuditWriter) {
s.auditWriter = writer
}
// RecordCarrierCallbackFailure 在已解析卡资源后记录运营商回调处理失败。
func (s *Service) RecordCarrierCallbackFailure(ctx context.Context, card *model.IotCard, integrationID string, businessErr error) {
if s == nil || s.auditWriter == nil || card == nil || card.ID == 0 {
return
}
s.auditWriter.WriteCardStateFailure(ctx, StateAudit{
ActionCode: constants.AuditActionIotCardRealnameCallbackSynced,
Summary: "运营商回调同步 IoT 卡实名状态失败", Card: card, IntegrationID: integrationID,
}, businessErr)
}
// ApplyCardObservation 在同一事务中应用实名状态、逆转窗口和状态变更事件。
func (s *Service) ApplyCardObservation(ctx context.Context, observation domain.RealnameObservation) (domain.RealnameDecision, error) {
if s == nil || s.db == nil || s.eventWriter == nil {
return domain.RealnameDecision{}, errors.New(errors.CodeInternalError, "卡实名观测能力未完整配置")
}
var decision domain.RealnameDecision
var auditedCard *model.IotCard
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var card model.IotCard
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ?", observation.CardID).First(&card).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return errors.New(errors.CodeNotFound, "IoT卡不存在")
}
return errors.Wrap(errors.CodeDatabaseError, err, "锁定IoT卡失败")
}
auditedCard = &card
nextDecision, decisionErr := domain.ApplyRealname(domain.CardRealnameSnapshot{
CardID: card.ID, Status: card.RealNameStatus, FirstRealnameAt: card.FirstRealnameAt,
ReversalCount: card.RealnameReversalCount, ReversalStartedAt: card.RealnameReversalStartedAt,
}, observation)
if decisionErr != nil {
return decisionErr
}
decision = nextDecision
updates := map[string]any{
"last_real_name_check_at": observation.Metadata.ObservedAt,
"realname_reversal_count": decision.ReversalCount,
"realname_reversal_started_at": decision.ReversalStartedAt,
}
if observation.Metadata.Source != constants.CardObservationSourceManualOverride {
updates["last_sync_time"] = observation.Metadata.ObservedAt
}
if decision.StatusChanged {
verified := decision.AfterStatus == constants.RealNameStatusVerified
updates["real_name_status"] = decision.AfterStatus
updates["activation_status"] = gorm.Expr(`CASE WHEN network_status = ? AND (card_category = ? OR ?) THEN 1 ELSE 0 END`, constants.NetworkStatusOnline, constants.CardCategoryIndustry, verified)
updates["activated_at"] = gorm.Expr(`CASE WHEN activated_at IS NULL AND (network_status = ? AND (card_category = ? OR ?)) THEN ? ELSE activated_at END`, constants.NetworkStatusOnline, constants.CardCategoryIndustry, verified, observation.Metadata.ObservedAt)
}
if decision.FirstVerified {
updates["first_realname_at"] = observation.Metadata.ObservedAt
}
result := tx.Model(&model.IotCard{}).Where("id = ? AND real_name_status = ?", card.ID, card.RealNameStatus).Updates(updates)
if result.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, result.Error, "更新卡实名事实失败")
}
if result.RowsAffected != 1 {
return errors.New(errors.CodeConflict, "卡实名状态已被其他请求更新")
}
if decision.StatusChanged {
eventID := realnameChangedEventID(card.ID, observation.Metadata.ObservationID)
if err := s.eventWriter.AppendRealname(ctx, tx, RealnameChangedEvent{
EventID: eventID, CardID: card.ID, BeforeStatus: card.RealNameStatus, AfterStatus: decision.AfterStatus,
FirstVerified: decision.FirstVerified, ObservedAt: observation.Metadata.ObservedAt,
Source: observation.Metadata.Source, Scene: observation.Metadata.Scene,
RequestID: observation.Metadata.RequestID, CorrelationID: observation.Metadata.CorrelationID,
}); err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "写入卡实名 Outbox 事件失败")
}
}
if observation.Metadata.Source == constants.CardObservationSourceManualOverride {
if s.auditWriter == nil {
return errors.New(errors.CodeInternalError, "卡状态统一审计能力未配置")
}
summary := "人工更新 IoT 卡实名状态"
if !decision.StatusChanged {
summary = "人工确认 IoT 卡实名状态无需变化"
}
if err := s.auditWriter.WriteCardStateAudit(ctx, tx, StateAudit{
ActionCode: constants.AuditActionIotCardRealnameStatusUpdated,
Summary: summary,
Card: &card,
BeforeData: map[string]any{"real_name_status": card.RealNameStatus, "first_realname_at": card.FirstRealnameAt},
AfterData: map[string]any{
"real_name_status": decision.AfterStatus, "first_realname_at": firstRealnameAfter(card.FirstRealnameAt, observation.Metadata.ObservedAt, decision.FirstVerified),
"status_changed": decision.StatusChanged,
},
}); err != nil {
return err
}
} else if actionCode, audited := manualRefreshAuditAction(ctx); observation.Metadata.Source == constants.CardObservationSourceManualSync && decision.StatusChanged && audited {
if s.auditWriter == nil {
return errors.New(errors.CodeInternalError, "卡状态统一审计能力未配置")
}
if err := s.auditWriter.WriteCardStateAudit(ctx, tx, StateAudit{
ActionCode: actionCode,
Summary: "人工刷新 IoT 卡实名状态",
Card: &card,
BeforeData: map[string]any{"real_name_status": card.RealNameStatus, "first_realname_at": card.FirstRealnameAt},
AfterData: map[string]any{
"real_name_status": decision.AfterStatus, "first_realname_at": firstRealnameAfter(card.FirstRealnameAt, observation.Metadata.ObservedAt, decision.FirstVerified),
},
}); err != nil {
return err
}
} else if observation.Metadata.Source == constants.CardObservationSourceCarrierCallback && decision.StatusChanged {
if s.auditWriter == nil {
return errors.New(errors.CodeInternalError, "卡状态统一审计能力未配置")
}
if err := s.auditWriter.WriteCardStateAudit(ctx, tx, StateAudit{
ActionCode: constants.AuditActionIotCardRealnameCallbackSynced,
Summary: "运营商回调同步 IoT 卡实名状态",
Card: &card,
IntegrationID: observation.Metadata.ObservationID,
BeforeData: map[string]any{"real_name_status": card.RealNameStatus, "first_realname_at": card.FirstRealnameAt},
AfterData: map[string]any{
"real_name_status": decision.AfterStatus, "first_realname_at": firstRealnameAfter(card.FirstRealnameAt, observation.Metadata.ObservedAt, decision.FirstVerified),
},
}); err != nil {
return err
}
} else if workerObservationAudited(ctx) && decision.StatusChanged {
if s.auditWriter == nil {
return errors.New(errors.CodeInternalError, "卡状态统一审计能力未配置")
}
if err := s.auditWriter.WriteCardStateAudit(ctx, tx, StateAudit{
ActionCode: constants.AuditActionIotCardWorkerRealnameSynced,
Summary: "Worker 同步 IoT 卡实名事实", Card: &card,
IntegrationID: observation.Metadata.ObservationID,
BeforeData: map[string]any{"real_name_status": card.RealNameStatus, "first_realname_at": card.FirstRealnameAt},
AfterData: map[string]any{
"real_name_status": decision.AfterStatus, "first_realname_at": firstRealnameAfter(card.FirstRealnameAt, observation.Metadata.ObservedAt, decision.FirstVerified),
},
}); err != nil {
return err
}
}
return nil
})
if err != nil {
if workerObservationAudited(ctx) && auditedCard != nil && s.auditWriter != nil {
s.auditWriter.WriteCardStateFailure(ctx, StateAudit{
ActionCode: constants.AuditActionIotCardWorkerRealnameSynced,
Summary: "Worker 同步 IoT 卡实名事实失败", Card: auditedCard,
IntegrationID: observation.Metadata.ObservationID,
}, err)
}
return domain.RealnameDecision{}, err
}
if s.cache != nil {
s.cache.Invalidate(ctx, observation.CardID)
}
return decision, nil
}
func firstRealnameAfter(before *time.Time, observedAt time.Time, firstVerified bool) *time.Time {
if firstVerified {
return &observedAt
}
return before
}
func manualRefreshAuditAction(ctx context.Context) (string, bool) {
switch auditcontext.From(ctx).ActorKind {
case constants.AuditActorAccount:
return constants.AuditActionIotCardManualRefreshed, true
case constants.AuditActorPersonalCustomer:
return constants.AuditActionIotCardPersonalRefreshed, true
default:
return "", false
}
}
func workerObservationAudited(ctx context.Context) bool {
linkage := auditcontext.From(ctx)
return linkage.ActorKind == constants.AuditActorSystemTask && linkage.Source == constants.AuditSourceWorker
}
func realnameChangedEventID(cardID uint, observationID string) string {
prefix := "card-realname:"
return outboxid.Stable(prefix, strconv.FormatUint(uint64(cardID), 10)+":"+observationID+":changed")
}