收口七月卡状态回调与系列授权兼容契约
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)

View File

@@ -0,0 +1,49 @@
// Package cardtrafficlock 提供卡流量上游请求的统一 Redis 互斥。
package cardtrafficlock
import (
"context"
"github.com/google/uuid"
"github.com/redis/go-redis/v9"
"github.com/break/junhong_cmp_fiber/pkg/constants"
)
var releaseScript = redis.NewScript(`
if redis.call('GET', KEYS[1]) == ARGV[1] then
return redis.call('DEL', KEYS[1])
end
return 0
`)
// Lock 统一卡流量上游请求互斥,防止过期持有者误删新锁。
type Lock struct {
redis *redis.Client
}
// New 创建卡流量请求锁。
func New(redisClient *redis.Client) *Lock {
return &Lock{redis: redisClient}
}
// Acquire 使用随机令牌获取至少覆盖完整 Gateway 重试窗口的卡级锁。
func (l *Lock) Acquire(ctx context.Context, cardID uint) (string, bool, error) {
if l == nil || l.redis == nil {
return "", true, nil
}
token := uuid.NewString()
acquired, err := l.redis.SetNX(ctx, constants.RedisCardTrafficSyncLockKey(cardID), token, constants.CardObservationGatewayLockTTL).Result()
if err != nil || !acquired {
return "", acquired, err
}
return token, true, nil
}
// Release 仅由仍持有相同随机令牌的调用方释放卡级锁。
func (l *Lock) Release(ctx context.Context, cardID uint, token string) error {
if l == nil || l.redis == nil || token == "" {
return nil
}
return releaseScript.Run(ctx, l.redis, []string{constants.RedisCardTrafficSyncLockKey(cardID)}, token).Err()
}

View File

@@ -0,0 +1,67 @@
package carriercallback
import (
"bytes"
"strings"
"github.com/bytedance/sonic"
"gorm.io/gorm"
"github.com/break/junhong_cmp_fiber/pkg/errors"
pkgvalidator "github.com/break/junhong_cmp_fiber/pkg/validator"
)
// CMCCRealnameTranslation 是移动实名 JSON 回调的受控语义。
type CMCCRealnameTranslation struct {
ICCID string
BusiSeq string
}
// CMCCRealnameTranslator 解析移动实名成功回调的固定 JSON 结构。
type CMCCRealnameTranslator struct{}
// CMCCCardResolver 复用系统级 ICCID 精确查询约束。
type CMCCCardResolver = CTCCCardResolver
// NewCMCCCardResolver 创建移动回调卡定位器。
func NewCMCCCardResolver(db *gorm.DB) *CMCCCardResolver {
return NewCTCCCardResolver(db)
}
// NewCMCCRealnameTranslator 创建移动实名回调 Translator。
func NewCMCCRealnameTranslator() *CMCCRealnameTranslator {
return &CMCCRealnameTranslator{}
}
type cmccRealnamePayload struct {
Status string `json:"status"`
Message string `json:"message"`
Result []struct {
RegStatus string `json:"regStatus"`
BusiSeq string `json:"busiSeq"`
ICCID string `json:"iccid"`
} `json:"result"`
}
// Translate 只接受 status=0、message=正确、首条结果实名成功且 ICCID 合法。
func (t *CMCCRealnameTranslator) Translate(body []byte) (CMCCRealnameTranslation, error) {
var result CMCCRealnameTranslation
if len(bytes.TrimSpace(body)) == 0 {
return result, errors.New(errors.CodeInvalidParam, "移动实名回调报文为空")
}
var payload cmccRealnamePayload
if err := sonic.Unmarshal(body, &payload); err != nil {
return result, errors.Wrap(errors.CodeInvalidParam, err, "移动实名回调 JSON 解析失败")
}
if len(payload.Result) > 0 {
result.BusiSeq = strings.TrimSpace(payload.Result[0].BusiSeq)
result.ICCID = strings.TrimSpace(payload.Result[0].ICCID)
}
if payload.Status != "0" || payload.Message != "正确" || len(payload.Result) == 0 || payload.Result[0].RegStatus != "00000" {
return result, errors.New(errors.CodeInvalidParam, "移动实名回调业务结果无效")
}
if validation := pkgvalidator.ValidateICCIDWithoutCarrier(result.ICCID); !validation.Valid {
return result, errors.New(errors.CodeInvalidParam, "移动实名回调 ICCID 无效")
}
return result, nil
}

View File

@@ -0,0 +1,107 @@
// Package carriercallback 提供运营商回调协议到内部观测模型的适配能力。
package carriercallback
import (
"bytes"
"context"
"encoding/xml"
"strings"
"gorm.io/gorm"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/errors"
pkgvalidator "github.com/break/junhong_cmp_fiber/pkg/validator"
)
const (
// CTCCRealnameActionVerified 表示电信实名补录已完成。
CTCCRealnameActionVerified = "verified"
// CTCCRealnameActionIgnored 表示报文合法但无需改变实名事实。
CTCCRealnameActionIgnored = "ignored"
)
// CTCCRealnameTranslation 是电信 XML 回调的受控内部语义。
type CTCCRealnameTranslation struct {
ICCID string
AcceptMessage string
ResultMessage string
GroupTransactionID string
Action string
}
type ctccContractRoot struct {
XMLName xml.Name `xml:"ContractRoot"`
ICCID string `xml:"ICCID"`
AcceptMessage string `xml:"ACCEPTMSG"`
ResultMessage string `xml:"RESULTMSG"`
GroupTransactionID string `xml:"GROUP_TRANSACTIONID"`
}
// CTCCRealnameTranslator 解析已知电信 XML 协议字段。
type CTCCRealnameTranslator struct{}
// NewCTCCRealnameTranslator 创建电信实名回调 Translator。
func NewCTCCRealnameTranslator() *CTCCRealnameTranslator {
return &CTCCRealnameTranslator{}
}
// Translate 将 XML 转为受控语义;未知业务结果保留为 ignored。
func (t *CTCCRealnameTranslator) Translate(body []byte) (CTCCRealnameTranslation, error) {
var result CTCCRealnameTranslation
if len(bytes.TrimSpace(body)) == 0 {
return result, errors.New(errors.CodeInvalidParam, "电信实名回调报文为空")
}
var payload ctccContractRoot
if err := xml.Unmarshal(body, &payload); err != nil {
return result, errors.Wrap(errors.CodeInvalidParam, err, "电信实名回调 XML 解析失败")
}
result.ICCID = strings.TrimSpace(payload.ICCID)
result.AcceptMessage = strings.TrimSpace(payload.AcceptMessage)
result.ResultMessage = strings.TrimSpace(payload.ResultMessage)
result.GroupTransactionID = strings.TrimSpace(payload.GroupTransactionID)
if err := t.validateICCID(result.ICCID); err != nil {
return result, err
}
result.Action = CTCCRealnameActionIgnored
if result.ResultMessage == "成功" && strings.Contains(result.AcceptMessage, "已完成实名信息补录") {
result.Action = CTCCRealnameActionVerified
}
return result, nil
}
func (t *CTCCRealnameTranslator) validateICCID(iccid string) error {
if result := pkgvalidator.ValidateICCIDWithoutCarrier(iccid); !result.Valid {
return errors.New(errors.CodeInvalidParam, "电信实名回调 ICCID 无效")
}
return nil
}
// CTCCCardResolver 使用系统级精确列查询定位电信回调卡。
type CTCCCardResolver struct {
db *gorm.DB
}
// NewCTCCCardResolver 创建电信回调卡定位器。
func NewCTCCCardResolver(db *gorm.DB) *CTCCCardResolver {
return &CTCCCardResolver{db: db}
}
// Resolve 按 ICCID 长度选择唯一列,禁止跨列降级。
func (r *CTCCCardResolver) Resolve(ctx context.Context, iccid string) ([]*model.IotCard, error) {
if r == nil || r.db == nil {
return nil, errors.New(errors.CodeInternalError, "电信实名回调卡查询能力未配置")
}
column := "iccid_19"
if len(iccid) == 20 {
column = "iccid_20"
}
var cards []*model.IotCard
if err := r.db.WithContext(ctx).
Where(column+" = ? AND deleted_at IS NULL", iccid).
Limit(2).
Find(&cards).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询电信实名回调卡失败")
}
return cards, nil
}

View File

@@ -0,0 +1,22 @@
package carriercallback
// CUCCRealnameTranslation 是联通实名成功回调的受控语义。
type CUCCRealnameTranslation = CUCCRealnameRemovalTranslation
// CUCCRealnameTranslator 解析联通外层 data 字符串及其内层实名 JSON。
type CUCCRealnameTranslator struct {
parser *CUCCRealnameRemovalTranslator
}
// NewCUCCRealnameTranslator 创建联通实名成功回调 Translator。
func NewCUCCRealnameTranslator() *CUCCRealnameTranslator {
return &CUCCRealnameTranslator{parser: NewCUCCRealnameRemovalTranslator()}
}
// Translate 解析联通实名成功报文;协议结构与解除实名报文一致。
func (t *CUCCRealnameTranslator) Translate(body []byte) (CUCCRealnameTranslation, error) {
if t == nil || t.parser == nil {
return NewCUCCRealnameRemovalTranslator().Translate(body)
}
return t.parser.Translate(body)
}

View File

@@ -0,0 +1,67 @@
package carriercallback
import (
"bytes"
"strings"
"github.com/bytedance/sonic"
"gorm.io/gorm"
"github.com/break/junhong_cmp_fiber/pkg/errors"
pkgvalidator "github.com/break/junhong_cmp_fiber/pkg/validator"
)
// CUCCRealnameRemovalTranslation 是联通解除实名回调的受控语义。
type CUCCRealnameRemovalTranslation struct {
ICCID string
DateChanged string
}
// CUCCRealnameRemovalTranslator 解析外层 data 字符串及其内层 JSON。
type CUCCRealnameRemovalTranslator struct{}
// NewCUCCRealnameRemovalTranslator 创建联通解除实名 Translator。
func NewCUCCRealnameRemovalTranslator() *CUCCRealnameRemovalTranslator {
return &CUCCRealnameRemovalTranslator{}
}
// Translate 解析并校验联通解除实名报文,不执行任何业务写入。
func (t *CUCCRealnameRemovalTranslator) Translate(body []byte) (CUCCRealnameRemovalTranslation, error) {
var result CUCCRealnameRemovalTranslation
if len(bytes.TrimSpace(body)) == 0 {
return result, errors.New(errors.CodeInvalidParam, "联通解除实名回调报文为空")
}
var outer struct {
Data string `json:"data"`
}
if err := sonic.Unmarshal(body, &outer); err != nil {
return result, errors.Wrap(errors.CodeInvalidParam, err, "联通解除实名外层 JSON 解析失败")
}
if strings.TrimSpace(outer.Data) == "" {
return result, errors.New(errors.CodeInvalidParam, "联通解除实名回调 data 为空")
}
var inner struct {
ICCID string `json:"iccid"`
DateChanged string `json:"dateChanged"`
}
if err := sonic.Unmarshal([]byte(outer.Data), &inner); err != nil {
return result, errors.Wrap(errors.CodeInvalidParam, err, "联通解除实名内层 JSON 解析失败")
}
result.ICCID = strings.TrimSpace(inner.ICCID)
result.DateChanged = strings.TrimSpace(inner.DateChanged)
if validation := pkgvalidator.ValidateICCIDWithoutCarrier(result.ICCID); !validation.Valid {
return result, errors.New(errors.CodeInvalidParam, "联通解除实名回调 ICCID 无效")
}
if result.DateChanged == "" {
return result, errors.New(errors.CodeInvalidParam, "联通解除实名回调变更时间为空")
}
return result, nil
}
// CUCCCardResolver 复用系统级 ICCID 精确查询约束。
type CUCCCardResolver = CTCCCardResolver
// NewCUCCCardResolver 创建联通回调卡定位器。
func NewCUCCCardResolver(db *gorm.DB) *CUCCCardResolver {
return NewCTCCCardResolver(db)
}

View File

@@ -187,7 +187,7 @@ func (r *Repository) RecordInbound(ctx context.Context, input InboundAttempt) (*
if r == nil || r.db == nil {
return nil, false, pkgerrors.New(pkgerrors.CodeInvalidStatus, "Integration Log 数据库未配置")
}
if input.Provider == "" || input.Operation == "" || input.IdempotencyKey == "" || len(input.RawPayload) == 0 {
if input.Provider == "" || input.Operation == "" || input.IdempotencyKey == "" {
return nil, false, pkgerrors.New(pkgerrors.CodeInvalidParam, "入站 Integration Log 参数无效")
}
if input.IntegrationID == "" {
@@ -233,6 +233,29 @@ func (r *Repository) RecordInbound(ctx context.Context, input InboundAttempt) (*
return &existing, false, nil
}
// ClaimExpiredInboundPending 原子认领已超过处理租约的入站 pending 记录。
func (r *Repository) ClaimExpiredInboundPending(ctx context.Context, integrationID string, lease time.Duration) (bool, error) {
if r == nil || r.db == nil {
return false, pkgerrors.New(pkgerrors.CodeInvalidStatus, "Integration Log 数据库未配置")
}
if strings.TrimSpace(integrationID) == "" || lease <= 0 {
return false, pkgerrors.New(pkgerrors.CodeInvalidParam, "入站 Integration Log 恢复参数无效")
}
now := r.now().UTC()
result := r.db.WithContext(ctx).Model(&model.IntegrationLog{}).
Where("integration_id = ? AND direction = ? AND result = ? AND (started_at IS NULL OR started_at <= ?)",
integrationID, constants.IntegrationDirectionInbound, constants.IntegrationResultPending, now.Add(-lease)).
Updates(map[string]any{
"started_at": now,
"attempt": gorm.Expr("attempt + 1"),
"updated_at": now,
})
if result.Error != nil {
return false, pkgerrors.Wrap(pkgerrors.CodeDatabaseError, result.Error, "认领待恢复入站 Integration Log 失败")
}
return result.RowsAffected == 1, nil
}
func validateAttempt(input Attempt) error {
if input.Provider == "" || input.Operation == "" {
return pkgerrors.New(pkgerrors.CodeInvalidParam, "Integration Log 提供方和操作不能为空")
@@ -250,6 +273,7 @@ func isTerminalResult(result string) bool {
switch result {
case constants.IntegrationResultSuccess, constants.IntegrationResultFailed, constants.IntegrationResultUnknown,
constants.IntegrationResultNotFound, constants.IntegrationResultInvalidPayload, constants.IntegrationResultIgnored,
constants.IntegrationResultConflict,
constants.IntegrationResultMerged, constants.IntegrationResultRateLimited, constants.IntegrationResultCompleted,
constants.IntegrationResultCancelled:
return true

View File

@@ -11,6 +11,7 @@ import (
"github.com/google/uuid"
"gorm.io/datatypes"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/asynctask"
@@ -42,6 +43,15 @@ func NewRepository() *Repository {
// Append 在业务事务中持久化稳定事件身份和必要快照。
func (r *Repository) Append(ctx context.Context, tx *gorm.DB, envelope Envelope) (*model.OutboxEvent, error) {
return r.append(ctx, tx, envelope, false)
}
// AppendIdempotent 在稳定 EventID 重投时保持原事件,不重复创建事实。
func (r *Repository) AppendIdempotent(ctx context.Context, tx *gorm.DB, envelope Envelope) (*model.OutboxEvent, error) {
return r.append(ctx, tx, envelope, true)
}
func (r *Repository) append(ctx context.Context, tx *gorm.DB, envelope Envelope, idempotent bool) (*model.OutboxEvent, error) {
if tx == nil {
return nil, stderrors.New("Outbox 追加必须传入 GORM 事务句柄")
}
@@ -71,7 +81,11 @@ func (r *Repository) Append(ctx context.Context, tx *gorm.DB, envelope Envelope)
RequestID: envelope.RequestID, CorrelationID: envelope.CorrelationID, Payload: datatypes.JSON(payload),
Status: constants.OutboxStatusPending, MaxRetries: constants.OutboxDefaultMaxRetries, NextAttemptAt: now,
}
if err := tx.WithContext(ctx).Create(event).Error; err != nil {
create := tx.WithContext(ctx)
if idempotent {
create = create.Clauses(clause.OnConflict{Columns: []clause.Column{{Name: "event_id"}}, DoNothing: true})
}
if err := create.Create(event).Error; err != nil {
return nil, err
}
return event, nil