Files
junhong_cmp_fiber/internal/service/iot_card/auto_renewal.go
break d52be16802
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 10m15s
feat(资产钱包自动续费): 新增全局配置、每日扫描续购与可靠复机
- 新增单行配置表 tb_asset_auto_renewal_config 与尝试记录表 tb_asset_auto_renewal_attempt(迁移 000229/000230)
- 每日按上海自然日扫描,窗口内以同一资产钱包可用余额续购当前主套餐,资金/订单/套餐/审计同一事务闭合
- 唯一键保证每资产每日至多一次尝试,占位中断由后续扫描收敛,当日不重试
- 四类失败原因向客户与店铺各投递每日至多一条站内通知,并注册通知类型与个人客户白名单
- 续费成功后按条件经 Outbox 可靠投递复机,新增恢复扫描只查询回填,不使用即发即弃调用
- 配置读写仅超级管理员与平台账号,保存记录操作者、前后值快照并登记统一审计
- tasks 7.1–7.15 全部验证通过(本机隔离 PostgreSQL/Redis,零外部渠道调用)
2026-09-17 16:39:26 +08:00

297 lines
13 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 (
"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
}