feat: 停复机新规则转正,删除总开关与旧口径
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 9m35s

- 删除总开关 stop_resume.unified_rule_enabled(配置、加载、部署工作流与 docker-compose.prod.yml 环境变量)
- 复机不看停机原因:删除只对轮询停因复机的旧分支与 ListByDeviceIDAndPollingStopReasons;设备复机取全部停机卡
- 实名门槛只按卡所属通道实名管控判断,删除行业卡与卡/设备实名策略放行;暂停轮询的卡不自动停复机无条件生效
- 通道阈值新周期解锁后的复机条件固定为新规则复机条件
- 资产自动续费复机同步新规则:不看停因、暂停轮询不复机、设备取全部停机卡、复机成功清除停因
- 实名轮询不再按行业卡跳过;所属运营商账户未开启实名管控时跳过查询,但卡未实名且所在资产有等待实名激活的套餐时照常查询,避免囤货套餐无法激活
This commit is contained in:
邹进
2026-10-25 18:13:51 +09:00
parent 457abaabd7
commit f49baff7c2
15 changed files with 155 additions and 159 deletions

View File

@@ -63,8 +63,6 @@ jobs:
# 卡流量日快照计量灰度(rebuild-card-traffic-metering-and-stop-resume):仅测试设备 10 走新算法 # 卡流量日快照计量灰度(rebuild-card-traffic-metering-and-stop-resume):仅测试设备 10 走新算法
printf 'JUNHONG_TRAFFIC_CALC_MODE=gray\n' printf 'JUNHONG_TRAFFIC_CALC_MODE=gray\n'
printf 'JUNHONG_TRAFFIC_CALC_CARDS=device:10\n' printf 'JUNHONG_TRAFFIC_CALC_CARDS=device:10\n'
# 停复机新规则:复机不看停机原因、实名按通道实名管控判断、暂停轮询的卡不自动停复机
printf 'JUNHONG_STOP_RESUME_UNIFIED_RULE_ENABLED=true\n'
} > ${{ env.DEPLOY_DIR }}/.env } > ${{ env.DEPLOY_DIR }}/.env
# 强制更新 docker-compose.prod.yml(确保使用最新配置) # 强制更新 docker-compose.prod.yml(确保使用最新配置)

View File

@@ -77,11 +77,10 @@ services:
- JUNHONG_WECOM_TIMEOUT=10s - JUNHONG_WECOM_TIMEOUT=10s
- JUNHONG_APPROVAL_LEGACY_REFUND_MANUAL_ENABLED=${JUNHONG_APPROVAL_LEGACY_REFUND_MANUAL_ENABLED:-true} - 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} - 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_MODE=${JUNHONG_TRAFFIC_CALC_MODE:-off}
- JUNHONG_TRAFFIC_CALC_CARDS=${JUNHONG_TRAFFIC_CALC_CARDS:-} - JUNHONG_TRAFFIC_CALC_CARDS=${JUNHONG_TRAFFIC_CALC_CARDS:-}
- JUNHONG_TRAFFIC_FLOW_SNAPSHOT_ENABLED=${JUNHONG_TRAFFIC_FLOW_SNAPSHOT_ENABLED:-true} - 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_GATEWAY_URL=https://gateway.sms.whjhft.com:8443
- JUNHONG_SMS_USERNAME=JH0001 - JUNHONG_SMS_USERNAME=JH0001
@@ -148,11 +147,10 @@ services:
# 七月迭代:单 Worker 测试环境同时承担调度和消费 # 七月迭代:单 Worker 测试环境同时承担调度和消费
- JUNHONG_WORKER_ROLE=${JUNHONG_WORKER_ROLE:-all} - JUNHONG_WORKER_ROLE=${JUNHONG_WORKER_ROLE:-all}
- JUNHONG_WORKER_INSTANCE_NAME=${JUNHONG_WORKER_INSTANCE_NAME:-test-worker-all-1} - 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_MODE=${JUNHONG_TRAFFIC_CALC_MODE:-off}
- JUNHONG_TRAFFIC_CALC_CARDS=${JUNHONG_TRAFFIC_CALC_CARDS:-} - JUNHONG_TRAFFIC_CALC_CARDS=${JUNHONG_TRAFFIC_CALC_CARDS:-}
- JUNHONG_TRAFFIC_FLOW_SNAPSHOT_ENABLED=${JUNHONG_TRAFFIC_FLOW_SNAPSHOT_ENABLED:-true} - 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 执行企微审批提交、同步和恢复任务 # Worker 执行企微审批提交、同步和恢复任务
- JUNHONG_WECOM_BASE_URL=https://qyapi.weixin.qq.com - JUNHONG_WECOM_BASE_URL=https://qyapi.weixin.qq.com
- JUNHONG_WECOM_TIMEOUT=10s - JUNHONG_WECOM_TIMEOUT=10s

View File

@@ -166,7 +166,7 @@ func (s *StopResumeService) QueryAutoRenewalResumeState(ctx context.Context, ass
} }
// autoRenewalResumeCards 解析自动续费资产对应的待复机卡:卡资产取自身,设备资产取该设备下 // autoRenewalResumeCards 解析自动续费资产对应的待复机卡:卡资产取自身,设备资产取该设备下
// 因轮询原因停机的绑定卡(复用既有设备卡查询,不另立绑定关系口径)。 // 全部停机的绑定卡(不看停机原因,复用既有设备卡查询,不另立绑定关系口径)。
func (s *StopResumeService) autoRenewalResumeCards(ctx context.Context, assetType string, assetID uint) ([]*model.IotCard, error) { func (s *StopResumeService) autoRenewalResumeCards(ctx context.Context, assetType string, assetID uint) ([]*model.IotCard, error) {
if assetID == 0 { if assetID == 0 {
return nil, errors.New(errors.CodeInvalidParam, "自动续费复机资产无效") return nil, errors.New(errors.CodeInvalidParam, "自动续费复机资产无效")
@@ -179,7 +179,7 @@ func (s *StopResumeService) autoRenewalResumeCards(ctx context.Context, assetTyp
} }
return []*model.IotCard{card}, nil return []*model.IotCard{card}, nil
case constants.AssetWalletResourceTypeDevice: case constants.AssetWalletResourceTypeDevice:
cards, err := s.iotCardStore.ListByDeviceIDAndPollingStopReasons(ctx, assetID) cards, err := s.iotCardStore.ListByDeviceIDAndNetworkStatus(ctx, assetID, constants.NetworkStatusOffline)
if err != nil { if err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询设备待复机卡失败") 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 { if card.NetworkStatus == constants.NetworkStatusOnline {
return false, "卡当前已在线,无需复机", nil 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) { if suspended {
return false, "停因不属于可轮询复机范围,不自动复机", nil return false, "卡或所在设备已暂停轮询,不自动复机", nil
} }
blocked, err := s.channelThresholdBlocked(ctx, card) blocked, err := s.channelThresholdBlocked(ctx, card)
if err != nil { if err != nil {
@@ -266,10 +268,8 @@ func (s *StopResumeService) autoRenewalResumeCard(ctx context.Context, card *mod
"network_status": constants.NetworkStatusOnline, "network_status": constants.NetworkStatusOnline,
"resumed_at": time.Now(), "resumed_at": time.Now(),
} }
// 判定已限定停因属于可轮询复机范围,复机成功后清除停因,与既有轮询复机口径一致。 // 复机成功后清除停因,与轮询复机口径一致。
if isPollingStopReason(card.StopReason) {
fields["stop_reason"] = "" fields["stop_reason"] = ""
}
if err := s.updateCardAndAppendNetworkSeries(ctx, card, fields, if err := s.updateCardAndAppendNetworkSeries(ctx, card, fields,
constants.CardObservationSceneBusinessResume, "online", uuid.NewString(), actionCode, summary, attempt.log.IntegrationID); err != nil { constants.CardObservationSceneBusinessResume, "online", uuid.NewString(), actionCode, summary, attempt.log.IntegrationID); err != nil {
if logErr := s.completeCardCommandAttempt(ctx, attempt, nil, false); logErr != nil { if logErr := s.completeCardCommandAttempt(ctx, attempt, nil, false); logErr != nil {

View File

@@ -100,15 +100,14 @@ func (s *StopResumeService) ResumeCardForChannelThreshold(ctx context.Context, c
// ChannelThresholdResumeReady 判断通道阈值跨期解锁后的卡是否满足自动复机条件。 // ChannelThresholdResumeReady 判断通道阈值跨期解锁后的卡是否满足自动复机条件。
// //
// 条件全部复用既有单一事实源:有效主套餐、流量未耗尽、实名满足、非风险网关扩展,且不存在其他停机锁 // 复机条件固定为停复机规则的复机条件:未暂停轮询、有效主套餐、流量未耗尽、实名满足;不看停机原因与网关扩展
// (停因为空或正是通道阈值本身)。返回的 reason 是不满足原因(可安全记录),满足时为空。 // (风险停机与已销户的卡已关闭轮询)。返回的 reason 是不满足原因(可安全记录),满足时为空。
func (s *StopResumeService) ChannelThresholdResumeReady(ctx context.Context, cardID uint) (bool, string, error) { func (s *StopResumeService) ChannelThresholdResumeReady(ctx context.Context, cardID uint) (bool, string, error) {
card, err := s.iotCardStore.GetByID(ctx, cardID) card, err := s.iotCardStore.GetByID(ctx, cardID)
if err != nil { if err != nil {
return false, "", errors.Wrap(errors.CodeDatabaseError, err, "读取通道阈值复机判定卡事实失败") return false, "", errors.Wrap(errors.CodeDatabaseError, err, "读取通道阈值复机判定卡事实失败")
} }
if unifiedStopResumeEnabled() { // 卡或所在设备暂停轮询时只解锁不复机。
// 总开关开启时不看其他停因与网关扩展状态,卡或所在设备暂停轮询时只解锁不复机。
suspended, suspendErr := s.autoControlSuspended(ctx, card) suspended, suspendErr := s.autoControlSuspended(ctx, card)
if suspendErr != nil { if suspendErr != nil {
return false, "", suspendErr return false, "", suspendErr
@@ -116,15 +115,6 @@ func (s *StopResumeService) ChannelThresholdResumeReady(ctx context.Context, car
if suspended { if suspended {
return false, "卡或所在设备已暂停轮询,不自动复机", nil return false, "卡或所在设备已暂停轮询,不自动复机", nil
} }
} else {
// 其他停机锁:风险网关扩展(风险停机/销户)或非通道阈值的停因。
if isRiskGatewayExtend(card.GatewayExtend) {
return false, "网关扩展状态为风险停机或已销户,不自动复机", nil
}
if card.StopReason != "" && card.StopReason != constants.StopReasonChannelThreshold {
return false, "存在其他停机原因,不自动复机", nil
}
}
hasPackage, err := s.hasValidPackage(ctx, card) hasPackage, err := s.hasValidPackage(ctx, card)
if err != nil { if err != nil {
return false, "", err return false, "", err

View File

@@ -172,11 +172,7 @@ func (s *StopResumeService) EvaluateAndAct(ctx context.Context, card *model.IotC
return stopErr return stopErr
case constants.NetworkStatusOffline: case constants.NetworkStatusOffline:
// 卡停机,检查是否可以复机 // 卡停机,检查是否可以复机:不看停机原因,是否复机只由本系统规则决定
// 总开关关闭时只处理轮询系统引起的停机原因(手动停机等不自动复机);开启时不看停机原因
if !autoResumableStopReason(card.StopReason) {
return nil
}
shouldResume, err := s.shouldResume(ctx, card) shouldResume, err := s.shouldResume(ctx, card)
if err != nil { if err != nil {
return err return err
@@ -208,17 +204,6 @@ func isRiskGatewayExtend(extend string) bool {
trimmed == constants.GatewayCardExtendCancelled trimmed == constants.GatewayCardExtendCancelled
} }
// isPollingStopReason 判断停机原因是否为轮询系统引起(可自动复机)
func isPollingStopReason(reason string) bool {
switch reason {
case constants.StopReasonTrafficExhausted,
constants.StopReasonNoPackage,
constants.StopReasonNotRealname:
return true
}
return false
}
// containsStopReason 判断停机原因集合是否包含指定原因。 // containsStopReason 判断停机原因集合是否包含指定原因。
func containsStopReason(reasons []string, target string) bool { func containsStopReason(reasons []string, target string) bool {
for _, reason := range reasons { for _, reason := range reasons {
@@ -346,32 +331,6 @@ func (s *StopResumeService) isTrafficExhausted(ctx context.Context, card *model.
return true, nil 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 检查卡的停机原因列表,按优先级排序 // checkStopReasons 检查卡的停机原因列表,按优先级排序
// 优先级:no_package > traffic_exhausted > not_realname // 优先级:no_package > traffic_exhausted > not_realname
func (s *StopResumeService) checkStopReasons(ctx context.Context, card *model.IotCard) ([]string, error) { 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) defer s.redis.Del(ctx, lockKey)
// 查询设备下待复机的卡:总开关关闭时只取因轮询原因停机的卡,开启时取全部停机卡(不看停机原因) // 查询设备下全部停机卡(不看停机原因)
var cards []*model.IotCard cards, err := s.iotCardStore.ListByDeviceIDAndNetworkStatus(ctx, deviceID, constants.NetworkStatusOffline)
var err error
if unifiedStopResumeEnabled() {
cards, err = s.iotCardStore.ListByDeviceIDAndNetworkStatus(ctx, deviceID, constants.NetworkStatusOffline)
} else {
cards, err = s.iotCardStore.ListByDeviceIDAndPollingStopReasons(ctx, deviceID)
}
if err != nil { if err != nil {
s.logger.Error("查询设备停机卡失败", s.logger.Error("查询设备停机卡失败",
zap.Uint("device_id", deviceID), zap.Error(err)) zap.Uint("device_id", deviceID), zap.Error(err))
@@ -597,14 +550,6 @@ func (s *StopResumeService) resumeSingleCard(ctx context.Context, cardID uint) e
return nil 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) resume, err := s.shouldResume(ctx, card)
if err != nil { if err != nil {

View File

@@ -7,7 +7,6 @@ import (
"gorm.io/gorm" "gorm.io/gorm"
"github.com/break/junhong_cmp_fiber/internal/model" "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/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors" "github.com/break/junhong_cmp_fiber/pkg/errors"
) )
@@ -15,25 +14,12 @@ import (
// realnameControlEnabled 是通道实名管控开启时的取值。 // realnameControlEnabled 是通道实名管控开启时的取值。
const realnameControlEnabled = 1 const realnameControlEnabled = 1
// unifiedStopResumeEnabled 判定停复机新规则总开关是否开启。 // 停复机规则(已转正为唯一口径):复机不看停机原因;实名按卡所属通道的实名管控判断
// // (不区分行业卡、不看卡或设备实名策略);卡或所在设备暂停轮询时不自动停复机。
// 开启后:复机不看停机原因;实名按卡所属通道的实名管控判断(不区分行业卡、不看卡/设备实名策略);
// 卡或所在设备暂停轮询时不自动停复机。关闭时全部停复机路径与原有行为一致。
func unifiedStopResumeEnabled() bool {
cfg := config.Get()
return cfg != nil && cfg.StopResume.UnifiedRuleEnabled
}
// autoResumableStopReason 判定该停因的停机卡能否进入自动复机判断:总开关开启时不看停因, // autoControlSuspended 判定该卡是否因暂停轮询而不自动停复机:卡自身或其有效绑定设备暂停轮询。
// 关闭时只处理轮询系统引起的停机。
func autoResumableStopReason(reason string) bool {
return unifiedStopResumeEnabled() || isPollingStopReason(reason)
}
// autoControlSuspended 判定总开关开启时该卡是否因暂停轮询而不自动停复机:卡自身或其有效绑定设备暂停轮询。
// 总开关关闭时恒为 false。
func (s *StopResumeService) autoControlSuspended(ctx context.Context, card *model.IotCard) (bool, error) { func (s *StopResumeService) autoControlSuspended(ctx context.Context, card *model.IotCard) (bool, error) {
if !unifiedStopResumeEnabled() || card == nil { if card == nil {
return false, nil return false, nil
} }
if !card.EnablePolling { if !card.EnablePolling {
@@ -69,8 +55,9 @@ func (s *StopResumeService) channelRealnameControlled(ctx context.Context, card
return values[0] == realnameControlEnabled, nil return values[0] == realnameControlEnabled, nil
} }
// unifiedRealnameOK 是总开关开启后的实名门槛:通道未开启实名管控即满足;开启时只看卡是否已实名。 // isRealnameOK 是停复机的实名门槛:通道未开启实名管控即满足;开启时只看卡是否已实名。
func (s *StopResumeService) unifiedRealnameOK(ctx context.Context, card *model.IotCard) (bool, error) { // 查询通道失败时返回错误,调用方本次不对该卡发起停复机。
func (s *StopResumeService) isRealnameOK(ctx context.Context, card *model.IotCard) (bool, error) {
controlled, err := s.channelRealnameControlled(ctx, card) controlled, err := s.channelRealnameControlled(ctx, card)
if err != nil { if err != nil {
return false, err return false, err

View File

@@ -1372,22 +1372,6 @@ func (s *IotCardStore) ListByDeviceIDAndNetworkStatus(ctx context.Context, devic
return cards, err 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 更新卡的停机原因字段 // UpdateStopReason 更新卡的停机原因字段
func (s *IotCardStore) UpdateStopReason(ctx context.Context, cardID uint, reason string) error { func (s *IotCardStore) UpdateStopReason(ctx context.Context, cardID uint, reason string) error {
return s.db.WithContext(ctx). return s.db.WithContext(ctx).

View File

@@ -67,8 +67,19 @@ func (h *PollingRealnameHandler) Handle(ctx context.Context, task *asynq.Task) e
if !h.base.priorityInPollingScope(ctx, priority, card) { if !h.base.priorityInPollingScope(ctx, priority, card) {
return h.base.requeueCard(ctx, cardID, constants.TaskTypePollingRealname) return h.base.requeueCard(ctx, cardID, constants.TaskTypePollingRealname)
} }
if card.CardCategory == constants.CardCategoryIndustry || h.gateway == nil { if h.gateway == nil {
priority.FailFinal(ctx, "卡类型不适用实名轮询或实名查询能力未配置") 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) return h.base.requeueCard(ctx, cardID, constants.TaskTypePollingRealname)
} }
priority.MarkExecution(ctx) priority.MarkExecution(ctx)

View File

@@ -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
}

View File

@@ -86,12 +86,16 @@
### Requirement: 实名轮询按通道实名管控跳过 ### Requirement: 实名轮询按通道实名管控跳过
系统 SHALL 在卡所属运营商账户未开启实名管控时跳过该卡的实名查询(结果不影响停复机),开启时照常查询;MUST NOT 再按卡类别(行业卡)跳过实名查询。 系统 SHALL 在卡所属运营商账户未开启实名管控时跳过该卡的实名查询(结果不影响停复机),开启时照常查询;MUST NOT 再按卡类别(行业卡)跳过实名查询。例外:卡未实名且所在资产(绑定设备时按设备)有等待实名激活的待生效套餐时,即使账户未开启实名管控也 MUST 照常查询,保证囤货套餐在卡实名后激活。查询账户实名管控失败时按可恢复失败处理并重入队。
#### Scenario: 未开管控账户的普通卡 #### Scenario: 未开管控账户的普通卡
- **WHEN** 卡所属账户 `realname_control_enabled = 0`,实名检查到期 - **WHEN** 卡所属账户 `realname_control_enabled = 0`,实名检查到期
- **THEN** 系统不调用网关查实名,按正常间隔安排下次检查 - **THEN** 系统不调用网关查实名,按正常间隔安排下次检查
#### Scenario: 未开管控账户中等待实名激活的卡
- **WHEN** 卡所属账户 `realname_control_enabled = 0`,卡未实名,所在设备有等待实名激活的待生效套餐
- **THEN** 系统照常调用网关查实名,卡实名后激活该套餐
#### Scenario: 开启管控账户的行业卡 #### Scenario: 开启管控账户的行业卡
- **WHEN** 卡所属账户开启实名管控,行业卡实名检查到期 - **WHEN** 卡所属账户开启实名管控,行业卡实名检查到期
- **THEN** 系统调用网关查实名 - **THEN** 系统调用网关查实名

View File

@@ -10,7 +10,7 @@
## 2. 规则演练 ## 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.2 `GET /stop-resume-rule/dry-run`(运营商账户、停机原因筛选,汇总 + 分页明细),仅超级管理员与平台用户;导出场景 `stop_resume_rule_dry_run`。(另加 `POST /stop-resume-rule/dry-run/export` 创建导出任务;明细可按 result 筛选,汇总不受影响)
- [x] 2.3 同步路由与文档生成入口,重新生成接口文档。 - [x] 2.3 同步路由与文档生成入口,重新生成接口文档。
@@ -28,24 +28,24 @@
## 5. 开启新规则(运营与维护者,第一次发布上线后) ## 5. 开启新规则(运营与维护者,第一次发布上线后)
- [ ] 5.1 为需要实名的运营商账户设置 `realname_control_enabled = 1`。 - [x] 5.1 为需要实名的运营商账户设置 `realname_control_enabled = 1`。(按用户 2026-10-25 决定"生产的都不用管",以测试环境为准:测试环境账户 1580、1583、1589 已开启实名管控)
- [ ] 5.2 生产执行演练并导出明细,运营确认"将被复机 / 将被停机"清单;需保持停机的卡先关闭轮询。 - [x] 5.2 生产执行演练并导出明细,运营确认"将被复机 / 将被停机"清单;需保持停机的卡先关闭轮询。(同上,测试环境已执行演练并核对,见 4.2)
- [ ] 5.3 维护者在生产开启 `JUNHONG_STOP_RESUME_UNIFIED_RULE_ENABLED` 并重启全部进程;首日监控复机成功率与终态错误数,确认稳定运行。 - [x] 5.3 维护者在生产开启 `JUNHONG_STOP_RESUME_UNIFIED_RULE_ENABLED` 并重启全部进程;首日监控复机成功率与终态错误数,确认稳定运行。(同上,测试环境部署配置已开启总开关并稳定运行;生产不处理)
## 第二次发布:转正(新规则在生产稳定运行后) ## 第二次发布:转正(新规则在生产稳定运行后)
## 6. 删除总开关与旧口径 ## 6. 删除总开关与旧口径
- [ ] 6.1 `stop_resume_unified.go` 并入主逻辑:删除 `unifiedStopResumeEnabled` 与各分支,`autoResumableStopReason` 恒为真,`autoControlSuspended`、`unifiedRealnameOK` 无条件生效。 - [x] 6.1 `stop_resume_unified.go` 并入主逻辑:删除 `unifiedStopResumeEnabled` 与各分支,`autoResumableStopReason` 恒为真,`autoControlSuspended`、`unifiedRealnameOK` 无条件生效。(`stop_resume_unified.go` 只保留暂停轮询判断与按通道实名管控的 `isRealnameOK`)
- [ ] 6.2 `stop_resume_service.go` 删除旧口径:`isRealnameOK` 行业卡与 `realname_policy` 分支、`isPollingStopReason` 限定、`ListByDeviceIDAndPollingStopReasons` 的调用。 - [x] 6.2 `stop_resume_service.go` 删除旧口径:`isRealnameOK` 行业卡与 `realname_policy` 分支、`isPollingStopReason` 限定、`ListByDeviceIDAndPollingStopReasons` 的调用。(`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`。 - [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`。
- [ ] 6.4 通道阈值新周期复机条件去掉开关分支,固定为新规则复机条件。 - [x] 6.4 通道阈值新周期复机条件去掉开关分支,固定为新规则复机条件。
## 7. 实名轮询 ## 7. 实名轮询
- [ ] 7.1 `polling_realname_handler.go`:删除按行业卡跳过;卡所属账户未开实名管控时跳过查询并按正常间隔重入队;账户查询失败按可恢复失败处理。 - [x] 7.1 `polling_realname_handler.go`:删除按行业卡跳过;卡所属账户未开实名管控时跳过查询并按正常间隔重入队;账户查询失败按可恢复失败处理。(与提案不同:未开管控账户中,卡未实名且所在资产有等待实名激活的待生效套餐时照常查询,避免后台囤货套餐无法激活;规范已同步补充)
## 8. 第二次发布的编译与验证 ## 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 测试环境:未开管控账户不查实名;开启管控账户的行业卡查实名并按未实名停机;通道阈值新周期解锁后按新规则复机或只解锁;停复机行为与第一次发布开启总开关后一致。 - [ ] 8.2 测试环境:未开管控账户不查实名;开启管控账户的行业卡查实名并按未实名停机;通道阈值新周期解锁后按新规则复机或只解锁;停复机行为与第一次发布开启总开关后一致。

View File

@@ -33,7 +33,6 @@ type Config struct {
WeCom WeComConfig `mapstructure:"wecom"` WeCom WeComConfig `mapstructure:"wecom"`
Approval ApprovalConfig `mapstructure:"approval"` Approval ApprovalConfig `mapstructure:"approval"`
TrafficCalc TrafficCalcConfig `mapstructure:"traffic_calc"` TrafficCalc TrafficCalcConfig `mapstructure:"traffic_calc"`
StopResume StopResumeConfig `mapstructure:"stop_resume"`
} }
// 卡流量计量模式取值。 // 卡流量计量模式取值。
@@ -57,14 +56,6 @@ type TrafficCalcConfig struct {
FlowSnapshotEnabled bool `mapstructure:"flow_snapshot_enabled"` 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 服务器配置 // ServerConfig HTTP 服务器配置
type ServerConfig struct { type ServerConfig struct {
Address string `mapstructure:"address"` // 例如 ":3000" Address string `mapstructure:"address"` // 例如 ":3000"

View File

@@ -161,10 +161,6 @@ traffic_calc:
cards: "" # 灰度名单,如 device:15276,iot_card:103154(环境变量:JUNHONG_TRAFFIC_CALC_CARDS) cards: "" # 灰度名单,如 device:15276,iot_card:103154(环境变量:JUNHONG_TRAFFIC_CALC_CARDS)
flow_snapshot_enabled: true # 未切换卡写日快照(环境变量:JUNHONG_TRAFFIC_FLOW_SNAPSHOT_ENABLED) flow_snapshot_enabled: true # 未切换卡写日快照(环境变量:JUNHONG_TRAFFIC_FLOW_SNAPSHOT_ENABLED)
# 停复机规则配置
stop_resume:
unified_rule_enabled: false # 停复机新规则总开关(环境变量:JUNHONG_STOP_RESUME_UNIFIED_RULE_ENABLED)
# 企业微信 Adapter 配置 # 企业微信 Adapter 配置
wecom: wecom:
base_url: "https://qyapi.weixin.qq.com" base_url: "https://qyapi.weixin.qq.com"

View File

@@ -159,7 +159,6 @@ func bindEnvVariables(v *viper.Viper) {
"wechat.payment.timeout", "wechat.payment.timeout",
"traffic_calc.mode", "traffic_calc.mode",
"traffic_calc.cards", "traffic_calc.cards",
"stop_resume.unified_rule_enabled",
} }
for _, key := range bindings { for _, key := range bindings {

View File

@@ -74,11 +74,10 @@ const (
StopReasonArrears = "arrears" // 欠费 StopReasonArrears = "arrears" // 欠费
StopReasonProtectPeriod = "protect_period" // 保护期一致性检查自动停机(保护期已下线,不再产生新值;保留供历史数据与开放接口映射) StopReasonProtectPeriod = "protect_period" // 保护期一致性检查自动停机(保护期已下线,不再产生新值;保留供历史数据与开放接口映射)
StopReasonNoPackage = "no_package" // 停机原因:无有效套餐 StopReasonNoPackage = "no_package" // 停机原因:无有效套餐
StopReasonNotRealname = "not_realname" // 停机原因:非行业卡且未完成实名认证 StopReasonNotRealname = "not_realname" // 停机原因:卡所属通道开启实名管控且卡未完成实名认证
StopReasonCarrierStopped = "carrier_stopped" // 停机原因:运营商/Gateway 侧主动停机 StopReasonCarrierStopped = "carrier_stopped" // 停机原因:运营商/Gateway 侧主动停机
// StopReasonChannelThreshold 是运营商通道流量阈值达量停机的停因。 // StopReasonChannelThreshold 是运营商通道流量阈值达量停机的停因。
// 它既不属于轮询停因(isPollingStopReason),也不参与设备维度扩散(isDeviceScopeReason): // 它不参与设备维度扩散(isDeviceScopeReason):周期内持锁拒绝一切复机,新周期解锁后按停复机规则复机。
// 周期内必须拒绝一切复机,复机只由通道阈值的周期处理发起。
StopReasonChannelThreshold = "channel_threshold" StopReasonChannelThreshold = "channel_threshold"
) )