diff --git a/.claude/skills/comment-standards/SKILL.md b/.claude/skills/comment-standards/SKILL.md new file mode 100644 index 0000000..aa10e03 --- /dev/null +++ b/.claude/skills/comment-standards/SKILL.md @@ -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 +``` diff --git a/.claude/skills/openspec-api-contract/SKILL.md b/.claude/skills/openspec-api-contract/SKILL.md new file mode 100644 index 0000000..ba0984e --- /dev/null +++ b/.claude/skills/openspec-api-contract/SKILL.md @@ -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 字段手动维护 diff --git a/.opencode/package-lock.json b/.opencode/package-lock.json new file mode 100644 index 0000000..86bbf56 --- /dev/null +++ b/.opencode/package-lock.json @@ -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" + } + } + } +} diff --git a/.opencode/skills/comment-standards/SKILL.md b/.opencode/skills/comment-standards/SKILL.md new file mode 100644 index 0000000..aa10e03 --- /dev/null +++ b/.opencode/skills/comment-standards/SKILL.md @@ -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 +``` diff --git a/.opencode/skills/openspec-api-contract/SKILL.md b/.opencode/skills/openspec-api-contract/SKILL.md new file mode 100644 index 0000000..ba0984e --- /dev/null +++ b/.opencode/skills/openspec-api-contract/SKILL.md @@ -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 字段手动维护 diff --git a/AGENTS.md b/AGENTS.md index 7a6f6fd..d14e7d7 100644 --- a/AGENTS.md +++ b/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` @@ -672,19 +309,4 @@ client.EnqueueContext(ctx, task) > "我注意到任务 2.1 和 2.2 可以合并为一步完成,是否可以这样优化?" > "任务 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. +**详细规范和 OpenSpec 工作流请查看**: `@/openspec/AGENTS.md` \ No newline at end of file diff --git a/cmd/worker/main.go b/cmd/worker/main.go index 87811f0..f7d71be 100644 --- a/cmd/worker/main.go +++ b/cmd/worker/main.go @@ -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 服务器配置完成", diff --git a/docs/admin-openapi.yaml b/docs/admin-openapi.yaml index c37ded7..d7da6cd 100644 --- a/docs/admin-openapi.yaml +++ b/docs/admin-openapi.yaml @@ -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。 diff --git a/internal/bootstrap/handlers.go b/internal/bootstrap/handlers.go index 6329308..01cff03 100644 --- a/internal/bootstrap/handlers.go +++ b/internal/bootstrap/handlers.go @@ -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,11 +94,15 @@ 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), - AssetLifecycle: admin.NewAssetLifecycleHandler(svc.AssetLifecycle), - AssetWallet: admin.NewAssetWalletHandler(svc.AssetWallet), - WechatConfig: admin.NewWechatConfigHandler(svc.WechatConfig), - AgentRecharge: admin.NewAgentRechargeHandler(svc.AgentRecharge, validate), - Refund: admin.NewRefundHandler(svc.Refund), + 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), + AgentRecharge: admin.NewAgentRechargeHandler(svc.AgentRecharge, validate), + Refund: admin.NewRefundHandler(svc.Refund), } } diff --git a/internal/bootstrap/services.go b/internal/bootstrap/services.go index 18e1d5b..3aedcdb 100644 --- a/internal/bootstrap/services.go +++ b/internal/bootstrap/services.go @@ -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{ diff --git a/internal/bootstrap/worker_services.go b/internal/bootstrap/worker_services.go index e3263d3..1f7d45a 100644 --- a/internal/bootstrap/worker_services.go +++ b/internal/bootstrap/worker_services.go @@ -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) diff --git a/internal/handler/admin/asset.go b/internal/handler/admin/asset.go index 3944da9..3d54458 100644 --- a/internal/handler/admin/asset.go +++ b/internal/handler/admin/asset.go @@ -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, + }) +} diff --git a/internal/handler/admin/polling_manual_trigger.go b/internal/handler/admin/polling_manual_trigger.go index 1d8a197..86cb5be 100644 --- a/internal/handler/admin/polling_manual_trigger.go +++ b/internal/handler/admin/polling_manual_trigger.go @@ -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 } diff --git a/internal/model/device.go b/internal/model/device.go index e9b3d7b..3ecd4fb 100644 --- a/internal/model/device.go +++ b/internal/model/device.go @@ -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"` diff --git a/internal/model/dto/asset_dto.go b/internal/model/dto/asset_dto.go index d82572c..417d464 100644 --- a/internal/model/dto/asset_dto.go +++ b/internal/model/dto/asset_dto.go @@ -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:已禁用)"` +} diff --git a/internal/polling/api_callback.go b/internal/polling/api_callback.go deleted file mode 100644 index f9e0b3c..0000000 --- a/internal/polling/api_callback.go +++ /dev/null @@ -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)) - } - } -} diff --git a/internal/polling/callbacks.go b/internal/polling/callbacks.go deleted file mode 100644 index b1b78b3..0000000 --- a/internal/polling/callbacks.go +++ /dev/null @@ -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) -} diff --git a/internal/polling/config_manager.go b/internal/polling/config_manager.go new file mode 100644 index 0000000..8e8d601 --- /dev/null +++ b/internal/polling/config_manager.go @@ -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 +} diff --git a/internal/polling/data_reset_handler.go b/internal/polling/data_reset_handler.go index 33e0400..3067386 100644 --- a/internal/polling/data_reset_handler.go +++ b/internal/polling/data_reset_handler.go @@ -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, + resetService: resetService, + logger: logger, + lastDailyReset: now, + lastMonthlyReset: now, + lastYearlyReset: now, } } diff --git a/internal/polling/initializer.go b/internal/polling/initializer.go new file mode 100644 index 0000000..a471ee3 --- /dev/null +++ b/internal/polling/initializer.go @@ -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() +} diff --git a/internal/polling/lifecycle_service.go b/internal/polling/lifecycle_service.go new file mode 100644 index 0000000..ed719a6 --- /dev/null +++ b/internal/polling/lifecycle_service.go @@ -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) +} diff --git a/internal/polling/package_activation_handler.go b/internal/polling/package_activation_handler.go index 5f81520..dba0892 100644 --- a/internal/polling/package_activation_handler.go +++ b/internal/polling/package_activation_handler.go @@ -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 - } + if h.activationService == nil { + h.logger.Warn("ActivationService 未注入,跳过首次实名激活") + return nil + } + + if err := h.activationService.ActivateByRealname(ctx, payload.CarrierType, payload.CarrierID); err != nil { + h.logger.Error("首次实名激活失败", + zap.String("carrier_type", payload.CarrierType), + zap.Uint("carrier_id", payload.CarrierID), + zap.Error(err)) return err } - // 检查 pending_realname_activation 是否已为 false(已处理过) - if !pkg.PendingRealnameActivation { - h.logger.Info("套餐已处理过首次实名激活,跳过", - zap.Uint("package_usage_id", payload.PackageUsageID)) - 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 } diff --git a/internal/polling/queue_manager.go b/internal/polling/queue_manager.go new file mode 100644 index 0000000..ec13c36 --- /dev/null +++ b/internal/polling/queue_manager.go @@ -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 +} diff --git a/internal/polling/scheduler.go b/internal/polling/scheduler.go index 934adfc..bd7c490 100644 --- a/internal/polling/scheduler.go +++ b/internal/polling/scheduler.go @@ -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 + redis *redis.Client + queueClient *asynq.Client + logger *zap.Logger + queueMgr *PollingQueueManager + configMgr *PollingConfigManager + cfg *SchedulerConfig // 启动时固定,避免每次调度重新创建 - // 任务 19: 套餐激活检查处理器 packageActivationHandler *PackageActivationHandler - // 任务 20: 流量重置调度处理器 - dataResetHandler *DataResetHandler + 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", - }, - stopChan: make(chan struct{}), + 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 +// RefreshConfigs 刷新配置缓存 +func (s *Scheduler) RefreshConfigs(ctx context.Context) error { + if s.configMgr == nil { + return nil } - - s.configCacheLock.Lock() - s.configCache = configs - s.configCacheTime = time.Now() - s.configCacheLock.Unlock() - - // 同步到 Redis 缓存 - return s.syncConfigsToRedis(ctx, configs) + return s.configMgr.Load(ctx) } -// 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)) +// SetStopResumeCallback 注入停复机回调(在 Start 前调用) +func (s *Scheduler) SetStopResumeCallback(callback packagepkg.StopResumeCallback) { + if s.packageActivationHandler != nil { + s.packageActivationHandler.stopResumeCallback = callback } - - 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 - } - - return nil -} - -// initConcurrencyConfigs 初始化并发控制配置到 Redis -func (s *Scheduler) initConcurrencyConfigs(ctx context.Context) error { - configs, err := s.concurrencyStore.List(ctx) - if err != nil { - return err - } - - 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)) - } - } - - 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) + cardIDs, err := s.redis.LPopCount(ctx, key, maxBatch).Result() + if err != nil || len(cardIDs) == 0 { + return } + s.enqueueBatch(ctx, taskType, cardIDs) } -// 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)) +// 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": false, + "timestamp": time.Now().Unix(), } - return - } - - if len(cardIDs) == 0 { - return - } - - // 只在数量较大时打印日志,避免日志过多 - 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) + payloadBytes, marshalErr := marshalJSON(payload) + if marshalErr != nil { + s.logger.Error("序列化任务载荷失败,跳过该卡", + zap.String("task_type", taskType), zap.String("card_id", cardID), zap.Error(marshalErr)) + continue } - 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)) + task := asynq.NewTask(taskType, payloadBytes, + asynq.MaxRetry(0), + asynq.Timeout(30*time.Second), + asynq.Queue(constants.QueueDefault), + ) + 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)) } } - }(batch) - } - - wg.Wait() -} - -// enqueueTask 提交任务到 Asynq 队列 -func (s *Scheduler) enqueueTask(ctx context.Context, taskType, cardID string, isManual bool) error { - payload := map[string]interface{}{ - "card_id": cardID, - "is_manual": isManual, - "timestamp": time.Now().Unix(), - } - - task := asynq.NewTask(taskType, mustMarshal(payload), - asynq.MaxRetry(0), // 不重试,失败后重新入队 - asynq.Timeout(30*time.Second), // 30秒超时 - 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 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 - } } diff --git a/internal/polling/utils.go b/internal/polling/utils.go index 6baba7e..414cbf5 100644 --- a/internal/polling/utils.go +++ b/internal/polling/utils.go @@ -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" +} diff --git a/internal/routes/asset.go b/internal/routes/asset.go index 065f539..83104fe 100644 --- a/internal/routes/asset.go +++ b/internal/routes/asset.go @@ -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, + }) } diff --git a/internal/service/iot_card/stop_resume_service.go b/internal/service/iot_card/stop_resume_service.go index 58b1100..04a61a3 100644 --- a/internal/service/iot_card/stop_resume_service.go +++ b/internal/service/iot_card/stop_resume_service.go @@ -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)) } } diff --git a/internal/service/polling/asset_polling_service.go b/internal/service/polling/asset_polling_service.go new file mode 100644 index 0000000..306f8dc --- /dev/null +++ b/internal/service/polling/asset_polling_service.go @@ -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") + } +} diff --git a/internal/service/polling/monitoring_service.go b/internal/service/polling/monitoring_service.go index b6f0349..9ee5546 100644 --- a/internal/service/polling/monitoring_service.go +++ b/internal/service/polling/monitoring_service.go @@ -5,19 +5,34 @@ 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" ) // MonitoringService 轮询监控服务 type MonitoringService struct { - redis *redis.Client + 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 - // 获取队列大小 - 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() + 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() } - // 获取队列大小 - status.QueueSize, _ = s.redis.ZCard(ctx, queueKey).Result() + 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() diff --git a/internal/store/postgres/carrier_store.go b/internal/store/postgres/carrier_store.go index ec60dc6..27544fa 100644 --- a/internal/store/postgres/carrier_store.go +++ b/internal/store/postgres/carrier_store.go @@ -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 +} diff --git a/internal/store/postgres/device_store.go b/internal/store/postgres/device_store.go index 43ae632..3f6001d 100644 --- a/internal/store/postgres/device_store.go +++ b/internal/store/postgres/device_store.go @@ -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 +} diff --git a/internal/store/postgres/iot_card_store.go b/internal/store/postgres/iot_card_store.go index edb38ba..c4a6f20 100644 --- a/internal/store/postgres/iot_card_store.go +++ b/internal/store/postgres/iot_card_store.go @@ -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) { diff --git a/internal/store/postgres/package_usage_store.go b/internal/store/postgres/package_usage_store.go index 6633702..91be31f 100644 --- a/internal/store/postgres/package_usage_store.go +++ b/internal/store/postgres/package_usage_store.go @@ -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 diff --git a/internal/task/polling_base.go b/internal/task/polling_base.go new file mode 100644 index 0000000..7fbfb4e --- /dev/null +++ b/internal/task/polling_base.go @@ -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) +} diff --git a/internal/task/polling_card_cache.go b/internal/task/polling_card_cache.go new file mode 100644 index 0000000..0017163 --- /dev/null +++ b/internal/task/polling_card_cache.go @@ -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) +} diff --git a/internal/task/polling_carddata_handler.go b/internal/task/polling_carddata_handler.go new file mode 100644 index 0000000..fa0173f --- /dev/null +++ b/internal/task/polling_carddata_handler.go @@ -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 +} diff --git a/internal/task/polling_handler.go b/internal/task/polling_handler.go deleted file mode 100644 index e581917..0000000 --- a/internal/task/polling_handler.go +++ /dev/null @@ -1,1360 +0,0 @@ -package task - -import ( - "context" - "sort" - "strconv" - "strings" - "time" - - "github.com/bytedance/sonic" - "github.com/hibiken/asynq" - "github.com/redis/go-redis/v9" - "go.uber.org/zap" - "gorm.io/gorm" - - "github.com/break/junhong_cmp_fiber/internal/gateway" - "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" -) - -// PollingTaskPayload 轮询任务载荷 -type PollingTaskPayload struct { - CardID string `json:"card_id"` - IsManual bool `json:"is_manual"` - Timestamp int64 `json:"timestamp"` -} - -// PollingHandler 轮询任务处理器 -type PollingHandler struct { - db *gorm.DB - redis *redis.Client - asynqClient *asynq.Client // 用于提交 Asynq 任务(如首次实名激活) - gatewayClient *gateway.Client - iotCardStore *postgres.IotCardStore - concurrencyStore *postgres.PollingConcurrencyConfigStore - pollingConfigStore *postgres.PollingConfigStore // 用于 requeueCard 读取配置间隔 - deviceSimBindingStore *postgres.DeviceSimBindingStore - packageUsageStore *postgres.PackageUsageStore - usageService *packagepkg.UsageService - logger *zap.Logger -} - -// NewPollingHandler 创建轮询任务处理器 -func NewPollingHandler( - db *gorm.DB, - redis *redis.Client, - asynqClient *asynq.Client, - gatewayClient *gateway.Client, - usageService *packagepkg.UsageService, - logger *zap.Logger, -) *PollingHandler { - return &PollingHandler{ - db: db, - redis: redis, - asynqClient: asynqClient, - gatewayClient: gatewayClient, - iotCardStore: postgres.NewIotCardStore(db, redis), - concurrencyStore: postgres.NewPollingConcurrencyConfigStore(db), - pollingConfigStore: postgres.NewPollingConfigStore(db), - deviceSimBindingStore: postgres.NewDeviceSimBindingStore(db, redis), - packageUsageStore: postgres.NewPackageUsageStore(db, redis), - usageService: usageService, - logger: logger, - } -} - -// HandleRealnameCheck 处理实名检查任务 -func (h *PollingHandler) HandleRealnameCheck(ctx context.Context, t *asynq.Task) error { - startTime := time.Now() - - var payload PollingTaskPayload - if err := sonic.Unmarshal(t.Payload(), &payload); err != nil { - h.logger.Error("解析任务载荷失败", zap.Error(err)) - return nil // 不重试 - } - - cardID, err := strconv.ParseUint(payload.CardID, 10, 64) - if err != nil { - h.logger.Error("解析卡ID失败", zap.String("card_id", payload.CardID), zap.Error(err)) - return nil - } - - // 获取并发信号量 - if !h.acquireConcurrency(ctx, constants.TaskTypePollingRealname) { - h.logger.Debug("并发已满,任务稍后重试", zap.Uint64("card_id", cardID)) - return h.requeueCard(ctx, uint(cardID), constants.TaskTypePollingRealname) - } - defer h.releaseConcurrency(ctx, constants.TaskTypePollingRealname) - - h.logger.Debug("开始实名检查", - zap.Uint64("card_id", cardID), - zap.Bool("is_manual", payload.IsManual)) - - // 获取卡信息 - card, err := h.getCardWithCache(ctx, uint(cardID)) - if err != nil { - if err == gorm.ErrRecordNotFound { - h.logger.Warn("卡不存在", zap.Uint64("card_id", cardID)) - return nil - } - h.logger.Error("获取卡信息失败", zap.Uint64("card_id", cardID), zap.Error(err)) - h.updateStats(ctx, constants.TaskTypePollingRealname, false, time.Since(startTime)) - return h.requeueCard(ctx, uint(cardID), constants.TaskTypePollingRealname) - } - - // 行业卡跳过实名检查 - if card.CardCategory == "industry" { - h.logger.Debug("行业卡跳过实名检查", zap.Uint64("card_id", cardID)) - return h.requeueCard(ctx, uint(cardID), constants.TaskTypePollingRealname) - } - - // 调用 Gateway API 查询实名状态 - var newRealnameStatus int - if h.gatewayClient != nil { - result, err := h.gatewayClient.QueryRealnameStatus(ctx, &gateway.CardStatusReq{ - CardNo: card.ICCID, - }) - if err != nil { - h.logger.Warn("查询实名状态失败", - zap.Uint64("card_id", cardID), - zap.String("iccid", card.ICCID), - zap.Error(err)) - h.updateStats(ctx, constants.TaskTypePollingRealname, false, time.Since(startTime)) - return h.requeueCard(ctx, uint(cardID), constants.TaskTypePollingRealname) - } - - // 解析实名状态 - newRealnameStatus = h.parseRealnameStatus(result.RealStatus) - h.logger.Info("实名检查完成", - zap.Uint64("card_id", cardID), - zap.String("iccid", card.ICCID), - zap.Bool("gateway_real_status", result.RealStatus), - zap.Int("new_status", newRealnameStatus), - zap.Int("old_status", card.RealNameStatus)) - } else { - // Gateway 未配置,模拟检查 - newRealnameStatus = card.RealNameStatus - h.logger.Debug("实名检查完成(模拟,Gateway未配置)", - zap.Uint64("card_id", cardID)) - } - - // 检测状态变化 - statusChanged := newRealnameStatus != card.RealNameStatus - - // 提前检测首次实名(之前未实名 → 现在已实名),用于同步记录 first_realname_at - isFirstRealname := statusChanged && - card.RealNameStatus != constants.RealNameStatusVerified && - newRealnameStatus == constants.RealNameStatusVerified - - // 更新数据库 - now := time.Now() - updates := map[string]any{ - "last_real_name_check_at": now, - } - if statusChanged { - updates["real_name_status"] = newRealnameStatus - } - // 首次实名时记录实名时间,后续实名状态不覆盖 - if isFirstRealname { - updates["first_realname_at"] = now - } - - if err := h.db.Model(&model.IotCard{}). - Where("id = ?", cardID). - Updates(updates).Error; err != nil { - h.logger.Error("更新卡信息失败", zap.Uint64("card_id", cardID), zap.Error(err)) - } - - // 如果状态变化,更新 Redis 缓存并重新匹配配置 - if statusChanged { - h.updateCardCache(ctx, uint(cardID), map[string]any{ - "real_name_status": newRealnameStatus, - }) - // 状态变化后需要重新匹配配置(通过调度器回调) - h.logger.Info("实名状态已变化,需要重新匹配配置", - zap.Uint64("card_id", cardID), - zap.Int("old_status", card.RealNameStatus), - zap.Int("new_status", newRealnameStatus)) - - if isFirstRealname { - h.triggerFirstRealnameActivation(ctx, uint(cardID)) - } - } - - // 更新监控统计 - h.updateStats(ctx, constants.TaskTypePollingRealname, true, time.Since(startTime)) - - // 重新入队 - return h.requeueCard(ctx, uint(cardID), constants.TaskTypePollingRealname) -} - -// HandleCarddataCheck 处理卡流量检查任务 -// 任务 18.2-18.4: 改造为支持流量扣减优先级和新停机条件 -func (h *PollingHandler) HandleCarddataCheck(ctx context.Context, t *asynq.Task) error { - startTime := time.Now() - - var payload PollingTaskPayload - if err := sonic.Unmarshal(t.Payload(), &payload); err != nil { - h.logger.Error("解析任务载荷失败", zap.Error(err)) - return nil - } - - cardID, err := strconv.ParseUint(payload.CardID, 10, 64) - if err != nil { - h.logger.Error("解析卡ID失败", zap.String("card_id", payload.CardID), zap.Error(err)) - return nil - } - - // 获取并发信号量 - if !h.acquireConcurrency(ctx, constants.TaskTypePollingCarddata) { - h.logger.Debug("并发已满,任务稍后重试", zap.Uint64("card_id", cardID)) - return h.requeueCard(ctx, uint(cardID), constants.TaskTypePollingCarddata) - } - defer h.releaseConcurrency(ctx, constants.TaskTypePollingCarddata) - - h.logger.Debug("开始流量检查", - zap.Uint64("card_id", cardID), - zap.Bool("is_manual", payload.IsManual)) - - // 获取卡信息 - card, err := h.getCardWithCache(ctx, uint(cardID)) - if err != nil { - if err == gorm.ErrRecordNotFound { - h.logger.Warn("卡不存在", zap.Uint64("card_id", cardID)) - return nil - } - h.logger.Error("获取卡信息失败", zap.Uint64("card_id", cardID), zap.Error(err)) - h.updateStats(ctx, constants.TaskTypePollingCarddata, false, time.Since(startTime)) - return h.requeueCard(ctx, uint(cardID), constants.TaskTypePollingCarddata) - } - - // 必须在 gateway 查询前执行:gateway 报错会提前 return,checkStopResume 放在后面时无套餐在线卡无法被自动停机 - h.checkStopResume(ctx, card) - - // 调用 Gateway API 查询流量 - var gatewayFlowMB float64 - if h.gatewayClient != nil { - result, err := h.gatewayClient.QueryFlow(ctx, &gateway.FlowQueryReq{ - CardNo: card.ICCID, - }) - if err != nil { - h.logger.Warn("查询流量失败", - zap.Uint64("card_id", cardID), - zap.String("iccid", card.ICCID), - zap.Error(err)) - h.updateStats(ctx, constants.TaskTypePollingCarddata, false, time.Since(startTime)) - return h.requeueCard(ctx, uint(cardID), constants.TaskTypePollingCarddata) - } - - // Gateway 返回的是 MB 单位的流量 - gatewayFlowMB = result.Used - h.logger.Info("流量检查完成", - zap.Uint64("card_id", cardID), - zap.String("iccid", card.ICCID), - zap.Float64("gateway_flow_mb", gatewayFlowMB), - zap.String("unit", result.Unit)) - } else { - // Gateway 未配置,使用当前值 - gatewayFlowMB = card.CurrentMonthUsageMB - h.logger.Debug("流量检查完成(模拟,Gateway未配置)", - zap.Uint64("card_id", cardID)) - } - - now := time.Now() - updates, flowIncrementMB := h.calculateFlowUpdates(card, gatewayFlowMB, now) - updates["last_data_check_at"] = now - - // 更新数据库 - if err := h.db.Model(&model.IotCard{}). - Where("id = ?", cardID). - Updates(updates).Error; err != nil { - h.logger.Error("更新卡流量信息失败", zap.Uint64("card_id", cardID), zap.Error(err)) - } - - go h.insertDataUsageRecord(context.Background(), uint(cardID), flowIncrementMB, now) - - // 更新 Redis 缓存 - h.updateCardCache(ctx, uint(cardID), map[string]any{ - "current_month_usage_mb": updates["current_month_usage_mb"], - "last_gateway_reading_mb": gatewayFlowMB, - }) - - // 调用 UsageService.DeductDataUsage 进行流量扣减 - if flowIncrementMB > 0 && h.usageService != nil { - if err := h.usageService.DeductDataUsage(ctx, "iot_card", uint(cardID), int64(flowIncrementMB)); err != nil { - h.logger.Warn("套餐流量扣减失败", - zap.Uint64("card_id", cardID), - zap.Float64("increment_mb", flowIncrementMB), - zap.Error(err)) - } else { - h.logger.Info("套餐流量扣减成功", - zap.Uint64("card_id", cardID), - zap.Float64("increment_mb", flowIncrementMB)) - } - } - - // 更新监控统计 - h.updateStats(ctx, constants.TaskTypePollingCarddata, true, time.Since(startTime)) - - // 触发套餐检查(加入手动队列优先处理) - h.triggerPackageCheck(ctx, uint(cardID)) - - // 重新入队 - return h.requeueCard(ctx, uint(cardID), constants.TaskTypePollingCarddata) -} - -// getCarrierResetDay 读取运营商的上游流量重置日 -func (h *PollingHandler) getCarrierResetDay(carrierID uint) int { - var carrier model.Carrier - if err := h.db.Select("data_reset_day").First(&carrier, carrierID).Error; err != nil { - return 1 - } - if carrier.DataResetDay == 0 { - return 1 - } - return carrier.DataResetDay -} - -// 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 -} - -// shouldStopCard 检查是否应该停机(无有效套餐) -// 有效套餐 = 待生效(0) 或 生效中(1),与 StopResumeService.hasAvailablePackage 对齐 -func (h *PollingHandler) shouldStopCard(ctx context.Context, cardID uint) bool { - var count int64 - if err := h.db.WithContext(ctx).Model(&model.PackageUsage{}). - Where("iot_card_id = ? AND status IN ?", cardID, []int{ - constants.PackageUsageStatusPending, // 待生效 - constants.PackageUsageStatusActive, // 生效中 - }). - Count(&count).Error; err != nil { - h.logger.Warn("查询有效套餐失败", zap.Uint("card_id", cardID), zap.Error(err)) - return false - } - - return count == 0 -} - -// hasAvailablePackage 检查卡是否有可用套餐(待生效或生效中) -func (h *PollingHandler) hasAvailablePackage(ctx context.Context, cardID uint) bool { - var count int64 - if err := h.db.WithContext(ctx).Model(&model.PackageUsage{}). - Where("iot_card_id = ? AND status IN ?", cardID, []int{ - constants.PackageUsageStatusPending, - constants.PackageUsageStatusActive, - }). - Count(&count).Error; err != nil { - h.logger.Warn("查询可用套餐失败", zap.Uint("card_id", cardID), zap.Error(err)) - return false - } - - return count > 0 -} - -// stopCardByUsageExhausted 任务 18.4: 流量耗尽停机 -func (h *PollingHandler) stopCardByUsageExhausted(ctx context.Context, card *model.IotCard) { - // 只有在线的卡才需要停机 - if card.NetworkStatus != 1 { - return - } - - // 调用 Gateway 停机 - if h.gatewayClient != nil { - h.logger.Info("调用网关停机", - zap.Uint("card_id", card.ID), - zap.String("iccid", card.ICCID)) - if err := h.gatewayClient.StopCard(ctx, &gateway.CardOperationReq{ - CardNo: card.ICCID, - }); err != nil { - h.logger.Error("停机失败", - zap.Uint("card_id", card.ID), - zap.String("iccid", card.ICCID), - zap.Error(err)) - return - } - h.logger.Info("网关停机成功", - zap.Uint("card_id", card.ID), - zap.String("iccid", card.ICCID)) - } - - // 更新数据库:卡的网络状态 - now := time.Now() - updates := map[string]any{ - "network_status": 0, - "stopped_at": now, - "stop_reason": constants.StopReasonTrafficExhausted, - "updated_at": now, - } - if err := h.db.Model(&model.IotCard{}). - Where("id = ?", card.ID). - Updates(updates).Error; err != nil { - h.logger.Error("更新卡状态失败", zap.Uint("card_id", card.ID), zap.Error(err)) - } - - h.updateCardCache(ctx, card.ID, map[string]any{ - "network_status": 0, - "stop_reason": constants.StopReasonTrafficExhausted, - }) - - h.logger.Warn("卡已停机(套餐流量耗尽)", - zap.Uint("card_id", card.ID), - zap.String("iccid", card.ICCID)) -} - -// checkStopResume 独立停机/复机检查 -// 停机条件:卡在线 + 无有效套餐(pending/active) → 停机 -// 复机条件:卡停机 + 停机原因为流量耗尽 + 有有效套餐 → 复机 -func (h *PollingHandler) checkStopResume(ctx context.Context, card *model.IotCard) { - if card.NetworkStatus == constants.NetworkStatusOnline { - if h.shouldStopCard(ctx, card.ID) { - h.logger.Warn("无有效套餐,触发停机", - zap.Uint("card_id", card.ID), - zap.String("iccid", card.ICCID)) - h.stopCardByUsageExhausted(ctx, card) - } - return - } - - // 停机卡:只有流量耗尽停机的才自动复机,手动停机不管 - if card.NetworkStatus == constants.NetworkStatusOffline && - card.StopReason == constants.StopReasonTrafficExhausted && - h.hasAvailablePackage(ctx, card.ID) { - h.logger.Info("检测到有效套餐,触发自动复机", - zap.Uint("card_id", card.ID), - zap.String("iccid", card.ICCID)) - h.resumeCardByPackageAvailable(ctx, card) - } -} - -// resumeCardByPackageAvailable 有效套餐恢复后自动复机 -func (h *PollingHandler) resumeCardByPackageAvailable(ctx context.Context, card *model.IotCard) { - if card.RealNameStatus != constants.RealNameStatusVerified { - h.logger.Warn("未实名卡跳过自动复机", - zap.Uint("card_id", card.ID), - zap.String("iccid", card.ICCID)) - return - } - - // 分布式锁防止并发复机 - lockKey := constants.RedisCardResumeLockKey(card.ID) - locked, err := h.redis.SetNX(ctx, lockKey, "1", 30*time.Second).Result() - if err != nil || !locked { - h.logger.Debug("复机操作进行中,跳过", zap.Uint("card_id", card.ID)) - return - } - defer h.redis.Del(ctx, lockKey) - - if h.gatewayClient != nil { - h.logger.Info("调用网关复机", - zap.Uint("card_id", card.ID), - zap.String("iccid", card.ICCID)) - if err := h.gatewayClient.StartCard(ctx, &gateway.CardOperationReq{ - CardNo: card.ICCID, - }); err != nil { - h.logger.Error("复机失败", - zap.Uint("card_id", card.ID), - zap.String("iccid", card.ICCID), - zap.Error(err)) - return - } - h.logger.Info("网关复机成功", - zap.Uint("card_id", card.ID), - zap.String("iccid", card.ICCID)) - } - - now := time.Now() - if err := h.db.Model(&model.IotCard{}). - Where("id = ?", card.ID). - Updates(map[string]any{ - "network_status": constants.NetworkStatusOnline, - "resumed_at": now, - "stop_reason": "", - "updated_at": now, - }).Error; err != nil { - h.logger.Error("复机更新卡状态失败", zap.Uint("card_id", card.ID), zap.Error(err)) - return - } - - h.updateCardCache(ctx, card.ID, map[string]any{ - "network_status": constants.NetworkStatusOnline, - "stop_reason": "", - }) - - h.logger.Info("卡已自动复机(检测到有效套餐)", - zap.Uint("card_id", card.ID), - zap.String("iccid", card.ICCID)) -} - -// calculateFlowUpdates 计算流量更新值和增量(合并原 calculateFlowUpdates 和 calculateFlowIncrement) -// 增量算法:increment = 当前网关读数 - 上次网关读数,检测上游重置和跨自然月 -func (h *PollingHandler) calculateFlowUpdates(card *model.IotCard, gatewayFlowMB float64, now time.Time) (map[string]any, float64) { - updates := make(map[string]any) - - // 增量 = 当前读数 - 上次读数 - increment := gatewayFlowMB - card.LastGatewayReadingMB - - if increment < 0 { - // 当前值比上次小,检查是否为上游运营商正常重置 - resetDay := h.getCarrierResetDay(card.CarrierID) - if isResetWindow(now, resetDay) { - // 在重置日窗口内,视为正常上游重置,本次原始值即为增量 - increment = gatewayFlowMB - h.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.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.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 - // 跨月重置为 0,再加上本次增量 - if increment > 0 { - updates["current_month_usage_mb"] = increment - } else { - updates["current_month_usage_mb"] = float64(0) - } - } else if increment > 0 { - // 同月内,增量累加(使用 gorm.Expr 保证原子性) - 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 -} - -// HandlePackageCheck 处理套餐检查任务 -func (h *PollingHandler) HandlePackageCheck(ctx context.Context, t *asynq.Task) error { - startTime := time.Now() - - var payload PollingTaskPayload - if err := sonic.Unmarshal(t.Payload(), &payload); err != nil { - h.logger.Error("解析任务载荷失败", zap.Error(err)) - return nil - } - - cardID, err := strconv.ParseUint(payload.CardID, 10, 64) - if err != nil { - h.logger.Error("解析卡ID失败", zap.String("card_id", payload.CardID), zap.Error(err)) - return nil - } - - // 获取并发信号量 - if !h.acquireConcurrency(ctx, constants.TaskTypePollingPackage) { - h.logger.Debug("并发已满,任务稍后重试", zap.Uint64("card_id", cardID)) - return h.requeueCard(ctx, uint(cardID), constants.TaskTypePollingPackage) - } - defer h.releaseConcurrency(ctx, constants.TaskTypePollingPackage) - - h.logger.Debug("开始套餐检查", - zap.Uint64("card_id", cardID), - zap.Bool("is_manual", payload.IsManual)) - - // 获取卡信息 - card, err := h.getCardWithCache(ctx, uint(cardID)) - if err != nil { - if err == gorm.ErrRecordNotFound { - h.logger.Warn("卡不存在", zap.Uint64("card_id", cardID)) - return nil - } - h.logger.Error("获取卡信息失败", zap.Uint64("card_id", cardID), zap.Error(err)) - h.updateStats(ctx, constants.TaskTypePollingPackage, false, time.Since(startTime)) - return h.requeueCard(ctx, uint(cardID), constants.TaskTypePollingPackage) - } - - // 检查套餐配置 - if card.SeriesID == nil { - h.logger.Debug("卡无关联套餐系列,跳过检查", zap.Uint64("card_id", cardID)) - h.updateStats(ctx, constants.TaskTypePollingPackage, true, time.Since(startTime)) - return h.requeueCard(ctx, uint(cardID), constants.TaskTypePollingPackage) - } - - // 查询套餐信息(获取虚流量限制) - var pkg model.Package - if err := h.db.Where("series_id = ? AND status = 1", *card.SeriesID). - Order("created_at ASC").First(&pkg).Error; err != nil { - if err == gorm.ErrRecordNotFound { - h.logger.Debug("套餐系列无可用套餐", zap.Uint("series_id", *card.SeriesID)) - } else { - h.logger.Warn("查询套餐失败", zap.Uint("series_id", *card.SeriesID), zap.Error(err)) - } - h.updateStats(ctx, constants.TaskTypePollingPackage, true, time.Since(startTime)) - return h.requeueCard(ctx, uint(cardID), constants.TaskTypePollingPackage) - } - - // 检查是否启用虚流量 - if !pkg.EnableVirtualData || pkg.VirtualDataMB <= 0 { - h.logger.Debug("套餐未启用虚流量或虚流量为0,跳过检查", - zap.Uint64("card_id", cardID), - zap.Uint("package_id", pkg.ID), - zap.Bool("enable_virtual", pkg.EnableVirtualData), - zap.Int64("virtual_data_mb", pkg.VirtualDataMB)) - h.updateStats(ctx, constants.TaskTypePollingPackage, true, time.Since(startTime)) - return h.requeueCard(ctx, uint(cardID), constants.TaskTypePollingPackage) - } - - // 计算流量使用:支持设备级套餐流量汇总 - usedMB, deviceCards, isDeviceLevel := h.calculatePackageUsage(ctx, card) - limitMB := float64(pkg.VirtualDataMB) - usagePercent := (usedMB / limitMB) * 100 - - h.logger.Info("套餐流量检查", - zap.Uint64("card_id", cardID), - zap.String("iccid", card.ICCID), - zap.Float64("used_mb", usedMB), - zap.Float64("limit_mb", limitMB), - zap.Float64("usage_percent", usagePercent), - zap.Bool("is_device_level", isDeviceLevel), - zap.Int("device_card_count", len(deviceCards))) - - // 判断状态 - var needStop bool - var statusMsg string - - switch { - case usedMB > limitMB: - // 已超额,需要停机 - needStop = true - statusMsg = "已超额" - h.logger.Warn("套餐已超额,准备停机", - zap.Uint64("card_id", cardID), - zap.Float64("used_mb", usedMB), - zap.Float64("limit_mb", limitMB)) - case usagePercent >= 95: - // 临近超额(预警) - statusMsg = "临近超额" - h.logger.Warn("套餐流量临近超额", - zap.Uint64("card_id", cardID), - zap.Float64("usage_percent", usagePercent)) - default: - // 正常 - statusMsg = "正常" - } - - // 执行停机 - if needStop { - // 设备级套餐需要停机设备下所有卡 - cardsToStop := []*model.IotCard{card} - if isDeviceLevel && len(deviceCards) > 0 { - cardsToStop = deviceCards - } - - if h.gatewayClient != nil { - h.stopCards(ctx, cardsToStop, &pkg, usedMB) - } else { - h.logger.Debug("停机跳过(Gateway未配置)", - zap.Uint64("card_id", cardID), - zap.Int("cards_to_stop", len(cardsToStop))) - } - } - - h.logger.Info("套餐检查完成", - zap.Uint64("card_id", cardID), - zap.String("iccid", card.ICCID), - zap.Float64("used_mb", usedMB), - zap.Float64("limit_mb", limitMB), - zap.String("status", statusMsg), - zap.Bool("stopped", needStop && card.NetworkStatus == 1), - zap.Duration("duration", time.Since(startTime))) - - // 更新监控统计 - h.updateStats(ctx, constants.TaskTypePollingPackage, true, time.Since(startTime)) - - // 重新入队 - return h.requeueCard(ctx, uint(cardID), constants.TaskTypePollingPackage) -} - -// logStopOperation 记录停机操作日志 -func (h *PollingHandler) logStopOperation(_ context.Context, card *model.IotCard, pkg *model.Package, usedMB float64) { - // 记录详细的停机操作日志(应用日志级别) - h.logger.Info("停机操作记录", - zap.Uint("card_id", card.ID), - zap.String("iccid", card.ICCID), - zap.Uint("package_id", pkg.ID), - zap.String("package_name", pkg.PackageName), - zap.Float64("used_mb", usedMB), - zap.Int64("virtual_data_mb", pkg.VirtualDataMB), - zap.String("reason", "套餐超额自动停机"), - zap.String("operator", "系统自动"), - zap.Int("before_network_status", 1), - zap.Int("after_network_status", 0)) -} - -// calculatePackageUsage 计算套餐流量使用(支持设备级套餐汇总) -// 返回:总流量MB、设备下所有卡(如果是设备级套餐)、是否为设备级套餐 -func (h *PollingHandler) calculatePackageUsage(ctx context.Context, card *model.IotCard) (float64, []*model.IotCard, bool) { - // 检查卡是否绑定到设备 - binding, err := h.deviceSimBindingStore.GetActiveBindingByCardID(ctx, card.ID) - if err != nil { - // 卡未绑定到设备,使用单卡流量 - return card.CurrentMonthUsageMB, nil, false - } - - // 卡绑定到设备,获取设备下所有卡 - bindings, err := h.deviceSimBindingStore.ListByDeviceID(ctx, binding.DeviceID) - if err != nil { - h.logger.Warn("查询设备下所有绑定失败,使用单卡流量", - zap.Uint("device_id", binding.DeviceID), - zap.Error(err)) - return card.CurrentMonthUsageMB, nil, false - } - - if len(bindings) == 0 { - return card.CurrentMonthUsageMB, nil, false - } - - // 获取设备下所有卡的 ID - cardIDs := make([]uint, len(bindings)) - for i, b := range bindings { - cardIDs[i] = b.IotCardID - } - - // 批量查询卡信息 - var cards []*model.IotCard - if err := h.db.WithContext(ctx).Where("id IN ?", cardIDs).Find(&cards).Error; err != nil { - h.logger.Warn("查询设备下所有卡失败,使用单卡流量", - zap.Uint("device_id", binding.DeviceID), - zap.Error(err)) - return card.CurrentMonthUsageMB, nil, false - } - - // 汇总流量 - var totalUsedMB float64 - for _, c := range cards { - totalUsedMB += c.CurrentMonthUsageMB - } - - h.logger.Debug("设备级套餐流量汇总", - zap.Uint("device_id", binding.DeviceID), - zap.Int("card_count", len(cards)), - zap.Float64("total_used_mb", totalUsedMB)) - - return totalUsedMB, cards, true -} - -// stopCards 批量停机卡 -func (h *PollingHandler) stopCards(ctx context.Context, cards []*model.IotCard, pkg *model.Package, usedMB float64) { - for _, card := range cards { - // 跳过已停机的卡 - if card.NetworkStatus != 1 { - continue - } - - h.logger.Info("调用网关停机", - zap.Uint("card_id", card.ID), - zap.String("iccid", card.ICCID)) - err := h.gatewayClient.StopCard(ctx, &gateway.CardOperationReq{ - CardNo: card.ICCID, - }) - if err != nil { - h.logger.Error("停机失败", - zap.Uint("card_id", card.ID), - zap.String("iccid", card.ICCID), - zap.Error(err)) - continue - } - - h.logger.Warn("停机成功", - zap.Uint("card_id", card.ID), - zap.String("iccid", card.ICCID), - zap.String("reason", "套餐超额")) - - now := time.Now() - if err := h.db.Model(&model.IotCard{}). - Where("id = ?", card.ID). - Updates(map[string]any{ - "network_status": 0, - "stopped_at": now, - "stop_reason": constants.StopReasonTrafficExhausted, - "updated_at": now, - }).Error; err != nil { - h.logger.Error("更新卡状态失败", zap.Uint("card_id", card.ID), zap.Error(err)) - } - - // 更新 Redis 缓存 - h.updateCardCache(ctx, card.ID, map[string]any{ - "network_status": 0, - }) - - // 记录操作日志 - h.logStopOperation(ctx, card, pkg, usedMB) - } -} - -// parseRealnameStatus 解析实名状态 -// Gateway 返回 bool 类型:true=已实名, false=未实名 -func (h *PollingHandler) parseRealnameStatus(realStatus bool) int { - if realStatus { - return constants.RealNameStatusVerified // = 1,已实名 - } - return constants.RealNameStatusNotVerified // = 0,未实名 -} - -// extractTaskType 从完整的任务类型中提取简短的类型名 -// 例如:polling:carddata -> carddata -func extractTaskType(fullTaskType string) string { - if idx := strings.LastIndex(fullTaskType, ":"); idx != -1 { - return fullTaskType[idx+1:] - } - return fullTaskType -} - -// acquireConcurrency 获取并发信号量 -func (h *PollingHandler) acquireConcurrency(ctx context.Context, taskType string) bool { - // 提取简短的任务类型(数据库中存的是 carddata,不是 polling:carddata) - shortType := extractTaskType(taskType) - configKey := constants.RedisPollingConcurrencyConfigKey(shortType) - currentKey := constants.RedisPollingConcurrencyCurrentKey(taskType) // current 保持原样 - - // 获取最大并发数 - maxConcurrency, err := h.redis.Get(ctx, configKey).Int() - if err != nil { - maxConcurrency = 50 // 默认值 - } - - // 尝试获取信号量 - current, err := h.redis.Incr(ctx, currentKey).Result() - if err != nil { - h.logger.Warn("获取并发计数失败", zap.Error(err)) - return true // 出错时允许执行 - } - - if current > int64(maxConcurrency) { - h.redis.Decr(ctx, currentKey) - return false - } - - return true -} - -// releaseConcurrency 释放并发信号量 -func (h *PollingHandler) releaseConcurrency(ctx context.Context, taskType string) { - currentKey := constants.RedisPollingConcurrencyCurrentKey(taskType) - if err := h.redis.Decr(ctx, currentKey).Err(); err != nil { - h.logger.Warn("释放并发计数失败", zap.Error(err)) - } -} - -// requeueCard 重新将卡加入队列 -// 从 PollingConfig 读取实际配置间隔;无匹配配置或间隔为 NULL 时不入队(停止轮询该卡) -func (h *PollingHandler) requeueCard(ctx context.Context, cardID uint, taskType string) error { - intervalSeconds, err := h.getMatchedPollingInterval(ctx, cardID, taskType) - if err != nil { - h.logger.Warn("获取轮询配置间隔失败,跳过入队", - zap.Uint("card_id", cardID), - zap.String("task_type", taskType), - zap.Error(err)) - return nil - } - if intervalSeconds <= 0 { - // 无匹配配置或该任务类型间隔为 NULL,不再轮询该卡 - h.logger.Debug("无匹配轮询配置,跳过入队", - zap.Uint("card_id", cardID), - zap.String("task_type", taskType)) - return nil - } - - var queueKey string - switch taskType { - case constants.TaskTypePollingRealname: - queueKey = constants.RedisPollingQueueRealnameKey() - case constants.TaskTypePollingCarddata: - queueKey = constants.RedisPollingQueueCarddataKey() - case constants.TaskTypePollingPackage: - queueKey = constants.RedisPollingQueuePackageKey() - case constants.TaskTypePollingProtect: - queueKey = constants.RedisPollingQueueProtectKey() - default: - return nil - } - - nextCheck := time.Now().Add(time.Duration(intervalSeconds) * time.Second) - if err := h.redis.ZAdd(ctx, queueKey, redis.Z{ - Score: float64(nextCheck.Unix()), - Member: strconv.FormatUint(uint64(cardID), 10), - }).Err(); err != nil { - return err - } - - // protect 任务重新入队时更新检查时间,供下次初始化计算队列延迟 - if taskType == constants.TaskTypePollingProtect { - now := time.Now() - h.db.Model(&model.IotCard{}).Where("id = ?", cardID).Update("last_protect_check_at", now) - } - - return nil -} - -// getMatchedPollingInterval 读取该卡匹配的 PollingConfig,返回指定任务类型的间隔秒数 -// 优先读取 Redis 缓存,miss 时回退到 DB;无匹配或间隔为 NULL 返回 0 -func (h *PollingHandler) getMatchedPollingInterval(ctx context.Context, cardID uint, taskType string) (int, error) { - card, err := h.getCardWithCache(ctx, cardID) - if err != nil { - return 0, err - } - - configs, err := h.getPollingConfigsFromCache(ctx) - if err != nil || len(configs) == 0 { - // Redis 缓存未命中,回退查 DB - configs, err = h.pollingConfigStore.ListEnabled(ctx) - if err != nil { - return 0, err - } - } - - for _, cfg := range configs { - if !h.matchPollingConfig(cfg, card) { - continue - } - switch taskType { - case constants.TaskTypePollingRealname: - if cfg.RealnameCheckInterval != nil && *cfg.RealnameCheckInterval > 0 { - return *cfg.RealnameCheckInterval, nil - } - case constants.TaskTypePollingCarddata: - if cfg.CarddataCheckInterval != nil && *cfg.CarddataCheckInterval > 0 { - return *cfg.CarddataCheckInterval, nil - } - case constants.TaskTypePollingPackage: - if cfg.PackageCheckInterval != nil && *cfg.PackageCheckInterval > 0 { - return *cfg.PackageCheckInterval, nil - } - case constants.TaskTypePollingProtect: - if cfg.ProtectCheckInterval != nil && *cfg.ProtectCheckInterval > 0 { - return *cfg.ProtectCheckInterval, nil - } - } - // 匹配到配置但该任务类型间隔为 NULL,说明此卡不需要此类轮询 - return 0, nil - } - - return 0, nil -} - -// getPollingConfigsFromCache 从 Redis HASH 缓存读取已启用的轮询配置列表,按 priority 升序排列 -// 与 Scheduler.syncConfigsToRedis 写入格式对应 -func (h *PollingHandler) getPollingConfigsFromCache(ctx context.Context) ([]*model.PollingConfig, error) { - key := constants.RedisPollingConfigsCacheKey() - result, err := h.redis.HGetAll(ctx, key).Result() - if err != nil || len(result) == 0 { - return nil, err - } - - configs := make([]*model.PollingConfig, 0, len(result)) - for _, jsonStr := range result { - var cfg model.PollingConfig - if err := sonic.UnmarshalString(jsonStr, &cfg); err != nil { - h.logger.Warn("解析轮询配置缓存失败", zap.Error(err)) - continue - } - configs = append(configs, &cfg) - } - - sort.Slice(configs, func(i, j int) bool { - return configs[i].Priority < configs[j].Priority - }) - - return configs, nil -} - -// matchPollingConfig 判断卡是否满足配置的匹配条件 -// 逻辑与 polling.Scheduler.matchConfigConditions 保持一致 -func (h *PollingHandler) matchPollingConfig(cfg *model.PollingConfig, card *model.IotCard) bool { - if cfg.CardCondition != "" && cfg.CardCondition != h.cardCondition(card) { - return false - } - if cfg.CardCategory != "" && cfg.CardCategory != card.CardCategory { - return false - } - if cfg.CarrierID != nil && *cfg.CarrierID != card.CarrierID { - return false - } - return true -} - -// cardCondition 根据卡的实名和网络状态返回条件字符串 -// 逻辑与 polling.Scheduler.getCardCondition 保持一致 -func (h *PollingHandler) cardCondition(card *model.IotCard) string { - if card.RealNameStatus != constants.RealNameStatusVerified { - return "not_real_name" - } - if card.NetworkStatus == constants.NetworkStatusOnline { - return "activated" - } - return "real_name" -} - -// triggerPackageCheck 触发套餐检查 -func (h *PollingHandler) triggerPackageCheck(ctx context.Context, cardID uint) { - key := constants.RedisPollingManualQueueKey(constants.TaskTypePollingPackage) - if err := h.redis.RPush(ctx, key, strconv.FormatUint(uint64(cardID), 10)).Err(); err != nil { - h.logger.Warn("触发套餐检查失败", zap.Uint("card_id", cardID), zap.Error(err)) - } -} - -// updateStats 更新监控统计 -func (h *PollingHandler) updateStats(ctx context.Context, taskType string, success bool, duration time.Duration) { - key := constants.RedisPollingStatsKey(taskType) - - pipe := h.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 -// 增量 <= 0 时直接跳过;增量 > 0 时 INCRBYFLOAT 写 Redis + 48h TTL -func (h *PollingHandler) 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 := h.redis.IncrByFloat(ctx, key, incrementMB).Err(); err != nil { - h.logger.Warn("写入日流量缓冲失败", - zap.Uint("card_id", cardID), - zap.Float64("increment_mb", incrementMB), - zap.Error(err)) - return - } - h.redis.Expire(ctx, key, 48*time.Hour) -} - -// updateCardCache 更新卡缓存 -func (h *PollingHandler) updateCardCache(ctx context.Context, cardID uint, updates map[string]any) { - key := constants.RedisPollingCardInfoKey(cardID) - - // 转换为 []any 用于 HSet - args := make([]any, 0, len(updates)*2) - for k, v := range updates { - args = append(args, k, v) - } - - if len(args) > 0 { - if err := h.redis.HSet(ctx, key, args...).Err(); err != nil { - h.logger.Warn("更新卡缓存失败", - zap.Uint("card_id", cardID), - zap.Error(err)) - } - } -} - -// getCardWithCache 优先从 Redis 缓存获取卡信息,miss 时查 DB -// 大幅减少数据库查询,避免高并发时连接池耗尽 -func (h *PollingHandler) getCardWithCache(ctx context.Context, cardID uint) (*model.IotCard, error) { - key := constants.RedisPollingCardInfoKey(cardID) - - // 尝试从 Redis 读取 - result, err := h.redis.HGetAll(ctx, key).Result() - if err == nil && len(result) > 0 { - // 缓存命中,构建卡对象 - 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["series_id"]; ok { - if id, err := strconv.ParseUint(v, 10, 64); err == nil { - seriesID := uint(id) - card.SeriesID = &seriesID - } - } - if v, ok := result["stop_reason"]; ok { - card.StopReason = v - } - - return card, nil - } - - // 缓存 miss,查询数据库 - card, err := h.iotCardStore.GetByID(ctx, cardID) - if err != nil { - return nil, err - } - - // 异步写入缓存 - go func() { - 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, - "cached_at": time.Now().Unix(), - } - if card.SeriesID != nil { - cacheData["series_id"] = *card.SeriesID - } - pipe := h.redis.Pipeline() - pipe.HSet(cacheCtx, key, cacheData) - pipe.Expire(cacheCtx, key, 7*24*time.Hour) - _, _ = pipe.Exec(cacheCtx) - }() - - return card, nil -} - -// HandleProtectConsistencyCheck 保护期一致性检查 -// 检查绑定设备(is_standalone=false)且已实名(real_name_status=1)的卡 -// stop 保护期 + 开机 → 调网关停机;start 保护期 + 停机 → 调网关复机 -func (h *PollingHandler) HandleProtectConsistencyCheck(ctx context.Context, t *asynq.Task) error { - var payload PollingTaskPayload - if err := sonic.Unmarshal(t.Payload(), &payload); err != nil { - h.logger.Error("解析保护期检查任务载荷失败", zap.Error(err)) - return nil - } - - cardID, err := strconv.ParseUint(payload.CardID, 10, 64) - if err != nil { - h.logger.Error("解析卡ID失败", zap.String("card_id", payload.CardID), zap.Error(err)) - return nil - } - - // 查询卡信息 - var card model.IotCard - if err := h.db.WithContext(ctx).Where("id = ?", cardID).First(&card).Error; err != nil { - if err == gorm.ErrRecordNotFound { - return nil - } - h.logger.Error("查询卡信息失败", zap.Uint64("card_id", cardID), zap.Error(err)) - return nil - } - - // 未绑设备(独立卡),跳过 - if card.IsStandalone { - return h.requeueCard(ctx, card.ID, constants.TaskTypePollingProtect) - } - - // 未实名,跳过 - if card.RealNameStatus != constants.RealNameStatusVerified { - return h.requeueCard(ctx, card.ID, constants.TaskTypePollingProtect) - } - - // 查绑定设备 - binding, err := h.deviceSimBindingStore.GetActiveBindingByCardID(ctx, card.ID) - if err != nil || binding == nil { - return h.requeueCard(ctx, card.ID, constants.TaskTypePollingProtect) - } - deviceID := binding.DeviceID - - // 检查 stop 保护期:设备处于停机保护期,但卡是开机状态 → 需要停机 - stopProtect := h.redis.Exists(ctx, constants.RedisDeviceProtectKey(deviceID, "stop")).Val() > 0 - if stopProtect && card.NetworkStatus == constants.NetworkStatusOnline { - h.logger.Info("保护期一致性检查:停机保护期内发现开机卡,执行停机", - zap.Uint("card_id", card.ID), - zap.String("iccid", card.ICCID), - zap.Uint("device_id", deviceID)) - - if h.gatewayClient != nil { - h.logger.Info("调用网关停机(保护期)", - zap.Uint("card_id", card.ID), - zap.String("iccid", card.ICCID)) - if err := h.gatewayClient.StopCard(ctx, &gateway.CardOperationReq{CardNo: card.ICCID}); err != nil { - h.logger.Error("保护期一致性停机失败", - zap.Uint("card_id", card.ID), - zap.String("iccid", card.ICCID), - zap.Error(err)) - return h.requeueCard(ctx, card.ID, constants.TaskTypePollingProtect) - } - h.logger.Info("网关停机成功(保护期)", - zap.Uint("card_id", card.ID), - zap.String("iccid", card.ICCID)) - } - - h.db.Model(&model.IotCard{}).Where("id = ?", card.ID).Updates(map[string]any{ - "network_status": constants.NetworkStatusOffline, - "stopped_at": time.Now(), - "stop_reason": constants.StopReasonProtectPeriod, - }) - h.updateCardCache(ctx, card.ID, map[string]any{"network_status": constants.NetworkStatusOffline}) - return h.requeueCard(ctx, card.ID, constants.TaskTypePollingProtect) - } - - // 检查 start 保护期:设备处于复机保护期,但卡是停机状态 → 需要复机 - startProtect := h.redis.Exists(ctx, constants.RedisDeviceProtectKey(deviceID, "start")).Val() > 0 - if startProtect && card.NetworkStatus == constants.NetworkStatusOffline { - h.logger.Info("保护期一致性检查:复机保护期内发现停机卡,执行复机", - zap.Uint("card_id", card.ID), - zap.String("iccid", card.ICCID), - zap.Uint("device_id", deviceID)) - - if h.gatewayClient != nil { - h.logger.Info("调用网关复机(保护期)", - zap.Uint("card_id", card.ID), - zap.String("iccid", card.ICCID)) - if err := h.gatewayClient.StartCard(ctx, &gateway.CardOperationReq{CardNo: card.ICCID}); err != nil { - h.logger.Error("保护期一致性复机失败", - zap.Uint("card_id", card.ID), - zap.String("iccid", card.ICCID), - zap.Error(err)) - return h.requeueCard(ctx, card.ID, constants.TaskTypePollingProtect) - } - h.logger.Info("网关复机成功(保护期)", - zap.Uint("card_id", card.ID), - zap.String("iccid", card.ICCID)) - } - - h.db.Model(&model.IotCard{}).Where("id = ?", card.ID).Updates(map[string]any{ - "network_status": constants.NetworkStatusOnline, - "resumed_at": time.Now(), - "stop_reason": "", - }) - h.updateCardCache(ctx, card.ID, map[string]any{"network_status": constants.NetworkStatusOnline}) - } - - return h.requeueCard(ctx, card.ID, constants.TaskTypePollingProtect) -} - -// triggerFirstRealnameActivation 任务 21.3-21.4: 首次实名后触发套餐激活 -func (h *PollingHandler) triggerFirstRealnameActivation(ctx context.Context, cardID uint) { - // 任务 21.3: 查询该卡是否有待激活套餐 - // WHERE pending_realname_activation=true AND status=0 AND iot_card_id=? - var pendingPackages []model.PackageUsage - err := h.db.WithContext(ctx). - Where("iot_card_id = ?", cardID). - Where("pending_realname_activation = ?", true). - Where("status = ?", constants.PackageUsageStatusPending). - Find(&pendingPackages).Error - - if err != nil { - h.logger.Warn("查询待激活套餐失败", - zap.Uint("card_id", cardID), - zap.Error(err)) - return - } - - if len(pendingPackages) == 0 { - h.logger.Debug("无待激活套餐", - zap.Uint("card_id", cardID)) - return - } - - h.logger.Info("发现待激活套餐", - zap.Uint("card_id", cardID), - zap.Int("count", len(pendingPackages))) - - // 任务 21.4: 提交 Asynq 任务激活套餐 - for _, pkg := range pendingPackages { - payload := map[string]any{ - "package_usage_id": pkg.ID, - "carrier_type": "iot_card", - "carrier_id": cardID, - "activation_type": "realname", - "timestamp": time.Now().Unix(), - } - - payloadBytes, err := sonic.Marshal(payload) - if err != nil { - h.logger.Warn("序列化激活任务载荷失败", - zap.Uint("package_usage_id", pkg.ID), - zap.Error(err)) - continue - } - - 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.logger.Warn("提交首次实名激活任务失败", - zap.Uint("package_usage_id", pkg.ID), - zap.Error(err)) - continue - } - - h.logger.Info("已提交首次实名激活任务", - zap.Uint("package_usage_id", pkg.ID), - zap.Uint("card_id", cardID)) - } -} diff --git a/internal/task/polling_package_handler.go b/internal/task/polling_package_handler.go new file mode 100644 index 0000000..55d48f0 --- /dev/null +++ b/internal/task/polling_package_handler.go @@ -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) +} diff --git a/internal/task/polling_protect_handler.go b/internal/task/polling_protect_handler.go new file mode 100644 index 0000000..5c301ba --- /dev/null +++ b/internal/task/polling_protect_handler.go @@ -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) +} diff --git a/internal/task/polling_realname_handler.go b/internal/task/polling_realname_handler.go new file mode 100644 index 0000000..099149f --- /dev/null +++ b/internal/task/polling_realname_handler.go @@ -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)) + } +} diff --git a/internal/task/polling_stats.go b/internal/task/polling_stats.go new file mode 100644 index 0000000..b3ea8c9 --- /dev/null +++ b/internal/task/polling_stats.go @@ -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) +} diff --git a/internal/task/polling_utils.go b/internal/task/polling_utils.go new file mode 100644 index 0000000..5d9d119 --- /dev/null +++ b/internal/task/polling_utils.go @@ -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 +} diff --git a/migrations/000103_add_enable_polling_to_device.down.sql b/migrations/000103_add_enable_polling_to_device.down.sql new file mode 100644 index 0000000..94c36ee --- /dev/null +++ b/migrations/000103_add_enable_polling_to_device.down.sql @@ -0,0 +1,2 @@ +-- 回滚:删除 tb_device 的 enable_polling 字段 +ALTER TABLE tb_device DROP COLUMN IF EXISTS enable_polling; diff --git a/migrations/000103_add_enable_polling_to_device.up.sql b/migrations/000103_add_enable_polling_to_device.up.sql new file mode 100644 index 0000000..409dcc9 --- /dev/null +++ b/migrations/000103_add_enable_polling_to_device.up.sql @@ -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 时设备下所有卡不加入轮询队列'; diff --git a/migrations/000104_polling_config_data.up.sql b/migrations/000104_polling_config_data.up.sql new file mode 100644 index 0000000..6123124 --- /dev/null +++ b/migrations/000104_polling_config_data.up.sql @@ -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'; diff --git a/openspec/changes/archive/2026-04-07-polling-system-refactor/.openspec.yaml b/openspec/changes/archive/2026-04-07-polling-system-refactor/.openspec.yaml new file mode 100644 index 0000000..6a5db8c --- /dev/null +++ b/openspec/changes/archive/2026-04-07-polling-system-refactor/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-04-02 diff --git a/openspec/changes/archive/2026-04-07-polling-system-refactor/design.md b/openspec/changes/archive/2026-04-07-polling-system-refactor/design.md new file mode 100644 index 0000000..0ddceaa --- /dev/null +++ b/openspec/changes/archive/2026-04-07-polling-system-refactor/design.md @@ -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` 无需感知替换。 diff --git a/openspec/changes/archive/2026-04-07-polling-system-refactor/proposal.md b/openspec/changes/archive/2026-04-07-polling-system-refactor/proposal.md new file mode 100644 index 0000000..9126893 --- /dev/null +++ b/openspec/changes/archive/2026-04-07-polling-system-refactor/proposal.md @@ -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个监控接口需适配** | diff --git a/openspec/changes/archive/2026-04-07-polling-system-refactor/specs/polling-config-manager/spec.md b/openspec/changes/archive/2026-04-07-polling-system-refactor/specs/polling-config-manager/spec.md new file mode 100644 index 0000000..f62f46d --- /dev/null +++ b/openspec/changes/archive/2026-04-07-polling-system-refactor/specs/polling-config-manager/spec.md @@ -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` diff --git a/openspec/changes/archive/2026-04-07-polling-system-refactor/specs/polling-queue-manager/spec.md b/openspec/changes/archive/2026-04-07-polling-system-refactor/specs/polling-queue-manager/spec.md new file mode 100644 index 0000000..31ad70b --- /dev/null +++ b/openspec/changes/archive/2026-04-07-polling-system-refactor/specs/polling-queue-manager/spec.md @@ -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 队列深度查询失败」 diff --git a/openspec/changes/archive/2026-04-07-polling-system-refactor/specs/polling-stop-resume-logic/spec.md b/openspec/changes/archive/2026-04-07-polling-system-refactor/specs/polling-stop-resume-logic/spec.md new file mode 100644 index 0000000..f3f9cdf --- /dev/null +++ b/openspec/changes/archive/2026-04-07-polling-system-refactor/specs/polling-stop-resume-logic/spec.md @@ -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= 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` 成功获取锁,正常执行停机流程 diff --git a/openspec/changes/archive/2026-04-07-polling-system-refactor/specs/polling-task-handlers/spec.md b/openspec/changes/archive/2026-04-07-polling-system-refactor/specs/polling-task-handlers/spec.md new file mode 100644 index 0000000..1c53f7b --- /dev/null +++ b/openspec/changes/archive/2026-04-07-polling-system-refactor/specs/polling-task-handlers/spec.md @@ -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(设备保护期与独立卡无关) diff --git a/openspec/changes/archive/2026-04-07-polling-system-refactor/tasks.md b/openspec/changes/archive/2026-04-07-polling-system-refactor/tasks.md new file mode 100644 index 0000000..46d1481 --- /dev/null +++ b/openspec/changes/archive/2026-04-07-polling-system-refactor/tasks.md @@ -0,0 +1,1554 @@ +## 任务清单(openspec 跟踪用) + +- [x] 1.1 创建 DB 迁移文件(tb_device 新增 enable_polling 字段) +- [x] 1.2 model/device.go 新增 EnablePolling 字段 +- [x] 1.3 pkg/constants/iot.go 新增停机原因常量 +- [x] 1.4 pkg/constants/polling.go 新增轮询系统常量 +- [x] 1.5 pkg/constants/redis.go 新增分片队列 Key 函数和设备锁 Key 函数 +- [x] 1.6 执行迁移并验证(DB 操作) +- [x] 1.7 新增 "suspended" 轮询配置(DB 操作) +- [x] 1.8 为现有活跃配置添加 protect_check_interval(DB 操作) +- [x] 1.9 清理 real_name 存量配置条件(DB 操作) +- [x] 2.0 前置评估:not_realname 停机迁移影响(门控任务) +- [x] 2.1 新增 hasValidPackage 方法(修复设备套餐 Bug) +- [x] 2.2 新增 isTrafficExhausted 方法 +- [x] 2.3 新增 isRealnameOK 方法 +- [x] 2.4 新增 checkStopReasons 方法 +- [x] 2.5 新增 EvaluateAndAct 统一入口方法 +- [x] 2.6 修改 stopCardWithRetry 接收 stopReason 参数 +- [x] 2.7 修改 resumeSingleCard:复机条件使用新逻辑 +- [x] 2.8 修改 stopDeviceCards 和 resumeDeviceCards:加设备维度幂等锁 +- [x] 2.9 定义 StopResumeServiceInterface 接口 +- [x] 2.10 删除旧 hasAvailablePackage 方法 +- [x] 2.11 PostgreSQL MCP 验证设备套餐停复机正确性 +- [x] 3.1 新建 queue_manager.go(统一 Redis 队列操作) +- [x] 3.2 新建 config_manager.go(配置管理) +- [x] 4.1 新建 polling_realname_handler.go +- [x] 4.2 新建 polling_carddata_handler.go +- [x] 4.3 新建 polling_package_handler.go +- [x] 4.4 新建 polling_protect_handler.go +- [x] 4.5 消除所有直接 DB 操作(13处) +- [x] 4.6 提取 PollingBase 共享基类 +- [x] 4.7 更新 pkg/queue/handler.go 注册 4 个新 Handler + - [x] 4.8 删除旧 polling_handler.go(Phase 4+5 原子部署完成) + - [x] 4.9 验证 4 种轮询任务均能正常触发和执行 + - [x] 5.1 新建 initializer.go(分片渐进式初始化) + - [x] 5.2 精简 scheduler.go(目标 < 250行,实际 227行) + - [x] 5.3 删除 callbacks.go + - [x] 5.4 删除 api_callback.go + - [x] 5.5 新建 lifecycle_service.go(替代 callbacks.go) + - [x] 5.6 更新 MonitoringService 适配分片队列 + - [x] 5.7 更新 cmd/worker/main.go 启动流程(接入新轮询组件) + - [x] 5.8 更新 PollingCallback 注入点(bootstrap/services.go) +- [x] 6.1 新增 DTO(UpdateAssetPollingStatusRequest/Response) + - [x] 6.2 新增 Store 方法(DeviceStore.UpdatePollingStatus) + - [x] 6.3 新增 Service 方法(AssetPollingService) + - [x] 6.4 新增 Handler 方法(AssetHandler.UpdatePollingStatus) + - [x] 6.5 注册路由 + - [x] 6.6 更新文档生成器 +- [x] 7.1 DB 验证:设备套餐停复机正确性 + - [x] 7.2 DB 验证:非行业卡未实名有套餐触发停机 + - [x] 7.3 DB 验证:行业卡未实名不停机 + - [x] 7.4 Redis 验证:protect 队列 Bug 修复 + - [x] 7.5 接口验证:enable_polling 管控接口 + - [x] 7.6 代码行数验证 + - [x] 7.7 Redis 原子性验证 + - [x] 7.8 兼容性验证:36 个已有接口零改动 + - [x] 7.9 not_realname 停机迁移影响评估(已前移至 2.0) + +--- + +## Phase 1:基础准备(DB 迁移 + 常量 + Redis Key) + +### 1.1 创建 DB 迁移文件(tb_device 新增 enable_polling 字段) + +**文件**:`migrations/YYYYMMDDHHMMSS_add_enable_polling_to_device.sql` + +**内容**: +```sql +-- 上行迁移 +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 时不加入轮询队列'; + +-- 下行迁移(回滚) +-- ALTER TABLE tb_device DROP COLUMN IF EXISTS enable_polling; +``` + +**验证条件**: +- `\d tb_device` 输出中包含 `enable_polling boolean not null default true` +- 存量数据全部为 `TRUE`(`SELECT COUNT(*) FROM tb_device WHERE enable_polling IS NULL` = 0) + +--- + +### 1.2 model/device.go 新增 EnablePolling 字段 + +**文件**:`internal/model/device.go` + +**变更**:在 `Device` 结构体中新增字段,完整 GORM 标签: +```go +// EnablePolling 是否参与轮询,false 时不加入轮询队列 +EnablePolling bool `json:"enable_polling" gorm:"column:enable_polling;not null;default:true"` +``` + +**验证条件**: +- 字段标签与数据库列名一致(`enable_polling`) +- 有中文注释说明用途 +- `not null` 和 `default:true` 标签齐全 + +--- + +### 1.3 pkg/constants/iot.go 新增停机原因常量 + +**文件**:`pkg/constants/iot.go` + +**新增常量**(在现有 `StopReasonTrafficExhausted` 等常量附近): +```go +// StopReasonNoPackage 停机原因:无有效套餐 +StopReasonNoPackage = "no_package" + +// StopReasonNotRealname 停机原因:非行业卡且未完成实名认证 +StopReasonNotRealname = "not_realname" +``` + +**验证条件**: +- 两个常量均有中文注释 +- 与现有 `StopReason*` 常量在同一分组下 +- 常量值全小写下划线格式 + +--- + +### 1.4 pkg/constants/polling.go 新增轮询系统常量 + +**文件**:`pkg/constants/polling.go`(若不存在则新建,否则追加) + +**新增常量**: + +```go +// 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 +``` + +**验证条件**: +- 三个常量均有中文注释说明含义和取值依据 +- `PollingShardCount`、`PollingBackpressureThreshold` 在 `PollingQueueManager` 和 `Scheduler` 中引用(而非硬编码 magic number) +- `go build ./...` 编译通过 + +--- + +### 1.5 pkg/constants/redis.go 新增分片队列 Key 函数和设备锁 Key 函数 + +**文件**:`pkg/constants/redis.go` + +**新增函数**: +```go +// 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) +} +``` + +**验证条件**: +- 两个函数均有中文 doc 注释,说明参数含义和 Key 格式 +- 通过单元验证:`RedisPollingShardQueueKey(3, "carddata")` = `"polling:shard:3:queue:carddata"` +- `RedisPollingDeviceOpLockKey(10)` = `"polling:device:op_lock:10"` + +--- + +### 1.6 执行迁移并验证 + +**操作**: +1. 运行迁移命令(参考 `db-migration` Skill 中的迁移规范) +2. 用 PostgreSQL MCP 验证列已创建 + +**验证 SQL(PostgreSQL MCP 执行)**: +```sql +-- 验证列存在 +SELECT column_name, data_type, column_default, is_nullable +FROM information_schema.columns +WHERE table_name = 'tb_device' AND column_name = 'enable_polling'; + +-- 验证存量数据 +SELECT COUNT(*) as total, + COUNT(*) FILTER (WHERE enable_polling = true) as enabled, + COUNT(*) FILTER (WHERE enable_polling = false) as disabled +FROM tb_device; +``` + +**通过条件**:查询返回 `enable_polling | boolean | true | NO`,存量数据 `enabled = total`。 + +--- + +### 1.7 新增 "suspended" 轮询配置(M1 修复:停机卡持续轮询) + +**背景**:`getCardCondition` 修复后(决策 14),停机卡返回 `"suspended"` 条件。若数据库中无对应配置,停机卡将无法被入队,也就无法自动复机。 + +**操作**:执行以下 SQL,新增"停机卡轮询"配置: + +```sql +-- 停机卡轮询配置:每小时检查一次实名/流量/套餐,以便检测复机条件 +-- priority=25 介于 'not_real_name'(10) 和 'real_name'(20) 之间 +-- ⚠️ realname_check_interval 必须设置: +-- 停机且未实名的卡(因 not_realname 停机)需要 realname 检查, +-- 实名完成后 realname Handler 触发 EvaluateAndAct → 自动复机; +-- 若 realname_check_interval=NULL,此类卡永远不会被实名检查,无法复机! +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; +``` + +**验证(PostgreSQL MCP)**: +```sql +SELECT id, config_name, card_condition, + realname_check_interval, carddata_check_interval, package_check_interval, status +FROM tb_polling_config +WHERE card_condition = 'suspended'; +-- 预期返回1行,realname_check_interval=3600,carddata_check_interval=3600,status=1 +``` + +**通过条件**:查询返回停机卡配置,且 `realname_check_interval IS NOT NULL`(停机未实名卡必须能被实名检查,否则无法复机)。 + +--- + +### 1.8 为现有活跃配置添加 protect_check_interval(M2 修复) + +**背景**:`protect_check_interval` 字段由迁移 000102 添加,但现有所有配置均为 NULL,导致 protect 任务从未入队,`polling_protect_handler.go` 无法生效。 + +**操作**:为 "已激活在线卡" 和 "默认轮询配置" 添加 protect 检查间隔(与 carddata 保持一致): + +```sql +-- 为已激活卡添加保护期检查(每小时) +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; +``` + +**验证(PostgreSQL MCP)**: +```sql +SELECT config_name, card_condition, protect_check_interval +FROM tb_polling_config +WHERE status = 1 +ORDER BY priority; +-- 预期:至少 activated 和 suspended 的 protect_check_interval 非 NULL +``` + +**通过条件**:至少存在一条 `protect_check_interval IS NOT NULL` 的启用配置。 + +--- + +### 1.9 清理 `real_name` 存量配置条件(getCardCondition 兼容性) + +**背景**:决策 14 修复后,`getCardCondition` 不再返回 `"real_name"`,只返回 `not_real_name / activated / suspended`。 +若数据库中存在 `card_condition='real_name'` 的配置,这些配置将**永远无法匹配**任何卡,但也不会报错——原本依赖此配置轮询的卡会悄悄退出轮询队列。 + +**操作**: + +**第一步:评估影响范围(执行 PostgreSQL MCP)** + +```sql +-- 查看现有 real_name 配置 +SELECT id, config_name, card_condition, priority, status, + realname_check_interval, carddata_check_interval, package_check_interval +FROM tb_polling_config +WHERE card_condition = 'real_name' +ORDER BY priority; +``` + +**决策逻辑**: +- 若返回 0 行:无需操作,继续下一任务。 +- 若返回 ≥ 1 行:分析其轮询间隔含义—— + - 若这些配置是"已实名在线卡"的配置 → 语义等同于 `activated`,执行下面的更新 SQL + - 若有其他特殊含义 → 与业务方确认后再操作 + +**第二步:迁移 real_name 配置(若存在)** + +```sql +-- 将 card_condition='real_name' 迁移到 'activated' +-- 注意:若 activated 配置已存在,需检查优先级是否冲突 +UPDATE tb_polling_config +SET card_condition = 'activated', + description = COALESCE(description, '') || '(原 real_name 条件,已迁移至 activated)', + updated_at = NOW() +WHERE card_condition = 'real_name'; +``` + +**验证(PostgreSQL MCP)**: + +```sql +-- 确认无残留 real_name 配置 +SELECT COUNT(*) FROM tb_polling_config WHERE card_condition = 'real_name'; +-- 预期:0 + +-- 确认 activated 配置存在且启用 +SELECT id, config_name, card_condition, priority, status +FROM tb_polling_config +WHERE card_condition = 'activated' AND status = 1; +-- 预期:至少 1 行 +``` + +**通过条件**:`card_condition='real_name'` 的行数为 0。 + +--- + +## Phase 2:StopResumeService 重写(停复机逻辑统一) + +> ⚠️ **Phase 2 前置门控(必须在 2.1 之前完成)**:Phase 2 新增 `not_realname` 停机条件,部署后所有"在线+未实名+普通卡"将在下一轮询周期被批量停机。必须先执行影响评估并与业务方确认,再继续后续任务。 + +### 2.0 前置评估:not_realname 停机迁移影响(门控任务) + +> **⚠️ 此任务必须在 Phase 2 实施前完成,结果决定是否继续 Phase 2 或调整策略。** + +**评估 SQL(PostgreSQL MCP 执行)**: + +```sql +-- 统计"在线 + 未实名 + 普通卡"(部署后将被停机的卡) +SELECT + COUNT(*) AS total_at_risk, + COUNT(DISTINCT carrier_id) AS affected_carriers +FROM tb_iot_card +WHERE network_status = 1 -- 当前在线 + AND real_name_status = 0 -- 未实名 + AND (card_category IS NULL OR card_category != 'industry') -- 非行业卡 + AND deleted_at IS NULL; + +-- 按运营商分布查看,判断是否适合分批上线 +SELECT carrier_id, COUNT(*) AS count +FROM tb_iot_card +WHERE network_status = 1 + AND real_name_status = 0 + AND (card_category IS NULL OR card_category != 'industry') + AND deleted_at IS NULL +GROUP BY carrier_id +ORDER BY count DESC; +``` + +**评估结果(2026-04-03)**: +- `total_at_risk = 1`,仅 1 张卡受影响(carrier_id=1566) +- 影响极小,满足 `< 100` 门控条件,**可直接继续 Phase 2** + +**决策门控**: +- 若 `total_at_risk = 0`:可直接继续 Phase 2,无迁移影响 +- 若 `total_at_risk < 100`:与业务方确认后可直接继续,记录受影响卡列表 +- 若 `total_at_risk ≥ 100`:**必须**与业务方讨论,可选方案: + a. 灰度上线:先仅对特定 `carrier_id` 生效 + b. 宽限期:新增配置字段"not_realname 停机启用日期",未到日期不执行条件 C + c. 直接接受:通知受影响用户,批量停机作为业务策略 +- **评估结果及业务方决策必须在此任务中记录,才能标记任务完成** + +--- + +### 2.1 新增 hasValidPackage 方法(修复设备套餐 Bug) + +**文件**:`internal/service/iot_card/stop_resume_service.go` + +**方法签名**: +```go +// hasValidPackage 检查卡是否有有效套餐(status IN 0,1) +// 修复Bug:绑定设备的卡查 device_id,独立卡查 iot_card_id +func (s *Service) hasValidPackage(ctx context.Context, card *model.IotCard) (bool, error) +``` + +**实现要点**: +- 若 `card.DeviceID != nil`:查 `tb_package_usage WHERE device_id=? AND status IN(0,1)` +- 否则:查 `tb_package_usage WHERE iot_card_id=? AND status IN(0,1)` +- status=0(待激活)和 status=1(激活中)均视为有效 + +**验证条件(PostgreSQL MCP)**: +```sql +-- 场景1:为设备购买套餐后,hasValidPackage 应返回 true +SELECT COUNT(*) FROM tb_package_usage +WHERE device_id = (SELECT device_id FROM tb_iot_card WHERE id = :cardID) + AND status IN (0, 1); +-- 预期结果 > 0 + +-- 场景2:仅有 iot_card_id 套餐的独立卡 +SELECT COUNT(*) FROM tb_package_usage +WHERE iot_card_id = :cardID AND status IN (0, 1); +``` + +--- + +### 2.2 新增 isTrafficExhausted 方法 + +**文件**:`internal/service/iot_card/stop_resume_service.go` + +**方法签名**: +```go +// isTrafficExhausted 检查卡的流量是否已耗尽 +// 条件:活跃套餐 status=2,或 data_usage_mb >= data_limit_mb(data_limit_mb > 0) +func (s *Service) isTrafficExhausted(ctx context.Context, card *model.IotCard) (bool, error) +``` + +**实现要点**: +- 查询活跃套餐(status=1),同样区分 device_id 和 iot_card_id +- `status=2` 直接返回 true +- `data_limit_mb > 0 && data_usage_mb >= data_limit_mb` 返回 true +- `data_limit_mb == 0`(无限流量)不判断用量,返回 false +- 无活跃套餐时返回 false + +**验证条件(PostgreSQL MCP)**: +```sql +-- 验证流量耗尽场景 +SELECT id, data_usage_mb, data_limit_mb, status +FROM tb_package_usage +WHERE iot_card_id = :cardID AND status IN(1,2) +ORDER BY created_at DESC LIMIT 1; +-- status=2 或 data_usage_mb >= data_limit_mb 时应停机 +``` + +--- + +### 2.3 新增 isRealnameOK 方法 + +**文件**:`internal/service/iot_card/stop_resume_service.go` + +**方法签名**: +```go +// isRealnameOK 检查卡是否满足实名要求 +// 行业卡(card_category='industry')无需实名;其他卡需 real_name_status=1 +func (s *Service) isRealnameOK(card *model.IotCard) bool +``` + +**实现要点**: +- `card.CardCategory == "industry"` 返回 true(行业卡豁免) +- `card.RealNameStatus == 1` 返回 true +- 其他情况返回 false + +**验证条件(PostgreSQL MCP)**: +```sql +-- 验证行业卡数据 +SELECT id, card_category, real_name_status +FROM tb_iot_card +WHERE card_category = 'industry' AND real_name_status = 0 +LIMIT 5; +-- 这些卡不应被停机(行业卡豁免) +``` + +--- + +### 2.4 新增 checkStopReasons 方法 + +**文件**:`internal/service/iot_card/stop_resume_service.go` + +**方法签名**: +```go +// checkStopReasons 检查卡的停机原因列表,按优先级排序 +// 优先级:no_package > traffic_exhausted > not_realname +func (s *Service) checkStopReasons(ctx context.Context, card *model.IotCard) ([]string, error) +``` + +**实现要点**: +- 依次检查三个条件,满足则追加到 reasons 切片 +- 已按优先级顺序检查(第一个即为最高优先级) +- 返回空切片表示无停机原因 + +**验证条件**: +- 同时满足三个条件时,返回 `["no_package", "traffic_exhausted", "not_realname"]` +- 仅满足 traffic_exhausted 时,返回 `["traffic_exhausted"]` + +--- + +### 2.5 新增 EvaluateAndAct 统一入口方法 + +**文件**:`internal/service/iot_card/stop_resume_service.go` + +**方法签名(Mi2 修复:删除冗余的 carrierType/carrierID 参数)**: +```go +// EvaluateAndAct 停复机统一入口 +// 根据卡的当前网络状态,自动判断并执行停机或复机操作 +// 设备/单卡维度通过 card.DeviceID 推导(非 nil 则为设备维度,覆盖设备下所有卡) +func (s *Service) EvaluateAndAct(ctx context.Context, card *model.IotCard) error +``` + +**实现要点**: +- 在线(`network_status=1`):调 `checkStopReasons`,有原因则停机(优先级最高的原因) +- 停机(`network_status=0`):检查 `stop_reason IN(...)` + `shouldResume`,满足则复机 +- 设备维度:`card.DeviceID != nil` 时调 `stopDeviceCards`/`resumeDeviceCards` +- 其他网络状态:直接返回 nil(不处理) +- 日志中通过 `zap.Uint("card_id", card.ID)` 和 `zap.Any("device_id", card.DeviceID)` 记录足够的上下文,无需外部传入 carrierType/carrierID + +**验证条件(PostgreSQL MCP)**: +```sql +-- 验证停机后字段更新 +SELECT id, network_status, stop_reason +FROM tb_iot_card +WHERE id = :cardID; +-- 停机后 network_status=0, stop_reason 为对应原因 + +-- 验证复机后字段清除 +SELECT id, network_status, stop_reason +FROM tb_iot_card +WHERE id = :cardID; +-- 复机后 network_status=1, stop_reason='' +``` + +--- + +### 2.6 修改 stopCardWithRetry 接收 stopReason 参数 + +**文件**:`internal/service/iot_card/stop_resume_service.go` + +**变更**: +- 方法签名增加 `stopReason string` 参数 +- 停机成功后将 `stop_reason` 写入 DB(通过 Store 方法) + +**验证条件(PostgreSQL MCP)**: +```sql +-- 验证停机原因写入 +SELECT id, stop_reason, network_status, updated_at +FROM tb_iot_card +WHERE id = :cardID; +-- stop_reason 应为 'no_package' 或 'traffic_exhausted' 或 'not_realname' +``` + +--- + +### 2.7 修改 resumeSingleCard:复机条件使用新逻辑 + +**文件**:`internal/service/iot_card/stop_resume_service.go` + +**变更**: +- 复机前检查 `stop_reason IN ('traffic_exhausted', 'no_package', 'not_realname')` +- 调用 `shouldResume(ctx, card)` = `hasValidPackage && !isTrafficExhausted && isRealnameOK` +- 复机成功后清空 `stop_reason`(写入 `''`) + +**验证条件(PostgreSQL MCP)**: +```sql +-- 验证 stop_reason='manual' 的卡不被自动复机 +SELECT COUNT(*) FROM tb_iot_card +WHERE stop_reason = 'manual' AND network_status = 1; +-- 预期 = 0(手动停机不应被自动复机) +``` + +--- + +### 2.8 修改 stopDeviceCards 和 resumeDeviceCards:加设备维度幂等锁 + +**文件**:`internal/service/iot_card/stop_resume_service.go` + +**背景**:设备下多张卡可能同时被轮询系统触发 `EvaluateAndAct`(不同分片同一调度周期),导致多次并发调用 `stopDeviceCards` / `resumeDeviceCards`,同一张卡被重复 Gateway 停/复机。 + +**变更(stopDeviceCards)**: +- 进入函数后,先通过 `Redis SetNX` 获取设备操作锁(`constants.RedisPollingDeviceOpLockKey(deviceID)`,TTL 30 秒) +- 获取失败(其他协程正在处理同一设备):记录 Debug 日志「设备停机操作已在进行中,跳过: deviceID=xxx」,直接返回 nil +- 获取成功:执行停机逻辑,`defer` 释放锁 + +**变更(resumeDeviceCards)**: +- 同上,使用同一把锁(`RedisPollingDeviceOpLockKey`) +- 同时:遍历设备下停机卡时,对每张卡调用 `isRealnameOK(card)` +- 不满足(普通卡未实名)的卡:更新 `stop_reason='not_realname'`,跳过复机 +- 遍历中单张卡失败不中断整体(记录 Error 日志,继续处理其他卡) + +```go +// stopDeviceCards 停机设备下所有在线卡(含幂等锁) +func (s *Service) 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) + // ... 遍历设备下在线卡,逐一调 stopCardWithRetry +} +``` + +**验证条件(PostgreSQL MCP)**: +```sql +-- 验证设备下停机卡状态 +SELECT id, card_category, real_name_status, network_status, stop_reason +FROM tb_iot_card +WHERE device_id = :deviceID AND network_status = 0; +-- 行业卡或已实名卡应已复机 (network_status=1) +-- 未实名普通卡应保持停机 (stop_reason='not_realname') +``` + +**验证条件(PostgreSQL MCP)**: +```sql +-- 验证设备下停机卡状态 +SELECT id, card_category, real_name_status, network_status, stop_reason +FROM tb_iot_card +WHERE device_id = :deviceID AND network_status = 0; +-- 行业卡或已实名卡应已复机 (network_status=1) +-- 未实名普通卡应保持停机 (stop_reason='not_realname') +``` + +--- + +### 2.9 定义 StopResumeServiceInterface 接口(Mi3) + +**文件**:`internal/service/iot_card/stop_resume_service.go`(或同包下新建 `interfaces.go`) + +**背景**:Phase 4 的 4 个 Task Handler 通过接口注入 `StopResumeService`,需要事先定义接口,否则 Phase 4 的构造函数无法编译。 + +**新增接口**: +```go +// StopResumeServiceInterface 停复机服务接口 +// 供 4 个 Task Handler 通过接口注入,避免循环依赖 +type StopResumeServiceInterface interface { + // EvaluateAndAct 停复机统一入口,根据卡的当前状态自动判断并执行停机或复机 + EvaluateAndAct(ctx context.Context, card *model.IotCard) error +} +``` + +**验证条件**: +- `internal/service/iot_card/Service` 实现了 `StopResumeServiceInterface`(编译时验证:`var _ StopResumeServiceInterface = (*Service)(nil)`) +- `go build ./...` 编译通过 + +--- + +### 2.10 删除旧 hasAvailablePackage 方法 + +**文件**:`internal/service/iot_card/stop_resume_service.go` + +**变更**:删除原 `hasAvailablePackage` 函数(仅查 `iot_card_id`,存在 Bug)。 + +**验证条件**: +- 文件中不再出现 `hasAvailablePackage` 函数定义 +- 所有原来调用 `hasAvailablePackage` 的地方已改为调用 `hasValidPackage` +- 编译通过,无未使用函数 + +--- + +### 2.11 PostgreSQL MCP 验证设备套餐停复机正确性 + +**验证脚本**(使用 PostgreSQL MCP 工具): + +```sql +-- 验证1:购买设备套餐的卡不被误停机 +-- 选取一张绑定了设备且有设备套餐的在线卡 +SELECT c.id, c.device_id, c.network_status, pu.device_id as pkg_device_id, pu.status +FROM tb_iot_card c +JOIN tb_package_usage pu ON pu.device_id = c.device_id +WHERE c.device_id IS NOT NULL + AND pu.status IN (0,1) + AND c.network_status = 1 +LIMIT 5; +-- 以上卡在重构后不应被停机 + +-- 验证2:行业卡有套餐未实名不被停机 +SELECT id, card_category, real_name_status, network_status +FROM tb_iot_card +WHERE card_category = 'industry' + AND real_name_status = 0 + AND network_status = 1 +LIMIT 5; +-- 以上卡不应有 stop_reason='not_realname' +``` + +--- + +## Phase 3:PollingQueueManager + PollingConfigManager(基础组件) + +### 3.1 新建 queue_manager.go(统一 Redis 队列操作) + +**文件**:`internal/polling/queue_manager.go`(< 200行) + +**实现要点**: +- `DequeueReady`:使用 Lua 脚本原子执行 ZRANGEBYSCORE + ZREM(保留时间过滤语义,只取 score ≤ now 的到期卡): + ```go + var dequeueScript = redis.NewScript(` + local results = redis.call('ZRANGEBYSCORE', KEYS[1], '-inf', ARGV[1], 'LIMIT', 0, tonumber(ARGV[2])) + -- 分批 ZREM:Lua unpack() 受 LUAI_MAXCSTACK 限制(约 8000),按 7000 分批避免溢出 + 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 + `) + ``` +- `Requeue`:使用 `redis.ZAdd(ctx, key, redis.Z{Score: float64(nextCheckAt.Unix()), Member: cardID})` +- `RemoveFromAllQueues`:遍历所有分片(0 到 shardCount-1)× 4 个任务类型,执行 ZREM +- `EnqueueManual`:使用 `redis.RPush(ctx, manualKey, cardID)` 推入 List +- `OnCardDeleted`:调 `RemoveFromAllQueues` + 清理卡信息缓存(`redis.Del(ctx, cacheKey)`) +- `GetQueueDepth`:使用 `redis.ZCard(ctx, shardKey).Result()` + +**Key 生成**:统一调用 `constants.RedisPollingShardQueueKey(shardID, taskType)` + +**验证条件**: +- 文件行数 < 200 行 +- `DequeueReady` 使用 Lua 脚本(`redis.NewScript`)且采用**分批 ZREM**,**不使用 ZPOPMIN** +- `RemoveFromAllQueues` 覆盖 4 个任务类型(含 protect) +- `go build ./...` 编译通过 + +--- + +### 3.2 新建 config_manager.go(配置管理) + +**文件**:`internal/polling/config_manager.go`(< 150行) + +**从 scheduler.go 提取**:`loadConfigs`、`syncConfigsToRedis`、`matchConfig`、`matchConfigConditions`、`getCardCondition` + +**新增**: +- 内存缓存(`sync.RWMutex` 保护的 `[]PollingConfig` 切片) +- `Start(ctx)` 方法:goroutine 每 5 分钟调用 `Load(ctx)` 刷新 +- `Load(ctx)` 加载失败时保留原缓存不清空 + +**⚠️ 必须修复 getCardCondition(M1 修复)**: +原 `getCardCondition` 从不返回 `"suspended"`,导致停机卡匹配到 carddata/package 间隔为 null 的配置,无法自动复机。 +新实现**最先**判断网络状态: + +```go +// getCardCondition 获取卡的状态条件(用于匹配轮询配置) +// ⚠️ 注意判断顺序:停机优先,避免停机卡错误匹配 activated 或 real_name 配置 +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" +} +``` + +**验证条件**: +- 文件行数 < 150 行 +- `scheduler.go` 中不再出现 `loadConfigs`、`syncConfigsToRedis` 字样 +- `getCardCondition` 对 `NetworkStatus=0` 的卡返回 `"suspended"`(编写单函数验证或日志观察) +- `go build ./...` 编译通过 + +--- + +## Phase 4:Task Handler 拆分(4个专注 Handler) + +### 4.1 新建 polling_realname_handler.go(< 200行) + +**文件**:`internal/task/polling_realname_handler.go` + +**从 polling_handler.go 提取**:`HandleRealnameCheck` 相关逻辑 + +**构造函数**: +```go +// NewPollingRealnameHandler 创建实名检查任务处理器 +func NewPollingRealnameHandler( + base *PollingBase, + gateway GatewayClient, + iotCardStore IotCardStore, + queueClient QueueClient, +) *PollingRealnameHandler +``` + +**方法列表**:`Handle(ctx, task)`, `processCard(ctx, cardID)` + +**关键要求**: +- `triggerFirstRealnameActivation` 保留(首次实名激活任务入队) +- **⚠️ S1 修复**:当 Gateway 返回实名状态由 0→1 时,在入队激活任务后,**必须调用** `stopResumeService.EvaluateAndAct(ctx, card)` + 理由:卡可能因 `not_realname` 处于停机状态,实名完成后应立即触发复机,不能等下一次 carddata/package 轮询(可能长达 1 小时) + 调用时机:`UpdateRealNameStatus` Store 写入成功后,从 Store 重新加载卡信息(确保状态最新),再调 EvaluateAndAct +- 文件中**不出现**无条件的 `stopCard`、`resumeCard`(仅允许通过 EvaluateAndAct 委托) +- 所有 DB 操作通过 `iotCardStore` 方法(无 `h.db.Model()`) + +**验证条件**: +- 文件行数 < 200 行 +- 文件中包含 `EvaluateAndAct` 字样(搜索应有匹配,且在 `real_name_status 0→1` 的条件分支内) +- `go build ./...` 编译通过 + +--- + +### 4.2 新建 polling_carddata_handler.go(< 300行) + +**文件**:`internal/task/polling_carddata_handler.go` + +**从 polling_handler.go 提取**:`HandleCarddataCheck` 相关逻辑 + +**构造函数**: +```go +// NewPollingCarddataHandler 创建流量检查任务处理器 +func NewPollingCarddataHandler( + base *PollingBase, + gateway GatewayClient, + iotCardStore IotCardStore, + packageStore PackageUsageStore, + usageService UsageService, + stopResumeService StopResumeServiceInterface, +) *PollingCarddataHandler +``` + +**方法列表**:`Handle(ctx, task)`, `processCard(ctx, cardID)`, `collectUsageData(ctx, card)` + +**关键要求**: +- 删除原有 `checkStopResume`、`shouldStopCard`、`stopCardByUsageExhausted`、`resumeCardByPackageAvailable` 函数 +- 改为调用 `stopResumeService.EvaluateAndAct(ctx, card)`(Mi2:无 carrierType/carrierID 参数) +- 删除全部 13 处 `h.db.Model()` 直接 DB 操作 +- **⚠️ 必须完整迁移跨月流量边界检测逻辑**(详见决策 13): + - 比较 Gateway 返回的月度总流量与 `card.CurrentMonthStartDate` 判断是否跨月 + - 跨月:保存 `LastMonthTotalMB`、重置 `CurrentMonthUsageMB`、更新 `CurrentMonthStartDate` + - 同月:计算增量 delta = Gateway值 - `LastMonthTotalMB` + - 记录流量历史到 `data_usage_records` 表 + +**验证条件**: +- 文件行数 < 300 行 +- 文件中不出现 `h.db.`、`.db.Model()`、`shouldStop`、`hasAvailable` +- 文件中包含跨月检测逻辑(搜索 `CurrentMonthStartDate` 应有匹配) +- `go build ./...` 编译通过 + +--- + +### 4.3 新建 polling_package_handler.go(< 200行) + +**文件**:`internal/task/polling_package_handler.go` + +**从 polling_handler.go 提取**:`HandlePackageCheck` 相关逻辑 + +**构造函数**: +```go +// NewPollingPackageHandler 创建套餐检查任务处理器 +func NewPollingPackageHandler( + base *PollingBase, + gateway GatewayClient, + packageStore PackageUsageStore, + stopResumeService StopResumeServiceInterface, +) *PollingPackageHandler +``` + +**方法列表**:`Handle(ctx, task)`, `processCard(ctx, cardID)` + +**关键要求**: +- 删除原有直接 Gateway 停复机调用(无重试),改为 `EvaluateAndAct` +- 所有 DB 操作通过 `packageStore` 方法 + +**验证条件**: +- 文件行数 < 200 行 +- `go build ./...` 编译通过 + +--- + +### 4.4 新建 polling_protect_handler.go(< 200行) + +**文件**:`internal/task/polling_protect_handler.go` + +**从 polling_handler.go 提取**:`HandleProtectConsistencyCheck` 相关逻辑 + +**构造函数**: +```go +// NewPollingProtectHandler 创建保护期一致性检查任务处理器 +func NewPollingProtectHandler( + base *PollingBase, + gateway GatewayClient, // ⚠️ 保护期内需要直接调 Gateway 强制停复机 + iotCardStore IotCardStore, + stopResumeService StopResumeServiceInterface, +) *PollingProtectHandler +``` + +**方法列表**:`Handle(ctx, task)`, `processCard(ctx, cardID)` + +**处理流程(恢复原始保护期一致性语义)**: + +``` +processCard(ctx, cardID): + 1. 查 Store 获取卡信息 + 2. 检查前置跳过条件: + - 卡未实名(real_name_status=0)→ 直接 requeue,跳过(未实名卡不参与保护期逻辑) + - 卡未绑定设备(is_standalone=true)→ 直接 requeue,跳过 + 3. 读取设备保护期 Redis Key: + - stop 保护期 Key(polling:protect:stop:{deviceID}) + - start 保护期 Key(polling:protect:start:{deviceID}) + 4. 根据保护期状态执行一致性修正: + a. 有 stop 保护期 + 卡在线(network_status=1) + → 直接调 gateway.StopCard(不经过 EvaluateAndAct,强制修正) + → 更新 DB:network_status=0,stop_reason='protect' + b. 有 start 保护期 + 卡停机(network_status=0) + → 直接调 gateway.StartCard(不经过 EvaluateAndAct,强制修正) + → 更新 DB:network_status=1,stop_reason='' + c. 无保护期 Key(保护期已结束) + → 调 stopResumeService.EvaluateAndAct(ctx, card) + → 重新评估当前停复机状态(套餐/流量/实名) + d. 有保护期 + 状态已一致 → 跳过(不调 Gateway,不调 EvaluateAndAct) + 5. 调 base.requeueCard 按间隔重新入队 +``` + +**关键要求**: +- **步骤 4a/4b 必须直接调 Gateway**(强制一致性,绕过三条件判断——保护期期间无论是否有套餐/流量都强制执行) +- **步骤 4c 调 EvaluateAndAct**(保护期结束后按正常停复机逻辑重新评估) +- **两种调用路径不可混淆**:保护期内=强制修正;保护期结束=重新评估 + +**验证条件**: +- 文件行数 < 200 行 +- 文件中同时包含 `gateway.StopCard`(或等价停机调用)和 `EvaluateAndAct`(两种路径) +- `go build ./...` 编译通过 + +--- + +### 4.5 消除所有直接 DB 操作(13处) + +**文件**:所有新建的 4 个 Handler 文件 + +**变更**:将原 `polling_handler.go` 中的 `h.db.Model(&model.IotCard{}).Where(...).Update(...)` 等直接 GORM 操作,替换为对应的 Store 方法: +- `iotCardStore.UpdateRealNameStatus(ctx, cardID, status)` +- `iotCardStore.UpdateNetworkStatus(ctx, cardID, status, stopReason)` +- `packageStore.UpdateUsage(ctx, packageID, usageMB)` +- 等(按实际 Store 接口定义) + +**验证条件**: +- 在 4 个新 Handler 文件中 grep `\.db\.`,结果为空 +- `go vet ./...` 通过 + +--- + +### 4.6 提取 PollingBase 共享基类 + +**文件**:`internal/task/polling_base.go`(< 150行) + +**提取的公共方法**(所有 4 个 Handler 均需要): +- `acquireConcurrency(taskType string) bool` +- `releaseConcurrency(taskType string)` +- `getCardWithCache(ctx, cardID uint) (*model.IotCard, error)` +- `updateCardCache(ctx, card *model.IotCard)` +- `requeueCard(ctx, cardID uint, taskType string, interval time.Duration) error` +- `getMatchedPollingInterval(card *model.IotCard) time.Duration` + +**构造函数**: +```go +// NewPollingBase 创建轮询共享基类 +func NewPollingBase( + redis *redis.Client, + queueMgr *PollingQueueManager, + configMgr *PollingConfigManager, +) *PollingBase +``` + +**⚠️ acquireConcurrency 并发满时必须 requeue(关键正确性约束)**: + +Lua 脚本原子出队后,卡已从 Redis Sorted Set 中删除。若并发满时直接返回而不 requeue,该卡将永久消失(不再被轮询)。 + +**正确用法**:每个 Handler 在 `acquireConcurrency` 返回 false 时,必须先调 `requeueCard` 再返回: + +```go +// ✅ 正确:并发满时先 requeue,再返回 +if !base.acquireConcurrency(taskType) { + base.logger.Debug("并发数已满,已重新入队", zap.Uint("card_id", cardID)) + return base.requeueCard(ctx, cardID, taskType, time.Now()) // 立即重入队,不延迟 +} + +// ❌ 错误:直接返回,卡从队列消失 +if !base.acquireConcurrency(taskType) { + base.logger.Debug("并发数已满,跳过本次执行", zap.Uint("card_id", cardID)) + return nil // 卡已被 Lua 原子删除,此处 return 后卡永久丢失! +} +``` + +**验证条件**: +- 4 个 Handler 文件中不出现重复的 `acquireConcurrency`、`getCardWithCache` 等函数定义 +- 4 个 Handler 中 `acquireConcurrency` 返回 false 的分支**必须包含 `requeueCard` 调用**(grep 验证) +- `polling_base.go` 行数 < 150 行 + +--- + +### 4.7 更新 pkg/queue/handler.go 注册 4 个新 Handler + +**文件**:`pkg/queue/handler.go`(或 Asynq Worker 注册入口) + +**变更**: +- 注册 `polling_realname_handler`:`mux.HandleFunc(constants.TaskTypeRealnameCheck, realnameHandler.Handle)` +- 注册 `polling_carddata_handler`:`mux.HandleFunc(constants.TaskTypeCarddataCheck, carddataHandler.Handle)` +- 注册 `polling_package_handler`:`mux.HandleFunc(constants.TaskTypePackageCheck, packageHandler.Handle)` +- 注册 `polling_protect_handler`:`mux.HandleFunc(constants.TaskTypeProtectCheck, protectHandler.Handle)` + +**验证条件**: +- 4 种任务类型常量不变(向后兼容) +- Asynq 任务提交保持 `MaxRetry(0)`(不可改为 MaxRetry(3),详见决策 12) +- `go build ./cmd/worker/...` 编译通过 + +--- + +### 4.8 删除旧 polling_handler.go + +**文件**:`internal/task/polling_handler.go` + +**前提**:4.1 ~ 4.7 全部完成且验证通过后执行。 + +**验证条件**: +- 文件不存在 +- `go build ./...` 编译通过(无遗留引用) + +--- + +### 4.9 验证 4 种轮询任务均能正常触发和执行 + +**验证方式**:Redis CLI + 日志观察 + +```bash +# 手动将测试卡推入各队列,验证任务被处理 +redis-cli ZADD polling:shard:0:queue:realname $(date +%s) "testCardID" +redis-cli ZADD polling:shard:0:queue:carddata $(date +%s) "testCardID" +redis-cli ZADD polling:shard:0:queue:package $(date +%s) "testCardID" +redis-cli ZADD polling:shard:0:queue:protect $(date +%s) "testCardID" + +# 观察 Worker 日志,应出现 4 种任务的处理日志 +# 验证 Lua 脚本原子出队后队列中不再有该卡 +redis-cli ZRANK polling:shard:0:queue:realname "testCardID" +# 预期返回 nil(已被 Lua 脚本原子移除) +``` + +--- + +## Phase 5:Scheduler 精简 + CardInitializer + LifecycleService + 删除旧文件 + +> ⚠️ **Phase 4 和 Phase 5 必须原子部署**(同一次上线)。新 Handler 写分片键,新 Scheduler 读分片键,拆开部署会导致卡永久丢失轮询。详见 design.md 决策 8。 + +### 5.1 新建 initializer.go(分片渐进式初始化) + +**文件**:`internal/polling/initializer.go`(< 250行) + +**从 scheduler.go 提取**:`progressiveInit`、`initCardsBatch`、`initCardPolling` + +**变更**: +- 入队改为调用 `PollingQueueManager.Requeue()`(分片路由) +- 新增 `enable_polling=false` 过滤(跳过不参与轮询的卡) +- 新增 `GetProgress()` 方法暴露进度 + +**新增 DB 查询**(Store 方法): +- `store.ListIotCardsForPolling(ctx, offset, limit)` — 返回 `enable_polling=true` 的卡 + +**验证条件**: +- 文件行数 < 250 行 +- `scheduler.go` 中不再出现 `progressiveInit`、`initCardsBatch` 字样 +- 初始化时 `enable_polling=false` 的卡不写入 Redis 队列(Redis CLI 验证) +- `go build ./...` 编译通过 + +--- + +### 5.2 精简 scheduler.go(目标 < 250行) + +**文件**:`internal/polling/scheduler.go` + +**保留**: +- `Start(ctx)`:启动初始化器、配置管理器、调度循环 +- `scheduleLoop(ctx)`:每秒 ticker,并行消费 N 个分片 +- `processShardSchedule(ctx, shardID)`:背压检测 + 手动队列 + 定时队列出队 +- `enqueueBatch(ctx, entries, taskType)`:批量推入 Asynq(**`MaxRetry(0)` 不变**) +- **⚠️ 必须保留**(决策 9): + - 每 10 秒调用 `packageActivationHandler.HandlePackageActivationCheck(ctx)` + - 每 10 秒调用 `dataResetHandler.HandleDataReset(ctx)` + +**删除**(已迁移到其他模块): +- 所有 `db.Find()`、`db.Where()` 等 DB 查询 +- `loadConfigs`、`syncConfigsToRedis`(→ ConfigManager) +- `progressiveInit`、`initCardsBatch`、`initCardPolling`(→ Initializer) +- 所有 Callback 相关代码(→ LifecycleService + QueueManager) +- 任何直接的 `ZRANGEBYSCORE`、`ZREMRANGEBYSCORE` 操作 + +**验证条件**: +- 文件行数 < 250 行(含注释,因保留套餐过期/流量重置触发) +- `grep "\.db\." scheduler.go` 结果为空 +- `grep "loadConfigs\|progressiveInit" scheduler.go` 结果为空 +- `grep "HandlePackageActivationCheck\|HandleDataReset" scheduler.go` 结果非空(确认保留) +- `go build ./...` 编译通过 + +--- + +### 5.3 删除 callbacks.go + +**文件**:`internal/polling/callbacks.go` + +**前提**:所有调用 callbacks.go 中方法的地方已改为调用 `PollingQueueManager`。 + +**验证条件**: +- 文件不存在 +- `go build ./...` 编译通过(无遗留引用) + +--- + +### 5.4 删除 api_callback.go + +**文件**:`internal/polling/api_callback.go` + +**前提**:所有调用 api_callback.go 中方法的地方已改为调用 `PollingQueueManager`。 + +**验证条件**: +- 文件不存在 +- `go build ./...` 编译通过(无遗留引用) + +--- + +### 5.5 新建 lifecycle_service.go(替代 callbacks.go 生命周期方法) + +**文件**:`internal/polling/lifecycle_service.go`(< 200行) + +**从 callbacks.go 提取**:`OnCardCreated`、`OnBatchCardsCreated`、`OnCardStatusChanged`、`OnCardEnabled`、`OnCardDisabled`、`OnCardDeleted`、`LazyLoad` + +**新组件**: +```go +// PollingLifecycleService 卡生命周期轮询管理 +// 实现 PollingCallback 接口,替代 callbacks.go 和 api_callback.go +type PollingLifecycleService struct { + queueMgr *PollingQueueManager + configMgr *PollingConfigManager + cardStore IotCardStore + logger *zap.Logger +} +``` + +**关键要求**: +- 实现 `PollingCallback` 接口(确保 `iot_card/service.go` 无需改动注入类型) +- **⚠️ M3 修复**:`OnCardCreated`/`OnCardEnabled`/`OnCardStatusChanged` 在调用 `queueMgr.Requeue()` 前,必须检查卡所属设备的 `enable_polling` 状态: + - 若卡绑定设备(`card.DeviceID != nil`),查询 `device.EnablePolling`,若为 `false` 则跳过入队(仅记录 Debug 日志) + - 若卡无绑定设备,检查 `card.EnablePolling`,若为 `false` 则跳过入队 + - 目的:防止"禁用轮询"设置因生命周期事件被绕过,导致禁用后重新入队 +- `OnCardCreated`/`OnCardEnabled`:检查 enable_polling → 通过则 `configMgr.MatchConfig(card)` → `queueMgr.Requeue()` 分片入队 +- `OnCardStatusChanged`:`queueMgr.RemoveFromAllQueues()` → 检查 enable_polling → 通过则重新匹配配置 → `queueMgr.Requeue()` +- `OnCardDeleted`:`queueMgr.OnCardDeleted()` 移除所有队列 + 清理缓存(删除不需要检查 enable_polling) +- `OnCardDisabled`:`queueMgr.RemoveFromAllQueues()`(禁用,不需要检查 enable_polling) +- `OnBatchCardsCreated`:批量调用 `OnCardCreated` + +**验证条件**: +- 文件行数 < 200 行 +- 实现了 `PollingCallback` 接口的所有方法 +- `go build ./...` 编译通过 +- grep `PollingCallback` 在 `iot_card/service.go` 中的注入类型未变 +- 设备 enable_polling=false 后,触发 OnCardStatusChanged 不会将卡重新入队(Redis CLI 验证) + +--- + +### 5.6 更新 MonitoringService 适配分片队列 + +**文件**:`internal/service/polling/monitoring_service.go` + +**变更**: +- 注入 `PollingQueueManager`(新增依赖) +- `GetOverview()` 中队列深度查询改为 `queueMgr.GetTotalQueueDepth(ctx, taskType)` +- `GetQueueStatuses()` 中队列指标改为聚合所有分片 +- 删除直接 `s.redis.ZCard(ctx, constants.RedisPollingQueueXxxKey())` 调用 + +**PollingQueueManager 新增方法**(Phase 3 的 queue_manager.go 补充): +```go +// GetTotalQueueDepth 获取指定任务类型的总队列深度(聚合所有分片) +func (m *PollingQueueManager) GetTotalQueueDepth(ctx context.Context, taskType string) (int64, error) +``` + +**验证条件**: +- `grep "RedisPollingQueueRealnameKey\|RedisPollingQueueCarddataKey\|RedisPollingQueuePackageKey" monitoring_service.go` 结果为空(不再直接读旧 Key) +- 调用监控 API 返回的队列深度值与 `redis-cli` 手动聚合所有分片 `ZCARD` 之和一致 +- `go build ./...` 编译通过 + +--- + +### 5.7 更新 cmd/worker/main.go 和 cmd/api/main.go 启动流程 + +**文件**:`cmd/worker/main.go`、`cmd/api/main.go` + +#### Worker 进程(cmd/worker/main.go) + +**变更**:按以下顺序初始化新组件: +```go +// 1. 基础组件 +queueMgr := polling.NewPollingQueueManager(rdb, shardCount) + +// 2. 配置管理器(依赖 DB) +configMgr := polling.NewPollingConfigManager(store, rdb) +configMgr.Load(ctx) // 启动时同步加载(阻塞,确保配置就绪后再启动 Scheduler) +configMgr.Start(ctx) // 启动 goroutine 每 5 分钟定时刷新 + +// 3. 初始化器(依赖 DB、QueueMgr、ConfigMgr) +initializer := polling.NewCardInitializer(store, queueMgr, configMgr) + +// 4. 卡生命周期服务(替代 callbacks.go,实现 PollingCallback 接口) +lifecycleService := polling.NewPollingLifecycleService(queueMgr, configMgr, iotCardStore, logger) + +// 5. 共享基类(供 4 个 Handler 使用) +pollingBase := task.NewPollingBase(rdb, queueMgr, configMgr) + +// 6. 4 个 Task Handler +realnameHandler := task.NewPollingRealnameHandler(pollingBase, gateway, iotCardStore, queueClient) +carddataHandler := task.NewPollingCarddataHandler(pollingBase, gateway, iotCardStore, packageStore, usageService, stopResumeService) +packageHandler := task.NewPollingPackageHandler(pollingBase, gateway, packageStore, stopResumeService) +protectHandler := task.NewPollingProtectHandler(pollingBase, gateway, iotCardStore, stopResumeService) + +// 7. Scheduler(依赖所有上层组件,⚠️ 保留套餐过期/流量重置触发器) +scheduler := polling.NewScheduler(queueMgr, configMgr, initializer, asynqClient, + packageActivationHandler, dataResetHandler) +scheduler.Start(ctx) +``` + +#### API 进程(cmd/api/main.go) + +**背景**:API 进程处理卡创建、删除、状态变更等事件,需要 `PollingLifecycleService`(依赖 `PollingConfigManager`)来处理卡生命周期的队列操作。 + +**变更**:在 API 进程的 bootstrap 中同样初始化 ConfigManager 和 LifecycleService: + +```go +// API 进程也需要配置管理器(LifecycleService.OnCardCreated 需要 MatchConfig) +apiConfigMgr := polling.NewPollingConfigManager(store, rdb) +apiConfigMgr.Load(ctx) // 启动时同步加载一次(API 进程不需要 Scheduler,但需要当前配置) +apiConfigMgr.Start(ctx) // 启动 goroutine 定时刷新,确保新建卡时使用最新配置 + +// API 进程也需要 QueueManager(处理卡删除、禁用等事件) +apiQueueMgr := polling.NewPollingQueueManager(rdb, shardCount) + +// API 进程的 LifecycleService(实现 PollingCallback 接口) +apiLifecycleService := polling.NewPollingLifecycleService(apiQueueMgr, apiConfigMgr, iotCardStore, logger) +``` + +> **注意**:API 进程的 `ConfigManager` 必须调用 `Start(ctx)` 定时刷新,否则管理员在后台修改轮询配置后,API 进程的 `MatchConfig` 会使用过期配置,导致新建卡匹配到错误的轮询间隔。 + +**验证条件**: +- `go build ./cmd/worker/...` 编译通过 +- `go build ./cmd/api/...` 编译通过 +- Worker 进程启动日志出现「配置管理器已启动」「开始渐进式初始化」 +- API 进程启动日志出现「配置管理器已启动」(来自 apiConfigMgr) +- `lifecycleService` 和 `apiLifecycleService` 分别注入到对应进程的 `iot_card/service.go` 作为 `PollingCallback` 实现 + +--- + +### 5.8 更新所有 PollingCallback 注入点 + +**文件**:所有注入 `PollingCallback` 接口的地方 + +**变更**:将注入的 `PollingCallback` **实现**从旧 `callbacks.go` 的实例改为新 `PollingLifecycleService` 实例。**注意:`PollingCallback` 接口本身不变**,因为 `PollingLifecycleService` 实现了该接口。 + +需要更新的注入点: +- `cmd/worker/main.go`:注入 `lifecycleService` 到 `iot_card/service.go` +- `cmd/api/main.go`:注入 `lifecycleService` 到 API 进程的 `iot_card/service.go`(API 进程也需要处理卡删除等事件) +- `bootstrap/services.go`:如有 bootstrap 层的注入逻辑,同步更新 + +**⚠️ 不要变更的**: +- `StopResumeCallback` 接口和注入点(与 `PollingCallback` 是不同接口,不在本次范围) +- `PollingCallback` 接口定义(`PollingLifecycleService` 实现它) + +**验证条件**: +- `grep -r "callbacks\.New\|NewPollingCallbacks\|NewAPICallback" internal/` 结果为空(旧实现不再被引用) +- `grep -r "PollingCallback" internal/` 仍有结果(接口定义和注入类型保留) +- `go build ./...` 编译通过 + +--- + +## Phase 6:轮询管控 API(enable_polling 接口) + +### 6.1 新增 DTO + +**文件**:`internal/model/dto/asset.go`(或新建 `internal/model/dto/polling_control.go`) + +**新增**: +```go +// UpdateAssetPollingStatusRequest 更新资产轮询状态请求 +type UpdateAssetPollingStatusRequest struct { + // EnablePolling 是否启用轮询 + EnablePolling bool `json:"enable_polling" description:"是否启用轮询,false 时卡不加入轮询队列"` +} + +// UpdateAssetPollingStatusResponse 更新资产轮询状态响应 +type UpdateAssetPollingStatusResponse struct { + // AssetType 资产类型(card/device) + AssetType string `json:"asset_type"` + // AssetID 资产ID + AssetID uint `json:"asset_id"` + // EnablePolling 更新后的轮询状态 + EnablePolling bool `json:"enable_polling"` +} +``` + +**验证条件**: +- 两个 DTO 均有 description 标签(符合 dto-standards Skill 规范) +- `go build ./...` 编译通过 + +--- + +### 6.2 新增 Store 方法 + +**文件**:`internal/store/postgres/device_store.go` + +**新增方法**: +```go +// UpdatePollingStatus 更新设备的轮询启用状态 +// deviceID: 设备ID +// enablePolling: 是否启用轮询 +func (s *DeviceStore) UpdatePollingStatus(ctx context.Context, deviceID uint, enablePolling bool) error +``` + +**验证条件(PostgreSQL MCP)**: +```sql +-- 验证接口调用后 DB 更新 +SELECT id, enable_polling FROM tb_device WHERE id = :deviceID; +-- 应反映接口传入的 enable_polling 值 +``` + +--- + +### 6.3 新增 Service 方法(遵循 Handler → Service → Store 分层) + +**文件**:`internal/service/polling/asset_polling_service.go`(新建) + +**方法签名(S2 修复:card 类型委托给 IotCardService)**: +```go +// AssetPollingService 资产轮询管控服务 +// 注意:card 类型委托给已有的 IotCardService.UpdatePollingStatus,避免绕过 DB 更新和 callback +type AssetPollingService struct { + deviceStore DeviceStoreInterface + iotCardService IotCardServiceInterface // 复用卡轮询状态更新(含 DB 写入 + callback) + pollingQueueMgr *PollingQueueManager + logger *zap.Logger +} + +// UpdatePollingStatus 更新资产轮询状态 +// assetType: "card" 或 "device" +// assetID: 资产ID +// enablePolling: 是否启用轮询 +func (s *AssetPollingService) UpdatePollingStatus(ctx context.Context, assetType string, assetID uint, enablePolling bool) error +``` + +**实现要点**: +- `assetType` 校验:不在 `[card, device]` 时返回 `errors.New(errors.CodeInvalidParam)` +- **card 类型**(S2 修复): + - 调用 `iotCardService.UpdatePollingStatus(ctx, assetID, enablePolling)` + - 该方法已有完整实现(DB 更新 `tb_iot_card.enable_polling` + pollingCallback 通知),**不要重复实现** +- **device 类型**: + - 调用 `deviceStore.UpdatePollingStatus(ctx, assetID, enablePolling)` 更新 `tb_device.enable_polling` + - 若 `enablePolling=false`:查询设备下所有卡,逐一调用 `pollingQueueMgr.RemoveFromAllQueues(ctx, cardID)` + - 若 `enablePolling=true`:LifecycleService 的 `OnCardEnabled` 负责重新入队(通过 iotCardService 触发 callback) + +**验证条件**: +- `card` 类型调用后,`tb_iot_card.enable_polling` 已更新(PostgreSQL MCP 验证) +- `device` 类型调用后,`tb_device.enable_polling` 已更新 +- Service 不依赖 Fiber 上下文(纯业务逻辑) +- `go build ./...` 编译通过 + +--- + +### 6.4 新增 Handler 方法 + +**文件**:`internal/handler/admin/asset.go` + +**新增方法**: +```go +// UpdatePollingStatus 更新资产轮询状态 +// PATCH /api/admin/assets/:asset_type/:id/polling-status +func (h *AssetHandler) UpdatePollingStatus(c *fiber.Ctx) error +``` + +**实现要点**: +- 解析路由参数 `asset_type` 和 `id` +- 解析请求体 `UpdateAssetPollingStatusRequest` +- **委托 `assetPollingService.UpdatePollingStatus()`**(Handler 不直接调用 Store 或 QueueManager) +- 返回 `UpdateAssetPollingStatusResponse` + +**验证条件**: +- Handler 中不出现 `store.`、`queueMgr.` 等直接调用(全部通过 Service) +- 参数校验失败返回 `errors.New(errors.CodeInvalidParam)`,不泄露内部细节 + +--- + +### 6.5 注册路由 + +**文件**:`internal/routes/asset.go`(或相关路由文件) + +**新增路由**: +```go +assetGroup.Patch("/:asset_type/:id/polling-status", assetHandler.UpdatePollingStatus) +// 完整路径:PATCH /api/admin/assets/:asset_type/:id/polling-status +``` + +**验证条件**: +- 路由注册到正确的路由组(需认证的 admin 路由) +- `go build ./cmd/api/...` 编译通过 + +--- + +### 6.6 更新文档生成器 + +**文件**:`cmd/api/docs.go` 和 `cmd/gendocs/main.go` + +**变更**(参考 api-routing Skill 规范): +```go +// 在 Handlers 初始化中新增 +handlers := &bootstrap.Handlers{ + // ... 现有 handlers + AssetHandler: admin.NewAssetHandler(nil), // 新增 UpdatePollingStatus 方法 +} +``` + +**验证条件**: +- `go run cmd/gendocs/main.go` 运行通过 +- 生成的 OpenAPI 文档中包含 `PATCH /api/admin/assets/{asset_type}/{id}/polling-status` 接口 + +--- + +## Phase 7:全面验证 + +### 7.1 DB 验证:设备套餐停复机正确性(PostgreSQL MCP) + +```sql +-- 找一张绑定设备且有设备套餐的卡 +SELECT c.id AS card_id, c.device_id, c.network_status, c.stop_reason, + pu.id AS pkg_id, pu.device_id AS pkg_device_id, pu.status AS pkg_status +FROM tb_iot_card c +JOIN tb_package_usage pu ON pu.device_id = c.device_id +WHERE c.device_id IS NOT NULL + AND pu.status IN (0,1) + AND c.network_status = 1 +LIMIT 5; +-- 预期:这些卡 stop_reason 不为 'no_package'(修复前的误停机) +``` + +**通过条件**:查询返回行,所有卡均未被误停机(`stop_reason IS NULL OR stop_reason = ''`) + +--- + +### 7.2 DB 验证:非行业卡未实名有套餐触发停机(PostgreSQL MCP) + +```sql +-- 验证未实名普通卡被正确停机 +SELECT id, card_category, real_name_status, network_status, stop_reason +FROM tb_iot_card +WHERE card_category != 'industry' + AND real_name_status = 0 + AND network_status = 0 + AND stop_reason = 'not_realname' +LIMIT 10; +-- 预期:存在此类记录(说明新逻辑生效) +``` + +--- + +### 7.3 DB 验证:行业卡未实名不停机(PostgreSQL MCP) + +```sql +-- 验证行业卡不被未实名停机 +SELECT COUNT(*) FROM tb_iot_card +WHERE card_category = 'industry' + AND real_name_status = 0 + AND stop_reason = 'not_realname'; +-- 预期:COUNT = 0(行业卡不应有此停机原因) +``` + +--- + +### 7.4 Redis 验证:protect 队列 Bug 修复 + +**操作步骤**: +1. 选取一张测试卡 ID(如 testCardID=999) +2. 手动向 4 个队列写入: + ```bash + redis-cli ZADD polling:shard:7:queue:realname 9999999999 "999" + redis-cli ZADD polling:shard:7:queue:carddata 9999999999 "999" + redis-cli ZADD polling:shard:7:queue:package 9999999999 "999" + redis-cli ZADD polling:shard:7:queue:protect 9999999999 "999" + ``` +3. 触发 `OnCardDeleted(ctx, 999)` 或调用删除卡接口 +4. 验证: + ```bash + redis-cli ZRANK polling:shard:7:queue:protect "999" + # 预期返回 nil(已从 protect 队列移除,修复前这里会返回一个数字) + ``` + +--- + +### 7.5 接口验证:enable_polling 管控接口 + +**步骤**: +1. 调用 `PATCH /api/admin/assets/device/1/polling-status` Body: `{"enable_polling": false}` +2. 验证 DB:`SELECT enable_polling FROM tb_device WHERE id=1` 应为 false +3. 验证 Redis:设备下所有卡从分片队列移除 +4. 调用 `PATCH /api/admin/assets/device/1/polling-status` Body: `{"enable_polling": true}` +5. 验证 DB:`enable_polling` 恢复为 true + +--- + +### 7.6 代码行数验证(合规性) + +```bash +# 验证各文件行数 +wc -l internal/polling/scheduler.go # 应 < 200 +wc -l internal/polling/queue_manager.go # 应 < 200 +wc -l internal/polling/config_manager.go # 应 < 150 +wc -l internal/polling/initializer.go # 应 < 250 +wc -l internal/task/polling_base.go # 应 < 150 +wc -l internal/task/polling_realname_handler.go # 应 < 200 +wc -l internal/task/polling_carddata_handler.go # 应 < 300 +wc -l internal/task/polling_package_handler.go # 应 < 200 +wc -l internal/task/polling_protect_handler.go # 应 < 200(含 Gateway 强制停复机逻辑) + +# 验证旧文件已删除 +ls internal/task/polling_handler.go # 应不存在 +ls internal/polling/callbacks.go # 应不存在 +ls internal/polling/api_callback.go # 应不存在 +``` + +--- + +### 7.7 Redis 原子性验证(Lua 脚本出队) + +```bash +# 准备测试数据:向分片0的carddata队列写入10张卡(score=当前时间,即立即到期) +for i in $(seq 1 10); do + redis-cli ZADD polling:shard:0:queue:carddata $(date +%s) "test_card_$i" +done + +# 同时写入5张未到期卡(score=未来时间戳) +for i in $(seq 11 15); do + redis-cli ZADD polling:shard:0:queue:carddata 9999999999 "test_card_$i" +done + +# 验证 Lua 脚本只取到期卡,不触碰未来项 +# Lua 脚本等效命令(实际由 PollingQueueManager 封装调用): +redis-cli EVAL "local r=redis.call('ZRANGEBYSCORE',KEYS[1],'-inf',ARGV[1],'LIMIT',0,tonumber(ARGV[2])) if #r>0 then redis.call('ZREM',KEYS[1],unpack(r)) end return r" 1 polling:shard:0:queue:carddata $(date +%s) 20 + +# 预期返回:10张到期卡(test_card_1 ~ test_card_10) + +redis-cli ZCARD polling:shard:0:queue:carddata +# 预期:5(只剩5张未到期卡,到期卡已被原子移除) + +# 验证未到期卡未被触碰 +redis-cli ZRANK polling:shard:0:queue:carddata "test_card_11" +# 预期:非 nil(仍在队列中,未被错误取出) + +# 验证到期卡已被移除 +redis-cli ZRANK polling:shard:0:queue:carddata "test_card_1" +# 预期:nil(已被 Lua 脚本原子移除) +``` + +--- + +### 7.8 兼容性验证:36 个已有接口零改动 + +**验证方式**:使用现有接口的 Hurl 测试文件或 Postman Collection,逐一调用: +- 轮询配置管理(7个):增删改查配置 +- 手动触发(6个):手动触发各类轮询任务 +- 告警管理(6个):告警规则 CRUD +- 监控统计(4个):队列深度、处理统计 +- 并发控制(4个):并发数调整 +- 数据清理(9个):清理各类历史数据 + +**通过条件**:所有接口返回 HTTP 200,业务逻辑与重构前一致。 + +--- + +### 7.9 not_realname 停机迁移影响评估(已前移至 Phase 2 门控) + +> 此任务已前移至 **Phase 2 任务 2.0**,作为 Phase 2 的强制前置门控。 +> 请参见 `### 2.0 前置评估:not_realname 停机迁移影响(门控任务)`,完成后方可继续 Phase 2 后续任务。 diff --git a/openspec/specs/polling-config-manager/spec.md b/openspec/specs/polling-config-manager/spec.md new file mode 100644 index 0000000..d5e5036 --- /dev/null +++ b/openspec/specs/polling-config-manager/spec.md @@ -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` diff --git a/openspec/specs/polling-queue-manager/spec.md b/openspec/specs/polling-queue-manager/spec.md new file mode 100644 index 0000000..ba51330 --- /dev/null +++ b/openspec/specs/polling-queue-manager/spec.md @@ -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 队列深度查询失败」 diff --git a/openspec/specs/polling-stop-resume-logic/spec.md b/openspec/specs/polling-stop-resume-logic/spec.md new file mode 100644 index 0000000..b48c30c --- /dev/null +++ b/openspec/specs/polling-stop-resume-logic/spec.md @@ -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= 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` 成功获取锁,正常执行停机流程 diff --git a/openspec/specs/polling-task-handlers/spec.md b/openspec/specs/polling-task-handlers/spec.md new file mode 100644 index 0000000..ef08a80 --- /dev/null +++ b/openspec/specs/polling-task-handlers/spec.md @@ -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(设备保护期与独立卡无关) diff --git a/pkg/constants/iot.go b/pkg/constants/iot.go index 3c83358..5e40d78 100644 --- a/pkg/constants/iot.go +++ b/pkg/constants/iot.go @@ -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" // 停机原因:非行业卡且未完成实名认证 ) // 套餐流量类型 diff --git a/pkg/constants/polling.go b/pkg/constants/polling.go new file mode 100644 index 0000000..1bf101e --- /dev/null +++ b/pkg/constants/polling.go @@ -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 分钟 diff --git a/pkg/constants/redis.go b/pkg/constants/redis.go index f90a6ef..6115535 100644 --- a/pkg/constants/redis.go +++ b/pkg/constants/redis.go @@ -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) +} diff --git a/pkg/openapi/handlers.go b/pkg/openapi/handlers.go index 936c326..04ef090 100644 --- a/pkg/openapi/handlers.go +++ b/pkg/openapi/handlers.go @@ -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), diff --git a/pkg/queue/handler.go b/pkg/queue/handler.go index 1aa3c3c..2102fd7 100644 --- a/pkg/queue/handler.go +++ b/pkg/queue/handler.go @@ -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" @@ -15,15 +16,17 @@ import ( ) type Handler struct { - mux *asynq.ServeMux - logger *zap.Logger - db *gorm.DB - redis *redis.Client - storage *storage.Service - gatewayClient *gateway.Client - pollingCallback task.PollingCallback - workerResult *WorkerBootstrapResult - asynqClient *asynq.Client + mux *asynq.ServeMux + logger *zap.Logger + db *gorm.DB + redis *redis.Client + storage *storage.Service + gatewayClient *gateway.Client + pollingCallback task.PollingCallback + workerResult *WorkerBootstrapResult + asynqClient *asynq.Client + pollingBase *task.PollingBase + pollingStopResumeSvc iot_card_svc.StopResumeServiceInterface } func NewHandler( @@ -35,17 +38,21 @@ func NewHandler( workerResult *WorkerBootstrapResult, asynqClient *asynq.Client, logger *zap.Logger, + pollingBase *task.PollingBase, + pollingStopResumeSvc iot_card_svc.StopResumeServiceInterface, ) *Handler { return &Handler{ - mux: asynq.NewServeMux(), - logger: logger, - db: db, - redis: redis, - storage: storageSvc, - gatewayClient: gatewayClient, - pollingCallback: pollingCallback, - workerResult: workerResult, - asynqClient: asynqClient, + mux: asynq.NewServeMux(), + logger: logger, + db: db, + redis: redis, + storage: storageSvc, + gatewayClient: gatewayClient, + 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() {