All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 12m59s
223 lines
11 KiB
Go
223 lines
11 KiB
Go
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)
|
||
}
|