// Package cardobservation 提供卡实名观测的复杂写用例。 package cardobservation import ( "context" "crypto/sha256" "encoding/hex" "strconv" "time" domain "github.com/break/junhong_cmp_fiber/internal/domain/cardobservation" "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" "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) } // 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 } // NewService 创建卡实名观测应用服务。 func NewService(db *gorm.DB, eventWriter EventWriter, cache CacheInvalidator) *Service { return &Service{db: db, eventWriter: eventWriter, cache: cache} } // 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:" digest := sha256.Sum256([]byte(strconv.FormatUint(uint64(cardID), 10) + ":" + observationID + ":changed")) // 使用稳定摘要保留幂等语义,同时严格服从公共 Outbox 的字段长度上限。 return prefix + hex.EncodeToString(digest[:])[:constants.OutboxEventIDMaxLength-len(prefix)] }