Files
openclaw-config/workspace-planner/tasks/dailyhot-job-review.md
T
2026-07-14 16:36:24 +08:00

207 lines
7.4 KiB
Markdown
Raw 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.
# DailyHotJob 代码审查报告
> 审查目标:`DailyHotJob#execute()` + `JackettServiceImpl.dailyHot()`
> 审查时间:2026-04-25
> 审查人:需求分析助手
---
## 1. 整体架构概览
```
DailyHotJob.execute(param)
└─ JackettServiceImpl.dailyHot()
├─ getDailyHotIds() → 爬取 wuji.me 获取 hot IDs
│ └─ getPageContent(url) → 通过 FlareSolverr 获取 HTML
├─ 逐条 selectOne 查重 → 过滤已存在的 ID
├─ 循环(逐条串行):
│ ├─ searchSingleKeyword() → 调用 Jackett API
│ ├─ 内存过滤(大小≥3G、当年发布)
│ ├─ ResourcesUtils.saveFile() → 追加写到 hot.txt
│ ├─ javInfoMapper.insert() → 逐条插入
│ └─ Thread.sleep() → sleep 间隔
└─ 结束
```
---
## 2. 🔴 P0 严重问题
### 2.1 异常全被吞,Job 日志永远显示「成功」
`JobHandlerInvoker` 依赖 `exception != null` 判断 Job 是否执行成功:
```java
// JobHandlerInvoker 的关键逻辑
try {
data = this.executeInternal(jobHandlerName, jobHandlerParam);
} catch (Throwable ex) {
exception = ex; // 只有这里 exception 才非 null
}
```
`DailyHotJob.execute()``dailyHot()` 内的每一层都是:
```java
// DailyHotJob
@Override
public String execute(String param) {
jackettService.dailyHot(); // 没有 throws,异常全在里面吞了
return "每日热点"; // 永远返回正常字符串
}
// dailyHot() 内部
for (...) {
try {
...
} catch (Exception e) {
log.error("搜索 '{}' 时出错", javId, e.getMessage()); // 只打日志
}
}
```
**后果**:即使远程 API 完全不可用、所有搜索全失败,Job 日志面板仍然显示「成功」。运维人员无法通过框架面板感知故障。
### 2.2 租户注解缺失问题
```java
@Override
@TenantIgnore // ← 只有最外层加了
public void dailyHot() { ... }
```
`dailyHot()` 内部调用了 `this.getDailyHotIds()``this.getPageContent()``this.searchSingleKeyword()`,都是**内部方法调用**。
在 Spring AOPJDK 动态代理)下,内部 `this.method()` 不会触发 `@TenantIgnore` 切面。如果 ruoyi 的租户 SQL 拦截器处于激活状态,可能导致这些方法执行时产生错误的 `WHERE tenant_id = ?` 过滤。
`saveRedisIds()` 方法也完全没加 `@TenantIgnore`,同样有风险。
---
## 3. 🟠 P1-P2 中等问题
### 3.1 N+1 数据库查询 + 逐条 insert
```java
// N 次独立 selectOne
Set<String> newIds = hotIds.stream()
.filter(javId -> javInfoMapper.selectOne(JavInfoDO::getJavId, javId) == null)
.collect(Collectors.toSet());
// M 次独立 insert
for (String javId : newIds) {
JavInfoDO javInfo = new JavInfoDO();
javInfo.setJavId(javId);
javInfoMapper.insert(javInfo); // 逐条
}
```
如果 hotIds 有 200 条,就是 200 次 select + 若干次 insert,数据库连接往返开销巨大。
**建议**:改为 `selectByJavIds(Collection)` 批量查重 + `insertBatch` 批量插入。
### 3.2 逐条串行远程搜索 + sleep 阻塞
```java
for (String javId : newIds) {
List<JackettSearchResultVO> results = searchSingleKeyword(javId, 30);
// 过滤...
Thread.sleep(jackettProperties.getSearchDelay()); // 通常 1-3s
}
```
假设只差 50 条新 ID,每次 search 耗时 3s + sleep 2s = 5s/条 → **总共 250 秒(4+ 分钟)**。如果匹配更多,耗时线性增长。
**建议**:使用并行流或 `CompletableFuture` 控制并发度(3-5),同时为 `RestTemplate` 配置连接/读取超时。
### 3.3 文件无限追加不轮转
```java
ResourcesUtils.saveFile(resultVO.getMagnetUri(),
jackettProperties.getWujiBaseDirectory(), "hot");
```
每次都追加到同一个 `hot.txt`,永不清理。每天几十上百条磁力链接,一年后文件可能几十上百 MB,I/O 持续劣化。
**建议**:按日期分文件,如 `hot/2026-04-25.txt`;或改用数据库存储。
### 3.4 年度过滤器不符合「热点」语义
```java
if (resultVO.getPublishDate().getYear() != currentYear) {
continue;
}
```
如果今天是 2026-04-25,这个条件会接受 2026-01-01 到 2026-04-24 的所有结果,而不是「最近发布的结果」。
**建议**:改为 `isAfter(LocalDateTime.now().minusHours(N))` 时间窗口过滤。
---
## 4. 🟡 P3 潜在坑
### 4.1 Redis Key 硬编码且已过时
```java
String key = "\"jav:rank:2023\"";
```
- 2023 硬编码,距离现在已经 3 年
- Key 带多余的双引号,实际可能是 `jav:rank:2023`
- 如果这是保存历史 ID 的入口,2023 早已不是有效数据源
### 4.2 非幂等 + 中断无保护
Job 执行到一半时应用关闭:
- 已插入的 `JavInfoDO` 已提交(无事务包裹)
- 已写入文件的磁力链已持久化
- 下次执行时已入库的 ID 被跳过,但 `hot.txt` 用 APPEND 追加,**产生重复行**
### 4.3 Redis 数据清洗掩盖上游 Bug
```java
private String cleanString(String raw) {
// 去首尾引号 + 转义
String cleaned = raw;
if (cleaned.startsWith("\"")) cleaned = cleaned.substring(1);
if (cleaned.endsWith("\"")) cleaned = cleaned.substring(0, cleaned.length() - 1);
cleaned = cleaned.replace("\\\"", "\"");
return cleaned.isEmpty() ? null : cleaned;
}
```
Redis 的 Set<String> 中存储的值不应该带序列化引号或转义。出现这种情况说明**上游写入数据的方式有问题**。消费侧做清洗只是遮盖症状,不是根本解决。
### 4.4 `saveFile()` 两个重载路径冲突
```java
// 重载1dailyHot 调用的)
saveFile(content, path, fileName) Paths.get(path, fileName + ".txt")
// 重载2
saveFile(content, name) dir = "/app/daily"; Paths.get(dir, name + ".txt")
```
重载2 硬编码了 `/app/daily`,如果其他调用方使用了这个重载但没有意识到路径硬编码,可能产生预期外的写入位置。
### 4.5 `Thread.sleep` 占用 Quartz 线程
`Thread.sleep` 是阻塞操作,在 Quartz 的工作线程中执行会让该线程无法处理其他 Job。虽然 `@DisallowConcurrentExecution` 避免了同 Job 并行,但如果 `JackettServiceImpl` 被多个 Job 共享,sleep 期间资源浪费更明显。
---
## 5. 改造建议优先级
| 优先级 | 问题 | 建议方案 |
|--------|------|----------|
| **P0** | 异常吞没,运维盲区 | `dailyHot()` 改为 `throws Exception`,仅捕获可恢复异常,向上传播不可恢复异常 |
| **P0** | 租户注解内部调用不生效 | 在 `getDailyHotIds()``searchSingleKeyword()``saveRedisIds()` 上也加 `@TenantIgnore` |
| **P1** | N+1 数据库操作 | 替换为 `selectByJavIds` 批量查重 + `insertBatch` 批量插入 |
| **P1** | 串行远程搜索过慢 | `CompletableFuture` 并发(并发度 3-5),配置 RestTemplate 超时(connect+read |
| **P2** | 文件追加不轮转 | 按日期分文件:`hot/yyyy-MM-dd.txt` |
| **P2** | 年度过滤语义不对 | 改为最近 N 小时/天的时间窗口过滤 |
| **P2** | Thread.sleep 阻塞 | 使用 `ThreadPoolTaskScheduler``ScheduledThreadPoolExecutor` 提交异步任务 |
| **P3** | Redis Key 硬编码 | 提取为配置项 `wuji.redis-rank-key` |
| **P3** | 非幂等设计 | 改造为支持幂等(基于 jav_id 唯一约束的 `ON DUPLICATE KEY UPDATE`,文件去重写入) |