diff --git a/internal/service/iot_card/stop_resume_polling_guard.go b/internal/service/iot_card/stop_resume_polling_guard.go new file mode 100644 index 00000000..bc4a3d9f --- /dev/null +++ b/internal/service/iot_card/stop_resume_polling_guard.go @@ -0,0 +1,38 @@ +package iot_card + +import ( + "context" + stderrors "errors" + + "gorm.io/gorm" + + "github.com/break/junhong_cmp_fiber/internal/model" + "github.com/break/junhong_cmp_fiber/pkg/errors" +) + +// pollingDisabled 判断卡是否退出本系统自动停复机:卡级关闭轮询,或卡绑定的设备关闭轮询。 +// 关闭轮询的卡/设备已交由其他卡管管理,本系统任何入口都不得再对其自动停机或复机。 +// 查询失败时返回错误,由调用方放弃本次停复机,避免在状态不明时误操作。 +func (s *StopResumeService) pollingDisabled(ctx context.Context, card *model.IotCard) (bool, error) { + if !card.EnablePolling { + return true, nil + } + if s.db == nil { + return false, nil + } + deviceID, bound, err := s.getCardDeviceID(ctx, card) + if err != nil { + return false, errors.Wrap(errors.CodeDatabaseError, err, "查询卡绑定设备失败") + } + if !bound { + return false, nil + } + var device model.Device + if err := s.db.WithContext(ctx).Select("id", "enable_polling").First(&device, deviceID).Error; err != nil { + if stderrors.Is(err, gorm.ErrRecordNotFound) { + return false, nil + } + return false, errors.Wrap(errors.CodeDatabaseError, err, "查询设备轮询状态失败") + } + return !device.EnablePolling, nil +} 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..8544e0b9 --- /dev/null +++ b/internal/service/iot_card/stop_resume_polling_guard_test.go @@ -0,0 +1,45 @@ +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" +) + +// 关闭轮询的卡已交由其他卡管:无论在线还是停机,EvaluateAndAct 都必须直接返回, +// 不得进入停机原因判断或复机流程(服务未注入任何 store/网关,进入后续流程会直接 panic)。 +func TestEvaluateAndAct_CardPollingDisabledSkipsStopResume(t *testing.T) { + svc := &StopResumeService{logger: zap.NewNop()} + + cases := []struct { + name string + card *model.IotCard + }{ + { + name: "在线卡无套餐不停机", + card: &model.IotCard{NetworkStatus: constants.NetworkStatusOnline, EnablePolling: false, IsStandalone: false}, + }, + { + name: "轮询原因停机的卡不复机", + card: &model.IotCard{NetworkStatus: constants.NetworkStatusOffline, StopReason: constants.StopReasonNoPackage, EnablePolling: false}, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if err := svc.EvaluateAndAct(context.Background(), tc.card); err != nil { + t.Fatalf("期望直接返回 nil,实际 %v", err) + } + }) + } +} + +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 123ccce8..9db57991 100644 --- a/internal/service/iot_card/stop_resume_service.go +++ b/internal/service/iot_card/stop_resume_service.go @@ -108,7 +108,19 @@ func (s *StopResumeService) SetPollingCallback(callback PollingCallback) { // EvaluateAndAct 停复机统一入口 // 根据卡的当前网络状态,自动判断并执行停机或复机操作 // 设备/单卡维度通过 card.IsStandalone 推导(false 则为设备维度,停/复机覆盖设备下所有卡) +// 卡或其绑定设备关闭轮询时直接返回,不停机也不复机。 func (s *StopResumeService) EvaluateAndAct(ctx context.Context, card *model.IotCard) error { + if card == nil { + return nil + } + disabled, err := s.pollingDisabled(ctx, card) + if err != nil { + return err + } + if disabled { + s.logger.Debug("卡或绑定设备已关闭轮询,跳过自动停复机", zap.Uint("card_id", card.ID)) + return nil + } switch card.NetworkStatus { case constants.NetworkStatusOnline: // 卡在线,检查是否需要停机