Files
junhong_cmp_fiber/internal/service/iot_card/stop_resume_command.go
邹进 a4e3fe4d8f
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 9m24s
fix: 停复机指令队列审查问题
- 开关开启但分发器心跳缺失时回退为当场执行,避免指令无人消费
- 待确认指令先查上游状态再判断;执行期间被覆盖时把待确认带到新指令
- 同方向失败退避中重复写入不提前执行时间
- 领取容量把执行上限与本秒剩余 QPS 分开计算
- 队列复机共用单卡复机锁;执行上下文取消后按结果未知确认
- 消费者并行停止;读取最近调用时间失败时不覆盖;成功但终结日志失败按成功处理

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 00:43:58 +09:00

208 lines
8.9 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"
"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
}
// 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
}