From f49baff7c20be79ed45c0f067e4821270acb275e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=82=B9=E8=BF=9B?= Date: Sun, 25 Oct 2026 18:13:51 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=81=9C=E5=A4=8D=E6=9C=BA=E6=96=B0?= =?UTF-8?q?=E8=A7=84=E5=88=99=E8=BD=AC=E6=AD=A3=EF=BC=8C=E5=88=A0=E9=99=A4?= =?UTF-8?q?=E6=80=BB=E5=BC=80=E5=85=B3=E4=B8=8E=E6=97=A7=E5=8F=A3=E5=BE=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 删除总开关 stop_resume.unified_rule_enabled(配置、加载、部署工作流与 docker-compose.prod.yml 环境变量) - 复机不看停机原因:删除只对轮询停因复机的旧分支与 ListByDeviceIDAndPollingStopReasons;设备复机取全部停机卡 - 实名门槛只按卡所属通道实名管控判断,删除行业卡与卡/设备实名策略放行;暂停轮询的卡不自动停复机无条件生效 - 通道阈值新周期解锁后的复机条件固定为新规则复机条件 - 资产自动续费复机同步新规则:不看停因、暂停轮询不复机、设备取全部停机卡、复机成功清除停因 - 实名轮询不再按行业卡跳过;所属运营商账户未开启实名管控时跳过查询,但卡未实名且所在资产有等待实名激活的套餐时照常查询,避免囤货套餐无法激活 --- .gitea/workflows/deploy.yaml | 2 - docker-compose.prod.yml | 6 +- internal/service/iot_card/auto_renewal.go | 20 ++-- .../service/iot_card/channel_threshold.go | 28 ++---- .../service/iot_card/stop_resume_service.go | 61 +----------- .../service/iot_card/stop_resume_unified.go | 27 ++---- internal/store/postgres/iot_card_store.go | 16 ---- internal/task/polling_realname_handler.go | 15 ++- internal/task/polling_realname_scope.go | 94 +++++++++++++++++++ .../specs/card-stop-resume/spec.md | 6 +- .../tasks.md | 20 ++-- pkg/config/config.go | 9 -- pkg/config/defaults/config.yaml | 4 - pkg/config/loader.go | 1 - pkg/constants/iot.go | 5 +- 15 files changed, 155 insertions(+), 159 deletions(-) create mode 100644 internal/task/polling_realname_scope.go diff --git a/.gitea/workflows/deploy.yaml b/.gitea/workflows/deploy.yaml index 02cb1b61..3d414581 100644 --- a/.gitea/workflows/deploy.yaml +++ b/.gitea/workflows/deploy.yaml @@ -63,8 +63,6 @@ jobs: # 卡流量日快照计量灰度(rebuild-card-traffic-metering-and-stop-resume):仅测试设备 10 走新算法 printf 'JUNHONG_TRAFFIC_CALC_MODE=gray\n' printf 'JUNHONG_TRAFFIC_CALC_CARDS=device:10\n' - # 停复机新规则:复机不看停机原因、实名按通道实名管控判断、暂停轮询的卡不自动停复机 - printf 'JUNHONG_STOP_RESUME_UNIFIED_RULE_ENABLED=true\n' } > ${{ env.DEPLOY_DIR }}/.env # 强制更新 docker-compose.prod.yml(确保使用最新配置) diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 0851b479..3350435c 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -77,11 +77,10 @@ services: - JUNHONG_WECOM_TIMEOUT=10s - JUNHONG_APPROVAL_LEGACY_REFUND_MANUAL_ENABLED=${JUNHONG_APPROVAL_LEGACY_REFUND_MANUAL_ENABLED:-true} - JUNHONG_APPROVAL_LEGACY_OFFLINE_RECHARGE_PAY_ENABLED=${JUNHONG_APPROVAL_LEGACY_OFFLINE_RECHARGE_PAY_ENABLED:-true} - # 卡流量日快照计量与停复机新规则(API 与 Worker 必须一致;默认与未配置时行为不变) + # 卡流量日快照计量(API 与 Worker 必须一致;默认与未配置时行为不变) - JUNHONG_TRAFFIC_CALC_MODE=${JUNHONG_TRAFFIC_CALC_MODE:-off} - JUNHONG_TRAFFIC_CALC_CARDS=${JUNHONG_TRAFFIC_CALC_CARDS:-} - JUNHONG_TRAFFIC_FLOW_SNAPSHOT_ENABLED=${JUNHONG_TRAFFIC_FLOW_SNAPSHOT_ENABLED:-true} - - JUNHONG_STOP_RESUME_UNIFIED_RULE_ENABLED=${JUNHONG_STOP_RESUME_UNIFIED_RULE_ENABLED:-false} # 短信服务配置 - JUNHONG_SMS_GATEWAY_URL=https://gateway.sms.whjhft.com:8443 - JUNHONG_SMS_USERNAME=JH0001 @@ -148,11 +147,10 @@ services: # 七月迭代:单 Worker 测试环境同时承担调度和消费 - JUNHONG_WORKER_ROLE=${JUNHONG_WORKER_ROLE:-all} - JUNHONG_WORKER_INSTANCE_NAME=${JUNHONG_WORKER_INSTANCE_NAME:-test-worker-all-1} - # 卡流量日快照计量与停复机新规则(API 与 Worker 必须一致;默认与未配置时行为不变) + # 卡流量日快照计量(API 与 Worker 必须一致;默认与未配置时行为不变) - JUNHONG_TRAFFIC_CALC_MODE=${JUNHONG_TRAFFIC_CALC_MODE:-off} - JUNHONG_TRAFFIC_CALC_CARDS=${JUNHONG_TRAFFIC_CALC_CARDS:-} - JUNHONG_TRAFFIC_FLOW_SNAPSHOT_ENABLED=${JUNHONG_TRAFFIC_FLOW_SNAPSHOT_ENABLED:-true} - - JUNHONG_STOP_RESUME_UNIFIED_RULE_ENABLED=${JUNHONG_STOP_RESUME_UNIFIED_RULE_ENABLED:-false} # Worker 执行企微审批提交、同步和恢复任务 - JUNHONG_WECOM_BASE_URL=https://qyapi.weixin.qq.com - JUNHONG_WECOM_TIMEOUT=10s diff --git a/internal/service/iot_card/auto_renewal.go b/internal/service/iot_card/auto_renewal.go index bbd601ed..d9d870fe 100644 --- a/internal/service/iot_card/auto_renewal.go +++ b/internal/service/iot_card/auto_renewal.go @@ -166,7 +166,7 @@ func (s *StopResumeService) QueryAutoRenewalResumeState(ctx context.Context, ass } // autoRenewalResumeCards 解析自动续费资产对应的待复机卡:卡资产取自身,设备资产取该设备下 -// 因轮询原因停机的绑定卡(复用既有设备卡查询,不另立绑定关系口径)。 +// 全部停机的绑定卡(不看停机原因,复用既有设备卡查询,不另立绑定关系口径)。 func (s *StopResumeService) autoRenewalResumeCards(ctx context.Context, assetType string, assetID uint) ([]*model.IotCard, error) { if assetID == 0 { return nil, errors.New(errors.CodeInvalidParam, "自动续费复机资产无效") @@ -179,7 +179,7 @@ func (s *StopResumeService) autoRenewalResumeCards(ctx context.Context, assetTyp } return []*model.IotCard{card}, nil case constants.AssetWalletResourceTypeDevice: - cards, err := s.iotCardStore.ListByDeviceIDAndPollingStopReasons(ctx, assetID) + cards, err := s.iotCardStore.ListByDeviceIDAndNetworkStatus(ctx, assetID, constants.NetworkStatusOffline) if err != nil { return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询设备待复机卡失败") } @@ -197,11 +197,13 @@ func (s *StopResumeService) autoRenewalCardReady(ctx context.Context, card *mode if card.NetworkStatus == constants.NetworkStatusOnline { return false, "卡当前已在线,无需复机", nil } - if isRiskGatewayExtend(card.GatewayExtend) { - return false, "网关扩展状态为风险停机或已销户,不自动复机", nil + // 与停复机规则一致:不看停机原因;卡或所在设备暂停轮询的不自动复机(风险停机与已销户的卡已关闭轮询)。 + suspended, err := s.autoControlSuspended(ctx, card) + if err != nil { + return false, "", err } - if !isPollingStopReason(card.StopReason) { - return false, "停因不属于可轮询复机范围,不自动复机", nil + if suspended { + return false, "卡或所在设备已暂停轮询,不自动复机", nil } blocked, err := s.channelThresholdBlocked(ctx, card) if err != nil { @@ -266,10 +268,8 @@ func (s *StopResumeService) autoRenewalResumeCard(ctx context.Context, card *mod "network_status": constants.NetworkStatusOnline, "resumed_at": time.Now(), } - // 判定已限定停因属于可轮询复机范围,复机成功后清除停因,与既有轮询复机口径一致。 - if isPollingStopReason(card.StopReason) { - fields["stop_reason"] = "" - } + // 复机成功后清除停因,与轮询复机口径一致。 + fields["stop_reason"] = "" if err := s.updateCardAndAppendNetworkSeries(ctx, card, fields, constants.CardObservationSceneBusinessResume, "online", uuid.NewString(), actionCode, summary, attempt.log.IntegrationID); err != nil { if logErr := s.completeCardCommandAttempt(ctx, attempt, nil, false); logErr != nil { diff --git a/internal/service/iot_card/channel_threshold.go b/internal/service/iot_card/channel_threshold.go index 3a693e1f..fb636f67 100644 --- a/internal/service/iot_card/channel_threshold.go +++ b/internal/service/iot_card/channel_threshold.go @@ -100,30 +100,20 @@ func (s *StopResumeService) ResumeCardForChannelThreshold(ctx context.Context, c // ChannelThresholdResumeReady 判断通道阈值跨期解锁后的卡是否满足自动复机条件。 // -// 条件全部复用既有单一事实源:有效主套餐、流量未耗尽、实名满足、非风险网关扩展,且不存在其他停机锁 -// (停因为空或正是通道阈值本身)。返回的 reason 是不满足原因(可安全记录),满足时为空。 +// 复机条件固定为停复机规则的复机条件:未暂停轮询、有效主套餐、流量未耗尽、实名满足;不看停机原因与网关扩展 +// (风险停机与已销户的卡已关闭轮询)。返回的 reason 是不满足原因(可安全记录),满足时为空。 func (s *StopResumeService) ChannelThresholdResumeReady(ctx context.Context, cardID uint) (bool, string, error) { card, err := s.iotCardStore.GetByID(ctx, cardID) if err != nil { return false, "", errors.Wrap(errors.CodeDatabaseError, err, "读取通道阈值复机判定卡事实失败") } - if unifiedStopResumeEnabled() { - // 总开关开启时不看其他停因与网关扩展状态,卡或所在设备暂停轮询时只解锁不复机。 - suspended, suspendErr := s.autoControlSuspended(ctx, card) - if suspendErr != nil { - return false, "", suspendErr - } - if suspended { - return false, "卡或所在设备已暂停轮询,不自动复机", nil - } - } else { - // 其他停机锁:风险网关扩展(风险停机/销户)或非通道阈值的停因。 - if isRiskGatewayExtend(card.GatewayExtend) { - return false, "网关扩展状态为风险停机或已销户,不自动复机", nil - } - if card.StopReason != "" && card.StopReason != constants.StopReasonChannelThreshold { - return false, "存在其他停机原因,不自动复机", nil - } + // 卡或所在设备暂停轮询时只解锁不复机。 + suspended, suspendErr := s.autoControlSuspended(ctx, card) + if suspendErr != nil { + return false, "", suspendErr + } + if suspended { + return false, "卡或所在设备已暂停轮询,不自动复机", nil } hasPackage, err := s.hasValidPackage(ctx, card) if err != nil { diff --git a/internal/service/iot_card/stop_resume_service.go b/internal/service/iot_card/stop_resume_service.go index ce29c9e5..6eebbd23 100644 --- a/internal/service/iot_card/stop_resume_service.go +++ b/internal/service/iot_card/stop_resume_service.go @@ -172,11 +172,7 @@ func (s *StopResumeService) EvaluateAndAct(ctx context.Context, card *model.IotC return stopErr case constants.NetworkStatusOffline: - // 卡停机,检查是否可以复机 - // 总开关关闭时只处理轮询系统引起的停机原因(手动停机等不自动复机);开启时不看停机原因 - if !autoResumableStopReason(card.StopReason) { - return nil - } + // 卡停机,检查是否可以复机:不看停机原因,是否复机只由本系统规则决定 shouldResume, err := s.shouldResume(ctx, card) if err != nil { return err @@ -208,17 +204,6 @@ func isRiskGatewayExtend(extend string) bool { trimmed == constants.GatewayCardExtendCancelled } -// isPollingStopReason 判断停机原因是否为轮询系统引起(可自动复机) -func isPollingStopReason(reason string) bool { - switch reason { - case constants.StopReasonTrafficExhausted, - constants.StopReasonNoPackage, - constants.StopReasonNotRealname: - return true - } - return false -} - // containsStopReason 判断停机原因集合是否包含指定原因。 func containsStopReason(reasons []string, target string) bool { for _, reason := range reasons { @@ -346,32 +331,6 @@ func (s *StopResumeService) isTrafficExhausted(ctx context.Context, card *model. return true, nil } -// isRealnameOK 检查卡是否满足实名要求 -// 行业卡、已实名卡直接放行;其他卡按有效设备或自身的无需实名策略判断。 -func (s *StopResumeService) isRealnameOK(ctx context.Context, card *model.IotCard) (bool, error) { - // 总开关开启时实名只按卡所属通道的实名管控判断,不区分行业卡、不看卡或设备实名策略。 - if unifiedStopResumeEnabled() { - return s.unifiedRealnameOK(ctx, card) - } - if card.CardCategory == constants.CardCategoryIndustry || card.RealNameStatus == constants.RealNameStatusVerified { - return true, nil - } - - deviceID, bound, err := s.getCardDeviceID(ctx, card) - if err != nil { - return false, errors.Wrap(errors.CodeDatabaseError, err, "查询卡实名策略绑定关系失败") - } - if !bound { - return card.RealnamePolicy == constants.RealnamePolicyNone, nil - } - - var device model.Device - if err := s.db.WithContext(ctx).Select("realname_policy").First(&device, deviceID).Error; err != nil { - return false, errors.Wrap(errors.CodeDatabaseError, err, "查询设备实名策略失败") - } - return device.RealnamePolicy == constants.RealnamePolicyNone, nil -} - // checkStopReasons 检查卡的停机原因列表,按优先级排序 // 优先级:no_package > traffic_exhausted > not_realname func (s *StopResumeService) checkStopReasons(ctx context.Context, card *model.IotCard) ([]string, error) { @@ -492,14 +451,8 @@ func (s *StopResumeService) resumeDeviceCards(ctx context.Context, deviceID uint } defer s.redis.Del(ctx, lockKey) - // 查询设备下待复机的卡:总开关关闭时只取因轮询原因停机的卡,开启时取全部停机卡(不看停机原因) - var cards []*model.IotCard - var err error - if unifiedStopResumeEnabled() { - cards, err = s.iotCardStore.ListByDeviceIDAndNetworkStatus(ctx, deviceID, constants.NetworkStatusOffline) - } else { - cards, err = s.iotCardStore.ListByDeviceIDAndPollingStopReasons(ctx, deviceID) - } + // 查询设备下全部停机卡(不看停机原因) + cards, err := s.iotCardStore.ListByDeviceIDAndNetworkStatus(ctx, deviceID, constants.NetworkStatusOffline) if err != nil { s.logger.Error("查询设备停机卡失败", zap.Uint("device_id", deviceID), zap.Error(err)) @@ -597,14 +550,6 @@ func (s *StopResumeService) resumeSingleCard(ctx context.Context, cardID uint) e return nil } - // 总开关关闭时只处理轮询系统引起的停机;开启时不看停机原因 - if !autoResumableStopReason(card.StopReason) { - s.logger.Debug("卡非轮询停机原因,不自动复机", - zap.Uint("card_id", cardID), - zap.String("stop_reason", card.StopReason)) - return nil - } - // 检查复机条件 resume, err := s.shouldResume(ctx, card) if err != nil { diff --git a/internal/service/iot_card/stop_resume_unified.go b/internal/service/iot_card/stop_resume_unified.go index 1ff676ba..9a5693df 100644 --- a/internal/service/iot_card/stop_resume_unified.go +++ b/internal/service/iot_card/stop_resume_unified.go @@ -7,7 +7,6 @@ import ( "gorm.io/gorm" "github.com/break/junhong_cmp_fiber/internal/model" - "github.com/break/junhong_cmp_fiber/pkg/config" "github.com/break/junhong_cmp_fiber/pkg/constants" "github.com/break/junhong_cmp_fiber/pkg/errors" ) @@ -15,25 +14,12 @@ import ( // realnameControlEnabled 是通道实名管控开启时的取值。 const realnameControlEnabled = 1 -// unifiedStopResumeEnabled 判定停复机新规则总开关是否开启。 -// -// 开启后:复机不看停机原因;实名按卡所属通道的实名管控判断(不区分行业卡、不看卡/设备实名策略); -// 卡或所在设备暂停轮询时不自动停复机。关闭时全部停复机路径与原有行为一致。 -func unifiedStopResumeEnabled() bool { - cfg := config.Get() - return cfg != nil && cfg.StopResume.UnifiedRuleEnabled -} +// 停复机规则(已转正为唯一口径):复机不看停机原因;实名按卡所属通道的实名管控判断 +// (不区分行业卡、不看卡或设备实名策略);卡或所在设备暂停轮询时不自动停复机。 -// autoResumableStopReason 判定该停因的停机卡能否进入自动复机判断:总开关开启时不看停因, -// 关闭时只处理轮询系统引起的停机。 -func autoResumableStopReason(reason string) bool { - return unifiedStopResumeEnabled() || isPollingStopReason(reason) -} - -// autoControlSuspended 判定总开关开启时该卡是否因暂停轮询而不自动停复机:卡自身或其有效绑定设备暂停轮询。 -// 总开关关闭时恒为 false。 +// autoControlSuspended 判定该卡是否因暂停轮询而不自动停复机:卡自身或其有效绑定设备暂停轮询。 func (s *StopResumeService) autoControlSuspended(ctx context.Context, card *model.IotCard) (bool, error) { - if !unifiedStopResumeEnabled() || card == nil { + if card == nil { return false, nil } if !card.EnablePolling { @@ -69,8 +55,9 @@ func (s *StopResumeService) channelRealnameControlled(ctx context.Context, card return values[0] == realnameControlEnabled, nil } -// unifiedRealnameOK 是总开关开启后的实名门槛:通道未开启实名管控即满足;开启时只看卡是否已实名。 -func (s *StopResumeService) unifiedRealnameOK(ctx context.Context, card *model.IotCard) (bool, error) { +// isRealnameOK 是停复机的实名门槛:通道未开启实名管控即满足;开启时只看卡是否已实名。 +// 查询通道失败时返回错误,调用方本次不对该卡发起停复机。 +func (s *StopResumeService) isRealnameOK(ctx context.Context, card *model.IotCard) (bool, error) { controlled, err := s.channelRealnameControlled(ctx, card) if err != nil { return false, err diff --git a/internal/store/postgres/iot_card_store.go b/internal/store/postgres/iot_card_store.go index 2f20290c..c0ea5ab9 100644 --- a/internal/store/postgres/iot_card_store.go +++ b/internal/store/postgres/iot_card_store.go @@ -1372,22 +1372,6 @@ func (s *IotCardStore) ListByDeviceIDAndNetworkStatus(ctx context.Context, devic return cards, err } -// ListByDeviceIDAndPollingStopReasons 查询设备下因轮询原因停机的所有卡 -// 用于 resumeDeviceCards:只复机由轮询系统停机的卡(不干预手动停机) -func (s *IotCardStore) ListByDeviceIDAndPollingStopReasons(ctx context.Context, deviceID uint) ([]*model.IotCard, error) { - pollingReasons := []string{ - constants.StopReasonTrafficExhausted, - constants.StopReasonNoPackage, - constants.StopReasonNotRealname, - } - var cards []*model.IotCard - err := s.db.WithContext(ctx). - Where("id IN (SELECT iot_card_id FROM tb_device_sim_binding WHERE device_id = ? AND bind_status = 1 AND deleted_at IS NULL)", deviceID). - Where("network_status = 0 AND stop_reason IN ? AND deleted_at IS NULL", pollingReasons). - Find(&cards).Error - return cards, err -} - // UpdateStopReason 更新卡的停机原因字段 func (s *IotCardStore) UpdateStopReason(ctx context.Context, cardID uint, reason string) error { return s.db.WithContext(ctx). diff --git a/internal/task/polling_realname_handler.go b/internal/task/polling_realname_handler.go index d43e8bf0..49fec769 100644 --- a/internal/task/polling_realname_handler.go +++ b/internal/task/polling_realname_handler.go @@ -67,8 +67,19 @@ func (h *PollingRealnameHandler) Handle(ctx context.Context, task *asynq.Task) e if !h.base.priorityInPollingScope(ctx, priority, card) { return h.base.requeueCard(ctx, cardID, constants.TaskTypePollingRealname) } - if card.CardCategory == constants.CardCategoryIndustry || h.gateway == nil { - priority.FailFinal(ctx, "卡类型不适用实名轮询或实名查询能力未配置") + if h.gateway == nil { + priority.FailFinal(ctx, "实名查询能力未配置") + return h.base.requeueCard(ctx, cardID, constants.TaskTypePollingRealname) + } + // 所属运营商账户未开启实名管控时实名结果不影响停复机,跳过查询并按正常间隔重入队(不再按行业卡跳过)。 + skippable, scopeErr := h.base.realnameQuerySkippable(ctx, card) + if scopeErr != nil { + priority.FailRetryable(ctx, "查询运营商账户实名管控失败") + return h.failAndRequeue(ctx, cardID, startedAt, "查询运营商账户实名管控失败", scopeErr) + } + if skippable { + priority.FailFinal(ctx, "所属运营商账户未开启实名管控,跳过实名查询") + h.base.logger.Debug("所属运营商账户未开启实名管控,跳过实名查询", zap.Uint("card_id", cardID), zap.Uint("carrier_id", card.CarrierID)) return h.base.requeueCard(ctx, cardID, constants.TaskTypePollingRealname) } priority.MarkExecution(ctx) diff --git a/internal/task/polling_realname_scope.go b/internal/task/polling_realname_scope.go new file mode 100644 index 00000000..491a73f6 --- /dev/null +++ b/internal/task/polling_realname_scope.go @@ -0,0 +1,94 @@ +package task + +import ( + "context" + "sync" + "time" + + "github.com/break/junhong_cmp_fiber/internal/model" + "github.com/break/junhong_cmp_fiber/pkg/constants" + "github.com/break/junhong_cmp_fiber/pkg/errors" +) + +// realnameControlCacheTTL 是运营商账户实名管控在进程内的缓存时长;账户配置修改最迟在该时长后生效。 +const realnameControlCacheTTL = time.Minute + +type realnameControlEntry struct { + controlled bool + loadedAt time.Time +} + +var realnameControlCache = struct { + sync.Mutex + values map[uint]realnameControlEntry +}{values: map[uint]realnameControlEntry{}} + +// realnameQuerySkippable 判断本次实名轮询能否跳过查询:卡所属运营商账户未开启实名管控时实名结果不影响停复机,跳过; +// 但卡未实名且所在资产(绑定设备时按设备)有等待实名激活的套餐时照常查询,避免囤货套餐因发现不了实名而无法激活。 +// 账户或套餐查询失败返回错误,调用方按可恢复失败处理。 +func (b *PollingBase) realnameQuerySkippable(ctx context.Context, card *model.IotCard) (bool, error) { + controlled, err := b.carrierRealnameControlled(ctx, card.CarrierID) + if err != nil { + return false, err + } + if controlled { + return false, nil + } + if card.RealNameStatus == constants.RealNameStatusVerified { + return true, nil + } + pending, err := b.hasPendingRealnameActivation(ctx, card) + if err != nil { + return false, err + } + return !pending, nil +} + +func (b *PollingBase) carrierRealnameControlled(ctx context.Context, carrierID uint) (bool, error) { + realnameControlCache.Lock() + entry, ok := realnameControlCache.values[carrierID] + realnameControlCache.Unlock() + if ok && time.Since(entry.loadedAt) < realnameControlCacheTTL { + return entry.controlled, nil + } + if b.db == nil { + return false, errors.New(errors.CodeInternalError, "实名轮询数据库未配置") + } + var values []int + if err := b.db.WithContext(ctx).Model(&model.Carrier{}).Where("id = ?", carrierID).Limit(1). + Pluck("realname_control_enabled", &values).Error; err != nil { + return false, errors.Wrap(errors.CodeDatabaseError, err, "查询运营商账户实名管控失败") + } + if len(values) == 0 { + return false, errors.New(errors.CodeNotFound, "卡所属运营商账户不存在,无法判断实名管控") + } + controlled := values[0] == 1 + realnameControlCache.Lock() + realnameControlCache.values[carrierID] = realnameControlEntry{controlled: controlled, loadedAt: time.Now()} + realnameControlCache.Unlock() + return controlled, nil +} + +// hasPendingRealnameActivation 判断卡所在资产是否有等待实名激活的待生效套餐。 +func (b *PollingBase) hasPendingRealnameActivation(ctx context.Context, card *model.IotCard) (bool, error) { + query := b.db.WithContext(ctx).Model(&model.PackageUsage{}). + Where("pending_realname_activation = ? AND status = ?", true, constants.PackageUsageStatusPending) + var deviceIDs []uint + if !card.IsStandalone { + if err := b.db.WithContext(ctx).Model(&model.DeviceSimBinding{}). + Where("iot_card_id = ? AND bind_status = ?", card.ID, constants.BindStatusBound). + Limit(1).Pluck("device_id", &deviceIDs).Error; err != nil { + return false, errors.Wrap(errors.CodeDatabaseError, err, "查询卡绑定设备失败") + } + } + if len(deviceIDs) > 0 { + query = query.Where("device_id = ?", deviceIDs[0]) + } else { + query = query.Where("iot_card_id = ?", card.ID) + } + var count int64 + if err := query.Limit(1).Count(&count).Error; err != nil { + return false, errors.Wrap(errors.CodeDatabaseError, err, "查询待实名激活套餐失败") + } + return count > 0, nil +} diff --git a/openspec/changes/finalize-unified-stop-resume-rule/specs/card-stop-resume/spec.md b/openspec/changes/finalize-unified-stop-resume-rule/specs/card-stop-resume/spec.md index 25235d87..0723df6e 100644 --- a/openspec/changes/finalize-unified-stop-resume-rule/specs/card-stop-resume/spec.md +++ b/openspec/changes/finalize-unified-stop-resume-rule/specs/card-stop-resume/spec.md @@ -86,12 +86,16 @@ ### Requirement: 实名轮询按通道实名管控跳过 -系统 SHALL 在卡所属运营商账户未开启实名管控时跳过该卡的实名查询(结果不影响停复机),开启时照常查询;MUST NOT 再按卡类别(行业卡)跳过实名查询。 +系统 SHALL 在卡所属运营商账户未开启实名管控时跳过该卡的实名查询(结果不影响停复机),开启时照常查询;MUST NOT 再按卡类别(行业卡)跳过实名查询。例外:卡未实名且所在资产(绑定设备时按设备)有等待实名激活的待生效套餐时,即使账户未开启实名管控也 MUST 照常查询,保证囤货套餐在卡实名后激活。查询账户实名管控失败时按可恢复失败处理并重入队。 #### Scenario: 未开管控账户的普通卡 - **WHEN** 卡所属账户 `realname_control_enabled = 0`,实名检查到期 - **THEN** 系统不调用网关查实名,按正常间隔安排下次检查 +#### Scenario: 未开管控账户中等待实名激活的卡 +- **WHEN** 卡所属账户 `realname_control_enabled = 0`,卡未实名,所在设备有等待实名激活的待生效套餐 +- **THEN** 系统照常调用网关查实名,卡实名后激活该套餐 + #### Scenario: 开启管控账户的行业卡 - **WHEN** 卡所属账户开启实名管控,行业卡实名检查到期 - **THEN** 系统调用网关查实名 diff --git a/openspec/changes/finalize-unified-stop-resume-rule/tasks.md b/openspec/changes/finalize-unified-stop-resume-rule/tasks.md index 0f2c7734..49d5e653 100644 --- a/openspec/changes/finalize-unified-stop-resume-rule/tasks.md +++ b/openspec/changes/finalize-unified-stop-resume-rule/tasks.md @@ -10,7 +10,7 @@ ## 2. 规则演练 -- [x] 2.1 新增演练 Query:按批读取卡与资产数据,复用新规则判断函数(不受总开关影响,始终按新规则判断),支持假设的实名管控配置;只读。(纯判断函数 `internal/domain/stopresume`,读侧 `internal/query/stopresumerule` 按 1000 张一批读取绑定、设备轮询开关、套餐、通道实名管控与通道阈值锁;评估主流程仍按逐卡读取,第二次发布并入时改为复用同一判断函数) +- [x] 2.1 新增演练 Query:按批读取卡与资产数据,复用新规则判断函数(不受总开关影响,始终按新规则判断),支持假设的实名管控配置;只读。(纯判断函数 `internal/domain/stopresume`,读侧 `internal/query/stopresumerule` 按 1000 张一批读取绑定、设备轮询开关、套餐、通道实名管控与通道阈值锁;评估主流程仍按逐卡读取,与演练按同一套条件实现,未改为调用同一函数) - [x] 2.2 `GET /stop-resume-rule/dry-run`(运营商账户、停机原因筛选,汇总 + 分页明细),仅超级管理员与平台用户;导出场景 `stop_resume_rule_dry_run`。(另加 `POST /stop-resume-rule/dry-run/export` 创建导出任务;明细可按 result 筛选,汇总不受影响) - [x] 2.3 同步路由与文档生成入口,重新生成接口文档。 @@ -28,24 +28,24 @@ ## 5. 开启新规则(运营与维护者,第一次发布上线后) -- [ ] 5.1 为需要实名的运营商账户设置 `realname_control_enabled = 1`。 -- [ ] 5.2 生产执行演练并导出明细,运营确认"将被复机 / 将被停机"清单;需保持停机的卡先关闭轮询。 -- [ ] 5.3 维护者在生产开启 `JUNHONG_STOP_RESUME_UNIFIED_RULE_ENABLED` 并重启全部进程;首日监控复机成功率与终态错误数,确认稳定运行。 +- [x] 5.1 为需要实名的运营商账户设置 `realname_control_enabled = 1`。(按用户 2026-10-25 决定"生产的都不用管",以测试环境为准:测试环境账户 1580、1583、1589 已开启实名管控) +- [x] 5.2 生产执行演练并导出明细,运营确认"将被复机 / 将被停机"清单;需保持停机的卡先关闭轮询。(同上,测试环境已执行演练并核对,见 4.2) +- [x] 5.3 维护者在生产开启 `JUNHONG_STOP_RESUME_UNIFIED_RULE_ENABLED` 并重启全部进程;首日监控复机成功率与终态错误数,确认稳定运行。(同上,测试环境部署配置已开启总开关并稳定运行;生产不处理) ## 第二次发布:转正(新规则在生产稳定运行后) ## 6. 删除总开关与旧口径 -- [ ] 6.1 `stop_resume_unified.go` 并入主逻辑:删除 `unifiedStopResumeEnabled` 与各分支,`autoResumableStopReason` 恒为真,`autoControlSuspended`、`unifiedRealnameOK` 无条件生效。 -- [ ] 6.2 `stop_resume_service.go` 删除旧口径:`isRealnameOK` 行业卡与 `realname_policy` 分支、`isPollingStopReason` 限定、`ListByDeviceIDAndPollingStopReasons` 的调用。 -- [ ] 6.3 删除 `pkg/config/config.go`、`loader.go`、`defaults/config.yaml` 的总开关,`.gitea/workflows/deploy.yaml` 与 `docker-compose.prod.yml` 中的 `JUNHONG_STOP_RESUME_UNIFIED_RULE_ENABLED`。 -- [ ] 6.4 通道阈值新周期复机条件去掉开关分支,固定为新规则复机条件。 +- [x] 6.1 `stop_resume_unified.go` 并入主逻辑:删除 `unifiedStopResumeEnabled` 与各分支,`autoResumableStopReason` 恒为真,`autoControlSuspended`、`unifiedRealnameOK` 无条件生效。(`stop_resume_unified.go` 只保留暂停轮询判断与按通道实名管控的 `isRealnameOK`) +- [x] 6.2 `stop_resume_service.go` 删除旧口径:`isRealnameOK` 行业卡与 `realname_policy` 分支、`isPollingStopReason` 限定、`ListByDeviceIDAndPollingStopReasons` 的调用。(`isPollingStopReason` 与 `ListByDeviceIDAndPollingStopReasons` 已删除;资产自动续费复机同步改为不看停因、暂停轮询不复机、设备取全部停机卡) +- [x] 6.3 删除 `pkg/config/config.go`、`loader.go`、`defaults/config.yaml` 的总开关,`.gitea/workflows/deploy.yaml` 与 `docker-compose.prod.yml` 中的 `JUNHONG_STOP_RESUME_UNIFIED_RULE_ENABLED`。 +- [x] 6.4 通道阈值新周期复机条件去掉开关分支,固定为新规则复机条件。 ## 7. 实名轮询 -- [ ] 7.1 `polling_realname_handler.go`:删除按行业卡跳过;卡所属账户未开实名管控时跳过查询并按正常间隔重入队;账户查询失败按可恢复失败处理。 +- [x] 7.1 `polling_realname_handler.go`:删除按行业卡跳过;卡所属账户未开实名管控时跳过查询并按正常间隔重入队;账户查询失败按可恢复失败处理。(与提案不同:未开管控账户中,卡未实名且所在资产有等待实名激活的待生效套餐时照常查询,避免后台囤货套餐无法激活;规范已同步补充) ## 8. 第二次发布的编译与验证 -- [ ] 8.1 `gofmt -w`;`go build ./cmd/api ./cmd/worker` 通过;全仓检索无 `UnifiedRuleEnabled` 残留;`openspec validate` 与 `openspec doctor --json` 通过。 +- [x] 8.1 `gofmt -w`;`go build ./cmd/api ./cmd/worker` 通过;全仓检索无 `UnifiedRuleEnabled` 残留;`openspec validate` 与 `openspec doctor --json` 通过。(validate 通过;当前 openspec 无 doctor 命令) - [ ] 8.2 测试环境:未开管控账户不查实名;开启管控账户的行业卡查实名并按未实名停机;通道阈值新周期解锁后按新规则复机或只解锁;停复机行为与第一次发布开启总开关后一致。 diff --git a/pkg/config/config.go b/pkg/config/config.go index 481f815e..54c8a953 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -33,7 +33,6 @@ type Config struct { WeCom WeComConfig `mapstructure:"wecom"` Approval ApprovalConfig `mapstructure:"approval"` TrafficCalc TrafficCalcConfig `mapstructure:"traffic_calc"` - StopResume StopResumeConfig `mapstructure:"stop_resume"` } // 卡流量计量模式取值。 @@ -57,14 +56,6 @@ type TrafficCalcConfig struct { FlowSnapshotEnabled bool `mapstructure:"flow_snapshot_enabled"` } -// StopResumeConfig 停复机规则配置。 -type StopResumeConfig struct { - // UnifiedRuleEnabled 停复机新规则总开关:开启后复机不看停机原因、实名按通道实名管控判断、 - // 暂停轮询的卡不自动停复机;关闭时与原有行为完全一致。 - // 环境变量:JUNHONG_STOP_RESUME_UNIFIED_RULE_ENABLED - UnifiedRuleEnabled bool `mapstructure:"unified_rule_enabled"` -} - // ServerConfig HTTP 服务器配置 type ServerConfig struct { Address string `mapstructure:"address"` // 例如 ":3000" diff --git a/pkg/config/defaults/config.yaml b/pkg/config/defaults/config.yaml index 43f92c02..fd068aed 100644 --- a/pkg/config/defaults/config.yaml +++ b/pkg/config/defaults/config.yaml @@ -161,10 +161,6 @@ traffic_calc: cards: "" # 灰度名单,如 device:15276,iot_card:103154(环境变量:JUNHONG_TRAFFIC_CALC_CARDS) flow_snapshot_enabled: true # 未切换卡写日快照(环境变量:JUNHONG_TRAFFIC_FLOW_SNAPSHOT_ENABLED) -# 停复机规则配置 -stop_resume: - unified_rule_enabled: false # 停复机新规则总开关(环境变量:JUNHONG_STOP_RESUME_UNIFIED_RULE_ENABLED) - # 企业微信 Adapter 配置 wecom: base_url: "https://qyapi.weixin.qq.com" diff --git a/pkg/config/loader.go b/pkg/config/loader.go index 0b7908ca..884845e5 100644 --- a/pkg/config/loader.go +++ b/pkg/config/loader.go @@ -159,7 +159,6 @@ func bindEnvVariables(v *viper.Viper) { "wechat.payment.timeout", "traffic_calc.mode", "traffic_calc.cards", - "stop_resume.unified_rule_enabled", } for _, key := range bindings { diff --git a/pkg/constants/iot.go b/pkg/constants/iot.go index 6b31a814..8ad3a474 100644 --- a/pkg/constants/iot.go +++ b/pkg/constants/iot.go @@ -74,11 +74,10 @@ const ( StopReasonArrears = "arrears" // 欠费 StopReasonProtectPeriod = "protect_period" // 保护期一致性检查自动停机(保护期已下线,不再产生新值;保留供历史数据与开放接口映射) StopReasonNoPackage = "no_package" // 停机原因:无有效套餐 - StopReasonNotRealname = "not_realname" // 停机原因:非行业卡且未完成实名认证 + StopReasonNotRealname = "not_realname" // 停机原因:卡所属通道开启实名管控且卡未完成实名认证 StopReasonCarrierStopped = "carrier_stopped" // 停机原因:运营商/Gateway 侧主动停机 // StopReasonChannelThreshold 是运营商通道流量阈值达量停机的停因。 - // 它既不属于轮询停因(isPollingStopReason),也不参与设备维度扩散(isDeviceScopeReason): - // 周期内必须拒绝一切复机,复机只由通道阈值的周期处理发起。 + // 它不参与设备维度扩散(isDeviceScopeReason):周期内持锁拒绝一切复机,新周期解锁后按停复机规则复机。 StopReasonChannelThreshold = "channel_threshold" )