Files

394 lines
14 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.
# DailyHotJob 改造任务清单
## 改造目标
修复 DailyHotJob 异常吞没、性能低下、运维盲区等核心缺陷,改造为可观测、可中断、高性能的定时任务。
## 涉及文件清单
| 文件 | 路径 | 改动量 |
|------|------|--------|
| DailyHotJob.java | `yudao-module-wellness/.../wellness/job/jackett/DailyHotJob.java` | 小改 |
| JackettServiceImpl.java | `yudao-module-wellness/.../wellness/service/jackett/impl/JackettServiceImpl.java` | 大改 |
| ResourcesUtils.java | `yudao-module-wellness/.../wellness/utils/ResourcesUtils.java` | 小改 |
| application-{env}.yml | `yudao-module-wellness/.../resources/` | 新增配置项 |
---
## 分步任务
### P0-1:异常不再吞没,让 JobHandlerInvoker 能记录真实状态
**描述**:当前 `DailyHotJob.execute()` 没有 throws Exception,内部 `dailyHot()` 所有异常都被 try-catch 吞掉。JobHandlerInvoker 永远收不到异常,Job 日志永远显示成功,运维完全盲区。
**改动点**
1. **`DailyHotJob.java`**`execute()` 方法改为 `throws Exception`,不再包 try-catch
```java
@Override
public String execute(String param) throws Exception {
jackettService.dailyHot();
return "每日热点";
}
```
注意:如果 `dailyHot()` 声明了 throws Exception,这里会自动向上传播,不需要额外处理。
2. **`JackettServiceImpl.java` — `dailyHot()` 方法签名**:改为 `throws Exception`
```java
@Override
@TenantIgnore
public void dailyHot() throws Exception { ... }
```
3. **`dailyHot()` 内部循环**:**保留**单个关键词搜索失败的捕获(因为一个搜失败不应该影响其他关键词),但去掉最外层那层兜底 try-catch,或者让最外层 catch 后重新 throw
```java
// ❌ 错误做法:catch 后只打日志
// ✅ 正确做法:catch 可恢复异常后继续循环,不可恢复异常向上抛
for (String javId : newIds) {
try {
...
} catch (Exception e) {
log.error("搜索 '{}' 时出错", javId, e.getMessage(), e);
// 单个失败不中断整体任务,continue 继续下一个
}
}
```
这样单个关键词搜索失败不会影响整体,但 `dailyHot()` 本身如果出现致命错误(如 FlareSolverr 不可用),会在 `getPageContent()` 抛出异常并向上传播。
**注意事项**
- `dailyHot()` 的循环内 `catch` 后应该 `continue`,而不是 `break` 或 `throw`
- 如果 `ResourcesUtils.saveFile()` 抛出 `RuntimeException`,它也会向上传播——需要考虑这个是否属于「可恢复」异常
---
### P0-2:租户注解问题修正
**描述**`@TenantIgnore` 只在 `dailyHot()` 上,但内部调用的 `getDailyHotIds()`、`getPageContent()`、`searchSingleKeyword()`、`searchRecent()` 都是通过 `this.` 内部调用,Spring AOP 切面不会触发,租户 SQL 拦截器可能拼入错误的 `WHERE tenant_id = ?`。
**改动点**
1. 在这些方法上补 `@TenantIgnore`
- `getDailyHotIds()`
- `getPageContent(String targetUrl)`
- `searchSingleKeyword(String query, Integer limit)`
- `searchRecent(List<String> keywords, Integer hours, Integer limit)`
```java
@Override
@TenantIgnore
public Set<String> getDailyHotIds() { ... }
@Override
@TenantIgnore
public String getPageContent(String targetUrl) { ... }
@Override
@TenantIgnore
public List<JackettSearchResultVO> searchSingleKeyword(String query, Integer limit) { ... }
@Override
@TenantIgnore
public List<JackettSearchResultVO> searchRecent(List<String> keywords, Integer hours, Integer limit) { ... }
```
**注意事项**
- `saveRedisIds()` 也加了 `@TenantIgnore` 了吗?检查一下,如果没有也要加上
- 如果 ruoyi 框架的租户拦截是通过 `@TenantIgnore` 在 Service 方法上加注解来跳过,这些方法都需要加
---
### P1-1:批量查重 + 批量插入替代 N+1
**描述**:当前对每个 hotId 执行一次 `selectOne`N 次查询)+ 循环内逐条 `insert`,严重低效。
**改动点**
1. **`JavInfoMapper.java`**:新增批量查询方法
```java
// 根据 javId 列表批量查询已存在的记录
List<JavInfoDO> selectByJavIds(@Param("javIds") Collection<String> javIds);
```
对应的 XML
```xml
<select id="selectByJavIds" resultMap="JavInfoResultMap">
SELECT * FROM wellness_jav_info WHERE jav_id IN
<foreach collection="javIds" item="javId" open="(" separator="," close=")">
#{javId}
</foreach>
</select>
```
2. **`JavInfoMapper.java`**:新增批量插入方法
```java
// 批量插入(忽略重复键)
int insertBatch(@Param("list") Collection<JavInfoDO> list);
```
对应的 XML(使用 MySQL `INSERT IGNORE` 或 `ON DUPLICATE KEY UPDATE`):
```xml
<insert id="insertBatch">
INSERT IGNORE INTO wellness_jav_info(jav_id, create_time, update_time) VALUES
<foreach collection="list" item="item" separator=",">
(#{item.javId}, #{item.createTime}, #{item.updateTime})
</foreach>
</insert>
```
3. **`JackettServiceImpl.dailyHot()`**:改造查重逻辑
```java
// ❌ 旧:逐条 selectOne
Set<String> newIds = hotIds.stream()
.filter(javId -> javInfoMapper.selectOne(JavInfoDO::getJavId, javId) == null)
.collect(Collectors.toSet());
// ✅ 新:批量查重
Set<String> existingIds = javInfoMapper.selectByJavIds(hotIds)
.stream().map(JavInfoDO::getJavId).collect(Collectors.toSet());
Set<String> newIds = hotIds.stream()
.filter(id -> !existingIds.contains(id))
.collect(Collectors.toSet());
```
4. **`JackettServiceImpl.dailyHot()`**:改造插入逻辑,循环结束后统一批量插入
```java
// 循环内不再逐条 insert,改为收集待插入
List<JavInfoDO> toInsert = new ArrayList<>();
for (String javId : newIds) {
// ...搜索+过滤+保存文件...
toInsert.add(new JavInfoDO().setJavId(javId));
}
// 循环结束后批量插入
if (!toInsert.isEmpty()) {
javInfoMapper.insertBatch(toInsert);
}
```
**注意事项**
- `insertBatch` 需要 MySQL 支持,如果是 PostgreSQL 或 Oracle 需要对应适配
- 使用 `INSERT IGNORE` 可以避免重复键冲突;如果不需要 `ignore` 行为,可以用 `ON DUPLICATE KEY UPDATE` 更新 update_time
---
### P1-2:并发搜索 + 超时控制
**描述**:逐条串行搜索 + `Thread.sleep`,如果 50 个新 ID,仅 sleep 就 100s,加上 HTTP 耗时 2-5s/次,整个 Job 跑几分钟。
**改动点**
1. **`JackettServiceImpl.java`**:引入并发控制
```java
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
```
在类中声明线程池(或者使用 Spring 的 `TaskExecutor`):
```java
private static final ExecutorService SEARCH_EXECUTOR = Executors.newFixedThreadPool(5);
```
改造 `dailyHot()` 中的循环:
```java
// ❌ 旧:串行 for 循环 + sleep
for (String javId : newIds) {
List<JackettSearchResultVO> results = searchSingleKeyword(javId, 30);
// ...过滤+保存...
Thread.sleep(jackettProperties.getSearchDelay());
}
// ✅ 新:CompletableFuture 并发
List<CompletableFuture<Void>> futures = new ArrayList<>();
for (String javId : newIds) {
CompletableFuture<Void> future = CompletableFuture.runAsync(() -> {
try {
List<JackettSearchResultVO> results = searchSingleKeyword(javId, 30);
// ...过滤+保存...
} catch (Exception e) {
log.error("搜索 '{}' 时出错", javId, e.getMessage(), e);
}
}, SEARCH_EXECUTOR);
futures.add(future);
}
// 等待所有搜索完成(带超时)
CompletableFuture.allOf(futures.toArray(new CompletableFuture[0]))
.get(jackettProperties.getSearchTimeoutMinutes(), TimeUnit.MINUTES);
```
`searchRecent()` 同理也要改造。
2. **`JackettProperties.java`**:新增配置项
```java
/** 搜索超时时间(分钟) */
private Integer searchTimeoutMinutes = 30;
```
3. **`application.yml`**:验证或添加配置
```yaml
jackett:
search-timeout-minutes: 30
```
4. **`RestTemplate` 配置**:在 `JackettConfig` 或 `RestTemplate` 的 `@Bean` 创建处,设置连接超时和读取超时
```java
SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory();
factory.setConnectTimeout(10_000); // 10秒连接超时
factory.setReadTimeout(30_000); // 30秒读取超时
RestTemplate restTemplate = new RestTemplate(factory);
```
**注意事项**
- 线程池要确保应用关闭时正确 shutdown(可以用 `@PreDestroy` 或在 Spring 的 `@Bean(destroyMethod="shutdown")`
- 并发度不宜过高(3-5 即可),避免 Jackett 服务端限流
- `searchSingleKeyword()` 和 `searchRecent()` 内部已有 try-catch,在 CompletableFuture 内还会继续 catch,注意异常不会自动传播到 `future.get()`,需要 `CompletableFuture` 的 `handle()` 或自行封装
---
### P2-1:文件按日期分片替代无限追加
**描述**`ResourcesUtils.saveFile(content, wujiBaseDirectory, "hot")` 永远追加到同一个 `hot.txt`,文件无限增长。
**改动点**
1. **`JackettServiceImpl.dailyHot()`**:传入文件名时带上日期
```java
// ❌ 旧
ResourcesUtils.saveFile(resultVO.getMagnetUri(),
jackettProperties.getWujiBaseDirectory(), "hot");
// ✅ 新:按日期分文件
String dateStr = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd"));
ResourcesUtils.saveFile(resultVO.getMagnetUri(),
jackettProperties.getWujiBaseDirectory(), "hot-" + dateStr);
```
或按年月日三级目录:
```java
String yyyy = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy"));
String MMdd = LocalDateTime.now().format(DateTimeFormatter.ofPattern("MM/dd"));
ResourcesUtils.saveFile(resultVO.getMagnetUri(),
jackettProperties.getWujiBaseDirectory() + "/" + yyyy + "/" + MMdd, "hot");
```
2. 或者如果决定改入库(推荐方案):
在 `JavInfoDO` 中新增 `magnet_uri` 字段,在 `insertBatch` 时一并写入,完全替代文件存储。
此时 `dailyHot()` 循环内去掉 `saveFile` 调用,`toInsert` 中设置 `magnetUri`。
**注意事项**
- 如果选择入库替代文件,需要考虑 `JavInfoDO` 表结构变更和数据库 migration,改动量更大
- 如果继续用文件,建议增加清理策略(如保留最近 30 天的文件,自动删除过期文件)
---
### P2-2:日期过滤改为时间窗口
**描述**:当前用 `resultVO.getPublishDate().getYear() != currentYear` 过滤,语义是「当年发布」而非「最近发布」。跨年时段逻辑偏差明显。
**改动点**
**`JackettServiceImpl.dailyHot()`**:替换过滤条件
```java
// ❌ 旧
if (resultVO.getPublishDate() == null
|| resultVO.getPublishDate().getYear() != currentYear) {
continue;
}
// ✅ 新:最近 24 小时内发布
LocalDateTime since = LocalDateTime.now().minusHours(24);
if (resultVO.getPublishDate() == null
|| resultVO.getPublishDate().isBefore(since)) {
continue;
}
```
时间窗口大小建议外部化配置:
```yaml
jackett:
hot-window-hours: 24
```
---
### P2-3:避免 Thread.sleep 阻塞 Quartz 线程
**描述**`Thread.sleep(searchDelay)` 在 Quartz 的 `@DisallowConcurrentExecution` 工作线程中阻塞,无法处理其他 Job。
**改动点**
如果改用了 P1-2 的并发方案(CompletableFuture),线程池已经独立于 Quartz 线程,sleep 问题自然解决。如果暂时不改并发,至少:
1. 将 `searchDelay` 从 sleep 改为使用 `ScheduledExecutorService` 调度
2. 或者在并发方案中就解决了,不用额外改动
**推荐**:P1-2 的并发方案已经涵盖了这一条,不需要独立处理。
---
### P3-1Redis Key 硬编码修复
**描述**`saveRedisIds()` 中的 Redis key 硬编码为 `"\""jav:rank:2023"\""`,年份过时且带有多余引号。
**改动点**
**`JackettProperties.java`**:新增配置项
```java
/** Redis 热门排名 Key */
private String redisRankKey = "jav:rank";
```
**`JackettServiceImpl.saveRedisIds()`**:改为使用配置
```java
// ❌ 旧
String key = "\""jav:rank:2023"\"";
// ✅ 新
String key = jackettProperties.getRedisRankKey();
```
**`application.yml`**:添加配置
```yaml
jackett:
redis-rank-key: "jav:rank:2026" # 或更通用 key
```
---
### P3-2:幂等性/中断支持
**描述**Job 执行中断时,已 insert 的记录和已保存的文件产生不一致,下次执行产生重复数据。
**改动点**
1. **数据库层**`JavInfoDO.jav_id` 加 UNIQUE 约束,`insertBatch` 使用 `INSERT IGNORE` → 幂等写入,重复自动跳过
```sql
ALTER TABLE wellness_jav_info ADD UNIQUE INDEX uk_jav_id (jav_id);
```
2. **文件层**:按日期分片后(P2-1),同一天执行多次也是追加到同一个日期文件,仍然会重复。可以在写入前检查是否已存在:
```java
// 方式 A:先读文件内容做 Set 去重(小文件可行,大文件低效)
// 方式 B:不管去重,反正按日期分片,运维定期清理即可
```
3. **「执行中标记」**:可选地在 Redis 中设置一个锁/标记,防止同一个 Job 实例并发(Quartz 的 `@DisallowConcurrentExecution` 已经解决了同一实例的并发,但多实例部署还需要确认)
---
## 验收条件
| # | 验收项 | 验证方式 |
|---|--------|----------|
| 1 | 异常能被 Job 框架记录 | 手动使 FlareSolverr 不可用,观察 Job 日志面板显示失败状态 |
| 2 | 租户注解正确 | 多租户环境下执行 Job,确认没有错误的 `tenant_id` SQL |
| 3 | 数据库批量查询+插入 | 100+ hotIds 时,SQL 日志只出现一次 select 和一次 insert |
| 4 | 并发搜索 | 50 个新 ID 的搜索时间 ≤ 30s(之前 4+ 分钟) |
| 5 | 文件按日期分片 | 执行每天 Job 后,目录下生成 `hot-2026-04-26.txt` 格式文件,而非累加到一个文件 |
| 6 | 日期过滤正确 | 插入数据库的应该是最近 24h 发布的数据,而非整年数据 |
| 7 | Redis Key 可配置 | 修改 `application.yml` 中的 `jackett.redis-rank-key`Job 读取新值 |
| 8 | 幂等性 | 同一批 hotIds 执行两次,数据库和文件不产生重复记录 |