All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 16m2s
- 轮询步骤异步模式下只调网关并发布 card.ingest 入库消息,入库消费者固定并发 30、按采集 ID 去重、可重试错误不限次数退避重投 - 采集记录经 card.collection 攒批写入,失败整批重投,新增 (observation_id, observed_at) 唯一索引 - 网络、实名观测增加乱序保护;轮询读查询走缓存,套餐状态查询各自计时且失败不缓存 - 入库积压告警与反压;系统配置 polling.ingest.mode 默认 sync 可回退;测试环境 nsqd 消息全部落盘 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
336 lines
15 KiB
Go
336 lines
15 KiB
Go
// 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
|
||
// collectionSink 把采集记录发布到批量写入消息;未注入或发布失败时同步写库。
|
||
collectionSink CollectionSink
|
||
}
|
||
|
||
// CollectionSink 发布一条采集记录(成功表或失败表二选一),由批量消费者写库。
|
||
type CollectionSink interface {
|
||
PublishCollection(ctx context.Context, success *model.CardTrafficCollection, failure *model.CardTrafficCollectionFailure) error
|
||
}
|
||
|
||
// SetCollectionSink 注入采集记录发布器(Worker 启用 card.collection 批量写入时使用)。
|
||
func (s *Service) SetCollectionSink(sink CollectionSink) {
|
||
s.collectionSink = sink
|
||
}
|
||
|
||
// 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
|
||
stale := false
|
||
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
|
||
if staleObservation(card.LastRealNameCheckAt, observation.Metadata.ObservedAt) {
|
||
// 异步入库可能乱序:早于已应用观测的实名结果不改卡事实,只记录。
|
||
s.warnLogger().Info("实名观测早于卡上最近一次已应用的检查,忽略",
|
||
zap.Uint("card_id", card.ID), zap.String("observation_id", observation.Metadata.ObservationID),
|
||
zap.Time("observed_at", observation.Metadata.ObservedAt), zap.Timep("last_checked_at", card.LastRealNameCheckAt),
|
||
zap.String("metric", "card_observation.realname_stale"))
|
||
stale = true
|
||
return nil
|
||
}
|
||
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 stale {
|
||
return domain.RealnameDecision{}, nil
|
||
}
|
||
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")
|
||
}
|