feat(通道流量阈值): AUG26-011 运营商通道流量阈值达量停机与周期复机
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 12m59s
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 12m59s
This commit is contained in:
@@ -8,6 +8,7 @@ import (
|
||||
"gorm.io/gorm"
|
||||
|
||||
systemconfigapp "github.com/break/junhong_cmp_fiber/internal/application/systemconfig"
|
||||
carrierthreshold "github.com/break/junhong_cmp_fiber/internal/domain/carrierthreshold"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model/dto"
|
||||
"github.com/break/junhong_cmp_fiber/internal/store"
|
||||
@@ -33,6 +34,25 @@ func (s *Service) Create(ctx context.Context, req *dto.CreateCarrierRequest) (*d
|
||||
return nil, errors.New(errors.CodeUnauthorized, "未授权访问")
|
||||
}
|
||||
|
||||
// 阈值字段只由超级管理员与平台账号配置:非平台账号提交即整体拒绝,不落任何配置。
|
||||
if req.HasTrafficThresholdFields() && !canManageThreshold(ctx) {
|
||||
s.recordDenied(ctx, constants.AuditOperationCarrierCreate, "拒绝非平台账号配置运营商通道流量阈值",
|
||||
thresholdCreateProbe(req), errors.CodeForbidden)
|
||||
return nil, errors.New(errors.CodeForbidden, constants.PlatformManagementForbiddenMessage)
|
||||
}
|
||||
thresholdEnabled, thresholdValue, thresholdUnit := 0, req.TrafficThresholdValue, ""
|
||||
if req.TrafficThresholdEnabled != nil {
|
||||
thresholdEnabled = *req.TrafficThresholdEnabled
|
||||
}
|
||||
if req.TrafficThresholdUnit != nil {
|
||||
thresholdUnit = *req.TrafficThresholdUnit
|
||||
}
|
||||
if err := validateThresholdConfig(thresholdEnabled, thresholdValue, thresholdUnit); err != nil {
|
||||
s.recordDenied(ctx, constants.AuditOperationCarrierCreate, "拒绝保存非法运营商通道流量阈值配置",
|
||||
thresholdCreateProbe(req), errors.CodeInvalidParam)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
existing, _ := s.carrierStore.GetByCode(ctx, req.CarrierCode)
|
||||
if existing != nil {
|
||||
s.recordDenied(ctx, constants.AuditOperationCarrierCreate, "拒绝创建重复运营商配置", existing, errors.CodeCarrierCodeExists)
|
||||
@@ -49,6 +69,10 @@ func (s *Service) Create(ctx context.Context, req *dto.CreateCarrierRequest) (*d
|
||||
Description: req.Description,
|
||||
Status: constants.StatusEnabled,
|
||||
DataResetDay: 1,
|
||||
|
||||
TrafficThresholdEnabled: thresholdEnabled,
|
||||
TrafficThresholdValue: thresholdValue,
|
||||
TrafficThresholdUnit: thresholdUnit,
|
||||
}
|
||||
if req.DataResetDay != nil {
|
||||
carrier.DataResetDay = *req.DataResetDay
|
||||
@@ -72,7 +96,7 @@ func (s *Service) Create(ctx context.Context, req *dto.CreateCarrierRequest) (*d
|
||||
return nil, errors.Wrap(errors.CodeInternalError, err, "创建运营商失败")
|
||||
}
|
||||
|
||||
return s.toResponse(carrier), nil
|
||||
return s.toResponse(ctx, carrier), nil
|
||||
}
|
||||
|
||||
func (s *Service) Get(ctx context.Context, id uint) (*dto.CarrierResponse, error) {
|
||||
@@ -83,7 +107,7 @@ func (s *Service) Get(ctx context.Context, id uint) (*dto.CarrierResponse, error
|
||||
}
|
||||
return nil, errors.Wrap(errors.CodeInternalError, err, "获取运营商失败")
|
||||
}
|
||||
return s.toResponse(carrier), nil
|
||||
return s.toResponse(ctx, carrier), nil
|
||||
}
|
||||
|
||||
func (s *Service) Update(ctx context.Context, id uint, req *dto.UpdateCarrierRequest) (*dto.CarrierResponse, error) {
|
||||
@@ -101,6 +125,32 @@ func (s *Service) Update(ctx context.Context, id uint, req *dto.UpdateCarrierReq
|
||||
}
|
||||
before := *carrier
|
||||
|
||||
// 阈值字段只由超级管理员与平台账号修改:非平台账号提交即整体拒绝。
|
||||
if req.HasTrafficThresholdFields() && !canManageThreshold(ctx) {
|
||||
s.recordDenied(ctx, constants.AuditOperationCarrierUpdate, "拒绝非平台账号配置运营商通道流量阈值",
|
||||
&before, errors.CodeForbidden)
|
||||
return nil, errors.New(errors.CodeForbidden, constants.PlatformManagementForbiddenMessage)
|
||||
}
|
||||
// 阈值按「请求覆盖既有配置」后的结果校验,避免保存出启用但缺数值/单位的半配置。
|
||||
thresholdEnabled, thresholdValue, thresholdUnit := carrier.TrafficThresholdEnabled, carrier.TrafficThresholdValue, carrier.TrafficThresholdUnit
|
||||
if req.TrafficThresholdEnabled != nil {
|
||||
thresholdEnabled = *req.TrafficThresholdEnabled
|
||||
}
|
||||
if req.TrafficThresholdValue != nil {
|
||||
thresholdValue = req.TrafficThresholdValue
|
||||
}
|
||||
if req.TrafficThresholdUnit != nil {
|
||||
thresholdUnit = *req.TrafficThresholdUnit
|
||||
}
|
||||
if err := validateThresholdConfig(thresholdEnabled, thresholdValue, thresholdUnit); err != nil {
|
||||
s.recordDenied(ctx, constants.AuditOperationCarrierUpdate, "拒绝保存非法运营商通道流量阈值配置",
|
||||
&before, errors.CodeInvalidParam)
|
||||
return nil, err
|
||||
}
|
||||
carrier.TrafficThresholdEnabled = thresholdEnabled
|
||||
carrier.TrafficThresholdValue = thresholdValue
|
||||
carrier.TrafficThresholdUnit = thresholdUnit
|
||||
|
||||
if req.CarrierName != nil {
|
||||
carrier.CarrierName = *req.CarrierName
|
||||
}
|
||||
@@ -133,7 +183,7 @@ func (s *Service) Update(ctx context.Context, id uint, req *dto.UpdateCarrierReq
|
||||
return nil, errors.Wrap(errors.CodeInternalError, err, "更新运营商失败")
|
||||
}
|
||||
|
||||
return s.toResponse(carrier), nil
|
||||
return s.toResponse(ctx, carrier), nil
|
||||
}
|
||||
|
||||
func (s *Service) Delete(ctx context.Context, id uint) error {
|
||||
@@ -193,7 +243,7 @@ func (s *Service) List(ctx context.Context, req *dto.CarrierListRequest) ([]*dto
|
||||
|
||||
responses := make([]*dto.CarrierResponse, len(carriers))
|
||||
for i, c := range carriers {
|
||||
responses[i] = s.toResponse(c)
|
||||
responses[i] = s.toResponse(ctx, c)
|
||||
}
|
||||
|
||||
return responses, total, nil
|
||||
@@ -303,11 +353,56 @@ func carrierAuditSnapshot(carrier *model.Carrier) map[string]any {
|
||||
"carrier_type": carrier.CarrierType, "description": carrier.Description, "status": carrier.Status,
|
||||
"realname_link_type": carrier.RealnameLinkType, "realname_link_template": carrier.RealnameLinkTemplate,
|
||||
"data_reset_day": carrier.DataResetDay,
|
||||
// 通道流量阈值的新增、修改、启用与停用都并入既有运营商更新审计的前后值快照,不另建审计动作。
|
||||
"traffic_threshold_enabled": carrier.TrafficThresholdEnabled,
|
||||
"traffic_threshold_value": carrier.TrafficThresholdValue,
|
||||
"traffic_threshold_unit": carrier.TrafficThresholdUnit,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) toResponse(c *model.Carrier) *dto.CarrierResponse {
|
||||
return &dto.CarrierResponse{
|
||||
// canManageThreshold 判断当前账号是否具备通道流量阈值字段的读写权限。
|
||||
// 只有超级管理员与平台账号可见可写,与 requirePlatformManagement 的判定一致(引用同一用户类型枚举)。
|
||||
func canManageThreshold(ctx context.Context) bool {
|
||||
userType := middleware.GetUserTypeFromContext(ctx)
|
||||
return userType == constants.UserTypeSuperAdmin || userType == constants.UserTypePlatform
|
||||
}
|
||||
|
||||
// validateThresholdConfig 校验通道流量阈值配置:启停仅 0/1、数值必须为正、单位仅 MB/GB、配置齐备。
|
||||
// 「启用但无数值/单位」与「只给数值不给单位」都会让达量判定失去唯一口径;
|
||||
// 非 0/1 的启停取值会被判定端按「未启用」静默处理,因此必须显式拒绝。
|
||||
func validateThresholdConfig(enabled int, value *float64, unit string) error {
|
||||
if enabled != constants.StatusDisabled && enabled != constants.StatusEnabled {
|
||||
return errors.New(errors.CodeInvalidParam, "通道流量阈值是否启用仅支持 0 或 1")
|
||||
}
|
||||
if value != nil && *value <= 0 {
|
||||
return errors.New(errors.CodeInvalidParam, "通道流量阈值数值必须为正数")
|
||||
}
|
||||
if unit != "" && unit != carrierthreshold.UnitMB && unit != carrierthreshold.UnitGB {
|
||||
return errors.New(errors.CodeInvalidParam, "通道流量阈值单位仅支持 MB 或 GB")
|
||||
}
|
||||
configured := value != nil || unit != ""
|
||||
if configured && (value == nil || unit == "") {
|
||||
return errors.New(errors.CodeInvalidParam, "通道流量阈值数值与流量单位必须同时提供")
|
||||
}
|
||||
if enabled == constants.StatusEnabled && !configured {
|
||||
return errors.New(errors.CodeInvalidParam, "启用通道流量阈值必须同时提供正的阈值数值与流量单位")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// thresholdCreateProbe 构造仅供拒绝审计使用的运营商身份,不写库。
|
||||
func thresholdCreateProbe(req *dto.CreateCarrierRequest) *model.Carrier {
|
||||
if req == nil {
|
||||
return nil
|
||||
}
|
||||
return &model.Carrier{
|
||||
CarrierCode: req.CarrierCode, CarrierName: req.CarrierName, CarrierType: req.CarrierType,
|
||||
}
|
||||
}
|
||||
|
||||
// toResponse 把运营商事实投影为响应;阈值字段只对超级管理员与平台账号返回。
|
||||
func (s *Service) toResponse(ctx context.Context, c *model.Carrier) *dto.CarrierResponse {
|
||||
result := &dto.CarrierResponse{
|
||||
ID: c.ID,
|
||||
CarrierCode: c.CarrierCode,
|
||||
CarrierName: c.CarrierName,
|
||||
@@ -320,4 +415,15 @@ func (s *Service) toResponse(c *model.Carrier) *dto.CarrierResponse {
|
||||
CreatedAt: c.CreatedAt.Format(time.RFC3339),
|
||||
UpdatedAt: c.UpdatedAt.Format(time.RFC3339),
|
||||
}
|
||||
if !canManageThreshold(ctx) {
|
||||
return result
|
||||
}
|
||||
enabled := c.TrafficThresholdEnabled
|
||||
result.TrafficThresholdEnabled = &enabled
|
||||
result.TrafficThresholdValue = c.TrafficThresholdValue
|
||||
if c.TrafficThresholdUnit != "" {
|
||||
unit := c.TrafficThresholdUnit
|
||||
result.TrafficThresholdUnit = &unit
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
222
internal/service/iot_card/channel_threshold.go
Normal file
222
internal/service/iot_card/channel_threshold.go
Normal file
@@ -0,0 +1,222 @@
|
||||
package iot_card
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"context"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
carddomain "github.com/break/junhong_cmp_fiber/internal/domain/cardobservation"
|
||||
carrierthresholddomain "github.com/break/junhong_cmp_fiber/internal/domain/carrierthreshold"
|
||||
"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"
|
||||
)
|
||||
|
||||
// ChannelThresholdLockGuard 判断卡在当前计费周期是否持有运营商通道流量阈值停机锁。
|
||||
// 由通道阈值能力实现并注入,停复机服务只做前置拒绝判定,不复制周期与锁的规则。
|
||||
type ChannelThresholdLockGuard interface {
|
||||
ChannelThresholdLocked(ctx context.Context, cardID uint) (bool, error)
|
||||
}
|
||||
|
||||
// ChannelThresholdResumeDeniedMessage 是持通道阈值锁复机被拒的统一可读文案。
|
||||
const ChannelThresholdResumeDeniedMessage = "该卡当前计费周期已达到运营商通道流量阈值并被锁定,禁止复机"
|
||||
|
||||
// ChannelThresholdDenialSnapshotKey 是拒绝审计中标记「持通道阈值锁」的快照键。
|
||||
const ChannelThresholdDenialSnapshotKey = "channel_threshold_lock"
|
||||
|
||||
// SetChannelThresholdLockGuard 注入通道阈值持锁判定能力。
|
||||
func (s *StopResumeService) SetChannelThresholdLockGuard(guard ChannelThresholdLockGuard) {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
s.channelThresholdGuard = guard
|
||||
}
|
||||
|
||||
// StopCardForChannelThreshold 执行通道阈值达量停机。
|
||||
//
|
||||
// 复用既有停机重试、Integration Log 与统一审计:成功后同一事务写回 network_status=offline、
|
||||
// stop_reason=channel_threshold、stopped_at。返回的 outcome 始终携带可回填的结果分类
|
||||
// (成功/失败/未知);error 只表示执行过程本身的基础设施故障(读卡、写审计或写卡失败),
|
||||
// 此时调用方不得据此判定终态,应保留 submitted 等待恢复扫描确认。
|
||||
func (s *StopResumeService) StopCardForChannelThreshold(ctx context.Context, cardID uint) (carrierthresholddomain.CommandOutcome, error) {
|
||||
card, err := s.iotCardStore.GetByID(ctx, cardID)
|
||||
if err != nil {
|
||||
return carrierthresholddomain.CommandOutcome{}, errors.Wrap(errors.CodeDatabaseError, err, "读取通道阈值停机卡事实失败")
|
||||
}
|
||||
return s.stopCardWithRetry(ctx, card, constants.StopReasonChannelThreshold)
|
||||
}
|
||||
|
||||
// ResumeCardForChannelThreshold 执行通道阈值新周期复机。
|
||||
//
|
||||
// 复用既有复机重试、Integration Log 与统一审计;成功时在同一事务写回 network_status=online、
|
||||
// resumed_at,并且只在该卡停因正是通道阈值时清除停因,绝不覆盖其他停因。
|
||||
func (s *StopResumeService) ResumeCardForChannelThreshold(ctx context.Context, cardID uint) (carrierthresholddomain.CommandOutcome, error) {
|
||||
card, err := s.iotCardStore.GetByID(ctx, cardID)
|
||||
if err != nil {
|
||||
return carrierthresholddomain.CommandOutcome{}, errors.Wrap(errors.CodeDatabaseError, err, "读取通道阈值复机卡事实失败")
|
||||
}
|
||||
actionCode, summary := constants.AuditActionIotCardAutoStarted, "自动恢复 IoT 卡网络"
|
||||
attempt, integrationID, err := s.resumeCardWithRetry(ctx, card, actionCode, summary)
|
||||
if err != nil {
|
||||
return carrierthresholddomain.CommandOutcome{
|
||||
IntegrationID: integrationID, Result: cardCommandAuditResult(err),
|
||||
}, err
|
||||
}
|
||||
fields := map[string]any{
|
||||
"network_status": constants.NetworkStatusOnline,
|
||||
"resumed_at": time.Now(),
|
||||
}
|
||||
if card.StopReason == constants.StopReasonChannelThreshold {
|
||||
fields["stop_reason"] = ""
|
||||
}
|
||||
if err := s.updateCardAndAppendNetworkSeries(ctx, card, fields,
|
||||
constants.CardObservationSceneBusinessResume, "online", uuid.NewString(), actionCode, summary, attempt.log.IntegrationID); err != nil {
|
||||
if logErr := s.completeCardCommandAttempt(ctx, attempt, nil, false); logErr != nil {
|
||||
s.logger.Error("终结通道阈值复机 Integration Log 失败", zap.String("integration_id", attempt.log.IntegrationID), zap.Error(logErr))
|
||||
}
|
||||
s.recordCardCommandAudit(ctx, card, actionCode, summary+"结果待核对", constants.AuditResultUnknown,
|
||||
attempt.log.IntegrationID, cardSnapshot(card), map[string]any{"requested_network_status": constants.NetworkStatusOnline}, err)
|
||||
// 运营商已成功但本地回写失败:按结果未知交恢复扫描查询确认。
|
||||
return carrierthresholddomain.CommandOutcome{
|
||||
IntegrationID: attempt.log.IntegrationID, Result: constants.AuditResultUnknown,
|
||||
}, err
|
||||
}
|
||||
s.reschedulePolling(ctx, card.ID)
|
||||
if logErr := s.completeCardCommandAttempt(ctx, attempt, nil, true); logErr != nil {
|
||||
return carrierthresholddomain.CommandOutcome{
|
||||
IntegrationID: attempt.log.IntegrationID, Result: constants.AuditResultSuccess,
|
||||
}, errors.Wrap(errors.CodeDatabaseError, logErr, "终结通道阈值复机 Integration Log 失败")
|
||||
}
|
||||
s.logger.Info("通道阈值新周期复机成功",
|
||||
zap.Uint("card_id", card.ID), zap.String("iccid", card.ICCID))
|
||||
return carrierthresholddomain.CommandOutcome{
|
||||
IntegrationID: attempt.log.IntegrationID, Result: constants.AuditResultSuccess,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ChannelThresholdResumeReady 判断通道阈值跨期解锁后的卡是否满足自动复机条件。
|
||||
//
|
||||
// 条件全部复用既有单一事实源:有效主套餐、流量未耗尽、实名满足、非风险网关扩展,且不存在其他停机锁
|
||||
// (停因为空或正是通道阈值本身)。返回的 reason 是不满足原因(可安全记录),满足时为空。
|
||||
func (s *StopResumeService) ChannelThresholdResumeReady(ctx context.Context, cardID uint) (bool, string, error) {
|
||||
card, err := s.iotCardStore.GetByID(ctx, cardID)
|
||||
if err != nil {
|
||||
return false, "", errors.Wrap(errors.CodeDatabaseError, err, "读取通道阈值复机判定卡事实失败")
|
||||
}
|
||||
// 其他停机锁:风险网关扩展(风险停机/销户)或非通道阈值的停因。
|
||||
if isRiskGatewayExtend(card.GatewayExtend) {
|
||||
return false, "网关扩展状态为风险停机或已销户,不自动复机", nil
|
||||
}
|
||||
if card.StopReason != "" && card.StopReason != constants.StopReasonChannelThreshold {
|
||||
return false, "存在其他停机原因,不自动复机", nil
|
||||
}
|
||||
hasPackage, err := s.hasValidPackage(ctx, card)
|
||||
if err != nil {
|
||||
return false, "", err
|
||||
}
|
||||
if !hasPackage {
|
||||
return false, "无有效主套餐,不自动复机", nil
|
||||
}
|
||||
exhausted, err := s.isTrafficExhausted(ctx, card)
|
||||
if err != nil {
|
||||
return false, "", err
|
||||
}
|
||||
if exhausted {
|
||||
return false, "套餐流量已耗尽,不自动复机", nil
|
||||
}
|
||||
realnameOK, err := s.isRealnameOK(ctx, card)
|
||||
if err != nil {
|
||||
return false, "", err
|
||||
}
|
||||
if !realnameOK {
|
||||
return false, "实名要求未满足,不自动复机", nil
|
||||
}
|
||||
return true, "", nil
|
||||
}
|
||||
|
||||
// QueryChannelThresholdCardStatus 只查询运营商卡状态并映射为本地网络状态,供恢复扫描回填。
|
||||
//
|
||||
// 不发起任何停复机调用:只做状态查询,并按既有网关状态映射规则给出本地网络状态。
|
||||
// 查询失败把 known 置 false 并返回错误,调用方必须按「仍未确认」处理,不得判为失败终态。
|
||||
func (s *StopResumeService) QueryChannelThresholdCardStatus(ctx context.Context, cardID uint) (int, bool, string, error) {
|
||||
card, err := s.iotCardStore.GetByID(ctx, cardID)
|
||||
if err != nil {
|
||||
return 0, false, "", errors.Wrap(errors.CodeDatabaseError, err, "读取通道阈值状态查询卡事实失败")
|
||||
}
|
||||
if s.gatewayClient == nil {
|
||||
return 0, false, "", errors.New(errors.CodeInternalError, "Gateway 未配置,无法查询卡状态")
|
||||
}
|
||||
attempt, err := s.startCardCommandAttempt(ctx, card, constants.IntegrationOperationGatewayNetwork,
|
||||
constants.CardObservationSceneCarrierThresholdRecovery, cardCommandSeriesKey(ctx), 1)
|
||||
if err != nil {
|
||||
return 0, false, "", err
|
||||
}
|
||||
response, callErr := s.gatewayClient.QueryCardStatus(ctx, &gateway.CardStatusReq{CardNo: card.ICCID})
|
||||
if callErr != nil {
|
||||
if completeErr := s.completeCardCommandAttempt(ctx, attempt, callErr, false); completeErr != nil {
|
||||
s.logger.Error("终结通道阈值状态查询 Integration Log 失败",
|
||||
zap.String("integration_id", attempt.log.IntegrationID), zap.Error(completeErr))
|
||||
}
|
||||
return 0, false, attempt.log.IntegrationID, callErr
|
||||
}
|
||||
if completeErr := s.completeCardCommandAttempt(ctx, attempt, nil, false); completeErr != nil {
|
||||
s.logger.Error("终结通道阈值状态查询 Integration Log 失败",
|
||||
zap.String("integration_id", attempt.log.IntegrationID), zap.Error(completeErr))
|
||||
return 0, false, attempt.log.IntegrationID, completeErr
|
||||
}
|
||||
status, known := carddomain.MapGatewayNetworkStatus(response.CardStatus, response.Extend)
|
||||
return status, known, attempt.log.IntegrationID, nil
|
||||
}
|
||||
|
||||
// ConfirmChannelThresholdCardState 按已确认的运营商结果补写卡状态,覆盖运营商调用成功但本地回写失败的场景。
|
||||
//
|
||||
// offline=true 写回停机事实(停因为通道阈值);offline=false 写回在线事实,并且只在该卡停因
|
||||
// 正是通道阈值时清除停因,绝不覆盖其他停因。卡状态与统一审计、观测序列在同一事务写入。
|
||||
func (s *StopResumeService) ConfirmChannelThresholdCardState(ctx context.Context, cardID uint, offline bool) error {
|
||||
card, err := s.iotCardStore.GetByID(ctx, cardID)
|
||||
if err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "读取通道阈值确认卡事实失败")
|
||||
}
|
||||
actionCode, summary := constants.AuditActionIotCardAutoStarted, "自动恢复 IoT 卡网络"
|
||||
scene, expected := constants.CardObservationSceneBusinessResume, "online"
|
||||
fields := map[string]any{
|
||||
"network_status": constants.NetworkStatusOnline,
|
||||
"resumed_at": time.Now(),
|
||||
}
|
||||
if offline {
|
||||
actionCode, summary = constants.AuditActionIotCardAutoStopped, "自动停用 IoT 卡网络"
|
||||
scene, expected = constants.CardObservationSceneBusinessStop, "offline"
|
||||
fields = map[string]any{
|
||||
"network_status": constants.NetworkStatusOffline,
|
||||
"stopped_at": time.Now(),
|
||||
"stop_reason": constants.StopReasonChannelThreshold,
|
||||
}
|
||||
} else if card.StopReason == constants.StopReasonChannelThreshold {
|
||||
fields["stop_reason"] = ""
|
||||
}
|
||||
return s.updateCardAndAppendNetworkSeries(ctx, card, fields, scene, expected, uuid.NewString(), actionCode, summary, "")
|
||||
}
|
||||
|
||||
// channelThresholdBlocked 判断复机入口是否必须拒绝:卡在当前周期持有通道阈值停机锁。
|
||||
// 判定失败直接返回错误,绝不在事实不可读时放开复机。
|
||||
func (s *StopResumeService) channelThresholdBlocked(ctx context.Context, card *model.IotCard) (bool, error) {
|
||||
if s == nil || s.channelThresholdGuard == nil || card == nil || card.ID == 0 {
|
||||
return false, nil
|
||||
}
|
||||
return s.channelThresholdGuard.ChannelThresholdLocked(ctx, card.ID)
|
||||
}
|
||||
|
||||
// recordChannelThresholdDenial 记录持锁复机被拒的事实:不改卡状态、不解除锁、不调用运营商。
|
||||
func (s *StopResumeService) recordChannelThresholdDenial(ctx context.Context, card *model.IotCard, actionCode, summary string) {
|
||||
s.recordCardCommandAudit(ctx, card, actionCode, summary+"被拒绝", constants.AuditResultDenied, "",
|
||||
cardSnapshot(card), map[string]any{ChannelThresholdDenialSnapshotKey: true}, channelThresholdDenyError())
|
||||
}
|
||||
|
||||
// channelThresholdDenyError 返回持锁复机被拒的稳定业务错误(中文可读)。
|
||||
func channelThresholdDenyError() error {
|
||||
return errors.New(errors.CodeForbidden, ChannelThresholdResumeDeniedMessage)
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"time"
|
||||
|
||||
cardObservationApp "github.com/break/junhong_cmp_fiber/internal/application/cardobservation"
|
||||
carrierthresholddomain "github.com/break/junhong_cmp_fiber/internal/domain/carrierthreshold"
|
||||
"github.com/google/uuid"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"go.uber.org/zap"
|
||||
@@ -53,6 +54,8 @@ type StopResumeService struct {
|
||||
|
||||
maxRetries int
|
||||
retryInterval time.Duration
|
||||
// channelThresholdGuard 是可选的通道阈值持锁判定能力;未注入时不做持锁拒绝。
|
||||
channelThresholdGuard ChannelThresholdLockGuard
|
||||
}
|
||||
|
||||
// SetObservationSeriesEventWriter 注入停复机成功观测序列 Outbox Writer。
|
||||
@@ -120,7 +123,8 @@ func (s *StopResumeService) EvaluateAndAct(ctx context.Context, card *model.IotC
|
||||
return s.stopDeviceCards(ctx, deviceID, primaryReason)
|
||||
}
|
||||
}
|
||||
return s.stopCardWithRetry(ctx, card, primaryReason)
|
||||
_, stopErr := s.stopCardWithRetry(ctx, card, primaryReason)
|
||||
return stopErr
|
||||
|
||||
case constants.NetworkStatusOffline:
|
||||
// 卡停机,检查是否可以复机
|
||||
@@ -353,7 +357,7 @@ func (s *StopResumeService) stopDeviceCards(ctx context.Context, deviceID uint,
|
||||
|
||||
var cardErrors []error
|
||||
for _, card := range cards {
|
||||
if stopErr := s.stopCardWithRetry(ctx, card, stopReason); stopErr != nil {
|
||||
if _, stopErr := s.stopCardWithRetry(ctx, card, stopReason); stopErr != nil {
|
||||
cardErrors = append(cardErrors, stopErr)
|
||||
s.logger.Warn("设备卡停机失败,继续处理其他卡",
|
||||
zap.Uint("device_id", deviceID),
|
||||
@@ -450,16 +454,26 @@ func (s *StopResumeService) ForceStopCard(ctx context.Context, card *model.IotCa
|
||||
if card == nil || card.ID == 0 {
|
||||
return errors.New(errors.CodeInvalidParam)
|
||||
}
|
||||
return s.stopCardWithRetry(ctx, card, stopReason)
|
||||
_, err := s.stopCardWithRetry(ctx, card, stopReason)
|
||||
return err
|
||||
}
|
||||
|
||||
// ForceStartCard 强制复机单张卡,不执行正常复机条件判断。
|
||||
// 持通道阈值锁的卡必须先拒绝,再走既有保护期一致性检查,避免强行复机锁定期内的卡。
|
||||
func (s *StopResumeService) ForceStartCard(ctx context.Context, card *model.IotCard) error {
|
||||
if card == nil || card.ID == 0 {
|
||||
return errors.New(errors.CodeInvalidParam)
|
||||
}
|
||||
actionCode, summary := constants.AuditActionIotCardAutoStarted, "自动恢复 IoT 卡网络"
|
||||
attempt, err := s.resumeCardWithRetry(ctx, card, actionCode, summary)
|
||||
blocked, err := s.channelThresholdBlocked(ctx, card)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if blocked {
|
||||
s.recordChannelThresholdDenial(ctx, card, actionCode, summary)
|
||||
return channelThresholdDenyError()
|
||||
}
|
||||
attempt, integrationID, err := s.resumeCardWithRetry(ctx, card, actionCode, summary)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -467,7 +481,7 @@ func (s *StopResumeService) ForceStartCard(ctx context.Context, card *model.IotC
|
||||
"network_status": constants.NetworkStatusOnline,
|
||||
"resumed_at": time.Now(),
|
||||
"stop_reason": "",
|
||||
}, constants.CardObservationSceneBusinessResume, "online", uuid.NewString(), actionCode, summary, attempt.log.IntegrationID); err != nil {
|
||||
}, constants.CardObservationSceneBusinessResume, "online", uuid.NewString(), actionCode, summary, integrationID); err != nil {
|
||||
if logErr := s.completeCardCommandAttempt(ctx, attempt, nil, false); logErr != nil {
|
||||
s.logger.Error("终结保护期复机 Integration Log 失败", zap.String("integration_id", attempt.log.IntegrationID), zap.Error(logErr))
|
||||
}
|
||||
@@ -483,13 +497,29 @@ func (s *StopResumeService) ForceStartCard(ctx context.Context, card *model.IotC
|
||||
}
|
||||
|
||||
// resumeSingleCard 对单张卡执行复机逻辑
|
||||
// 依次检查:已开机则跳过 → 非轮询停机原因则跳过 → 不满足复机条件则跳过 → 加锁 → 调 Gateway → 更新 DB
|
||||
// 依次检查:持通道阈值锁则拒绝 → 已开机则跳过 → 非轮询停机原因则跳过 → 不满足复机条件则跳过 → 加锁 → 调 Gateway → 更新 DB
|
||||
func (s *StopResumeService) resumeSingleCard(ctx context.Context, cardID uint) error {
|
||||
card, err := s.iotCardStore.GetByID(ctx, cardID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
actionCode, summary := constants.AuditActionIotCardAutoStarted, "自动恢复 IoT 卡网络"
|
||||
|
||||
// 持通道阈值锁:周期内拒绝一切复机,记录拒绝事实并保留锁。
|
||||
// 该入口被轮询评估、套餐激活/重置与设备维度复机复用,返回 nil 表示本次不动作,
|
||||
// 避免让自动链路按失败重试;用户可见入口由 ManualStartCard/ForceStartCard 返回拒绝错误。
|
||||
blocked, blockErr := s.channelThresholdBlocked(ctx, card)
|
||||
if blockErr != nil {
|
||||
return blockErr
|
||||
}
|
||||
if blocked {
|
||||
s.recordChannelThresholdDenial(ctx, card, actionCode, summary)
|
||||
s.logger.Info("卡持通道阈值锁,跳过自动复机",
|
||||
zap.Uint("card_id", cardID), zap.String("stop_reason", card.StopReason))
|
||||
return nil
|
||||
}
|
||||
|
||||
if card.NetworkStatus == constants.NetworkStatusOnline {
|
||||
return nil
|
||||
}
|
||||
@@ -526,8 +556,7 @@ func (s *StopResumeService) resumeSingleCard(ctx context.Context, cardID uint) e
|
||||
}
|
||||
defer s.redis.Del(ctx, lockKey)
|
||||
|
||||
actionCode, summary := constants.AuditActionIotCardAutoStarted, "自动恢复 IoT 卡网络"
|
||||
attempt, err := s.resumeCardWithRetry(ctx, card, actionCode, summary)
|
||||
attempt, integrationID, err := s.resumeCardWithRetry(ctx, card, actionCode, summary)
|
||||
if err != nil {
|
||||
s.logger.Error("调用运营商复机接口失败",
|
||||
zap.Uint("card_id", cardID),
|
||||
@@ -541,7 +570,7 @@ func (s *StopResumeService) resumeSingleCard(ctx context.Context, cardID uint) e
|
||||
"network_status": constants.NetworkStatusOnline,
|
||||
"resumed_at": now,
|
||||
"stop_reason": "",
|
||||
}, constants.CardObservationSceneBusinessResume, "online", uuid.NewString(), actionCode, summary, attempt.log.IntegrationID); err != nil {
|
||||
}, constants.CardObservationSceneBusinessResume, "online", uuid.NewString(), actionCode, summary, integrationID); err != nil {
|
||||
if logErr := s.completeCardCommandAttempt(ctx, attempt, nil, false); logErr != nil {
|
||||
s.logger.Error("终结复机 Integration Log 失败", zap.String("integration_id", attempt.log.IntegrationID), zap.Error(logErr))
|
||||
}
|
||||
@@ -567,14 +596,17 @@ func (s *StopResumeService) resumeSingleCard(ctx context.Context, cardID uint) e
|
||||
return nil
|
||||
}
|
||||
|
||||
// stopCardWithRetry 调用运营商停机接口(带重试机制),并更新 DB 停机原因
|
||||
func (s *StopResumeService) stopCardWithRetry(ctx context.Context, card *model.IotCard, stopReason string) error {
|
||||
// stopCardWithRetry 调用运营商停机接口(带重试机制),并更新 DB 停机原因。
|
||||
//
|
||||
// 返回的 outcome 始终携带可回填可靠任务的结果分类(成功/失败/未知)与最后一次尝试的
|
||||
// Integration Log 标识;error 与既有语义一致,表示本次停机未成功。
|
||||
func (s *StopResumeService) stopCardWithRetry(ctx context.Context, card *model.IotCard, stopReason string) (carrierthresholddomain.CommandOutcome, error) {
|
||||
actionCode, summary := stopAuditAction(ctx, stopReason)
|
||||
if s.gatewayClient == nil {
|
||||
failErr := errors.New(errors.CodeInternalError, "Gateway 未配置,停复机操作不可用")
|
||||
s.recordCardCommandAudit(ctx, card, actionCode, summary+"失败", constants.AuditResultFailed, "",
|
||||
cardSnapshot(card), nil, failErr)
|
||||
return failErr
|
||||
return carrierthresholddomain.CommandOutcome{Result: constants.AuditResultFailed}, failErr
|
||||
}
|
||||
|
||||
s.logger.Info("调用网关停机",
|
||||
@@ -606,7 +638,9 @@ func (s *StopResumeService) stopCardWithRetry(ctx context.Context, card *model.I
|
||||
if lastErr == nil {
|
||||
lastErr = attemptObserver.recordingErr
|
||||
}
|
||||
break
|
||||
return carrierthresholddomain.CommandOutcome{
|
||||
IntegrationID: lastIntegrationID, Result: attemptObserver.auditResult(lastErr),
|
||||
}, lastErr
|
||||
}
|
||||
if callErr == nil {
|
||||
attempt := attemptObserver.successful
|
||||
@@ -631,14 +665,21 @@ func (s *StopResumeService) stopCardWithRetry(ctx context.Context, card *model.I
|
||||
lastIntegrationID,
|
||||
map[string]any{"network_status": card.NetworkStatus, "stop_reason": card.StopReason},
|
||||
map[string]any{"requested_network_status": constants.NetworkStatusOffline, "stop_reason": stopReason}, updateErr)
|
||||
return updateErr
|
||||
// 运营商已成功但本地回写失败:按结果未知交恢复扫描查询确认。
|
||||
return carrierthresholddomain.CommandOutcome{
|
||||
IntegrationID: lastIntegrationID, Result: constants.AuditResultUnknown,
|
||||
}, updateErr
|
||||
}
|
||||
|
||||
s.reschedulePolling(ctx, card.ID)
|
||||
if logErr := s.completeCardCommandAttempt(ctx, attempt, nil, true); logErr != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, logErr, "终结停机 Integration Log 失败")
|
||||
return carrierthresholddomain.CommandOutcome{
|
||||
IntegrationID: lastIntegrationID, Result: constants.AuditResultSuccess,
|
||||
}, errors.Wrap(errors.CodeDatabaseError, logErr, "终结停机 Integration Log 失败")
|
||||
}
|
||||
return nil
|
||||
return carrierthresholddomain.CommandOutcome{
|
||||
IntegrationID: lastIntegrationID, Result: constants.AuditResultSuccess,
|
||||
}, nil
|
||||
}
|
||||
|
||||
lastErr = callErr
|
||||
@@ -652,15 +693,18 @@ func (s *StopResumeService) stopCardWithRetry(ctx context.Context, card *model.I
|
||||
map[string]any{"network_status": card.NetworkStatus, "stop_reason": card.StopReason},
|
||||
map[string]any{"requested_network_status": constants.NetworkStatusOffline, "stop_reason": stopReason}, lastErr)
|
||||
|
||||
return lastErr
|
||||
return carrierthresholddomain.CommandOutcome{
|
||||
IntegrationID: lastIntegrationID, Result: attemptObserver.auditResult(lastErr),
|
||||
}, lastErr
|
||||
}
|
||||
|
||||
// resumeCardWithRetry 调用运营商复机接口(带重试机制)。
|
||||
func (s *StopResumeService) resumeCardWithRetry(ctx context.Context, card *model.IotCard, actionCode, summary string) (*gatewayAttempt, error) {
|
||||
// 第二个返回值是最后一次尝试的 Integration Log 标识(无论成功或失败),供调用方回填可靠任务状态。
|
||||
func (s *StopResumeService) resumeCardWithRetry(ctx context.Context, card *model.IotCard, actionCode, summary string) (*gatewayAttempt, string, error) {
|
||||
if s.gatewayClient == nil {
|
||||
failErr := errors.New(errors.CodeInternalError, "Gateway 未配置,停复机操作不可用")
|
||||
s.recordCardCommandAudit(ctx, card, actionCode, summary+"失败", constants.AuditResultFailed, "", cardSnapshot(card), nil, failErr)
|
||||
return nil, failErr
|
||||
return nil, "", failErr
|
||||
}
|
||||
|
||||
s.logger.Info("调用网关复机",
|
||||
@@ -695,13 +739,13 @@ func (s *StopResumeService) resumeCardWithRetry(ctx context.Context, card *model
|
||||
if lastErr == nil {
|
||||
lastErr = attemptObserver.recordingErr
|
||||
}
|
||||
break
|
||||
return nil, lastIntegrationID, lastErr
|
||||
}
|
||||
if callErr == nil {
|
||||
s.logger.Info("网关复机成功",
|
||||
zap.Uint("card_id", card.ID),
|
||||
zap.String("iccid", card.ICCID))
|
||||
return attemptObserver.successful, nil
|
||||
return attemptObserver.successful, lastIntegrationID, nil
|
||||
}
|
||||
|
||||
lastErr = callErr
|
||||
@@ -714,7 +758,7 @@ func (s *StopResumeService) resumeCardWithRetry(ctx context.Context, card *model
|
||||
s.recordCardCommandAudit(ctx, card, actionCode, summary+"未完成", attemptObserver.auditResult(lastErr), lastIntegrationID,
|
||||
map[string]any{"network_status": card.NetworkStatus, "stop_reason": card.StopReason, "gateway_extend": card.GatewayExtend},
|
||||
map[string]any{"requested_network_status": constants.NetworkStatusOnline}, lastErr)
|
||||
return nil, lastErr
|
||||
return nil, lastIntegrationID, lastErr
|
||||
}
|
||||
|
||||
// StartMachineSeparatedCard 对机卡分离停机卡执行复机
|
||||
@@ -724,6 +768,17 @@ func (s *StopResumeService) StartMachineSeparatedCard(ctx context.Context, card
|
||||
return errors.New(errors.CodeInvalidParam)
|
||||
}
|
||||
actionCode, summary := constants.AuditActionIotCardOpenAPIStarted, "OpenAPI 恢复 IoT 卡网络"
|
||||
|
||||
// 持通道阈值锁:周期内拒绝一切复机,记录拒绝事实并保留锁。
|
||||
blocked, blockErr := s.channelThresholdBlocked(ctx, card)
|
||||
if blockErr != nil {
|
||||
return blockErr
|
||||
}
|
||||
if blocked {
|
||||
s.recordChannelThresholdDenial(ctx, card, actionCode, summary)
|
||||
return channelThresholdDenyError()
|
||||
}
|
||||
|
||||
if s.gatewayClient == nil {
|
||||
failErr := errors.New(errors.CodeInternalError, "Gateway 未配置,停复机操作不可用")
|
||||
s.recordCardCommandAudit(ctx, card, actionCode, summary+"失败", constants.AuditResultFailed, "", cardSnapshot(card), nil, failErr)
|
||||
@@ -752,7 +807,7 @@ func (s *StopResumeService) StartMachineSeparatedCard(ctx context.Context, card
|
||||
return denyErr
|
||||
}
|
||||
|
||||
attempt, err := s.resumeCardWithRetry(ctx, card, actionCode, summary)
|
||||
attempt, integrationID, err := s.resumeCardWithRetry(ctx, card, actionCode, summary)
|
||||
if err != nil {
|
||||
wrapErr := errors.Wrap(errors.CodeGatewayError, err, "调用运营商复机失败,请稍后重试")
|
||||
return wrapErr
|
||||
@@ -764,7 +819,7 @@ func (s *StopResumeService) StartMachineSeparatedCard(ctx context.Context, card
|
||||
"resumed_at": now,
|
||||
"stop_reason": "",
|
||||
"gateway_extend": "",
|
||||
}, constants.CardObservationSceneBusinessResume, "online", uuid.NewString(), actionCode, summary, attempt.log.IntegrationID); err != nil {
|
||||
}, constants.CardObservationSceneBusinessResume, "online", uuid.NewString(), actionCode, summary, integrationID); err != nil {
|
||||
if logErr := s.completeCardCommandAttempt(ctx, attempt, nil, false); logErr != nil {
|
||||
s.logger.Error("终结复机 Integration Log 失败", zap.String("integration_id", attempt.log.IntegrationID), zap.Error(logErr))
|
||||
}
|
||||
@@ -818,7 +873,7 @@ func (s *StopResumeService) ManualStopCard(ctx context.Context, iccid string) er
|
||||
}
|
||||
}
|
||||
|
||||
if err := s.stopCardWithRetry(ctx, card, constants.StopReasonManual); err != nil {
|
||||
if _, err := s.stopCardWithRetry(ctx, card, constants.StopReasonManual); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -833,6 +888,16 @@ func (s *StopResumeService) ManualStartCard(ctx context.Context, iccid string) e
|
||||
}
|
||||
actionCode, summary := constants.AuditActionIotCardManualStarted, "人工恢复 IoT 卡网络"
|
||||
|
||||
// 持通道阈值锁:周期内拒绝一切复机,记录拒绝事实并保留锁。
|
||||
blocked, blockErr := s.channelThresholdBlocked(ctx, card)
|
||||
if blockErr != nil {
|
||||
return blockErr
|
||||
}
|
||||
if blocked {
|
||||
s.recordChannelThresholdDenial(ctx, card, actionCode, summary)
|
||||
return channelThresholdDenyError()
|
||||
}
|
||||
|
||||
// 独立卡处于风险停机或已销户状态时,拒绝复机
|
||||
if card.IsStandalone && isRiskGatewayExtend(card.GatewayExtend) {
|
||||
var denyMsg string
|
||||
@@ -875,7 +940,7 @@ func (s *StopResumeService) ManualStartCard(ctx context.Context, iccid string) e
|
||||
}
|
||||
}
|
||||
|
||||
attempt, err := s.resumeCardWithRetry(ctx, card, actionCode, summary)
|
||||
attempt, integrationID, err := s.resumeCardWithRetry(ctx, card, actionCode, summary)
|
||||
if err != nil {
|
||||
wrapErr := errors.Wrap(errors.CodeGatewayError, err, "调用运营商复机失败,请稍后重试")
|
||||
return wrapErr
|
||||
@@ -886,7 +951,7 @@ func (s *StopResumeService) ManualStartCard(ctx context.Context, iccid string) e
|
||||
"network_status": constants.NetworkStatusOnline,
|
||||
"resumed_at": now,
|
||||
"stop_reason": "",
|
||||
}, constants.CardObservationSceneBusinessResume, "online", uuid.NewString(), actionCode, summary, attempt.log.IntegrationID); err != nil {
|
||||
}, constants.CardObservationSceneBusinessResume, "online", uuid.NewString(), actionCode, summary, integrationID); err != nil {
|
||||
if logErr := s.completeCardCommandAttempt(ctx, attempt, nil, false); logErr != nil {
|
||||
s.logger.Error("终结复机 Integration Log 失败", zap.String("integration_id", attempt.log.IntegrationID), zap.Error(logErr))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user