Files
junhong_cmp_fiber/internal/application/cardobservation/network.go
邹进 1214181e78
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 16m2s
feat: 轮询观测入库异步化,调网关与入库分离,采集记录经消息攒批写入
- 轮询步骤异步模式下只调网关并发布 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>
2026-11-06 16:33:29 +09:00

202 lines
8.6 KiB
Go

package cardobservation
import (
"context"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/pollingpause"
"go.uber.org/zap"
"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/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/outboxid"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// NetworkChangedEvent 是卡网络状态变化的可靠领域事实。
type NetworkChangedEvent struct {
EventID string `json:"event_id"`
CardID uint `json:"card_id"`
BeforeStatus int `json:"before_status"`
AfterStatus int `json:"after_status"`
GatewayExtend string `json:"gateway_extend"`
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"`
}
// ApplyNetworkObservation 串行应用 Gateway 网络状态、扩展原因、IMEI 和风险轮询规则。
func (s *Service) ApplyNetworkObservation(ctx context.Context, observation domain.NetworkObservation) (domain.NetworkDecision, error) {
if s == nil || s.db == nil || s.eventWriter == nil {
return domain.NetworkDecision{}, errors.New(errors.CodeInternalError, "卡网络观测能力未完整配置")
}
var decision domain.NetworkDecision
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.LastCardStatusCheckAt, 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.LastCardStatusCheckAt),
zap.String("metric", "card_observation.network_stale"))
stale = true
return nil
}
nextDecision, decisionErr := domain.ApplyNetwork(domain.CardNetworkSnapshot{
CardID: card.ID, NetworkStatus: card.NetworkStatus, StopReason: card.StopReason,
EnablePolling: card.EnablePolling, StoppedAt: card.StoppedAt, ResumedAt: card.ResumedAt,
CommandGrace: s.currentCommandGrace(ctx),
}, observation)
if decisionErr != nil {
return decisionErr
}
if nextDecision.StatusSuppressed {
s.warnLogger().Info("停复机成功后保护窗口内观测到相反状态,判定为上游状态滞后,不回写网络状态",
zap.Uint("card_id", card.ID), zap.Int("network_status", card.NetworkStatus),
zap.String("gateway_status", observation.GatewayStatus), zap.String("metric", "card_observation.network_suppressed"))
}
decision = nextDecision
updates := map[string]any{
"last_card_status_check_at": observation.Metadata.ObservedAt,
"last_sync_time": observation.Metadata.ObservedAt,
"gateway_extend": decision.GatewayExtend,
}
if decision.UpdateIMEI {
updates["gateway_card_imei"] = decision.GatewayIMEI
}
if decision.StatusChanged {
updates["network_status"] = decision.AfterStatus
}
if decision.StopReasonChanged {
updates["stop_reason"] = decision.StopReason
}
if decision.StopPolling {
updates["enable_polling"] = false
}
result := tx.Model(&model.IotCard{}).
Where("id = ? AND network_status = ? AND enable_polling = ?", card.ID, card.NetworkStatus, card.EnablePolling).
Updates(updates)
if result.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, result.Error, "更新卡网络事实失败")
}
if result.RowsAffected != 1 {
return errors.New(errors.CodeConflict, "卡网络事实已被其他请求更新")
}
if decision.StopPolling && card.EnablePolling {
// 风险停机或已销户自动关闭轮询:同事务写不轮询清单(只关该卡自身,不级联设备)。
if err := pollingpause.Record(ctx, tx, pollingpause.CardChange(&card, false), constants.PollingToggleSourceRiskAuto); err != nil {
return err
}
}
if decision.StatusChanged {
eventID := outboxid.Stable("card-network:", strconv.FormatUint(uint64(card.ID), 10)+":"+observation.Metadata.ObservationID+":changed")
if err := s.eventWriter.AppendNetwork(ctx, tx, NetworkChangedEvent{
EventID: eventID, CardID: card.ID, BeforeStatus: card.NetworkStatus, AfterStatus: decision.AfterStatus,
GatewayExtend: decision.GatewayExtend, 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 事件失败")
}
}
stateChanged := decision.StatusChanged || decision.StopReasonChanged || decision.StopPolling ||
decision.GatewayExtend != card.GatewayExtend || decision.UpdateIMEI && decision.GatewayIMEI != card.GatewayCardIMEI
if actionCode, audited := manualRefreshAuditAction(ctx); observation.Metadata.Source == constants.CardObservationSourceManualSync && stateChanged && audited {
if s.auditWriter == nil {
return errors.New(errors.CodeInternalError, "卡状态统一审计能力未配置")
}
enablePolling := card.EnablePolling
if decision.StopPolling {
enablePolling = false
}
gatewayIMEI := card.GatewayCardIMEI
if decision.UpdateIMEI {
gatewayIMEI = decision.GatewayIMEI
}
if err := s.auditWriter.WriteCardStateAudit(ctx, tx, StateAudit{
ActionCode: actionCode,
Summary: "人工刷新 IoT 卡网络状态",
Card: &card,
BeforeData: map[string]any{
"network_status": card.NetworkStatus, "stop_reason": card.StopReason,
"gateway_extend": card.GatewayExtend, "gateway_card_imei": card.GatewayCardIMEI,
"enable_polling": card.EnablePolling,
},
AfterData: map[string]any{
"network_status": decision.AfterStatus, "stop_reason": decision.StopReason,
"gateway_extend": decision.GatewayExtend, "gateway_card_imei": gatewayIMEI,
"enable_polling": enablePolling,
},
}); err != nil {
return err
}
} else if workerObservationAudited(ctx) && stateChanged {
if s.auditWriter == nil {
return errors.New(errors.CodeInternalError, "卡状态统一审计能力未配置")
}
enablePolling := card.EnablePolling
if decision.StopPolling {
enablePolling = false
}
gatewayIMEI := card.GatewayCardIMEI
if decision.UpdateIMEI {
gatewayIMEI = decision.GatewayIMEI
}
if err := s.auditWriter.WriteCardStateAudit(ctx, tx, StateAudit{
ActionCode: constants.AuditActionIotCardWorkerNetworkSynced,
Summary: "Worker 同步 IoT 卡网络事实", Card: &card,
IntegrationID: observation.Metadata.ObservationID,
BeforeData: map[string]any{
"network_status": card.NetworkStatus, "stop_reason": card.StopReason,
"gateway_extend": card.GatewayExtend, "gateway_card_imei": card.GatewayCardIMEI,
"enable_polling": card.EnablePolling,
},
AfterData: map[string]any{
"network_status": decision.AfterStatus, "stop_reason": decision.StopReason,
"gateway_extend": decision.GatewayExtend, "gateway_card_imei": gatewayIMEI,
"enable_polling": enablePolling,
},
}); err != nil {
return err
}
}
return nil
})
if err != nil {
if workerObservationAudited(ctx) && auditedCard != nil && s.auditWriter != nil {
s.auditWriter.WriteCardStateFailure(ctx, StateAudit{
ActionCode: constants.AuditActionIotCardWorkerNetworkSynced,
Summary: "Worker 同步 IoT 卡网络事实失败", Card: auditedCard,
IntegrationID: observation.Metadata.ObservationID,
}, err)
}
return domain.NetworkDecision{}, err
}
if stale {
return domain.NetworkDecision{}, nil
}
if s.cache != nil {
s.cache.Invalidate(ctx, observation.CardID)
}
return decision, nil
}
// staleObservation 判断观测时间是否早于卡上最近一次已应用的同类检查时间(异步入库乱序保护)。
func staleObservation(lastChecked *time.Time, observedAt time.Time) bool {
return lastChecked != nil && !observedAt.IsZero() && observedAt.Before(*lastChecked)
}