Files
junhong_cmp_fiber/internal/application/cardobservation/apply.go
邹进 d4fde99cc3
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 9m29s
fix: 停复机成功后上游状态滞后导致暂停轮询的卡到期或退款漏停
- 停复机成功后保护窗口内(polling.stop_resume.status_grace_seconds,默认 300 秒)查卡状态返回操作前的状态视为上游滞后,不回写网络状态
- 套餐到期、退款场景不看库里的开停状态,资产无可用主套餐即停机,上游已停时按已是目标状态结束
- 迁移 000257 插入保护窗口配置

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 10:41:45 +09:00

316 lines
14 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
// trafficCalc 判定卡是否走日快照计量算法;未注入时全部卡走卡表基线算法且不写快照。
trafficCalc *TrafficCalcPolicy
// 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)
}
// SetTrafficCalcPolicy 注入卡流量计量切换策略与告警日志。
func (s *Service) SetTrafficCalcPolicy(policy *TrafficCalcPolicy, logger *zap.Logger) {
s.trafficCalc = policy
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")
}
// TrafficSwitched 判定卡是否已切换到日快照计量(激活前流量结算只对已切换的卡执行)。
func (s *Service) TrafficSwitched(cardID uint) bool {
if s == nil {
return false
}
return s.trafficCalc.Switched(cardID)
}