收口七月卡状态回调与系列授权兼容契约
Some checks failed
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Has been cancelled
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:
221
internal/infrastructure/cardobservation/best_effort.go
Normal file
221
internal/infrastructure/cardobservation/best_effort.go
Normal 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)
|
||||
@@ -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, "卡流量事件类型或版本不受支持")
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
119
internal/infrastructure/cardobservation/series_event.go
Normal file
119
internal/infrastructure/cardobservation/series_event.go
Normal 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)
|
||||
@@ -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(¤tBinding).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(¤t).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)
|
||||
|
||||
49
internal/infrastructure/cardtrafficlock/lock.go
Normal file
49
internal/infrastructure/cardtrafficlock/lock.go
Normal 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()
|
||||
}
|
||||
67
internal/infrastructure/carriercallback/cmcc_realname.go
Normal file
67
internal/infrastructure/carriercallback/cmcc_realname.go
Normal 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
|
||||
}
|
||||
107
internal/infrastructure/carriercallback/ctcc_realname.go
Normal file
107
internal/infrastructure/carriercallback/ctcc_realname.go
Normal 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
|
||||
}
|
||||
22
internal/infrastructure/carriercallback/cucc_realname.go
Normal file
22
internal/infrastructure/carriercallback/cucc_realname.go
Normal 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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user