All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 8m25s
261 lines
11 KiB
Go
261 lines
11 KiB
Go
// Package cardobservation 提供卡实名观测的复杂写用例。
|
|
package cardobservation
|
|
|
|
import (
|
|
"context"
|
|
"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"
|
|
"github.com/break/junhong_cmp_fiber/pkg/outboxid"
|
|
"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:"
|
|
return outboxid.Stable(prefix, strconv.FormatUint(uint64(cardID), 10)+":"+observationID+":changed")
|
|
}
|