收口七月卡状态回调与系列授权兼容契约
Some checks failed
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Has been cancelled

完成运营商实名回调、业务事件观测序列与受控配置装配,同时恢复 UR43 已交付的 packages[].remove 字段及旧响应兼容,统一更新 OpenSpec、OpenAPI 和交付文档。

Constraint: 七月测试环境里程碑不新增或运行自动化测试

Rejected: 以必填 operation_type 替换 packages[].remove | 会破坏已交付前端契约

Confidence: high

Scope-risk: broad

Directive: 后续修改系列套餐管理接口必须保持 packages[].remove 和 ShopSeriesGrantResponse 兼容

Tested: go run ./cmd/gendocs;go build -buildvcs=false ./...;openspec validate complete-july-iteration-test-release --strict;git diff --check

Not-tested: 按本 Change 约定未运行 go test,真实运营商与 Gateway 联调延期
This commit is contained in:
2026-07-24 19:59:24 +08:00
parent a18ed8bc8d
commit 5c4d17e9fc
79 changed files with 4341 additions and 478 deletions

View File

@@ -0,0 +1,221 @@
package cardobservation
import (
"context"
"strconv"
"strings"
"time"
"github.com/google/uuid"
"go.uber.org/zap"
cardapp "github.com/break/junhong_cmp_fiber/internal/application/cardobservation"
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
"github.com/break/junhong_cmp_fiber/pkg/constants"
)
// BestEffortSeriesDispatcher 将读入口触发失败降级为中文安全日志。
type BestEffortSeriesDispatcher struct {
trigger *cardapp.SeriesTrigger
logger *zap.Logger
jobs chan cardapp.SeriesRequest
deviceJobs chan cardapp.DeviceCardsSeriesRequest
deviceControlJobs chan cardapp.DeviceControlSeriesRequest
realnameJobs chan cardapp.RealnameCapabilitySeriesRequest
deviceSimBindingStore *postgres.DeviceSimBindingStore
carrierStore *postgres.CarrierStore
}
const bestEffortSeriesQueueSize = 1024
// NewBestEffortSeriesDispatcher 创建读入口观测序列分发器。
func NewBestEffortSeriesDispatcher(trigger *cardapp.SeriesTrigger, logger *zap.Logger, deviceSimBindingStore *postgres.DeviceSimBindingStore, carrierStore *postgres.CarrierStore) *BestEffortSeriesDispatcher {
dispatcher := &BestEffortSeriesDispatcher{
trigger: trigger, logger: logger,
jobs: make(chan cardapp.SeriesRequest, bestEffortSeriesQueueSize),
deviceJobs: make(chan cardapp.DeviceCardsSeriesRequest, bestEffortSeriesQueueSize),
deviceControlJobs: make(chan cardapp.DeviceControlSeriesRequest, bestEffortSeriesQueueSize),
realnameJobs: make(chan cardapp.RealnameCapabilitySeriesRequest, bestEffortSeriesQueueSize),
deviceSimBindingStore: deviceSimBindingStore, carrierStore: carrierStore,
}
go dispatcher.run()
return dispatcher
}
// DispatchDeviceControl 在后台解析设备控制前后的相关卡,避免增加原操作响应耗时。
func (d *BestEffortSeriesDispatcher) DispatchDeviceControl(_ context.Context, request cardapp.DeviceControlSeriesRequest) {
if d == nil || d.trigger == nil || request.DeviceID == 0 {
return
}
request.Request = normalizeRequest(request.Request)
select {
case d.deviceControlJobs <- request:
default:
d.logFailure(request.Request, "", false, nil, "后台设备控制观测队列已满")
}
}
// Dispatch 尝试创建或合并序列,失败不向原查询调用方返回。
func (d *BestEffortSeriesDispatcher) Dispatch(_ context.Context, request cardapp.SeriesRequest) {
if d == nil || d.trigger == nil {
return
}
request = normalizeRequest(request)
select {
case d.jobs <- request:
default:
d.logFailure(request, "", false, nil, "后台观测队列已满")
}
}
// DispatchDeviceCards 在后台展开设备当前有效绑定卡,避免请求协程逐卡查询数据库。
func (d *BestEffortSeriesDispatcher) DispatchDeviceCards(_ context.Context, request cardapp.DeviceCardsSeriesRequest) {
if d == nil || d.trigger == nil || request.DeviceID == 0 || d.deviceSimBindingStore == nil {
return
}
request.Request = normalizeRequest(request.Request)
select {
case d.deviceJobs <- request:
default:
d.logFailure(request.Request, "", false, nil, "后台设备观测队列已满")
}
}
// DispatchRealnameWithCapability 在后台读取运营商实名能力none 能力不触发观测。
func (d *BestEffortSeriesDispatcher) DispatchRealnameWithCapability(_ context.Context, request cardapp.RealnameCapabilitySeriesRequest) {
if d == nil || d.trigger == nil || request.CarrierID == 0 || d.carrierStore == nil {
return
}
request.Request = normalizeRequest(request.Request)
select {
case d.realnameJobs <- request:
default:
d.logFailure(request.Request, "", false, nil, "后台实名观测队列已满")
}
}
func (d *BestEffortSeriesDispatcher) run() {
for {
select {
case request := <-d.jobs:
d.triggerRequest(request)
case request := <-d.deviceJobs:
d.expandDeviceRequest(request)
case request := <-d.deviceControlJobs:
d.expandDeviceControlRequest(request)
case request := <-d.realnameJobs:
d.expandRealnameRequest(request)
}
}
}
func (d *BestEffortSeriesDispatcher) expandDeviceControlRequest(request cardapp.DeviceControlSeriesRequest) {
deviceRequest := request.Request
deviceRequest.ResourceType = constants.CardObservationResourceTypeDevice
deviceRequest.ResourceID = strconv.FormatUint(uint64(request.DeviceID), 10)
deviceRequest.SyncType = constants.CardObservationSyncTypeDeviceInfo
deviceRequest.ExpectedValue = strings.TrimSpace(request.TargetICCID)
d.triggerRequest(deviceRequest)
if strings.TrimSpace(request.TargetICCID) == "" {
for _, cardID := range request.BoundCardIDs {
d.triggerCardControlRequest(request.Request, cardID, constants.CardObservationSyncTypeNetwork)
}
return
}
d.triggerCardControlRequest(request.Request, request.SourceCardID, constants.CardObservationSyncTypeNetwork)
if request.TargetCardID != request.SourceCardID {
d.triggerCardControlRequest(request.Request, request.TargetCardID, constants.CardObservationSyncTypeNetwork)
}
if request.IncludeTargetTraffic {
d.triggerCardControlRequest(request.Request, request.TargetCardID, constants.CardObservationSyncTypeTraffic)
}
}
func (d *BestEffortSeriesDispatcher) triggerCardControlRequest(base cardapp.SeriesRequest, cardID uint, syncType string) {
if cardID == 0 {
return
}
base.ResourceType = constants.CardObservationResourceTypeCard
base.ResourceID = strconv.FormatUint(uint64(cardID), 10)
base.SyncType = syncType
base.ExpectedValue = ""
d.triggerRequest(base)
}
func (d *BestEffortSeriesDispatcher) triggerRequest(request cardapp.SeriesRequest) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
seriesID, merged, err := d.trigger.Trigger(ctx, request)
cancel()
if err != nil {
d.logFailure(request, seriesID, merged, err, "后台观测序列触发失败")
}
}
func (d *BestEffortSeriesDispatcher) expandDeviceRequest(request cardapp.DeviceCardsSeriesRequest) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
bindings, err := d.deviceSimBindingStore.ListByDeviceID(ctx, request.DeviceID)
cancel()
if err != nil {
d.logFailure(request.Request, "", false, err, "查询设备有效绑定卡失败")
return
}
for _, binding := range bindings {
if binding == nil || binding.IotCardID == 0 {
continue
}
cardRequest := request.Request
cardRequest.ResourceType = constants.CardObservationResourceTypeCard
cardRequest.ResourceID = strconv.FormatUint(uint64(binding.IotCardID), 10)
d.triggerRequest(cardRequest)
}
}
func (d *BestEffortSeriesDispatcher) expandRealnameRequest(request cardapp.RealnameCapabilitySeriesRequest) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
carrier, err := d.carrierStore.GetByID(ctx, request.CarrierID)
cancel()
if err != nil {
d.logFailure(request.Request, "", false, err, "查询运营商实名能力失败")
return
}
if carrier.RealnameLinkType == constants.RealnameLinkTypeNone {
return
}
d.triggerRequest(request.Request)
}
func normalizeRequest(request cardapp.SeriesRequest) cardapp.SeriesRequest {
request.RequestID = strings.TrimSpace(request.RequestID)
request.CorrelationID = strings.TrimSpace(request.CorrelationID)
if request.RequestID == "" && request.CorrelationID == "" {
request.RequestID = uuid.NewString()
request.CorrelationID = request.RequestID
} else if request.RequestID == "" {
request.RequestID = request.CorrelationID
} else if request.CorrelationID == "" {
request.CorrelationID = request.RequestID
}
return request
}
func (d *BestEffortSeriesDispatcher) logFailure(request cardapp.SeriesRequest, seriesID string, merged bool, err error, message string) {
if d.logger == nil {
return
}
fields := []zap.Field{
zap.String("scene", request.Scene),
zap.String("resource_type", request.ResourceType),
zap.String("resource_id", request.ResourceID),
zap.String("sync_type", request.SyncType),
zap.String("series_id", seriesID),
zap.Bool("merged", merged),
zap.String("request_id", request.RequestID),
zap.String("correlation_id", request.CorrelationID),
}
if err != nil {
fields = append(fields, zap.Error(err))
}
d.logger.Warn(message+",已保持原查询结果", fields...)
}
var _ cardapp.BestEffortSeriesDispatcher = (*BestEffortSeriesDispatcher)(nil)

View File

@@ -146,6 +146,7 @@ func (c *NetworkChangedConsumer) Consume(ctx context.Context, envelope outbox.De
if c == nil || c.db == nil {
return errors.New(errors.CodeInternalError, "卡网络事件消费者未配置")
}
ctx = cardapp.SuppressSeriesTriggerContext(ctx)
if envelope.EventType != constants.OutboxEventTypeCardNetworkChanged || envelope.PayloadVersion != constants.CardNetworkChangedPayloadVersionV1 {
return errors.New(errors.CodeInvalidParam, "卡网络事件类型或版本不受支持")
}
@@ -179,6 +180,7 @@ func (c *RealnameChangedConsumer) Consume(ctx context.Context, envelope outbox.D
if c == nil || c.db == nil {
return errors.New(errors.CodeInternalError, "卡实名事件消费者未配置")
}
ctx = cardapp.SuppressSeriesTriggerContext(ctx)
if envelope.EventType != constants.OutboxEventTypeCardRealnameChanged || envelope.PayloadVersion != constants.CardRealnameChangedPayloadVersionV1 {
return errors.New(errors.CodeInvalidParam, "卡实名事件类型或版本不受支持")
}
@@ -235,6 +237,7 @@ func (c *TrafficIncrementedConsumer) Consume(ctx context.Context, envelope outbo
if c == nil || c.db == nil || c.redis == nil || c.deductor == nil {
return errors.New(errors.CodeInternalError, "卡流量事件消费者未配置")
}
ctx = cardapp.SuppressSeriesTriggerContext(ctx)
if envelope.EventType != constants.OutboxEventTypeCardTrafficIncremented || envelope.PayloadVersion != constants.CardTrafficIncrementedPayloadVersionV1 {
return errors.New(errors.CodeInvalidParam, "卡流量事件类型或版本不受支持")
}

View File

@@ -5,49 +5,87 @@ import (
"strconv"
"time"
"github.com/bytedance/sonic"
"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/internal/infrastructure/cardtrafficlock"
"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
redis *redis.Client
trafficLock *cardtrafficlock.Lock
now func() time.Time
}
// ClaimSchedule 保证每个固定阶梯任务只成功入队一次。
func (c *SeriesCoordinator) ClaimSchedule(ctx context.Context, seriesID string, attempt int) (bool, error) {
claimed, err := c.redis.SetNX(ctx, constants.RedisCardObservationScheduleKey(seriesID, attempt), "scheduled", constants.CardObservationSeriesResultTTL).Result()
if err != nil {
return false, errors.Wrap(errors.CodeInternalError, err, "领取卡观测序列入队槽位失败")
}
return claimed, nil
}
// IsScheduled 判断稳定业务事件是否已经创建过第一阶梯任务。
func (c *SeriesCoordinator) IsScheduled(ctx context.Context, seriesID string) (bool, error) {
keys := make([]string, 0, constants.CardObservationSeriesAttemptCount)
for attempt := 1; attempt <= constants.CardObservationSeriesAttemptCount; attempt++ {
keys = append(keys, constants.RedisCardObservationScheduleKey(seriesID, attempt))
}
exists, err := c.redis.Exists(ctx, keys...).Result()
if err != nil {
return false, errors.Wrap(errors.CodeInternalError, err, "读取卡观测序列业务幂等状态失败")
}
return exists == int64(constants.CardObservationSeriesAttemptCount), nil
}
// ReleaseSchedule 在入队失败时释放槽位,允许后续同场景触发补齐原序列。
func (c *SeriesCoordinator) ReleaseSchedule(ctx context.Context, seriesID string, attempt int) {
_ = c.redis.Del(ctx, constants.RedisCardObservationScheduleKey(seriesID, attempt)).Err()
}
// NewSeriesCoordinator 创建观测序列 Redis 协调器。
func NewSeriesCoordinator(redisClient *redis.Client) *SeriesCoordinator {
return &SeriesCoordinator{redis: redisClient, now: time.Now}
return &SeriesCoordinator{redis: redisClient, trafficLock: cardtrafficlock.New(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) {
func (c *SeriesCoordinator) Reserve(ctx context.Context, request cardapp.SeriesRequest, candidateSeriesID string, candidateBaseTime time.Time) (string, time.Time, cardapp.SeriesRequest, bool, error) {
if c == nil || c.redis == nil {
return "", time.Time{}, false, errors.New(errors.CodeInternalError, "卡观测序列 Redis 未配置")
return "", time.Time{}, cardapp.SeriesRequest{}, false, errors.New(errors.CodeInternalError, "卡观测序列 Redis 未配置")
}
requestJSON, err := sonic.Marshal(request)
if err != nil {
return "", time.Time{}, cardapp.SeriesRequest{}, false, errors.Wrap(errors.CodeInvalidParam, err, "序列原始请求无法保存")
}
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])
local current = redis.call('HMGET', KEYS[1], 'series_id', 'base_time_ms', 'request_json')
if current[1] then return {current[1], current[2], current[3], '1'} end
redis.call('HSET', KEYS[1], 'series_id', ARGV[1], 'base_time_ms', ARGV[2], 'request_json', ARGV[3])
redis.call('EXPIRE', KEYS[1], ARGV[4])
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, "保留卡观测序列失败")
redis.call('EXPIRE', KEYS[2], ARGV[4])
return {ARGV[1], ARGV[2], ARGV[3], '0'}
`, []string{seriesKey, indexKey}, candidateSeriesID, candidateBaseTime.UTC().UnixMilli(), string(requestJSON), int(constants.CardObservationSeriesTTL.Seconds())).StringSlice()
if err != nil || len(result) != 4 {
return "", time.Time{}, cardapp.SeriesRequest{}, 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 "", time.Time{}, cardapp.SeriesRequest{}, false, errors.Wrap(errors.CodeInternalError, parseErr, "解析卡观测序列基准时间失败")
}
return result[0], time.UnixMilli(baseTimeMS).UTC(), result[2] == "1", nil
var originalRequest cardapp.SeriesRequest
if unmarshalErr := sonic.Unmarshal([]byte(result[2]), &originalRequest); unmarshalErr != nil {
return "", time.Time{}, cardapp.SeriesRequest{}, false, errors.Wrap(errors.CodeInternalError, unmarshalErr, "解析序列原始请求失败")
}
return result[0], time.UnixMilli(baseTimeMS).UTC(), originalRequest, result[3] == "1", nil
}
// IsCompleted 判断序列是否已由预期状态或回调提前完成。
@@ -70,14 +108,14 @@ func (c *SeriesCoordinator) ClaimAttempt(ctx context.Context, seriesID string, a
// 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))
return c.acquireTrafficRequest(ctx, payload, provider, uint(cardID))
}
}
token := uuid.NewString()
lockKey := constants.RedisCardObservationInflightKey(provider, payload.SyncType, payload.ResourceID)
lastKey := constants.RedisCardObservationLastRequestKey(provider, payload.SyncType, payload.ResourceID)
nowMS := c.now().UTC().UnixMilli()
result, err := c.redis.Eval(ctx, `
@@ -105,6 +143,34 @@ return 0
return release, time.Duration(result[0]) * time.Millisecond, true, nil
}
func (c *SeriesCoordinator) acquireTrafficRequest(ctx context.Context, payload cardapp.SeriesTaskPayload, provider string, cardID uint) (func(), time.Duration, bool, error) {
token, acquired, err := c.trafficLock.Acquire(ctx, cardID)
if err != nil {
return nil, 0, false, errors.Wrap(errors.CodeInternalError, err, "获取卡流量 Gateway 请求互斥失败")
}
if !acquired {
return func() {}, 0, false, nil
}
release := func() {
_ = c.trafficLock.Release(context.Background(), cardID, token)
}
lastKey := constants.RedisCardObservationLastRequestKey(provider, payload.SyncType, payload.ResourceID)
nowMS := c.now().UTC().UnixMilli()
wait, err := c.redis.Eval(ctx, `
local now = tonumber(ARGV[1])
local minimum = tonumber(ARGV[2])
local last = tonumber(redis.call('GET', KEYS[1]) or '0')
local wait = math.max(0, last + minimum - now)
redis.call('PSETEX', KEYS[1], minimum * 3, now + wait)
return wait
`, []string{lastKey}, nowMS, constants.CardObservationGatewayMinInterval.Milliseconds()).Int64()
if err != nil {
release()
return nil, 0, false, errors.Wrap(errors.CodeInternalError, err, "获取卡流量 Gateway 最小请求间隔失败")
}
return release, time.Duration(wait) * 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 {

View File

@@ -0,0 +1,119 @@
package cardobservation
import (
"context"
"strconv"
"strings"
"github.com/bytedance/sonic"
"gorm.io/gorm"
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"
)
// SeriesEventWriter 将业务成功观测请求写入公共 Outbox。
type SeriesEventWriter struct {
outbox *outbox.Repository
}
// NewSeriesEventWriter 创建业务观测事件 Writer。
func NewSeriesEventWriter(repository *outbox.Repository) *SeriesEventWriter {
return &SeriesEventWriter{outbox: repository}
}
// AppendSeriesRequested 在原业务事务中追加稳定观测请求事件。
func (w *SeriesEventWriter) AppendSeriesRequested(ctx context.Context, tx *gorm.DB, event cardapp.SeriesRequestedEvent) error {
if w == nil || w.outbox == nil {
return errors.New(errors.CodeInternalError, "业务观测 Outbox Writer 未配置")
}
if cardapp.IsSeriesTriggerSuppressed(ctx) {
return nil
}
if strings.TrimSpace(event.EventID) == "" || event.ResourceID == 0 || len(event.SyncTypes) == 0 {
return errors.New(errors.CodeInvalidParam, "业务观测事件载荷不完整")
}
if tx == nil {
return errors.New(errors.CodeInternalError, "业务观测事件缺少原业务事务")
}
if event.ResourceType == constants.CardObservationResourceTypeDevice {
event.ResourceIDs = nil
if err := tx.WithContext(ctx).
Model(&model.DeviceSimBinding{}).
Where("device_id = ? AND bind_status = ?", event.ResourceID, constants.BindStatusBound).
Order("slot_position ASC").
Pluck("iot_card_id", &event.ResourceIDs).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "冻结业务观测设备绑定卡快照失败")
}
}
_, err := w.outbox.AppendIdempotent(ctx, tx, outbox.Envelope{
EventID: event.EventID, EventType: constants.OutboxEventTypeCardSeriesRequested,
PayloadVersion: constants.CardSeriesRequestedPayloadVersionV1,
AggregateType: event.ResourceType, AggregateID: strconv.FormatUint(uint64(event.ResourceID), 10),
ResourceType: event.ResourceType, ResourceID: strconv.FormatUint(uint64(event.ResourceID), 10),
BusinessKey: event.EventID, RequestID: event.RequestID, CorrelationID: event.CorrelationID,
Payload: event,
})
if err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "写入业务观测 Outbox 事件失败")
}
return nil
}
// SeriesRequestedConsumer 消费业务成功事实并触发卡观测序列。
type SeriesRequestedConsumer struct {
trigger *cardapp.SeriesTrigger
}
// NewSeriesRequestedConsumer 创建业务观测序列消费者。
func NewSeriesRequestedConsumer(trigger *cardapp.SeriesTrigger) *SeriesRequestedConsumer {
return &SeriesRequestedConsumer{trigger: trigger}
}
// Consume 将一个业务事件展开为卡观测序列;重复投递由稳定序列键幂等。
func (c *SeriesRequestedConsumer) Consume(ctx context.Context, envelope outbox.DeliveryEnvelope) error {
if c == nil || c.trigger == nil {
return errors.New(errors.CodeInternalError, "业务观测序列消费者未配置")
}
if envelope.EventType != constants.OutboxEventTypeCardSeriesRequested || envelope.PayloadVersion != constants.CardSeriesRequestedPayloadVersionV1 {
return errors.New(errors.CodeInvalidParam, "业务观测事件类型或版本不受支持")
}
var event cardapp.SeriesRequestedEvent
if err := sonic.Unmarshal(envelope.Payload, &event); err != nil {
return errors.Wrap(errors.CodeInvalidParam, err, "业务观测事件载荷无法解析")
}
if event.EventID != envelope.EventID || event.ResourceID == 0 || len(event.SyncTypes) == 0 {
return errors.New(errors.CodeInvalidParam, "业务观测事件载荷不完整")
}
if event.ResourceType != constants.CardObservationResourceTypeCard && event.ResourceType != constants.CardObservationResourceTypeDevice {
return errors.New(errors.CodeInvalidParam, "业务观测事件资源类型不受支持")
}
cardIDs := []uint{event.ResourceID}
if event.ResourceType == constants.CardObservationResourceTypeDevice {
cardIDs = event.ResourceIDs
}
if len(cardIDs) == 0 {
return nil
}
for _, cardID := range cardIDs {
for _, syncType := range event.SyncTypes {
request := cardapp.SeriesRequest{
Scene: event.Scene, ResourceType: constants.CardObservationResourceTypeCard,
ResourceID: strconv.FormatUint(uint64(cardID), 10), SyncType: syncType,
ExpectedValue: event.ExpectedValue, Source: event.Source,
RequestID: event.RequestID, CorrelationID: event.CorrelationID,
}
key := event.EventID + ":" + strconv.FormatUint(uint64(cardID), 10) + ":" + syncType
if _, _, err := c.trigger.TriggerEvent(ctx, key, request); err != nil {
return err
}
}
}
return nil
}
var _ cardapp.SeriesEventWriter = (*SeriesEventWriter)(nil)
var _ outbox.EventConsumer = (*SeriesRequestedConsumer)(nil)

View File

@@ -37,14 +37,20 @@ func (l *SeriesAttemptLogger) Record(ctx context.Context, payload cardapp.Series
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{
attempt, 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,
Attempt: payload.Attempt, InitialResult: initialResult(result), RequestID: requestID, CorrelationID: correlationID,
Metadata: map[string]any{"reason": reason, "sync_type": payload.SyncType},
})
if err != nil || result != constants.IntegrationResultFailed {
return err
}
_, err = l.repository.Complete(ctx, attempt.IntegrationID, integrationlog.Completion{
Result: constants.IntegrationResultFailed, ResponseSummary: map[string]any{"reason": reason},
})
return err
}
@@ -80,6 +86,12 @@ func NewSeriesRunner(db *gorm.DB, gatewayClient *gateway.Client, observation *ca
// Provider 返回用于请求互斥的运营商接入标识。
func (r *SeriesRunner) Provider(ctx context.Context, payload cardapp.SeriesTaskPayload) (string, error) {
if payload.ResourceType == constants.CardObservationResourceTypeDevice {
if _, err := r.loadDevice(ctx, payload); err != nil {
return "", err
}
return constants.CardObservationResourceTypeDevice, nil
}
card, err := r.loadCard(ctx, payload)
if err != nil {
return "", err
@@ -97,6 +109,18 @@ func (r *SeriesRunner) ExpectationMet(ctx context.Context, payload cardapp.Serie
if expected == "" {
return false, nil
}
if payload.ResourceType == constants.CardObservationResourceTypeDevice && payload.SyncType == constants.CardObservationSyncTypeDeviceInfo {
currentICCIDs, err := r.loadCurrentICCIDs(ctx, payload)
if err != nil {
return false, err
}
for _, currentICCID := range currentICCIDs {
if strings.EqualFold(expected, strings.TrimSpace(currentICCID)) {
return true, nil
}
}
return false, nil
}
card, err := r.loadCard(ctx, payload)
if err != nil {
return false, err
@@ -120,6 +144,9 @@ func (r *SeriesRunner) Run(ctx context.Context, payload cardapp.SeriesTaskPayloa
if r == nil || r.db == nil || r.gateway == nil || r.observation == nil || r.integration == nil {
return cardapp.RunResult{}, apperrors.New(apperrors.CodeInternalError, "卡观测序列 Gateway 能力未完整配置")
}
if payload.ResourceType == constants.CardObservationResourceTypeDevice && payload.SyncType == constants.CardObservationSyncTypeDeviceInfo {
return r.runDeviceInfo(ctx, payload)
}
card, err := r.loadCard(ctx, payload)
if err != nil {
return cardapp.RunResult{}, err
@@ -148,6 +175,178 @@ func (r *SeriesRunner) Run(ctx context.Context, payload cardapp.SeriesTaskPayloa
return result, runErr
}
func (r *SeriesRunner) runDeviceInfo(ctx context.Context, payload cardapp.SeriesTaskPayload) (cardapp.RunResult, error) {
device, err := r.loadDevice(ctx, payload)
if err != nil {
return cardapp.RunResult{}, err
}
attempt, err := r.startDeviceAttempt(ctx, payload, device)
if err != nil {
return cardapp.RunResult{}, err
}
startedAt := time.Now()
response, runErr := r.gateway.SyncDeviceInfo(ctx, &gateway.SyncDeviceInfoReq{CardNo: deviceGatewayIdentifier(device)})
result := cardapp.RunResult{}
if runErr == nil {
result.StateChanged, runErr = r.applyDeviceInfo(ctx, device, response)
}
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) startDeviceAttempt(ctx context.Context, payload cardapp.SeriesTaskPayload, device *model.Device) (*model.IntegrationLog, error) {
resourceID, source, scene, seriesID := payload.ResourceID, payload.Source, payload.Scene, payload.SeriesID
resourceKey := "device:" + strconv.FormatUint(uint64(device.ID), 10)
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{"device_id": device.ID, "sync_type": payload.SyncType},
RequestID: optionalText(payload.RequestID), CorrelationID: optionalText(payload.CorrelationID),
})
}
func (r *SeriesRunner) applyDeviceInfo(ctx context.Context, device *model.Device, response *gateway.SyncDeviceInfoResp) (bool, error) {
if response == nil {
return false, apperrors.New(apperrors.CodeGatewayError, "Gateway 设备信息响应为空")
}
now := time.Now()
updates := map[string]any{
"online_status": int(response.OnlineStatus),
"software_version": string(response.SoftwareVersion),
"switch_mode": string(response.SwitchMode),
"last_gateway_sync_at": now,
}
if lastOnlineTime := parseGatewayTime(response.LastOnlineTime); lastOnlineTime != nil {
updates["last_online_time"] = lastOnlineTime
}
var currentBinding struct {
SlotPosition int
}
currentSlotErr := r.db.WithContext(ctx).Model(&model.DeviceSimBinding{}).
Select("slot_position").
Where("device_id = ? AND bind_status = ? AND is_current = ?", device.ID, constants.BindStatusBound, true).
Take(&currentBinding).Error
if currentSlotErr != nil && currentSlotErr != gorm.ErrRecordNotFound {
return false, apperrors.Wrap(apperrors.CodeDatabaseError, currentSlotErr, "读取设备当前槽位失败")
}
changed := device.OnlineStatus != int(response.OnlineStatus) ||
device.SoftwareVersion != string(response.SoftwareVersion) ||
device.SwitchMode != string(response.SwitchMode) ||
currentBinding.SlotPosition != int(response.CurrentSlotNo)
err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Model(&model.Device{}).Where("id = ?", device.ID).Updates(updates).Error; err != nil {
return err
}
if err := tx.Model(&model.DeviceSimBinding{}).
Where("device_id = ? AND bind_status = ?", device.ID, constants.BindStatusBound).
Update("is_current", false).Error; err != nil {
return err
}
if int(response.CurrentSlotNo) <= 0 {
return nil
}
return tx.Model(&model.DeviceSimBinding{}).
Where("device_id = ? AND slot_position = ? AND bind_status = ?", device.ID, int(response.CurrentSlotNo), constants.BindStatusBound).
Update("is_current", true).Error
})
if err != nil {
return false, apperrors.Wrap(apperrors.CodeDatabaseError, err, "回写设备 Gateway 信息失败")
}
return changed, nil
}
func (r *SeriesRunner) loadDevice(ctx context.Context, payload cardapp.SeriesTaskPayload) (*model.Device, error) {
if payload.ResourceType != constants.CardObservationResourceTypeDevice {
return nil, apperrors.New(apperrors.CodeInvalidParam, "设备信息观测资源类型无效")
}
deviceID, err := strconv.ParseUint(payload.ResourceID, 10, 64)
if err != nil || deviceID == 0 {
return nil, apperrors.New(apperrors.CodeInvalidParam, "设备观测资源 ID 无效")
}
var device model.Device
if err := r.db.WithContext(ctx).Where("id = ?", uint(deviceID)).First(&device).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return nil, apperrors.New(apperrors.CodeNotFound, "设备不存在")
}
return nil, apperrors.Wrap(apperrors.CodeDatabaseError, err, "查询设备观测本地快照失败")
}
if deviceGatewayIdentifier(&device) == "" {
return nil, apperrors.New(apperrors.CodeInvalidParam, "设备缺少 Gateway 标识")
}
return &device, nil
}
func (r *SeriesRunner) loadCurrentICCIDs(ctx context.Context, payload cardapp.SeriesTaskPayload) ([]string, error) {
device, err := r.loadDevice(ctx, payload)
if err != nil {
return nil, err
}
var current struct {
ICCID string
ICCID19 string
ICCID20 *string
}
err = r.db.WithContext(ctx).Table("tb_device_sim_binding AS binding").
Select("card.iccid, card.iccid_19, card.iccid_20").
Joins("JOIN tb_iot_card AS card ON card.id = binding.iot_card_id AND card.deleted_at IS NULL").
Where("binding.device_id = ? AND binding.bind_status = ? AND binding.is_current = ? AND binding.deleted_at IS NULL", device.ID, constants.BindStatusBound, true).
Take(&current).Error
if err == gorm.ErrRecordNotFound {
return nil, nil
}
if err != nil {
return nil, apperrors.Wrap(apperrors.CodeDatabaseError, err, "查询设备当前卡失败")
}
iccids := []string{current.ICCID, current.ICCID19}
if current.ICCID20 != nil {
iccids = append(iccids, *current.ICCID20)
}
return iccids, nil
}
func deviceGatewayIdentifier(device *model.Device) string {
if strings.TrimSpace(device.IMEI) != "" {
return strings.TrimSpace(device.IMEI)
}
return strings.TrimSpace(device.SN)
}
func parseGatewayTime(raw gateway.FlexString) *time.Time {
value := strings.TrimSpace(string(raw))
if value == "" {
return nil
}
if parsed, err := time.Parse(time.RFC3339, value); err == nil {
return &parsed
}
timestamp, err := strconv.ParseInt(value, 10, 64)
if err != nil {
return nil
}
if timestamp > 1_000_000_000_000 {
timestamp /= 1000
}
parsed := time.Unix(timestamp, 0)
return &parsed
}
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)
@@ -235,7 +434,7 @@ func operationForSyncType(syncType string) string {
case constants.CardObservationSyncTypeNetwork:
return constants.IntegrationOperationGatewayNetwork
default:
return "query_device_info"
return constants.IntegrationOperationGatewayDeviceInfo
}
}
@@ -255,5 +454,12 @@ func optionalText(value string) *string {
return &value
}
func initialResult(result string) string {
if result == constants.IntegrationResultFailed {
return constants.IntegrationResultPending
}
return result
}
var _ cardapp.SeriesAttemptLogger = (*SeriesAttemptLogger)(nil)
var _ cardapp.SeriesRunner = (*SeriesRunner)(nil)