Files
junhong_cmp_fiber/docs/polling-system/manual-trigger-analysis.md
huang 7308afe801
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 7m18s
归档
2026-04-13 15:03:02 +08:00

597 lines
19 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 轮询系统手动触发功能深入分析报告
## 执行摘要
轮询系统的手动触发功能包含 **884 行代码**Service 469行 + Handler 307行 + Store 108行实现了 **6 个 API 接口**,支持单卡、批量、条件筛选三种触发方式。
**核心发现**
-**功能完整性高**:支持多种触发方式、权限控制、进度追踪、任务取消
- ⚠️ **使用价值存疑**缺乏实际业务需求支撑频次限制每日100次、1000张卡设置不合理
-**过度设计风险**:复杂度与实际使用场景不匹配,维护成本高
---
## 1. 功能接口完整分析
### 1.1 API 接口清单
| 接口 | 方法 | 功能 | 代码行数 |
|------|------|------|---------|
| `/polling-manual-trigger/single` | POST | 单卡手动触发 | 18 |
| `/polling-manual-trigger/batch` | POST | 批量手动触发最多1000张 | 19 |
| `/polling-manual-trigger/by-condition` | POST | 条件筛选触发 | 29 |
| `/polling-manual-trigger/status` | GET | 获取触发状态 | 34 |
| `/polling-manual-trigger/history` | GET | 获取触发历史 | 31 |
| `/polling-manual-trigger/cancel` | POST | 取消触发任务 | 18 |
### 1.2 触发方式详解
#### 方式1单卡触发TriggerSingle
```go
// 限制每日100次
// 流程:
// 1. 验证任务类型realname/carddata/package
// 2. 权限检查(企业账号禁止,代理只能管理自己的卡)
// 3. 检查每日触发次数≥100则拒绝
// 4. Redis Set 去重1小时过期
// 5. 加入手动触发队列List
// 6. 更新日志状态为 completed
```
**问题**
- 单卡触发后立即标记为 completed但实际处理是异步的
- 去重 key 1小时过期但日限制是按天计算时间不对齐
#### 方式2批量触发TriggerBatch
```go
// 限制单次最多1000张卡每日100次
// 流程:
// 1. 验证卡数量(>1000则拒绝
// 2. 权限检查(批量检查所有卡)
// 3. 异步处理:
// - 逐卡检查去重
// - 加入队列
// - 每100卡更新一次进度
// 4. 最终更新状态为 completed
```
**问题**
- 异步处理中的去重失败被计为 failedCount但实际上是重复触发的防护
- 没有考虑批量操作的原子性
#### 方式3条件筛选触发TriggerByCondition
```go
// 限制筛选结果最多1000张卡每日100次
// 支持筛选条件:
// - card_status: 卡状态
// - carrier_code: 运营商
// - card_type: 卡类型
// - shop_id: 店铺ID
// - package_ids: 套餐ID列表
// - enable_polling: 是否启用轮询
// 流程:
// 1. 权限过滤(代理只能筛选自己店铺的卡)
// 2. 数据库查询符合条件的卡
// 3. 异步批量处理同方式2
```
**问题**
- 权限过滤逻辑:代理未指定 ShopID 时,自动限制为当前店铺(而非所有下级店铺)
- 这与代理的权限模型不一致(代理应该能管理下级店铺的卡)
---
## 2. 业务需求支撑分析
### 2.1 需求来源
通过代码搜索,找到的需求记录:
```
./openspec/changes/archive/2026-02-10-polling-system-implementation/tasks.md
- [x] 12.12 实现手动触发限流单次限制1000张、每日限制100次
```
**问题**
- 没有找到需求文档proposal.md 或 spec.md
- 没有业务场景说明
- 没有使用频率预测
### 2.2 实际使用场景推测
根据代码注释和文档,推测的使用场景:
| 场景 | 频率 | 合理性 | 说明 |
|------|------|--------|------|
| 故障恢复 | 低(<1次/天) | ✅ 高 | 轮询失败时手动重试 |
| 数据修复 | 低(<1次/周) | ✅ 高 | 修复错误数据后重新检查 |
| 性能测试 | 低(<1次/月) | ✅ 中 | 测试轮询系统性能 |
| 日常运维 | 中1-10次/天) | ⚠️ 中 | 运维人员定期检查 |
| 业务需求 | 高(>100次/天) | ❌ 低 | 业务系统频繁触发 |
**结论**
- 如果是故障恢复/数据修复场景每日100次限制过高实际需求<10次
- 如果是业务系统频繁触发每日100次限制过低实际需求>1000次
- **频次限制设置不合理,说明需求定义不清**
### 2.3 与自动轮询的关系
```
自动轮询流程:
┌─────────────────────────────────────────┐
│ Scheduler定时调度
│ - 每秒从分片队列出队卡 │
│ - 按配置间隔重新入队 │
└──────────────┬──────────────────────────┘
┌─────────────────────────────────────────┐
│ 手动触发队列(优先级高) │
│ - List 结构FIFO
│ - 调度器优先消费 │
└──────────────┬──────────────────────────┘
┌─────────────────────────────────────────┐
│ Asynq 任务队列 │
│ - 实际执行轮询任务 │
└─────────────────────────────────────────┘
```
**关键发现**
- 手动触发队列是 **List**FIFO自动轮询队列是 **Sorted Set**(按时间戳排序)
- 调度器优先消费手动触发队列(`processManualQueue``processScheduledQueue` 之前)
- 手动触发卡会立即进入 Asynq 队列,不受轮询间隔限制
**必要性评估**
-**必要**:如果需要快速重试失败的卡
-**不必要**:如果自动轮询已经足够频繁
- ⚠️ **可替代**:可以通过调整轮询配置的间隔来实现相同效果
---
## 3. 频次限制合理性评估
### 3.1 限制规则
```go
// 每日触发次数限制100次
if todayCount >= 100 {
return errors.New(errors.CodeInvalidParam, "已达到每日触发次数上限")
}
// 单次卡数限制1000张
if len(cardIDs) > 1000 {
return errors.New(errors.CodeInvalidParam, "单次最多触发1000张卡")
}
```
### 3.2 限制的问题
#### 问题1每日100次限制不合理
**场景分析**
```
场景A故障恢复
- 实际需求1-5次/天
- 限制100次/天
- 评价:过高,浪费
场景B数据修复
- 实际需求:<1次/周
- 限制100次/天
- 评价:过高,浪费
场景C业务系统集成
- 实际需求:可能>100次/天
- 限制100次/天
- 评价:过低,无法满足
场景D性能测试
- 实际需求:可能>100次/天
- 限制100次/天
- 评价:过低,无法满足
```
**结论**
- 如果是人工运维操作100次/天 过高
- 如果是自动化系统调用100次/天 过低
- **限制设置没有明确的业务依据**
#### 问题2单次1000张卡限制
**分析**
```go
// 单次最多1000张卡
if len(cardIDs) > 1000 {
return nil, errors.New(errors.CodeInvalidParam, "单次最多触发1000张卡")
}
// 但批量处理时每100卡更新一次进度
if processedCount%100 == 0 {
_ = s.logStore.UpdateProgress(ctx, logID, processedCount, successCount, failedCount)
}
```
**问题**
- 1000张卡的限制来自哪里没有文档说明
- 是否考虑了数据库查询性能?
- 是否考虑了 Redis 操作性能?
- 是否考虑了 Asynq 队列容量?
#### 问题3去重机制的时间不对齐
```go
// 去重 key 1小时过期
s.redis.Expire(ctx, dedupeKey, time.Hour)
// 但日限制是按天计算
if todayCount >= 100 {
return errors.New(...)
}
```
**问题**
- 去重 key 在1小时后过期但日限制是按天计算
- 如果在 23:00 触发一张卡1小时后00:00去重 key 过期
- 同一张卡可能在同一天内被触发两次(如果去重 key 过期了)
- **去重机制与日限制不一致**
### 3.3 限制的实现问题
#### 问题1CountTodayTriggers 的性能
```go
func (s *PollingManualTriggerLogStore) CountTodayTriggers(ctx context.Context, triggeredBy uint) (int64, error) {
var count int64
if err := s.db.WithContext(ctx).Model(&model.PollingManualTriggerLog{}).
Where("triggered_by = ? AND DATE(triggered_at) = CURRENT_DATE", triggeredBy).
Count(&count).Error; err != nil {
return 0, err
}
return count, nil
}
```
**问题**
- 每次触发都要查询数据库
- 没有索引优化(`triggered_by` + `triggered_at`
- 没有缓存(可以用 Redis 计数器)
- 在高频触发场景下,这个查询会成为瓶颈
#### 问题2去重 Set 的内存占用
```go
// 每个任务类型一个去重 Set
dedupeKey := constants.RedisPollingManualDedupeKey(taskType)
added, err := s.redis.SAdd(ctx, dedupeKey, cardID).Result()
```
**问题**
- 如果每日触发1000张卡去重 Set 会有1000个元素
- 4个任务类型 × 1000张卡 = 4000个元素
- 1小时过期后清空但在高峰期可能占用大量内存
- 没有考虑 Redis 内存限制
---
## 4. 代码实现复杂度分析
### 4.1 代码规模
```
总代码行数884 行
├── Service 层469 行53%
├── Handler 层307 行35%
└── Store 层108 行12%
功能点数:
├── 3 种触发方式
├── 权限检查3 个函数)
├── 进度追踪(异步处理)
├── 任务取消
├── 历史查询
└── 状态查询
```
### 4.2 复杂度评估
#### 权限检查的复杂性
```go
// canManageCard单卡权限检查
// - 用户类型判断3种
// - 卡信息查询
// - 店铺权限检查
// 复杂度O(1) + O(1) = O(1)
// canManageCards批量权限检查
// - 用户类型判断3种
// - 批量查询卡信息
// - 逐卡权限检查
// 复杂度O(n) 其中 n = 卡数量
// applyShopPermissionFilter条件筛选权限过滤
// - 用户类型判断3种
// - 店铺权限检查
// 复杂度O(1)
```
**问题**
- `canManageCards` 的 O(n) 复杂度在 n=1000 时可能有性能问题
- 没有批量查询的优化(如分页查询)
#### 异步处理的复杂性
```go
// processBatchTrigger异步处理批量触发
// - 逐卡检查去重O(n)
// - 逐卡加入队列O(n)
// - 每100卡更新一次进度O(n/100)
// 复杂度O(n)
// 问题:
// 1. 没有错误处理(如果 Redis 操作失败)
// 2. 没有超时控制(如果处理时间过长)
// 3. 没有并发控制(如果同时有多个批量触发)
```
### 4.3 维护成本
| 方面 | 成本 | 说明 |
|------|------|------|
| 代码理解 | 中 | 需要理解权限模型、队列机制、异步处理 |
| 测试覆盖 | 高 | 需要测试6个接口、3种权限、3种触发方式 |
| 故障排查 | 高 | 涉及数据库、Redis、Asynq 三个系统 |
| 性能优化 | 中 | 需要优化数据库查询、Redis 操作 |
| 功能扩展 | 中 | 添加新的触发方式或限制规则需要修改多个地方 |
---
## 5. 与自动轮询的必要性分析
### 5.1 自动轮询的能力
```
自动轮询配置示例:
┌─────────────────────────────────────────┐
│ 配置1未实名卡 │
│ - 实名检查间隔60秒 │
│ - 流量检查间隔NULL不检查
│ - 套餐检查间隔NULL不检查
└─────────────────────────────────────────┘
┌─────────────────────────────────────────┐
│ 配置2已实名卡 │
│ - 实名检查间隔3600秒1小时
│ - 流量检查间隔1800秒30分钟
│ - 套餐检查间隔1800秒30分钟
└─────────────────────────────────────────┘
```
### 5.2 手动触发的优势
| 场景 | 自动轮询 | 手动触发 | 必要性 |
|------|----------|----------|--------|
| 故障恢复 | 需要等待下次轮询 | 立即执行 | ✅ 高 |
| 数据修复 | 需要等待下次轮询 | 立即执行 | ✅ 高 |
| 性能测试 | 无法控制 | 可以控制 | ✅ 中 |
| 日常运维 | 足够 | 可选 | ⚠️ 低 |
| 业务集成 | 足够 | 可选 | ⚠️ 低 |
### 5.3 可替代方案
#### 方案1调整轮询配置
```go
// 不需要手动触发,直接调整配置
// 原配置:实名检查间隔 3600秒
// 新配置:实名检查间隔 60秒故障期间
// 恢复:实名检查间隔 3600秒故障解决后
优点
- 简单不需要额外代码
- 自动应用到所有卡
- 易于理解和维护
缺点
- 需要手动修改配置
- 可能影响其他卡的轮询频率
```
#### 方案2优先级队列
```go
// 不需要手动触发,使用优先级队列
// 自动轮询队列Sorted Set按时间戳排序
// 优先级队列Sorted Set按优先级排序
优点
- 灵活可以动态调整优先级
- 不需要额外的队列机制
缺点
- 需要修改调度器逻辑
- 需要定义优先级规则
```
#### 方案3保留手动触发当前方案
```go
优点
- 灵活支持多种触发方式
- 不影响自动轮询配置
- 可以精确控制触发范围
缺点
- 代码复杂度高884
- 维护成本高
- 频次限制设置不合理
```
### 5.4 结论
**手动触发功能的必要性:中等**
- ✅ 对于故障恢复和数据修复场景,手动触发是有价值的
- ⚠️ 对于日常运维和业务集成,自动轮询已经足够
- ❌ 当前的实现过于复杂,频次限制不合理
---
## 6. 过度设计问题
### 6.1 功能过度设计
| 功能 | 使用频率 | 必要性 | 说明 |
|------|----------|--------|------|
| 单卡触发 | 低 | ✅ 高 | 用于快速测试 |
| 批量触发 | 中 | ✅ 中 | 用于批量修复 |
| 条件筛选触发 | 低 | ⚠️ 低 | 可以用 API 查询后再批量触发 |
| 进度追踪 | 低 | ⚠️ 低 | 异步处理,用户无法实时看到 |
| 任务取消 | 极低 | ❌ 低 | 取消后卡已经在队列中,无法真正取消 |
| 历史查询 | 低 | ⚠️ 低 | 可以用日志系统查询 |
### 6.2 权限检查过度设计
```go
// 当前实现3个权限检查函数
- canManageCard(ctx, cardID) // 单卡权限检查
- canManageCards(ctx, cardIDs) // 批量权限检查
- applyShopPermissionFilter(...) // 条件筛选权限过滤
// 问题:
// 1. 代码重复(都是检查 shop_id
// 2. 逻辑复杂(需要理解权限模型)
// 3. 易出错(权限检查不一致)
// 可以简化为:
- canManageCards(ctx, cardIDs) // 统一的权限检查
```
### 6.3 数据库设计过度设计
```go
// 当前表结构tb_polling_manual_trigger_log
type PollingManualTriggerLog struct {
ID uint // 日志ID
TaskType string // 任务类型
TriggerType string // 触发类型single/batch/by_condition
CardIDs string // 卡ID列表JSON
ConditionFilter string // 筛选条件JSON
TotalCount int // 总卡数
ProcessedCount int // 已处理数
SuccessCount int // 成功数
FailedCount int // 失败数
Status string // 状态
TriggeredBy uint // 触发人ID
TriggeredAt time.Time // 触发时间
CompletedAt *time.Time // 完成时间
}
// 问题:
// 1. CardIDs 和 ConditionFilter 都是 JSON 字符串,难以查询
// 2. ProcessedCount/SuccessCount/FailedCount 在异步处理中更新,可能不准确
// 3. Status 字段的状态转移不清楚pending -> processing -> completed
// 可以简化为:
// - 只记录触发信息TaskType, TriggerType, TotalCount, TriggeredBy, TriggeredAt
// - 不记录进度信息ProcessedCount, SuccessCount, FailedCount
// - 不记录详细条件CardIDs, ConditionFilter
```
---
## 7. 实际使用价值评估
### 7.1 使用场景评分
| 场景 | 频率 | 价值 | 复杂度 | 综合评分 |
|------|------|------|--------|---------|
| 故障恢复 | 低 | 高 | 低 | ⭐⭐⭐⭐ |
| 数据修复 | 低 | 高 | 低 | ⭐⭐⭐⭐ |
| 性能测试 | 低 | 中 | 中 | ⭐⭐⭐ |
| 日常运维 | 中 | 低 | 中 | ⭐⭐ |
| 业务集成 | 高 | 低 | 高 | ⭐ |
### 7.2 成本收益分析
```
成本:
- 代码行数884 行
- 维护成本:中等(需要理解权限、队列、异步处理)
- 测试成本6个接口多种权限多种触发方式
- 性能成本:低(不影响自动轮询)
收益:
- 故障恢复:高(可以快速重试失败的卡)
- 数据修复:高(可以快速重新检查修复的卡)
- 性能测试:中(可以控制测试场景)
- 日常运维:低(自动轮询已经足够)
- 业务集成:低(频次限制过低)
综合评估:
- 如果只用于故障恢复和数据修复,成本收益比 = 低成本 / 高收益 = ✅ 值得
- 如果用于日常运维和业务集成,成本收益比 = 中成本 / 低收益 = ❌ 不值得
```
### 7.3 建议
#### 短期建议(保留当前功能)
1. **明确使用场景**
- 文档化手动触发的使用场景
- 定义频次限制的业务依据
- 添加使用指南
2. **优化频次限制**
- 每日限制改为 1000 次(支持更多使用场景)
- 单次限制改为 10000 张卡(支持大规模修复)
- 或者完全移除限制(由业务层控制)
3. **改进实现**
- 修复去重 key 过期时间与日限制的不对齐
- 添加 Redis 缓存优化 CountTodayTriggers 查询
- 改进异步处理的错误处理和超时控制
#### 长期建议(重新设计)
1. **简化功能**
- 移除条件筛选触发(可以用 API 查询后再批量触发)
- 移除进度追踪(异步处理,用户无法实时看到)
- 移除任务取消(无法真正取消已入队的任务)
2. **改进设计**
- 统一权限检查逻辑
- 简化数据库表结构
- 使用事件驱动而不是 API 驱动
3. **考虑替代方案**
- 使用优先级队列替代手动触发队列
- 使用配置调整替代手动触发
- 使用事件系统替代 API 调用
---
## 8. 总结
### 8.1 核心发现
1. **功能完整性高**:支持多种触发方式、权限控制、进度追踪
2. **使用价值存疑**:缺乏明确的业务需求支撑
3. **频次限制不合理**每日100次、1000张卡的限制没有业务依据
4. **过度设计风险**:代码复杂度与实际使用场景不匹配
5. **维护成本高**884行代码涉及多个系统数据库、Redis、Asynq
### 8.2 建议
**保留手动触发功能,但需要改进**
1. 明确使用场景和频次限制的业务依据
2. 优化频次限制(提高或移除)
3. 改进实现(修复 bug优化性能
4. 简化功能(移除不必要的功能)
5. 改进文档(添加使用指南和最佳实践)
**不建议完全移除**,因为:
- 故障恢复和数据修复场景有实际价值
- 代码已经实现,移除成本高
- 可以通过改进来提高价值