暂存一下,防止丢失

This commit is contained in:
2026-07-24 16:07:18 +08:00
parent 5d6e23f1a5
commit a18ed8bc8d
180 changed files with 13597 additions and 1986 deletions

View File

@@ -0,0 +1,336 @@
// Package cardobservation 提供卡观测的 Outbox、缓存和消费者适配器。
package cardobservation
import (
"context"
"strconv"
"time"
"github.com/bytedance/sonic"
"github.com/redis/go-redis/v9"
"go.uber.org/zap"
"gorm.io/gorm"
"gorm.io/gorm/clause"
cardapp "github.com/break/junhong_cmp_fiber/internal/application/cardobservation"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/messaging/outbox"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
func uintString(value uint) string {
return strconv.FormatUint(uint64(value), 10)
}
// EventWriter 将卡实名状态变化写入公共 Outbox。
type EventWriter struct {
outbox *outbox.Repository
}
// NewEventWriter 创建卡实名事件 Writer。
func NewEventWriter(repository *outbox.Repository) *EventWriter {
return &EventWriter{outbox: repository}
}
// Append 在调用方事务中追加卡实名状态变化事件。
func (w *EventWriter) AppendRealname(ctx context.Context, tx *gorm.DB, event cardapp.RealnameChangedEvent) error {
if w == nil || w.outbox == nil {
return errors.New(errors.CodeInternalError, "卡实名 Outbox Writer 未配置")
}
_, err := w.outbox.Append(ctx, tx, outbox.Envelope{
EventID: event.EventID, EventType: constants.OutboxEventTypeCardRealnameChanged,
PayloadVersion: constants.CardRealnameChangedPayloadVersionV1,
AggregateType: "iot_card", AggregateID: uintString(event.CardID),
ResourceType: constants.AssetTypeIotCard, ResourceID: uintString(event.CardID),
BusinessKey: event.EventID, RequestID: event.RequestID, CorrelationID: event.CorrelationID,
Payload: event,
})
return err
}
// AppendTraffic 在调用方事务中追加卡流量正增量事件。
func (w *EventWriter) AppendTraffic(ctx context.Context, tx *gorm.DB, event cardapp.TrafficIncrementedEvent) error {
if w == nil || w.outbox == nil {
return errors.New(errors.CodeInternalError, "卡流量 Outbox Writer 未配置")
}
_, err := w.outbox.Append(ctx, tx, outbox.Envelope{
EventID: event.EventID, EventType: constants.OutboxEventTypeCardTrafficIncremented,
PayloadVersion: constants.CardTrafficIncrementedPayloadVersionV1,
AggregateType: "iot_card", AggregateID: uintString(event.CardID),
ResourceType: constants.AssetTypeIotCard, ResourceID: uintString(event.CardID),
BusinessKey: event.EventID, RequestID: event.RequestID, CorrelationID: event.CorrelationID,
Payload: event,
})
return err
}
// AppendNetwork 在调用方事务中追加卡网络状态变化事件。
func (w *EventWriter) AppendNetwork(ctx context.Context, tx *gorm.DB, event cardapp.NetworkChangedEvent) error {
if w == nil || w.outbox == nil {
return errors.New(errors.CodeInternalError, "卡网络 Outbox Writer 未配置")
}
_, err := w.outbox.Append(ctx, tx, outbox.Envelope{
EventID: event.EventID, EventType: constants.OutboxEventTypeCardNetworkChanged,
PayloadVersion: constants.CardNetworkChangedPayloadVersionV1,
AggregateType: "iot_card", AggregateID: uintString(event.CardID),
ResourceType: constants.AssetTypeIotCard, ResourceID: uintString(event.CardID),
BusinessKey: event.EventID, RequestID: event.RequestID, CorrelationID: event.CorrelationID,
Payload: event,
})
return err
}
// CacheInvalidator 在提交后删除轮询卡缓存和旧 Redis 逆转计数。
type CacheInvalidator struct {
redis *redis.Client
logger *zap.Logger
}
// NewCacheInvalidator 创建卡观测缓存失效适配器。
func NewCacheInvalidator(redisClient *redis.Client, logger *zap.Logger) *CacheInvalidator {
return &CacheInvalidator{redis: redisClient, logger: logger}
}
// Invalidate 删除可由数据库重建的缓存;失败只告警,不伪造事务回滚。
func (i *CacheInvalidator) Invalidate(ctx context.Context, cardID uint) {
if i == nil || i.redis == nil {
return
}
if err := i.redis.Del(ctx, constants.RedisPollingCardInfoKey(cardID), constants.RedisPollingRealnameReversalCountKey(cardID)).Err(); err != nil && i.logger != nil {
i.logger.Warn("卡实名事实已提交但缓存失效失败", zap.Uint("card_id", cardID), zap.Error(err))
}
}
// RealnameActivator 激活卡或设备待实名套餐。
type RealnameActivator interface {
ActivateByRealname(ctx context.Context, carrierType string, carrierID uint) error
}
// TrafficDeductor 按卡流量正增量扣减套餐流量。
type TrafficDeductor interface {
DeductDataUsage(ctx context.Context, carrierType string, carrierID uint, usageMB float64) error
}
// StopResumeEvaluator 根据卡的当前事实执行停复机评估。
type StopResumeEvaluator interface {
EvaluateAndAct(ctx context.Context, card *model.IotCard) error
}
// DeviceBindingReader 查询卡当前绑定的设备。
type DeviceBindingReader interface {
GetActiveBindingByCardID(ctx context.Context, cardID uint) (*model.DeviceSimBinding, error)
}
// RealnameChangedConsumer 消费实名变化事件并串联现有幂等业务副作用。
type RealnameChangedConsumer struct {
db *gorm.DB
activator RealnameActivator
binding DeviceBindingReader
evaluator StopResumeEvaluator
}
// NetworkChangedConsumer 消费网络状态变化并按当前权威事实执行停复机评估。
type NetworkChangedConsumer struct {
db *gorm.DB
evaluator StopResumeEvaluator
}
// NewNetworkChangedConsumer 创建卡网络状态变化事件消费者。
func NewNetworkChangedConsumer(db *gorm.DB, evaluator StopResumeEvaluator) *NetworkChangedConsumer {
return &NetworkChangedConsumer{db: db, evaluator: evaluator}
}
// Consume 校验网络事件并按当前卡事实执行评估。
func (c *NetworkChangedConsumer) Consume(ctx context.Context, envelope outbox.DeliveryEnvelope) error {
if c == nil || c.db == nil {
return errors.New(errors.CodeInternalError, "卡网络事件消费者未配置")
}
if envelope.EventType != constants.OutboxEventTypeCardNetworkChanged || envelope.PayloadVersion != constants.CardNetworkChangedPayloadVersionV1 {
return errors.New(errors.CodeInvalidParam, "卡网络事件类型或版本不受支持")
}
var event cardapp.NetworkChangedEvent
if err := sonic.Unmarshal(envelope.Payload, &event); err != nil {
return errors.Wrap(errors.CodeInvalidParam, err, "卡网络事件载荷无法解析")
}
if event.EventID != envelope.EventID || event.CardID == 0 || event.BeforeStatus == event.AfterStatus {
return errors.New(errors.CodeInvalidParam, "卡网络事件载荷不完整")
}
if c.evaluator == nil {
return nil
}
var card model.IotCard
if err := c.db.WithContext(ctx).Where("id = ?", event.CardID).First(&card).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return nil
}
return errors.Wrap(errors.CodeDatabaseError, err, "查询网络变化后的卡事实失败")
}
return c.evaluator.EvaluateAndAct(ctx, &card)
}
// NewRealnameChangedConsumer 创建卡实名变化事件消费者。
func NewRealnameChangedConsumer(db *gorm.DB, activator RealnameActivator, binding DeviceBindingReader, evaluator StopResumeEvaluator) *RealnameChangedConsumer {
return &RealnameChangedConsumer{db: db, activator: activator, binding: binding, evaluator: evaluator}
}
// Consume 校验载荷,并以当前权威卡事实执行可重入副作用。
func (c *RealnameChangedConsumer) Consume(ctx context.Context, envelope outbox.DeliveryEnvelope) error {
if c == nil || c.db == nil {
return errors.New(errors.CodeInternalError, "卡实名事件消费者未配置")
}
if envelope.EventType != constants.OutboxEventTypeCardRealnameChanged || envelope.PayloadVersion != constants.CardRealnameChangedPayloadVersionV1 {
return errors.New(errors.CodeInvalidParam, "卡实名事件类型或版本不受支持")
}
var event cardapp.RealnameChangedEvent
if err := sonic.Unmarshal(envelope.Payload, &event); err != nil {
return errors.Wrap(errors.CodeInvalidParam, err, "卡实名事件载荷无法解析")
}
if event.EventID != envelope.EventID || event.CardID == 0 || event.BeforeStatus == event.AfterStatus {
return errors.New(errors.CodeInvalidParam, "卡实名事件载荷不完整")
}
if event.FirstVerified && c.activator != nil {
if err := c.activator.ActivateByRealname(ctx, constants.AssetTypeIotCard, event.CardID); err != nil {
return err
}
if c.binding != nil {
binding, err := c.binding.GetActiveBindingByCardID(ctx, event.CardID)
if err == nil && binding != nil {
if err := c.activator.ActivateByRealname(ctx, constants.AssetTypeDevice, binding.DeviceID); err != nil {
return err
}
} else if err != nil && err != gorm.ErrRecordNotFound {
return errors.Wrap(errors.CodeDatabaseError, err, "查询实名卡绑定设备失败")
}
}
}
if c.evaluator == nil {
return nil
}
var card model.IotCard
if err := c.db.WithContext(ctx).Where("id = ?", event.CardID).First(&card).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return nil
}
return errors.Wrap(errors.CodeDatabaseError, err, "查询实名变化后的卡事实失败")
}
return c.evaluator.EvaluateAndAct(ctx, &card)
}
// TrafficIncrementedConsumer 消费卡流量正增量并推进幂等副作用进度。
type TrafficIncrementedConsumer struct {
db *gorm.DB
redis *redis.Client
deductor TrafficDeductor
evaluator StopResumeEvaluator
}
// NewTrafficIncrementedConsumer 创建卡流量正增量事件消费者。
func NewTrafficIncrementedConsumer(db *gorm.DB, redisClient *redis.Client, deductor TrafficDeductor, evaluator StopResumeEvaluator) *TrafficIncrementedConsumer {
return &TrafficIncrementedConsumer{db: db, redis: redisClient, deductor: deductor, evaluator: evaluator}
}
// Consume 按事件处理进度避免至少一次投递重复扣减套餐流量。
func (c *TrafficIncrementedConsumer) Consume(ctx context.Context, envelope outbox.DeliveryEnvelope) error {
if c == nil || c.db == nil || c.redis == nil || c.deductor == nil {
return errors.New(errors.CodeInternalError, "卡流量事件消费者未配置")
}
if envelope.EventType != constants.OutboxEventTypeCardTrafficIncremented || envelope.PayloadVersion != constants.CardTrafficIncrementedPayloadVersionV1 {
return errors.New(errors.CodeInvalidParam, "卡流量事件类型或版本不受支持")
}
var event cardapp.TrafficIncrementedEvent
if err := sonic.Unmarshal(envelope.Payload, &event); err != nil {
return errors.Wrap(errors.CodeInvalidParam, err, "卡流量事件载荷无法解析")
}
if event.EventID != envelope.EventID || event.CardID == 0 || event.IncrementMB <= 0 {
return errors.New(errors.CodeInvalidParam, "卡流量事件载荷不完整")
}
receipt, err := c.acquireTrafficEffect(ctx, event.EventID)
if err != nil {
return err
}
if receipt.Status == constants.CardObservationEffectStatusCompleted {
return nil
}
if receipt.Status == constants.CardObservationEffectStatusProcessing {
dailyKey := constants.RedisCardDailyTrafficKey(event.CardID, event.ObservedAt.Format("2006-01-02"))
pipe := c.redis.TxPipeline()
pipe.IncrByFloat(ctx, dailyKey, event.IncrementMB)
pipe.Expire(ctx, dailyKey, 48*time.Hour)
if _, err := pipe.Exec(ctx); err != nil {
return errors.Wrap(errors.CodeInternalError, err, "记录卡日流量缓冲失败,处理结果未知")
}
if err := c.updateTrafficEffectStatus(ctx, receipt.ID, constants.CardObservationEffectStatusProcessing, constants.CardObservationEffectStatusDailySaved); err != nil {
return err
}
receipt.Status = constants.CardObservationEffectStatusDailySaved
}
if receipt.Status == constants.CardObservationEffectStatusDailySaved {
if err := c.updateTrafficEffectStatus(ctx, receipt.ID, constants.CardObservationEffectStatusDailySaved, constants.CardObservationEffectStatusProcessing); err != nil {
return err
}
if err := c.deductor.DeductDataUsage(ctx, constants.AssetTypeIotCard, event.CardID, event.IncrementMB); err != nil {
return err
}
if err := c.updateTrafficEffectStatus(ctx, receipt.ID, constants.CardObservationEffectStatusProcessing, constants.CardObservationEffectStatusDeducted); err != nil {
return err
}
receipt.Status = constants.CardObservationEffectStatusDeducted
}
if c.evaluator != nil {
var card model.IotCard
if err := c.db.WithContext(ctx).Where("id = ?", event.CardID).First(&card).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询流量变化后的卡事实失败")
}
if err := c.evaluator.EvaluateAndAct(ctx, &card); err != nil {
return err
}
}
if err := c.updateTrafficEffectStatus(ctx, receipt.ID, constants.CardObservationEffectStatusDeducted, constants.CardObservationEffectStatusCompleted); err != nil {
return err
}
return nil
}
func (c *TrafficIncrementedConsumer) acquireTrafficEffect(ctx context.Context, eventID string) (*model.CardObservationEffect, error) {
var receipt model.CardObservationEffect
err := c.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("event_id = ?", eventID).First(&receipt).Error
if err == gorm.ErrRecordNotFound {
receipt = model.CardObservationEffect{
EventID: eventID, EffectType: constants.CardObservationEffectTypeTrafficIncrement,
Status: constants.CardObservationEffectStatusProcessing,
}
return tx.Create(&receipt).Error
}
if err != nil {
return err
}
switch receipt.Status {
case constants.CardObservationEffectStatusCompleted, constants.CardObservationEffectStatusDailySaved, constants.CardObservationEffectStatusDeducted:
return nil
case constants.CardObservationEffectStatusProcessing:
return errors.New(errors.CodeConflict, "卡流量副作用仍在处理或结果未知")
default:
return tx.Model(&receipt).Where("status = ?", constants.CardObservationEffectStatusPending).
Update("status", constants.CardObservationEffectStatusProcessing).Error
}
})
if err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "获取卡流量副作用处理权失败")
}
return &receipt, nil
}
func (c *TrafficIncrementedConsumer) updateTrafficEffectStatus(ctx context.Context, id uint, beforeStatus, afterStatus int) error {
result := c.db.WithContext(ctx).Model(&model.CardObservationEffect{}).
Where("id = ? AND status = ?", id, beforeStatus).
Update("status", afterStatus)
if result.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, result.Error, "更新卡流量副作用进度失败")
}
if result.RowsAffected != 1 {
return errors.New(errors.CodeConflict, "卡流量副作用进度已被其他消费者更新")
}
return nil
}

View File

@@ -0,0 +1,185 @@
package cardobservation
import (
"context"
"strconv"
"time"
"github.com/google/uuid"
"github.com/redis/go-redis/v9"
cardapp "github.com/break/junhong_cmp_fiber/internal/application/cardobservation"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// SeriesCoordinator 使用 Redis 原子协调活跃序列、幂等尝试和实际请求互斥。
type SeriesCoordinator struct {
redis *redis.Client
now func() time.Time
}
// NewSeriesCoordinator 创建观测序列 Redis 协调器。
func NewSeriesCoordinator(redisClient *redis.Client) *SeriesCoordinator {
return &SeriesCoordinator{redis: redisClient, now: time.Now}
}
// Reserve 原子保留同场景活跃序列;重复触发不会刷新 TTL。
func (c *SeriesCoordinator) Reserve(ctx context.Context, request cardapp.SeriesRequest, candidateSeriesID string, candidateBaseTime time.Time) (string, time.Time, bool, error) {
if c == nil || c.redis == nil {
return "", time.Time{}, false, errors.New(errors.CodeInternalError, "卡观测序列 Redis 未配置")
}
seriesKey := constants.RedisCardObservationSeriesKey(request.Scene, request.ResourceType, request.ResourceID, request.SyncType)
indexKey := constants.RedisCardObservationSeriesIndexKey(request.ResourceType, request.ResourceID, request.SyncType)
result, err := c.redis.Eval(ctx, `
local current = redis.call('HMGET', KEYS[1], 'series_id', 'base_time_ms')
if current[1] then return {current[1], current[2], '1'} end
redis.call('HSET', KEYS[1], 'series_id', ARGV[1], 'base_time_ms', ARGV[2])
redis.call('EXPIRE', KEYS[1], ARGV[3])
redis.call('SADD', KEYS[2], KEYS[1])
redis.call('EXPIRE', KEYS[2], ARGV[3])
return {ARGV[1], ARGV[2], '0'}
`, []string{seriesKey, indexKey}, candidateSeriesID, candidateBaseTime.UTC().UnixMilli(), int(constants.CardObservationSeriesTTL.Seconds())).StringSlice()
if err != nil || len(result) != 3 {
return "", time.Time{}, false, errors.Wrap(errors.CodeInternalError, err, "保留卡观测序列失败")
}
baseTimeMS, parseErr := strconv.ParseInt(result[1], 10, 64)
if parseErr != nil {
return "", time.Time{}, false, errors.Wrap(errors.CodeInternalError, parseErr, "解析卡观测序列基准时间失败")
}
return result[0], time.UnixMilli(baseTimeMS).UTC(), result[2] == "1", nil
}
// IsCompleted 判断序列是否已由预期状态或回调提前完成。
func (c *SeriesCoordinator) IsCompleted(ctx context.Context, seriesID string) (bool, error) {
exists, err := c.redis.Exists(ctx, constants.RedisCardObservationSeriesCompletedKey(seriesID)).Result()
if err != nil {
return false, errors.Wrap(errors.CodeInternalError, err, "读取卡观测序列完成状态失败")
}
return exists > 0, nil
}
// ClaimAttempt 以 series_id + attempt 原子保证任务只执行一次。
func (c *SeriesCoordinator) ClaimAttempt(ctx context.Context, seriesID string, attempt int) (bool, error) {
claimed, err := c.redis.SetNX(ctx, constants.RedisCardObservationAttemptKey(seriesID, attempt), "processing", constants.CardObservationSeriesResultTTL).Result()
if err != nil {
return false, errors.Wrap(errors.CodeInternalError, err, "领取卡观测序列尝试失败")
}
return claimed, nil
}
// AcquireRequest 获取仅覆盖实际 Gateway 请求的互斥,并返回最小间隔剩余等待时间。
func (c *SeriesCoordinator) AcquireRequest(ctx context.Context, payload cardapp.SeriesTaskPayload, provider string) (func(), time.Duration, bool, error) {
token := uuid.NewString()
lockKey := constants.RedisCardObservationInflightKey(provider, payload.SyncType, payload.ResourceID)
// 流量观测复用现有卡级同步锁,避免事件、轮询和手动刷新并发读取同一上游读数。
if payload.SyncType == constants.CardObservationSyncTypeTraffic {
if cardID, parseErr := strconv.ParseUint(payload.ResourceID, 10, 64); parseErr == nil && cardID > 0 {
lockKey = constants.RedisCardTrafficSyncLockKey(uint(cardID))
}
}
lastKey := constants.RedisCardObservationLastRequestKey(provider, payload.SyncType, payload.ResourceID)
nowMS := c.now().UTC().UnixMilli()
result, err := c.redis.Eval(ctx, `
if redis.call('EXISTS', KEYS[1]) == 1 then return {-1} end
local now = tonumber(ARGV[2])
local minimum = tonumber(ARGV[3])
local last = tonumber(redis.call('GET', KEYS[2]) or '0')
local wait = math.max(0, last + minimum - now)
redis.call('PSETEX', KEYS[1], ARGV[4], ARGV[1])
redis.call('PSETEX', KEYS[2], minimum * 3, now + wait)
return {wait}
`, []string{lockKey, lastKey}, token, nowMS, constants.CardObservationGatewayMinInterval.Milliseconds(), constants.CardObservationGatewayLockTTL.Milliseconds()).Int64Slice()
if err != nil || len(result) != 1 {
return nil, 0, false, errors.Wrap(errors.CodeInternalError, err, "获取卡观测 Gateway 请求互斥失败")
}
if result[0] < 0 {
return func() {}, 0, false, nil
}
release := func() {
_, _ = c.redis.Eval(context.Background(), `
if redis.call('GET', KEYS[1]) == ARGV[1] then return redis.call('DEL', KEYS[1]) end
return 0
`, []string{lockKey}, token).Result()
}
return release, time.Duration(result[0]) * time.Millisecond, true, nil
}
// FinishAttempt 保存尝试终态,并在最后一次尝试后释放同场景活跃序列。
func (c *SeriesCoordinator) FinishAttempt(ctx context.Context, payload cardapp.SeriesTaskPayload) error {
if err := c.redis.Set(ctx, constants.RedisCardObservationAttemptKey(payload.SeriesID, payload.Attempt), "completed", constants.CardObservationSeriesResultTTL).Err(); err != nil {
return errors.Wrap(errors.CodeInternalError, err, "完成卡观测序列尝试失败")
}
if payload.Attempt < constants.CardObservationSeriesAttemptCount {
return nil
}
return c.releaseActiveSeries(ctx, payload, false)
}
// CompleteSeries 提前完成当前及剩余任务,并释放同场景合并键。
func (c *SeriesCoordinator) CompleteSeries(ctx context.Context, payload cardapp.SeriesTaskPayload) error {
if err := c.releaseActiveSeries(ctx, payload, true); err != nil {
return err
}
pipe := c.redis.TxPipeline()
for attempt := payload.Attempt; attempt <= constants.CardObservationSeriesAttemptCount; attempt++ {
pipe.Set(ctx, constants.RedisCardObservationAttemptKey(payload.SeriesID, attempt), "completed", constants.CardObservationSeriesResultTTL)
}
if _, err := pipe.Exec(ctx); err != nil {
return errors.Wrap(errors.CodeInternalError, err, "标记卡观测剩余尝试完成失败")
}
return nil
}
func (c *SeriesCoordinator) releaseActiveSeries(ctx context.Context, payload cardapp.SeriesTaskPayload, completed bool) error {
seriesKey := constants.RedisCardObservationSeriesKey(payload.Scene, payload.ResourceType, payload.ResourceID, payload.SyncType)
indexKey := constants.RedisCardObservationSeriesIndexKey(payload.ResourceType, payload.ResourceID, payload.SyncType)
completedValue := "0"
if completed {
completedValue = "1"
}
if _, err := c.redis.Eval(ctx, `
if ARGV[2] == '1' then redis.call('SET', KEYS[3], '1', 'EX', ARGV[3]) end
if redis.call('HGET', KEYS[1], 'series_id') == ARGV[1] then
redis.call('DEL', KEYS[1])
redis.call('SREM', KEYS[2], KEYS[1])
end
return 1
`, []string{seriesKey, indexKey, constants.RedisCardObservationSeriesCompletedKey(payload.SeriesID)}, payload.SeriesID, completedValue, int(constants.CardObservationSeriesResultTTL.Seconds())).Result(); err != nil {
return errors.Wrap(errors.CodeInternalError, err, "释放卡观测活跃序列失败")
}
return nil
}
// CompleteResourceSeries 提前完成同资源、同步类型下所有场景的活跃序列。
func (c *SeriesCoordinator) CompleteResourceSeries(ctx context.Context, resourceType, resourceID, syncType string) error {
indexKey := constants.RedisCardObservationSeriesIndexKey(resourceType, resourceID, syncType)
seriesKeys, err := c.redis.SMembers(ctx, indexKey).Result()
if err != nil {
return errors.Wrap(errors.CodeInternalError, err, "读取卡观测活跃序列索引失败")
}
for _, seriesKey := range seriesKeys {
seriesID, getErr := c.redis.HGet(ctx, seriesKey, "series_id").Result()
if getErr == redis.Nil {
_ = c.redis.SRem(ctx, indexKey, seriesKey).Err()
continue
}
if getErr != nil {
return errors.Wrap(errors.CodeInternalError, getErr, "读取卡观测活跃序列失败")
}
pipe := c.redis.TxPipeline()
pipe.Set(ctx, constants.RedisCardObservationSeriesCompletedKey(seriesID), "1", constants.CardObservationSeriesResultTTL)
pipe.Del(ctx, seriesKey)
pipe.SRem(ctx, indexKey, seriesKey)
if _, pipeErr := pipe.Exec(ctx); pipeErr != nil {
return errors.Wrap(errors.CodeInternalError, pipeErr, "提前完成卡观测资源序列失败")
}
}
return nil
}
var _ cardapp.SeriesCoordinator = (*SeriesCoordinator)(nil)
func formatCardID(cardID uint) string {
return strconv.FormatUint(uint64(cardID), 10)
}

View File

@@ -0,0 +1,259 @@
package cardobservation
import (
"context"
stderrors "errors"
"strconv"
"strings"
"time"
"gorm.io/gorm"
cardapp "github.com/break/junhong_cmp_fiber/internal/application/cardobservation"
carddomain "github.com/break/junhong_cmp_fiber/internal/domain/cardobservation"
"github.com/break/junhong_cmp_fiber/internal/gateway"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/integrationlog"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
"github.com/break/junhong_cmp_fiber/pkg/constants"
apperrors "github.com/break/junhong_cmp_fiber/pkg/errors"
)
// SeriesAttemptLogger 将未发送的序列结果写入统一 Integration Log。
type SeriesAttemptLogger struct {
repository *integrationlog.Repository
}
// NewSeriesAttemptLogger 创建未发送尝试日志适配器。
func NewSeriesAttemptLogger(repository *integrationlog.Repository) *SeriesAttemptLogger {
return &SeriesAttemptLogger{repository: repository}
}
// Record 写入互斥、限频或提前完成结果。
func (l *SeriesAttemptLogger) Record(ctx context.Context, payload cardapp.SeriesTaskPayload, result, reason string) error {
if l == nil || l.repository == nil {
return apperrors.New(apperrors.CodeInternalError, "卡观测 Integration Log 未配置")
}
resourceID := payload.ResourceID
source, scene, seriesID := payload.Source, payload.Scene, payload.SeriesID
requestID, correlationID := optionalText(payload.RequestID), optionalText(payload.CorrelationID)
_, err := l.repository.Start(ctx, integrationlog.Attempt{
IntegrationID: unsentIntegrationID(payload), Provider: constants.IntegrationProviderGateway,
Direction: constants.IntegrationDirectionOutbound, Operation: operationForSyncType(payload.SyncType),
ResourceType: payload.ResourceType, ResourceID: &resourceID, TriggerSource: &source,
TriggerScene: &scene, TriggerSeries: &seriesID, ScheduledAt: &payload.ScheduledAt,
Attempt: payload.Attempt, InitialResult: result, RequestID: requestID, CorrelationID: correlationID,
Metadata: map[string]any{"reason": reason, "sync_type": payload.SyncType},
})
return err
}
// RecordMerged 记录同场景重复触发被合并,不创建第二组三任务。
func (l *SeriesAttemptLogger) RecordMerged(ctx context.Context, request cardapp.SeriesRequest, seriesID string) error {
resourceID := request.ResourceID
source, scene := request.Source, request.Scene
now := time.Now().UTC()
_, err := l.repository.Start(ctx, integrationlog.Attempt{
Provider: constants.IntegrationProviderGateway, Direction: constants.IntegrationDirectionOutbound,
Operation: operationForSyncType(request.SyncType), ResourceType: request.ResourceType,
ResourceID: &resourceID, TriggerSource: &source, TriggerScene: &scene, TriggerSeries: &seriesID,
ScheduledAt: &now, Attempt: 1, InitialResult: constants.IntegrationResultMerged,
RequestID: optionalText(request.RequestID), CorrelationID: optionalText(request.CorrelationID),
Metadata: map[string]any{"reason": "同场景未结束序列已存在", "sync_type": request.SyncType},
})
return err
}
// SeriesRunner 负责卡本地预期判断、Gateway 查询和公共观测应用。
type SeriesRunner struct {
db *gorm.DB
gateway *gateway.Client
observation *cardapp.Service
integration *integrationlog.Repository
carrier *postgres.CarrierStore
}
// NewSeriesRunner 创建卡观测序列 Gateway 执行器。
func NewSeriesRunner(db *gorm.DB, gatewayClient *gateway.Client, observation *cardapp.Service, integration *integrationlog.Repository) *SeriesRunner {
return &SeriesRunner{db: db, gateway: gatewayClient, observation: observation, integration: integration, carrier: postgres.NewCarrierStore(db)}
}
// Provider 返回用于请求互斥的运营商接入标识。
func (r *SeriesRunner) Provider(ctx context.Context, payload cardapp.SeriesTaskPayload) (string, error) {
card, err := r.loadCard(ctx, payload)
if err != nil {
return "", err
}
provider := strings.ToLower(strings.TrimSpace(card.CarrierType))
if provider == "" {
provider = "carrier-" + strconv.FormatUint(uint64(card.CarrierID), 10)
}
return provider, nil
}
// ExpectationMet 在访问 Gateway 前读取本地权威快照。
func (r *SeriesRunner) ExpectationMet(ctx context.Context, payload cardapp.SeriesTaskPayload) (bool, error) {
expected := strings.ToLower(strings.TrimSpace(payload.ExpectedValue))
if expected == "" {
return false, nil
}
card, err := r.loadCard(ctx, payload)
if err != nil {
return false, err
}
switch payload.SyncType {
case constants.CardObservationSyncTypeRealname:
return (expected == "verified" || expected == "1") && card.RealNameStatus == constants.RealNameStatusVerified, nil
case constants.CardObservationSyncTypeNetwork:
if expected == "online" || expected == "1" {
return card.NetworkStatus == constants.NetworkStatusOnline, nil
}
if expected == "offline" || expected == "stopped" || expected == "0" {
return card.NetworkStatus == constants.NetworkStatusOffline, nil
}
}
return false, nil
}
// Run 执行一次 Gateway 查询,并将结果交给公共卡观测应用服务。
func (r *SeriesRunner) Run(ctx context.Context, payload cardapp.SeriesTaskPayload) (cardapp.RunResult, error) {
if r == nil || r.db == nil || r.gateway == nil || r.observation == nil || r.integration == nil {
return cardapp.RunResult{}, apperrors.New(apperrors.CodeInternalError, "卡观测序列 Gateway 能力未完整配置")
}
card, err := r.loadCard(ctx, payload)
if err != nil {
return cardapp.RunResult{}, err
}
attempt, err := r.startAttempt(ctx, payload, card)
if err != nil {
return cardapp.RunResult{}, err
}
startedAt := time.Now()
result, runErr := r.queryAndApply(ctx, payload, card, attempt.IntegrationID)
completionResult := constants.IntegrationResultSuccess
if runErr != nil {
completionResult = constants.IntegrationResultFailed
if isRateLimited(runErr) {
completionResult = constants.IntegrationResultRateLimited
result.RateLimited = true
}
}
_, completeErr := r.integration.Complete(ctx, attempt.IntegrationID, integrationlog.Completion{
Result: completionResult, DurationMS: time.Since(startedAt).Milliseconds(), StateChanged: result.StateChanged,
ResponseSummary: map[string]any{"sync_type": payload.SyncType, "applied": runErr == nil},
})
if completeErr != nil {
return result, completeErr
}
return result, runErr
}
func (r *SeriesRunner) startAttempt(ctx context.Context, payload cardapp.SeriesTaskPayload, card *model.IotCard) (*model.IntegrationLog, error) {
resourceID, source, scene, seriesID := payload.ResourceID, payload.Source, payload.Scene, payload.SeriesID
resourceKey := "card:" + formatCardID(card.ID)
return r.integration.Start(ctx, integrationlog.Attempt{
IntegrationID: gatewayIntegrationID(payload), Provider: constants.IntegrationProviderGateway,
Direction: constants.IntegrationDirectionOutbound, Operation: operationForSyncType(payload.SyncType),
ResourceType: payload.ResourceType, ResourceID: &resourceID, ResourceKey: &resourceKey,
TriggerSource: &source, TriggerScene: &scene, TriggerSeries: &seriesID,
ScheduledAt: &payload.ScheduledAt, Attempt: payload.Attempt,
RequestSummary: map[string]any{"card_id": card.ID, "sync_type": payload.SyncType},
RequestID: optionalText(payload.RequestID), CorrelationID: optionalText(payload.CorrelationID),
})
}
func (r *SeriesRunner) queryAndApply(ctx context.Context, payload cardapp.SeriesTaskPayload, card *model.IotCard, observationID string) (cardapp.RunResult, error) {
metadata := carddomain.ObservationMetadata{
ObservationID: observationID, Source: constants.CardObservationSourceBusinessEvent,
Scene: payload.Scene, ObservedAt: time.Now().UTC(), RequestID: payload.RequestID,
CorrelationID: payload.CorrelationID,
}
switch payload.SyncType {
case constants.CardObservationSyncTypeRealname:
response, err := r.gateway.QueryRealnameStatus(ctx, &gateway.CardStatusReq{CardNo: card.ICCID})
if err != nil {
return cardapp.RunResult{}, err
}
decision, err := r.observation.ApplyCardObservation(ctx, carddomain.RealnameObservation{CardID: card.ID, Verified: response.RealStatus, Metadata: metadata})
return cardapp.RunResult{StateChanged: decision.StatusChanged}, err
case constants.CardObservationSyncTypeTraffic:
response, err := r.gateway.QueryFlow(ctx, &gateway.FlowQueryReq{CardNo: card.ICCID})
if err != nil {
return cardapp.RunResult{}, err
}
decision, err := r.observation.ApplyTrafficObservation(ctx, carddomain.TrafficObservation{
CardID: card.ID, GatewayReadingMB: float64(response.Used), ResetDay: r.carrier.GetDataResetDay(ctx, card.CarrierID), Metadata: metadata,
})
return cardapp.RunResult{StateChanged: decision.IncrementMB > 0 || decision.CrossMonth}, err
case constants.CardObservationSyncTypeNetwork:
response, err := r.gateway.QueryCardStatus(ctx, &gateway.CardStatusReq{CardNo: card.ICCID})
if err != nil {
return cardapp.RunResult{}, err
}
decision, err := r.observation.ApplyNetworkObservation(ctx, carddomain.NetworkObservation{
CardID: card.ID, GatewayStatus: response.CardStatus, GatewayExtend: response.Extend,
GatewayIMEI: response.IMEI, Metadata: metadata,
})
return cardapp.RunResult{StateChanged: decision.StatusChanged}, err
default:
return cardapp.RunResult{}, apperrors.New(apperrors.CodeInvalidParam, "设备信息观测执行器尚未注册")
}
}
func (r *SeriesRunner) loadCard(ctx context.Context, payload cardapp.SeriesTaskPayload) (*model.IotCard, error) {
if payload.ResourceType != constants.CardObservationResourceTypeCard {
return nil, apperrors.New(apperrors.CodeInvalidParam, "当前观测执行器仅支持 IoT 卡资源")
}
cardID, err := strconv.ParseUint(payload.ResourceID, 10, 64)
if err != nil || cardID == 0 {
return nil, apperrors.New(apperrors.CodeInvalidParam, "卡观测资源 ID 无效")
}
var card model.IotCard
if err := r.db.WithContext(ctx).Where("id = ?", uint(cardID)).First(&card).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return nil, apperrors.New(apperrors.CodeNotFound, "IoT卡不存在")
}
return nil, apperrors.Wrap(apperrors.CodeDatabaseError, err, "查询卡观测本地快照失败")
}
return &card, nil
}
func isRateLimited(err error) bool {
var appErr *apperrors.AppError
if stderrors.As(err, &appErr) && appErr.Code == apperrors.CodeTooManyRequests {
return true
}
return strings.Contains(err.Error(), "429") || strings.Contains(strings.ToLower(err.Error()), "rate limit")
}
func operationForSyncType(syncType string) string {
switch syncType {
case constants.CardObservationSyncTypeRealname:
return constants.IntegrationOperationGatewayRealname
case constants.CardObservationSyncTypeTraffic:
return constants.IntegrationOperationGatewayTraffic
case constants.CardObservationSyncTypeNetwork:
return constants.IntegrationOperationGatewayNetwork
default:
return "query_device_info"
}
}
func gatewayIntegrationID(payload cardapp.SeriesTaskPayload) string {
return "card-series:" + payload.SeriesID + ":" + strconv.Itoa(payload.Attempt)
}
func unsentIntegrationID(payload cardapp.SeriesTaskPayload) string {
return gatewayIntegrationID(payload) + ":unsent"
}
func optionalText(value string) *string {
value = strings.TrimSpace(value)
if value == "" {
return nil
}
return &value
}
var _ cardapp.SeriesAttemptLogger = (*SeriesAttemptLogger)(nil)
var _ cardapp.SeriesRunner = (*SeriesRunner)(nil)