Files
junhong_cmp_fiber/internal/service/iot_card/channel_threshold.go
break 59b3df868a
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 12m59s
feat(通道流量阈值): AUG26-011 运营商通道流量阈值达量停机与周期复机
2026-09-16 15:54:49 +08:00

223 lines
11 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}