From dc816b44d065900ae446aca66553fb6b3822ebdd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=82=B9=E8=BF=9B?= Date: Mon, 2 Nov 2026 17:01:55 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20=E5=8D=A1=E6=88=96=E7=BB=91=E5=AE=9A?= =?UTF-8?q?=E8=AE=BE=E5=A4=87=E5=85=B3=E9=97=AD=E8=BD=AE=E8=AF=A2=E6=97=B6?= =?UTF-8?q?=E4=B8=8D=E5=86=8D=E8=87=AA=E5=8A=A8=E5=81=9C=E5=A4=8D=E6=9C=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 关闭轮询的卡/设备已导入其他卡管,去掉套餐到期、退款场景对暂停轮询卡的停机放行, 任何场景都不停机也不复机(单卡判断与设备下逐卡停机同步收口)。 场景判断函数更名为 StopResumeSceneIgnoresNetworkStatus,只保留不看库里开停状态的语义。 Co-Authored-By: Claude Opus 5.5 --- .../service/iot_card/stop_resume_decide.go | 22 ++------- .../stop_resume_polling_guard_test.go | 48 +++++++++++++++++++ .../service/iot_card/stop_resume_service.go | 9 ++-- pkg/constants/stop_resume_queue.go | 5 +- 4 files changed, 61 insertions(+), 23 deletions(-) create mode 100644 internal/service/iot_card/stop_resume_polling_guard_test.go diff --git a/internal/service/iot_card/stop_resume_decide.go b/internal/service/iot_card/stop_resume_decide.go index e4a17747..8b8020fa 100644 --- a/internal/service/iot_card/stop_resume_decide.go +++ b/internal/service/iot_card/stop_resume_decide.go @@ -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 { diff --git a/internal/service/iot_card/stop_resume_polling_guard_test.go b/internal/service/iot_card/stop_resume_polling_guard_test.go new file mode 100644 index 00000000..03ddada0 --- /dev/null +++ b/internal/service/iot_card/stop_resume_polling_guard_test.go @@ -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) + } +} diff --git a/internal/service/iot_card/stop_resume_service.go b/internal/service/iot_card/stop_resume_service.go index 15357acc..0bf4d924 100644 --- a/internal/service/iot_card/stop_resume_service.go +++ b/internal/service/iot_card/stop_resume_service.go @@ -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 { diff --git a/pkg/constants/stop_resume_queue.go b/pkg/constants/stop_resume_queue.go index ff0ec3cf..02d0b051 100644 --- a/pkg/constants/stop_resume_queue.go +++ b/pkg/constants/stop_resume_queue.go @@ -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