fix: 统一 wp_drive_movie 表映射,移除废弃的 wp_movie 表引用

- MovieDO/MovieMapper 改为映射 wp_drive_movie 表

- 移除 wp_movie 建表逻辑,由 drive-service 统一管理

- 修复 TMDB 刮削查询条件,放开 scrape_type=none 限制

- 修复 api-doc.html 中 9 处与实际代码不一致

- 统一所有服务 Jasypt 加密算法

- 移除默认加密密钥,生产环境需通过环境变量设置

- 新增 PROJECT_REFERENCE.md 项目规范文件

- 新增 IconCleanupScheduler 定时清理图片

- 调整日志级别为 INFO,移除 MyBatis-Plus SQL 日志
This commit is contained in:
liRQ 2026-06-18 19:02:43 +08:00
parent f79c193b09
commit 49cce2dbb5
20 changed files with 1318 additions and 156 deletions

475
PROJECT_REFERENCE.md Normal file
View File

@ -0,0 +1,475 @@
# 网盘系统 (WangPan) - 项目全局参考文件
> **用途**:本文档记录项目规范、命名约定、常见问题与解决方案,供 TraeAI 在每次调整代码前阅读参考。
>
> **规则**每次修改项目代码前TraeAI 必须读取本文件以确保遵循项目规范。
---
## 一、项目概览
| 项目 | 说明 |
|------|------|
| 名称 | WangPan网盘系统 |
| 架构 | 微服务Spring Boot 3.2.5 + Spring Cloud Gateway |
| 语言 | Java 17 |
| 构建工具 | Maven |
| 包名根路径 | `com.wangpan` |
| 数据库 | MySQL 8.0.33 |
| 对象存储 | MinIO |
| ORM | MyBatis-Plus 3.5.7 |
| 配置加密 | JasyptJasypt Spring Boot Starter |
### 模块结构与端口
| 模块 | 端口 | 说明 |
|------|------|------|
| `wangpan-common` | - | 公共模块DO/Mapper/Repository/Entity/Util/Interceptor |
| `wangpan-user-service` | 8081 | 用户认证服务登录、注册、Token管理 |
| `wangpan-drive-service` | 8082 | 网盘管理服务(文件管理、电影管理) |
| `wangpan-tmdb-service` | 8083 | TMDB 刮削服务(电影元数据抓取) |
| `wangpan-gateway` | 8888 | API 网关(统一路由入口) |
---
## 二、命名规范
### 2.1 包命名
```
com.wangpan # 根包
├── common # 公共模块
│ ├── annotation # 自定义注解
│ ├── config # 配置类
│ ├── dal # 数据访问层
│ │ ├── dataobject # 数据对象DO
│ │ ├── mapper # MyBatis Mapper 接口
│ │ └── repository # Repository 层(封装数据访问)
│ ├── entity # 业务实体
│ ├── interceptor # 拦截器
│ ├── listener # 监听器 / 定时任务
│ └── util # 工具类
├── controller # 控制器(各子模块各自持有)
├── service # 业务服务层(各子模块各自持有)
└── Application.java # 启动类(各子模块各自持有)
```
**规则**
- 各子服务的 Controller 和 Service 放在各自模块的 `src/main/java/com/wangpan/`
- 公共的 DO、Mapper、Repository、Entity、Util 放在 `wangpan-common`
- 所有 Mapper XML 文件统一放在 `wangpan-common/src/main/resources/com/wangpan/common/dal/mapper/`
### 2.2 类命名
| 类型 | 命名规则 | 示例 |
|------|---------|------|
| DO数据对象 | `{实体名}DO` | `DriveMovieDO`, `UserDO`, `MovieDO` |
| Mapper 接口 | `{实体名}Mapper` | `DriveMovieMapper`, `UserMapper` |
| Repository | `{实体名}Repository` | `DriveMovieRepository`, `UserRepository` |
| Entity业务实体 | `{实体名}` | `DriveMovie`, `User`, `Movie` |
| Controller | `{模块名}Controller` | `DriveController`, `AuthController`, `TmdbController` |
| Service | `{模块名}Service` | `DriveService`, `UserService`, `TmdbService` |
| 工具类 | `{功能}Util` | `TokenUtil`, `MinioUtil`, `AuthUtil`, `EncryptUtil` |
| 配置类 | `{功能}Config` | `WebMvcConfig`, `MyBatisPlusConfig` |
| 启动类 | `{模块名}Application` | `UserServiceApplication`, `DriveServiceApplication` |
| 拦截器 | `{功能}Interceptor` | `AuthInterceptor` |
| 定时任务 | `{功能}Scheduler` | `TmdbScheduler`, `IconCleanupScheduler` |
### 2.3 方法命名
| 操作 | 命名规则 | 示例 |
|------|---------|------|
| 查询单个 | `findBy{条件}` | `findById`, `findByMovieId` |
| 查询列表 | `queryBy{条件}` | `queryByUserId`, `queryByDriveAndFolder` |
| 统计数量 | `countBy{条件}` | `countByDrive`, `countByUser` |
| 插入 | `insert` | `insert(DriveMovie movie)` |
| 更新 | `update` / `update{字段}` | `update`, `updateScrapeInfo` |
| 逻辑删除 | `logicalDelete` | `logicalDelete(Long id, Long userId)` |
| 物理删除 | `delete` | `delete(Long id, Long userId)` |
| 移动 | `moveTo{目标}` | `moveToFolder` |
### 2.4 数据库命名
| 元素 | 命名规则 | 示例 |
|------|---------|------|
| 表名 | `wp_{模块}_{实体}` | `wp_drive_movie`, `wp_user`, `wp_tmdb_cache` |
| 字段名 | 小写 + 下划线分隔 | `drive_id`, `user_id`, `create_time`, `movie_title` |
| 主键 | `id`BIGINT AUTO_INCREMENT | `id` |
| 索引 | `idx_{字段名}` | `idx_drive_id`, `idx_user_id`, `idx_status` |
| 状态字段 | 字符串枚举值 | `'active'`, `'deleted'`, `'expired'` |
### 2.5 API 命名
| 模块 | 路径前缀 | 示例 |
|------|---------|------|
| 用户服务 | `/api/login`, `/api/register`, `/api/token/**`, `/api/users/**`, `/api/login-logs/**` | `POST /api/login` |
| 网盘服务 | `/api/drive/**`, `/api/icon/**` | `GET /api/drive/movies/duplicates` |
| TMDB服务 | `/api/movies/**`, `/api/tmdb/**`, `/api/tmdb-scraper/**` | `GET /api/movies/{id}` |
---
## 三、技术架构模式
### 3.1 分层架构
```
Controller → 接收请求、参数校验、调用 Service、返回 Result
Service → 业务逻辑处理、事务管理
Repository → 数据访问封装、Entity ↔ DO 转换、MyBatis-Plus LambdaQueryWrapper 查询
Mapper → MyBatis-Plus BaseMapper 接口 + XML 复杂 SQL
DO → 数据库表映射(@TableName 注解)
```
**关键规则**
- Controller 直接返回 `Result<T>` 对象,不要直接返回原始数据
- Repository 继承 `ServiceImpl<Mapper, DO>`,自动获得 MyBatis-Plus CRUD 能力
- Mapper 继承 `BaseMapper<DO>`,简单 CRUD 自动生成
- 复杂 SQL 写在 Mapper XML 中,方法签名定义在 Mapper 接口中
- Repository 负责 Entity ↔ DO 转换,对外暴露 Entity 或 Map 类型
### 3.2 Result 统一响应
```java
// 成功
Result.success("操作成功", data);
// 各类错误
Result.badRequest("参数错误"); // 400
Result.unauthorized("登录已过期"); // 401
Result.forbidden("权限不足"); // 403
Result.notFound("资源不存在"); // 404
Result.tooManyRequests("请求过于频繁"); // 429
Result.error("服务器错误"); // 500
Result.error(code, "自定义错误"); // 自定义状态码
```
### 3.3 认证模式
- 使用 `@RequireAuth` 注解标记需要登录的接口
- 使用 `@RequireAdmin` 注解标记需要管理员权限的接口
- `AuthInterceptor` 拦截器统一处理认证逻辑
- Controller 通过 `request.getAttribute("currentUser")` 获取当前用户
- Token 使用 `TokenUtil` 生成和验证
### 3.4 MyBatis-Plus 查询模式
```java
// 简单查询 → LambdaQueryWrapper
var wrapper = new LambdaQueryWrapper<DriveMovieDO>()
.eq(DriveMovieDO::getDriveId, driveId)
.eq(DriveMovieDO::getUserId, userId)
.eq(DriveMovieDO::getStatus, "active")
.orderByDesc(DriveMovieDO::getCreateTime);
// 分页查询
var page = new Page<DriveMovieDO>(pageNo, pageSize);
var result = page(page, wrapper);
// 更新 → LambdaUpdateWrapper
var wrapper = new LambdaUpdateWrapper<DriveMovieDO>()
.eq(DriveMovieDO::getId, id)
.set(DriveMovieDO::getStatus, "deleted");
```
### 3.5 Jasypt 配置加密模式
所有服务(必须一致):
```yaml
jasypt:
encryptor:
password: ${JASYPT_ENCRYPTOR_PASSWORD} # 必须通过环境变量传入
algorithm: PBEWITHHMACSHA512ANDAES_256 # 统一使用强加密算法
key-obtention-iterations: 1000
pool-size: 1
salt-generator-classname: org.jasypt.salt.RandomSaltGenerator
iv-generator-classname: org.jasypt.iv.RandomIvGenerator
string-output-type: base64
```
**加密密钥**`wangpan-secret-key-2024`
**启动命令**(必须带 Jasypt 密钥参数):
```bash
java -DJASYPT_ENCRYPTOR_PASSWORD=wangpan-secret-key-2024 -jar xxx.jar
```
### 3.6 MinIO 存储模式
- `MinioUtil` 作为 Spring Bean使用 `@PostConstruct` 初始化
- 支持静态方法调用和 Spring Bean 注入两种方式
- 预签名 URL 有效期7 天
- 存储桶按服务区分:`wangpan`(网盘服务)、`tmdb`TMDB服务
### 3.7 定时任务模式
```java
@Component
public class TmdbScheduler {
@Scheduled(fixedDelay = 6 * 60 * 60 * 1000, initialDelay = 5 * 60 * 1000)
public void executeScrape() { ... }
}
```
---
## 四、MyBatis XML 映射规则(重要!)
### 4.1 列别名规则
XML 中 `SELECT` 返回的列别名 **必须与 Java 代码中 `Map.get()` 使用的 key 完全一致**
```xml
<!-- XML 中 -->
<select id="findDuplicateMovies" resultType="java.util.Map">
SELECT title, COUNT(*) AS cnt <!-- 注意:列别名是 cnt -->
FROM wp_drive_movie
...
</select>
```
```java
// Java 代码中必须使用相同的 key
g.get("cnt") // ✅ 正确 - 与 XML 别名 cnt 一致
g.get("count") // ❌ 错误 - 别名是 cnt 不是 count
```
### 4.2 数值类型转换
XML 中 `resultType="java.util.Map"` 返回的数值Java 中获取时需要安全转换:
```java
// ✅ 推荐:使用 Number 父类转换
((Number) g.get("cnt")).intValue()
// ❌ 不推荐:直接强制转换可能失败
(int) g.get("cnt")
```
### 4.3 分页查询
XML 中使用 `LIMIT #{pageSize} OFFSET #{pageNo}` 时,分页偏移量计算规则:
```java
// 前端传 pageNo 从 1 开始XML 中 offset = (pageNo - 1) * pageSize
// 或者前端直接传 offset 值
```
---
## 五、已发现的问题与解决方案
### 问题 1SQL 列别名与 Java Map key 不匹配导致 NPE
**日期**2026-06-15
**问题描述**:调用 `GET /api/drive/movies/duplicates` 返回 500报错
```
java.lang.NullPointerException: Cannot invoke "java.lang.Integer.intValue()"
because the return value of "java.util.Map.get(Object)" is null
```
**根因**`DriveMovieMapper.xml` 中 SQL 别名是 `cnt`,但 `DriveController.java` 中使用的 key 是 `count`
**解决方案**
1. 将 Controller 中的 `g.get("count")` 改为 `g.get("cnt")`
2. 使用安全转换 `((Number) g.get("cnt")).intValue()` 替代 `(int) g.get("cnt")`
**影响文件**
- `wangpan-drive-service/.../controller/DriveController.java` 第 676 行
- `wangpan-common/.../mapper/DriveMovieMapper.xml` 第 10 行SQL 别名 `cnt`
**教训**:修改 XML 中的 SQL 列别名时,必须同步修改所有引用该结果集的 Java 代码中的 Map key。
### 问题 2网关 Jasypt 加密算法不一致
**日期**2026-06-15
**问题描述**:网关服务使用 `PBEWithMD5AndDES` 算法,其他服务使用 `PBEWITHHMACSHA512ANDAES_256`,不一致且 MD5+DES 算法已过时。
**解决方案**:统一所有服务使用 `PBEWITHHMACSHA512ANDAES_256`
**影响文件**`wangpan-gateway/src/main/resources/application.yml`
### 问题 3硬编码默认 Jasypt 密钥
**日期**2026-06-15
**问题描述**:所有服务配置了 `${JASYPT_ENCRYPTOR_PASSWORD:wangpan-secret-key-2024}` 默认值,存在安全风险。
**解决方案**:移除默认值,改为 `${JASYPT_ENCRYPTOR_PASSWORD}`,启动时强制通过命令行参数 `-DJASYPT_ENCRYPTOR_PASSWORD=xxx` 传入。
**影响文件**:所有服务的 `application.yml`
### 问题 4日志级别过高
**日期**2026-06-15
**问题描述**:所有服务日志级别为 `DEBUG`,生产环境会产生大量日志。
**解决方案**:改为 `INFO` 级别。
### 问题 5MyBatis-Plus SQL 日志输出到控制台
**日期**2026-06-15
**问题描述**:配置了 `log-impl: org.apache.ibatis.logging.stdout.StdOutImpl`,生产环境会打印所有 SQL。
**解决方案**:移除该配置。
### 问题 6启动时 Jasypt 环境变量未传递
**日期**2026-06-15
**问题描述**:使用 `$env:JASYPT_ENCRYPTOR_PASSWORD="xxx"` + `Start-Process` 启动服务时,子进程未继承环境变量,导致 `Failed to bind properties under 'spring.datasource.password'`
**解决方案**:使用 `-DJASYPT_ENCRYPTOR_PASSWORD=xxx` 命令行参数替代环境变量。
### 问题 7TMDB 刮削查询了错误的电影表
**日期**2026-06-18
**问题描述**TMDB 刮削器(`MovieMapper`/`MovieDO`/`MovieRepository`)映射的是 `wp_movie` 表,但项目中实际使用的电影表是 `wp_drive_movie`(由 drive-service 管理)。`wp_movie` 表无数据,导致刮削始终返回"没有需要刮削的电影"。
**根因**:项目中存在两张结构相同的电影表(`wp_movie` 和 `wp_drive_movie`),但实际只有 `wp_drive_movie` 有数据。
**解决方案**
1. 将 `MovieDO``@TableName``wp_movie` 改为 `wp_drive_movie`
2. 将所有 `MovieMapper` 的 SQL含 XML 和注解)中的 `wp_movie` 改为 `wp_drive_movie`
3. 将 `TmdbCacheMapper.xml` 中 JOIN 的 `wp_movie` 改为 `wp_drive_movie`
4. 移除 `MovieMapper.CREATE_TABLE_SQL``wp_drive_movie` 由 `DriveMovieMapper` 负责建表)
5. 从 tmdb-service 的 `DatabaseInitializer` 中移除 `wp_movie` 表创建
6. 修改 `findMoviesNeedingScrape` 查询条件,移除 `scrape_type != 'none'` 限制,允许未刮削的电影被选中
**影响文件**
- `wangpan-common/.../dataobject/MovieDO.java``@TableName` 改为 `wp_drive_movie`
- `wangpan-common/.../mapper/MovieMapper.java` — 移除 `CREATE_TABLE_SQL`SQL 改表名
- `wangpan-common/.../mapper/MovieMapper.xml` — SQL 表名改为 `wp_drive_movie`,放开 `scrape_type` 限制
- `wangpan-common/.../mapper/TmdbCacheMapper.xml` — JOIN 表名改为 `wp_drive_movie`
- `wangpan-common/.../mapper/TmdbCacheMapper.java` — 注释更新
- `wangpan-common/.../entity/Movie.java` — 注释更新
- `wangpan-tmdb-service/.../config/DatabaseInitializer.java` — 移除 `wp_movie` 建表
- `wangpan-tmdb-service/.../service/TmdbScraperService.java` — 注释更新
**教训**:项目中统一使用 `wp_drive_movie` 作为电影表所有模块drive-service、tmdb-service都应操作同一张表。`wp_movie` 表已被废弃,不应再使用。
---
## 六、配置文件关键信息
### 6.1 数据库连接
- 地址:`118.178.238.159:33306`
- 数据库名:`wangpan`
- 连接参数:`useUnicode=true&characterEncoding=utf-8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true`
- 所有密码使用 Jasypt `ENC(...)` 加密
### 6.2 MinIO 连接
- 地址:`118.178.238.159:39000`
- 访问密钥和秘密密钥使用 Jasypt `ENC(...)` 加密
- 存储桶:`wangpan`(网盘服务)、`tmdb`TMDB服务
### 6.3 网关路由规则
| 路由路径 | 转发目标 |
|---------|---------|
| `/api/login`, `/api/register`, `/api/token/**`, `/api/login-logs/**`, `/api/users/**` | `http://localhost:8081` |
| `/api/drive/**`, `/api/icon/**` | `http://localhost:8082` |
| `/api/movies/**`, `/api/tmdb/**`, `/api/tmdb-scraper/**` | `http://localhost:8083` |
所有路由 `StripPrefix=0`(不裁剪路径前缀)。
---
## 七、TraeAI 操作规则
### 7.1 每次修改代码前必须执行
1. **读取本文件**`PROJECT_REFERENCE.md`)了解项目规范
2. 查找相关代码,理解现有模式后再修改
3. 修改代码时遵循项目命名规范
### 7.2 修改规范
1. **禁止**创建不必要的新文件,优先编辑现有文件
2. **禁止**创建文档(*.md除非用户明确要求
3. 修改 SQL 列别名时,**必须同步检查**所有 Java 代码中对 Map key 的引用
4. 修改配置文件时,**确保所有服务配置一致**Jasypt 算法、日志级别等)
5. 使用 `Result` 类统一返回,不要返回原始类型
6. 数据库操作使用 MyBatis-Plus LambdaQueryWrapper/LambdaUpdateWrapper不要拼接 SQL 字符串
7. 复杂 SQL 写在 Mapper XML 中,不要写在 Java 代码中
### 7.3 编译与启动
```bash
# 编译
mvn clean install -DskipTests
# 启动单个服务
java -DJASYPT_ENCRYPTOR_PASSWORD=wangpan-secret-key-2024 -jar xxx\target\xxx-1.0-SNAPSHOT.jar
```
### 7.4 遇到新问题时
在本文档 **第五章** 中追加新问题记录,包含:
- 日期
- 问题描述
- 根因
- 解决方案
- 影响文件
- 教训
---
## 八、MyBatis-Plus 配置要点
```yaml
mybatis-plus:
mapper-locations: classpath*:com/wangpan/common/dal/mapper/**/*.xml
type-aliases-package: com.wangpan.common.dal.dataobject
configuration:
map-underscore-to-camel-case: true # 自动驼峰映射
global-config:
db-config:
id-type: auto # 主键自增
```
**注意**`mapper-locations` 使用 `classpath*:` 前缀,因为 Mapper XML 在 `wangpan-common` 模块中,所有子服务都需要加载。
---
## 九、api-doc.html 维护规范
### 9.1 文件位置
`wangpan-gateway/src/main/resources/META-INF/resources/api-doc.html`
### 9.2 历史不一致问题记录
**问题 8api-doc.html 与代码多处不一致**
**日期**2026-06-18
**发现的不一致**
| 接口 | 文档描述 | 实际代码 | 严重程度 |
|------|---------|---------|---------|
| `GET /api/movies` 查询参数 `status` | `pending / scraped / failed` | `active / deleted`status 是记录状态scrapeType 是刮削状态) | **高** |
| `GET /api/movies` 缺少参数 | 无 `sortBy`/`sortOrder` | 支持 `sortBy``sortOrder` 排序参数 | 中 |
| `POST /api/movies` 请求体 | 只有 `{ "title": "..." }` | 支持 title, subtitle, resourceLibrary, maintainer, status, category, scrapeType, rating, posterUrl, tags | **高** |
| `PUT /api/movies/{id}` 请求体 | 空白 | 支持全部字段的部分更新 | **高** |
| `GET /api/movies/stats` 响应 | 空白 | 返回 totalCount, scrapedCount, pendingCount | 中 |
| `POST /api/tmdb-scraper/scrape` | 仅请求体方式 | 同时支持 URL 参数 `?movieId=1` 和请求体 | 中 |
| `POST /api/tmdb-scraper/batch-scrape` | `movieIds` 为必填 | `movieIds` 可选,不传则自动查找 | 中 |
| 多个 `/api/tmdb-scraper/*` 端点 | 响应文档空白 | stats, trigger, scraper-logs, cache delete 等缺少响应 | 中 |
| 路由表缺少 `/api/icon/**` | 未列出 | `/api/icon/**` → drive-service | 低 |
**解决方案**:逐一修复 api-doc.html 中所有不一致的字段、参数和响应示例。
**教训**:每次新增或修改 Controller 接口时,必须同步更新 api-doc.html。
---
> **最后更新**2026-06-18

View File

@ -10,13 +10,14 @@ import java.time.LocalDateTime;
/**
* 电影数据对象Data Object
* 对应数据库表wp_movie
* 对应数据库表wp_drive_movie网盘电影表
*
* 设计说明
* - DO 字段名使用 Java 驼峰命名MyBatis-Plus 自动映射为数据库 snake_case
* - DO 仅负责数据映射不包含业务逻辑
* - 注意 DO 仅映射 wp_drive_movie 表中 TMDB 刮削相关的字段其他字段由 DriveMovieDO 映射
*/
@TableName(value = "wp_movie", autoResultMap = true)
@TableName(value = "wp_drive_movie", autoResultMap = true)
public class MovieDO implements Serializable {
private static final long serialVersionUID = 1L;

View File

@ -18,41 +18,6 @@ import java.util.Map;
@Mapper
public interface MovieMapper extends BaseMapper<MovieDO> {
/** 建表SQL保留供 DatabaseInitializer 使用) */
String CREATE_TABLE_SQL = """
CREATE TABLE IF NOT EXISTS wp_movie (
id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '电影ID',
drive_id BIGINT NOT NULL COMMENT '所属网盘ID',
folder_id BIGINT NOT NULL DEFAULT 0 COMMENT '所属文件夹ID0=根目录',
user_id BIGINT NOT NULL COMMENT '所属用户ID',
title VARCHAR(300) NOT NULL COMMENT '电影标题',
subtitle VARCHAR(300) DEFAULT NULL COMMENT '电影副标题',
original_name VARCHAR(300) DEFAULT NULL COMMENT '原始文件名',
minio_object_name VARCHAR(500) DEFAULT NULL COMMENT 'MinIO对象名称',
minio_url TEXT DEFAULT NULL COMMENT 'MinIO访问URL加密存储',
mime_type VARCHAR(100) DEFAULT NULL COMMENT 'MIME类型',
file_size BIGINT NOT NULL DEFAULT 0 COMMENT '文件大小字节',
extension VARCHAR(20) DEFAULT NULL COMMENT '文件扩展名',
description TEXT DEFAULT NULL COMMENT '电影描述',
resource_library VARCHAR(100) DEFAULT NULL COMMENT '资源库来源',
maintainer VARCHAR(100) DEFAULT NULL COMMENT '维护者',
status VARCHAR(20) NOT NULL DEFAULT 'active' COMMENT '状态active/deleted',
category VARCHAR(50) DEFAULT NULL COMMENT '分类',
scrape_type VARCHAR(20) DEFAULT 'none' COMMENT '刮削类型tmdb/manual/none',
rating INT DEFAULT NULL COMMENT '评分0-100',
poster_url TEXT DEFAULT NULL COMMENT '海报URL加密存储',
tags VARCHAR(500) DEFAULT NULL COMMENT '标签逗号分隔',
update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
INDEX idx_drive_id (drive_id),
INDEX idx_user_id (user_id),
INDEX idx_folder_id (drive_id, folder_id),
INDEX idx_status (status),
INDEX idx_rating (rating),
INDEX idx_scrape_type (scrape_type)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='电影表'
""";
/**
* 查找用户所有网盘下的重复电影 title 分组
* 复杂SQL在XML中实现
@ -74,24 +39,24 @@ public interface MovieMapper extends BaseMapper<MovieDO> {
/**
* 更新电影评分
*/
@Update("UPDATE wp_movie SET rating = #{rating}, update_time = NOW() WHERE id = #{id}")
@Update("UPDATE wp_drive_movie SET rating = #{rating}, update_time = NOW() WHERE id = #{id}")
int updateRating(@Param("id") Long id, @Param("rating") Integer rating);
/**
* 更新电影海报URL
*/
@Update("UPDATE wp_movie SET poster_url = #{posterUrl}, update_time = NOW() WHERE id = #{id}")
@Update("UPDATE wp_drive_movie SET poster_url = #{posterUrl}, update_time = NOW() WHERE id = #{id}")
int updatePosterUrl(@Param("id") Long id, @Param("posterUrl") String posterUrl);
/**
* 更新电影标签
*/
@Update("UPDATE wp_movie SET tags = #{tags}, update_time = NOW() WHERE id = #{id}")
@Update("UPDATE wp_drive_movie SET tags = #{tags}, update_time = NOW() WHERE id = #{id}")
int updateTags(@Param("id") Long id, @Param("tags") String tags);
/**
* 更新刮削类型标记
*/
@Update("UPDATE wp_movie SET scrape_type = #{scrapeType}, update_time = NOW() WHERE id = #{id}")
@Update("UPDATE wp_drive_movie SET scrape_type = #{scrapeType}, update_time = NOW() WHERE id = #{id}")
int updateScrapeType(@Param("id") Long id, @Param("scrapeType") String scrapeType);
}

View File

@ -22,7 +22,7 @@ public interface TmdbCacheMapper extends BaseMapper<TmdbCacheDO> {
String CREATE_TABLE_SQL = """
CREATE TABLE IF NOT EXISTS wp_tmdb_cache (
id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '记录ID',
movie_id BIGINT NOT NULL COMMENT '关联wp_movie表的电影ID',
movie_id BIGINT NOT NULL COMMENT '关联wp_drive_movie表的电影ID',
user_id BIGINT NOT NULL COMMENT '关联用户ID',
tmdb_id INT DEFAULT NULL COMMENT 'TMDB电影ID',
title VARCHAR(200) NOT NULL COMMENT '电影标题来自TMDB',

View File

@ -146,16 +146,17 @@ public class DriveMovieRepository extends ServiceImpl<DriveMovieMapper, DriveMov
* 更新电影基本信息
* 使用 MyBatis-Plus LambdaUpdateWrapper 构建更新条件
*/
public boolean update(Long id, String title, String subtitle, String description,
public boolean update(Long id, String title, String originalName, String subtitle, String description,
Integer rating, String tags, String category) {
var wrapper = new LambdaUpdateWrapper<DriveMovieDO>()
.eq(DriveMovieDO::getId, id)
.set(DriveMovieDO::getTitle, title)
.set(DriveMovieDO::getSubtitle, subtitle)
.set(DriveMovieDO::getDescription, description)
.set(DriveMovieDO::getRating, rating)
.set(DriveMovieDO::getTags, tags)
.set(DriveMovieDO::getCategory, category)
.set(title != null, DriveMovieDO::getTitle, title)
.set(originalName != null, DriveMovieDO::getOriginalName, originalName)
.set(subtitle != null, DriveMovieDO::getSubtitle, subtitle)
.set(description != null, DriveMovieDO::getDescription, description)
.set(rating != null, DriveMovieDO::getRating, rating)
.set(tags != null, DriveMovieDO::getTags, tags)
.set(category != null, DriveMovieDO::getCategory, category)
.set(DriveMovieDO::getUpdateTime, LocalDateTime.now());
return update(wrapper);
}
@ -287,7 +288,7 @@ public class DriveMovieRepository extends ServiceImpl<DriveMovieMapper, DriveMov
}
/**
* DO Map 转换用于 API 返回
* DO Map 转换用于 API 返回包含所有字段
*/
private Map<String, Object> toMap(DriveMovieDO movieDO) {
var map = new LinkedHashMap<String, Object>();
@ -308,8 +309,10 @@ public class DriveMovieRepository extends ServiceImpl<DriveMovieMapper, DriveMov
map.put("scrapeType", movieDO.getScrapeType());
map.put("rating", movieDO.getRating());
map.put("tags", movieDO.getTags());
map.put("updateTime", movieDO.getUpdateTime() != null ? movieDO.getUpdateTime().toString() : null);
map.put("posterUrl", movieDO.getPosterUrl());
map.put("minioUrl", movieDO.getMinioUrl());
map.put("createTime", movieDO.getCreateTime() != null ? movieDO.getCreateTime().toString() : null);
map.put("updateTime", movieDO.getUpdateTime() != null ? movieDO.getUpdateTime().toString() : null);
return map;
}
}

View File

@ -116,6 +116,27 @@ public class IconRepository extends ServiceImpl<IconMapper, IconDO> {
return remove(wrapper);
}
// ==================== 查询所有在用 logoUrl ====================
/**
* 查询所有 Icon logoUrl用于清理无用图片
*
* @return 所有在用 logoUrl 列表去重
*/
public List<String> findAllLogoUrls() {
var wrapper = new LambdaQueryWrapper<IconDO>()
.select(IconDO::getLogoUrl)
.isNotNull(IconDO::getLogoUrl)
.ne(IconDO::getLogoUrl, "");
var list = list(wrapper);
return list.stream()
.map(IconDO::getLogoUrl)
.filter(url -> url != null && !url.isEmpty())
.distinct()
.toList();
}
// ==================== Entity DO 转换 ====================
private IconDO toIconDO(Icon icon) {

View File

@ -6,7 +6,7 @@ import java.util.Objects;
/**
* 电影实体类
* 对应数据库表wp_movie用户电影库
* 对应数据库表wp_drive_movie网盘电影
*
* 功能
* 1. 封装电影的基本信息标题评分海报等

View File

@ -186,6 +186,19 @@ public class MinioUtil {
return instance != null ? instance.bucketName : null;
}
/**
* 删除 MinIO 中的文件静态方法
*
* @param objectName 对象名称文件路径
* @return 删除成功返回 true失败返回 false
*/
public static boolean deleteFile(String objectName) {
if (instance == null) {
throw new IllegalStateException("MinioUtil未初始化请确保Spring容器已启动");
}
return instance.doDeleteFile(objectName);
}
// ==================== 内部实现方法 ====================
private String doUpload(InputStream inputStream, String objectName, String contentType) {
@ -238,4 +251,25 @@ public class MinioUtil {
return endpoint + "/" + bucketName + "/" + objectName;
}
}
/**
* 删除 MinIO 中的文件内部实现
*/
private boolean doDeleteFile(String objectName) {
try {
getClient().removeObject(
RemoveObjectArgs.builder()
.bucket(bucketName)
.object(objectName)
.build()
);
LOGGER.info("[MinioUtil] 文件删除成功: " + objectName);
return true;
} catch (Exception e) {
LOGGER.log(Level.WARNING, "[MinioUtil] 文件删除失败: " + objectName, e);
return false;
}
}
}

View File

@ -8,7 +8,7 @@
<!-- 查找用户所有网盘下的重复电影(按 title 分组,出现次数 > 1 -->
<select id="findDuplicateMovies" resultType="java.util.Map">
SELECT title, COUNT(*) AS cnt
FROM wp_movie
FROM wp_drive_movie
WHERE user_id = #{userId} AND status = 'active'
GROUP BY title
HAVING COUNT(*) > 1
@ -24,14 +24,14 @@
AND u.status = 1
</select>
<!-- 查找指定用户需要刮削的电影 -->
<!-- 查找指定用户需要刮削的电影(包含 scrape_type='none' 的未刮削电影) -->
<select id="findMoviesNeedingScrape" resultType="com.wangpan.common.dal.dataobject.MovieDO">
SELECT m.* FROM wp_movie m
SELECT m.* FROM wp_drive_movie m
LEFT JOIN wp_tmdb_cache c ON m.id = c.movie_id AND c.status = 'active'
WHERE m.user_id = #{userId}
AND m.title IS NOT NULL AND m.title != ''
AND m.scrape_type != 'none'
AND (c.id IS NULL OR c.cache_time &lt; DATE_SUB(NOW(), INTERVAL #{expireDays} DAY))
AND m.status = 'active'
ORDER BY m.update_time DESC
LIMIT 100
</select>

View File

@ -19,7 +19,7 @@
<select id="queryByUserId" resultType="java.util.Map">
SELECT c.*, m.title AS movie_title
FROM wp_tmdb_cache c
LEFT JOIN wp_movie m ON c.movie_id = m.id
LEFT JOIN wp_drive_movie m ON c.movie_id = m.id
WHERE c.user_id = #{userId} AND c.status = 'active'
ORDER BY c.cache_time DESC
LIMIT #{pageSize} OFFSET #{pageNo}

View File

@ -347,8 +347,16 @@ public class DriveController {
// ==================== 电影操作 ====================
/**
* 创建电影上传文件
* 创建电影只传文件名
* POST /api/drive/{driveId}/movies
*
* 请求体
* {
* "title": "文件名.mp4",
* "folderId": 0
* }
*
* 说明只记录文件名不上传文件数据后续通过 TMDB 刮削补充信息
*/
@PostMapping("/{driveId}/movies")
public Result<?> createMovie(@PathVariable("driveId") Long driveId,
@ -363,7 +371,7 @@ public class DriveController {
String title = (String) data.get("title");
if (title == null || title.isBlank()) {
return Result.error("创建电影失败:电影标题不能为空");
return Result.error("创建电影失败:文件名不能为空");
}
Long folderId = 0L;
@ -372,54 +380,11 @@ public class DriveController {
folderId = ((Number) folderIdObj).longValue();
}
String subtitle = (String) data.get("subtitle");
String description = (String) data.get("description");
String fileDataBase64 = (String) data.get("fileData");
if (fileDataBase64 == null || fileDataBase64.isBlank()) {
return Result.error("创建电影失败:电影文件数据不能为空,请上传有效的视频文件");
}
// 解码 Base64 文件数据
byte[] fileBytes;
try {
fileBytes = Base64.getDecoder().decode(fileDataBase64);
} catch (Exception e) {
return Result.error("创建电影失败:文件数据格式错误,请确保 fileData 是有效的 Base64 编码");
}
// 构建 MinIO 对象名称
String objectName = "drive/" + driveId + "/" + folderId + "/" + System.currentTimeMillis() + "_" + title;
try {
String mimeType = detectMimeType(title);
try (var inputStream = new ByteArrayInputStream(fileBytes)) {
String uploadedName = MinioUtil.upload(inputStream, objectName, mimeType);
if (uploadedName == null) {
return Result.error("创建电影失败:文件上传到存储服务失败,请检查存储配置");
}
}
// 获取文件访问 URL
String fileUrl = MinioUtil.getFileUrl(objectName);
// 创建电影记录
DriveMovie movie = new DriveMovie(driveId, folderId, user.getId(), title.trim(), title);
movie.setSubtitle(subtitle);
movie.setMinioObjectName(objectName);
movie.setMinioUrl(fileUrl);
movie.setMimeType(mimeType);
movie.setFileSize((long) fileBytes.length);
// 创建电影记录只记录文件名不上传文件数据
DriveMovie movie = new DriveMovie(driveId, folderId, user.getId(), title.trim(), title.trim());
movie.setMimeType(detectMimeType(title));
movie.setExtension(getExtension(title));
movie.setDescription(description);
// 处理可选字段
Object ratingObj = data.get("rating");
if (ratingObj != null) {
movie.setRating(((Number) ratingObj).intValue());
}
movie.setTags((String) data.get("tags"));
Long id = movieRepository.insert(movie);
if (id != null) {
@ -493,6 +458,19 @@ public class DriveController {
/**
* 更新电影信息
* PUT /api/drive/{driveId}/movies/{movieId}
*
* 请求体所有字段可选
* {
* "title": "新标题",
* "originalName": "新文件名.mp4",
* "subtitle": "副标题",
* "description": "描述",
* "rating": 85,
* "tags": "标签1,标签2",
* "category": "分类"
* }
*
* 说明用于 TMDB 刮削有误时用户自定义修改文件名等信息
*/
@PutMapping("/{driveId}/movies/{movieId}")
public Result<?> updateMovie(@PathVariable("driveId") Long driveId,
@ -507,6 +485,7 @@ public class DriveController {
}
String title = (String) data.get("title");
String originalName = (String) data.get("originalName");
String subtitle = (String) data.get("subtitle");
String description = (String) data.get("description");
Object ratingObj = data.get("rating");
@ -517,7 +496,7 @@ public class DriveController {
String tags = (String) data.get("tags");
String category = (String) data.get("category");
boolean success = movieRepository.update(movieId, title, subtitle, description, rating, tags, category);
boolean success = movieRepository.update(movieId, title, originalName, subtitle, description, rating, tags, category);
if (success) {
return Result.success("更新成功");
} else {
@ -694,7 +673,7 @@ public class DriveController {
result.put("totalGroups", duplicates.size());
int totalDuplicates = duplicates.stream()
.mapToInt(g -> (int) g.get("count"))
.mapToInt(g -> ((Number) g.get("cnt")).intValue())
.sum();
result.put("totalDuplicates", totalDuplicates);

View File

@ -7,10 +7,12 @@ import com.wangpan.common.entity.Icon;
import com.wangpan.common.entity.User;
import com.wangpan.common.interceptor.AuthInterceptor;
import com.wangpan.common.util.IconLogoFetcher;
import com.wangpan.common.util.MinioUtil;
import jakarta.servlet.http.HttpServletRequest;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import java.util.LinkedHashMap;
import java.util.List;
@ -358,4 +360,172 @@ public class IconController {
return Result.error("删除失败:" + e.getMessage());
}
}
// ==================== 上传自定义图标 ====================
/**
* 上传自定义图标图片
* POST /api/icon/{id}/logo
*
* 请求multipart/form-data字段名 "file"
* 支持格式pngjpgjpeggifwebpico
* 最大大小1MB
*
* 流程
* 1. 校验文件合法性格式大小
* 2. 上传新图片到 MinIO
* 3. 更新数据库中的 logoUrl
* 4. 删除旧的 MinIO 图片防止硬盘过满
*/
@PostMapping("/{id}/logo")
public Result<?> uploadLogo(@PathVariable Long id, @RequestParam("file") MultipartFile file, HttpServletRequest request) {
User user = getCurrentUser(request);
log.info("用户 [{}] 上传自定义图标Icon ID={}", user.getUsername(), id);
try {
// 校验文件
if (file.isEmpty()) {
return Result.error("文件不能为空");
}
if (file.getSize() > 1024 * 1024) {
return Result.error("文件大小不能超过 1MB");
}
// 校验文件格式
String originalFilename = file.getOriginalFilename();
String ext = getFileExtension(originalFilename);
if (!isAllowedImageType(ext)) {
return Result.error("仅支持 png、jpg、jpeg、gif、webp、ico 格式的图片");
}
// 校验 Icon 归属
Icon icon = iconRepository.findByIdAndUserId(id, user.getId());
if (icon == null) {
return Result.error("Icon 不存在或无权访问");
}
// 记录旧的 logoUrl用于后续删除
String oldLogoUrl = icon.getLogoUrl();
// 上传新图片到 MinIO
String contentType = file.getContentType();
if (contentType == null || contentType.isEmpty()) {
contentType = guessContentType(ext);
}
String objectName = "icons/" + user.getId() + "/" + System.currentTimeMillis() + "." + ext;
String uploadedObjectName = MinioUtil.uploadFile(objectName, file.getBytes(), contentType);
if (uploadedObjectName == null) {
return Result.error("文件上传失败,请重试");
}
// 获取新图片的访问 URL
String newLogoUrl = MinioUtil.getFileUrl(uploadedObjectName);
if (newLogoUrl == null) {
return Result.error("获取文件 URL 失败");
}
// 更新数据库中的 logoUrl
iconRepository.updateLogoUrl(id, newLogoUrl);
log.info("图标更新成功,新 URL: {}", newLogoUrl);
// 异步删除旧的 MinIO 图片防止硬盘过满
if (oldLogoUrl != null && !oldLogoUrl.isEmpty()) {
String oldObjectName = extractObjectName(oldLogoUrl);
if (oldObjectName != null) {
boolean deleted = MinioUtil.deleteFile(oldObjectName);
if (deleted) {
log.info("旧图标删除成功: {}", oldObjectName);
} else {
log.warn("旧图标删除失败: {}", oldObjectName);
}
}
}
// 返回更新后的 Icon 信息
Icon updatedIcon = iconRepository.findByIdAndUserId(id, user.getId());
Map<String, Object> result = new LinkedHashMap<>();
result.put("id", updatedIcon.getId());
result.put("name", updatedIcon.getName());
result.put("logoUrl", updatedIcon.getLogoUrl());
result.put("updateTime", updatedIcon.getUpdateTime() != null ? updatedIcon.getUpdateTime().toString() : null);
return Result.success("图标上传成功", result);
} catch (Exception e) {
log.error("用户 [{}] 上传自定义图标失败ID={}", user.getUsername(), id, e);
return Result.error("上传失败:" + e.getMessage());
}
}
// ==================== 私有辅助方法 ====================
/**
* 获取文件扩展名小写
*/
private String getFileExtension(String filename) {
if (filename == null || !filename.contains(".")) {
return "";
}
return filename.substring(filename.lastIndexOf('.') + 1).toLowerCase();
}
/**
* 校验是否为允许的图片格式
*/
private boolean isAllowedImageType(String ext) {
return ext.equals("png") || ext.equals("jpg") || ext.equals("jpeg")
|| ext.equals("gif") || ext.equals("webp") || ext.equals("ico");
}
/**
* 根据扩展名猜测 Content-Type
*/
private String guessContentType(String ext) {
return switch (ext) {
case "png" -> "image/png";
case "jpg", "jpeg" -> "image/jpeg";
case "gif" -> "image/gif";
case "webp" -> "image/webp";
case "ico" -> "image/x-icon";
default -> "application/octet-stream";
};
}
/**
* MinIO 访问 URL 中提取 objectName
*
* 例如http://minio:9000/bucket/icons/1/123456.png -> icons/1/123456.png
*/
private String extractObjectName(String fileUrl) {
if (fileUrl == null || fileUrl.isEmpty()) {
return null;
}
try {
String bucketName = MinioUtil.getBucketName();
String endpoint = MinioUtil.getEndpoint();
if (bucketName == null || endpoint == null) {
return null;
}
// 构建前缀endpoint/bucketName/
String prefix = endpoint + "/" + bucketName + "/";
if (fileUrl.startsWith(prefix)) {
return fileUrl.substring(prefix.length());
}
// 尝试不带 endpoint 的情况如预签名 URL 可能包含查询参数
int bucketIndex = fileUrl.indexOf("/" + bucketName + "/");
if (bucketIndex >= 0) {
String afterBucket = fileUrl.substring(bucketIndex + bucketName.length() + 2);
// 去掉查询参数
int queryIndex = afterBucket.indexOf('?');
if (queryIndex >= 0) {
return afterBucket.substring(0, queryIndex);
}
return afterBucket;
}
return null;
} catch (Exception e) {
log.warn("提取 objectName 失败: {}", fileUrl, e);
return null;
}
}
}

View File

@ -0,0 +1,291 @@
package com.wangpan.listener;
import com.wangpan.common.dal.repository.IconRepository;
import com.wangpan.common.util.MinioUtil;
import io.minio.ListObjectsArgs;
import io.minio.Result;
import io.minio.messages.Item;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
import jakarta.annotation.PostConstruct;
import java.util.HashSet;
import java.util.Set;
import java.util.Timer;
import java.util.TimerTask;
import java.util.concurrent.atomic.AtomicBoolean;
/**
* Icon 图片定时清理器
*
* 功能
* 1. 定时扫描 MinIO icons/ 目录下的所有图片
* 2. 对比数据库中在用的 logoUrl找出无用的图片
* 3. 删除无用图片释放硬盘空间
*
* 调度策略
* - 首次延迟启动后 10 分钟
* - 执行间隔 24 小时执行一次
* - 防止并发同一时间只允许一个清理任务运行
*/
@Component
public class IconCleanupScheduler {
private static final Logger log = LoggerFactory.getLogger(IconCleanupScheduler.class);
private final IconRepository iconRepository;
private Timer timer;
private final AtomicBoolean isRunning = new AtomicBoolean(false);
/** 首次执行延迟(毫秒):启动后 10 分钟 */
private static final long INITIAL_DELAY_MS = 10 * 60 * 1000L;
/** 执行间隔(毫秒):每 24 小时执行一次 */
private static final long PERIOD_MS = 24 * 60 * 60 * 1000L;
/** MinIO 中 icon 图片的前缀路径 */
private static final String ICONS_PREFIX = "icons/";
public IconCleanupScheduler(IconRepository iconRepository) {
this.iconRepository = iconRepository;
}
/**
* 应用启动后自动启动定时任务
*/
@PostConstruct
public void init() {
start();
}
/**
* 启动定时清理任务
*/
public void start() {
if (timer != null) {
log.warn("[IconCleanupScheduler] 清理器已在运行中,跳过重复启动");
return;
}
timer = new Timer("Icon-Cleanup-Scheduler", true); // daemon 线程
timer.scheduleAtFixedRate(new CleanupTask(), INITIAL_DELAY_MS, PERIOD_MS);
log.info("[IconCleanupScheduler] 定时清理任务已启动 | 首次延迟={}分钟 | 间隔={}小时",
INITIAL_DELAY_MS / 60000, PERIOD_MS / 3600000);
}
/**
* 停止定时清理任务
*/
public void stop() {
if (timer != null) {
timer.cancel();
timer = null;
log.info("[IconCleanupScheduler] 定时清理任务已停止");
}
}
/**
* 手动触发一次清理供管理接口调用
*
* @return 清理结果统计
*/
public String manualCleanup() {
return executeCleanup();
}
/**
* 定时任务内部类
*/
private class CleanupTask extends TimerTask {
@Override
public void run() {
executeCleanup();
}
}
/**
* 执行清理逻辑
*
* 流程
* 1. 查询数据库中所有在用的 logoUrl
* 2. 列出 MinIO icons/ 目录下的所有文件
* 3. 对比找出不在数据库中的文件无用文件
* 4. 删除无用文件
*
* @return 清理结果统计字符串
*/
private String executeCleanup() {
// 防止并发执行
if (!isRunning.compareAndSet(false, true)) {
log.warn("[IconCleanupScheduler] 上次清理仍在进行中,跳过本次");
return "上次清理仍在进行中,已跳过";
}
int totalFiles = 0;
int deletedFiles = 0;
int failedFiles = 0;
try {
log.info("[IconCleanupScheduler] 开始执行 Icon 图片清理任务");
// 1 查询数据库中所有在用的 logoUrl
Set<String> inUseObjectNames = getInUseObjectNames();
log.info("[IconCleanupScheduler] 数据库中共有 {} 个在用的 Icon 图片", inUseObjectNames.size());
// 2 列出 MinIO icons/ 目录下的所有文件
Set<String> minioObjectNames = listMinioIcons();
totalFiles = minioObjectNames.size();
log.info("[IconCleanupScheduler] MinIO 中共有 {} 个 Icon 图片文件", totalFiles);
// 3 找出无用文件 MinIO 中但不在数据库中
Set<String> orphanedFiles = new HashSet<>(minioObjectNames);
orphanedFiles.removeAll(inUseObjectNames);
log.info("[IconCleanupScheduler] 发现 {} 个无用文件需要删除", orphanedFiles.size());
// 4 删除无用文件
for (String objectName : orphanedFiles) {
try {
boolean deleted = MinioUtil.deleteFile(objectName);
if (deleted) {
deletedFiles++;
log.debug("[IconCleanupScheduler] 删除无用文件成功: {}", objectName);
} else {
failedFiles++;
log.warn("[IconCleanupScheduler] 删除无用文件失败: {}", objectName);
}
} catch (Exception e) {
failedFiles++;
log.warn("[IconCleanupScheduler] 删除无用文件异常: {}", objectName, e);
}
}
String result = String.format("清理完成 | 总文件数=%d | 删除成功=%d | 删除失败=%d",
totalFiles, deletedFiles, failedFiles);
log.info("[IconCleanupScheduler] {}", result);
return result;
} catch (Exception e) {
log.error("[IconCleanupScheduler] 清理任务执行异常", e);
return "清理任务执行异常: " + e.getMessage();
} finally {
isRunning.set(false);
}
}
/**
* 获取数据库中所有在用的 Icon 图片 objectName 集合
*/
private Set<String> getInUseObjectNames() {
Set<String> objectNames = new HashSet<>();
try {
var logoUrls = iconRepository.findAllLogoUrls();
for (String logoUrl : logoUrls) {
String objectName = extractObjectName(logoUrl);
if (objectName != null && !objectName.isEmpty()) {
objectNames.add(objectName);
}
}
} catch (Exception e) {
log.error("[IconCleanupScheduler] 查询数据库中的 logoUrl 失败", e);
}
return objectNames;
}
/**
* 列出 MinIO icons/ 目录下的所有文件 objectName
*/
private Set<String> listMinioIcons() {
Set<String> objectNames = new HashSet<>();
try {
String bucketName = MinioUtil.getBucketName();
if (bucketName == null) {
log.warn("[IconCleanupScheduler] 无法获取 MinIO bucket 名称");
return objectNames;
}
// 使用 MinIO SDK 列出对象
var client = getMinioClient();
if (client == null) {
log.warn("[IconCleanupScheduler] 无法获取 MinIO 客户端");
return objectNames;
}
var iterable = client.listObjects(
ListObjectsArgs.builder()
.bucket(bucketName)
.prefix(ICONS_PREFIX)
.recursive(true)
.build()
);
for (Result<Item> result : iterable) {
Item item = result.get();
objectNames.add(item.objectName());
}
} catch (Exception e) {
log.error("[IconCleanupScheduler] 列出 MinIO icons 目录失败", e);
}
return objectNames;
}
/**
* 获取 MinIO 客户端实例通过反射访问 MinioUtil 的私有方法
*
* 注意这是一个临时方案后续可以考虑在 MinioUtil 中暴露 getClient() 方法
*/
private io.minio.MinioClient getMinioClient() {
try {
// 通过反射获取 MinioUtil client 字段
var field = MinioUtil.class.getDeclaredField("client");
field.setAccessible(true);
return (io.minio.MinioClient) field.get(null);
} catch (Exception e) {
log.error("[IconCleanupScheduler] 反射获取 MinioClient 失败", e);
return null;
}
}
/**
* MinIO 访问 URL 中提取 objectName
*
* 例如http://minio:9000/bucket/icons/1/123456.png -> icons/1/123456.png
*/
private String extractObjectName(String fileUrl) {
if (fileUrl == null || fileUrl.isEmpty()) {
return null;
}
try {
String bucketName = MinioUtil.getBucketName();
String endpoint = MinioUtil.getEndpoint();
if (bucketName == null || endpoint == null) {
return null;
}
// 构建前缀endpoint/bucketName/
String prefix = endpoint + "/" + bucketName + "/";
if (fileUrl.startsWith(prefix)) {
return fileUrl.substring(prefix.length());
}
// 尝试不带 endpoint 的情况如预签名 URL 可能包含查询参数
int bucketIndex = fileUrl.indexOf("/" + bucketName + "/");
if (bucketIndex >= 0) {
String afterBucket = fileUrl.substring(bucketIndex + bucketName.length() + 2);
// 去掉查询参数
int queryIndex = afterBucket.indexOf('?');
if (queryIndex >= 0) {
return afterBucket.substring(0, queryIndex);
}
return afterBucket;
}
return null;
} catch (Exception e) {
log.warn("[IconCleanupScheduler] 提取 objectName 失败: {}", fileUrl, e);
return null;
}
}
}

View File

@ -5,6 +5,10 @@ server:
spring:
application:
name: wangpan-drive-service
servlet:
multipart:
max-file-size: 10MB
max-request-size: 10MB
datasource:
url: ENC(nJXPBEsg+NwtZt+qnD9A0AwRIdPFwex6kYEfGDetJ+N42ha/dH7V3Lq/hSCYgZa08cocXV5N+AaiisG/gh+i8CcNYjXzFV8CMSRfLwx03cxxNwnDzak+2ZEY5nb8UTbC/a6b3pWuAwg9lnHF9ziroFIrOn8+s/SYN7e+oMh4tZFmfCT4h5ATsEEuB6RkYGTzf0oImBVmet1jRR4CRdHPSeh6jSWa7y1TutJoeBWyDpzHNgqE/JRLd1VNoc583/Bn)
username: ENC(9cCYl0WIh2m3gQhAfq4ykWAm5+PUvr6yPcuGKPwYDPpADnTZKVv9eYhwT806vYti)
@ -21,7 +25,7 @@ minio:
# Jasypt 加密配置
jasypt:
encryptor:
password: ${JASYPT_ENCRYPTOR_PASSWORD:wangpan-secret-key-2024}
password: ${JASYPT_ENCRYPTOR_PASSWORD}
algorithm: PBEWITHHMACSHA512ANDAES_256
key-obtention-iterations: 1000
pool-size: 1
@ -31,7 +35,7 @@ jasypt:
logging:
level:
com.wangpan: DEBUG
com.wangpan: INFO
# MyBatis-Plus 配置
mybatis-plus:
@ -39,7 +43,6 @@ mybatis-plus:
type-aliases-package: com.wangpan.common.dal.dataobject
configuration:
map-underscore-to-camel-case: true
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
global-config:
db-config:
id-type: auto

View File

@ -346,7 +346,9 @@
<tr><th>请求路径</th><th>转发服务</th><th>说明</th></tr>
<tr><td>/api/login, /api/register</td><td>user-service</td><td>登录注册</td></tr>
<tr><td>/api/token/**, /api/login-logs/**</td><td>user-service</td><td>令牌/日志</td></tr>
<tr><td>/api/users/**</td><td>user-service</td><td>用户管理</td></tr>
<tr><td>/api/drive/**</td><td>drive-service</td><td>网盘管理</td></tr>
<tr><td>/api/icon/**</td><td>drive-service</td><td>Icon管理</td></tr>
<tr><td>/api/movies/**</td><td>tmdb-service</td><td>电影管理</td></tr>
<tr><td>/api/tmdb/**, /api/tmdb-scraper/**</td><td>tmdb-service</td><td>TMDB刮削</td></tr>
</table>
@ -941,7 +943,7 @@
<div class="api-header" onclick="toggleDetail(this)">
<span class="method POST">POST</span>
<span class="api-path">/api/drive/{driveId}/movies</span>
<span class="api-desc">上传电影文件</span><span class="auth-badge">需Token</span>
<span class="api-desc">创建电影(只传文件名)</span><span class="auth-badge">需Token</span>
</div>
<div class="api-detail">
<div class="detail-section">
@ -954,31 +956,21 @@
<div class="detail-section">
<h4>请求体</h4>
<div class="code-block">{
<span class="key">"title"</span>: <span class="string">"电影名称"</span>, <span style="color:#718096">// 必填</span>
<span class="key">"folderId"</span>: <span class="number">0</span>, <span style="color:#718096">// 可选默认0根目录</span>
<span class="key">"subtitle"</span>: <span class="string">"副标题"</span>, <span style="color:#718096">// 可选</span>
<span class="key">"description"</span>: <span class="string">"描述"</span>, <span style="color:#718096">// 可选</span>
<span class="key">"fileData"</span>: <span class="string">"Base64编码的文件数据"</span>, <span style="color:#718096">// 必填</span>
<span class="key">"rating"</span>: <span class="number">85</span>, <span style="color:#718096">// 可选评分0-100</span>
<span class="key">"tags"</span>: <span class="string">"标签1,标签2"</span> <span style="color:#718096">// 可选</span>
<span class="key">"title"</span>: <span class="string">"文件名.mp4"</span>, <span style="color:#718096">// 必填,文件名(同时作为电影标题)</span>
<span class="key">"folderId"</span>: <span class="number">0</span> <span style="color:#718096">// 可选默认0根目录</span>
}</div>
</div>
<div class="detail-section">
<h4>参数说明</h4>
<table class="param-table">
<tr><th>参数</th><th>类型</th><th>必填</th><th>说明</th></tr>
<tr><td>title</td><td>String</td><td></td><td>电影名称(同时作为文件名</td></tr>
<tr><td>title</td><td>String</td><td></td><td>文件名(同时作为电影标题,后续通过 TMDB 刮削补充信息</td></tr>
<tr><td>folderId</td><td>Long</td><td></td><td>目标文件夹ID默认0根目录</td></tr>
<tr><td>subtitle</td><td>String</td><td></td><td>副标题</td></tr>
<tr><td>description</td><td>String</td><td></td><td>描述</td></tr>
<tr><td>fileData</td><td>String</td><td></td><td>Base64编码的视频文件数据</td></tr>
<tr><td>rating</td><td>Integer</td><td></td><td>评分</td></tr>
<tr><td>tags</td><td>String</td><td></td><td>标签</td></tr>
</table>
</div>
<div class="detail-section">
<h4>说明</h4>
<p>文件通过 Base64 编码上传,服务端解码后存储到 MinIO返回文件访问 URL。自动检测 MIME 类型mp4/mkv/avi → video/mp4</p>
<p>只记录文件名,不上传文件数据。创建后通过 TMDB 刮削接口补充电影的海报、描述、评分等信息。自动检测 MIME 类型mp4/mkv/avi → video/mp4</p>
</div>
</div>
</div>
@ -1088,9 +1080,10 @@
</table>
</div>
<div class="detail-section">
<h4>请求体(部分更新)</h4>
<h4>请求体(部分更新,所有字段可选</h4>
<div class="code-block">{
<span class="key">"title"</span>: <span class="string">"新电影名称"</span>,
<span class="key">"originalName"</span>: <span class="string">"新文件名.mp4"</span>,
<span class="key">"subtitle"</span>: <span class="string">"新副标题"</span>,
<span class="key">"description"</span>: <span class="string">"新描述"</span>,
<span class="key">"rating"</span>: <span class="number">90</span>,
@ -1098,6 +1091,23 @@
<span class="key">"category"</span>: <span class="string">"新分类"</span>
}</div>
</div>
<div class="detail-section">
<h4>参数说明</h4>
<table class="param-table">
<tr><th>参数</th><th>类型</th><th>说明</th></tr>
<tr><td>title</td><td>String</td><td>电影名称</td></tr>
<tr><td>originalName</td><td>String</td><td>原始文件名(用于 TMDB 刮削有误时修改)</td></tr>
<tr><td>subtitle</td><td>String</td><td>副标题</td></tr>
<tr><td>description</td><td>String</td><td>描述</td></tr>
<tr><td>rating</td><td>Integer</td><td>评分 0-100</td></tr>
<tr><td>tags</td><td>String</td><td>标签</td></tr>
<tr><td>category</td><td>String</td><td>分类</td></tr>
</table>
</div>
<div class="detail-section">
<h4>说明</h4>
<p>用于 TMDB 刮削有误时,用户可自定义修改文件名等信息。所有字段均为可选,只更新传入的字段。</p>
</div>
</div>
</div>
@ -1463,6 +1473,64 @@
</div>
</div>
</div>
<!-- 上传自定义图标 -->
<div class="api-item">
<div class="api-header" onclick="toggleDetail(this)">
<span class="method POST">POST</span>
<span class="api-path">/api/icon/{id}/logo</span>
<span class="api-desc">上传自定义图标图片</span><span class="auth-badge">需Token</span>
</div>
<div class="api-detail">
<div class="detail-section">
<h4>功能说明</h4>
<p>上传自定义图标图片替换自动抓取的 logo。上传后会自动删除旧的 MinIO 图片,防止硬盘空间浪费。系统每 24 小时自动清理无用图片。</p>
</div>
<div class="detail-section">
<h4>路径参数</h4>
<table class="param-table">
<tr><th>参数</th><th>类型</th><th>说明</th></tr>
<tr><td>id</td><td>Long</td><td>Icon ID</td></tr>
</table>
</div>
<div class="detail-section">
<h4>请求格式</h4>
<p><code>multipart/form-data</code></p>
<table class="param-table">
<tr><th>字段</th><th>类型</th><th>必填</th><th>说明</th></tr>
<tr><td>file</td><td>File</td><td></td><td>图片文件png/jpg/jpeg/gif/webp/ico最大 1MB</td></tr>
</table>
</div>
<div class="detail-section">
<h4>请求示例curl</h4>
<div class="code-block">curl -X POST http://localhost:8888/api/icon/1/logo \
-H "Authorization: Bearer &lt;token&gt;" \
-F "file=@/path/to/icon.png"</div>
</div>
<div class="detail-section">
<h4>成功响应200</h4>
<div class="code-block">{
<span class="key">"code"</span>: <span class="number">200</span>,
<span class="key">"message"</span>: <span class="string">"图标上传成功"</span>,
<span class="key">"data"</span>: {
<span class="key">"id"</span>: <span class="number">1</span>,
<span class="key">"name"</span>: <span class="string">"百度"</span>,
<span class="key">"logoUrl"</span>: <span class="string">"http://118.178.238.159:39000/wangpan/icons/2/1718265600000.png"</span>,
<span class="key">"updateTime"</span>: <span class="string">"2026-06-15T10:00:00"</span>
}
}</div>
</div>
<div class="detail-section">
<h4>错误码</h4>
<table class="param-table">
<tr><th>code</th><th>说明</th></tr>
<tr><td>400</td><td>文件为空/格式不支持/超过 1MB</td></tr>
<tr><td>401</td><td>未登录或 Token 无效</td></tr>
<tr><td>500</td><td>Icon 不存在或无权访问/上传失败</td></tr>
</table>
</div>
</div>
</div>
</div>
<!-- TMDB服务 -->
@ -1554,9 +1622,11 @@
<table class="param-table">
<tr><th>参数</th><th>类型</th><th>必填</th><th>说明</th></tr>
<tr><td>keyword</td><td>String</td><td></td><td>搜索关键词</td></tr>
<tr><td>status</td><td>String</td><td></td><td>pending / scraped / failed</td></tr>
<tr><td>status</td><td>String</td><td></td><td>电影状态active正常/ deleted已删除</td></tr>
<tr><td>category</td><td>String</td><td></td><td>分类</td></tr>
<tr><td>scrapeType</td><td>String</td><td></td><td>刮削类型</td></tr>
<tr><td>scrapeType</td><td>String</td><td></td><td>刮削类型tmdb / manual / none</td></tr>
<tr><td>sortBy</td><td>String</td><td></td><td>排序字段(如 createTime、rating、title</td></tr>
<tr><td>sortOrder</td><td>String</td><td></td><td>排序方向asc / desc</td></tr>
<tr><td>pageNo</td><td>int</td><td></td><td>页码,默认 1</td></tr>
<tr><td>pageSize</td><td>int</td><td></td><td>每页条数,默认 20</td></tr>
</table>
@ -1581,8 +1651,19 @@
</div>
<div class="api-detail">
<div class="detail-section">
<h4>请求体</h4>
<div class="code-block">{ <span class="key">"title"</span>: <span class="string">"电影名称"</span> }</div>
<h4>请求体所有字段可选title 必填)</h4>
<div class="code-block">{
<span class="key">"title"</span>: <span class="string">"电影名称"</span>, <span style="color:#718096">// 必填</span>
<span class="key">"subtitle"</span>: <span class="string">"副标题"</span>, <span style="color:#718096">// 可选</span>
<span class="key">"resourceLibrary"</span>: <span class="string">"资源库"</span>, <span style="color:#718096">// 可选</span>
<span class="key">"maintainer"</span>: <span class="string">"维护者"</span>, <span style="color:#718096">// 可选</span>
<span class="key">"status"</span>: <span class="string">"active"</span>, <span style="color:#718096">// 可选active / deleted</span>
<span class="key">"category"</span>: <span class="string">"动作"</span>, <span style="color:#718096">// 可选</span>
<span class="key">"scrapeType"</span>: <span class="string">"tmdb"</span>, <span style="color:#718096">// 可选tmdb / manual / none</span>
<span class="key">"rating"</span>: <span class="number">85</span>, <span style="color:#718096">// 可选0-100</span>
<span class="key">"posterUrl"</span>: <span class="string">"海报URL"</span>, <span style="color:#718096">// 可选</span>
<span class="key">"tags"</span>: <span class="string">"标签1,标签2"</span> <span style="color:#718096">// 可选</span>
}</div>
</div>
</div>
</div>
@ -1593,7 +1674,30 @@
<span class="api-path">/api/movies/{id}</span>
<span class="api-desc">更新电影</span><span class="auth-badge">需Token</span>
</div>
<div class="api-detail"></div>
<div class="api-detail">
<div class="detail-section">
<h4>路径参数</h4>
<table class="param-table">
<tr><th>参数</th><th>类型</th><th>说明</th></tr>
<tr><td>id</td><td>Long</td><td>电影ID</td></tr>
</table>
</div>
<div class="detail-section">
<h4>请求体(部分更新,所有字段可选)</h4>
<div class="code-block">{
<span class="key">"title"</span>: <span class="string">"新电影名称"</span>,
<span class="key">"subtitle"</span>: <span class="string">"新副标题"</span>,
<span class="key">"resourceLibrary"</span>: <span class="string">"新资源库"</span>,
<span class="key">"maintainer"</span>: <span class="string">"新维护者"</span>,
<span class="key">"status"</span>: <span class="string">"active"</span>,
<span class="key">"category"</span>: <span class="string">"新分类"</span>,
<span class="key">"scrapeType"</span>: <span class="string">"tmdb"</span>,
<span class="key">"rating"</span>: <span class="number">90</span>,
<span class="key">"posterUrl"</span>: <span class="string">"新海报URL"</span>,
<span class="key">"tags"</span>: <span class="string">"新标签"</span>
}</div>
</div>
</div>
</div>
<div class="api-item">
@ -1625,7 +1729,23 @@
<span class="api-path">/api/movies/stats</span>
<span class="api-desc">电影统计</span><span class="auth-badge">需Token</span>
</div>
<div class="api-detail"></div>
<div class="api-detail">
<div class="detail-section">
<h4>功能说明</h4>
<p>获取当前用户的电影数量统计,按刮削类型和分类分组。</p>
</div>
<div class="detail-section">
<h4>响应示例</h4>
<div class="code-block">{
<span class="key">"code"</span>: <span class="number">200</span>,
<span class="key">"data"</span>: {
<span class="key">"totalCount"</span>: <span class="number">10</span>,
<span class="key">"scrapedCount"</span>: <span class="number">8</span>,
<span class="key">"pendingCount"</span>: <span class="number">2</span>
}
}</div>
</div>
</div>
</div>
<div class="api-item">
@ -1659,9 +1779,25 @@
</div>
<div class="api-detail">
<div class="detail-section">
<h4>请求体</h4>
<h4>请求方式</h4>
<p>支持两种方式传递 movieId</p>
<p>1. <strong>URL 查询参数</strong><code>?movieId=1</code></p>
<p>2. <strong>请求体 JSON</strong></p>
<div class="code-block">{ <span class="key">"movieId"</span>: <span class="number">1</span> }</div>
</div>
<div class="detail-section">
<h4>响应示例</h4>
<div class="code-block">{
<span class="key">"code"</span>: <span class="number">200</span>,
<span class="key">"message"</span>: <span class="string">"刮削完成"</span>,
<span class="key">"data"</span>: {
<span class="key">"movieId"</span>: <span class="number">1</span>,
<span class="key">"title"</span>: <span class="string">"电影名称"</span>,
<span class="key">"rating"</span>: <span class="number">85</span>,
<span class="key">"posterUrl"</span>: <span class="string">"海报URL"</span>
}
}</div>
</div>
</div>
</div>
@ -1673,9 +1809,20 @@
</div>
<div class="api-detail">
<div class="detail-section">
<h4>请求体</h4>
<div class="code-block">{ <span class="key">"movieIds"</span>: [<span class="number">1</span>, <span class="number">2</span>, <span class="number">3</span>] }</div>
<h4>请求体movieIds 可选)</h4>
<div class="code-block">{ <span class="key">"movieIds"</span>: [<span class="number">1</span>, <span class="number">2</span>, <span class="number">3</span>] } <span style="color:#718096">// 可选:不传则自动查找需要刮削的电影</span></div>
</div>
<div class="detail-section">
<h4>响应示例</h4>
<div class="code-block">{
<span class="key">"code"</span>: <span class="number">200</span>,
<span class="key">"message"</span>: <span class="string">"批量刮削完成"</span>,
<span class="key">"data"</span>: {
<span class="key">"successCount"</span>: <span class="number">2</span>,
<span class="key">"failCount"</span>: <span class="number">0</span>,
<span class="key">"total"</span>: <span class="number">2</span>
}
}</div>
</div>
</div>
@ -1703,7 +1850,20 @@
<span class="api-path">/api/tmdb-scraper/stats</span>
<span class="api-desc">刮削统计</span><span class="auth-badge">需Token</span>
</div>
<div class="api-detail"></div>
<div class="api-detail">
<div class="detail-section">
<h4>响应示例</h4>
<div class="code-block">{
<span class="key">"code"</span>: <span class="number">200</span>,
<span class="key">"data"</span>: {
<span class="key">"totalCaches"</span>: <span class="number">5</span>,
<span class="key">"activeCaches"</span>: <span class="number">3</span>,
<span class="key">"expiredCaches"</span>: <span class="number">2</span>,
<span class="key">"userId"</span>: <span class="number">2</span>
}
}</div>
</div>
</div>
</div>
<div class="api-item">
@ -1712,7 +1872,24 @@
<span class="api-path">/api/tmdb-scraper/trigger</span>
<span class="api-desc">手动触发定时任务</span><span class="auth-badge">需Token</span>
</div>
<div class="api-detail"></div>
<div class="api-detail">
<div class="detail-section">
<h4>功能说明</h4>
<p>手动触发 TMDB 定时扫描任务,查找需要刮削的电影并执行刮削。无需请求体。</p>
</div>
<div class="detail-section">
<h4>响应示例</h4>
<div class="code-block">{
<span class="key">"code"</span>: <span class="number">200</span>,
<span class="key">"message"</span>: <span class="string">"扫描任务已触发"</span>,
<span class="key">"data"</span>: {
<span class="key">"message"</span>: <span class="string">"扫描任务已触发"</span>,
<span class="key">"triggeredBy"</span>: <span class="number">2</span>,
<span class="key">"triggerType"</span>: <span class="string">"manual"</span>
}
}</div>
</div>
</div>
</div>
<div class="api-item">
@ -1730,6 +1907,26 @@
<tr><td>pageSize</td><td>20</td><td>每页条数</td></tr>
</table>
</div>
<div class="detail-section">
<h4>响应示例</h4>
<div class="code-block">{
<span class="key">"code"</span>: <span class="number">200</span>,
<span class="key">"data"</span>: {
<span class="key">"total"</span>: <span class="number">10</span>,
<span class="key">"list"</span>: [
{
<span class="key">"id"</span>: <span class="number">1</span>,
<span class="key">"scanTime"</span>: <span class="string">"2026-06-18T10:00:00"</span>,
<span class="key">"totalMovies"</span>: <span class="number">5</span>,
<span class="key">"successCount"</span>: <span class="number">4</span>,
<span class="key">"failCount"</span>: <span class="number">1</span>
}
],
<span class="key">"pageNo"</span>: <span class="number">1</span>,
<span class="key">"pageSize"</span>: <span class="number">20</span>
}
}</div>
</div>
</div>
</div>
@ -1739,7 +1936,27 @@
<span class="api-path">/api/tmdb-scraper/cache/{movieId}</span>
<span class="api-desc">删除缓存</span><span class="auth-badge">需Token</span>
</div>
<div class="api-detail"></div>
<div class="api-detail">
<div class="detail-section">
<h4>路径参数</h4>
<table class="param-table">
<tr><th>参数</th><th>类型</th><th>说明</th></tr>
<tr><td>movieId</td><td>Long</td><td>电影ID</td></tr>
</table>
</div>
<div class="detail-section">
<h4>功能说明</h4>
<p>删除指定电影的 TMDB 刮削缓存,下次刮削时将重新从 TMDB 获取数据。</p>
</div>
<div class="detail-section">
<h4>响应示例</h4>
<div class="code-block">{
<span class="key">"code"</span>: <span class="number">200</span>,
<span class="key">"message"</span>: <span class="string">"缓存删除成功"</span>,
<span class="key">"data"</span>: <span class="keyword">null</span>
}</div>
</div>
</div>
</div>
</div>

View File

@ -5,6 +5,8 @@ server:
spring:
application:
name: wangpan-gateway
codec:
max-in-memory-size: 10MB
cloud:
gateway:
routes:
@ -33,10 +35,15 @@ spring:
# Jasypt 加密配置
jasypt:
encryptor:
password: ${JASYPT_ENCRYPTOR_PASSWORD:wangpan-secret-key-2024}
algorithm: PBEWithMD5AndDES
password: ${JASYPT_ENCRYPTOR_PASSWORD}
algorithm: PBEWITHHMACSHA512ANDAES_256
key-obtention-iterations: 1000
pool-size: 1
salt-generator-classname: org.jasypt.salt.RandomSaltGenerator
iv-generator-classname: org.jasypt.iv.RandomIvGenerator
string-output-type: base64
logging:
level:
com.wangpan: DEBUG
org.springframework.cloud.gateway: DEBUG
com.wangpan: INFO
org.springframework.cloud.gateway: INFO

View File

@ -1,6 +1,5 @@
package com.wangpan.config;
import com.wangpan.common.dal.mapper.MovieMapper;
import com.wangpan.common.dal.mapper.TmdbCacheMapper;
import com.wangpan.common.dal.mapper.TmdbScraperLogMapper;
import com.wangpan.common.util.DBUtil;
@ -74,11 +73,10 @@ public class DatabaseInitializer {
}
// 创建TMDB相关表使用各 Mapper 中定义的建表SQL
SchemaUtil.createTableIfNotExists(conn, "wp_movie", MovieMapper.CREATE_TABLE_SQL);
// wp_drive_movie 表由 drive-service DatabaseInitializer 负责创建此处不再重复创建
SchemaUtil.createTableIfNotExists(conn, "wp_tmdb_cache", TmdbCacheMapper.CREATE_TABLE_SQL);
SchemaUtil.createTableIfNotExists(conn, "wp_tmdb_scraper_log", TmdbScraperLogMapper.CREATE_TABLE_SQL);
System.out.println(" ✓ [wp_movie] 表创建完成");
System.out.println(" ✓ [wp_tmdb_cache] 表创建完成");
System.out.println(" ✓ [wp_tmdb_scraper_log] 表创建完成");
}

View File

@ -112,9 +112,9 @@ public class TmdbScraperService {
* 5. 获取电影详情
* 6. 下载海报图片到本地
* 7. 缓存刮削结果到数据库
* 8. 更新wp_movie表的相关字段
* 8. 更新wp_drive_movie表的相关字段
*
* @param movieId 要刮削的电影IDwp_movie表主键
* @param movieId 要刮削的电影IDwp_drive_movie表主键
* @param userId 用户ID用于获取API Key
* @return 刮削结果Map包含successmessagedata等
*/
@ -191,7 +191,7 @@ public class TmdbScraperService {
var cacheData = buildCacheData(detailResult != null ? detailResult : bestMatch, movieId, userId, localPosterPath);
tmdbCacheRepository.saveOrUpdate(cacheData);
// ========== 第9步更新wp_movie表的相关字段 ==========
// ========== 第9步更新wp_drive_movie表的相关字段 ==========
updateMovieFromScrape(movie, cacheData, localPosterPath);
LOGGER.info("[TmdbScraper] 刮削完成: " + cacheData.get("title"));
@ -567,7 +567,7 @@ public class TmdbScraperService {
}
/**
* 根据刮削结果更新wp_movie表的相关字段
* 根据刮削结果更新wp_drive_movie表的相关字段
*/
private void updateMovieFromScrape(Movie movie, Map<String, Object> cacheData, String encryptedPosterUrl) {
// 更新评分如果之前没有或更低
@ -580,7 +580,7 @@ public class TmdbScraperService {
// 更新海报URL优先使用 MinIO 存储的加密 URL
if (encryptedPosterUrl != null) {
// 解密后存储到 wp_movie 前端可直接使用
// 解密后存储到 wp_drive_movie 前端可直接使用
try {
var decryptedUrl = EncryptUtil.decrypt(encryptedPosterUrl);
movieRepository.updatePosterUrl(movie.getId(), decryptedUrl);

View File

@ -21,7 +21,7 @@ minio:
# Jasypt 加密配置
jasypt:
encryptor:
password: ${JASYPT_ENCRYPTOR_PASSWORD:wangpan-secret-key-2024}
password: ${JASYPT_ENCRYPTOR_PASSWORD}
algorithm: PBEWITHHMACSHA512ANDAES_256
key-obtention-iterations: 1000
pool-size: 1
@ -31,7 +31,7 @@ jasypt:
logging:
level:
com.wangpan: DEBUG
com.wangpan: INFO
# MyBatis-Plus 配置
mybatis-plus:
@ -39,7 +39,6 @@ mybatis-plus:
type-aliases-package: com.wangpan.common.dal.dataobject
configuration:
map-underscore-to-camel-case: true
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
global-config:
db-config:
id-type: auto

View File

@ -14,7 +14,7 @@ spring:
# Jasypt 加密配置
jasypt:
encryptor:
password: ${JASYPT_ENCRYPTOR_PASSWORD:wangpan-secret-key-2024}
password: ${JASYPT_ENCRYPTOR_PASSWORD}
algorithm: PBEWITHHMACSHA512ANDAES_256
key-obtention-iterations: 1000
pool-size: 1
@ -24,7 +24,7 @@ jasypt:
logging:
level:
com.wangpan: DEBUG
com.wangpan: INFO
# MyBatis-Plus 配置
mybatis-plus:
@ -32,7 +32,6 @@ mybatis-plus:
type-aliases-package: com.wangpan.common.dal.dataobject
configuration:
map-underscore-to-camel-case: true
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
global-config:
db-config:
id-type: auto