fix: 卡或绑定设备关闭轮询时不再自动停复机

关闭轮询的卡/设备已导入其他卡管,去掉套餐到期、退款场景对暂停轮询卡的停机放行,
任何场景都不停机也不复机(单卡判断与设备下逐卡停机同步收口)。
场景判断函数更名为 StopResumeSceneIgnoresNetworkStatus,只保留不看库里开停状态的语义。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
邹进
2026-11-02 17:01:55 +09:00
parent f274f8bb58
commit dc816b44d0
4 changed files with 61 additions and 23 deletions

View File

@@ -28,8 +28,8 @@ type stopResumeDecision struct {
DeviceID uint // 设备维度动作的设备;0 表示单卡
}
// decideCard 统一判断函数:暂停轮询(按场景) → 停机条件 → 复机条件。
// 暂停轮询的卡只在套餐失效类场景(套餐到期、退款)下放行无套餐停机,其余自动停复机一律不做;
// decideCard 统一判断函数:暂停轮询 → 停机条件 → 复机条件。
// 卡或绑定设备暂停轮询(已交由其他卡管)时任何场景都不停机也不复机;
// 套餐失效类场景不看库里的开停状态,资产无可用主套餐即停机。
// 设备维度原因(无套餐、流量耗尽)与绑定设备的复机返回 DeviceID,由调用方按设备处理。通道阈值锁由复机执行方判断。
func (s *StopResumeService) decideCard(ctx context.Context, card *model.IotCard, scenes []string) (stopResumeDecision, error) {
@@ -37,11 +37,10 @@ func (s *StopResumeService) decideCard(ctx context.Context, card *model.IotCard,
if err != nil {
return stopResumeDecision{}, err
}
allowSuspendedStop := constants.StopResumeSceneAllowsSuspendedStop(scenes)
if suspended && !allowSuspendedStop {
if suspended {
return stopResumeDecision{}, nil
}
if allowSuspendedStop {
if constants.StopResumeSceneIgnoresNetworkStatus(scenes) {
// 套餐失效类场景(套餐到期、退款):不看库里的开停状态,资产无可用主套餐即停机。
// 停复机成功后上游状态有延迟,库里的开停状态可能是旧值;上游已停时调用按已是目标状态结束。
reasons, err := s.checkStopReasons(ctx, card)
@@ -55,9 +54,6 @@ func (s *StopResumeService) decideCard(ctx context.Context, card *model.IotCard,
}
return decision, nil
}
if suspended {
return stopResumeDecision{}, nil
}
}
switch card.NetworkStatus {
case constants.NetworkStatusOnline:
@@ -66,22 +62,12 @@ func (s *StopResumeService) decideCard(ctx context.Context, card *model.IotCard,
return stopResumeDecision{}, err
}
primary := reasons[0]
if suspended {
// 暂停轮询:只有套餐失效(无可用主套餐)才停机。
if !containsStopReason(reasons, constants.StopReasonNoPackage) {
return stopResumeDecision{}, nil
}
primary = constants.StopReasonNoPackage
}
decision := stopResumeDecision{Action: constants.StopResumeActionStop, Reason: primary, Reasons: reasons}
if !card.IsStandalone && isDeviceScopeReason(primary) {
decision.DeviceID = s.boundDeviceID(ctx, card)
}
return decision, nil
case constants.NetworkStatusOffline:
if suspended {
return stopResumeDecision{}, nil
}
// 不看停机原因,是否复机只由本系统规则决定。
resume, err := s.shouldResume(ctx, card)
if err != nil || !resume {

View File

@@ -0,0 +1,48 @@
package iot_card
import (
"context"
"testing"
"go.uber.org/zap"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/constants"
)
// 关闭轮询的卡已交由其他卡管:任何场景(含套餐到期、退款)都不停机也不复机,
// 不得进入停机原因判断或复机流程(服务未注入任何 store/网关,进入后续流程会直接 panic)。
func TestDecideCard_PollingDisabledNeverActs(t *testing.T) {
svc := &StopResumeService{logger: zap.NewNop()}
cards := map[string]*model.IotCard{
"在线卡": {NetworkStatus: constants.NetworkStatusOnline, EnablePolling: false},
"设备下在线卡": {NetworkStatus: constants.NetworkStatusOnline, EnablePolling: false, IsStandalone: false},
"无套餐停机的卡": {NetworkStatus: constants.NetworkStatusOffline, StopReason: constants.StopReasonNoPackage, EnablePolling: false},
}
sceneSets := map[string][]string{
"无场景": nil,
"套餐到期": {constants.StopResumeScenePackageExpired},
"退款": {constants.StopResumeSceneRefund},
"流量扣减": {constants.StopResumeSceneUsage},
}
for cardName, card := range cards {
for sceneName, scenes := range sceneSets {
t.Run(cardName+"/"+sceneName, func(t *testing.T) {
decision, err := svc.decideCard(context.Background(), card, scenes)
if err != nil {
t.Fatalf("期望 nil,实际 %v", err)
}
if decision.Action != "" {
t.Fatalf("关闭轮询的卡不应停复机,实际动作 %q", decision.Action)
}
})
}
}
}
func TestEvaluateAndAct_NilCard(t *testing.T) {
svc := &StopResumeService{logger: zap.NewNop()}
if err := svc.EvaluateAndAct(context.Background(), nil); err != nil {
t.Fatalf("期望 nil,实际 %v", err)
}
}

View File

@@ -134,6 +134,9 @@ func (s *StopResumeService) SetPollingCallback(callback PollingCallback) {
// 停复机出口开关开启时只请求评估(生成指令或请求设备判断),不当场调用网关;关闭时当场执行。
// 设备/单卡维度通过 card.IsStandalone 推导(false 则为设备维度,停/复机覆盖设备下所有卡)。触发场景见 WithStopResumeScene。
func (s *StopResumeService) EvaluateAndAct(ctx context.Context, card *model.IotCard) error {
if card == nil {
return nil
}
scenes := stopResumeScenes(ctx)
decision, err := s.decideCard(ctx, card, scenes)
if err != nil {
@@ -376,7 +379,7 @@ func (s *StopResumeService) stopDeviceCards(ctx context.Context, deviceID uint,
// 查询设备下在线卡;套餐失效类场景(套餐到期、退款)不看库里的开停状态(可能因上游延迟是旧值),设备下全部卡都停。
var cards []*model.IotCard
var err error
if stopReason == constants.StopReasonNoPackage && constants.StopResumeSceneAllowsSuspendedStop(stopResumeScenes(ctx)) {
if stopReason == constants.StopReasonNoPackage && constants.StopResumeSceneIgnoresNetworkStatus(stopResumeScenes(ctx)) {
cards, err = s.deviceCards(ctx, deviceID)
} else {
cards, err = s.iotCardStore.ListByDeviceIDAndNetworkStatus(ctx, deviceID, constants.NetworkStatusOnline)
@@ -389,11 +392,11 @@ func (s *StopResumeService) stopDeviceCards(ctx context.Context, deviceID uint,
var cardErrors []error
for _, card := range cards {
// 暂停轮询的卡不随设备自动停机;套餐失效类场景(套餐到期、退款)照常停机。
// 暂停轮询的卡已交由其他卡管,任何场景都不随设备自动停机。
if suspended, suspendErr := s.autoControlSuspended(ctx, card); suspendErr != nil {
cardErrors = append(cardErrors, suspendErr)
continue
} else if suspended && !(stopReason == constants.StopReasonNoPackage && constants.StopResumeSceneAllowsSuspendedStop(stopResumeScenes(ctx))) {
} else if suspended {
continue
}
if _, stopErr := s.stopCardWithRetry(ctx, card, stopReason); stopErr != nil {

View File

@@ -22,8 +22,9 @@ const (
StopResumeSceneUsage = "usage" // 流量扣减后
)
// StopResumeSceneAllowsSuspendedStop 判断场景集合中是否含套餐失效类场景:暂停轮询的卡在这些场景下照常停机。
func StopResumeSceneAllowsSuspendedStop(scenes []string) bool {
// StopResumeSceneIgnoresNetworkStatus 判断场景集合中是否含套餐失效类场景(套餐到期、退款):
// 这些场景下停机判断不看库里的开停状态(上游状态有延迟可能是旧值)。暂停轮询的卡在任何场景下都不停机。
func StopResumeSceneIgnoresNetworkStatus(scenes []string) bool {
for _, scene := range scenes {
if scene == StopResumeScenePackageExpired || scene == StopResumeSceneRefund {
return true