收口七月卡状态回调与系列授权兼容契约
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:
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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, "刷新卡数据失败")
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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(¤tUsage, packageUsageID).Error; err != nil {
|
||||
@@ -323,21 +344,16 @@ func (s *ActivationService) ActivateSpecificPackage(ctx context.Context, package
|
||||
if err := s.activatePendingUsage(ctx, tx, ¤tUsage, &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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user