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

@@ -4,9 +4,11 @@ import (
"context"
"fmt"
"math/rand"
"strconv"
"strings"
"time"
cardObservationApp "github.com/break/junhong_cmp_fiber/internal/application/cardobservation"
domainwallet "github.com/break/junhong_cmp_fiber/internal/domain/wallet"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
@@ -38,6 +40,12 @@ type Service struct {
agentWalletStore *postgres.AgentWalletStore
deviceSimBindingStore *postgres.DeviceSimBindingStore
deviceStore *postgres.DeviceStore
observationSeries cardObservationApp.BestEffortSeriesDispatcher
}
// SetObservationSeriesDispatcher 注入 OpenAPI 读取后的后台观测序列端口。
func (s *Service) SetObservationSeriesDispatcher(dispatcher cardObservationApp.BestEffortSeriesDispatcher) {
s.observationSeries = dispatcher
}
// New 创建代理开放接口业务编排服务
@@ -83,7 +91,11 @@ func (s *Service) GetCardTraffic(ctx context.Context, req *dto.AgentOpenAPICardQ
if cardErr == nil {
// 独立卡:直接查卡维度流量
if card.IsStandalone {
return s.buildCardTrafficResponse(ctx, req.CardNo, "iot_card", card.ID, "")
resp, err := s.buildCardTrafficResponse(ctx, req.CardNo, "iot_card", card.ID, "")
if err == nil {
s.dispatchCardObservation(ctx, constants.CardObservationSceneOpenCardTraffic, card.ID, constants.CardObservationSyncTypeTraffic)
}
return resp, err
}
// 已绑定设备的卡:反查设备后查设备维度流量
binding, err := s.deviceSimBindingStore.GetActiveBindingByCardID(ctx, card.ID)
@@ -94,7 +106,11 @@ func (s *Service) GetCardTraffic(ctx context.Context, req *dto.AgentOpenAPICardQ
if err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "查询绑定设备信息失败")
}
return s.buildCardTrafficResponse(ctx, req.CardNo, "device", device.ID, device.VirtualNo)
resp, buildErr := s.buildCardTrafficResponse(ctx, req.CardNo, "device", device.ID, device.VirtualNo)
if buildErr == nil {
s.dispatchCardObservation(ctx, constants.CardObservationSceneOpenCardTraffic, card.ID, constants.CardObservationSyncTypeTraffic)
}
return resp, buildErr
}
// 兜底:尝试解析为设备标识(支持 IMEI/虚拟号)
@@ -103,7 +119,11 @@ func (s *Service) GetCardTraffic(ctx context.Context, req *dto.AgentOpenAPICardQ
// 两种解析都失败,返回原始卡解析错误(语义更贴近入参)
return nil, cardErr
}
return s.buildCardTrafficResponse(ctx, req.CardNo, "device", device.ID, device.VirtualNo)
resp, buildErr := s.buildCardTrafficResponse(ctx, req.CardNo, "device", device.ID, device.VirtualNo)
if buildErr == nil {
s.dispatchDeviceCardObservations(ctx, constants.CardObservationSceneOpenCardTraffic, device.ID, constants.CardObservationSyncTypeTraffic)
}
return resp, buildErr
}
// buildCardTrafficResponse 统一构造卡流量响应,支持卡和设备两种载体维度
@@ -167,14 +187,16 @@ func (s *Service) GetCardStatus(ctx context.Context, req *dto.AgentOpenAPICardQu
stopReason = ""
}
return &dto.AgentOpenAPICardStatusResponse{
resp := &dto.AgentOpenAPICardStatusResponse{
CardNo: req.CardNo,
CardStatus: cardStatus,
CardStatusName: constants.AgentOpenAPICardStatusName(cardStatus),
GatewayExtend: card.GatewayExtend,
StopReason: stopReason,
StopReasonName: constants.AgentOpenAPIStopReasonName(stopReason),
}, nil
}
s.dispatchCardObservation(ctx, constants.CardObservationSceneOpenCardNetwork, card.ID, constants.CardObservationSyncTypeNetwork)
return resp, nil
}
// GetRealnameStatus 查询开放接口单卡实名状态
@@ -184,10 +206,12 @@ func (s *Service) GetRealnameStatus(ctx context.Context, req *dto.AgentOpenAPICa
return nil, err
}
return &dto.AgentOpenAPIRealnameStatusResponse{
resp := &dto.AgentOpenAPIRealnameStatusResponse{
CardNo: req.CardNo,
IsRealnamed: card.RealNameStatus == constants.RealNameStatusVerified,
}, nil
}
s.dispatchCardObservation(ctx, constants.CardObservationSceneOpenCardRealname, card.ID, constants.CardObservationSyncTypeRealname)
return resp, nil
}
// ResumeCard 调用上游复机机卡分离停机卡
@@ -396,6 +420,41 @@ func (s *Service) CreateWalletPackageOrders(ctx context.Context, req *dto.AgentO
return resp, nil
}
func (s *Service) dispatchDeviceCardObservations(ctx context.Context, scene string, deviceID uint, syncType string) {
if s.observationSeries == nil || deviceID == 0 {
return
}
requestID := ""
if value := middleware.GetRequestIDFromContext(ctx); value != nil {
requestID = *value
}
s.observationSeries.DispatchDeviceCards(ctx, cardObservationApp.DeviceCardsSeriesRequest{
DeviceID: deviceID,
Request: cardObservationApp.SeriesRequest{
Scene: scene, ResourceType: constants.CardObservationResourceTypeDevice,
ResourceID: strconv.FormatUint(uint64(deviceID), 10), SyncType: syncType,
Source: constants.CardObservationSourceBusinessEvent,
RequestID: requestID, CorrelationID: requestID,
},
})
}
func (s *Service) dispatchCardObservation(ctx context.Context, scene string, cardID uint, syncType string) {
if s.observationSeries == nil || cardID == 0 {
return
}
requestID := ""
if value := middleware.GetRequestIDFromContext(ctx); value != nil {
requestID = *value
}
s.observationSeries.Dispatch(ctx, cardObservationApp.SeriesRequest{
Scene: scene, ResourceType: constants.CardObservationResourceTypeCard,
ResourceID: strconv.FormatUint(uint64(cardID), 10), SyncType: syncType,
Source: constants.CardObservationSourceBusinessEvent,
RequestID: requestID, CorrelationID: requestID,
})
}
// resolveOpenAPIDevice 将开放接口设备标识解析为当前代理可见的设备
// 设备不存在或不属于代理管辖店铺时统一返回 CodeForbidden避免信息泄露
func (s *Service) resolveOpenAPIDevice(ctx context.Context, deviceNo string) (*model.Device, error) {
@@ -473,6 +532,7 @@ func (s *Service) GetDeviceTraffic(ctx context.Context, req *dto.AgentOpenAPIDev
resp.PendingPackages = append(resp.PendingPackages, s.buildTrafficItem(usage, packageMap, seriesMap, false))
}
s.dispatchDeviceCardObservations(ctx, constants.CardObservationSceneOpenDeviceTraffic, device.ID, constants.CardObservationSyncTypeTraffic)
return resp, nil
}

View File

@@ -129,6 +129,7 @@ func (s *Service) GatewaySetWiFi(ctx context.Context, identifier string, req *dt
)
return err
}
observation := s.captureDeviceControlObservation(ctx, device.ID, 0, "")
if err = s.gatewayClient.SetWiFi(ctx, &gateway.WiFiReq{
CardNo: imei,
Params: gateway.WiFiParams{
@@ -175,6 +176,7 @@ func (s *Service) GatewaySetWiFi(ctx context.Context, identifier string, req *dt
0,
nil,
)
s.dispatchDeviceControlObservation(ctx, device.ID, constants.CardObservationSceneDeviceSetWiFi, observation, false)
return nil
}
@@ -200,6 +202,7 @@ func (s *Service) GatewaySwitchCard(ctx context.Context, identifier string, req
)
return err
}
observation := s.captureDeviceControlObservation(ctx, device.ID, 0, req.TargetICCID)
if err = s.gatewayClient.SwitchCard(ctx, &gateway.SwitchCardReq{
CardNo: imei,
ICCID: req.TargetICCID,
@@ -233,6 +236,7 @@ func (s *Service) GatewaySwitchCard(ctx context.Context, identifier string, req
0,
nil,
)
s.dispatchDeviceControlObservation(ctx, device.ID, constants.CardObservationSceneDeviceSwitchCard, observation, true)
return nil
}
@@ -255,6 +259,7 @@ func (s *Service) GatewayRebootDevice(ctx context.Context, identifier string) er
)
return err
}
observation := s.captureDeviceControlObservation(ctx, device.ID, 0, "")
if err = s.gatewayClient.RebootDevice(ctx, &gateway.DeviceOperationReq{
DeviceID: imei,
}); err != nil {
@@ -287,6 +292,7 @@ func (s *Service) GatewayRebootDevice(ctx context.Context, identifier string) er
0,
nil,
)
s.dispatchDeviceControlObservation(ctx, device.ID, constants.CardObservationSceneDeviceReboot, observation, false)
return nil
}
@@ -309,6 +315,7 @@ func (s *Service) GatewayResetDevice(ctx context.Context, identifier string) err
)
return err
}
observation := s.captureDeviceControlObservation(ctx, device.ID, 0, "")
if err = s.gatewayClient.ResetDevice(ctx, &gateway.DeviceOperationReq{
DeviceID: imei,
}); err != nil {
@@ -341,6 +348,7 @@ func (s *Service) GatewayResetDevice(ctx context.Context, identifier string) err
0,
nil,
)
s.dispatchDeviceControlObservation(ctx, device.ID, constants.CardObservationSceneDeviceReset, observation, false)
return nil
}
@@ -556,6 +564,7 @@ func (s *Service) GatewaySwitchMode(ctx context.Context, identifier string, req
)
return appErr
}
observation := s.captureDeviceControlObservation(ctx, device.ID, targetCard.ID, targetCard.ICCID)
if err = s.gatewayClient.SwitchMode(ctx, &gateway.SwitchModeReq{
CardNo: imei,
SwitchMode: strconv.Itoa(switchMode),
@@ -590,5 +599,6 @@ func (s *Service) GatewaySwitchMode(ctx context.Context, identifier string, req
0,
nil,
)
s.dispatchDeviceControlObservation(ctx, device.ID, constants.CardObservationSceneDeviceSwitchMode, observation, true)
return nil
}

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,
})
})
}

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 {

View File

@@ -10,6 +10,7 @@ import (
"strings"
"time"
cardObservationApp "github.com/break/junhong_cmp_fiber/internal/application/cardobservation"
walletapp "github.com/break/junhong_cmp_fiber/internal/application/wallet"
packagedomain "github.com/break/junhong_cmp_fiber/internal/domain/package"
"github.com/break/junhong_cmp_fiber/internal/model"
@@ -66,6 +67,12 @@ type Service struct {
personalCustomerPhoneStore *postgres.PersonalCustomerPhoneStore
agentWalletDebit *walletapp.DebitService
agentWalletReservation *walletapp.ReservationService
observationSeriesEvents cardObservationApp.SeriesEventWriter
}
// SetObservationSeriesEventWriter 注入购包成功观测序列 Outbox Writer。
func (s *Service) SetObservationSeriesEventWriter(writer cardObservationApp.SeriesEventWriter) {
s.observationSeriesEvents = writer
}
// SetAgentWalletDebitService 注入代理主钱包统一扣款用例。
@@ -1940,8 +1947,9 @@ func (s *Service) tryResumeAfterPayment(ctx context.Context, order *model.Order)
return
}
resumeCtx := context.WithoutCancel(ctx)
go func(ct string, cid uint) {
if err := s.resumeCallback.ResumeCardIfStopped(context.Background(), ct, cid); err != nil {
if err := s.resumeCallback.ResumeCardIfStopped(resumeCtx, ct, cid); err != nil {
s.logger.Error("支付后自动复机失败",
zap.String("carrier_type", ct),
zap.Uint("carrier_id", cid),
@@ -1975,6 +1983,7 @@ func (s *Service) activatePackage(ctx context.Context, tx *gorm.DB, order *model
}
now := time.Now()
createdUsage := false
for _, item := range items {
// 检查是否已存在使用记录
var existingUsage model.PackageUsage
@@ -2002,17 +2011,44 @@ func (s *Service) activatePackage(ctx context.Context, tx *gorm.DB, order *model
if err := s.activateMainPackage(ctx, tx, order, &pkg, carrierType, carrierID, now); err != nil {
return err
}
createdUsage = true
} else if pkg.PackageType == "addon" {
// 加油包处理逻辑(任务 8.5-8.7
if err := s.activateAddonPackage(ctx, tx, order, &pkg, carrierType, carrierID, now); err != nil {
return err
}
createdUsage = true
}
}
if createdUsage {
if s.observationSeriesEvents == nil {
return errors.New(errors.CodeInternalError, "购包观测 Outbox Writer 未配置")
}
eventID := "card-observation:order:" + strconv.FormatUint(uint64(order.ID), 10)
requestID := requestIDFromContext(ctx)
if requestID == "" {
requestID = eventID
}
if err := s.observationSeriesEvents.AppendSeriesRequested(ctx, tx, cardObservationApp.SeriesRequestedEvent{
EventID: eventID,
Scene: constants.CardObservationScenePackageChanged, ResourceType: carrierType, ResourceID: carrierID,
SyncTypes: []string{constants.CardObservationSyncTypeRealname, constants.CardObservationSyncTypeTraffic, constants.CardObservationSyncTypeNetwork},
Source: constants.CardObservationSourceBusinessEvent, OccurredAt: now,
RequestID: requestID, CorrelationID: requestID,
}); err != nil {
return err
}
}
return nil
}
func requestIDFromContext(ctx context.Context) string {
if value := middleware.GetRequestIDFromContext(ctx); value != nil {
return *value
}
return ""
}
func (s *Service) lockPackageCarrier(ctx context.Context, tx *gorm.DB, carrierType string, carrierID uint) error {
switch carrierType {
case constants.AssetWalletResourceTypeIotCard, "card":

View File

@@ -2,8 +2,10 @@ package packagepkg
import (
"context"
"strconv"
"time"
cardObservationApp "github.com/break/junhong_cmp_fiber/internal/application/cardobservation"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
"github.com/break/junhong_cmp_fiber/pkg/constants"
@@ -27,6 +29,12 @@ type ActivationService struct {
packageUsageDailyRecord *postgres.PackageUsageDailyRecordStore
logger *zap.Logger
resumeCallback ResumeCallback // 复机回调,可选
observationSeriesEvents cardObservationApp.SeriesEventWriter
}
// SetObservationSeriesEventWriter 注入套餐激活成功观测序列 Outbox Writer。
func (s *ActivationService) SetObservationSeriesEventWriter(writer cardObservationApp.SeriesEventWriter) {
s.observationSeriesEvents = writer
}
func NewActivationService(
@@ -81,8 +89,9 @@ func (s *ActivationService) ActivateByRealname(ctx context.Context, carrierType
now := time.Now()
activated := false
// 在事务中激活套餐
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
for _, usage := range pendingUsages {
// 查询套餐信息
var pkg model.Package
@@ -144,6 +153,9 @@ func (s *ActivationService) ActivateByRealname(ctx context.Context, carrierType
if err := tx.Model(usage).Updates(updates).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "激活套餐失败")
}
if err := s.appendActivationObservation(ctx, tx, usage, carrierType, carrierID, now); err != nil {
return err
}
s.syncCarrierStatusActivated(ctx, tx, usage, carrierType, carrierID)
@@ -153,20 +165,18 @@ func (s *ActivationService) ActivateByRealname(ctx context.Context, carrierType
zap.Time("activated_at", activatedAt),
zap.Time("expires_at", expiresAt))
if s.resumeCallback != nil {
go func(ct string, cid uint) {
if err := s.resumeCallback.ResumeCardIfStopped(context.Background(), ct, cid); err != nil {
s.logger.Error("实名激活后自动复机失败",
zap.String("carrier_type", ct),
zap.Uint("carrier_id", cid),
zap.Error(err))
}
}(carrierType, carrierID)
}
activated = true
}
return nil
})
if err != nil {
return err
}
if activated {
s.resumeAfterActivation(ctx, carrierType, carrierID, "实名激活后自动复机失败")
}
return nil
}
// ActivateQueuedPackage 任务 9.4-9.7: 排队主套餐激活
@@ -187,7 +197,8 @@ func (s *ActivationService) ActivateQueuedPackage(ctx context.Context, carrierTy
}
defer s.redis.Del(ctx, lockKey)
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
activated := false
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
// 任务 9.5: 检测并标记过期的主套餐
now := time.Now()
var expiredMainUsages []*model.PackageUsage
@@ -221,13 +232,22 @@ func (s *ActivationService) ActivateQueuedPackage(ctx context.Context, carrierTy
}
// 任务 9.6: 激活下一个待生效主套餐
if err := s.activateNextMainPackage(ctx, tx, carrierType, carrierID, now); err != nil {
currentActivated, err := s.activateNextMainPackage(ctx, tx, carrierType, carrierID, now)
if err != nil {
return err
}
activated = activated || currentActivated
}
return nil
})
if err != nil {
return err
}
if activated {
s.resumeAfterActivation(ctx, carrierType, carrierID, "排队激活后自动复机失败")
}
return nil
}
// ActivateSpecificPackage 任务 4: 激活指定的套餐使用记录
@@ -285,7 +305,8 @@ func (s *ActivationService) ActivateSpecificPackage(ctx context.Context, package
}
// 事务内激活
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
activated := false
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
// 再次幂等检查(防止锁释放期间被其他操作激活)
var currentUsage model.PackageUsage
if err := tx.First(&currentUsage, packageUsageID).Error; err != nil {
@@ -323,21 +344,16 @@ func (s *ActivationService) ActivateSpecificPackage(ctx context.Context, package
if err := s.activatePendingUsage(ctx, tx, &currentUsage, &pkg, carrierType, carrierID, time.Now(), "指定套餐已激活"); err != nil {
return err
}
// 异步复机
if s.resumeCallback != nil {
go func(ct string, cid uint) {
if err := s.resumeCallback.ResumeCardIfStopped(context.Background(), ct, cid); err != nil {
s.logger.Error("激活后自动复机失败",
zap.String("carrier_type", ct),
zap.Uint("carrier_id", cid),
zap.Error(err))
}
}(carrierType, carrierID)
}
activated = true
return nil
})
if err != nil {
return err
}
if activated {
s.resumeAfterActivation(ctx, carrierType, carrierID, "激活后自动复机失败")
}
return nil
}
// ActivateNextPendingMainPackage 按购买顺序激活载体的下一个待生效主套餐。
@@ -409,6 +425,9 @@ func (s *ActivationService) ActivateNextPendingMainPackage(ctx context.Context,
if err != nil {
return false, err
}
if activated {
s.resumeAfterActivation(ctx, carrierType, carrierID, "队首激活后自动复机失败")
}
return activated, nil
}
@@ -451,22 +470,22 @@ func (s *ActivationService) invalidateAddons(ctx context.Context, tx *gorm.DB, m
}
// activateNextMainPackage 任务 9.6: 激活下一个待生效主套餐
func (s *ActivationService) activateNextMainPackage(ctx context.Context, tx *gorm.DB, carrierType string, carrierID uint, now time.Time) error {
func (s *ActivationService) activateNextMainPackage(ctx context.Context, tx *gorm.DB, carrierType string, carrierID uint, now time.Time) (bool, error) {
// 查询下一个待生效主套餐
nextMain, err := s.getNextPendingMainPackage(ctx, tx, carrierType, carrierID)
if err == gorm.ErrRecordNotFound {
s.logger.Info("没有待生效的主套餐",
zap.String("carrier_type", carrierType),
zap.Uint("carrier_id", carrierID))
return nil
return false, nil
}
if err != nil {
return err
return false, err
}
canActivate, err := s.canActivatePendingUsage(ctx, tx, nextMain, carrierType, carrierID)
if err != nil {
return err
return false, err
}
if !canActivate {
s.logger.Info("下一个待生效主套餐暂不满足激活条件",
@@ -474,16 +493,19 @@ func (s *ActivationService) activateNextMainPackage(ctx context.Context, tx *gor
zap.String("carrier_type", carrierType),
zap.Uint("carrier_id", carrierID),
zap.Bool("pending_realname_activation", nextMain.PendingRealnameActivation))
return nil
return false, nil
}
// 查询套餐信息
var pkg model.Package
if err := tx.First(&pkg, nextMain.PackageID).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询套餐信息失败")
return false, errors.Wrap(errors.CodeDatabaseError, err, "查询套餐信息失败")
}
return s.activatePendingUsage(ctx, tx, nextMain, &pkg, carrierType, carrierID, now, "排队主套餐已激活")
if err := s.activatePendingUsage(ctx, tx, nextMain, &pkg, carrierType, carrierID, now, "排队主套餐已激活"); err != nil {
return false, err
}
return true, nil
}
func (s *ActivationService) getNextPendingMainPackage(ctx context.Context, tx *gorm.DB, carrierType string, carrierID uint) (*model.PackageUsage, error) {
@@ -589,6 +611,9 @@ func (s *ActivationService) activatePendingUsage(ctx context.Context, tx *gorm.D
if err := tx.Model(usage).Updates(updates).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "激活排队主套餐失败")
}
if err := s.appendActivationObservation(ctx, tx, usage, carrierType, carrierID, now); err != nil {
return err
}
s.syncCarrierStatusActivated(ctx, tx, usage, carrierType, carrierID)
@@ -598,20 +623,45 @@ func (s *ActivationService) activatePendingUsage(ctx context.Context, tx *gorm.D
zap.Time("activated_at", activatedAt),
zap.Time("expires_at", expiresAt))
if s.resumeCallback != nil {
go func(ct string, cid uint) {
if err := s.resumeCallback.ResumeCardIfStopped(context.Background(), ct, cid); err != nil {
s.logger.Error("排队激活后自动复机失败",
zap.String("carrier_type", ct),
zap.Uint("carrier_id", cid),
zap.Error(err))
}
}(carrierType, carrierID)
}
return nil
}
// resumeAfterActivation 在激活事务提交后异步尝试复机,避免外部副作用先于业务事实提交。
func (s *ActivationService) resumeAfterActivation(ctx context.Context, carrierType string, carrierID uint, errorMessage string) {
if s.resumeCallback == nil {
return
}
resumeCtx := context.WithoutCancel(ctx)
go func() {
if err := s.resumeCallback.ResumeCardIfStopped(resumeCtx, carrierType, carrierID); err != nil {
s.logger.Error(errorMessage,
zap.String("carrier_type", carrierType),
zap.Uint("carrier_id", carrierID),
zap.Error(err))
}
}()
}
func (s *ActivationService) appendActivationObservation(ctx context.Context, tx *gorm.DB, usage *model.PackageUsage, carrierType string, carrierID uint, occurredAt time.Time) error {
if cardObservationApp.IsSeriesTriggerSuppressed(ctx) {
return nil
}
if s.observationSeriesEvents == nil {
return errors.New(errors.CodeInternalError, "套餐激活观测 Outbox Writer 未配置")
}
if usage == nil || usage.ID == 0 || carrierID == 0 {
return errors.New(errors.CodeInvalidParam, "套餐激活观测事件缺少载体")
}
eventID := "card-observation:package-usage:" + strconv.FormatUint(uint64(usage.ID), 10) + ":activated"
return s.observationSeriesEvents.AppendSeriesRequested(ctx, tx, cardObservationApp.SeriesRequestedEvent{
EventID: eventID, Scene: constants.CardObservationScenePackageChanged,
ResourceType: carrierType, ResourceID: carrierID,
SyncTypes: []string{constants.CardObservationSyncTypeRealname, constants.CardObservationSyncTypeTraffic, constants.CardObservationSyncTypeNetwork},
Source: constants.CardObservationSourceBusinessEvent, OccurredAt: occurredAt,
RequestID: eventID, CorrelationID: eventID,
})
}
func (s *ActivationService) syncCarrierStatusActivated(ctx context.Context, tx *gorm.DB, usage *model.PackageUsage, carrierType string, carrierID uint) {
if usage == nil {
return

View File

@@ -247,6 +247,7 @@ func (s *ResetService) triggerResumeForPackages(ctx context.Context, packages []
return
}
resumeCtx := context.WithoutCancel(ctx)
for _, pkg := range packages {
var carrierType string
var carrierID uint
@@ -262,7 +263,7 @@ func (s *ResetService) triggerResumeForPackages(ctx context.Context, packages []
}
go func(ct string, cid uint) {
if err := s.resumeCallback.ResumeCardIfStopped(context.Background(), ct, cid); err != nil {
if err := s.resumeCallback.ResumeCardIfStopped(resumeCtx, ct, cid); err != nil {
s.logger.Warn("流量重置后自动复机失败",
zap.String("carrier_type", ct),
zap.Uint("carrier_id", cid),

View File

@@ -186,7 +186,7 @@ func (s *UsageService) DeductDataUsage(ctx context.Context, carrierType string,
}
if shouldSuspend {
s.triggerSuspensionAfterCommit(suspendCarrierType, suspendCarrierID)
s.triggerSuspensionAfterCommit(ctx, suspendCarrierType, suspendCarrierID)
}
return nil
@@ -405,7 +405,7 @@ func (s *UsageService) checkAndTriggerSuspension(ctx context.Context, tx *gorm.D
}
// triggerSuspensionAfterCommit 在事务提交后触发停机检查,避免回调读到未提交状态。
func (s *UsageService) triggerSuspensionAfterCommit(carrierType string, carrierID uint) {
func (s *UsageService) triggerSuspensionAfterCommit(ctx context.Context, carrierType string, carrierID uint) {
if s.stopResumeCallback == nil {
if carrierType == constants.AssetTypeDevice {
s.logger.Warn("停复机回调未注入,跳过设备绑定卡停机",
@@ -414,9 +414,9 @@ func (s *UsageService) triggerSuspensionAfterCommit(carrierType string, carrierI
return
}
stopCtx := context.WithoutCancel(ctx)
if carrierType == constants.AssetTypeIotCard {
go func() {
stopCtx := context.Background()
if err := s.stopResumeCallback.CheckAndStopCard(stopCtx, carrierID); err != nil {
s.logger.Error("调用停机服务失败",
zap.Uint("card_id", carrierID),
@@ -430,7 +430,7 @@ func (s *UsageService) triggerSuspensionAfterCommit(carrierType string, carrierI
return
}
bindings, err := s.deviceSimBindingStore.ListByDeviceID(context.Background(), carrierID)
bindings, err := s.deviceSimBindingStore.ListByDeviceID(stopCtx, carrierID)
if err != nil {
s.logger.Error("查询设备绑定卡失败",
zap.Uint("device_id", carrierID),
@@ -446,7 +446,6 @@ func (s *UsageService) triggerSuspensionAfterCommit(carrierType string, carrierI
for _, b := range bindings {
cardID := b.IotCardID
go func(cID uint) {
stopCtx := context.Background()
if err := s.stopResumeCallback.CheckAndStopCard(stopCtx, cID); err != nil {
s.logger.Error("调用停机服务失败",
zap.Uint("card_id", cID),

View File

@@ -16,6 +16,7 @@ import (
"github.com/break/junhong_cmp_fiber/pkg/middleware"
"go.uber.org/zap"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// Service 代理系列授权业务服务
@@ -30,6 +31,12 @@ type Service struct {
logger *zap.Logger
}
// initialGrantPackage 保存通过创建前校验的套餐及其请求价格。
type initialGrantPackage struct {
item dto.GrantPackageItem
packageModel *model.Package
}
// New 创建代理系列授权服务实例
func New(
db *gorm.DB,
@@ -53,6 +60,89 @@ func New(
}
}
// validateInitialGrantPackageItems 校验首次授权套餐的数量、价格和批内唯一性。
func validateInitialGrantPackageItems(items []dto.GrantPackageItem) ([]uint, error) {
if len(items) == 0 || len(items) > constants.ShopSeriesGrantMaxPackages {
return nil, errors.New(errors.CodeInvalidParam, "初始授权套餐数量必须为1到100项")
}
packageIDs := make([]uint, 0, len(items))
seen := make(map[uint]struct{}, len(items))
for _, item := range items {
if item.PackageID == 0 || item.CostPrice == nil || *item.CostPrice < 0 {
return nil, errors.New(errors.CodeInvalidParam, "套餐ID和成本价参数无效")
}
if _, exists := seen[item.PackageID]; exists {
return nil, errors.New(errors.CodeInvalidParam, "初始授权套餐ID不能重复")
}
seen[item.PackageID] = struct{}{}
packageIDs = append(packageIDs, item.PackageID)
}
return packageIDs, nil
}
// validateInitialGrantPackages 批量校验首次授权的套餐归属、授权链和成本价边界。
func (s *Service) validateInitialGrantPackages(ctx context.Context, tx *gorm.DB, req *dto.CreateShopSeriesGrantRequest, allocatorShopID uint) ([]initialGrantPackage, error) {
packageIDs, err := validateInitialGrantPackageItems(req.Packages)
if err != nil {
return nil, err
}
var packages []*model.Package
if err := tx.WithContext(ctx).
Clauses(clause.Locking{Strength: "UPDATE"}).
Where("id IN ?", packageIDs).
Find(&packages).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询初始授权套餐失败")
}
if len(packages) != len(packageIDs) {
return nil, errors.New(errors.CodeInvalidParam, "初始授权套餐不存在或已删除")
}
packageMap := make(map[uint]*model.Package, len(packages))
for _, pkg := range packages {
packageMap[pkg.ID] = pkg
}
parentCostMap := make(map[uint]int64, len(packageIDs))
if allocatorShopID > 0 {
var parentAllocations []*model.ShopPackageAllocation
if err := tx.WithContext(ctx).
Clauses(clause.Locking{Strength: "UPDATE"}).
Where("shop_id = ? AND package_id IN ? AND status = ?", allocatorShopID, packageIDs, constants.StatusEnabled).
Find(&parentAllocations).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询上级套餐授权失败")
}
if len(parentAllocations) != len(packageIDs) {
return nil, errors.New(errors.CodeForbidden, "无权限分配套餐")
}
for _, allocation := range parentAllocations {
parentCostMap[allocation.PackageID] = allocation.CostPrice
}
}
validated := make([]initialGrantPackage, 0, len(req.Packages))
for _, item := range req.Packages {
pkg := packageMap[item.PackageID]
if pkg.SeriesID != req.SeriesID {
return nil, errors.New(errors.CodeInvalidParam, "套餐不属于该系列,无法添加到此授权")
}
if pkg.IsGift {
return nil, errors.New(errors.CodeForbidden, "赠送套餐不能分配给代理")
}
minimumCost := pkg.CostPrice
if allocatorShopID > 0 {
minimumCost = parentCostMap[item.PackageID]
}
if *item.CostPrice < minimumCost {
return nil, errors.New(errors.CodeInvalidParam, "授权成本价不能低于当前上级成本价")
}
validated = append(validated, initialGrantPackage{item: item, packageModel: pkg})
}
return validated, nil
}
// getParentCeilingFixed 查询固定模式佣金天花板
// allocatorShopID=0 表示平台分配,天花板为 PackageSeries.commission_amount
// allocatorShopID>0 表示代理分配,天花板为分配者自身的 ShopSeriesAllocation.one_time_commission_amount
@@ -225,10 +315,13 @@ func (s *Service) Create(ctx context.Context, req *dto.CreateShopSeriesGrantRequ
}
config, _ := series.GetOneTimeCommissionConfig()
// 1.5 校验目标店铺是否存在
_, err = s.shopStore.GetByID(ctx, req.ShopID)
// 1.5 校验管理范围,避免通过错误差异探测其他店铺。
if err := middleware.CanManageShop(ctx, req.ShopID); err != nil {
return nil, errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")
}
targetShop, err := s.shopStore.GetByID(ctx, req.ShopID)
if err != nil {
return nil, errors.New(errors.CodeNotFound, "目标店铺不存在")
return nil, errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")
}
// 2. 检查重复授权
@@ -244,12 +337,20 @@ func (s *Service) Create(ctx context.Context, req *dto.CreateShopSeriesGrantRequ
var allocatorShopID uint
if operatorType == constants.UserTypeAgent {
allocatorShopID = operatorShopID
_, err := s.shopSeriesAllocationStore.GetByShopAndSeries(ctx, allocatorShopID, req.SeriesID)
if err != nil {
if allocatorShopID == 0 || targetShop.ParentID == nil || *targetShop.ParentID != allocatorShopID {
return nil, errors.New(errors.CodeForbidden, "只能授权直属下级店铺")
}
var parentSeriesAllocation model.ShopSeriesAllocation
if err := s.db.WithContext(ctx).
Where("shop_id = ? AND series_id = ? AND status = ?", allocatorShopID, req.SeriesID, constants.StatusEnabled).
First(&parentSeriesAllocation).Error; err != nil {
return nil, errors.New(errors.CodeForbidden, "当前账号无此系列授权,无法向下分配")
}
}
// 平台/超管 allocatorShopID = 0
if operatorType != constants.UserTypeAgent && operatorType != constants.UserTypePlatform && operatorType != constants.UserTypeSuperAdmin {
return nil, errors.New(errors.CodeForbidden, "无权限创建系列授权")
}
// 4. 参数验证:仅启用一次性佣金的系列才需要配置佣金金额
allocation := &model.ShopSeriesAllocation{
@@ -322,59 +423,67 @@ func (s *Service) Create(ctx context.Context, req *dto.CreateShopSeriesGrantRequ
// 6. 事务中创建 ShopSeriesAllocation + N 条 ShopPackageAllocation
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var lockedTargetShop model.Shop
if lockErr := tx.WithContext(ctx).
Clauses(clause.Locking{Strength: "UPDATE"}).
First(&lockedTargetShop, req.ShopID).Error; lockErr != nil {
return errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")
}
if allocatorShopID > 0 {
if lockedTargetShop.ParentID == nil || *lockedTargetShop.ParentID != allocatorShopID {
return errors.New(errors.CodeForbidden, "只能授权直属下级店铺")
}
var parentSeriesAllocation model.ShopSeriesAllocation
if lockErr := tx.WithContext(ctx).
Clauses(clause.Locking{Strength: "UPDATE"}).
Where("shop_id = ? AND series_id = ? AND status = ?", allocatorShopID, req.SeriesID, constants.StatusEnabled).
First(&parentSeriesAllocation).Error; lockErr != nil {
return errors.New(errors.CodeForbidden, "当前账号无有效系列授权,无法向下分配")
}
}
validatedPackages, validateErr := s.validateInitialGrantPackages(ctx, tx, req, allocatorShopID)
if validateErr != nil {
return validateErr
}
txSeriesStore := postgres.NewShopSeriesAllocationStore(tx)
if createErr := txSeriesStore.Create(ctx, allocation); createErr != nil {
return errors.Wrap(errors.CodeDatabaseError, createErr, "创建系列授权失败")
}
// 创建套餐分配
if len(req.Packages) > 0 {
txPkgStore := postgres.NewShopPackageAllocationStore(tx)
txHistoryStore := postgres.NewShopPackageAllocationPriceHistoryStore(tx)
for _, item := range req.Packages {
if item.Remove != nil && *item.Remove {
continue
}
// W1: 校验套餐归属于该系列,防止跨系列套餐混入
pkg, pkgErr := s.packageStore.GetByID(ctx, item.PackageID)
if pkgErr != nil || pkg.SeriesID != req.SeriesID {
return errors.New(errors.CodeInvalidParam, "套餐不属于该系列,无法添加到此授权")
}
if pkg.IsGift {
return errors.New(errors.CodeForbidden, "赠送套餐不能分配给代理")
}
// W2: 代理操作时,校验分配者已拥有此套餐授权,防止越权分配
if allocatorShopID > 0 {
_, authErr := s.shopPackageAllocationStore.GetByShopAndPackageForSystem(ctx, allocatorShopID, item.PackageID)
if authErr != nil {
return errors.New(errors.CodeForbidden, "无权限分配该套餐")
}
}
pkgAlloc := &model.ShopPackageAllocation{
ShopID: req.ShopID,
PackageID: item.PackageID,
AllocatorShopID: allocatorShopID,
CostPrice: item.CostPrice,
RetailPrice: pkg.SuggestedRetailPrice,
RetailPriceConfigStatus: pkg.PriceConfigStatus,
SeriesAllocationID: &allocation.ID,
ExpiryBaseOverride: expiryBaseOverride,
Status: constants.StatusEnabled,
ShelfStatus: constants.ShelfStatusOn,
}
pkgAlloc.Creator = operatorID
pkgAlloc.Updater = operatorID
if err := txPkgStore.Create(ctx, pkgAlloc); err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "创建套餐分配失败")
}
_ = txHistoryStore.Create(ctx, &model.ShopPackageAllocationPriceHistory{
AllocationID: pkgAlloc.ID,
OldCostPrice: 0,
NewCostPrice: item.CostPrice,
ChangeReason: "初始授权",
ChangedBy: operatorID,
EffectiveFrom: time.Now(),
})
// 所有套餐已在当前事务内完成批量校验,此处只写入同一业务单元。
txPkgStore := postgres.NewShopPackageAllocationStore(tx)
txHistoryStore := postgres.NewShopPackageAllocationPriceHistoryStore(tx)
for _, validated := range validatedPackages {
item := validated.item
pkg := validated.packageModel
pkgAlloc := &model.ShopPackageAllocation{
ShopID: req.ShopID,
PackageID: item.PackageID,
AllocatorShopID: allocatorShopID,
CostPrice: *item.CostPrice,
RetailPrice: pkg.SuggestedRetailPrice,
RetailPriceConfigStatus: pkg.PriceConfigStatus,
SeriesAllocationID: &allocation.ID,
ExpiryBaseOverride: expiryBaseOverride,
Status: constants.StatusEnabled,
ShelfStatus: constants.ShelfStatusOn,
}
pkgAlloc.Creator = operatorID
pkgAlloc.Updater = operatorID
if err := txPkgStore.Create(ctx, pkgAlloc); err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "创建套餐分配失败")
}
if err := txHistoryStore.Create(ctx, &model.ShopPackageAllocationPriceHistory{
AllocationID: pkgAlloc.ID,
OldCostPrice: 0,
NewCostPrice: *item.CostPrice,
ChangeReason: "初始授权",
ChangedBy: operatorID,
EffectiveFrom: time.Now(),
}); err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "创建套餐价格历史失败")
}
}
@@ -639,45 +748,56 @@ func (s *Service) ManagePackages(ctx context.Context, id uint, req *dto.ManageGr
for _, item := range req.Packages {
if item.Remove != nil && *item.Remove {
// 软删除已有的 active 分配
// 软删除已有的有效分配
existing, findErr := txPkgStore.GetByShopAndPackageForSystem(ctx, allocation.ShopID, item.PackageID)
if findErr != nil {
// 找不到则静默忽略
// 已不存在时按幂等成功处理
continue
}
_ = txPkgStore.Delete(ctx, existing.ID)
if deleteErr := txPkgStore.Delete(ctx, existing.ID); deleteErr != nil {
return errors.Wrap(errors.CodeDatabaseError, deleteErr, "删除套餐分配失败")
}
continue
}
if item.CostPrice == nil {
return errors.New(errors.CodeInvalidParam, "新增或修改套餐授权时必须提交成本价")
}
costPrice := *item.CostPrice
// 新增或更新套餐分配
existing, findErr := txPkgStore.GetByShopAndPackageForSystem(ctx, allocation.ShopID, item.PackageID)
if findErr == nil {
// 已有记录:更新成本价并写历史
oldPrice := existing.CostPrice
if oldPrice != item.CostPrice {
if oldPrice != costPrice {
// cost_price 锁定检查:存在下级分配记录时禁止修改
var subCount int64
tx.Model(&model.ShopPackageAllocation{}).
if countErr := tx.Model(&model.ShopPackageAllocation{}).
Where("allocator_shop_id = ? AND package_id = ? AND deleted_at IS NULL", allocation.ShopID, item.PackageID).
Count(&subCount)
Count(&subCount).Error; countErr != nil {
return errors.Wrap(errors.CodeDatabaseError, countErr, "查询下级套餐分配失败")
}
if subCount > 0 {
return errors.New(errors.CodeForbidden, "存在下级分配记录,请先回收后再修改成本价")
}
}
existing.CostPrice = item.CostPrice
existing.CostPrice = costPrice
existing.Updater = operatorID
if updateErr := txPkgStore.Update(ctx, existing); updateErr != nil {
return errors.Wrap(errors.CodeDatabaseError, updateErr, "更新套餐分配失败")
}
if oldPrice != item.CostPrice {
_ = txHistoryStore.Create(ctx, &model.ShopPackageAllocationPriceHistory{
if oldPrice != costPrice {
if historyErr := txHistoryStore.Create(ctx, &model.ShopPackageAllocationPriceHistory{
AllocationID: existing.ID,
OldCostPrice: oldPrice,
NewCostPrice: item.CostPrice,
NewCostPrice: costPrice,
ChangeReason: "手动调价",
ChangedBy: operatorID,
EffectiveFrom: time.Now(),
})
}); historyErr != nil {
return errors.Wrap(errors.CodeDatabaseError, historyErr, "创建套餐价格历史失败")
}
}
} else {
pkg, pkgErr := s.packageStore.GetByID(ctx, item.PackageID)
@@ -697,7 +817,7 @@ func (s *Service) ManagePackages(ctx context.Context, id uint, req *dto.ManageGr
ShopID: allocation.ShopID,
PackageID: item.PackageID,
AllocatorShopID: allocation.AllocatorShopID,
CostPrice: item.CostPrice,
CostPrice: costPrice,
RetailPrice: pkg.SuggestedRetailPrice,
RetailPriceConfigStatus: pkg.PriceConfigStatus,
SeriesAllocationID: &allocation.ID,
@@ -710,14 +830,16 @@ func (s *Service) ManagePackages(ctx context.Context, id uint, req *dto.ManageGr
if createErr := txPkgStore.Create(ctx, pkgAlloc); createErr != nil {
return errors.Wrap(errors.CodeDatabaseError, createErr, "创建套餐分配失败")
}
_ = txHistoryStore.Create(ctx, &model.ShopPackageAllocationPriceHistory{
if historyErr := txHistoryStore.Create(ctx, &model.ShopPackageAllocationPriceHistory{
AllocationID: pkgAlloc.ID,
OldCostPrice: 0,
NewCostPrice: item.CostPrice,
NewCostPrice: costPrice,
ChangeReason: "新增授权",
ChangedBy: operatorID,
EffectiveFrom: time.Now(),
})
}); historyErr != nil {
return errors.Wrap(errors.CodeDatabaseError, historyErr, "创建套餐价格历史失败")
}
}
}
return nil