All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 10m15s
- 新增单行配置表 tb_asset_auto_renewal_config 与尝试记录表 tb_asset_auto_renewal_attempt(迁移 000229/000230) - 每日按上海自然日扫描,窗口内以同一资产钱包可用余额续购当前主套餐,资金/订单/套餐/审计同一事务闭合 - 唯一键保证每资产每日至多一次尝试,占位中断由后续扫描收敛,当日不重试 - 四类失败原因向客户与店铺各投递每日至多一条站内通知,并注册通知类型与个人客户白名单 - 续费成功后按条件经 Outbox 可靠投递复机,新增恢复扫描只查询回填,不使用即发即弃调用 - 配置读写仅超级管理员与平台账号,保存记录操作者、前后值快照并登记统一审计 - tasks 7.1–7.15 全部验证通过(本机隔离 PostgreSQL/Redis,零外部渠道调用)
297 lines
13 KiB
Go
297 lines
13 KiB
Go
package iot_card
|
||
|
||
import (
|
||
"context"
|
||
"time"
|
||
|
||
"github.com/google/uuid"
|
||
"go.uber.org/zap"
|
||
|
||
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/model"
|
||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||
)
|
||
|
||
// AutoRenewalResumeOutcome 是资产钱包自动续费成功后一次复机调用可安全记录的结果摘要。
|
||
//
|
||
// 它只承载可靠任务回填所需的事实:是否真的发起过复机、Integration Log 标识、结果分类与可安全
|
||
// 展示的原因,不携带渠道报文原文、凭证或内部错误细节。
|
||
type AutoRenewalResumeOutcome struct {
|
||
// Applied 为 false 表示本次未满足可复机条件,没有发起任何复机调用。
|
||
Applied bool
|
||
// IntegrationID 是本次复机 Gateway 调用的 Integration Log 标识,未发起时为空。
|
||
IntegrationID string
|
||
// Result 取值 constants.AuditResultSuccess / Failed / Unknown。
|
||
Result string
|
||
// SafeReason 是可安全记录的不复机原因或失败原因,成功时为空。
|
||
SafeReason string
|
||
}
|
||
|
||
// AutoRenewalResumeReady 判断续费成功后的资产是否满足自动复机条件。
|
||
//
|
||
// 判定规则全部复用既有单一来源(风险网关扩展、可轮询停因、通道阈值锁、有效主套餐、流量未耗尽、
|
||
// 实名),不复制任何规则;本入口与既有「若已停机则复机」入口的区别只在于:它明确区分
|
||
// 「条件不成立」与「已发起复机」,因此可作为可靠任务的结果来源。
|
||
// 卡资产按该卡自身判定;设备资产按设备下因轮询原因停机的卡逐个判定,任一卡满足即返回可复机。
|
||
func (s *StopResumeService) AutoRenewalResumeReady(ctx context.Context, assetType string, assetID uint) (bool, string, error) {
|
||
cards, err := s.autoRenewalResumeCards(ctx, assetType, assetID)
|
||
if err != nil {
|
||
return false, "", err
|
||
}
|
||
if len(cards) == 0 {
|
||
return false, "该资产当前没有可恢复停机的卡", nil
|
||
}
|
||
reason := ""
|
||
for _, card := range cards {
|
||
ready, cardReason, readyErr := s.autoRenewalCardReady(ctx, card)
|
||
if readyErr != nil {
|
||
return false, "", readyErr
|
||
}
|
||
if ready {
|
||
return true, "", nil
|
||
}
|
||
if reason == "" {
|
||
reason = cardReason
|
||
}
|
||
}
|
||
if reason == "" {
|
||
reason = "该资产当前没有可恢复停机的卡"
|
||
}
|
||
return false, reason, nil
|
||
}
|
||
|
||
// ResumeAssetForAutoRenewal 在可复机判定成立时执行复机,并返回结果分类。
|
||
//
|
||
// 复用既有复机重试、Integration Log、统一审计与观测序列:成功时在同一事务写回 network_status=online、
|
||
// resumed_at,并清除可轮询停因。判定不成立时返回 Applied=false 且不发起任何调用;
|
||
// 判定成立但调用失败或结果未知时,续费事实不回滚,结果交可靠任务与恢复扫描收敛。
|
||
func (s *StopResumeService) ResumeAssetForAutoRenewal(ctx context.Context, assetType string, assetID uint) (AutoRenewalResumeOutcome, error) {
|
||
cards, err := s.autoRenewalResumeCards(ctx, assetType, assetID)
|
||
if err != nil {
|
||
return AutoRenewalResumeOutcome{}, err
|
||
}
|
||
reason := "该资产当前没有可恢复停机的卡"
|
||
applied := false
|
||
outcome := AutoRenewalResumeOutcome{Result: constants.AuditResultSuccess}
|
||
for _, card := range cards {
|
||
ready, cardReason, readyErr := s.autoRenewalCardReady(ctx, card)
|
||
if readyErr != nil {
|
||
return AutoRenewalResumeOutcome{}, readyErr
|
||
}
|
||
if !ready {
|
||
if reason == "该资产当前没有可恢复停机的卡" && cardReason != "" {
|
||
reason = cardReason
|
||
}
|
||
continue
|
||
}
|
||
applied = true
|
||
cardOutcome, resumeErr := s.autoRenewalResumeCard(ctx, card)
|
||
if cardOutcome.IntegrationID != "" {
|
||
outcome.IntegrationID = cardOutcome.IntegrationID
|
||
}
|
||
switch cardOutcome.Result {
|
||
case constants.AuditResultFailed:
|
||
outcome.Result = constants.AuditResultFailed
|
||
outcome.SafeReason = cardOutcome.SafeReason
|
||
case constants.AuditResultUnknown:
|
||
if outcome.Result == constants.AuditResultSuccess {
|
||
outcome.Result = constants.AuditResultUnknown
|
||
outcome.SafeReason = cardOutcome.SafeReason
|
||
}
|
||
}
|
||
if resumeErr != nil {
|
||
s.logger.Warn("自动续费成功后复机未完成",
|
||
zap.String("asset_type", assetType), zap.Uint("asset_id", assetID),
|
||
zap.Uint("card_id", card.ID), zap.Error(resumeErr))
|
||
}
|
||
if outcome.Result == constants.AuditResultFailed {
|
||
break
|
||
}
|
||
}
|
||
if !applied {
|
||
return AutoRenewalResumeOutcome{Applied: false, Result: constants.AuditResultSuccess, SafeReason: reason}, nil
|
||
}
|
||
outcome.Applied = true
|
||
return outcome, nil
|
||
}
|
||
|
||
// QueryAutoRenewalResumeState 只查询运营商卡状态,供恢复扫描回填自动续费的复机结果。
|
||
//
|
||
// 不发起任何复机调用:只做状态查询,并按既有网关状态映射规则给出是否已复机。
|
||
// 查询失败把 known 置 false 并返回错误,调用方必须按「仍未确认」处理,不得判为失败终态。
|
||
func (s *StopResumeService) QueryAutoRenewalResumeState(ctx context.Context, assetType string, assetID uint) (bool, bool, string, error) {
|
||
cards, err := s.autoRenewalResumeCards(ctx, assetType, assetID)
|
||
if err != nil {
|
||
return false, false, "", err
|
||
}
|
||
if len(cards) == 0 {
|
||
return false, false, "", errors.New(errors.CodeNotFound, "该资产当前没有可查询的卡")
|
||
}
|
||
if s.gatewayClient == nil {
|
||
return false, false, "", errors.New(errors.CodeInternalError, "Gateway 未配置,无法查询卡状态")
|
||
}
|
||
integrationID := ""
|
||
online := true
|
||
for _, card := range cards {
|
||
attempt, startErr := s.startCardCommandAttempt(ctx, card, constants.IntegrationOperationGatewayNetwork,
|
||
constants.CardObservationSceneAssetAutoRenewalResumeRecovery, cardCommandSeriesKey(ctx), 1)
|
||
if startErr != nil {
|
||
return false, false, integrationID, startErr
|
||
}
|
||
integrationID = attempt.log.IntegrationID
|
||
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 false, false, 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 false, false, integrationID, completeErr
|
||
}
|
||
status, known := carddomain.MapGatewayNetworkStatus(response.CardStatus, response.Extend)
|
||
if !known {
|
||
return false, false, integrationID, nil
|
||
}
|
||
if status != constants.NetworkStatusOnline {
|
||
online = false
|
||
}
|
||
}
|
||
return online, true, integrationID, nil
|
||
}
|
||
|
||
// autoRenewalResumeCards 解析自动续费资产对应的待复机卡:卡资产取自身,设备资产取该设备下
|
||
// 因轮询原因停机的绑定卡(复用既有设备卡查询,不另立绑定关系口径)。
|
||
func (s *StopResumeService) autoRenewalResumeCards(ctx context.Context, assetType string, assetID uint) ([]*model.IotCard, error) {
|
||
if assetID == 0 {
|
||
return nil, errors.New(errors.CodeInvalidParam, "自动续费复机资产无效")
|
||
}
|
||
switch assetType {
|
||
case constants.AssetWalletResourceTypeIotCard:
|
||
card, err := s.iotCardStore.GetByID(ctx, assetID)
|
||
if err != nil {
|
||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "读取自动续费复机卡事实失败")
|
||
}
|
||
return []*model.IotCard{card}, nil
|
||
case constants.AssetWalletResourceTypeDevice:
|
||
cards, err := s.iotCardStore.ListByDeviceIDAndPollingStopReasons(ctx, assetID)
|
||
if err != nil {
|
||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询设备待复机卡失败")
|
||
}
|
||
return cards, nil
|
||
default:
|
||
return nil, errors.New(errors.CodeInvalidParam, "资产类型无效")
|
||
}
|
||
}
|
||
|
||
// autoRenewalCardReady 判断单卡是否满足自动复机条件;返回的 reason 是可安全记录的不满足原因。
|
||
func (s *StopResumeService) autoRenewalCardReady(ctx context.Context, card *model.IotCard) (bool, string, error) {
|
||
if card == nil || card.ID == 0 {
|
||
return false, "", errors.New(errors.CodeInvalidParam, "自动续费复机卡无效")
|
||
}
|
||
if card.NetworkStatus == constants.NetworkStatusOnline {
|
||
return false, "卡当前已在线,无需复机", nil
|
||
}
|
||
if isRiskGatewayExtend(card.GatewayExtend) {
|
||
return false, "网关扩展状态为风险停机或已销户,不自动复机", nil
|
||
}
|
||
if !isPollingStopReason(card.StopReason) {
|
||
return false, "停因不属于可轮询复机范围,不自动复机", nil
|
||
}
|
||
blocked, err := s.channelThresholdBlocked(ctx, card)
|
||
if err != nil {
|
||
return false, "", err
|
||
}
|
||
if blocked {
|
||
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
|
||
}
|
||
|
||
// autoRenewalResumeCard 对单卡执行自动续费后的复机并返回结果分类。
|
||
//
|
||
// 复用既有每卡复机分布式锁(constants.RedisCardResumeLockKey,与 resumeSingleCard 同一把锁与 TTL):
|
||
// 认领字段只保证「同一尝试至多一次」,跨链路(轮询/套餐激活/流量重置 vs 自动续费)仍可能同时复机,
|
||
// 该锁是既有链路共用的幂等闸门。抢不到锁表示另一条链路正在复机,本次不重复调用运营商,按未发起处理。
|
||
func (s *StopResumeService) autoRenewalResumeCard(ctx context.Context, card *model.IotCard) (AutoRenewalResumeOutcome, error) {
|
||
if s.redis != nil {
|
||
lockKey := constants.RedisCardResumeLockKey(card.ID)
|
||
locked, lockErr := s.redis.SetNX(ctx, lockKey, "1", 30*time.Second).Result()
|
||
if lockErr != nil {
|
||
return AutoRenewalResumeOutcome{}, errors.Wrap(errors.CodeRedisError, lockErr, "获取复机分布式锁失败")
|
||
}
|
||
if !locked {
|
||
return AutoRenewalResumeOutcome{
|
||
Applied: false, Result: constants.AuditResultSuccess,
|
||
SafeReason: "该卡正在被其他链路复机,本次不重复调用",
|
||
}, nil
|
||
}
|
||
defer func() { _ = s.redis.Del(ctx, lockKey) }()
|
||
}
|
||
actionCode, summary := constants.AuditActionIotCardAutoStarted, "自动续费成功后恢复 IoT 卡网络"
|
||
attempt, integrationID, err := s.resumeCardWithRetry(ctx, card, actionCode, summary)
|
||
if err != nil {
|
||
return AutoRenewalResumeOutcome{
|
||
Applied: true, IntegrationID: integrationID, Result: cardCommandAuditResult(err),
|
||
SafeReason: "Gateway 复机请求未成功",
|
||
}, err
|
||
}
|
||
fields := map[string]any{
|
||
"network_status": constants.NetworkStatusOnline,
|
||
"resumed_at": time.Now(),
|
||
}
|
||
// 判定已限定停因属于可轮询复机范围,复机成功后清除停因,与既有轮询复机口径一致。
|
||
if isPollingStopReason(card.StopReason) {
|
||
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 AutoRenewalResumeOutcome{
|
||
Applied: true, IntegrationID: attempt.log.IntegrationID, Result: constants.AuditResultUnknown,
|
||
SafeReason: "本地状态回写失败,结果待核对",
|
||
}, err
|
||
}
|
||
s.reschedulePolling(ctx, card.ID)
|
||
if logErr := s.completeCardCommandAttempt(ctx, attempt, nil, true); logErr != nil {
|
||
return AutoRenewalResumeOutcome{
|
||
Applied: true, 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 AutoRenewalResumeOutcome{
|
||
Applied: true, IntegrationID: attempt.log.IntegrationID, Result: constants.AuditResultSuccess,
|
||
}, nil
|
||
}
|