feat(资产钱包自动续费): 新增全局配置、每日扫描续购与可靠复机
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,零外部渠道调用)
This commit is contained in:
2026-09-17 16:39:26 +08:00
parent 70e6b186df
commit d52be16802
54 changed files with 5164 additions and 97 deletions

View File

@@ -0,0 +1,296 @@
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
}