Files
junhong_cmp_fiber/internal/service/iot_card/stop_resume_command.go
邹进 8eb7d403c4 feat: 退款资产后处理逐次尝试记录与重试上限,停复机指令调用前结束写跳过记录
- 新增 tb_refund_post_process_attempt,每次尝试一条(步骤、触发方式、结果、错误码与信息、耗时)
- 退款单增加 asset_post_status/attempts/last_error/next_at;失败按 2^(n-1) 分钟退避(最长 1 小时),10 次后转人工
- 中间失败只写应用日志,成功或转人工写一条结论审计(带总尝试次数、首次失败时间、最后错误码)
- 补偿扫描跳过需人工处理与未到重试时间的退款单,Outbox 不再无限重投
- 新增 POST /refunds/:id/asset-post-processing/retry 人工重试
- 停复机指令执行前结束(已是目标状态、条件不再满足、需相反动作)写 skipped 停复机记录
- 迁移 000271 需维护者在测试库执行后再部署

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 18:33:32 +09:00

220 lines
9.6 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"
stderrors "errors"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/gatewaylimit"
"time"
"github.com/google/uuid"
"go.uber.org/zap"
"gorm.io/gorm"
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"
)
// 停复机指令执行器使用的服务能力(add-stop-resume-command-queue)。执行器只调用一次网关,不 sleep 重试。
// CommandDecision 是执行前按指令场景重新判断的结果。
type CommandDecision struct {
Card *model.IotCard
Action string // 空表示条件不再满足或已是目标状态,结束指令
Reason string
Gone bool // 卡已不存在
}
// DecideCommand 执行前按指令记录的场景重新判断该卡(只看本卡,不扩散到设备)。
// 复机持通道阈值锁时视为不再满足。
func (s *StopResumeService) DecideCommand(ctx context.Context, cardID uint, scenes []string) (CommandDecision, error) {
card, err := s.iotCardStore.GetByID(ctx, cardID)
if err != nil {
if stderrors.Is(err, gorm.ErrRecordNotFound) {
return CommandDecision{Gone: true}, nil
}
return CommandDecision{}, errors.Wrap(errors.CodeDatabaseError, err, "读取卡失败")
}
decision, err := s.decideCard(ctx, card, scenes)
if err != nil {
return CommandDecision{Card: card}, err
}
if decision.Action == constants.StopResumeActionStart {
blocked, blockErr := s.channelThresholdBlocked(ctx, card)
if blockErr != nil {
return CommandDecision{Card: card}, blockErr
}
if blocked {
return CommandDecision{Card: card}, nil
}
}
return CommandDecision{Card: card, Action: decision.Action, Reason: decision.Reason}, nil
}
// RecordCommandSkipped 为执行前结束、未调用上游的停复机指令写一条"跳过"停复机记录;记录写入失败只告警。
func (s *StopResumeService) RecordCommandSkipped(ctx context.Context, card *model.IotCard, action, scene, reason, skipReason string) {
if s.stopResumeRecorder == nil || card == nil {
return
}
s.stopResumeRecorder.Record(ctx, gatewaylimit.StopResumeRecordInput{
CardID: card.ID, ICCID: card.ICCID, CarrierID: card.CarrierID, Action: action,
Source: commandSource(ctx), Scene: scene, Reason: reason, Attempt: 0, SkipReason: skipReason,
})
}
// ErrCommandBusy 表示该卡复机正在其他路径执行(持单卡复机锁),本次未调用上游。
var ErrCommandBusy = stderrors.New("该卡复机正在执行中")
// CommandCallResult 是一次停复机网关调用的结果。
type CommandCallResult struct {
// Unknown 为 true 表示结果未知:超时、连接失败,或上游已成功但本地回写失败。
Unknown bool
}
// ExecuteCommand 对卡调用一次停机或复机(限流等待模式由调用方 ctx 声明),成功后按现有回写更新卡状态、
// 停机原因与网络状态序列,并写停复机记录。返回的 error 为网关错误或回写错误,由执行器按错误分类处理。
func (s *StopResumeService) ExecuteCommand(ctx context.Context, card *model.IotCard, action, reason string) (CommandCallResult, error) {
if card == nil {
return CommandCallResult{}, errors.New(errors.CodeInvalidParam)
}
if action == constants.StopResumeActionStop {
outcome, err := s.stopCardAttempts(ctx, card, reason, 1)
if err != nil && outcome.Result == constants.AuditResultSuccess {
// 停机与回写已成功,只是 Integration Log 终结失败:按成功处理。
s.logger.Warn("停机成功但终结 Integration Log 失败", zap.Uint("card_id", card.ID), zap.Error(err))
return CommandCallResult{}, nil
}
return CommandCallResult{Unknown: err != nil && outcome.Result == constants.AuditResultUnknown && !gateway.IsRateLimited(err)}, err
}
// 与旧复机路径共用单卡复机锁,避免与自动续费、通道阈值等直接复机并发。
if s.redis != nil {
lockKey := constants.RedisCardResumeLockKey(card.ID)
locked, lockErr := s.redis.SetNX(ctx, lockKey, "1", 30*time.Second).Result()
if lockErr != nil || !locked {
return CommandCallResult{}, ErrCommandBusy
}
defer s.redis.Del(context.WithoutCancel(ctx), lockKey)
}
actionCode, summary := constants.AuditActionIotCardAutoStarted, "自动恢复 IoT 卡网络"
attempt, integrationID, err := s.resumeCardAttempts(ctx, card, actionCode, summary, 1)
if err != nil {
return CommandCallResult{Unknown: isUnknownGatewayError(err)}, err
}
if err := s.completeAutoResume(ctx, card, attempt, integrationID, actionCode, summary); err != nil {
return CommandCallResult{Unknown: true}, err
}
return CommandCallResult{}, nil
}
// isUnknownGatewayError 判断网关错误是否为结果未知(超时、连接失败):请求可能已到达上游。
func isUnknownGatewayError(err error) bool {
gwErr, ok := gateway.AsGatewayError(err)
if !ok {
return false
}
return gwErr.Category == constants.GatewayErrorTimeout || gwErr.Category == constants.GatewayErrorNetwork
}
// QueryCommandCardStatus 结果未知后的确认查询:只查一次卡状态(受限流、不受单卡最小重试间隔约束)。
// 查询失败时 known=false 并返回错误,调用方保持待确认、不发起停复机。
func (s *StopResumeService) QueryCommandCardStatus(ctx context.Context, card *model.IotCard) (int, bool, error) {
if s.gatewayClient == nil {
return 0, false, errors.New(errors.CodeInternalError, "Gateway 未配置,无法查询卡状态")
}
queryCtx := gateway.WithCarrier(ctx, card.CarrierID)
response, err := s.gatewayClient.QueryCardStatus(queryCtx, &gateway.CardStatusReq{CardNo: card.ICCID})
if err != nil {
return 0, false, err
}
status, known := carddomain.MapGatewayNetworkStatus(response.CardStatus, response.Extend)
return status, known, nil
}
// ConfirmCommandState 按已确认的上游结果(确认查询已是目标状态、上游返回已是目标状态、停机遇上游终态)回写卡状态。
func (s *StopResumeService) ConfirmCommandState(ctx context.Context, card *model.IotCard, action, reason string) error {
if card == nil {
return errors.New(errors.CodeInvalidParam)
}
actionCode, summary := constants.AuditActionIotCardAutoStarted, "自动恢复 IoT 卡网络(确认)"
scene, expected := constants.CardObservationSceneBusinessResume, "online"
fields := map[string]any{"network_status": constants.NetworkStatusOnline, "resumed_at": time.Now(), "stop_reason": ""}
if action == constants.StopResumeActionStop {
actionCode, summary = stopAuditAction(ctx, reason)
summary += "(确认)"
scene, expected = constants.CardObservationSceneBusinessStop, "offline"
fields = map[string]any{"network_status": constants.NetworkStatusOffline, "stopped_at": time.Now(), "stop_reason": reason}
}
if err := s.updateCardAndAppendNetworkSeries(ctx, card, fields, scene, expected, uuid.NewString(), actionCode, summary, ""); err != nil {
return err
}
s.reschedulePolling(ctx, card.ID)
return nil
}
// EvaluateDeviceCommands 设备维度判断:读取设备下各卡最新数据,按统一判断函数(各卡按所属运营商账户判断实名,
// 暂停轮询按场景集合判断)为需要动作的卡生成指令,进入各卡所属账户的停复机队列。只读数据库与写共享缓存,不调网关。
func (s *StopResumeService) EvaluateDeviceCommands(ctx context.Context, deviceID uint, scenes []string) error {
if s.commandQueue == nil {
return errors.New(errors.CodeInternalError, "停复机指令队列未配置")
}
cards, err := s.deviceCards(ctx, deviceID)
if err != nil {
return err
}
var failed []error
for _, card := range cards {
decision, decideErr := s.decideCard(ctx, card, scenes)
if decideErr != nil {
failed = append(failed, decideErr)
continue
}
if decision.Action == "" {
s.markNotRealnameIfBlocked(ctx, card, scenes)
continue
}
decision.DeviceID = 0
if writeErr := s.writeCardCommand(ctx, card, decision, scenes, "device_eval"); writeErr != nil {
failed = append(failed, writeErr)
}
}
if len(failed) > 0 {
return errors.Wrap(errors.CodeInternalError, stderrors.Join(failed...), "设备判断存在未写入指令的卡")
}
return nil
}
// markNotRealnameIfBlocked 设备下停机卡套餐满足但实名不满足时,把停机原因更新为未实名(与旧设备复机路径一致)。
func (s *StopResumeService) markNotRealnameIfBlocked(ctx context.Context, card *model.IotCard, scenes []string) {
if card.NetworkStatus != constants.NetworkStatusOffline || card.StopReason == constants.StopReasonNotRealname {
return
}
if suspended, err := s.autoControlSuspended(ctx, card); err != nil || suspended {
return
}
hasPackage, err := s.hasValidPackage(ctx, card)
if err != nil || !hasPackage {
return
}
if exhausted, err := s.isTrafficExhausted(ctx, card); err != nil || exhausted {
return
}
realnameOK, err := s.isRealnameOK(ctx, card)
if err != nil || realnameOK {
return
}
if err := s.updateCardStopReasonWithAudit(ctx, card, constants.StopReasonNotRealname); err != nil {
s.logger.Warn("更新未实名卡停机原因失败", zap.Uint("card_id", card.ID), zap.Strings("scenes", scenes), zap.Error(err))
}
}
// LegacyDeviceOpLocked 判断旧路径是否正在对该设备执行停复机(持设备锁);设备判断须推后,不并行执行。
func (s *StopResumeService) LegacyDeviceOpLocked(ctx context.Context, deviceID uint) bool {
if s.redis == nil {
return false
}
exists, err := s.redis.Exists(ctx, constants.RedisPollingDeviceOpLockKey(deviceID)).Result()
return err == nil && exists > 0
}