收口七月卡状态回调与系列授权兼容契约
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,11 @@ package device
import (
"context"
"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"
@@ -37,6 +40,93 @@ type Service struct {
enterpriseDeviceAuthStore *postgres.EnterpriseDeviceAuthorizationStore
enterpriseStore *postgres.EnterpriseStore
packageExpiryQuery *packageexpiry.Query
observationSeriesEvents cardObservationApp.SeriesEventWriter
observationSeries cardObservationApp.BestEffortSeriesDispatcher
}
// SetObservationSeriesEventWriter 注入设备停复机成功观测序列 Outbox Writer。
func (s *Service) SetObservationSeriesEventWriter(writer cardObservationApp.SeriesEventWriter) {
s.observationSeriesEvents = writer
}
// SetObservationSeriesDispatcher 注入设备控制成功后的后台观测分发器。
func (s *Service) SetObservationSeriesDispatcher(dispatcher cardObservationApp.BestEffortSeriesDispatcher) {
s.observationSeries = dispatcher
}
type deviceControlObservationSnapshot struct {
SourceCardID uint
TargetCardID uint
BoundCardIDs []uint
TargetICCID string
}
func (s *Service) captureDeviceControlObservation(ctx context.Context, deviceID, knownTargetCardID uint, targetICCID string) deviceControlObservationSnapshot {
snapshot := deviceControlObservationSnapshot{TargetICCID: targetICCID}
bindings, err := s.deviceSimBindingStore.ListByDeviceID(ctx, deviceID)
if err != nil {
logger.GetAppLogger().Warn("冻结设备控制观测绑定快照失败,继续执行原操作", zap.Uint("device_id", deviceID), zap.Error(err))
return snapshot
}
bound := make(map[uint]struct{}, len(bindings))
for _, binding := range bindings {
if binding == nil || binding.IotCardID == 0 {
continue
}
snapshot.BoundCardIDs = append(snapshot.BoundCardIDs, binding.IotCardID)
bound[binding.IotCardID] = struct{}{}
if binding.IsCurrent {
snapshot.SourceCardID = binding.IotCardID
}
}
if knownTargetCardID != 0 {
if _, ok := bound[knownTargetCardID]; ok {
snapshot.TargetCardID = knownTargetCardID
}
return snapshot
}
if strings.TrimSpace(targetICCID) == "" || len(snapshot.BoundCardIDs) == 0 {
return snapshot
}
cards, err := s.iotCardStore.GetByIDs(ctx, snapshot.BoundCardIDs)
if err != nil {
logger.GetAppLogger().Warn("冻结设备控制观测目标卡快照失败,继续执行原操作", zap.Uint("device_id", deviceID), zap.String("target_iccid", targetICCID), zap.Error(err))
return snapshot
}
for _, card := range cards {
if card != nil && cardMatchesICCID(card, targetICCID) {
snapshot.TargetCardID = card.ID
break
}
}
return snapshot
}
func cardMatchesICCID(card *model.IotCard, expected string) bool {
expected = strings.TrimSpace(expected)
return strings.EqualFold(strings.TrimSpace(card.ICCID), expected) ||
strings.EqualFold(strings.TrimSpace(card.ICCID19), expected) ||
(card.ICCID20 != nil && strings.EqualFold(strings.TrimSpace(*card.ICCID20), expected))
}
func (s *Service) dispatchDeviceControlObservation(ctx context.Context, deviceID uint, scene string, snapshot deviceControlObservationSnapshot, includeTargetTraffic bool) {
if s.observationSeries == nil || deviceID == 0 {
return
}
requestID := ""
if value := middleware.GetRequestIDFromContext(ctx); value != nil {
requestID = *value
}
request := cardObservationApp.SeriesRequest{
Scene: scene, Source: constants.CardObservationSourceBusinessEvent,
RequestID: requestID, CorrelationID: requestID,
}
s.observationSeries.DispatchDeviceControl(ctx, cardObservationApp.DeviceControlSeriesRequest{
DeviceID: deviceID, TargetICCID: snapshot.TargetICCID,
SourceCardID: snapshot.SourceCardID, TargetCardID: snapshot.TargetCardID,
BoundCardIDs: snapshot.BoundCardIDs,
IncludeTargetTraffic: includeTargetTraffic, Request: request,
})
}
// SetPackageExpiryQuery 注入套餐最终到期查询,供列表使用批量投影。
@@ -1678,11 +1768,11 @@ func (s *Service) StopDevice(ctx context.Context, deviceID uint) (*dto.DeviceSus
}
now := time.Now()
if dbErr := s.iotCardStore.UpdateFields(ctx, card.ID, map[string]any{
if dbErr := s.updateCardAndAppendNetworkSeries(ctx, card.ID, map[string]any{
"network_status": constants.NetworkStatusOffline,
"stopped_at": now,
"stop_reason": constants.StopReasonManual,
}); dbErr != nil {
}, constants.CardObservationSceneBusinessStop, "offline", s.gatewayClient != nil); dbErr != nil {
log.Error("设备停机-更新卡状态失败",
zap.Uint("card_id", card.ID),
zap.Error(dbErr))
@@ -1699,7 +1789,7 @@ func (s *Service) StopDevice(ctx context.Context, deviceID uint) (*dto.DeviceSus
// 成功停机至少一张卡后设置保护期
if successCount > 0 && s.redis != nil {
s.redis.Set(ctx, constants.RedisDeviceProtectKey(deviceID, "stop"), 1, constants.DeviceProtectPeriodDuration)
s.redis.Set(ctx, constants.RedisDeviceProtectKey(deviceID, "stop"), uuid.NewString(), constants.DeviceProtectPeriodDuration)
s.redis.Del(ctx, constants.RedisDeviceProtectKey(deviceID, "start"))
}
@@ -1907,11 +1997,11 @@ func (s *Service) StartDevice(ctx context.Context, deviceID uint) error {
}
now := time.Now()
if dbErr := s.iotCardStore.UpdateFields(ctx, card.ID, map[string]any{
if dbErr := s.updateCardAndAppendNetworkSeries(ctx, card.ID, map[string]any{
"network_status": constants.NetworkStatusOnline,
"resumed_at": now,
"stop_reason": "",
}); dbErr != nil {
}, constants.CardObservationSceneBusinessResume, "online", s.gatewayClient != nil); dbErr != nil {
log.Error("设备复机-更新卡状态失败",
zap.Uint("card_id", card.ID),
zap.Error(dbErr))
@@ -1926,7 +2016,7 @@ func (s *Service) StartDevice(ctx context.Context, deviceID uint) error {
// 成功复机至少一张卡后设置保护期
if successCount > 0 && s.redis != nil {
s.redis.Set(ctx, constants.RedisDeviceProtectKey(deviceID, "start"), 1, constants.DeviceProtectPeriodDuration)
s.redis.Set(ctx, constants.RedisDeviceProtectKey(deviceID, "start"), uuid.NewString(), constants.DeviceProtectPeriodDuration)
s.redis.Del(ctx, constants.RedisDeviceProtectKey(deviceID, "stop"))
}
@@ -2089,3 +2179,29 @@ func (s *Service) invalidatePollingCardCache(cardID uint) {
}
_ = s.redis.Del(context.Background(), constants.RedisPollingCardInfoKey(cardID)).Err()
}
func (s *Service) updateCardAndAppendNetworkSeries(ctx context.Context, cardID uint, fields map[string]any, scene, expected string, upstreamCalled bool) error {
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 !upstreamCalled || cardObservationApp.IsSeriesTriggerSuppressed(ctx) {
return nil
}
if s.observationSeriesEvents == nil {
return errors.New(errors.CodeInternalError, "设备停复机观测 Outbox Writer 未配置")
}
operationID := uuid.NewString()
requestID := operationID
if value := middleware.GetRequestIDFromContext(ctx); value != nil && *value != "" {
requestID = *value
}
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,
})
})
}