feat: 轮询系统重构(分片队列 + 停复机统一 + Handler 拆分)
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 9m46s
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 9m46s
【核心变更】
1. 停复机逻辑统一(StopResumeService)
- 新增 EvaluateAndAct 统一入口,封装三条件停复机判断
- 停机条件:无套餐(no_package) / 流量耗尽(traffic_exhausted) / 未实名(not_realname)
- 复机条件:stop_reason 合规 + 有套餐且未耗尽 + 已实名或行业卡
- 修复设备套餐 Bug:hasValidPackage 按 device_id 查套餐,而非仅 iot_card_id
- 设备维度停复机加幂等锁(Redis SetNX,TTL 30s),防止多卡并发重复调 Gateway
2. Redis 分片队列(PollingQueueManager)
- 新建 queue_manager.go,封装所有轮询 Redis 操作
- 16 分片 Sorted Set,Key 格式:polling:shard:{shardID}:queue:{taskType}
- Lua 脚本原子出队(ZRANGEBYSCORE + 分批 ZREM),消除竞态窗口
- 新增背压检测:队列深度超 50 万时 Scheduler 跳过该分片
- RemoveFromAllQueues 覆盖 4 种任务类型(含 protect)
3. Handler 拆分(polling_handler.go 1360行 → 5个专注文件)
- polling_base.go:共享基类(并发控制/卡缓存/重入队)
- polling_realname_handler.go:实名采集,实名 0→1 时立即触发复机
- polling_carddata_handler.go:流量采集,保留跨月边界检测逻辑
- polling_package_handler.go:套餐采集,委托 EvaluateAndAct 决策
- polling_protect_handler.go:保护期一致性检查,保护期内强制修正
4. 配置管理(PollingConfigManager)
- 新建 config_manager.go,从 scheduler.go 提取配置职责
- 内存缓存 + 5 分钟定时刷新,刷新失败保留原缓存
- 修复 getCardCondition:停机卡返回 suspended,不再错配 activated 配置
5. 渐进式初始化(CardInitializer)
- 新建 initializer.go,分批加载(每批 10 万),批次间 sleep 500ms
- 过滤 enable_polling=false 的卡,初始化完成前 Scheduler 不出队
6. 卡生命周期服务(PollingLifecycleService)
- 新建 lifecycle_service.go,替代已删除的 callbacks.go 和 api_callback.go
- OnCardCreated/OnCardEnabled/OnCardStatusChanged 入队前检查 enable_polling
7. Scheduler 精简(1000+行 → 227行)
- 保留纯调度循环:scheduleLoop + processShardSchedule + enqueueBatch
- 保留每 10 秒触发套餐过期检测和流量重置
- 移除所有 DB 操作、配置加载、卡初始化逻辑
8. 轮询管控 API(enable_polling)
- 新增 PUT /api/admin/assets/:id/polling-status 接口
- 支持对设备/卡维度开关轮询,关闭后从所有分片队列移除
9. 数据库迁移
- 000103:tb_device 新增 enable_polling 字段(boolean, NOT NULL, DEFAULT true)
- 000104:新增 suspended 轮询配置,为 activated 配置补全 protect_check_interval
【文件统计】
- 新增:19 个文件(handler × 5、polling 组件 × 4、迁移 × 3 等)
- 修改:20 个文件(bootstrap 注入、store 接口、monitoring 适配分片等)
- 删除:3 个文件(polling_handler.go、callbacks.go、api_callback.go)
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
139
.claude/skills/comment-standards/SKILL.md
Normal file
139
.claude/skills/comment-standards/SKILL.md
Normal file
@@ -0,0 +1,139 @@
|
||||
---
|
||||
name: comment-standards
|
||||
description: Go 注释规范。编写 Go 代码注释、文档注释时使用。包含包注释、结构体注释、接口注释、函数注释、内联注释的完整规范与示例。
|
||||
---
|
||||
|
||||
# Go 注释规范
|
||||
|
||||
**基本原则**:
|
||||
- **所有注释使用中文**
|
||||
- **导出符号必须有文档注释**(包、函数、方法、类型、接口、常量、变量)
|
||||
- **复杂逻辑必须有实现注释**(解释"为什么",而不是"做了什么")
|
||||
- **禁止废话注释**(不要用注释复述代码本身)
|
||||
- **修改代码时必须同步更新注释**
|
||||
|
||||
---
|
||||
|
||||
## 包注释
|
||||
|
||||
每个包的入口文件(通常是主文件或 `doc.go`)必须有包注释:
|
||||
|
||||
```go
|
||||
// Package account 提供账号管理的业务逻辑服务
|
||||
// 包含账号创建、修改、删除、权限分配等功能
|
||||
package account
|
||||
```
|
||||
|
||||
## 结构体注释
|
||||
|
||||
所有导出结构体必须有文档注释,说明该结构体代表什么:
|
||||
|
||||
```go
|
||||
// Service 账号业务服务
|
||||
// 负责账号的 CRUD、角色分配、密码管理等业务逻辑
|
||||
type Service struct {
|
||||
store *Store
|
||||
auditService AuditServiceInterface
|
||||
}
|
||||
```
|
||||
|
||||
## 接口注释
|
||||
|
||||
导出接口必须注释接口用途,每个方法必须说明契约:
|
||||
|
||||
```go
|
||||
// PermissionChecker 权限检查器接口
|
||||
// 用于查询用户的权限列表
|
||||
type PermissionChecker interface {
|
||||
// CheckPermission 检查用户是否拥有指定权限
|
||||
// userID: 用户ID
|
||||
// permCode: 权限编码(格式: module:action)
|
||||
// platform: 端口类型 (all/web/h5)
|
||||
CheckPermission(ctx context.Context, userID uint, permCode string, platform string) (bool, error)
|
||||
}
|
||||
```
|
||||
|
||||
## 函数和方法注释
|
||||
|
||||
**导出函数/方法**必须以函数名开头,说明功能:
|
||||
|
||||
```go
|
||||
// Create 创建账号
|
||||
// POST /api/admin/accounts
|
||||
func (h *AccountHandler) Create(c *fiber.Ctx) error {
|
||||
```
|
||||
|
||||
**复杂方法**(超过 30 行或包含复杂业务逻辑)必须额外说明实现思路:
|
||||
|
||||
```go
|
||||
// ActivateByRealname 首次实名激活套餐
|
||||
// 当用户完成实名认证后,自动激活处于"囤货待实名"状态的套餐:
|
||||
// 1. 查找该卡所有 status=3(待实名激活)的套餐
|
||||
// 2. 按创建时间排序,第一个主套餐立即激活(status=1)
|
||||
// 3. 其余主套餐进入排队状态(status=4)
|
||||
// 4. 加油包如果绑定了已激活的主套餐则一并激活
|
||||
func (s *UsageService) ActivateByRealname(ctx context.Context, cardID uint) error {
|
||||
```
|
||||
|
||||
**未导出函数/方法**:
|
||||
- 简单逻辑(< 15 行):可以不加注释
|
||||
- 复杂逻辑(≥ 15 行)或非显而易见的算法:必须加注释
|
||||
|
||||
```go
|
||||
// buildPermissionTree 递归构建权限树
|
||||
// 采用 map 索引 + 单次遍历算法,时间复杂度 O(n)
|
||||
func (s *Service) buildPermissionTree(permissions []*model.Permission) []*dto.PermissionTreeNode {
|
||||
```
|
||||
|
||||
## 常量和枚举注释
|
||||
|
||||
分组常量必须有组注释,每个值必须有行内注释:
|
||||
|
||||
```go
|
||||
// 用户类型常量
|
||||
const (
|
||||
UserTypeSuperAdmin = 1 // 超级管理员
|
||||
UserTypePlatform = 2 // 平台用户
|
||||
UserTypeAgent = 3 // 代理账号
|
||||
UserTypeEnterprise = 4 // 企业账号
|
||||
)
|
||||
```
|
||||
|
||||
## 内联注释规范
|
||||
|
||||
**必须添加内联注释的场景**:
|
||||
|
||||
| 场景 | 要求 |
|
||||
|------|------|
|
||||
| 复杂条件判断 | 解释判断的业务含义 |
|
||||
| 多步骤业务流程 | 用编号注释标明每一步 |
|
||||
| 非显而易见的设计决策 | 解释"为什么这样做"而不是"做了什么" |
|
||||
| 缓存/事务/并发处理 | 说明策略和原因 |
|
||||
| 临时方案/兼容逻辑 | 标注 TODO 或说明背景 |
|
||||
|
||||
**✅ 好的内联注释(解释为什么)**:
|
||||
|
||||
```go
|
||||
// 使用 Redis 分布式锁防止并发重复创建,锁超时 10 秒
|
||||
if !s.acquireLock(ctx, lockKey, 10*time.Second) {
|
||||
return errors.New(errors.CodeTooManyRequests, "操作过于频繁,请稍后重试")
|
||||
}
|
||||
|
||||
// 先冻结佣金再扣款,保证资金安全(失败时佣金自动解冻)
|
||||
if err := s.freezeCommission(ctx, tx, orderID); err != nil {
|
||||
return err
|
||||
}
|
||||
```
|
||||
|
||||
**❌ 废话注释(禁止)**:
|
||||
|
||||
```go
|
||||
// 获取用户ID ← 禁止:代码本身已经很清楚
|
||||
userID := middleware.GetUserIDFromContext(ctx)
|
||||
|
||||
// 创建账号 ← 禁止:变量名已说明意图
|
||||
account := &model.Account{}
|
||||
|
||||
// 返回错误 ← 禁止:return err 不需要注释
|
||||
return err
|
||||
```
|
||||
97
.claude/skills/openspec-api-contract/SKILL.md
Normal file
97
.claude/skills/openspec-api-contract/SKILL.md
Normal file
@@ -0,0 +1,97 @@
|
||||
---
|
||||
name: openspec-api-contract
|
||||
description: OpenSpec API 契约规范。创建涉及接口的 OpenSpec 提案时使用。探索阶段提供业务与契约引导清单,提案文档要求 API 契约设计、错误码与完成标准等必填章节。
|
||||
---
|
||||
|
||||
# OpenSpec API 契约规范
|
||||
|
||||
**适用场景**:创建涉及 API/接口的 OpenSpec 提案时,探索和提案两个阶段均须遵守本规范。
|
||||
|
||||
---
|
||||
|
||||
## 一、探索阶段引导清单
|
||||
|
||||
在 `openspec-explore` 阶段,当内容涉及接口时,讨论必须覆盖以下所有维度。
|
||||
|
||||
### 业务与契约确认
|
||||
|
||||
**输入与输出**
|
||||
- 请求方是谁?用户类型(SuperAdmin/Platform/Agent/Enterprise/Personal)?
|
||||
- 输入参数:必填/选填字段、格式约束、参数来源(路径/查询/Body)?
|
||||
- 输出结构:哪些字段必须返回?是否需要分页?
|
||||
|
||||
**业务规则**
|
||||
- 核心业务规则与边界条件?
|
||||
- 是否涉及状态流转?状态机的完整定义?
|
||||
- 依赖外部服务时,外部异常的降级行为?
|
||||
|
||||
**权限与资源所有权**
|
||||
- 哪些用户类型可以访问?
|
||||
- 是否涉及跨用户/跨店铺/跨企业的资源访问?(需三层越权防护)
|
||||
- 资源所有权校验方式:`CanManageShop` / `CanManageEnterprise` / 自有资源?
|
||||
|
||||
**幂等性**
|
||||
- 操作类型:查询(天然幂等)/ 创建 / 更新 / 删除?
|
||||
- 写操作幂等策略:状态条件更新 / Redis 业务键防重 + 分布式锁 / 乐观锁(version)?
|
||||
- 异步任务是否需要任务锁?
|
||||
|
||||
**数据模型变更**
|
||||
- 是否需要新建表、修改现有表或数据回填?
|
||||
- 迁移策略:上线顺序、兼容旧数据的方式?
|
||||
- 是否影响 GORM Callback 自动数据权限过滤?
|
||||
|
||||
**错误码与异常语义**
|
||||
- 预期错误场景及对应错误码?
|
||||
- 错误响应是否泄露敏感信息?(参数校验失败统一返回 `CodeInvalidParam`)
|
||||
|
||||
---
|
||||
|
||||
## 二、提案文档必填章节
|
||||
|
||||
在 `openspec-propose` 生成的提案(`proposal.md` / `design.md`)中,涉及接口时以下内容**不可缺失**。
|
||||
|
||||
### API 契约设计
|
||||
|
||||
**接口定义**
|
||||
|
||||
| 项 | 内容 |
|
||||
|---|---|
|
||||
| Endpoint | `METHOD /api/{scope}/{resource}[/:id]` |
|
||||
| 请求参数 | 字段名、类型、必填/选填、说明 |
|
||||
| 响应结构 | `data` 字段的完整结构定义 |
|
||||
| 鉴权要求 | 允许的用户类型 |
|
||||
| 资源所有权 | 所有权校验方式 |
|
||||
|
||||
**列表接口额外要求**
|
||||
- 分页:`page` + `page_size`(默认 20,最大 100)
|
||||
- 排序:默认排序字段与方向
|
||||
- 过滤:支持的过滤条件列表
|
||||
|
||||
**错误码清单**
|
||||
|
||||
| 场景 | 错误码 | 说明 |
|
||||
|---|---|---|
|
||||
| 参数校验失败 | `CodeInvalidParam` | 统一返回,不泄露细节 |
|
||||
| 资源不存在/越权 | `CodeForbidden` | 不区分两者,防止信息泄露 |
|
||||
| (业务错误场景...) | (对应错误码) | (说明) |
|
||||
|
||||
### 完成标准
|
||||
|
||||
**最小验证步骤**(按顺序列出可操作的验证步骤)
|
||||
|
||||
1. (例:调用创建接口,验证返回 `code=0`)
|
||||
2. (例:查询接口确认数据存在且字段正确)
|
||||
3. (例:PostgreSQL MCP 查询确认数据库记录符合预期)
|
||||
|
||||
**影响范围说明**
|
||||
- 新增/修改的表:
|
||||
- 影响的现有接口:
|
||||
- 影响的权限与数据过滤范围:
|
||||
|
||||
---
|
||||
|
||||
## 约束(必须遵守)
|
||||
|
||||
- **优先复用现有架构与库**:不引入新依赖,错误码优先复用已有定义
|
||||
- **不做顺手重构**:提案范围严格限定在目标功能;发现可优化点,记录到 backlog 但不执行
|
||||
- **数据库设计**:禁止外键约束,禁止 GORM 关联标签,关联通过 ID 字段手动维护
|
||||
115
.opencode/package-lock.json
generated
Normal file
115
.opencode/package-lock.json
generated
Normal file
@@ -0,0 +1,115 @@
|
||||
{
|
||||
"name": ".opencode",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"dependencies": {
|
||||
"@opencode-ai/plugin": "1.3.17"
|
||||
}
|
||||
},
|
||||
"node_modules/@opencode-ai/plugin": {
|
||||
"version": "1.3.17",
|
||||
"resolved": "https://registry.npmjs.org/@opencode-ai/plugin/-/plugin-1.3.17.tgz",
|
||||
"integrity": "sha512-N5lckFtYvEu2R8K1um//MIOTHsJHniF2kHoPIWPCrxKG5Jpismt1ISGzIiU3aKI2ht/9VgcqKPC5oZFLdmpxPw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@opencode-ai/sdk": "1.3.17",
|
||||
"zod": "4.1.8"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opentui/core": ">=0.1.96",
|
||||
"@opentui/solid": ">=0.1.96"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@opentui/core": {
|
||||
"optional": true
|
||||
},
|
||||
"@opentui/solid": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@opencode-ai/sdk": {
|
||||
"version": "1.3.17",
|
||||
"resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.3.17.tgz",
|
||||
"integrity": "sha512-2+MGgu7wynqTBwxezR01VAGhILXlpcHDY/pF7SWB87WOgLt3kD55HjKHNj6PWxyY8n575AZolR95VUC3gtwfmA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cross-spawn": "7.0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/cross-spawn": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"path-key": "^3.1.0",
|
||||
"shebang-command": "^2.0.0",
|
||||
"which": "^2.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/isexe": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
|
||||
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/path-key": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
|
||||
"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/shebang-command": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
||||
"integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"shebang-regex": "^3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/shebang-regex": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
|
||||
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/which": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"isexe": "^2.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"node-which": "bin/node-which"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/zod": {
|
||||
"version": "4.1.8",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
139
.opencode/skills/comment-standards/SKILL.md
Normal file
139
.opencode/skills/comment-standards/SKILL.md
Normal file
@@ -0,0 +1,139 @@
|
||||
---
|
||||
name: comment-standards
|
||||
description: Go 注释规范。编写 Go 代码注释、文档注释时使用。包含包注释、结构体注释、接口注释、函数注释、内联注释的完整规范与示例。
|
||||
---
|
||||
|
||||
# Go 注释规范
|
||||
|
||||
**基本原则**:
|
||||
- **所有注释使用中文**
|
||||
- **导出符号必须有文档注释**(包、函数、方法、类型、接口、常量、变量)
|
||||
- **复杂逻辑必须有实现注释**(解释"为什么",而不是"做了什么")
|
||||
- **禁止废话注释**(不要用注释复述代码本身)
|
||||
- **修改代码时必须同步更新注释**
|
||||
|
||||
---
|
||||
|
||||
## 包注释
|
||||
|
||||
每个包的入口文件(通常是主文件或 `doc.go`)必须有包注释:
|
||||
|
||||
```go
|
||||
// Package account 提供账号管理的业务逻辑服务
|
||||
// 包含账号创建、修改、删除、权限分配等功能
|
||||
package account
|
||||
```
|
||||
|
||||
## 结构体注释
|
||||
|
||||
所有导出结构体必须有文档注释,说明该结构体代表什么:
|
||||
|
||||
```go
|
||||
// Service 账号业务服务
|
||||
// 负责账号的 CRUD、角色分配、密码管理等业务逻辑
|
||||
type Service struct {
|
||||
store *Store
|
||||
auditService AuditServiceInterface
|
||||
}
|
||||
```
|
||||
|
||||
## 接口注释
|
||||
|
||||
导出接口必须注释接口用途,每个方法必须说明契约:
|
||||
|
||||
```go
|
||||
// PermissionChecker 权限检查器接口
|
||||
// 用于查询用户的权限列表
|
||||
type PermissionChecker interface {
|
||||
// CheckPermission 检查用户是否拥有指定权限
|
||||
// userID: 用户ID
|
||||
// permCode: 权限编码(格式: module:action)
|
||||
// platform: 端口类型 (all/web/h5)
|
||||
CheckPermission(ctx context.Context, userID uint, permCode string, platform string) (bool, error)
|
||||
}
|
||||
```
|
||||
|
||||
## 函数和方法注释
|
||||
|
||||
**导出函数/方法**必须以函数名开头,说明功能:
|
||||
|
||||
```go
|
||||
// Create 创建账号
|
||||
// POST /api/admin/accounts
|
||||
func (h *AccountHandler) Create(c *fiber.Ctx) error {
|
||||
```
|
||||
|
||||
**复杂方法**(超过 30 行或包含复杂业务逻辑)必须额外说明实现思路:
|
||||
|
||||
```go
|
||||
// ActivateByRealname 首次实名激活套餐
|
||||
// 当用户完成实名认证后,自动激活处于"囤货待实名"状态的套餐:
|
||||
// 1. 查找该卡所有 status=3(待实名激活)的套餐
|
||||
// 2. 按创建时间排序,第一个主套餐立即激活(status=1)
|
||||
// 3. 其余主套餐进入排队状态(status=4)
|
||||
// 4. 加油包如果绑定了已激活的主套餐则一并激活
|
||||
func (s *UsageService) ActivateByRealname(ctx context.Context, cardID uint) error {
|
||||
```
|
||||
|
||||
**未导出函数/方法**:
|
||||
- 简单逻辑(< 15 行):可以不加注释
|
||||
- 复杂逻辑(≥ 15 行)或非显而易见的算法:必须加注释
|
||||
|
||||
```go
|
||||
// buildPermissionTree 递归构建权限树
|
||||
// 采用 map 索引 + 单次遍历算法,时间复杂度 O(n)
|
||||
func (s *Service) buildPermissionTree(permissions []*model.Permission) []*dto.PermissionTreeNode {
|
||||
```
|
||||
|
||||
## 常量和枚举注释
|
||||
|
||||
分组常量必须有组注释,每个值必须有行内注释:
|
||||
|
||||
```go
|
||||
// 用户类型常量
|
||||
const (
|
||||
UserTypeSuperAdmin = 1 // 超级管理员
|
||||
UserTypePlatform = 2 // 平台用户
|
||||
UserTypeAgent = 3 // 代理账号
|
||||
UserTypeEnterprise = 4 // 企业账号
|
||||
)
|
||||
```
|
||||
|
||||
## 内联注释规范
|
||||
|
||||
**必须添加内联注释的场景**:
|
||||
|
||||
| 场景 | 要求 |
|
||||
|------|------|
|
||||
| 复杂条件判断 | 解释判断的业务含义 |
|
||||
| 多步骤业务流程 | 用编号注释标明每一步 |
|
||||
| 非显而易见的设计决策 | 解释"为什么这样做"而不是"做了什么" |
|
||||
| 缓存/事务/并发处理 | 说明策略和原因 |
|
||||
| 临时方案/兼容逻辑 | 标注 TODO 或说明背景 |
|
||||
|
||||
**✅ 好的内联注释(解释为什么)**:
|
||||
|
||||
```go
|
||||
// 使用 Redis 分布式锁防止并发重复创建,锁超时 10 秒
|
||||
if !s.acquireLock(ctx, lockKey, 10*time.Second) {
|
||||
return errors.New(errors.CodeTooManyRequests, "操作过于频繁,请稍后重试")
|
||||
}
|
||||
|
||||
// 先冻结佣金再扣款,保证资金安全(失败时佣金自动解冻)
|
||||
if err := s.freezeCommission(ctx, tx, orderID); err != nil {
|
||||
return err
|
||||
}
|
||||
```
|
||||
|
||||
**❌ 废话注释(禁止)**:
|
||||
|
||||
```go
|
||||
// 获取用户ID ← 禁止:代码本身已经很清楚
|
||||
userID := middleware.GetUserIDFromContext(ctx)
|
||||
|
||||
// 创建账号 ← 禁止:变量名已说明意图
|
||||
account := &model.Account{}
|
||||
|
||||
// 返回错误 ← 禁止:return err 不需要注释
|
||||
return err
|
||||
```
|
||||
97
.opencode/skills/openspec-api-contract/SKILL.md
Normal file
97
.opencode/skills/openspec-api-contract/SKILL.md
Normal file
@@ -0,0 +1,97 @@
|
||||
---
|
||||
name: openspec-api-contract
|
||||
description: OpenSpec API 契约规范。创建涉及接口的 OpenSpec 提案时使用。探索阶段提供业务与契约引导清单,提案文档要求 API 契约设计、错误码与完成标准等必填章节。
|
||||
---
|
||||
|
||||
# OpenSpec API 契约规范
|
||||
|
||||
**适用场景**:创建涉及 API/接口的 OpenSpec 提案时,探索和提案两个阶段均须遵守本规范。
|
||||
|
||||
---
|
||||
|
||||
## 一、探索阶段引导清单
|
||||
|
||||
在 `openspec-explore` 阶段,当内容涉及接口时,讨论必须覆盖以下所有维度。
|
||||
|
||||
### 业务与契约确认
|
||||
|
||||
**输入与输出**
|
||||
- 请求方是谁?用户类型(SuperAdmin/Platform/Agent/Enterprise/Personal)?
|
||||
- 输入参数:必填/选填字段、格式约束、参数来源(路径/查询/Body)?
|
||||
- 输出结构:哪些字段必须返回?是否需要分页?
|
||||
|
||||
**业务规则**
|
||||
- 核心业务规则与边界条件?
|
||||
- 是否涉及状态流转?状态机的完整定义?
|
||||
- 依赖外部服务时,外部异常的降级行为?
|
||||
|
||||
**权限与资源所有权**
|
||||
- 哪些用户类型可以访问?
|
||||
- 是否涉及跨用户/跨店铺/跨企业的资源访问?(需三层越权防护)
|
||||
- 资源所有权校验方式:`CanManageShop` / `CanManageEnterprise` / 自有资源?
|
||||
|
||||
**幂等性**
|
||||
- 操作类型:查询(天然幂等)/ 创建 / 更新 / 删除?
|
||||
- 写操作幂等策略:状态条件更新 / Redis 业务键防重 + 分布式锁 / 乐观锁(version)?
|
||||
- 异步任务是否需要任务锁?
|
||||
|
||||
**数据模型变更**
|
||||
- 是否需要新建表、修改现有表或数据回填?
|
||||
- 迁移策略:上线顺序、兼容旧数据的方式?
|
||||
- 是否影响 GORM Callback 自动数据权限过滤?
|
||||
|
||||
**错误码与异常语义**
|
||||
- 预期错误场景及对应错误码?
|
||||
- 错误响应是否泄露敏感信息?(参数校验失败统一返回 `CodeInvalidParam`)
|
||||
|
||||
---
|
||||
|
||||
## 二、提案文档必填章节
|
||||
|
||||
在 `openspec-propose` 生成的提案(`proposal.md` / `design.md`)中,涉及接口时以下内容**不可缺失**。
|
||||
|
||||
### API 契约设计
|
||||
|
||||
**接口定义**
|
||||
|
||||
| 项 | 内容 |
|
||||
|---|---|
|
||||
| Endpoint | `METHOD /api/{scope}/{resource}[/:id]` |
|
||||
| 请求参数 | 字段名、类型、必填/选填、说明 |
|
||||
| 响应结构 | `data` 字段的完整结构定义 |
|
||||
| 鉴权要求 | 允许的用户类型 |
|
||||
| 资源所有权 | 所有权校验方式 |
|
||||
|
||||
**列表接口额外要求**
|
||||
- 分页:`page` + `page_size`(默认 20,最大 100)
|
||||
- 排序:默认排序字段与方向
|
||||
- 过滤:支持的过滤条件列表
|
||||
|
||||
**错误码清单**
|
||||
|
||||
| 场景 | 错误码 | 说明 |
|
||||
|---|---|---|
|
||||
| 参数校验失败 | `CodeInvalidParam` | 统一返回,不泄露细节 |
|
||||
| 资源不存在/越权 | `CodeForbidden` | 不区分两者,防止信息泄露 |
|
||||
| (业务错误场景...) | (对应错误码) | (说明) |
|
||||
|
||||
### 完成标准
|
||||
|
||||
**最小验证步骤**(按顺序列出可操作的验证步骤)
|
||||
|
||||
1. (例:调用创建接口,验证返回 `code=0`)
|
||||
2. (例:查询接口确认数据存在且字段正确)
|
||||
3. (例:PostgreSQL MCP 查询确认数据库记录符合预期)
|
||||
|
||||
**影响范围说明**
|
||||
- 新增/修改的表:
|
||||
- 影响的现有接口:
|
||||
- 影响的权限与数据过滤范围:
|
||||
|
||||
---
|
||||
|
||||
## 约束(必须遵守)
|
||||
|
||||
- **优先复用现有架构与库**:不引入新依赖,错误码优先复用已有定义
|
||||
- **不做顺手重构**:提案范围严格限定在目标功能;发现可优化点,记录到 backlog 但不执行
|
||||
- **数据库设计**:禁止外键约束,禁止 GORM 关联标签,关联通过 ID 字段手动维护
|
||||
434
AGENTS.md
434
AGENTS.md
@@ -18,36 +18,8 @@
|
||||
| 数据库迁移 | `db-migration` | 迁移命令、文件规范、执行流程、失败处理 |
|
||||
| 维护规范文档 | `doc-management` | 规范文档流程和维护规则 |
|
||||
| 调试 bug / 排查异常 | `systematic-debugging` | 四阶段根因分析流程、逐层诊断、场景速查表 |
|
||||
| 涉及业务逻辑的开发/修改 | `kb-sync` | 开发前查阅知识库、开发后更新知识库、业务决策记录 |
|
||||
| 编写接口测试 / Hurl 测试 | `hurl-test` | 四阶段交互式生成 .hurl 测试文件(探索→确认→生成→验证),DTO 驱动的字段完整性断言 |
|
||||
|
||||
---
|
||||
|
||||
## 知识库规范(强制执行)
|
||||
|
||||
本项目的业务知识库在 Obsidian vault **「长沙鑫精」** 中,通过 `obsidian` CLI 访问。
|
||||
|
||||
**必须遵守:**
|
||||
|
||||
- 任何涉及业务逻辑的开发前,必须通过 `obsidian vault="长沙鑫精" search/read` 查阅知识库对应模块
|
||||
- 业务逻辑变更完成后,必须更新知识库中受影响的模块文档
|
||||
- 涉及业务决策变更时(为什么从 A 改成 B),必须在对应模块追加决策记录
|
||||
- 新增业务模块时,必须在知识库中创建对应文档
|
||||
|
||||
**禁止:**
|
||||
|
||||
- ❌ 不查阅知识库就开始开发业务功能
|
||||
- ❌ 改了业务逻辑但不更新知识库
|
||||
- ❌ 涉及业务决策变更但不记录原因
|
||||
|
||||
**知识库结构:**
|
||||
|
||||
| 文件夹 | 内容 |
|
||||
|--------|------|
|
||||
| `卡管业务整理/` | 正确的业务流程文档(流程图 + 接口字段速查 + 决策记录) |
|
||||
| `修正业务/` | 已知问题和修复方案 |
|
||||
|
||||
**详细操作规范**:加载 `kb-sync` Skill
|
||||
| 编写 Go 代码注释/文档注释 | `comment-standards` | 包/结构体/接口/函数/内联注释完整规范与示例 |
|
||||
| 创建涉及接口的 OpenSpec 提案 | `openspec-api-contract` | 探索阶段引导清单、提案必填章节与完成标准 |
|
||||
|
||||
---
|
||||
|
||||
@@ -143,150 +115,13 @@ Handler → Service → Store → Model
|
||||
|
||||
### 注释规范
|
||||
|
||||
#### 基本原则
|
||||
- **所有注释使用中文**,导出符号必须有文档注释(包、函数、类型、接口、常量)
|
||||
- 复杂逻辑解释"为什么"而非"做了什么",禁止废话注释(复述代码本身)
|
||||
- Handler 方法注释必须包含 HTTP 方法和路径(`// Create 创建账号` + `// POST /api/admin/accounts`)
|
||||
- 未导出函数:< 15 行可省略,≥ 15 行或非显而易见算法必须注释
|
||||
- 修改代码时必须同步更新注释,过时注释比没有注释更有害
|
||||
|
||||
- **所有注释使用中文**(与语言要求一致)
|
||||
- **导出符号必须有文档注释**(包、函数、方法、类型、接口、常量、变量)
|
||||
- **复杂逻辑必须有实现注释**(解释"为什么",而不是"做了什么")
|
||||
- **禁止废话注释**(不要用注释复述代码本身)
|
||||
- **修改代码时必须同步更新注释**(过时的注释比没有注释更有害)
|
||||
|
||||
#### 包注释
|
||||
|
||||
每个包的入口文件(通常是主文件或 `doc.go`)必须有包注释:
|
||||
|
||||
```go
|
||||
// Package account 提供账号管理的业务逻辑服务
|
||||
// 包含账号创建、修改、删除、权限分配等功能
|
||||
package account
|
||||
```
|
||||
|
||||
#### 结构体注释
|
||||
|
||||
所有导出结构体必须有文档注释,说明该结构体代表什么:
|
||||
|
||||
```go
|
||||
// Service 账号业务服务
|
||||
// 负责账号的 CRUD、角色分配、密码管理等业务逻辑
|
||||
type Service struct {
|
||||
store *Store
|
||||
auditService AuditServiceInterface
|
||||
}
|
||||
```
|
||||
|
||||
#### 接口注释
|
||||
|
||||
导出接口必须注释接口用途,每个方法必须说明契约:
|
||||
|
||||
```go
|
||||
// PermissionChecker 权限检查器接口
|
||||
// 用于查询用户的权限列表
|
||||
type PermissionChecker interface {
|
||||
// CheckPermission 检查用户是否拥有指定权限
|
||||
// userID: 用户ID
|
||||
// permCode: 权限编码(格式: module:action)
|
||||
// platform: 端口类型 (all/web/h5)
|
||||
CheckPermission(ctx context.Context, userID uint, permCode string, platform string) (bool, error)
|
||||
}
|
||||
```
|
||||
|
||||
#### 函数和方法注释
|
||||
|
||||
导出函数/方法必须以函数名开头,说明功能:
|
||||
|
||||
```go
|
||||
// Create 创建账号
|
||||
// POST /api/admin/accounts
|
||||
func (h *AccountHandler) Create(c *fiber.Ctx) error {
|
||||
```
|
||||
|
||||
**复杂方法**(超过 30 行或包含复杂业务逻辑)必须额外说明实现思路:
|
||||
|
||||
```go
|
||||
// ActivateByRealname 首次实名激活套餐
|
||||
// 当用户完成实名认证后,自动激活处于"囤货待实名"状态的套餐:
|
||||
// 1. 查找该卡所有 status=3(待实名激活)的套餐
|
||||
// 2. 按创建时间排序,第一个主套餐立即激活(status=1)
|
||||
// 3. 其余主套餐进入排队状态(status=4)
|
||||
// 4. 加油包如果绑定了已激活的主套餐则一并激活
|
||||
func (s *UsageService) ActivateByRealname(ctx context.Context, cardID uint) error {
|
||||
```
|
||||
|
||||
#### 未导出符号的注释
|
||||
|
||||
未导出(小写)的函数/方法:
|
||||
|
||||
- **简单逻辑**(< 15 行):可以不加注释
|
||||
- **复杂逻辑**(≥ 15 行)或 **非显而易见的算法**:必须加注释
|
||||
|
||||
```go
|
||||
// buildPermissionTree 递归构建权限树
|
||||
// 采用 map 索引 + 单次遍历算法,时间复杂度 O(n)
|
||||
func (s *Service) buildPermissionTree(permissions []*model.Permission) []*dto.PermissionTreeNode {
|
||||
```
|
||||
|
||||
#### 内联注释(实现逻辑注释)
|
||||
|
||||
以下场景**必须**添加内联注释:
|
||||
|
||||
| 场景 | 要求 |
|
||||
|------|------|
|
||||
| 复杂条件判断 | 解释判断的业务含义 |
|
||||
| 多步骤业务流程 | 用编号注释标明每一步 |
|
||||
| 非显而易见的设计决策 | 解释"为什么这样做"而不是"做了什么" |
|
||||
| 缓存/事务/并发处理 | 说明策略和原因 |
|
||||
| 临时方案/兼容逻辑 | 标注 TODO 或说明背景 |
|
||||
|
||||
**✅ 好的内联注释(解释为什么)**:
|
||||
|
||||
```go
|
||||
// 使用 Redis 分布式锁防止并发重复创建,锁超时 10 秒
|
||||
if !s.acquireLock(ctx, lockKey, 10*time.Second) {
|
||||
return errors.New(errors.CodeTooManyRequests, "操作过于频繁,请稍后重试")
|
||||
}
|
||||
|
||||
// 先冻结佣金再扣款,保证资金安全(失败时佣金自动解冻)
|
||||
if err := s.freezeCommission(ctx, tx, orderID); err != nil {
|
||||
return err
|
||||
}
|
||||
```
|
||||
|
||||
**❌ 废话注释(禁止)**:
|
||||
|
||||
```go
|
||||
// 获取用户ID ← 禁止:代码本身已经很清楚
|
||||
userID := middleware.GetUserIDFromContext(ctx)
|
||||
|
||||
// 创建账号 ← 禁止:变量名已说明意图
|
||||
account := &model.Account{}
|
||||
|
||||
// 返回错误 ← 禁止:return err 不需要注释
|
||||
return err
|
||||
```
|
||||
|
||||
#### 常量和枚举注释
|
||||
|
||||
分组常量必须有组注释,每个值必须有行内注释:
|
||||
|
||||
```go
|
||||
// 用户类型常量
|
||||
const (
|
||||
UserTypeSuperAdmin = 1 // 超级管理员
|
||||
UserTypePlatform = 2 // 平台用户
|
||||
UserTypeAgent = 3 // 代理账号
|
||||
UserTypeEnterprise = 4 // 企业账号
|
||||
)
|
||||
```
|
||||
|
||||
#### Handler 层特殊要求
|
||||
|
||||
Handler 方法的注释必须包含 HTTP 方法和路径:
|
||||
|
||||
```go
|
||||
// Create 创建账号
|
||||
// POST /api/admin/accounts
|
||||
func (h *AccountHandler) Create(c *fiber.Ctx) error {
|
||||
```
|
||||
**详细规范与示例**:见 `comment-standards` skill
|
||||
|
||||
### Go 代码风格
|
||||
|
||||
@@ -324,35 +159,9 @@ func (h *AccountHandler) Create(c *fiber.Ctx) error {
|
||||
|
||||
## ⚠️ 测试禁令(强制执行)
|
||||
|
||||
**本项目不使用任何形式的自动化测试代码。**
|
||||
|
||||
**绝对禁止:**
|
||||
|
||||
- ❌ **禁止编写单元测试** - 无论任何场景
|
||||
- ❌ **禁止编写集成测试** - 无论任何场景
|
||||
- ❌ **禁止编写验收测试** - 无论任何场景
|
||||
- ❌ **禁止编写流程测试** - 无论任何场景
|
||||
- ❌ **禁止编写 E2E 测试** - 无论任何场景
|
||||
- ❌ **禁止创建 `*_test.go` 文件** - 除非用户明确要求
|
||||
- ❌ **禁止在任务中包含测试相关工作** - 规划和实现均不涉及测试
|
||||
- ❌ **禁止在文档中提及测试要求** - 规范、设计文档均不讨论测试
|
||||
|
||||
**唯一例外:**
|
||||
|
||||
- ✅ **仅当用户明确要求**时才编写测试代码
|
||||
- ✅ 用户必须主动说明"请写测试"或"需要测试"
|
||||
|
||||
**原因说明:**
|
||||
|
||||
- 业务系统的正确性通过人工验证和生产环境监控保证
|
||||
- 测试代码的维护成本高于价值
|
||||
- 快速迭代优先于测试覆盖率
|
||||
|
||||
**替代方案:**
|
||||
|
||||
- 使用 PostgreSQL MCP 工具手动验证数据
|
||||
- 使用 Postman/curl 手动测试 API
|
||||
- 依赖生产环境日志和监控发现问题
|
||||
**本项目禁止任何形式的自动化测试**(单元/集成/E2E/`*_test.go` 文件),规划和文档中也不讨论测试。
|
||||
**唯一例外**:用户明确说"请写测试"时。
|
||||
**替代验证**:PostgreSQL MCP 手动验证数据、Postman/curl 手动测试 API。
|
||||
|
||||
## 性能要求
|
||||
|
||||
@@ -427,148 +236,30 @@ func (h *AccountHandler) Create(c *fiber.Ctx) error {
|
||||
|
||||
### 越权防护规范
|
||||
|
||||
**适用场景**:任何涉及跨用户、跨店铺、跨企业的资源访问
|
||||
**三层防护机制**(基础设施已就绪,无需自建):
|
||||
1. **路由层中间件**:粗粒度拦截(如企业账号禁止访问账号管理)
|
||||
2. **Service 层业务检查**:`middleware.CanManageShop()` / `middleware.CanManageEnterprise()` 细粒度验证;示例见 `internal/service/account/service.go`
|
||||
3. **GORM Callback 自动过滤**:代理按店铺层级、企业按企业ID,已自动应用无需手动调用
|
||||
|
||||
**三层防护机制**:
|
||||
|
||||
1. **路由层中间件**(粗粒度拦截)
|
||||
- 用于明显的权限限制(如企业账号禁止访问账号管理)
|
||||
- 示例:
|
||||
|
||||
```go
|
||||
group.Use(func(c *fiber.Ctx) error {
|
||||
userType := middleware.GetUserTypeFromContext(c.UserContext())
|
||||
if userType == constants.UserTypeEnterprise {
|
||||
return errors.New(errors.CodeForbidden, "无权限访问账号管理功能")
|
||||
}
|
||||
return c.Next()
|
||||
})
|
||||
```
|
||||
|
||||
2. **Service 层业务检查**(细粒度验证)
|
||||
- 使用 `middleware.CanManageShop(ctx, targetShopID, shopStore)` 验证店铺权限
|
||||
- 使用 `middleware.CanManageEnterprise(ctx, targetEnterpriseID, enterpriseStore, shopStore)` 验证企业权限
|
||||
- 类型级权限检查(如代理不能创建平台账号)
|
||||
- 示例见 `internal/service/account/service.go`
|
||||
|
||||
3. **GORM Callback 自动过滤**(兜底)
|
||||
- 已有实现,自动应用到所有查询
|
||||
- 代理用户:`WHERE shop_id IN (自己店铺+下级店铺)`
|
||||
- 企业用户:`WHERE enterprise_id = 当前企业ID`
|
||||
- 无需手动调用
|
||||
|
||||
**统一错误返回**:
|
||||
|
||||
- 越权访问统一返回:`errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")`
|
||||
- 不区分"不存在"和"无权限",防止信息泄露
|
||||
统一错误返回:`errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")`(不区分"不存在"与"无权限",防止信息泄露)
|
||||
|
||||
### 幂等性规范
|
||||
|
||||
**适用场景**:任何可能被重复触发的写操作
|
||||
|
||||
#### 必须实现幂等性的场景
|
||||
|
||||
| 场景 | 原因 | 实现策略 |
|
||||
|------|------|----------|
|
||||
| 订单创建 | 用户双击、网络重试 | Redis 业务键防重 + 分布式锁 |
|
||||
| 支付回调 | 第三方平台重复通知 | 状态条件更新(`WHERE status = pending`) |
|
||||
| 钱包扣款/充值 | 并发请求、消息重投 | 乐观锁(version 字段)+ 状态条件更新 |
|
||||
| 套餐激活 | 异步任务重试 | Redis 分布式锁 + 已存在记录检查 |
|
||||
| 异步任务处理 | Asynq 自动重试 | Redis 任务锁(`RedisTaskLockKey`) |
|
||||
| 佣金计算 | 支付成功后触发 | 幂等任务入队 + 状态检查 |
|
||||
|
||||
#### 不需要幂等性的场景
|
||||
|
||||
- 纯查询接口(GET 请求天然幂等)
|
||||
- 管理后台的配置修改(低频操作,人为确认)
|
||||
- 日志记录、审计记录(允许重复写入)
|
||||
|
||||
#### 实现策略选择
|
||||
|
||||
根据场景特征选择合适的策略:
|
||||
|
||||
**策略 1:状态条件更新(首选,适用于有明确状态流转的操作)**
|
||||
写操作按场景选择策略:
|
||||
- **状态流转操作** → 状态条件更新(首选)
|
||||
- **创建类操作(无状态可依赖)** → Redis 业务键防重 + 分布式锁(三层检测)
|
||||
- **余额/库存数值更新** → 乐观锁(`version` 字段)
|
||||
|
||||
```go
|
||||
// 通过 WHERE 条件确保只有预期状态才能更新,RowsAffected == 0 说明已被处理
|
||||
// 策略1示例(首选):通过 WHERE 条件确保幂等,RowsAffected=0 说明已被处理
|
||||
result := tx.Model(&model.Order{}).
|
||||
Where("id = ? AND payment_status = ?", orderID, model.PaymentStatusPending).
|
||||
Updates(map[string]any{"payment_status": model.PaymentStatusPaid})
|
||||
|
||||
if result.RowsAffected == 0 {
|
||||
// 已被处理,检查当前状态决定返回成功还是错误
|
||||
}
|
||||
if result.RowsAffected == 0 { /* 已处理,检查当前状态 */ }
|
||||
```
|
||||
|
||||
**策略 2:Redis 业务键防重 + 分布式锁(适用于创建类操作,无状态可依赖)**
|
||||
|
||||
```go
|
||||
// 业务键 = 唯一标识请求意图的组合字段
|
||||
// 示例:order:create:{buyer_type}:{buyer_id}:{carrier_type}:{carrier_id}:{sorted_package_ids}
|
||||
idempotencyKey := buildBusinessKey(...)
|
||||
redisKey := constants.RedisXxxIdempotencyKey(idempotencyKey)
|
||||
|
||||
// 第 1 层:Redis GET 快速检测
|
||||
val, err := s.redis.Get(ctx, redisKey).Result()
|
||||
if err == nil && val != "" {
|
||||
return existingResult // 已创建,直接返回
|
||||
}
|
||||
|
||||
// 第 2 层:分布式锁防止并发
|
||||
lockKey := constants.RedisXxxLockKey(resourceType, resourceID)
|
||||
locked, _ := s.redis.SetNX(ctx, lockKey, time.Now().String(), lockTTL).Result()
|
||||
if !locked {
|
||||
return errors.New(errors.CodeTooManyRequests, "操作进行中,请勿重复提交")
|
||||
}
|
||||
defer s.redis.Del(ctx, lockKey)
|
||||
|
||||
// 第 3 层:加锁后二次检测
|
||||
val, err = s.redis.Get(ctx, redisKey).Result()
|
||||
if err == nil && val != "" {
|
||||
return existingResult
|
||||
}
|
||||
|
||||
// 执行业务逻辑...
|
||||
|
||||
// 成功后标记
|
||||
s.redis.Set(ctx, redisKey, resultID, idempotencyTTL)
|
||||
```
|
||||
|
||||
**策略 3:乐观锁(适用于余额、库存等数值更新)**
|
||||
|
||||
```go
|
||||
result := tx.Model(&model.Wallet{}).
|
||||
Where("id = ? AND balance >= ? AND version = ?", walletID, amount, currentVersion).
|
||||
Updates(map[string]any{
|
||||
"balance": gorm.Expr("balance - ?", amount),
|
||||
"version": gorm.Expr("version + 1"),
|
||||
})
|
||||
if result.RowsAffected == 0 {
|
||||
return errors.New(errors.CodeInsufficientBalance, "余额不足或并发冲突")
|
||||
}
|
||||
```
|
||||
|
||||
#### Redis Key 命名规范
|
||||
|
||||
幂等性相关的 Redis Key 统一在 `pkg/constants/redis.go` 定义:
|
||||
|
||||
```go
|
||||
// 幂等性检测键:Redis{Module}IdempotencyKey — TTL 通常 3~5 分钟
|
||||
func RedisOrderIdempotencyKey(businessKey string) string
|
||||
|
||||
// 分布式锁键:Redis{Module}{Action}LockKey — TTL 通常 10~30 秒
|
||||
func RedisOrderCreateLockKey(carrierType string, carrierID uint) string
|
||||
```
|
||||
|
||||
#### 现有幂等性实现参考
|
||||
|
||||
| 模块 | 文件 | 策略 |
|
||||
|------|------|------|
|
||||
| 订单创建 | `internal/service/order/service.go` → `Create()` | 策略 2:Redis 业务键 + 分布式锁 |
|
||||
| 钱包支付 | `internal/service/order/service.go` → `WalletPay()` | 策略 1:状态条件更新 |
|
||||
| 支付回调 | `internal/service/order/service.go` → `HandlePaymentCallback()` | 策略 1:状态条件更新 |
|
||||
| 套餐激活 | `internal/service/package/activation_service.go` → `ActivateQueuedPackage()` | 策略 2(简化版):Redis 分布式锁 |
|
||||
| 钱包扣款 | `internal/service/order/service.go` → `WalletPay()` | 策略 3:乐观锁(version 字段) |
|
||||
- Redis Key 定义在 `pkg/constants/redis.go`,分布式锁必须用 `defer` 释放
|
||||
- 参考实现:`internal/service/order/service.go`
|
||||
|
||||
### 异步任务载荷规范(Asynq)
|
||||
|
||||
@@ -581,24 +272,12 @@ func RedisOrderCreateLockKey(carrierType string, carrierID uint) string
|
||||
// ✅ 正确:传 struct
|
||||
queueClient.EnqueueTask(ctx, constants.TaskTypeXxx, MyPayload{OrderID: id})
|
||||
|
||||
// ✅ 正确:传 map
|
||||
queueClient.EnqueueTask(ctx, constants.TaskTypeXxx, map[string]any{"order_id": id})
|
||||
|
||||
// ❌ 错误:预先序列化后传 []byte(二次 Marshal → base64 编码)
|
||||
payloadBytes, _ := sonic.Marshal(MyPayload{OrderID: id})
|
||||
queueClient.EnqueueTask(ctx, constants.TaskTypeXxx, payloadBytes)
|
||||
```
|
||||
|
||||
**直接用 `asynq.NewTask` 入队时**(绕过 `EnqueueTask`)才需要自己 Marshal:
|
||||
|
||||
```go
|
||||
// 直接构造 asynq.Task 时,需自行序列化
|
||||
payloadBytes, _ := sonic.Marshal(MyPayload{OrderID: id})
|
||||
task := asynq.NewTask(constants.TaskTypeXxx, payloadBytes, asynq.Queue(...))
|
||||
client.EnqueueContext(ctx, task)
|
||||
```
|
||||
|
||||
**原因**:`pkg/queue/client.go` 的 `EnqueueTask` 在内部已封装了 `sonic.Marshal`,统一处理序列化,调用方不应关心底层字节格式。
|
||||
直接用 `asynq.NewTask` 入队时(绕过 `EnqueueTask`)才需要自己 Marshal。
|
||||
|
||||
---
|
||||
|
||||
@@ -606,51 +285,9 @@ client.EnqueueContext(ctx, task)
|
||||
|
||||
**适用场景**:任何敏感操作(账号管理、权限变更、数据删除等)
|
||||
|
||||
**使用方式**:
|
||||
|
||||
1. **Service 层集成审计日志**:
|
||||
|
||||
```go
|
||||
type Service struct {
|
||||
store *Store
|
||||
auditService AuditServiceInterface
|
||||
}
|
||||
|
||||
func (s *Service) SensitiveOperation(ctx context.Context, ...) error {
|
||||
// 1. 执行业务操作
|
||||
err := s.store.DoSomething(ctx, ...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 2. 记录审计日志(异步)
|
||||
s.auditService.LogOperation(ctx, &model.OperationLog{
|
||||
OperatorID: middleware.GetUserIDFromContext(ctx),
|
||||
OperationType: "operation_type",
|
||||
OperationDesc: "操作描述",
|
||||
BeforeData: beforeData, // 变更前数据
|
||||
AfterData: afterData, // 变更后数据
|
||||
RequestID: middleware.GetRequestIDFromContext(ctx),
|
||||
IPAddress: middleware.GetIPFromContext(ctx),
|
||||
UserAgent: middleware.GetUserAgentFromContext(ctx),
|
||||
})
|
||||
|
||||
return nil
|
||||
}
|
||||
```
|
||||
|
||||
2. **审计日志字段说明**:
|
||||
- `operator_id`, `operator_type`, `operator_name`: 操作人信息(必填)
|
||||
- `target_*`: 目标资源信息(可选)
|
||||
- `operation_type`: 操作类型(create/update/delete/assign_roles等)
|
||||
- `operation_desc`: 操作描述(中文,便于查看)
|
||||
- `before_data`, `after_data`: 变更数据(JSON 格式)
|
||||
- `request_id`, `ip_address`, `user_agent`: 请求上下文
|
||||
|
||||
3. **异步写入**:
|
||||
- 审计日志使用 Goroutine 异步写入
|
||||
- 写入失败不影响业务操作
|
||||
- 失败时记录 Error 日志,包含完整审计信息
|
||||
- Service 层注入 `auditService AuditServiceInterface`,操作成功后调用 `LogOperation()`
|
||||
- 必填字段:`OperatorID`、`OperationType`、`OperationDesc`、`BeforeData`、`AfterData`
|
||||
- 异步写入(Goroutine),写入失败不影响业务,失败时记录 Error 日志
|
||||
|
||||
**示例参考**:`internal/service/account/service.go`
|
||||
|
||||
@@ -673,18 +310,3 @@ client.EnqueueContext(ctx, task)
|
||||
> "任务 3.1 在当前实现中可能不需要,是否可以跳过?"
|
||||
|
||||
**详细规范和 OpenSpec 工作流请查看**: `@/openspec/AGENTS.md`
|
||||
|
||||
# English Learning Mode
|
||||
|
||||
The user is learning English through practical use. Apply these rules in every conversation:
|
||||
|
||||
1. **Always respond in Chinese** — regardless of whether the user writes in English or Chinese.
|
||||
|
||||
2. **When the user writes in English**, append a one-line correction at the end of your response in this format:
|
||||
→ `[natural version of what they wrote]`
|
||||
No explanation needed — just the corrected phrase.
|
||||
|
||||
3. **When the user mixes Chinese into English** (e.g., "I want to 实现 dark mode"), translate the Chinese word/phrase inline and continue naturally. Do not make a
|
||||
big deal of it.
|
||||
|
||||
4. **Never interrupt the flow** to give grammar lessons. Corrections are silent and brief — the user's focus is on the task, not the language.
|
||||
|
||||
@@ -15,6 +15,9 @@ import (
|
||||
"github.com/break/junhong_cmp_fiber/internal/bootstrap"
|
||||
"github.com/break/junhong_cmp_fiber/internal/gateway"
|
||||
"github.com/break/junhong_cmp_fiber/internal/polling"
|
||||
iot_card_svc "github.com/break/junhong_cmp_fiber/internal/service/iot_card"
|
||||
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
|
||||
"github.com/break/junhong_cmp_fiber/internal/task"
|
||||
pkgBootstrap "github.com/break/junhong_cmp_fiber/pkg/bootstrap"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/config"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
@@ -137,15 +140,37 @@ func main() {
|
||||
// 创建 Asynq Worker 服务器
|
||||
workerServer := queue.NewServer(redisClient, &cfg.Queue, appLogger)
|
||||
|
||||
// 初始化轮询调度器(在创建 Handler 之前,因为 Handler 需要使用调度器作为回调)
|
||||
scheduler := polling.NewScheduler(db, redisClient, asynqClient, appLogger)
|
||||
// 初始化轮询系统新组件(Phase 5.7)
|
||||
pollingConfigStore := postgres.NewPollingConfigStore(db)
|
||||
pollingConfigMgr := polling.NewPollingConfigManager(pollingConfigStore, redisClient, appLogger)
|
||||
if err := pollingConfigMgr.Load(ctx); err != nil {
|
||||
appLogger.Warn("加载轮询配置失败,使用空配置继续", zap.Error(err))
|
||||
}
|
||||
pollingConfigMgr.Start(ctx)
|
||||
|
||||
// 注入流量重置服务到调度器
|
||||
pollingQueueMgr := polling.NewPollingQueueManager(redisClient, constants.PollingShardCount, appLogger)
|
||||
|
||||
pollingIotCardStore := postgres.NewIotCardStore(db, redisClient)
|
||||
pollingBase := task.NewPollingBase(redisClient, pollingQueueMgr, pollingConfigMgr, pollingIotCardStore, appLogger)
|
||||
|
||||
// 后台渐进式初始化(将全量卡数据写入分片 Sorted Set)
|
||||
pollingInitializer := polling.NewPollingInitializer(pollingIotCardStore, redisClient, pollingConfigMgr, pollingQueueMgr, appLogger)
|
||||
pollingInitializer.StartBackground(ctx)
|
||||
|
||||
// 创建生命周期服务(Worker 进程用,功能更完整)
|
||||
pollingDeviceSimBindingStore := postgres.NewDeviceSimBindingStore(db, redisClient)
|
||||
pollingDeviceStore := postgres.NewDeviceStore(db, redisClient)
|
||||
lifecycleSvc := polling.NewPollingLifecycleService(pollingQueueMgr, pollingConfigMgr, pollingIotCardStore, pollingDeviceSimBindingStore, pollingDeviceStore, appLogger)
|
||||
|
||||
// 初始化调度器(激活/重置 Handler 通过构造函数注入,防止遗漏)
|
||||
dataResetHandler := polling.NewDataResetHandler(workerResult.Services.ResetService, appLogger)
|
||||
scheduler.SetResetService(dataResetHandler)
|
||||
|
||||
// 注入停复机回调到调度器(套餐过期后主动触发停机)
|
||||
scheduler.SetStopResumeCallback(workerResult.Services.StopResumeService)
|
||||
activationHandler := polling.NewPackageActivationHandler(
|
||||
db, redisClient, asynqClient,
|
||||
workerResult.Services.ActivationService,
|
||||
workerResult.Services.StopResumeService,
|
||||
appLogger,
|
||||
)
|
||||
scheduler := polling.NewScheduler(redisClient, asynqClient, pollingQueueMgr, pollingConfigMgr, appLogger, activationHandler, dataResetHandler)
|
||||
|
||||
if err := scheduler.Start(ctx); err != nil {
|
||||
appLogger.Error("启动轮询调度器失败", zap.Error(err))
|
||||
@@ -153,8 +178,10 @@ func main() {
|
||||
appLogger.Info("轮询调度器已启动")
|
||||
}
|
||||
|
||||
// 创建任务处理器管理器并注册所有处理器
|
||||
taskHandler := queue.NewHandler(db, redisClient, storageSvc, gatewayClient, scheduler, workerResult, asynqClient, appLogger)
|
||||
// 类型断言:StopResumeService 实际是 *iotCardSvc.StopResumeService,实现了 EvaluateAndAct 接口
|
||||
stopResumeSvc, _ := workerResult.Services.StopResumeService.(iot_card_svc.StopResumeServiceInterface)
|
||||
// 创建任务处理器并注册
|
||||
taskHandler := queue.NewHandler(db, redisClient, storageSvc, gatewayClient, lifecycleSvc, workerResult, asynqClient, appLogger, pollingBase, stopResumeSvc)
|
||||
taskHandler.RegisterHandlers()
|
||||
|
||||
appLogger.Info("Worker 服务器配置完成",
|
||||
|
||||
@@ -6709,6 +6709,25 @@ components:
|
||||
nullable: true
|
||||
type: string
|
||||
type: object
|
||||
DtoUpdateAssetPollingStatusRequest:
|
||||
properties:
|
||||
enable_polling:
|
||||
description: 是否启用轮询 (true:启用, false:禁用)
|
||||
type: boolean
|
||||
type: object
|
||||
DtoUpdateAssetPollingStatusResponse:
|
||||
properties:
|
||||
asset_id:
|
||||
description: 资产ID
|
||||
minimum: 0
|
||||
type: integer
|
||||
asset_type:
|
||||
description: 资产类型 (card:IoT卡, device:设备)
|
||||
type: string
|
||||
enable_polling:
|
||||
description: 更新后的轮询状态 (true:已启用, false:已禁用)
|
||||
type: boolean
|
||||
type: object
|
||||
DtoUpdateAuthorizationRemarkReq:
|
||||
properties:
|
||||
remark:
|
||||
@@ -9166,6 +9185,86 @@ paths:
|
||||
summary: 资产套餐列表
|
||||
tags:
|
||||
- 资产管理
|
||||
/api/admin/assets/{asset_type}/{id}/polling-status:
|
||||
patch:
|
||||
description: 启用或禁用指定资产(IoT卡或设备)的定期状态轮询。asset_type 为 iot_card 或 device。
|
||||
parameters:
|
||||
- description: 资产类型 (iot_card:IoT卡, device:设备)
|
||||
in: path
|
||||
name: asset_type
|
||||
required: true
|
||||
schema:
|
||||
description: 资产类型 (iot_card:IoT卡, device:设备)
|
||||
type: string
|
||||
- description: 资产ID
|
||||
in: path
|
||||
name: id
|
||||
required: true
|
||||
schema:
|
||||
description: 资产ID
|
||||
minimum: 0
|
||||
type: integer
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/DtoUpdateAssetPollingStatusRequest'
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
code:
|
||||
description: 响应码
|
||||
example: 0
|
||||
type: integer
|
||||
data:
|
||||
$ref: '#/components/schemas/DtoUpdateAssetPollingStatusResponse'
|
||||
msg:
|
||||
description: 响应消息
|
||||
example: success
|
||||
type: string
|
||||
timestamp:
|
||||
description: 时间戳
|
||||
format: date-time
|
||||
type: string
|
||||
required:
|
||||
- code
|
||||
- msg
|
||||
- data
|
||||
- timestamp
|
||||
type: object
|
||||
description: 成功
|
||||
"400":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
description: 请求参数错误
|
||||
"401":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
description: 未认证或认证已过期
|
||||
"403":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
description: 无权访问
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
description: 服务器内部错误
|
||||
security:
|
||||
- BearerAuth: []
|
||||
summary: 更新资产轮询状态
|
||||
tags:
|
||||
- 资产管理
|
||||
/api/admin/assets/{asset_type}/{id}/realtime-status:
|
||||
get:
|
||||
description: 读取 DB/Redis 中的持久化状态,不调网关。asset_type 为 card 或 device。
|
||||
|
||||
@@ -5,8 +5,11 @@ import (
|
||||
"github.com/break/junhong_cmp_fiber/internal/handler/app"
|
||||
authHandler "github.com/break/junhong_cmp_fiber/internal/handler/auth"
|
||||
"github.com/break/junhong_cmp_fiber/internal/handler/callback"
|
||||
pollingPkg "github.com/break/junhong_cmp_fiber/internal/polling"
|
||||
clientOrderSvc "github.com/break/junhong_cmp_fiber/internal/service/client_order"
|
||||
pollingSvcPkg "github.com/break/junhong_cmp_fiber/internal/service/polling"
|
||||
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/go-playground/validator/v10"
|
||||
)
|
||||
|
||||
@@ -91,7 +94,11 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers {
|
||||
PollingAlert: admin.NewPollingAlertHandler(svc.PollingAlert),
|
||||
PollingCleanup: admin.NewPollingCleanupHandler(svc.PollingCleanup),
|
||||
PollingManualTrigger: admin.NewPollingManualTriggerHandler(svc.PollingManualTrigger),
|
||||
Asset: admin.NewAssetHandler(svc.Asset, svc.Device, svc.StopResumeService),
|
||||
Asset: func() *admin.AssetHandler {
|
||||
pollingQueueMgr := pollingPkg.NewPollingQueueManager(deps.Redis, constants.PollingShardCount, deps.Logger)
|
||||
assetPollingSvc := pollingSvcPkg.NewAssetPollingService(deviceStore, deviceSimBindingStore, svc.IotCard, pollingQueueMgr, deps.Logger)
|
||||
return admin.NewAssetHandler(svc.Asset, svc.Device, svc.StopResumeService, assetPollingSvc)
|
||||
}(),
|
||||
AssetLifecycle: admin.NewAssetLifecycleHandler(svc.AssetLifecycle),
|
||||
AssetWallet: admin.NewAssetWalletHandler(svc.AssetWallet),
|
||||
WechatConfig: admin.NewWechatConfigHandler(svc.WechatConfig),
|
||||
|
||||
@@ -12,6 +12,8 @@ import (
|
||||
commissionStatsSvc "github.com/break/junhong_cmp_fiber/internal/service/commission_stats"
|
||||
commissionWithdrawalSvc "github.com/break/junhong_cmp_fiber/internal/service/commission_withdrawal"
|
||||
commissionWithdrawalSettingSvc "github.com/break/junhong_cmp_fiber/internal/service/commission_withdrawal_setting"
|
||||
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
|
||||
assetSvc "github.com/break/junhong_cmp_fiber/internal/service/asset"
|
||||
assetWalletSvc "github.com/break/junhong_cmp_fiber/internal/service/asset_wallet"
|
||||
@@ -105,7 +107,11 @@ func initServices(s *stores, deps *Dependencies) *services {
|
||||
|
||||
// 创建 IotCard service 并设置回调
|
||||
iotCard := iotCardSvc.New(deps.DB, s.IotCard, s.Shop, s.AssetAllocationRecord, s.ShopPackageAllocation, s.ShopSeriesAllocation, s.PackageSeries, deps.GatewayClient, deps.Logger)
|
||||
iotCard.SetPollingCallback(polling.NewAPICallback(deps.Redis, deps.Logger))
|
||||
// 使用 PollingLifecycleService 替代 APICallback:通过分片队列准确操作(修复 api_callback.go 遗漏 protect 队列的 Bug3)
|
||||
pollingConfigStore := postgres.NewPollingConfigStore(deps.DB)
|
||||
pollingConfigMgr := polling.NewPollingConfigManager(pollingConfigStore, deps.Redis, deps.Logger)
|
||||
pollingQueueMgr := polling.NewPollingQueueManager(deps.Redis, constants.PollingShardCount, deps.Logger)
|
||||
iotCard.SetPollingCallback(polling.NewPollingLifecycleService(pollingQueueMgr, pollingConfigMgr, s.IotCard, s.DeviceSimBinding, s.Device, deps.Logger))
|
||||
// 注入流量扣减回调,使手动刷新资产时能触发套餐流量扣减
|
||||
usageService := packageSvc.NewUsageService(deps.DB, deps.Redis, s.PackageUsage, s.PackageUsageDailyRecord, s.DeviceSimBinding, deps.Logger)
|
||||
iotCard.SetDataDeductor(usageService)
|
||||
@@ -122,7 +128,7 @@ func initServices(s *stores, deps *Dependencies) *services {
|
||||
deps.Logger,
|
||||
)
|
||||
|
||||
stopResumeService := iotCardSvc.NewStopResumeService(deps.DB, deps.Redis, s.IotCard, s.DeviceSimBinding, deps.GatewayClient, deps.Logger)
|
||||
stopResumeService := iotCardSvc.NewStopResumeService(deps.Redis, s.IotCard, s.PackageUsage, s.DeviceSimBinding, deps.GatewayClient, deps.Logger)
|
||||
device := deviceSvc.New(deps.DB, deps.Redis, s.Device, s.DeviceSimBinding, s.IotCard, s.Shop, s.AssetAllocationRecord, s.ShopPackageAllocation, s.ShopSeriesAllocation, s.PackageSeries, deps.GatewayClient)
|
||||
|
||||
return &services{
|
||||
|
||||
@@ -103,7 +103,7 @@ func initWorkerServices(stores *queue.WorkerStores, deps *WorkerDependencies) *q
|
||||
)
|
||||
|
||||
// 创建停复机服务并注入回调:流量耗尽自动停机、套餐激活/重置/支付后自动复机
|
||||
stopResumeService := iotCardSvc.NewStopResumeService(deps.DB, deps.Redis, stores.IotCard, stores.DeviceSimBinding, deps.GatewayClient, deps.Logger)
|
||||
stopResumeService := iotCardSvc.NewStopResumeService(deps.Redis, stores.IotCard, stores.PackageUsage, stores.DeviceSimBinding, deps.GatewayClient, deps.Logger)
|
||||
usageService.SetStopResumeCallback(stopResumeService)
|
||||
activationService.SetResumeCallback(stopResumeService)
|
||||
orderService.SetResumeCallback(stopResumeService)
|
||||
|
||||
@@ -5,9 +5,11 @@ import (
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
|
||||
dto "github.com/break/junhong_cmp_fiber/internal/model/dto"
|
||||
assetService "github.com/break/junhong_cmp_fiber/internal/service/asset"
|
||||
deviceService "github.com/break/junhong_cmp_fiber/internal/service/device"
|
||||
iotCardService "github.com/break/junhong_cmp_fiber/internal/service/iot_card"
|
||||
pollingSvc "github.com/break/junhong_cmp_fiber/internal/service/polling"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/middleware"
|
||||
@@ -15,11 +17,12 @@ import (
|
||||
)
|
||||
|
||||
// AssetHandler 资产管理处理器
|
||||
// 提供统一的资产解析、实时状态、套餐查询、停复机等接口
|
||||
// 提供统一的资产解析、实时状态、套餐查询、停复机、轮询管控等接口
|
||||
type AssetHandler struct {
|
||||
assetService *assetService.Service
|
||||
deviceService *deviceService.Service
|
||||
iotCardStopResume *iotCardService.StopResumeService
|
||||
assetPolling *pollingSvc.AssetPollingService
|
||||
}
|
||||
|
||||
// NewAssetHandler 创建资产管理处理器
|
||||
@@ -27,11 +30,13 @@ func NewAssetHandler(
|
||||
assetSvc *assetService.Service,
|
||||
deviceSvc *deviceService.Service,
|
||||
iotCardStopResume *iotCardService.StopResumeService,
|
||||
assetPolling *pollingSvc.AssetPollingService,
|
||||
) *AssetHandler {
|
||||
return &AssetHandler{
|
||||
assetService: assetSvc,
|
||||
deviceService: deviceSvc,
|
||||
iotCardStopResume: iotCardStopResume,
|
||||
assetPolling: assetPolling,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -187,3 +192,40 @@ func (h *AssetHandler) StartCard(c *fiber.Ctx) error {
|
||||
|
||||
return response.Success(c, nil)
|
||||
}
|
||||
|
||||
// UpdatePollingStatus 更新资产轮询状态
|
||||
// PATCH /api/admin/assets/:asset_type/:id/polling-status
|
||||
func (h *AssetHandler) UpdatePollingStatus(c *fiber.Ctx) error {
|
||||
assetType := c.Params("asset_type")
|
||||
idStr := c.Params("id")
|
||||
|
||||
if assetType == "" || idStr == "" {
|
||||
return errors.New(errors.CodeInvalidParam)
|
||||
}
|
||||
|
||||
id, err := strconv.ParseUint(idStr, 10, 64)
|
||||
if err != nil {
|
||||
return errors.New(errors.CodeInvalidParam)
|
||||
}
|
||||
|
||||
var req dto.UpdateAssetPollingStatusRequest
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return errors.New(errors.CodeInvalidParam)
|
||||
}
|
||||
|
||||
_ = middleware.GetUserIDFromContext(c.UserContext())
|
||||
|
||||
if h.assetPolling == nil {
|
||||
return errors.New(errors.CodeInternalError, "轮询管控服务未配置")
|
||||
}
|
||||
|
||||
if err := h.assetPolling.UpdatePollingStatus(c.UserContext(), assetType, uint(id), req.EnablePolling); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return response.Success(c, &dto.UpdateAssetPollingStatusResponse{
|
||||
AssetType: assetType,
|
||||
AssetID: uint(id),
|
||||
EnablePolling: req.EnablePolling,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -152,12 +152,12 @@ func (h *PollingManualTriggerHandler) GetStatus(c *fiber.Ctx) error {
|
||||
items = append(items, h.toLogResp(log))
|
||||
}
|
||||
|
||||
// 获取各队列大小
|
||||
queueSizes := make(map[string]int64)
|
||||
for _, taskType := range []string{
|
||||
constants.TaskTypePollingRealname,
|
||||
constants.TaskTypePollingCarddata,
|
||||
constants.TaskTypePollingPackage,
|
||||
constants.TaskTypePollingProtect,
|
||||
} {
|
||||
size, _ := h.service.GetQueueSize(ctx, taskType)
|
||||
queueSizes[taskType] = size
|
||||
@@ -271,6 +271,8 @@ func (h *PollingManualTriggerHandler) getTaskTypeName(taskType string) string {
|
||||
return "流量检查"
|
||||
case constants.TaskTypePollingPackage:
|
||||
return "套餐检查"
|
||||
case constants.TaskTypePollingProtect:
|
||||
return "保护期检查"
|
||||
default:
|
||||
return taskType
|
||||
}
|
||||
|
||||
@@ -37,6 +37,8 @@ type Device struct {
|
||||
FirstRechargeTriggeredBySeriesJSON string `gorm:"column:first_recharge_triggered_by_series;type:jsonb;default:'{}';comment:按套餐系列追踪的首充触发状态" json:"-"`
|
||||
AssetStatus int `gorm:"column:asset_status;type:int;not null;default:1;comment:业务状态 1-在库 2-已销售 3-已换货 4-已停用" json:"asset_status"`
|
||||
Generation int `gorm:"column:generation;type:int;not null;default:1;comment:资产世代编号" json:"generation"`
|
||||
// EnablePolling 是否参与轮询,false 时设备下所有卡不加入轮询队列
|
||||
EnablePolling bool `gorm:"column:enable_polling;not null;default:true" json:"enable_polling"`
|
||||
// 以下字段由 Gateway sync-info 接口同步,有离线存储意义
|
||||
OnlineStatus int `gorm:"column:online_status;type:int;not null;default:0;comment:在线状态:0未知 1在线 2离线" json:"online_status"`
|
||||
LastOnlineTime *time.Time `gorm:"column:last_online_time;comment:最后在线时间(来自 Gateway sync-info)" json:"last_online_time,omitempty"`
|
||||
|
||||
@@ -196,3 +196,18 @@ type DeviceGatewayInfo struct {
|
||||
IMEI *string `json:"imei,omitempty" description:"IMEI号"`
|
||||
IMSI *string `json:"imsi,omitempty" description:"IMSI用户标识码"`
|
||||
}
|
||||
|
||||
// UpdateAssetPollingStatusRequest 更新资产轮询状态请求
|
||||
type UpdateAssetPollingStatusRequest struct {
|
||||
AssetType string `path:"asset_type" description:"资产类型 (iot_card:IoT卡, device:设备)" required:"true"`
|
||||
ID uint `path:"id" description:"资产ID" required:"true"`
|
||||
// EnablePolling 是否启用轮询
|
||||
EnablePolling bool `json:"enable_polling" description:"是否启用轮询 (true:启用, false:禁用)"`
|
||||
}
|
||||
|
||||
// UpdateAssetPollingStatusResponse 更新资产轮询状态响应
|
||||
type UpdateAssetPollingStatusResponse struct {
|
||||
AssetType string `json:"asset_type" description:"资产类型 (card:IoT卡, device:设备)"`
|
||||
AssetID uint `json:"asset_id" description:"资产ID"`
|
||||
EnablePolling bool `json:"enable_polling" description:"更新后的轮询状态 (true:已启用, false:已禁用)"`
|
||||
}
|
||||
|
||||
@@ -1,107 +0,0 @@
|
||||
package polling
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
)
|
||||
|
||||
// APICallback API 进程使用的轻量级轮询回调
|
||||
// 直接操作 Redis 队列,不依赖调度器
|
||||
type APICallback struct {
|
||||
redis *redis.Client
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// NewAPICallback 创建 API 回调实例
|
||||
func NewAPICallback(redis *redis.Client, logger *zap.Logger) *APICallback {
|
||||
return &APICallback{
|
||||
redis: redis,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// OnCardCreated 卡创建时的回调
|
||||
// 注意:大多数卡创建是通过 Worker 的批量导入完成的,这个方法主要用于单卡创建场景
|
||||
func (c *APICallback) OnCardCreated(ctx context.Context, card *model.IotCard) {
|
||||
if card == nil {
|
||||
return
|
||||
}
|
||||
c.logger.Debug("API 回调:卡创建", zap.Uint("card_id", card.ID))
|
||||
// 卡创建后,scheduler 的渐进式初始化会将其加入队列
|
||||
// 这里不做处理,让 scheduler 处理
|
||||
}
|
||||
|
||||
// OnCardStatusChanged 卡状态变化时的回调
|
||||
func (c *APICallback) OnCardStatusChanged(ctx context.Context, cardID uint) {
|
||||
c.logger.Debug("API 回调:卡状态变化", zap.Uint("card_id", cardID))
|
||||
// 状态变化后,scheduler 下次扫描时会更新配置匹配
|
||||
// 这里不做处理,让 scheduler 处理
|
||||
}
|
||||
|
||||
// OnCardDeleted 卡删除时的回调
|
||||
// 从所有队列中移除卡
|
||||
func (c *APICallback) OnCardDeleted(ctx context.Context, cardID uint) {
|
||||
c.logger.Debug("API 回调:卡删除", zap.Uint("card_id", cardID))
|
||||
|
||||
member := strconv.FormatUint(uint64(cardID), 10)
|
||||
|
||||
// 从所有轮询队列中移除
|
||||
queues := []string{
|
||||
constants.RedisPollingQueueRealnameKey(),
|
||||
constants.RedisPollingQueueCarddataKey(),
|
||||
constants.RedisPollingQueuePackageKey(),
|
||||
}
|
||||
|
||||
for _, queueKey := range queues {
|
||||
if err := c.redis.ZRem(ctx, queueKey, member).Err(); err != nil {
|
||||
c.logger.Warn("从队列移除卡失败",
|
||||
zap.String("queue", queueKey),
|
||||
zap.Uint("card_id", cardID),
|
||||
zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
// 删除卡信息缓存
|
||||
cacheKey := constants.RedisPollingCardInfoKey(cardID)
|
||||
if err := c.redis.Del(ctx, cacheKey).Err(); err != nil {
|
||||
c.logger.Warn("删除卡缓存失败",
|
||||
zap.Uint("card_id", cardID),
|
||||
zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
// OnCardEnabled 卡启用轮询时的回调
|
||||
func (c *APICallback) OnCardEnabled(ctx context.Context, cardID uint) {
|
||||
c.logger.Debug("API 回调:卡启用轮询", zap.Uint("card_id", cardID))
|
||||
// 启用后,scheduler 下次扫描时会将其加入队列
|
||||
}
|
||||
|
||||
// OnCardDisabled 卡禁用轮询时的回调
|
||||
// 从所有队列中移除卡
|
||||
func (c *APICallback) OnCardDisabled(ctx context.Context, cardID uint) {
|
||||
c.logger.Debug("API 回调:卡禁用轮询", zap.Uint("card_id", cardID))
|
||||
|
||||
member := strconv.FormatUint(uint64(cardID), 10)
|
||||
|
||||
// 从所有轮询队列中移除
|
||||
queues := []string{
|
||||
constants.RedisPollingQueueRealnameKey(),
|
||||
constants.RedisPollingQueueCarddataKey(),
|
||||
constants.RedisPollingQueuePackageKey(),
|
||||
}
|
||||
|
||||
for _, queueKey := range queues {
|
||||
if err := c.redis.ZRem(ctx, queueKey, member).Err(); err != nil {
|
||||
c.logger.Warn("从队列移除卡失败",
|
||||
zap.String("queue", queueKey),
|
||||
zap.Uint("card_id", cardID),
|
||||
zap.Error(err))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,228 +0,0 @@
|
||||
package polling
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
)
|
||||
|
||||
// OnCardCreated 卡创建时的回调
|
||||
// 将新卡加入轮询队列
|
||||
func (s *Scheduler) OnCardCreated(ctx context.Context, card *model.IotCard) {
|
||||
if card == nil {
|
||||
return
|
||||
}
|
||||
|
||||
s.logger.Debug("卡创建回调", zap.Uint("card_id", card.ID))
|
||||
|
||||
if err := s.initCardPolling(ctx, card); err != nil {
|
||||
s.logger.Error("初始化新卡轮询失败",
|
||||
zap.Uint("card_id", card.ID),
|
||||
zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
// OnBatchCardsCreated 批量卡创建时的回调
|
||||
func (s *Scheduler) OnBatchCardsCreated(ctx context.Context, cards []*model.IotCard) {
|
||||
if len(cards) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
s.logger.Info("批量卡创建回调", zap.Int("count", len(cards)))
|
||||
|
||||
for _, card := range cards {
|
||||
if err := s.initCardPolling(ctx, card); err != nil {
|
||||
s.logger.Warn("初始化批量导入卡轮询失败",
|
||||
zap.Uint("card_id", card.ID),
|
||||
zap.Error(err))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// OnCardStatusChanged 卡状态变化时的回调
|
||||
// 重新匹配配置并更新轮询队列
|
||||
func (s *Scheduler) OnCardStatusChanged(ctx context.Context, cardID uint) {
|
||||
s.logger.Debug("卡状态变化回调", zap.Uint("card_id", cardID))
|
||||
|
||||
// 从数据库重新加载卡信息
|
||||
card, err := s.iotCardStore.GetByID(ctx, cardID)
|
||||
if err != nil {
|
||||
s.logger.Error("加载卡信息失败",
|
||||
zap.Uint("card_id", cardID),
|
||||
zap.Error(err))
|
||||
return
|
||||
}
|
||||
|
||||
// 先从所有队列中移除
|
||||
s.removeFromAllQueues(ctx, cardID)
|
||||
|
||||
// 重新初始化轮询
|
||||
if err := s.initCardPolling(ctx, card); err != nil {
|
||||
s.logger.Error("重新初始化卡轮询失败",
|
||||
zap.Uint("card_id", cardID),
|
||||
zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
// OnCardDeleted 卡删除时的回调
|
||||
// 从轮询队列中移除
|
||||
func (s *Scheduler) OnCardDeleted(ctx context.Context, cardID uint) {
|
||||
s.logger.Debug("卡删除回调", zap.Uint("card_id", cardID))
|
||||
|
||||
// 从所有队列中移除
|
||||
s.removeFromAllQueues(ctx, cardID)
|
||||
|
||||
// 删除缓存
|
||||
key := constants.RedisPollingCardInfoKey(cardID)
|
||||
if err := s.redis.Del(ctx, key).Err(); err != nil {
|
||||
s.logger.Warn("删除卡缓存失败",
|
||||
zap.Uint("card_id", cardID),
|
||||
zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
// OnCardEnabled 卡启用轮询时的回调
|
||||
func (s *Scheduler) OnCardEnabled(ctx context.Context, cardID uint) {
|
||||
s.logger.Debug("卡启用轮询回调", zap.Uint("card_id", cardID))
|
||||
|
||||
// 从数据库加载卡信息
|
||||
card, err := s.iotCardStore.GetByID(ctx, cardID)
|
||||
if err != nil {
|
||||
s.logger.Error("加载卡信息失败",
|
||||
zap.Uint("card_id", cardID),
|
||||
zap.Error(err))
|
||||
return
|
||||
}
|
||||
|
||||
// 初始化轮询
|
||||
if err := s.initCardPolling(ctx, card); err != nil {
|
||||
s.logger.Error("启用卡轮询失败",
|
||||
zap.Uint("card_id", cardID),
|
||||
zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
// OnCardDisabled 卡禁用轮询时的回调
|
||||
func (s *Scheduler) OnCardDisabled(ctx context.Context, cardID uint) {
|
||||
s.logger.Debug("卡禁用轮询回调", zap.Uint("card_id", cardID))
|
||||
|
||||
// 从所有队列中移除
|
||||
s.removeFromAllQueues(ctx, cardID)
|
||||
}
|
||||
|
||||
// removeFromAllQueues 从所有轮询队列中移除卡
|
||||
func (s *Scheduler) removeFromAllQueues(ctx context.Context, cardID uint) {
|
||||
member := formatUint(cardID)
|
||||
|
||||
queues := []string{
|
||||
constants.RedisPollingQueueRealnameKey(),
|
||||
constants.RedisPollingQueueCarddataKey(),
|
||||
constants.RedisPollingQueuePackageKey(),
|
||||
}
|
||||
|
||||
for _, queueKey := range queues {
|
||||
if err := s.redis.ZRem(ctx, queueKey, member).Err(); err != nil {
|
||||
s.logger.Warn("从队列移除卡失败",
|
||||
zap.String("queue", queueKey),
|
||||
zap.Uint("card_id", cardID),
|
||||
zap.Error(err))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// RequeueCard 重新将卡加入队列
|
||||
// 用于任务完成后重新入队
|
||||
func (s *Scheduler) RequeueCard(ctx context.Context, cardID uint, taskType string) error {
|
||||
// 从数据库加载卡信息
|
||||
card, err := s.iotCardStore.GetByID(ctx, cardID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 匹配配置
|
||||
config := s.MatchConfig(card)
|
||||
if config == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
var queueKey string
|
||||
var intervalSeconds int
|
||||
|
||||
switch taskType {
|
||||
case constants.TaskTypePollingRealname:
|
||||
if config.RealnameCheckInterval == nil {
|
||||
return nil
|
||||
}
|
||||
queueKey = constants.RedisPollingQueueRealnameKey()
|
||||
intervalSeconds = *config.RealnameCheckInterval
|
||||
case constants.TaskTypePollingCarddata:
|
||||
if config.CarddataCheckInterval == nil {
|
||||
return nil
|
||||
}
|
||||
queueKey = constants.RedisPollingQueueCarddataKey()
|
||||
intervalSeconds = *config.CarddataCheckInterval
|
||||
case constants.TaskTypePollingPackage:
|
||||
if config.PackageCheckInterval == nil {
|
||||
return nil
|
||||
}
|
||||
queueKey = constants.RedisPollingQueuePackageKey()
|
||||
intervalSeconds = *config.PackageCheckInterval
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
|
||||
nextCheck := now.Add(time.Duration(intervalSeconds) * time.Second)
|
||||
return s.addToQueue(ctx, queueKey, cardID, nextCheck)
|
||||
}
|
||||
|
||||
// TriggerManualCheck 触发手动检查
|
||||
func (s *Scheduler) TriggerManualCheck(ctx context.Context, cardID uint, taskType string) error {
|
||||
key := constants.RedisPollingManualQueueKey(taskType)
|
||||
return s.redis.RPush(ctx, key, formatUint(cardID)).Err()
|
||||
}
|
||||
|
||||
// TriggerBatchManualCheck 批量触发手动检查
|
||||
func (s *Scheduler) TriggerBatchManualCheck(ctx context.Context, cardIDs []uint, taskType string) error {
|
||||
if len(cardIDs) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
key := constants.RedisPollingManualQueueKey(taskType)
|
||||
|
||||
// 转换为 interface{} 切片
|
||||
members := make([]interface{}, len(cardIDs))
|
||||
for i, id := range cardIDs {
|
||||
members[i] = formatUint(id)
|
||||
}
|
||||
|
||||
return s.redis.RPush(ctx, key, members...).Err()
|
||||
}
|
||||
|
||||
// LazyLoad 懒加载卡信息
|
||||
// 当卡未初始化但被访问时调用
|
||||
func (s *Scheduler) LazyLoad(ctx context.Context, cardID uint) error {
|
||||
// 检查是否已在缓存中
|
||||
key := constants.RedisPollingCardInfoKey(cardID)
|
||||
exists, err := s.redis.Exists(ctx, key).Result()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if exists > 0 {
|
||||
return nil // 已缓存
|
||||
}
|
||||
|
||||
// 从数据库加载
|
||||
card, err := s.iotCardStore.GetByID(ctx, cardID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 初始化轮询
|
||||
return s.initCardPolling(ctx, card)
|
||||
}
|
||||
152
internal/polling/config_manager.go
Normal file
152
internal/polling/config_manager.go
Normal file
@@ -0,0 +1,152 @@
|
||||
package polling
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/bytedance/sonic"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
)
|
||||
|
||||
// PollingConfigManager 轮询配置管理器
|
||||
// 从 DB 加载 tb_polling_config,同步到 Redis Hash,内存缓存(读写锁)
|
||||
// 5 分钟定时自动刷新;加载失败时保留原缓存不清空
|
||||
type PollingConfigManager struct {
|
||||
configStore *postgres.PollingConfigStore
|
||||
redis *redis.Client
|
||||
logger *zap.Logger
|
||||
|
||||
mu sync.RWMutex
|
||||
configs []*model.PollingConfig
|
||||
refreshOnce sync.Once
|
||||
}
|
||||
|
||||
// NewPollingConfigManager 创建配置管理器
|
||||
func NewPollingConfigManager(configStore *postgres.PollingConfigStore, redisClient *redis.Client, logger *zap.Logger) *PollingConfigManager {
|
||||
return &PollingConfigManager{
|
||||
configStore: configStore,
|
||||
redis: redisClient,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// Load 加载配置到内存缓存并同步到 Redis
|
||||
// 加载失败时保留原缓存不清空,确保可用性
|
||||
func (m *PollingConfigManager) Load(ctx context.Context) error {
|
||||
configs, err := m.configStore.ListEnabled(ctx)
|
||||
if err != nil {
|
||||
m.logger.Error("加载轮询配置失败", zap.Error(err))
|
||||
return err
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
m.configs = configs
|
||||
m.mu.Unlock()
|
||||
|
||||
if syncErr := m.syncToRedis(ctx, configs); syncErr != nil {
|
||||
m.logger.Warn("同步配置到 Redis 失败", zap.Error(syncErr))
|
||||
}
|
||||
|
||||
m.logger.Info("轮询配置已加载", zap.Int("count", len(configs)))
|
||||
return nil
|
||||
}
|
||||
|
||||
// Start 启动定时刷新(每 5 分钟自动 Load 一次)
|
||||
// 使用 sync.Once 确保只启动一个刷新 goroutine
|
||||
func (m *PollingConfigManager) Start(ctx context.Context) {
|
||||
m.refreshOnce.Do(func() {
|
||||
go func() {
|
||||
ticker := time.NewTicker(5 * time.Minute)
|
||||
defer ticker.Stop()
|
||||
m.logger.Info("配置管理器已启动,5分钟自动刷新")
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
if err := m.Load(ctx); err != nil {
|
||||
m.logger.Warn("定时刷新轮询配置失败,保留原配置", zap.Error(err))
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
})
|
||||
}
|
||||
|
||||
// MatchConfig 按优先级匹配第一个符合条件的轮询配置
|
||||
// 配置按 priority ASC 排序(DB 层保证),数字越小优先级越高
|
||||
func (m *PollingConfigManager) MatchConfig(card *model.IotCard) *model.PollingConfig {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
for _, cfg := range m.configs {
|
||||
if matchConfigConditions(cfg, card) {
|
||||
return cfg
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// matchConfigConditions 检查卡是否满足配置的匹配条件
|
||||
func matchConfigConditions(cfg *model.PollingConfig, card *model.IotCard) bool {
|
||||
if cfg.CardCondition != "" {
|
||||
if cfg.CardCondition != getCardCondition(card) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
if cfg.CardCategory != "" {
|
||||
if cfg.CardCategory != card.CardCategory {
|
||||
return false
|
||||
}
|
||||
}
|
||||
if cfg.CarrierID != nil {
|
||||
if *cfg.CarrierID != card.CarrierID {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// getCardCondition 获取卡的状态条件(用于匹配轮询配置)
|
||||
// ⚠️ 注意判断顺序:停机优先,避免停机卡错误匹配 activated 或 not_real_name 配置
|
||||
// 停机卡需要继续轮询 carddata/package 以检测复机条件(套餐购买、流量重置、实名完成)
|
||||
func getCardCondition(card *model.IotCard) string {
|
||||
if card.NetworkStatus == constants.NetworkStatusOffline {
|
||||
return "suspended"
|
||||
}
|
||||
if card.RealNameStatus != constants.RealNameStatusVerified {
|
||||
return "not_real_name"
|
||||
}
|
||||
return "activated"
|
||||
}
|
||||
|
||||
// syncToRedis 将配置同步到 Redis Hash(TTL 24h)
|
||||
func (m *PollingConfigManager) syncToRedis(ctx context.Context, configs []*model.PollingConfig) error {
|
||||
if len(configs) == 0 {
|
||||
return nil
|
||||
}
|
||||
key := constants.RedisPollingConfigsCacheKey()
|
||||
configData := make([]interface{}, 0, len(configs)*2)
|
||||
for _, cfg := range configs {
|
||||
jsonData, err := sonic.Marshal(cfg)
|
||||
if err != nil {
|
||||
m.logger.Warn("序列化轮询配置失败", zap.Uint("config_id", cfg.ID), zap.Error(err))
|
||||
continue
|
||||
}
|
||||
configData = append(configData, cfg.ID, string(jsonData))
|
||||
}
|
||||
if len(configData) == 0 {
|
||||
return nil
|
||||
}
|
||||
pipe := m.redis.Pipeline()
|
||||
pipe.HSet(ctx, key, configData...)
|
||||
pipe.Expire(ctx, key, 24*time.Hour)
|
||||
_, err := pipe.Exec(ctx)
|
||||
return err
|
||||
}
|
||||
@@ -15,7 +15,6 @@ type DataResetHandler struct {
|
||||
resetService *packagepkg.ResetService
|
||||
logger *zap.Logger
|
||||
|
||||
// 上次执行时间(用于限流,避免重复执行)
|
||||
lastDailyReset time.Time
|
||||
lastMonthlyReset time.Time
|
||||
lastYearlyReset time.Time
|
||||
@@ -26,9 +25,13 @@ func NewDataResetHandler(
|
||||
resetService *packagepkg.ResetService,
|
||||
logger *zap.Logger,
|
||||
) *DataResetHandler {
|
||||
now := time.Now()
|
||||
return &DataResetHandler{
|
||||
resetService: resetService,
|
||||
logger: logger,
|
||||
lastDailyReset: now,
|
||||
lastMonthlyReset: now,
|
||||
lastYearlyReset: now,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
297
internal/polling/initializer.go
Normal file
297
internal/polling/initializer.go
Normal file
@@ -0,0 +1,297 @@
|
||||
package polling
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
)
|
||||
|
||||
// initProgress 初始化进度内部状态(字段通过 GetProgress 快照对外,避免暴露锁)
|
||||
type initProgress struct {
|
||||
mu sync.RWMutex
|
||||
totalCards int64
|
||||
loadedCards int64
|
||||
startTime time.Time
|
||||
lastBatchTime time.Time
|
||||
status string
|
||||
errorMessage string
|
||||
}
|
||||
|
||||
// InitProgress 初始化进度快照(GetProgress 返回的值拷贝,调用方无需加锁)
|
||||
type InitProgress struct {
|
||||
TotalCards int64 `json:"total_cards"`
|
||||
LoadedCards int64 `json:"loaded_cards"`
|
||||
StartTime time.Time `json:"start_time"`
|
||||
LastBatchTime time.Time `json:"last_batch_time"`
|
||||
Status string `json:"status"`
|
||||
ErrorMessage string `json:"error_message"`
|
||||
}
|
||||
|
||||
// pipelineFlushSize 单次 Pipeline Exec 最大命令数(每张卡最多 6 条)
|
||||
// 10000 条约 2MB 内存峰值;千万级卡下约 6000 次 Exec,RTT 总开销可忽略
|
||||
const pipelineFlushSize = 10000
|
||||
|
||||
// PollingInitializer 分片渐进式初始化器
|
||||
// 启动时从 DB 分批加载全量卡数据到分片 Sorted Set
|
||||
// 使用 card_id % shardCount 分片,每 pipelineFlushSize 条命令 flush 一次 Pipeline
|
||||
type PollingInitializer struct {
|
||||
iotCardStore *postgres.IotCardStore
|
||||
redis *redis.Client
|
||||
configMgr *PollingConfigManager
|
||||
queueMgr *PollingQueueManager
|
||||
logger *zap.Logger
|
||||
|
||||
progress initProgress
|
||||
initCompleted atomic.Bool
|
||||
|
||||
stopChan chan struct{}
|
||||
wg sync.WaitGroup
|
||||
}
|
||||
|
||||
// NewPollingInitializer 创建初始化器
|
||||
func NewPollingInitializer(
|
||||
iotCardStore *postgres.IotCardStore,
|
||||
redisClient *redis.Client,
|
||||
configMgr *PollingConfigManager,
|
||||
queueMgr *PollingQueueManager,
|
||||
logger *zap.Logger,
|
||||
) *PollingInitializer {
|
||||
p := &PollingInitializer{
|
||||
iotCardStore: iotCardStore,
|
||||
redis: redisClient,
|
||||
configMgr: configMgr,
|
||||
queueMgr: queueMgr,
|
||||
logger: logger,
|
||||
stopChan: make(chan struct{}),
|
||||
}
|
||||
p.progress.status = "pending"
|
||||
return p
|
||||
}
|
||||
|
||||
// StartBackground 启动后台渐进式初始化(非阻塞)
|
||||
func (p *PollingInitializer) StartBackground(ctx context.Context) {
|
||||
p.wg.Add(1)
|
||||
go p.run(ctx)
|
||||
}
|
||||
|
||||
// Stop 停止初始化
|
||||
func (p *PollingInitializer) Stop() {
|
||||
close(p.stopChan)
|
||||
p.wg.Wait()
|
||||
}
|
||||
|
||||
// IsCompleted 检查初始化是否完成
|
||||
func (p *PollingInitializer) IsCompleted() bool {
|
||||
return p.initCompleted.Load()
|
||||
}
|
||||
|
||||
// GetProgress 返回当前初始化进度快照(加锁读取,返回值拷贝)
|
||||
func (p *PollingInitializer) GetProgress() InitProgress {
|
||||
p.progress.mu.RLock()
|
||||
defer p.progress.mu.RUnlock()
|
||||
return InitProgress{
|
||||
TotalCards: p.progress.totalCards,
|
||||
LoadedCards: p.progress.loadedCards,
|
||||
StartTime: p.progress.startTime,
|
||||
LastBatchTime: p.progress.lastBatchTime,
|
||||
Status: p.progress.status,
|
||||
ErrorMessage: p.progress.errorMessage,
|
||||
}
|
||||
}
|
||||
|
||||
// run 执行渐进式初始化
|
||||
func (p *PollingInitializer) run(ctx context.Context) {
|
||||
defer p.wg.Done()
|
||||
|
||||
p.setStatus("running", "")
|
||||
p.progress.mu.Lock()
|
||||
p.progress.startTime = time.Now()
|
||||
p.progress.mu.Unlock()
|
||||
|
||||
p.logger.Info("开始分片渐进式初始化...")
|
||||
|
||||
totalCards, err := p.iotCardStore.CountForPolling(ctx)
|
||||
if err != nil {
|
||||
p.logger.Error("获取卡总数失败", zap.Error(err))
|
||||
p.setStatus("failed", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
p.progress.mu.Lock()
|
||||
p.progress.totalCards = totalCards
|
||||
p.progress.mu.Unlock()
|
||||
|
||||
p.logger.Info("开始加载卡数据", zap.Int64("total_cards", totalCards))
|
||||
|
||||
const batchSize = 100000
|
||||
const batchSleep = 500 * time.Millisecond
|
||||
var lastID uint
|
||||
batchCount := 0
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-p.stopChan:
|
||||
p.logger.Info("渐进式初始化被中断")
|
||||
return
|
||||
default:
|
||||
}
|
||||
|
||||
cards, fetchErr := p.iotCardStore.ListForPollingBatch(ctx, lastID, batchSize)
|
||||
if fetchErr != nil {
|
||||
p.logger.Error("加载卡数据失败", zap.Error(fetchErr))
|
||||
p.setStatus("failed", fetchErr.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if len(cards) == 0 {
|
||||
break
|
||||
}
|
||||
|
||||
if initErr := p.initBatch(ctx, cards); initErr != nil {
|
||||
p.logger.Warn("批量初始化失败", zap.Error(initErr))
|
||||
}
|
||||
|
||||
lastID = cards[len(cards)-1].ID
|
||||
batchCount++
|
||||
|
||||
p.progress.mu.Lock()
|
||||
p.progress.loadedCards += int64(len(cards))
|
||||
p.progress.lastBatchTime = time.Now()
|
||||
loaded := p.progress.loadedCards
|
||||
p.progress.mu.Unlock()
|
||||
|
||||
if batchCount%10 == 0 || len(cards) < batchSize {
|
||||
p.logger.Info("初始化进度",
|
||||
zap.Int("batch", batchCount),
|
||||
zap.Int64("loaded", loaded),
|
||||
zap.Int64("total", totalCards))
|
||||
}
|
||||
|
||||
time.Sleep(batchSleep)
|
||||
}
|
||||
|
||||
p.setStatus("completed", "")
|
||||
p.initCompleted.Store(true)
|
||||
|
||||
snapshot := p.GetProgress()
|
||||
p.logger.Info("分片渐进式初始化完成",
|
||||
zap.Int64("total_loaded", snapshot.LoadedCards),
|
||||
zap.Duration("duration", time.Since(snapshot.StartTime)))
|
||||
}
|
||||
|
||||
// initBatch 使用 Pipeline 将一批卡写入分片队列和缓存
|
||||
// 每 pipelineFlushSize 条命令 Exec 一次,控制内存峰值并降低单次失败损失
|
||||
func (p *PollingInitializer) initBatch(ctx context.Context, cards []*model.IotCard) error {
|
||||
if len(cards) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
cardCacheTTL := 7 * 24 * time.Hour
|
||||
pipe := p.redis.Pipeline()
|
||||
cmdCount := 0
|
||||
|
||||
flushPipe := func() {
|
||||
if cmdCount == 0 {
|
||||
return
|
||||
}
|
||||
if _, execErr := pipe.Exec(ctx); execErr != nil {
|
||||
p.logger.Warn("Pipeline flush 失败,继续下一批", zap.Error(execErr))
|
||||
}
|
||||
pipe = p.redis.Pipeline()
|
||||
cmdCount = 0
|
||||
}
|
||||
|
||||
for _, card := range cards {
|
||||
cfg := p.configMgr.MatchConfig(card)
|
||||
if cfg == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
shardID := int(card.ID) % p.queueMgr.shardCount
|
||||
cardIDStr := fmt.Sprintf("%d", card.ID)
|
||||
|
||||
if cfg.RealnameCheckInterval != nil && *cfg.RealnameCheckInterval > 0 {
|
||||
nextCheck := calculateNextCheckTime(card.LastRealNameCheckAt, *cfg.RealnameCheckInterval, now)
|
||||
pipe.ZAdd(ctx, constants.RedisPollingShardQueueKey(shardID, constants.TaskTypePollingRealname), redis.Z{
|
||||
Score: float64(nextCheck.Unix()), Member: cardIDStr,
|
||||
})
|
||||
cmdCount++
|
||||
}
|
||||
if cfg.CarddataCheckInterval != nil && *cfg.CarddataCheckInterval > 0 {
|
||||
nextCheck := calculateNextCheckTime(card.LastDataCheckAt, *cfg.CarddataCheckInterval, now)
|
||||
pipe.ZAdd(ctx, constants.RedisPollingShardQueueKey(shardID, constants.TaskTypePollingCarddata), redis.Z{
|
||||
Score: float64(nextCheck.Unix()), Member: cardIDStr,
|
||||
})
|
||||
cmdCount++
|
||||
}
|
||||
if cfg.PackageCheckInterval != nil && *cfg.PackageCheckInterval > 0 {
|
||||
nextCheck := calculateNextCheckTime(card.LastDataCheckAt, *cfg.PackageCheckInterval, now)
|
||||
pipe.ZAdd(ctx, constants.RedisPollingShardQueueKey(shardID, constants.TaskTypePollingPackage), redis.Z{
|
||||
Score: float64(nextCheck.Unix()), Member: cardIDStr,
|
||||
})
|
||||
cmdCount++
|
||||
}
|
||||
if cfg.ProtectCheckInterval != nil && *cfg.ProtectCheckInterval > 0 {
|
||||
nextCheck := calculateNextCheckTime(card.LastProtectCheckAt, *cfg.ProtectCheckInterval, now)
|
||||
pipe.ZAdd(ctx, constants.RedisPollingShardQueueKey(shardID, constants.TaskTypePollingProtect), redis.Z{
|
||||
Score: float64(nextCheck.Unix()), Member: cardIDStr,
|
||||
})
|
||||
cmdCount++
|
||||
}
|
||||
|
||||
cacheKey := constants.RedisPollingCardInfoKey(card.ID)
|
||||
cacheData := map[string]interface{}{
|
||||
"id": card.ID, "iccid": card.ICCID,
|
||||
"card_category": card.CardCategory, "real_name_status": card.RealNameStatus,
|
||||
"network_status": card.NetworkStatus, "carrier_id": card.CarrierID,
|
||||
"current_month_usage_mb": card.CurrentMonthUsageMB,
|
||||
"last_gateway_reading_mb": card.LastGatewayReadingMB,
|
||||
"data_usage_mb": card.DataUsageMB,
|
||||
"stop_reason": card.StopReason, "is_standalone": boolToStr(card.IsStandalone),
|
||||
"cached_at": now.Unix(),
|
||||
}
|
||||
if card.CurrentMonthStartDate != nil {
|
||||
cacheData["current_month_start_date"] = card.CurrentMonthStartDate.Unix()
|
||||
}
|
||||
pipe.HSet(ctx, cacheKey, cacheData)
|
||||
pipe.Expire(ctx, cacheKey, cardCacheTTL)
|
||||
cmdCount += 2
|
||||
|
||||
if cmdCount >= pipelineFlushSize {
|
||||
flushPipe()
|
||||
}
|
||||
}
|
||||
|
||||
flushPipe()
|
||||
return nil
|
||||
}
|
||||
|
||||
// calculateNextCheckTime 计算下次检查时间
|
||||
func calculateNextCheckTime(lastCheckAt *time.Time, intervalSeconds int, now time.Time) time.Time {
|
||||
if lastCheckAt == nil {
|
||||
jitter := time.Duration(now.UnixNano()%int64(intervalSeconds)) * time.Second / 10
|
||||
return now.Add(jitter)
|
||||
}
|
||||
nextCheck := lastCheckAt.Add(time.Duration(intervalSeconds) * time.Second)
|
||||
if nextCheck.Before(now) {
|
||||
return now
|
||||
}
|
||||
return nextCheck
|
||||
}
|
||||
|
||||
func (p *PollingInitializer) setStatus(status, errMsg string) {
|
||||
p.progress.mu.Lock()
|
||||
p.progress.status = status
|
||||
p.progress.errorMessage = errMsg
|
||||
p.progress.mu.Unlock()
|
||||
}
|
||||
195
internal/polling/lifecycle_service.go
Normal file
195
internal/polling/lifecycle_service.go
Normal file
@@ -0,0 +1,195 @@
|
||||
package polling
|
||||
|
||||
import (
|
||||
"context"
|
||||
"math/rand"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
)
|
||||
|
||||
// PollingLifecycleService 卡生命周期轮询管理服务
|
||||
// 替代 callbacks.go 和 api_callback.go 中的生命周期方法
|
||||
// 实现 iot_card.PollingCallback 接口,两个进程(API 和 Worker)共享同一实现
|
||||
// 依赖:PollingQueueManager(队列操作)+ PollingConfigManager(配置匹配)
|
||||
type PollingLifecycleService struct {
|
||||
queueMgr *PollingQueueManager
|
||||
configMgr *PollingConfigManager
|
||||
iotCardStore *postgres.IotCardStore
|
||||
deviceBindingStore *postgres.DeviceSimBindingStore
|
||||
deviceStore *postgres.DeviceStore
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// NewPollingLifecycleService 创建卡生命周期轮询管理服务
|
||||
func NewPollingLifecycleService(
|
||||
queueMgr *PollingQueueManager,
|
||||
configMgr *PollingConfigManager,
|
||||
iotCardStore *postgres.IotCardStore,
|
||||
deviceBindingStore *postgres.DeviceSimBindingStore,
|
||||
deviceStore *postgres.DeviceStore,
|
||||
logger *zap.Logger,
|
||||
) *PollingLifecycleService {
|
||||
return &PollingLifecycleService{
|
||||
queueMgr: queueMgr,
|
||||
configMgr: configMgr,
|
||||
iotCardStore: iotCardStore,
|
||||
deviceBindingStore: deviceBindingStore,
|
||||
deviceStore: deviceStore,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// OnCardCreated 新卡创建后初始化轮询
|
||||
func (s *PollingLifecycleService) OnCardCreated(ctx context.Context, card *model.IotCard) {
|
||||
if card == nil {
|
||||
return
|
||||
}
|
||||
if !s.shouldEnqueue(ctx, card) {
|
||||
s.logger.Debug("卡禁用轮询,跳过入队", zap.Uint("card_id", card.ID))
|
||||
return
|
||||
}
|
||||
s.enqueueCard(ctx, card)
|
||||
}
|
||||
|
||||
// OnBatchCardsCreated 批量卡创建后批量初始化轮询
|
||||
func (s *PollingLifecycleService) OnBatchCardsCreated(ctx context.Context, cards []*model.IotCard) {
|
||||
for _, card := range cards {
|
||||
s.OnCardCreated(ctx, card)
|
||||
}
|
||||
}
|
||||
|
||||
// OnCardStatusChanged 卡状态变化后重新匹配配置并更新队列
|
||||
func (s *PollingLifecycleService) OnCardStatusChanged(ctx context.Context, cardID uint) {
|
||||
if err := s.queueMgr.RemoveFromAllQueues(ctx, cardID); err != nil {
|
||||
s.logger.Warn("卡状态变化:从队列移除失败", zap.Uint("card_id", cardID), zap.Error(err))
|
||||
}
|
||||
card, err := s.iotCardStore.GetByID(ctx, cardID)
|
||||
if err != nil {
|
||||
s.logger.Error("卡状态变化:加载卡信息失败", zap.Uint("card_id", cardID), zap.Error(err))
|
||||
return
|
||||
}
|
||||
if !s.shouldEnqueue(ctx, card) {
|
||||
return
|
||||
}
|
||||
s.enqueueCard(ctx, card)
|
||||
}
|
||||
|
||||
// OnCardDeleted 卡删除后移除所有队列并清理缓存
|
||||
func (s *PollingLifecycleService) OnCardDeleted(ctx context.Context, cardID uint) {
|
||||
if err := s.queueMgr.OnCardDeleted(ctx, cardID); err != nil {
|
||||
s.logger.Warn("卡删除:清理队列和缓存失败", zap.Uint("card_id", cardID), zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
// OnCardEnabled 卡启用轮询后初始化
|
||||
func (s *PollingLifecycleService) OnCardEnabled(ctx context.Context, cardID uint) {
|
||||
card, err := s.iotCardStore.GetByID(ctx, cardID)
|
||||
if err != nil {
|
||||
s.logger.Error("卡启用:加载卡信息失败", zap.Uint("card_id", cardID), zap.Error(err))
|
||||
return
|
||||
}
|
||||
if !s.shouldEnqueue(ctx, card) {
|
||||
return
|
||||
}
|
||||
s.enqueueCard(ctx, card)
|
||||
}
|
||||
|
||||
// OnCardDisabled 卡禁用轮询后移除所有队列
|
||||
func (s *PollingLifecycleService) OnCardDisabled(ctx context.Context, cardID uint) {
|
||||
if err := s.queueMgr.RemoveFromAllQueues(ctx, cardID); err != nil {
|
||||
s.logger.Warn("卡禁用:从队列移除失败", zap.Uint("card_id", cardID), zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
// shouldEnqueue M3 修复:检查卡或其绑定设备是否允许轮询
|
||||
// 两层检查:先查卡级 enable_polling,再查设备级 enable_polling(IsStandalone=false 时)
|
||||
// 直接查 device.enable_polling,不依赖级联同步正确性,防止同步失败时生命周期事件绕过设备级禁用
|
||||
func (s *PollingLifecycleService) shouldEnqueue(ctx context.Context, card *model.IotCard) bool {
|
||||
if !card.EnablePolling {
|
||||
return false
|
||||
}
|
||||
if !card.IsStandalone && s.deviceBindingStore != nil && s.deviceStore != nil {
|
||||
binding, err := s.deviceBindingStore.GetActiveBindingByCardID(ctx, card.ID)
|
||||
if err == nil && binding != nil {
|
||||
device, devErr := s.deviceStore.GetByID(ctx, binding.DeviceID)
|
||||
if devErr == nil && !device.EnablePolling {
|
||||
s.logger.Debug("设备已禁用轮询,跳过卡入队",
|
||||
zap.Uint("card_id", card.ID), zap.Uint("device_id", binding.DeviceID))
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// enqueueCard 按匹配配置将卡入队分片队列
|
||||
func (s *PollingLifecycleService) enqueueCard(ctx context.Context, card *model.IotCard) {
|
||||
cfg := s.configMgr.MatchConfig(card)
|
||||
if cfg == nil {
|
||||
return
|
||||
}
|
||||
taskTypes := getEnabledTaskTypes(cfg)
|
||||
for _, taskType := range taskTypes {
|
||||
if err := s.queueMgr.Requeue(ctx, card.ID, taskType, calcInitialDelay(card, cfg, taskType)); err != nil {
|
||||
s.logger.Warn("卡入队失败",
|
||||
zap.Uint("card_id", card.ID), zap.String("task_type", taskType), zap.Error(err))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// getEnabledTaskTypes 返回配置中启用的任务类型列表
|
||||
func getEnabledTaskTypes(cfg *model.PollingConfig) []string {
|
||||
var types []string
|
||||
if cfg.RealnameCheckInterval != nil && *cfg.RealnameCheckInterval > 0 {
|
||||
types = append(types, constants.TaskTypePollingRealname)
|
||||
}
|
||||
if cfg.CarddataCheckInterval != nil && *cfg.CarddataCheckInterval > 0 {
|
||||
types = append(types, constants.TaskTypePollingCarddata)
|
||||
}
|
||||
if cfg.PackageCheckInterval != nil && *cfg.PackageCheckInterval > 0 {
|
||||
types = append(types, constants.TaskTypePollingPackage)
|
||||
}
|
||||
if cfg.ProtectCheckInterval != nil && *cfg.ProtectCheckInterval > 0 {
|
||||
types = append(types, constants.TaskTypePollingProtect)
|
||||
}
|
||||
return types
|
||||
}
|
||||
|
||||
// calcInitialDelay 计算新入队卡的初始检查时间(加随机抖动避免惊群)
|
||||
// 抖动范围 [0, max(interval/10, 2)) 秒;下界取 2 保证 rand.Intn 有实际随机性
|
||||
// (rand.Intn(1) 永远返回 0,对 interval≤10 的配置会导致零抖动)
|
||||
func calcInitialDelay(_ *model.IotCard, cfg *model.PollingConfig, taskType string) time.Time {
|
||||
now := time.Now()
|
||||
var interval int
|
||||
switch taskType {
|
||||
case constants.TaskTypePollingRealname:
|
||||
if cfg.RealnameCheckInterval != nil {
|
||||
interval = *cfg.RealnameCheckInterval
|
||||
}
|
||||
case constants.TaskTypePollingCarddata:
|
||||
if cfg.CarddataCheckInterval != nil {
|
||||
interval = *cfg.CarddataCheckInterval
|
||||
}
|
||||
case constants.TaskTypePollingPackage:
|
||||
if cfg.PackageCheckInterval != nil {
|
||||
interval = *cfg.PackageCheckInterval
|
||||
}
|
||||
case constants.TaskTypePollingProtect:
|
||||
if cfg.ProtectCheckInterval != nil {
|
||||
interval = *cfg.ProtectCheckInterval
|
||||
}
|
||||
}
|
||||
if interval <= 0 {
|
||||
return now
|
||||
}
|
||||
jitterMax := interval / 10
|
||||
if jitterMax < 2 {
|
||||
jitterMax = 2
|
||||
}
|
||||
return now.Add(time.Duration(rand.Intn(jitterMax)) * time.Second)
|
||||
}
|
||||
@@ -113,8 +113,11 @@ func (h *PackageActivationHandler) findExpiredMainPackages(ctx context.Context)
|
||||
}
|
||||
|
||||
// processExpiredPackage 处理单个过期套餐
|
||||
// triggerStopAfterExpiry 在事务提交后才调用,避免 goroutine 在 TX 提交前读到脏数据
|
||||
func (h *PackageActivationHandler) processExpiredPackage(ctx context.Context, pkg *model.PackageUsage) error {
|
||||
return h.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
carrierType, carrierID := h.getCarrierInfo(pkg)
|
||||
|
||||
err := h.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
// 任务 19.3: 更新过期主套餐状态为 Expired (status=3)
|
||||
if err := tx.Model(pkg).Update("status", constants.PackageUsageStatusExpired).Error; err != nil {
|
||||
return err
|
||||
@@ -129,11 +132,9 @@ func (h *PackageActivationHandler) processExpiredPackage(ctx context.Context, pk
|
||||
h.logger.Warn("加油包级联失效失败",
|
||||
zap.Uint("master_usage_id", pkg.ID),
|
||||
zap.Error(err))
|
||||
// 不返回错误,继续处理
|
||||
}
|
||||
|
||||
// 任务 19.5: 查询并激活下一个待生效主套餐
|
||||
carrierType, carrierID := h.getCarrierInfo(pkg)
|
||||
if carrierType != "" && carrierID > 0 {
|
||||
if err := h.activateNextPackage(ctx, tx, carrierType, carrierID); err != nil {
|
||||
h.logger.Warn("激活下一个待生效套餐失败",
|
||||
@@ -148,13 +149,21 @@ func (h *PackageActivationHandler) processExpiredPackage(ctx context.Context, pk
|
||||
zap.Uint("carrier_id", carrierID),
|
||||
zap.Error(err))
|
||||
}
|
||||
|
||||
// 套餐过期后主动触发停机,消除依赖 carddata 轮询兜底的 60s 延迟窗口
|
||||
h.triggerStopAfterExpiry(ctx, carrierType, carrierID)
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 事务提交后再触发异步停机,确保 CheckAndStopCard 读到最新的套餐状态
|
||||
if carrierType != "" && carrierID > 0 {
|
||||
h.triggerStopAfterExpiry(ctx, carrierType, carrierID)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// invalidateAddons 任务 19.4: 加油包级联失效
|
||||
@@ -439,67 +448,43 @@ func (h *PackageActivationHandler) HandlePackageQueueActivation(ctx context.Cont
|
||||
}
|
||||
|
||||
// HandlePackageFirstActivation 处理首次实名激活任务(Asynq Handler)
|
||||
// 任务 22: 由 Asynq 调用,执行首次实名后的套餐激活
|
||||
// 由 polling_realname_handler 在检测到首次实名时触发。
|
||||
// payload 中仅需 carrier_type + carrier_id,ActivateByRealname 内部自行查找
|
||||
// pending_realname_activation=true 的套餐,无需外部指定 PackageUsageID。
|
||||
func (h *PackageActivationHandler) HandlePackageFirstActivation(ctx context.Context, t *asynq.Task) error {
|
||||
var payload PackageActivationPayload
|
||||
if err := sonic.Unmarshal(t.Payload(), &payload); err != nil {
|
||||
h.logger.Error("解析首次实名激活任务载荷失败", zap.Error(err))
|
||||
return nil // 不重试
|
||||
return nil
|
||||
}
|
||||
|
||||
if payload.CarrierType == "" || payload.CarrierID == 0 {
|
||||
h.logger.Error("首次实名激活任务 carrier 信息缺失",
|
||||
zap.String("carrier_type", payload.CarrierType),
|
||||
zap.Uint("carrier_id", payload.CarrierID))
|
||||
return nil
|
||||
}
|
||||
|
||||
h.logger.Info("开始执行首次实名激活",
|
||||
zap.Uint("package_usage_id", payload.PackageUsageID),
|
||||
zap.String("carrier_type", payload.CarrierType),
|
||||
zap.Uint("carrier_id", payload.CarrierID))
|
||||
|
||||
// 任务 22.4: 幂等性检查
|
||||
var pkg model.PackageUsage
|
||||
if err := h.db.First(&pkg, payload.PackageUsageID).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
h.logger.Warn("套餐使用记录不存在", zap.Uint("package_usage_id", payload.PackageUsageID))
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// 检查 pending_realname_activation 是否已为 false(已处理过)
|
||||
if !pkg.PendingRealnameActivation {
|
||||
h.logger.Info("套餐已处理过首次实名激活,跳过",
|
||||
zap.Uint("package_usage_id", payload.PackageUsageID))
|
||||
if h.activationService == nil {
|
||||
h.logger.Warn("ActivationService 未注入,跳过首次实名激活")
|
||||
return nil
|
||||
}
|
||||
|
||||
// 如果已经是生效状态,跳过
|
||||
if pkg.Status == constants.PackageUsageStatusActive {
|
||||
h.logger.Info("套餐已激活,跳过",
|
||||
zap.Uint("package_usage_id", payload.PackageUsageID))
|
||||
return nil
|
||||
}
|
||||
|
||||
// 任务 22.3: 调用 ActivationService.ActivateByRealname 激活套餐
|
||||
if h.activationService != nil {
|
||||
if err := h.activationService.ActivateByRealname(ctx, payload.CarrierType, payload.CarrierID); err != nil {
|
||||
h.logger.Error("首次实名激活失败",
|
||||
zap.Uint("package_usage_id", payload.PackageUsageID),
|
||||
zap.String("carrier_type", payload.CarrierType),
|
||||
zap.Uint("carrier_id", payload.CarrierID),
|
||||
zap.Error(err))
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
// ActivationService 未注入,直接更新状态(备用逻辑)
|
||||
now := time.Now()
|
||||
if err := h.db.Model(&pkg).Updates(map[string]any{
|
||||
"status": constants.PackageUsageStatusActive,
|
||||
"activated_at": now,
|
||||
"pending_realname_activation": false,
|
||||
}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
h.logger.Info("首次实名激活成功",
|
||||
zap.Uint("package_usage_id", payload.PackageUsageID))
|
||||
zap.String("carrier_type", payload.CarrierType),
|
||||
zap.Uint("carrier_id", payload.CarrierID))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
159
internal/polling/queue_manager.go
Normal file
159
internal/polling/queue_manager.go
Normal file
@@ -0,0 +1,159 @@
|
||||
package polling
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
)
|
||||
|
||||
// allTaskTypes 轮询系统的全部任务类型(用于 RemoveFromAllQueues 遍历)
|
||||
var allTaskTypes = []string{
|
||||
constants.TaskTypePollingRealname,
|
||||
constants.TaskTypePollingCarddata,
|
||||
constants.TaskTypePollingPackage,
|
||||
constants.TaskTypePollingProtect,
|
||||
}
|
||||
|
||||
// dequeueScript Lua 脚本:原子出队(ZRANGEBYSCORE + ZREM 服务端原子执行)
|
||||
// 保留时间过滤语义:只取 score ≤ now 的到期卡,不触碰未来项
|
||||
// 分批 ZREM:Lua unpack() 受 LUAI_MAXCSTACK 约 8000 限制,按 7000 分批避免溢出
|
||||
var dequeueScript = redis.NewScript(`
|
||||
local results = redis.call('ZRANGEBYSCORE', KEYS[1], '-inf', ARGV[1], 'LIMIT', 0, tonumber(ARGV[2]))
|
||||
for i = 1, #results, 7000 do
|
||||
local j = math.min(i + 6999, #results)
|
||||
redis.call('ZREM', KEYS[1], unpack(results, i, j))
|
||||
end
|
||||
return results
|
||||
`)
|
||||
|
||||
// CardEntry 出队卡信息
|
||||
type CardEntry struct {
|
||||
CardID uint
|
||||
}
|
||||
|
||||
// PollingQueueManager 统一 Redis 轮询队列操作
|
||||
// 两个进程(API 进程和 Worker 进程)共享,仅依赖 Redis Client
|
||||
// 支持分片 Sorted Set,实现千万级规模
|
||||
type PollingQueueManager struct {
|
||||
redis *redis.Client
|
||||
shardCount int
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// NewPollingQueueManager 创建轮询队列管理器
|
||||
func NewPollingQueueManager(redisClient *redis.Client, shardCount int, logger *zap.Logger) *PollingQueueManager {
|
||||
if shardCount <= 0 {
|
||||
shardCount = constants.PollingShardCount
|
||||
}
|
||||
return &PollingQueueManager{
|
||||
redis: redisClient,
|
||||
shardCount: shardCount,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// DequeueReady 原子出队到期卡(Lua 脚本:ZRANGEBYSCORE + ZREM 服务端原子执行)
|
||||
// 只取 score ≤ now 的到期卡,不触碰未来项
|
||||
// taskType: realname | carddata | package | protect
|
||||
// shardID: 0 到 shardCount-1
|
||||
func (m *PollingQueueManager) DequeueReady(ctx context.Context, shardID int, taskType string, batchSize int) ([]CardEntry, error) {
|
||||
// 防御:batchSize 不超过 Lua unpack 栈限制
|
||||
if batchSize <= 0 || batchSize > constants.PollingDequeueMaxBatchSize {
|
||||
batchSize = constants.PollingDequeueMaxBatchSize
|
||||
}
|
||||
key := constants.RedisPollingShardQueueKey(shardID, taskType)
|
||||
now := time.Now().Unix()
|
||||
|
||||
results, err := dequeueScript.Run(ctx, m.redis, []string{key}, now, batchSize).StringSlice()
|
||||
if err != nil && err != redis.Nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
entries := make([]CardEntry, 0, len(results))
|
||||
for _, s := range results {
|
||||
id, parseErr := strconv.ParseUint(s, 10, 64)
|
||||
if parseErr != nil {
|
||||
m.logger.Warn("解析卡ID失败", zap.String("value", s), zap.Error(parseErr))
|
||||
continue
|
||||
}
|
||||
entries = append(entries, CardEntry{CardID: uint(id)})
|
||||
}
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
// Requeue 将卡重新入队(ZADD,score 为下次检查时间戳)
|
||||
func (m *PollingQueueManager) Requeue(ctx context.Context, cardID uint, taskType string, nextCheckAt time.Time) error {
|
||||
shardID := int(cardID) % m.shardCount
|
||||
key := constants.RedisPollingShardQueueKey(shardID, taskType)
|
||||
return m.redis.ZAdd(ctx, key, redis.Z{
|
||||
Score: float64(nextCheckAt.Unix()),
|
||||
Member: fmt.Sprintf("%d", cardID),
|
||||
}).Err()
|
||||
}
|
||||
|
||||
// RemoveFromAllQueues 从所有分片的所有4个队列(含protect)移除指定卡
|
||||
// 修复 Bug3:旧实现漏掉 protect 队列
|
||||
func (m *PollingQueueManager) RemoveFromAllQueues(ctx context.Context, cardID uint) error {
|
||||
member := fmt.Sprintf("%d", cardID)
|
||||
pipe := m.redis.Pipeline()
|
||||
for i := 0; i < m.shardCount; i++ {
|
||||
for _, taskType := range allTaskTypes {
|
||||
key := constants.RedisPollingShardQueueKey(i, taskType)
|
||||
pipe.ZRem(ctx, key, member)
|
||||
}
|
||||
}
|
||||
_, err := pipe.Exec(ctx)
|
||||
return err
|
||||
}
|
||||
|
||||
// EnqueueManual 手动触发入队(List RPUSH,调度器优先消费)
|
||||
func (m *PollingQueueManager) EnqueueManual(ctx context.Context, cardID uint, taskType string) error {
|
||||
key := constants.RedisPollingManualQueueKey(taskType)
|
||||
return m.redis.RPush(ctx, key, fmt.Sprintf("%d", cardID)).Err()
|
||||
}
|
||||
|
||||
// OnCardDeleted 卡删除事件处理(移除所有队列 + 清理卡信息缓存)
|
||||
func (m *PollingQueueManager) OnCardDeleted(ctx context.Context, cardID uint) error {
|
||||
// 从所有分片队列移除
|
||||
if err := m.RemoveFromAllQueues(ctx, cardID); err != nil {
|
||||
return err
|
||||
}
|
||||
// 清理轮询卡信息缓存
|
||||
cacheKey := constants.RedisPollingCardInfoKey(cardID)
|
||||
return m.redis.Del(ctx, cacheKey).Err()
|
||||
}
|
||||
|
||||
// GetQueueDepth 获取分片队列深度(用于背压检测)
|
||||
func (m *PollingQueueManager) GetQueueDepth(ctx context.Context, shardID int, taskType string) (int64, error) {
|
||||
key := constants.RedisPollingShardQueueKey(shardID, taskType)
|
||||
return m.redis.ZCard(ctx, key).Result()
|
||||
}
|
||||
|
||||
// GetTotalQueueDepth 获取指定任务类型的总队列深度(聚合所有分片)
|
||||
// 供 MonitoringService 使用,替代直接读取旧的非分片 Redis Key
|
||||
// 若任意分片查询失败,返回已累计的部分总量和第一个错误,调用方可据此判断数据完整性
|
||||
func (m *PollingQueueManager) GetTotalQueueDepth(ctx context.Context, taskType string) (int64, error) {
|
||||
var total int64
|
||||
var firstErr error
|
||||
for i := 0; i < m.shardCount; i++ {
|
||||
depth, err := m.GetQueueDepth(ctx, i, taskType)
|
||||
if err != nil {
|
||||
m.logger.Warn("获取分片队列深度失败",
|
||||
zap.Int("shard_id", i),
|
||||
zap.String("task_type", taskType),
|
||||
zap.Error(err))
|
||||
if firstErr == nil {
|
||||
firstErr = err
|
||||
}
|
||||
continue
|
||||
}
|
||||
total += depth
|
||||
}
|
||||
return total, firstErr
|
||||
}
|
||||
@@ -2,144 +2,81 @@ package polling
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/hibiken/asynq"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
packagepkg "github.com/break/junhong_cmp_fiber/internal/service/package"
|
||||
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
)
|
||||
|
||||
// InitProgress 已迁移到 initializer.go
|
||||
|
||||
// Scheduler 轮询调度器
|
||||
// 负责管理 IoT 卡的定期检查任务(实名、流量、套餐)
|
||||
// 职责:读取分片 Sorted Set 中到期的卡,生成 Asynq 任务
|
||||
// 不再负责:配置加载、卡初始化(分别由 PollingConfigManager、PollingInitializer 负责)
|
||||
type Scheduler struct {
|
||||
db *gorm.DB
|
||||
redis *redis.Client
|
||||
queueClient *asynq.Client
|
||||
logger *zap.Logger
|
||||
configStore *postgres.PollingConfigStore
|
||||
iotCardStore *postgres.IotCardStore
|
||||
concurrencyStore *postgres.PollingConcurrencyConfigStore
|
||||
queueMgr *PollingQueueManager
|
||||
configMgr *PollingConfigManager
|
||||
cfg *SchedulerConfig // 启动时固定,避免每次调度重新创建
|
||||
|
||||
// 任务 19: 套餐激活检查处理器
|
||||
packageActivationHandler *PackageActivationHandler
|
||||
// 任务 20: 流量重置调度处理器
|
||||
dataResetHandler *DataResetHandler
|
||||
|
||||
// 配置缓存
|
||||
configCache []*model.PollingConfig
|
||||
configCacheLock sync.RWMutex
|
||||
configCacheTime time.Time
|
||||
|
||||
// 初始化状态
|
||||
initProgress *InitProgress
|
||||
initCompleted atomic.Bool
|
||||
|
||||
// 控制信号
|
||||
stopChan chan struct{}
|
||||
wg sync.WaitGroup
|
||||
}
|
||||
|
||||
// InitProgress 初始化进度
|
||||
type InitProgress struct {
|
||||
mu sync.RWMutex
|
||||
TotalCards int64 `json:"total_cards"` // 总卡数
|
||||
LoadedCards int64 `json:"loaded_cards"` // 已加载卡数
|
||||
StartTime time.Time `json:"start_time"` // 开始时间
|
||||
LastBatchTime time.Time `json:"last_batch_time"` // 最后一批处理时间
|
||||
Status string `json:"status"` // 状态: pending, running, completed, failed
|
||||
ErrorMessage string `json:"error_message"` // 错误信息
|
||||
}
|
||||
|
||||
// SchedulerConfig 调度器配置
|
||||
// 设计目标:支持一亿张卡规模
|
||||
type SchedulerConfig struct {
|
||||
ScheduleInterval time.Duration // 调度循环间隔(默认 1 秒,支持高吞吐)
|
||||
InitBatchSize int // 初始化每批加载数量(默认 100000)
|
||||
InitBatchSleepDuration time.Duration // 初始化批次间休眠时间(默认 500ms)
|
||||
ConfigCacheTTL time.Duration // 配置缓存 TTL(默认 5 分钟)
|
||||
CardCacheTTL time.Duration // 卡信息缓存 TTL(默认 7 天)
|
||||
ScheduleBatchSize int // 每次调度取出的卡数(默认 50000)
|
||||
MaxManualBatchSize int // 手动触发每次处理数量(默认 1000)
|
||||
ScheduleInterval time.Duration
|
||||
MaxManualBatchSize int
|
||||
ScheduleBatchSize int
|
||||
}
|
||||
|
||||
// DefaultSchedulerConfig 默认调度器配置
|
||||
// 单 Worker 设计吞吐:50000 张/秒,支持多 Worker 水平扩展
|
||||
func DefaultSchedulerConfig() *SchedulerConfig {
|
||||
return &SchedulerConfig{
|
||||
ScheduleInterval: 1 * time.Second, // 1秒调度一次,提高响应速度
|
||||
InitBatchSize: 100000, // 10万张/批初始化
|
||||
InitBatchSleepDuration: 500 * time.Millisecond, // 500ms 间隔,加快初始化
|
||||
ConfigCacheTTL: 5 * time.Minute,
|
||||
CardCacheTTL: 7 * 24 * time.Hour,
|
||||
ScheduleBatchSize: 50000, // 每次取 5 万张,每秒可调度 5 万张
|
||||
MaxManualBatchSize: 1000, // 手动触发每次处理 1000 张
|
||||
ScheduleInterval: 1 * time.Second,
|
||||
MaxManualBatchSize: 1000,
|
||||
ScheduleBatchSize: constants.PollingDequeueMaxBatchSize,
|
||||
}
|
||||
}
|
||||
|
||||
// NewScheduler 创建调度器实例
|
||||
// NewScheduler 创建调度器
|
||||
func NewScheduler(
|
||||
db *gorm.DB,
|
||||
redisClient *redis.Client,
|
||||
queueClient *asynq.Client,
|
||||
queueMgr *PollingQueueManager,
|
||||
configMgr *PollingConfigManager,
|
||||
logger *zap.Logger,
|
||||
packageActivationHandler *PackageActivationHandler,
|
||||
dataResetHandler *DataResetHandler,
|
||||
) *Scheduler {
|
||||
return &Scheduler{
|
||||
db: db,
|
||||
redis: redisClient,
|
||||
queueClient: queueClient,
|
||||
queueMgr: queueMgr,
|
||||
configMgr: configMgr,
|
||||
logger: logger,
|
||||
configStore: postgres.NewPollingConfigStore(db),
|
||||
iotCardStore: postgres.NewIotCardStore(db, redisClient),
|
||||
concurrencyStore: postgres.NewPollingConcurrencyConfigStore(db),
|
||||
packageActivationHandler: NewPackageActivationHandler(db, redisClient, queueClient, nil, nil, logger),
|
||||
dataResetHandler: NewDataResetHandler(nil, logger), // ResetService 需要通过 SetResetService 注入
|
||||
initProgress: &InitProgress{
|
||||
Status: "pending",
|
||||
},
|
||||
cfg: DefaultSchedulerConfig(),
|
||||
stopChan: make(chan struct{}),
|
||||
packageActivationHandler: packageActivationHandler,
|
||||
dataResetHandler: dataResetHandler,
|
||||
}
|
||||
}
|
||||
|
||||
// Start 启动调度器
|
||||
// 快速启动:10秒内完成配置加载和调度器启动
|
||||
// Start 启动调度循环(快速启动,配置加载和初始化由外部完成)
|
||||
func (s *Scheduler) Start(ctx context.Context) error {
|
||||
startTime := time.Now()
|
||||
s.logger.Info("轮询调度器启动中...")
|
||||
|
||||
// 1. 加载轮询配置到缓存
|
||||
if err := s.loadConfigs(ctx); err != nil {
|
||||
s.logger.Error("加载轮询配置失败", zap.Error(err))
|
||||
return err
|
||||
}
|
||||
s.logger.Info("轮询配置已加载", zap.Int("config_count", len(s.configCache)))
|
||||
|
||||
// 2. 初始化并发控制配置
|
||||
if err := s.initConcurrencyConfigs(ctx); err != nil {
|
||||
s.logger.Warn("初始化并发控制配置失败,使用默认值", zap.Error(err))
|
||||
}
|
||||
|
||||
// 3. 启动调度循环(非阻塞)
|
||||
s.wg.Add(1)
|
||||
go s.scheduleLoop(ctx)
|
||||
|
||||
// 4. 启动后台渐进式初始化(非阻塞)
|
||||
s.wg.Add(1)
|
||||
go s.progressiveInit(ctx)
|
||||
|
||||
elapsed := time.Since(startTime)
|
||||
s.logger.Info("轮询调度器已启动",
|
||||
zap.Duration("startup_time", elapsed),
|
||||
zap.Bool("fast_start", elapsed < 10*time.Second))
|
||||
|
||||
s.logger.Info("轮询调度器已启动")
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -151,115 +88,116 @@ func (s *Scheduler) Stop() {
|
||||
s.logger.Info("轮询调度器已停止")
|
||||
}
|
||||
|
||||
// loadConfigs 加载轮询配置到缓存
|
||||
func (s *Scheduler) loadConfigs(ctx context.Context) error {
|
||||
configs, err := s.configStore.ListEnabled(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
s.configCacheLock.Lock()
|
||||
s.configCache = configs
|
||||
s.configCacheTime = time.Now()
|
||||
s.configCacheLock.Unlock()
|
||||
|
||||
// 同步到 Redis 缓存
|
||||
return s.syncConfigsToRedis(ctx, configs)
|
||||
}
|
||||
|
||||
// syncConfigsToRedis 同步配置到 Redis
|
||||
func (s *Scheduler) syncConfigsToRedis(ctx context.Context, configs []*model.PollingConfig) error {
|
||||
key := constants.RedisPollingConfigsCacheKey()
|
||||
|
||||
// 序列化配置列表为 JSON
|
||||
configData := make([]interface{}, 0, len(configs)*2)
|
||||
for _, cfg := range configs {
|
||||
jsonData, err := json.Marshal(cfg)
|
||||
if err != nil {
|
||||
s.logger.Warn("序列化轮询配置失败", zap.Uint("config_id", cfg.ID), zap.Error(err))
|
||||
continue
|
||||
}
|
||||
configData = append(configData, cfg.ID, string(jsonData))
|
||||
}
|
||||
|
||||
if len(configData) > 0 {
|
||||
pipe := s.redis.Pipeline()
|
||||
pipe.Del(ctx, key)
|
||||
// 使用 HSET 存储配置
|
||||
pipe.HSet(ctx, key, configData...)
|
||||
pipe.Expire(ctx, key, 24*time.Hour)
|
||||
_, err := pipe.Exec(ctx)
|
||||
return err
|
||||
}
|
||||
|
||||
// RefreshConfigs 刷新配置缓存
|
||||
func (s *Scheduler) RefreshConfigs(ctx context.Context) error {
|
||||
if s.configMgr == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// initConcurrencyConfigs 初始化并发控制配置到 Redis
|
||||
func (s *Scheduler) initConcurrencyConfigs(ctx context.Context) error {
|
||||
configs, err := s.concurrencyStore.List(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
return s.configMgr.Load(ctx)
|
||||
}
|
||||
|
||||
for _, cfg := range configs {
|
||||
key := constants.RedisPollingConcurrencyConfigKey(cfg.TaskType)
|
||||
if err := s.redis.Set(ctx, key, cfg.MaxConcurrency, 0).Err(); err != nil {
|
||||
s.logger.Warn("设置并发配置失败",
|
||||
zap.String("task_type", cfg.TaskType),
|
||||
zap.Error(err))
|
||||
// SetStopResumeCallback 注入停复机回调(在 Start 前调用)
|
||||
func (s *Scheduler) SetStopResumeCallback(callback packagepkg.StopResumeCallback) {
|
||||
if s.packageActivationHandler != nil {
|
||||
s.packageActivationHandler.stopResumeCallback = callback
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// scheduleLoop 调度循环
|
||||
// 每 10 秒执行一次,从 Redis Sorted Set 获取到期的卡,生成 Asynq 任务
|
||||
func (s *Scheduler) scheduleLoop(ctx context.Context) {
|
||||
defer s.wg.Done()
|
||||
|
||||
config := DefaultSchedulerConfig()
|
||||
ticker := time.NewTicker(config.ScheduleInterval)
|
||||
ticker := time.NewTicker(s.cfg.ScheduleInterval)
|
||||
activationTicker := time.NewTicker(10 * time.Second)
|
||||
defer ticker.Stop()
|
||||
defer activationTicker.Stop()
|
||||
|
||||
s.logger.Info("调度循环已启动", zap.Duration("interval", config.ScheduleInterval))
|
||||
s.logger.Info("调度循环已启动", zap.Duration("interval", s.cfg.ScheduleInterval))
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-s.stopChan:
|
||||
s.logger.Info("调度循环收到停止信号")
|
||||
return
|
||||
case <-ctx.Done():
|
||||
s.logger.Info("调度循环收到 ctx 取消信号")
|
||||
return
|
||||
case <-ticker.C:
|
||||
s.processSchedule(ctx)
|
||||
s.processShardSchedule(ctx)
|
||||
case <-activationTicker.C:
|
||||
s.processActivationTasks(ctx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// processSchedule 处理一次调度
|
||||
func (s *Scheduler) processSchedule(ctx context.Context) {
|
||||
now := time.Now().Unix()
|
||||
// processShardSchedule 处理手动队列和分片定时队列(每 1 秒触发)
|
||||
// 使用 90% 的 tick 间隔作为超时,确保单分片 Redis 挂起时不阻塞下一个 tick
|
||||
func (s *Scheduler) processShardSchedule(ctx context.Context) {
|
||||
for _, taskType := range allTaskTypes {
|
||||
s.processManualQueue(ctx, taskType, s.cfg.MaxManualBatchSize)
|
||||
}
|
||||
|
||||
// 1. 优先处理手动触发队列
|
||||
s.processManualQueue(ctx, constants.TaskTypePollingRealname)
|
||||
s.processManualQueue(ctx, constants.TaskTypePollingCarddata)
|
||||
s.processManualQueue(ctx, constants.TaskTypePollingPackage)
|
||||
s.processManualQueue(ctx, constants.TaskTypePollingProtect)
|
||||
if s.queueMgr == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// 2. 处理定时队列
|
||||
s.processTimedQueue(ctx, constants.RedisPollingQueueRealnameKey(), constants.TaskTypePollingRealname, now)
|
||||
s.processTimedQueue(ctx, constants.RedisPollingQueueCarddataKey(), constants.TaskTypePollingCarddata, now)
|
||||
s.processTimedQueue(ctx, constants.RedisPollingQueuePackageKey(), constants.TaskTypePollingPackage, now)
|
||||
s.processTimedQueue(ctx, constants.RedisPollingQueueProtectKey(), constants.TaskTypePollingProtect, now)
|
||||
timeout := s.cfg.ScheduleInterval * 9 / 10
|
||||
tickCtx, cancel := context.WithTimeout(ctx, timeout)
|
||||
defer cancel()
|
||||
|
||||
// 任务 19.6: 套餐激活检查(每次调度都执行,内部会限流)
|
||||
var wg sync.WaitGroup
|
||||
for shardID := 0; shardID < s.queueMgr.shardCount; shardID++ {
|
||||
wg.Add(1)
|
||||
go func(sid int) {
|
||||
defer wg.Done()
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
s.logger.Error("分片处理 panic,已恢复",
|
||||
zap.Int("shard_id", sid), zap.Any("panic", r))
|
||||
}
|
||||
}()
|
||||
s.processOneShard(tickCtx, sid)
|
||||
}(shardID)
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
// processOneShard 处理单个分片的所有任务类型出队并推入 Asynq
|
||||
func (s *Scheduler) processOneShard(ctx context.Context, shardID int) {
|
||||
for _, taskType := range allTaskTypes {
|
||||
depth, err := s.queueMgr.GetQueueDepth(ctx, shardID, taskType)
|
||||
if err != nil {
|
||||
s.logger.Warn("获取分片队列深度失败",
|
||||
zap.Int("shard_id", shardID), zap.String("task_type", taskType), zap.Error(err))
|
||||
} else if depth > constants.PollingBackpressureThreshold {
|
||||
s.logger.Debug("背压:分片队列积压过深,跳过本轮出队",
|
||||
zap.Int("shard_id", shardID), zap.String("task_type", taskType), zap.Int64("depth", depth))
|
||||
continue
|
||||
}
|
||||
|
||||
entries, err := s.queueMgr.DequeueReady(ctx, shardID, taskType, s.cfg.ScheduleBatchSize)
|
||||
if err != nil {
|
||||
s.logger.Error("分片出队失败",
|
||||
zap.Int("shard_id", shardID), zap.String("task_type", taskType), zap.Error(err))
|
||||
continue
|
||||
}
|
||||
if len(entries) > 0 {
|
||||
cardIDs := make([]string, len(entries))
|
||||
for i, e := range entries {
|
||||
cardIDs[i] = formatUint(e.CardID)
|
||||
}
|
||||
s.enqueueBatch(ctx, taskType, cardIDs)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// processActivationTasks 套餐激活检查和流量重置调度(每 10 秒触发)
|
||||
func (s *Scheduler) processActivationTasks(ctx context.Context) {
|
||||
if s.packageActivationHandler != nil {
|
||||
if err := s.packageActivationHandler.HandlePackageActivationCheck(ctx); err != nil {
|
||||
s.logger.Warn("套餐激活检查失败", zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
// 任务 20.6: 流量重置调度(每次调度都执行,内部会限流)
|
||||
if s.dataResetHandler != nil {
|
||||
if err := s.dataResetHandler.HandleDataReset(ctx); err != nil {
|
||||
s.logger.Warn("流量重置调度失败", zap.Error(err))
|
||||
@@ -268,497 +206,43 @@ func (s *Scheduler) processSchedule(ctx context.Context) {
|
||||
}
|
||||
|
||||
// processManualQueue 处理手动触发队列
|
||||
// 优化:批量读取和提交,提高吞吐
|
||||
func (s *Scheduler) processManualQueue(ctx context.Context, taskType string) {
|
||||
config := DefaultSchedulerConfig()
|
||||
func (s *Scheduler) processManualQueue(ctx context.Context, taskType string, maxBatch int) {
|
||||
key := constants.RedisPollingManualQueueKey(taskType)
|
||||
|
||||
// 批量读取手动触发任务
|
||||
cardIDs := make([]string, 0, config.MaxManualBatchSize)
|
||||
for i := 0; i < config.MaxManualBatchSize; i++ {
|
||||
cardIDStr, err := s.redis.LPop(ctx, key).Result()
|
||||
if err != nil {
|
||||
if err != redis.Nil {
|
||||
s.logger.Error("读取手动触发队列失败",
|
||||
zap.String("task_type", taskType),
|
||||
zap.Error(err))
|
||||
}
|
||||
break
|
||||
}
|
||||
cardIDs = append(cardIDs, cardIDStr)
|
||||
}
|
||||
|
||||
// 批量提交任务
|
||||
if len(cardIDs) > 0 {
|
||||
s.enqueueBatch(ctx, taskType, cardIDs, true)
|
||||
}
|
||||
}
|
||||
|
||||
// processTimedQueue 处理定时队列
|
||||
// 优化:支持大批量处理,每次最多取 ScheduleBatchSize 张卡
|
||||
func (s *Scheduler) processTimedQueue(ctx context.Context, queueKey, taskType string, now int64) {
|
||||
config := DefaultSchedulerConfig()
|
||||
|
||||
// 获取所有到期的卡(score <= now)
|
||||
// 使用 ZRANGEBYSCORE 获取,每次最多取 ScheduleBatchSize 张
|
||||
cardIDs, err := s.redis.ZRangeByScore(ctx, queueKey, &redis.ZRangeBy{
|
||||
Min: "-inf",
|
||||
Max: formatInt64(now),
|
||||
Count: int64(config.ScheduleBatchSize),
|
||||
}).Result()
|
||||
|
||||
if err != nil {
|
||||
if err != redis.Nil {
|
||||
s.logger.Error("读取定时队列失败",
|
||||
zap.String("queue_key", queueKey),
|
||||
zap.Error(err))
|
||||
}
|
||||
cardIDs, err := s.redis.LPopCount(ctx, key, maxBatch).Result()
|
||||
if err != nil || len(cardIDs) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
if len(cardIDs) == 0 {
|
||||
return
|
||||
s.enqueueBatch(ctx, taskType, cardIDs)
|
||||
}
|
||||
|
||||
// 只在数量较大时打印日志,避免日志过多
|
||||
if len(cardIDs) >= 1000 {
|
||||
s.logger.Info("处理定时队列",
|
||||
zap.String("task_type", taskType),
|
||||
zap.Int("card_count", len(cardIDs)))
|
||||
}
|
||||
|
||||
// 移除已取出的卡(使用最后一个卡的 score 作为边界,更精确)
|
||||
if err := s.redis.ZRemRangeByScore(ctx, queueKey, "-inf", formatInt64(now)).Err(); err != nil {
|
||||
s.logger.Error("移除已处理的卡失败", zap.Error(err))
|
||||
}
|
||||
|
||||
// 批量提交任务(使用 goroutine 并行提交,提高吞吐)
|
||||
s.enqueueBatch(ctx, taskType, cardIDs, false)
|
||||
}
|
||||
|
||||
// enqueueBatch 批量提交任务到 Asynq 队列
|
||||
// 使用多 goroutine 并行提交,提高吞吐量
|
||||
func (s *Scheduler) enqueueBatch(ctx context.Context, taskType string, cardIDs []string, isManual bool) {
|
||||
if len(cardIDs) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
// 分批并行提交,每批 1000 个,最多 10 个并行
|
||||
batchSize := 1000
|
||||
maxParallel := 10
|
||||
sem := make(chan struct{}, maxParallel)
|
||||
var wg sync.WaitGroup
|
||||
|
||||
for i := 0; i < len(cardIDs); i += batchSize {
|
||||
end := i + batchSize
|
||||
if end > len(cardIDs) {
|
||||
end = len(cardIDs)
|
||||
}
|
||||
batch := cardIDs[i:end]
|
||||
|
||||
wg.Add(1)
|
||||
sem <- struct{}{} // 获取信号量
|
||||
|
||||
go func(batch []string) {
|
||||
defer wg.Done()
|
||||
defer func() { <-sem }() // 释放信号量
|
||||
|
||||
for _, cardID := range batch {
|
||||
if err := s.enqueueTask(ctx, taskType, cardID, isManual); err != nil {
|
||||
s.logger.Warn("提交任务失败",
|
||||
zap.String("task_type", taskType),
|
||||
zap.String("card_id", cardID),
|
||||
zap.Error(err))
|
||||
}
|
||||
}
|
||||
}(batch)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
// enqueueTask 提交任务到 Asynq 队列
|
||||
func (s *Scheduler) enqueueTask(ctx context.Context, taskType, cardID string, isManual bool) error {
|
||||
// enqueueBatch 批量提交任务到 Asynq 队列;入队失败时回退至分片队列防止卡永久丢失
|
||||
func (s *Scheduler) enqueueBatch(ctx context.Context, taskType string, cardIDs []string) {
|
||||
for _, cardID := range cardIDs {
|
||||
payload := map[string]interface{}{
|
||||
"card_id": cardID,
|
||||
"is_manual": isManual,
|
||||
"is_manual": false,
|
||||
"timestamp": time.Now().Unix(),
|
||||
}
|
||||
|
||||
task := asynq.NewTask(taskType, mustMarshal(payload),
|
||||
asynq.MaxRetry(0), // 不重试,失败后重新入队
|
||||
asynq.Timeout(30*time.Second), // 30秒超时
|
||||
payloadBytes, marshalErr := marshalJSON(payload)
|
||||
if marshalErr != nil {
|
||||
s.logger.Error("序列化任务载荷失败,跳过该卡",
|
||||
zap.String("task_type", taskType), zap.String("card_id", cardID), zap.Error(marshalErr))
|
||||
continue
|
||||
}
|
||||
task := asynq.NewTask(taskType, payloadBytes,
|
||||
asynq.MaxRetry(0),
|
||||
asynq.Timeout(30*time.Second),
|
||||
asynq.Queue(constants.QueueDefault),
|
||||
)
|
||||
|
||||
_, err := s.queueClient.Enqueue(task)
|
||||
return err
|
||||
}
|
||||
|
||||
// progressiveInit 渐进式初始化
|
||||
// 分批加载卡数据到 Redis,每批 10 万张,sleep 1 秒
|
||||
func (s *Scheduler) progressiveInit(ctx context.Context) {
|
||||
defer s.wg.Done()
|
||||
|
||||
config := DefaultSchedulerConfig()
|
||||
|
||||
s.initProgress.mu.Lock()
|
||||
s.initProgress.Status = "running"
|
||||
s.initProgress.StartTime = time.Now()
|
||||
s.initProgress.mu.Unlock()
|
||||
|
||||
s.logger.Info("开始渐进式初始化...")
|
||||
|
||||
// 获取总卡数
|
||||
var totalCards int64
|
||||
if err := s.db.Model(&model.IotCard{}).Count(&totalCards).Error; err != nil {
|
||||
s.logger.Error("获取卡总数失败", zap.Error(err))
|
||||
s.setInitError(err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
s.initProgress.mu.Lock()
|
||||
s.initProgress.TotalCards = totalCards
|
||||
s.initProgress.mu.Unlock()
|
||||
|
||||
s.logger.Info("开始加载卡数据", zap.Int64("total_cards", totalCards))
|
||||
|
||||
// 使用游标分批加载
|
||||
var lastID uint = 0
|
||||
batchCount := 0
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-s.stopChan:
|
||||
s.logger.Info("渐进式初始化被中断")
|
||||
return
|
||||
default:
|
||||
}
|
||||
|
||||
// 加载一批卡
|
||||
var cards []*model.IotCard
|
||||
err := s.db.WithContext(ctx).
|
||||
Where("id > ?", lastID).
|
||||
Order("id ASC").
|
||||
Limit(config.InitBatchSize).
|
||||
Find(&cards).Error
|
||||
|
||||
if err != nil {
|
||||
s.logger.Error("加载卡数据失败", zap.Error(err))
|
||||
s.setInitError(err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if len(cards) == 0 {
|
||||
break
|
||||
}
|
||||
|
||||
// 批量处理这批卡(使用 Pipeline 提高性能)
|
||||
if err := s.initCardsBatch(ctx, cards); err != nil {
|
||||
s.logger.Warn("批量初始化卡轮询失败", zap.Error(err))
|
||||
}
|
||||
|
||||
lastID = cards[len(cards)-1].ID
|
||||
batchCount++
|
||||
|
||||
s.initProgress.mu.Lock()
|
||||
s.initProgress.LoadedCards += int64(len(cards))
|
||||
s.initProgress.LastBatchTime = time.Now()
|
||||
s.initProgress.mu.Unlock()
|
||||
|
||||
s.logger.Info("完成一批卡初始化",
|
||||
zap.Int("batch", batchCount),
|
||||
zap.Int("batch_size", len(cards)),
|
||||
zap.Int64("loaded", s.initProgress.LoadedCards),
|
||||
zap.Int64("total", totalCards))
|
||||
|
||||
// 批次间休眠,避免打爆数据库
|
||||
time.Sleep(config.InitBatchSleepDuration)
|
||||
}
|
||||
|
||||
s.initProgress.mu.Lock()
|
||||
s.initProgress.Status = "completed"
|
||||
s.initProgress.mu.Unlock()
|
||||
s.initCompleted.Store(true)
|
||||
|
||||
s.logger.Info("渐进式初始化完成",
|
||||
zap.Int64("total_loaded", s.initProgress.LoadedCards),
|
||||
zap.Duration("duration", time.Since(s.initProgress.StartTime)))
|
||||
}
|
||||
|
||||
// initCardsBatch 批量初始化卡的轮询
|
||||
// 使用 Redis Pipeline 批量写入,大幅提高初始化性能
|
||||
// 10万张卡从 ~60秒 优化到 ~5秒
|
||||
func (s *Scheduler) initCardsBatch(ctx context.Context, cards []*model.IotCard) error {
|
||||
if len(cards) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
config := DefaultSchedulerConfig()
|
||||
now := time.Now()
|
||||
pipe := s.redis.Pipeline()
|
||||
|
||||
for _, card := range cards {
|
||||
// 匹配配置
|
||||
cfg := s.MatchConfig(card)
|
||||
if cfg == nil {
|
||||
continue // 无匹配配置,不需要轮询
|
||||
}
|
||||
|
||||
// 添加到相应的轮询队列
|
||||
if cfg.RealnameCheckInterval != nil && *cfg.RealnameCheckInterval > 0 {
|
||||
nextCheck := s.calculateNextCheckTime(card.LastRealNameCheckAt, *cfg.RealnameCheckInterval)
|
||||
pipe.ZAdd(ctx, constants.RedisPollingQueueRealnameKey(), redis.Z{
|
||||
Score: float64(nextCheck.Unix()),
|
||||
Member: card.ID,
|
||||
})
|
||||
}
|
||||
|
||||
if cfg.CarddataCheckInterval != nil && *cfg.CarddataCheckInterval > 0 {
|
||||
nextCheck := s.calculateNextCheckTime(card.LastDataCheckAt, *cfg.CarddataCheckInterval)
|
||||
pipe.ZAdd(ctx, constants.RedisPollingQueueCarddataKey(), redis.Z{
|
||||
Score: float64(nextCheck.Unix()),
|
||||
Member: card.ID,
|
||||
})
|
||||
}
|
||||
|
||||
if cfg.PackageCheckInterval != nil && *cfg.PackageCheckInterval > 0 {
|
||||
nextCheck := s.calculateNextCheckTime(card.LastDataCheckAt, *cfg.PackageCheckInterval)
|
||||
pipe.ZAdd(ctx, constants.RedisPollingQueuePackageKey(), redis.Z{
|
||||
Score: float64(nextCheck.Unix()),
|
||||
Member: card.ID,
|
||||
})
|
||||
}
|
||||
|
||||
if cfg.ProtectCheckInterval != nil && *cfg.ProtectCheckInterval > 0 {
|
||||
nextCheck := s.calculateNextCheckTime(card.LastProtectCheckAt, *cfg.ProtectCheckInterval)
|
||||
pipe.ZAdd(ctx, constants.RedisPollingQueueProtectKey(), redis.Z{
|
||||
Score: float64(nextCheck.Unix()),
|
||||
Member: card.ID,
|
||||
})
|
||||
}
|
||||
|
||||
// 缓存卡信息到 Redis
|
||||
cacheKey := constants.RedisPollingCardInfoKey(card.ID)
|
||||
cacheData := map[string]interface{}{
|
||||
"id": card.ID,
|
||||
"iccid": card.ICCID,
|
||||
"card_category": card.CardCategory,
|
||||
"real_name_status": card.RealNameStatus,
|
||||
"network_status": card.NetworkStatus,
|
||||
"carrier_id": card.CarrierID,
|
||||
"stop_reason": card.StopReason,
|
||||
"cached_at": now.Unix(),
|
||||
}
|
||||
pipe.HSet(ctx, cacheKey, cacheData)
|
||||
pipe.Expire(ctx, cacheKey, config.CardCacheTTL)
|
||||
}
|
||||
|
||||
// 执行 Pipeline
|
||||
_, err := pipe.Exec(ctx)
|
||||
return err
|
||||
}
|
||||
|
||||
// initCardPolling 初始化单张卡的轮询(保留用于懒加载场景)
|
||||
func (s *Scheduler) initCardPolling(ctx context.Context, card *model.IotCard) error {
|
||||
// 匹配配置
|
||||
config := s.MatchConfig(card)
|
||||
if config == nil {
|
||||
return nil // 无匹配配置,不需要轮询
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
|
||||
// 添加到相应的轮询队列
|
||||
if config.RealnameCheckInterval != nil && *config.RealnameCheckInterval > 0 {
|
||||
nextCheck := s.calculateNextCheckTime(card.LastRealNameCheckAt, *config.RealnameCheckInterval)
|
||||
if err := s.addToQueue(ctx, constants.RedisPollingQueueRealnameKey(), card.ID, nextCheck); err != nil {
|
||||
return err
|
||||
if _, err := s.queueClient.Enqueue(task); err != nil {
|
||||
s.logger.Error("提交任务失败,回退至分片队列防止卡永久丢失",
|
||||
zap.String("task_type", taskType), zap.String("card_id", cardID), zap.Error(err))
|
||||
if id, parseErr := parseUint(cardID); parseErr == nil {
|
||||
if reqErr := s.queueMgr.Requeue(ctx, id, taskType, time.Now()); reqErr != nil {
|
||||
s.logger.Error("回退入队失败,卡可能永久丢失",
|
||||
zap.String("card_id", cardID), zap.Error(reqErr))
|
||||
}
|
||||
}
|
||||
|
||||
if config.CarddataCheckInterval != nil && *config.CarddataCheckInterval > 0 {
|
||||
nextCheck := s.calculateNextCheckTime(card.LastDataCheckAt, *config.CarddataCheckInterval)
|
||||
if err := s.addToQueue(ctx, constants.RedisPollingQueueCarddataKey(), card.ID, nextCheck); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if config.PackageCheckInterval != nil && *config.PackageCheckInterval > 0 {
|
||||
// 套餐检查使用流量检查时间作为参考
|
||||
nextCheck := s.calculateNextCheckTime(card.LastDataCheckAt, *config.PackageCheckInterval)
|
||||
if err := s.addToQueue(ctx, constants.RedisPollingQueuePackageKey(), card.ID, nextCheck); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if config.ProtectCheckInterval != nil && *config.ProtectCheckInterval > 0 {
|
||||
nextCheck := s.calculateNextCheckTime(card.LastProtectCheckAt, *config.ProtectCheckInterval)
|
||||
if err := s.addToQueue(ctx, constants.RedisPollingQueueProtectKey(), card.ID, nextCheck); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// 缓存卡信息到 Redis
|
||||
return s.cacheCardInfo(ctx, card, now)
|
||||
}
|
||||
|
||||
// MatchConfig 匹配轮询配置
|
||||
// 按优先级返回第一个匹配的配置
|
||||
func (s *Scheduler) MatchConfig(card *model.IotCard) *model.PollingConfig {
|
||||
s.configCacheLock.RLock()
|
||||
defer s.configCacheLock.RUnlock()
|
||||
|
||||
for _, cfg := range s.configCache {
|
||||
if s.matchConfigConditions(cfg, card) {
|
||||
return cfg
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// matchConfigConditions 检查卡是否匹配配置条件
|
||||
func (s *Scheduler) matchConfigConditions(cfg *model.PollingConfig, card *model.IotCard) bool {
|
||||
// 检查卡状态条件
|
||||
if cfg.CardCondition != "" {
|
||||
cardCondition := s.getCardCondition(card)
|
||||
if cfg.CardCondition != cardCondition {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// 检查卡业务类型
|
||||
if cfg.CardCategory != "" {
|
||||
if cfg.CardCategory != card.CardCategory {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// 检查运营商
|
||||
if cfg.CarrierID != nil {
|
||||
if *cfg.CarrierID != card.CarrierID {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// getCardCondition 获取卡的状态条件
|
||||
func (s *Scheduler) getCardCondition(card *model.IotCard) string {
|
||||
if card.RealNameStatus != constants.RealNameStatusVerified {
|
||||
return "not_real_name"
|
||||
}
|
||||
if card.NetworkStatus == 1 {
|
||||
return "activated"
|
||||
}
|
||||
return "real_name"
|
||||
}
|
||||
|
||||
// calculateNextCheckTime 计算下次检查时间
|
||||
func (s *Scheduler) calculateNextCheckTime(lastCheckAt *time.Time, intervalSeconds int) time.Time {
|
||||
now := time.Now()
|
||||
|
||||
if lastCheckAt == nil {
|
||||
// 首次检查,立即执行(加上随机抖动避免集中)
|
||||
jitter := time.Duration(now.UnixNano()%int64(intervalSeconds)) * time.Second / 10
|
||||
return now.Add(jitter)
|
||||
}
|
||||
|
||||
// 计算下次检查时间
|
||||
nextCheck := lastCheckAt.Add(time.Duration(intervalSeconds) * time.Second)
|
||||
if nextCheck.Before(now) {
|
||||
// 如果已过期,立即执行
|
||||
return now
|
||||
}
|
||||
|
||||
return nextCheck
|
||||
}
|
||||
|
||||
// addToQueue 添加卡到轮询队列
|
||||
func (s *Scheduler) addToQueue(ctx context.Context, queueKey string, cardID uint, nextCheck time.Time) error {
|
||||
score := float64(nextCheck.Unix())
|
||||
member := formatUint(cardID)
|
||||
|
||||
return s.redis.ZAdd(ctx, queueKey, redis.Z{
|
||||
Score: score,
|
||||
Member: member,
|
||||
}).Err()
|
||||
}
|
||||
|
||||
// cacheCardInfo 缓存卡信息到 Redis
|
||||
func (s *Scheduler) cacheCardInfo(ctx context.Context, card *model.IotCard, cachedAt time.Time) error {
|
||||
key := constants.RedisPollingCardInfoKey(card.ID)
|
||||
config := DefaultSchedulerConfig()
|
||||
|
||||
data := map[string]interface{}{
|
||||
"id": card.ID,
|
||||
"iccid": card.ICCID,
|
||||
"card_category": card.CardCategory,
|
||||
"real_name_status": card.RealNameStatus,
|
||||
"network_status": card.NetworkStatus,
|
||||
"carrier_id": card.CarrierID,
|
||||
"stop_reason": card.StopReason,
|
||||
"cached_at": cachedAt.Unix(),
|
||||
}
|
||||
|
||||
pipe := s.redis.Pipeline()
|
||||
pipe.HSet(ctx, key, data)
|
||||
pipe.Expire(ctx, key, config.CardCacheTTL)
|
||||
_, err := pipe.Exec(ctx)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// setInitError 设置初始化错误
|
||||
func (s *Scheduler) setInitError(msg string) {
|
||||
s.initProgress.mu.Lock()
|
||||
s.initProgress.Status = "failed"
|
||||
s.initProgress.ErrorMessage = msg
|
||||
s.initProgress.mu.Unlock()
|
||||
}
|
||||
|
||||
// GetInitProgress 获取初始化进度
|
||||
func (s *Scheduler) GetInitProgress() InitProgress {
|
||||
s.initProgress.mu.RLock()
|
||||
defer s.initProgress.mu.RUnlock()
|
||||
|
||||
return InitProgress{
|
||||
TotalCards: s.initProgress.TotalCards,
|
||||
LoadedCards: s.initProgress.LoadedCards,
|
||||
StartTime: s.initProgress.StartTime,
|
||||
LastBatchTime: s.initProgress.LastBatchTime,
|
||||
Status: s.initProgress.Status,
|
||||
ErrorMessage: s.initProgress.ErrorMessage,
|
||||
}
|
||||
}
|
||||
|
||||
// IsInitCompleted 检查初始化是否完成
|
||||
func (s *Scheduler) IsInitCompleted() bool {
|
||||
return s.initCompleted.Load()
|
||||
}
|
||||
|
||||
// RefreshConfigs 刷新配置缓存
|
||||
func (s *Scheduler) RefreshConfigs(ctx context.Context) error {
|
||||
return s.loadConfigs(ctx)
|
||||
}
|
||||
|
||||
// SetResetService 设置流量重置服务(用于依赖注入)
|
||||
func (s *Scheduler) SetResetService(resetService interface{}) {
|
||||
if rs, ok := resetService.(*DataResetHandler); ok {
|
||||
s.dataResetHandler = rs
|
||||
}
|
||||
}
|
||||
|
||||
// SetActivationService 设置套餐激活服务(用于依赖注入)
|
||||
func (s *Scheduler) SetActivationService(activationHandler *PackageActivationHandler) {
|
||||
s.packageActivationHandler = activationHandler
|
||||
}
|
||||
|
||||
// SetStopResumeCallback 注入停复机回调(用于套餐过期后主动触发停机)
|
||||
func (s *Scheduler) SetStopResumeCallback(callback packagepkg.StopResumeCallback) {
|
||||
if s.packageActivationHandler != nil {
|
||||
s.packageActivationHandler.stopResumeCallback = callback
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,23 +6,13 @@ import (
|
||||
"github.com/bytedance/sonic"
|
||||
)
|
||||
|
||||
// formatInt64 将 int64 转换为字符串
|
||||
func formatInt64(n int64) string {
|
||||
return strconv.FormatInt(n, 10)
|
||||
}
|
||||
|
||||
// formatUint 将 uint 转换为字符串
|
||||
func formatUint(n uint) string {
|
||||
return strconv.FormatUint(uint64(n), 10)
|
||||
}
|
||||
|
||||
// mustMarshal 序列化为 JSON,失败时 panic
|
||||
func mustMarshal(v interface{}) []byte {
|
||||
data, err := sonic.Marshal(v)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return data
|
||||
func marshalJSON(v interface{}) ([]byte, error) {
|
||||
return sonic.Marshal(v)
|
||||
}
|
||||
|
||||
// parseUint 将字符串解析为 uint
|
||||
@@ -33,3 +23,11 @@ func parseUint(s string) (uint, error) {
|
||||
}
|
||||
return uint(n), nil
|
||||
}
|
||||
|
||||
// boolToStr 布尔值转字符串(Redis Hash 存储用)
|
||||
func boolToStr(b bool) string {
|
||||
if b {
|
||||
return "1"
|
||||
}
|
||||
return "0"
|
||||
}
|
||||
|
||||
@@ -109,4 +109,13 @@ func registerAssetRoutes(router fiber.Router, handler *admin.AssetHandler, walle
|
||||
Output: new(dto.AssetWalletTransactionListResponse),
|
||||
Auth: true,
|
||||
})
|
||||
|
||||
Register(assets, doc, groupPath, "PATCH", "/:asset_type/:id/polling-status", handler.UpdatePollingStatus, RouteSpec{
|
||||
Summary: "更新资产轮询状态",
|
||||
Description: "启用或禁用指定资产(IoT卡或设备)的定期状态轮询。asset_type 为 iot_card 或 device。",
|
||||
Tags: []string{"资产管理"},
|
||||
Input: new(dto.UpdateAssetPollingStatusRequest),
|
||||
Output: new(dto.UpdateAssetPollingStatusResponse),
|
||||
Auth: true,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -17,12 +17,22 @@ import (
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
)
|
||||
|
||||
// StopResumeServiceInterface 停复机服务接口
|
||||
// 供 4 个 Task Handler 通过接口注入,避免循环依赖
|
||||
type StopResumeServiceInterface interface {
|
||||
// EvaluateAndAct 停复机统一入口,根据卡的当前状态自动判断并执行停机或复机
|
||||
EvaluateAndAct(ctx context.Context, card *model.IotCard) error
|
||||
}
|
||||
|
||||
// 编译时验证 StopResumeService 实现了 StopResumeServiceInterface
|
||||
var _ StopResumeServiceInterface = (*StopResumeService)(nil)
|
||||
|
||||
// StopResumeService 停复机服务
|
||||
// 处理 IoT 卡的自动停机、复机和手动停复机逻辑
|
||||
type StopResumeService struct {
|
||||
db *gorm.DB
|
||||
redis *redis.Client
|
||||
iotCardStore *postgres.IotCardStore
|
||||
packageUsageStore *postgres.PackageUsageStore
|
||||
deviceSimBindingStore *postgres.DeviceSimBindingStore
|
||||
gatewayClient *gateway.Client
|
||||
logger *zap.Logger
|
||||
@@ -33,17 +43,17 @@ type StopResumeService struct {
|
||||
|
||||
// NewStopResumeService 创建停复机服务
|
||||
func NewStopResumeService(
|
||||
db *gorm.DB,
|
||||
redis *redis.Client,
|
||||
iotCardStore *postgres.IotCardStore,
|
||||
packageUsageStore *postgres.PackageUsageStore,
|
||||
deviceSimBindingStore *postgres.DeviceSimBindingStore,
|
||||
gatewayClient *gateway.Client,
|
||||
logger *zap.Logger,
|
||||
) *StopResumeService {
|
||||
return &StopResumeService{
|
||||
db: db,
|
||||
redis: redis,
|
||||
iotCardStore: iotCardStore,
|
||||
packageUsageStore: packageUsageStore,
|
||||
deviceSimBindingStore: deviceSimBindingStore,
|
||||
gatewayClient: gatewayClient,
|
||||
logger: logger,
|
||||
@@ -52,79 +62,297 @@ func NewStopResumeService(
|
||||
}
|
||||
}
|
||||
|
||||
// CheckAndStopCard 任务 24.3: 检查流量耗尽并停机
|
||||
// 当所有套餐流量用完时,调用运营商接口停机
|
||||
// EvaluateAndAct 停复机统一入口
|
||||
// 根据卡的当前网络状态,自动判断并执行停机或复机操作
|
||||
// 设备/单卡维度通过 card.IsStandalone 推导(false 则为设备维度,停/复机覆盖设备下所有卡)
|
||||
func (s *StopResumeService) EvaluateAndAct(ctx context.Context, card *model.IotCard) error {
|
||||
switch card.NetworkStatus {
|
||||
case constants.NetworkStatusOnline:
|
||||
// 卡在线,检查是否需要停机
|
||||
reasons, err := s.checkStopReasons(ctx, card)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(reasons) == 0 {
|
||||
return nil
|
||||
}
|
||||
// 取最高优先级原因(第一个)
|
||||
primaryReason := reasons[0]
|
||||
if !card.IsStandalone {
|
||||
deviceID, bound, lookupErr := s.getCardDeviceID(ctx, card)
|
||||
if lookupErr != nil {
|
||||
s.logger.Error("查询设备绑定关系失败,降级为单卡停机",
|
||||
zap.Uint("card_id", card.ID), zap.Error(lookupErr))
|
||||
// 降级:查不到绑定时按单卡处理
|
||||
} else if bound {
|
||||
s.stopDeviceCards(ctx, deviceID, primaryReason)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return s.stopCardWithRetry(ctx, card, primaryReason)
|
||||
|
||||
case constants.NetworkStatusOffline:
|
||||
// 卡停机,检查是否可以复机
|
||||
// 只处理轮询系统引起的停机原因(手动停机等不自动复机)
|
||||
if !isPollingStopReason(card.StopReason) {
|
||||
return nil
|
||||
}
|
||||
shouldResume, err := s.shouldResume(ctx, card)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !shouldResume {
|
||||
return nil
|
||||
}
|
||||
if !card.IsStandalone {
|
||||
deviceID, bound, lookupErr := s.getCardDeviceID(ctx, card)
|
||||
if lookupErr != nil {
|
||||
s.logger.Error("查询设备绑定关系失败,降级为单卡复机",
|
||||
zap.Uint("card_id", card.ID), zap.Error(lookupErr))
|
||||
} else if bound {
|
||||
s.resumeDeviceCards(ctx, deviceID)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return s.resumeSingleCard(ctx, card.ID)
|
||||
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// isPollingStopReason 判断停机原因是否为轮询系统引起(可自动复机)
|
||||
func isPollingStopReason(reason string) bool {
|
||||
switch reason {
|
||||
case constants.StopReasonTrafficExhausted,
|
||||
constants.StopReasonNoPackage,
|
||||
constants.StopReasonNotRealname:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// getCardDeviceID 获取卡所属设备ID(非独立卡时有效)
|
||||
// 返回:deviceID, isDeviceBound, error
|
||||
func (s *StopResumeService) getCardDeviceID(ctx context.Context, card *model.IotCard) (uint, bool, error) {
|
||||
if card.IsStandalone {
|
||||
return 0, false, nil
|
||||
}
|
||||
binding, err := s.deviceSimBindingStore.GetActiveBindingByCardID(ctx, card.ID)
|
||||
if err != nil {
|
||||
if stderrors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return 0, false, nil
|
||||
}
|
||||
return 0, false, err
|
||||
}
|
||||
if binding == nil {
|
||||
return 0, false, nil
|
||||
}
|
||||
return binding.DeviceID, true, nil
|
||||
}
|
||||
|
||||
// hasValidPackage 检查卡是否有有效套餐(status IN 0,1)
|
||||
// 修复Bug1:绑定设备的卡查 device_id,独立卡查 iot_card_id
|
||||
func (s *StopResumeService) hasValidPackage(ctx context.Context, card *model.IotCard) (bool, error) {
|
||||
if !card.IsStandalone {
|
||||
deviceID, bound, lookupErr := s.getCardDeviceID(ctx, card)
|
||||
if lookupErr != nil {
|
||||
return false, lookupErr
|
||||
}
|
||||
if bound {
|
||||
return s.packageUsageStore.HasValidByCarrier(ctx, "device", deviceID)
|
||||
}
|
||||
}
|
||||
return s.packageUsageStore.HasValidByCarrier(ctx, "iot_card", card.ID)
|
||||
}
|
||||
|
||||
// isTrafficExhausted 检查卡的流量是否已耗尽
|
||||
// 条件:活跃套餐 status=2(已用完),或 data_usage_mb >= data_limit_mb(data_limit_mb > 0)
|
||||
// 业务约定:data_limit_mb=0 表示无限流量套餐,永远不判定为耗尽
|
||||
func (s *StopResumeService) isTrafficExhausted(ctx context.Context, card *model.IotCard) (bool, error) {
|
||||
carrierType := "iot_card"
|
||||
carrierID := card.ID
|
||||
|
||||
if !card.IsStandalone {
|
||||
deviceID, bound, lookupErr := s.getCardDeviceID(ctx, card)
|
||||
if lookupErr != nil {
|
||||
return false, lookupErr
|
||||
}
|
||||
if bound {
|
||||
carrierType = "device"
|
||||
carrierID = deviceID
|
||||
}
|
||||
}
|
||||
|
||||
pkg, err := s.packageUsageStore.GetActiveOrDepletedByCarrier(ctx, carrierType, carrierID)
|
||||
if err != nil {
|
||||
if stderrors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return false, nil
|
||||
}
|
||||
return false, err
|
||||
}
|
||||
|
||||
if pkg.Status == constants.PackageUsageStatusDepleted {
|
||||
return true, nil
|
||||
}
|
||||
return pkg.DataLimitMB > 0 && pkg.DataUsageMB >= pkg.DataLimitMB, nil
|
||||
}
|
||||
|
||||
// isRealnameOK 检查卡是否满足实名要求
|
||||
// 行业卡(card_category='industry')无需实名;其他卡需 real_name_status=1
|
||||
func (s *StopResumeService) isRealnameOK(card *model.IotCard) bool {
|
||||
return card.CardCategory == constants.CardCategoryIndustry ||
|
||||
card.RealNameStatus == constants.RealNameStatusVerified
|
||||
}
|
||||
|
||||
// checkStopReasons 检查卡的停机原因列表,按优先级排序
|
||||
// 优先级:no_package > traffic_exhausted > not_realname
|
||||
func (s *StopResumeService) checkStopReasons(ctx context.Context, card *model.IotCard) ([]string, error) {
|
||||
var reasons []string
|
||||
|
||||
// 条件A:无有效套餐(优先级最高)
|
||||
hasPackage, err := s.hasValidPackage(ctx, card)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !hasPackage {
|
||||
reasons = append(reasons, constants.StopReasonNoPackage)
|
||||
}
|
||||
|
||||
// 条件B:虚流量耗尽
|
||||
exhausted, err := s.isTrafficExhausted(ctx, card)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if exhausted {
|
||||
reasons = append(reasons, constants.StopReasonTrafficExhausted)
|
||||
}
|
||||
|
||||
// 条件C:非行业卡且未实名
|
||||
if !s.isRealnameOK(card) {
|
||||
reasons = append(reasons, constants.StopReasonNotRealname)
|
||||
}
|
||||
|
||||
return reasons, nil
|
||||
}
|
||||
|
||||
// shouldResume 检查停机卡是否满足复机条件
|
||||
// 须同时满足:有有效套餐 + 流量未耗尽 + 实名OK
|
||||
func (s *StopResumeService) shouldResume(ctx context.Context, card *model.IotCard) (bool, error) {
|
||||
hasPackage, err := s.hasValidPackage(ctx, card)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if !hasPackage {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
exhausted, err := s.isTrafficExhausted(ctx, card)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if exhausted {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
return s.isRealnameOK(card), nil
|
||||
}
|
||||
|
||||
// stopDeviceCards 停机设备下所有在线卡(含设备维度幂等锁)
|
||||
// 防止设备下多张卡并发触发 EvaluateAndAct 导致重复 Gateway 停机调用
|
||||
func (s *StopResumeService) stopDeviceCards(ctx context.Context, deviceID uint, stopReason string) {
|
||||
// 设备维度幂等锁:防止多协程同时对同一设备停机
|
||||
lockKey := constants.RedisPollingDeviceOpLockKey(deviceID)
|
||||
locked, _ := s.redis.SetNX(ctx, lockKey, "1", 30*time.Second).Result()
|
||||
if !locked {
|
||||
s.logger.Debug("设备停机操作已在进行中,跳过重复调用",
|
||||
zap.Uint("device_id", deviceID))
|
||||
return
|
||||
}
|
||||
defer s.redis.Del(ctx, lockKey)
|
||||
|
||||
// 查询设备下所有在线卡
|
||||
cards, err := s.iotCardStore.ListByDeviceIDAndNetworkStatus(ctx, deviceID, constants.NetworkStatusOnline)
|
||||
if err != nil {
|
||||
s.logger.Error("查询设备在线卡失败",
|
||||
zap.Uint("device_id", deviceID), zap.Error(err))
|
||||
return
|
||||
}
|
||||
|
||||
for _, card := range cards {
|
||||
if stopErr := s.stopCardWithRetry(ctx, card, stopReason); stopErr != nil {
|
||||
s.logger.Warn("设备卡停机失败,继续处理其他卡",
|
||||
zap.Uint("device_id", deviceID),
|
||||
zap.Uint("card_id", card.ID),
|
||||
zap.Error(stopErr))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// resumeDeviceCards 复机设备下满足条件的停机卡(含设备维度幂等锁)
|
||||
// 遍历时对每张卡检查实名状态:未实名普通卡更新 stop_reason='not_realname' 后跳过
|
||||
func (s *StopResumeService) resumeDeviceCards(ctx context.Context, deviceID uint) {
|
||||
// 设备维度幂等锁:与 stopDeviceCards 共用,防止停/复机并发
|
||||
lockKey := constants.RedisPollingDeviceOpLockKey(deviceID)
|
||||
locked, _ := s.redis.SetNX(ctx, lockKey, "1", 30*time.Second).Result()
|
||||
if !locked {
|
||||
s.logger.Debug("设备复机操作已在进行中,跳过重复调用",
|
||||
zap.Uint("device_id", deviceID))
|
||||
return
|
||||
}
|
||||
defer s.redis.Del(ctx, lockKey)
|
||||
|
||||
// 查询设备下因轮询原因停机的卡
|
||||
cards, err := s.iotCardStore.ListByDeviceIDAndPollingStopReasons(ctx, deviceID)
|
||||
if err != nil {
|
||||
s.logger.Error("查询设备停机卡失败",
|
||||
zap.Uint("device_id", deviceID), zap.Error(err))
|
||||
return
|
||||
}
|
||||
|
||||
for _, card := range cards {
|
||||
if !s.isRealnameOK(card) {
|
||||
if updateErr := s.iotCardStore.UpdateStopReason(ctx, card.ID, constants.StopReasonNotRealname); updateErr != nil {
|
||||
s.logger.Warn("更新未实名卡停机原因失败",
|
||||
zap.Uint("card_id", card.ID), zap.Error(updateErr))
|
||||
}
|
||||
continue
|
||||
}
|
||||
if resumeErr := s.resumeSingleCard(ctx, card.ID); resumeErr != nil {
|
||||
s.logger.Warn("设备卡复机失败,继续处理其他卡",
|
||||
zap.Uint("device_id", deviceID),
|
||||
zap.Uint("card_id", card.ID),
|
||||
zap.Error(resumeErr))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// CheckAndStopCard 检查流量耗尽并停机(旧入口,保留兼容性)
|
||||
// 新代码应优先使用 EvaluateAndAct
|
||||
func (s *StopResumeService) CheckAndStopCard(ctx context.Context, cardID uint) error {
|
||||
// 查询卡信息
|
||||
card, err := s.iotCardStore.GetByID(ctx, cardID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 如果已经是停机状态,跳过
|
||||
if card.NetworkStatus == constants.NetworkStatusOffline {
|
||||
s.logger.Debug("卡已处于停机状态,跳过",
|
||||
zap.Uint("card_id", cardID))
|
||||
return nil
|
||||
}
|
||||
|
||||
// 检查是否有可用套餐(status=1 生效中 或 status=0 待生效)
|
||||
hasAvailablePackage, err := s.hasAvailablePackage(ctx, cardID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 如果还有可用套餐,不停机
|
||||
if hasAvailablePackage {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 任务 24.5: 调用运营商停机接口(带重试机制)
|
||||
if err := s.stopCardWithRetry(ctx, card); err != nil {
|
||||
s.logger.Error("调用运营商停机接口失败",
|
||||
zap.Uint("card_id", cardID),
|
||||
zap.String("iccid", card.ICCID),
|
||||
zap.Error(err))
|
||||
return err
|
||||
}
|
||||
|
||||
// 更新卡状态
|
||||
now := time.Now()
|
||||
if err := s.db.WithContext(ctx).Model(card).Updates(map[string]any{
|
||||
"network_status": constants.NetworkStatusOffline,
|
||||
"stopped_at": now,
|
||||
"stop_reason": constants.StopReasonTrafficExhausted,
|
||||
}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 失效轮询缓存,确保下次轮询从 DB 读取最新状态
|
||||
s.invalidatePollingCache(cardID)
|
||||
|
||||
s.logger.Info("卡因流量耗尽已停机",
|
||||
zap.Uint("card_id", cardID),
|
||||
zap.String("iccid", card.ICCID))
|
||||
|
||||
return nil
|
||||
return s.EvaluateAndAct(ctx, card)
|
||||
}
|
||||
|
||||
// ResumeCardIfStopped 套餐激活或流量重置后自动复机入口
|
||||
// 支持 iot_card 和 device 两种载体类型:
|
||||
// - iot_card:对单张卡执行实名检查 → 停机原因检查 → 分布式锁 → Gateway → 更新 DB
|
||||
// - device:查询设备下所有已绑定卡,逐一执行单卡复机逻辑
|
||||
// 支持 iot_card 和 device 两种载体类型
|
||||
func (s *StopResumeService) ResumeCardIfStopped(ctx context.Context, carrierType string, carrierID uint) error {
|
||||
switch carrierType {
|
||||
case "iot_card":
|
||||
return s.resumeSingleCard(ctx, carrierID)
|
||||
case "device":
|
||||
return s.resumeDeviceCards(ctx, carrierID)
|
||||
s.resumeDeviceCards(ctx, carrierID)
|
||||
return nil
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// resumeSingleCard 对单张卡执行复机逻辑
|
||||
// 依次检查:已开机则跳过 → 未实名则跳过(WARN)→ 非流量耗尽停机则跳过 → 加锁 → 调 Gateway → 更新 DB
|
||||
// 依次检查:已开机则跳过 → 非轮询停机原因则跳过 → 不满足复机条件则跳过 → 加锁 → 调 Gateway → 更新 DB
|
||||
func (s *StopResumeService) resumeSingleCard(ctx context.Context, cardID uint) error {
|
||||
card, err := s.iotCardStore.GetByID(ctx, cardID)
|
||||
if err != nil {
|
||||
@@ -135,17 +363,20 @@ func (s *StopResumeService) resumeSingleCard(ctx context.Context, cardID uint) e
|
||||
return nil
|
||||
}
|
||||
|
||||
if card.RealNameStatus != constants.RealNameStatusVerified {
|
||||
s.logger.Warn("未实名卡跳过自动复机",
|
||||
// 只处理轮询系统引起的停机
|
||||
if !isPollingStopReason(card.StopReason) {
|
||||
s.logger.Debug("卡非轮询停机原因,不自动复机",
|
||||
zap.Uint("card_id", cardID),
|
||||
zap.String("iccid", card.ICCID))
|
||||
zap.String("stop_reason", card.StopReason))
|
||||
return nil
|
||||
}
|
||||
|
||||
if card.StopReason != constants.StopReasonTrafficExhausted {
|
||||
s.logger.Debug("卡非流量耗尽停机,不自动复机",
|
||||
zap.Uint("card_id", cardID),
|
||||
zap.String("stop_reason", card.StopReason))
|
||||
// 检查复机条件
|
||||
resume, err := s.shouldResume(ctx, card)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !resume {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -173,12 +404,11 @@ func (s *StopResumeService) resumeSingleCard(ctx context.Context, cardID uint) e
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
if err := s.db.WithContext(ctx).Model(card).Updates(map[string]any{
|
||||
if err := s.iotCardStore.UpdateFields(ctx, cardID, map[string]any{
|
||||
"network_status": constants.NetworkStatusOnline,
|
||||
"resumed_at": now,
|
||||
"stop_reason": "",
|
||||
}).Error; err != nil {
|
||||
// Gateway 成功但 DB 更新失败:记录 ERROR 供人工排查,不阻断流程
|
||||
}); err != nil {
|
||||
s.logger.Error("复机 Gateway 成功但 DB 更新失败",
|
||||
zap.Uint("card_id", cardID),
|
||||
zap.String("iccid", card.ICCID),
|
||||
@@ -186,7 +416,7 @@ func (s *StopResumeService) resumeSingleCard(ctx context.Context, cardID uint) e
|
||||
return nil
|
||||
}
|
||||
|
||||
s.invalidatePollingCache(cardID)
|
||||
s.invalidatePollingCache(ctx, card.ID)
|
||||
|
||||
s.logger.Info("卡已自动复机",
|
||||
zap.Uint("card_id", cardID),
|
||||
@@ -195,56 +425,16 @@ func (s *StopResumeService) resumeSingleCard(ctx context.Context, cardID uint) e
|
||||
return nil
|
||||
}
|
||||
|
||||
// resumeDeviceCards 对设备绑定的所有已绑定卡逐一执行复机逻辑
|
||||
// 部分卡实名、部分未实名时:已实名卡复机,未实名卡跳过,互不影响
|
||||
func (s *StopResumeService) resumeDeviceCards(ctx context.Context, deviceID uint) error {
|
||||
bindings, err := s.deviceSimBindingStore.ListByDeviceID(ctx, deviceID)
|
||||
if err != nil {
|
||||
s.logger.Warn("查询设备绑定卡失败",
|
||||
zap.Uint("device_id", deviceID),
|
||||
zap.Error(err))
|
||||
return nil
|
||||
}
|
||||
|
||||
for _, binding := range bindings {
|
||||
if resumeErr := s.resumeSingleCard(ctx, binding.IotCardID); resumeErr != nil {
|
||||
s.logger.Warn("设备绑定卡复机失败,继续处理其他卡",
|
||||
zap.Uint("device_id", deviceID),
|
||||
zap.Uint("card_id", binding.IotCardID),
|
||||
zap.Error(resumeErr))
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// hasAvailablePackage 检查是否有可用套餐
|
||||
func (s *StopResumeService) hasAvailablePackage(ctx context.Context, cardID uint) (bool, error) {
|
||||
var count int64
|
||||
err := s.db.WithContext(ctx).Model(&model.PackageUsage{}).
|
||||
Where("iot_card_id = ?", cardID).
|
||||
Where("status IN ?", []int{
|
||||
constants.PackageUsageStatusPending, // 待生效
|
||||
constants.PackageUsageStatusActive, // 生效中
|
||||
}).
|
||||
Count(&count).Error
|
||||
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
return count > 0, nil
|
||||
}
|
||||
|
||||
// stopCardWithRetry 任务 24.5: 调用运营商停机接口(带重试机制)
|
||||
func (s *StopResumeService) stopCardWithRetry(ctx context.Context, card *model.IotCard) error {
|
||||
// stopCardWithRetry 调用运营商停机接口(带重试机制),并更新 DB 停机原因
|
||||
func (s *StopResumeService) stopCardWithRetry(ctx context.Context, card *model.IotCard, stopReason string) error {
|
||||
if s.gatewayClient == nil {
|
||||
return errors.New(errors.CodeInternalError, "Gateway 未配置,停复机操作不可用")
|
||||
}
|
||||
|
||||
s.logger.Info("调用网关停机",
|
||||
zap.Uint("card_id", card.ID),
|
||||
zap.String("iccid", card.ICCID))
|
||||
zap.String("iccid", card.ICCID),
|
||||
zap.String("stop_reason", stopReason))
|
||||
|
||||
var lastErr error
|
||||
for i := 0; i < s.maxRetries; i++ {
|
||||
@@ -262,6 +452,20 @@ func (s *StopResumeService) stopCardWithRetry(ctx context.Context, card *model.I
|
||||
s.logger.Info("网关停机成功",
|
||||
zap.Uint("card_id", card.ID),
|
||||
zap.String("iccid", card.ICCID))
|
||||
|
||||
now := time.Now()
|
||||
if updateErr := s.iotCardStore.UpdateFields(ctx, card.ID, map[string]any{
|
||||
"network_status": constants.NetworkStatusOffline,
|
||||
"stopped_at": now,
|
||||
"stop_reason": stopReason,
|
||||
}); updateErr != nil {
|
||||
s.logger.Error("停机 Gateway 成功但 DB 更新失败",
|
||||
zap.Uint("card_id", card.ID),
|
||||
zap.String("iccid", card.ICCID),
|
||||
zap.Error(updateErr))
|
||||
}
|
||||
|
||||
s.invalidatePollingCache(ctx, card.ID)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -275,7 +479,7 @@ func (s *StopResumeService) stopCardWithRetry(ctx context.Context, card *model.I
|
||||
return lastErr
|
||||
}
|
||||
|
||||
// resumeCardWithRetry 任务 24.5: 调用运营商复机接口(带重试机制)
|
||||
// resumeCardWithRetry 调用运营商复机接口(带重试机制)
|
||||
func (s *StopResumeService) resumeCardWithRetry(ctx context.Context, card *model.IotCard) error {
|
||||
if s.gatewayClient == nil {
|
||||
return errors.New(errors.CodeInternalError, "Gateway 未配置,停复机操作不可用")
|
||||
@@ -338,21 +542,10 @@ func (s *StopResumeService) ManualStopCard(ctx context.Context, iccid string) er
|
||||
}
|
||||
}
|
||||
|
||||
if err := s.stopCardWithRetry(ctx, card); err != nil {
|
||||
if err := s.stopCardWithRetry(ctx, card, constants.StopReasonManual); err != nil {
|
||||
return errors.Wrap(errors.CodeGatewayError, err, "调用运营商停机失败,请稍后重试")
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
if err := s.db.WithContext(ctx).Model(card).Updates(map[string]any{
|
||||
"network_status": constants.NetworkStatusOffline,
|
||||
"stopped_at": now,
|
||||
"stop_reason": constants.StopReasonManual,
|
||||
}).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "更新卡状态失败")
|
||||
}
|
||||
|
||||
s.invalidatePollingCache(card.ID)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -385,26 +578,26 @@ func (s *StopResumeService) ManualStartCard(ctx context.Context, iccid string) e
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
if err := s.db.WithContext(ctx).Model(card).Updates(map[string]any{
|
||||
if err := s.iotCardStore.UpdateFields(ctx, card.ID, map[string]any{
|
||||
"network_status": constants.NetworkStatusOnline,
|
||||
"resumed_at": now,
|
||||
"stop_reason": "",
|
||||
}).Error; err != nil {
|
||||
}); err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "更新卡状态失败")
|
||||
}
|
||||
|
||||
s.invalidatePollingCache(card.ID)
|
||||
s.invalidatePollingCache(ctx, card.ID)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// invalidatePollingCache 删除轮询卡缓存,下次轮询自动从 DB 重建
|
||||
func (s *StopResumeService) invalidatePollingCache(cardID uint) {
|
||||
func (s *StopResumeService) invalidatePollingCache(ctx context.Context, cardID uint) {
|
||||
if s.redis == nil {
|
||||
return
|
||||
}
|
||||
key := constants.RedisPollingCardInfoKey(cardID)
|
||||
if err := s.redis.Del(context.Background(), key).Err(); err != nil {
|
||||
s.logger.Warn("删除轮询卡缓存失败", zap.Uint("card_id", cardID), zap.Error(err))
|
||||
if err := s.redis.Del(ctx, key).Err(); err != nil {
|
||||
s.logger.Warn("删除轮询卡缓存失败", zap.Uint("card_id", cardID))
|
||||
}
|
||||
}
|
||||
|
||||
89
internal/service/polling/asset_polling_service.go
Normal file
89
internal/service/polling/asset_polling_service.go
Normal file
@@ -0,0 +1,89 @@
|
||||
package polling
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/polling"
|
||||
iotCardSvc "github.com/break/junhong_cmp_fiber/internal/service/iot_card"
|
||||
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
)
|
||||
|
||||
// AssetPollingService 资产轮询管控服务
|
||||
// 管理 IoT 卡和设备的轮询启用状态
|
||||
// S2 修复:card 类型委托给 IotCardService(含 DB 写入 + callback 通知),避免绕过生命周期
|
||||
type AssetPollingService struct {
|
||||
deviceStore *postgres.DeviceStore
|
||||
deviceBindingStore *postgres.DeviceSimBindingStore
|
||||
iotCardService *iotCardSvc.Service
|
||||
queueMgr *polling.PollingQueueManager
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// NewAssetPollingService 创建资产轮询管控服务
|
||||
func NewAssetPollingService(
|
||||
deviceStore *postgres.DeviceStore,
|
||||
deviceBindingStore *postgres.DeviceSimBindingStore,
|
||||
iotCardService *iotCardSvc.Service,
|
||||
queueMgr *polling.PollingQueueManager,
|
||||
logger *zap.Logger,
|
||||
) *AssetPollingService {
|
||||
return &AssetPollingService{
|
||||
deviceStore: deviceStore,
|
||||
deviceBindingStore: deviceBindingStore,
|
||||
iotCardService: iotCardService,
|
||||
queueMgr: queueMgr,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// UpdatePollingStatus 更新资产轮询状态
|
||||
// assetType: "card" 或 "device"
|
||||
// assetID: 资产ID
|
||||
// enablePolling: 是否启用轮询
|
||||
func (s *AssetPollingService) UpdatePollingStatus(ctx context.Context, assetType string, assetID uint, enablePolling bool) error {
|
||||
switch assetType {
|
||||
case constants.AssetTypeIotCard:
|
||||
// S2 修复:委托给 IotCardService,确保 DB 写入 + PollingCallback 回调一并触发
|
||||
return s.iotCardService.UpdatePollingStatus(ctx, assetID, enablePolling)
|
||||
|
||||
case constants.AssetTypeDevice:
|
||||
// 1. 更新设备的 enable_polling 字段
|
||||
if err := s.deviceStore.UpdatePollingStatus(ctx, assetID, enablePolling); err != nil {
|
||||
return err
|
||||
}
|
||||
bindings, err := s.deviceBindingStore.ListByDeviceID(ctx, assetID)
|
||||
if err != nil {
|
||||
s.logger.Warn("查询设备绑定卡失败,绑定卡轮询状态同步可能不完整",
|
||||
zap.Uint("device_id", assetID), zap.Error(err))
|
||||
return nil
|
||||
}
|
||||
if len(bindings) == 0 {
|
||||
return nil
|
||||
}
|
||||
cardIDs := make([]uint, len(bindings))
|
||||
for i, b := range bindings {
|
||||
cardIDs[i] = b.IotCardID
|
||||
}
|
||||
// 2. M3 修复:级联同步绑定卡的 enable_polling,防止生命周期事件绕过设备级设置
|
||||
if syncErr := s.iotCardService.BatchUpdatePollingStatus(ctx, cardIDs, enablePolling); syncErr != nil {
|
||||
s.logger.Warn("批量同步绑定卡轮询状态失败",
|
||||
zap.Uint("device_id", assetID), zap.Error(syncErr))
|
||||
}
|
||||
if !enablePolling {
|
||||
for _, b := range bindings {
|
||||
if rmErr := s.queueMgr.RemoveFromAllQueues(ctx, b.IotCardID); rmErr != nil {
|
||||
s.logger.Warn("从队列移除卡失败",
|
||||
zap.Uint("card_id", b.IotCardID), zap.Error(rmErr))
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
|
||||
default:
|
||||
return errors.New(errors.CodeInvalidParam, "资产类型无效,支持 card 或 device")
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,9 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/polling"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
)
|
||||
@@ -13,11 +15,24 @@ import (
|
||||
// MonitoringService 轮询监控服务
|
||||
type MonitoringService struct {
|
||||
redis *redis.Client
|
||||
queueMgr *polling.PollingQueueManager
|
||||
}
|
||||
|
||||
// NewMonitoringService 创建轮询监控服务实例
|
||||
func NewMonitoringService(redis *redis.Client) *MonitoringService {
|
||||
return &MonitoringService{redis: redis}
|
||||
// queueMgr 可选(nil 时降级到旧非分片 Key 兼容模式)
|
||||
func NewMonitoringService(redis *redis.Client, opts ...interface{}) *MonitoringService {
|
||||
svc := &MonitoringService{redis: redis}
|
||||
for _, opt := range opts {
|
||||
if qm, ok := opt.(*polling.PollingQueueManager); ok {
|
||||
svc.queueMgr = qm
|
||||
}
|
||||
}
|
||||
return svc
|
||||
}
|
||||
|
||||
// NewMonitoringServiceWithQueueMgr 创建注入了 PollingQueueManager 的监控服务(推荐)
|
||||
func NewMonitoringServiceWithQueueMgr(redis *redis.Client, queueMgr *polling.PollingQueueManager, _ *zap.Logger) *MonitoringService {
|
||||
return &MonitoringService{redis: redis, queueMgr: queueMgr}
|
||||
}
|
||||
|
||||
// OverviewStats 总览统计
|
||||
@@ -84,10 +99,15 @@ func (s *MonitoringService) GetOverview(ctx context.Context) (*OverviewStats, er
|
||||
}
|
||||
stats.IsInitializing = stats.InitializedCards < stats.TotalCards && stats.TotalCards > 0
|
||||
|
||||
// 获取队列大小
|
||||
if s.queueMgr != nil {
|
||||
stats.RealnameQueueSize, _ = s.queueMgr.GetTotalQueueDepth(ctx, constants.TaskTypePollingRealname)
|
||||
stats.CarddataQueueSize, _ = s.queueMgr.GetTotalQueueDepth(ctx, constants.TaskTypePollingCarddata)
|
||||
stats.PackageQueueSize, _ = s.queueMgr.GetTotalQueueDepth(ctx, constants.TaskTypePollingPackage)
|
||||
} else {
|
||||
stats.RealnameQueueSize, _ = s.redis.ZCard(ctx, constants.RedisPollingQueueRealnameKey()).Result()
|
||||
stats.CarddataQueueSize, _ = s.redis.ZCard(ctx, constants.RedisPollingQueueCarddataKey()).Result()
|
||||
stats.PackageQueueSize, _ = s.redis.ZCard(ctx, constants.RedisPollingQueuePackageKey()).Result()
|
||||
}
|
||||
|
||||
return stats, nil
|
||||
}
|
||||
@@ -120,8 +140,11 @@ func (s *MonitoringService) GetQueueStatuses(ctx context.Context) ([]*QueueStatu
|
||||
queueKey = constants.RedisPollingQueuePackageKey()
|
||||
}
|
||||
|
||||
// 获取队列大小
|
||||
if s.queueMgr != nil {
|
||||
status.QueueSize, _ = s.queueMgr.GetTotalQueueDepth(ctx, taskType)
|
||||
} else {
|
||||
status.QueueSize, _ = s.redis.ZCard(ctx, queueKey).Result()
|
||||
}
|
||||
|
||||
// 获取到期数量(score <= now)
|
||||
status.DueCount, _ = s.redis.ZCount(ctx, queueKey, "-inf", formatInt64(now)).Result()
|
||||
|
||||
@@ -81,3 +81,17 @@ func (s *CarrierStore) List(ctx context.Context, opts *store.QueryOptions, filte
|
||||
|
||||
return carriers, total, nil
|
||||
}
|
||||
|
||||
// GetDataResetDay 获取运营商上游流量重置日(1-28),查询失败默认返回 1
|
||||
func (s *CarrierStore) GetDataResetDay(ctx context.Context, carrierID uint) int {
|
||||
var day int
|
||||
err := s.db.WithContext(ctx).
|
||||
Model(&model.Carrier{}).
|
||||
Select("data_reset_day").
|
||||
Where("id = ?", carrierID).
|
||||
Scan(&day).Error
|
||||
if err != nil || day == 0 {
|
||||
return 1
|
||||
}
|
||||
return day
|
||||
}
|
||||
|
||||
@@ -243,3 +243,10 @@ func (s *DeviceStore) UpdateRechargeTrackingFields(ctx context.Context, deviceID
|
||||
"first_recharge_triggered_by_series": triggeredJSON,
|
||||
}).Error
|
||||
}
|
||||
|
||||
// UpdatePollingStatus 更新设备的轮询启用状态
|
||||
func (s *DeviceStore) UpdatePollingStatus(ctx context.Context, deviceID uint, enablePolling bool) error {
|
||||
return s.db.WithContext(ctx).Model(&model.Device{}).
|
||||
Where("id = ?", deviceID).
|
||||
Update("enable_polling", enablePolling).Error
|
||||
}
|
||||
|
||||
@@ -110,6 +110,20 @@ func (s *IotCardStore) Update(ctx context.Context, card *model.IotCard) error {
|
||||
return s.db.WithContext(ctx).Save(card).Error
|
||||
}
|
||||
|
||||
// UpdateFields 按 ID 部分更新卡字段,供轮询系统的 Handler 使用
|
||||
func (s *IotCardStore) UpdateFields(ctx context.Context, cardID uint, fields map[string]any) error {
|
||||
return s.db.WithContext(ctx).Model(&model.IotCard{}).
|
||||
Where("id = ?", cardID).
|
||||
Updates(fields).Error
|
||||
}
|
||||
|
||||
// UpdatePollingStatus 更新卡的轮询启用状态
|
||||
func (s *IotCardStore) UpdatePollingStatus(ctx context.Context, cardID uint, enablePolling bool) error {
|
||||
return s.db.WithContext(ctx).Model(&model.IotCard{}).
|
||||
Where("id = ?", cardID).
|
||||
Update("enable_polling", enablePolling).Error
|
||||
}
|
||||
|
||||
func (s *IotCardStore) Delete(ctx context.Context, id uint) error {
|
||||
return s.db.WithContext(ctx).Delete(&model.IotCard{}, id).Error
|
||||
}
|
||||
@@ -927,6 +941,65 @@ func (s *IotCardStore) ExistsByVirtualNoBatch(ctx context.Context, virtualNos []
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// ==================== 轮询系统专用查询 ====================
|
||||
|
||||
// ListByDeviceIDAndNetworkStatus 查询设备下指定网络状态的所有卡
|
||||
// 通过 tb_device_sim_binding 关联查询(项目禁止外键约束,用子查询实现)
|
||||
func (s *IotCardStore) ListByDeviceIDAndNetworkStatus(ctx context.Context, deviceID uint, networkStatus int) ([]*model.IotCard, error) {
|
||||
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 = ? AND deleted_at IS NULL", networkStatus).
|
||||
Find(&cards).Error
|
||||
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).
|
||||
Model(&model.IotCard{}).
|
||||
Where("id = ?", cardID).
|
||||
Update("stop_reason", reason).Error
|
||||
}
|
||||
|
||||
// CountForPolling 统计启用轮询的卡总数(仅供轮询初始化使用,不应用数据权限过滤)
|
||||
func (s *IotCardStore) CountForPolling(ctx context.Context) (int64, error) {
|
||||
var total int64
|
||||
err := s.db.WithContext(ctx).
|
||||
Model(&model.IotCard{}).
|
||||
Where("enable_polling = true AND deleted_at IS NULL").
|
||||
Count(&total).Error
|
||||
return total, err
|
||||
}
|
||||
|
||||
// ListForPollingBatch 分批查询启用轮询的卡,按 id ASC 游标翻页(仅供轮询初始化使用,不应用数据权限过滤)
|
||||
// lastID: 上批最后一条卡的 id(首次传 0);limit: 每批大小
|
||||
func (s *IotCardStore) ListForPollingBatch(ctx context.Context, lastID uint, limit int) ([]*model.IotCard, error) {
|
||||
var cards []*model.IotCard
|
||||
err := s.db.WithContext(ctx).
|
||||
Where("id > ? AND enable_polling = true AND deleted_at IS NULL", lastID).
|
||||
Order("id ASC").
|
||||
Limit(limit).
|
||||
Find(&cards).Error
|
||||
return cards, err
|
||||
}
|
||||
|
||||
// ==================== 列表计数缓存 ====================
|
||||
|
||||
func (s *IotCardStore) getCachedCount(ctx context.Context, table string, filters map[string]any) (int64, bool) {
|
||||
|
||||
@@ -172,6 +172,51 @@ func (s *PackageUsageStore) ResetDataUsage(ctx context.Context, id uint, lastRes
|
||||
}).Error
|
||||
}
|
||||
|
||||
// HasValidByCarrier 检查载体是否有有效套餐(status IN 待生效/生效中)
|
||||
// 修复Bug1:通过 carrierType 动态切换 device_id / iot_card_id 查询
|
||||
// carrierType: "iot_card" 或 "device"
|
||||
func (s *PackageUsageStore) HasValidByCarrier(ctx context.Context, carrierType string, carrierID uint) (bool, error) {
|
||||
var count int64
|
||||
validStatuses := []int{
|
||||
constants.PackageUsageStatusPending, // 待生效
|
||||
constants.PackageUsageStatusActive, // 生效中
|
||||
}
|
||||
query := s.db.WithContext(ctx).Model(&model.PackageUsage{}).
|
||||
Where("status IN ?", validStatuses)
|
||||
if carrierType == "device" {
|
||||
query = query.Where("device_id = ?", carrierID)
|
||||
} else {
|
||||
query = query.Where("iot_card_id = ?", carrierID)
|
||||
}
|
||||
if err := query.Count(&count).Error; err != nil {
|
||||
return false, err
|
||||
}
|
||||
return count > 0, nil
|
||||
}
|
||||
|
||||
// GetActiveOrDepletedByCarrier 查询载体的活跃或耗尽套餐(用于流量耗尽检测)
|
||||
// ORDER BY status ASC 优先返回活跃套餐(status=1),有活跃套餐时先评估活跃套餐的流量上限
|
||||
// 返回 gorm.ErrRecordNotFound 表示无活跃或耗尽套餐(不判定为流量耗尽)
|
||||
func (s *PackageUsageStore) GetActiveOrDepletedByCarrier(ctx context.Context, carrierType string, carrierID uint) (*model.PackageUsage, error) {
|
||||
var pkg model.PackageUsage
|
||||
query := s.db.WithContext(ctx).Model(&model.PackageUsage{}).
|
||||
Where("status IN ?", []int{
|
||||
constants.PackageUsageStatusActive, // 生效中
|
||||
constants.PackageUsageStatusDepleted, // 已用完
|
||||
}).
|
||||
Order("status ASC").
|
||||
Limit(1)
|
||||
if carrierType == "device" {
|
||||
query = query.Where("device_id = ?", carrierID)
|
||||
} else {
|
||||
query = query.Where("iot_card_id = ?", carrierID)
|
||||
}
|
||||
if err := query.First(&pkg).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &pkg, nil
|
||||
}
|
||||
|
||||
// ListByCarrier 查询载体的所有套餐使用记录(包括所有状态)
|
||||
func (s *PackageUsageStore) ListByCarrier(ctx context.Context, carrierType string, carrierID uint) ([]*model.PackageUsage, error) {
|
||||
var usages []*model.PackageUsage
|
||||
|
||||
118
internal/task/polling_base.go
Normal file
118
internal/task/polling_base.go
Normal file
@@ -0,0 +1,118 @@
|
||||
package task
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/polling"
|
||||
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
)
|
||||
|
||||
// acquireConcurrencyScript 原子获取并发信号量的 Lua 脚本
|
||||
// INCR + EXPIRE 合并为单个服务端操作,消除二者之间的崩溃窗口:
|
||||
// 若 Worker 在 INCR 后、EXPIRE 前崩溃,key 将永久留在 Redis 导致计数器卡死。
|
||||
// KEYS[1]: 当前并发计数 key
|
||||
// ARGV[1]: 最大并发数;ARGV[2]: key TTL(秒)
|
||||
// 返回 -1 表示超额拒绝,>0 表示成功获取后的计数值
|
||||
var acquireConcurrencyScript = redis.NewScript(`
|
||||
local current = redis.call('INCR', KEYS[1])
|
||||
if tonumber(current) > tonumber(ARGV[1]) then
|
||||
redis.call('DECR', KEYS[1])
|
||||
return -1
|
||||
end
|
||||
redis.call('EXPIRE', KEYS[1], tonumber(ARGV[2]))
|
||||
return current
|
||||
`)
|
||||
|
||||
// PollingBase 轮询共享基类
|
||||
// 封装并发控制、卡缓存、重入队、配置间隔查询等公共方法,4 个 Handler 共享
|
||||
type PollingBase struct {
|
||||
redis *redis.Client
|
||||
queueMgr *polling.PollingQueueManager
|
||||
configMgr *polling.PollingConfigManager
|
||||
iotCardStore *postgres.IotCardStore
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// NewPollingBase 创建轮询共享基类
|
||||
func NewPollingBase(
|
||||
redisClient *redis.Client,
|
||||
queueMgr *polling.PollingQueueManager,
|
||||
configMgr *polling.PollingConfigManager,
|
||||
iotCardStore *postgres.IotCardStore,
|
||||
logger *zap.Logger,
|
||||
) *PollingBase {
|
||||
return &PollingBase{
|
||||
redis: redisClient,
|
||||
queueMgr: queueMgr,
|
||||
configMgr: configMgr,
|
||||
iotCardStore: iotCardStore,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// acquireConcurrency 原子获取并发信号量
|
||||
// 使用 Lua 脚本将 INCR 与 EXPIRE 合并为单个服务端操作,消除非原子窗口:
|
||||
// 旧实现中若 Worker 在 INCR 后、EXPIRE 前崩溃,key 永久留在 Redis,计数器卡死。
|
||||
func (b *PollingBase) acquireConcurrency(ctx context.Context, taskType string) bool {
|
||||
shortType := shortTaskType(taskType)
|
||||
configKey := constants.RedisPollingConcurrencyConfigKey(shortType)
|
||||
currentKey := constants.RedisPollingConcurrencyCurrentKey(taskType)
|
||||
|
||||
maxConcurrency, err := b.redis.Get(ctx, configKey).Int()
|
||||
if err != nil {
|
||||
maxConcurrency = constants.PollingDefaultMaxConcurrency
|
||||
}
|
||||
|
||||
result, err := acquireConcurrencyScript.Run(
|
||||
ctx, b.redis, []string{currentKey},
|
||||
maxConcurrency, constants.PollingConcurrencyKeyTTL,
|
||||
).Int64()
|
||||
if err != nil {
|
||||
b.logger.Warn("获取并发计数失败,放行任务", zap.Error(err))
|
||||
return true
|
||||
}
|
||||
|
||||
return result != -1
|
||||
}
|
||||
|
||||
// releaseConcurrency 释放并发信号量
|
||||
func (b *PollingBase) releaseConcurrency(ctx context.Context, taskType string) {
|
||||
currentKey := constants.RedisPollingConcurrencyCurrentKey(taskType)
|
||||
if err := b.redis.Decr(ctx, currentKey).Err(); err != nil {
|
||||
b.logger.Warn("释放并发计数失败", zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
// requeueCard 将卡按匹配配置间隔重新入队分片 Sorted Set
|
||||
// ⚠️ 关键:Lua 脚本原子出队后卡已从队列移除,若并发满时直接 return 会导致卡永久丢失
|
||||
// 调用方必须在 acquireConcurrency 返回 false 时调用此方法入队后再返回
|
||||
func (b *PollingBase) requeueCard(ctx context.Context, cardID uint, taskType string) error {
|
||||
card, err := b.getCardWithCache(ctx, cardID)
|
||||
if err != nil {
|
||||
b.logger.Warn("重入队:获取卡信息失败", zap.Uint("card_id", cardID), zap.Error(err))
|
||||
// 获取卡失败时立即重入队(下次仍然尝试处理)
|
||||
return b.queueMgr.Requeue(ctx, cardID, taskType, time.Now().Add(30*time.Second))
|
||||
}
|
||||
|
||||
cfg := b.configMgr.MatchConfig(card)
|
||||
if cfg == nil {
|
||||
// 兜底:配置未加载(DB 暂时不可达)时延迟 30 秒重入队,防止卡从轮询永久消失
|
||||
b.logger.Warn("无匹配轮询配置,30秒后重入队(配置可能未加载)",
|
||||
zap.Uint("card_id", cardID), zap.String("task_type", taskType))
|
||||
return b.queueMgr.Requeue(ctx, cardID, taskType, time.Now().Add(30*time.Second))
|
||||
}
|
||||
|
||||
interval := getIntervalByTaskType(cfg, taskType)
|
||||
if interval <= 0 {
|
||||
b.logger.Debug("轮询间隔为 NULL,不入队", zap.Uint("card_id", cardID), zap.String("task_type", taskType))
|
||||
return nil
|
||||
}
|
||||
|
||||
nextCheckAt := time.Now().Add(time.Duration(interval) * time.Second)
|
||||
return b.queueMgr.Requeue(ctx, cardID, taskType, nextCheckAt)
|
||||
}
|
||||
136
internal/task/polling_card_cache.go
Normal file
136
internal/task/polling_card_cache.go
Normal file
@@ -0,0 +1,136 @@
|
||||
package task
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
)
|
||||
|
||||
// getCardWithCache 优先从 Redis 缓存获取卡信息,缓存 miss 时查 DB 并异步写缓存
|
||||
func (b *PollingBase) getCardWithCache(ctx context.Context, cardID uint) (*model.IotCard, error) {
|
||||
key := constants.RedisPollingCardInfoKey(cardID)
|
||||
|
||||
result, err := b.redis.HGetAll(ctx, key).Result()
|
||||
if err == nil && len(result) > 0 {
|
||||
return parseCardFromCache(cardID, result), nil
|
||||
}
|
||||
|
||||
card, err := b.iotCardStore.GetByID(ctx, cardID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
go writeCardToCache(b, key, card)
|
||||
return card, nil
|
||||
}
|
||||
|
||||
// updateCardCache 更新 Redis 卡信息缓存中的指定字段
|
||||
func (b *PollingBase) updateCardCache(ctx context.Context, cardID uint, updates map[string]any) {
|
||||
key := constants.RedisPollingCardInfoKey(cardID)
|
||||
args := make([]any, 0, len(updates)*2)
|
||||
for k, v := range updates {
|
||||
args = append(args, k, v)
|
||||
}
|
||||
if len(args) > 0 {
|
||||
if err := b.redis.HSet(ctx, key, args...).Err(); err != nil {
|
||||
b.logger.Warn("更新卡缓存失败", zap.Uint("card_id", cardID), zap.Error(err))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// parseCardFromCache 从 Redis Hash 反序列化卡信息
|
||||
func parseCardFromCache(cardID uint, result map[string]string) *model.IotCard {
|
||||
card := &model.IotCard{}
|
||||
card.ID = cardID
|
||||
if v, ok := result["iccid"]; ok {
|
||||
card.ICCID = v
|
||||
}
|
||||
if v, ok := result["card_category"]; ok {
|
||||
card.CardCategory = v
|
||||
}
|
||||
if v, ok := result["real_name_status"]; ok {
|
||||
if status, err := strconv.Atoi(v); err == nil {
|
||||
card.RealNameStatus = status
|
||||
}
|
||||
}
|
||||
if v, ok := result["network_status"]; ok {
|
||||
if status, err := strconv.Atoi(v); err == nil {
|
||||
card.NetworkStatus = status
|
||||
}
|
||||
}
|
||||
if v, ok := result["carrier_id"]; ok {
|
||||
if id, err := strconv.ParseUint(v, 10, 64); err == nil {
|
||||
card.CarrierID = uint(id)
|
||||
}
|
||||
}
|
||||
if v, ok := result["current_month_usage_mb"]; ok {
|
||||
if usage, err := strconv.ParseFloat(v, 64); err == nil {
|
||||
card.CurrentMonthUsageMB = usage
|
||||
}
|
||||
}
|
||||
if v, ok := result["last_gateway_reading_mb"]; ok {
|
||||
if reading, err := strconv.ParseFloat(v, 64); err == nil {
|
||||
card.LastGatewayReadingMB = reading
|
||||
}
|
||||
}
|
||||
if v, ok := result["data_usage_mb"]; ok {
|
||||
if usage, err := strconv.ParseInt(v, 10, 64); err == nil {
|
||||
card.DataUsageMB = usage
|
||||
}
|
||||
}
|
||||
if v, ok := result["stop_reason"]; ok {
|
||||
card.StopReason = v
|
||||
}
|
||||
if v, ok := result["is_standalone"]; ok {
|
||||
card.IsStandalone = v == "1" || v == "true"
|
||||
} else {
|
||||
card.IsStandalone = true
|
||||
}
|
||||
if v, ok := result["series_id"]; ok {
|
||||
if id, err := strconv.ParseUint(v, 10, 64); err == nil {
|
||||
seriesID := uint(id)
|
||||
card.SeriesID = &seriesID
|
||||
}
|
||||
}
|
||||
if v, ok := result["current_month_start_date"]; ok {
|
||||
if ts, err := strconv.ParseInt(v, 10, 64); err == nil {
|
||||
t := time.Unix(ts, 0)
|
||||
card.CurrentMonthStartDate = &t
|
||||
}
|
||||
}
|
||||
return card
|
||||
}
|
||||
|
||||
// writeCardToCache 将卡信息异步写入 Redis 缓存(7 天 TTL)
|
||||
func writeCardToCache(b *PollingBase, key string, card *model.IotCard) {
|
||||
cacheCtx := context.Background()
|
||||
cacheData := map[string]any{
|
||||
"id": card.ID,
|
||||
"iccid": card.ICCID,
|
||||
"card_category": card.CardCategory,
|
||||
"real_name_status": card.RealNameStatus,
|
||||
"network_status": card.NetworkStatus,
|
||||
"carrier_id": card.CarrierID,
|
||||
"current_month_usage_mb": card.CurrentMonthUsageMB,
|
||||
"last_gateway_reading_mb": card.LastGatewayReadingMB,
|
||||
"data_usage_mb": card.DataUsageMB,
|
||||
"stop_reason": card.StopReason,
|
||||
"is_standalone": boolToStr(card.IsStandalone),
|
||||
"cached_at": time.Now().Unix(),
|
||||
}
|
||||
if card.SeriesID != nil {
|
||||
cacheData["series_id"] = *card.SeriesID
|
||||
}
|
||||
if card.CurrentMonthStartDate != nil {
|
||||
cacheData["current_month_start_date"] = card.CurrentMonthStartDate.Unix()
|
||||
}
|
||||
pipe := b.redis.Pipeline()
|
||||
pipe.HSet(cacheCtx, key, cacheData)
|
||||
pipe.Expire(cacheCtx, key, 7*24*time.Hour)
|
||||
_, _ = pipe.Exec(cacheCtx)
|
||||
}
|
||||
188
internal/task/polling_carddata_handler.go
Normal file
188
internal/task/polling_carddata_handler.go
Normal file
@@ -0,0 +1,188 @@
|
||||
package task
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/hibiken/asynq"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/gateway"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
iot_card_svc "github.com/break/junhong_cmp_fiber/internal/service/iot_card"
|
||||
packagepkg "github.com/break/junhong_cmp_fiber/internal/service/package"
|
||||
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
)
|
||||
|
||||
// PollingCarddataHandler 流量检查任务处理器
|
||||
// 职责:调 Gateway 查流量 → 写 DB → 扣减套餐流量 → 调 EvaluateAndAct 判断停复机
|
||||
// 不做:直接停复机决策,委托给 StopResumeService.EvaluateAndAct
|
||||
type PollingCarddataHandler struct {
|
||||
base *PollingBase
|
||||
gateway *gateway.Client
|
||||
iotCardStore *postgres.IotCardStore
|
||||
carrierStore *postgres.CarrierStore
|
||||
usageService *packagepkg.UsageService
|
||||
stopResumeSvc iot_card_svc.StopResumeServiceInterface
|
||||
}
|
||||
|
||||
// NewPollingCarddataHandler 创建流量检查任务处理器
|
||||
func NewPollingCarddataHandler(
|
||||
base *PollingBase,
|
||||
gw *gateway.Client,
|
||||
iotCardStore *postgres.IotCardStore,
|
||||
carrierStore *postgres.CarrierStore,
|
||||
usageService *packagepkg.UsageService,
|
||||
stopResumeSvc iot_card_svc.StopResumeServiceInterface,
|
||||
) *PollingCarddataHandler {
|
||||
return &PollingCarddataHandler{
|
||||
base: base,
|
||||
gateway: gw,
|
||||
iotCardStore: iotCardStore,
|
||||
carrierStore: carrierStore,
|
||||
usageService: usageService,
|
||||
stopResumeSvc: stopResumeSvc,
|
||||
}
|
||||
}
|
||||
|
||||
// Handle 处理流量检查任务
|
||||
func (h *PollingCarddataHandler) Handle(ctx context.Context, t *asynq.Task) error {
|
||||
startTime := time.Now()
|
||||
|
||||
cardID, ok := parseTaskPayload(t.Payload(), h.base.logger)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
if !h.base.acquireConcurrency(ctx, constants.TaskTypePollingCarddata) {
|
||||
h.base.logger.Debug("并发已满,重新入队", zap.Uint("card_id", cardID))
|
||||
return h.base.requeueCard(ctx, cardID, constants.TaskTypePollingCarddata)
|
||||
}
|
||||
defer h.base.releaseConcurrency(ctx, constants.TaskTypePollingCarddata)
|
||||
|
||||
card, err := h.base.getCardWithCache(ctx, cardID)
|
||||
if err != nil {
|
||||
if isNotFound(err) {
|
||||
return nil
|
||||
}
|
||||
h.base.logger.Error("获取卡信息失败", zap.Uint("card_id", cardID), zap.Error(err))
|
||||
h.base.updateStats(ctx, constants.TaskTypePollingCarddata, false, time.Since(startTime))
|
||||
return h.base.requeueCard(ctx, cardID, constants.TaskTypePollingCarddata)
|
||||
}
|
||||
|
||||
var gatewayFlowMB float64
|
||||
if h.gateway != nil {
|
||||
result, gwErr := h.gateway.QueryFlow(ctx, &gateway.FlowQueryReq{CardNo: card.ICCID})
|
||||
if gwErr != nil {
|
||||
h.base.logger.Warn("查询流量失败",
|
||||
zap.Uint("card_id", cardID), zap.String("iccid", card.ICCID), zap.Error(gwErr))
|
||||
h.base.updateStats(ctx, constants.TaskTypePollingCarddata, false, time.Since(startTime))
|
||||
return h.base.requeueCard(ctx, cardID, constants.TaskTypePollingCarddata)
|
||||
}
|
||||
gatewayFlowMB = result.Used
|
||||
} else {
|
||||
gatewayFlowMB = card.CurrentMonthUsageMB
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
resetDay := h.carrierStore.GetDataResetDay(ctx, card.CarrierID)
|
||||
updates, flowIncrementMB, isCrossMonth := h.calculateFlowUpdates(card, gatewayFlowMB, now, resetDay)
|
||||
updates["last_data_check_at"] = now
|
||||
|
||||
if updateErr := h.iotCardStore.UpdateFields(ctx, cardID, updates); updateErr != nil {
|
||||
h.base.logger.Error("更新卡流量信息失败", zap.Uint("card_id", cardID), zap.Error(updateErr))
|
||||
}
|
||||
|
||||
go h.base.insertDataUsageRecord(context.Background(), cardID, flowIncrementMB, now)
|
||||
|
||||
cacheUpdates := map[string]any{"last_gateway_reading_mb": gatewayFlowMB}
|
||||
if isCrossMonth {
|
||||
// 跨月时同步更新缓存中的本月开始日期和本月用量,避免下次从缓存读取时再次误判跨月
|
||||
currentMonthStart := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location())
|
||||
cacheUpdates["current_month_start_date"] = currentMonthStart.Unix()
|
||||
if flowIncrementMB > 0 {
|
||||
cacheUpdates["current_month_usage_mb"] = flowIncrementMB
|
||||
} else {
|
||||
cacheUpdates["current_month_usage_mb"] = float64(0)
|
||||
}
|
||||
}
|
||||
h.base.updateCardCache(ctx, cardID, cacheUpdates)
|
||||
|
||||
if flowIncrementMB > 0 && h.usageService != nil {
|
||||
if deductErr := h.usageService.DeductDataUsage(ctx, "iot_card", cardID, int64(flowIncrementMB)); deductErr != nil {
|
||||
h.base.logger.Warn("套餐流量扣减失败",
|
||||
zap.Uint("card_id", cardID), zap.Float64("increment_mb", flowIncrementMB), zap.Error(deductErr))
|
||||
}
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
h.base.updateStats(ctx, constants.TaskTypePollingCarddata, true, time.Since(startTime))
|
||||
return h.base.requeueCard(ctx, cardID, constants.TaskTypePollingCarddata)
|
||||
}
|
||||
|
||||
// calculateFlowUpdates 计算流量更新字段、增量和是否跨月
|
||||
// 决策13:完整迁移跨月流量边界检测逻辑(月份切换检测、上月总量保存、当月计数器重置)
|
||||
// 第三个返回值 isCrossMonth 供调用方同步更新 Redis 缓存,避免下次误判跨月
|
||||
func (h *PollingCarddataHandler) calculateFlowUpdates(card *model.IotCard, gatewayFlowMB float64, now time.Time, resetDay int) (map[string]any, float64, bool) {
|
||||
updates := make(map[string]any)
|
||||
|
||||
increment := gatewayFlowMB - card.LastGatewayReadingMB
|
||||
if increment < 0 {
|
||||
if isResetWindow(now, resetDay) {
|
||||
increment = gatewayFlowMB
|
||||
h.base.logger.Info("检测到上游运营商重置",
|
||||
zap.Uint("card_id", card.ID),
|
||||
zap.Float64("gateway_flow", gatewayFlowMB),
|
||||
zap.Float64("last_reading", card.LastGatewayReadingMB),
|
||||
zap.Int("reset_day", resetDay))
|
||||
} else {
|
||||
h.base.logger.Warn("流量异常:非重置日出现值下降",
|
||||
zap.Uint("card_id", card.ID),
|
||||
zap.Float64("gateway_flow", gatewayFlowMB),
|
||||
zap.Float64("last_reading", card.LastGatewayReadingMB))
|
||||
increment = 0
|
||||
}
|
||||
}
|
||||
|
||||
updates["last_gateway_reading_mb"] = gatewayFlowMB
|
||||
|
||||
currentMonthStart := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location())
|
||||
isCrossMonth := card.CurrentMonthStartDate == nil ||
|
||||
card.CurrentMonthStartDate.Before(currentMonthStart)
|
||||
|
||||
if isCrossMonth {
|
||||
h.base.logger.Info("检测到跨月,重置流量计数",
|
||||
zap.Uint("card_id", card.ID),
|
||||
zap.Float64("last_month_total", card.CurrentMonthUsageMB))
|
||||
updates["last_month_total_mb"] = card.CurrentMonthUsageMB
|
||||
updates["current_month_start_date"] = currentMonthStart
|
||||
if increment > 0 {
|
||||
updates["current_month_usage_mb"] = increment
|
||||
} else {
|
||||
updates["current_month_usage_mb"] = float64(0)
|
||||
}
|
||||
} else if increment > 0 {
|
||||
updates["current_month_usage_mb"] = gorm.Expr("current_month_usage_mb + ?", increment)
|
||||
}
|
||||
|
||||
if increment > 0 {
|
||||
updates["data_usage_mb"] = gorm.Expr("data_usage_mb + ?", int64(increment))
|
||||
}
|
||||
|
||||
if card.CurrentMonthStartDate == nil {
|
||||
updates["current_month_start_date"] = currentMonthStart
|
||||
}
|
||||
|
||||
return updates, increment, isCrossMonth
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
76
internal/task/polling_package_handler.go
Normal file
76
internal/task/polling_package_handler.go
Normal file
@@ -0,0 +1,76 @@
|
||||
package task
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/hibiken/asynq"
|
||||
"go.uber.org/zap"
|
||||
|
||||
iot_card_svc "github.com/break/junhong_cmp_fiber/internal/service/iot_card"
|
||||
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
)
|
||||
|
||||
// PollingPackageHandler 套餐检查任务处理器
|
||||
// 职责:触发 EvaluateAndAct 检查套餐状态并判断停复机
|
||||
// 不做:直接停复机决策,委托给 StopResumeService.EvaluateAndAct
|
||||
// 注:套餐状态由 PackageActivationHandler/DataResetHandler/UsageService 维护,
|
||||
// 本 Handler 无需查 Gateway,只负责周期性触发再评估。
|
||||
type PollingPackageHandler struct {
|
||||
base *PollingBase
|
||||
iotCardStore *postgres.IotCardStore
|
||||
stopResumeSvc iot_card_svc.StopResumeServiceInterface
|
||||
}
|
||||
|
||||
// NewPollingPackageHandler 创建套餐检查任务处理器
|
||||
func NewPollingPackageHandler(
|
||||
base *PollingBase,
|
||||
iotCardStore *postgres.IotCardStore,
|
||||
stopResumeSvc iot_card_svc.StopResumeServiceInterface,
|
||||
) *PollingPackageHandler {
|
||||
return &PollingPackageHandler{
|
||||
base: base,
|
||||
iotCardStore: iotCardStore,
|
||||
stopResumeSvc: stopResumeSvc,
|
||||
}
|
||||
}
|
||||
|
||||
// Handle 处理套餐检查任务
|
||||
func (h *PollingPackageHandler) Handle(ctx context.Context, t *asynq.Task) error {
|
||||
startTime := time.Now()
|
||||
|
||||
cardID, ok := parseTaskPayload(t.Payload(), h.base.logger)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
if !h.base.acquireConcurrency(ctx, constants.TaskTypePollingPackage) {
|
||||
h.base.logger.Debug("并发已满,重新入队", zap.Uint("card_id", cardID))
|
||||
return h.base.requeueCard(ctx, cardID, constants.TaskTypePollingPackage)
|
||||
}
|
||||
defer h.base.releaseConcurrency(ctx, constants.TaskTypePollingPackage)
|
||||
|
||||
if _, cacheErr := h.base.getCardWithCache(ctx, cardID); cacheErr != nil {
|
||||
if isNotFound(cacheErr) {
|
||||
return nil
|
||||
}
|
||||
h.base.logger.Error("获取卡信息失败", zap.Uint("card_id", cardID), zap.Error(cacheErr))
|
||||
h.base.updateStats(ctx, constants.TaskTypePollingPackage, false, time.Since(startTime))
|
||||
return h.base.requeueCard(ctx, cardID, constants.TaskTypePollingPackage)
|
||||
}
|
||||
|
||||
if h.stopResumeSvc != nil {
|
||||
// EvaluateAndAct 需要完整新鲜数据(含 DeviceID),从 DB 获取
|
||||
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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
h.base.updateStats(ctx, constants.TaskTypePollingPackage, true, time.Since(startTime))
|
||||
return h.base.requeueCard(ctx, cardID, constants.TaskTypePollingPackage)
|
||||
}
|
||||
151
internal/task/polling_protect_handler.go
Normal file
151
internal/task/polling_protect_handler.go
Normal file
@@ -0,0 +1,151 @@
|
||||
package task
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/hibiken/asynq"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/gateway"
|
||||
iot_card_svc "github.com/break/junhong_cmp_fiber/internal/service/iot_card"
|
||||
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
)
|
||||
|
||||
// PollingProtectHandler 保护期一致性检查任务处理器
|
||||
// 保护期内:直接调 Gateway 强制停/复机(绕过 EvaluateAndAct 三条件判断)
|
||||
// 保护期结束:调 EvaluateAndAct 重新评估正常停复机条件
|
||||
// 两种路径不可混淆:保护期内=强制修正;保护期结束=重新评估
|
||||
type PollingProtectHandler struct {
|
||||
base *PollingBase
|
||||
gateway *gateway.Client
|
||||
iotCardStore *postgres.IotCardStore
|
||||
deviceSimBindingStore *postgres.DeviceSimBindingStore
|
||||
stopResumeSvc iot_card_svc.StopResumeServiceInterface
|
||||
}
|
||||
|
||||
// NewPollingProtectHandler 创建保护期一致性检查任务处理器
|
||||
func NewPollingProtectHandler(
|
||||
base *PollingBase,
|
||||
gw *gateway.Client,
|
||||
iotCardStore *postgres.IotCardStore,
|
||||
deviceSimBindingStore *postgres.DeviceSimBindingStore,
|
||||
stopResumeSvc iot_card_svc.StopResumeServiceInterface,
|
||||
) *PollingProtectHandler {
|
||||
return &PollingProtectHandler{
|
||||
base: base,
|
||||
gateway: gw,
|
||||
iotCardStore: iotCardStore,
|
||||
deviceSimBindingStore: deviceSimBindingStore,
|
||||
stopResumeSvc: stopResumeSvc,
|
||||
}
|
||||
}
|
||||
|
||||
// Handle 处理保护期一致性检查任务
|
||||
func (h *PollingProtectHandler) Handle(ctx context.Context, t *asynq.Task) error {
|
||||
startTime := time.Now()
|
||||
|
||||
cardID, ok := parseTaskPayload(t.Payload(), h.base.logger)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
if !h.base.acquireConcurrency(ctx, constants.TaskTypePollingProtect) {
|
||||
h.base.logger.Debug("并发已满,重新入队", zap.Uint("card_id", cardID))
|
||||
return h.base.requeueCard(ctx, cardID, constants.TaskTypePollingProtect)
|
||||
}
|
||||
defer h.base.releaseConcurrency(ctx, constants.TaskTypePollingProtect)
|
||||
|
||||
card, err := h.iotCardStore.GetByID(ctx, cardID)
|
||||
if err != nil {
|
||||
if isNotFound(err) {
|
||||
return nil
|
||||
}
|
||||
h.base.logger.Error("查询卡信息失败", zap.Uint("card_id", cardID), zap.Error(err))
|
||||
return h.base.requeueCard(ctx, cardID, constants.TaskTypePollingProtect)
|
||||
}
|
||||
|
||||
// 独立卡(未绑设备)跳过保护期检查
|
||||
if card.IsStandalone {
|
||||
return h.base.requeueCard(ctx, cardID, constants.TaskTypePollingProtect)
|
||||
}
|
||||
|
||||
// 未实名卡跳过保护期检查
|
||||
if card.RealNameStatus != constants.RealNameStatusVerified {
|
||||
return h.base.requeueCard(ctx, cardID, constants.TaskTypePollingProtect)
|
||||
}
|
||||
|
||||
binding, err := h.deviceSimBindingStore.GetActiveBindingByCardID(ctx, card.ID)
|
||||
if err != nil || binding == nil {
|
||||
return h.base.requeueCard(ctx, cardID, constants.TaskTypePollingProtect)
|
||||
}
|
||||
deviceID := binding.DeviceID
|
||||
|
||||
stopProtect := h.base.redis.Exists(ctx, constants.RedisDeviceProtectKey(deviceID, "stop")).Val() > 0
|
||||
startProtect := h.base.redis.Exists(ctx, constants.RedisDeviceProtectKey(deviceID, "start")).Val() > 0
|
||||
|
||||
switch {
|
||||
case stopProtect && card.NetworkStatus == constants.NetworkStatusOnline:
|
||||
// 保护期内:停机保护期发现开机卡 → 强制停机(绕过 EvaluateAndAct)
|
||||
h.base.logger.Info("保护期一致性:停机保护期内发现开机卡,强制停机",
|
||||
zap.Uint("card_id", card.ID), zap.Uint("device_id", deviceID))
|
||||
if h.gateway == nil {
|
||||
break
|
||||
}
|
||||
if err := h.gateway.StopCard(ctx, &gateway.CardOperationReq{CardNo: card.ICCID}); err != nil {
|
||||
h.base.logger.Error("保护期强制停机失败",
|
||||
zap.Uint("card_id", card.ID), zap.Error(err))
|
||||
h.base.updateStats(ctx, constants.TaskTypePollingProtect, false, time.Since(startTime))
|
||||
return h.base.requeueCard(ctx, cardID, constants.TaskTypePollingProtect)
|
||||
}
|
||||
if updateErr := h.iotCardStore.UpdateFields(ctx, cardID, map[string]any{
|
||||
"network_status": constants.NetworkStatusOffline,
|
||||
"stopped_at": time.Now(),
|
||||
"stop_reason": constants.StopReasonProtectPeriod,
|
||||
}); updateErr != nil {
|
||||
h.base.logger.Warn("保护期停机 DB 更新失败", zap.Uint("card_id", cardID), zap.Error(updateErr))
|
||||
}
|
||||
h.base.updateCardCache(ctx, cardID, map[string]any{"network_status": constants.NetworkStatusOffline})
|
||||
|
||||
case startProtect && card.NetworkStatus == constants.NetworkStatusOffline:
|
||||
// 保护期内:复机保护期发现停机卡 → 强制复机(绕过 EvaluateAndAct)
|
||||
h.base.logger.Info("保护期一致性:复机保护期内发现停机卡,强制复机",
|
||||
zap.Uint("card_id", card.ID), zap.Uint("device_id", deviceID))
|
||||
if h.gateway == nil {
|
||||
break
|
||||
}
|
||||
if err := h.gateway.StartCard(ctx, &gateway.CardOperationReq{CardNo: card.ICCID}); err != nil {
|
||||
h.base.logger.Error("保护期强制复机失败",
|
||||
zap.Uint("card_id", card.ID), zap.Error(err))
|
||||
h.base.updateStats(ctx, constants.TaskTypePollingProtect, false, time.Since(startTime))
|
||||
return h.base.requeueCard(ctx, cardID, constants.TaskTypePollingProtect)
|
||||
}
|
||||
if updateErr := h.iotCardStore.UpdateFields(ctx, cardID, map[string]any{
|
||||
"network_status": constants.NetworkStatusOnline,
|
||||
"resumed_at": time.Now(),
|
||||
"stop_reason": "",
|
||||
}); updateErr != nil {
|
||||
h.base.logger.Warn("保护期复机 DB 更新失败", zap.Uint("card_id", cardID), zap.Error(updateErr))
|
||||
}
|
||||
h.base.updateCardCache(ctx, cardID, map[string]any{"network_status": constants.NetworkStatusOnline})
|
||||
|
||||
case !stopProtect && !startProtect:
|
||||
// 保护期结束:调 EvaluateAndAct 重新评估正常停复机条件
|
||||
if h.stopResumeSvc != nil {
|
||||
if evalErr := h.stopResumeSvc.EvaluateAndAct(ctx, card); evalErr != nil {
|
||||
h.base.logger.Warn("保护期结束后停复机评估失败",
|
||||
zap.Uint("card_id", cardID), zap.Error(evalErr))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if updateErr := h.iotCardStore.UpdateFields(ctx, cardID, map[string]any{
|
||||
"last_protect_check_at": time.Now(),
|
||||
}); updateErr != nil {
|
||||
h.base.logger.Warn("更新保护期检查时间失败", zap.Uint("card_id", cardID), zap.Error(updateErr))
|
||||
}
|
||||
|
||||
h.base.updateStats(ctx, constants.TaskTypePollingProtect, true, time.Since(startTime))
|
||||
return h.base.requeueCard(ctx, cardID, constants.TaskTypePollingProtect)
|
||||
}
|
||||
172
internal/task/polling_realname_handler.go
Normal file
172
internal/task/polling_realname_handler.go
Normal file
@@ -0,0 +1,172 @@
|
||||
package task
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/bytedance/sonic"
|
||||
"github.com/hibiken/asynq"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/gateway"
|
||||
iot_card_svc "github.com/break/junhong_cmp_fiber/internal/service/iot_card"
|
||||
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
)
|
||||
|
||||
// PollingRealnameHandler 实名检查任务处理器
|
||||
// 职责:调 Gateway 查实名状态 → 写 DB → 触发首次实名激活任务
|
||||
// 不做:停复机决策(实名状态 0→1 时通过 EvaluateAndAct 触发 not_realname 复机)
|
||||
type PollingRealnameHandler struct {
|
||||
base *PollingBase
|
||||
gateway *gateway.Client
|
||||
iotCardStore *postgres.IotCardStore
|
||||
asynqClient *asynq.Client
|
||||
stopResumeSvc iot_card_svc.StopResumeServiceInterface
|
||||
}
|
||||
|
||||
// NewPollingRealnameHandler 创建实名检查任务处理器
|
||||
func NewPollingRealnameHandler(
|
||||
base *PollingBase,
|
||||
gw *gateway.Client,
|
||||
iotCardStore *postgres.IotCardStore,
|
||||
asynqClient *asynq.Client,
|
||||
stopResumeSvc iot_card_svc.StopResumeServiceInterface,
|
||||
) *PollingRealnameHandler {
|
||||
return &PollingRealnameHandler{
|
||||
base: base,
|
||||
gateway: gw,
|
||||
iotCardStore: iotCardStore,
|
||||
asynqClient: asynqClient,
|
||||
stopResumeSvc: stopResumeSvc,
|
||||
}
|
||||
}
|
||||
|
||||
// Handle 处理实名检查任务
|
||||
func (h *PollingRealnameHandler) Handle(ctx context.Context, t *asynq.Task) error {
|
||||
startTime := time.Now()
|
||||
|
||||
cardID, ok := parseTaskPayload(t.Payload(), h.base.logger)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
if !h.base.acquireConcurrency(ctx, constants.TaskTypePollingRealname) {
|
||||
h.base.logger.Debug("并发已满,重新入队", zap.Uint("card_id", cardID))
|
||||
return h.base.requeueCard(ctx, cardID, constants.TaskTypePollingRealname)
|
||||
}
|
||||
defer h.base.releaseConcurrency(ctx, constants.TaskTypePollingRealname)
|
||||
|
||||
card, err := h.base.getCardWithCache(ctx, cardID)
|
||||
if err != nil {
|
||||
if isNotFound(err) {
|
||||
return nil
|
||||
}
|
||||
h.base.logger.Error("获取卡信息失败", zap.Uint("card_id", cardID), zap.Error(err))
|
||||
h.base.updateStats(ctx, constants.TaskTypePollingRealname, false, time.Since(startTime))
|
||||
return h.base.requeueCard(ctx, cardID, constants.TaskTypePollingRealname)
|
||||
}
|
||||
|
||||
if card.CardCategory == constants.CardCategoryIndustry {
|
||||
return h.base.requeueCard(ctx, cardID, constants.TaskTypePollingRealname)
|
||||
}
|
||||
|
||||
var newStatus int
|
||||
if h.gateway != nil {
|
||||
result, gwErr := h.gateway.QueryRealnameStatus(ctx, &gateway.CardStatusReq{CardNo: card.ICCID})
|
||||
if gwErr != nil {
|
||||
h.base.logger.Warn("查询实名状态失败",
|
||||
zap.Uint("card_id", cardID), zap.String("iccid", card.ICCID), zap.Error(gwErr))
|
||||
h.base.updateStats(ctx, constants.TaskTypePollingRealname, false, time.Since(startTime))
|
||||
return h.base.requeueCard(ctx, cardID, constants.TaskTypePollingRealname)
|
||||
}
|
||||
if result.RealStatus {
|
||||
newStatus = constants.RealNameStatusVerified
|
||||
} else {
|
||||
newStatus = constants.RealNameStatusNotVerified
|
||||
}
|
||||
} else {
|
||||
newStatus = card.RealNameStatus
|
||||
}
|
||||
|
||||
statusChanged := newStatus != card.RealNameStatus
|
||||
isFirstRealname := statusChanged &&
|
||||
card.RealNameStatus != constants.RealNameStatusVerified &&
|
||||
newStatus == constants.RealNameStatusVerified
|
||||
|
||||
now := time.Now()
|
||||
fields := map[string]any{"last_real_name_check_at": now}
|
||||
if statusChanged {
|
||||
fields["real_name_status"] = newStatus
|
||||
}
|
||||
if isFirstRealname {
|
||||
fields["first_realname_at"] = now
|
||||
}
|
||||
if err := h.iotCardStore.UpdateFields(ctx, cardID, fields); err != nil {
|
||||
h.base.logger.Error("更新卡实名信息失败", zap.Uint("card_id", cardID), zap.Error(err))
|
||||
}
|
||||
|
||||
if statusChanged {
|
||||
h.base.updateCardCache(ctx, cardID, map[string]any{"real_name_status": newStatus})
|
||||
h.base.logger.Info("实名状态已变化",
|
||||
zap.Uint("card_id", cardID),
|
||||
zap.Int("old_status", card.RealNameStatus),
|
||||
zap.Int("new_status", newStatus))
|
||||
|
||||
if isFirstRealname {
|
||||
// 0→1:首次实名,触发套餐激活并立即复机评估(不等待下一个 carddata/package 周期)
|
||||
h.triggerFirstRealnameActivation(ctx, 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))
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if newStatus == constants.RealNameStatusNotVerified {
|
||||
// 1→0:实名逆转,立即触发停机评估(不等待下一个 carddata/package 周期,否则最长延迟 1 小时)
|
||||
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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
h.base.updateStats(ctx, constants.TaskTypePollingRealname, true, time.Since(startTime))
|
||||
return h.base.requeueCard(ctx, cardID, constants.TaskTypePollingRealname)
|
||||
}
|
||||
|
||||
// triggerFirstRealnameActivation 首次实名后触发套餐激活任务
|
||||
func (h *PollingRealnameHandler) triggerFirstRealnameActivation(ctx context.Context, cardID uint) {
|
||||
if h.asynqClient == nil {
|
||||
return
|
||||
}
|
||||
payload := map[string]any{
|
||||
"carrier_type": "iot_card",
|
||||
"carrier_id": cardID,
|
||||
"activation_type": "realname",
|
||||
"timestamp": time.Now().Unix(),
|
||||
}
|
||||
payloadBytes, err := sonic.Marshal(payload)
|
||||
if err != nil {
|
||||
h.base.logger.Warn("序列化首次实名激活任务载荷失败", zap.Uint("card_id", cardID), zap.Error(err))
|
||||
return
|
||||
}
|
||||
task := asynq.NewTask(constants.TaskTypePackageFirstActivation, payloadBytes,
|
||||
asynq.MaxRetry(3),
|
||||
asynq.Timeout(30*time.Second),
|
||||
asynq.Queue(constants.QueueDefault),
|
||||
)
|
||||
if _, err := h.asynqClient.EnqueueContext(ctx, task); err != nil {
|
||||
h.base.logger.Warn("提交首次实名激活任务失败", zap.Uint("card_id", cardID), zap.Error(err))
|
||||
}
|
||||
}
|
||||
39
internal/task/polling_stats.go
Normal file
39
internal/task/polling_stats.go
Normal file
@@ -0,0 +1,39 @@
|
||||
package task
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
)
|
||||
|
||||
// updateStats 更新监控统计(成功/失败计数 + 耗时)
|
||||
func (b *PollingBase) updateStats(ctx context.Context, taskType string, success bool, duration time.Duration) {
|
||||
key := constants.RedisPollingStatsKey(taskType)
|
||||
pipe := b.redis.Pipeline()
|
||||
if success {
|
||||
pipe.HIncrBy(ctx, key, "success_count_1h", 1)
|
||||
} else {
|
||||
pipe.HIncrBy(ctx, key, "failure_count_1h", 1)
|
||||
}
|
||||
pipe.HIncrBy(ctx, key, "total_duration_1h", duration.Milliseconds())
|
||||
pipe.Expire(ctx, key, 2*time.Hour)
|
||||
_, _ = pipe.Exec(ctx)
|
||||
}
|
||||
|
||||
// insertDataUsageRecord 写入日流量缓冲到 Redis(INCRBYFLOAT + 48h TTL)
|
||||
func (b *PollingBase) insertDataUsageRecord(ctx context.Context, cardID uint, incrementMB float64, checkTime time.Time) {
|
||||
if incrementMB <= 0 {
|
||||
return
|
||||
}
|
||||
dateStr := checkTime.Format("2006-01-02")
|
||||
key := constants.RedisCardDailyTrafficKey(cardID, dateStr)
|
||||
if err := b.redis.IncrByFloat(ctx, key, incrementMB).Err(); err != nil {
|
||||
b.logger.Warn("写入日流量缓冲失败",
|
||||
zap.Uint("card_id", cardID), zap.Float64("increment_mb", incrementMB), zap.Error(err))
|
||||
return
|
||||
}
|
||||
b.redis.Expire(ctx, key, 48*time.Hour)
|
||||
}
|
||||
88
internal/task/polling_utils.go
Normal file
88
internal/task/polling_utils.go
Normal file
@@ -0,0 +1,88 @@
|
||||
package task
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/bytedance/sonic"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
)
|
||||
|
||||
// getIntervalByTaskType 从配置中提取指定任务类型的轮询间隔(秒)
|
||||
func getIntervalByTaskType(cfg *model.PollingConfig, taskType string) int {
|
||||
switch taskType {
|
||||
case constants.TaskTypePollingRealname:
|
||||
if cfg.RealnameCheckInterval != nil && *cfg.RealnameCheckInterval > 0 {
|
||||
return *cfg.RealnameCheckInterval
|
||||
}
|
||||
case constants.TaskTypePollingCarddata:
|
||||
if cfg.CarddataCheckInterval != nil && *cfg.CarddataCheckInterval > 0 {
|
||||
return *cfg.CarddataCheckInterval
|
||||
}
|
||||
case constants.TaskTypePollingPackage:
|
||||
if cfg.PackageCheckInterval != nil && *cfg.PackageCheckInterval > 0 {
|
||||
return *cfg.PackageCheckInterval
|
||||
}
|
||||
case constants.TaskTypePollingProtect:
|
||||
if cfg.ProtectCheckInterval != nil && *cfg.ProtectCheckInterval > 0 {
|
||||
return *cfg.ProtectCheckInterval
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// shortTaskType 从完整任务类型中提取简短名称(如 polling:carddata → carddata)
|
||||
func shortTaskType(fullTaskType string) string {
|
||||
for i := len(fullTaskType) - 1; i >= 0; i-- {
|
||||
if fullTaskType[i] == ':' {
|
||||
return fullTaskType[i+1:]
|
||||
}
|
||||
}
|
||||
return fullTaskType
|
||||
}
|
||||
|
||||
// parseTaskPayload 解析轮询任务载荷,返回 cardID
|
||||
func parseTaskPayload(payload []byte, logger *zap.Logger) (uint, bool) {
|
||||
var p struct {
|
||||
CardID string `json:"card_id"`
|
||||
}
|
||||
if err := sonic.Unmarshal(payload, &p); err != nil {
|
||||
logger.Error("解析任务载荷失败", zap.Error(err))
|
||||
return 0, false
|
||||
}
|
||||
id, parseErr := strconv.ParseUint(p.CardID, 10, 64)
|
||||
if parseErr != nil {
|
||||
logger.Error("解析卡ID失败", zap.String("card_id", p.CardID), zap.Error(parseErr))
|
||||
return 0, false
|
||||
}
|
||||
return uint(id), true
|
||||
}
|
||||
|
||||
// boolToStr 布尔值转字符串(Redis hash 存储用)
|
||||
func boolToStr(b bool) string {
|
||||
if b {
|
||||
return "1"
|
||||
}
|
||||
return "0"
|
||||
}
|
||||
|
||||
// isNotFound 判断是否为 GORM 记录不存在错误
|
||||
func isNotFound(err error) bool {
|
||||
return err == gorm.ErrRecordNotFound
|
||||
}
|
||||
|
||||
// isResetWindow 判断今天是否在运营商流量重置日窗口内
|
||||
// 窗口 = 重置日当天 + 前一天(容错网关数据延迟上报)
|
||||
func isResetWindow(now time.Time, resetDay int) bool {
|
||||
today := now.Day()
|
||||
if today == resetDay {
|
||||
return true
|
||||
}
|
||||
resetDate := time.Date(now.Year(), now.Month(), resetDay, 0, 0, 0, 0, now.Location())
|
||||
prevDay := resetDate.AddDate(0, 0, -1).Day()
|
||||
return today == prevDay
|
||||
}
|
||||
2
migrations/000103_add_enable_polling_to_device.down.sql
Normal file
2
migrations/000103_add_enable_polling_to_device.down.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
-- 回滚:删除 tb_device 的 enable_polling 字段
|
||||
ALTER TABLE tb_device DROP COLUMN IF EXISTS enable_polling;
|
||||
4
migrations/000103_add_enable_polling_to_device.up.sql
Normal file
4
migrations/000103_add_enable_polling_to_device.up.sql
Normal file
@@ -0,0 +1,4 @@
|
||||
-- 为 tb_device 表新增 enable_polling 字段
|
||||
-- 用于控制设备是否参与轮询队列
|
||||
ALTER TABLE tb_device ADD COLUMN IF NOT EXISTS enable_polling BOOLEAN NOT NULL DEFAULT TRUE;
|
||||
COMMENT ON COLUMN tb_device.enable_polling IS '是否参与轮询,false 时设备下所有卡不加入轮询队列';
|
||||
34
migrations/000104_polling_config_data.up.sql
Normal file
34
migrations/000104_polling_config_data.up.sql
Normal file
@@ -0,0 +1,34 @@
|
||||
-- 任务 1.7:新增停机卡轮询配置
|
||||
-- 停机卡需要持续轮询以检测复机条件(套餐购买、流量重置、实名完成)
|
||||
-- priority=25 介于 not_real_name(10) 和 real_name(20) 之间
|
||||
-- realname_check_interval 必须设置:停机且未实名的卡需要实名检查,实名完成后自动复机
|
||||
INSERT INTO tb_polling_config (
|
||||
config_name, card_condition, card_category, carrier_id,
|
||||
priority, realname_check_interval, carddata_check_interval, package_check_interval, protect_check_interval,
|
||||
status, description, created_at, updated_at
|
||||
) VALUES (
|
||||
'停机卡轮询', 'suspended', NULL, NULL,
|
||||
25, 3600, 3600, 3600, NULL,
|
||||
1, '停机卡每小时检查实名/流量/套餐,满足条件时自动复机', NOW(), NOW()
|
||||
)
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- 任务 1.8:为已激活在线卡配置添加保护期检查间隔
|
||||
-- protect_check_interval 由迁移 000102 添加,但存量配置均为 NULL,导致 protect 任务从未入队
|
||||
UPDATE tb_polling_config
|
||||
SET protect_check_interval = 3600, updated_at = NOW()
|
||||
WHERE card_condition = 'activated' AND protect_check_interval IS NULL;
|
||||
|
||||
-- 同步为停机卡配置添加保护期检查(新增配置天然包含,此处保险更新)
|
||||
UPDATE tb_polling_config
|
||||
SET protect_check_interval = 3600, updated_at = NOW()
|
||||
WHERE card_condition = 'suspended' AND protect_check_interval IS NULL;
|
||||
|
||||
-- 任务 1.9:将 card_condition='real_name' 的配置迁移到 'activated'
|
||||
-- getCardCondition 修复后不再返回 real_name,只返回 not_real_name/activated/suspended
|
||||
-- real_name 配置将永远无法匹配,需迁移到 activated
|
||||
UPDATE tb_polling_config
|
||||
SET card_condition = 'activated',
|
||||
description = COALESCE(description, '') || '(原 real_name 条件,已迁移至 activated)',
|
||||
updated_at = NOW()
|
||||
WHERE card_condition = 'real_name';
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-04-02
|
||||
@@ -0,0 +1,837 @@
|
||||
## Context
|
||||
|
||||
### 当前架构(混乱状态)
|
||||
|
||||
```
|
||||
internal/
|
||||
├── polling/
|
||||
│ ├── scheduler.go (764行)
|
||||
│ │ 职责:调度循环 + 卡初始化 + 配置管理 + 生命周期回调
|
||||
│ │ + 套餐激活触发 + 流量重置触发
|
||||
│ │ 问题:六职合一,改一处可能破坏另外五处
|
||||
│ ├── callbacks.go (228行)
|
||||
│ │ 职责:Worker进程卡生命周期回调(Redis队列操作)
|
||||
│ │ 问题:与 api_callback.go 大量重复
|
||||
│ ├── api_callback.go (107行)
|
||||
│ │ 职责:API进程卡生命周期回调(同上,只是所在进程不同)
|
||||
│ │ 问题:与 callbacks.go 几乎相同代码,双重维护
|
||||
│ ├── package_activation_handler.go (505行) 套餐过期处理(直接DB)
|
||||
│ └── data_reset_handler.go (116行) 流量重置调度
|
||||
│
|
||||
└── task/
|
||||
└── polling_handler.go (1360行)
|
||||
职责:4类检查(实名/流量/套餐/保护期)
|
||||
+ 停复机决策(与 stop_resume_service.go 重复)
|
||||
+ 直接DB操作(13处,绕过Store层)
|
||||
+ 直接Gateway调用(7处,无重试)
|
||||
+ 缓存管理 + 队列管理
|
||||
问题:单文件承担8类职责,改动风险极高
|
||||
|
||||
service/iot_card/
|
||||
└── stop_resume_service.go (410行)
|
||||
职责:停复机逻辑(有3次重试)
|
||||
问题:与 polling_handler 中另一套停复机逻辑并存,
|
||||
hasAvailablePackage 重复定义,
|
||||
只查 iot_card_id,遗漏 device_id(Bug1)
|
||||
```
|
||||
|
||||
**四个线上 Bug:**
|
||||
```
|
||||
Bug 1:设备套餐误停机
|
||||
PackageUsage.device_id 存设备套餐,
|
||||
但 hasAvailablePackage 只查 iot_card_id → 购买设备套餐后仍被误停机
|
||||
|
||||
Bug 2:停机条件不完整
|
||||
仅检查「无套餐」,未检查「流量耗尽」「非行业卡未实名」
|
||||
|
||||
Bug 3:protect 队列遗漏
|
||||
removeFromAllQueues 只清 realname/carddata/package 3个队列
|
||||
漏掉 protect → 删除卡后 protect 队列残留脏数据
|
||||
|
||||
Bug 4:出队竞态
|
||||
ZRANGEBYSCORE + ZREMRANGEBYSCORE 两步非原子
|
||||
高并发下同一张卡可能被多个 Worker 重复取出
|
||||
```
|
||||
|
||||
### 目标架构(清晰)
|
||||
|
||||
```
|
||||
internal/
|
||||
├── polling/
|
||||
│ ├── scheduler.go (<200行) 纯调度循环
|
||||
│ │ 职责:从分片队列出队(Lua脚本原子出队)→ 批量推入 Asynq
|
||||
│ │ 依赖:PollingQueueManager、PollingConfigManager
|
||||
│ │
|
||||
│ ├── queue_manager.go (<200行) 统一 Redis 队列操作
|
||||
│ │ 职责:DequeueReady(Lua脚本原子出队)
|
||||
│ │ Requeue(ZADD重入队)
|
||||
│ │ RemoveFromAllQueues(含protect,修复Bug3)
|
||||
│ │ EnqueueManual(手动触发)
|
||||
│ │ OnCardDeleted(卡删除清理)
|
||||
│ │ 特性:分片Sorted Set支持千万级
|
||||
│ │
|
||||
│ ├── config_manager.go (<150行) 配置管理
|
||||
│ │ 职责:从DB加载PollingConfig → 内存缓存(读写锁)
|
||||
│ │ → Redis同步(TTL 24h)→ 5分钟定时刷新
|
||||
│ │
|
||||
│ └── initializer.go (<250行) 分片渐进式初始化
|
||||
│ 职责:分批(10万/批)从DB加载卡
|
||||
│ → card_id % shard_count 分桶入队
|
||||
│ → 跳过 enable_polling=false 的卡
|
||||
│ → 暴露进度给 MonitoringService
|
||||
│
|
||||
└── task/ (每个 < 300行)
|
||||
├── polling_base.go (<150行) 共享基类
|
||||
│ 职责:并发控制(acquireConcurrency/releaseConcurrency)
|
||||
│ 卡缓存(getCardWithCache/updateCardCache)
|
||||
│ 重入队(requeueCard)
|
||||
│ 配置匹配(getMatchedPollingInterval)
|
||||
│
|
||||
├── polling_realname_handler.go (<200行) 实名检查
|
||||
│ 职责:调Gateway查实名 → 写DB → 触发首次实名激活任务
|
||||
│ 不做:停复机决策
|
||||
│
|
||||
├── polling_carddata_handler.go (<300行) 流量检查
|
||||
│ 职责:调Gateway查流量增量 → 写DB → 调DeductDataUsage
|
||||
│ → 调 StopResumeService.EvaluateAndAct()
|
||||
│ 不做:判断是否停机(由StopResumeService决定)
|
||||
│
|
||||
├── polling_package_handler.go (<200行) 套餐检查
|
||||
│ 职责:计算套餐流量使用
|
||||
│ → 调 StopResumeService.EvaluateAndAct()
|
||||
│ 不做:停复机决策
|
||||
│
|
||||
└── polling_protect_handler.go (<150行) 保护期检查
|
||||
职责:检查保护期Redis Key
|
||||
→ 调 StopResumeService 执行保护期停复机
|
||||
不做:停复机决策
|
||||
|
||||
service/iot_card/
|
||||
└── stop_resume_service.go 唯一停复机入口
|
||||
新增:EvaluateAndAct(ctx, card, carrierType, carrierID) error
|
||||
修复:设备套餐查询 Bug(device_id vs iot_card_id)
|
||||
新增:三条件停机(无套餐/流量耗尽/非行业卡未实名)
|
||||
完整:复机条件(含行业卡豁免)
|
||||
设备维度:停机覆盖所有卡,复机跳过未实名普通卡
|
||||
```
|
||||
|
||||
**架构原则:**
|
||||
```
|
||||
Task Handler = 数据采集层(调Gateway + Store,不做业务决策)
|
||||
StopResumeService = 停复机的唯一决策和执行层
|
||||
PollingQueueManager = Redis 操作的唯一封装
|
||||
PollingConfigManager = 配置管理的唯一封装
|
||||
CardInitializer = 初始化的唯一封装
|
||||
PollingLifecycleService = 卡生命周期轮询管理(替代 callbacks.go)
|
||||
```
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
- 每个文件职责单一,Handler 类控制在 < 300 行,Scheduler < 250 行
|
||||
- 停复机逻辑有且只有一处(StopResumeService.EvaluateAndAct)
|
||||
- 消除所有直接 DB 访问(polling_handler → Store 层方法)
|
||||
- 消除无重试的 Gateway 直调(统一通过 StopResumeService 3次重试)
|
||||
- 修复原子性 Bug(Lua 脚本原子出队替代 ZRANGEBYSCORE+ZREMRANGEBYSCORE)
|
||||
- 修复 protect 队列遗漏 Bug(RemoveFromAllQueues 覆盖4个队列)
|
||||
- 修复设备套餐查询 Bug(device_id vs iot_card_id)
|
||||
- 实现三条件停机判断(无套餐/流量耗尽/非行业卡未实名)
|
||||
- 支持千万级规模(分片 Sorted Set,16分片默认,背压机制)
|
||||
- 新增 Device.enable_polling 字段和轮询管控 HTTP 接口
|
||||
|
||||
**Non-Goals:**
|
||||
- 不改变 Asynq 任务类型常量(保持任务名称向后兼容)
|
||||
- 不改变已有 Redis Key 结构(仅新增分片 Key,旧 Key 不变)
|
||||
- 不改变 HTTP API 接口契约(32个接口零改动;4个监控接口内部实现适配分片队列,对外响应格式不变)
|
||||
- 不改变 `StopResumeCallback` 接口(`triggerStopAfterExpiry()` 和 `checkAndTriggerSuspension()` 不受影响)
|
||||
- 不修改 PackageActivationHandler 内部逻辑(另立提案)
|
||||
- 不引入新的外部依赖(Lua 脚本、ZRANGEBYSCORE、ZREM 均为 Redis 原生命令)
|
||||
|
||||
## Decisions
|
||||
|
||||
### 决策 1:分片队列设计(千万级核心)
|
||||
|
||||
**问题背景**:单个 Sorted Set 存千万级卡 ID,单节点出队成为瓶颈。
|
||||
|
||||
**数学估算**:
|
||||
```
|
||||
目标规模:1000万张卡,4种任务类型
|
||||
单队列深度:1000万 / 4 = 250万
|
||||
单次出队:每秒 1 次,每次取 1000条 → 2500秒处理完一轮 ≈ 41分钟
|
||||
16个分片后:每分片 ~15.6万张卡,每分片每次取 1000条
|
||||
并行消费:16分片同时 Lua 脚本原子出队,吞吐量提升 16x
|
||||
```
|
||||
|
||||
**Key 命名规范**:
|
||||
```
|
||||
分片队列 Key:polling:shard:{shardID}:queue:{taskType}
|
||||
shardID:0 到 N-1(默认 N=16)
|
||||
taskType:realname | carddata | package | protect
|
||||
示例:polling:shard:3:queue:carddata
|
||||
|
||||
手动触发 Key(List):polling:manual:{taskType}
|
||||
示例:polling:manual:realname
|
||||
|
||||
背压监控 Key(已有):polling:stats:queue_depth:{taskType}
|
||||
```
|
||||
|
||||
**分片路由算法**:
|
||||
```go
|
||||
// 入队时按 card_id 取模分桶,确保同一张卡始终在同一分片
|
||||
shardID := cardID % uint(shardCount)
|
||||
key := fmt.Sprintf("polling:shard:%d:queue:%s", shardID, taskType)
|
||||
```
|
||||
|
||||
**背压机制**:
|
||||
```go
|
||||
// 单分片队列深度超过阈值(默认 500K)时,跳过该分片本轮调度
|
||||
depth, _ := redis.ZCard(ctx, shardKey).Result()
|
||||
if depth > BackpressureThreshold {
|
||||
continue // 跳过该分片,防止 Asynq 过载
|
||||
}
|
||||
```
|
||||
|
||||
**初始化阶段兼容**:初始化完成前(initCompleted=false),分片队列保持为空,Scheduler 不出队;初始化通过 CardInitializer.Run() 并行写入各分片。
|
||||
|
||||
---
|
||||
|
||||
### 决策 2:Lua 脚本原子出队替代 ZRANGEBYSCORE+ZREMRANGEBYSCORE
|
||||
|
||||
**问题**:当前两步操作存在两层风险:
|
||||
```
|
||||
风险1(重复处理):
|
||||
Worker A:ZRANGEBYSCORE → 得到 [card1, card2]
|
||||
Worker B:ZRANGEBYSCORE → 得到 [card1, card2](未被移除)
|
||||
结果:card1 和 card2 被重复入队 Asynq,触发重复停复机 Gateway 调用
|
||||
|
||||
风险2(卡丢失,更严重):
|
||||
ZRANGEBYSCORE 有 LIMIT(如 50000),ZREMRANGEBYSCORE 无 LIMIT
|
||||
当到期卡数 > 50000 时,ZRANGEBYSCORE 只读前 50000 张
|
||||
ZREMRANGEBYSCORE 删除全部到期卡(含未读取的后 50000 张)
|
||||
结果:超出批次的卡永久丢失,不再被轮询
|
||||
```
|
||||
|
||||
**解决方案**:使用 Lua 脚本在 Redis 服务端原子执行 ZRANGEBYSCORE + ZREM:
|
||||
```lua
|
||||
-- polling_dequeue.lua
|
||||
-- KEYS[1]: 分片队列 Key
|
||||
-- ARGV[1]: 当前时间戳(score 上限,只取到期卡)
|
||||
-- ARGV[2]: 批次大小(LIMIT,Go 层已限制 ≤ 7000,即 DequeueMaxBatchSize)
|
||||
local results = redis.call('ZRANGEBYSCORE', KEYS[1], '-inf', ARGV[1], 'LIMIT', 0, tonumber(ARGV[2]))
|
||||
-- 注意:由于 Go 层已将 batchSize 限制在 7000 以内,results 最多 7000 个。
|
||||
-- 以下分批 ZREM 循环实际只执行一次,是防御性代码(应对未来放宽 batchSize 上限的情况)。
|
||||
-- Lua unpack() 在 Lua 5.1(Redis 内置版本)中受 LUAI_MAXCSTACK 约 8000 限制。
|
||||
for i = 1, #results, 7000 do
|
||||
local j = math.min(i + 6999, #results)
|
||||
redis.call('ZREM', KEYS[1], unpack(results, i, j))
|
||||
end
|
||||
return results
|
||||
```
|
||||
|
||||
```go
|
||||
// Go 端封装(redis.NewScript 自动处理 EVALSHA 缓存)
|
||||
// DequeueMaxBatchSize Lua 脚本单次出队上限(受 Lua unpack 栈限制,不超过 7000)
|
||||
// 实际上限制在此值时,Lua 内部的 7000 分批 ZREM 循环只执行一次(防御性代码)
|
||||
const DequeueMaxBatchSize = 7000
|
||||
|
||||
var dequeueScript = redis.NewScript(`
|
||||
local results = redis.call('ZRANGEBYSCORE', KEYS[1], '-inf', ARGV[1], 'LIMIT', 0, tonumber(ARGV[2]))
|
||||
for i = 1, #results, 7000 do
|
||||
local j = math.min(i + 6999, #results)
|
||||
redis.call('ZREM', KEYS[1], unpack(results, i, j))
|
||||
end
|
||||
return results
|
||||
`)
|
||||
|
||||
func (m *PollingQueueManager) DequeueReady(ctx context.Context, shardID int, taskType string, batchSize int) ([]CardEntry, error) {
|
||||
// 防御:batchSize 不超过 Lua unpack 栈限制,保证 results ≤ DequeueMaxBatchSize
|
||||
if batchSize > DequeueMaxBatchSize {
|
||||
batchSize = DequeueMaxBatchSize
|
||||
}
|
||||
key := constants.RedisPollingShardQueueKey(shardID, taskType)
|
||||
now := time.Now().Unix()
|
||||
results, err := dequeueScript.Run(ctx, m.redis, []string{key}, now, batchSize).StringSlice()
|
||||
// 解析 results 为 []CardEntry(Redis ZRANGEBYSCORE 返回 string,需 strconv.ParseUint 转换为 uint)
|
||||
}
|
||||
```
|
||||
|
||||
**语义保持说明**:
|
||||
- 保留 ZRANGEBYSCORE 的时间过滤语义:只取 score ≤ now 的到期卡,不触碰未来项
|
||||
- 保留 LIMIT 语义:每次最多取 batchSize 条,不多删
|
||||
- 原子性保证:Lua 脚本在 Redis 单线程中执行,ZRANGEBYSCORE 和 ZREM 之间无竞态窗口
|
||||
- 重入队由 Task Handler 在处理完成后通过 `PollingQueueManager.Requeue()` 负责
|
||||
|
||||
**为何不用 ZPOPMIN**:
|
||||
- ZPOPMIN 弹出 score 最小的 N 个成员,**不区分 score 是否 ≤ now**
|
||||
- 卡的 score 是下次检查时间戳,未到期卡(score > now)会被提前取出处理,违反轮询间隔设计
|
||||
- 初始化阶段大量卡 score 在未来,ZPOPMIN 会将它们全部错误地提前处理
|
||||
|
||||
**Lua 脚本性能说明**:
|
||||
- Redis Lua 在服务端单线程执行,与原生命令性能差异在微秒级
|
||||
- 脚本首次执行后被 Redis 缓存(SHA1),后续通过 EVALSHA 执行,等效于原生命令
|
||||
- 对于 1 秒间隔的调度循环,此差异无实际影响
|
||||
|
||||
---
|
||||
|
||||
### 决策 3:StopResumeService.EvaluateAndAct() 完整伪代码设计
|
||||
|
||||
> **签名变更(Mi2 修复)**:删除原设计中的 `carrierType string, carrierID uint` 参数。
|
||||
> 这两个参数原来"仅用于日志",放在方法签名里会误导实现者认为它们影响业务逻辑。
|
||||
> 设备/单卡维度的判断通过 `card.DeviceID` 完全可推导,日志上下文通过 `zap.Field` 传递。
|
||||
|
||||
```
|
||||
EvaluateAndAct(ctx, card):
|
||||
// 停复机维度通过 card.DeviceID 推导:
|
||||
// card.DeviceID != nil → 设备维度(停/复机覆盖设备下所有卡)
|
||||
// card.DeviceID == nil → 单卡维度
|
||||
if card.NetworkStatus == Online(1):
|
||||
// 卡在线,检查是否需要停机
|
||||
reasons = checkStopReasons(ctx, card)
|
||||
if len(reasons) > 0:
|
||||
// 取最高优先级原因(no_package > traffic_exhausted > not_realname)
|
||||
primaryReason = reasons[0]
|
||||
if card.DeviceID != nil:
|
||||
// 设备维度:停机覆盖设备下所有在线卡
|
||||
stopDeviceCards(ctx, card.DeviceID, primaryReason)
|
||||
else:
|
||||
// 单卡维度
|
||||
stopCardWithRetry(ctx, card, primaryReason)
|
||||
else if card.NetworkStatus == Offline(0):
|
||||
// 卡停机,检查是否可以复机
|
||||
if card.StopReason NOT IN [traffic_exhausted, no_package, not_realname]:
|
||||
return nil // 手动停机等非轮询原因,不自动复机
|
||||
if shouldResume(ctx, card):
|
||||
if card.DeviceID != nil:
|
||||
resumeDeviceCards(ctx, card.DeviceID)
|
||||
else:
|
||||
resumeSingleCard(ctx, card)
|
||||
|
||||
checkStopReasons(ctx, card) → []string:
|
||||
reasons = []
|
||||
// 条件A:无有效套餐(优先级最高)
|
||||
if !hasValidPackage(ctx, card):
|
||||
reasons += ["no_package"]
|
||||
// 条件B:虚流量耗尽
|
||||
if isTrafficExhausted(ctx, card):
|
||||
reasons += ["traffic_exhausted"]
|
||||
// 条件C:非行业卡且未实名
|
||||
if !isRealnameOK(card):
|
||||
reasons += ["not_realname"]
|
||||
return reasons // 已按优先级排序
|
||||
|
||||
hasValidPackage(ctx, card) → bool:
|
||||
if card.DeviceID != nil:
|
||||
// 修复Bug1:设备套餐查 device_id
|
||||
count = db.Count(tb_package_usage WHERE device_id=card.DeviceID AND status IN(0,1))
|
||||
else:
|
||||
count = db.Count(tb_package_usage WHERE iot_card_id=card.ID AND status IN(0,1))
|
||||
return count > 0
|
||||
|
||||
isTrafficExhausted(ctx, card) → bool:
|
||||
// 查询活跃(status=1)或已耗尽(status=2)的套餐
|
||||
// ORDER BY status ASC: 优先取 status=1(活跃),再取 status=2(耗尽)
|
||||
// 语义:只要存在一个活跃套餐(status=1)且未超限,就不判定为流量耗尽
|
||||
if card.DeviceID != nil:
|
||||
pkg = db.Get(tb_package_usage WHERE device_id=card.DeviceID AND status IN(1,2) ORDER BY status ASC LIMIT 1)
|
||||
else:
|
||||
pkg = db.Get(tb_package_usage WHERE iot_card_id=card.ID AND status IN(1,2) ORDER BY status ASC LIMIT 1)
|
||||
if pkg == nil: return false // 无活跃或耗尽套餐,不判定为流量耗尽
|
||||
// status=2 表示系统已标记虚流量耗尽
|
||||
if pkg.Status == 2: return true
|
||||
// 活跃套餐(status=1):用量 >= 上限(data_limit_mb > 0 防止无限流量卡误判)
|
||||
// 业务约定:data_limit_mb=0 表示无限流量套餐,永远不判定为耗尽
|
||||
return pkg.DataLimitMB > 0 && pkg.DataUsageMB >= pkg.DataLimitMB
|
||||
|
||||
isRealnameOK(card) → bool:
|
||||
// 行业卡无需实名
|
||||
return card.CardCategory == "industry" || card.RealNameStatus == 1
|
||||
|
||||
shouldResume(ctx, card) → bool:
|
||||
return hasValidPackage(ctx, card) &&
|
||||
!isTrafficExhausted(ctx, card) &&
|
||||
isRealnameOK(card)
|
||||
|
||||
stopDeviceCards(ctx, deviceID, stopReason):
|
||||
cards = db.List(tb_iot_card WHERE device_id=deviceID AND network_status=1)
|
||||
for card in cards:
|
||||
stopCardWithRetry(ctx, card, stopReason) // 3次重试
|
||||
|
||||
resumeDeviceCards(ctx, deviceID):
|
||||
cards = db.List(tb_iot_card WHERE device_id=deviceID AND network_status=0
|
||||
AND stop_reason IN(traffic_exhausted, no_package, not_realname))
|
||||
for card in cards:
|
||||
if isRealnameOK(card): // 跳过未实名普通卡
|
||||
resumeSingleCard(ctx, card)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 决策 4:Task Handler 职责边界表
|
||||
|
||||
> **S1 修复**:`polling_realname_handler.go` 在检测到实名状态 0→1 时,**必须**调用 `EvaluateAndAct`,
|
||||
> 以确保因 `not_realname` 停机的卡在实名完成后能立即复机,不依赖下一个 carddata/package 轮询周期(可能长达 1 小时)。
|
||||
> 这不是"一般性停复机决策",而是实名事件驱动的精确复机触发。
|
||||
|
||||
| Handler | 调用 Gateway | 调用 Store | 调用 StopResumeService | 禁止 |
|
||||
|---------|-------------|-----------|----------------------|------|
|
||||
| `polling_realname_handler.go` | ✅ QueryRealname | ✅ UpdateRealNameStatus | ⚡ **仅当实名状态 0→1 时**调 `EvaluateAndAct`(触发 not_realname 复机) | 无条件停复机判断 |
|
||||
| `polling_carddata_handler.go` | ✅ QueryDataUsage | ✅ UpdateDataUsage | ✅ EvaluateAndAct | 直接停复机判断 |
|
||||
| `polling_package_handler.go` | ✅ QueryPackageInfo | ✅ UpdatePackageUsage | ✅ EvaluateAndAct | 直接停复机判断 |
|
||||
| `polling_protect_handler.go` | ✅ **保护期内强制停复机**(StopCard/StartCard) | ✅ GetCard | ⚡ **仅保护期结束后**调 `EvaluateAndAct`(重新评估) | 将保护期内操作走 EvaluateAndAct 三条件判断 |
|
||||
| `polling_base.go`(共享基类) | ❌ | ✅ GetCard(缓存) | ❌ | 业务逻辑 |
|
||||
|
||||
> **protect Handler 双路径说明**:
|
||||
> - 保护期**内**(保护期 Key 存在)+ 状态不一致 → 直接调 Gateway 强制修正(绕过 EvaluateAndAct,保护期期间强制执行,无论套餐/流量/实名状态如何)
|
||||
> - 保护期**结束后**(保护期 Key 不存在) → 调 `EvaluateAndAct` 重新评估正常停复机条件
|
||||
> - 两种路径不可混淆:保护期内的强制修正是"一致性保障",不能被三条件判断覆盖
|
||||
|
||||
**Handler 不再包含的函数**(全部迁移到 StopResumeService):
|
||||
- `checkStopResume`、`shouldStopCard`、`hasAvailablePackage`
|
||||
- `stopCardByUsageExhausted`、`resumeCardByPackageAvailable`
|
||||
- 任何直接调用 `h.db.Model()` 的语句
|
||||
|
||||
---
|
||||
|
||||
### 决策 5:PollingQueueManager 接口设计
|
||||
|
||||
```go
|
||||
// PollingQueueManager 统一 Redis 轮询队列操作
|
||||
// 两个进程(API 进程和 Worker 进程)共享,仅依赖 Redis Client
|
||||
type PollingQueueManager interface {
|
||||
// DequeueReady 原子出队到期卡(Lua 脚本:ZRANGEBYSCORE + ZREM 服务端原子执行)
|
||||
// 只取 score ≤ now 的到期卡,不触碰未来项
|
||||
// taskType: realname | carddata | package | protect
|
||||
// shardID: 0 到 shardCount-1
|
||||
// batchSize: 每次出队数量上限
|
||||
DequeueReady(ctx context.Context, shardID int, taskType string, batchSize int) ([]CardEntry, error)
|
||||
|
||||
// Requeue 将卡重新入队指定时间
|
||||
Requeue(ctx context.Context, cardID uint, taskType string, nextCheckAt time.Time) error
|
||||
|
||||
// RemoveFromAllQueues 从所有分片的所有4个队列(含protect)移除
|
||||
RemoveFromAllQueues(ctx context.Context, cardID uint) error
|
||||
|
||||
// EnqueueManual 手动触发入队(List RPUSH,调度器优先消费)
|
||||
EnqueueManual(ctx context.Context, cardID uint, taskType string) error
|
||||
|
||||
// OnCardDeleted 卡删除事件处理(移除队列 + 清理缓存)
|
||||
OnCardDeleted(ctx context.Context, cardID uint) error
|
||||
|
||||
// GetQueueDepth 获取分片队列深度(用于背压检测和监控统计)
|
||||
GetQueueDepth(ctx context.Context, shardID int, taskType string) (int64, error)
|
||||
}
|
||||
|
||||
// CardEntry 出队卡信息
|
||||
type CardEntry struct {
|
||||
CardID uint
|
||||
Score float64 // Unix 时间戳(到期时间)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 决策 6:Scheduler 精简设计(伪代码)
|
||||
|
||||
```
|
||||
Scheduler:
|
||||
依赖:PollingQueueManager、PollingConfigManager、Asynq Client、
|
||||
PackageActivationHandler、DataResetHandler
|
||||
不再包含:DB查询、配置加载、卡初始化、Gateway调用、Callback注册
|
||||
|
||||
Start(ctx):
|
||||
cardInitializer.Run(ctx) // 异步,后台渐进式初始化
|
||||
configManager.Start(ctx) // 定时刷新配置
|
||||
go scheduleLoop(ctx) // 调度主循环
|
||||
|
||||
scheduleLoop(ctx):
|
||||
ticker = time.NewTicker(1秒)
|
||||
activationTicker = time.NewTicker(10秒) // 套餐过期检测周期
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
for shardID in 0..shardCount-1:
|
||||
go processShardSchedule(ctx, shardID) // 并行消费分片
|
||||
case <-activationTicker.C:
|
||||
// ⚠️ 必须保留:套餐过期检测和流量重置(决策 9)
|
||||
packageActivationHandler.HandlePackageActivationCheck(ctx)
|
||||
dataResetHandler.HandleDataReset(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
processShardSchedule(ctx, shardID):
|
||||
for taskType in [realname, carddata, package, protect]:
|
||||
// 背压检测
|
||||
depth = queueMgr.GetQueueDepth(ctx, shardID, taskType)
|
||||
if depth > BackpressureThreshold:
|
||||
continue // 跳过,Asynq 已有大量待处理任务
|
||||
// 优先消费手动触发队列
|
||||
processManualQueue(ctx, taskType, MaxManualBatch)
|
||||
// Lua 脚本原子出队到期卡(score ≤ now)
|
||||
entries = queueMgr.DequeueReady(ctx, shardID, taskType, ScheduleBatchSize)
|
||||
enqueueBatch(ctx, entries, taskType) // 批量推入 Asynq
|
||||
|
||||
enqueueBatch(ctx, entries, taskType):
|
||||
for entry in entries:
|
||||
task = asynq.NewTask(taskType, payload{CardID: entry.CardID})
|
||||
// ⚠️ MaxRetry(0) 保持不变(决策 12):避免与 Scheduler 出队产生并发双重处理
|
||||
err = asynqClient.Enqueue(task, asynq.Queue("polling"), asynq.MaxRetry(0))
|
||||
if err != nil:
|
||||
// 回退:Asynq 入队失败时,将卡重新放回分片队列,防止卡丢失
|
||||
queueMgr.Requeue(ctx, entry.CardID, taskType, time.Now())
|
||||
logger.Error("Asynq入队失败,已回退到分片队列", cardID=entry.CardID, error=err)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 决策 7:CardInitializer 分片初始化伪代码
|
||||
|
||||
```
|
||||
CardInitializer:
|
||||
依赖:Store(DB查询)、PollingQueueManager、PollingConfigManager
|
||||
状态:progress(已处理卡数)、total(总卡数)、completed(是否完成)
|
||||
|
||||
Run(ctx):
|
||||
total = store.CountIotCards(ctx) // 查询总卡数
|
||||
offset = 0
|
||||
for offset < total:
|
||||
// 分批加载,每批 10万张
|
||||
cards = store.ListIotCards(ctx, offset, BatchSize=100000)
|
||||
for card in cards:
|
||||
if !card.EnablePolling: continue // 跳过禁用轮询的卡
|
||||
cfg = configManager.MatchConfig(card)
|
||||
if cfg == nil: continue // 无匹配配置,跳过
|
||||
shardID = card.ID % shardCount // 分片路由
|
||||
for taskType in cfg.EnabledTaskTypes:
|
||||
// ZADD:score = now + initialDelay(随机散列,避免同时触发)
|
||||
initialDelay = rand.Intn(cfg.Interval)
|
||||
queueMgr.Requeue(ctx, card.ID, taskType, now.Add(initialDelay))
|
||||
progress += len(cards)
|
||||
offset += BatchSize
|
||||
time.Sleep(500ms) // 批次间休眠,减少对 DB 和 Redis 的压力
|
||||
completed = true
|
||||
|
||||
GetProgress() → InitProgress:
|
||||
return {Total: total, Processed: progress, Completed: completed}
|
||||
```
|
||||
|
||||
### 决策 8:Phase 4/5 原子部署(解决迁移断层)
|
||||
|
||||
**问题**:Phase 4 新 Handler 使用 `PollingBase.requeueCard()` 写入分片键(`polling:shard:N:queue:type`),Phase 5 新 Scheduler 从分片键读取。但若 Phase 4 和 Phase 5 分开部署,中间窗口期旧 Scheduler 仍从非分片键读取 → 新 Handler 重入队到分片键的卡永久消失。
|
||||
|
||||
**决定**:Phase 4 和 Phase 5 **必须原子部署**(同一次上线),取消中间 24 小时观察窗口。
|
||||
|
||||
**理由**:
|
||||
- 双写方案(同时写新旧两套键)增加代码复杂度且引入清理问题
|
||||
- Phase 4 和 Phase 5 共同构成"新出队/入队管线",拆开部署无意义
|
||||
- 原子部署配合 Phase 1-3 的逐步准备,风险可控
|
||||
|
||||
**回滚方案**:原子回滚 Phase 4+5 → 恢复旧 `polling_handler.go` + 旧 `scheduler.go` + 旧 `callbacks.go`。
|
||||
|
||||
---
|
||||
|
||||
### 决策 9:Scheduler 保留套餐过期检测和流量重置触发
|
||||
|
||||
**问题**:当前 `scheduler.go` 的 `processSchedule()` 每 10 秒调用 `PackageActivationHandler.HandlePackageActivationCheck()` 和 `DataResetHandler.HandleDataReset()`。精简 Scheduler 为"纯调度循环"会丢失这两个核心业务触发器。
|
||||
|
||||
**决定**:精简后的 Scheduler 保留这两个定时触发调用。
|
||||
|
||||
**Scheduler 精简后完整职责**:
|
||||
1. 启动 CardInitializer.Run(ctx)(异步)
|
||||
2. 启动 PollingConfigManager.Start(ctx)(定时刷新)
|
||||
3. 运行 scheduleLoop:
|
||||
- 分片出队 → 推入 Asynq(核心调度)
|
||||
- 每 10 秒调用 `PackageActivationHandler.HandlePackageActivationCheck(ctx)`
|
||||
- 每 10 秒调用 `DataResetHandler.HandleDataReset(ctx)`
|
||||
4. 背压检测
|
||||
|
||||
**目标行数调整**:< 250 行(原 < 200 行,因保留两个触发器)。
|
||||
|
||||
---
|
||||
|
||||
### 决策 10:PollingLifecycleService 替代 callbacks.go 生命周期方法
|
||||
|
||||
**问题**:`callbacks.go` 实现了 `PollingCallback` 接口,被 `iot_card/service.go` 在卡创建/状态变更/启用/禁用/删除时调用。`PollingQueueManager` 设计为"只依赖 Redis Client,无 DB 依赖",无法承担需要配置匹配和 DB 查询的生命周期方法。
|
||||
|
||||
**决定**:新增 `PollingLifecycleService`,封装「配置匹配 + 队列操作」组合逻辑。
|
||||
|
||||
```go
|
||||
// PollingLifecycleService 卡生命周期轮询管理
|
||||
// 替代 callbacks.go 和 api_callback.go 中的生命周期方法
|
||||
// 两个进程(API 和 Worker)共享同一实现
|
||||
type PollingLifecycleService struct {
|
||||
queueMgr *PollingQueueManager
|
||||
configMgr *PollingConfigManager
|
||||
cardStore IotCardStore
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// OnCardCreated 新卡创建后初始化轮询
|
||||
func (s *PollingLifecycleService) OnCardCreated(ctx context.Context, cardID uint) error
|
||||
// OnBatchCardsCreated 批量导入后批量初始化
|
||||
func (s *PollingLifecycleService) OnBatchCardsCreated(ctx context.Context, cardIDs []uint) error
|
||||
// OnCardStatusChanged 卡状态变化后重新匹配配置
|
||||
func (s *PollingLifecycleService) OnCardStatusChanged(ctx context.Context, cardID uint) error
|
||||
// OnCardEnabled 卡启用后初始化轮询
|
||||
func (s *PollingLifecycleService) OnCardEnabled(ctx context.Context, cardID uint) error
|
||||
// OnCardDisabled 卡禁用后移除所有队列
|
||||
func (s *PollingLifecycleService) OnCardDisabled(ctx context.Context, cardID uint) error
|
||||
// OnCardDeleted 卡删除后移除所有队列并清理缓存
|
||||
func (s *PollingLifecycleService) OnCardDeleted(ctx context.Context, cardID uint) error
|
||||
```
|
||||
|
||||
**PollingLifecycleService 实现 `PollingCallback` 接口**,`iot_card/service.go` 无需感知替换。
|
||||
|
||||
---
|
||||
|
||||
### 决策 11:MonitoringService 适配分片队列
|
||||
|
||||
**问题**:`MonitoringService` 直接读取非分片键(`polling:queue:realname` 等)。分片后这些键不再有数据,监控指标全部返回 0。
|
||||
|
||||
**决定**:
|
||||
1. `PollingQueueManager` 新增 `GetTotalQueueDepth(ctx, taskType) (int64, error)` 方法,聚合所有分片的 `ZCard`
|
||||
2. `MonitoringService` 注入 `PollingQueueManager`,替代直接 Redis 调用
|
||||
|
||||
```go
|
||||
// GetTotalQueueDepth 获取指定任务类型的总队列深度(聚合所有分片)
|
||||
func (m *PollingQueueManager) GetTotalQueueDepth(ctx context.Context, taskType string) (int64, error) {
|
||||
var total int64
|
||||
for i := 0; i < m.shardCount; i++ {
|
||||
depth, err := m.GetQueueDepth(ctx, i, taskType)
|
||||
if err != nil { continue }
|
||||
total += depth
|
||||
}
|
||||
return total, nil
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 决策 12:保持 Asynq MaxRetry(0)
|
||||
|
||||
**问题**:当前设计使用 `MaxRetry(0)`,失败后通过 `requeueCard` 放回 Redis Sorted Set 延后处理。若改为 `MaxRetry(3)`,Asynq 重试期间同一张卡可能被 Scheduler 从 Redis 队列再次取出,导致并发双重处理。
|
||||
|
||||
**决定**:保持 `MaxRetry(0)` 不变。
|
||||
|
||||
**理由**:
|
||||
- 当前的"软重试"机制(失败 → requeueCard → 下个轮询周期重新处理)更安全
|
||||
- 避免 Gateway 重复调用(停机已成功但 DB 更新失败 → 重试再次停机)
|
||||
- 避免 Asynq 重试窗口与 Scheduler 出队的并发冲突
|
||||
|
||||
---
|
||||
|
||||
### 决策 13:carddata Handler 必须保留跨月流量边界检测逻辑
|
||||
|
||||
**问题**:当前 `HandleCarddataCheck` 包含复杂的跨月检测逻辑(月份切换检测、`current_month_start_date` 比较、上月总量保存到 `last_month_total_mb`、当月计数器重置)。这不是"自然包含"在 Gateway 数据采集步骤中的,需要显式保留。
|
||||
|
||||
**决定**:`polling_carddata_handler.go` 的 `processCard` 方法须完整迁移以下逻辑:
|
||||
1. Gateway 返回月度总流量后,与 `card.CurrentMonthStartDate` 比较检测跨月
|
||||
2. 跨月时:保存 `card.LastMonthTotalMB`、重置 `card.CurrentMonthUsageMB`、更新 `card.CurrentMonthStartDate`
|
||||
3. 同月时:计算增量 delta = gateway值 - card.LastMonthTotalMB
|
||||
4. 记录流量历史到 `data_usage_records`
|
||||
|
||||
---
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
| 风险 | 严重度 | 缓解措施 |
|
||||
|------|--------|---------|
|
||||
| 重构范围大(7个文件新建/重写),引入回归 Bug | 高 | 逐 Phase 替换,每个 Phase 独立验证;使用 PostgreSQL MCP 验证关键数据路径 |
|
||||
| polling_handler.go 重写可能遗漏边界逻辑 | 高 | 对照旧代码逐行比对;重点检查 cardCondition、matchPollingConfig、retry 逻辑 |
|
||||
| **新增 `not_realname` 停机条件导致存量卡批量停机(S3)** | **高** | **Phase 2 实施前,用 PostgreSQL MCP 统计「在线+未实名+普通卡」的数量;与业务方确认是否允许部署后批量停机;若影响较大,可通过给不匹配的配置添加豁免期或先灰度单一运营商** |
|
||||
| Lua 脚本出队的消费性语义(卡被移除后须重入队)| 中 | 验证重入队逻辑:Handler 处理完成后必须调 Requeue;异常时由 Asynq 重试覆盖 |
|
||||
| 分片队列需要一次性全量重新初始化 | 中 | 初始化完成前 Scheduler 不出队;提供进度监控接口;初始化幂等(重复加入同一队列只更新 score) |
|
||||
| Scheduler 拆分后依赖注入复杂度增加 | 低 | 在 cmd/worker/main.go 中按固定顺序组装:QueueMgr → ConfigMgr → Initializer → Scheduler |
|
||||
| Lua 原子出队后 Asynq 入队失败,卡可能丢失 | 中 | `enqueueBatch` 入队失败时立即调 `Requeue` 回退到分片队列;极端情况(Redis 连接断开)依赖 Worker 重启后 `CardInitializer` 全量重建 |
|
||||
| 现有 36 个 HTTP 接口兼容性 | 低 | 32个接口只依赖 Redis + Store,零改动;4个监控接口需适配分片队列(内部聚合 ZCard,对外响应格式不变) |
|
||||
|
||||
**Trade-off 说明**:
|
||||
- 分片引入后,`RemoveFromAllQueues` 需要遍历所有 N 个分片的所有 4 个队列 = 4N 次 Redis 操作。16分片 = 64次操作,对于删除卡这种低频操作可以接受。
|
||||
- Lua 脚本原子出队的消费性语义要求每个 Task Handler 都必须在完成后调用 `Requeue`,否则卡将永久从队列消失。需在代码 Review 时重点检查。
|
||||
|
||||
## Migration Plan
|
||||
|
||||
### Phase 1:基础准备(DB 迁移 + 常量)
|
||||
**变更内容**:
|
||||
- `tb_device` 新增 `enable_polling BOOLEAN NOT NULL DEFAULT TRUE` 列
|
||||
- `internal/model/device.go` 新增 `EnablePolling bool` 字段
|
||||
- `pkg/constants/iot.go` 新增 `StopReasonNoPackage`、`StopReasonNotRealname`
|
||||
- `pkg/constants/redis.go` 新增 `RedisPollingShardQueueKey(shardID int, taskType string)`
|
||||
- 执行迁移验证
|
||||
|
||||
**可部署状态**:✅ 无功能变化,仅数据库结构和常量变更
|
||||
**回滚方案**:`ALTER TABLE tb_device DROP COLUMN IF EXISTS enable_polling;` 回滚迁移,删除新增常量和 Redis Key 函数。新增字段有默认值(true),回滚前无代码依赖。
|
||||
|
||||
### Phase 2:StopResumeService 重写(停复机逻辑统一)
|
||||
**变更内容**:
|
||||
- 新增 `hasValidPackage`(修复设备套餐 Bug1)
|
||||
- 新增 `isTrafficExhausted`、`isRealnameOK`、`checkStopReasons`
|
||||
- 新增 `EvaluateAndAct` 统一入口
|
||||
- 修改 `stopCardWithRetry` 接收 stopReason 参数
|
||||
- 修改 `resumeSingleCard`、`resumeDeviceCards` 按新复机条件
|
||||
- 删除旧 `hasAvailablePackage`
|
||||
|
||||
**可部署状态**:✅ StopResumeService 兼容旧调用,新方法独立,可安全部署
|
||||
**回滚方案**:删除新增方法(`EvaluateAndAct`、`hasValidPackage` 等),恢复旧 `hasAvailablePackage`。新方法在 Phase 3 之前无调用者,回滚不影响现有功能。
|
||||
|
||||
### Phase 3:PollingQueueManager + PollingConfigManager(基础组件)
|
||||
**变更内容**:
|
||||
- 新建 `queue_manager.go`(含分片 Sorted Set、Lua 脚本原子出队、分批 ZREM)
|
||||
- 新建 `config_manager.go`(DB 加载 → 内存缓存 → Redis 同步 → 5分钟定时刷新)
|
||||
|
||||
**前置依赖**:Phase 1 常量(`RedisPollingShardQueueKey`)
|
||||
**被依赖方**:Phase 4 的 `PollingBase` 和 Phase 5 的 `Scheduler` 均依赖这两个组件
|
||||
|
||||
**可部署状态**:✅ 新增文件,无功能变化(旧 Scheduler 和 callbacks.go 仍在运行),新组件创建但尚未被调用
|
||||
**回滚方案**:删除新建的 `queue_manager.go` 和 `config_manager.go`。纯新增文件,回滚不影响现有功能。
|
||||
|
||||
### Phase 4+5(原子部署):Task Handler 拆分 + Scheduler 精简 + CardInitializer + 删除旧文件
|
||||
|
||||
> ⚠️ **Phase 4 和 Phase 5 必须原子部署**:新 Handler 的 `PollingBase.requeueCard()` 写入分片键,新 Scheduler 从分片键读取。若分开部署,中间窗口期旧 Scheduler 仍读非分片键 → 卡永久丢失轮询。详见决策 8。
|
||||
|
||||
**Phase 4 变更内容**:
|
||||
- 新建 `polling_realname/carddata/package/protect_handler.go`
|
||||
- 新建 `polling_base.go`(共享基类,依赖 Phase 3 的 QueueManager 和 ConfigManager)
|
||||
- `polling_carddata_handler.go` 须完整迁移跨月流量边界检测逻辑(详见决策 13)
|
||||
- 所有直接 DB 操作替换为 Store 方法调用
|
||||
- 所有停复机决策改为调用 `StopResumeService.EvaluateAndAct()`
|
||||
- 注册 4 个新 Handler 到 Asynq(`MaxRetry(0)` 不变,详见决策 12)
|
||||
- 删除旧 `polling_handler.go`
|
||||
|
||||
**Phase 5 变更内容**:
|
||||
- 新建 `initializer.go`(分片渐进式初始化)
|
||||
- 新建 `lifecycle_service.go`(替代 callbacks.go 的卡生命周期方法,详见决策 10)
|
||||
- 精简 `scheduler.go`(< 250行,保留调度循环 + 套餐过期检测触发 + 流量重置触发,详见决策 9)
|
||||
- 更新 `MonitoringService`(适配分片队列,详见决策 11)
|
||||
- 删除 `callbacks.go`、`api_callback.go`(队列操作由 PollingQueueManager 替代,生命周期方法由 PollingLifecycleService 替代)
|
||||
- 更新 `cmd/worker/main.go` 启动流程
|
||||
- 更新所有 `PollingCallback` 引用 → `PollingLifecycleService`
|
||||
|
||||
**前置依赖**:Phase 2(StopResumeService)+ Phase 3(QueueManager、ConfigManager)
|
||||
|
||||
**可部署状态**:✅ Asynq 任务类型常量不变;32个HTTP接口零改动,4个监控接口适配分片
|
||||
**回滚方案**:原子回滚 Phase 4+5 → 恢复旧 `polling_handler.go` + 旧 `scheduler.go` + 旧 `callbacks.go` + 旧 `api_callback.go`,回退 `cmd/worker/main.go` 和 `MonitoringService`。**此为最高风险节点**,涉及出队机制变更和队列数据结构变更。建议:① 部署前清空旧队列(初始化器会重建);② 灰度观察 48 小时。
|
||||
|
||||
### Phase 6:轮询管控 API(enable_polling 接口)
|
||||
**变更内容**:
|
||||
- DTO:`UpdateAssetPollingStatusRequest/Response`
|
||||
- Store:`device_store.UpdatePollingStatus`
|
||||
- Service:`AssetPollingService.UpdatePollingStatus`
|
||||
- Handler:`asset.UpdatePollingStatus`
|
||||
- Route:`PATCH /api/admin/assets/:asset_type/:id/polling-status`
|
||||
- Docs:更新 docs.go 和 gendocs/main.go
|
||||
|
||||
**可部署状态**:✅ 新增接口,不影响现有功能
|
||||
**回滚方案**:删除新增路由、Handler、Service、Store 方法和 DTO。纯新增接口,回滚不影响现有功能。
|
||||
|
||||
### Phase 7:全面验证
|
||||
- DB 验证(PostgreSQL MCP):设备套餐停复机、行业卡实名豁免
|
||||
- Redis 验证:Lua 脚本原子性、protect 队列清理
|
||||
- 接口验证:enable_polling 接口、分片队列监控
|
||||
- 兼容性验证:36个已有接口全量回归
|
||||
|
||||
---
|
||||
|
||||
### 决策 14(新增):设备维度停复机幂等锁(防止多卡并发重复调 Gateway)
|
||||
|
||||
**问题背景**:设备下的多张卡分布在不同分片,可能在同一调度周期内同时被 Scheduler 出队并触发 `EvaluateAndAct`:
|
||||
|
||||
```
|
||||
card-A(shard 3)→ EvaluateAndAct → stopDeviceCards(deviceID=10) ← 同时
|
||||
card-B(shard 7)→ EvaluateAndAct → stopDeviceCards(deviceID=10) ← 同时
|
||||
card-C(shard 2)→ EvaluateAndAct → stopDeviceCards(deviceID=10) ← 同时
|
||||
```
|
||||
|
||||
三次 `stopDeviceCards` 均遍历设备下所有在线卡,对每张卡调 Gateway 停机 → **每张卡被停机 3 次**,大量重复 Gateway 调用。
|
||||
|
||||
**决定**:在 `stopDeviceCards` 和 `resumeDeviceCards` 执行前,使用 Redis 分布式锁(`SetNX`)确保设备维度操作的幂等性。
|
||||
|
||||
**实现方案**:
|
||||
|
||||
```go
|
||||
// RedisPollingDeviceOpLockKey 设备维度停复机操作锁
|
||||
// TTL 建议 30 秒(覆盖 stopDeviceCards 最长执行时间)
|
||||
func RedisPollingDeviceOpLockKey(deviceID uint) string {
|
||||
return fmt.Sprintf("polling:device:op_lock:%d", deviceID)
|
||||
}
|
||||
|
||||
// stopDeviceCards 中加锁
|
||||
func (s *Service) stopDeviceCards(ctx context.Context, deviceID uint, stopReason string) {
|
||||
lockKey := constants.RedisPollingDeviceOpLockKey(deviceID)
|
||||
locked, _ := s.redis.SetNX(ctx, lockKey, time.Now().String(), 30*time.Second).Result()
|
||||
if !locked {
|
||||
s.logger.Debug("设备停复机操作已在进行中,跳过重复调用", zap.Uint("device_id", deviceID))
|
||||
return // 其他协程正在处理,本次跳过
|
||||
}
|
||||
defer s.redis.Del(ctx, lockKey)
|
||||
// ... 执行停机逻辑
|
||||
}
|
||||
```
|
||||
|
||||
同样适用于 `resumeDeviceCards`,使用同一把锁(`op_lock` 覆盖停机和复机,防止同一设备同时触发相反操作)。
|
||||
|
||||
**需要新增的常量**(`pkg/constants/redis.go`):
|
||||
|
||||
```go
|
||||
// RedisPollingDeviceOpLockKey 设备维度停复机操作锁 Key
|
||||
// TTL 30 秒,防止设备下多张卡并发触发重复 Gateway 调用
|
||||
func RedisPollingDeviceOpLockKey(deviceID uint) string {
|
||||
return fmt.Sprintf("polling:device:op_lock:%d", deviceID)
|
||||
}
|
||||
```
|
||||
|
||||
**需要新增的任务**(在 tasks.md Phase 2.5 和 2.8 中分别加入加锁逻辑,并在 Phase 1 的 redis.go 中新增 Key 函数)。
|
||||
|
||||
---
|
||||
|
||||
### 决策 15:修复 `getCardCondition` 中 `suspended` 永远不返回的问题(M1)
|
||||
|
||||
**问题背景**:
|
||||
现有 `getCardCondition` 逻辑如下:
|
||||
```go
|
||||
if card.RealNameStatus != RealNameStatusVerified { return "not_real_name" }
|
||||
if card.NetworkStatus == 1 { return "activated" }
|
||||
return "real_name" // 停机+已实名卡落到这里
|
||||
```
|
||||
- 停机且已实名的卡返回 `"real_name"`,而 `"real_name"` 配置通常 `carddata_check_interval=null`、`package_check_interval=null`
|
||||
- 结果:停机卡不再被 carddata/package 轮询,`EvaluateAndAct` 不会被调用,**无法自动复机**
|
||||
- `card_condition='suspended'` 在 DTO 中作为合法值存在,但 `getCardCondition` 从未返回该值,是死代码
|
||||
|
||||
**修复方案**:在 `PollingConfigManager.getCardCondition` 中,**最先**检查网络状态:
|
||||
```go
|
||||
func getCardCondition(card *model.IotCard) string {
|
||||
// 停机卡(无论实名状态),使用独立的 suspended 配置
|
||||
// 停机卡需要继续轮询 carddata/package 以便检测复机条件
|
||||
if card.NetworkStatus == 0 {
|
||||
return "suspended"
|
||||
}
|
||||
// 在线卡按实名状态细分
|
||||
if card.RealNameStatus != constants.RealNameStatusVerified {
|
||||
return "not_real_name"
|
||||
}
|
||||
return "activated"
|
||||
}
|
||||
```
|
||||
|
||||
**配套数据库配置**:需为 `suspended` 条件添加对应的 PollingConfig,包含 carddata/package 检查间隔(建议与 `activated` 相同或更低频率),确保停机卡能持续被轮询以便自动复机。示例:
|
||||
```sql
|
||||
INSERT INTO tb_polling_config (config_name, card_condition, priority, carddata_check_interval, package_check_interval, status)
|
||||
VALUES ('停机卡轮询', 'suspended', 25, 3600, 3600, 1);
|
||||
```
|
||||
|
||||
**注意**:`"real_name"` 条件从 `getCardCondition` 的返回值中删除(仅保留 not_real_name / activated / suspended)。现有配置中 `card_condition='real_name'` 的条目不再被匹配,可按需清理或转换。
|
||||
|
||||
---
|
||||
|
||||
## Open Questions
|
||||
|
||||
1. **分片数量配置化**:shardCount 是否需要可配置(当前设计为常量16)?
|
||||
建议:Phase 4 实现时作为 `PollingConfig.ShardCount` 写入 Redis,`PollingConfigManager` 读取,修改无需重启。
|
||||
|
||||
2. `polling_handler.go` 中 `HandleCarddataCheck` 调用的 `usageService.DeductDataUsage()`,在拆分后应归属于 `carddata_handler` 还是 `StopResumeService`?
|
||||
→ **决定**:保留在 `carddata_handler`,仅为数据计算,不是停复机决策。
|
||||
|
||||
3. `PackageActivationHandler` 的直接 DB 操作(4处)是否在本次重构范围内?
|
||||
→ **不在本次范围**,计划在单独提案中处理,避免本次范围蔓延。
|
||||
|
||||
4. ~~初始化期间(initCompleted=false),新注册卡如何处理?~~ **已解决**(决策 10)
|
||||
通过 `PollingLifecycleService.OnCardCreated()` 处理:匹配配置 → 调用 `PollingQueueManager.Requeue()` 写入分片队列。与初始化并发进行,ZADD 的幂等性保证不重复入队。`PollingLifecycleService` 实现 `PollingCallback` 接口,`iot_card/service.go` 无需感知替换。
|
||||
@@ -0,0 +1,141 @@
|
||||
## Why(已合并 polling-logic-redesign,可废弃该提案)
|
||||
|
||||
轮询系统经过多次迭代后出现了四类问题,须同步解决:
|
||||
|
||||
### 一、结构性问题(影响长期可维护性)
|
||||
|
||||
**问题 1:`polling_handler.go`(1360行)职责爆炸**
|
||||
Asynq Task Handler 本应只做「数据采集」,但它同时承担:停复机决策(与 stop_resume_service.go 重复)、直接 DB 操作(15处绕过 Store 层)、直接 Gateway 调用(7处无重试)、缓存管理、队列管理。
|
||||
|
||||
**问题 2:停复机逻辑分裂,可靠性不一致**
|
||||
`polling_handler.go` 和 `stop_resume_service.go` 各有一套停复机实现,`hasAvailablePackage` 重复定义;前者 Gateway 调用无重试,后者有 3 次重试,同一业务,不同可靠性。
|
||||
|
||||
**问题 3:Scheduler(764行)身兼六职**
|
||||
调度循环 + 配置管理 + 卡初始化 + 生命周期回调 + 套餐激活触发 + 流量重置触发。`callbacks.go` 与 `api_callback.go` 大量重复代码(两套相同的 Redis 队列操作逻辑)。
|
||||
|
||||
### 二、线上 Bug(需立即修复)
|
||||
|
||||
**Bug 1(停复机判断错误):设备套餐被忽略 → 误停机**
|
||||
`PackageUsage` 设备套餐存 `device_id`,但 `shouldStopCard` / `hasAvailablePackage` 只查 `iot_card_id`,购买设备套餐后卡仍被误停机。
|
||||
|
||||
**Bug 2(停复机条件不完整):停机条件缺失**
|
||||
当前停机条件仅为「无有效套餐」,未判断虚流量是否耗尽,未区分行业卡/非行业卡的实名要求。
|
||||
|
||||
**Bug 3(保护期队列遗漏):protect 队列残留脏数据**
|
||||
`removeFromAllQueues` 只清 3 个队列(realname、carddata、package),漏掉 `protect` 队列,删除卡后 protect 队列残留。
|
||||
|
||||
**Bug 4(出队竞态):ZRANGEBYSCORE + ZREMRANGEBYSCORE 非原子**
|
||||
两层风险:① 高并发下多 Worker 读取同一批卡,导致重复停机/复机 Gateway 调用;② `ZRANGEBYSCORE` 有 LIMIT 但 `ZREMRANGEBYSCORE` 无 LIMIT,当到期卡数超过批次大小时,超出部分被删除但从未被读取——**卡永久丢失,不再被轮询**。
|
||||
|
||||
### 三、规模挑战(千万级支撑设计)
|
||||
|
||||
当前设计假设百万级(100K 初始化批次、50K 调度批次、50 并发)。千万级规模下,单个 Sorted Set 深度达千万条,需引入:**分片队列**(Sharded Sorted Set)、**多 Worker 分片消费**(无协调开销)、**分片并行初始化**。
|
||||
|
||||
### 四、管理接口兼容性确认
|
||||
|
||||
现有 36 个轮询管理 HTTP 接口(配置/手动触发/告警/监控/并发/清理)经全量检查,**绝大部分 Service 只依赖 Redis Client 和 Store,不依赖 Scheduler 对象**。其中 **32 个接口全部兼容,零改动**;**4 个监控统计接口需适配分片队列**(`MonitoringService` 读取队列深度的 Key 从 `polling:queue:{type}` 变更为聚合所有分片 `polling:shard:{N}:queue:{type}` 的 `ZCard` 之和)。
|
||||
|
||||
## What Changes
|
||||
|
||||
**功能修复(来自 polling-logic-redesign,合并入本提案):**
|
||||
- 修复 `hasAvailablePackage` / `shouldStopCard`:根据卡绑定关系动态切换 `iot_card_id` / `device_id` 查询
|
||||
- 重写停复机判断:新增三条件停机(无套餐/流量耗尽/未实名),区分行业卡
|
||||
- 新增停机原因精细化常量:`no_package`、`not_realname`
|
||||
- 新增 `Device.enable_polling` 字段
|
||||
- 新增 HTTP 接口:`PATCH /api/admin/assets/:asset_type/:id/polling-status`
|
||||
|
||||
**架构重构:**
|
||||
- 拆分 `polling_handler.go`(1360行)→ 4 个专注 Handler(实名/流量/套餐/保护期)+ 共享基类,每个 < 300行
|
||||
- 新增 `PollingQueueManager`:统一 Redis 队列操作,Lua 脚本原子出队(ZRANGEBYSCORE + ZREM 原子执行),修复 protect 遗漏 Bug
|
||||
- 新增 `PollingConfigManager`:独立配置管理,支持 5 分钟定时刷新
|
||||
- 新增 `CardInitializer`:独立初始化模块,支持分片并行
|
||||
- 精简 `Scheduler`(目标 < 250行):保留调度循环 + 套餐过期检测触发 + 流量重置触发(`PackageActivationHandler.HandlePackageActivationCheck` 和 `DataResetHandler.HandleDataReset` 必须保留在调度主循环中)
|
||||
- 新增 `PollingLifecycleService`:替代 `callbacks.go` + `api_callback.go` 中卡生命周期方法(`OnCardCreated`/`OnCardStatusChanged`/`OnCardEnabled`/`OnCardDisabled`/`OnCardDeleted`),依赖 `PollingQueueManager` + `PollingConfigManager`,封装「匹配配置 + 入队」组合逻辑
|
||||
- 删除 `callbacks.go` + `api_callback.go`(队列操作由 PollingQueueManager 替代,生命周期方法由 PollingLifecycleService 替代)
|
||||
- `MonitoringService` 适配分片队列:队列深度查询改为聚合所有分片的 `ZCard` 之和
|
||||
|
||||
**千万级规模设计:**
|
||||
- 分片 Sorted Set:`polling:shard:{0..N-1}:queue:{taskType}`,默认 16 分片
|
||||
- `CardInitializer` 按 `card_id % shard_count` 分桶入队
|
||||
- Scheduler 并行消费 N 个分片(每个分片独立 Lua 脚本原子出队)
|
||||
- 背压机制:单分片队列深度超阈值时,跳过该分片本轮调度
|
||||
|
||||
**重要设计约束:**
|
||||
- Asynq 任务提交保持 `MaxRetry(0)`(与当前设计一致),失败后通过 `requeueCard` 放回 Redis Sorted Set 延后处理,避免 Asynq 重试与 Scheduler 出队产生并发双重处理
|
||||
- `StopResumeCallback` 接口不在本次变更范围内(仅替换 `PollingCallback`),`triggerStopAfterExpiry()` 和 `checkAndTriggerSuspension()` 不受影响
|
||||
- Phase 4(Task Handler 拆分)和 Phase 5(Scheduler 精简)必须**原子部署**,不可分开上线(详见迁移计划)
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
- `polling-queue-manager`: 统一 Redis 队列操作封装——Lua 脚本原子出队(ZRANGEBYSCORE + ZREM 服务端原子执行,保留时间过滤语义)、分片 Sorted Set(支持千万级)、修复 protect 遗漏 Bug、入队/重入队/手动触发/删除的统一接口;替代 callbacks.go、api_callback.go、polling_handler.go 中所有分散的队列操作
|
||||
- `polling-config-manager`: 独立配置管理模块——从 DB 加载 `tb_polling_config`、同步到 Redis Hash(TTL 24h)、内存缓存(读写锁)、5 分钟定时自动刷新、`MatchConfig(card)` 按优先级返回第一个匹配配置
|
||||
- `card-initializer`: 独立卡初始化模块——渐进式分批(100K/批)、按分片入队(`card_id % shard_count`)、`enable_polling=false` 过滤、进度状态暴露给 MonitoringService
|
||||
- `polling-lifecycle-service`: 卡生命周期轮询管理——替代 `callbacks.go` 和 `api_callback.go` 中的 `OnCardCreated`/`OnBatchCardsCreated`/`OnCardStatusChanged`/`OnCardEnabled`/`OnCardDisabled`/`OnCardDeleted`,依赖 `PollingQueueManager`(队列操作)+ `PollingConfigManager`(配置匹配),封装「匹配配置 → 分片入队」组合逻辑;两个进程(API 和 Worker)共享
|
||||
- `asset-polling-control`: 资产轮询管控 HTTP 接口——`PATCH /api/admin/assets/:asset_type/:id/polling-status`,支持 card/device 两种资产类型,启用/禁用轮询
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
- `polling-stop-resume-logic`: 停复机逻辑统一到 `StopResumeService`——新增 `EvaluateAndAct()` 统一入口、修复设备套餐查询 Bug(`device_id` vs `iot_card_id`)、实现三条件停机判断(无套餐/流量耗尽/未实名)、完整复机条件(含行业卡豁免)、停机原因精细化
|
||||
- `polling-task-handlers`: `polling_handler.go` 拆分为 4 个专注文件——每个 Handler 只做数据采集,停复机通过 `StopResumeService.EvaluateAndAct()` 完成,消除所有直接 DB 操作和无重试 Gateway 调用;`polling_carddata_handler.go` 须完整保留当前 `HandleCarddataCheck` 中的跨月流量边界检测逻辑(月份切换检测、上月总量保存、当月计数器重置)
|
||||
- `polling-monitoring-service`: `MonitoringService` 适配分片队列——队列深度查询改为调用 `PollingQueueManager.GetTotalQueueDepth(taskType)` 聚合所有分片,替代直接读取旧的非分片 Redis Key
|
||||
|
||||
## Impact
|
||||
|
||||
### 新建文件
|
||||
|
||||
| 文件 | 职责 | 目标行数 |
|
||||
|------|------|---------|
|
||||
| `internal/task/polling_realname_handler.go` | 实名检查 Task Handler | < 200行 |
|
||||
| `internal/task/polling_carddata_handler.go` | 流量检查 Task Handler | < 300行 |
|
||||
| `internal/task/polling_package_handler.go` | 套餐检查 Task Handler | < 200行 |
|
||||
| `internal/task/polling_protect_handler.go` | 保护期一致性 Task Handler | < 200行 |
|
||||
| `internal/task/polling_base.go` | 共享基类(并发控制/缓存/重入队) | < 150行 |
|
||||
| `internal/polling/queue_manager.go` | 统一 Redis 队列操作 | < 200行 |
|
||||
| `internal/polling/config_manager.go` | 配置加载管理 | < 150行 |
|
||||
| `internal/polling/initializer.go` | 分片渐进式初始化 | < 250行 |
|
||||
| `internal/polling/lifecycle_service.go` | 卡生命周期轮询管理(替代 callbacks.go) | < 200行 |
|
||||
|
||||
### 修改文件
|
||||
|
||||
| 文件 | 变更类型 | 变更内容 |
|
||||
|------|---------|---------|
|
||||
| `internal/polling/scheduler.go` | 精简重写 | 保留调度循环 + 套餐过期/流量重置触发,< 250行 |
|
||||
| `internal/service/iot_card/stop_resume_service.go` | 扩展+修复 | 新增 EvaluateAndAct + 修复设备套餐 Bug + 三条件停机 |
|
||||
| `internal/model/device.go` | 字段新增 | `EnablePolling bool` |
|
||||
| `internal/handler/admin/asset.go` | 方法新增 | `UpdatePollingStatus` handler |
|
||||
| `internal/routes/asset.go` | 路由新增 | `PATCH /assets/:asset_type/:id/polling-status` |
|
||||
| `internal/store/postgres/device_store.go` | 方法新增 | `UpdatePollingStatus(ctx, id, enabled)` |
|
||||
| `pkg/constants/iot.go` | 常量新增 | `StopReasonNoPackage`、`StopReasonNotRealname` |
|
||||
| `pkg/constants/redis.go` | 函数新增 | `RedisPollingShardQueueKey(shard, taskType)` |
|
||||
| `internal/service/polling/monitoring_service.go` | 适配更新 | 队列深度查询改为聚合分片 ZCard |
|
||||
| `cmd/worker/main.go` | 启动流程更新 | 使用新组件,注册 4 个 Handler |
|
||||
| `pkg/queue/handler.go` | Handler 注册更新 | 注册 4 个新 Task Handler |
|
||||
| `cmd/api/docs.go` + `cmd/gendocs/main.go` | 文档更新 | 注册新 AssetHandler 方法 |
|
||||
|
||||
### 删除文件
|
||||
|
||||
| 文件 | 删除原因 |
|
||||
|------|---------|
|
||||
| `internal/task/polling_handler.go` | 拆分为 4 个专注文件后废弃 |
|
||||
| `internal/polling/callbacks.go` | 职责转移到 PollingQueueManager |
|
||||
| `internal/polling/api_callback.go` | 职责转移到 PollingQueueManager |
|
||||
|
||||
### 数据库迁移
|
||||
|
||||
| 表 | 变更 |
|
||||
|----|------|
|
||||
| `tb_device` | 新增 `enable_polling BOOLEAN NOT NULL DEFAULT TRUE` |
|
||||
|
||||
### 管理接口兼容性(零改动)
|
||||
|
||||
| 模块 | 接口数 | 兼容性 |
|
||||
|------|--------|--------|
|
||||
| 轮询配置管理(polling-configs) | 7个 | ✅ 完全兼容 |
|
||||
| 手动触发(polling-manual-trigger) | 6个 | ✅ 完全兼容 |
|
||||
| 告警管理(polling-alert-rules) | 6个 | ✅ 完全兼容 |
|
||||
| 监控统计(polling-stats) | 4个 | ⚠️ 需适配分片队列(聚合 ZCard) |
|
||||
| 并发控制(polling-concurrency) | 4个 | ✅ 完全兼容 |
|
||||
| 数据清理(data-cleanup) | 9个 | ✅ 完全兼容 |
|
||||
| **合计** | **36个** | **32个零改动 + 4个监控接口需适配** |
|
||||
@@ -0,0 +1,118 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 独立配置管理模块(PollingConfigManager)
|
||||
|
||||
新建 `internal/polling/config_manager.go`,提供 `PollingConfigManager` 类型,从 `scheduler.go` 中拆分配置相关职责。
|
||||
|
||||
**文件目标**:< 150 行,职责:DB 加载 → 内存缓存 → Redis 同步 → 定时刷新。
|
||||
|
||||
**从 Scheduler 提取的方法**:
|
||||
- `loadConfigs(ctx)`:从 DB 加载所有启用的 `tb_polling_config`
|
||||
- `syncConfigsToRedis(configs)`:写入 Redis Hash(TTL 24小时)
|
||||
- `MatchConfig(card)`:按优先级顺序返回第一个匹配的配置
|
||||
- `matchConfigConditions(config, card)`:判断卡是否满足配置条件
|
||||
- `getCardCondition(card)`:获取卡的匹配条件(carrier、simtype 等)
|
||||
|
||||
**Scheduler 修改后**:不包含任何 DB 查询或配置加载逻辑,通过 `configManager.MatchConfig(card)` 获取配置。
|
||||
|
||||
#### Scenario: 启动时加载配置并同步 Redis
|
||||
- **GIVEN** Worker 进程启动,DB 中有 5 条启用的 PollingConfig
|
||||
- **WHEN** `PollingConfigManager.Load(ctx)` 被调用
|
||||
- **THEN** 从 DB 加载 5 条配置,写入内存缓存(sync.RWMutex 保护),同步到 Redis Hash(TTL 24小时);加载完成日志记录「已加载 5 条轮询配置」
|
||||
|
||||
#### Scenario: 配置按优先级匹配
|
||||
- **GIVEN** 内存中有 3 条配置,优先级分别为 1、5、10(数字越小优先级越高)
|
||||
- **WHEN** 调用 `MatchConfig(card)` 匹配某张卡
|
||||
- **THEN** 按优先级升序遍历,返回第一个满足所有条件的配置;无匹配时返回 nil,该卡不加入轮询队列
|
||||
|
||||
#### Scenario: 配置匹配使用读锁不阻塞
|
||||
- **GIVEN** Scheduler 高频调用 `MatchConfig(card)`(每秒可能数千次)
|
||||
- **WHEN** 同时有定时刷新正在写入新配置(写锁)
|
||||
- **THEN** 读取操作等待写锁释放后继续,不发生数据竞争;写锁持有时间极短(内存赋值),不影响 Scheduler 调度性能
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 定时刷新配置(5分钟周期)
|
||||
|
||||
`PollingConfigManager.Start(ctx)` 启动后台 goroutine,每 5 分钟自动重新加载配置。
|
||||
|
||||
#### Scenario: 定时刷新不重启 Worker
|
||||
- **GIVEN** Worker 进程已运行 10 分钟,管理员在控制台修改了某条 PollingConfig 的轮询间隔
|
||||
- **WHEN** 距上次加载超过 5 分钟,定时器触发
|
||||
- **THEN** 自动重新从 DB 加载配置,更新内存缓存,新配置在下一轮调度中生效;无需重启 Worker 进程
|
||||
|
||||
#### Scenario: 刷新失败不影响当前配置
|
||||
- **GIVEN** 定时刷新时 DB 连接超时
|
||||
- **WHEN** `Load(ctx)` 返回 error
|
||||
- **THEN** 内存缓存保持原有配置不变(不清空),记录 Error 日志「配置刷新失败: xxx」;下一轮(5分钟后)重试
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 独立卡初始化模块(CardInitializer)
|
||||
|
||||
新建 `internal/polling/initializer.go`,提供 `CardInitializer` 类型,从 `scheduler.go` 中拆分渐进式初始化职责。
|
||||
|
||||
**文件目标**:< 250 行,职责:分批 DB 查询 → 分片路由入队 → 进度追踪 → enable_polling 过滤。
|
||||
|
||||
**从 Scheduler 提取的方法**:
|
||||
- `progressiveInit(ctx)`:分批加载卡并入队
|
||||
- `initCardsBatch(ctx, offset, limit)`:加载单批次卡
|
||||
- `initCardPolling(ctx, card)`:为单张卡匹配配置并入队
|
||||
|
||||
**Scheduler 修改后**:不包含任何初始化逻辑,通过 `cardInitializer.Run(ctx)` 异步启动,通过 `cardInitializer.GetProgress()` 查询进度。
|
||||
|
||||
#### Scenario: 渐进式分批初始化避免 DB 过载
|
||||
- **GIVEN** DB 中有 500 万张启用轮询的卡
|
||||
- **WHEN** `CardInitializer.Run(ctx)` 被调用
|
||||
- **THEN** 分批加载(每批 10 万张),批次间 Sleep 500ms;约 500 批次,每批处理后记录进度日志「初始化进度: 100000/5000000」;全部完成后 `initCompleted=true`,Scheduler 开始正常出队
|
||||
|
||||
#### Scenario: 跳过 enable_polling=false 的卡
|
||||
- **GIVEN** 10 万张卡中,5000 张卡的 `enable_polling=false`
|
||||
- **WHEN** 批次处理时遇到这 5000 张卡
|
||||
- **THEN** 这 5000 张卡不加入任何轮询队列(ZADD 跳过);其余 95000 张卡按正常流程入队
|
||||
|
||||
#### Scenario: 初始化幂等(重启不重复)
|
||||
- **GIVEN** Worker 重启,部分卡已在分片队列中(上次初始化留下)
|
||||
- **WHEN** 重新执行 `CardInitializer.Run(ctx)`
|
||||
- **THEN** ZADD 操作幂等:若卡已在队列中,只更新 score 为新的初始延迟时间(不添加重复条目);Redis Sorted Set 中不出现重复成员
|
||||
|
||||
#### Scenario: Scheduler 只在初始化完成后出队
|
||||
- **GIVEN** Worker 刚启动,初始化尚未完成(`initCompleted=false`)
|
||||
- **WHEN** Scheduler 的 `scheduleLoop` 执行
|
||||
- **THEN** 检查 `cardInitializer.GetProgress().Completed`,若为 false 则跳过本轮所有分片的出队;避免初始化中途 Scheduler 取到不完整队列
|
||||
|
||||
#### Scenario: 进度暴露给监控接口
|
||||
- **GIVEN** 管理员调用轮询状态监控接口
|
||||
- **WHEN** `MonitoringService` 调用 `cardInitializer.GetProgress()`
|
||||
- **THEN** 返回 `{Total: 5000000, Processed: 1500000, Completed: false}`;前端可展示初始化进度百分比
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Scheduler 精简到 < 250 行(Mi1 修复)
|
||||
|
||||
> **注意**:本 spec 原写 `< 200 行`,已与 `design.md` 决策 9 和 `tasks.md` 统一更正为 **`< 250 行`**。
|
||||
> 原因:精简后的 Scheduler 保留了套餐过期检测和流量重置两个触发器(见决策 9),额外占用约 50 行。
|
||||
|
||||
`internal/polling/scheduler.go` 精简后只保留纯调度循环逻辑,所有子职责委托给新建的三个模块。
|
||||
|
||||
**精简后 Scheduler 的完整职责**:
|
||||
1. 启动 `CardInitializer.Run(ctx)`(异步)
|
||||
2. 启动 `PollingConfigManager.Start(ctx)`(定时刷新)
|
||||
3. 运行 `scheduleLoop`:每秒 × N分片 × 4任务类型 的出队循环
|
||||
4. 背压检测:`GetQueueDepth > 阈值` 时跳过该分片
|
||||
|
||||
**禁止出现在精简后 Scheduler 中的内容**:
|
||||
- 任何 `db.Find()`、`db.Where()` 等 DB 操作
|
||||
- 配置加载逻辑(`loadConfigs`、`syncConfigsToRedis`)
|
||||
- 卡初始化逻辑(`progressiveInit`、`initCardsBatch`)
|
||||
- 任何 Callback 注册(`callbacks.go` 已删除)
|
||||
|
||||
#### Scenario: Scheduler 保留套餐过期检测和流量重置触发
|
||||
- **GIVEN** Phase 4+5 原子部署完成,精简后的 Scheduler 运行中
|
||||
- **WHEN** 每 10 秒定时器触发
|
||||
- **THEN** 调用 `PackageActivationHandler.HandlePackageActivationCheck(ctx)` 检查过期套餐;调用 `DataResetHandler.HandleDataReset(ctx)` 检查流量重置;这两个触发器在精简后 Scheduler 中**必须保留**(决策 9)
|
||||
|
||||
#### Scenario: Scheduler 精简后行数验证
|
||||
- **GIVEN** Phase 4+5 重构完成
|
||||
- **WHEN** 统计 `internal/polling/scheduler.go` 的代码行数
|
||||
- **THEN** 文件行数(含注释)**< 250 行**(因保留套餐过期/流量重置触发器,此处统一以 design.md 决策 9 和 tasks.md 的 <250 为准);文件中不出现 `db.Find`、`db.Where`、`loadConfig` 等字样;文件中出现 `HandlePackageActivationCheck` 和 `HandleDataReset`
|
||||
@@ -0,0 +1,173 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 统一 Redis 队列操作封装(PollingQueueManager)
|
||||
|
||||
新建 `internal/polling/queue_manager.go`,提供 `PollingQueueManager` 类型,封装所有轮询相关的 Redis 队列操作。
|
||||
|
||||
**文件目标**:< 200 行,只依赖 `redis.Client`,无 DB 依赖,两个进程(API 进程和 Worker 进程)共享同一实现。
|
||||
|
||||
以下代码的 Redis 操作均须通过 `PollingQueueManager`:
|
||||
- `internal/polling/callbacks.go`(删除后由此替代)
|
||||
- `internal/polling/api_callback.go`(删除后由此替代)
|
||||
- `internal/polling/scheduler.go` 中的所有队列出队/入队操作
|
||||
- `internal/task/polling_handler.go` 中的 `requeueCard`
|
||||
|
||||
**接口定义(方法签名)**:
|
||||
```go
|
||||
// PollingQueueManager 轮询队列统一管理器
|
||||
type PollingQueueManager struct {
|
||||
redis *redis.Client
|
||||
shardCount int // 默认16
|
||||
}
|
||||
|
||||
// DequeueReady 从指定分片出队到期卡(Lua 脚本:ZRANGEBYSCORE + ZREM 原子执行)
|
||||
// 只取 score ≤ now 的到期卡,不触碰未来项
|
||||
// 返回 []CardEntry,包含 CardID(从 ZRANGEBYSCORE 结果解析)
|
||||
func (m *PollingQueueManager) DequeueReady(ctx context.Context, shardID int, taskType string, batchSize int) ([]CardEntry, error)
|
||||
|
||||
// Requeue 将卡重新入队(ZADD,score=nextCheckAt Unix时间戳)
|
||||
func (m *PollingQueueManager) Requeue(ctx context.Context, cardID uint, taskType string, nextCheckAt time.Time) error
|
||||
|
||||
// RemoveFromAllQueues 从所有分片的4个队列(含protect)移除该卡
|
||||
func (m *PollingQueueManager) RemoveFromAllQueues(ctx context.Context, cardID uint) error
|
||||
|
||||
// EnqueueManual 手动触发入队(List RPUSH,调度器优先消费)
|
||||
func (m *PollingQueueManager) EnqueueManual(ctx context.Context, cardID uint, taskType string) error
|
||||
|
||||
// OnCardDeleted 卡删除事件:移除所有队列 + 清理缓存
|
||||
func (m *PollingQueueManager) OnCardDeleted(ctx context.Context, cardID uint) error
|
||||
|
||||
// GetQueueDepth 获取分片队列深度(用于背压检测)
|
||||
func (m *PollingQueueManager) GetQueueDepth(ctx context.Context, shardID int, taskType string) (int64, error)
|
||||
|
||||
// GetTotalQueueDepth 获取指定任务类型的总队列深度(聚合所有分片 ZCard 之和)
|
||||
// 供 MonitoringService 使用,替代直接读取旧的非分片 Redis Key
|
||||
func (m *PollingQueueManager) GetTotalQueueDepth(ctx context.Context, taskType string) (int64, error)
|
||||
```
|
||||
|
||||
#### Scenario: Lua 脚本原子出队替换竞态操作
|
||||
- **GIVEN** 调度器从分片队列出队
|
||||
- **WHEN** Scheduler 调用 `DequeueReady(ctx, shardID=3, taskType="carddata", batchSize=1000)`
|
||||
- **THEN** Lua 脚本在 Redis 服务端原子执行 `ZRANGEBYSCORE + ZREM`,只取 score ≤ now 的到期卡;不存在原来 ZRANGEBYSCORE + ZREMRANGEBYSCORE 分步操作的竞态窗口和卡丢失风险;返回卡ID列表,每张卡保证只被取出一次
|
||||
|
||||
#### Scenario: 高并发下不重复出队
|
||||
- **GIVEN** 2个 Scheduler Worker 同时消费同一分片
|
||||
- **WHEN** 两个 Worker 同时调用 `DequeueReady(ctx, shardID=0, taskType="realname", batchSize=500)`
|
||||
- **THEN** 两次调用返回的 CardID 集合不相交(Lua 脚本在 Redis 单线程中串行执行,保证原子性),不存在同一张卡被两个 Worker 同时处理的情况
|
||||
|
||||
#### Scenario: 未到期卡不被提前取出
|
||||
- **GIVEN** 分片队列中有 100 张到期卡(score ≤ now)和 50 张未到期卡(score > now)
|
||||
- **WHEN** Scheduler 调用 `DequeueReady(ctx, shardID=0, taskType="carddata", batchSize=200)`
|
||||
- **THEN** 只返回 100 张到期卡;50 张未到期卡保持在队列中不受影响;这是 Lua 脚本方案相比 ZPOPMIN 的关键优势(ZPOPMIN 会错误取出未到期卡)
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 分片 Sorted Set 支持千万级规模
|
||||
|
||||
`PollingQueueManager` 的存储结构使用分片 Sorted Set,Key 格式为 `polling:shard:{shardID}:queue:{taskType}`。
|
||||
|
||||
**分片路由**:卡入队时按 `cardID % shardCount` 分桶,确保同一张卡的同一任务类型始终在固定分片。
|
||||
|
||||
**Key 命名**:通过 `pkg/constants/redis.go` 的 `RedisPollingShardQueueKey(shardID int, taskType string) string` 生成。
|
||||
|
||||
#### Scenario: 分片路由一致性
|
||||
- **GIVEN** 默认 16 个分片
|
||||
- **WHEN** cardID=1000 的卡执行 `Requeue(ctx, 1000, "carddata", nextTime)`
|
||||
- **THEN** 写入 `polling:shard:8:queue:carddata`(1000 % 16 = 8),每次入队该卡都写同一个分片
|
||||
|
||||
#### Scenario: 背压跳过深度超限的分片
|
||||
- **GIVEN** 分片 5 的 `carddata` 队列深度达到 600,000(超过阈值 500,000)
|
||||
- **WHEN** Scheduler 调用 `GetQueueDepth(ctx, 5, "carddata")`
|
||||
- **THEN** 返回 600000;Scheduler 跳过该分片本轮出队,等待 Asynq 消化积压后恢复
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 从所有队列移除(修复 protect 队列遗漏 Bug)
|
||||
|
||||
`RemoveFromAllQueues` 须覆盖 **4 个任务类型**(realname、carddata、package、**protect**)× N 个分片 = 4N 次 Redis ZREM 操作。
|
||||
|
||||
#### Scenario: 删除卡后 protect 队列不残留
|
||||
- **GIVEN** cardID=500 的卡在 realname/carddata/package/protect 4个队列均有条目
|
||||
- **WHEN** 调用 `RemoveFromAllQueues(ctx, 500)`
|
||||
- **THEN** 4 类任务类型 × 16 分片共 64 个 Key 均执行 ZREM;Redis CLI 验证 `ZRANK polling:shard:*:queue:protect 500` 均不存在
|
||||
|
||||
#### Scenario: 不存在的卡调用无副作用
|
||||
- **GIVEN** cardID=999 的卡从未入队
|
||||
- **WHEN** 调用 `RemoveFromAllQueues(ctx, 999)`
|
||||
- **THEN** 无报错返回,各队列 ZREM 操作幂等(不存在成员 ZREM 返回 0,不视为错误)
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 手动触发入队
|
||||
|
||||
`EnqueueManual` 使用 List 的 `RPUSH` 将卡 ID 推入手动触发队列,Scheduler 在每轮调度中优先消费该队列(LPOP)。
|
||||
|
||||
#### Scenario: 手动触发优先于定时队列
|
||||
- **GIVEN** 某卡定时轮询间隔为 30 分钟,距下次定时触发还有 25 分钟
|
||||
- **WHEN** 管理员调用手动触发接口 → `EnqueueManual(ctx, cardID, "carddata")`
|
||||
- **THEN** 卡被推入 `polling:manual:carddata` 列表;下一个调度周期(1秒内)即被 Scheduler 取出推入 Asynq,无需等待 25 分钟
|
||||
|
||||
#### Scenario: 手动队列不影响分片定时队列
|
||||
- **GIVEN** cardID=200 的卡在手动队列和定时分片队列均有条目
|
||||
- **WHEN** Scheduler 先消费手动队列取出 cardID=200,推入 Asynq
|
||||
- **THEN** 分片队列中的条目不受影响(仍按原定时间等待);Task Handler 执行完后通过 `Requeue` 更新分片队列的 score
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 合并两个 Callback 实现
|
||||
|
||||
删除 `internal/polling/callbacks.go` 和 `internal/polling/api_callback.go`,统一通过 `PollingQueueManager` 处理所有卡生命周期事件。
|
||||
|
||||
两个进程(API 进程和 Worker 进程)共享同一个 `PollingQueueManager` 实例,均只需要 Redis Client,无需 Scheduler 实例。
|
||||
|
||||
#### Scenario: API 进程处理卡删除不依赖 Scheduler
|
||||
- **GIVEN** API 进程没有启动 Scheduler(只有 Worker 进程有)
|
||||
- **WHEN** API 进程处理卡删除请求,调用 `pollingQueueMgr.OnCardDeleted(ctx, cardID)`
|
||||
- **THEN** 卡从所有 4 类任务 × 16 分片队列中移除,卡信息 Redis 缓存清理;操作不依赖 Scheduler 对象,API 进程独立完成
|
||||
|
||||
#### Scenario: Worker 进程卡状态变化重新入队
|
||||
- **GIVEN** Worker 进程检测到卡状态变化(如卡由停机变为在线)
|
||||
- **WHEN** 调用 `pollingQueueMgr.Requeue(ctx, cardID, taskType, time.Now())`
|
||||
- **THEN** 卡按 `cardID % shardCount` 路由到对应分片,score=当前时间戳,Scheduler 下一轮即可出队处理
|
||||
|
||||
#### Scenario: 两个进程不产生重复清理
|
||||
- **GIVEN** API 进程和 Worker 进程同时响应同一卡的删除事件(极端情况)
|
||||
- **WHEN** 两者同时调用 `OnCardDeleted(ctx, cardID)`
|
||||
- **THEN** Redis ZREM 操作幂等,不产生副作用;缓存 DEL 操作幂等,不报错
|
||||
|
||||
---
|
||||
|
||||
### Requirement: LifecycleService 入队前检查 enable_polling(M3)
|
||||
|
||||
`PollingLifecycleService` 在 `OnCardCreated`、`OnCardEnabled`、`OnCardStatusChanged` 触发重新入队前,**必须**检查资产的 enable_polling 状态,防止禁用轮询的设置因生命周期事件被意外绕过。
|
||||
|
||||
#### Scenario: 设备禁用轮询后状态变更不重新入队
|
||||
- **GIVEN** deviceID=10 已被设置 `enable_polling=false`,设备下 card-A(cardID=100)因状态变化触发 `OnCardStatusChanged`
|
||||
- **WHEN** `PollingLifecycleService.OnCardStatusChanged(ctx, 100)` 执行
|
||||
- **THEN** 先调 `RemoveFromAllQueues(ctx, 100)` 清理队列;再检查 `card.DeviceID` → 查 `device.EnablePolling=false` → **跳过** Requeue;记录 Debug 日志「设备轮询已禁用,跳过重新入队: deviceID=10, cardID=100」
|
||||
|
||||
#### Scenario: 卡自身禁用轮询后不重新入队
|
||||
- **GIVEN** cardID=200 的 `enable_polling=false`(无绑定设备),触发 `OnCardEnabled`
|
||||
- **WHEN** `PollingLifecycleService.OnCardEnabled(ctx, 200)` 执行
|
||||
- **THEN** 检查 `card.EnablePolling=false` → 跳过 Requeue;记录 Debug 日志「卡轮询已禁用,跳过入队: cardID=200」
|
||||
|
||||
#### Scenario: 删除和禁用事件无需检查 enable_polling
|
||||
- **GIVEN** `OnCardDeleted`、`OnCardDisabled` 被调用
|
||||
- **WHEN** 执行队列清理操作
|
||||
- **THEN** 直接执行 `RemoveFromAllQueues`,不需要检查 enable_polling(清理操作本身是正确行为)
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 监控服务适配分片队列
|
||||
|
||||
`PollingQueueManager` 新增 `GetTotalQueueDepth(ctx, taskType)` 方法,聚合所有分片的 `ZCard` 之和,供 `MonitoringService` 替代直接读取旧的非分片 Redis Key。
|
||||
|
||||
#### Scenario: MonitoringService 读取分片后的队列深度
|
||||
- **GIVEN** 16 个分片中 `carddata` 队列分别有 100、200、300...1600 条数据
|
||||
- **WHEN** `MonitoringService` 调用 `queueMgr.GetTotalQueueDepth(ctx, "carddata")`
|
||||
- **THEN** 返回 13600(所有分片 ZCard 之和);与重构前直接读 `polling:queue:carddata` 的语义等价
|
||||
|
||||
#### Scenario: 部分分片 ZCard 失败不影响总体
|
||||
- **GIVEN** 16 个分片中 1 个分片 Redis 读取超时
|
||||
- **WHEN** `GetTotalQueueDepth` 执行
|
||||
- **THEN** 跳过超时分片,返回其余 15 个分片之和;记录 Warn 日志「分片 X 队列深度查询失败」
|
||||
@@ -0,0 +1,164 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: EvaluateAndAct——停复机统一入口
|
||||
|
||||
`StopResumeService` 新增 `EvaluateAndAct(ctx context.Context, card *model.IotCard) error` 方法,封装完整的停复机判断和执行逻辑。
|
||||
|
||||
> **签名说明**:删除原设计的 `carrierType string, carrierID uint` 参数(仅用于日志,放入签名会误导实现者)。
|
||||
> 设备/单卡维度通过 `card.DeviceID` 推导,日志上下文通过函数内部的 `zap.Field` 记录。
|
||||
|
||||
**删除** `internal/task/polling_handler.go` 中的以下函数(迁移到 StopResumeService):
|
||||
- `checkStopResume`
|
||||
- `shouldStopCard`
|
||||
- `hasAvailablePackage`(旧版,被新 `hasValidPackage` 替代)
|
||||
- `stopCardByUsageExhausted`
|
||||
- `resumeCardByPackageAvailable`
|
||||
|
||||
所有 Task Handler(carddata、package、protect)在需要停复机决策时,统一调用 `stopResumeService.EvaluateAndAct()`。
|
||||
|
||||
#### Scenario: Task Handler 调用统一停复机入口
|
||||
- **GIVEN** `carddata_handler.go` 完成流量数据采集和 DB 写入
|
||||
- **WHEN** 调用 `stopResumeService.EvaluateAndAct(ctx, card)`
|
||||
- **THEN** StopResumeService 完整执行停复机判断逻辑;Handler 不包含任何无条件停复机相关代码(`shouldStop`、`hasPackage` 等函数不出现在 Handler 文件中)
|
||||
|
||||
#### Scenario: 停机原因按优先级记录
|
||||
- **GIVEN** 卡在线,同时满足「无套餐」和「流量耗尽」和「未实名」三个条件
|
||||
- **WHEN** `EvaluateAndAct` 执行 `checkStopReasons`
|
||||
- **THEN** 按优先级取最高:`no_package > traffic_exhausted > not_realname`;`stop_reason` 字段记录 `no_package`;停机后 DB 中该卡 `stop_reason='no_package'`
|
||||
|
||||
#### Scenario: EvaluateAndAct 幂等
|
||||
- **GIVEN** 卡已停机,`stop_reason='no_package'`,无套餐状态未变化
|
||||
- **WHEN** 下次轮询再次调用 `EvaluateAndAct`
|
||||
- **THEN** 检测到卡已停机(`network_status=0`),进入复机判断分支;复机条件不满足(仍无套餐),不发起 Gateway 调用,返回 nil;DB 状态不变
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 停机条件——三种场景全面覆盖
|
||||
|
||||
卡在线(`network_status=1`)时,满足以下**任一**条件触发停机:
|
||||
|
||||
**条件 A(no_package)**:无有效套餐
|
||||
- 独立卡:`tb_package_usage` 中无 `iot_card_id=卡ID AND status IN (0,1)` 的记录
|
||||
- 绑定设备的卡:`tb_package_usage` 中无 `device_id=设备ID AND status IN (0,1)` 的记录
|
||||
- status=0(待激活)和 status=1(激活中)均视为有效套餐
|
||||
|
||||
**条件 B(traffic_exhausted)**:虚流量耗尽
|
||||
- 活跃套餐 `status=2`(系统标记为虚流量耗尽),或
|
||||
- 活跃套餐 `data_usage_mb >= data_limit_mb`(且 `data_limit_mb > 0`,防止无限流量卡误判)
|
||||
|
||||
**条件 C(not_realname)**:非行业卡且未实名
|
||||
- `card_category != 'industry'` 且 `real_name_status = 0`
|
||||
|
||||
#### Scenario: 无套餐触发停机(独立卡)
|
||||
- **GIVEN** cardID=100 的独立卡(无 device_id),`network_status=1`
|
||||
- **WHEN** `tb_package_usage` 中无任何 `iot_card_id=100 AND status IN(0,1)` 的记录
|
||||
- **THEN** `checkStopReasons` 返回 `["no_package"]`;发起 Gateway 停机调用(3次重试);DB 更新 `network_status=0, stop_reason='no_package'`
|
||||
|
||||
#### Scenario: 无套餐触发停机(设备卡,修复Bug1)
|
||||
- **GIVEN** cardID=200 的卡绑定 deviceID=50,`network_status=1`
|
||||
- **WHEN** `tb_package_usage` 中无 `iot_card_id=200` 的记录,但有 `device_id=50 AND status=1` 的记录(购买了设备套餐)
|
||||
- **THEN** `hasValidPackage` 检测到设备套餐存在,返回 true;不触发停机;卡保持在线状态(修复之前只查 iot_card_id 导致误停机的 Bug)
|
||||
|
||||
#### Scenario: 流量耗尽触发停机
|
||||
- **GIVEN** 卡在线,活跃套餐 `data_limit_mb=1000, data_usage_mb=1001`
|
||||
- **WHEN** `isTrafficExhausted` 检测
|
||||
- **THEN** `data_usage_mb(1001) >= data_limit_mb(1000)` 条件满足;返回 true;触发停机,`stop_reason='traffic_exhausted'`
|
||||
|
||||
#### Scenario: 套餐 status=2 触发停机
|
||||
- **GIVEN** 卡在线,活跃套餐 `status=2`(系统已标记虚流量耗尽)
|
||||
- **WHEN** `isTrafficExhausted` 检测
|
||||
- **THEN** 检测到 `status=2`;返回 true;触发停机,`stop_reason='traffic_exhausted'`
|
||||
|
||||
#### Scenario: 无限流量套餐不误判流量耗尽
|
||||
- **GIVEN** 卡在线,活跃套餐 `data_limit_mb=0`(无限流量),`data_usage_mb=9999`
|
||||
- **WHEN** `isTrafficExhausted` 检测
|
||||
- **THEN** `data_limit_mb=0` 时跳过用量比较(防止无限流量卡被误停机);返回 false;不触发停机
|
||||
|
||||
#### Scenario: 未实名普通卡触发停机
|
||||
- **GIVEN** 卡在线,`card_category='normal'`,`real_name_status=0`,有有效套餐且流量未耗尽
|
||||
- **WHEN** `checkStopReasons` 检测条件 C
|
||||
- **THEN** `!isRealnameOK(card)` 为 true;触发停机,`stop_reason='not_realname'`
|
||||
|
||||
#### Scenario: 行业卡无需实名不停机
|
||||
- **GIVEN** 卡在线,`card_category='industry'`,`real_name_status=0`,有有效套餐
|
||||
- **WHEN** `checkStopReasons` 检测
|
||||
- **THEN** `isRealnameOK(card)` 返回 true(行业卡豁免实名要求);不触发停机;卡保持在线
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 复机条件——全部满足才复机
|
||||
|
||||
卡停机(`network_status=0`)时,以下**全部**条件满足才触发自动复机:
|
||||
|
||||
1. `stop_reason IN ('traffic_exhausted', 'no_package', 'not_realname')`(排除手动停机、运营商停机等其他原因)
|
||||
2. 有有效套餐且流量未耗尽(`hasValidPackage=true` 且 `isTrafficExhausted=false`)
|
||||
3. 行业卡 OR 已实名(`card_category='industry'` OR `real_name_status=1`)
|
||||
|
||||
#### Scenario: 购买套餐后自动复机
|
||||
- **GIVEN** cardID=300 因 `no_package` 停机,`stop_reason='no_package'`,现在购买了套餐(status=1)
|
||||
- **WHEN** 下次轮询执行 `EvaluateAndAct`
|
||||
- **THEN** 三个复机条件全部满足(stop_reason合规 + 有套餐 + 已实名或行业卡);发起 Gateway 复机调用(3次重试);DB 更新 `network_status=1, stop_reason=''`
|
||||
|
||||
#### Scenario: 手动停机不自动复机
|
||||
- **GIVEN** 卡 `stop_reason='manual'`(管理员手动停机)
|
||||
- **WHEN** 该卡购买了套餐,完成实名认证,轮询执行 `EvaluateAndAct`
|
||||
- **THEN** 条件1不满足(`'manual'` 不在 IN 列表中);不触发自动复机;卡保持停机状态;需管理员手动复机
|
||||
|
||||
#### Scenario: 流量耗尽停机后购买新套餐复机
|
||||
- **GIVEN** 卡因 `traffic_exhausted` 停机,`stop_reason='traffic_exhausted'`;旧套餐已过期(status=3),新购套餐 status=1,usage=0
|
||||
- **WHEN** 轮询执行 `EvaluateAndAct`
|
||||
- **THEN** 条件1满足(traffic_exhausted);条件2满足(新套餐有效,usage<limit);条件3满足(已实名);触发复机
|
||||
|
||||
#### Scenario: 未实名卡实名后复机
|
||||
- **GIVEN** 普通卡因 `not_realname` 停机,用户完成实名认证(`real_name_status=1`)
|
||||
- **WHEN** 实名检查轮询执行,更新 DB 后调用 `EvaluateAndAct`
|
||||
- **THEN** 条件1满足;条件2满足(有套餐且未耗尽);条件3满足(`real_name_status=1`);触发复机
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 设备维度停复机——覆盖所有绑定卡
|
||||
|
||||
**停机**操作覆盖设备下所有在线卡(`network_status=1`);**复机**操作对满足条件的卡执行,跳过未满足 `isRealnameOK` 的普通卡。
|
||||
|
||||
#### Scenario: 设备套餐耗尽停所有绑定卡
|
||||
- **GIVEN** deviceID=10 下有 3 张在线卡(cardID=1,2,3),设备套餐 `data_usage_mb >= data_limit_mb`
|
||||
- **WHEN** 任一绑定卡触发 `EvaluateAndAct`,检测到设备套餐流量耗尽
|
||||
- **THEN** 调用 `stopDeviceCards(ctx, deviceID=10, "traffic_exhausted")`;3 张卡均发起 Gateway 停机调用;3 张卡均更新 `stop_reason='traffic_exhausted'`
|
||||
|
||||
#### Scenario: 设备停机调用 Gateway 带3次重试
|
||||
- **GIVEN** 设备下有 3 张卡需要停机,Gateway 第一次调用返回超时
|
||||
- **WHEN** `stopCardWithRetry` 执行
|
||||
- **THEN** 自动重试至多 3 次;至少 1 次成功则记录成功,最终失败则记录 Error 日志「卡停机失败: cardID=xxx, error=xxx」
|
||||
|
||||
#### Scenario: 购买设备套餐后全卡复机
|
||||
- **GIVEN** deviceID=10 下 3 张卡均因 `traffic_exhausted` 停机,购买新套餐后 status=1
|
||||
- **WHEN** 轮询执行 `EvaluateAndAct`,`shouldResume` 返回 true
|
||||
- **THEN** `resumeDeviceCards(ctx, deviceID=10)` 被调用;检查所有 `stop_reason IN(...)` 的停机卡;3 张卡均满足 `isRealnameOK`(均已实名);3 张卡均触发复机
|
||||
|
||||
#### Scenario: 设备复机跳过未实名普通卡
|
||||
- **GIVEN** deviceID=20 下有 3 张卡:card-A(已实名)、card-B(已实名)、card-C(`card_category='normal'`, `real_name_status=0`)
|
||||
- **WHEN** 设备复机条件满足,执行 `resumeDeviceCards`
|
||||
- **THEN** card-A 和 card-B 触发复机(各执行 Gateway 复机调用);card-C 因 `isRealnameOK=false` 被跳过(保持停机);card-C 的 `stop_reason` 自动更新为 `not_realname`
|
||||
|
||||
#### Scenario: 设备复机中单卡 Gateway 失败不阻止其他卡
|
||||
- **GIVEN** 设备下 3 张卡需要复机,card-B 的 Gateway 复机调用失败(含重试)
|
||||
- **WHEN** `resumeDeviceCards` 遍历执行
|
||||
- **THEN** card-A 和 card-C 正常复机;card-B 记录 Error 日志并跳过,不影响其他卡的复机;`resumeDeviceCards` 整体不返回 error(尽力复机语义)
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 设备维度操作幂等锁——防止并发重复 Gateway 调用
|
||||
|
||||
设备下多张卡分布在不同分片队列,同一调度周期内可能同时触发 `EvaluateAndAct`,进而并发调用 `stopDeviceCards` / `resumeDeviceCards`,导致同一张卡被 Gateway 重复停/复机。
|
||||
|
||||
`stopDeviceCards` 和 `resumeDeviceCards` 均在执行前通过 Redis `SetNX` 获取设备操作锁(`polling:device:op_lock:{deviceID}`,TTL 30 秒)。获取失败时视为"其他协程正在处理",直接跳过。
|
||||
|
||||
#### Scenario: 多卡并发触发不重复调 Gateway
|
||||
- **GIVEN** deviceID=10 下有 card-A(shard 3)和 card-B(shard 7),同一调度周期被并发出队
|
||||
- **WHEN** card-A 和 card-B 同时触发 `EvaluateAndAct` → 均尝试调用 `stopDeviceCards(deviceID=10)`
|
||||
- **THEN** 第一个调用获取设备锁成功,执行完整的停机流程;第二个调用 `SetNX` 返回 false,记录 Debug 日志「设备停机操作已在进行中,跳过: deviceID=10」,直接返回;设备下各卡只被 Gateway 停机一次
|
||||
|
||||
#### Scenario: 设备锁 TTL 超时后可重新获取
|
||||
- **GIVEN** 设备操作锁已过期(上次操作完成后 `Del` 释放,或 TTL 30 秒到期)
|
||||
- **WHEN** 下一轮轮询再次触发 `stopDeviceCards`
|
||||
- **THEN** `SetNX` 成功获取锁,正常执行停机流程
|
||||
@@ -0,0 +1,269 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: polling_handler.go 拆分为 4 个专注 Handler
|
||||
|
||||
将 `internal/task/polling_handler.go`(1360行)拆分为 4 个职责单一的文件,每个文件只负责一种任务类型的数据采集,停复机决策统一委托给 `StopResumeService.EvaluateAndAct()`。
|
||||
|
||||
**文件目标行数**:
|
||||
| 文件 | 职责 | 目标行数 |
|
||||
|------|------|---------|
|
||||
| `polling_base.go` | 共享基类(并发/缓存/重入队) | < 150行 |
|
||||
| `polling_realname_handler.go` | 实名状态采集 | < 200行 |
|
||||
| `polling_carddata_handler.go` | 流量数据采集 | < 300行 |
|
||||
| `polling_package_handler.go` | 套餐数据采集 | < 200行 |
|
||||
| `polling_protect_handler.go` | 保护期一致性检查 | < 150行 |
|
||||
|
||||
**删除**:拆分完成后,`internal/task/polling_handler.go` 整体删除。
|
||||
|
||||
**Handler 边界原则**:
|
||||
- ✅ 允许:调 Gateway 采集数据、写 Store(DB更新)、调 StopResumeService.EvaluateAndAct()
|
||||
- ❌ 禁止:直接 `h.db.Model()`(绕过 Store 层)、停复机判断逻辑、直接调 Gateway 停复机接口
|
||||
|
||||
#### Scenario: Asynq 任务类型常量不变(向后兼容)
|
||||
- **GIVEN** 现有 Asynq Worker 已注册 4 种任务类型常量
|
||||
- **WHEN** 拆分后注册 4 个新 Handler
|
||||
- **THEN** 任务类型常量名称完全不变(`TaskTypeRealnameCheck`、`TaskTypeCarddataCheck` 等);已在 Asynq 队列中的待处理任务无需清空,新 Handler 直接接管处理
|
||||
|
||||
#### Scenario: 4 个 Handler 并行处理不干扰
|
||||
- **GIVEN** 同时有 realname、carddata、package、protect 四种任务在处理
|
||||
- **WHEN** 各 Handler 独立执行
|
||||
- **THEN** 各 Handler 使用独立的并发锁(`PollingBase.acquireConcurrency` 按任务类型隔离);realname 任务的并发数不占用 carddata 任务的并发配额
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 共享基类 PollingBase
|
||||
|
||||
新建 `internal/task/polling_base.go`,提供 `PollingBase` 结构体,供 4 个 Handler 组合使用。
|
||||
|
||||
**提取的公共方法**(原来在 `polling_handler.go` 中重复出现):
|
||||
- `acquireConcurrency(taskType) bool`:获取并发控制信号量
|
||||
- `releaseConcurrency(taskType)`:释放并发控制信号量
|
||||
- `getCardWithCache(ctx, cardID) (*model.IotCard, error)`:带 Redis 缓存的卡查询
|
||||
- `updateCardCache(ctx, card)`:更新 Redis 卡信息缓存
|
||||
- `requeueCard(ctx, cardID, taskType, interval)`:按间隔重新入队(调 `PollingQueueManager.Requeue`)
|
||||
- `getMatchedPollingInterval(card) time.Duration`:获取该卡匹配的轮询间隔
|
||||
|
||||
#### Scenario: 缓存命中减少 DB 压力
|
||||
- **GIVEN** cardID=100 的卡信息已缓存在 Redis(TTL 5分钟)
|
||||
- **WHEN** `polling_realname_handler.go` 调用 `base.getCardWithCache(ctx, 100)`
|
||||
- **THEN** 直接从 Redis 读取,不查询 DB;缓存命中后更新 TTL(滑动窗口)
|
||||
|
||||
#### Scenario: 缓存未命中回源 DB
|
||||
- **GIVEN** cardID=200 的卡信息不在 Redis 缓存中
|
||||
- **WHEN** 任一 Handler 调用 `base.getCardWithCache(ctx, 200)`
|
||||
- **THEN** 查询 DB,将结果写入 Redis(TTL 5分钟);返回卡信息
|
||||
|
||||
#### Scenario: 并发控制防止过载——并发满时必须 requeue
|
||||
- **GIVEN** `carddata` 任务最大并发数配置为 50
|
||||
- **WHEN** 同时有 60 个 carddata 任务尝试执行
|
||||
- **THEN** 前 50 个获取到信号量正常执行;后 10 个 `acquireConcurrency` 返回 false;**后 10 个任务调 `requeueCard(ctx, cardID, taskType, time.Now())` 立即重入队**,记录 Debug 日志「并发数已满,已重新入队: cardID=xxx」,返回 nil(Asynq 不报错,不重试)
|
||||
|
||||
> **⚠️ 正确性约束**:Lua 脚本原子出队时卡已从 Redis Sorted Set 中删除。若并发满时直接 return nil 而不 requeue,该卡将永久消失(不再被轮询)。所有 Handler 必须在 `acquireConcurrency` 返回 false 时先调 `requeueCard` 再返回。
|
||||
|
||||
---
|
||||
|
||||
### Requirement: polling_realname_handler.go——实名数据采集
|
||||
|
||||
**文件**:`internal/task/polling_realname_handler.go`(< 200行)
|
||||
|
||||
**构造函数依赖**(通过构造函数注入,禁止全局变量):
|
||||
```go
|
||||
func NewPollingRealnameHandler(
|
||||
base *PollingBase,
|
||||
gateway GatewayClient,
|
||||
iotCardStore IotCardStore,
|
||||
queueClient QueueClient, // 用于触发首次实名激活任务
|
||||
) *PollingRealnameHandler
|
||||
```
|
||||
|
||||
**方法列表**:
|
||||
- `Handle(ctx, task) error`:Asynq 任务入口
|
||||
- `processCard(ctx, cardID) error`:核心处理逻辑
|
||||
|
||||
**处理流程**:
|
||||
1. 调 `base.acquireConcurrency("realname")`,获取失败则跳过
|
||||
2. 调 `base.getCardWithCache(ctx, cardID)` 获取卡信息
|
||||
3. 调 Gateway 查询实名状态
|
||||
4. 写 Store(更新 `real_name_status`)
|
||||
5. 若实名状态变为已实名(0→1):
|
||||
a. 入队首次实名激活 Asynq 任务(`triggerFirstRealnameActivation`)
|
||||
b. **调 `stopResumeService.EvaluateAndAct(ctx, card)` 触发复机判断**(卡可能因 `not_realname` 停机,需立即复机)
|
||||
6. 调 `base.requeueCard(ctx, cardID, "realname", interval)` 重新入队
|
||||
7. 调 `base.releaseConcurrency("realname")`
|
||||
|
||||
#### Scenario: 实名状态由未实名变为已实名触发激活和复机
|
||||
- **GIVEN** cardID=100,`real_name_status=0`(未实名),且卡因 `not_realname` 处于停机状态(`network_status=0, stop_reason='not_realname'`)
|
||||
- **WHEN** Gateway 返回实名已完成,`HandleRealnameCheck` 执行
|
||||
- **THEN** Store 更新 `real_name_status=1`;入队首次实名激活 Asynq 任务;**立即调用 `EvaluateAndAct` 检测复机条件**;若有有效套餐且流量未耗尽,发起 Gateway 复机调用,DB 更新 `network_status=1, stop_reason=''`;记录日志「卡实名状态变更: cardID=100, 0→1,触发激活任务和复机评估」
|
||||
|
||||
#### Scenario: 实名状态未变化不触发激活
|
||||
- **GIVEN** cardID=200,`real_name_status=1`(已实名),Gateway 返回仍已实名
|
||||
- **WHEN** `HandleRealnameCheck` 执行
|
||||
- **THEN** Store 不执行更新(无变化);不入队激活任务;调 `requeueCard` 按正常间隔重新入队
|
||||
|
||||
#### Scenario: realname Handler 仅在实名 0→1 时调用 EvaluateAndAct(S1 修复)
|
||||
- **GIVEN** `polling_realname_handler.go` 代码 Review
|
||||
- **WHEN** 检查文件内容
|
||||
- **THEN** 文件中**不出现**无条件的 `stopCard`、`resumeCard` 等停复机操作;但**允许且必须**在实名状态由 0→1 时调用 `stopResumeService.EvaluateAndAct(ctx, card)` 触发复机判断;原因:若卡因 `not_realname` 停机,实名完成后应立即复机,不能等下一个 carddata/package 轮询周期(可能长达 1 小时)
|
||||
|
||||
---
|
||||
|
||||
### Requirement: polling_carddata_handler.go——流量数据采集
|
||||
|
||||
**文件**:`internal/task/polling_carddata_handler.go`(< 300行)
|
||||
|
||||
**构造函数依赖**:
|
||||
```go
|
||||
func NewPollingCarddataHandler(
|
||||
base *PollingBase,
|
||||
gateway GatewayClient,
|
||||
iotCardStore IotCardStore,
|
||||
packageStore PackageUsageStore,
|
||||
usageService UsageService, // DeductDataUsage
|
||||
stopResumeService StopResumeServiceInterface,
|
||||
) *PollingCarddataHandler
|
||||
```
|
||||
|
||||
**方法列表**:
|
||||
- `Handle(ctx, task) error`:Asynq 任务入口
|
||||
- `processCard(ctx, cardID) error`:核心处理逻辑
|
||||
- `collectUsageData(ctx, card) (*UsageData, error)`:调 Gateway 获取流量增量
|
||||
|
||||
**处理流程**:
|
||||
1. 获取并发控制
|
||||
2. 调 Gateway 查询流量增量
|
||||
3. 写 Store(更新 `data_usage_mb`)
|
||||
4. 调 `usageService.DeductDataUsage()`(套餐流量扣减计算)
|
||||
5. 调 `stopResumeService.EvaluateAndAct(ctx, card, ...)`(停复机决策)
|
||||
6. 重新入队,释放并发控制
|
||||
|
||||
#### Scenario: 流量更新后委托停复机决策
|
||||
- **GIVEN** cardID=100,流量更新后 `data_usage_mb` 达到 `data_limit_mb`
|
||||
- **WHEN** `HandleCarddataCheck` 执行完流量写入
|
||||
- **THEN** 调用 `stopResumeService.EvaluateAndAct(ctx, card, "iot_card", 100)`;由 StopResumeService 决定是否停机;Handler 不包含 `if usage >= limit { stop() }` 这类判断代码
|
||||
|
||||
#### Scenario: 消除直接 DB 操作
|
||||
- **GIVEN** 原 `polling_handler.go` 中有 13 处 `h.db.Model()` 直接 DB 操作
|
||||
- **WHEN** 拆分后的 `polling_carddata_handler.go` 代码 Review
|
||||
- **THEN** 文件中不出现 `h.db.`、`db.Model()`、`db.Where()` 等直接 GORM 调用;所有 DB 操作通过 Store 接口(`iotCardStore.UpdateXxx()`、`packageStore.UpdateXxx()`)
|
||||
|
||||
#### Scenario: 跨月流量边界检测(必须保留)
|
||||
- **GIVEN** cardID=100,`current_month_start_date=2026-03-01`,当前日期为 `2026-04-02`
|
||||
- **WHEN** Gateway 返回月度总流量 `500MB`,`processCard` 检测到跨月(当前月份首日 != current_month_start_date)
|
||||
- **THEN** 保存 `last_month_total_mb = 旧 current_month_total`;重置 `current_month_usage_mb = 500`(以 Gateway 新月份值为准);更新 `current_month_start_date = 2026-04-01`;记录流量历史到 `data_usage_records`
|
||||
|
||||
#### Scenario: 同月流量增量计算
|
||||
- **GIVEN** cardID=200,`current_month_start_date=2026-04-01`,`last_month_total_mb=100`,当前日期为 `2026-04-02`
|
||||
- **WHEN** Gateway 返回月度总流量 `150MB`
|
||||
- **THEN** 计算增量 `delta = 150 - 100 = 50MB`;更新 `current_month_usage_mb += 50`;更新 `last_month_total_mb = 150`
|
||||
|
||||
#### Scenario: Gateway 调用失败不丢失数据
|
||||
- **GIVEN** cardID=300,Gateway 返回网络超时
|
||||
- **WHEN** `collectUsageData` 调用 Gateway 失败
|
||||
- **THEN** 不更新 DB(不写入错误数据);记录 Warn 日志「流量查询失败: cardID=300, error=xxx」;调 `requeueCard` 按较短间隔(重试间隔)重新入队;Asynq 层面不触发重试(Handler 返回 nil,MaxRetry=0)
|
||||
|
||||
---
|
||||
|
||||
### Requirement: polling_package_handler.go——套餐数据采集
|
||||
|
||||
**文件**:`internal/task/polling_package_handler.go`(< 200行)
|
||||
|
||||
**构造函数依赖**:
|
||||
```go
|
||||
func NewPollingPackageHandler(
|
||||
base *PollingBase,
|
||||
gateway GatewayClient,
|
||||
packageStore PackageUsageStore,
|
||||
stopResumeService StopResumeServiceInterface,
|
||||
) *PollingPackageHandler
|
||||
```
|
||||
|
||||
**方法列表**:
|
||||
- `Handle(ctx, task) error`:Asynq 任务入口
|
||||
- `processCard(ctx, cardID) error`:核心处理逻辑
|
||||
|
||||
**处理流程**:
|
||||
1. 获取并发控制
|
||||
2. 调 Gateway 查询套餐信息(剩余流量、状态)
|
||||
3. 写 Store(更新 `tb_package_usage` 套餐状态/用量)
|
||||
4. 调 `stopResumeService.EvaluateAndAct(ctx, card, ...)`(停复机决策)
|
||||
5. 重新入队,释放并发控制
|
||||
|
||||
#### Scenario: 套餐到期后触发复机判断
|
||||
- **GIVEN** 卡因 `traffic_exhausted` 停机,旧套餐已过期(status=3),Gateway 返回新套餐已激活
|
||||
- **WHEN** `HandlePackageCheck` 执行,更新套餐状态后
|
||||
- **THEN** 调 `EvaluateAndAct`;StopResumeService 检测到新套餐有效、流量未耗尽;触发复机;Handler 不直接调用 resumeCard
|
||||
|
||||
#### Scenario: package Handler 不做停复机判断
|
||||
- **GIVEN** `polling_package_handler.go` 代码 Review
|
||||
- **WHEN** 检查文件内容
|
||||
- **THEN** 文件中不出现 `shouldStopCard`、`hasAvailablePackage`、`stopCard`、`resumeCard` 等函数;套餐处理逻辑仅限于数据采集和 Store 写入
|
||||
|
||||
---
|
||||
|
||||
### Requirement: polling_protect_handler.go——保护期一致性检查
|
||||
|
||||
**文件**:`internal/task/polling_protect_handler.go`(< 200行)
|
||||
|
||||
> **业务语义说明**:本 Handler 的核心目的是"确保保护期内卡状态与保护期方向一致,防止状态漂移"。
|
||||
> 保护期**内**:强制修正状态(直接调 Gateway,不走 EvaluateAndAct 三条件判断)。
|
||||
> 保护期**结束后**:调 EvaluateAndAct 重新评估(按正常停复机逻辑处理)。
|
||||
|
||||
**构造函数依赖**:
|
||||
```go
|
||||
func NewPollingProtectHandler(
|
||||
base *PollingBase,
|
||||
gateway GatewayClient, // 保护期内强制停复机需要直接调 Gateway
|
||||
iotCardStore IotCardStore,
|
||||
stopResumeService StopResumeServiceInterface,
|
||||
) *PollingProtectHandler
|
||||
```
|
||||
|
||||
**方法列表**:
|
||||
- `Handle(ctx, task) error`:Asynq 任务入口
|
||||
- `processCard(ctx, cardID) error`:核心处理逻辑
|
||||
|
||||
**处理流程**:
|
||||
1. 获取并发控制(并发满时 **requeue 后返回**,不丢弃)
|
||||
2. 查 Store 获取卡信息
|
||||
3. 前置跳过检查:
|
||||
- 卡未实名(`real_name_status=0`)→ requeue,直接返回
|
||||
- 卡未绑定设备(`is_standalone=true`)→ requeue,直接返回
|
||||
4. 读取设备保护期 Redis Key(`stop` 保护期 Key + `start` 保护期 Key)
|
||||
5. 根据保护期状态执行对应逻辑:
|
||||
- **有 stop 保护期 + 卡在线(network_status=1)**:直接调 `gateway.StopCard`(强制修正),写 DB `stop_reason='protect'`
|
||||
- **有 start 保护期 + 卡停机(network_status=0)**:直接调 `gateway.StartCard`(强制修正),清空 `stop_reason`
|
||||
- **有保护期 + 状态已一致**:跳过(不调 Gateway,不调 EvaluateAndAct)
|
||||
- **无保护期(保护期已结束)**:调 `stopResumeService.EvaluateAndAct(ctx, card)` 重新评估
|
||||
6. 调 `base.requeueCard` 按间隔重新入队
|
||||
7. 释放并发控制
|
||||
|
||||
#### Scenario: stop 保护期内卡在线——强制停机
|
||||
- **GIVEN** 卡已实名且绑定设备,设备有 stop 保护期(`polling:protect:stop:{deviceID}` Key 存在),但卡当前 `network_status=1`(在线,与保护期方向不一致)
|
||||
- **WHEN** `HandleProtectConsistencyCheck` 执行
|
||||
- **THEN** 检测到 stop 保护期存在且状态不一致;直接调 `gateway.StopCard`(不走 EvaluateAndAct,强制修正);DB 更新 `network_status=0, stop_reason='protect'`;记录 Info 日志「保护期强制停机: cardID=xxx, deviceID=xxx」
|
||||
|
||||
#### Scenario: start 保护期内卡停机——强制复机
|
||||
- **GIVEN** 卡已实名且绑定设备,设备有 start 保护期(`polling:protect:start:{deviceID}` Key 存在),但卡当前 `network_status=0`(停机,与保护期方向不一致)
|
||||
- **WHEN** `HandleProtectConsistencyCheck` 执行
|
||||
- **THEN** 检测到 start 保护期存在且状态不一致;直接调 `gateway.StartCard`(不走 EvaluateAndAct,强制修正);DB 更新 `network_status=1, stop_reason=''`;记录 Info 日志「保护期强制复机: cardID=xxx, deviceID=xxx」
|
||||
|
||||
#### Scenario: 保护期内状态已一致——跳过
|
||||
- **GIVEN** 设备有 stop 保护期,卡已是停机状态(`network_status=0`)
|
||||
- **WHEN** `HandleProtectConsistencyCheck` 执行
|
||||
- **THEN** 检测到保护期存在且状态已一致;跳过,不调 Gateway,不调 EvaluateAndAct;直接 requeue
|
||||
|
||||
#### Scenario: 保护期已结束——调 EvaluateAndAct 重新评估
|
||||
- **GIVEN** 卡在保护期内已停机(`stop_reason='protect'`),保护期 Key 已过期(TTL = 0)
|
||||
- **WHEN** `HandleProtectConsistencyCheck` 执行,读取保护期 Key 不存在
|
||||
- **THEN** 调 `stopResumeService.EvaluateAndAct(ctx, card)` 重新评估;若有有效套餐且已实名,触发复机;否则保持停机(`stop_reason` 更新为实际原因)
|
||||
|
||||
#### Scenario: 未实名卡跳过保护期逻辑
|
||||
- **GIVEN** 卡 `real_name_status=0`(未实名)
|
||||
- **WHEN** `HandleProtectConsistencyCheck` 执行
|
||||
- **THEN** 检测到未实名,直接 requeue(不检查保护期,不调 Gateway)
|
||||
|
||||
#### Scenario: 独立卡(未绑定设备)跳过
|
||||
- **GIVEN** 卡 `is_standalone=true`(未绑定设备)
|
||||
- **WHEN** `HandleProtectConsistencyCheck` 执行
|
||||
- **THEN** 检测到独立卡,直接 requeue(设备保护期与独立卡无关)
|
||||
1554
openspec/changes/archive/2026-04-07-polling-system-refactor/tasks.md
Normal file
1554
openspec/changes/archive/2026-04-07-polling-system-refactor/tasks.md
Normal file
File diff suppressed because it is too large
Load Diff
116
openspec/specs/polling-config-manager/spec.md
Normal file
116
openspec/specs/polling-config-manager/spec.md
Normal file
@@ -0,0 +1,116 @@
|
||||
### Requirement: 独立配置管理模块(PollingConfigManager)
|
||||
|
||||
新建 `internal/polling/config_manager.go`,提供 `PollingConfigManager` 类型,从 `scheduler.go` 中拆分配置相关职责。
|
||||
|
||||
**文件目标**:< 150 行,职责:DB 加载 → 内存缓存 → Redis 同步 → 定时刷新。
|
||||
|
||||
**从 Scheduler 提取的方法**:
|
||||
- `loadConfigs(ctx)`:从 DB 加载所有启用的 `tb_polling_config`
|
||||
- `syncConfigsToRedis(configs)`:写入 Redis Hash(TTL 24小时)
|
||||
- `MatchConfig(card)`:按优先级顺序返回第一个匹配的配置
|
||||
- `matchConfigConditions(config, card)`:判断卡是否满足配置条件
|
||||
- `getCardCondition(card)`:获取卡的匹配条件(carrier、simtype 等)
|
||||
|
||||
**Scheduler 修改后**:不包含任何 DB 查询或配置加载逻辑,通过 `configManager.MatchConfig(card)` 获取配置。
|
||||
|
||||
#### Scenario: 启动时加载配置并同步 Redis
|
||||
- **GIVEN** Worker 进程启动,DB 中有 5 条启用的 PollingConfig
|
||||
- **WHEN** `PollingConfigManager.Load(ctx)` 被调用
|
||||
- **THEN** 从 DB 加载 5 条配置,写入内存缓存(sync.RWMutex 保护),同步到 Redis Hash(TTL 24小时);加载完成日志记录「已加载 5 条轮询配置」
|
||||
|
||||
#### Scenario: 配置按优先级匹配
|
||||
- **GIVEN** 内存中有 3 条配置,优先级分别为 1、5、10(数字越小优先级越高)
|
||||
- **WHEN** 调用 `MatchConfig(card)` 匹配某张卡
|
||||
- **THEN** 按优先级升序遍历,返回第一个满足所有条件的配置;无匹配时返回 nil,该卡不加入轮询队列
|
||||
|
||||
#### Scenario: 配置匹配使用读锁不阻塞
|
||||
- **GIVEN** Scheduler 高频调用 `MatchConfig(card)`(每秒可能数千次)
|
||||
- **WHEN** 同时有定时刷新正在写入新配置(写锁)
|
||||
- **THEN** 读取操作等待写锁释放后继续,不发生数据竞争;写锁持有时间极短(内存赋值),不影响 Scheduler 调度性能
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 定时刷新配置(5分钟周期)
|
||||
|
||||
`PollingConfigManager.Start(ctx)` 启动后台 goroutine,每 5 分钟自动重新加载配置。
|
||||
|
||||
#### Scenario: 定时刷新不重启 Worker
|
||||
- **GIVEN** Worker 进程已运行 10 分钟,管理员在控制台修改了某条 PollingConfig 的轮询间隔
|
||||
- **WHEN** 距上次加载超过 5 分钟,定时器触发
|
||||
- **THEN** 自动重新从 DB 加载配置,更新内存缓存,新配置在下一轮调度中生效;无需重启 Worker 进程
|
||||
|
||||
#### Scenario: 刷新失败不影响当前配置
|
||||
- **GIVEN** 定时刷新时 DB 连接超时
|
||||
- **WHEN** `Load(ctx)` 返回 error
|
||||
- **THEN** 内存缓存保持原有配置不变(不清空),记录 Error 日志「配置刷新失败: xxx」;下一轮(5分钟后)重试
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 独立卡初始化模块(CardInitializer)
|
||||
|
||||
新建 `internal/polling/initializer.go`,提供 `CardInitializer` 类型,从 `scheduler.go` 中拆分渐进式初始化职责。
|
||||
|
||||
**文件目标**:< 250 行,职责:分批 DB 查询 → 分片路由入队 → 进度追踪 → enable_polling 过滤。
|
||||
|
||||
**从 Scheduler 提取的方法**:
|
||||
- `progressiveInit(ctx)`:分批加载卡并入队
|
||||
- `initCardsBatch(ctx, offset, limit)`:加载单批次卡
|
||||
- `initCardPolling(ctx, card)`:为单张卡匹配配置并入队
|
||||
|
||||
**Scheduler 修改后**:不包含任何初始化逻辑,通过 `cardInitializer.Run(ctx)` 异步启动,通过 `cardInitializer.GetProgress()` 查询进度。
|
||||
|
||||
#### Scenario: 渐进式分批初始化避免 DB 过载
|
||||
- **GIVEN** DB 中有 500 万张启用轮询的卡
|
||||
- **WHEN** `CardInitializer.Run(ctx)` 被调用
|
||||
- **THEN** 分批加载(每批 10 万张),批次间 Sleep 500ms;约 500 批次,每批处理后记录进度日志「初始化进度: 100000/5000000」;全部完成后 `initCompleted=true`,Scheduler 开始正常出队
|
||||
|
||||
#### Scenario: 跳过 enable_polling=false 的卡
|
||||
- **GIVEN** 10 万张卡中,5000 张卡的 `enable_polling=false`
|
||||
- **WHEN** 批次处理时遇到这 5000 张卡
|
||||
- **THEN** 这 5000 张卡不加入任何轮询队列(ZADD 跳过);其余 95000 张卡按正常流程入队
|
||||
|
||||
#### Scenario: 初始化幂等(重启不重复)
|
||||
- **GIVEN** Worker 重启,部分卡已在分片队列中(上次初始化留下)
|
||||
- **WHEN** 重新执行 `CardInitializer.Run(ctx)`
|
||||
- **THEN** ZADD 操作幂等:若卡已在队列中,只更新 score 为新的初始延迟时间(不添加重复条目);Redis Sorted Set 中不出现重复成员
|
||||
|
||||
#### Scenario: Scheduler 只在初始化完成后出队
|
||||
- **GIVEN** Worker 刚启动,初始化尚未完成(`initCompleted=false`)
|
||||
- **WHEN** Scheduler 的 `scheduleLoop` 执行
|
||||
- **THEN** 检查 `cardInitializer.GetProgress().Completed`,若为 false 则跳过本轮所有分片的出队;避免初始化中途 Scheduler 取到不完整队列
|
||||
|
||||
#### Scenario: 进度暴露给监控接口
|
||||
- **GIVEN** 管理员调用轮询状态监控接口
|
||||
- **WHEN** `MonitoringService` 调用 `cardInitializer.GetProgress()`
|
||||
- **THEN** 返回 `{Total: 5000000, Processed: 1500000, Completed: false}`;前端可展示初始化进度百分比
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Scheduler 精简到 < 250 行(Mi1 修复)
|
||||
|
||||
> **注意**:本 spec 原写 `< 200 行`,已与 `design.md` 决策 9 和 `tasks.md` 统一更正为 **`< 250 行`**。
|
||||
> 原因:精简后的 Scheduler 保留了套餐过期检测和流量重置两个触发器(见决策 9),额外占用约 50 行。
|
||||
|
||||
`internal/polling/scheduler.go` 精简后只保留纯调度循环逻辑,所有子职责委托给新建的三个模块。
|
||||
|
||||
**精简后 Scheduler 的完整职责**:
|
||||
1. 启动 `CardInitializer.Run(ctx)`(异步)
|
||||
2. 启动 `PollingConfigManager.Start(ctx)`(定时刷新)
|
||||
3. 运行 `scheduleLoop`:每秒 × N分片 × 4任务类型 的出队循环
|
||||
4. 背压检测:`GetQueueDepth > 阈值` 时跳过该分片
|
||||
|
||||
**禁止出现在精简后 Scheduler 中的内容**:
|
||||
- 任何 `db.Find()`、`db.Where()` 等 DB 操作
|
||||
- 配置加载逻辑(`loadConfigs`、`syncConfigsToRedis`)
|
||||
- 卡初始化逻辑(`progressiveInit`、`initCardsBatch`)
|
||||
- 任何 Callback 注册(`callbacks.go` 已删除)
|
||||
|
||||
#### Scenario: Scheduler 保留套餐过期检测和流量重置触发
|
||||
- **GIVEN** Phase 4+5 原子部署完成,精简后的 Scheduler 运行中
|
||||
- **WHEN** 每 10 秒定时器触发
|
||||
- **THEN** 调用 `PackageActivationHandler.HandlePackageActivationCheck(ctx)` 检查过期套餐;调用 `DataResetHandler.HandleDataReset(ctx)` 检查流量重置;这两个触发器在精简后 Scheduler 中**必须保留**(决策 9)
|
||||
|
||||
#### Scenario: Scheduler 精简后行数验证
|
||||
- **GIVEN** Phase 4+5 重构完成
|
||||
- **WHEN** 统计 `internal/polling/scheduler.go` 的代码行数
|
||||
- **THEN** 文件行数(含注释)**< 250 行**(因保留套餐过期/流量重置触发器,此处统一以 design.md 决策 9 和 tasks.md 的 <250 为准);文件中不出现 `db.Find`、`db.Where`、`loadConfig` 等字样;文件中出现 `HandlePackageActivationCheck` 和 `HandleDataReset`
|
||||
171
openspec/specs/polling-queue-manager/spec.md
Normal file
171
openspec/specs/polling-queue-manager/spec.md
Normal file
@@ -0,0 +1,171 @@
|
||||
### Requirement: 统一 Redis 队列操作封装(PollingQueueManager)
|
||||
|
||||
新建 `internal/polling/queue_manager.go`,提供 `PollingQueueManager` 类型,封装所有轮询相关的 Redis 队列操作。
|
||||
|
||||
**文件目标**:< 200 行,只依赖 `redis.Client`,无 DB 依赖,两个进程(API 进程和 Worker 进程)共享同一实现。
|
||||
|
||||
以下代码的 Redis 操作均须通过 `PollingQueueManager`:
|
||||
- `internal/polling/callbacks.go`(删除后由此替代)
|
||||
- `internal/polling/api_callback.go`(删除后由此替代)
|
||||
- `internal/polling/scheduler.go` 中的所有队列出队/入队操作
|
||||
- `internal/task/polling_handler.go` 中的 `requeueCard`
|
||||
|
||||
**接口定义(方法签名)**:
|
||||
```go
|
||||
// PollingQueueManager 轮询队列统一管理器
|
||||
type PollingQueueManager struct {
|
||||
redis *redis.Client
|
||||
shardCount int // 默认16
|
||||
}
|
||||
|
||||
// DequeueReady 从指定分片出队到期卡(Lua 脚本:ZRANGEBYSCORE + ZREM 原子执行)
|
||||
// 只取 score ≤ now 的到期卡,不触碰未来项
|
||||
// 返回 []CardEntry,包含 CardID(从 ZRANGEBYSCORE 结果解析)
|
||||
func (m *PollingQueueManager) DequeueReady(ctx context.Context, shardID int, taskType string, batchSize int) ([]CardEntry, error)
|
||||
|
||||
// Requeue 将卡重新入队(ZADD,score=nextCheckAt Unix时间戳)
|
||||
func (m *PollingQueueManager) Requeue(ctx context.Context, cardID uint, taskType string, nextCheckAt time.Time) error
|
||||
|
||||
// RemoveFromAllQueues 从所有分片的4个队列(含protect)移除该卡
|
||||
func (m *PollingQueueManager) RemoveFromAllQueues(ctx context.Context, cardID uint) error
|
||||
|
||||
// EnqueueManual 手动触发入队(List RPUSH,调度器优先消费)
|
||||
func (m *PollingQueueManager) EnqueueManual(ctx context.Context, cardID uint, taskType string) error
|
||||
|
||||
// OnCardDeleted 卡删除事件:移除所有队列 + 清理缓存
|
||||
func (m *PollingQueueManager) OnCardDeleted(ctx context.Context, cardID uint) error
|
||||
|
||||
// GetQueueDepth 获取分片队列深度(用于背压检测)
|
||||
func (m *PollingQueueManager) GetQueueDepth(ctx context.Context, shardID int, taskType string) (int64, error)
|
||||
|
||||
// GetTotalQueueDepth 获取指定任务类型的总队列深度(聚合所有分片 ZCard 之和)
|
||||
// 供 MonitoringService 使用,替代直接读取旧的非分片 Redis Key
|
||||
func (m *PollingQueueManager) GetTotalQueueDepth(ctx context.Context, taskType string) (int64, error)
|
||||
```
|
||||
|
||||
#### Scenario: Lua 脚本原子出队替换竞态操作
|
||||
- **GIVEN** 调度器从分片队列出队
|
||||
- **WHEN** Scheduler 调用 `DequeueReady(ctx, shardID=3, taskType="carddata", batchSize=1000)`
|
||||
- **THEN** Lua 脚本在 Redis 服务端原子执行 `ZRANGEBYSCORE + ZREM`,只取 score ≤ now 的到期卡;不存在原来 ZRANGEBYSCORE + ZREMRANGEBYSCORE 分步操作的竞态窗口和卡丢失风险;返回卡ID列表,每张卡保证只被取出一次
|
||||
|
||||
#### Scenario: 高并发下不重复出队
|
||||
- **GIVEN** 2个 Scheduler Worker 同时消费同一分片
|
||||
- **WHEN** 两个 Worker 同时调用 `DequeueReady(ctx, shardID=0, taskType="realname", batchSize=500)`
|
||||
- **THEN** 两次调用返回的 CardID 集合不相交(Lua 脚本在 Redis 单线程中串行执行,保证原子性),不存在同一张卡被两个 Worker 同时处理的情况
|
||||
|
||||
#### Scenario: 未到期卡不被提前取出
|
||||
- **GIVEN** 分片队列中有 100 张到期卡(score ≤ now)和 50 张未到期卡(score > now)
|
||||
- **WHEN** Scheduler 调用 `DequeueReady(ctx, shardID=0, taskType="carddata", batchSize=200)`
|
||||
- **THEN** 只返回 100 张到期卡;50 张未到期卡保持在队列中不受影响;这是 Lua 脚本方案相比 ZPOPMIN 的关键优势(ZPOPMIN 会错误取出未到期卡)
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 分片 Sorted Set 支持千万级规模
|
||||
|
||||
`PollingQueueManager` 的存储结构使用分片 Sorted Set,Key 格式为 `polling:shard:{shardID}:queue:{taskType}`。
|
||||
|
||||
**分片路由**:卡入队时按 `cardID % shardCount` 分桶,确保同一张卡的同一任务类型始终在固定分片。
|
||||
|
||||
**Key 命名**:通过 `pkg/constants/redis.go` 的 `RedisPollingShardQueueKey(shardID int, taskType string) string` 生成。
|
||||
|
||||
#### Scenario: 分片路由一致性
|
||||
- **GIVEN** 默认 16 个分片
|
||||
- **WHEN** cardID=1000 的卡执行 `Requeue(ctx, 1000, "carddata", nextTime)`
|
||||
- **THEN** 写入 `polling:shard:8:queue:carddata`(1000 % 16 = 8),每次入队该卡都写同一个分片
|
||||
|
||||
#### Scenario: 背压跳过深度超限的分片
|
||||
- **GIVEN** 分片 5 的 `carddata` 队列深度达到 600,000(超过阈值 500,000)
|
||||
- **WHEN** Scheduler 调用 `GetQueueDepth(ctx, 5, "carddata")`
|
||||
- **THEN** 返回 600000;Scheduler 跳过该分片本轮出队,等待 Asynq 消化积压后恢复
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 从所有队列移除(修复 protect 队列遗漏 Bug)
|
||||
|
||||
`RemoveFromAllQueues` 须覆盖 **4 个任务类型**(realname、carddata、package、**protect**)× N 个分片 = 4N 次 Redis ZREM 操作。
|
||||
|
||||
#### Scenario: 删除卡后 protect 队列不残留
|
||||
- **GIVEN** cardID=500 的卡在 realname/carddata/package/protect 4个队列均有条目
|
||||
- **WHEN** 调用 `RemoveFromAllQueues(ctx, 500)`
|
||||
- **THEN** 4 类任务类型 × 16 分片共 64 个 Key 均执行 ZREM;Redis CLI 验证 `ZRANK polling:shard:*:queue:protect 500` 均不存在
|
||||
|
||||
#### Scenario: 不存在的卡调用无副作用
|
||||
- **GIVEN** cardID=999 的卡从未入队
|
||||
- **WHEN** 调用 `RemoveFromAllQueues(ctx, 999)`
|
||||
- **THEN** 无报错返回,各队列 ZREM 操作幂等(不存在成员 ZREM 返回 0,不视为错误)
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 手动触发入队
|
||||
|
||||
`EnqueueManual` 使用 List 的 `RPUSH` 将卡 ID 推入手动触发队列,Scheduler 在每轮调度中优先消费该队列(LPOP)。
|
||||
|
||||
#### Scenario: 手动触发优先于定时队列
|
||||
- **GIVEN** 某卡定时轮询间隔为 30 分钟,距下次定时触发还有 25 分钟
|
||||
- **WHEN** 管理员调用手动触发接口 → `EnqueueManual(ctx, cardID, "carddata")`
|
||||
- **THEN** 卡被推入 `polling:manual:carddata` 列表;下一个调度周期(1秒内)即被 Scheduler 取出推入 Asynq,无需等待 25 分钟
|
||||
|
||||
#### Scenario: 手动队列不影响分片定时队列
|
||||
- **GIVEN** cardID=200 的卡在手动队列和定时分片队列均有条目
|
||||
- **WHEN** Scheduler 先消费手动队列取出 cardID=200,推入 Asynq
|
||||
- **THEN** 分片队列中的条目不受影响(仍按原定时间等待);Task Handler 执行完后通过 `Requeue` 更新分片队列的 score
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 合并两个 Callback 实现
|
||||
|
||||
删除 `internal/polling/callbacks.go` 和 `internal/polling/api_callback.go`,统一通过 `PollingQueueManager` 处理所有卡生命周期事件。
|
||||
|
||||
两个进程(API 进程和 Worker 进程)共享同一个 `PollingQueueManager` 实例,均只需要 Redis Client,无需 Scheduler 实例。
|
||||
|
||||
#### Scenario: API 进程处理卡删除不依赖 Scheduler
|
||||
- **GIVEN** API 进程没有启动 Scheduler(只有 Worker 进程有)
|
||||
- **WHEN** API 进程处理卡删除请求,调用 `pollingQueueMgr.OnCardDeleted(ctx, cardID)`
|
||||
- **THEN** 卡从所有 4 类任务 × 16 分片队列中移除,卡信息 Redis 缓存清理;操作不依赖 Scheduler 对象,API 进程独立完成
|
||||
|
||||
#### Scenario: Worker 进程卡状态变化重新入队
|
||||
- **GIVEN** Worker 进程检测到卡状态变化(如卡由停机变为在线)
|
||||
- **WHEN** 调用 `pollingQueueMgr.Requeue(ctx, cardID, taskType, time.Now())`
|
||||
- **THEN** 卡按 `cardID % shardCount` 路由到对应分片,score=当前时间戳,Scheduler 下一轮即可出队处理
|
||||
|
||||
#### Scenario: 两个进程不产生重复清理
|
||||
- **GIVEN** API 进程和 Worker 进程同时响应同一卡的删除事件(极端情况)
|
||||
- **WHEN** 两者同时调用 `OnCardDeleted(ctx, cardID)`
|
||||
- **THEN** Redis ZREM 操作幂等,不产生副作用;缓存 DEL 操作幂等,不报错
|
||||
|
||||
---
|
||||
|
||||
### Requirement: LifecycleService 入队前检查 enable_polling(M3)
|
||||
|
||||
`PollingLifecycleService` 在 `OnCardCreated`、`OnCardEnabled`、`OnCardStatusChanged` 触发重新入队前,**必须**检查资产的 enable_polling 状态,防止禁用轮询的设置因生命周期事件被意外绕过。
|
||||
|
||||
#### Scenario: 设备禁用轮询后状态变更不重新入队
|
||||
- **GIVEN** deviceID=10 已被设置 `enable_polling=false`,设备下 card-A(cardID=100)因状态变化触发 `OnCardStatusChanged`
|
||||
- **WHEN** `PollingLifecycleService.OnCardStatusChanged(ctx, 100)` 执行
|
||||
- **THEN** 先调 `RemoveFromAllQueues(ctx, 100)` 清理队列;再检查 `card.DeviceID` → 查 `device.EnablePolling=false` → **跳过** Requeue;记录 Debug 日志「设备轮询已禁用,跳过重新入队: deviceID=10, cardID=100」
|
||||
|
||||
#### Scenario: 卡自身禁用轮询后不重新入队
|
||||
- **GIVEN** cardID=200 的 `enable_polling=false`(无绑定设备),触发 `OnCardEnabled`
|
||||
- **WHEN** `PollingLifecycleService.OnCardEnabled(ctx, 200)` 执行
|
||||
- **THEN** 检查 `card.EnablePolling=false` → 跳过 Requeue;记录 Debug 日志「卡轮询已禁用,跳过入队: cardID=200」
|
||||
|
||||
#### Scenario: 删除和禁用事件无需检查 enable_polling
|
||||
- **GIVEN** `OnCardDeleted`、`OnCardDisabled` 被调用
|
||||
- **WHEN** 执行队列清理操作
|
||||
- **THEN** 直接执行 `RemoveFromAllQueues`,不需要检查 enable_polling(清理操作本身是正确行为)
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 监控服务适配分片队列
|
||||
|
||||
`PollingQueueManager` 新增 `GetTotalQueueDepth(ctx, taskType)` 方法,聚合所有分片的 `ZCard` 之和,供 `MonitoringService` 替代直接读取旧的非分片 Redis Key。
|
||||
|
||||
#### Scenario: MonitoringService 读取分片后的队列深度
|
||||
- **GIVEN** 16 个分片中 `carddata` 队列分别有 100、200、300...1600 条数据
|
||||
- **WHEN** `MonitoringService` 调用 `queueMgr.GetTotalQueueDepth(ctx, "carddata")`
|
||||
- **THEN** 返回 13600(所有分片 ZCard 之和);与重构前直接读 `polling:queue:carddata` 的语义等价
|
||||
|
||||
#### Scenario: 部分分片 ZCard 失败不影响总体
|
||||
- **GIVEN** 16 个分片中 1 个分片 Redis 读取超时
|
||||
- **WHEN** `GetTotalQueueDepth` 执行
|
||||
- **THEN** 跳过超时分片,返回其余 15 个分片之和;记录 Warn 日志「分片 X 队列深度查询失败」
|
||||
162
openspec/specs/polling-stop-resume-logic/spec.md
Normal file
162
openspec/specs/polling-stop-resume-logic/spec.md
Normal file
@@ -0,0 +1,162 @@
|
||||
### Requirement: EvaluateAndAct——停复机统一入口
|
||||
|
||||
`StopResumeService` 新增 `EvaluateAndAct(ctx context.Context, card *model.IotCard) error` 方法,封装完整的停复机判断和执行逻辑。
|
||||
|
||||
> **签名说明**:删除原设计的 `carrierType string, carrierID uint` 参数(仅用于日志,放入签名会误导实现者)。
|
||||
> 设备/单卡维度通过 `card.DeviceID` 推导,日志上下文通过函数内部的 `zap.Field` 记录。
|
||||
|
||||
**删除** `internal/task/polling_handler.go` 中的以下函数(迁移到 StopResumeService):
|
||||
- `checkStopResume`
|
||||
- `shouldStopCard`
|
||||
- `hasAvailablePackage`(旧版,被新 `hasValidPackage` 替代)
|
||||
- `stopCardByUsageExhausted`
|
||||
- `resumeCardByPackageAvailable`
|
||||
|
||||
所有 Task Handler(carddata、package、protect)在需要停复机决策时,统一调用 `stopResumeService.EvaluateAndAct()`。
|
||||
|
||||
#### Scenario: Task Handler 调用统一停复机入口
|
||||
- **GIVEN** `carddata_handler.go` 完成流量数据采集和 DB 写入
|
||||
- **WHEN** 调用 `stopResumeService.EvaluateAndAct(ctx, card)`
|
||||
- **THEN** StopResumeService 完整执行停复机判断逻辑;Handler 不包含任何无条件停复机相关代码(`shouldStop`、`hasPackage` 等函数不出现在 Handler 文件中)
|
||||
|
||||
#### Scenario: 停机原因按优先级记录
|
||||
- **GIVEN** 卡在线,同时满足「无套餐」和「流量耗尽」和「未实名」三个条件
|
||||
- **WHEN** `EvaluateAndAct` 执行 `checkStopReasons`
|
||||
- **THEN** 按优先级取最高:`no_package > traffic_exhausted > not_realname`;`stop_reason` 字段记录 `no_package`;停机后 DB 中该卡 `stop_reason='no_package'`
|
||||
|
||||
#### Scenario: EvaluateAndAct 幂等
|
||||
- **GIVEN** 卡已停机,`stop_reason='no_package'`,无套餐状态未变化
|
||||
- **WHEN** 下次轮询再次调用 `EvaluateAndAct`
|
||||
- **THEN** 检测到卡已停机(`network_status=0`),进入复机判断分支;复机条件不满足(仍无套餐),不发起 Gateway 调用,返回 nil;DB 状态不变
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 停机条件——三种场景全面覆盖
|
||||
|
||||
卡在线(`network_status=1`)时,满足以下**任一**条件触发停机:
|
||||
|
||||
**条件 A(no_package)**:无有效套餐
|
||||
- 独立卡:`tb_package_usage` 中无 `iot_card_id=卡ID AND status IN (0,1)` 的记录
|
||||
- 绑定设备的卡:`tb_package_usage` 中无 `device_id=设备ID AND status IN (0,1)` 的记录
|
||||
- status=0(待激活)和 status=1(激活中)均视为有效套餐
|
||||
|
||||
**条件 B(traffic_exhausted)**:虚流量耗尽
|
||||
- 活跃套餐 `status=2`(系统标记为虚流量耗尽),或
|
||||
- 活跃套餐 `data_usage_mb >= data_limit_mb`(且 `data_limit_mb > 0`,防止无限流量卡误判)
|
||||
|
||||
**条件 C(not_realname)**:非行业卡且未实名
|
||||
- `card_category != 'industry'` 且 `real_name_status = 0`
|
||||
|
||||
#### Scenario: 无套餐触发停机(独立卡)
|
||||
- **GIVEN** cardID=100 的独立卡(无 device_id),`network_status=1`
|
||||
- **WHEN** `tb_package_usage` 中无任何 `iot_card_id=100 AND status IN(0,1)` 的记录
|
||||
- **THEN** `checkStopReasons` 返回 `["no_package"]`;发起 Gateway 停机调用(3次重试);DB 更新 `network_status=0, stop_reason='no_package'`
|
||||
|
||||
#### Scenario: 无套餐触发停机(设备卡,修复Bug1)
|
||||
- **GIVEN** cardID=200 的卡绑定 deviceID=50,`network_status=1`
|
||||
- **WHEN** `tb_package_usage` 中无 `iot_card_id=200` 的记录,但有 `device_id=50 AND status=1` 的记录(购买了设备套餐)
|
||||
- **THEN** `hasValidPackage` 检测到设备套餐存在,返回 true;不触发停机;卡保持在线状态(修复之前只查 iot_card_id 导致误停机的 Bug)
|
||||
|
||||
#### Scenario: 流量耗尽触发停机
|
||||
- **GIVEN** 卡在线,活跃套餐 `data_limit_mb=1000, data_usage_mb=1001`
|
||||
- **WHEN** `isTrafficExhausted` 检测
|
||||
- **THEN** `data_usage_mb(1001) >= data_limit_mb(1000)` 条件满足;返回 true;触发停机,`stop_reason='traffic_exhausted'`
|
||||
|
||||
#### Scenario: 套餐 status=2 触发停机
|
||||
- **GIVEN** 卡在线,活跃套餐 `status=2`(系统已标记虚流量耗尽)
|
||||
- **WHEN** `isTrafficExhausted` 检测
|
||||
- **THEN** 检测到 `status=2`;返回 true;触发停机,`stop_reason='traffic_exhausted'`
|
||||
|
||||
#### Scenario: 无限流量套餐不误判流量耗尽
|
||||
- **GIVEN** 卡在线,活跃套餐 `data_limit_mb=0`(无限流量),`data_usage_mb=9999`
|
||||
- **WHEN** `isTrafficExhausted` 检测
|
||||
- **THEN** `data_limit_mb=0` 时跳过用量比较(防止无限流量卡被误停机);返回 false;不触发停机
|
||||
|
||||
#### Scenario: 未实名普通卡触发停机
|
||||
- **GIVEN** 卡在线,`card_category='normal'`,`real_name_status=0`,有有效套餐且流量未耗尽
|
||||
- **WHEN** `checkStopReasons` 检测条件 C
|
||||
- **THEN** `!isRealnameOK(card)` 为 true;触发停机,`stop_reason='not_realname'`
|
||||
|
||||
#### Scenario: 行业卡无需实名不停机
|
||||
- **GIVEN** 卡在线,`card_category='industry'`,`real_name_status=0`,有有效套餐
|
||||
- **WHEN** `checkStopReasons` 检测
|
||||
- **THEN** `isRealnameOK(card)` 返回 true(行业卡豁免实名要求);不触发停机;卡保持在线
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 复机条件——全部满足才复机
|
||||
|
||||
卡停机(`network_status=0`)时,以下**全部**条件满足才触发自动复机:
|
||||
|
||||
1. `stop_reason IN ('traffic_exhausted', 'no_package', 'not_realname')`(排除手动停机、运营商停机等其他原因)
|
||||
2. 有有效套餐且流量未耗尽(`hasValidPackage=true` 且 `isTrafficExhausted=false`)
|
||||
3. 行业卡 OR 已实名(`card_category='industry'` OR `real_name_status=1`)
|
||||
|
||||
#### Scenario: 购买套餐后自动复机
|
||||
- **GIVEN** cardID=300 因 `no_package` 停机,`stop_reason='no_package'`,现在购买了套餐(status=1)
|
||||
- **WHEN** 下次轮询执行 `EvaluateAndAct`
|
||||
- **THEN** 三个复机条件全部满足(stop_reason合规 + 有套餐 + 已实名或行业卡);发起 Gateway 复机调用(3次重试);DB 更新 `network_status=1, stop_reason=''`
|
||||
|
||||
#### Scenario: 手动停机不自动复机
|
||||
- **GIVEN** 卡 `stop_reason='manual'`(管理员手动停机)
|
||||
- **WHEN** 该卡购买了套餐,完成实名认证,轮询执行 `EvaluateAndAct`
|
||||
- **THEN** 条件1不满足(`'manual'` 不在 IN 列表中);不触发自动复机;卡保持停机状态;需管理员手动复机
|
||||
|
||||
#### Scenario: 流量耗尽停机后购买新套餐复机
|
||||
- **GIVEN** 卡因 `traffic_exhausted` 停机,`stop_reason='traffic_exhausted'`;旧套餐已过期(status=3),新购套餐 status=1,usage=0
|
||||
- **WHEN** 轮询执行 `EvaluateAndAct`
|
||||
- **THEN** 条件1满足(traffic_exhausted);条件2满足(新套餐有效,usage<limit);条件3满足(已实名);触发复机
|
||||
|
||||
#### Scenario: 未实名卡实名后复机
|
||||
- **GIVEN** 普通卡因 `not_realname` 停机,用户完成实名认证(`real_name_status=1`)
|
||||
- **WHEN** 实名检查轮询执行,更新 DB 后调用 `EvaluateAndAct`
|
||||
- **THEN** 条件1满足;条件2满足(有套餐且未耗尽);条件3满足(`real_name_status=1`);触发复机
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 设备维度停复机——覆盖所有绑定卡
|
||||
|
||||
**停机**操作覆盖设备下所有在线卡(`network_status=1`);**复机**操作对满足条件的卡执行,跳过未满足 `isRealnameOK` 的普通卡。
|
||||
|
||||
#### Scenario: 设备套餐耗尽停所有绑定卡
|
||||
- **GIVEN** deviceID=10 下有 3 张在线卡(cardID=1,2,3),设备套餐 `data_usage_mb >= data_limit_mb`
|
||||
- **WHEN** 任一绑定卡触发 `EvaluateAndAct`,检测到设备套餐流量耗尽
|
||||
- **THEN** 调用 `stopDeviceCards(ctx, deviceID=10, "traffic_exhausted")`;3 张卡均发起 Gateway 停机调用;3 张卡均更新 `stop_reason='traffic_exhausted'`
|
||||
|
||||
#### Scenario: 设备停机调用 Gateway 带3次重试
|
||||
- **GIVEN** 设备下有 3 张卡需要停机,Gateway 第一次调用返回超时
|
||||
- **WHEN** `stopCardWithRetry` 执行
|
||||
- **THEN** 自动重试至多 3 次;至少 1 次成功则记录成功,最终失败则记录 Error 日志「卡停机失败: cardID=xxx, error=xxx」
|
||||
|
||||
#### Scenario: 购买设备套餐后全卡复机
|
||||
- **GIVEN** deviceID=10 下 3 张卡均因 `traffic_exhausted` 停机,购买新套餐后 status=1
|
||||
- **WHEN** 轮询执行 `EvaluateAndAct`,`shouldResume` 返回 true
|
||||
- **THEN** `resumeDeviceCards(ctx, deviceID=10)` 被调用;检查所有 `stop_reason IN(...)` 的停机卡;3 张卡均满足 `isRealnameOK`(均已实名);3 张卡均触发复机
|
||||
|
||||
#### Scenario: 设备复机跳过未实名普通卡
|
||||
- **GIVEN** deviceID=20 下有 3 张卡:card-A(已实名)、card-B(已实名)、card-C(`card_category='normal'`, `real_name_status=0`)
|
||||
- **WHEN** 设备复机条件满足,执行 `resumeDeviceCards`
|
||||
- **THEN** card-A 和 card-B 触发复机(各执行 Gateway 复机调用);card-C 因 `isRealnameOK=false` 被跳过(保持停机);card-C 的 `stop_reason` 自动更新为 `not_realname`
|
||||
|
||||
#### Scenario: 设备复机中单卡 Gateway 失败不阻止其他卡
|
||||
- **GIVEN** 设备下 3 张卡需要复机,card-B 的 Gateway 复机调用失败(含重试)
|
||||
- **WHEN** `resumeDeviceCards` 遍历执行
|
||||
- **THEN** card-A 和 card-C 正常复机;card-B 记录 Error 日志并跳过,不影响其他卡的复机;`resumeDeviceCards` 整体不返回 error(尽力复机语义)
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 设备维度操作幂等锁——防止并发重复 Gateway 调用
|
||||
|
||||
设备下多张卡分布在不同分片队列,同一调度周期内可能同时触发 `EvaluateAndAct`,进而并发调用 `stopDeviceCards` / `resumeDeviceCards`,导致同一张卡被 Gateway 重复停/复机。
|
||||
|
||||
`stopDeviceCards` 和 `resumeDeviceCards` 均在执行前通过 Redis `SetNX` 获取设备操作锁(`polling:device:op_lock:{deviceID}`,TTL 30 秒)。获取失败时视为"其他协程正在处理",直接跳过。
|
||||
|
||||
#### Scenario: 多卡并发触发不重复调 Gateway
|
||||
- **GIVEN** deviceID=10 下有 card-A(shard 3)和 card-B(shard 7),同一调度周期被并发出队
|
||||
- **WHEN** card-A 和 card-B 同时触发 `EvaluateAndAct` → 均尝试调用 `stopDeviceCards(deviceID=10)`
|
||||
- **THEN** 第一个调用获取设备锁成功,执行完整的停机流程;第二个调用 `SetNX` 返回 false,记录 Debug 日志「设备停机操作已在进行中,跳过: deviceID=10」,直接返回;设备下各卡只被 Gateway 停机一次
|
||||
|
||||
#### Scenario: 设备锁 TTL 超时后可重新获取
|
||||
- **GIVEN** 设备操作锁已过期(上次操作完成后 `Del` 释放,或 TTL 30 秒到期)
|
||||
- **WHEN** 下一轮轮询再次触发 `stopDeviceCards`
|
||||
- **THEN** `SetNX` 成功获取锁,正常执行停机流程
|
||||
267
openspec/specs/polling-task-handlers/spec.md
Normal file
267
openspec/specs/polling-task-handlers/spec.md
Normal file
@@ -0,0 +1,267 @@
|
||||
### Requirement: polling_handler.go 拆分为 4 个专注 Handler
|
||||
|
||||
将 `internal/task/polling_handler.go`(1360行)拆分为 4 个职责单一的文件,每个文件只负责一种任务类型的数据采集,停复机决策统一委托给 `StopResumeService.EvaluateAndAct()`。
|
||||
|
||||
**文件目标行数**:
|
||||
| 文件 | 职责 | 目标行数 |
|
||||
|------|------|---------|
|
||||
| `polling_base.go` | 共享基类(并发/缓存/重入队) | < 150行 |
|
||||
| `polling_realname_handler.go` | 实名状态采集 | < 200行 |
|
||||
| `polling_carddata_handler.go` | 流量数据采集 | < 300行 |
|
||||
| `polling_package_handler.go` | 套餐数据采集 | < 200行 |
|
||||
| `polling_protect_handler.go` | 保护期一致性检查 | < 150行 |
|
||||
|
||||
**删除**:拆分完成后,`internal/task/polling_handler.go` 整体删除。
|
||||
|
||||
**Handler 边界原则**:
|
||||
- ✅ 允许:调 Gateway 采集数据、写 Store(DB更新)、调 StopResumeService.EvaluateAndAct()
|
||||
- ❌ 禁止:直接 `h.db.Model()`(绕过 Store 层)、停复机判断逻辑、直接调 Gateway 停复机接口
|
||||
|
||||
#### Scenario: Asynq 任务类型常量不变(向后兼容)
|
||||
- **GIVEN** 现有 Asynq Worker 已注册 4 种任务类型常量
|
||||
- **WHEN** 拆分后注册 4 个新 Handler
|
||||
- **THEN** 任务类型常量名称完全不变(`TaskTypeRealnameCheck`、`TaskTypeCarddataCheck` 等);已在 Asynq 队列中的待处理任务无需清空,新 Handler 直接接管处理
|
||||
|
||||
#### Scenario: 4 个 Handler 并行处理不干扰
|
||||
- **GIVEN** 同时有 realname、carddata、package、protect 四种任务在处理
|
||||
- **WHEN** 各 Handler 独立执行
|
||||
- **THEN** 各 Handler 使用独立的并发锁(`PollingBase.acquireConcurrency` 按任务类型隔离);realname 任务的并发数不占用 carddata 任务的并发配额
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 共享基类 PollingBase
|
||||
|
||||
新建 `internal/task/polling_base.go`,提供 `PollingBase` 结构体,供 4 个 Handler 组合使用。
|
||||
|
||||
**提取的公共方法**(原来在 `polling_handler.go` 中重复出现):
|
||||
- `acquireConcurrency(taskType) bool`:获取并发控制信号量
|
||||
- `releaseConcurrency(taskType)`:释放并发控制信号量
|
||||
- `getCardWithCache(ctx, cardID) (*model.IotCard, error)`:带 Redis 缓存的卡查询
|
||||
- `updateCardCache(ctx, card)`:更新 Redis 卡信息缓存
|
||||
- `requeueCard(ctx, cardID, taskType, interval)`:按间隔重新入队(调 `PollingQueueManager.Requeue`)
|
||||
- `getMatchedPollingInterval(card) time.Duration`:获取该卡匹配的轮询间隔
|
||||
|
||||
#### Scenario: 缓存命中减少 DB 压力
|
||||
- **GIVEN** cardID=100 的卡信息已缓存在 Redis(TTL 5分钟)
|
||||
- **WHEN** `polling_realname_handler.go` 调用 `base.getCardWithCache(ctx, 100)`
|
||||
- **THEN** 直接从 Redis 读取,不查询 DB;缓存命中后更新 TTL(滑动窗口)
|
||||
|
||||
#### Scenario: 缓存未命中回源 DB
|
||||
- **GIVEN** cardID=200 的卡信息不在 Redis 缓存中
|
||||
- **WHEN** 任一 Handler 调用 `base.getCardWithCache(ctx, 200)`
|
||||
- **THEN** 查询 DB,将结果写入 Redis(TTL 5分钟);返回卡信息
|
||||
|
||||
#### Scenario: 并发控制防止过载——并发满时必须 requeue
|
||||
- **GIVEN** `carddata` 任务最大并发数配置为 50
|
||||
- **WHEN** 同时有 60 个 carddata 任务尝试执行
|
||||
- **THEN** 前 50 个获取到信号量正常执行;后 10 个 `acquireConcurrency` 返回 false;**后 10 个任务调 `requeueCard(ctx, cardID, taskType, time.Now())` 立即重入队**,记录 Debug 日志「并发数已满,已重新入队: cardID=xxx」,返回 nil(Asynq 不报错,不重试)
|
||||
|
||||
> **⚠️ 正确性约束**:Lua 脚本原子出队时卡已从 Redis Sorted Set 中删除。若并发满时直接 return nil 而不 requeue,该卡将永久消失(不再被轮询)。所有 Handler 必须在 `acquireConcurrency` 返回 false 时先调 `requeueCard` 再返回。
|
||||
|
||||
---
|
||||
|
||||
### Requirement: polling_realname_handler.go——实名数据采集
|
||||
|
||||
**文件**:`internal/task/polling_realname_handler.go`(< 200行)
|
||||
|
||||
**构造函数依赖**(通过构造函数注入,禁止全局变量):
|
||||
```go
|
||||
func NewPollingRealnameHandler(
|
||||
base *PollingBase,
|
||||
gateway GatewayClient,
|
||||
iotCardStore IotCardStore,
|
||||
queueClient QueueClient, // 用于触发首次实名激活任务
|
||||
) *PollingRealnameHandler
|
||||
```
|
||||
|
||||
**方法列表**:
|
||||
- `Handle(ctx, task) error`:Asynq 任务入口
|
||||
- `processCard(ctx, cardID) error`:核心处理逻辑
|
||||
|
||||
**处理流程**:
|
||||
1. 调 `base.acquireConcurrency("realname")`,获取失败则跳过
|
||||
2. 调 `base.getCardWithCache(ctx, cardID)` 获取卡信息
|
||||
3. 调 Gateway 查询实名状态
|
||||
4. 写 Store(更新 `real_name_status`)
|
||||
5. 若实名状态变为已实名(0→1):
|
||||
a. 入队首次实名激活 Asynq 任务(`triggerFirstRealnameActivation`)
|
||||
b. **调 `stopResumeService.EvaluateAndAct(ctx, card)` 触发复机判断**(卡可能因 `not_realname` 停机,需立即复机)
|
||||
6. 调 `base.requeueCard(ctx, cardID, "realname", interval)` 重新入队
|
||||
7. 调 `base.releaseConcurrency("realname")`
|
||||
|
||||
#### Scenario: 实名状态由未实名变为已实名触发激活和复机
|
||||
- **GIVEN** cardID=100,`real_name_status=0`(未实名),且卡因 `not_realname` 处于停机状态(`network_status=0, stop_reason='not_realname'`)
|
||||
- **WHEN** Gateway 返回实名已完成,`HandleRealnameCheck` 执行
|
||||
- **THEN** Store 更新 `real_name_status=1`;入队首次实名激活 Asynq 任务;**立即调用 `EvaluateAndAct` 检测复机条件**;若有有效套餐且流量未耗尽,发起 Gateway 复机调用,DB 更新 `network_status=1, stop_reason=''`;记录日志「卡实名状态变更: cardID=100, 0→1,触发激活任务和复机评估」
|
||||
|
||||
#### Scenario: 实名状态未变化不触发激活
|
||||
- **GIVEN** cardID=200,`real_name_status=1`(已实名),Gateway 返回仍已实名
|
||||
- **WHEN** `HandleRealnameCheck` 执行
|
||||
- **THEN** Store 不执行更新(无变化);不入队激活任务;调 `requeueCard` 按正常间隔重新入队
|
||||
|
||||
#### Scenario: realname Handler 仅在实名 0→1 时调用 EvaluateAndAct(S1 修复)
|
||||
- **GIVEN** `polling_realname_handler.go` 代码 Review
|
||||
- **WHEN** 检查文件内容
|
||||
- **THEN** 文件中**不出现**无条件的 `stopCard`、`resumeCard` 等停复机操作;但**允许且必须**在实名状态由 0→1 时调用 `stopResumeService.EvaluateAndAct(ctx, card)` 触发复机判断;原因:若卡因 `not_realname` 停机,实名完成后应立即复机,不能等下一个 carddata/package 轮询周期(可能长达 1 小时)
|
||||
|
||||
---
|
||||
|
||||
### Requirement: polling_carddata_handler.go——流量数据采集
|
||||
|
||||
**文件**:`internal/task/polling_carddata_handler.go`(< 300行)
|
||||
|
||||
**构造函数依赖**:
|
||||
```go
|
||||
func NewPollingCarddataHandler(
|
||||
base *PollingBase,
|
||||
gateway GatewayClient,
|
||||
iotCardStore IotCardStore,
|
||||
packageStore PackageUsageStore,
|
||||
usageService UsageService, // DeductDataUsage
|
||||
stopResumeService StopResumeServiceInterface,
|
||||
) *PollingCarddataHandler
|
||||
```
|
||||
|
||||
**方法列表**:
|
||||
- `Handle(ctx, task) error`:Asynq 任务入口
|
||||
- `processCard(ctx, cardID) error`:核心处理逻辑
|
||||
- `collectUsageData(ctx, card) (*UsageData, error)`:调 Gateway 获取流量增量
|
||||
|
||||
**处理流程**:
|
||||
1. 获取并发控制
|
||||
2. 调 Gateway 查询流量增量
|
||||
3. 写 Store(更新 `data_usage_mb`)
|
||||
4. 调 `usageService.DeductDataUsage()`(套餐流量扣减计算)
|
||||
5. 调 `stopResumeService.EvaluateAndAct(ctx, card, ...)`(停复机决策)
|
||||
6. 重新入队,释放并发控制
|
||||
|
||||
#### Scenario: 流量更新后委托停复机决策
|
||||
- **GIVEN** cardID=100,流量更新后 `data_usage_mb` 达到 `data_limit_mb`
|
||||
- **WHEN** `HandleCarddataCheck` 执行完流量写入
|
||||
- **THEN** 调用 `stopResumeService.EvaluateAndAct(ctx, card, "iot_card", 100)`;由 StopResumeService 决定是否停机;Handler 不包含 `if usage >= limit { stop() }` 这类判断代码
|
||||
|
||||
#### Scenario: 消除直接 DB 操作
|
||||
- **GIVEN** 原 `polling_handler.go` 中有 13 处 `h.db.Model()` 直接 DB 操作
|
||||
- **WHEN** 拆分后的 `polling_carddata_handler.go` 代码 Review
|
||||
- **THEN** 文件中不出现 `h.db.`、`db.Model()`、`db.Where()` 等直接 GORM 调用;所有 DB 操作通过 Store 接口(`iotCardStore.UpdateXxx()`、`packageStore.UpdateXxx()`)
|
||||
|
||||
#### Scenario: 跨月流量边界检测(必须保留)
|
||||
- **GIVEN** cardID=100,`current_month_start_date=2026-03-01`,当前日期为 `2026-04-02`
|
||||
- **WHEN** Gateway 返回月度总流量 `500MB`,`processCard` 检测到跨月(当前月份首日 != current_month_start_date)
|
||||
- **THEN** 保存 `last_month_total_mb = 旧 current_month_total`;重置 `current_month_usage_mb = 500`(以 Gateway 新月份值为准);更新 `current_month_start_date = 2026-04-01`;记录流量历史到 `data_usage_records`
|
||||
|
||||
#### Scenario: 同月流量增量计算
|
||||
- **GIVEN** cardID=200,`current_month_start_date=2026-04-01`,`last_month_total_mb=100`,当前日期为 `2026-04-02`
|
||||
- **WHEN** Gateway 返回月度总流量 `150MB`
|
||||
- **THEN** 计算增量 `delta = 150 - 100 = 50MB`;更新 `current_month_usage_mb += 50`;更新 `last_month_total_mb = 150`
|
||||
|
||||
#### Scenario: Gateway 调用失败不丢失数据
|
||||
- **GIVEN** cardID=300,Gateway 返回网络超时
|
||||
- **WHEN** `collectUsageData` 调用 Gateway 失败
|
||||
- **THEN** 不更新 DB(不写入错误数据);记录 Warn 日志「流量查询失败: cardID=300, error=xxx」;调 `requeueCard` 按较短间隔(重试间隔)重新入队;Asynq 层面不触发重试(Handler 返回 nil,MaxRetry=0)
|
||||
|
||||
---
|
||||
|
||||
### Requirement: polling_package_handler.go——套餐数据采集
|
||||
|
||||
**文件**:`internal/task/polling_package_handler.go`(< 200行)
|
||||
|
||||
**构造函数依赖**:
|
||||
```go
|
||||
func NewPollingPackageHandler(
|
||||
base *PollingBase,
|
||||
gateway GatewayClient,
|
||||
packageStore PackageUsageStore,
|
||||
stopResumeService StopResumeServiceInterface,
|
||||
) *PollingPackageHandler
|
||||
```
|
||||
|
||||
**方法列表**:
|
||||
- `Handle(ctx, task) error`:Asynq 任务入口
|
||||
- `processCard(ctx, cardID) error`:核心处理逻辑
|
||||
|
||||
**处理流程**:
|
||||
1. 获取并发控制
|
||||
2. 调 Gateway 查询套餐信息(剩余流量、状态)
|
||||
3. 写 Store(更新 `tb_package_usage` 套餐状态/用量)
|
||||
4. 调 `stopResumeService.EvaluateAndAct(ctx, card, ...)`(停复机决策)
|
||||
5. 重新入队,释放并发控制
|
||||
|
||||
#### Scenario: 套餐到期后触发复机判断
|
||||
- **GIVEN** 卡因 `traffic_exhausted` 停机,旧套餐已过期(status=3),Gateway 返回新套餐已激活
|
||||
- **WHEN** `HandlePackageCheck` 执行,更新套餐状态后
|
||||
- **THEN** 调 `EvaluateAndAct`;StopResumeService 检测到新套餐有效、流量未耗尽;触发复机;Handler 不直接调用 resumeCard
|
||||
|
||||
#### Scenario: package Handler 不做停复机判断
|
||||
- **GIVEN** `polling_package_handler.go` 代码 Review
|
||||
- **WHEN** 检查文件内容
|
||||
- **THEN** 文件中不出现 `shouldStopCard`、`hasAvailablePackage`、`stopCard`、`resumeCard` 等函数;套餐处理逻辑仅限于数据采集和 Store 写入
|
||||
|
||||
---
|
||||
|
||||
### Requirement: polling_protect_handler.go——保护期一致性检查
|
||||
|
||||
**文件**:`internal/task/polling_protect_handler.go`(< 200行)
|
||||
|
||||
> **业务语义说明**:本 Handler 的核心目的是"确保保护期内卡状态与保护期方向一致,防止状态漂移"。
|
||||
> 保护期**内**:强制修正状态(直接调 Gateway,不走 EvaluateAndAct 三条件判断)。
|
||||
> 保护期**结束后**:调 EvaluateAndAct 重新评估(按正常停复机逻辑处理)。
|
||||
|
||||
**构造函数依赖**:
|
||||
```go
|
||||
func NewPollingProtectHandler(
|
||||
base *PollingBase,
|
||||
gateway GatewayClient, // 保护期内强制停复机需要直接调 Gateway
|
||||
iotCardStore IotCardStore,
|
||||
stopResumeService StopResumeServiceInterface,
|
||||
) *PollingProtectHandler
|
||||
```
|
||||
|
||||
**方法列表**:
|
||||
- `Handle(ctx, task) error`:Asynq 任务入口
|
||||
- `processCard(ctx, cardID) error`:核心处理逻辑
|
||||
|
||||
**处理流程**:
|
||||
1. 获取并发控制(并发满时 **requeue 后返回**,不丢弃)
|
||||
2. 查 Store 获取卡信息
|
||||
3. 前置跳过检查:
|
||||
- 卡未实名(`real_name_status=0`)→ requeue,直接返回
|
||||
- 卡未绑定设备(`is_standalone=true`)→ requeue,直接返回
|
||||
4. 读取设备保护期 Redis Key(`stop` 保护期 Key + `start` 保护期 Key)
|
||||
5. 根据保护期状态执行对应逻辑:
|
||||
- **有 stop 保护期 + 卡在线(network_status=1)**:直接调 `gateway.StopCard`(强制修正),写 DB `stop_reason='protect'`
|
||||
- **有 start 保护期 + 卡停机(network_status=0)**:直接调 `gateway.StartCard`(强制修正),清空 `stop_reason`
|
||||
- **有保护期 + 状态已一致**:跳过(不调 Gateway,不调 EvaluateAndAct)
|
||||
- **无保护期(保护期已结束)**:调 `stopResumeService.EvaluateAndAct(ctx, card)` 重新评估
|
||||
6. 调 `base.requeueCard` 按间隔重新入队
|
||||
7. 释放并发控制
|
||||
|
||||
#### Scenario: stop 保护期内卡在线——强制停机
|
||||
- **GIVEN** 卡已实名且绑定设备,设备有 stop 保护期(`polling:protect:stop:{deviceID}` Key 存在),但卡当前 `network_status=1`(在线,与保护期方向不一致)
|
||||
- **WHEN** `HandleProtectConsistencyCheck` 执行
|
||||
- **THEN** 检测到 stop 保护期存在且状态不一致;直接调 `gateway.StopCard`(不走 EvaluateAndAct,强制修正);DB 更新 `network_status=0, stop_reason='protect'`;记录 Info 日志「保护期强制停机: cardID=xxx, deviceID=xxx」
|
||||
|
||||
#### Scenario: start 保护期内卡停机——强制复机
|
||||
- **GIVEN** 卡已实名且绑定设备,设备有 start 保护期(`polling:protect:start:{deviceID}` Key 存在),但卡当前 `network_status=0`(停机,与保护期方向不一致)
|
||||
- **WHEN** `HandleProtectConsistencyCheck` 执行
|
||||
- **THEN** 检测到 start 保护期存在且状态不一致;直接调 `gateway.StartCard`(不走 EvaluateAndAct,强制修正);DB 更新 `network_status=1, stop_reason=''`;记录 Info 日志「保护期强制复机: cardID=xxx, deviceID=xxx」
|
||||
|
||||
#### Scenario: 保护期内状态已一致——跳过
|
||||
- **GIVEN** 设备有 stop 保护期,卡已是停机状态(`network_status=0`)
|
||||
- **WHEN** `HandleProtectConsistencyCheck` 执行
|
||||
- **THEN** 检测到保护期存在且状态已一致;跳过,不调 Gateway,不调 EvaluateAndAct;直接 requeue
|
||||
|
||||
#### Scenario: 保护期已结束——调 EvaluateAndAct 重新评估
|
||||
- **GIVEN** 卡在保护期内已停机(`stop_reason='protect'`),保护期 Key 已过期(TTL = 0)
|
||||
- **WHEN** `HandleProtectConsistencyCheck` 执行,读取保护期 Key 不存在
|
||||
- **THEN** 调 `stopResumeService.EvaluateAndAct(ctx, card)` 重新评估;若有有效套餐且已实名,触发复机;否则保持停机(`stop_reason` 更新为实际原因)
|
||||
|
||||
#### Scenario: 未实名卡跳过保护期逻辑
|
||||
- **GIVEN** 卡 `real_name_status=0`(未实名)
|
||||
- **WHEN** `HandleProtectConsistencyCheck` 执行
|
||||
- **THEN** 检测到未实名,直接 requeue(不检查保护期,不调 Gateway)
|
||||
|
||||
#### Scenario: 独立卡(未绑定设备)跳过
|
||||
- **GIVEN** 卡 `is_standalone=true`(未绑定设备)
|
||||
- **WHEN** `HandleProtectConsistencyCheck` 执行
|
||||
- **THEN** 检测到独立卡,直接 requeue(设备保护期与独立卡无关)
|
||||
@@ -54,12 +54,14 @@ const (
|
||||
NetworkStatusOnline = 1 // 开机
|
||||
)
|
||||
|
||||
// 任务 24.1: IoT 卡停机原因
|
||||
// IoT 卡停机原因
|
||||
const (
|
||||
StopReasonTrafficExhausted = "traffic_exhausted" // 流量耗尽
|
||||
StopReasonManual = "manual" // 手动停机
|
||||
StopReasonArrears = "arrears" // 欠费
|
||||
StopReasonProtectPeriod = "protect_period" // 保护期一致性检查自动停机
|
||||
StopReasonNoPackage = "no_package" // 停机原因:无有效套餐
|
||||
StopReasonNotRealname = "not_realname" // 停机原因:非行业卡且未完成实名认证
|
||||
)
|
||||
|
||||
// 套餐流量类型
|
||||
|
||||
23
pkg/constants/polling.go
Normal file
23
pkg/constants/polling.go
Normal file
@@ -0,0 +1,23 @@
|
||||
package constants
|
||||
|
||||
// PollingShardCount 轮询队列默认分片数
|
||||
// 千万级规模下,16 分片可将单队列深度控制在 ~60 万以内
|
||||
const PollingShardCount = 16
|
||||
|
||||
// PollingBackpressureThreshold 单分片队列背压阈值
|
||||
// 超过此深度时,Scheduler 跳过该分片本轮出队,防止 Asynq 积压过载
|
||||
// 默认 50 万,可根据 Asynq Worker 处理速度调整
|
||||
const PollingBackpressureThreshold = 500_000
|
||||
|
||||
// PollingDequeueMaxBatchSize Lua 脚本单次出队上限
|
||||
// 受 Lua unpack() 的 LUAI_MAXCSTACK 约 8000 限制,保守取 7000
|
||||
const PollingDequeueMaxBatchSize = 7000
|
||||
|
||||
// PollingDefaultMaxConcurrency 并发信号量默认上限
|
||||
// 读取 Redis 配置 key 失败时使用,作为安全兜底值
|
||||
// 300:支撑 10M 卡、2小时 carddata 间隔(Gateway 200ms → 300并发 = 1500任务/秒/类型)
|
||||
const PollingDefaultMaxConcurrency = 300
|
||||
|
||||
// PollingConcurrencyKeyTTL 并发计数 key 的过期时间(秒)
|
||||
// 保证 Worker 意外崩溃(defer Decr 未执行)时计数器在此时间内自动归零
|
||||
const PollingConcurrencyKeyTTL = 600 // 10 分钟
|
||||
@@ -407,3 +407,23 @@ func RedisWechatConfigActiveKey() string {
|
||||
func RedisCardDailyTrafficKey(cardID uint, date string) string {
|
||||
return fmt.Sprintf("traffic:daily:%d:%s", cardID, date)
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 轮询分片队列相关 Redis Key
|
||||
// ========================================
|
||||
|
||||
// RedisPollingShardQueueKey 轮询分片队列 Key
|
||||
// shardID: 分片编号(0 到 N-1)
|
||||
// taskType: 任务类型(realname/carddata/package/protect)
|
||||
// 格式:polling:shard:{shardID}:queue:{taskType}
|
||||
func RedisPollingShardQueueKey(shardID int, taskType string) string {
|
||||
return fmt.Sprintf("polling:shard:%d:queue:%s", shardID, taskType)
|
||||
}
|
||||
|
||||
// RedisPollingDeviceOpLockKey 设备维度停复机操作锁 Key
|
||||
// 防止设备下多张卡并发触发 EvaluateAndAct 导致重复 Gateway 调用
|
||||
// TTL 建议 30 秒(覆盖 stopDeviceCards/resumeDeviceCards 最长执行时间)
|
||||
// 格式:polling:device:op_lock:{deviceID}
|
||||
func RedisPollingDeviceOpLockKey(deviceID uint) string {
|
||||
return fmt.Sprintf("polling:device:op_lock:%d", deviceID)
|
||||
}
|
||||
|
||||
@@ -54,7 +54,7 @@ func BuildDocHandlers() *bootstrap.Handlers {
|
||||
PollingAlert: admin.NewPollingAlertHandler(nil),
|
||||
PollingCleanup: admin.NewPollingCleanupHandler(nil),
|
||||
PollingManualTrigger: admin.NewPollingManualTriggerHandler(nil),
|
||||
Asset: admin.NewAssetHandler(nil, nil, nil),
|
||||
Asset: admin.NewAssetHandler(nil, nil, nil, nil),
|
||||
AssetWallet: admin.NewAssetWalletHandler(nil),
|
||||
WechatConfig: admin.NewWechatConfigHandler(nil),
|
||||
AgentRecharge: admin.NewAgentRechargeHandler(nil, nil),
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/gateway"
|
||||
"github.com/break/junhong_cmp_fiber/internal/polling"
|
||||
iot_card_svc "github.com/break/junhong_cmp_fiber/internal/service/iot_card"
|
||||
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
|
||||
"github.com/break/junhong_cmp_fiber/internal/task"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
@@ -24,6 +25,8 @@ type Handler struct {
|
||||
pollingCallback task.PollingCallback
|
||||
workerResult *WorkerBootstrapResult
|
||||
asynqClient *asynq.Client
|
||||
pollingBase *task.PollingBase
|
||||
pollingStopResumeSvc iot_card_svc.StopResumeServiceInterface
|
||||
}
|
||||
|
||||
func NewHandler(
|
||||
@@ -35,6 +38,8 @@ func NewHandler(
|
||||
workerResult *WorkerBootstrapResult,
|
||||
asynqClient *asynq.Client,
|
||||
logger *zap.Logger,
|
||||
pollingBase *task.PollingBase,
|
||||
pollingStopResumeSvc iot_card_svc.StopResumeServiceInterface,
|
||||
) *Handler {
|
||||
return &Handler{
|
||||
mux: asynq.NewServeMux(),
|
||||
@@ -46,6 +51,8 @@ func NewHandler(
|
||||
pollingCallback: pollingCallback,
|
||||
workerResult: workerResult,
|
||||
asynqClient: asynqClient,
|
||||
pollingBase: pollingBase,
|
||||
pollingStopResumeSvc: pollingStopResumeSvc,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -149,26 +156,25 @@ func (h *Handler) registerCommissionCalculationHandler() {
|
||||
}
|
||||
|
||||
func (h *Handler) registerPollingHandlers() {
|
||||
pollingHandler := task.NewPollingHandler(
|
||||
h.db,
|
||||
h.redis,
|
||||
h.asynqClient,
|
||||
h.gatewayClient,
|
||||
h.workerResult.Services.UsageService,
|
||||
h.logger,
|
||||
)
|
||||
realnameHandler := task.NewPollingRealnameHandler(
|
||||
h.pollingBase, h.gatewayClient, h.workerResult.Stores.IotCard,
|
||||
h.asynqClient, h.pollingStopResumeSvc)
|
||||
carrierStore := postgres.NewCarrierStore(h.db)
|
||||
carddataHandler := task.NewPollingCarddataHandler(
|
||||
h.pollingBase, h.gatewayClient, h.workerResult.Stores.IotCard,
|
||||
carrierStore, h.workerResult.Services.UsageService, h.pollingStopResumeSvc)
|
||||
packageHandler := task.NewPollingPackageHandler(
|
||||
h.pollingBase, h.workerResult.Stores.IotCard,
|
||||
h.pollingStopResumeSvc)
|
||||
protectHandler := task.NewPollingProtectHandler(
|
||||
h.pollingBase, h.gatewayClient, h.workerResult.Stores.IotCard,
|
||||
h.workerResult.Stores.DeviceSimBinding, h.pollingStopResumeSvc)
|
||||
|
||||
h.mux.HandleFunc(constants.TaskTypePollingRealname, pollingHandler.HandleRealnameCheck)
|
||||
h.logger.Info("注册实名检查任务处理器", zap.String("task_type", constants.TaskTypePollingRealname))
|
||||
|
||||
h.mux.HandleFunc(constants.TaskTypePollingCarddata, pollingHandler.HandleCarddataCheck)
|
||||
h.logger.Info("注册流量检查任务处理器", zap.String("task_type", constants.TaskTypePollingCarddata))
|
||||
|
||||
h.mux.HandleFunc(constants.TaskTypePollingPackage, pollingHandler.HandlePackageCheck)
|
||||
h.logger.Info("注册套餐检查任务处理器", zap.String("task_type", constants.TaskTypePollingPackage))
|
||||
|
||||
h.mux.HandleFunc(constants.TaskTypePollingProtect, pollingHandler.HandleProtectConsistencyCheck)
|
||||
h.logger.Info("注册保护期一致性检查任务处理器", zap.String("task_type", constants.TaskTypePollingProtect))
|
||||
h.mux.HandleFunc(constants.TaskTypePollingRealname, realnameHandler.Handle)
|
||||
h.mux.HandleFunc(constants.TaskTypePollingCarddata, carddataHandler.Handle)
|
||||
h.mux.HandleFunc(constants.TaskTypePollingPackage, packageHandler.Handle)
|
||||
h.mux.HandleFunc(constants.TaskTypePollingProtect, protectHandler.Handle)
|
||||
h.logger.Info("已注册轮询任务处理器(realname/carddata/package/protect)")
|
||||
}
|
||||
|
||||
func (h *Handler) registerPackageActivationHandlers() {
|
||||
|
||||
Reference in New Issue
Block a user