收口七月卡状态回调与系列授权兼容契约
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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user