All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 9m37s
191 lines
7.1 KiB
Markdown
191 lines
7.1 KiB
Markdown
# 实现任务清单
|
||
|
||
## Fix 1:not_realname 连坐修复
|
||
|
||
### 1.1 新增 isDeviceScopeReason 函数
|
||
- **文件**:`internal/service/iot_card/stop_resume_service.go`
|
||
- **位置**:紧接 `isPollingStopReason` 函数之后
|
||
- **内容**:
|
||
```go
|
||
// isDeviceScopeReason 判断停机原因是否应扩散到设备维度
|
||
// 套餐/流量是设备共享资源,需整体停机;实名是单卡属性,只停该卡
|
||
func isDeviceScopeReason(reason string) bool {
|
||
return reason == constants.StopReasonNoPackage ||
|
||
reason == constants.StopReasonTrafficExhausted
|
||
}
|
||
```
|
||
|
||
### 1.2 修改 EvaluateAndAct 在线分支的停机判断
|
||
- **文件**:`internal/service/iot_card/stop_resume_service.go`
|
||
- **修改位置**:`EvaluateAndAct` 函数的 `NetworkStatusOnline` 分支
|
||
- **原逻辑**:
|
||
```go
|
||
if !card.IsStandalone {
|
||
deviceID, bound, lookupErr := s.getCardDeviceID(ctx, card)
|
||
if lookupErr != nil { ... }
|
||
else if bound {
|
||
s.stopDeviceCards(ctx, deviceID, primaryReason)
|
||
return nil
|
||
}
|
||
}
|
||
return s.stopCardWithRetry(ctx, card, primaryReason)
|
||
```
|
||
- **改为**:
|
||
```go
|
||
if !card.IsStandalone && isDeviceScopeReason(primaryReason) {
|
||
deviceID, bound, lookupErr := s.getCardDeviceID(ctx, card)
|
||
if lookupErr != nil { ... }
|
||
else if bound {
|
||
s.stopDeviceCards(ctx, deviceID, primaryReason)
|
||
return nil
|
||
}
|
||
}
|
||
return s.stopCardWithRetry(ctx, card, primaryReason)
|
||
```
|
||
- **注意**:仅在条件中增加 `&& isDeviceScopeReason(primaryReason)`,其余代码不变
|
||
|
||
### 1.3 数据库验证
|
||
- 使用 PostgreSQL MCP 确认设备 862639076038233 下 card_id=5(未实名)触发停机时,card_id=2 和 card_id=4 不再被停机
|
||
|
||
---
|
||
|
||
## Fix 2:实名逆转防抖
|
||
|
||
### 2.1 新增 Redis Key 常量
|
||
- **文件**:`pkg/constants/redis.go`
|
||
- **新增**:
|
||
```go
|
||
// RedisPollingRealnameReversalCountKey 实名逆转连续计数器,防止上游单次误报触发停机
|
||
// TTL: 10分钟
|
||
func RedisPollingRealnameReversalCountKey(cardID uint) string {
|
||
return fmt.Sprintf("polling:card:realname:reversal:%d", cardID)
|
||
}
|
||
```
|
||
|
||
### 2.2 新增防抖阈值常量
|
||
- **文件**:`pkg/constants/iot.go`
|
||
- **新增**(放在实名状态常量附近):
|
||
```go
|
||
// PollingRealnameReversalThreshold 实名逆转防抖阈值:连续 N 次检测到逆转才触发停机
|
||
PollingRealnameReversalThreshold = 3
|
||
```
|
||
|
||
### 2.3 修改 polling_realname_handler.go 逆转处理逻辑
|
||
- **文件**:`internal/task/polling_realname_handler.go`
|
||
- **修改位置**:`Handle` 函数中 `else if newStatus == constants.RealNameStatusNotVerified` 分支
|
||
- **原逻辑**(约第 154-167 行):
|
||
```go
|
||
} else if newStatus == constants.RealNameStatusNotVerified {
|
||
h.base.logger.Warn("检测到实名逆转,立即触发停机评估", ...)
|
||
if h.stopResumeSvc != nil {
|
||
freshCard, _ := h.iotCardStore.GetByID(ctx, cardID)
|
||
h.stopResumeSvc.EvaluateAndAct(ctx, freshCard)
|
||
}
|
||
}
|
||
```
|
||
- **改为**:
|
||
```go
|
||
} else if newStatus == constants.RealNameStatusNotVerified {
|
||
reversalKey := constants.RedisPollingRealnameReversalCountKey(cardID)
|
||
count, incrErr := h.base.redis.Incr(ctx, reversalKey).Result()
|
||
if incrErr == nil {
|
||
h.base.redis.Expire(ctx, reversalKey, 10*time.Minute)
|
||
}
|
||
h.base.logger.Warn("检测到实名逆转",
|
||
zap.Uint("card_id", cardID),
|
||
zap.Int64("reversal_count", count),
|
||
zap.Int("threshold", constants.PollingRealnameReversalThreshold))
|
||
if count >= int64(constants.PollingRealnameReversalThreshold) {
|
||
h.base.redis.Del(ctx, reversalKey)
|
||
h.base.logger.Warn("实名逆转达到阈值,触发停机评估",
|
||
zap.Uint("card_id", cardID))
|
||
if h.stopResumeSvc != nil {
|
||
freshCard, loadErr := h.iotCardStore.GetByID(ctx, cardID)
|
||
if loadErr == nil {
|
||
if evalErr := h.stopResumeSvc.EvaluateAndAct(ctx, freshCard); evalErr != nil {
|
||
h.base.logger.Warn("实名逆转后触发停机评估失败",
|
||
zap.Uint("card_id", cardID), zap.Error(evalErr))
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
```
|
||
- **同时**:在 `isFirstRealname` 分支(0→1)中,复机成功前清除计数器:
|
||
```go
|
||
// 0→1 时清除逆转计数器
|
||
h.base.redis.Del(ctx, constants.RedisPollingRealnameReversalCountKey(cardID))
|
||
```
|
||
放在 `triggerFirstRealnameActivation` 调用之前
|
||
|
||
### 2.4 确认 PollingBase 有 redis 字段可访问
|
||
- 检查 `internal/task/polling_base.go`(或同类文件),确认 `h.base.redis` 可用
|
||
- 若不可用,通过 `PollingRealnameHandler` 直接持有 `redis *redis.Client` 字段,在 `NewPollingRealnameHandler` 中注入
|
||
|
||
---
|
||
|
||
## Fix 3:carrier_stopped 停机原因
|
||
|
||
### 3.1 新增 StopReasonCarrierStopped 常量
|
||
- **文件**:`pkg/constants/iot.go`
|
||
- **新增**(放在现有 StopReason 常量组中):
|
||
```go
|
||
StopReasonCarrierStopped = "carrier_stopped" // 停机原因:运营商/Gateway 侧主动停机
|
||
```
|
||
|
||
### 3.2 修改 polling_cardstatus_handler.go 补写 stop_reason
|
||
- **文件**:`internal/task/polling_cardstatus_handler.go`
|
||
- **修改位置**:第 97-103 行,`statusChanged` 判断块内
|
||
- **原逻辑**:
|
||
```go
|
||
fields := map[string]any{"last_card_status_check_at": now}
|
||
if statusChanged {
|
||
fields["network_status"] = newNetworkStatus
|
||
}
|
||
```
|
||
- **改为**:
|
||
```go
|
||
fields := map[string]any{"last_card_status_check_at": now}
|
||
if statusChanged {
|
||
fields["network_status"] = newNetworkStatus
|
||
// 网关侧停机事件:补写 stop_reason,便于状态追踪和区分手动停机
|
||
if newNetworkStatus == constants.NetworkStatusOffline && card.StopReason == "" {
|
||
fields["stop_reason"] = constants.StopReasonCarrierStopped
|
||
}
|
||
}
|
||
```
|
||
- **注意**:`card.StopReason == ""` 条件确保不覆盖已有停机原因(如 `not_realname`)
|
||
|
||
### 3.3 确认 isPollingStopReason 不包含 carrier_stopped
|
||
- **文件**:`internal/service/iot_card/stop_resume_service.go`
|
||
- **确认**:`isPollingStopReason` 函数的 switch case 中**不包含** `carrier_stopped`
|
||
- **无需修改**(已符合预期),仅做代码 review 确认
|
||
|
||
---
|
||
|
||
## 验证
|
||
|
||
### V1:验证 Fix 1(连坐修复)
|
||
```sql
|
||
-- 通过 PostgreSQL MCP 检查:card_id=5 停机时,card_id=2、card_id=4 应保持在线
|
||
SELECT id, iccid, real_name_status, network_status, stop_reason
|
||
FROM tb_iot_card
|
||
WHERE device_virtual_no = '862639076038233'
|
||
ORDER BY id;
|
||
```
|
||
预期:card_id=5 被停机,card_id=2 和 card_id=4 不受影响(或按各自状态独立判断)
|
||
|
||
### V2:验证 Fix 2(防抖)
|
||
- 在 Redis 中观察 `polling:card:realname:reversal:*` key 的变化
|
||
- 确认单次 false 后 key 计数为 1 且不触发停机日志
|
||
- 确认 3 次 false 后出现"实名逆转达到阈值"日志并触发停机
|
||
|
||
### V3:验证 Fix 3(carrier_stopped)
|
||
```sql
|
||
-- 检查状态为 carrier_stopped 的卡能被正确识别
|
||
SELECT id, iccid, network_status, stop_reason, stopped_at
|
||
FROM tb_iot_card
|
||
WHERE stop_reason = 'carrier_stopped';
|
||
```
|
||
预期:`stop_reason='carrier_stopped'` 的卡不会被轮询系统自动复机(需观察日志)
|