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

@@ -2,8 +2,12 @@ package iot_card
import (
"context"
"strconv"
cardapp "github.com/break/junhong_cmp_fiber/internal/application/cardobservation"
"github.com/break/junhong_cmp_fiber/internal/gateway"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"go.uber.org/zap"
)
@@ -50,11 +54,35 @@ func (s *Service) GatewayQueryRealnameStatus(ctx context.Context, iccid string)
// GatewayGetRealnameLink 获取实名认证跳转链接
func (s *Service) GatewayGetRealnameLink(ctx context.Context, iccid string) (*gateway.RealnameLinkResp, error) {
if err := s.validateCardAccess(ctx, iccid); err != nil {
card, err := s.iotCardStore.GetByICCID(ctx, iccid)
if err != nil {
return nil, errors.New(errors.CodeNotFound, "卡不存在或无权限访问")
}
response, err := s.gatewayClient.GetRealnameLink(ctx, &gateway.CardStatusReq{
CardNo: iccid,
})
if err != nil {
return nil, err
}
return s.gatewayClient.GetRealnameLink(ctx, &gateway.CardStatusReq{
CardNo: iccid,
s.dispatchAdminRealnameObservation(ctx, card)
return response, nil
}
func (s *Service) dispatchAdminRealnameObservation(ctx context.Context, card *model.IotCard) {
if s.observationSeries == nil || card == nil {
return
}
requestID := requestIDFromContext(ctx)
s.observationSeries.DispatchRealnameWithCapability(ctx, cardapp.RealnameCapabilitySeriesRequest{
CarrierID: card.CarrierID,
Request: cardapp.SeriesRequest{
Scene: constants.CardObservationSceneAdminRealnameLink,
ResourceType: constants.CardObservationResourceTypeCard,
ResourceID: strconv.FormatUint(uint64(card.ID), 10),
SyncType: constants.CardObservationSyncTypeRealname,
ExpectedValue: "verified", Source: constants.CardObservationSourceBusinessEvent,
RequestID: requestID, CorrelationID: requestID,
},
})
}

View File

@@ -9,6 +9,7 @@ import (
cardapp "github.com/break/junhong_cmp_fiber/internal/application/cardobservation"
carddomain "github.com/break/junhong_cmp_fiber/internal/domain/cardobservation"
"github.com/break/junhong_cmp_fiber/internal/gateway"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/cardtrafficlock"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
packageexpiry "github.com/break/junhong_cmp_fiber/internal/query/packageexpiry"
@@ -24,8 +25,6 @@ import (
"gorm.io/gorm"
)
const cardTrafficSyncLockTTL = 10 * time.Minute
// PollingCallback 轮询回调接口
// 用于在卡生命周期事件发生时通知轮询调度器
type PollingCallback interface {
@@ -71,6 +70,13 @@ type Service struct {
enterpriseStore *postgres.EnterpriseStore
packageExpiryQuery *packageexpiry.Query
cardObservation *cardapp.Service
observationSeries cardapp.BestEffortSeriesDispatcher
trafficLock *cardtrafficlock.Lock
}
// SetObservationSeriesDispatcher 注入获取实名链接后的后台观测端口。
func (s *Service) SetObservationSeriesDispatcher(dispatcher cardapp.BestEffortSeriesDispatcher) {
s.observationSeries = dispatcher
}
// SetCardObservationService 注入统一卡实名观测写入用例。
@@ -152,22 +158,17 @@ func (s *Service) SetDeviceSimBindingStore(deviceSimBindingStore *postgres.Devic
// 用于清理实名逆转计数器,避免历史计数干扰手动实名后的判定
func (s *Service) SetRedisClient(redisClient *redis.Client) {
s.redis = redisClient
s.trafficLock = cardtrafficlock.New(redisClient)
}
// acquireCardTrafficSyncLock 获取卡流量同步锁,避免主动刷新与轮询重复统计同一上游读数。
func (s *Service) acquireCardTrafficSyncLock(ctx context.Context, cardID uint) (bool, error) {
if s.redis == nil {
return true, nil
}
return s.redis.SetNX(ctx, constants.RedisCardTrafficSyncLockKey(cardID), "1", cardTrafficSyncLockTTL).Result()
func (s *Service) acquireCardTrafficSyncLock(ctx context.Context, cardID uint) (string, bool, error) {
return s.trafficLock.Acquire(ctx, cardID)
}
// releaseCardTrafficSyncLock 释放卡流量同步锁。
func (s *Service) releaseCardTrafficSyncLock(ctx context.Context, cardID uint) {
if s.redis == nil {
return
}
if err := s.redis.Del(ctx, constants.RedisCardTrafficSyncLockKey(cardID)).Err(); err != nil {
func (s *Service) releaseCardTrafficSyncLock(ctx context.Context, cardID uint, token string) {
if err := s.trafficLock.Release(ctx, cardID, token); err != nil {
s.logger.Warn("释放卡流量同步锁失败", zap.Uint("card_id", cardID), zap.Error(err))
}
}
@@ -1531,13 +1532,13 @@ func (s *Service) RefreshCardDataFromGateway(ctx context.Context, iccid string)
return err
}
locked, lockErr := s.acquireCardTrafficSyncLock(ctx, card.ID)
lockToken, locked, lockErr := s.acquireCardTrafficSyncLock(ctx, card.ID)
if lockErr != nil {
return errors.Wrap(errors.CodeInternalError, lockErr, "获取卡流量同步锁失败")
} else if !locked {
return errors.New(errors.CodeTooManyRequests, "卡流量正在同步,请稍后重试")
} else {
defer s.releaseCardTrafficSyncLock(ctx, card.ID)
defer s.releaseCardTrafficSyncLock(ctx, card.ID, lockToken)
latestCard, loadErr := s.iotCardStore.GetByID(ctx, card.ID)
if loadErr != nil {
return errors.Wrap(errors.CodeInternalError, loadErr, "刷新卡数据失败")

View File

@@ -6,6 +6,8 @@ import (
"strings"
"time"
cardObservationApp "github.com/break/junhong_cmp_fiber/internal/application/cardobservation"
"github.com/google/uuid"
"github.com/redis/go-redis/v9"
"go.uber.org/zap"
"gorm.io/gorm"
@@ -31,19 +33,27 @@ var _ StopResumeServiceInterface = (*StopResumeService)(nil)
// StopResumeService 停复机服务
// 处理 IoT 卡的自动停机、复机和手动停复机逻辑
type StopResumeService struct {
redis *redis.Client
iotCardStore *postgres.IotCardStore
packageUsageStore *postgres.PackageUsageStore
deviceSimBindingStore *postgres.DeviceSimBindingStore
gatewayClient *gateway.Client
logger *zap.Logger
assetAuditService AssetAuditService
pollingCallback PollingCallback
db *gorm.DB
redis *redis.Client
iotCardStore *postgres.IotCardStore
packageUsageStore *postgres.PackageUsageStore
deviceSimBindingStore *postgres.DeviceSimBindingStore
gatewayClient *gateway.Client
logger *zap.Logger
assetAuditService AssetAuditService
pollingCallback PollingCallback
observationSeriesEvents cardObservationApp.SeriesEventWriter
maxRetries int
retryInterval time.Duration
}
// SetObservationSeriesEventWriter 注入停复机成功观测序列 Outbox Writer。
func (s *StopResumeService) SetObservationSeriesEventWriter(db *gorm.DB, writer cardObservationApp.SeriesEventWriter) {
s.db = db
s.observationSeriesEvents = writer
}
// NewStopResumeService 创建停复机服务
func NewStopResumeService(
redis *redis.Client,
@@ -96,8 +106,7 @@ func (s *StopResumeService) EvaluateAndAct(ctx context.Context, card *model.IotC
zap.Uint("card_id", card.ID), zap.Error(lookupErr))
// 降级:查不到绑定时按单卡处理
} else if bound {
s.stopDeviceCards(ctx, deviceID, primaryReason)
return nil
return s.stopDeviceCards(ctx, deviceID, primaryReason)
}
}
return s.stopCardWithRetry(ctx, card, primaryReason)
@@ -121,8 +130,7 @@ func (s *StopResumeService) EvaluateAndAct(ctx context.Context, card *model.IotC
s.logger.Error("查询设备绑定关系失败,降级为单卡复机",
zap.Uint("card_id", card.ID), zap.Error(lookupErr))
} else if bound {
s.resumeDeviceCards(ctx, deviceID)
return nil
return s.resumeDeviceCards(ctx, deviceID)
}
}
return s.resumeSingleCard(ctx, card.ID)
@@ -291,14 +299,17 @@ func (s *StopResumeService) shouldResume(ctx context.Context, card *model.IotCar
// stopDeviceCards 停机设备下所有在线卡(含设备维度幂等锁)
// 防止设备下多张卡并发触发 EvaluateAndAct 导致重复 Gateway 停机调用
func (s *StopResumeService) stopDeviceCards(ctx context.Context, deviceID uint, stopReason string) {
func (s *StopResumeService) stopDeviceCards(ctx context.Context, deviceID uint, stopReason string) error {
// 设备维度幂等锁:防止多协程同时对同一设备停机
lockKey := constants.RedisPollingDeviceOpLockKey(deviceID)
locked, _ := s.redis.SetNX(ctx, lockKey, "1", 30*time.Second).Result()
locked, lockErr := s.redis.SetNX(ctx, lockKey, "1", 30*time.Second).Result()
if lockErr != nil {
return errors.Wrap(errors.CodeRedisError, lockErr, "获取设备停机锁失败")
}
if !locked {
s.logger.Debug("设备停机操作已在进行中,跳过重复调用",
zap.Uint("device_id", deviceID))
return
return nil
}
defer s.redis.Del(ctx, lockKey)
@@ -307,29 +318,38 @@ func (s *StopResumeService) stopDeviceCards(ctx context.Context, deviceID uint,
if err != nil {
s.logger.Error("查询设备在线卡失败",
zap.Uint("device_id", deviceID), zap.Error(err))
return
return errors.Wrap(errors.CodeDatabaseError, err, "查询设备在线卡失败")
}
var cardErrors []error
for _, card := range cards {
if stopErr := s.stopCardWithRetry(ctx, card, stopReason); stopErr != nil {
cardErrors = append(cardErrors, stopErr)
s.logger.Warn("设备卡停机失败,继续处理其他卡",
zap.Uint("device_id", deviceID),
zap.Uint("card_id", card.ID),
zap.Error(stopErr))
}
}
if len(cardErrors) > 0 {
return errors.Wrap(errors.CodeInternalError, stderrors.Join(cardErrors...), "设备停机存在未成功处理的卡")
}
return nil
}
// resumeDeviceCards 复机设备下满足条件的停机卡(含设备维度幂等锁)
// 遍历时对每张卡检查实名状态:未实名普通卡更新 stop_reason='not_realname' 后跳过
func (s *StopResumeService) resumeDeviceCards(ctx context.Context, deviceID uint) {
func (s *StopResumeService) resumeDeviceCards(ctx context.Context, deviceID uint) error {
// 设备维度幂等锁:与 stopDeviceCards 共用,防止停/复机并发
lockKey := constants.RedisPollingDeviceOpLockKey(deviceID)
locked, _ := s.redis.SetNX(ctx, lockKey, "1", 30*time.Second).Result()
locked, lockErr := s.redis.SetNX(ctx, lockKey, "1", 30*time.Second).Result()
if lockErr != nil {
return errors.Wrap(errors.CodeRedisError, lockErr, "获取设备复机锁失败")
}
if !locked {
s.logger.Debug("设备复机操作已在进行中,跳过重复调用",
zap.Uint("device_id", deviceID))
return
return nil
}
defer s.redis.Del(ctx, lockKey)
@@ -338,24 +358,31 @@ func (s *StopResumeService) resumeDeviceCards(ctx context.Context, deviceID uint
if err != nil {
s.logger.Error("查询设备停机卡失败",
zap.Uint("device_id", deviceID), zap.Error(err))
return
return errors.Wrap(errors.CodeDatabaseError, err, "查询设备停机卡失败")
}
var cardErrors []error
for _, card := range cards {
if !s.isRealnameOK(card) {
if updateErr := s.iotCardStore.UpdateStopReason(ctx, card.ID, constants.StopReasonNotRealname); updateErr != nil {
cardErrors = append(cardErrors, updateErr)
s.logger.Warn("更新未实名卡停机原因失败",
zap.Uint("card_id", card.ID), zap.Error(updateErr))
}
continue
}
if resumeErr := s.resumeSingleCard(ctx, card.ID); resumeErr != nil {
cardErrors = append(cardErrors, resumeErr)
s.logger.Warn("设备卡复机失败,继续处理其他卡",
zap.Uint("device_id", deviceID),
zap.Uint("card_id", card.ID),
zap.Error(resumeErr))
}
}
if len(cardErrors) > 0 {
return errors.Wrap(errors.CodeInternalError, stderrors.Join(cardErrors...), "设备复机存在未成功处理的卡")
}
return nil
}
// CheckAndStopCard 检查流量耗尽并停机(旧入口,保留兼容性)
@@ -375,8 +402,7 @@ func (s *StopResumeService) ResumeCardIfStopped(ctx context.Context, carrierType
case "iot_card":
return s.resumeSingleCard(ctx, carrierID)
case "device":
s.resumeDeviceCards(ctx, carrierID)
return nil
return s.resumeDeviceCards(ctx, carrierID)
default:
return nil
}
@@ -460,11 +486,11 @@ func (s *StopResumeService) resumeSingleCard(ctx context.Context, cardID uint) e
}
now := time.Now()
if err := s.iotCardStore.UpdateFields(ctx, cardID, map[string]any{
if err := s.updateCardAndAppendNetworkSeries(ctx, cardID, map[string]any{
"network_status": constants.NetworkStatusOnline,
"resumed_at": now,
"stop_reason": "",
}); err != nil {
}, constants.CardObservationSceneBusinessResume, "online", uuid.NewString()); err != nil {
s.logger.Error("复机 Gateway 成功但 DB 更新失败",
zap.Uint("card_id", cardID),
zap.String("iccid", card.ICCID),
@@ -488,7 +514,7 @@ func (s *StopResumeService) resumeSingleCard(ctx context.Context, cardID uint) e
"stop_reason": "",
},
})
return nil
return err
}
s.reschedulePolling(ctx, card.ID)
@@ -568,11 +594,11 @@ func (s *StopResumeService) stopCardWithRetry(ctx context.Context, card *model.I
zap.String("iccid", card.ICCID))
now := time.Now()
if updateErr := s.iotCardStore.UpdateFields(ctx, card.ID, map[string]any{
if updateErr := s.updateCardAndAppendNetworkSeries(ctx, card.ID, map[string]any{
"network_status": constants.NetworkStatusOffline,
"stopped_at": now,
"stop_reason": stopReason,
}); updateErr != nil {
}, constants.CardObservationSceneBusinessStop, "offline", uuid.NewString()); updateErr != nil {
s.logger.Error("停机 Gateway 成功但 DB 更新失败",
zap.Uint("card_id", card.ID),
zap.String("iccid", card.ICCID),
@@ -596,6 +622,7 @@ func (s *StopResumeService) stopCardWithRetry(ctx context.Context, card *model.I
"stop_reason": stopReason,
},
})
return updateErr
}
s.reschedulePolling(ctx, card.ID)
@@ -763,12 +790,12 @@ func (s *StopResumeService) StartMachineSeparatedCard(ctx context.Context, card
}
now := time.Now()
if err := s.iotCardStore.UpdateFields(ctx, card.ID, map[string]any{
if err := s.updateCardAndAppendNetworkSeries(ctx, card.ID, map[string]any{
"network_status": constants.NetworkStatusOnline,
"resumed_at": now,
"stop_reason": "",
"gateway_extend": "",
}); err != nil {
}, constants.CardObservationSceneBusinessResume, "online", uuid.NewString()); err != nil {
wrapErr := errors.Wrap(errors.CodeDatabaseError, err, "更新卡状态失败")
errorCode, errorMsg := assetAuditSvc.BuildErrorInfo(wrapErr)
s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{
@@ -1008,11 +1035,11 @@ func (s *StopResumeService) ManualStartCard(ctx context.Context, iccid string) e
}
now := time.Now()
if err := s.iotCardStore.UpdateFields(ctx, card.ID, map[string]any{
if err := s.updateCardAndAppendNetworkSeries(ctx, card.ID, map[string]any{
"network_status": constants.NetworkStatusOnline,
"resumed_at": now,
"stop_reason": "",
}); err != nil {
}, constants.CardObservationSceneBusinessResume, "online", uuid.NewString()); err != nil {
wrapErr := errors.Wrap(errors.CodeDatabaseError, err, "更新卡状态失败")
errorCode, errorMsg := assetAuditSvc.BuildErrorInfo(wrapErr)
s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{
@@ -1056,6 +1083,31 @@ func (s *StopResumeService) ManualStartCard(ctx context.Context, iccid string) e
return nil
}
func (s *StopResumeService) updateCardAndAppendNetworkSeries(ctx context.Context, cardID uint, fields map[string]any, scene, expected, operationID string) error {
if s.db == nil || s.observationSeriesEvents == nil {
return errors.New(errors.CodeInternalError, "停复机观测 Outbox 能力未配置")
}
requestID := requestIDFromContext(ctx)
if requestID == "" {
requestID = operationID
}
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Model(&model.IotCard{}).Where("id = ?", cardID).Updates(fields).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "更新卡停复机状态失败")
}
if cardObservationApp.IsSeriesTriggerSuppressed(ctx) {
return nil
}
return s.observationSeriesEvents.AppendSeriesRequested(ctx, tx, cardObservationApp.SeriesRequestedEvent{
EventID: "card-observation:network-command:" + operationID,
Scene: scene, ResourceType: constants.CardObservationResourceTypeCard, ResourceID: cardID,
SyncTypes: []string{constants.CardObservationSyncTypeNetwork}, ExpectedValue: expected,
Source: constants.CardObservationSourceBusinessEvent, OccurredAt: time.Now().UTC(),
RequestID: requestID, CorrelationID: requestID,
})
})
}
// invalidatePollingCache 删除轮询卡缓存,下次轮询自动从 DB 重建
func (s *StopResumeService) invalidatePollingCache(ctx context.Context, cardID uint) {
if s.redis == nil {