commit f79c193b094e92d78d07ca02551892fe0fbe8a15 Author: liRQ <813144884@qq.com> Date: Mon Jun 15 10:25:06 2026 +0800 Initial commit diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..e69de29 diff --git a/.idea/CoolRequestSetting.xml b/.idea/CoolRequestSetting.xml new file mode 100644 index 0000000..11b39a6 --- /dev/null +++ b/.idea/CoolRequestSetting.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/.idea/compiler.xml b/.idea/compiler.xml new file mode 100644 index 0000000..e8a0534 --- /dev/null +++ b/.idea/compiler.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/encodings.xml b/.idea/encodings.xml new file mode 100644 index 0000000..e119eba --- /dev/null +++ b/.idea/encodings.xml @@ -0,0 +1,12 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml new file mode 100644 index 0000000..8d66637 --- /dev/null +++ b/.idea/inspectionProfiles/Project_Default.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/.idea/jarRepositories.xml b/.idea/jarRepositories.xml new file mode 100644 index 0000000..5a2f139 --- /dev/null +++ b/.idea/jarRepositories.xml @@ -0,0 +1,20 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..41bd1a0 --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,12 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/workspace.xml b/.idea/workspace.xml new file mode 100644 index 0000000..1d03036 --- /dev/null +++ b/.idea/workspace.xml @@ -0,0 +1,152 @@ + + + + + + + + + + + + + { + "associatedIndex": 4 +} + + + + { + "keyToString": { + "Application.Application.executor": "Run", + "ModuleVcsDetector.initialDetectionPerformed": "true", + "RequestMappingsPanelOrder0": "0", + "RequestMappingsPanelOrder1": "1", + "RequestMappingsPanelWidth0": "75", + "RequestMappingsPanelWidth1": "75", + "RunOnceActivity.ShowReadmeOnStart": "true", + "RunOnceActivity.typescript.service.memoryLimit.init": "true", + "Spring Boot.DriveServiceApplication.executor": "Run", + "Spring Boot.GatewayApplication.executor": "Run", + "Spring Boot.TmdbServiceApplication.executor": "Run", + "Spring Boot.UserServiceApplication.executor": "Run", + "kotlin-language-version-configured": "true", + "last_opened_file_path": "D:/java2/wangpan/java", + "nodejs_package_manager_path": "npm", + "project.structure.last.edited": "Project", + "project.structure.proportion": "0.0", + "project.structure.side.proportion": "0.0", + "run.configurations.included.in.services": "true", + "settings.editor.selected.configurable": "preferences.pluginManager", + "vue.rearranger.settings.migration": "true" + } +} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 1780735042508 + + + + + + \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..0be1c0c --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,4 @@ +{ + "java.configuration.updateBuildConfiguration": "automatic", + "java.compile.nullAnalysis.mode": "automatic" +} \ No newline at end of file diff --git a/API.md b/API.md new file mode 100644 index 0000000..0363e98 --- /dev/null +++ b/API.md @@ -0,0 +1,316 @@ +# 网盘系统 API 接口文档 + +## 网关入口 + +**统一访问地址:** `http://localhost:8888` + +所有请求通过网关路由到对应微服务,前端无需关心后端服务拆分。 + +--- + +## 1. 用户认证服务 (wangpan-user-service:8081) + +### 1.1 用户登录 +- **接口:** `POST /api/login` +- **请求体:** +```json +{ + "username": "用户名", + "password": "密码" +} +``` +- **响应:** +```json +{ + "code": 200, + "message": "登录成功", + "data": { + "success": true, + "token": "访问令牌", + "user": { "id": 1, "username": "xxx", ... } + } +} +``` + +### 1.2 用户注册 +- **接口:** `POST /api/register` +- **请求体:** +```json +{ + "username": "用户名", + "password": "密码" +} +``` +- **响应:** 登录成功相同格式 + +### 1.3 令牌验证 +- **接口:** `GET /api/token/validate` +- **请求头:** `Authorization: Bearer ` +- **响应:** 令牌是否有效 + +### 1.4 登录日志查询 +- **接口:** `GET /api/login-logs` +- **参数:** + - `username` (可选) - 按用户名筛选 + - `result` (可选) - 按登录结果筛选 (success/fail) + - `pageNo` (默认 1) - 页码 + - `pageSize` (默认 20) - 每页条数 +- **请求头:** `Authorization: Bearer ` + +--- + +## 2. 网盘管理服务 (wangpan-drive-service:8082) + +### 2.1 创建网盘 +- **接口:** `POST /api/drive` +- **请求体:** +```json +{ + "name": "网盘名称" +} +``` +- **请求头:** `Authorization: Bearer ` + +### 2.2 查询网盘列表 +- **接口:** `GET /api/drive` +- **参数:** + - `pageNo` (默认 1) + - `pageSize` (默认 20) +- **请求头:** `Authorization: Bearer ` + +### 2.3 查询网盘详情 +- **接口:** `GET /api/drive/{id}` +- **请求头:** `Authorization: Bearer ` + +### 2.4 更新网盘 +- **接口:** `PUT /api/drive/{id}` +- **请求体:** +```json +{ + "name": "新网盘名称" +} +``` +- **请求头:** `Authorization: Bearer ` + +### 2.5 删除网盘 +- **接口:** `DELETE /api/drive/{id}` +- **请求头:** `Authorization: Bearer ` + +### 2.6 创建文件夹 +- **接口:** `POST /api/drive/{driveId}/folders` +- **请求体:** +```json +{ + "name": "文件夹名称", + "parentId": 0 +} +``` +- **请求头:** `Authorization: Bearer ` + +### 2.7 查询文件夹列表 +- **接口:** `GET /api/drive/{driveId}/folders` +- **参数:** + - `parentId` (默认 0) - 父文件夹 ID +- **请求头:** `Authorization: Bearer ` + +### 2.8 查询文件夹详情 +- **接口:** `GET /api/drive/{driveId}/folders/{folderId}` +- **请求头:** `Authorization: Bearer ` + +### 2.9 更新文件夹 +- **接口:** `PUT /api/drive/{driveId}/folders/{folderId}` +- **请求体:** +```json +{ + "name": "新文件夹名称" +} +``` +- **请求头:** `Authorization: Bearer ` + +### 2.10 删除文件夹 +- **接口:** `DELETE /api/drive/{driveId}/folders/{folderId}` +- **请求头:** `Authorization: Bearer ` + +### 2.11 创建电影文件 +- **接口:** `POST /api/drive/{driveId}/movies` +- **请求体:** +```json +{ + "title": "电影名称", + "folderId": 0, + "posterUrl": "海报URL", + "overview": "简介", + "releaseDate": "2024-01-01", + "voteAverage": 8.5, + "tmdbId": 12345, + "category": "动作", + "scrapeType": "auto" +} +``` +- **请求头:** `Authorization: Bearer ` + +### 2.12 查询电影文件列表 +- **接口:** `GET /api/drive/{driveId}/movies` +- **参数:** + - `folderId` (默认 0) - 文件夹 ID +- **请求头:** `Authorization: Bearer ` + +### 2.13 查询电影文件详情 +- **接口:** `GET /api/drive/{driveId}/movies/{movieId}` +- **请求头:** `Authorization: Bearer ` + +### 2.14 更新电影文件 +- **接口:** `PUT /api/drive/{driveId}/movies/{movieId}` +- **请求体:** 同创建 +- **请求头:** `Authorization: Bearer ` + +### 2.15 删除电影文件 +- **接口:** `DELETE /api/drive/{driveId}/movies/{movieId}` +- **请求头:** `Authorization: Bearer ` + +--- + +## 3. TMDB 刮削服务 (wangpan-tmdb-service:8083) + +### 3.1 电影列表查询 +- **接口:** `GET /api/movies` +- **参数:** + - `keyword` (可选) - 搜索关键词 + - `status` (可选) - 状态 (pending/scraped/failed) + - `category` (可选) - 分类 + - `scrapeType` (可选) - 刮削类型 + - `pageNo` (默认 1) + - `pageSize` (默认 20) +- **请求头:** `Authorization: Bearer ` + +### 3.2 电影详情查询 +- **接口:** `GET /api/movies/{id}` +- **请求头:** `Authorization: Bearer ` + +### 3.3 创建电影 +- **接口:** `POST /api/movies` +- **请求体:** +```json +{ + "title": "电影名称" +} +``` +- **请求头:** `Authorization: Bearer ` + +### 3.4 更新电影 +- **接口:** `PUT /api/movies/{id}` +- **请求体:** 电影更新字段 +- **请求头:** `Authorization: Bearer ` + +### 3.5 删除电影 +- **接口:** `DELETE /api/movies/{id}` +- **请求头:** `Authorization: Bearer ` + +### 3.6 批量删除电影 +- **接口:** `POST /api/movies/batch-delete` +- **请求体:** +```json +{ + "ids": [1, 2, 3] +} +``` +- **请求头:** `Authorization: Bearer ` + +### 3.7 电影统计 +- **接口:** `GET /api/movies/stats` +- **请求头:** `Authorization: Bearer ` + +### 3.8 TMDB API Key 获取 +- **接口:** `GET /api/tmdb/key` +- **请求头:** `Authorization: Bearer ` + +### 3.9 TMDB API Key 设置 +- **接口:** `POST /api/tmdb/key` +- **请求体:** +```json +{ + "tmdbApiKey": "你的API Key" +} +``` +- **请求头:** `Authorization: Bearer ` + +### 3.10 单部电影刮削 +- **接口:** `POST /api/tmdb-scraper/scrape` +- **请求体:** +```json +{ + "movieId": 1 +} +``` +- **请求头:** `Authorization: Bearer ` + +### 3.11 批量刮削 +- **接口:** `POST /api/tmdb-scraper/batch-scrape` +- **请求体:** +```json +{ + "movieIds": [1, 2, 3] +} +``` +- **请求头:** `Authorization: Bearer ` + +### 3.12 查询缓存列表 +- **接口:** `GET /api/tmdb-scraper/caches` +- **参数:** + - `pageNo` (默认 1) + - `pageSize` (默认 20) +- **请求头:** `Authorization: Bearer ` + +### 3.13 刮削统计 +- **接口:** `GET /api/tmdb-scraper/stats` +- **请求头:** `Authorization: Bearer ` + +### 3.14 手动触发定时任务 +- **接口:** `POST /api/tmdb-scraper/trigger` +- **请求头:** `Authorization: Bearer ` + +### 3.15 查询刮削日志 +- **接口:** `GET /api/tmdb-scraper/scraper-logs` +- **参数:** + - `pageNo` (默认 1) + - `pageSize` (默认 20) +- **请求头:** `Authorization: Bearer ` + +### 3.16 删除缓存 +- **接口:** `DELETE /api/tmdb-scraper/cache/{movieId}` +- **请求头:** `Authorization: Bearer ` + +--- + +## 通用响应格式 + +```json +{ + "code": 200, + "message": "操作结果描述", + "data": { ... } +} +``` + +### 状态码说明 +| 状态码 | 说明 | +|--------|------| +| 200 | 成功 | +| 400 | 请求参数错误 | +| 401 | 未授权/令牌无效 | +| 403 | 权限不足 | +| 404 | 资源不存在 | +| 500 | 服务器内部错误 | + +--- + +## 服务架构说明 + +``` +前端 → Gateway(8888) → 路由转发 → 微服务 + ├── user-service(8081) + ├── drive-service(8082) + └── tmdb-service(8083) +``` + +所有服务通过网关统一路由转发,各服务独立运行。 diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..188cb64 --- /dev/null +++ b/pom.xml @@ -0,0 +1,67 @@ + + + 4.0.0 + + com.wangpan + wangpan-parent + 1.0-SNAPSHOT + pom + + WangPan Parent + 网盘系统 - 微服务架构父 POM + + + + wangpan-common + wangpan-user-service + wangpan-drive-service + wangpan-tmdb-service + wangpan-gateway + + + + + org.springframework.boot + spring-boot-starter-parent + 3.2.5 + + + + + 17 + 17 + UTF-8 + UTF-8 + + + 8.0.33 + 8.5.7 + + + + + + + com.wangpan + wangpan-common + ${project.version} + + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + + 17 + 17 + UTF-8 + + + + + diff --git a/wangpan-common/pom.xml b/wangpan-common/pom.xml new file mode 100644 index 0000000..0044dc0 --- /dev/null +++ b/wangpan-common/pom.xml @@ -0,0 +1,68 @@ + + + 4.0.0 + + + com.wangpan + wangpan-parent + 1.0-SNAPSHOT + + + wangpan-common + jar + + WangPan Common + 公共模块 - 实体类、工具类、通用响应 + + + 3.5.7 + + + + + + org.springframework.boot + spring-boot-starter-web + provided + + + + + com.baomidou + mybatis-plus-spring-boot3-starter + ${mybatis-plus.version} + + + + + com.mysql + mysql-connector-j + + + + + org.jasypt + jasypt + 1.9.3 + + + + + io.minio + minio + ${minio.version} + + + + + com.fasterxml.jackson.core + jackson-databind + + + com.fasterxml.jackson.datatype + jackson-datatype-jsr310 + + + diff --git a/wangpan-common/src/main/java/com/wangpan/common/Result.java b/wangpan-common/src/main/java/com/wangpan/common/Result.java new file mode 100644 index 0000000..dca133c --- /dev/null +++ b/wangpan-common/src/main/java/com/wangpan/common/Result.java @@ -0,0 +1,242 @@ +package com.wangpan.common; + +import java.io.Serializable; + +/** + * 统一API响应结果类 + * 功能:封装所有API接口的返回数据格式 + * + * 设计原则: + * - 统一返回格式,便于前端解析 + * - 包含状态码、消息、数据和时间戳 + * - 支持泛型,可返回任意类型的数据 + * + * 前端使用示例: + * { + * "code": 200, + * "message": "操作成功", + * "data": {...}, + * "timestamp": 1686000000000 + * } + */ +public class Result implements Serializable { + + // 序列化版本号 + private static final long serialVersionUID = 1L; + + // ==================== 响应状态码常量 ==================== + + /** 操作成功 */ + public static final int CODE_SUCCESS = 200; + + /** 参数错误(400)- 请求参数缺失或格式错误 */ + public static final int CODE_BAD_REQUEST = 400; + + /** 未授权(401)- 用户未登录或token无效 */ + public static final int CODE_UNAUTHORIZED = 401; + + /** 禁止访问(403)- 无权限或账户被禁用 */ + public static final int CODE_FORBIDDEN = 403; + + /** 资源不存在(404)- 用户不存在等 */ + public static final int CODE_NOT_FOUND = 404; + + /** 请求过于频繁(429)- 登录失败次数过多被限流 */ + public static final int CODE_TOO_MANY_REQUESTS = 429; + + /** 服务器内部错误(500)- 系统异常 */ + public static final int CODE_SERVER_ERROR = 500; + + // ==================== 属性字段 ==================== + + /** 响应状态码:200成功,其他为失败 */ + private int code; + + /** 响应消息:描述操作结果的文字信息 */ + private String message; + + /** 响应数据:具体的业务数据对象 */ + private T data; + + /** 时间戳:服务器处理请求的时间 */ + private long timestamp; + + // ==================== 构造方法 ==================== + + /** + * 私有构造方法:强制使用静态工厂方法创建实例 + */ + private Result() { + this.timestamp = System.currentTimeMillis(); + } + + /** + * 带参数的私有构造方法 + * + * @param code 状态码 + * @param message 消息 + * @param data 数据 + */ + private Result(int code, String message, T data) { + this.code = code; + this.message = message; + this.data = data; + this.timestamp = System.currentTimeMillis(); + } + + // ==================== 静态工厂方法(推荐使用) ==================== + + /** + * 创建成功响应(无数据) + * + * @return 成功的Result对象 + */ + public static Result success() { + return new Result<>(CODE_SUCCESS, "操作成功", null); + } + + /** + * 创建成功响应(带消息) + * + * @param message 成功消息 + * @return 成功的Result对象 + */ + public static Result success(String message) { + return new Result<>(CODE_SUCCESS, message, null); + } + + /** + * 创建成功响应(带数据) + * 最常用的成功响应方法 + * + * @param data 要返回的数据对象 + * @return 包含数据的成功Result对象 + */ + public static Result success(T data) { + return new Result<>(CODE_SUCCESS, "操作成功", data); + } + + /** + * 创建成功响应(带消息和数据) + * + * @param message 成功消息 + * @param data 返回的数据 + * @return Result对象 + */ + public static Result success(String message, T data) { + return new Result<>(CODE_SUCCESS, message, data); + } + + /** + * 创建失败响应(仅包含错误消息) + * + * @param message 错误信息 + * @return 失败的Result对象 + */ + public static Result error(String message) { + return new Result<>(CODE_SERVER_ERROR, message, null); + } + + /** + * 创建失败响应(指定状态码和消息) + * + * @param code 错误状态码 + * @param message 错误信息 + * @return 失败的Result对象 + */ + public static Result error(int code, String message) { + return new Result<>(code, message, null); + } + + // ==================== 快捷失败方法 ==================== + + /** + * 参数错误响应(400) + * 用于参数校验失败的场景 + * + * @param message 错误详情 + * @return 400状态码的Result + */ + public static Result badRequest(String message) { + return new Result<>(CODE_BAD_REQUEST, message, null); + } + + /** + * 未授权响应(401) + * 用于用户未登录或认证失败的场景 + * + * @param message 错误详情 + * @return 401状态码的Result + */ + public static Result unauthorized(String message) { + return new Result<>(CODE_UNAUTHORIZED, message, null); + } + + /** + * 禁止访问响应(403) + * 用于权限不足或账户被禁用的场景 + * + * @param message 错误详情 + * @return 403状态码的Result + */ + public static Result forbidden(String message) { + return new Result<>(CODE_FORBIDDEN, message, null); + } + + /** + * 资源不存在响应(404) + * 用于查询的用户不存在等场景 + * + * @param message 错误详情 + * @return 404状态码的Result + */ + public static Result notFound(String message) { + return new Result<>(CODE_NOT_FOUND, message, null); + } + + /** + * 请求频繁响应(429) + * 用于登录失败次数过多被限流的场景 + * + * @param message 错误详情 + * @return 429状态码的Result + */ + public static Result tooManyRequests(String message) { + return new Result<>(CODE_TOO_MANY_REQUESTS, message, null); + } + + // ==================== Getter和Setter方法 ==================== + + public int getCode() { return code; } + public void setCode(int code) { this.code = code; } + + public String getMessage() { return message; } + public void setMessage(String message) { this.message = message; } + + public T getData() { return data; } + public void setData(T data) { this.data = data; } + + public long getTimestamp() { return timestamp; } + public void setTimestamp(long timestamp) { this.timestamp = timestamp; } + + // ==================== 判断方法 ==================== + + /** + * 判断是否为成功响应 + * + * @return true-成功,false-失败 + */ + public boolean isSuccess() { + return code == CODE_SUCCESS; + } + + @Override + public String toString() { + return "Result{" + + "code=" + code + + ", message='" + message + '\'' + + ", data=" + data + + ", timestamp=" + timestamp + + '}'; + } +} diff --git a/wangpan-common/src/main/java/com/wangpan/common/annotation/RequireAdmin.java b/wangpan-common/src/main/java/com/wangpan/common/annotation/RequireAdmin.java new file mode 100644 index 0000000..875e88d --- /dev/null +++ b/wangpan-common/src/main/java/com/wangpan/common/annotation/RequireAdmin.java @@ -0,0 +1,15 @@ +package com.wangpan.common.annotation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * 标记需要管理员权限的接口 + * 配合 AuthInterceptor 使用,在 RequireAuth 基础上额外校验管理员身份 + */ +@Target({ElementType.METHOD, ElementType.TYPE}) +@Retention(RetentionPolicy.RUNTIME) +public @interface RequireAdmin { +} \ No newline at end of file diff --git a/wangpan-common/src/main/java/com/wangpan/common/annotation/RequireAuth.java b/wangpan-common/src/main/java/com/wangpan/common/annotation/RequireAuth.java new file mode 100644 index 0000000..875f9d8 --- /dev/null +++ b/wangpan-common/src/main/java/com/wangpan/common/annotation/RequireAuth.java @@ -0,0 +1,15 @@ +package com.wangpan.common.annotation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * 标记需要用户认证的接口 + * 配合 AuthInterceptor 使用,自动校验 Token 并将用户信息注入请求属性 + */ +@Target({ElementType.METHOD, ElementType.TYPE}) +@Retention(RetentionPolicy.RUNTIME) +public @interface RequireAuth { +} \ No newline at end of file diff --git a/wangpan-common/src/main/java/com/wangpan/common/config/WebMvcConfig.java b/wangpan-common/src/main/java/com/wangpan/common/config/WebMvcConfig.java new file mode 100644 index 0000000..d39b266 --- /dev/null +++ b/wangpan-common/src/main/java/com/wangpan/common/config/WebMvcConfig.java @@ -0,0 +1,31 @@ +package com.wangpan.common.config; + +import com.wangpan.common.interceptor.AuthInterceptor; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.InterceptorRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +/** + * Web MVC 配置 + * 注册 AuthInterceptor 拦截器,统一处理认证和权限校验 + */ +@Configuration +public class WebMvcConfig implements WebMvcConfigurer { + + private final AuthInterceptor authInterceptor; + + public WebMvcConfig(AuthInterceptor authInterceptor) { + this.authInterceptor = authInterceptor; + } + + @Override + public void addInterceptors(InterceptorRegistry registry) { + // 注册认证拦截器,拦截所有请求 + registry.addInterceptor(authInterceptor) + .addPathPatterns("/**") + .excludePathPatterns( + "/api/login", // 登录接口 + "/api/register" // 注册接口 + ); + } +} \ No newline at end of file diff --git a/wangpan-common/src/main/java/com/wangpan/common/dal/dataobject/DriveDO.java b/wangpan-common/src/main/java/com/wangpan/common/dal/dataobject/DriveDO.java new file mode 100644 index 0000000..ced9feb --- /dev/null +++ b/wangpan-common/src/main/java/com/wangpan/common/dal/dataobject/DriveDO.java @@ -0,0 +1,96 @@ +package com.wangpan.common.dal.dataobject; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableLogic; +import com.baomidou.mybatisplus.annotation.TableName; + +import java.io.Serializable; +import java.time.LocalDateTime; + +/** + * 网盘数据对象(Data Object) + * 对应数据库表:wp_drive + * + * 设计说明: + * - DO 字段名使用 Java 驼峰命名,MyBatis-Plus 自动映射为数据库 snake_case 列 + * - DO 仅负责数据映射,不包含业务逻辑 + * - 与 Entity 分离:Entity 面向业务层,DO 面向数据层 + */ +@TableName(value = "wp_drive", autoResultMap = true) +public class DriveDO implements Serializable { + + private static final long serialVersionUID = 1L; + + /** 网盘ID:主键,自增长 */ + @TableId(type = IdType.AUTO) + private Long id; + + /** 所属用户ID */ + private Long userId; + + /** 网盘名称 */ + private String name; + + /** 网盘描述 */ + private String description; + + /** 网盘 Logo URL(MinIO 存储链接) */ + private String logoUrl; + + /** 文件夹数量 */ + private Integer folderCount; + + /** 文件数量 */ + private Integer fileCount; + + /** 总大小(字节) */ + private Long totalSize; + + /** 状态:active / disabled */ + private String status; + + /** 更新时间 */ + @TableField(update = "NOW()") + private LocalDateTime updateTime; + + /** 创建时间 */ + @TableField(fill = com.baomidou.mybatisplus.annotation.FieldFill.INSERT) + private LocalDateTime createTime; + + // ==================== Getter 和 Setter ==================== + + public Long getId() { return id; } + public void setId(Long id) { this.id = id; } + + public Long getUserId() { return userId; } + public void setUserId(Long userId) { this.userId = userId; } + + public String getName() { return name; } + public void setName(String name) { this.name = name; } + + public String getDescription() { return description; } + public void setDescription(String description) { this.description = description; } + + public String getLogoUrl() { return logoUrl; } + public void setLogoUrl(String logoUrl) { this.logoUrl = logoUrl; } + + public Integer getFolderCount() { return folderCount; } + public void setFolderCount(Integer folderCount) { this.folderCount = folderCount; } + + public Integer getFileCount() { return fileCount; } + public void setFileCount(Integer fileCount) { this.fileCount = fileCount; } + + public Long getTotalSize() { return totalSize; } + public void setTotalSize(Long totalSize) { this.totalSize = totalSize; } + + public String getStatus() { return status; } + public void setStatus(String status) { this.status = status; } + + public LocalDateTime getUpdateTime() { return updateTime; } + public void setUpdateTime(LocalDateTime updateTime) { this.updateTime = updateTime; } + + public LocalDateTime getCreateTime() { return createTime; } + public void setCreateTime(LocalDateTime createTime) { this.createTime = createTime; } +} diff --git a/wangpan-common/src/main/java/com/wangpan/common/dal/dataobject/DriveFolderDO.java b/wangpan-common/src/main/java/com/wangpan/common/dal/dataobject/DriveFolderDO.java new file mode 100644 index 0000000..70ab7ea --- /dev/null +++ b/wangpan-common/src/main/java/com/wangpan/common/dal/dataobject/DriveFolderDO.java @@ -0,0 +1,106 @@ +package com.wangpan.common.dal.dataobject; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; + +import java.io.Serializable; +import java.time.LocalDateTime; + +/** + * 网盘文件夹数据对象(Data Object) + * 对应数据库表:wp_drive_folder + * + * 设计说明: + * - DO 字段名使用 Java 驼峰命名,MyBatis-Plus 自动映射为数据库 snake_case 列 + * - DO 仅负责数据映射,不包含业务逻辑 + */ +@TableName(value = "wp_drive_folder", autoResultMap = true) +public class DriveFolderDO implements Serializable { + + private static final long serialVersionUID = 1L; + + /** 文件夹ID:主键,自增长 */ + @TableId(type = IdType.AUTO) + private Long id; + + /** 所属网盘ID */ + private Long driveId; + + /** 所属用户ID */ + private Long userId; + + /** 父文件夹ID(0=根目录) */ + private Long parentId; + + /** 文件夹名称 */ + private String name; + + /** 文件夹描述 */ + private String description; + + /** 排序权重 */ + private Integer sortOrder; + + /** 层级深度(根目录为0) */ + private Integer depth; + + /** 子文件夹数量 */ + private Integer childFolderCount; + + /** 文件数量 */ + private Integer fileCount; + + /** 总大小(字节) */ + private Long totalSize; + + /** 更新时间 */ + @TableField(update = "NOW()") + private LocalDateTime updateTime; + + /** 创建时间 */ + @TableField(fill = com.baomidou.mybatisplus.annotation.FieldFill.INSERT) + private LocalDateTime createTime; + + // ==================== Getter 和 Setter ==================== + + public Long getId() { return id; } + public void setId(Long id) { this.id = id; } + + public Long getDriveId() { return driveId; } + public void setDriveId(Long driveId) { this.driveId = driveId; } + + public Long getUserId() { return userId; } + public void setUserId(Long userId) { this.userId = userId; } + + public Long getParentId() { return parentId; } + public void setParentId(Long parentId) { this.parentId = parentId; } + + public String getName() { return name; } + public void setName(String name) { this.name = name; } + + public String getDescription() { return description; } + public void setDescription(String description) { this.description = description; } + + public Integer getSortOrder() { return sortOrder; } + public void setSortOrder(Integer sortOrder) { this.sortOrder = sortOrder; } + + public Integer getDepth() { return depth; } + public void setDepth(Integer depth) { this.depth = depth; } + + public Integer getChildFolderCount() { return childFolderCount; } + public void setChildFolderCount(Integer childFolderCount) { this.childFolderCount = childFolderCount; } + + public Integer getFileCount() { return fileCount; } + public void setFileCount(Integer fileCount) { this.fileCount = fileCount; } + + public Long getTotalSize() { return totalSize; } + public void setTotalSize(Long totalSize) { this.totalSize = totalSize; } + + public LocalDateTime getUpdateTime() { return updateTime; } + public void setUpdateTime(LocalDateTime updateTime) { this.updateTime = updateTime; } + + public LocalDateTime getCreateTime() { return createTime; } + public void setCreateTime(LocalDateTime createTime) { this.createTime = createTime; } +} diff --git a/wangpan-common/src/main/java/com/wangpan/common/dal/dataobject/DriveMovieDO.java b/wangpan-common/src/main/java/com/wangpan/common/dal/dataobject/DriveMovieDO.java new file mode 100644 index 0000000..acc5199 --- /dev/null +++ b/wangpan-common/src/main/java/com/wangpan/common/dal/dataobject/DriveMovieDO.java @@ -0,0 +1,167 @@ +package com.wangpan.common.dal.dataobject; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; + +import java.io.Serializable; +import java.time.LocalDateTime; + +/** + * 网盘电影数据对象(Data Object) + * 对应数据库表:wp_drive_movie + * + * 设计说明: + * - DO 字段名使用 Java 驼峰命名,MyBatis-Plus 自动映射为数据库 snake_case 列 + * - DO 仅负责数据映射,不包含业务逻辑 + * - minioUrl 和 posterUrl 在数据库中为加密存储,DO 中存储的是解密后的明文 + */ +@TableName(value = "wp_drive_movie", autoResultMap = true) +public class DriveMovieDO implements Serializable { + + private static final long serialVersionUID = 1L; + + /** 电影ID:主键,自增长 */ + @TableId(type = IdType.AUTO) + private Long id; + + /** 所属网盘ID */ + private Long driveId; + + /** 所属文件夹ID(0=根目录) */ + private Long folderId; + + /** 所属用户ID */ + private Long userId; + + /** 电影标题 */ + private String title; + + /** 电影副标题 */ + private String subtitle; + + /** 原始文件名 */ + private String originalName; + + /** MinIO 对象名称 */ + private String minioObjectName; + + /** MinIO 访问 URL(加密存储,DO 中为解密后的明文) */ + private String minioUrl; + + /** MIME 类型 */ + private String mimeType; + + /** 文件大小(字节) */ + private Long fileSize; + + /** 文件扩展名 */ + private String extension; + + /** 电影描述 */ + private String description; + + /** 资源库来源 */ + private String resourceLibrary; + + /** 维护者 */ + private String maintainer; + + /** 状态:active / deleted */ + private String status; + + /** 分类 */ + private String category; + + /** 刮削类型:tmdb / manual / none */ + private String scrapeType; + + /** 评分(0-100) */ + private Integer rating; + + /** 海报 URL(加密存储,DO 中为解密后的明文) */ + private String posterUrl; + + /** 标签(逗号分隔) */ + private String tags; + + /** 更新时间 */ + @TableField(update = "NOW()") + private LocalDateTime updateTime; + + /** 创建时间 */ + @TableField(fill = com.baomidou.mybatisplus.annotation.FieldFill.INSERT) + private LocalDateTime createTime; + + // ==================== Getter 和 Setter ==================== + + public Long getId() { return id; } + public void setId(Long id) { this.id = id; } + + public Long getDriveId() { return driveId; } + public void setDriveId(Long driveId) { this.driveId = driveId; } + + public Long getFolderId() { return folderId; } + public void setFolderId(Long folderId) { this.folderId = folderId; } + + public Long getUserId() { return userId; } + public void setUserId(Long userId) { this.userId = userId; } + + public String getTitle() { return title; } + public void setTitle(String title) { this.title = title; } + + public String getSubtitle() { return subtitle; } + public void setSubtitle(String subtitle) { this.subtitle = subtitle; } + + public String getOriginalName() { return originalName; } + public void setOriginalName(String originalName) { this.originalName = originalName; } + + public String getMinioObjectName() { return minioObjectName; } + public void setMinioObjectName(String minioObjectName) { this.minioObjectName = minioObjectName; } + + public String getMinioUrl() { return minioUrl; } + public void setMinioUrl(String minioUrl) { this.minioUrl = minioUrl; } + + public String getMimeType() { return mimeType; } + public void setMimeType(String mimeType) { this.mimeType = mimeType; } + + public Long getFileSize() { return fileSize; } + public void setFileSize(Long fileSize) { this.fileSize = fileSize; } + + public String getExtension() { return extension; } + public void setExtension(String extension) { this.extension = extension; } + + public String getDescription() { return description; } + public void setDescription(String description) { this.description = description; } + + public String getResourceLibrary() { return resourceLibrary; } + public void setResourceLibrary(String resourceLibrary) { this.resourceLibrary = resourceLibrary; } + + public String getMaintainer() { return maintainer; } + public void setMaintainer(String maintainer) { this.maintainer = maintainer; } + + public String getStatus() { return status; } + public void setStatus(String status) { this.status = status; } + + public String getCategory() { return category; } + public void setCategory(String category) { this.category = category; } + + public String getScrapeType() { return scrapeType; } + public void setScrapeType(String scrapeType) { this.scrapeType = scrapeType; } + + public Integer getRating() { return rating; } + public void setRating(Integer rating) { this.rating = rating; } + + public String getPosterUrl() { return posterUrl; } + public void setPosterUrl(String posterUrl) { this.posterUrl = posterUrl; } + + public String getTags() { return tags; } + public void setTags(String tags) { this.tags = tags; } + + public LocalDateTime getUpdateTime() { return updateTime; } + public void setUpdateTime(LocalDateTime updateTime) { this.updateTime = updateTime; } + + public LocalDateTime getCreateTime() { return createTime; } + public void setCreateTime(LocalDateTime createTime) { this.createTime = createTime; } +} diff --git a/wangpan-common/src/main/java/com/wangpan/common/dal/dataobject/IconDO.java b/wangpan-common/src/main/java/com/wangpan/common/dal/dataobject/IconDO.java new file mode 100644 index 0000000..c714e95 --- /dev/null +++ b/wangpan-common/src/main/java/com/wangpan/common/dal/dataobject/IconDO.java @@ -0,0 +1,159 @@ +package com.wangpan.common.dal.dataobject; + +import com.baomidou.mybatisplus.annotation.FieldFill; +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import java.io.Serializable; +import java.time.LocalDateTime; + +/** + * Icon 数据对象(Data Object) + * 对应数据库表:wp_icon + * + * 设计说明: + * - DO 字段名使用 Java 驼峰命名,MyBatis-Plus 自动映射为数据库 snake_case 列 + * - DO 仅负责数据映射,不包含业务逻辑 + * - 与 Entity 分离:Entity 面向业务层,DO 面向数据层 + */ +@TableName("wp_icon") +public class IconDO implements Serializable { + + private static final long serialVersionUID = 1L; + + /** Icon ID:主键,自增长 */ + @TableId(type = IdType.AUTO) + private Long id; + + /** 所属用户ID */ + private Long userId; + + /** Icon 名称 */ + private String name; + + /** Icon 链接(外网 URL) */ + private String url; + + /** 内网链接 URL */ + private String internalUrl; + + /** 链接类型:external(仅外网)/ internal(仅内网)/ both(内外网) */ + private String linkType; + + /** Icon 描述 */ + private String description; + + /** 排序值(数字越小越靠前) */ + private Integer sortOrder; + + /** 状态:active / disabled */ + private String status; + + /** Logo 图片 URL(MinIO 存储) */ + private String logoUrl; + + /** 创建时间 */ + private LocalDateTime createTime; + + /** 更新时间 */ + private LocalDateTime updateTime; + + // ==================== Getter 和 Setter ==================== + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public Long getUserId() { + return userId; + } + + public void setUserId(Long userId) { + this.userId = userId; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getUrl() { + return url; + } + + public void setUrl(String url) { + this.url = url; + } + + public String getInternalUrl() { + return internalUrl; + } + + public void setInternalUrl(String internalUrl) { + this.internalUrl = internalUrl; + } + + public String getLinkType() { + return linkType; + } + + public void setLinkType(String linkType) { + this.linkType = linkType; + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } + + public Integer getSortOrder() { + return sortOrder; + } + + public void setSortOrder(Integer sortOrder) { + this.sortOrder = sortOrder; + } + + public String getStatus() { + return status; + } + + public void setStatus(String status) { + this.status = status; + } + + public String getLogoUrl() { + return logoUrl; + } + + public void setLogoUrl(String logoUrl) { + this.logoUrl = logoUrl; + } + + public LocalDateTime getCreateTime() { + return createTime; + } + + public void setCreateTime(LocalDateTime createTime) { + this.createTime = createTime; + } + + public LocalDateTime getUpdateTime() { + return updateTime; + } + + public void setUpdateTime(LocalDateTime updateTime) { + this.updateTime = updateTime; + } +} diff --git a/wangpan-common/src/main/java/com/wangpan/common/dal/dataobject/LoginLogDO.java b/wangpan-common/src/main/java/com/wangpan/common/dal/dataobject/LoginLogDO.java new file mode 100644 index 0000000..c305966 --- /dev/null +++ b/wangpan-common/src/main/java/com/wangpan/common/dal/dataobject/LoginLogDO.java @@ -0,0 +1,76 @@ +package com.wangpan.common.dal.dataobject; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; + +import java.io.Serializable; +import java.time.LocalDateTime; + +/** + * 登录日志数据对象(Data Object) + * 对应数据库表:wp_login_log + * + * 设计说明: + * - DO 字段名使用 Java 驼峰命名,MyBatis-Plus 自动映射为数据库 snake_case 列 + * - DO 仅负责数据映射,不包含业务逻辑 + * - 日志是只追加的审计记录,不提供 UPDATE/DELETE 操作 + */ +@TableName(value = "wp_login_log", autoResultMap = true) +public class LoginLogDO implements Serializable { + + private static final long serialVersionUID = 1L; + + /** 记录ID */ + @TableId(type = IdType.AUTO) + private Long id; + + /** 用户ID(可能为null,如用户名不存在时) */ + private Long userId; + + /** 登录输入的用户名 */ + private String loginUsername; + + /** 客户端IP */ + private String loginIp; + + /** 登录结果:success / fail */ + private String loginResult; + + /** 失败原因(成功时为null) */ + private String failReason; + + /** 浏览器UA信息 */ + private String userAgent; + + /** 登录时间 */ + @TableField(fill = com.baomidou.mybatisplus.annotation.FieldFill.INSERT) + private LocalDateTime loginTime; + + // ==================== Getter 和 Setter ==================== + + public Long getId() { return id; } + public void setId(Long id) { this.id = id; } + + public Long getUserId() { return userId; } + public void setUserId(Long userId) { this.userId = userId; } + + public String getLoginUsername() { return loginUsername; } + public void setLoginUsername(String loginUsername) { this.loginUsername = loginUsername; } + + public String getLoginIp() { return loginIp; } + public void setLoginIp(String loginIp) { this.loginIp = loginIp; } + + public String getLoginResult() { return loginResult; } + public void setLoginResult(String loginResult) { this.loginResult = loginResult; } + + public String getFailReason() { return failReason; } + public void setFailReason(String failReason) { this.failReason = failReason; } + + public String getUserAgent() { return userAgent; } + public void setUserAgent(String userAgent) { this.userAgent = userAgent; } + + public LocalDateTime getLoginTime() { return loginTime; } + public void setLoginTime(LocalDateTime loginTime) { this.loginTime = loginTime; } +} diff --git a/wangpan-common/src/main/java/com/wangpan/common/dal/dataobject/MovieDO.java b/wangpan-common/src/main/java/com/wangpan/common/dal/dataobject/MovieDO.java new file mode 100644 index 0000000..08137dc --- /dev/null +++ b/wangpan-common/src/main/java/com/wangpan/common/dal/dataobject/MovieDO.java @@ -0,0 +1,112 @@ +package com.wangpan.common.dal.dataobject; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; + +import java.io.Serializable; +import java.time.LocalDateTime; + +/** + * 电影数据对象(Data Object) + * 对应数据库表:wp_movie + * + * 设计说明: + * - DO 字段名使用 Java 驼峰命名,MyBatis-Plus 自动映射为数据库 snake_case 列 + * - DO 仅负责数据映射,不包含业务逻辑 + */ +@TableName(value = "wp_movie", autoResultMap = true) +public class MovieDO implements Serializable { + + private static final long serialVersionUID = 1L; + + /** 电影ID:主键,自增长 */ + @TableId(type = IdType.AUTO) + private Long id; + + /** 所属用户ID */ + private Long userId; + + /** 电影标题 */ + private String title; + + /** 副标题 */ + private String subtitle; + + /** 资源库名称 */ + private String resourceLibrary; + + /** 维护人/来源 */ + private String maintainer; + + /** 状态:pending_review / reviewed / published / archived */ + private String status; + + /** 分类 */ + private String category; + + /** 刮削类型:auto / manual / none */ + private String scrapeType; + + /** 评分(0-100) */ + private Integer rating; + + /** 海报图片URL */ + private String posterUrl; + + /** 标签(逗号分隔) */ + private String tags; + + /** 更新时间 */ + @TableField(update = "NOW()") + private LocalDateTime updateTime; + + /** 创建时间 */ + @TableField(fill = com.baomidou.mybatisplus.annotation.FieldFill.INSERT) + private LocalDateTime createTime; + + // ==================== Getter 和 Setter ==================== + + public Long getId() { return id; } + public void setId(Long id) { this.id = id; } + + public Long getUserId() { return userId; } + public void setUserId(Long userId) { this.userId = userId; } + + public String getTitle() { return title; } + public void setTitle(String title) { this.title = title; } + + public String getSubtitle() { return subtitle; } + public void setSubtitle(String subtitle) { this.subtitle = subtitle; } + + public String getResourceLibrary() { return resourceLibrary; } + public void setResourceLibrary(String resourceLibrary) { this.resourceLibrary = resourceLibrary; } + + public String getMaintainer() { return maintainer; } + public void setMaintainer(String maintainer) { this.maintainer = maintainer; } + + public String getStatus() { return status; } + public void setStatus(String status) { this.status = status; } + + public String getCategory() { return category; } + public void setCategory(String category) { this.category = category; } + + public String getScrapeType() { return scrapeType; } + public void setScrapeType(String scrapeType) { this.scrapeType = scrapeType; } + + public Integer getRating() { return rating; } + public void setRating(Integer rating) { this.rating = rating; } + + public String getPosterUrl() { return posterUrl; } + public void setPosterUrl(String posterUrl) { this.posterUrl = posterUrl; } + + public String getTags() { return tags; } + public void setTags(String tags) { this.tags = tags; } + + public LocalDateTime getUpdateTime() { return updateTime; } + public void setUpdateTime(LocalDateTime updateTime) { this.updateTime = updateTime; } + + public LocalDateTime getCreateTime() { return createTime; } + public void setCreateTime(LocalDateTime createTime) { this.createTime = createTime; } +} diff --git a/wangpan-common/src/main/java/com/wangpan/common/dal/dataobject/TmdbCacheDO.java b/wangpan-common/src/main/java/com/wangpan/common/dal/dataobject/TmdbCacheDO.java new file mode 100644 index 0000000..9394fe8 --- /dev/null +++ b/wangpan-common/src/main/java/com/wangpan/common/dal/dataobject/TmdbCacheDO.java @@ -0,0 +1,160 @@ +package com.wangpan.common.dal.dataobject; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; + +import java.io.Serializable; +import java.time.LocalDateTime; + +/** + * TMDB 刮削缓存数据对象(Data Object) + * 对应数据库表:wp_tmdb_cache + * + * 设计说明: + * - DO 字段名使用 Java 驼峰命名,MyBatis-Plus 自动映射为数据库 snake_case 列 + * - DO 仅负责数据映射,不包含业务逻辑 + * - localPosterUrl 在数据库中为加密存储,DO 中存储的是解密后的明文 + */ +@TableName(value = "wp_tmdb_cache", autoResultMap = true) +public class TmdbCacheDO implements Serializable { + + private static final long serialVersionUID = 1L; + + /** 记录ID */ + @TableId(type = IdType.AUTO) + private Long id; + + /** 关联电影ID */ + private Long movieId; + + /** 关联用户ID */ + private Long userId; + + /** TMDB 电影ID */ + private Integer tmdbId; + + /** 电影标题(来自TMDB) */ + private String title; + + /** 原始标题(外文) */ + private String originalTitle; + + /** 剧情简介 */ + private String overview; + + /** 上映日期 */ + private String releaseDate; + + /** TMDB 海报路径 */ + private String posterPath; + + /** TMDB 背景图路径 */ + private String backdropPath; + + /** 本地海报URL(加密存储,DO 中为解密后的明文) */ + private String localPosterUrl; + + /** TMDB 评分(0-100分制) */ + private Integer tmdbRating; + + /** TMDB 投票数 */ + private Integer voteCount; + + /** 片长(分钟) */ + private Integer runtime; + + /** 类型(逗号分隔) */ + private String genres; + + /** 导演(逗号分隔) */ + private String directors; + + /** 主演(逗号分隔) */ + private String casts; + + /** 原始语言 */ + private String originalLanguage; + + /** 制片国家 */ + private String productionCountries; + + /** 缓存状态:active / expired */ + private String status; + + /** 缓存时间 */ + private LocalDateTime cacheTime; + + /** 更新时间 */ + @TableField(update = "NOW()") + private LocalDateTime updateTime; + + // ==================== Getter 和 Setter ==================== + + public Long getId() { return id; } + public void setId(Long id) { this.id = id; } + + public Long getMovieId() { return movieId; } + public void setMovieId(Long movieId) { this.movieId = movieId; } + + public Long getUserId() { return userId; } + public void setUserId(Long userId) { this.userId = userId; } + + public Integer getTmdbId() { return tmdbId; } + public void setTmdbId(Integer tmdbId) { this.tmdbId = tmdbId; } + + public String getTitle() { return title; } + public void setTitle(String title) { this.title = title; } + + public String getOriginalTitle() { return originalTitle; } + public void setOriginalTitle(String originalTitle) { this.originalTitle = originalTitle; } + + public String getOverview() { return overview; } + public void setOverview(String overview) { this.overview = overview; } + + public String getReleaseDate() { return releaseDate; } + public void setReleaseDate(String releaseDate) { this.releaseDate = releaseDate; } + + public String getPosterPath() { return posterPath; } + public void setPosterPath(String posterPath) { this.posterPath = posterPath; } + + public String getBackdropPath() { return backdropPath; } + public void setBackdropPath(String backdropPath) { this.backdropPath = backdropPath; } + + public String getLocalPosterUrl() { return localPosterUrl; } + public void setLocalPosterUrl(String localPosterUrl) { this.localPosterUrl = localPosterUrl; } + + public Integer getTmdbRating() { return tmdbRating; } + public void setTmdbRating(Integer tmdbRating) { this.tmdbRating = tmdbRating; } + + public Integer getVoteCount() { return voteCount; } + public void setVoteCount(Integer voteCount) { this.voteCount = voteCount; } + + public Integer getRuntime() { return runtime; } + public void setRuntime(Integer runtime) { this.runtime = runtime; } + + public String getGenres() { return genres; } + public void setGenres(String genres) { this.genres = genres; } + + public String getDirectors() { return directors; } + public void setDirectors(String directors) { this.directors = directors; } + + public String getCasts() { return casts; } + public void setCasts(String casts) { this.casts = casts; } + + public String getOriginalLanguage() { return originalLanguage; } + public void setOriginalLanguage(String originalLanguage) { this.originalLanguage = originalLanguage; } + + public String getProductionCountries() { return productionCountries; } + public void setProductionCountries(String productionCountries) { this.productionCountries = productionCountries; } + + public String getStatus() { return status; } + public void setStatus(String status) { this.status = status; } + + public LocalDateTime getCacheTime() { return cacheTime; } + public void setCacheTime(LocalDateTime cacheTime) { this.cacheTime = cacheTime; } + + public LocalDateTime getUpdateTime() { return updateTime; } + public void setUpdateTime(LocalDateTime updateTime) { this.updateTime = updateTime; } +} diff --git a/wangpan-common/src/main/java/com/wangpan/common/dal/dataobject/TmdbScraperLogDO.java b/wangpan-common/src/main/java/com/wangpan/common/dal/dataobject/TmdbScraperLogDO.java new file mode 100644 index 0000000..6027dc2 --- /dev/null +++ b/wangpan-common/src/main/java/com/wangpan/common/dal/dataobject/TmdbScraperLogDO.java @@ -0,0 +1,111 @@ +package com.wangpan.common.dal.dataobject; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; + +import java.io.Serializable; +import java.time.LocalDateTime; + +/** + * TMDB 定时刮削执行记录数据对象(Data Object) + * 对应数据库表:wp_tmdb_scraper_log + * + * 设计说明: + * - DO 字段名使用 Java 驼峰命名,MyBatis-Plus 自动映射为数据库 snake_case 列 + * - DO 仅负责数据映射,不包含业务逻辑 + */ +@TableName(value = "wp_tmdb_scraper_log", autoResultMap = true) +public class TmdbScraperLogDO implements Serializable { + + private static final long serialVersionUID = 1L; + + /** 记录ID */ + @TableId(type = IdType.AUTO) + private Long id; + + /** 触发方式:auto=定时自动 / manual=手动触发 */ + private String triggerType; + + /** 执行状态:success / partial / failed / skipped */ + private String status; + + /** 总处理电影数 */ + private Integer totalProcessed; + + /** 成功刮削数 */ + private Integer totalSuccess; + + /** 跳过数 */ + private Integer totalSkipped; + + /** 失败数 */ + private Integer totalFailed; + + /** 涉及用户数 */ + private Integer userCount; + + /** 详细执行结果JSON */ + private String detailJson; + + /** 错误信息 */ + private String errorMessage; + + /** 执行耗时(毫秒) */ + private Long durationMs; + + /** 开始时间 */ + private LocalDateTime startTime; + + /** 结束时间 */ + private LocalDateTime endTime; + + /** 创建时间 */ + @TableField(fill = com.baomidou.mybatisplus.annotation.FieldFill.INSERT) + private LocalDateTime createTime; + + // ==================== Getter 和 Setter ==================== + + public Long getId() { return id; } + public void setId(Long id) { this.id = id; } + + public String getTriggerType() { return triggerType; } + public void setTriggerType(String triggerType) { this.triggerType = triggerType; } + + public String getStatus() { return status; } + public void setStatus(String status) { this.status = status; } + + public Integer getTotalProcessed() { return totalProcessed; } + public void setTotalProcessed(Integer totalProcessed) { this.totalProcessed = totalProcessed; } + + public Integer getTotalSuccess() { return totalSuccess; } + public void setTotalSuccess(Integer totalSuccess) { this.totalSuccess = totalSuccess; } + + public Integer getTotalSkipped() { return totalSkipped; } + public void setTotalSkipped(Integer totalSkipped) { this.totalSkipped = totalSkipped; } + + public Integer getTotalFailed() { return totalFailed; } + public void setTotalFailed(Integer totalFailed) { this.totalFailed = totalFailed; } + + public Integer getUserCount() { return userCount; } + public void setUserCount(Integer userCount) { this.userCount = userCount; } + + public String getDetailJson() { return detailJson; } + public void setDetailJson(String detailJson) { this.detailJson = detailJson; } + + public String getErrorMessage() { return errorMessage; } + public void setErrorMessage(String errorMessage) { this.errorMessage = errorMessage; } + + public Long getDurationMs() { return durationMs; } + public void setDurationMs(Long durationMs) { this.durationMs = durationMs; } + + public LocalDateTime getStartTime() { return startTime; } + public void setStartTime(LocalDateTime startTime) { this.startTime = startTime; } + + public LocalDateTime getEndTime() { return endTime; } + public void setEndTime(LocalDateTime endTime) { this.endTime = endTime; } + + public LocalDateTime getCreateTime() { return createTime; } + public void setCreateTime(LocalDateTime createTime) { this.createTime = createTime; } +} diff --git a/wangpan-common/src/main/java/com/wangpan/common/dal/dataobject/TokenDO.java b/wangpan-common/src/main/java/com/wangpan/common/dal/dataobject/TokenDO.java new file mode 100644 index 0000000..85123a0 --- /dev/null +++ b/wangpan-common/src/main/java/com/wangpan/common/dal/dataobject/TokenDO.java @@ -0,0 +1,76 @@ +package com.wangpan.common.dal.dataobject; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; + +import java.io.Serializable; +import java.time.LocalDateTime; + +/** + * 用户令牌数据对象(Data Object) + * 对应数据库表:wp_user_token + * + * 设计说明: + * - DO 字段名使用 Java 驼峰命名,MyBatis-Plus 自动映射为数据库 snake_case 列 + * - DO 仅负责数据映射,不包含业务逻辑 + * - token 字段在数据库中为加密存储 + */ +@TableName(value = "wp_user_token", autoResultMap = true) +public class TokenDO implements Serializable { + + private static final long serialVersionUID = 1L; + + /** 记录ID */ + @TableId(type = IdType.AUTO) + private Long id; + + /** 关联用户ID */ + private Long userId; + + /** 令牌值(加密存储) */ + private String token; + + /** 设备信息 */ + private String deviceInfo; + + /** IP地址 */ + private String ipAddress; + + /** 过期时间 */ + private LocalDateTime expireTime; + + /** 状态:1=有效, 0=已撤销 */ + private Integer status; + + /** 创建时间 */ + @TableField(fill = com.baomidou.mybatisplus.annotation.FieldFill.INSERT) + private LocalDateTime createTime; + + // ==================== Getter 和 Setter ==================== + + public Long getId() { return id; } + public void setId(Long id) { this.id = id; } + + public Long getUserId() { return userId; } + public void setUserId(Long userId) { this.userId = userId; } + + public String getToken() { return token; } + public void setToken(String token) { this.token = token; } + + public String getDeviceInfo() { return deviceInfo; } + public void setDeviceInfo(String deviceInfo) { this.deviceInfo = deviceInfo; } + + public String getIpAddress() { return ipAddress; } + public void setIpAddress(String ipAddress) { this.ipAddress = ipAddress; } + + public LocalDateTime getExpireTime() { return expireTime; } + public void setExpireTime(LocalDateTime expireTime) { this.expireTime = expireTime; } + + public Integer getStatus() { return status; } + public void setStatus(Integer status) { this.status = status; } + + public LocalDateTime getCreateTime() { return createTime; } + public void setCreateTime(LocalDateTime createTime) { this.createTime = createTime; } +} diff --git a/wangpan-common/src/main/java/com/wangpan/common/dal/dataobject/UserDO.java b/wangpan-common/src/main/java/com/wangpan/common/dal/dataobject/UserDO.java new file mode 100644 index 0000000..66db985 --- /dev/null +++ b/wangpan-common/src/main/java/com/wangpan/common/dal/dataobject/UserDO.java @@ -0,0 +1,139 @@ +package com.wangpan.common.dal.dataobject; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableLogic; +import com.baomidou.mybatisplus.annotation.TableName; + +import java.io.Serializable; +import java.time.LocalDateTime; + +/** + * 用户数据对象(Data Object) + * 对应数据库表:wp_user + * + * 设计说明: + * - DO 字段名使用 Java 驼峰命名,MyBatis-Plus 自动映射为数据库 snake_case 列 + * - DO 仅负责数据映射,不包含业务逻辑 + * - tmdbApiKey 在数据库中为加密存储,DO 中存储的是加密后的密文 + */ +@TableName(value = "wp_user", autoResultMap = true) +public class UserDO implements Serializable { + + private static final long serialVersionUID = 1L; + + /** 用户ID:主键,自增长 */ + @TableId(type = IdType.AUTO) + private Long id; + + /** 用户名 */ + private String username; + + /** 密码(加密存储) */ + private String password; + + /** 邮箱地址 */ + private String email; + + /** 手机号码 */ + private String phone; + + /** 真实姓名 */ + private String realName; + + /** 头像URL */ + private String avatarUrl; + + /** 状态:0=禁用, 1=正常, 2=锁定 */ + private Integer status; + + /** 用户类型:0=普通, 1=VIP, 2=管理员 */ + private Integer userType; + + /** 最后登录IP */ + private String lastLoginIp; + + /** 最后登录时间 */ + private LocalDateTime lastLoginTime; + + /** 锁定截止时间(NULL=未锁定) */ + private LocalDateTime lockUntil; + + /** 连续登录失败次数 */ + private Integer failCount; + + /** TMDB API Key(加密存储) */ + private String tmdbApiKey; + + /** 创建时间 */ + @TableField(fill = com.baomidou.mybatisplus.annotation.FieldFill.INSERT) + private LocalDateTime createTime; + + /** 更新时间 */ + @TableField(update = "NOW()") + private LocalDateTime updateTime; + + /** 逻辑删除标记:0=未删除, 1=已删除 */ + @TableLogic + private Integer isDeleted; + + /** 备注信息 */ + private String remark; + + // ==================== Getter 和 Setter ==================== + + public Long getId() { return id; } + public void setId(Long id) { this.id = id; } + + public String getUsername() { return username; } + public void setUsername(String username) { this.username = username; } + + public String getPassword() { return password; } + public void setPassword(String password) { this.password = password; } + + public String getEmail() { return email; } + public void setEmail(String email) { this.email = email; } + + public String getPhone() { return phone; } + public void setPhone(String phone) { this.phone = phone; } + + public String getRealName() { return realName; } + public void setRealName(String realName) { this.realName = realName; } + + public String getAvatarUrl() { return avatarUrl; } + public void setAvatarUrl(String avatarUrl) { this.avatarUrl = avatarUrl; } + + public Integer getStatus() { return status; } + public void setStatus(Integer status) { this.status = status; } + + public Integer getUserType() { return userType; } + public void setUserType(Integer userType) { this.userType = userType; } + + public String getLastLoginIp() { return lastLoginIp; } + public void setLastLoginIp(String lastLoginIp) { this.lastLoginIp = lastLoginIp; } + + public LocalDateTime getLastLoginTime() { return lastLoginTime; } + public void setLastLoginTime(LocalDateTime lastLoginTime) { this.lastLoginTime = lastLoginTime; } + + public LocalDateTime getLockUntil() { return lockUntil; } + public void setLockUntil(LocalDateTime lockUntil) { this.lockUntil = lockUntil; } + + public Integer getFailCount() { return failCount; } + public void setFailCount(Integer failCount) { this.failCount = failCount; } + + public String getTmdbApiKey() { return tmdbApiKey; } + public void setTmdbApiKey(String tmdbApiKey) { this.tmdbApiKey = tmdbApiKey; } + + public LocalDateTime getCreateTime() { return createTime; } + public void setCreateTime(LocalDateTime createTime) { this.createTime = createTime; } + + public LocalDateTime getUpdateTime() { return updateTime; } + public void setUpdateTime(LocalDateTime updateTime) { this.updateTime = updateTime; } + + public Integer getIsDeleted() { return isDeleted; } + public void setIsDeleted(Integer isDeleted) { this.isDeleted = isDeleted; } + + public String getRemark() { return remark; } + public void setRemark(String remark) { this.remark = remark; } +} diff --git a/wangpan-common/src/main/java/com/wangpan/common/dal/mapper/DriveFolderMapper.java b/wangpan-common/src/main/java/com/wangpan/common/dal/mapper/DriveFolderMapper.java new file mode 100644 index 0000000..52cb5a6 --- /dev/null +++ b/wangpan-common/src/main/java/com/wangpan/common/dal/mapper/DriveFolderMapper.java @@ -0,0 +1,37 @@ +package com.wangpan.common.dal.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.wangpan.common.dal.dataobject.DriveFolderDO; +import org.apache.ibatis.annotations.Mapper; + +/** + * 网盘文件夹 Mapper 接口 + * + * 继承 MyBatis-Plus BaseMapper,自动获得 CRUD 能力 + * 复杂查询方法在 XML 中实现 + */ +@Mapper +public interface DriveFolderMapper extends BaseMapper { + + /** 建表SQL(保留供 DatabaseInitializer 使用) */ + String CREATE_TABLE_SQL = """ + CREATE TABLE IF NOT EXISTS wp_drive_folder ( + id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '文件夹ID', + drive_id BIGINT NOT NULL COMMENT '所属网盘ID', + user_id BIGINT NOT NULL COMMENT '所属用户ID', + parent_id BIGINT NOT NULL DEFAULT 0 COMMENT '父文件夹ID(0=根目录)', + name VARCHAR(200) NOT NULL COMMENT '文件夹名称', + description VARCHAR(500) DEFAULT NULL COMMENT '文件夹描述', + sort_order INT NOT NULL DEFAULT 0 COMMENT '排序权重', + depth INT NOT NULL DEFAULT 0 COMMENT '层级深度', + child_folder_count INT NOT NULL DEFAULT 0 COMMENT '子文件夹数量', + file_count INT NOT NULL DEFAULT 0 COMMENT '文件数量', + total_size BIGINT NOT NULL DEFAULT 0 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_parent_id (drive_id, parent_id) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='网盘文件夹表' + """; +} diff --git a/wangpan-common/src/main/java/com/wangpan/common/dal/mapper/DriveMapper.java b/wangpan-common/src/main/java/com/wangpan/common/dal/mapper/DriveMapper.java new file mode 100644 index 0000000..1529e30 --- /dev/null +++ b/wangpan-common/src/main/java/com/wangpan/common/dal/mapper/DriveMapper.java @@ -0,0 +1,34 @@ +package com.wangpan.common.dal.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.wangpan.common.dal.dataobject.DriveDO; +import org.apache.ibatis.annotations.Mapper; + +/** + * 网盘 Mapper 接口 + * + * 继承 MyBatis-Plus BaseMapper,自动获得 CRUD 能力 + * 复杂查询方法在 XML 中实现 + */ +@Mapper +public interface DriveMapper extends BaseMapper { + + /** 建表SQL(保留供 DatabaseInitializer 使用) */ + String CREATE_TABLE_SQL = """ + CREATE TABLE IF NOT EXISTS wp_drive ( + id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '网盘ID', + user_id BIGINT NOT NULL COMMENT '所属用户ID', + name VARCHAR(100) NOT NULL COMMENT '网盘名称', + description VARCHAR(500) DEFAULT NULL COMMENT '网盘描述', + logo_url TEXT DEFAULT NULL COMMENT '网盘Logo URL(MinIO存储链接)', + folder_count INT NOT NULL DEFAULT 0 COMMENT '文件夹数量', + file_count INT NOT NULL DEFAULT 0 COMMENT '文件数量', + total_size BIGINT NOT NULL DEFAULT 0 COMMENT '总大小(字节)', + status VARCHAR(20) NOT NULL DEFAULT 'active' COMMENT '状态:active/disabled', + update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + INDEX idx_user_id (user_id), + INDEX idx_status (status) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='网盘表' + """; +} diff --git a/wangpan-common/src/main/java/com/wangpan/common/dal/mapper/DriveMovieMapper.java b/wangpan-common/src/main/java/com/wangpan/common/dal/mapper/DriveMovieMapper.java new file mode 100644 index 0000000..457dacb --- /dev/null +++ b/wangpan-common/src/main/java/com/wangpan/common/dal/mapper/DriveMovieMapper.java @@ -0,0 +1,60 @@ +package com.wangpan.common.dal.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.wangpan.common.dal.dataobject.DriveMovieDO; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +import java.util.List; +import java.util.Map; + +/** + * 网盘电影 Mapper 接口 + * + * 继承 MyBatis-Plus BaseMapper,自动获得 CRUD 能力 + * 复杂查询方法在 XML 中实现 + */ +@Mapper +public interface DriveMovieMapper extends BaseMapper { + + /** 建表SQL(保留供 DatabaseInitializer 使用) */ + String CREATE_TABLE_SQL = """ + CREATE TABLE IF NOT EXISTS wp_drive_movie ( + id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '电影ID', + drive_id BIGINT NOT NULL COMMENT '所属网盘ID', + folder_id BIGINT NOT NULL DEFAULT 0 COMMENT '所属文件夹ID(0=根目录)', + 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中实现 + */ + List> findDuplicateMovies(@Param("userId") Long userId); +} diff --git a/wangpan-common/src/main/java/com/wangpan/common/dal/mapper/IconMapper.java b/wangpan-common/src/main/java/com/wangpan/common/dal/mapper/IconMapper.java new file mode 100644 index 0000000..3c0ede9 --- /dev/null +++ b/wangpan-common/src/main/java/com/wangpan/common/dal/mapper/IconMapper.java @@ -0,0 +1,36 @@ +package com.wangpan.common.dal.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.wangpan.common.dal.dataobject.IconDO; +import org.apache.ibatis.annotations.Mapper; + +/** + * Icon Mapper 接口 + * + * 继承 MyBatis-Plus BaseMapper,自动获得 CRUD 能力 + * 复杂查询方法在 XML 中实现 + */ +@Mapper +public interface IconMapper extends BaseMapper { + + /** 建表 SQL(保留供 DatabaseInitializer 使用) */ + String CREATE_TABLE_SQL = """ + CREATE TABLE IF NOT EXISTS wp_icon ( + id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT 'Icon ID', + user_id BIGINT NOT NULL COMMENT '所属用户ID', + name VARCHAR(100) NOT NULL COMMENT 'Icon 名称', + url VARCHAR(500) DEFAULT NULL COMMENT '外网链接 URL', + internal_url VARCHAR(500) DEFAULT NULL COMMENT '内网链接 URL', + link_type VARCHAR(20) NOT NULL DEFAULT 'external' COMMENT '链接类型:external(仅外网)/ internal(仅内网)/ both(内外网)', + description VARCHAR(500) DEFAULT NULL COMMENT 'Icon 描述', + sort_order INT NOT NULL DEFAULT 0 COMMENT '排序值(数字越小越靠前)', + status VARCHAR(20) NOT NULL DEFAULT 'active' COMMENT '状态:active/disabled', + logo_url VARCHAR(500) DEFAULT NULL COMMENT 'Logo 图片 URL(MinIO 存储)', + create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + INDEX idx_user_id (user_id), + INDEX idx_status (status), + INDEX idx_sort_order (sort_order) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='Icon 表' + """; +} diff --git a/wangpan-common/src/main/java/com/wangpan/common/dal/mapper/LoginLogMapper.java b/wangpan-common/src/main/java/com/wangpan/common/dal/mapper/LoginLogMapper.java new file mode 100644 index 0000000..6cd0f47 --- /dev/null +++ b/wangpan-common/src/main/java/com/wangpan/common/dal/mapper/LoginLogMapper.java @@ -0,0 +1,35 @@ +package com.wangpan.common.dal.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.wangpan.common.dal.dataobject.LoginLogDO; +import org.apache.ibatis.annotations.Mapper; + +/** + * 登录日志 Mapper 接口 + * + * 继承 MyBatis-Plus BaseMapper,自动获得 CRUD 能力 + * 复杂查询方法在 XML 中实现 + */ +@Mapper +public interface LoginLogMapper extends BaseMapper { + + /** 建表SQL(保留供 DatabaseInitializer 使用) + * 列名与 LoginLogDO 字段名通过 MyBatis-Plus 驼峰转下划线自动映射: + * loginUsername -> login_username, loginIp -> login_ip, loginResult -> login_result + */ + String CREATE_TABLE_SQL = """ + CREATE TABLE IF NOT EXISTS wp_login_log ( + id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '记录ID', + user_id BIGINT NOT NULL COMMENT '关联用户ID', + login_username VARCHAR(50) NOT NULL COMMENT '登录用户名', + login_ip VARCHAR(50) DEFAULT NULL COMMENT '登录IP地址', + user_agent VARCHAR(500) DEFAULT NULL COMMENT '浏览器User-Agent', + login_result VARCHAR(20) NOT NULL DEFAULT 'success' COMMENT '登录结果:success/fail', + fail_reason VARCHAR(200) DEFAULT NULL COMMENT '失败原因', + login_time DATETIME NOT NULL COMMENT '登录时间', + INDEX idx_user_id (user_id), + INDEX idx_login_time (login_time), + INDEX idx_login_username (login_username) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='登录日志表' + """; +} diff --git a/wangpan-common/src/main/java/com/wangpan/common/dal/mapper/MovieMapper.java b/wangpan-common/src/main/java/com/wangpan/common/dal/mapper/MovieMapper.java new file mode 100644 index 0000000..6e3478b --- /dev/null +++ b/wangpan-common/src/main/java/com/wangpan/common/dal/mapper/MovieMapper.java @@ -0,0 +1,97 @@ +package com.wangpan.common.dal.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.wangpan.common.dal.dataobject.MovieDO; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Update; + +import java.util.List; +import java.util.Map; + +/** + * 电影 Mapper 接口 + * + * 继承 MyBatis-Plus BaseMapper,自动获得 CRUD 能力 + * 复杂查询方法在 XML 中实现 + */ +@Mapper +public interface MovieMapper extends BaseMapper { + + /** 建表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 '所属文件夹ID(0=根目录)', + 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中实现 + */ + List> findDuplicateMovies(@Param("userId") Long userId); + + /** + * 查找所有配置了TMDB API Key的用户(供定时刮削使用) + * 复杂SQL,在XML中实现 + */ + List> findUsersWithTmdbKey(); + + /** + * 查找指定用户需要刮削的电影 + * 复杂SQL,在XML中实现 + */ + List findMoviesNeedingScrape(@Param("userId") Long userId, @Param("expireDays") int expireDays); + + /** + * 更新电影评分 + */ + @Update("UPDATE wp_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}") + int updatePosterUrl(@Param("id") Long id, @Param("posterUrl") String posterUrl); + + /** + * 更新电影标签 + */ + @Update("UPDATE wp_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}") + int updateScrapeType(@Param("id") Long id, @Param("scrapeType") String scrapeType); +} diff --git a/wangpan-common/src/main/java/com/wangpan/common/dal/mapper/TmdbCacheMapper.java b/wangpan-common/src/main/java/com/wangpan/common/dal/mapper/TmdbCacheMapper.java new file mode 100644 index 0000000..ba74b4b --- /dev/null +++ b/wangpan-common/src/main/java/com/wangpan/common/dal/mapper/TmdbCacheMapper.java @@ -0,0 +1,96 @@ +package com.wangpan.common.dal.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.wangpan.common.dal.dataobject.TmdbCacheDO; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; + +/** + * TMDB 刮削缓存 Mapper 接口 + * + * 继承 MyBatis-Plus BaseMapper,自动获得 CRUD 能力 + * 复杂查询方法在 XML 中实现 + */ +@Mapper +public interface TmdbCacheMapper extends BaseMapper { + + /** 建表SQL(保留供 DatabaseInitializer 使用) */ + 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', + user_id BIGINT NOT NULL COMMENT '关联用户ID', + tmdb_id INT DEFAULT NULL COMMENT 'TMDB电影ID', + title VARCHAR(200) NOT NULL COMMENT '电影标题(来自TMDB)', + original_title VARCHAR(200) DEFAULT NULL COMMENT '原始标题(外文)', + overview TEXT DEFAULT NULL COMMENT '剧情简介', + release_date VARCHAR(20) DEFAULT NULL COMMENT '上映日期', + poster_path VARCHAR(300) DEFAULT NULL COMMENT 'TMDB海报路径', + backdrop_path VARCHAR(300) DEFAULT NULL COMMENT 'TMDB背景图路径', + local_poster_url TEXT DEFAULT NULL COMMENT '本地海报URL(加密存储)', + tmdb_rating INT DEFAULT NULL COMMENT 'TMDB评分(0-100分制)', + vote_count INT DEFAULT NULL COMMENT 'TMDB投票数', + runtime INT DEFAULT NULL COMMENT '片长(分钟)', + genres VARCHAR(300) DEFAULT NULL COMMENT '类型(逗号分隔)', + directors VARCHAR(200) DEFAULT NULL COMMENT '导演(逗号分隔)', + casts VARCHAR(500) DEFAULT NULL COMMENT '主演(逗号分隔)', + original_language VARCHAR(10) DEFAULT NULL COMMENT '原始语言', + production_countries VARCHAR(200) DEFAULT NULL COMMENT '制片国家', + status VARCHAR(20) NOT NULL DEFAULT 'active' COMMENT '缓存状态:active/expired', + cache_time DATETIME NOT NULL COMMENT '缓存时间', + update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + UNIQUE KEY uk_movie_user (movie_id, user_id), + INDEX idx_tmdb_id (tmdb_id), + INDEX idx_user_cache (user_id, status), + INDEX idx_cache_time (cache_time), + INDEX idx_status (status) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='TMDB刮削缓存表' + """; + + /** + * 根据电影ID查询有效缓存 + */ + TmdbCacheDO findByMovieId(@Param("movieId") Long movieId); + + /** + * 根据TMDB ID查询有效缓存列表 + */ + List findByTmdbId(@Param("tmdbId") Integer tmdbId); + + /** + * 分页查询用户缓存(含关联电影标题) + * 复杂SQL,在XML中实现 + */ + Map queryByUserId(@Param("userId") Long userId, @Param("pageNo") int pageNo, @Param("pageSize") int pageSize); + + /** + * 查询所有过期的缓存ID列表 + */ + List findExpiredCaches(@Param("expireBefore") LocalDateTime expireBefore); + + /** + * 新增或更新缓存记录(UPSERT) + * 复杂SQL,在XML中实现 + */ + int saveOrUpdate(@Param("data") Map data); + + /** + * 批量将缓存标记为过期 + */ + int markAsExpired(@Param("cacheIds") List cacheIds); + + /** + * 删除指定电影的缓存 + */ + int deleteByMovieId(@Param("movieId") Long movieId); + + /** + * 获取用户的缓存统计信息 + * 复杂SQL,在XML中实现 + */ + Map getStatistics(@Param("userId") Long userId); +} diff --git a/wangpan-common/src/main/java/com/wangpan/common/dal/mapper/TmdbScraperLogMapper.java b/wangpan-common/src/main/java/com/wangpan/common/dal/mapper/TmdbScraperLogMapper.java new file mode 100644 index 0000000..5fe9b5a --- /dev/null +++ b/wangpan-common/src/main/java/com/wangpan/common/dal/mapper/TmdbScraperLogMapper.java @@ -0,0 +1,66 @@ +package com.wangpan.common.dal.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.wangpan.common.dal.dataobject.TmdbScraperLogDO; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +import java.util.List; +import java.util.Map; + +/** + * TMDB 定时刮削执行记录 Mapper 接口 + * + * 继承 MyBatis-Plus BaseMapper,自动获得 CRUD 能力 + * 复杂查询方法在 XML 中实现 + */ +@Mapper +public interface TmdbScraperLogMapper extends BaseMapper { + + /** 建表SQL(保留供 DatabaseInitializer 使用) */ + String CREATE_TABLE_SQL = """ + CREATE TABLE IF NOT EXISTS wp_tmdb_scraper_log ( + id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '记录ID', + trigger_type VARCHAR(20) NOT NULL DEFAULT 'auto' COMMENT '触发方式:auto=定时自动 / manual=手动触发', + status VARCHAR(20) NOT NULL COMMENT '执行状态:success/partial/failed/skipped', + total_processed INT NOT NULL DEFAULT 0 COMMENT '总处理电影数', + total_success INT NOT NULL DEFAULT 0 COMMENT '成功刮削数', + total_skipped INT NOT NULL DEFAULT 0 COMMENT '跳过数', + total_failed INT NOT NULL DEFAULT 0 COMMENT '失败数', + user_count INT NOT NULL DEFAULT 0 COMMENT '涉及用户数', + detail_json TEXT DEFAULT NULL COMMENT '详细执行结果JSON', + error_message TEXT DEFAULT NULL COMMENT '错误信息(如有)', + duration_ms BIGINT NOT NULL DEFAULT 0 COMMENT '执行耗时(毫秒)', + start_time DATETIME NOT NULL COMMENT '开始时间', + end_time DATETIME NOT NULL COMMENT '结束时间', + create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + INDEX idx_trigger_type (trigger_type), + INDEX idx_status (status), + INDEX idx_start_time (start_time) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='TMDB定时刮削执行记录表' + """; + + /** + * 插入一条执行记录 + * 复杂SQL,在XML中实现 + */ + int insertLog(@Param("log") Map log); + + /** + * 分页查询最近的执行记录 + * 复杂SQL,在XML中实现 + */ + Map queryRecent(@Param("pageNo") int pageNo, @Param("pageSize") int pageSize); + + /** + * 查询最近的N条执行记录 + * 复杂SQL,在XML中实现 + */ + List> queryRecentByLimit(@Param("limit") int limit); + + /** + * 根据ID查询单条记录 + * 复杂SQL,在XML中实现 + */ + Map findById(@Param("id") Long id); +} diff --git a/wangpan-common/src/main/java/com/wangpan/common/dal/mapper/TokenMapper.java b/wangpan-common/src/main/java/com/wangpan/common/dal/mapper/TokenMapper.java new file mode 100644 index 0000000..29052e8 --- /dev/null +++ b/wangpan-common/src/main/java/com/wangpan/common/dal/mapper/TokenMapper.java @@ -0,0 +1,72 @@ +package com.wangpan.common.dal.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.wangpan.common.dal.dataobject.TokenDO; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; +import org.apache.ibatis.annotations.Update; + +/** + * 用户令牌 Mapper 接口 + * + * 继承 MyBatis-Plus BaseMapper,自动获得 CRUD 能力 + * 复杂查询方法在 XML 中实现 + */ +@Mapper +public interface TokenMapper extends BaseMapper { + + /** 建表SQL(保留供 DatabaseInitializer 使用) */ + String CREATE_TABLE_SQL = """ + CREATE TABLE IF NOT EXISTS wp_user_token ( + id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '记录ID', + user_id BIGINT NOT NULL COMMENT '关联用户ID', + token VARCHAR(500) NOT NULL COMMENT '令牌值(Jasypt加密存储)', + device_info VARCHAR(200) DEFAULT NULL COMMENT '设备信息(User-Agent摘要)', + ip_address VARCHAR(50) DEFAULT NULL COMMENT '创建时的IP地址', + expire_time DATETIME NOT NULL COMMENT '过期时间', + status TINYINT NOT NULL DEFAULT 1 COMMENT '状态:1-有效,0-已撤销', + create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + INDEX idx_user_id (user_id), + INDEX idx_user_status (user_id, status), + INDEX idx_token (token(100)), + INDEX idx_expire_time (expire_time) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + COMMENT='用户令牌表'"""; + + /** + * 查询用户的有效令牌数量 + */ + @Select("SELECT COUNT(*) FROM wp_user_token WHERE user_id = #{userId} AND status = 1 AND expire_time > NOW()") + int countActiveTokens(@Param("userId") Long userId); + + /** + * 验证令牌是否有效 + */ + @Select("SELECT COUNT(*) FROM wp_user_token WHERE token = #{token} AND status = 1 AND expire_time > NOW()") + boolean isValid(@Param("token") String token); + + /** + * 撤销指定令牌 + */ + @Update("UPDATE wp_user_token SET status = 0 WHERE id = #{tokenId} AND user_id = #{userId}") + int revokeById(@Param("tokenId") Long tokenId, @Param("userId") Long userId); + + /** + * 撤销用户的所有令牌 + */ + @Update("UPDATE wp_user_token SET status = 0 WHERE user_id = #{userId} AND status = 1") + int revokeAllByUserId(@Param("userId") Long userId); + + /** + * 清理过期的已撤销令牌 + */ + @Update("DELETE FROM wp_user_token WHERE status = 0 AND expire_time < DATE_SUB(NOW(), INTERVAL 30 DAY)") + int cleanExpiredRevokedTokens(); + + /** + * 删除用户的所有令牌记录 + */ + @Update("DELETE FROM wp_user_token WHERE user_id = #{userId}") + int deleteByUserId(@Param("userId") Long userId); +} diff --git a/wangpan-common/src/main/java/com/wangpan/common/dal/mapper/UserMapper.java b/wangpan-common/src/main/java/com/wangpan/common/dal/mapper/UserMapper.java new file mode 100644 index 0000000..c5d70e9 --- /dev/null +++ b/wangpan-common/src/main/java/com/wangpan/common/dal/mapper/UserMapper.java @@ -0,0 +1,101 @@ +package com.wangpan.common.dal.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.wangpan.common.dal.dataobject.UserDO; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Update; + +import java.time.LocalDateTime; + +/** + * 用户 Mapper 接口 + * + * 继承 MyBatis-Plus BaseMapper,自动获得 CRUD 能力 + * 复杂查询方法在 XML 中实现 + */ +@Mapper +public interface UserMapper extends BaseMapper { + + /** 建表SQL(保留供 DatabaseInitializer 使用) */ + String CREATE_TABLE_SQL = """ + CREATE TABLE IF NOT EXISTS wp_user ( + id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '用户ID', + username VARCHAR(50) NOT NULL COMMENT '用户名', + password VARCHAR(100) NOT NULL COMMENT '密码(MD5加密)', + email VARCHAR(100) DEFAULT NULL COMMENT '邮箱地址', + phone VARCHAR(20) DEFAULT NULL COMMENT '手机号码', + real_name VARCHAR(50) DEFAULT NULL COMMENT '真实姓名', + avatar_url TEXT DEFAULT NULL COMMENT '头像URL', + status TINYINT NOT NULL DEFAULT 1 COMMENT '状态:0=禁用, 1=正常, 2=锁定', + user_type TINYINT NOT NULL DEFAULT 0 COMMENT '用户类型:0=普通, 1=VIP, 2=管理员', + last_login_ip VARCHAR(50) DEFAULT NULL COMMENT '最后登录IP', + last_login_time DATETIME DEFAULT NULL COMMENT '最后登录时间', + lock_until DATETIME DEFAULT NULL COMMENT '锁定截止时间(NULL=未锁定)', + fail_count INT NOT NULL DEFAULT 0 COMMENT '连续登录失败次数', + tmdb_api_key VARCHAR(500) DEFAULT NULL COMMENT 'TMDB API Key(Jasypt加密存储)', + is_deleted TINYINT NOT NULL DEFAULT 0 COMMENT '逻辑删除:0=未删除, 1=已删除', + remark VARCHAR(500) DEFAULT NULL COMMENT '备注信息', + update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + UNIQUE KEY uk_username (username), + INDEX idx_email (email), + INDEX idx_status (status), + INDEX idx_user_type (user_type) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表' + """; + + /** + * 更新用户的最后登录信息 + */ + @Update("UPDATE wp_user SET last_login_ip = #{loginIp}, last_login_time = #{loginTime}, update_time = NOW() WHERE id = #{userId}") + int updateLastLoginInfo(@Param("userId") Long userId, @Param("loginIp") String loginIp, @Param("loginTime") LocalDateTime loginTime); + + /** + * 原子性增加连续失败次数 + */ + @Update("UPDATE wp_user SET fail_count = fail_count + 1, update_time = NOW() WHERE id = #{userId}") + int incrementFailCount(@Param("userId") Long userId); + + /** + * 重置连续失败次数 + */ + @Update("UPDATE wp_user SET fail_count = 0, update_time = NOW() WHERE id = #{userId}") + int resetFailCount(@Param("userId") Long userId); + + /** + * 设置用户的锁定截止时间 + */ + @Update("UPDATE wp_user SET lock_until = #{lockUntil}, status = 2, update_time = NOW() WHERE id = #{userId}") + int updateLockUntil(@Param("userId") Long userId, @Param("lockUntil") LocalDateTime lockUntil); + + /** + * 清除用户的锁定状态 + */ + @Update("UPDATE wp_user SET lock_until = NULL, status = 1, update_time = NOW() WHERE id = #{userId}") + int clearLockUntil(@Param("userId") Long userId); + + /** + * 更新用户密码 + */ + @Update("UPDATE wp_user SET password = #{password}, update_time = NOW() WHERE id = #{userId}") + int updatePassword(@Param("userId") Long userId, @Param("password") String password); + + /** + * 更新用户状态 + */ + @Update("UPDATE wp_user SET status = #{status}, update_time = NOW() WHERE id = #{userId}") + int updateStatus(@Param("userId") Long userId, @Param("status") int status); + + /** + * 更新用户的TMDB API Key + */ + @Update("UPDATE wp_user SET tmdb_api_key = #{tmdbApiKey}, update_time = NOW() WHERE id = #{userId}") + int updateTmdbApiKey(@Param("userId") Long userId, @Param("tmdbApiKey") String tmdbApiKey); + + /** + * 软删除用户 + */ + @Update("UPDATE wp_user SET is_deleted = 1, update_time = NOW() WHERE id = #{userId} AND is_deleted = 0") + int softDelete(@Param("userId") Long userId); +} diff --git a/wangpan-common/src/main/java/com/wangpan/common/dal/repository/DriveFolderRepository.java b/wangpan-common/src/main/java/com/wangpan/common/dal/repository/DriveFolderRepository.java new file mode 100644 index 0000000..fed55b5 --- /dev/null +++ b/wangpan-common/src/main/java/com/wangpan/common/dal/repository/DriveFolderRepository.java @@ -0,0 +1,223 @@ +package com.wangpan.common.dal.repository; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import com.wangpan.common.dal.dataobject.DriveFolderDO; +import com.wangpan.common.dal.mapper.DriveFolderMapper; +import com.wangpan.common.entity.DriveFolder; +import org.springframework.stereotype.Repository; + +import java.time.LocalDateTime; +import java.util.*; + +/** + * 网盘文件夹 Repository 层(Dao) + * + * 继承 MyBatis-Plus ServiceImpl,自动获得 CRUD 能力 + * 职责: + * 1. 对外提供业务维度的数据访问接口(使用 Entity/Map) + * 2. 使用 MyBatis-Plus 的 LambdaQueryWrapper/LambdaUpdateWrapper 构建查询 + * 3. 处理 Entity ↔ DriveFolderDO 的转换 + */ +@Repository +public class DriveFolderRepository extends ServiceImpl { + + // ==================== 插入 ==================== + + /** + * 创建文件夹 + * 将业务 Entity 转换为 DO 后调用 MyBatis-Plus save 方法插入 + */ + public Long insert(DriveFolder folder) { + var folderDO = toDriveFolderDO(folder); + save(folderDO); + return folderDO.getId(); + } + + // ==================== 查询 ==================== + + /** + * 根据ID查询文件夹(返回 Entity) + * 使用 MyBatis-Plus getById 方法 + */ + public DriveFolder findById(Long id) { + var folderDO = getById(id); + return folderDO != null ? toDriveFolder(folderDO) : null; + } + + /** + * 查询指定网盘下指定父文件夹的子文件夹列表 + * 使用 MyBatis-Plus LambdaQueryWrapper 构建查询条件 + */ + public List> queryByDriveAndParent(Long driveId, Long parentId, Long userId) { + var wrapper = new LambdaQueryWrapper() + .eq(DriveFolderDO::getDriveId, driveId) + .eq(DriveFolderDO::getParentId, parentId) + .eq(DriveFolderDO::getUserId, userId) + .orderByAsc(DriveFolderDO::getSortOrder) + .orderByAsc(DriveFolderDO::getId); + + var folderDOs = list(wrapper); + var list = new ArrayList>(); + for (var folderDO : folderDOs) { + list.add(toMap(folderDO)); + } + return list; + } + + /** + * 统计指定网盘下指定父文件夹的子文件夹数量 + * 使用 MyBatis-Plus count 方法 + */ + public int countByDriveAndParent(Long driveId, Long parentId, Long userId) { + return (int) count(new LambdaQueryWrapper() + .eq(DriveFolderDO::getDriveId, driveId) + .eq(DriveFolderDO::getParentId, parentId) + .eq(DriveFolderDO::getUserId, userId)); + } + + /** + * 统计指定网盘下所有层级的文件夹总数 + */ + public int countByDrive(Long driveId, Long userId) { + return (int) count(new LambdaQueryWrapper() + .eq(DriveFolderDO::getDriveId, driveId) + .eq(DriveFolderDO::getUserId, userId)); + } + + /** + * 查询指定网盘下的所有文件夹(用于树形结构构建) + * 使用 MyBatis-Plus LambdaQueryWrapper 构建查询条件 + */ + public List> queryAllByDrive(Long driveId, Long userId) { + var wrapper = new LambdaQueryWrapper() + .eq(DriveFolderDO::getDriveId, driveId) + .eq(DriveFolderDO::getUserId, userId) + .orderByAsc(DriveFolderDO::getDepth) + .orderByAsc(DriveFolderDO::getSortOrder); + + var folderDOs = list(wrapper); + var list = new ArrayList>(); + for (var folderDO : folderDOs) { + list.add(toMap(folderDO)); + } + return list; + } + + // ==================== 更新 ==================== + + /** + * 更新文件夹基本信息 + * 使用 MyBatis-Plus LambdaUpdateWrapper 构建更新条件 + */ + public boolean update(Long id, String name, String description, Long parentId) { + var wrapper = new LambdaUpdateWrapper() + .eq(DriveFolderDO::getId, id) + .set(DriveFolderDO::getName, name) + .set(DriveFolderDO::getDescription, description) + .set(DriveFolderDO::getParentId, parentId) + .set(DriveFolderDO::getUpdateTime, LocalDateTime.now()); + return update(wrapper); + } + + /** + * 更新文件夹统计信息 + * 使用 MyBatis-Plus LambdaUpdateWrapper 构建更新条件 + */ + public boolean updateStats(Long id, Integer childFolderCount, Integer fileCount, Long totalSize) { + var wrapper = new LambdaUpdateWrapper() + .eq(DriveFolderDO::getId, id) + .set(DriveFolderDO::getChildFolderCount, childFolderCount) + .set(DriveFolderDO::getFileCount, fileCount) + .set(DriveFolderDO::getTotalSize, totalSize) + .set(DriveFolderDO::getUpdateTime, LocalDateTime.now()); + return update(wrapper); + } + + // ==================== 删除 ==================== + + /** + * 删除文件夹(需确保文件夹为空) + * 删除前校验:子文件夹数量和文件数量必须都为0 + */ + public boolean delete(Long id, Long userId) { + // 先查询文件夹是否存在且属于该用户 + var wrapper = new LambdaQueryWrapper() + .eq(DriveFolderDO::getId, id) + .eq(DriveFolderDO::getUserId, userId); + var folderDO = getOne(wrapper, false); + if (folderDO == null) { + return false; + } + // 校验文件夹是否为空 + if (folderDO.getChildFolderCount() > 0 || folderDO.getFileCount() > 0) { + throw new IllegalStateException("文件夹不为空,无法删除(子文件夹:" + folderDO.getChildFolderCount() + + ",文件:" + folderDO.getFileCount() + ")"); + } + return remove(wrapper); + } + + // ==================== Entity ↔ DO 转换 ==================== + + /** + * Entity → DO 转换 + */ + private DriveFolderDO toDriveFolderDO(DriveFolder folder) { + var folderDO = new DriveFolderDO(); + folderDO.setId(folder.getId()); + folderDO.setDriveId(folder.getDriveId()); + folderDO.setUserId(folder.getUserId()); + folderDO.setParentId(folder.getParentId()); + folderDO.setName(folder.getName()); + folderDO.setDescription(folder.getDescription()); + folderDO.setSortOrder(folder.getSortOrder()); + folderDO.setDepth(folder.getDepth()); + folderDO.setChildFolderCount(folder.getChildFolderCount()); + folderDO.setFileCount(folder.getFileCount()); + folderDO.setTotalSize(folder.getTotalSize()); + folderDO.setUpdateTime(folder.getUpdateTime()); + return folderDO; + } + + /** + * DO → Entity 转换 + */ + private DriveFolder toDriveFolder(DriveFolderDO folderDO) { + var folder = new DriveFolder(); + folder.setId(folderDO.getId()); + folder.setDriveId(folderDO.getDriveId()); + folder.setUserId(folderDO.getUserId()); + folder.setParentId(folderDO.getParentId()); + folder.setName(folderDO.getName()); + folder.setDescription(folderDO.getDescription()); + folder.setSortOrder(folderDO.getSortOrder()); + folder.setDepth(folderDO.getDepth()); + folder.setChildFolderCount(folderDO.getChildFolderCount()); + folder.setFileCount(folderDO.getFileCount()); + folder.setTotalSize(folderDO.getTotalSize()); + folder.setUpdateTime(folderDO.getUpdateTime()); + folder.setCreateTime(folderDO.getCreateTime()); + return folder; + } + + /** + * DO → Map 转换(用于 API 返回) + */ + private Map toMap(DriveFolderDO folderDO) { + var map = new LinkedHashMap(); + map.put("id", folderDO.getId()); + map.put("driveId", folderDO.getDriveId()); + map.put("parentId", folderDO.getParentId()); + map.put("name", folderDO.getName()); + map.put("description", folderDO.getDescription()); + map.put("sortOrder", folderDO.getSortOrder()); + map.put("depth", folderDO.getDepth()); + map.put("childFolderCount", folderDO.getChildFolderCount()); + map.put("fileCount", folderDO.getFileCount()); + map.put("totalSize", folderDO.getTotalSize()); + map.put("updateTime", folderDO.getUpdateTime() != null ? folderDO.getUpdateTime().toString() : null); + map.put("createTime", folderDO.getCreateTime() != null ? folderDO.getCreateTime().toString() : null); + return map; + } +} diff --git a/wangpan-common/src/main/java/com/wangpan/common/dal/repository/DriveMovieRepository.java b/wangpan-common/src/main/java/com/wangpan/common/dal/repository/DriveMovieRepository.java new file mode 100644 index 0000000..3b99baf --- /dev/null +++ b/wangpan-common/src/main/java/com/wangpan/common/dal/repository/DriveMovieRepository.java @@ -0,0 +1,315 @@ +package com.wangpan.common.dal.repository; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import com.wangpan.common.dal.dataobject.DriveMovieDO; +import com.wangpan.common.dal.mapper.DriveMovieMapper; +import com.wangpan.common.entity.DriveMovie; +import org.springframework.stereotype.Repository; + +import java.time.LocalDateTime; +import java.util.*; + +/** + * 网盘电影 Repository 层(Dao) + * + * 继承 MyBatis-Plus ServiceImpl,自动获得 CRUD 能力 + * 职责: + * 1. 对外提供业务维度的数据访问接口(使用 Entity/Map) + * 2. 使用 MyBatis-Plus 的 LambdaQueryWrapper/LambdaUpdateWrapper 构建查询 + * 3. 处理 Entity ↔ DriveMovieDO 的转换 + */ +@Repository +public class DriveMovieRepository extends ServiceImpl { + + // ==================== 插入 ==================== + + /** + * 创建电影记录 + * 将业务 Entity 转换为 DO 后调用 MyBatis-Plus save 方法插入 + */ + public Long insert(DriveMovie movie) { + var movieDO = toDriveMovieDO(movie); + save(movieDO); + return movieDO.getId(); + } + + // ==================== 查询 ==================== + + /** + * 根据ID查询电影(返回 Entity) + * 使用 MyBatis-Plus getById 方法 + */ + public DriveMovie findById(Long id) { + var movieDO = getById(id); + return movieDO != null ? toDriveMovie(movieDO) : null; + } + + /** + * 查询指定网盘下指定文件夹的电影列表(分页) + * 使用 MyBatis-Plus LambdaQueryWrapper 构建分页查询 + */ + public List> queryByDriveAndFolder(Long driveId, Long folderId, Long userId, + int pageNo, int pageSize) { + var page = new Page(pageNo, pageSize); + var wrapper = new LambdaQueryWrapper() + .eq(DriveMovieDO::getDriveId, driveId) + .eq(DriveMovieDO::getFolderId, folderId) + .eq(DriveMovieDO::getUserId, userId) + .eq(DriveMovieDO::getStatus, "active") + .orderByDesc(DriveMovieDO::getCreateTime); + + var result = page(page, wrapper); + var list = new ArrayList>(); + for (var movieDO : result.getRecords()) { + list.add(toMap(movieDO)); + } + return list; + } + + /** + * 统计指定网盘下指定文件夹的电影数量 + * 使用 MyBatis-Plus count 方法 + */ + public int countByDriveAndFolder(Long driveId, Long folderId, Long userId) { + return (int) count(new LambdaQueryWrapper() + .eq(DriveMovieDO::getDriveId, driveId) + .eq(DriveMovieDO::getFolderId, folderId) + .eq(DriveMovieDO::getUserId, userId) + .eq(DriveMovieDO::getStatus, "active")); + } + + /** + * 统计指定网盘下所有层级的电影总数 + */ + public int countByDrive(Long driveId, Long userId) { + return (int) count(new LambdaQueryWrapper() + .eq(DriveMovieDO::getDriveId, driveId) + .eq(DriveMovieDO::getUserId, userId) + .eq(DriveMovieDO::getStatus, "active")); + } + + /** + * 查询用户所有网盘下的电影列表(分页) + * 使用 MyBatis-Plus LambdaQueryWrapper 构建分页查询 + */ + public List> queryByUser(Long userId, int pageNo, int pageSize) { + var page = new Page(pageNo, pageSize); + var wrapper = new LambdaQueryWrapper() + .eq(DriveMovieDO::getUserId, userId) + .eq(DriveMovieDO::getStatus, "active") + .orderByDesc(DriveMovieDO::getCreateTime); + + var result = page(page, wrapper); + var list = new ArrayList>(); + for (var movieDO : result.getRecords()) { + list.add(toMap(movieDO)); + } + return list; + } + + /** + * 统计用户所有网盘下的电影数量 + * 使用 MyBatis-Plus count 方法 + */ + public int countByUser(Long userId) { + return (int) count(new LambdaQueryWrapper() + .eq(DriveMovieDO::getUserId, userId) + .eq(DriveMovieDO::getStatus, "active")); + } + + /** + * 按评分排序查询电影列表(分页) + * 使用 MyBatis-Plus LambdaQueryWrapper 构建分页查询 + */ + public List> queryByRating(Long driveId, Long userId, int pageNo, int pageSize) { + var page = new Page(pageNo, pageSize); + var wrapper = new LambdaQueryWrapper() + .eq(DriveMovieDO::getDriveId, driveId) + .eq(DriveMovieDO::getUserId, userId) + .eq(DriveMovieDO::getStatus, "active") + .orderByDesc(DriveMovieDO::getRating); + + var result = page(page, wrapper); + var list = new ArrayList>(); + for (var movieDO : result.getRecords()) { + list.add(toMap(movieDO)); + } + return list; + } + + // ==================== 更新 ==================== + + /** + * 更新电影基本信息 + * 使用 MyBatis-Plus LambdaUpdateWrapper 构建更新条件 + */ + public boolean update(Long id, String title, String subtitle, String description, + Integer rating, String tags, String category) { + var wrapper = new LambdaUpdateWrapper() + .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(DriveMovieDO::getUpdateTime, LocalDateTime.now()); + return update(wrapper); + } + + /** + * 更新电影刮削信息 + * 使用 MyBatis-Plus LambdaUpdateWrapper 构建更新条件 + */ + public boolean updateScrapeInfo(Long id, String scrapeType, String resourceLibrary, + String maintainer, String posterUrl) { + var wrapper = new LambdaUpdateWrapper() + .eq(DriveMovieDO::getId, id) + .set(DriveMovieDO::getScrapeType, scrapeType) + .set(DriveMovieDO::getResourceLibrary, resourceLibrary) + .set(DriveMovieDO::getMaintainer, maintainer) + .set(DriveMovieDO::getPosterUrl, posterUrl) + .set(DriveMovieDO::getUpdateTime, LocalDateTime.now()); + return update(wrapper); + } + + /** + * 移动电影到另一个文件夹 + * 使用 MyBatis-Plus LambdaUpdateWrapper 构建更新条件 + */ + public boolean moveToFolder(Long movieId, Long newFolderId, Long userId) { + var wrapper = new LambdaUpdateWrapper() + .eq(DriveMovieDO::getId, movieId) + .eq(DriveMovieDO::getUserId, userId) + .set(DriveMovieDO::getFolderId, newFolderId) + .set(DriveMovieDO::getUpdateTime, LocalDateTime.now()); + return update(wrapper); + } + + // ==================== 删除 ==================== + + /** + * 逻辑删除电影 + * 使用 MyBatis-Plus LambdaUpdateWrapper 将状态设为 deleted + */ + public boolean logicalDelete(Long id, Long userId) { + var wrapper = new LambdaUpdateWrapper() + .eq(DriveMovieDO::getId, id) + .eq(DriveMovieDO::getUserId, userId) + .set(DriveMovieDO::getStatus, "deleted") + .set(DriveMovieDO::getUpdateTime, LocalDateTime.now()); + return update(wrapper); + } + + /** + * 物理删除电影 + * 使用 MyBatis-Plus remove 方法配合条件构造器 + */ + public boolean delete(Long id, Long userId) { + var wrapper = new LambdaQueryWrapper() + .eq(DriveMovieDO::getId, id) + .eq(DriveMovieDO::getUserId, userId); + return remove(wrapper); + } + + /** + * 查找用户所有网盘下的重复电影(按 title 分组) + * 委托给 Mapper 接口中定义的 XML 查询 + */ + public List> findDuplicateMovies(Long userId) { + return baseMapper.findDuplicateMovies(userId); + } + + // ==================== Entity ↔ DO 转换 ==================== + + /** + * Entity → DO 转换 + */ + private DriveMovieDO toDriveMovieDO(DriveMovie movie) { + var movieDO = new DriveMovieDO(); + movieDO.setId(movie.getId()); + movieDO.setDriveId(movie.getDriveId()); + movieDO.setFolderId(movie.getFolderId()); + movieDO.setUserId(movie.getUserId()); + movieDO.setTitle(movie.getTitle()); + movieDO.setSubtitle(movie.getSubtitle()); + movieDO.setOriginalName(movie.getOriginalName()); + movieDO.setMinioObjectName(movie.getMinioObjectName()); + movieDO.setMinioUrl(movie.getMinioUrl()); + movieDO.setMimeType(movie.getMimeType()); + movieDO.setFileSize(movie.getFileSize()); + movieDO.setExtension(movie.getExtension()); + movieDO.setDescription(movie.getDescription()); + movieDO.setResourceLibrary(movie.getResourceLibrary()); + movieDO.setMaintainer(movie.getMaintainer()); + movieDO.setStatus(movie.getStatus()); + movieDO.setCategory(movie.getCategory()); + movieDO.setScrapeType(movie.getScrapeType()); + movieDO.setRating(movie.getRating()); + movieDO.setPosterUrl(movie.getPosterUrl()); + movieDO.setTags(movie.getTags()); + movieDO.setUpdateTime(movie.getUpdateTime()); + return movieDO; + } + + /** + * DO → Entity 转换 + */ + private DriveMovie toDriveMovie(DriveMovieDO movieDO) { + var movie = new DriveMovie(); + movie.setId(movieDO.getId()); + movie.setDriveId(movieDO.getDriveId()); + movie.setFolderId(movieDO.getFolderId()); + movie.setUserId(movieDO.getUserId()); + movie.setTitle(movieDO.getTitle()); + movie.setSubtitle(movieDO.getSubtitle()); + movie.setOriginalName(movieDO.getOriginalName()); + movie.setMinioObjectName(movieDO.getMinioObjectName()); + movie.setMinioUrl(movieDO.getMinioUrl()); + movie.setMimeType(movieDO.getMimeType()); + movie.setFileSize(movieDO.getFileSize()); + movie.setExtension(movieDO.getExtension()); + movie.setDescription(movieDO.getDescription()); + movie.setResourceLibrary(movieDO.getResourceLibrary()); + movie.setMaintainer(movieDO.getMaintainer()); + movie.setStatus(movieDO.getStatus()); + movie.setCategory(movieDO.getCategory()); + movie.setScrapeType(movieDO.getScrapeType()); + movie.setRating(movieDO.getRating()); + movie.setPosterUrl(movieDO.getPosterUrl()); + movie.setTags(movieDO.getTags()); + movie.setUpdateTime(movieDO.getUpdateTime()); + movie.setCreateTime(movieDO.getCreateTime()); + return movie; + } + + /** + * DO → Map 转换(用于 API 返回) + */ + private Map toMap(DriveMovieDO movieDO) { + var map = new LinkedHashMap(); + map.put("id", movieDO.getId()); + map.put("driveId", movieDO.getDriveId()); + map.put("folderId", movieDO.getFolderId()); + map.put("title", movieDO.getTitle()); + map.put("subtitle", movieDO.getSubtitle()); + map.put("originalName", movieDO.getOriginalName()); + map.put("mimeType", movieDO.getMimeType()); + map.put("fileSize", movieDO.getFileSize()); + map.put("extension", movieDO.getExtension()); + map.put("description", movieDO.getDescription()); + map.put("resourceLibrary", movieDO.getResourceLibrary()); + map.put("maintainer", movieDO.getMaintainer()); + map.put("status", movieDO.getStatus()); + map.put("category", movieDO.getCategory()); + 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("createTime", movieDO.getCreateTime() != null ? movieDO.getCreateTime().toString() : null); + return map; + } +} diff --git a/wangpan-common/src/main/java/com/wangpan/common/dal/repository/DriveRepository.java b/wangpan-common/src/main/java/com/wangpan/common/dal/repository/DriveRepository.java new file mode 100644 index 0000000..76b37ae --- /dev/null +++ b/wangpan-common/src/main/java/com/wangpan/common/dal/repository/DriveRepository.java @@ -0,0 +1,192 @@ +package com.wangpan.common.dal.repository; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import com.wangpan.common.dal.dataobject.DriveDO; +import com.wangpan.common.dal.mapper.DriveMapper; +import com.wangpan.common.entity.Drive; +import org.springframework.stereotype.Repository; + +import java.time.LocalDateTime; +import java.util.*; + +/** + * 网盘 Repository 层(Dao) + * + * 继承 MyBatis-Plus ServiceImpl,自动获得 CRUD 能力 + * 职责: + * 1. 对外提供业务维度的数据访问接口(使用 Entity/Map) + * 2. 使用 MyBatis-Plus 的 LambdaQueryWrapper/LambdaUpdateWrapper 构建查询 + * 3. 处理 Entity ↔ DriveDO 的转换 + */ +@Repository +public class DriveRepository extends ServiceImpl { + + // ==================== 插入 ==================== + + /** + * 创建网盘 + * 将业务 Entity 转换为 DO 后调用 MyBatis-Plus save 方法插入 + */ + public Long insert(Drive drive) { + var driveDO = toDriveDO(drive); + save(driveDO); + return driveDO.getId(); + } + + // ==================== 查询 ==================== + + /** + * 根据ID查询网盘(返回 Entity) + * 使用 MyBatis-Plus getById 方法 + */ + public Drive findById(Long id) { + var driveDO = getById(id); + return driveDO != null ? toDrive(driveDO) : null; + } + + /** + * 查询用户的网盘列表(分页,返回 Map 列表供 API 使用) + * 使用 MyBatis-Plus LambdaQueryWrapper 构建分页查询 + */ + public List> queryByUserId(Long userId, int pageNo, int pageSize) { + // 构建分页查询条件:按用户ID过滤,按创建时间降序排列 + var page = new Page(pageNo, pageSize); + var wrapper = new LambdaQueryWrapper() + .eq(DriveDO::getUserId, userId) + .orderByDesc(DriveDO::getCreateTime); + + var result = page(page, wrapper); + var list = new ArrayList>(); + for (var driveDO : result.getRecords()) { + list.add(toMap(driveDO)); + } + return list; + } + + /** + * 统计用户的网盘数量 + * 使用 MyBatis-Plus count 方法 + */ + public int countByUserId(Long userId) { + return (int) count(new LambdaQueryWrapper().eq(DriveDO::getUserId, userId)); + } + + // ==================== 更新 ==================== + + /** + * 更新网盘基本信息 + * 使用 MyBatis-Plus LambdaUpdateWrapper 构建更新条件 + */ + public boolean update(Long id, String name, String description, String logoUrl) { + var wrapper = new LambdaUpdateWrapper() + .eq(DriveDO::getId, id) + .set(DriveDO::getName, name) + .set(DriveDO::getDescription, description) + .set(DriveDO::getLogoUrl, logoUrl) + .set(DriveDO::getUpdateTime, LocalDateTime.now()); + return update(wrapper); + } + + /** + * 更新网盘统计信息 + * 使用 MyBatis-Plus LambdaUpdateWrapper 构建更新条件 + */ + public boolean updateStats(Long id, Integer folderCount, Integer fileCount, Long totalSize) { + var wrapper = new LambdaUpdateWrapper() + .eq(DriveDO::getId, id) + .set(DriveDO::getFolderCount, folderCount) + .set(DriveDO::getFileCount, fileCount) + .set(DriveDO::getTotalSize, totalSize) + .set(DriveDO::getUpdateTime, LocalDateTime.now()); + return update(wrapper); + } + + /** + * 更新网盘状态 + * 使用 MyBatis-Plus LambdaUpdateWrapper 构建更新条件 + */ + public boolean updateStatus(Long id, String status) { + var wrapper = new LambdaUpdateWrapper() + .eq(DriveDO::getId, id) + .set(DriveDO::getStatus, status) + .set(DriveDO::getUpdateTime, LocalDateTime.now()); + return update(wrapper); + } + + // ==================== 删除 ==================== + + /** + * 删除网盘 + * 使用 MyBatis-Plus removeById 方法 + */ + public boolean delete(Long id, Long userId) { + // 先校验网盘属于该用户,再删除 + var wrapper = new LambdaQueryWrapper() + .eq(DriveDO::getId, id) + .eq(DriveDO::getUserId, userId); + return remove(wrapper); + } + + // ==================== Entity ↔ DO 转换 ==================== + + /** + * Entity → DO 转换 + * 将业务层的 Drive 实体转换为数据层的 DriveDO + */ + private DriveDO toDriveDO(Drive drive) { + var driveDO = new DriveDO(); + driveDO.setId(drive.getId()); + driveDO.setUserId(drive.getUserId()); + driveDO.setName(drive.getName()); + driveDO.setDescription(drive.getDescription()); + driveDO.setLogoUrl(drive.getLogoUrl()); + driveDO.setFolderCount(drive.getFolderCount()); + driveDO.setFileCount(drive.getFileCount()); + driveDO.setTotalSize(drive.getTotalSize()); + driveDO.setStatus(drive.getStatus()); + driveDO.setUpdateTime(drive.getUpdateTime()); + return driveDO; + } + + /** + * DO → Entity 转换 + * 将数据层的 DriveDO 转换为业务层的 Drive 实体 + */ + private Drive toDrive(DriveDO driveDO) { + var drive = new Drive(); + drive.setId(driveDO.getId()); + drive.setUserId(driveDO.getUserId()); + drive.setName(driveDO.getName()); + drive.setDescription(driveDO.getDescription()); + drive.setLogoUrl(driveDO.getLogoUrl()); + drive.setFolderCount(driveDO.getFolderCount()); + drive.setFileCount(driveDO.getFileCount()); + drive.setTotalSize(driveDO.getTotalSize()); + drive.setStatus(driveDO.getStatus()); + drive.setUpdateTime(driveDO.getUpdateTime()); + drive.setCreateTime(driveDO.getCreateTime()); + return drive; + } + + /** + * DO → Map 转换 + * 将 DriveDO 转换为 API 返回用的 Map + */ + private Map toMap(DriveDO driveDO) { + var map = new LinkedHashMap(); + map.put("id", driveDO.getId()); + map.put("name", driveDO.getName()); + map.put("description", driveDO.getDescription()); + map.put("logoUrl", driveDO.getLogoUrl()); + map.put("folderCount", driveDO.getFolderCount()); + map.put("fileCount", driveDO.getFileCount()); + map.put("totalSize", driveDO.getTotalSize()); + map.put("status", driveDO.getStatus()); + map.put("updateTime", driveDO.getUpdateTime() != null ? driveDO.getUpdateTime().toString() : null); + map.put("createTime", driveDO.getCreateTime() != null ? driveDO.getCreateTime().toString() : null); + return map; + } +} diff --git a/wangpan-common/src/main/java/com/wangpan/common/dal/repository/IconRepository.java b/wangpan-common/src/main/java/com/wangpan/common/dal/repository/IconRepository.java new file mode 100644 index 0000000..af7a1d2 --- /dev/null +++ b/wangpan-common/src/main/java/com/wangpan/common/dal/repository/IconRepository.java @@ -0,0 +1,172 @@ +package com.wangpan.common.dal.repository; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import com.wangpan.common.dal.dataobject.IconDO; +import com.wangpan.common.dal.mapper.IconMapper; +import com.wangpan.common.entity.Icon; +import org.springframework.stereotype.Repository; + +import java.time.LocalDateTime; +import java.util.*; + +/** + * Icon Repository 层(Dao) + * + * 继承 MyBatis-Plus ServiceImpl,自动获得 CRUD 能力 + * 职责: + * 1. 对外提供业务维度的数据访问接口(使用 Entity/Map) + * 2. 使用 MyBatis-Plus 的 LambdaQueryWrapper/LambdaUpdateWrapper 构建查询 + * 3. 处理 Entity ↔ IconDO 的转换 + */ +@Repository +public class IconRepository extends ServiceImpl { + + // ==================== 插入 ==================== + + /** + * 创建 Icon + * 将业务 Entity 转换为 DO 后调用 MyBatis-Plus save 方法插入 + */ + public Long insert(Icon icon) { + var iconDO = toIconDO(icon); + save(iconDO); + return iconDO.getId(); + } + + // ==================== 查询 ==================== + + /** + * 根据 ID 查询 Icon(返回 Entity) + */ + public Icon findById(Long id) { + var iconDO = getById(id); + return iconDO != null ? toIcon(iconDO) : null; + } + + /** + * 查询用户的 Icon 列表(返回 Map 列表供 API 使用) + * 按 sort_order 升序、create_time 降序排列 + */ + public List> queryByUserId(Long userId) { + var wrapper = new LambdaQueryWrapper() + .eq(IconDO::getUserId, userId) + .orderByAsc(IconDO::getSortOrder) + .orderByDesc(IconDO::getCreateTime); + + var result = list(wrapper); + var list = new ArrayList>(); + for (var iconDO : result) { + list.add(toMap(iconDO)); + } + return list; + } + + /** + * 根据 ID 和用户 ID 查询 Icon(确保只能访问自己的数据) + */ + public Icon findByIdAndUserId(Long id, Long userId) { + var wrapper = new LambdaQueryWrapper() + .eq(IconDO::getId, id) + .eq(IconDO::getUserId, userId); + var iconDO = getOne(wrapper); + return iconDO != null ? toIcon(iconDO) : null; + } + + // ==================== 更新 ==================== + + /** + * 更新 Icon + */ + public boolean update(Long id, Long userId, String name, String url, String internalUrl, String linkType, String description, Integer sortOrder) { + var wrapper = new LambdaUpdateWrapper() + .eq(IconDO::getId, id) + .eq(IconDO::getUserId, userId) + .set(name != null, IconDO::getName, name) + .set(url != null, IconDO::getUrl, url) + .set(internalUrl != null, IconDO::getInternalUrl, internalUrl) + .set(linkType != null, IconDO::getLinkType, linkType) + .set(description != null, IconDO::getDescription, description) + .set(sortOrder != null, IconDO::getSortOrder, sortOrder) + .set(IconDO::getUpdateTime, LocalDateTime.now()); + return update(wrapper); + } + + /** + * 更新 Icon 的 logoUrl + */ + public boolean updateLogoUrl(Long id, String logoUrl) { + var wrapper = new LambdaUpdateWrapper() + .eq(IconDO::getId, id) + .set(IconDO::getLogoUrl, logoUrl) + .set(IconDO::getUpdateTime, LocalDateTime.now()); + return update(wrapper); + } + + // ==================== 删除 ==================== + + /** + * 删除 Icon(校验用户归属) + */ + public boolean delete(Long id, Long userId) { + var wrapper = new LambdaQueryWrapper() + .eq(IconDO::getId, id) + .eq(IconDO::getUserId, userId); + return remove(wrapper); + } + + // ==================== Entity ↔ DO 转换 ==================== + + private IconDO toIconDO(Icon icon) { + var iconDO = new IconDO(); + iconDO.setId(icon.getId()); + iconDO.setUserId(icon.getUserId()); + iconDO.setName(icon.getName()); + iconDO.setUrl(icon.getUrl()); + iconDO.setInternalUrl(icon.getInternalUrl()); + iconDO.setLinkType(icon.getLinkType() != null ? icon.getLinkType() : "external"); + iconDO.setDescription(icon.getDescription()); + iconDO.setSortOrder(icon.getSortOrder()); + iconDO.setStatus(icon.getStatus()); + iconDO.setLogoUrl(icon.getLogoUrl()); + // 项目未配置 MetaObjectHandler,需手动设置时间字段 + var now = java.time.LocalDateTime.now(); + iconDO.setCreateTime(icon.getCreateTime() != null ? icon.getCreateTime() : now); + iconDO.setUpdateTime(icon.getUpdateTime() != null ? icon.getUpdateTime() : now); + return iconDO; + } + + private Icon toIcon(IconDO iconDO) { + var icon = new Icon(); + icon.setId(iconDO.getId()); + icon.setUserId(iconDO.getUserId()); + icon.setName(iconDO.getName()); + icon.setUrl(iconDO.getUrl()); + icon.setInternalUrl(iconDO.getInternalUrl()); + icon.setLinkType(iconDO.getLinkType()); + icon.setDescription(iconDO.getDescription()); + icon.setSortOrder(iconDO.getSortOrder()); + icon.setStatus(iconDO.getStatus()); + icon.setLogoUrl(iconDO.getLogoUrl()); + icon.setCreateTime(iconDO.getCreateTime()); + icon.setUpdateTime(iconDO.getUpdateTime()); + return icon; + } + + private Map toMap(IconDO iconDO) { + var map = new LinkedHashMap(); + map.put("id", iconDO.getId()); + map.put("name", iconDO.getName()); + map.put("url", iconDO.getUrl()); + map.put("internalUrl", iconDO.getInternalUrl()); + map.put("linkType", iconDO.getLinkType()); + map.put("description", iconDO.getDescription()); + map.put("sortOrder", iconDO.getSortOrder()); + map.put("status", iconDO.getStatus()); + map.put("logoUrl", iconDO.getLogoUrl()); + map.put("createTime", iconDO.getCreateTime() != null ? iconDO.getCreateTime().toString() : null); + map.put("updateTime", iconDO.getUpdateTime() != null ? iconDO.getUpdateTime().toString() : null); + return map; + } +} diff --git a/wangpan-common/src/main/java/com/wangpan/common/dal/repository/LoginLogRepository.java b/wangpan-common/src/main/java/com/wangpan/common/dal/repository/LoginLogRepository.java new file mode 100644 index 0000000..bbd6f8c --- /dev/null +++ b/wangpan-common/src/main/java/com/wangpan/common/dal/repository/LoginLogRepository.java @@ -0,0 +1,140 @@ +package com.wangpan.common.dal.repository; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import com.wangpan.common.dal.dataobject.LoginLogDO; +import com.wangpan.common.dal.mapper.LoginLogMapper; +import org.springframework.stereotype.Repository; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * 登录日志 Repository 层(Dao) + * + * 继承 MyBatis-Plus ServiceImpl,自动获得 CRUD 能力 + * 职责: + * 1. 对外提供业务维度的数据访问接口 + * 2. 使用 MyBatis-Plus 的 LambdaQueryWrapper 构建查询 + * 3. LoginLog 模块没有 Entity,直接使用 DO 和 Map + */ +@Repository +public class LoginLogRepository extends ServiceImpl { + + // ==================== 插入 ==================== + + /** + * 插入一条登录日志 + * 构建 LoginLogDO 后调用 MyBatis-Plus save 方法插入 + * + * @param userId 用户ID(可能为null,如用户名不存在时) + * @param loginUsername 登录输入的用户名 + * @param loginIp 客户端IP + * @param loginResult 登录结果:success / fail + * @param failReason 失败原因(成功时为null) + * @param userAgent 浏览器UA信息 + */ + public int insert(Long userId, String loginUsername, String loginIp, + String loginResult, String failReason, String userAgent) { + var logDO = new LoginLogDO(); + logDO.setUserId(userId); + logDO.setLoginUsername(loginUsername); + logDO.setLoginIp(loginIp); + logDO.setLoginResult(loginResult); + logDO.setFailReason(failReason); + logDO.setUserAgent(userAgent); + logDO.setLoginTime(LocalDateTime.now()); + save(logDO); + return 1; + } + + // ==================== 查询 ==================== + + /** + * 统计指定时间范围内的失败次数 + * 使用 MyBatis-Plus LambdaQueryWrapper 构建查询条件 + */ + public int countRecentFailures(String loginUsername, int minutes) { + var since = LocalDateTime.now().minusMinutes(minutes); + return (int) count(new LambdaQueryWrapper() + .eq(LoginLogDO::getLoginUsername, loginUsername) + .eq(LoginLogDO::getLoginResult, "fail") + .ge(LoginLogDO::getLoginTime, since)); + } + + /** + * 查询登录日志列表(分页 + 条件筛选 + 用户权限过滤) + * 使用 MyBatis-Plus LambdaQueryWrapper 构建分页查询 + * + * @param loginUsername 用户名模糊搜索(可选) + * @param loginResult 登录结果过滤:success/fail(可选) + * @param startDate 开始日期(可选) + * @param endDate 结束日期(可选) + * @param userId 用户ID权限过滤(普通用户只能看自己的日志,管理员传null) + * @param pageNo 页码 + * @param pageSize 每页条数 + */ + public Map queryLoginLogs(String loginUsername, String loginResult, + String startDate, String endDate, + Long userId, + int pageNo, int pageSize) { + var page = new Page(pageNo, pageSize); + var wrapper = new LambdaQueryWrapper(); + + // 动态条件:用户权限过滤(普通用户只能看自己的日志) + if (userId != null) { + wrapper.eq(LoginLogDO::getUserId, userId); + } + // 动态条件:用户名模糊查询 + if (loginUsername != null && !loginUsername.isBlank()) { + wrapper.like(LoginLogDO::getLoginUsername, loginUsername); + } + // 动态条件:登录结果过滤 + if (loginResult != null && !loginResult.isBlank()) { + wrapper.eq(LoginLogDO::getLoginResult, loginResult); + } + // 动态条件:开始日期过滤 + if (startDate != null && !startDate.isBlank()) { + wrapper.ge(LoginLogDO::getLoginTime, startDate + " 00:00:00"); + } + // 动态条件:结束日期过滤 + if (endDate != null && !endDate.isBlank()) { + wrapper.le(LoginLogDO::getLoginTime, endDate + " 23:59:59"); + } + + wrapper.orderByDesc(LoginLogDO::getLoginTime); + + var result = page(page, wrapper); + var list = new ArrayList>(); + for (var logDO : result.getRecords()) { + list.add(toMap(logDO)); + } + + var resultMap = new LinkedHashMap(); + resultMap.put("total", result.getTotal()); + resultMap.put("list", list); + return resultMap; + } + + // ==================== DO → Map 转换 ==================== + + /** + * DO → Map 转换(用于 API 返回) + * 保持与前端兼容的字段名:username, ipAddress, status(success/fail) + */ + private Map toMap(LoginLogDO logDO) { + var map = new LinkedHashMap(); + map.put("id", logDO.getId()); + map.put("userId", logDO.getUserId()); + map.put("username", logDO.getLoginUsername()); + map.put("ipAddress", logDO.getLoginIp()); + map.put("userAgent", logDO.getUserAgent()); + map.put("status", logDO.getLoginResult()); + map.put("failReason", logDO.getFailReason()); + map.put("loginTime", logDO.getLoginTime() != null ? logDO.getLoginTime().toString() : null); + return map; + } +} diff --git a/wangpan-common/src/main/java/com/wangpan/common/dal/repository/MovieRepository.java b/wangpan-common/src/main/java/com/wangpan/common/dal/repository/MovieRepository.java new file mode 100644 index 0000000..dbc681e --- /dev/null +++ b/wangpan-common/src/main/java/com/wangpan/common/dal/repository/MovieRepository.java @@ -0,0 +1,340 @@ +package com.wangpan.common.dal.repository; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import com.wangpan.common.dal.dataobject.MovieDO; +import com.wangpan.common.dal.mapper.MovieMapper; +import com.wangpan.common.entity.Movie; +import org.springframework.stereotype.Repository; + +import java.time.LocalDateTime; +import java.util.*; + +/** + * 电影 Repository 层(Dao) + * + * 继承 MyBatis-Plus ServiceImpl,自动获得 CRUD 能力 + * 职责: + * 1. 对外提供业务维度的数据访问接口(使用 Entity/Map) + * 2. 使用 MyBatis-Plus 的 LambdaQueryWrapper/LambdaUpdateWrapper 构建查询 + * 3. 处理 Entity ↔ MovieDO 的转换 + */ +@Repository +public class MovieRepository extends ServiceImpl { + + // ==================== 插入 ==================== + + /** + * 新增电影记录 + * 将业务 Entity 转换为 DO 后调用 MyBatis-Plus save 方法插入 + */ + public long insert(Movie movie) { + var movieDO = toMovieDO(movie); + save(movieDO); + return movieDO.getId(); + } + + /** + * 批量插入电影记录 + * 使用 MyBatis-Plus saveBatch 方法批量插入 + */ + public int batchInsert(List movies) { + var movieDOs = movies.stream().map(this::toMovieDO).toList(); + saveBatch(movieDOs); + return movieDOs.size(); + } + + // ==================== 查询 ==================== + + /** + * 根据ID查询单个电影(不带用户ID校验,供内部刮削使用) + * 使用 MyBatis-Plus getById 方法 + */ + public Movie findById(Long id) { + var movieDO = getById(id); + return movieDO != null ? toMovie(movieDO) : null; + } + + /** + * 根据ID查询单个电影(带用户ID校验) + * 使用 MyBatis-Plus LambdaQueryWrapper 构建查询条件 + */ + public Movie findById(Long id, Long userId) { + var wrapper = new LambdaQueryWrapper() + .eq(MovieDO::getId, id) + .eq(MovieDO::getUserId, userId); + var movieDO = getOne(wrapper, false); + return movieDO != null ? toMovie(movieDO) : null; + } + + /** + * 分页查询用户的电影列表 + * 使用 MyBatis-Plus LambdaQueryWrapper 构建分页查询,支持动态条件 + */ + public Map queryByPage(Long userId, String keyword, String status, String category, + String scrapeType, String sortBy, String sortOrder, + int pageNo, int pageSize) { + var page = new Page(pageNo, pageSize); + var wrapper = new LambdaQueryWrapper() + .eq(MovieDO::getUserId, userId); + + // 动态条件:关键词模糊查询(标题或副标题) + if (keyword != null && !keyword.isBlank()) { + wrapper.and(w -> w.like(MovieDO::getTitle, keyword).or().like(MovieDO::getSubtitle, keyword)); + } + // 动态条件:状态过滤 + if (status != null && !status.isBlank()) { + wrapper.eq(MovieDO::getStatus, status); + } + // 动态条件:分类过滤 + if (category != null && !category.isBlank()) { + wrapper.eq(MovieDO::getCategory, category); + } + // 动态条件:刮削类型过滤 + if (scrapeType != null && !scrapeType.isBlank()) { + wrapper.eq(MovieDO::getScrapeType, scrapeType); + } + + // 排序:默认按创建时间降序 + if ("rating".equals(sortBy)) { + wrapper.orderBy(true, "asc".equals(sortOrder), MovieDO::getRating); + } else if ("title".equals(sortBy)) { + wrapper.orderBy(true, "asc".equals(sortOrder), MovieDO::getTitle); + } else { + wrapper.orderByDesc(MovieDO::getCreateTime); + } + + var result = page(page, wrapper); + var list = new ArrayList>(); + for (var movieDO : result.getRecords()) { + list.add(toMap(movieDO)); + } + + var resultMap = new LinkedHashMap(); + resultMap.put("total", result.getTotal()); + resultMap.put("list", list); + return resultMap; + } + + /** + * 获取用户的所有分类列表 + * 直接查询 category 字段后在内存中去重,避免数据库层面的 groupBy 冗余 + */ + public List findCategories(Long userId) { + var wrapper = new LambdaQueryWrapper() + .select(MovieDO::getCategory) + .eq(MovieDO::getUserId, userId) + .isNotNull(MovieDO::getCategory) + .ne(MovieDO::getCategory, ""); + + return list(wrapper).stream() + .map(MovieDO::getCategory) + .filter(Objects::nonNull) + .distinct() + .toList(); + } + + /** + * 获取用户的电影总数统计 + * 使用 MyBatis-Plus count 方法构建统计查询 + */ + public Map getStatistics(Long userId) { + var total = count(new LambdaQueryWrapper().eq(MovieDO::getUserId, userId)); + var active = count(new LambdaQueryWrapper() + .eq(MovieDO::getUserId, userId).eq(MovieDO::getStatus, "active")); + var scraped = count(new LambdaQueryWrapper() + .eq(MovieDO::getUserId, userId).ne(MovieDO::getScrapeType, "none")); + + var stats = new LinkedHashMap(); + stats.put("total", total); + stats.put("active", active); + stats.put("scraped", scraped); + return stats; + } + + // ==================== 更新 ==================== + + /** + * 更新电影信息 + * 使用 MyBatis-Plus updateById 方法 + */ + public int update(Movie movie) { + var movieDO = toMovieDO(movie); + return updateById(movieDO) ? 1 : 0; + } + + /** + * 更新电影状态 + * 使用 MyBatis-Plus LambdaUpdateWrapper 构建更新条件 + */ + public int updateStatus(Long id, Long userId, String status) { + var wrapper = new LambdaUpdateWrapper() + .eq(MovieDO::getId, id) + .eq(MovieDO::getUserId, userId) + .set(MovieDO::getStatus, status) + .set(MovieDO::getUpdateTime, LocalDateTime.now()); + return update(wrapper) ? 1 : 0; + } + + // ==================== 删除 ==================== + + /** + * 删除电影 + * 使用 MyBatis-Plus remove 方法配合条件构造器 + */ + public int deleteById(Long id, Long userId) { + var wrapper = new LambdaQueryWrapper() + .eq(MovieDO::getId, id) + .eq(MovieDO::getUserId, userId); + return remove(wrapper) ? 1 : 0; + } + + /** + * 批量删除电影 + * 使用 MyBatis-Plus removeByIds 方法配合条件构造器 + */ + public int batchDelete(List ids, Long userId) { + var wrapper = new LambdaQueryWrapper() + .in(MovieDO::getId, ids) + .eq(MovieDO::getUserId, userId); + return remove(wrapper) ? ids.size() : 0; + } + + /** + * 随机查询指定数量的电影(用于今日推荐) + * 使用 MySQL 的 RAND() 函数实现随机排序 + * + * @param userId 用户ID + * @param limit 返回数量,默认3 + * @return 随机电影列表 + */ + public List> findRandomMovies(Long userId, int limit) { + var wrapper = new LambdaQueryWrapper() + .eq(MovieDO::getUserId, userId) + .last("ORDER BY RAND() LIMIT " + limit); + + var movieDOs = list(wrapper); + return movieDOs.stream().map(this::toMap).toList(); + } + + // ==================== 刮削辅助方法 ==================== + + /** + * 查找所有配置了TMDB API Key的用户(供定时刮削使用) + * 委托给 MovieMapper 中定义的 XML 查询 + */ + public List> findUsersWithTmdbKey() { + return baseMapper.findUsersWithTmdbKey(); + } + + /** + * 查找指定用户需要刮削的电影 + * 委托给 MovieMapper 中定义的 XML 查询 + */ + public List findMoviesNeedingScrape(Long userId) { + var movieDOs = baseMapper.findMoviesNeedingScrape(userId, 7); + return movieDOs.stream().map(this::toMovie).toList(); + } + + /** + * 更新电影评分 + * 委托给 MovieMapper 中定义的 @Update 注解方法 + */ + public int updateRating(Long id, Integer rating) { + return baseMapper.updateRating(id, rating); + } + + /** + * 更新电影海报URL + * 委托给 MovieMapper 中定义的 @Update 注解方法 + */ + public int updatePosterUrl(Long id, String posterUrl) { + return baseMapper.updatePosterUrl(id, posterUrl); + } + + /** + * 更新电影标签 + * 委托给 MovieMapper 中定义的 @Update 注解方法 + */ + public int updateTags(Long id, String tags) { + return baseMapper.updateTags(id, tags); + } + + /** + * 更新刮削类型标记 + * 委托给 MovieMapper 中定义的 @Update 注解方法 + */ + public int updateScrapeType(Long id, String scrapeType) { + return baseMapper.updateScrapeType(id, scrapeType); + } + + // ==================== Entity ↔ DO 转换 ==================== + + /** + * Entity → DO 转换 + */ + private MovieDO toMovieDO(Movie movie) { + var movieDO = new MovieDO(); + movieDO.setId(movie.getId()); + movieDO.setUserId(movie.getUserId()); + movieDO.setTitle(movie.getTitle()); + movieDO.setSubtitle(movie.getSubtitle()); + movieDO.setResourceLibrary(movie.getResourceLibrary()); + movieDO.setMaintainer(movie.getMaintainer()); + movieDO.setStatus(movie.getStatus()); + movieDO.setCategory(movie.getCategory()); + movieDO.setScrapeType(movie.getScrapeType()); + movieDO.setRating(movie.getRating()); + movieDO.setPosterUrl(movie.getPosterUrl()); + movieDO.setTags(movie.getTags()); + movieDO.setUpdateTime(movie.getUpdateTime()); + movieDO.setCreateTime(movie.getCreateTime()); + return movieDO; + } + + /** + * DO → Entity 转换 + */ + private Movie toMovie(MovieDO movieDO) { + var movie = new Movie(); + movie.setId(movieDO.getId()); + movie.setUserId(movieDO.getUserId()); + movie.setTitle(movieDO.getTitle()); + movie.setSubtitle(movieDO.getSubtitle()); + movie.setResourceLibrary(movieDO.getResourceLibrary()); + movie.setMaintainer(movieDO.getMaintainer()); + movie.setStatus(movieDO.getStatus()); + movie.setCategory(movieDO.getCategory()); + movie.setScrapeType(movieDO.getScrapeType()); + movie.setRating(movieDO.getRating()); + movie.setPosterUrl(movieDO.getPosterUrl()); + movie.setTags(movieDO.getTags()); + movie.setUpdateTime(movieDO.getUpdateTime()); + movie.setCreateTime(movieDO.getCreateTime()); + return movie; + } + + /** + * DO → Map 转换(用于 API 返回) + */ + private Map toMap(MovieDO movieDO) { + var map = new LinkedHashMap(); + map.put("id", movieDO.getId()); + map.put("userId", movieDO.getUserId()); + map.put("title", movieDO.getTitle()); + map.put("subtitle", movieDO.getSubtitle()); + map.put("resourceLibrary", movieDO.getResourceLibrary()); + map.put("maintainer", movieDO.getMaintainer()); + map.put("status", movieDO.getStatus()); + map.put("category", movieDO.getCategory()); + map.put("scrapeType", movieDO.getScrapeType()); + map.put("rating", movieDO.getRating()); + map.put("posterUrl", movieDO.getPosterUrl()); + map.put("tags", movieDO.getTags()); + map.put("updateTime", movieDO.getUpdateTime() != null ? movieDO.getUpdateTime().toString() : null); + map.put("createTime", movieDO.getCreateTime() != null ? movieDO.getCreateTime().toString() : null); + return map; + } +} diff --git a/wangpan-common/src/main/java/com/wangpan/common/dal/repository/TmdbCacheRepository.java b/wangpan-common/src/main/java/com/wangpan/common/dal/repository/TmdbCacheRepository.java new file mode 100644 index 0000000..75abd87 --- /dev/null +++ b/wangpan-common/src/main/java/com/wangpan/common/dal/repository/TmdbCacheRepository.java @@ -0,0 +1,126 @@ +package com.wangpan.common.dal.repository; + +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import com.wangpan.common.dal.dataobject.TmdbCacheDO; +import com.wangpan.common.dal.mapper.TmdbCacheMapper; +import org.springframework.stereotype.Repository; + +import java.time.LocalDateTime; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * TMDB 刮削缓存 Repository 层(Dao) + * + * 继承 MyBatis-Plus ServiceImpl,自动获得 CRUD 能力 + * 职责: + * 1. 对外提供业务维度的数据访问接口 + * 2. 使用 MyBatis-Plus 的 LambdaQueryWrapper 构建查询 + * 3. TmdbCache 模块没有 Entity,直接使用 DO 和 Map + */ +@Repository +public class TmdbCacheRepository extends ServiceImpl { + + // ==================== 查询 ==================== + + /** + * 根据电影ID查询缓存 + * 委托给 TmdbCacheMapper 中定义的 XML 查询 + */ + public TmdbCacheDO findByMovieId(Long movieId) { + return baseMapper.findByMovieId(movieId); + } + + /** + * 根据TMDB ID查询缓存 + * 委托给 TmdbCacheMapper 中定义的 XML 查询 + */ + public List findByTmdbId(Integer tmdbId) { + return baseMapper.findByTmdbId(tmdbId); + } + + /** + * 查询用户的所有缓存记录(分页) + * 委托给 TmdbCacheMapper 中定义的 XML 查询 + */ + public Map queryByUserId(Long userId, int pageNo, int pageSize) { + return baseMapper.queryByUserId(userId, pageNo, pageSize); + } + + /** + * 查询所有过期的缓存记录 + * 委托给 TmdbCacheMapper 中定义的 XML 查询 + */ + public List findExpiredCaches(LocalDateTime expireBefore) { + return baseMapper.findExpiredCaches(expireBefore); + } + + // ==================== 写入/更新 ==================== + + /** + * 新增或更新缓存记录 + * 委托给 TmdbCacheMapper 中定义的 XML 查询(UPSERT) + */ + public int saveOrUpdate(Map data) { + return baseMapper.saveOrUpdate(data); + } + + /** + * 将缓存标记为过期 + * 委托给 TmdbCacheMapper 中定义的 XML 查询 + */ + public int markAsExpired(List cacheIds) { + return baseMapper.markAsExpired(cacheIds); + } + + /** + * 删除指定电影的缓存 + * 委托给 TmdbCacheMapper 中定义的 XML 查询 + */ + public int deleteByMovieId(Long movieId) { + return baseMapper.deleteByMovieId(movieId); + } + + /** + * 获取用户的缓存统计信息 + * 委托给 TmdbCacheMapper 中定义的 XML 查询 + */ + public Map getStatistics(Long userId) { + return baseMapper.getStatistics(userId); + } + + // ==================== DO → Map 转换 ==================== + + /** + * 将 TmdbCacheDO 转换为 Map(供业务层使用,兼容旧接口返回格式) + */ + public Map toMap(TmdbCacheDO cacheDO) { + if (cacheDO == null) return null; + + var map = new LinkedHashMap(); + map.put("id", cacheDO.getId()); + map.put("movieId", cacheDO.getMovieId()); + map.put("userId", cacheDO.getUserId()); + map.put("tmdbId", cacheDO.getTmdbId()); + map.put("title", cacheDO.getTitle()); + map.put("originalTitle", cacheDO.getOriginalTitle()); + map.put("overview", cacheDO.getOverview()); + map.put("releaseDate", cacheDO.getReleaseDate()); + map.put("posterPath", cacheDO.getPosterPath()); + map.put("backdropPath", cacheDO.getBackdropPath()); + map.put("localPosterUrl", cacheDO.getLocalPosterUrl()); + map.put("tmdbRating", cacheDO.getTmdbRating()); + map.put("voteCount", cacheDO.getVoteCount()); + map.put("runtime", cacheDO.getRuntime()); + map.put("genres", cacheDO.getGenres()); + map.put("directors", cacheDO.getDirectors()); + map.put("casts", cacheDO.getCasts()); + map.put("originalLanguage", cacheDO.getOriginalLanguage()); + map.put("productionCountries", cacheDO.getProductionCountries()); + map.put("status", cacheDO.getStatus()); + map.put("cacheTime", cacheDO.getCacheTime() != null ? cacheDO.getCacheTime().toString() : null); + map.put("updateTime", cacheDO.getUpdateTime() != null ? cacheDO.getUpdateTime().toString() : null); + return map; + } +} diff --git a/wangpan-common/src/main/java/com/wangpan/common/dal/repository/TmdbScraperLogRepository.java b/wangpan-common/src/main/java/com/wangpan/common/dal/repository/TmdbScraperLogRepository.java new file mode 100644 index 0000000..585b7b7 --- /dev/null +++ b/wangpan-common/src/main/java/com/wangpan/common/dal/repository/TmdbScraperLogRepository.java @@ -0,0 +1,57 @@ +package com.wangpan.common.dal.repository; + +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import com.wangpan.common.dal.dataobject.TmdbScraperLogDO; +import com.wangpan.common.dal.mapper.TmdbScraperLogMapper; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Map; + +/** + * TMDB 定时刮削执行记录 Repository 层(Dao) + * + * 继承 MyBatis-Plus ServiceImpl,自动获得 CRUD 能力 + * 职责: + * 1. 对外提供业务维度的数据访问接口 + * 2. TmdbScraperLog 模块没有 Entity,直接透传 Mapper 返回的 Map + */ +@Repository +public class TmdbScraperLogRepository extends ServiceImpl { + + // ==================== 插入 ==================== + + /** + * 插入一条执行记录 + * 委托给 TmdbScraperLogMapper 中定义的 XML 查询 + */ + public int insert(Map log) { + return baseMapper.insertLog(log); + } + + // ==================== 查询 ==================== + + /** + * 查询最近的执行记录(分页) + * 委托给 TmdbScraperLogMapper 中定义的 XML 查询 + */ + public Map queryRecent(int pageNo, int pageSize) { + return baseMapper.queryRecent(pageNo, pageSize); + } + + /** + * 查询最近的N条执行记录 + * 委托给 TmdbScraperLogMapper 中定义的 XML 查询 + */ + public List> queryRecent(int limit) { + return baseMapper.queryRecentByLimit(limit); + } + + /** + * 根据ID查询单条记录 + * 委托给 TmdbScraperLogMapper 中定义的 XML 查询 + */ + public Map findById(Long id) { + return baseMapper.findById(id); + } +} diff --git a/wangpan-common/src/main/java/com/wangpan/common/dal/repository/TokenRepository.java b/wangpan-common/src/main/java/com/wangpan/common/dal/repository/TokenRepository.java new file mode 100644 index 0000000..dafc24a --- /dev/null +++ b/wangpan-common/src/main/java/com/wangpan/common/dal/repository/TokenRepository.java @@ -0,0 +1,138 @@ +package com.wangpan.common.dal.repository; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import com.wangpan.common.dal.dataobject.TokenDO; +import com.wangpan.common.dal.mapper.TokenMapper; +import org.springframework.stereotype.Repository; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * 用户令牌 Repository 层(Dao) + * + * 继承 MyBatis-Plus ServiceImpl,自动获得 CRUD 能力 + * 职责: + * 1. 对外提供业务维度的数据访问接口 + * 2. 使用 MyBatis-Plus 的 LambdaQueryWrapper 构建查询 + * 3. Token 模块没有 Entity,直接使用 DO 和 Map + */ +@Repository +public class TokenRepository extends ServiceImpl { + + // ==================== 插入 ==================== + + /** + * 保存新令牌 + * 构建 TokenDO 后调用 MyBatis-Plus save 方法插入 + */ + public long save(Long userId, String token, String deviceInfo, + String ipAddress, LocalDateTime expireTime) { + var tokenDO = new TokenDO(); + tokenDO.setUserId(userId); + tokenDO.setToken(token); + tokenDO.setDeviceInfo(deviceInfo); + tokenDO.setIpAddress(ipAddress); + tokenDO.setExpireTime(expireTime); + tokenDO.setStatus(1); // 1-有效 + save(tokenDO); + return tokenDO.getId(); + } + + // ==================== 查询 ==================== + + /** + * 查询用户的有效令牌数量 + * 委托给 TokenMapper 中定义的 @Select 注解方法 + */ + public int countActiveTokens(Long userId) { + return baseMapper.countActiveTokens(userId); + } + + /** + * 查询用户的令牌列表 + * 使用 MyBatis-Plus LambdaQueryWrapper 构建查询条件 + */ + public List> findByUserId(Long userId, int maxCount) { + // 使用 Page 分页替代 .last("LIMIT ..."),避免 SQL 注入风险 + var page = new Page(1, maxCount); + var wrapper = new LambdaQueryWrapper() + .eq(TokenDO::getUserId, userId) + .eq(TokenDO::getStatus, 1) + .gt(TokenDO::getExpireTime, LocalDateTime.now()) + .orderByDesc(TokenDO::getCreateTime); + + var result = page(page, wrapper); + var list = new ArrayList>(); + for (var tokenDO : result.getRecords()) { + list.add(toMap(tokenDO)); + } + return list; + } + + /** + * 验证令牌是否有效 + * 委托给 TokenMapper 中定义的 @Select 注解方法 + */ + public boolean isValid(String token) { + return baseMapper.isValid(token); + } + + // ==================== 更新 ==================== + + /** + * 撤销指定令牌 + * 委托给 TokenMapper 中定义的 @Update 注解方法 + */ + public int revokeById(Long tokenId, Long userId) { + return baseMapper.revokeById(tokenId, userId); + } + + /** + * 撤销用户的所有令牌 + * 委托给 TokenMapper 中定义的 @Update 注解方法 + */ + public int revokeAllByUserId(Long userId) { + return baseMapper.revokeAllByUserId(userId); + } + + // ==================== 删除 ==================== + + /** + * 清理过期的已撤销令牌 + * 委托给 TokenMapper 中定义的 @Update 注解方法 + */ + public int cleanExpiredRevokedTokens() { + return baseMapper.cleanExpiredRevokedTokens(); + } + + /** + * 删除用户的所有令牌记录 + * 委托给 TokenMapper 中定义的 @Update 注解方法 + */ + public int deleteByUserId(Long userId) { + return baseMapper.deleteByUserId(userId); + } + + // ==================== DO → Map 转换 ==================== + + /** + * DO → Map 转换(用于 API 返回) + */ + private Map toMap(TokenDO tokenDO) { + var map = new LinkedHashMap(); + map.put("id", tokenDO.getId()); + map.put("userId", tokenDO.getUserId()); + map.put("deviceInfo", tokenDO.getDeviceInfo()); + map.put("ipAddress", tokenDO.getIpAddress()); + map.put("expireTime", tokenDO.getExpireTime() != null ? tokenDO.getExpireTime().toString() : null); + map.put("status", tokenDO.getStatus()); + map.put("createTime", tokenDO.getCreateTime() != null ? tokenDO.getCreateTime().toString() : null); + return map; + } +} diff --git a/wangpan-common/src/main/java/com/wangpan/common/dal/repository/UserRepository.java b/wangpan-common/src/main/java/com/wangpan/common/dal/repository/UserRepository.java new file mode 100644 index 0000000..963e1ca --- /dev/null +++ b/wangpan-common/src/main/java/com/wangpan/common/dal/repository/UserRepository.java @@ -0,0 +1,277 @@ +package com.wangpan.common.dal.repository; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import com.wangpan.common.dal.dataobject.UserDO; +import com.wangpan.common.dal.mapper.UserMapper; +import com.wangpan.common.entity.User; +import org.springframework.stereotype.Repository; + +import java.time.LocalDateTime; +import java.util.Map; + +/** + * 用户 Repository 层(Dao) + * + * 继承 MyBatis-Plus ServiceImpl,自动获得 CRUD 能力 + * 职责: + * 1. 对外提供业务维度的数据访问接口(使用 Entity/Map) + * 2. 使用 MyBatis-Plus 的 LambdaQueryWrapper 构建查询 + * 3. 处理 Entity ↔ UserDO 的转换 + */ +@Repository +public class UserRepository extends ServiceImpl { + + // ==================== 查询 ==================== + + /** + * 根据用户名查询用户 + * 使用 MyBatis-Plus LambdaQueryWrapper 构建查询条件 + */ + public User findByUsername(String username) { + var wrapper = new LambdaQueryWrapper() + .eq(UserDO::getUsername, username) + .eq(UserDO::getIsDeleted, 0); + var userDO = getOne(wrapper, false); + return userDO != null ? toUser(userDO) : null; + } + + /** + * 根据用户ID查询用户 + * 使用 MyBatis-Plus getById 方法 + */ + public User findById(Long userId) { + var userDO = getById(userId); + // 检查逻辑删除 + if (userDO != null && userDO.getIsDeleted() != null && userDO.getIsDeleted() == 1) { + return null; + } + return userDO != null ? toUser(userDO) : null; + } + + /** + * 根据邮箱查询用户 + * 使用 MyBatis-Plus LambdaQueryWrapper 构建查询条件 + */ + public User findByEmail(String email) { + var wrapper = new LambdaQueryWrapper() + .eq(UserDO::getEmail, email) + .eq(UserDO::getIsDeleted, 0); + var userDO = getOne(wrapper, false); + return userDO != null ? toUser(userDO) : null; + } + + // ==================== 插入 ==================== + + /** + * 插入新用户 + * 将业务 Entity 转换为 DO 后调用 MyBatis-Plus save 方法插入 + */ + public int insert(User user) { + var userDO = toUserDO(user); + save(userDO); + return 1; + } + + // ==================== 更新 ==================== + + /** + * 更新用户的最后登录信息 + * 委托给 UserMapper 中定义的 @Update 注解方法 + */ + public int updateLastLoginInfo(Long userId, String loginIp, LocalDateTime loginTime) { + return baseMapper.updateLastLoginInfo(userId, loginIp, loginTime); + } + + /** + * 更新用户密码 + * 委托给 UserMapper 中定义的 @Update 注解方法 + */ + public int updatePassword(Long userId, String newPassword) { + return baseMapper.updatePassword(userId, newPassword); + } + + /** + * 更新用户状态 + * 委托给 UserMapper 中定义的 @Update 注解方法 + */ + public int updateStatus(Long userId, int status) { + return baseMapper.updateStatus(userId, status); + } + + /** + * 设置用户的锁定截止时间 + * 委托给 UserMapper 中定义的 @Update 注解方法 + */ + public int updateLockUntil(Long userId, LocalDateTime lockUntil) { + return baseMapper.updateLockUntil(userId, lockUntil); + } + + /** + * 清除用户的锁定状态 + * 委托给 UserMapper 中定义的 @Update 注解方法 + */ + public int clearLockUntil(Long userId) { + return baseMapper.clearLockUntil(userId); + } + + /** + * 原子性增加连续失败次数 + * 委托给 UserMapper 中定义的 @Update 注解方法 + */ + public int incrementFailCount(Long userId) { + return baseMapper.incrementFailCount(userId); + } + + /** + * 重置连续失败次数 + * 委托给 UserMapper 中定义的 @Update 注解方法 + */ + public int resetFailCount(Long userId) { + return baseMapper.resetFailCount(userId); + } + + /** + * 更新用户的TMDB API Key + * 委托给 UserMapper 中定义的 @Update 注解方法 + */ + public int updateTmdbApiKey(Long userId, String tmdbApiKey) { + return baseMapper.updateTmdbApiKey(userId, tmdbApiKey); + } + + /** + * 管理员分页查询用户列表 + * 使用 MyBatis-Plus LambdaQueryWrapper 构建分页查询 + */ + public Map queryUsers(String username, Integer status, Integer userType, + int pageNo, int pageSize) { + var page = new Page(pageNo, pageSize); + var wrapper = new LambdaQueryWrapper() + .eq(UserDO::getIsDeleted, 0); + + // 动态条件:用户名模糊查询 + if (username != null && !username.isBlank()) { + wrapper.like(UserDO::getUsername, username); + } + // 动态条件:状态过滤 + if (status != null) { + wrapper.eq(UserDO::getStatus, status); + } + // 动态条件:用户类型过滤 + if (userType != null) { + wrapper.eq(UserDO::getUserType, userType); + } + + wrapper.orderByDesc(UserDO::getCreateTime); + + var result = page(page, wrapper); + var list = new java.util.ArrayList>(); + for (var userDO : result.getRecords()) { + list.add(toUserMap(userDO)); + } + + var resultMap = new java.util.LinkedHashMap(); + resultMap.put("total", result.getTotal()); + resultMap.put("list", list); + return resultMap; + } + + /** + * 管理员更新用户信息 + * 使用 MyBatis-Plus LambdaUpdateWrapper 构建更新条件 + */ + public int updateUser(Long userId, String email, String phone, String realName, + Integer status, Integer userType) { + var wrapper = new LambdaUpdateWrapper() + .eq(UserDO::getId, userId) + .set(UserDO::getEmail, email) + .set(UserDO::getPhone, phone) + .set(UserDO::getRealName, realName) + .set(UserDO::getStatus, status) + .set(UserDO::getUserType, userType) + .set(UserDO::getUpdateTime, LocalDateTime.now()); + return update(wrapper) ? 1 : 0; + } + + /** + * 软删除用户 + * 委托给 UserMapper 中定义的 @Update 注解方法 + */ + public int softDelete(Long userId) { + return baseMapper.softDelete(userId); + } + + // ==================== Entity ↔ DO 转换 ==================== + + /** + * Entity → DO 转换 + */ + private UserDO toUserDO(User user) { + var userDO = new UserDO(); + userDO.setId(user.getId()); + userDO.setUsername(user.getUsername()); + userDO.setPassword(user.getPassword()); + userDO.setEmail(user.getEmail()); + userDO.setPhone(user.getPhone()); + userDO.setRealName(user.getRealName()); + userDO.setAvatarUrl(user.getAvatarUrl()); + userDO.setStatus(user.getStatus()); + userDO.setUserType(user.getUserType()); + userDO.setLastLoginIp(user.getLastLoginIp()); + userDO.setLastLoginTime(user.getLastLoginTime()); + userDO.setLockUntil(user.getLockUntil()); + userDO.setFailCount(user.getFailCount()); + userDO.setTmdbApiKey(user.getTmdbApiKey()); + userDO.setCreateTime(user.getCreateTime()); + userDO.setUpdateTime(user.getUpdateTime()); + userDO.setIsDeleted(user.getIsDeleted()); + userDO.setRemark(user.getRemark()); + return userDO; + } + + /** + * DO → Entity 转换 + */ + private User toUser(UserDO userDO) { + var user = new User(); + user.setId(userDO.getId()); + user.setUsername(userDO.getUsername()); + user.setPassword(userDO.getPassword()); + user.setEmail(userDO.getEmail()); + user.setPhone(userDO.getPhone()); + user.setRealName(userDO.getRealName()); + user.setAvatarUrl(userDO.getAvatarUrl()); + user.setStatus(userDO.getStatus()); + user.setUserType(userDO.getUserType()); + user.setLastLoginIp(userDO.getLastLoginIp()); + user.setLastLoginTime(userDO.getLastLoginTime()); + user.setLockUntil(userDO.getLockUntil()); + user.setFailCount(userDO.getFailCount()); + user.setTmdbApiKey(userDO.getTmdbApiKey()); + user.setCreateTime(userDO.getCreateTime()); + user.setUpdateTime(userDO.getUpdateTime()); + user.setIsDeleted(userDO.getIsDeleted()); + user.setRemark(userDO.getRemark()); + return user; + } + + /** + * DO → Map 转换(用于管理员查询列表返回) + */ + private Map toUserMap(UserDO userDO) { + var map = new java.util.LinkedHashMap(); + map.put("id", userDO.getId()); + map.put("username", userDO.getUsername()); + map.put("email", userDO.getEmail()); + map.put("phone", userDO.getPhone()); + map.put("realName", userDO.getRealName()); + map.put("status", userDO.getStatus()); + map.put("userType", userDO.getUserType()); + map.put("lastLoginIp", userDO.getLastLoginIp()); + map.put("lastLoginTime", userDO.getLastLoginTime() != null ? userDO.getLastLoginTime().toString() : null); + map.put("createTime", userDO.getCreateTime() != null ? userDO.getCreateTime().toString() : null); + return map; + } +} diff --git a/wangpan-common/src/main/java/com/wangpan/common/entity/Drive.java b/wangpan-common/src/main/java/com/wangpan/common/entity/Drive.java new file mode 100644 index 0000000..0d68207 --- /dev/null +++ b/wangpan-common/src/main/java/com/wangpan/common/entity/Drive.java @@ -0,0 +1,154 @@ +package com.wangpan.common.entity; + +import java.io.Serializable; +import java.time.LocalDateTime; +import java.util.Objects; + +/** + * 网盘(资源集合)实体类 + * 对应数据库表:wp_drive + * + * 功能: + * 1. 用户创建的资源集合(类似"我的电影库"、"我的音乐库") + * 2. 每个网盘下可以包含多个文件夹和文件 + * 3. 私有模式:仅创建者可见和操作 + * + * 设计说明: + * - user_id 隔离:每个用户拥有独立的网盘 + * - 支持网盘名称、描述、图标等基本信息 + * - 统计字段记录文件夹和文件数量 + */ +public class Drive implements Serializable { + + /** 序列化版本号 */ + private static final long serialVersionUID = 1L; + + // ==================== 基础属性 ==================== + + /** 网盘ID:主键,自增长 */ + private Long id; + + /** 所属用户ID:关联 wp_user 表 */ + private Long userId; + + /** 网盘名称:如"我的电影库"、"学习资料" */ + private String name; + + /** 网盘描述:详细说明 */ + private String description; + + /** 网盘 Logo URL:存储在 MinIO 中的图片链接 */ + private String logoUrl; + + // ==================== 统计属性 ==================== + + /** 文件夹数量 */ + private Integer folderCount; + + /** 文件数量 */ + private Integer fileCount; + + /** 总大小(字节) */ + private Long totalSize; + + // ==================== 状态属性 ==================== + + /** + * 状态: + * - active - 正常(默认) + * - disabled - 已禁用 + */ + private String status; + + // ==================== 时间戳属性 ==================== + + /** 更新时间 */ + private LocalDateTime updateTime; + + /** 创建时间 */ + private LocalDateTime createTime; + + // ==================== 构造方法 ==================== + + /** 无参构造方法 */ + public Drive() {} + + /** + * 基础构造方法 + * + * @param userId 用户ID + * @param name 网盘名称 + * @param description 网盘描述 + */ + public Drive(Long userId, String name, String description) { + this.userId = userId; + this.name = name; + this.description = description; + this.folderCount = 0; + this.fileCount = 0; + this.totalSize = 0L; + this.status = "active"; + this.createTime = LocalDateTime.now(); + } + + // ==================== Getter和Setter方法 ==================== + + public Long getId() { return id; } + public void setId(Long id) { this.id = id; } + + public Long getUserId() { return userId; } + public void setUserId(Long userId) { this.userId = userId; } + + public String getName() { return name; } + public void setName(String name) { this.name = name; } + + public String getDescription() { return description; } + public void setDescription(String description) { this.description = description; } + + public String getLogoUrl() { return logoUrl; } + public void setLogoUrl(String logoUrl) { this.logoUrl = logoUrl; } + + public Integer getFolderCount() { return folderCount; } + public void setFolderCount(Integer folderCount) { this.folderCount = folderCount; } + + public Integer getFileCount() { return fileCount; } + public void setFileCount(Integer fileCount) { this.fileCount = fileCount; } + + public Long getTotalSize() { return totalSize; } + public void setTotalSize(Long totalSize) { this.totalSize = totalSize; } + + public String getStatus() { return status; } + public void setStatus(String status) { this.status = status; } + + public LocalDateTime getUpdateTime() { return updateTime; } + public void setUpdateTime(LocalDateTime updateTime) { this.updateTime = updateTime; } + + public LocalDateTime getCreateTime() { return createTime; } + public void setCreateTime(LocalDateTime createTime) { this.createTime = createTime; } + + // ==================== Object方法重写 ==================== + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + Drive drive = (Drive) o; + return Objects.equals(id, drive.id); + } + + @Override + public int hashCode() { + return Objects.hash(id); + } + + @Override + public String toString() { + return "Drive{" + + "id=" + id + + ", userId=" + userId + + ", name='" + name + '\'' + + ", status='" + status + '\'' + + ", createTime=" + createTime + + '}'; + } +} diff --git a/wangpan-common/src/main/java/com/wangpan/common/entity/DriveFolder.java b/wangpan-common/src/main/java/com/wangpan/common/entity/DriveFolder.java new file mode 100644 index 0000000..8db0e1b --- /dev/null +++ b/wangpan-common/src/main/java/com/wangpan/common/entity/DriveFolder.java @@ -0,0 +1,167 @@ +package com.wangpan.common.entity; + +import java.io.Serializable; +import java.time.LocalDateTime; +import java.util.Objects; + +/** + * 网盘文件夹实体类 + * 对应数据库表:wp_drive_folder + * + * 功能: + * 1. 网盘内的文件夹管理 + * 2. 支持多级嵌套(通过 parent_id 实现) + * 3. 私有模式:仅创建者可见和操作 + * + * 设计说明: + * - drive_id 关联所属网盘 + * - parent_id 实现树形结构(0 表示根目录) + * - sort_order 控制同级文件夹排序 + */ +public class DriveFolder implements Serializable { + + /** 序列化版本号 */ + private static final long serialVersionUID = 1L; + + // ==================== 基础属性 ==================== + + /** 文件夹ID:主键,自增长 */ + private Long id; + + /** 所属网盘ID:关联 wp_drive 表 */ + private Long driveId; + + /** 所属用户ID:关联 wp_user 表(冗余字段,便于查询) */ + private Long userId; + + /** 父文件夹ID:0 表示根目录 */ + private Long parentId; + + /** 文件夹名称 */ + private String name; + + /** 文件夹描述 */ + private String description; + + // ==================== 排序与层级属性 ==================== + + /** 排序权重:数值越小越靠前 */ + private Integer sortOrder; + + /** 层级深度:根目录为 0 */ + private Integer depth; + + // ==================== 统计属性 ==================== + + /** 子文件夹数量 */ + private Integer childFolderCount; + + /** 文件数量 */ + private Integer fileCount; + + /** 总大小(字节) */ + private Long totalSize; + + // ==================== 时间戳属性 ==================== + + /** 更新时间 */ + private LocalDateTime updateTime; + + /** 创建时间 */ + private LocalDateTime createTime; + + // ==================== 构造方法 ==================== + + /** 无参构造方法 */ + public DriveFolder() {} + + /** + * 基础构造方法 + * + * @param driveId 网盘ID + * @param userId 用户ID + * @param parentId 父文件夹ID(0 表示根目录) + * @param name 文件夹名称 + * @param description 文件夹描述 + */ + public DriveFolder(Long driveId, Long userId, Long parentId, String name, String description) { + this.driveId = driveId; + this.userId = userId; + this.parentId = parentId; + this.name = name; + this.description = description; + this.sortOrder = 0; + this.depth = 0; + this.childFolderCount = 0; + this.fileCount = 0; + this.totalSize = 0L; + this.createTime = LocalDateTime.now(); + } + + // ==================== Getter和Setter方法 ==================== + + public Long getId() { return id; } + public void setId(Long id) { this.id = id; } + + public Long getDriveId() { return driveId; } + public void setDriveId(Long driveId) { this.driveId = driveId; } + + public Long getUserId() { return userId; } + public void setUserId(Long userId) { this.userId = userId; } + + public Long getParentId() { return parentId; } + public void setParentId(Long parentId) { this.parentId = parentId; } + + public String getName() { return name; } + public void setName(String name) { this.name = name; } + + public String getDescription() { return description; } + public void setDescription(String description) { this.description = description; } + + public Integer getSortOrder() { return sortOrder; } + public void setSortOrder(Integer sortOrder) { this.sortOrder = sortOrder; } + + public Integer getDepth() { return depth; } + public void setDepth(Integer depth) { this.depth = depth; } + + public Integer getChildFolderCount() { return childFolderCount; } + public void setChildFolderCount(Integer childFolderCount) { this.childFolderCount = childFolderCount; } + + public Integer getFileCount() { return fileCount; } + public void setFileCount(Integer fileCount) { this.fileCount = fileCount; } + + public Long getTotalSize() { return totalSize; } + public void setTotalSize(Long totalSize) { this.totalSize = totalSize; } + + public LocalDateTime getUpdateTime() { return updateTime; } + public void setUpdateTime(LocalDateTime updateTime) { this.updateTime = updateTime; } + + public LocalDateTime getCreateTime() { return createTime; } + public void setCreateTime(LocalDateTime createTime) { this.createTime = createTime; } + + // ==================== Object方法重写 ==================== + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + DriveFolder that = (DriveFolder) o; + return Objects.equals(id, that.id); + } + + @Override + public int hashCode() { + return Objects.hash(id); + } + + @Override + public String toString() { + return "DriveFolder{" + + "id=" + id + + ", driveId=" + driveId + + ", parentId=" + parentId + + ", name='" + name + '\'' + + ", createTime=" + createTime + + '}'; + } +} diff --git a/wangpan-common/src/main/java/com/wangpan/common/entity/DriveMovie.java b/wangpan-common/src/main/java/com/wangpan/common/entity/DriveMovie.java new file mode 100644 index 0000000..737c2b8 --- /dev/null +++ b/wangpan-common/src/main/java/com/wangpan/common/entity/DriveMovie.java @@ -0,0 +1,239 @@ +package com.wangpan.common.entity; + +import java.io.Serializable; +import java.time.LocalDateTime; +import java.util.Objects; + +/** + * 网盘电影实体类(文件与电影合并) + * 对应数据库表:wp_drive_movie + * + * 功能: + * 1. 网盘内的电影管理(每个文件就是一个电影) + * 2. 电影文件存储在 MinIO 对象存储中 + * 3. 支持 TMDB 刮削信息存储 + * 4. 私有模式:仅创建者可见和操作 + * + * 设计说明: + * - drive_id 关联所属网盘 + * - folder_id 关联所属文件夹(0 表示根目录) + * - minio_object_name 存储 MinIO 中的对象名称 + * - minio_url 存储加密后的访问 URL + * - 每个电影就是一个文件,文件夹下直接创建电影 + */ +public class DriveMovie implements Serializable { + + /** 序列化版本号 */ + private static final long serialVersionUID = 1L; + + // ==================== 基础属性 ==================== + + /** 电影ID:主键,自增长 */ + private Long id; + + /** 所属网盘ID:关联 wp_drive 表 */ + private Long driveId; + + /** 所属文件夹ID:关联 wp_drive_folder 表,0 表示根目录 */ + private Long folderId; + + /** 所属用户ID:关联 wp_user 表(冗余字段,便于查询) */ + private Long userId; + + /** 电影标题:显示名称 */ + private String title; + + /** 电影副标题:如 "蓝光/科幻" */ + private String subtitle; + + /** 原始文件名:上传时的文件名 */ + private String originalName; + + // ==================== MinIO 存储属性 ==================== + + /** MinIO 对象名称:如 drive/1/2/filename.mp4 */ + private String minioObjectName; + + /** MinIO 访问 URL(加密存储) */ + private String minioUrl; + + // ==================== 文件属性 ==================== + + /** MIME 类型:如 video/mp4, image/jpeg */ + private String mimeType; + + /** 文件大小(字节) */ + private Long fileSize; + + /** 文件扩展名:如 mp4, mkv, avi */ + private String extension; + + /** 电影描述 */ + private String description; + + // ==================== 电影相关属性 ==================== + + /** 资源库来源:如 TMDB、本地等 */ + private String resourceLibrary; + + /** 维护者:资源维护人员 */ + private String maintainer; + + /** + * 状态: + * - active - 正常(默认) + * - deleted - 已删除(逻辑删除) + */ + private String status; + + /** 分类:如 action/sci-fi/drama 等 */ + private String category; + + /** + * 刮削类型: + * - tmdb - TMDB 刮削 + * - manual - 手动录入 + * - none - 未刮削 + */ + private String scrapeType; + + /** 评分:0-100 */ + private Integer rating; + + /** 海报 URL(加密存储) */ + private String posterUrl; + + /** 标签:逗号分隔,如 "科幻,IMAX,杜比视界" */ + private String tags; + + // ==================== 时间戳属性 ==================== + + /** 更新时间 */ + private LocalDateTime updateTime; + + /** 创建时间 */ + private LocalDateTime createTime; + + // ==================== 构造方法 ==================== + + /** 无参构造方法 */ + public DriveMovie() {} + + /** + * 基础构造方法 + * + * @param driveId 网盘ID + * @param folderId 文件夹ID(0 表示根目录) + * @param userId 用户ID + * @param title 电影标题 + * @param originalName 原始文件名 + */ + public DriveMovie(Long driveId, Long folderId, Long userId, String title, String originalName) { + this.driveId = driveId; + this.folderId = folderId; + this.userId = userId; + this.title = title; + this.originalName = originalName; + this.status = "active"; + this.scrapeType = "none"; + this.createTime = LocalDateTime.now(); + } + + // ==================== Getter和Setter方法 ==================== + + public Long getId() { return id; } + public void setId(Long id) { this.id = id; } + + public Long getDriveId() { return driveId; } + public void setDriveId(Long driveId) { this.driveId = driveId; } + + public Long getFolderId() { return folderId; } + public void setFolderId(Long folderId) { this.folderId = folderId; } + + public Long getUserId() { return userId; } + public void setUserId(Long userId) { this.userId = userId; } + + public String getTitle() { return title; } + public void setTitle(String title) { this.title = title; } + + public String getSubtitle() { return subtitle; } + public void setSubtitle(String subtitle) { this.subtitle = subtitle; } + + public String getOriginalName() { return originalName; } + public void setOriginalName(String originalName) { this.originalName = originalName; } + + public String getMinioObjectName() { return minioObjectName; } + public void setMinioObjectName(String minioObjectName) { this.minioObjectName = minioObjectName; } + + public String getMinioUrl() { return minioUrl; } + public void setMinioUrl(String minioUrl) { this.minioUrl = minioUrl; } + + public String getMimeType() { return mimeType; } + public void setMimeType(String mimeType) { this.mimeType = mimeType; } + + public Long getFileSize() { return fileSize; } + public void setFileSize(Long fileSize) { this.fileSize = fileSize; } + + public String getExtension() { return extension; } + public void setExtension(String extension) { this.extension = extension; } + + public String getDescription() { return description; } + public void setDescription(String description) { this.description = description; } + + public String getResourceLibrary() { return resourceLibrary; } + public void setResourceLibrary(String resourceLibrary) { this.resourceLibrary = resourceLibrary; } + + public String getMaintainer() { return maintainer; } + public void setMaintainer(String maintainer) { this.maintainer = maintainer; } + + public String getStatus() { return status; } + public void setStatus(String status) { this.status = status; } + + public String getCategory() { return category; } + public void setCategory(String category) { this.category = category; } + + public String getScrapeType() { return scrapeType; } + public void setScrapeType(String scrapeType) { this.scrapeType = scrapeType; } + + public Integer getRating() { return rating; } + public void setRating(Integer rating) { this.rating = rating; } + + public String getPosterUrl() { return posterUrl; } + public void setPosterUrl(String posterUrl) { this.posterUrl = posterUrl; } + + public String getTags() { return tags; } + public void setTags(String tags) { this.tags = tags; } + + public LocalDateTime getUpdateTime() { return updateTime; } + public void setUpdateTime(LocalDateTime updateTime) { this.updateTime = updateTime; } + + public LocalDateTime getCreateTime() { return createTime; } + public void setCreateTime(LocalDateTime createTime) { this.createTime = createTime; } + + // ==================== Object方法重写 ==================== + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + DriveMovie movie = (DriveMovie) o; + return Objects.equals(id, movie.id); + } + + @Override + public int hashCode() { + return Objects.hash(id); + } + + @Override + public String toString() { + return "DriveMovie{" + + "id=" + id + + ", driveId=" + driveId + + ", title='" + title + '\'' + + ", rating=" + rating + + ", fileSize=" + fileSize + + ", createTime=" + createTime + + '}'; + } +} diff --git a/wangpan-common/src/main/java/com/wangpan/common/entity/Icon.java b/wangpan-common/src/main/java/com/wangpan/common/entity/Icon.java new file mode 100644 index 0000000..d723953 --- /dev/null +++ b/wangpan-common/src/main/java/com/wangpan/common/entity/Icon.java @@ -0,0 +1,148 @@ +package com.wangpan.common.entity; + +import java.io.Serializable; +import java.time.LocalDateTime; +import java.util.Objects; + +/** + * Icon 实体类 + * 对应数据库表:wp_icon + * + * 功能: + * 1. 用户创建的快捷入口图标(类似书签/快捷方式) + * 2. 每个用户拥有独立的 icon 列表,互不影响 + * 3. 支持名称、链接、描述、排序等信息 + * + * 设计说明: + * - user_id 隔离:每个用户拥有独立的 icon 集合 + * - 与网盘模块完全独立,不共享数据 + */ +public class Icon implements Serializable { + + /** 序列化版本号 */ + private static final long serialVersionUID = 1L; + + /** Icon ID:主键,自增长 */ + private Long id; + + /** 所属用户ID:关联 wp_user 表 */ + private Long userId; + + /** Icon 名称:如"百度"、"GitHub" */ + private String name; + + /** Icon 链接:外网 URL */ + private String url; + + /** 内网链接 URL */ + private String internalUrl; + + /** 链接类型:external(仅外网)/ internal(仅内网)/ both(内外网) */ + private String linkType; + + /** Icon 描述:详细说明 */ + private String description; + + /** 排序值(数字越小越靠前) */ + private Integer sortOrder; + + /** 状态:active / disabled */ + private String status; + + /** Logo 图片 URL(MinIO 存储) */ + private String logoUrl; + + /** 创建时间 */ + private LocalDateTime createTime; + + /** 更新时间 */ + private LocalDateTime updateTime; + + // ==================== 构造方法 ==================== + + /** 无参构造方法 */ + public Icon() {} + + /** + * 基础构造方法 + * + * @param userId 用户ID + * @param name Icon 名称 + * @param url Icon 链接 + * @param description Icon 描述 + */ + public Icon(Long userId, String name, String url, String description) { + this.userId = userId; + this.name = name; + this.url = url; + this.description = description; + this.sortOrder = 0; + this.status = "active"; + this.createTime = LocalDateTime.now(); + } + + // ==================== Getter 和 Setter ==================== + + public Long getId() { return id; } + public void setId(Long id) { this.id = id; } + + public Long getUserId() { return userId; } + public void setUserId(Long userId) { this.userId = userId; } + + public String getName() { return name; } + public void setName(String name) { this.name = name; } + + public String getUrl() { return url; } + public void setUrl(String url) { this.url = url; } + + public String getInternalUrl() { return internalUrl; } + public void setInternalUrl(String internalUrl) { this.internalUrl = internalUrl; } + + public String getLinkType() { return linkType; } + public void setLinkType(String linkType) { this.linkType = linkType; } + + public String getDescription() { return description; } + public void setDescription(String description) { this.description = description; } + + public Integer getSortOrder() { return sortOrder; } + public void setSortOrder(Integer sortOrder) { this.sortOrder = sortOrder; } + + public String getStatus() { return status; } + public void setStatus(String status) { this.status = status; } + + public String getLogoUrl() { return logoUrl; } + public void setLogoUrl(String logoUrl) { this.logoUrl = logoUrl; } + + public LocalDateTime getCreateTime() { return createTime; } + public void setCreateTime(LocalDateTime createTime) { this.createTime = createTime; } + + public LocalDateTime getUpdateTime() { return updateTime; } + public void setUpdateTime(LocalDateTime updateTime) { this.updateTime = updateTime; } + + // ==================== Object 方法重写 ==================== + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + Icon icon = (Icon) o; + return Objects.equals(id, icon.id); + } + + @Override + public int hashCode() { + return Objects.hash(id); + } + + @Override + public String toString() { + return "Icon{" + + "id=" + id + + ", userId=" + userId + + ", name='" + name + '\'' + + ", url='" + url + '\'' + + ", status='" + status + '\'' + + ", createTime=" + createTime + + '}'; + } +} diff --git a/wangpan-common/src/main/java/com/wangpan/common/entity/Movie.java b/wangpan-common/src/main/java/com/wangpan/common/entity/Movie.java new file mode 100644 index 0000000..386eafb --- /dev/null +++ b/wangpan-common/src/main/java/com/wangpan/common/entity/Movie.java @@ -0,0 +1,178 @@ +package com.wangpan.common.entity; + +import java.io.Serializable; +import java.time.LocalDateTime; +import java.util.Objects; + +/** + * 电影实体类 + * 对应数据库表:wp_movie(用户电影库表) + * + * 功能: + * 1. 封装电影的基本信息(标题、评分、海报等) + * 2. 每个用户拥有独立的电影库(通过 user_id 关联) + * 3. 支持状态管理、分类、标签等功能 + * + * 设计说明: + * - 数据量可能超过1万条,字段设计考虑查询性能 + * - 使用合理的索引策略优化查询速度 + */ +public class Movie implements Serializable { + + /** 序列化版本号 */ + private static final long serialVersionUID = 1L; + + // ==================== 基础属性 ==================== + + /** 电影ID:主键,自增长 */ + private Long id; + + /** 所属用户ID:关联 wp_user 表,用于用户隔离 */ + private Long userId; + + /** 电影标题:主名称,如"沙丘" */ + private String title; + + /** 副标题:补充信息,如"蓝光/双语/冒险" */ + private String subtitle; + + // ==================== 资源相关属性 ==================== + + /** 资源库名称:如"夸克资源池"、"阿里云盘" */ + private String resourceLibrary; + + /** 维护人/来源:如"极光共享"、"手动添加" */ + private String maintainer; + + // ==================== 状态与分类属性 ==================== + + /** + * 状态枚举: + * - pending_review - 待复核(默认) + * - reviewed - 已复核 + * - published - 已发布 + * - archived - 已归档 + * - deleted - 已删除(逻辑删除) + */ + private String status; + + /** 分类:如"电影资源"、"电视剧"、"纪录片" */ + private String category; + + /** + * 刮削类型: + * - auto - 自动刮削(从TMDB获取) + * - manual - 手动录入 + * - none - 未刮削 + */ + private String scrapeType; + + // ==================== 评分与展示属性 ==================== + + /** 评分:0-100分制 */ + private Integer rating; + + /** 海报图片URL地址 */ + private String posterUrl; + + /** 标签:逗号分隔,如"科幻, IMAX, 杜比视界" */ + private String tags; + + // ==================== 时间戳属性 ==================== + + /** 更新时间:资源最后更新时间 */ + private LocalDateTime updateTime; + + /** 创建时间:记录创建时刻 */ + private LocalDateTime createTime; + + // ==================== 构造方法 ==================== + + /** 无参构造方法:用于框架反射和JSON反序列化 */ + public Movie() {} + + /** + * 基础构造方法:创建新电影时使用 + * + * @param userId 用户ID + * @param title 电影标题 + */ + public Movie(Long userId, String title) { + this.userId = userId; + this.title = title; + this.status = "pending_review"; // 默认待复核状态 + this.scrapeType = "manual"; // 默认手动录入 + this.createTime = LocalDateTime.now(); + } + + // ==================== Getter和Setter方法 ==================== + + public Long getId() { return id; } + public void setId(Long id) { this.id = id; } + + public Long getUserId() { return userId; } + public void setUserId(Long userId) { this.userId = userId; } + + public String getTitle() { return title; } + public void setTitle(String title) { this.title = title; } + + public String getSubtitle() { return subtitle; } + public void setSubtitle(String subtitle) { this.subtitle = subtitle; } + + public String getResourceLibrary() { return resourceLibrary; } + public void setResourceLibrary(String resourceLibrary) { this.resourceLibrary = resourceLibrary; } + + public String getMaintainer() { return maintainer; } + public void setMaintainer(String maintainer) { this.maintainer = maintainer; } + + public String getStatus() { return status; } + public void setStatus(String status) { this.status = status; } + + public String getCategory() { return category; } + public void setCategory(String category) { this.category = category; } + + public String getScrapeType() { return scrapeType; } + public void setScrapeType(String scrapeType) { this.scrapeType = scrapeType; } + + public Integer getRating() { return rating; } + public void setRating(Integer rating) { this.rating = rating; } + + public String getPosterUrl() { return posterUrl; } + public void setPosterUrl(String posterUrl) { this.posterUrl = posterUrl; } + + public String getTags() { return tags; } + public void setTags(String tags) { this.tags = tags; } + + public LocalDateTime getUpdateTime() { return updateTime; } + public void setUpdateTime(LocalDateTime updateTime) { this.updateTime = updateTime; } + + public LocalDateTime getCreateTime() { return createTime; } + public void setCreateTime(LocalDateTime createTime) { this.createTime = createTime; } + + // ==================== Object方法重写 ==================== + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + Movie movie = (Movie) o; + return Objects.equals(id, movie.id); + } + + @Override + public int hashCode() { + return Objects.hash(id); + } + + @Override + public String toString() { + return "Movie{" + + "id=" + id + + ", userId=" + userId + + ", title='" + title + '\'' + + ", status='" + status + '\'' + + ", rating=" + rating + + ", createTime=" + createTime + + '}'; + } +} diff --git a/wangpan-common/src/main/java/com/wangpan/common/entity/User.java b/wangpan-common/src/main/java/com/wangpan/common/entity/User.java new file mode 100644 index 0000000..d85901a --- /dev/null +++ b/wangpan-common/src/main/java/com/wangpan/common/entity/User.java @@ -0,0 +1,299 @@ +package com.wangpan.common.entity; + +import java.io.Serializable; +import java.time.LocalDateTime; +import java.util.Objects; + +/** + * 用户实体类 + * 对应数据库表:wp_user(网盘用户表) + * + * 功能: + * 1. 封装用户的基本信息和登录凭证 + * 2. 作为数据传输对象在各个层之间传递 + * 3. 实现Serializable接口支持序列化操作 + * + * 设计原则: + * - 使用Java Bean规范,提供getter/setter方法 + * - 字段与数据库表字段一一对应 + * - 提供构造方法便于创建对象 + */ +public class User implements Serializable { + + // 序列化版本号,用于反序列化时验证类的兼容性 + private static final long serialVersionUID = 1L; + + // ==================== 基本属性 ==================== + + /** 用户ID:主键,自增长 */ + private Long id; + + /** 用户名:登录标识符,唯一不可重复 */ + private String username; + + /** 密码:加密存储的密码字符串 */ + private String password; + + /** 邮箱地址:用于找回密码和通知 */ + private String email; + + /** 手机号码:用于短信验证和二次认证 */ + private String phone; + + /** 真实姓名:用户的真实姓名 */ + private String realName; + + /** 头像URL:头像图片的网络地址 */ + private String avatarUrl; + + // ==================== 状态相关属性 ==================== + + /** + * 用户状态: + * 0 - 禁用(账户被冻结) + * 1 - 正常(可正常使用) + * 2 - 锁定(因多次失败被锁定) + */ + private Integer status; + + /** + * 用户类型/角色: + * 0 - 普通用户 + * 1 - VIP用户(享有更多存储空间) + * 2 - 管理员(拥有系统管理权限) + */ + private Integer userType; + + // ==================== 登录记录属性 ==================== + + /** 最后登录IP地址 */ + private String lastLoginIp; + + /** 最后登录时间 */ + private LocalDateTime lastLoginTime; + + /** + * 锁定截止时间(lock_until) + * + * 设计说明: + * - 当用户因多次登录失败被锁定时,此字段记录具体的解锁时刻 + * - null 表示未锁定或已解锁 + * - 有值表示账户处于锁定状态,直到该时间点才能登录 + * + * 使用场景: + * 1. 首次达到失败次数上限时,设置 lock_until = now + 30分钟 + * 2. 锁定期内再次尝试登录,直接返回剩余等待时间 + * 3. 锁定期间再次触发锁定(如管理员手动延长),更新为新的时间 + * 4. 当前时间超过 lock_until 时,自动清除该字段并恢复正常状态 + */ + private LocalDateTime lockUntil; + + /** + * 连续失败登录次数(fail_count) + * + * 设计说明: + * - 记录用户当前连续失败的次数(从上次成功/重置后开始累计) + * - 登录成功或解锁后重置为 0 + * - 达到阈值(如5次)时触发账户锁定 + * + * 为什么不用查日志表? + * - 登录日志是审计记录,不可篡改、不可删除 + * - 用此字段管理"当前状态",日志只做历史追溯 + * + * @see com.wangpan.service.UserService#MAX_LOGIN_FAILURES 阈值常量 + */ + private Integer failCount; + + // ==================== TMDB相关属性 ==================== + + /** + * 用户的TMDB API Key(加密存储) + * + * 设计说明: + * - 每个用户可以绑定自己的TMDB API Key + * - 使用Jasypt加密后存入数据库,保证安全性 + * - 登录后可根据当前用户身份操作对应的TMDB数据 + * - null表示尚未绑定TMDB API Key + */ + private String tmdbApiKey; + + // ==================== 时间戳属性 ==================== + + /** 账户创建时间 */ + private LocalDateTime createTime; + + /** 最后更新时间 */ + private LocalDateTime updateTime; + + /** 逻辑删除标记:0-未删除,1-已删除 */ + private Integer isDeleted; + + /** 备注信息 */ + private String remark; + + // ==================== 构造方法 ==================== + + /** + * 无参构造方法 + * 用于框架反射创建对象、JSON反序列化等场景 + */ + public User() { + super(); + } + + /** + * 基础构造方法:用于创建新用户时使用 + * + * @param username 用户名 + * @param password 密码(应该是加密后的) + */ + public User(String username, String password) { + this.username = username; + this.password = password; + // 默认设置状态为正常 + this.status = 1; + // 默认设置为普通用户 + this.userType = 0; + // 默认未删除 + this.isDeleted = 0; + } + + /** + * 完整构造方法:包含所有必要信息 + * + * @param username 用户名 + * @param password 密码 + * @param email 邮箱 + * @param phone 手机号 + * @param realName 真实姓名 + */ + public User(String username, String password, String email, String phone, String realName) { + this(username, password); + this.email = email; + this.phone = phone; + this.realName = realName; + } + + // ==================== Getter和Setter方法 ==================== + + public Long getId() { return id; } + public void setId(Long id) { this.id = id; } + + public String getUsername() { return username; } + public void setUsername(String username) { this.username = username; } + + public String getPassword() { return password; } + public void setPassword(String password) { this.password = password; } + + public String getEmail() { return email; } + public void setEmail(String email) { this.email = email; } + + public String getPhone() { return phone; } + public void setPhone(String phone) { this.phone = phone; } + + public String getRealName() { return realName; } + public void setRealName(String realName) { this.realName = realName; } + + public String getAvatarUrl() { return avatarUrl; } + public void setAvatarUrl(String avatarUrl) { this.avatarUrl = avatarUrl; } + + public Integer getStatus() { return status; } + public void setStatus(Integer status) { this.status = status; } + + public Integer getUserType() { return userType; } + public void setUserType(Integer userType) { this.userType = userType; } + + public String getLastLoginIp() { return lastLoginIp; } + public void setLastLoginIp(String lastLoginIp) { this.lastLoginIp = lastLoginIp; } + + public LocalDateTime getLastLoginTime() { return lastLoginTime; } + public void setLastLoginTime(LocalDateTime lastLoginTime) { this.lastLoginTime = lastLoginTime; } + + /** 获取锁定截止时间 */ + public LocalDateTime getLockUntil() { return lockUntil; } + /** 设置锁定截止时间 */ + public void setLockUntil(LocalDateTime lockUntil) { this.lockUntil = lockUntil; } + + /** 获取当前连续失败次数 */ + public Integer getFailCount() { return failCount; } + /** 设置连续失败次数 */ + public void setFailCount(Integer failCount) { this.failCount = failCount; } + + /** 获取TMDB API Key(加密存储) */ + public String getTmdbApiKey() { return tmdbApiKey; } + /** 设置TMDB API Key(加密后存储) */ + public void setTmdbApiKey(String tmdbApiKey) { this.tmdbApiKey = tmdbApiKey; } + + /** + * 判断账户当前是否处于锁定状态 + * + * @return true-已锁定(lock_until不为null且未过期),false-未锁定 + */ + public boolean isLocked() { + if (lockUntil == null) { + return false; // 没有锁定时间 = 未锁定 + } + // 有锁定时间,检查是否已过期 + return LocalDateTime.now().isBefore(lockUntil); + } + + public LocalDateTime getCreateTime() { return createTime; } + public void setCreateTime(LocalDateTime createTime) { this.createTime = createTime; } + + public LocalDateTime getUpdateTime() { return updateTime; } + public void setUpdateTime(LocalDateTime updateTime) { this.updateTime = updateTime; } + + public Integer getIsDeleted() { return isDeleted; } + public void setIsDeleted(Integer isDeleted) { this.isDeleted = isDeleted; } + + public String getRemark() { return remark; } + public void setRemark(String remark) { this.remark = remark; } + + // ==================== 业务判断方法 ==================== + + /** + * 判断用户是否处于正常状态 + * + * @return true-账户正常可用,false-账户异常 + */ + public boolean isActive() { + return status != null && status == 1; + } + + /** + * 判断用户是否为管理员 + * + * @return true-是管理员,false-非管理员 + */ + public boolean isAdmin() { + return userType != null && userType == 2; + } + + // ==================== Object方法重写 ==================== + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + User user = (User) o; + // 主要通过ID判断是否为同一用户 + return Objects.equals(id, user.id); + } + + @Override + public int hashCode() { + return Objects.hash(id); + } + + @Override + public String toString() { + return "User{" + + "id=" + id + + ", username='" + username + '\'' + + ", email='" + email + '\'' + + ", status=" + status + + ", userType=" + userType + + ", createTime=" + createTime + + '}'; + } +} diff --git a/wangpan-common/src/main/java/com/wangpan/common/interceptor/AuthInterceptor.java b/wangpan-common/src/main/java/com/wangpan/common/interceptor/AuthInterceptor.java new file mode 100644 index 0000000..9ce8015 --- /dev/null +++ b/wangpan-common/src/main/java/com/wangpan/common/interceptor/AuthInterceptor.java @@ -0,0 +1,80 @@ +package com.wangpan.common.interceptor; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.wangpan.common.Result; +import com.wangpan.common.annotation.RequireAdmin; +import com.wangpan.common.annotation.RequireAuth; +import com.wangpan.common.util.AuthUtil; +import com.wangpan.common.entity.User; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.stereotype.Component; +import org.springframework.web.method.HandlerMethod; +import org.springframework.web.servlet.HandlerInterceptor; + +/** + * 认证拦截器 + * + * 统一处理 @RequireAuth 和 @RequireAdmin 注解: + * - @RequireAuth:验证 Token 有效性,将用户信息注入请求属性 + * - @RequireAdmin:在 RequireAuth 基础上额外校验管理员身份 + * + * 使用方式:在 Controller 方法上添加 @RequireAuth 或 @RequireAdmin 注解 + */ +@Component +public class AuthInterceptor implements HandlerInterceptor { + + private final AuthUtil authUtil; + private final ObjectMapper objectMapper; + + public AuthInterceptor(AuthUtil authUtil, ObjectMapper objectMapper) { + this.authUtil = authUtil; + this.objectMapper = objectMapper; + } + + /** 请求属性名:当前登录用户 */ + public static final String CURRENT_USER_ATTR = "currentUser"; + + @Override + public boolean preHandle(HttpServletRequest request, HttpServletResponse response, + Object handler) throws Exception { + // 仅处理方法级别的请求 + if (!(handler instanceof HandlerMethod handlerMethod)) { + return true; + } + + // 检查类级别和方法级别的注解 + boolean requireAuth = handlerMethod.getMethodAnnotation(RequireAuth.class) != null + || handlerMethod.getBeanType().getAnnotation(RequireAuth.class) != null; + boolean requireAdmin = handlerMethod.getMethodAnnotation(RequireAdmin.class) != null + || handlerMethod.getBeanType().getAnnotation(RequireAdmin.class) != null; + + // 无需认证的接口直接放行 + if (!requireAuth && !requireAdmin) { + return true; + } + + // 执行认证 + User user = authUtil.authenticate(request); + if (user == null) { + response.setStatus(401); + response.setContentType("application/json; charset=utf-8"); + Result result = Result.unauthorized("登录已过期,请重新登录"); + response.getWriter().write(objectMapper.writeValueAsString(result)); + return false; + } + + // 需要管理员权限时额外校验 + if (requireAdmin && !user.isAdmin()) { + response.setStatus(403); + response.setContentType("application/json; charset=utf-8"); + Result result = Result.forbidden("权限不足,仅管理员可进行此操作"); + response.getWriter().write(objectMapper.writeValueAsString(result)); + return false; + } + + // 将用户信息注入请求属性,Controller 可通过 request.getAttribute("currentUser") 获取 + request.setAttribute(CURRENT_USER_ATTR, user); + return true; + } +} \ No newline at end of file diff --git a/wangpan-common/src/main/java/com/wangpan/common/util/AuthUtil.java b/wangpan-common/src/main/java/com/wangpan/common/util/AuthUtil.java new file mode 100644 index 0000000..96955e6 --- /dev/null +++ b/wangpan-common/src/main/java/com/wangpan/common/util/AuthUtil.java @@ -0,0 +1,61 @@ +package com.wangpan.common.util; + +import com.wangpan.common.dal.repository.TokenRepository; +import com.wangpan.common.dal.repository.UserRepository; +import com.wangpan.common.entity.User; +import jakarta.servlet.http.HttpServletRequest; +import org.springframework.stereotype.Component; + +/** + * 认证工具类(Spring组件,实例方法) + * + * 从 HTTP 请求中提取并验证用户身份。 + * 所有 Controller 注入此 Bean 后调用实例方法 authenticate()。 + * + * 认证流程:提取 Bearer Token → 验证有效性 → 查询用户信息 + */ +@Component +public class AuthUtil { + + private final TokenRepository tokenRepository; + private final UserRepository userRepository; + + /** + * 构造函数注入:Spring 容器初始化时自动注入 Repository 实例 + */ + public AuthUtil(TokenRepository tokenRepository, UserRepository userRepository) { + this.tokenRepository = tokenRepository; + this.userRepository = userRepository; + } + + /** + * 从 HTTP 请求中认证用户 + * + * 流程: + * 1. 从请求头提取 Authorization: Bearer + * 2. 解析 Token 获取用户ID + * 3. 验证 Token 是否有效(未过期、未注销) + * 4. 查询用户信息并返回 + * + * @param request HTTP 请求对象 + * @return 认证成功的用户对象,认证失败返回 null + */ + public User authenticate(HttpServletRequest request) { + String authHeader = request.getHeader("Authorization"); + if (authHeader == null || !authHeader.startsWith("Bearer ")) { + return null; + } + + String token = authHeader.substring(7); + Long userId = TokenUtil.getUserId(token); + if (userId == null || !tokenRepository.isValid(token)) { + return null; + } + + try { + return userRepository.findById(userId); + } catch (Exception e) { + return null; + } + } +} \ No newline at end of file diff --git a/wangpan-common/src/main/java/com/wangpan/common/util/DBUtil.java b/wangpan-common/src/main/java/com/wangpan/common/util/DBUtil.java new file mode 100644 index 0000000..9777e16 --- /dev/null +++ b/wangpan-common/src/main/java/com/wangpan/common/util/DBUtil.java @@ -0,0 +1,83 @@ +package com.wangpan.common.util; + +import jakarta.annotation.PostConstruct; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; + +/** + * 数据库连接工具类(Spring Bean) + * 功能: + * 1. 从Spring配置中读取数据库配置(支持Jasypt自动解密) + * 2. 管理数据库连接的获取和释放 + * 3. 提供统一的资源关闭方法 + * + * 支持两种使用方式: + * 1. 静态方法调用(向后兼容):DBUtil.getConnection() + * 2. Spring Bean注入:@Resource private DBUtil dbUtil; + */ +@Component +public class DBUtil { + + /** 静态实例引用,用于静态方法访问 */ + private static DBUtil instance; + + /** 数据库连接URL */ + @Value("${spring.datasource.url}") + private String url; + + /** 数据库用户名 */ + @Value("${spring.datasource.username}") + private String username; + + /** 数据库密码(Jasypt自动解密) */ + @Value("${spring.datasource.password}") + private String password; + + /** JDBC驱动类名 */ + @Value("${spring.datasource.driver-class-name:com.mysql.cj.jdbc.Driver}") + private String driverClassName; + + /** + * Spring容器初始化时设置静态实例引用并加载驱动 + */ + @PostConstruct + public void init() { + instance = this; + try { + Class.forName(driverClassName); + System.out.println("✓ 数据库配置加载成功: " + url); + } catch (ClassNotFoundException e) { + System.err.println("✗ JDBC驱动加载失败:" + driverClassName); + throw new RuntimeException("MySQL驱动未找到,请检查依赖配置", e); + } + } + + // ==================== 静态方法(向后兼容) ==================== + + /** + * 获取数据库连接(静态方法) + */ + public static Connection getConnection() throws SQLException { + if (instance == null) { + throw new IllegalStateException("DBUtil未初始化,请确保Spring容器已启动"); + } + return instance.doGetConnection(); + } + + // ==================== 内部实现方法 ==================== + + private Connection doGetConnection() throws SQLException { + try { + return DriverManager.getConnection(url, username, password); + } catch (SQLException e) { + System.err.println("✗ 获取数据库连接失败:" + e.getMessage()); + throw e; + } + } +} diff --git a/wangpan-common/src/main/java/com/wangpan/common/util/EncryptUtil.java b/wangpan-common/src/main/java/com/wangpan/common/util/EncryptUtil.java new file mode 100644 index 0000000..2b89c23 --- /dev/null +++ b/wangpan-common/src/main/java/com/wangpan/common/util/EncryptUtil.java @@ -0,0 +1,299 @@ +package com.wangpan.common.util; + +import org.jasypt.encryption.pbe.StandardPBEStringEncryptor; +import org.jasypt.encryption.pbe.config.SimplePBEConfig; + +/** + * Jasypt 加解密工具类 + * 功能: + * 1. 对配置文件中的敏感信息进行加密(密码、用户名等) + * 2. 在运行时自动识别 enc() 格式并解密为明文 + * 3. 提供命令行工具生成加密后的密文 + * + * 加密格式:enc(密文) + * + * 安全说明: + * - 加密密钥通过环境变量 JASYPT_ENCRYPTOR_PASSWORD 传入 + * - 生产环境建议通过 JVM 参数或环境变量传递密钥 + */ +public class EncryptUtil { + + // ==================== 常量定义 ==================== + + /** + * 加密值的前缀标识 + * 当属性值以 "enc(" 开头时,表示该值是加密的,需要解密 + */ + private static final String ENCRYPT_PREFIX = "enc("; + + /** + * 加密值的后缀标识 + * 与前缀配对使用,完整格式为 enc(密文) + */ + private static final String ENCRYPT_SUFFIX = ")"; + + /** + * 默认加密算法 - PBEWithMD5AndDES + * PBE = Password Based Encryption(基于密码的加密) + * MD5 + DES 是经典组合,兼容性好 + * 如需更高安全性可改为 PBEWITHSHA256AND256BITAES-CBC-BC + */ + private static final String DEFAULT_ALGORITHM = "PBEWithMD5AndDES"; + + /** + * 默认加密密钥(仅用于开发环境!) + * 生产环境必须通过环境变量覆盖此值: + * -Djasypt.encryptor.password=你的密钥 + * 或设置环境变量:JASYPT_ENCRYPTOR_PASSWORD=你的密钥 + */ + private static final String DEFAULT_PASSWORD = "wangpan2024"; + + // ==================== 单例实例 ==================== + + /** 加密器实例(线程安全,全局复用) */ + private static volatile StandardPBEStringEncryptor encryptor; + + /** 是否已初始化标志 */ + private static volatile boolean initialized = false; + + // ==================== 公开方法 ==================== + + /** + * 解密属性值 + * + * 自动判断是否为加密格式: + * - 如果是 enc(密文) 格式 → 解密返回明文 + * - 如果是普通明文 → 直接返回原值 + * + * 使用示例: + * String password = decryptValue("enc(密文...)"); + * // 返回: 解密后的明文 + * + * @param value 属性值(可能是加密的 enc(...) 格式,也可能是普通明文) + * @return 解密后的明文,如果输入为null则返回null + */ + public static String decryptValue(String value) { + // 空值直接返回 + if (value == null || value.trim().isEmpty()) { + return value; + } + + // 检查是否为加密格式:以 enc( 开头且以 ) 结尾 + if (value.startsWith(ENCRYPT_PREFIX) && value.endsWith(ENCRYPT_SUFFIX)) { + // 提取括号内的密文部分 + String encryptedText = value.substring( + ENCRYPT_PREFIX.length(), + value.length() - ENCRYPT_SUFFIX.length() + ); + + // 调用解密方法 + return decrypt(encryptedText); + } + + // 非加密格式,直接返回原值 + return value; + } + + /** + * 加密明文字符串 + * + * 用于生成加密后的密文,写入配置文件 + * + * 使用示例: + * String encrypted = encrypt("明文"); + * // 返回: 加密后的密文 + * + * @param plainText 需要加密的明文 + * @return Base64编码的密文字符串 + */ + public static String encrypt(String plainText) { + return getEncryptor().encrypt(plainText); + } + + /** + * 解密密文字符串 + * + * 将Base64编码的密文还原为原始明文 + * + * @param encryptedText Base64编码的密文 + * @return 解密后的明文 + */ + public static String decrypt(String encryptedText) { + return getEncryptor().decrypt(encryptedText); + } + + /** + * 判断一个值是否为加密格式 + * + * @param value 待检查的字符串 + * @return true-是 enc(...) 格式,false-不是 + */ + public static boolean isEncrypted(String value) { + return value != null + && value.startsWith(ENCRYPT_PREFIX) + && value.endsWith(ENCRYPT_SUFFIX); + } + + // ==================== 私有方法 ==================== + + /** + * 获取加密器单例(懒加载 + 双重检查锁) + * + * 设计说明: + * 1. 使用双重检查锁确保线程安全 + * 2. 懒加载模式,首次调用时才初始化 + * 3. 加密器创建后全局复用,避免重复创建开销 + * + * 密钥优先级(从高到低): + * 1. 系统属性 jasypt.encryptor.password(JVM参数) + * 2. 环境变量 JASYPT_ENCRYPTOR_PASSWORD + * 3. 代码中的默认值(仅开发用) + * + * @return StandardPBEStringEncryptor 加密器实例 + */ + private static StandardPBEStringEncryptor getEncryptor() { + // 第一次检查(无锁) + if (!initialized || encryptor == null) { + synchronized (EncryptUtil.class) { + // 第二次检查(加锁后再次验证) + if (!initialized || encryptor == null) { + // 创建新的加密器实例 + encryptor = createEncryptor(); + initialized = true; + + System.out.println("✓ Jasypt加密器初始化完成"); + System.out.println(" 算法:" + DEFAULT_ALGORITHM); + System.out.println(" 密钥来源:" + getPasswordSource()); + } + } + } + return encryptor; + } + + /** + * 创建并配置加密器实例 + * + * 配置项: + * - algorithm: 加密算法 + * - password: 加密密钥(从环境变量或系统属性获取) + * - keyObtentionIterations: 密钥派生迭代次数(增加安全性) + * + * @return 已配置好的 StandardPBEStringEncryptor 实例 + */ + private static StandardPBEStringEncryptor createEncryptor() { + StandardPBEStringEncryptor newEncryptor = new StandardPBEStringEncryptor(); + + // 创建配置对象(使用SimplePBEConfig实现类) + SimplePBEConfig config = new SimplePBEConfig(); + config.setAlgorithm(DEFAULT_ALGORITHM); + config.setPassword(getEncryptionPassword()); + config.setKeyObtentionIterations(1000); // 密钥派生迭代次数 + + newEncryptor.setConfig(config); + return newEncryptor; + } + + /** + * 获取加密密钥 + * + * 优先级(从高到低): + * 1. JVM系统属性:-Djasypt.encryptor.password=xxx + * 2. 环境变量:JASYPT_ENCRYPTOR_PASSWORD + * 3. 默认值:dev_key(仅用于开发环境) + * + * @return 加密密钥字符串 + */ + private static String getEncryptionPassword() { + // 优先从JVM系统属性获取(最高优先级) + String password = System.getProperty("jasypt.encryptor.password"); + if (password != null && !password.isEmpty()) { + return password; + } + + // 其次从环境变量获取 + password = System.getenv("JASYPT_ENCRYPTOR_PASSWORD"); + if (password != null && !password.isEmpty()) { + return password; + } + + // 最后使用默认值(开发环境) + System.out.println("⚠ [安全警告] 使用默认加密密钥(开发模式)"); + System.out.println(" 生产环境请通过 -Djasypt.encryptor.password=xxx 设置密钥"); + return DEFAULT_PASSWORD; + } + + /** + * 获取当前使用的密钥来源描述(用于日志输出) + * + * @return 密钥来源说明字符串 + */ + private static String getPasswordSource() { + if (System.getProperty("jasypt.encryptor.password") != null) { + return "JVM参数 (-Djasypt.encryptor.password)"; + } else if (System.getenv("JASYPT_ENCRYPTOR_PASSWORD") != null) { + return "环境变量 (JASYPT_ENCRYPTOR_PASSWORD)"; + } else { + return "默认值(开发模式)"; + } + } + + // ==================== 主方法(命令行工具) ==================== + + /** + * 命令行工具入口 + * + * 用法示例: + * # 加密(生成密文) + * mvn exec:java -Dexec.mainClass="com.wangpan.util.EncryptUtil" -Dexec.args="encrypt 明文" + * + * # 解密(验证密文) + * mvn exec:java -Dexec.mainClass="com.wangpan.util.EncryptUtil" -Dexec.args="decrypt 密文" + * + * @param args 命令行参数:[encrypt|decrypt] [文本] + */ + public static void main(String[] args) { + if (args.length < 2) { + System.out.println("========================================"); + System.out.println(" Jasypt 加解密工具"); + System.out.println("========================================\n"); + System.out.println("用法:"); + System.out.println(" java EncryptUtil encrypt <明文> # 加密,生成密文"); + System.out.println(" java EncryptUtil decrypt <密文> # 解密,还原明文\n"); + System.out.println("示例:"); + System.out.println(" java EncryptUtil encrypt your_password"); + System.out.println(" java EncryptUtil decrypt encrypted_text\n"); + System.out.println("自定义密钥:"); + System.out.println(" -Djasypt.encryptor.password=你的密钥\n"); + return; + } + + String operation = args[0]; + String text = args[1]; + + try { + if ("encrypt".equalsIgnoreCase(operation)) { + // 执行加密 + String encrypted = encrypt(text); + System.out.println("\n✓ 加密结果:"); + System.out.println(" 明文:" + text); + System.out.println(" 密文:" + encrypted); + System.out.println("\n配置文件中使用方式:"); + System.out.println(" db.password=enc(" + encrypted + ")"); + + } else if ("decrypt".equalsIgnoreCase(operation)) { + // 执行解密 + String decrypted = decrypt(text); + System.out.println("\n✓ 解密结果:"); + System.out.println(" 密文:" + text); + System.out.println(" 明文:" + decrypted); + + } else { + System.err.println("未知操作:" + operation + ",支持的操作:encrypt / decrypt"); + } + + } catch (Exception e) { + System.err.println("操作失败:" + e.getMessage()); + e.printStackTrace(); + } + } +} diff --git a/wangpan-common/src/main/java/com/wangpan/common/util/IconLogoFetcher.java b/wangpan-common/src/main/java/com/wangpan/common/util/IconLogoFetcher.java new file mode 100644 index 0000000..8807199 --- /dev/null +++ b/wangpan-common/src/main/java/com/wangpan/common/util/IconLogoFetcher.java @@ -0,0 +1,380 @@ +package com.wangpan.common.util; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.ByteArrayOutputStream; +import java.io.InputStream; +import java.net.HttpURLConnection; +import java.net.URI; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Icon Logo 抓取工具类 + * + * 功能: + * 1. 根据网址自动抓取网站的 logo 图标 + * 2. 将图标上传到 MinIO 存储 + * 3. 返回 MinIO 中的访问 URL + * + * 抓取策略(按优先级): + * 1. og:image(Open Graph 图片,通常是高质量 logo) + * 2. apple-touch-icon(苹果设备图标,质量较好) + * 3. link rel="icon"(标准 favicon) + * 4. 默认 /favicon.ico + */ +public class IconLogoFetcher { + + private static final Logger log = LoggerFactory.getLogger(IconLogoFetcher.class); + + /** 连接超时时间(毫秒) */ + private static final int CONNECT_TIMEOUT = 5000; + + /** 读取超时时间(毫秒) */ + private static final int READ_TIMEOUT = 10000; + + /** 最大下载大小(1MB) */ + private static final int MAX_DOWNLOAD_SIZE = 1024 * 1024; + + // ==================== 正则匹配模式 ==================== + + /** 匹配 og:image meta 标签 */ + private static final Pattern OG_IMAGE_PATTERN = Pattern.compile( + "]+property=[\"']og:image[\"'][^>]+content=[\"']([^\"']+)[\"']", + Pattern.CASE_INSENSITIVE); + + private static final Pattern OG_IMAGE_CONTENT_PATTERN = Pattern.compile( + "]+content=[\"']([^\"']+)[\"'][^>]+property=[\"']og:image[\"']", + Pattern.CASE_INSENSITIVE); + + /** 匹配 apple-touch-icon */ + private static final Pattern APPLE_TOUCH_ICON_PATTERN = Pattern.compile( + "]+rel=[\"']apple-touch-icon[^\"']*[\"'][^>]+href=[\"']([^\"']+)[\"']", + Pattern.CASE_INSENSITIVE); + + private static final Pattern APPLE_ICON_HREF_PATTERN = Pattern.compile( + "]+href=[\"']([^\"']+)[\"'][^>]+rel=[\"']apple-touch-icon[^\"']*[\"']", + Pattern.CASE_INSENSITIVE); + + /** 匹配标准 favicon */ + private static final Pattern LINK_ICON_PATTERN = Pattern.compile( + "]+rel=[\"'](?:shortcut )?icon[\"'][^>]+href=[\"']([^\"']+)[\"']", + Pattern.CASE_INSENSITIVE); + + private static final Pattern LINK_HREF_REL_PATTERN = Pattern.compile( + "]+href=[\"']([^\"']+)[\"'][^>]+rel=[\"'](?:shortcut )?icon[\"']", + Pattern.CASE_INSENSITIVE); + + /** + * 根据网址抓取 logo 并上传到 MinIO + * + * @param websiteUrl 网站地址(如 https://www.baidu.com) + * @param userId 用户ID(用于 MinIO 路径隔离) + * @return 上传成功返回 MinIO 访问 URL,失败返回 null + */ + public static String fetchAndUploadLogo(String websiteUrl, Long userId) { + try { + // 按优先级尝试获取高质量 logo + String logoUrl = extractLogoFromHtml(websiteUrl); + + // 如果 HTML 中没找到任何 logo,使用默认 /favicon.ico + if (logoUrl == null) { + logoUrl = resolveUrl(websiteUrl, "/favicon.ico"); + } + + log.info("[IconLogoFetcher] 准备下载 logo: {}", logoUrl); + + // 下载图片 + byte[] imageBytes = downloadImage(logoUrl); + if (imageBytes == null || imageBytes.length == 0) { + log.warn("[IconLogoFetcher] logo 下载失败: {}", logoUrl); + return null; + } + + // 确定文件扩展名 + String ext = guessExtension(imageBytes, logoUrl); + + // 上传到 MinIO + String objectName = "icons/" + userId + "/" + System.currentTimeMillis() + "." + ext; + String contentType = guessContentType(ext); + String uploaded = MinioUtil.uploadFile(objectName, imageBytes, contentType); + + if (uploaded != null) { + String fileUrl = MinioUtil.getFileUrl(uploaded); + log.info("[IconLogoFetcher] logo 上传成功: {} -> {}", websiteUrl, fileUrl); + return fileUrl; + } + + return null; + } catch (Exception e) { + log.warn("[IconLogoFetcher] 抓取 logo 失败: {}", websiteUrl, e); + return null; + } + } + + /** + * 从网页 HTML 中提取高质量 logo 地址 + * 按优先级尝试:og:image > apple-touch-icon > favicon + */ + private static String extractLogoFromHtml(String websiteUrl) { + try { + String html = fetchHtml(websiteUrl); + if (html == null || html.isEmpty()) { + return null; + } + + // 只取前 50KB 内容来查找 logo(通常在 head 区域) + String headHtml = html.length() > 51200 ? html.substring(0, 51200) : html; + + // 1. 优先尝试 og:image(通常是高质量 logo) + String ogImage = extractOgImage(headHtml); + if (ogImage != null) { + String resolved = resolveUrl(websiteUrl, ogImage); + if (resolved != null) { + log.info("[IconLogoFetcher] 找到 og:image: {}", resolved); + return resolved; + } + } + + // 2. 尝试 apple-touch-icon(质量较好) + String appleIcon = extractAppleTouchIcon(headHtml); + if (appleIcon != null) { + String resolved = resolveUrl(websiteUrl, appleIcon); + if (resolved != null) { + log.info("[IconLogoFetcher] 找到 apple-touch-icon: {}", resolved); + return resolved; + } + } + + // 3. 尝试标准 favicon + String favicon = extractFavicon(headHtml); + if (favicon != null) { + String resolved = resolveUrl(websiteUrl, favicon); + if (resolved != null) { + log.info("[IconLogoFetcher] 找到 favicon: {}", resolved); + return resolved; + } + } + + return null; + } catch (Exception e) { + log.warn("[IconLogoFetcher] 解析 HTML 提取 logo 失败: {}", websiteUrl, e); + return null; + } + } + + /** + * 提取 og:image(Open Graph 图片) + */ + private static String extractOgImage(String html) { + String match = extractMatch(html, OG_IMAGE_PATTERN); + if (match != null) { + return match; + } + return extractMatch(html, OG_IMAGE_CONTENT_PATTERN); + } + + /** + * 提取 apple-touch-icon + */ + private static String extractAppleTouchIcon(String html) { + String match = extractMatch(html, APPLE_TOUCH_ICON_PATTERN); + if (match != null) { + return match; + } + return extractMatch(html, APPLE_ICON_HREF_PATTERN); + } + + /** + * 提取标准 favicon + */ + private static String extractFavicon(String html) { + String match = extractMatch(html, LINK_ICON_PATTERN); + if (match != null) { + return match; + } + return extractMatch(html, LINK_HREF_REL_PATTERN); + } + + private static String extractMatch(String html, Pattern pattern) { + Matcher matcher = pattern.matcher(html); + if (matcher.find()) { + return matcher.group(1); + } + return null; + } + + /** + * 获取网页 HTML 内容(仅获取前 30KB) + */ + private static String fetchHtml(String url) { + HttpURLConnection conn = null; + try { + conn = (HttpURLConnection) new URI(url).toURL().openConnection(); + conn.setRequestMethod("GET"); + conn.setConnectTimeout(CONNECT_TIMEOUT); + conn.setReadTimeout(READ_TIMEOUT); + conn.setRequestProperty("User-Agent", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"); + conn.setInstanceFollowRedirects(true); + + int code = conn.getResponseCode(); + if (code != 200) { + log.debug("[IconLogoFetcher] 网页返回非200状态: {} -> {}", url, code); + return null; + } + + String contentType = conn.getContentType(); + if (contentType != null && !contentType.contains("text/html")) { + log.debug("[IconLogoFetcher] 非HTML内容,跳过: {} -> {}", url, contentType); + return null; + } + + try (InputStream is = conn.getInputStream()) { + byte[] bytes = readLimited(is, 30 * 1024); + return new String(bytes, "UTF-8"); + } + } catch (Exception e) { + log.debug("[IconLogoFetcher] 获取网页失败: {} -> {}", url, e.getMessage()); + return null; + } finally { + if (conn != null) { + conn.disconnect(); + } + } + } + + /** + * 下载图片数据 + */ + private static byte[] downloadImage(String imageUrl) { + HttpURLConnection conn = null; + try { + conn = (HttpURLConnection) new URI(imageUrl).toURL().openConnection(); + conn.setRequestMethod("GET"); + conn.setConnectTimeout(CONNECT_TIMEOUT); + conn.setReadTimeout(READ_TIMEOUT); + conn.setRequestProperty("User-Agent", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"); + conn.setInstanceFollowRedirects(true); + + int code = conn.getResponseCode(); + if (code != 200) { + log.debug("[IconLogoFetcher] 图片下载返回非200: {} -> {}", imageUrl, code); + return null; + } + + try (InputStream is = conn.getInputStream()) { + return readLimited(is, MAX_DOWNLOAD_SIZE); + } + } catch (Exception e) { + log.debug("[IconLogoFetcher] 下载图片失败: {} -> {}", imageUrl, e.getMessage()); + return null; + } finally { + if (conn != null) { + conn.disconnect(); + } + } + } + + /** + * 从输入流中读取有限大小的数据 + */ + private static byte[] readLimited(InputStream is, int maxBytes) throws Exception { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + byte[] buffer = new byte[4096]; + int totalRead = 0; + int bytesRead; + while ((bytesRead = is.read(buffer)) != -1) { + int toWrite = Math.min(bytesRead, maxBytes - totalRead); + baos.write(buffer, 0, toWrite); + totalRead += toWrite; + if (totalRead >= maxBytes) { + break; + } + } + return baos.toByteArray(); + } + + /** + * 将相对 URL 解析为绝对 URL + */ + private static String resolveUrl(String baseUrl, String relativeUrl) { + try { + if (relativeUrl == null || relativeUrl.isEmpty()) { + return null; + } + // 已经是完整 URL + if (relativeUrl.startsWith("http://") || relativeUrl.startsWith("https://")) { + return relativeUrl; + } + // 协议相对 URL(如 //example.com/favicon.ico) + if (relativeUrl.startsWith("//")) { + String scheme = baseUrl.startsWith("https://") ? "https:" : "http:"; + return scheme + relativeUrl; + } + // 使用 URI.resolve 解析相对路径 + URI base = new URI(baseUrl); + return base.resolve(relativeUrl).toString(); + } catch (Exception e) { + log.debug("[IconLogoFetcher] URL 解析失败: base={}, relative={}", baseUrl, relativeUrl); + return null; + } + } + + /** + * 根据文件头魔术字节猜测图片扩展名 + */ + private static String guessExtension(byte[] data, String url) { + if (data.length >= 8) { + // PNG: 89 50 4E 47 + if (data[0] == (byte) 0x89 && data[1] == (byte) 0x50) { + return "png"; + } + // JPEG: FF D8 FF + if (data[0] == (byte) 0xFF && data[1] == (byte) 0xD8) { + return "jpg"; + } + // GIF: 47 49 46 + if (data[0] == (byte) 0x47 && data[1] == (byte) 0x49) { + return "gif"; + } + // WEBP: 52 49 46 46 ... 57 45 42 50 + if (data[0] == (byte) 0x52 && data.length >= 12 + && data[8] == (byte) 0x57 && data[9] == (byte) 0x45) { + return "webp"; + } + // SVG: 以 "image/png"; + case "jpg", "jpeg" -> "image/jpeg"; + case "gif" -> "image/gif"; + case "webp" -> "image/webp"; + case "svg" -> "image/svg+xml"; + default -> "image/x-icon"; + }; + } +} diff --git a/wangpan-common/src/main/java/com/wangpan/common/util/MinioUtil.java b/wangpan-common/src/main/java/com/wangpan/common/util/MinioUtil.java new file mode 100644 index 0000000..a5959cd --- /dev/null +++ b/wangpan-common/src/main/java/com/wangpan/common/util/MinioUtil.java @@ -0,0 +1,241 @@ +package com.wangpan.common.util; + +import io.minio.*; +import io.minio.http.Method; +import jakarta.annotation.PostConstruct; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; + +import java.io.InputStream; +import java.util.concurrent.TimeUnit; +import java.util.logging.Level; +import java.util.logging.Logger; + +/** + * MinIO 对象存储工具类(Spring Bean) + * + * 功能: + * 1. 从Spring配置中读取MinIO配置(支持Jasypt自动解密) + * 2. 初始化 MinIO 客户端连接 + * 3. 上传文件到指定桶(Bucket) + * 4. 获取文件的访问 URL(预签名 URL 或公开 URL) + * + * 支持两种使用方式: + * 1. 静态方法调用(向后兼容):MinioUtil.upload(...) + * 2. Spring Bean注入:@Resource private MinioUtil minioUtil; + */ +@Component +@ConditionalOnProperty(prefix = "minio", name = "endpoint") +public class MinioUtil { + + /** 日志记录器 */ + private static final Logger LOGGER = Logger.getLogger(MinioUtil.class.getName()); + + /** 静态实例引用,用于静态方法访问 */ + private static MinioUtil instance; + + /** MinIO 服务端点地址(Jasypt自动解密) */ + @Value("${minio.endpoint}") + private String endpoint; + + /** 访问密钥(Access Key,Jasypt自动解密) */ + @Value("${minio.accessKey}") + private String accessKey; + + /** 秘密密钥(Secret Key,Jasypt自动解密) */ + @Value("${minio.secretKey}") + private String secretKey; + + /** 存储桶名称 */ + @Value("${minio.bucketName}") + private String bucketName; + + /** 预签名 URL 有效期(7天) */ + private static final int PRESIGNED_URL_EXPIRY_DAYS = 7; + + /** MinIO 客户端实例(单例模式) */ + private static volatile MinioClient client; + + /** + * Spring容器初始化时设置静态实例引用并初始化MinIO客户端 + */ + @PostConstruct + public void init() { + instance = this; + initClient(); + } + + /** + * 初始化 MinIO 客户端 + */ + private void initClient() { + try { + client = MinioClient.builder() + .endpoint(endpoint) + .credentials(accessKey, secretKey) + .build(); + + // 确保存储桶存在 + ensureBucketExists(); + + LOGGER.info("[MinioUtil] MinIO 客户端初始化成功: " + endpoint); + } catch (Exception e) { + LOGGER.log(Level.SEVERE, "[MinioUtil] MinIO 客户端初始化失败", e); + throw new RuntimeException("MinIO 初始化失败: " + e.getMessage(), e); + } + } + + /** + * 获取 MinIO 客户端实例(懒加载 + 双重检查锁) + * + * @return MinioClient 实例 + */ + private static MinioClient getClient() { + if (client == null) { + synchronized (MinioUtil.class) { + if (client == null && instance != null) { + instance.initClient(); + } + } + } + return client; + } + + /** + * 确保存储桶存在,不存在则创建 + */ + private void ensureBucketExists() { + try { + boolean exists = client.bucketExists( + BucketExistsArgs.builder().bucket(bucketName).build() + ); + if (!exists) { + client.makeBucket( + MakeBucketArgs.builder().bucket(bucketName).build() + ); + LOGGER.info("[MinioUtil] 创建存储桶成功: " + bucketName); + } else { + LOGGER.info("[MinioUtil] 存储桶已存在: " + bucketName); + } + } catch (Exception e) { + LOGGER.log(Level.WARNING, "[MinioUtil] 检查/创建存储桶失败", e); + } + } + + // ==================== 静态方法(向后兼容) ==================== + + /** + * 上传文件到 MinIO(静态方法) + * + * @param inputStream 文件输入流 + * @param objectName 对象名称(文件路径,如 posters/123_456.jpg) + * @param contentType 文件 MIME 类型(如 image/jpeg) + * @return 上传成功返回 MinIO 对象名称,失败返回 null + */ + public static String upload(InputStream inputStream, String objectName, String contentType) { + if (instance == null) { + throw new IllegalStateException("MinioUtil未初始化,请确保Spring容器已启动"); + } + return instance.doUpload(inputStream, objectName, contentType); + } + + /** + * 上传字节数组到 MinIO(静态方法) + * + * @param objectName 对象名称 + * @param fileBytes 文件字节数组 + * @param contentType 文件 MIME 类型 + * @return 上传成功返回 MinIO 对象名称,失败返回 null + */ + public static String uploadFile(String objectName, byte[] fileBytes, String contentType) { + if (instance == null) { + throw new IllegalStateException("MinioUtil未初始化,请确保Spring容器已启动"); + } + return instance.doUploadFile(objectName, fileBytes, contentType); + } + + /** + * 获取文件的访问 URL(静态方法) + * + * @param objectName 对象名称 + * @return 文件访问 URL,失败返回 null + */ + public static String getFileUrl(String objectName) { + if (instance == null) { + throw new IllegalStateException("MinioUtil未初始化,请确保Spring容器已启动"); + } + return instance.doGetFileUrl(objectName); + } + + /** + * 获取 MinIO 服务端点地址(静态方法) + * + * @return 端点地址 + */ + public static String getEndpoint() { + return instance != null ? instance.endpoint : null; + } + + /** + * 获取存储桶名称(静态方法) + * + * @return 桶名称 + */ + public static String getBucketName() { + return instance != null ? instance.bucketName : null; + } + + // ==================== 内部实现方法 ==================== + + private String doUpload(InputStream inputStream, String objectName, String contentType) { + try { + getClient().putObject( + PutObjectArgs.builder() + .bucket(bucketName) + .object(objectName) + .stream(inputStream, -1, 10 * 1024 * 1024) // 最大 10MB + .contentType(contentType) + .build() + ); + + LOGGER.info("[MinioUtil] 文件上传成功: " + objectName); + return objectName; + + } catch (Exception e) { + LOGGER.log(Level.WARNING, "[MinioUtil] 文件上传失败: " + objectName, e); + return null; + } + } + + private String doUploadFile(String objectName, byte[] fileBytes, String contentType) { + try (var inputStream = new java.io.ByteArrayInputStream(fileBytes)) { + return doUpload(inputStream, objectName, contentType); + } catch (Exception e) { + LOGGER.log(Level.WARNING, "[MinioUtil] 文件上传失败: " + objectName, e); + return null; + } + } + + private String doGetFileUrl(String objectName) { + try { + // 尝试生成预签名 URL(适用于私有桶) + String presignedUrl = getClient().getPresignedObjectUrl( + GetPresignedObjectUrlArgs.builder() + .method(Method.GET) + .bucket(bucketName) + .object(objectName) + .expiry(PRESIGNED_URL_EXPIRY_DAYS, TimeUnit.DAYS) + .build() + ); + + LOGGER.info("[MinioUtil] 生成预签名 URL 成功: " + objectName); + return presignedUrl; + + } catch (Exception e) { + LOGGER.log(Level.WARNING, "[MinioUtil] 生成预签名 URL 失败: " + objectName, e); + // 如果预签名失败,尝试返回直接访问 URL + return endpoint + "/" + bucketName + "/" + objectName; + } + } +} diff --git a/wangpan-common/src/main/java/com/wangpan/common/util/SchemaUtil.java b/wangpan-common/src/main/java/com/wangpan/common/util/SchemaUtil.java new file mode 100644 index 0000000..fe22dc6 --- /dev/null +++ b/wangpan-common/src/main/java/com/wangpan/common/util/SchemaUtil.java @@ -0,0 +1,60 @@ +package com.wangpan.common.util; + +import java.sql.Connection; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Statement; + +/** + * 数据库表结构工具类 + * + * 提供数据库表和字段的检查与创建功能,消除各服务 DatabaseInitializer 中的重复代码。 + * 包含两个核心方法: + * - createTableIfNotExists:检查表是否存在,不存在则创建 + * - ensureColumnExists:检查字段是否存在,不存在则添加 + */ +public class SchemaUtil { + + /** + * 如果表不存在则创建 + * + * @param conn 数据库连接 + * @param tableName 表名 + * @param createSql 建表 SQL 语句 + */ + public static void createTableIfNotExists(Connection conn, String tableName, String createSql) { + try (Statement stmt = conn.createStatement(); + ResultSet rs = stmt.executeQuery("SHOW TABLES LIKE '" + tableName + "'")) { + + if (!rs.next()) { + stmt.executeUpdate(createSql); + System.out.println(" ✓ 创建表:" + tableName); + } else { + System.out.println(" ✓ 表已存在:" + tableName); + } + } catch (SQLException e) { + System.err.println(" ⚠ 创建表失败 [" + tableName + "]:" + e.getMessage()); + } + } + + /** + * 检查列是否存在,不存在则添加 + * + * @param conn 数据库连接 + * @param tableName 表名 + * @param columnName 列名 + * @param columnDef 列定义(类型、默认值、注释等) + */ + public static void ensureColumnExists(Connection conn, String tableName, String columnName, String columnDef) { + try (Statement stmt = conn.createStatement(); + ResultSet rs = stmt.executeQuery("SHOW COLUMNS FROM " + tableName + " LIKE '" + columnName + "'")) { + + if (!rs.next()) { + stmt.executeUpdate("ALTER TABLE " + tableName + " ADD COLUMN " + columnName + " " + columnDef); + System.out.println(" ✓ [" + tableName + "] 添加字段:" + columnName); + } + } catch (SQLException e) { + System.err.println(" 检查字段失败 [" + tableName + "." + columnName + "]:" + e.getMessage()); + } + } +} diff --git a/wangpan-common/src/main/java/com/wangpan/common/util/TokenUtil.java b/wangpan-common/src/main/java/com/wangpan/common/util/TokenUtil.java new file mode 100644 index 0000000..c5bc5f9 --- /dev/null +++ b/wangpan-common/src/main/java/com/wangpan/common/util/TokenUtil.java @@ -0,0 +1,300 @@ +package com.wangpan.common.util; + +import java.security.MessageDigest; +import java.nio.charset.StandardCharsets; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.UUID; + +/** + * 用户令牌(Token)管理工具类 - Java 17 版本 + * + * 功能: + * 1. 登录成功后生成唯一令牌(有效期7天) + * 2. 验证令牌有效性(解密 + 过期检查) + * 3. 令牌刷新/撤销功能 + * + * 设计说明: + * - Token = Jasypt加密(userId + UUID + expireTime + secret) + * - 存储在数据库 wp_user_token 表中(服务重启后仍有效) + * - 内存缓存活跃令牌(减少数据库查询) + * - 7天自动过期机制 + * + * 安全特性: + * - 使用Jasypt加密存储(与数据库密码同一套加密体系) + * - 包含用户ID + UUID + 时间戳防伪造 + * - IP绑定可选(防止令牌被盗用) + */ +public class TokenUtil { + + // ==================== 常量定义 ==================== + + /** 令牌有效期:7天(单位:小时) */ + public static final long TOKEN_EXPIRE_HOURS = 7 * 24; // 168小时 + + /** 令牌分隔符(用于拼接原始数据) */ + private static final String SEPARATOR = "|"; + + /** 时间格式化器 */ + private static final DateTimeFormatter TIME_FORMATTER = + DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); + + /** 令牌前缀标识(用于快速识别是否为加密令牌) */ + private static final String TOKEN_PREFIX = "tk_"; + + // ==================== 内存缓存(性能优化) ==================== + + /** + * 活跃令牌缓存 + * Key: 加密后的Token值 + * Value: TokenInfo对象(包含userId和过期时间) + * + * 设计说明: + * - 使用 ConcurrentHashMap 保证线程安全 + * - 缓存减少对数据库的频繁查询 + * - 服务重启后从数据库重新加载 + */ + private static final java.util.concurrent.ConcurrentHashMap tokenCache = + new java.util.concurrent.ConcurrentHashMap<>(); + + /** 最大缓存数量(防止内存泄漏) */ + private static final int MAX_CACHE_SIZE = 10000; + + // ==================== 核心方法 ==================== + + /** + * 生成新的用户令牌 + * + * 流程: + * 1. 生成UUID作为唯一标识 + * 2. 计算过期时间(当前时间 + 7天) + * 3. 拼接原始字符串:userId|uuid|expireTime|secret + * 4. 使用Jasypt加密得到最终Token + * 5. 存入内存缓存 + * + * @param userId 用户ID + * @return 加密后的令牌字符串(如 tk_abc123...) + */ + public static String generateToken(Long userId) { + // 生成唯一UUID + var uuid = UUID.randomUUID().toString().replace("-", ""); + + // 计算过期时间 + var expireTime = LocalDateTime.now().plusHours(TOKEN_EXPIRE_HOURS); + var expireTimeStr = expireTime.format(TIME_FORMATTER); + + // 拼接原始数据(明文):userId|uuid|expireTime|secret + var rawToken = userId + SEPARATOR + uuid + SEPARATOR + expireTimeStr + SEPARATOR + getSecret(); + + // 使用Jasypt加密 + var encryptedToken = EncryptUtil.encrypt(rawToken); + + // 添加前缀便于识别 + var finalToken = TOKEN_PREFIX + encryptedToken; + + // 存入内存缓存 + cacheToken(finalToken, new TokenInfo(userId, expireTime)); + + System.out.println("[TokenUtil] 为用户 [" + userId + "] 生成新令牌,有效期至:" + expireTimeStr); + + return finalToken; + } + + /** + * 从令牌中快速获取用户ID(简化版validateToken) + * + * @param token 客户端传入的令牌 + * @return 用户ID,无效返回null + */ + public static Long getUserId(String token) { + var info = validateToken(token); + return info != null ? info.userId : null; + } + + /** + * 验证令牌有效性并获取用户ID + * + * 验证流程: + * 1. 先查内存缓存(快速路径) + * 2. 缓存未命中则解密验证(慢速路径) + * 3. 检查是否过期 + * 4. 返回用户ID或null + * + * @param token 客户端传入的令牌 + * @return TokenInfo对象(含userId),无效返回null + */ + public static TokenInfo validateToken(String token) { + // 空值校验 + if (token == null || token.isBlank()) { + System.out.println("[TokenUtil] 令牌为空"); + return null; + } + + // 格式校验(必须以 tk_ 开头) + if (!token.startsWith(TOKEN_PREFIX)) { + System.out.println("[TokenUtil] 令牌格式错误(缺少前缀)"); + return null; + } + + // ========== 第1步:先查内存缓存(快速路径) ========== + var cachedInfo = tokenCache.get(token); + if (cachedInfo != null) { + // 检查缓存中的令牌是否已过期 + if (LocalDateTime.now().isBefore(cachedInfo.expireTime)) { + return cachedInfo; // 缓存命中且未过期 → 直接返回 + } else { + // 已过期 → 从缓存移除 + tokenCache.remove(token); + System.out.println("[TokenUtil] 缓存中的令牌已过期"); + return null; + } + } + + // ========== 第2步:解密验证(慢速路径) ========== + try { + // 去掉前缀,提取加密部分 + var encryptedPart = token.substring(TOKEN_PREFIX.length()); + + // 解密得到原始字符串 + var rawToken = EncryptUtil.decrypt(encryptedPart); + + // 解析各字段 + var parts = rawToken.split("\\|", -1); // -1保留空字段 + if (parts.length < 4) { + System.err.println("[TokenUtil] 令牌解析失败(字段数不足)"); + return null; + } + + var userId = Long.parseLong(parts[0]); + var expireTimeStr = parts[2]; + var expireTime = LocalDateTime.parse(expireTimeStr, TIME_FORMATTER); + + // ========== 第3步:检查是否过期 ========== + var now = LocalDateTime.now(); + if (now.isAfter(expireTime)) { + System.out.println("[TokenUtil] 令牌已过期(过期时间:" + expireTimeStr + ")"); + return null; + } + + // ========== 第4步:重新缓存(下次走快速路径) ========== + var info = new TokenInfo(userId, expireTime); + cacheToken(token, info); + + return info; + + } catch (Exception e) { + System.err.println("[TokenUtil] 令牌验证失败:" + e.getMessage()); + return null; + } + } + + /** + * 撤销令牌(登出时调用) + * 从缓存和数据库中移除 + * + * @param token 要撤销的令牌 + */ + public static void revokeToken(String token) { + if (token != null) { + // 从内存缓存移除 + tokenCache.remove(token); + System.out.println("[TokenUtil] 令牌已撤销"); + } + } + + /** + * 清理所有过期的缓存令牌 + * 可由定时任务调用,防止内存泄漏 + */ + public static void cleanExpiredTokens() { + var now = LocalDateTime.now(); + var iterator = tokenCache.entrySet().iterator(); + var cleanedCount = 0; + + while (iterator.hasNext()) { + var entry = iterator.next(); + if (now.isAfter(entry.getValue().expireTime)) { + iterator.remove(); + cleanedCount++; + } + } + + if (cleanedCount > 0) { + System.out.println("[TokenUtil] 清理了 " + cleanedCount + " 个过期令牌缓存"); + } + } + + // ==================== 私有辅助方法 ==================== + + /** + * 将令牌存入内存缓存(带容量限制) + */ + private static void cacheToken(String token, TokenInfo info) { + // 超过最大容量时清理旧条目 + if (tokenCache.size() >= MAX_CACHE_SIZE) { + cleanExpiredTokens(); + } + tokenCache.put(token, info); + } + + /** + * 获取签名密钥(用于防止令牌被伪造) + * 结合固定密钥 + 系统属性,增强安全性 + */ + private static String getSecret() { + // 使用固定的签名密钥(生产环境应改为配置项) + var baseSecret = "wp-token-secret"; + // 加入系统属性增加随机性 + var systemProp = System.getProperty("os.name", "") + System.getProperty("user.name", ""); + return md5(baseSecret + systemProp).substring(0, 16); // 取前16位作为secret + } + + /** + * MD5哈希计算 + */ + private static String md5(String input) { + try { + var md = MessageDigest.getInstance("MD5"); + var digest = md.digest(input.getBytes(StandardCharsets.UTF_8)); + var sb = new StringBuilder(); + for (var b : digest) { + sb.append(String.format("%02x", b & 0xff)); + } + return sb.toString(); + } catch (Exception e) { + throw new RuntimeException("MD5计算失败", e); + } + } + + // ==================== 内部类 ==================== + + /** + * 令牌信息内部类 + * 用于封装令牌关联的用户信息和过期时间 + */ + public static class TokenInfo { + /** 关联的用户ID */ + public final Long userId; + /** 令牌过期时间 */ + public final LocalDateTime expireTime; + + public TokenInfo(Long userId, LocalDateTime expireTime) { + this.userId = userId; + this.expireTime = expireTime; + } + + /** + * 判断令牌是否即将过期(剩余不足1天) + */ + public boolean isExpiringSoon() { + return LocalDateTime.now().plusDays(1).isAfter(expireTime); + } + + /** + * 获取剩余有效时间(秒) + */ + public long getRemainingSeconds() { + return java.time.Duration.between(LocalDateTime.now(), expireTime).getSeconds(); + } + } +} diff --git a/wangpan-common/src/main/resources/com/wangpan/common/dal/mapper/DriveFolderMapper.xml b/wangpan-common/src/main/resources/com/wangpan/common/dal/mapper/DriveFolderMapper.xml new file mode 100644 index 0000000..cd019e6 --- /dev/null +++ b/wangpan-common/src/main/resources/com/wangpan/common/dal/mapper/DriveFolderMapper.xml @@ -0,0 +1,8 @@ + + + + + + + diff --git a/wangpan-common/src/main/resources/com/wangpan/common/dal/mapper/DriveMapper.xml b/wangpan-common/src/main/resources/com/wangpan/common/dal/mapper/DriveMapper.xml new file mode 100644 index 0000000..8d36ec4 --- /dev/null +++ b/wangpan-common/src/main/resources/com/wangpan/common/dal/mapper/DriveMapper.xml @@ -0,0 +1,8 @@ + + + + + + + diff --git a/wangpan-common/src/main/resources/com/wangpan/common/dal/mapper/DriveMovieMapper.xml b/wangpan-common/src/main/resources/com/wangpan/common/dal/mapper/DriveMovieMapper.xml new file mode 100644 index 0000000..3137d1f --- /dev/null +++ b/wangpan-common/src/main/resources/com/wangpan/common/dal/mapper/DriveMovieMapper.xml @@ -0,0 +1,18 @@ + + + + + + + + + + diff --git a/wangpan-common/src/main/resources/com/wangpan/common/dal/mapper/LoginLogMapper.xml b/wangpan-common/src/main/resources/com/wangpan/common/dal/mapper/LoginLogMapper.xml new file mode 100644 index 0000000..eebb206 --- /dev/null +++ b/wangpan-common/src/main/resources/com/wangpan/common/dal/mapper/LoginLogMapper.xml @@ -0,0 +1,8 @@ + + + + + + + diff --git a/wangpan-common/src/main/resources/com/wangpan/common/dal/mapper/MovieMapper.xml b/wangpan-common/src/main/resources/com/wangpan/common/dal/mapper/MovieMapper.xml new file mode 100644 index 0000000..c3b6589 --- /dev/null +++ b/wangpan-common/src/main/resources/com/wangpan/common/dal/mapper/MovieMapper.xml @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + diff --git a/wangpan-common/src/main/resources/com/wangpan/common/dal/mapper/TmdbCacheMapper.xml b/wangpan-common/src/main/resources/com/wangpan/common/dal/mapper/TmdbCacheMapper.xml new file mode 100644 index 0000000..4072efb --- /dev/null +++ b/wangpan-common/src/main/resources/com/wangpan/common/dal/mapper/TmdbCacheMapper.xml @@ -0,0 +1,90 @@ + + + + + + + + + + + + + + + + + + + + + INSERT INTO wp_tmdb_cache ( + movie_id, user_id, tmdb_id, title, original_title, overview, + release_date, poster_path, backdrop_path, local_poster_url, + tmdb_rating, vote_count, runtime, genres, directors, casts, + original_language, production_countries, status, cache_time + ) VALUES ( + #{data.movieId}, #{data.userId}, #{data.tmdbId}, #{data.title}, #{data.originalTitle}, #{data.overview}, + #{data.releaseDate}, #{data.posterPath}, #{data.backdropPath}, #{data.localPosterUrl}, + #{data.tmdbRating}, #{data.voteCount}, #{data.runtime}, #{data.genres}, #{data.directors}, #{data.casts}, + #{data.originalLanguage}, #{data.productionCountries}, 'active', NOW() + ) + ON DUPLICATE KEY UPDATE + tmdb_id = VALUES(tmdb_id), + title = VALUES(title), + original_title = VALUES(original_title), + overview = VALUES(overview), + release_date = VALUES(release_date), + poster_path = VALUES(poster_path), + backdrop_path = VALUES(backdrop_path), + local_poster_url = VALUES(local_poster_url), + tmdb_rating = VALUES(tmdb_rating), + vote_count = VALUES(vote_count), + runtime = VALUES(runtime), + genres = VALUES(genres), + directors = VALUES(directors), + casts = VALUES(casts), + original_language = VALUES(original_language), + production_countries = VALUES(production_countries), + status = 'active', + cache_time = NOW() + + + + + UPDATE wp_tmdb_cache SET status = 'expired' WHERE id IN + + #{id} + + + + + + DELETE FROM wp_tmdb_cache WHERE movie_id = #{movieId} + + + + + + diff --git a/wangpan-common/src/main/resources/com/wangpan/common/dal/mapper/TmdbScraperLogMapper.xml b/wangpan-common/src/main/resources/com/wangpan/common/dal/mapper/TmdbScraperLogMapper.xml new file mode 100644 index 0000000..3a22a24 --- /dev/null +++ b/wangpan-common/src/main/resources/com/wangpan/common/dal/mapper/TmdbScraperLogMapper.xml @@ -0,0 +1,37 @@ + + + + + + + + + INSERT INTO wp_tmdb_scraper_log ( + trigger_type, status, total_processed, total_success, total_skipped, + total_failed, user_count, detail_json, error_message, duration_ms, + start_time, end_time + ) VALUES ( + #{log.triggerType}, #{log.status}, #{log.totalProcessed}, #{log.totalSuccess}, #{log.totalSkipped}, + #{log.totalFailed}, #{log.userCount}, #{log.detailJson}, #{log.errorMessage}, #{log.durationMs}, + #{log.startTime}, #{log.endTime} + ) + + + + + + + + + + + + diff --git a/wangpan-common/src/main/resources/com/wangpan/common/dal/mapper/TokenMapper.xml b/wangpan-common/src/main/resources/com/wangpan/common/dal/mapper/TokenMapper.xml new file mode 100644 index 0000000..89d685a --- /dev/null +++ b/wangpan-common/src/main/resources/com/wangpan/common/dal/mapper/TokenMapper.xml @@ -0,0 +1,8 @@ + + + + + + + diff --git a/wangpan-common/src/main/resources/com/wangpan/common/dal/mapper/UserMapper.xml b/wangpan-common/src/main/resources/com/wangpan/common/dal/mapper/UserMapper.xml new file mode 100644 index 0000000..6bc3f60 --- /dev/null +++ b/wangpan-common/src/main/resources/com/wangpan/common/dal/mapper/UserMapper.xml @@ -0,0 +1,8 @@ + + + + + + + diff --git a/wangpan-common/target/classes/com/wangpan/common/Result.class b/wangpan-common/target/classes/com/wangpan/common/Result.class new file mode 100644 index 0000000..f62556d Binary files /dev/null and b/wangpan-common/target/classes/com/wangpan/common/Result.class differ diff --git a/wangpan-common/target/classes/com/wangpan/common/annotation/RequireAdmin.class b/wangpan-common/target/classes/com/wangpan/common/annotation/RequireAdmin.class new file mode 100644 index 0000000..0a9580f Binary files /dev/null and b/wangpan-common/target/classes/com/wangpan/common/annotation/RequireAdmin.class differ diff --git a/wangpan-common/target/classes/com/wangpan/common/annotation/RequireAuth.class b/wangpan-common/target/classes/com/wangpan/common/annotation/RequireAuth.class new file mode 100644 index 0000000..e2dbfe9 Binary files /dev/null and b/wangpan-common/target/classes/com/wangpan/common/annotation/RequireAuth.class differ diff --git a/wangpan-common/target/classes/com/wangpan/common/config/WebMvcConfig.class b/wangpan-common/target/classes/com/wangpan/common/config/WebMvcConfig.class new file mode 100644 index 0000000..ac77e5f Binary files /dev/null and b/wangpan-common/target/classes/com/wangpan/common/config/WebMvcConfig.class differ diff --git a/wangpan-common/target/classes/com/wangpan/common/dal/dataobject/DriveDO.class b/wangpan-common/target/classes/com/wangpan/common/dal/dataobject/DriveDO.class new file mode 100644 index 0000000..784a6ad Binary files /dev/null and b/wangpan-common/target/classes/com/wangpan/common/dal/dataobject/DriveDO.class differ diff --git a/wangpan-common/target/classes/com/wangpan/common/dal/dataobject/DriveFolderDO.class b/wangpan-common/target/classes/com/wangpan/common/dal/dataobject/DriveFolderDO.class new file mode 100644 index 0000000..a325f8f Binary files /dev/null and b/wangpan-common/target/classes/com/wangpan/common/dal/dataobject/DriveFolderDO.class differ diff --git a/wangpan-common/target/classes/com/wangpan/common/dal/dataobject/DriveMovieDO.class b/wangpan-common/target/classes/com/wangpan/common/dal/dataobject/DriveMovieDO.class new file mode 100644 index 0000000..991517b Binary files /dev/null and b/wangpan-common/target/classes/com/wangpan/common/dal/dataobject/DriveMovieDO.class differ diff --git a/wangpan-common/target/classes/com/wangpan/common/dal/dataobject/IconDO.class b/wangpan-common/target/classes/com/wangpan/common/dal/dataobject/IconDO.class new file mode 100644 index 0000000..a1b9179 Binary files /dev/null and b/wangpan-common/target/classes/com/wangpan/common/dal/dataobject/IconDO.class differ diff --git a/wangpan-common/target/classes/com/wangpan/common/dal/dataobject/LoginLogDO.class b/wangpan-common/target/classes/com/wangpan/common/dal/dataobject/LoginLogDO.class new file mode 100644 index 0000000..9bcf02a Binary files /dev/null and b/wangpan-common/target/classes/com/wangpan/common/dal/dataobject/LoginLogDO.class differ diff --git a/wangpan-common/target/classes/com/wangpan/common/dal/dataobject/MovieDO.class b/wangpan-common/target/classes/com/wangpan/common/dal/dataobject/MovieDO.class new file mode 100644 index 0000000..a8db667 Binary files /dev/null and b/wangpan-common/target/classes/com/wangpan/common/dal/dataobject/MovieDO.class differ diff --git a/wangpan-common/target/classes/com/wangpan/common/dal/dataobject/TmdbCacheDO.class b/wangpan-common/target/classes/com/wangpan/common/dal/dataobject/TmdbCacheDO.class new file mode 100644 index 0000000..17deb75 Binary files /dev/null and b/wangpan-common/target/classes/com/wangpan/common/dal/dataobject/TmdbCacheDO.class differ diff --git a/wangpan-common/target/classes/com/wangpan/common/dal/dataobject/TmdbScraperLogDO.class b/wangpan-common/target/classes/com/wangpan/common/dal/dataobject/TmdbScraperLogDO.class new file mode 100644 index 0000000..20dad19 Binary files /dev/null and b/wangpan-common/target/classes/com/wangpan/common/dal/dataobject/TmdbScraperLogDO.class differ diff --git a/wangpan-common/target/classes/com/wangpan/common/dal/dataobject/TokenDO.class b/wangpan-common/target/classes/com/wangpan/common/dal/dataobject/TokenDO.class new file mode 100644 index 0000000..1ee5336 Binary files /dev/null and b/wangpan-common/target/classes/com/wangpan/common/dal/dataobject/TokenDO.class differ diff --git a/wangpan-common/target/classes/com/wangpan/common/dal/dataobject/UserDO.class b/wangpan-common/target/classes/com/wangpan/common/dal/dataobject/UserDO.class new file mode 100644 index 0000000..9519dba Binary files /dev/null and b/wangpan-common/target/classes/com/wangpan/common/dal/dataobject/UserDO.class differ diff --git a/wangpan-common/target/classes/com/wangpan/common/dal/mapper/DriveFolderMapper.class b/wangpan-common/target/classes/com/wangpan/common/dal/mapper/DriveFolderMapper.class new file mode 100644 index 0000000..a0be8b2 Binary files /dev/null and b/wangpan-common/target/classes/com/wangpan/common/dal/mapper/DriveFolderMapper.class differ diff --git a/wangpan-common/target/classes/com/wangpan/common/dal/mapper/DriveFolderMapper.xml b/wangpan-common/target/classes/com/wangpan/common/dal/mapper/DriveFolderMapper.xml new file mode 100644 index 0000000..cd019e6 --- /dev/null +++ b/wangpan-common/target/classes/com/wangpan/common/dal/mapper/DriveFolderMapper.xml @@ -0,0 +1,8 @@ + + + + + + + diff --git a/wangpan-common/target/classes/com/wangpan/common/dal/mapper/DriveMapper.class b/wangpan-common/target/classes/com/wangpan/common/dal/mapper/DriveMapper.class new file mode 100644 index 0000000..694c38e Binary files /dev/null and b/wangpan-common/target/classes/com/wangpan/common/dal/mapper/DriveMapper.class differ diff --git a/wangpan-common/target/classes/com/wangpan/common/dal/mapper/DriveMapper.xml b/wangpan-common/target/classes/com/wangpan/common/dal/mapper/DriveMapper.xml new file mode 100644 index 0000000..8d36ec4 --- /dev/null +++ b/wangpan-common/target/classes/com/wangpan/common/dal/mapper/DriveMapper.xml @@ -0,0 +1,8 @@ + + + + + + + diff --git a/wangpan-common/target/classes/com/wangpan/common/dal/mapper/DriveMovieMapper.class b/wangpan-common/target/classes/com/wangpan/common/dal/mapper/DriveMovieMapper.class new file mode 100644 index 0000000..795414a Binary files /dev/null and b/wangpan-common/target/classes/com/wangpan/common/dal/mapper/DriveMovieMapper.class differ diff --git a/wangpan-common/target/classes/com/wangpan/common/dal/mapper/DriveMovieMapper.xml b/wangpan-common/target/classes/com/wangpan/common/dal/mapper/DriveMovieMapper.xml new file mode 100644 index 0000000..3137d1f --- /dev/null +++ b/wangpan-common/target/classes/com/wangpan/common/dal/mapper/DriveMovieMapper.xml @@ -0,0 +1,18 @@ + + + + + + + + + + diff --git a/wangpan-common/target/classes/com/wangpan/common/dal/mapper/IconMapper.class b/wangpan-common/target/classes/com/wangpan/common/dal/mapper/IconMapper.class new file mode 100644 index 0000000..87a20d6 Binary files /dev/null and b/wangpan-common/target/classes/com/wangpan/common/dal/mapper/IconMapper.class differ diff --git a/wangpan-common/target/classes/com/wangpan/common/dal/mapper/LoginLogMapper.class b/wangpan-common/target/classes/com/wangpan/common/dal/mapper/LoginLogMapper.class new file mode 100644 index 0000000..250aea5 Binary files /dev/null and b/wangpan-common/target/classes/com/wangpan/common/dal/mapper/LoginLogMapper.class differ diff --git a/wangpan-common/target/classes/com/wangpan/common/dal/mapper/LoginLogMapper.xml b/wangpan-common/target/classes/com/wangpan/common/dal/mapper/LoginLogMapper.xml new file mode 100644 index 0000000..eebb206 --- /dev/null +++ b/wangpan-common/target/classes/com/wangpan/common/dal/mapper/LoginLogMapper.xml @@ -0,0 +1,8 @@ + + + + + + + diff --git a/wangpan-common/target/classes/com/wangpan/common/dal/mapper/MovieMapper.class b/wangpan-common/target/classes/com/wangpan/common/dal/mapper/MovieMapper.class new file mode 100644 index 0000000..e09714d Binary files /dev/null and b/wangpan-common/target/classes/com/wangpan/common/dal/mapper/MovieMapper.class differ diff --git a/wangpan-common/target/classes/com/wangpan/common/dal/mapper/MovieMapper.xml b/wangpan-common/target/classes/com/wangpan/common/dal/mapper/MovieMapper.xml new file mode 100644 index 0000000..c3b6589 --- /dev/null +++ b/wangpan-common/target/classes/com/wangpan/common/dal/mapper/MovieMapper.xml @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + diff --git a/wangpan-common/target/classes/com/wangpan/common/dal/mapper/TmdbCacheMapper.class b/wangpan-common/target/classes/com/wangpan/common/dal/mapper/TmdbCacheMapper.class new file mode 100644 index 0000000..9ef3135 Binary files /dev/null and b/wangpan-common/target/classes/com/wangpan/common/dal/mapper/TmdbCacheMapper.class differ diff --git a/wangpan-common/target/classes/com/wangpan/common/dal/mapper/TmdbCacheMapper.xml b/wangpan-common/target/classes/com/wangpan/common/dal/mapper/TmdbCacheMapper.xml new file mode 100644 index 0000000..4072efb --- /dev/null +++ b/wangpan-common/target/classes/com/wangpan/common/dal/mapper/TmdbCacheMapper.xml @@ -0,0 +1,90 @@ + + + + + + + + + + + + + + + + + + + + + INSERT INTO wp_tmdb_cache ( + movie_id, user_id, tmdb_id, title, original_title, overview, + release_date, poster_path, backdrop_path, local_poster_url, + tmdb_rating, vote_count, runtime, genres, directors, casts, + original_language, production_countries, status, cache_time + ) VALUES ( + #{data.movieId}, #{data.userId}, #{data.tmdbId}, #{data.title}, #{data.originalTitle}, #{data.overview}, + #{data.releaseDate}, #{data.posterPath}, #{data.backdropPath}, #{data.localPosterUrl}, + #{data.tmdbRating}, #{data.voteCount}, #{data.runtime}, #{data.genres}, #{data.directors}, #{data.casts}, + #{data.originalLanguage}, #{data.productionCountries}, 'active', NOW() + ) + ON DUPLICATE KEY UPDATE + tmdb_id = VALUES(tmdb_id), + title = VALUES(title), + original_title = VALUES(original_title), + overview = VALUES(overview), + release_date = VALUES(release_date), + poster_path = VALUES(poster_path), + backdrop_path = VALUES(backdrop_path), + local_poster_url = VALUES(local_poster_url), + tmdb_rating = VALUES(tmdb_rating), + vote_count = VALUES(vote_count), + runtime = VALUES(runtime), + genres = VALUES(genres), + directors = VALUES(directors), + casts = VALUES(casts), + original_language = VALUES(original_language), + production_countries = VALUES(production_countries), + status = 'active', + cache_time = NOW() + + + + + UPDATE wp_tmdb_cache SET status = 'expired' WHERE id IN + + #{id} + + + + + + DELETE FROM wp_tmdb_cache WHERE movie_id = #{movieId} + + + + + + diff --git a/wangpan-common/target/classes/com/wangpan/common/dal/mapper/TmdbScraperLogMapper.class b/wangpan-common/target/classes/com/wangpan/common/dal/mapper/TmdbScraperLogMapper.class new file mode 100644 index 0000000..c6ff6e7 Binary files /dev/null and b/wangpan-common/target/classes/com/wangpan/common/dal/mapper/TmdbScraperLogMapper.class differ diff --git a/wangpan-common/target/classes/com/wangpan/common/dal/mapper/TmdbScraperLogMapper.xml b/wangpan-common/target/classes/com/wangpan/common/dal/mapper/TmdbScraperLogMapper.xml new file mode 100644 index 0000000..3a22a24 --- /dev/null +++ b/wangpan-common/target/classes/com/wangpan/common/dal/mapper/TmdbScraperLogMapper.xml @@ -0,0 +1,37 @@ + + + + + + + + + INSERT INTO wp_tmdb_scraper_log ( + trigger_type, status, total_processed, total_success, total_skipped, + total_failed, user_count, detail_json, error_message, duration_ms, + start_time, end_time + ) VALUES ( + #{log.triggerType}, #{log.status}, #{log.totalProcessed}, #{log.totalSuccess}, #{log.totalSkipped}, + #{log.totalFailed}, #{log.userCount}, #{log.detailJson}, #{log.errorMessage}, #{log.durationMs}, + #{log.startTime}, #{log.endTime} + ) + + + + + + + + + + + + diff --git a/wangpan-common/target/classes/com/wangpan/common/dal/mapper/TokenMapper.class b/wangpan-common/target/classes/com/wangpan/common/dal/mapper/TokenMapper.class new file mode 100644 index 0000000..57f5a94 Binary files /dev/null and b/wangpan-common/target/classes/com/wangpan/common/dal/mapper/TokenMapper.class differ diff --git a/wangpan-common/target/classes/com/wangpan/common/dal/mapper/TokenMapper.xml b/wangpan-common/target/classes/com/wangpan/common/dal/mapper/TokenMapper.xml new file mode 100644 index 0000000..89d685a --- /dev/null +++ b/wangpan-common/target/classes/com/wangpan/common/dal/mapper/TokenMapper.xml @@ -0,0 +1,8 @@ + + + + + + + diff --git a/wangpan-common/target/classes/com/wangpan/common/dal/mapper/UserMapper.class b/wangpan-common/target/classes/com/wangpan/common/dal/mapper/UserMapper.class new file mode 100644 index 0000000..c198ced Binary files /dev/null and b/wangpan-common/target/classes/com/wangpan/common/dal/mapper/UserMapper.class differ diff --git a/wangpan-common/target/classes/com/wangpan/common/dal/mapper/UserMapper.xml b/wangpan-common/target/classes/com/wangpan/common/dal/mapper/UserMapper.xml new file mode 100644 index 0000000..6bc3f60 --- /dev/null +++ b/wangpan-common/target/classes/com/wangpan/common/dal/mapper/UserMapper.xml @@ -0,0 +1,8 @@ + + + + + + + diff --git a/wangpan-common/target/classes/com/wangpan/common/dal/repository/DriveFolderRepository.class b/wangpan-common/target/classes/com/wangpan/common/dal/repository/DriveFolderRepository.class new file mode 100644 index 0000000..2e654c1 Binary files /dev/null and b/wangpan-common/target/classes/com/wangpan/common/dal/repository/DriveFolderRepository.class differ diff --git a/wangpan-common/target/classes/com/wangpan/common/dal/repository/DriveMovieRepository.class b/wangpan-common/target/classes/com/wangpan/common/dal/repository/DriveMovieRepository.class new file mode 100644 index 0000000..713efee Binary files /dev/null and b/wangpan-common/target/classes/com/wangpan/common/dal/repository/DriveMovieRepository.class differ diff --git a/wangpan-common/target/classes/com/wangpan/common/dal/repository/DriveRepository.class b/wangpan-common/target/classes/com/wangpan/common/dal/repository/DriveRepository.class new file mode 100644 index 0000000..d9c4e1a Binary files /dev/null and b/wangpan-common/target/classes/com/wangpan/common/dal/repository/DriveRepository.class differ diff --git a/wangpan-common/target/classes/com/wangpan/common/dal/repository/IconRepository.class b/wangpan-common/target/classes/com/wangpan/common/dal/repository/IconRepository.class new file mode 100644 index 0000000..7429ddc Binary files /dev/null and b/wangpan-common/target/classes/com/wangpan/common/dal/repository/IconRepository.class differ diff --git a/wangpan-common/target/classes/com/wangpan/common/dal/repository/LoginLogRepository.class b/wangpan-common/target/classes/com/wangpan/common/dal/repository/LoginLogRepository.class new file mode 100644 index 0000000..aea94f9 Binary files /dev/null and b/wangpan-common/target/classes/com/wangpan/common/dal/repository/LoginLogRepository.class differ diff --git a/wangpan-common/target/classes/com/wangpan/common/dal/repository/MovieRepository.class b/wangpan-common/target/classes/com/wangpan/common/dal/repository/MovieRepository.class new file mode 100644 index 0000000..35c7954 Binary files /dev/null and b/wangpan-common/target/classes/com/wangpan/common/dal/repository/MovieRepository.class differ diff --git a/wangpan-common/target/classes/com/wangpan/common/dal/repository/TmdbCacheRepository.class b/wangpan-common/target/classes/com/wangpan/common/dal/repository/TmdbCacheRepository.class new file mode 100644 index 0000000..a2c5747 Binary files /dev/null and b/wangpan-common/target/classes/com/wangpan/common/dal/repository/TmdbCacheRepository.class differ diff --git a/wangpan-common/target/classes/com/wangpan/common/dal/repository/TmdbScraperLogRepository.class b/wangpan-common/target/classes/com/wangpan/common/dal/repository/TmdbScraperLogRepository.class new file mode 100644 index 0000000..55ce18f Binary files /dev/null and b/wangpan-common/target/classes/com/wangpan/common/dal/repository/TmdbScraperLogRepository.class differ diff --git a/wangpan-common/target/classes/com/wangpan/common/dal/repository/TokenRepository.class b/wangpan-common/target/classes/com/wangpan/common/dal/repository/TokenRepository.class new file mode 100644 index 0000000..158dd69 Binary files /dev/null and b/wangpan-common/target/classes/com/wangpan/common/dal/repository/TokenRepository.class differ diff --git a/wangpan-common/target/classes/com/wangpan/common/dal/repository/UserRepository.class b/wangpan-common/target/classes/com/wangpan/common/dal/repository/UserRepository.class new file mode 100644 index 0000000..1cd2a76 Binary files /dev/null and b/wangpan-common/target/classes/com/wangpan/common/dal/repository/UserRepository.class differ diff --git a/wangpan-common/target/classes/com/wangpan/common/entity/Drive.class b/wangpan-common/target/classes/com/wangpan/common/entity/Drive.class new file mode 100644 index 0000000..3341e5b Binary files /dev/null and b/wangpan-common/target/classes/com/wangpan/common/entity/Drive.class differ diff --git a/wangpan-common/target/classes/com/wangpan/common/entity/DriveFolder.class b/wangpan-common/target/classes/com/wangpan/common/entity/DriveFolder.class new file mode 100644 index 0000000..1d9610a Binary files /dev/null and b/wangpan-common/target/classes/com/wangpan/common/entity/DriveFolder.class differ diff --git a/wangpan-common/target/classes/com/wangpan/common/entity/DriveMovie.class b/wangpan-common/target/classes/com/wangpan/common/entity/DriveMovie.class new file mode 100644 index 0000000..5dfaa6c Binary files /dev/null and b/wangpan-common/target/classes/com/wangpan/common/entity/DriveMovie.class differ diff --git a/wangpan-common/target/classes/com/wangpan/common/entity/Icon.class b/wangpan-common/target/classes/com/wangpan/common/entity/Icon.class new file mode 100644 index 0000000..c1cb477 Binary files /dev/null and b/wangpan-common/target/classes/com/wangpan/common/entity/Icon.class differ diff --git a/wangpan-common/target/classes/com/wangpan/common/entity/Movie.class b/wangpan-common/target/classes/com/wangpan/common/entity/Movie.class new file mode 100644 index 0000000..f16ec62 Binary files /dev/null and b/wangpan-common/target/classes/com/wangpan/common/entity/Movie.class differ diff --git a/wangpan-common/target/classes/com/wangpan/common/entity/User.class b/wangpan-common/target/classes/com/wangpan/common/entity/User.class new file mode 100644 index 0000000..9da3049 Binary files /dev/null and b/wangpan-common/target/classes/com/wangpan/common/entity/User.class differ diff --git a/wangpan-common/target/classes/com/wangpan/common/interceptor/AuthInterceptor.class b/wangpan-common/target/classes/com/wangpan/common/interceptor/AuthInterceptor.class new file mode 100644 index 0000000..55e11ab Binary files /dev/null and b/wangpan-common/target/classes/com/wangpan/common/interceptor/AuthInterceptor.class differ diff --git a/wangpan-common/target/classes/com/wangpan/common/util/AuthUtil.class b/wangpan-common/target/classes/com/wangpan/common/util/AuthUtil.class new file mode 100644 index 0000000..d1226e1 Binary files /dev/null and b/wangpan-common/target/classes/com/wangpan/common/util/AuthUtil.class differ diff --git a/wangpan-common/target/classes/com/wangpan/common/util/DBUtil.class b/wangpan-common/target/classes/com/wangpan/common/util/DBUtil.class new file mode 100644 index 0000000..0b370ea Binary files /dev/null and b/wangpan-common/target/classes/com/wangpan/common/util/DBUtil.class differ diff --git a/wangpan-common/target/classes/com/wangpan/common/util/EncryptUtil.class b/wangpan-common/target/classes/com/wangpan/common/util/EncryptUtil.class new file mode 100644 index 0000000..2c200f3 Binary files /dev/null and b/wangpan-common/target/classes/com/wangpan/common/util/EncryptUtil.class differ diff --git a/wangpan-common/target/classes/com/wangpan/common/util/IconLogoFetcher.class b/wangpan-common/target/classes/com/wangpan/common/util/IconLogoFetcher.class new file mode 100644 index 0000000..d45f601 Binary files /dev/null and b/wangpan-common/target/classes/com/wangpan/common/util/IconLogoFetcher.class differ diff --git a/wangpan-common/target/classes/com/wangpan/common/util/MinioUtil.class b/wangpan-common/target/classes/com/wangpan/common/util/MinioUtil.class new file mode 100644 index 0000000..d67095f Binary files /dev/null and b/wangpan-common/target/classes/com/wangpan/common/util/MinioUtil.class differ diff --git a/wangpan-common/target/classes/com/wangpan/common/util/SchemaUtil.class b/wangpan-common/target/classes/com/wangpan/common/util/SchemaUtil.class new file mode 100644 index 0000000..00efe52 Binary files /dev/null and b/wangpan-common/target/classes/com/wangpan/common/util/SchemaUtil.class differ diff --git a/wangpan-common/target/classes/com/wangpan/common/util/TokenUtil$TokenInfo.class b/wangpan-common/target/classes/com/wangpan/common/util/TokenUtil$TokenInfo.class new file mode 100644 index 0000000..198b463 Binary files /dev/null and b/wangpan-common/target/classes/com/wangpan/common/util/TokenUtil$TokenInfo.class differ diff --git a/wangpan-common/target/classes/com/wangpan/common/util/TokenUtil.class b/wangpan-common/target/classes/com/wangpan/common/util/TokenUtil.class new file mode 100644 index 0000000..f33b1c0 Binary files /dev/null and b/wangpan-common/target/classes/com/wangpan/common/util/TokenUtil.class differ diff --git a/wangpan-common/target/maven-archiver/pom.properties b/wangpan-common/target/maven-archiver/pom.properties new file mode 100644 index 0000000..dd2a9ca --- /dev/null +++ b/wangpan-common/target/maven-archiver/pom.properties @@ -0,0 +1,3 @@ +artifactId=wangpan-common +groupId=com.wangpan +version=1.0-SNAPSHOT diff --git a/wangpan-common/target/maven-status/maven-compiler-plugin/compile/default-compile/createdFiles.lst b/wangpan-common/target/maven-status/maven-compiler-plugin/compile/default-compile/createdFiles.lst new file mode 100644 index 0000000..11e5e68 --- /dev/null +++ b/wangpan-common/target/maven-status/maven-compiler-plugin/compile/default-compile/createdFiles.lst @@ -0,0 +1,49 @@ +com\wangpan\common\dal\repository\IconRepository.class +com\wangpan\common\util\EncryptUtil.class +com\wangpan\common\dal\dataobject\MovieDO.class +com\wangpan\common\dal\dataobject\TmdbCacheDO.class +com\wangpan\common\interceptor\AuthInterceptor.class +com\wangpan\common\dal\repository\TokenRepository.class +com\wangpan\common\entity\Drive.class +com\wangpan\common\dal\mapper\TmdbCacheMapper.class +com\wangpan\common\util\DBUtil.class +com\wangpan\common\entity\DriveMovie.class +com\wangpan\common\config\WebMvcConfig.class +com\wangpan\common\dal\mapper\DriveMovieMapper.class +com\wangpan\common\util\TokenUtil.class +com\wangpan\common\dal\mapper\MovieMapper.class +com\wangpan\common\dal\mapper\TmdbScraperLogMapper.class +com\wangpan\common\dal\mapper\DriveMapper.class +com\wangpan\common\dal\mapper\IconMapper.class +com\wangpan\common\dal\dataobject\DriveFolderDO.class +com\wangpan\common\dal\repository\DriveRepository.class +com\wangpan\common\dal\repository\DriveMovieRepository.class +com\wangpan\common\util\TokenUtil$TokenInfo.class +com\wangpan\common\util\SchemaUtil.class +com\wangpan\common\dal\mapper\TokenMapper.class +com\wangpan\common\dal\repository\DriveFolderRepository.class +com\wangpan\common\entity\DriveFolder.class +com\wangpan\common\entity\Icon.class +com\wangpan\common\dal\dataobject\UserDO.class +com\wangpan\common\dal\dataobject\DriveDO.class +com\wangpan\common\util\AuthUtil.class +com\wangpan\common\dal\dataobject\TmdbScraperLogDO.class +com\wangpan\common\dal\dataobject\TokenDO.class +com\wangpan\common\dal\mapper\UserMapper.class +com\wangpan\common\dal\dataobject\IconDO.class +com\wangpan\common\dal\mapper\LoginLogMapper.class +com\wangpan\common\annotation\RequireAuth.class +com\wangpan\common\dal\dataobject\LoginLogDO.class +com\wangpan\common\util\MinioUtil.class +com\wangpan\common\dal\repository\LoginLogRepository.class +com\wangpan\common\dal\repository\TmdbScraperLogRepository.class +com\wangpan\common\entity\Movie.class +com\wangpan\common\dal\repository\MovieRepository.class +com\wangpan\common\entity\User.class +com\wangpan\common\dal\mapper\DriveFolderMapper.class +com\wangpan\common\annotation\RequireAdmin.class +com\wangpan\common\dal\repository\TmdbCacheRepository.class +com\wangpan\common\util\IconLogoFetcher.class +com\wangpan\common\dal\dataobject\DriveMovieDO.class +com\wangpan\common\Result.class +com\wangpan\common\dal\repository\UserRepository.class diff --git a/wangpan-common/target/maven-status/maven-compiler-plugin/compile/default-compile/inputFiles.lst b/wangpan-common/target/maven-status/maven-compiler-plugin/compile/default-compile/inputFiles.lst new file mode 100644 index 0000000..c828377 --- /dev/null +++ b/wangpan-common/target/maven-status/maven-compiler-plugin/compile/default-compile/inputFiles.lst @@ -0,0 +1,48 @@ +D:\java2\wangpan\java\wangpan-common\src\main\java\com\wangpan\common\dal\repository\MovieRepository.java +D:\java2\wangpan\java\wangpan-common\src\main\java\com\wangpan\common\dal\dataobject\DriveMovieDO.java +D:\java2\wangpan\java\wangpan-common\src\main\java\com\wangpan\common\dal\repository\DriveFolderRepository.java +D:\java2\wangpan\java\wangpan-common\src\main\java\com\wangpan\common\util\SchemaUtil.java +D:\java2\wangpan\java\wangpan-common\src\main\java\com\wangpan\common\util\AuthUtil.java +D:\java2\wangpan\java\wangpan-common\src\main\java\com\wangpan\common\dal\mapper\TmdbCacheMapper.java +D:\java2\wangpan\java\wangpan-common\src\main\java\com\wangpan\common\dal\mapper\TmdbScraperLogMapper.java +D:\java2\wangpan\java\wangpan-common\src\main\java\com\wangpan\common\dal\dataobject\LoginLogDO.java +D:\java2\wangpan\java\wangpan-common\src\main\java\com\wangpan\common\dal\repository\TokenRepository.java +D:\java2\wangpan\java\wangpan-common\src\main\java\com\wangpan\common\dal\dataobject\UserDO.java +D:\java2\wangpan\java\wangpan-common\src\main\java\com\wangpan\common\entity\User.java +D:\java2\wangpan\java\wangpan-common\src\main\java\com\wangpan\common\dal\repository\IconRepository.java +D:\java2\wangpan\java\wangpan-common\src\main\java\com\wangpan\common\dal\mapper\UserMapper.java +D:\java2\wangpan\java\wangpan-common\src\main\java\com\wangpan\common\entity\DriveMovie.java +D:\java2\wangpan\java\wangpan-common\src\main\java\com\wangpan\common\interceptor\AuthInterceptor.java +D:\java2\wangpan\java\wangpan-common\src\main\java\com\wangpan\common\dal\repository\TmdbCacheRepository.java +D:\java2\wangpan\java\wangpan-common\src\main\java\com\wangpan\common\dal\dataobject\DriveFolderDO.java +D:\java2\wangpan\java\wangpan-common\src\main\java\com\wangpan\common\util\TokenUtil.java +D:\java2\wangpan\java\wangpan-common\src\main\java\com\wangpan\common\dal\dataobject\TmdbScraperLogDO.java +D:\java2\wangpan\java\wangpan-common\src\main\java\com\wangpan\common\dal\repository\LoginLogRepository.java +D:\java2\wangpan\java\wangpan-common\src\main\java\com\wangpan\common\dal\dataobject\DriveDO.java +D:\java2\wangpan\java\wangpan-common\src\main\java\com\wangpan\common\dal\mapper\IconMapper.java +D:\java2\wangpan\java\wangpan-common\src\main\java\com\wangpan\common\entity\Drive.java +D:\java2\wangpan\java\wangpan-common\src\main\java\com\wangpan\common\dal\mapper\TokenMapper.java +D:\java2\wangpan\java\wangpan-common\src\main\java\com\wangpan\common\dal\repository\UserRepository.java +D:\java2\wangpan\java\wangpan-common\src\main\java\com\wangpan\common\entity\Icon.java +D:\java2\wangpan\java\wangpan-common\src\main\java\com\wangpan\common\util\IconLogoFetcher.java +D:\java2\wangpan\java\wangpan-common\src\main\java\com\wangpan\common\dal\mapper\DriveMapper.java +D:\java2\wangpan\java\wangpan-common\src\main\java\com\wangpan\common\entity\DriveFolder.java +D:\java2\wangpan\java\wangpan-common\src\main\java\com\wangpan\common\dal\repository\DriveMovieRepository.java +D:\java2\wangpan\java\wangpan-common\src\main\java\com\wangpan\common\dal\repository\DriveRepository.java +D:\java2\wangpan\java\wangpan-common\src\main\java\com\wangpan\common\entity\Movie.java +D:\java2\wangpan\java\wangpan-common\src\main\java\com\wangpan\common\dal\mapper\MovieMapper.java +D:\java2\wangpan\java\wangpan-common\src\main\java\com\wangpan\common\dal\dataobject\TokenDO.java +D:\java2\wangpan\java\wangpan-common\src\main\java\com\wangpan\common\annotation\RequireAdmin.java +D:\java2\wangpan\java\wangpan-common\src\main\java\com\wangpan\common\dal\mapper\LoginLogMapper.java +D:\java2\wangpan\java\wangpan-common\src\main\java\com\wangpan\common\Result.java +D:\java2\wangpan\java\wangpan-common\src\main\java\com\wangpan\common\dal\dataobject\IconDO.java +D:\java2\wangpan\java\wangpan-common\src\main\java\com\wangpan\common\dal\repository\TmdbScraperLogRepository.java +D:\java2\wangpan\java\wangpan-common\src\main\java\com\wangpan\common\dal\mapper\DriveFolderMapper.java +D:\java2\wangpan\java\wangpan-common\src\main\java\com\wangpan\common\config\WebMvcConfig.java +D:\java2\wangpan\java\wangpan-common\src\main\java\com\wangpan\common\dal\mapper\DriveMovieMapper.java +D:\java2\wangpan\java\wangpan-common\src\main\java\com\wangpan\common\annotation\RequireAuth.java +D:\java2\wangpan\java\wangpan-common\src\main\java\com\wangpan\common\dal\dataobject\MovieDO.java +D:\java2\wangpan\java\wangpan-common\src\main\java\com\wangpan\common\util\EncryptUtil.java +D:\java2\wangpan\java\wangpan-common\src\main\java\com\wangpan\common\dal\dataobject\TmdbCacheDO.java +D:\java2\wangpan\java\wangpan-common\src\main\java\com\wangpan\common\util\DBUtil.java +D:\java2\wangpan\java\wangpan-common\src\main\java\com\wangpan\common\util\MinioUtil.java diff --git a/wangpan-common/target/wangpan-common-1.0-SNAPSHOT.jar b/wangpan-common/target/wangpan-common-1.0-SNAPSHOT.jar new file mode 100644 index 0000000..9e873b5 Binary files /dev/null and b/wangpan-common/target/wangpan-common-1.0-SNAPSHOT.jar differ diff --git a/wangpan-drive-service/pom.xml b/wangpan-drive-service/pom.xml new file mode 100644 index 0000000..2d3dd1e --- /dev/null +++ b/wangpan-drive-service/pom.xml @@ -0,0 +1,55 @@ + + + 4.0.0 + + + com.wangpan + wangpan-parent + 1.0-SNAPSHOT + + + wangpan-drive-service + jar + + WangPan Drive Service + 网盘管理服务 - 网盘、文件夹、电影管理 + + + + + com.wangpan + wangpan-common + + + + + org.springframework.boot + spring-boot-starter-web + + + + + com.github.ulisesbocchio + jasypt-spring-boot-starter + 3.0.5 + + + + + org.springframework.boot + spring-boot-starter-test + test + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + diff --git a/wangpan-drive-service/src/main/java/com/wangpan/DriveServiceApplication.java b/wangpan-drive-service/src/main/java/com/wangpan/DriveServiceApplication.java new file mode 100644 index 0000000..9a1140d --- /dev/null +++ b/wangpan-drive-service/src/main/java/com/wangpan/DriveServiceApplication.java @@ -0,0 +1,21 @@ +package com.wangpan; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import com.ulisesbocchio.jasyptspringboot.annotation.EnableEncryptableProperties; + +/** + * 网盘管理服务启动类 + * 负责:网盘管理、文件夹管理、电影文件管理、MinIO 存储 + */ +@SpringBootApplication +@EnableEncryptableProperties +public class DriveServiceApplication { + + public static void main(String[] args) { + SpringApplication.run(DriveServiceApplication.class, args); + System.out.println("========================================"); + System.out.println(" 网盘管理服务启动成功!"); + System.out.println("========================================"); + } +} diff --git a/wangpan-drive-service/src/main/java/com/wangpan/config/DatabaseInitializer.java b/wangpan-drive-service/src/main/java/com/wangpan/config/DatabaseInitializer.java new file mode 100644 index 0000000..b723d62 --- /dev/null +++ b/wangpan-drive-service/src/main/java/com/wangpan/config/DatabaseInitializer.java @@ -0,0 +1,116 @@ +package com.wangpan.config; + +import com.wangpan.common.dal.mapper.DriveFolderMapper; +import com.wangpan.common.dal.mapper.DriveMapper; +import com.wangpan.common.dal.mapper.DriveMovieMapper; +import com.wangpan.common.dal.mapper.IconMapper; +import com.wangpan.common.util.DBUtil; +import com.wangpan.common.util.SchemaUtil; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Component; + +import jakarta.annotation.PostConstruct; +import java.sql.Connection; +import java.sql.SQLException; + +/** + * 数据库初始化器 + * 在应用启动时检查并创建网盘相关表结构 + * + * 使用 Mapper 中定义的 CREATE_TABLE_SQL 常量 + SchemaUtil 工具类建表 + * 保留 DBUtil 原始 JDBC 连接用于 SchemaUtil 的 DDL 操作 + */ +@Component +public class DatabaseInitializer { + + /** 注入DBUtil Bean(用于获取JDBC连接执行DDL) */ + @Resource + private DBUtil dbUtil; + + @PostConstruct + public void init() { + System.out.println("========================================"); + System.out.println(" [DatabaseInitializer] 网盘服务数据库初始化..."); + System.out.println("========================================"); + + long startTime = System.currentTimeMillis(); + boolean initSuccess = true; + + try { + checkAndFixDatabaseSchema(); + System.out.println("✓ 数据库初始化检查完成!"); + } catch (Exception e) { + System.err.println("⚠ 数据库初始化警告:" + e.getMessage()); + e.printStackTrace(); + initSuccess = false; + } + + long duration = System.currentTimeMillis() - startTime; + System.out.println("========================================"); + System.out.println(initSuccess + ? " ✓ 数据库初始化成功!耗时:" + duration + "ms" + : " ⚠ 数据库初始化存在警告,耗时:" + duration + "ms"); + System.out.println("========================================"); + } + + /** + * 检查并修复数据库表结构 + * 使用各 Mapper 中定义的 CREATE_TABLE_SQL 常量建表 + */ + private void checkAndFixDatabaseSchema() throws SQLException { + try (Connection conn = dbUtil.getConnection()) { + if (conn == null || conn.isClosed()) { + System.err.println("✗ 无法获取数据库连接,跳过表结构检查"); + return; + } + + // 创建网盘相关表(使用各 Mapper 中定义的建表SQL) + SchemaUtil.createTableIfNotExists(conn, "wp_drive", DriveMapper.CREATE_TABLE_SQL); + SchemaUtil.createTableIfNotExists(conn, "wp_drive_folder", DriveFolderMapper.CREATE_TABLE_SQL); + SchemaUtil.createTableIfNotExists(conn, "wp_drive_movie", DriveMovieMapper.CREATE_TABLE_SQL); + SchemaUtil.createTableIfNotExists(conn, "wp_icon", IconMapper.CREATE_TABLE_SQL); + + // 检查并添加新字段(兼容旧表结构升级) + SchemaUtil.ensureColumnExists(conn, "wp_drive", "logo_url", + "TEXT DEFAULT NULL COMMENT 'drive logo url'"); + + // 检查 wp_icon 表是否有 logo_url 字段 + SchemaUtil.ensureColumnExists(conn, "wp_icon", "logo_url", + "VARCHAR(500) DEFAULT NULL COMMENT 'Logo 图片 URL(MinIO 存储)'"); + + // 检查 wp_icon 表是否有 internal_url 字段(内网链接) + SchemaUtil.ensureColumnExists(conn, "wp_icon", "internal_url", + "VARCHAR(500) DEFAULT NULL COMMENT '内网链接 URL'"); + + // 检查 wp_icon 表是否有 link_type 字段(链接类型) + SchemaUtil.ensureColumnExists(conn, "wp_icon", "link_type", + "VARCHAR(20) NOT NULL DEFAULT 'external' COMMENT '链接类型:external(仅外网)/ internal(仅内网)/ both(内外网)'"); + + // 移除已废弃的 icon 列(已被 logo_url 替代) + try (var checkStmt = conn.createStatement(); + var checkRs = checkStmt.executeQuery("SHOW COLUMNS FROM wp_drive LIKE 'icon'")) { + if (checkRs.next()) { + conn.createStatement().executeUpdate("ALTER TABLE wp_drive DROP COLUMN icon"); + System.out.println(" ✓ [wp_drive] 已移除废弃的 icon 列"); + } + } + + // 验证字段是否添加成功 + try (var checkStmt = conn.createStatement(); + var checkRs = checkStmt.executeQuery("SHOW COLUMNS FROM wp_drive LIKE 'logo_url'")) { + if (checkRs.next()) { + System.out.println(" ✓ [wp_drive] logo_url 字段已存在"); + } else { + System.err.println(" ⚠ [wp_drive] logo_url 字段添加失败,尝试直接执行 ALTER TABLE"); + conn.createStatement().executeUpdate( + "ALTER TABLE wp_drive ADD COLUMN logo_url TEXT DEFAULT NULL"); + System.out.println(" ✓ [wp_drive] logo_url 字段通过 ALTER TABLE 添加成功"); + } + } + + System.out.println(" ✓ [wp_drive] 表创建完成"); + System.out.println(" ✓ [wp_drive_folder] 表创建完成"); + System.out.println(" ✓ [wp_drive_movie] 表创建完成"); + } + } +} diff --git a/wangpan-drive-service/src/main/java/com/wangpan/controller/DriveController.java b/wangpan-drive-service/src/main/java/com/wangpan/controller/DriveController.java new file mode 100644 index 0000000..b60ac06 --- /dev/null +++ b/wangpan-drive-service/src/main/java/com/wangpan/controller/DriveController.java @@ -0,0 +1,732 @@ +package com.wangpan.controller; + +import com.wangpan.common.Result; +import com.wangpan.common.annotation.RequireAuth; +import com.wangpan.common.dal.repository.DriveFolderRepository; +import com.wangpan.common.dal.repository.DriveMovieRepository; +import com.wangpan.common.dal.repository.DriveRepository; +import com.wangpan.common.entity.Drive; +import com.wangpan.common.entity.DriveFolder; +import com.wangpan.common.entity.DriveMovie; +import com.wangpan.common.entity.User; +import com.wangpan.common.interceptor.AuthInterceptor; +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 java.io.ByteArrayInputStream; +import java.util.Base64; +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * 网盘管理控制器 + * + * 提供网盘、文件夹、电影的增删改查接口 + */ +@RestController +@RequireAuth +@RequestMapping("/api/drive") +@CrossOrigin(origins = {"*"}, allowCredentials = "false") +public class DriveController { + + private static final Logger log = LoggerFactory.getLogger(DriveController.class); + + /** 最大分页大小 */ + private static final int MAX_PAGE_SIZE = 100; + + private final DriveRepository driveRepository; + private final DriveFolderRepository folderRepository; + private final DriveMovieRepository movieRepository; + + /** + * 构造函数注入 Repository 依赖 + */ + public DriveController(DriveRepository driveRepository, + DriveFolderRepository folderRepository, + DriveMovieRepository movieRepository) { + this.driveRepository = driveRepository; + this.folderRepository = folderRepository; + this.movieRepository = movieRepository; + } + + /** + * 从请求属性中获取当前登录用户(由 AuthInterceptor 注入) + */ + private User getCurrentUser(HttpServletRequest request) { + return (User) request.getAttribute(AuthInterceptor.CURRENT_USER_ATTR); + } + + // ==================== 网盘操作 ==================== + + /** + * 创建网盘 + * POST /api/drive + */ + @PostMapping + public Result createDrive(@RequestBody Map data, HttpServletRequest request) { + User user = getCurrentUser(request); + + String name = (String) data.get("name"); + if (name == null || name.isBlank()) { + return Result.error("创建网盘失败:网盘名称不能为空"); + } + + Drive drive = new Drive(user.getId(), name.trim(), (String) data.get("description")); + + // 处理 Logo 图片上传:前端传入 Base64 编码的图片数据,上传到 MinIO 后存储 URL + String logoData = (String) data.get("logoData"); + if (logoData != null && !logoData.isBlank()) { + try { + String logoUrl = uploadDriveLogo(logoData); + drive.setLogoUrl(logoUrl); + } catch (Exception e) { + log.error("Logo上传失败", e); + return Result.error("创建网盘失败:Logo图片上传失败"); + } + } + + Long id = driveRepository.insert(drive); + if (id != null) { + drive.setId(id); + return Result.success("创建成功", driveToMap(drive)); + } else { + return Result.error("创建网盘失败:数据库写入失败,请稍后重试"); + } + } + + /** + * 查询网盘列表 + * GET /api/drive?pageNo=1&pageSize=20 + */ + @GetMapping + public Result listDrives(@RequestParam(defaultValue = "1") int pageNo, + @RequestParam(defaultValue = "20") int pageSize, + HttpServletRequest request) { + User user = getCurrentUser(request); + + pageSize = Math.min(MAX_PAGE_SIZE, Math.max(1, pageSize)); + + var list = driveRepository.queryByUserId(user.getId(), pageNo, pageSize); + var total = driveRepository.countByUserId(user.getId()); + + var result = new LinkedHashMap(); + result.put("list", list); + result.put("total", total); + result.put("pageNo", pageNo); + result.put("pageSize", pageSize); + + return Result.success("查询成功", result); + } + + /** + * 查询网盘详情 + * GET /api/drive/{id} + */ + @GetMapping("/{id}") + public Result getDrive(@PathVariable("id") Long id, HttpServletRequest request) { + User user = getCurrentUser(request); + + Drive drive = driveRepository.findById(id); + if (drive == null || !drive.getUserId().equals(user.getId())) { + return Result.error("查询失败:网盘不存在或无权访问"); + } + + return Result.success("查询成功", driveToMap(drive)); + } + + /** + * 更新网盘 + * PUT /api/drive/{id} + */ + @PutMapping("/{id}") + public Result updateDrive(@PathVariable("id") Long id, + @RequestBody Map data, + HttpServletRequest request) { + User user = getCurrentUser(request); + + Drive drive = driveRepository.findById(id); + if (drive == null || !drive.getUserId().equals(user.getId())) { + return Result.error("更新失败:网盘不存在或无权访问"); + } + + String name = (String) data.get("name"); + if (name != null && !name.isBlank()) { + drive.setName(name.trim()); + } + + String description = (String) data.get("description"); + + // 处理 Logo 图片更新:前端传入新的 Base64 编码图片数据,上传到 MinIO 后更新 URL + String logoUrl = drive.getLogoUrl(); + String logoData = (String) data.get("logoData"); + if (logoData != null && !logoData.isBlank()) { + try { + logoUrl = uploadDriveLogo(logoData); + } catch (Exception e) { + log.error("Logo更新失败", e); + return Result.error("更新网盘失败:Logo图片上传失败"); + } + } + + boolean success = driveRepository.update(id, drive.getName(), description, logoUrl); + if (success) { + return Result.success("更新成功"); + } else { + return Result.error("更新网盘失败:数据库写入失败,请稍后重试"); + } + } + + /** + * 删除网盘 + * DELETE /api/drive/{id} + */ + @DeleteMapping("/{id}") + public Result deleteDrive(@PathVariable("id") Long id, HttpServletRequest request) { + User user = getCurrentUser(request); + + boolean success = driveRepository.delete(id, user.getId()); + if (success) { + return Result.success("删除成功"); + } else { + return Result.error("删除网盘失败:网盘不存在或无权操作"); + } + } + + // ==================== 文件夹操作 ==================== + + /** + * 创建文件夹 + * POST /api/drive/{driveId}/folders + */ + @PostMapping("/{driveId}/folders") + public Result createFolder(@PathVariable("driveId") Long driveId, + @RequestBody Map data, + HttpServletRequest request) { + User user = getCurrentUser(request); + + Drive drive = driveRepository.findById(driveId); + if (drive == null || !drive.getUserId().equals(user.getId())) { + return Result.error("创建文件夹失败:网盘不存在或无权访问"); + } + + String name = (String) data.get("name"); + if (name == null || name.isBlank()) { + return Result.error("创建文件夹失败:文件夹名称不能为空"); + } + + Long parentId = 0L; + Object parentIdObj = data.get("parentId"); + if (parentIdObj != null) { + parentId = ((Number) parentIdObj).longValue(); + } + + DriveFolder folder = new DriveFolder(driveId, user.getId(), parentId, name.trim(), (String) data.get("description")); + + // 计算层级深度 + if (parentId > 0) { + DriveFolder parent = folderRepository.findById(parentId); + if (parent != null) { + folder.setDepth(parent.getDepth() + 1); + } + } + + Long id = folderRepository.insert(folder); + if (id != null) { + folder.setId(id); + return Result.success("创建成功", folderToMap(folder)); + } else { + return Result.error("创建文件夹失败:数据库写入失败,请稍后重试"); + } + } + + /** + * 查询文件夹列表 + * GET /api/drive/{driveId}/folders?parentId=0 + */ + @GetMapping("/{driveId}/folders") + public Result listFolders(@PathVariable("driveId") Long driveId, + @RequestParam(defaultValue = "0") Long parentId, + HttpServletRequest request) { + User user = getCurrentUser(request); + + Drive drive = driveRepository.findById(driveId); + if (drive == null || !drive.getUserId().equals(user.getId())) { + return Result.error("查询失败:网盘不存在或无权访问"); + } + + var list = folderRepository.queryByDriveAndParent(driveId, parentId, user.getId()); + + var result = new LinkedHashMap(); + result.put("list", list); + result.put("parentId", parentId); + + return Result.success("查询成功", result); + } + + /** + * 查询文件夹详情 + * GET /api/drive/{driveId}/folders/{folderId} + */ + @GetMapping("/{driveId}/folders/{folderId}") + public Result getFolder(@PathVariable("driveId") Long driveId, + @PathVariable("folderId") Long folderId, + HttpServletRequest request) { + User user = getCurrentUser(request); + + DriveFolder folder = folderRepository.findById(folderId); + if (folder == null || !folder.getUserId().equals(user.getId())) { + return Result.error("查询失败:文件夹不存在或无权访问"); + } + + return Result.success("查询成功", folderToMap(folder)); + } + + /** + * 更新文件夹 + * PUT /api/drive/{driveId}/folders/{folderId} + */ + @PutMapping("/{driveId}/folders/{folderId}") + public Result updateFolder(@PathVariable("driveId") Long driveId, + @PathVariable("folderId") Long folderId, + @RequestBody Map data, + HttpServletRequest request) { + User user = getCurrentUser(request); + + DriveFolder folder = folderRepository.findById(folderId); + if (folder == null || !folder.getUserId().equals(user.getId())) { + return Result.error("更新失败:文件夹不存在或无权访问"); + } + + String name = (String) data.get("name"); + String description = (String) data.get("description"); + Long parentId = null; + Object parentIdObj = data.get("parentId"); + if (parentIdObj != null) { + parentId = ((Number) parentIdObj).longValue(); + } + + boolean success = folderRepository.update(folderId, name, description, parentId); + if (success) { + return Result.success("更新成功"); + } else { + return Result.error("更新文件夹失败:数据库写入失败,请稍后重试"); + } + } + + /** + * 删除文件夹 + * DELETE /api/drive/{driveId}/folders/{folderId} + */ + @DeleteMapping("/{driveId}/folders/{folderId}") + public Result deleteFolder(@PathVariable("driveId") Long driveId, + @PathVariable("folderId") Long folderId, + HttpServletRequest request) { + User user = getCurrentUser(request); + + DriveFolder folder = folderRepository.findById(folderId); + if (folder == null || !folder.getUserId().equals(user.getId())) { + return Result.error("删除失败:文件夹不存在或无权访问"); + } + + // 检查文件夹是否为空 + if (folder.getChildFolderCount() > 0 || folder.getFileCount() > 0) { + return Result.error("删除失败:文件夹不为空,请先删除文件夹内的子文件夹和电影"); + } + + boolean success = folderRepository.delete(folderId, user.getId()); + if (success) { + return Result.success("删除成功"); + } else { + return Result.error("删除文件夹失败:数据库操作失败,请稍后重试"); + } + } + + // ==================== 电影操作 ==================== + + /** + * 创建电影(上传文件) + * POST /api/drive/{driveId}/movies + */ + @PostMapping("/{driveId}/movies") + public Result createMovie(@PathVariable("driveId") Long driveId, + @RequestBody Map data, + HttpServletRequest request) { + User user = getCurrentUser(request); + + Drive drive = driveRepository.findById(driveId); + if (drive == null || !drive.getUserId().equals(user.getId())) { + return Result.error("创建电影失败:网盘不存在或无权访问"); + } + + String title = (String) data.get("title"); + if (title == null || title.isBlank()) { + return Result.error("创建电影失败:电影标题不能为空"); + } + + Long folderId = 0L; + Object folderIdObj = data.get("folderId"); + if (folderIdObj != null) { + 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); + 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) { + movie.setId(id); + + // 更新网盘和文件夹的统计信息 + updateDriveStats(driveId, user.getId()); + if (folderId > 0) { + updateFolderStats(folderId, user.getId()); + } + + return Result.success("创建成功", movieToMap(movie)); + } else { + return Result.error("创建电影失败:数据库写入失败,请稍后重试"); + } + } catch (Exception e) { + log.error("创建电影异常", e); + return Result.error("创建电影失败:系统异常,请联系管理员"); + } + } + + /** + * 查询文件夹下电影列表 + * GET /api/drive/{driveId}/folders/{folderId}/movies + */ + @GetMapping("/{driveId}/folders/{folderId}/movies") + public Result listMovies(@PathVariable("driveId") Long driveId, + @PathVariable("folderId") Long folderId, + @RequestParam(defaultValue = "1") int pageNo, + @RequestParam(defaultValue = "20") int pageSize, + HttpServletRequest request) { + User user = getCurrentUser(request); + + Drive drive = driveRepository.findById(driveId); + if (drive == null || !drive.getUserId().equals(user.getId())) { + return Result.error("查询失败:网盘不存在或无权访问"); + } + + pageSize = Math.min(MAX_PAGE_SIZE, Math.max(1, pageSize)); + + var list = movieRepository.queryByDriveAndFolder(driveId, folderId, user.getId(), pageNo, pageSize); + var total = movieRepository.countByDriveAndFolder(driveId, folderId, user.getId()); + + var result = new LinkedHashMap(); + result.put("list", list); + result.put("total", total); + result.put("pageNo", pageNo); + result.put("pageSize", pageSize); + + return Result.success("查询成功", result); + } + + /** + * 查询电影详情 + * GET /api/drive/{driveId}/movies/{movieId} + */ + @GetMapping("/{driveId}/movies/{movieId}") + public Result getMovie(@PathVariable("driveId") Long driveId, + @PathVariable("movieId") Long movieId, + HttpServletRequest request) { + User user = getCurrentUser(request); + + DriveMovie movie = movieRepository.findById(movieId); + if (movie == null || !movie.getUserId().equals(user.getId())) { + return Result.error("查询失败:电影不存在或无权访问"); + } + + return Result.success("查询成功", movieToMap(movie)); + } + + /** + * 更新电影信息 + * PUT /api/drive/{driveId}/movies/{movieId} + */ + @PutMapping("/{driveId}/movies/{movieId}") + public Result updateMovie(@PathVariable("driveId") Long driveId, + @PathVariable("movieId") Long movieId, + @RequestBody Map data, + HttpServletRequest request) { + User user = getCurrentUser(request); + + DriveMovie movie = movieRepository.findById(movieId); + if (movie == null || !movie.getUserId().equals(user.getId())) { + return Result.error("更新失败:电影不存在或无权访问"); + } + + String title = (String) data.get("title"); + String subtitle = (String) data.get("subtitle"); + String description = (String) data.get("description"); + Object ratingObj = data.get("rating"); + Integer rating = null; + if (ratingObj != null) { + rating = ((Number) ratingObj).intValue(); + } + String tags = (String) data.get("tags"); + String category = (String) data.get("category"); + + boolean success = movieRepository.update(movieId, title, subtitle, description, rating, tags, category); + if (success) { + return Result.success("更新成功"); + } else { + return Result.error("更新电影失败:数据库写入失败,请稍后重试"); + } + } + + /** + * 删除电影 + * DELETE /api/drive/{driveId}/movies/{movieId} + */ + @DeleteMapping("/{driveId}/movies/{movieId}") + public Result deleteMovie(@PathVariable("driveId") Long driveId, + @PathVariable("movieId") Long movieId, + HttpServletRequest request) { + User user = getCurrentUser(request); + + DriveMovie movie = movieRepository.findById(movieId); + if (movie == null || !movie.getUserId().equals(user.getId())) { + return Result.error("删除失败:电影不存在或无权访问"); + } + + boolean success = movieRepository.logicalDelete(movieId, user.getId()); + if (success) { + // 更新统计信息 + updateDriveStats(movie.getDriveId(), user.getId()); + if (movie.getFolderId() > 0) { + updateFolderStats(movie.getFolderId(), user.getId()); + } + return Result.success("删除成功"); + } else { + return Result.error("删除电影失败:数据库操作失败,请稍后重试"); + } + } + + // ==================== 辅助方法 ==================== + + /** + * 更新网盘统计信息(统计所有层级的文件夹和电影) + */ + private void updateDriveStats(Long driveId, Long userId) { + // 统计整个网盘下所有层级的文件夹总数 + var folderCount = folderRepository.countByDrive(driveId, userId); + // 统计整个网盘下所有层级的电影总数 + var movieCount = movieRepository.countByDrive(driveId, userId); + driveRepository.updateStats(driveId, folderCount, movieCount, 0L); + } + + /** + * 更新文件夹统计信息(统计直接子文件夹和直接文件) + */ + private void updateFolderStats(Long folderId, Long userId) { + var folder = folderRepository.findById(folderId); + if (folder != null) { + var childCount = folderRepository.countByDriveAndParent(folder.getDriveId(), folderId, userId); + var movieCount = movieRepository.countByDriveAndFolder(folder.getDriveId(), folderId, userId); + folderRepository.updateStats(folderId, childCount, movieCount, 0L); + } + } + + /** + * 检测文件 MIME 类型 + */ + private String detectMimeType(String fileName) { + if (fileName == null) return "application/octet-stream"; + var lower = fileName.toLowerCase(); + if (lower.endsWith(".mp4") || lower.endsWith(".mkv") || lower.endsWith(".avi")) return "video/mp4"; + if (lower.endsWith(".jpg") || lower.endsWith(".jpeg")) return "image/jpeg"; + if (lower.endsWith(".png")) return "image/png"; + if (lower.endsWith(".gif")) return "image/gif"; + if (lower.endsWith(".webp")) return "image/webp"; + if (lower.endsWith(".pdf")) return "application/pdf"; + if (lower.endsWith(".doc") || lower.endsWith(".docx")) return "application/msword"; + if (lower.endsWith(".mp3")) return "audio/mpeg"; + return "application/octet-stream"; + } + + /** + * 上传网盘 Logo 图片到 MinIO + */ + private String uploadDriveLogo(String logoData) throws Exception { + String base64Content = logoData; + String mimeType = "image/png"; + + if (logoData.startsWith("data:")) { + int semicolonIndex = logoData.indexOf(';'); + int commaIndex = logoData.indexOf(','); + if (semicolonIndex > 0 && commaIndex > semicolonIndex) { + mimeType = logoData.substring(5, semicolonIndex); + base64Content = logoData.substring(commaIndex + 1); + } + } + + byte[] imageBytes = Base64.getDecoder().decode(base64Content); + + String extension = switch (mimeType) { + case "image/jpeg", "image/jpg" -> ".jpg"; + case "image/gif" -> ".gif"; + case "image/webp" -> ".webp"; + default -> ".png"; + }; + + String objectName = "drive-logo/" + System.currentTimeMillis() + "_" + (int)(Math.random() * 10000) + extension; + + try (var inputStream = new ByteArrayInputStream(imageBytes)) { + String uploadedName = MinioUtil.upload(inputStream, objectName, mimeType); + if (uploadedName == null) { + throw new RuntimeException("文件上传到存储服务失败"); + } + } + + return MinioUtil.getFileUrl(objectName); + } + + /** + * 获取文件扩展名 + */ + private String getExtension(String fileName) { + if (fileName == null || !fileName.contains(".")) return ""; + return fileName.substring(fileName.lastIndexOf(".") + 1).toLowerCase(); + } + + /** + * Drive 实体转 Map + */ + private Map driveToMap(Drive drive) { + var map = new LinkedHashMap(); + map.put("id", drive.getId()); + map.put("name", drive.getName()); + map.put("description", drive.getDescription()); + map.put("logoUrl", drive.getLogoUrl()); + map.put("folderCount", drive.getFolderCount()); + map.put("fileCount", drive.getFileCount()); + map.put("totalSize", drive.getTotalSize()); + map.put("status", drive.getStatus()); + map.put("createTime", drive.getCreateTime() != null ? drive.getCreateTime().toString() : null); + map.put("updateTime", drive.getUpdateTime() != null ? drive.getUpdateTime().toString() : null); + return map; + } + + /** + * DriveFolder 实体转 Map + */ + private Map folderToMap(DriveFolder folder) { + var map = new LinkedHashMap(); + map.put("id", folder.getId()); + map.put("driveId", folder.getDriveId()); + map.put("parentId", folder.getParentId()); + map.put("name", folder.getName()); + map.put("description", folder.getDescription()); + map.put("depth", folder.getDepth()); + map.put("childFolderCount", folder.getChildFolderCount()); + map.put("fileCount", folder.getFileCount()); + map.put("totalSize", folder.getTotalSize()); + map.put("createTime", folder.getCreateTime() != null ? folder.getCreateTime().toString() : null); + map.put("updateTime", folder.getUpdateTime() != null ? folder.getUpdateTime().toString() : null); + return map; + } + + // ==================== 重复电影检查 ==================== + + /** + * 检查当前用户所有网盘下的重复电影 + * GET /api/drive/movies/duplicates + */ + @GetMapping("/movies/duplicates") + public Result checkDuplicateMovies(HttpServletRequest request) { + User user = getCurrentUser(request); + + var duplicates = movieRepository.findDuplicateMovies(user.getId()); + + var result = new LinkedHashMap(); + result.put("duplicateGroups", duplicates); + result.put("totalGroups", duplicates.size()); + + int totalDuplicates = duplicates.stream() + .mapToInt(g -> (int) g.get("count")) + .sum(); + result.put("totalDuplicates", totalDuplicates); + + return Result.success("查询成功", result); + } + + /** + * DriveMovie 实体转 Map + */ + private Map movieToMap(DriveMovie movie) { + var map = new LinkedHashMap(); + map.put("id", movie.getId()); + map.put("driveId", movie.getDriveId()); + map.put("folderId", movie.getFolderId()); + map.put("title", movie.getTitle()); + map.put("subtitle", movie.getSubtitle()); + map.put("originalName", movie.getOriginalName()); + map.put("mimeType", movie.getMimeType()); + map.put("fileSize", movie.getFileSize()); + map.put("extension", movie.getExtension()); + map.put("description", movie.getDescription()); + map.put("resourceLibrary", movie.getResourceLibrary()); + map.put("maintainer", movie.getMaintainer()); + map.put("status", movie.getStatus()); + map.put("category", movie.getCategory()); + map.put("scrapeType", movie.getScrapeType()); + map.put("rating", movie.getRating()); + map.put("tags", movie.getTags()); + map.put("minioUrl", movie.getMinioUrl()); + map.put("posterUrl", movie.getPosterUrl()); + map.put("createTime", movie.getCreateTime() != null ? movie.getCreateTime().toString() : null); + map.put("updateTime", movie.getUpdateTime() != null ? movie.getUpdateTime().toString() : null); + return map; + } +} \ No newline at end of file diff --git a/wangpan-drive-service/src/main/java/com/wangpan/controller/IconController.java b/wangpan-drive-service/src/main/java/com/wangpan/controller/IconController.java new file mode 100644 index 0000000..96b471f --- /dev/null +++ b/wangpan-drive-service/src/main/java/com/wangpan/controller/IconController.java @@ -0,0 +1,361 @@ +package com.wangpan.controller; + +import com.wangpan.common.Result; +import com.wangpan.common.annotation.RequireAuth; +import com.wangpan.common.dal.repository.IconRepository; +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 jakarta.servlet.http.HttpServletRequest; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.web.bind.annotation.*; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * Icon 管理控制器 + * + * 提供 Icon 的增删改查接口 + * 每个用户只能管理自己创建的 Icon,数据完全隔离 + */ +@RestController +@RequireAuth +@RequestMapping("/api/icon") +@CrossOrigin(origins = {"*"}, allowCredentials = "false") +public class IconController { + + private static final Logger log = LoggerFactory.getLogger(IconController.class); + + private final IconRepository iconRepository; + + /** + * 构造函数注入 Repository 依赖 + */ + public IconController(IconRepository iconRepository) { + this.iconRepository = iconRepository; + } + + /** + * 从请求属性中获取当前登录用户(由 AuthInterceptor 注入) + */ + private User getCurrentUser(HttpServletRequest request) { + return (User) request.getAttribute(AuthInterceptor.CURRENT_USER_ATTR); + } + + // ==================== 创建 Icon ==================== + + /** + * 创建 Icon + * POST /api/icon + * + * 请求体: + * { + * "name": "百度", + * "url": "https://www.baidu.com", + * "description": "搜索引擎", + * "sortOrder": 1 + * } + */ + @PostMapping + public Result createIcon(@RequestBody Map body, HttpServletRequest request) { + User user = getCurrentUser(request); + log.info("用户 [{}] 创建 Icon", user.getUsername()); + + try { + // 参数提取和校验 + String name = (String) body.get("name"); + String url = (String) body.get("url"); + String internalUrl = (String) body.get("internalUrl"); + String linkType = (String) body.get("linkType"); + String description = (String) body.get("description"); + Integer sortOrder = body.get("sortOrder") != null ? ((Number) body.get("sortOrder")).intValue() : 0; + + if (name == null || name.trim().isEmpty()) { + return Result.error("Icon 名称不能为空"); + } + if (name.length() > 100) { + return Result.error("Icon 名称不能超过 100 个字符"); + } + // 根据 linkType 校验链接 + if (linkType == null || linkType.trim().isEmpty()) { + linkType = "external"; + } + if (!linkType.equals("external") && !linkType.equals("internal") && !linkType.equals("both")) { + return Result.error("linkType 只能是 external、internal 或 both"); + } + if (linkType.equals("external") || linkType.equals("both")) { + if (url == null || url.trim().isEmpty()) { + return Result.error("外网链接不能为空"); + } + if (url.length() > 500) { + return Result.error("外网链接不能超过 500 个字符"); + } + } + if (linkType.equals("internal") || linkType.equals("both")) { + if (internalUrl == null || internalUrl.trim().isEmpty()) { + return Result.error("内网链接不能为空"); + } + if (internalUrl.length() > 500) { + return Result.error("内网链接不能超过 500 个字符"); + } + } + if (description != null && description.length() > 500) { + return Result.error("Icon 描述不能超过 500 个字符"); + } + + // 创建 Icon 实体 + Icon icon = new Icon(); + icon.setUserId(user.getId()); + icon.setName(name.trim()); + icon.setUrl(url != null ? url.trim() : null); + icon.setInternalUrl(internalUrl != null ? internalUrl.trim() : null); + icon.setLinkType(linkType); + icon.setDescription(description != null ? description.trim() : null); + icon.setSortOrder(sortOrder); + icon.setStatus("active"); + + // 自动抓取 logo 并上传到 MinIO(优先使用外网链接抓取) + String logoSourceUrl = url != null ? url.trim() : internalUrl != null ? internalUrl.trim() : null; + if (logoSourceUrl != null) { + log.info("开始抓取 logo: {}", logoSourceUrl); + String logoUrl = IconLogoFetcher.fetchAndUploadLogo(logoSourceUrl, user.getId()); + if (logoUrl != null) { + icon.setLogoUrl(logoUrl); + log.info("Logo 抓取成功: {}", logoUrl); + } else { + log.warn("Logo 抓取失败,将保存为空"); + } + } + + icon.setCreateTime(java.time.LocalDateTime.now()); + icon.setUpdateTime(java.time.LocalDateTime.now()); + + // 插入数据库 + Long iconId = iconRepository.insert(icon); + log.info("用户 [{}] 创建 Icon 成功,ID={}", user.getUsername(), iconId); + + // 返回创建的 Icon 信息 + Icon createdIcon = iconRepository.findById(iconId); + Map result = new LinkedHashMap<>(); + result.put("id", createdIcon.getId()); + result.put("name", createdIcon.getName()); + result.put("url", createdIcon.getUrl()); + result.put("internalUrl", createdIcon.getInternalUrl()); + result.put("linkType", createdIcon.getLinkType()); + result.put("description", createdIcon.getDescription()); + result.put("sortOrder", createdIcon.getSortOrder()); + result.put("status", createdIcon.getStatus()); + result.put("logoUrl", createdIcon.getLogoUrl()); + result.put("createTime", createdIcon.getCreateTime() != null ? createdIcon.getCreateTime().toString() : null); + + return Result.success("创建成功", result); + + } catch (Exception e) { + log.error("用户 [{}] 创建 Icon 失败", user.getUsername(), e); + return Result.error("创建失败:" + e.getMessage()); + } + } + + // ==================== 查询 Icon ==================== + + /** + * 查询当前用户的 Icon 列表 + * GET /api/icon + */ + @GetMapping + public Result listIcons(HttpServletRequest request) { + User user = getCurrentUser(request); + log.info("用户 [{}] 查询 Icon 列表", user.getUsername()); + + try { + List> iconList = iconRepository.queryByUserId(user.getId()); + + Map result = new LinkedHashMap<>(); + result.put("list", iconList); + result.put("total", iconList.size()); + + return Result.success("查询成功", result); + + } catch (Exception e) { + log.error("用户 [{}] 查询 Icon 列表失败", user.getUsername(), e); + return Result.error("查询失败:" + e.getMessage()); + } + } + + /** + * 查询单个 Icon 详情 + * GET /api/icon/{id} + */ + @GetMapping("/{id}") + public Result getIconById(@PathVariable Long id, HttpServletRequest request) { + User user = getCurrentUser(request); + log.info("用户 [{}] 查询 Icon 详情,ID={}", user.getUsername(), id); + + try { + // 查询 Icon(校验用户归属) + Icon icon = iconRepository.findByIdAndUserId(id, user.getId()); + if (icon == null) { + return Result.error("Icon 不存在或无权访问"); + } + + Map result = new LinkedHashMap<>(); + result.put("id", icon.getId()); + result.put("name", icon.getName()); + result.put("url", icon.getUrl()); + result.put("internalUrl", icon.getInternalUrl()); + result.put("linkType", icon.getLinkType()); + result.put("description", icon.getDescription()); + result.put("sortOrder", icon.getSortOrder()); + result.put("status", icon.getStatus()); + result.put("logoUrl", icon.getLogoUrl()); + result.put("createTime", icon.getCreateTime() != null ? icon.getCreateTime().toString() : null); + result.put("updateTime", icon.getUpdateTime() != null ? icon.getUpdateTime().toString() : null); + + return Result.success("查询成功", result); + + } catch (Exception e) { + log.error("用户 [{}] 查询 Icon 详情失败,ID={}", user.getUsername(), id, e); + return Result.error("查询失败:" + e.getMessage()); + } + } + + // ==================== 更新 Icon ==================== + + /** + * 更新 Icon + * PUT /api/icon/{id} + * + * 请求体(所有字段可选): + * { + * "name": "新名称", + * "url": "https://new-url.com", + * "description": "新描述", + * "sortOrder": 2 + * } + */ + @PutMapping("/{id}") + public Result updateIcon(@PathVariable Long id, @RequestBody Map body, HttpServletRequest request) { + User user = getCurrentUser(request); + log.info("用户 [{}] 更新 Icon,ID={}", user.getUsername(), id); + + try { + // 参数提取 + String name = (String) body.get("name"); + String url = (String) body.get("url"); + String internalUrl = (String) body.get("internalUrl"); + String linkType = (String) body.get("linkType"); + String description = (String) body.get("description"); + Integer sortOrder = body.get("sortOrder") != null ? ((Number) body.get("sortOrder")).intValue() : null; + + // 参数校验 + if (name != null) { + if (name.trim().isEmpty()) { + return Result.error("Icon 名称不能为空"); + } + if (name.length() > 100) { + return Result.error("Icon 名称不能超过 100 个字符"); + } + name = name.trim(); + } + if (url != null) { + if (url.trim().isEmpty()) { + return Result.error("外网链接不能为空"); + } + if (url.length() > 500) { + return Result.error("外网链接不能超过 500 个字符"); + } + url = url.trim(); + } + if (internalUrl != null) { + if (internalUrl.trim().isEmpty()) { + return Result.error("内网链接不能为空"); + } + if (internalUrl.length() > 500) { + return Result.error("内网链接不能超过 500 个字符"); + } + internalUrl = internalUrl.trim(); + } + if (linkType != null) { + if (!linkType.equals("external") && !linkType.equals("internal") && !linkType.equals("both")) { + return Result.error("linkType 只能是 external、internal 或 both"); + } + } + if (description != null && description.length() > 500) { + return Result.error("Icon 描述不能超过 500 个字符"); + } + if (description != null) { + description = description.trim(); + } + + // 更新数据库(校验用户归属) + boolean success = iconRepository.update(id, user.getId(), name, url, internalUrl, linkType, description, sortOrder); + if (!success) { + return Result.error("Icon 不存在或无权修改"); + } + + // 如果 url 发生变化,重新抓取 logo + if (url != null) { + log.info("URL 已更新,重新抓取 logo: {}", url); + String newLogoUrl = IconLogoFetcher.fetchAndUploadLogo(url, user.getId()); + if (newLogoUrl != null) { + iconRepository.updateLogoUrl(id, newLogoUrl); + log.info("Logo 重新抓取成功: {}", newLogoUrl); + } + } + + log.info("用户 [{}] 更新 Icon 成功,ID={}", user.getUsername(), id); + + // 返回更新后的 Icon 信息 + Icon updatedIcon = iconRepository.findByIdAndUserId(id, user.getId()); + Map result = new LinkedHashMap<>(); + result.put("id", updatedIcon.getId()); + result.put("name", updatedIcon.getName()); + result.put("url", updatedIcon.getUrl()); + result.put("internalUrl", updatedIcon.getInternalUrl()); + result.put("linkType", updatedIcon.getLinkType()); + result.put("description", updatedIcon.getDescription()); + result.put("sortOrder", updatedIcon.getSortOrder()); + result.put("status", updatedIcon.getStatus()); + result.put("logoUrl", updatedIcon.getLogoUrl()); + result.put("createTime", updatedIcon.getCreateTime() != null ? updatedIcon.getCreateTime().toString() : null); + result.put("updateTime", updatedIcon.getUpdateTime() != null ? updatedIcon.getUpdateTime().toString() : null); + + return Result.success("更新成功", result); + + } catch (Exception e) { + log.error("用户 [{}] 更新 Icon 失败,ID={}", user.getUsername(), id, e); + return Result.error("更新失败:" + e.getMessage()); + } + } + + // ==================== 删除 Icon ==================== + + /** + * 删除 Icon + * DELETE /api/icon/{id} + */ + @DeleteMapping("/{id}") + public Result deleteIcon(@PathVariable Long id, HttpServletRequest request) { + User user = getCurrentUser(request); + log.info("用户 [{}] 删除 Icon,ID={}", user.getUsername(), id); + + try { + // 删除 Icon(校验用户归属) + boolean success = iconRepository.delete(id, user.getId()); + if (!success) { + return Result.error("Icon 不存在或无权删除"); + } + + log.info("用户 [{}] 删除 Icon 成功,ID={}", user.getUsername(), id); + return Result.success("删除成功", null); + + } catch (Exception e) { + log.error("用户 [{}] 删除 Icon 失败,ID={}", user.getUsername(), id, e); + return Result.error("删除失败:" + e.getMessage()); + } + } +} diff --git a/wangpan-drive-service/src/main/resources/application.yml b/wangpan-drive-service/src/main/resources/application.yml new file mode 100644 index 0000000..9f7b41b --- /dev/null +++ b/wangpan-drive-service/src/main/resources/application.yml @@ -0,0 +1,45 @@ +# 网盘服务配置 +server: + port: 8082 + +spring: + application: + name: wangpan-drive-service + datasource: + url: ENC(nJXPBEsg+NwtZt+qnD9A0AwRIdPFwex6kYEfGDetJ+N42ha/dH7V3Lq/hSCYgZa08cocXV5N+AaiisG/gh+i8CcNYjXzFV8CMSRfLwx03cxxNwnDzak+2ZEY5nb8UTbC/a6b3pWuAwg9lnHF9ziroFIrOn8+s/SYN7e+oMh4tZFmfCT4h5ATsEEuB6RkYGTzf0oImBVmet1jRR4CRdHPSeh6jSWa7y1TutJoeBWyDpzHNgqE/JRLd1VNoc583/Bn) + username: ENC(9cCYl0WIh2m3gQhAfq4ykWAm5+PUvr6yPcuGKPwYDPpADnTZKVv9eYhwT806vYti) + password: ENC(Nsh0DCCayqJJpWpEmCLX/cYbMtmip1pgmd/0vunXnNAOK1Kn3JSSdHxZV+0s3Unj) + driver-class-name: com.mysql.cj.jdbc.Driver + +# MinIO 配置 +minio: + endpoint: ENC(nDpSqjv62odavtIjInqIzcjmWua20QGTKKfKsJlxgCTkWGWj/JJsIAEfsLIzKjpbeAMl/mxVJuPQv8o58b46gQ==) + accessKey: ENC(YM+d/P5KM/QqGFcbbxcZlM+jH0dTgaYlGiYOJ04dIyBX+Z4fu+h05tdylTy0SroK) + secretKey: ENC(BKqQFlceb8mGmAMWbmXFVV1US1Ib+24VApZCci0xyz+xAqL9MQetHJKsrWW1Vib2) + bucketName: wangpan + +# Jasypt 加密配置 +jasypt: + encryptor: + password: ${JASYPT_ENCRYPTOR_PASSWORD:wangpan-secret-key-2024} + 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 + +# MyBatis-Plus 配置 +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 + log-impl: org.apache.ibatis.logging.stdout.StdOutImpl + global-config: + db-config: + id-type: auto diff --git a/wangpan-drive-service/target/classes/application.yml b/wangpan-drive-service/target/classes/application.yml new file mode 100644 index 0000000..9f7b41b --- /dev/null +++ b/wangpan-drive-service/target/classes/application.yml @@ -0,0 +1,45 @@ +# 网盘服务配置 +server: + port: 8082 + +spring: + application: + name: wangpan-drive-service + datasource: + url: ENC(nJXPBEsg+NwtZt+qnD9A0AwRIdPFwex6kYEfGDetJ+N42ha/dH7V3Lq/hSCYgZa08cocXV5N+AaiisG/gh+i8CcNYjXzFV8CMSRfLwx03cxxNwnDzak+2ZEY5nb8UTbC/a6b3pWuAwg9lnHF9ziroFIrOn8+s/SYN7e+oMh4tZFmfCT4h5ATsEEuB6RkYGTzf0oImBVmet1jRR4CRdHPSeh6jSWa7y1TutJoeBWyDpzHNgqE/JRLd1VNoc583/Bn) + username: ENC(9cCYl0WIh2m3gQhAfq4ykWAm5+PUvr6yPcuGKPwYDPpADnTZKVv9eYhwT806vYti) + password: ENC(Nsh0DCCayqJJpWpEmCLX/cYbMtmip1pgmd/0vunXnNAOK1Kn3JSSdHxZV+0s3Unj) + driver-class-name: com.mysql.cj.jdbc.Driver + +# MinIO 配置 +minio: + endpoint: ENC(nDpSqjv62odavtIjInqIzcjmWua20QGTKKfKsJlxgCTkWGWj/JJsIAEfsLIzKjpbeAMl/mxVJuPQv8o58b46gQ==) + accessKey: ENC(YM+d/P5KM/QqGFcbbxcZlM+jH0dTgaYlGiYOJ04dIyBX+Z4fu+h05tdylTy0SroK) + secretKey: ENC(BKqQFlceb8mGmAMWbmXFVV1US1Ib+24VApZCci0xyz+xAqL9MQetHJKsrWW1Vib2) + bucketName: wangpan + +# Jasypt 加密配置 +jasypt: + encryptor: + password: ${JASYPT_ENCRYPTOR_PASSWORD:wangpan-secret-key-2024} + 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 + +# MyBatis-Plus 配置 +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 + log-impl: org.apache.ibatis.logging.stdout.StdOutImpl + global-config: + db-config: + id-type: auto diff --git a/wangpan-drive-service/target/classes/com/wangpan/DriveServiceApplication.class b/wangpan-drive-service/target/classes/com/wangpan/DriveServiceApplication.class new file mode 100644 index 0000000..8fae267 Binary files /dev/null and b/wangpan-drive-service/target/classes/com/wangpan/DriveServiceApplication.class differ diff --git a/wangpan-drive-service/target/classes/com/wangpan/config/DatabaseInitializer.class b/wangpan-drive-service/target/classes/com/wangpan/config/DatabaseInitializer.class new file mode 100644 index 0000000..befee8d Binary files /dev/null and b/wangpan-drive-service/target/classes/com/wangpan/config/DatabaseInitializer.class differ diff --git a/wangpan-drive-service/target/classes/com/wangpan/controller/DriveController.class b/wangpan-drive-service/target/classes/com/wangpan/controller/DriveController.class new file mode 100644 index 0000000..6753c4e Binary files /dev/null and b/wangpan-drive-service/target/classes/com/wangpan/controller/DriveController.class differ diff --git a/wangpan-drive-service/target/classes/com/wangpan/controller/IconController.class b/wangpan-drive-service/target/classes/com/wangpan/controller/IconController.class new file mode 100644 index 0000000..6efff54 Binary files /dev/null and b/wangpan-drive-service/target/classes/com/wangpan/controller/IconController.class differ diff --git a/wangpan-drive-service/target/maven-archiver/pom.properties b/wangpan-drive-service/target/maven-archiver/pom.properties new file mode 100644 index 0000000..fc7328f --- /dev/null +++ b/wangpan-drive-service/target/maven-archiver/pom.properties @@ -0,0 +1,3 @@ +artifactId=wangpan-drive-service +groupId=com.wangpan +version=1.0-SNAPSHOT diff --git a/wangpan-drive-service/target/maven-status/maven-compiler-plugin/compile/default-compile/createdFiles.lst b/wangpan-drive-service/target/maven-status/maven-compiler-plugin/compile/default-compile/createdFiles.lst new file mode 100644 index 0000000..7c920b6 --- /dev/null +++ b/wangpan-drive-service/target/maven-status/maven-compiler-plugin/compile/default-compile/createdFiles.lst @@ -0,0 +1,4 @@ +com\wangpan\config\DatabaseInitializer.class +com\wangpan\controller\DriveController.class +com\wangpan\controller\IconController.class +com\wangpan\DriveServiceApplication.class diff --git a/wangpan-drive-service/target/maven-status/maven-compiler-plugin/compile/default-compile/inputFiles.lst b/wangpan-drive-service/target/maven-status/maven-compiler-plugin/compile/default-compile/inputFiles.lst new file mode 100644 index 0000000..947c925 --- /dev/null +++ b/wangpan-drive-service/target/maven-status/maven-compiler-plugin/compile/default-compile/inputFiles.lst @@ -0,0 +1,4 @@ +D:\java2\wangpan\java\wangpan-drive-service\src\main\java\com\wangpan\config\DatabaseInitializer.java +D:\java2\wangpan\java\wangpan-drive-service\src\main\java\com\wangpan\controller\DriveController.java +D:\java2\wangpan\java\wangpan-drive-service\src\main\java\com\wangpan\DriveServiceApplication.java +D:\java2\wangpan\java\wangpan-drive-service\src\main\java\com\wangpan\controller\IconController.java diff --git a/wangpan-drive-service/target/wangpan-drive-service-1.0-SNAPSHOT.jar b/wangpan-drive-service/target/wangpan-drive-service-1.0-SNAPSHOT.jar new file mode 100644 index 0000000..26d101f Binary files /dev/null and b/wangpan-drive-service/target/wangpan-drive-service-1.0-SNAPSHOT.jar differ diff --git a/wangpan-drive-service/target/wangpan-drive-service-1.0-SNAPSHOT.jar.original b/wangpan-drive-service/target/wangpan-drive-service-1.0-SNAPSHOT.jar.original new file mode 100644 index 0000000..63e00e4 Binary files /dev/null and b/wangpan-drive-service/target/wangpan-drive-service-1.0-SNAPSHOT.jar.original differ diff --git a/wangpan-gateway/pom.xml b/wangpan-gateway/pom.xml new file mode 100644 index 0000000..f5dbe9d --- /dev/null +++ b/wangpan-gateway/pom.xml @@ -0,0 +1,50 @@ + + + 4.0.0 + + + com.wangpan + wangpan-parent + 1.0-SNAPSHOT + + + wangpan-gateway + jar + + WangPan Gateway + API 网关 - 统一入口、路由转发、跨域处理 + + + + + org.springframework.cloud + spring-cloud-starter-gateway + 4.1.4 + + + + + com.github.ulisesbocchio + jasypt-spring-boot-starter + 3.0.5 + + + + + org.springframework.boot + spring-boot-starter-test + test + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + diff --git a/wangpan-gateway/src/main/java/com/wangpan/GatewayApplication.java b/wangpan-gateway/src/main/java/com/wangpan/GatewayApplication.java new file mode 100644 index 0000000..39f73ce --- /dev/null +++ b/wangpan-gateway/src/main/java/com/wangpan/GatewayApplication.java @@ -0,0 +1,19 @@ +package com.wangpan; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +/** + * API 网关启动类 + * 负责:统一入口、路由转发、跨域处理、负载均衡 + */ +@SpringBootApplication +public class GatewayApplication { + + public static void main(String[] args) { + SpringApplication.run(GatewayApplication.class, args); + System.out.println("========================================"); + System.out.println(" API 网关启动成功!"); + System.out.println("========================================"); + } +} diff --git a/wangpan-gateway/src/main/resources/META-INF/resources/api-doc.html b/wangpan-gateway/src/main/resources/META-INF/resources/api-doc.html new file mode 100644 index 0000000..9a87fd8 --- /dev/null +++ b/wangpan-gateway/src/main/resources/META-INF/resources/api-doc.html @@ -0,0 +1,1968 @@ + + + + + + 网盘系统 API 接口文档 + + + +
+
+

网盘系统 API 接口文档

+

微服务架构 · 统一网关入口

+
网关地址:http://localhost:8888
+
+ + + + +
+ +
+
+ +
+ +
+
+

服务架构图

+
+
前端
+ +
Gateway:8888
+ +
User:8081
+
+
+ + + + +
Drive:8082
+
+
+ + + + +
TMDB:8083
+
+
+ +
+

路由规则

+ + + + + + + +
请求路径转发服务说明
/api/login, /api/registeruser-service登录注册
/api/token/**, /api/login-logs/**user-service令牌/日志
/api/drive/**drive-service网盘管理
/api/movies/**tmdb-service电影管理
/api/tmdb/**, /api/tmdb-scraper/**tmdb-serviceTMDB刮削
+
+
+ + +
+
+

用户认证服务

+ wangpan-user-service : 8081 +
+ +
+
+ POST + /api/login + 用户登录 +
+
+
+

请求体

+
{ + "username": "用户名", + "password": "密码" +}
+
+
+

成功响应示例

+
{ + "code": 200, + "message": "登录成功", + "data": { + "id": 7, + "username": "lrq", + "userType": 0, // 0=普通用户, 1=VIP, 2=管理员 + "status": 1, + "email": "xxx@xx.com", + "phone": "138****0000", + "realName": "张三", + "avatarUrl": "", + "lastLoginIp": "127.0.0.1", + "lastLoginTime": "2026-06-10T11:20:21", + "hasTmdbKey": false, + "token": "tk_xxx...", + "tokenType": "Bearer", + "expireIn": 604800 + } +}
+
+
+

userType 说明

+ + + + + +
角色权限说明
0普通用户仅访问自己的数据(如日志只看自己的)
1VIP 用户普通用户 + 更多存储空间
2管理员可查看所有用户数据(如全部日志)
+
+
+
+ +
+
+ POST + /api/register + 用户注册 +
+
+
+

请求体

+
{ + "username": "用户名", + "password": "密码" +}
+
+
+
+ +
+
+ GET + /api/token/validate + 令牌验证 +
+
+
+

请求头

+
Authorization: Bearer <token>
+
+
+
+ +
+
+ GET + /api/login-logs + 登录日志查询需Token +
+
+
+

权限说明

+ + + + +
用户类型可见范围
普通用户(userType=0/1)仅自己的登录记录
管理员(userType=2)所有用户的登录记录
+
+
+

查询参数

+ + + + + + + + +
参数类型必填说明
usernameString按用户名模糊筛选(管理员可用)
resultString登录结果:success / fail
startDateString开始日期(含),格式:yyyy-MM-dd,如 2026-06-01
endDateString结束日期(含),格式:yyyy-MM-dd,如 2026-06-10
pageNoint页码,默认 1
pageSizeint每页条数,默认 20
+

提示:可组合使用多个筛选条件,如 ?result=fail&startDate=2026-06-01&endDate=2026-06-10

+
+
+
+
+ + +
+
+

用户管理

+ wangpan-user-service : 8081 + 仅管理员 (userType=2) +
+ + +
+
+ GET + /api/users + 查询用户列表需Token + 管理员 +
+
+
+

权限说明

+ + + + +
角色访问权限
普通/VIP 用户403 权限不足
管理员可查看所有用户列表
+
+
+

查询参数

+ + + + + + + +
参数类型必填说明
usernameString用户名模糊搜索
statusint状态:0=禁用, 1=正常, 2=锁定
userTypeint角色:0=普通, 1=VIP, 2=管理员
pageNoint页码,默认 1
pageSizeint每页条数,默认 20
+
+
+

响应示例

+
{ + "code": 200, + "data": { + "total": 5, + "list": [ + { "id": 7, "username": "lrq", "userType": 0, "status": 1, ... } + ], + "pageNo": 1, + "pageSize": 20 + } +}
+
+
+
+ + +
+
+ POST + /api/users + 新增用户需Token + 管理员 +
+
+
+

请求体

+
{ + "username": "必填", + "password": "必填", + "email": "可选", + "phone": "可选", + "realName": "可选", + "status": 1, // 可选:0=禁用, 1=正常, 默认1 + "userType": 0 // 可选:0=普通, 1=VIP, 2=管理员, 默认0 +}
+
+
+

成功响应

+
{ + "code": 200, + "message": "用户创建成功", + "data": { + "id": 10, + "username": "newuser", + "email": "new@test.com", + "status": 1, + "userType": 0 + } +}
+
+
+

错误码

+ + + + + + + +
code说明
400用户名或密码为空
409用户名已被占用
401未登录或 Token 无效
403非管理员,无权限操作
500服务器内部错误
+
+
+
+ + +
+
+ PUT + /api/users/{userId} + 修改用户信息需Token + 管理员 +
+
+
+

路径参数

+ + + +
参数类型说明
userIdLong目标用户 ID
+
+
+

请求体(部分更新,只传需要修改的字段)

+
{ + "email": "新邮箱", // 可选 + "phone": "新手机号", // 可选 + "realName": "新姓名", // 可选 + "status": 0, // 可选:0=禁用, 1=正常, 2=锁定 + "userType": 1 // 可选:0=普通, 1=VIP, 2=管理员 +}
+
+
+

成功响应

+
{ + "code": 200, + "message": "用户信息更新成功", + "data": { + "id": 10, + "username": "newuser", + "email": "new@test.com", + "status": 1, + "userType": 0 + } +}
+
+
+

错误码

+ + + + + + +
code说明
404目标用户不存在
400没有需要更新的字段
403非管理员
500服务器内部错误
+
+
+
+ + +
+
+ DELETE + /api/users/{userId} + 删除用户(软删除)需Token + 管理员 +
+
+
+

路径参数

+ + + +
参数类型说明
userIdLong要删除的用户 ID
+
+
+

说明

+

软删除操作,将 is_deleted 设为 1,不物理删除数据。

+
+
+

成功响应

+
{ + "code": 200, + "message": "用户删除成功", + "data": null +}
+
+
+

错误码

+ + + + + + +
code说明
404目标用户不存在
400删除失败(可能已被删除)
403非管理员
500服务器内部错误
+
+
+
+
+ + +
+
+

网盘管理服务

+ wangpan-drive-service : 8082 +
+ +
+
+ POST + /api/drive + 创建网盘需Token +
+
+
+

请求体

+
{ + "name": "网盘名称", + "description": "网盘描述", + "logoData": "data:image/png;base64,iVBOR..." +}
+
+
+

参数说明

+ + + + + +
参数类型必填说明
nameString网盘名称
descriptionString网盘描述
logoDataStringLogo图片Base64编码,支持 data:image/xxx;base64, 前缀格式,上传后存储到MinIO,数据库仅保存URL
+
+
+
+ +
+
+ GET + /api/drive + 查询网盘列表需Token +
+
+
+

查询参数

+ + + + +
参数默认值说明
pageNo1页码
pageSize20每页条数
+
+
+

响应字段

+ + + + + + + + + + + + +
字段类型说明
idLong网盘ID
nameString网盘名称
descriptionString网盘描述
logoUrlString网盘Logo图片链接(MinIO存储地址)
folderCountInteger文件夹数量
fileCountInteger文件数量
totalSizeLong总大小(字节)
statusString状态
createTimeString创建时间
updateTimeString更新时间
+
+
+
+ +
+
+ GET + /api/drive/{id} + 查询网盘详情需Token +
+
+
+

路径参数

+ + + +
参数类型说明
idLong网盘ID
+
+
+

响应字段

+ + + + + + + + + + + + +
字段类型说明
idLong网盘ID
nameString网盘名称
descriptionString网盘描述
logoUrlString网盘Logo图片链接(MinIO存储地址)
folderCountInteger文件夹数量
fileCountInteger文件数量
totalSizeLong总大小(字节)
statusString状态
createTimeString创建时间
updateTimeString更新时间
+
+
+
+ +
+
+ PUT + /api/drive/{id} + 更新网盘需Token +
+
+
+

请求体

+
{ + "name": "新网盘名称", + "description": "新描述", + "logoData": "data:image/png;base64,iVBOR..." +}
+
+
+

参数说明

+ + + + + +
参数类型必填说明
nameString网盘名称
descriptionString网盘描述
logoDataString新的Logo图片Base64编码,传入后覆盖旧Logo;不传则保留原Logo
+
+
+
+ +
+
+ DELETE + /api/drive/{id} + 删除网盘需Token +
+
+
+ +
+
+ POST + /api/drive/{driveId}/folders + 创建文件夹需Token +
+
+
+

请求体

+
{ + "name": "文件夹名称", + "parentId": 0 +}
+
+
+
+ +
+
+ GET + /api/drive/{driveId}/folders + 查询文件夹列表需Token +
+
+
+

查询参数

+ + + +
参数默认值说明
parentId0父文件夹 ID
+
+
+
+ +
+
+ GET + /api/drive/{driveId}/folders/{folderId} + 查询文件夹详情需Token +
+
+
+

路径参数

+ + + + +
参数类型说明
driveIdLong网盘ID
folderIdLong文件夹ID
+
+
+

响应字段

+ + + + + + + + + + + + + +
字段类型说明
idLong文件夹ID
driveIdLong所属网盘ID
parentIdLong父文件夹ID(0表示根目录)
nameString文件夹名称
descriptionString文件夹描述
depthInteger层级深度
childFolderCountInteger子文件夹数量
fileCountInteger文件数量
totalSizeLong总大小(字节)
createTimeString创建时间
updateTimeString更新时间
+
+
+
+ +
+
+ PUT + /api/drive/{driveId}/folders/{folderId} + 更新文件夹需Token +
+
+
+

路径参数

+ + + + +
参数类型说明
driveIdLong网盘ID
folderIdLong文件夹ID
+
+
+

请求体(部分更新)

+
{ + "name": "新文件夹名称", + "description": "新描述", + "parentId": 0 +}
+
+
+

参数说明

+ + + + + +
参数类型必填说明
nameString文件夹名称
descriptionString文件夹描述
parentIdLong父文件夹ID(可移动文件夹)
+
+
+
+ +
+
+ DELETE + /api/drive/{driveId}/folders/{folderId} + 删除文件夹需Token +
+
+
+

路径参数

+ + + + +
参数类型说明
driveIdLong网盘ID
folderIdLong文件夹ID
+
+
+

说明

+

删除文件夹前会检查文件夹是否为空,如果包含子文件夹或电影文件则拒绝删除。

+
+
+

错误码

+ + + + + +
code说明
400文件夹不为空或数据库操作失败
401未登录或Token无效
403无权访问该文件夹
+
+
+
+ +
+
+ POST + /api/drive/{driveId}/movies + 上传电影文件需Token +
+
+
+

路径参数

+ + + +
参数类型说明
driveIdLong网盘ID
+
+
+

请求体

+
{ + "title": "电影名称", // 必填 + "folderId": 0, // 可选,默认0(根目录) + "subtitle": "副标题", // 可选 + "description": "描述", // 可选 + "fileData": "Base64编码的文件数据", // 必填 + "rating": 85, // 可选,评分0-100 + "tags": "标签1,标签2" // 可选 +}
+
+
+

参数说明

+ + + + + + + + + +
参数类型必填说明
titleString电影名称(同时作为文件名)
folderIdLong目标文件夹ID,默认0(根目录)
subtitleString副标题
descriptionString描述
fileDataStringBase64编码的视频文件数据
ratingInteger评分
tagsString标签
+
+
+

说明

+

文件通过 Base64 编码上传,服务端解码后存储到 MinIO,返回文件访问 URL。自动检测 MIME 类型(mp4/mkv/avi → video/mp4)。

+
+
+
+ +
+
+ GET + /api/drive/{driveId}/folders/{folderId}/movies + 查询电影文件列表需Token +
+
+
+

路径参数

+ + + + +
参数类型说明
driveIdLong网盘ID
folderIdLong文件夹ID(0表示根目录)
+
+
+

查询参数

+ + + + +
参数默认值说明
pageNo1页码
pageSize20每页条数(最大100)
+
+
+

响应字段

+ + + + + + + + + + + + + + +
字段类型说明
list[].idLong电影ID
list[].titleString电影名称
list[].originalNameString原始文件名
list[].mimeTypeStringMIME类型
list[].fileSizeLong文件大小(字节)
list[].extensionString文件扩展名
list[].minioUrlStringMinIO文件访问URL
list[].ratingInteger评分
list[].tagsString标签
totalInteger总数
pageNoInteger当前页码
pageSizeInteger每页条数
+
+
+
+ +
+
+ GET + /api/drive/{driveId}/movies/{movieId} + 查询电影文件详情需Token +
+
+
+

路径参数

+ + + + +
参数类型说明
driveIdLong网盘ID
movieIdLong电影ID
+
+
+

响应字段

+ + + + + + + + + + + + + + + + + + + + + + + +
字段类型说明
idLong电影ID
driveIdLong所属网盘ID
folderIdLong所属文件夹ID
titleString电影名称
subtitleString副标题
originalNameString原始文件名
mimeTypeStringMIME类型
fileSizeLong文件大小(字节)
extensionString文件扩展名
descriptionString描述
resourceLibraryString资源库
maintainerString维护者
statusString状态
categoryString分类
scrapeTypeString刮削类型
ratingInteger评分
tagsString标签
minioUrlStringMinIO文件访问URL
posterUrlString海报URL
createTimeString创建时间
updateTimeString更新时间
+
+
+
+ +
+
+ PUT + /api/drive/{driveId}/movies/{movieId} + 更新电影文件信息需Token +
+
+
+

路径参数

+ + + + +
参数类型说明
driveIdLong网盘ID
movieIdLong电影ID
+
+
+

请求体(部分更新)

+
{ + "title": "新电影名称", + "subtitle": "新副标题", + "description": "新描述", + "rating": 90, + "tags": "新标签", + "category": "新分类" +}
+
+
+
+ +
+
+ DELETE + /api/drive/{driveId}/movies/{movieId} + 删除电影文件需Token +
+
+
+

路径参数

+ + + + +
参数类型说明
driveIdLong网盘ID
movieIdLong电影ID
+
+
+

说明

+

逻辑删除(软删除),删除后自动更新网盘和文件夹的统计信息。

+
+
+
+ + +
+
+ GET + /api/drive/movies/duplicates + 检查重复电影需Token +
+
+
+

功能说明

+

查询当前用户所有网盘下所有文件夹中的电影,按标题(title)分组找出重复的电影。返回每组重复电影的详细信息,包括所在文件夹的完整路径,前端可让用户选择删除其中一个。

+
+
+

成功响应

+
{ + "code": 200, + "message": "查询成功", + "data": { + "duplicateGroups": [ + { + "title": "电影名称", + "count": 2, + "movies": [ + { + "id": 10, + "driveId": 1, + "folderId": 5, + "title": "电影名称", + "originalName": "电影名称.mp4", + "fileSize": 1073741824, + "extension": "mp4", + "category": "sci-fi", + "rating": 85, + "createTime": "2025-01-01T10:00:00", + "folderPath": "我的电影库/科幻/2024" + }, + { + "id": 20, + "driveId": 1, + "folderId": 8, + "title": "电影名称", + "originalName": "电影名称.mkv", + "fileSize": 2147483648, + "extension": "mkv", + "category": "sci-fi", + "rating": null, + "createTime": "2025-02-01T15:00:00", + "folderPath": "我的电影库/动作" + } + ] + } + ], + "totalGroups": 1, + "totalDuplicates": 2 + } +}
+
+
+

返回字段说明

+ + + + + + + + + + +
字段类型说明
duplicateGroupsArray重复电影分组列表
duplicateGroups[].titleString重复的电影标题
duplicateGroups[].countInteger该标题下的重复数量
duplicateGroups[].moviesArray该标题下的所有电影记录
duplicateGroups[].movies[].idLong电影ID(可用于删除接口)
duplicateGroups[].movies[].folderPathString所在文件夹完整路径,如"网盘名/文件夹1/文件夹2"
totalGroupsInteger重复组总数
totalDuplicatesInteger涉及重复的电影总数
+
+
+

错误码

+ + + + +
code说明
401未登录或 Token 无效
500服务器内部错误
+
+
+
+
+ + +
+
+

Icon 管理服务

+ wangpan-drive-service : 8082 +
+ + +
+
+ POST + /api/icon + 创建 Icon需Token +
+
+
+

请求体(JSON)

+ + + + + + + + +
字段类型必填说明
nameStringIcon 名称(最大100字符)
urlString条件必填外网链接 URL(最大500字符,linkType为external或both时必填)
internalUrlString条件必填内网链接 URL(最大500字符,linkType为internal或both时必填)
linkTypeString链接类型:external(仅外网,默认)/ internal(仅内网)/ both(内外网)
descriptionStringIcon 描述(最大500字符)
sortOrderInteger排序值,数字越小越靠前,默认0
+
+
+

请求示例(仅外网)

+
{ + "name": "百度", + "url": "https://www.baidu.com", + "linkType": "external", + "description": "搜索引擎", + "sortOrder": 1 +}
+
+
+

请求示例(内外网都有)

+
{ + "name": "内部系统", + "url": "https://system.example.com", + "internalUrl": "http://192.168.1.100:8080", + "linkType": "both", + "description": "内部管理系统", + "sortOrder": 2 +}
+
+
+

成功响应(200)

+
{ + "code": 200, + "message": "创建成功", + "data": { + "id": 1, + "name": "百度", + "url": "https://www.baidu.com", + "internalUrl": null, + "linkType": "external", + "description": "搜索引擎", + "sortOrder": 1, + "status": "active", + "logoUrl": "http://118.178.238.159:39000/wangpan/icons/2/1718265600000.png", + "createTime": "2026-06-13T16:00:00" + } +}
+
+
+
+ + +
+
+ GET + /api/icon + 查询当前用户的 Icon 列表需Token +
+
+
+

功能说明

+

查询当前登录用户的所有 Icon,按 sortOrder 升序、createTime 降序排列。每个用户只能看到自己创建的 Icon。

+
+
+

成功响应(200)

+
{ + "code": 200, + "message": "查询成功", + "data": { + "list": [ + { + "id": 1, + "name": "百度", + "url": "https://www.baidu.com", + "internalUrl": null, + "linkType": "external", + "description": "搜索引擎", + "sortOrder": 1, + "status": "active", + "logoUrl": "http://118.178.238.159:39000/wangpan/icons/2/1718265600000.png", + "createTime": "2026-06-13T16:00:00", + "updateTime": "2026-06-13T16:00:00" + } + ], + "total": 1 + } +}
+
+
+
+ + +
+
+ GET + /api/icon/{id} + 查询单个 Icon 详情需Token +
+
+
+

路径参数

+ + + +
参数类型说明
idLongIcon ID
+
+
+

成功响应(200)

+
{ + "code": 200, + "message": "查询成功", + "data": { + "id": 1, + "name": "百度", + "url": "https://www.baidu.com", + "internalUrl": null, + "linkType": "external", + "description": "搜索引擎", + "sortOrder": 1, + "status": "active", + "logoUrl": "http://118.178.238.159:39000/wangpan/icons/2/1718265600000.png", + "createTime": "2026-06-13T16:00:00", + "updateTime": "2026-06-13T16:00:00" + } +}
+
+
+

错误码

+ + + + +
code说明
401未登录或 Token 无效
500Icon 不存在或无权访问
+
+
+
+ + +
+
+ PUT + /api/icon/{id} + 更新 Icon需Token +
+
+
+

路径参数

+ + + +
参数类型说明
idLongIcon ID
+
+
+

请求体(JSON,所有字段可选)

+ + + + + + + + +
字段类型说明
nameStringIcon 名称
urlString外网链接
internalUrlString内网链接
linkTypeString链接类型:external / internal / both
descriptionStringIcon 描述
sortOrderInteger排序值
+
+
+

请求示例

+
{ + "name": "新名称", + "url": "https://new-url.com", + "internalUrl": "http://192.168.1.100:8080", + "linkType": "both", + "sortOrder": 2 +}
+
+
+

成功响应(200)

+
{ + "code": 200, + "message": "更新成功", + "data": { + "id": 1, + "name": "新名称", + "url": "https://new-url.com", + "internalUrl": "http://192.168.1.100:8080", + "linkType": "both", + "description": "搜索引擎", + "sortOrder": 2, + "status": "active", + "logoUrl": "http://118.178.238.159:39000/wangpan/icons/2/1718265600000.png", + "createTime": "2026-06-13T16:00:00", + "updateTime": "2026-06-13T17:00:00" + } +}
+
+
+

错误码

+ + + + +
code说明
401未登录或 Token 无效
500Icon 不存在或无权修改
+
+
+
+ + +
+
+ DELETE + /api/icon/{id} + 删除 Icon需Token +
+
+
+

路径参数

+ + + +
参数类型说明
idLongIcon ID
+
+
+

成功响应(200)

+
{ + "code": 200, + "message": "删除成功", + "data": null +}
+
+
+

错误码

+ + + + +
code说明
401未登录或 Token 无效
500Icon 不存在或无权删除
+
+
+
+
+ + +
+
+

TMDB 刮削服务

+ wangpan-tmdb-service : 8083 +
+ +
+
+ GET + /api/movies/recommend + 今日推荐(随机推荐3部电影)需Token +
+
+
+

功能说明

+

从当前用户的电影库中随机抽取3部电影用于首页展示,每次请求返回不同的推荐结果。

+
+
+

成功响应示例

+
{ + "code": 200, + "message": "查询成功", + "data": { + "list": [ + { + "id": 10, + "title": "电影名称1", + "subtitle": "副标题", + "posterUrl": "海报URL", + "rating": 85, + "category": "动作", + "tags": "标签1,标签2", + "createTime": "2025-01-01T10:00:00" + }, + { + "id": 20, + "title": "电影名称2", + ... + }, + { + "id": 30, + "title": "电影名称3", + ... + } + ], + "count": 3 + } +}
+
+
+

返回字段说明

+ + + + + + + + + + + + +
字段类型说明
listArray推荐的电影列表(最多3部)
list[].idLong电影ID
list[].titleString电影标题
list[].subtitleString副标题
list[].posterUrlString海报URL
list[].ratingInteger评分(0-100)
list[].categoryString分类
list[].tagsString标签
list[].createTimeString创建时间
countInteger实际返回的电影数量(可能小于3)
+
+
+

错误码

+ + + + +
code说明
401未登录或 Token 无效
500服务器内部错误
+
+
+
+ +
+
+ GET + /api/movies + 电影列表查询需Token +
+
+
+

查询参数

+ + + + + + + + +
参数类型必填说明
keywordString搜索关键词
statusStringpending / scraped / failed
categoryString分类
scrapeTypeString刮削类型
pageNoint页码,默认 1
pageSizeint每页条数,默认 20
+
+
+
+ +
+
+ GET + /api/movies/{id} + 电影详情查询需Token +
+
+
+ +
+
+ POST + /api/movies + 创建电影需Token +
+
+
+

请求体

+
{ "title": "电影名称" }
+
+
+
+ +
+
+ PUT + /api/movies/{id} + 更新电影需Token +
+
+
+ +
+
+ DELETE + /api/movies/{id} + 删除电影需Token +
+
+
+ +
+
+ POST + /api/movies/batch-delete + 批量删除电影需Token +
+
+
+

请求体

+
{ "ids": [1, 2, 3] }
+
+
+
+ +
+
+ GET + /api/movies/stats + 电影统计需Token +
+
+
+ +
+
+ GET + /api/tmdb/key + 获取 TMDB API Key需Token +
+
+
+ +
+
+ POST + /api/tmdb/key + 设置 TMDB API Key需Token +
+
+
+

请求体

+
{ "tmdbApiKey": "你的API Key" }
+
+
+
+ +
+
+ POST + /api/tmdb-scraper/scrape + 单部电影刮削需Token +
+
+
+

请求体

+
{ "movieId": 1 }
+
+
+
+ +
+
+ POST + /api/tmdb-scraper/batch-scrape + 批量刮削需Token +
+
+
+

请求体

+
{ "movieIds": [1, 2, 3] }
+
+
+
+ +
+
+ GET + /api/tmdb-scraper/caches + 查询缓存列表需Token +
+
+
+

查询参数

+ + + + +
参数默认值说明
pageNo1页码
pageSize20每页条数
+
+
+
+ +
+
+ GET + /api/tmdb-scraper/stats + 刮削统计需Token +
+
+
+ +
+
+ POST + /api/tmdb-scraper/trigger + 手动触发定时任务需Token +
+
+
+ +
+
+ GET + /api/tmdb-scraper/scraper-logs + 查询刮削日志需Token +
+
+
+

查询参数

+ + + + +
参数默认值说明
pageNo1页码
pageSize20每页条数
+
+
+
+ +
+
+ DELETE + /api/tmdb-scraper/cache/{movieId} + 删除缓存需Token +
+
+
+
+ + +
+
+

通用响应格式

+
{ + "code": 200, + "message": "操作结果描述", + "data": { ... } +}
+
+ +
+

状态码说明

+ + + + + + + + +
状态码说明
200请求成功
400请求参数错误
401未授权 / 令牌无效
403权限不足
404资源不存在
500服务器内部错误
+
+ +
+

认证说明

+

+ 除以下 3 个接口 外,所有接口都需要在请求头中携带访问令牌:

+ + 无需 Token 的接口:
+   POST /api/login  用户登录
+   POST /api/register  用户注册
+   GET /api/token/validate?token=xxx  令牌验证(token 通过 URL 参数传递)

+ + 需要 Token 的接口(带 需Token 标识):
+   在请求头中添加:Authorization: Bearer <token> +

+
+
+
+
+ + + + diff --git a/wangpan-gateway/src/main/resources/application.yml b/wangpan-gateway/src/main/resources/application.yml new file mode 100644 index 0000000..5d1ef64 --- /dev/null +++ b/wangpan-gateway/src/main/resources/application.yml @@ -0,0 +1,42 @@ +# 网关服务配置 +server: + port: 8888 + +spring: + application: + name: wangpan-gateway + cloud: + gateway: + routes: + # 用户服务路由 + - id: wangpan-user-service + uri: http://localhost:8081 + predicates: + - Path=/api/login,/api/register,/api/token/**,/api/login-logs/**,/api/users/** + filters: + - StripPrefix=0 + # 网盘服务路由 + - id: wangpan-drive-service + uri: http://localhost:8082 + predicates: + - Path=/api/drive/**,/api/icon/** + filters: + - StripPrefix=0 + # TMDB服务路由 + - id: wangpan-tmdb-service + uri: http://localhost:8083 + predicates: + - Path=/api/movies/**,/api/tmdb/**,/api/tmdb-scraper/** + filters: + - StripPrefix=0 + +# Jasypt 加密配置 +jasypt: + encryptor: + password: ${JASYPT_ENCRYPTOR_PASSWORD:wangpan-secret-key-2024} + algorithm: PBEWithMD5AndDES + +logging: + level: + com.wangpan: DEBUG + org.springframework.cloud.gateway: DEBUG diff --git a/wangpan-gateway/target/classes/META-INF/resources/api-doc.html b/wangpan-gateway/target/classes/META-INF/resources/api-doc.html new file mode 100644 index 0000000..9a87fd8 --- /dev/null +++ b/wangpan-gateway/target/classes/META-INF/resources/api-doc.html @@ -0,0 +1,1968 @@ + + + + + + 网盘系统 API 接口文档 + + + +
+
+

网盘系统 API 接口文档

+

微服务架构 · 统一网关入口

+
网关地址:http://localhost:8888
+
+ + + + +
+ +
+
+ +
+ +
+
+

服务架构图

+
+
前端
+ +
Gateway:8888
+ +
User:8081
+
+
+ + + + +
Drive:8082
+
+
+ + + + +
TMDB:8083
+
+
+ +
+

路由规则

+ + + + + + + +
请求路径转发服务说明
/api/login, /api/registeruser-service登录注册
/api/token/**, /api/login-logs/**user-service令牌/日志
/api/drive/**drive-service网盘管理
/api/movies/**tmdb-service电影管理
/api/tmdb/**, /api/tmdb-scraper/**tmdb-serviceTMDB刮削
+
+
+ + +
+
+

用户认证服务

+ wangpan-user-service : 8081 +
+ +
+
+ POST + /api/login + 用户登录 +
+
+
+

请求体

+
{ + "username": "用户名", + "password": "密码" +}
+
+
+

成功响应示例

+
{ + "code": 200, + "message": "登录成功", + "data": { + "id": 7, + "username": "lrq", + "userType": 0, // 0=普通用户, 1=VIP, 2=管理员 + "status": 1, + "email": "xxx@xx.com", + "phone": "138****0000", + "realName": "张三", + "avatarUrl": "", + "lastLoginIp": "127.0.0.1", + "lastLoginTime": "2026-06-10T11:20:21", + "hasTmdbKey": false, + "token": "tk_xxx...", + "tokenType": "Bearer", + "expireIn": 604800 + } +}
+
+
+

userType 说明

+ + + + + +
角色权限说明
0普通用户仅访问自己的数据(如日志只看自己的)
1VIP 用户普通用户 + 更多存储空间
2管理员可查看所有用户数据(如全部日志)
+
+
+
+ +
+
+ POST + /api/register + 用户注册 +
+
+
+

请求体

+
{ + "username": "用户名", + "password": "密码" +}
+
+
+
+ +
+
+ GET + /api/token/validate + 令牌验证 +
+
+
+

请求头

+
Authorization: Bearer <token>
+
+
+
+ +
+
+ GET + /api/login-logs + 登录日志查询需Token +
+
+
+

权限说明

+ + + + +
用户类型可见范围
普通用户(userType=0/1)仅自己的登录记录
管理员(userType=2)所有用户的登录记录
+
+
+

查询参数

+ + + + + + + + +
参数类型必填说明
usernameString按用户名模糊筛选(管理员可用)
resultString登录结果:success / fail
startDateString开始日期(含),格式:yyyy-MM-dd,如 2026-06-01
endDateString结束日期(含),格式:yyyy-MM-dd,如 2026-06-10
pageNoint页码,默认 1
pageSizeint每页条数,默认 20
+

提示:可组合使用多个筛选条件,如 ?result=fail&startDate=2026-06-01&endDate=2026-06-10

+
+
+
+
+ + +
+
+

用户管理

+ wangpan-user-service : 8081 + 仅管理员 (userType=2) +
+ + +
+
+ GET + /api/users + 查询用户列表需Token + 管理员 +
+
+
+

权限说明

+ + + + +
角色访问权限
普通/VIP 用户403 权限不足
管理员可查看所有用户列表
+
+
+

查询参数

+ + + + + + + +
参数类型必填说明
usernameString用户名模糊搜索
statusint状态:0=禁用, 1=正常, 2=锁定
userTypeint角色:0=普通, 1=VIP, 2=管理员
pageNoint页码,默认 1
pageSizeint每页条数,默认 20
+
+
+

响应示例

+
{ + "code": 200, + "data": { + "total": 5, + "list": [ + { "id": 7, "username": "lrq", "userType": 0, "status": 1, ... } + ], + "pageNo": 1, + "pageSize": 20 + } +}
+
+
+
+ + +
+
+ POST + /api/users + 新增用户需Token + 管理员 +
+
+
+

请求体

+
{ + "username": "必填", + "password": "必填", + "email": "可选", + "phone": "可选", + "realName": "可选", + "status": 1, // 可选:0=禁用, 1=正常, 默认1 + "userType": 0 // 可选:0=普通, 1=VIP, 2=管理员, 默认0 +}
+
+
+

成功响应

+
{ + "code": 200, + "message": "用户创建成功", + "data": { + "id": 10, + "username": "newuser", + "email": "new@test.com", + "status": 1, + "userType": 0 + } +}
+
+
+

错误码

+ + + + + + + +
code说明
400用户名或密码为空
409用户名已被占用
401未登录或 Token 无效
403非管理员,无权限操作
500服务器内部错误
+
+
+
+ + +
+
+ PUT + /api/users/{userId} + 修改用户信息需Token + 管理员 +
+
+
+

路径参数

+ + + +
参数类型说明
userIdLong目标用户 ID
+
+
+

请求体(部分更新,只传需要修改的字段)

+
{ + "email": "新邮箱", // 可选 + "phone": "新手机号", // 可选 + "realName": "新姓名", // 可选 + "status": 0, // 可选:0=禁用, 1=正常, 2=锁定 + "userType": 1 // 可选:0=普通, 1=VIP, 2=管理员 +}
+
+
+

成功响应

+
{ + "code": 200, + "message": "用户信息更新成功", + "data": { + "id": 10, + "username": "newuser", + "email": "new@test.com", + "status": 1, + "userType": 0 + } +}
+
+
+

错误码

+ + + + + + +
code说明
404目标用户不存在
400没有需要更新的字段
403非管理员
500服务器内部错误
+
+
+
+ + +
+
+ DELETE + /api/users/{userId} + 删除用户(软删除)需Token + 管理员 +
+
+
+

路径参数

+ + + +
参数类型说明
userIdLong要删除的用户 ID
+
+
+

说明

+

软删除操作,将 is_deleted 设为 1,不物理删除数据。

+
+
+

成功响应

+
{ + "code": 200, + "message": "用户删除成功", + "data": null +}
+
+
+

错误码

+ + + + + + +
code说明
404目标用户不存在
400删除失败(可能已被删除)
403非管理员
500服务器内部错误
+
+
+
+
+ + +
+
+

网盘管理服务

+ wangpan-drive-service : 8082 +
+ +
+
+ POST + /api/drive + 创建网盘需Token +
+
+
+

请求体

+
{ + "name": "网盘名称", + "description": "网盘描述", + "logoData": "data:image/png;base64,iVBOR..." +}
+
+
+

参数说明

+ + + + + +
参数类型必填说明
nameString网盘名称
descriptionString网盘描述
logoDataStringLogo图片Base64编码,支持 data:image/xxx;base64, 前缀格式,上传后存储到MinIO,数据库仅保存URL
+
+
+
+ +
+
+ GET + /api/drive + 查询网盘列表需Token +
+
+
+

查询参数

+ + + + +
参数默认值说明
pageNo1页码
pageSize20每页条数
+
+
+

响应字段

+ + + + + + + + + + + + +
字段类型说明
idLong网盘ID
nameString网盘名称
descriptionString网盘描述
logoUrlString网盘Logo图片链接(MinIO存储地址)
folderCountInteger文件夹数量
fileCountInteger文件数量
totalSizeLong总大小(字节)
statusString状态
createTimeString创建时间
updateTimeString更新时间
+
+
+
+ +
+
+ GET + /api/drive/{id} + 查询网盘详情需Token +
+
+
+

路径参数

+ + + +
参数类型说明
idLong网盘ID
+
+
+

响应字段

+ + + + + + + + + + + + +
字段类型说明
idLong网盘ID
nameString网盘名称
descriptionString网盘描述
logoUrlString网盘Logo图片链接(MinIO存储地址)
folderCountInteger文件夹数量
fileCountInteger文件数量
totalSizeLong总大小(字节)
statusString状态
createTimeString创建时间
updateTimeString更新时间
+
+
+
+ +
+
+ PUT + /api/drive/{id} + 更新网盘需Token +
+
+
+

请求体

+
{ + "name": "新网盘名称", + "description": "新描述", + "logoData": "data:image/png;base64,iVBOR..." +}
+
+
+

参数说明

+ + + + + +
参数类型必填说明
nameString网盘名称
descriptionString网盘描述
logoDataString新的Logo图片Base64编码,传入后覆盖旧Logo;不传则保留原Logo
+
+
+
+ +
+
+ DELETE + /api/drive/{id} + 删除网盘需Token +
+
+
+ +
+
+ POST + /api/drive/{driveId}/folders + 创建文件夹需Token +
+
+
+

请求体

+
{ + "name": "文件夹名称", + "parentId": 0 +}
+
+
+
+ +
+
+ GET + /api/drive/{driveId}/folders + 查询文件夹列表需Token +
+
+
+

查询参数

+ + + +
参数默认值说明
parentId0父文件夹 ID
+
+
+
+ +
+
+ GET + /api/drive/{driveId}/folders/{folderId} + 查询文件夹详情需Token +
+
+
+

路径参数

+ + + + +
参数类型说明
driveIdLong网盘ID
folderIdLong文件夹ID
+
+
+

响应字段

+ + + + + + + + + + + + + +
字段类型说明
idLong文件夹ID
driveIdLong所属网盘ID
parentIdLong父文件夹ID(0表示根目录)
nameString文件夹名称
descriptionString文件夹描述
depthInteger层级深度
childFolderCountInteger子文件夹数量
fileCountInteger文件数量
totalSizeLong总大小(字节)
createTimeString创建时间
updateTimeString更新时间
+
+
+
+ +
+
+ PUT + /api/drive/{driveId}/folders/{folderId} + 更新文件夹需Token +
+
+
+

路径参数

+ + + + +
参数类型说明
driveIdLong网盘ID
folderIdLong文件夹ID
+
+
+

请求体(部分更新)

+
{ + "name": "新文件夹名称", + "description": "新描述", + "parentId": 0 +}
+
+
+

参数说明

+ + + + + +
参数类型必填说明
nameString文件夹名称
descriptionString文件夹描述
parentIdLong父文件夹ID(可移动文件夹)
+
+
+
+ +
+
+ DELETE + /api/drive/{driveId}/folders/{folderId} + 删除文件夹需Token +
+
+
+

路径参数

+ + + + +
参数类型说明
driveIdLong网盘ID
folderIdLong文件夹ID
+
+
+

说明

+

删除文件夹前会检查文件夹是否为空,如果包含子文件夹或电影文件则拒绝删除。

+
+
+

错误码

+ + + + + +
code说明
400文件夹不为空或数据库操作失败
401未登录或Token无效
403无权访问该文件夹
+
+
+
+ +
+
+ POST + /api/drive/{driveId}/movies + 上传电影文件需Token +
+
+
+

路径参数

+ + + +
参数类型说明
driveIdLong网盘ID
+
+
+

请求体

+
{ + "title": "电影名称", // 必填 + "folderId": 0, // 可选,默认0(根目录) + "subtitle": "副标题", // 可选 + "description": "描述", // 可选 + "fileData": "Base64编码的文件数据", // 必填 + "rating": 85, // 可选,评分0-100 + "tags": "标签1,标签2" // 可选 +}
+
+
+

参数说明

+ + + + + + + + + +
参数类型必填说明
titleString电影名称(同时作为文件名)
folderIdLong目标文件夹ID,默认0(根目录)
subtitleString副标题
descriptionString描述
fileDataStringBase64编码的视频文件数据
ratingInteger评分
tagsString标签
+
+
+

说明

+

文件通过 Base64 编码上传,服务端解码后存储到 MinIO,返回文件访问 URL。自动检测 MIME 类型(mp4/mkv/avi → video/mp4)。

+
+
+
+ +
+
+ GET + /api/drive/{driveId}/folders/{folderId}/movies + 查询电影文件列表需Token +
+
+
+

路径参数

+ + + + +
参数类型说明
driveIdLong网盘ID
folderIdLong文件夹ID(0表示根目录)
+
+
+

查询参数

+ + + + +
参数默认值说明
pageNo1页码
pageSize20每页条数(最大100)
+
+
+

响应字段

+ + + + + + + + + + + + + + +
字段类型说明
list[].idLong电影ID
list[].titleString电影名称
list[].originalNameString原始文件名
list[].mimeTypeStringMIME类型
list[].fileSizeLong文件大小(字节)
list[].extensionString文件扩展名
list[].minioUrlStringMinIO文件访问URL
list[].ratingInteger评分
list[].tagsString标签
totalInteger总数
pageNoInteger当前页码
pageSizeInteger每页条数
+
+
+
+ +
+
+ GET + /api/drive/{driveId}/movies/{movieId} + 查询电影文件详情需Token +
+
+
+

路径参数

+ + + + +
参数类型说明
driveIdLong网盘ID
movieIdLong电影ID
+
+
+

响应字段

+ + + + + + + + + + + + + + + + + + + + + + + +
字段类型说明
idLong电影ID
driveIdLong所属网盘ID
folderIdLong所属文件夹ID
titleString电影名称
subtitleString副标题
originalNameString原始文件名
mimeTypeStringMIME类型
fileSizeLong文件大小(字节)
extensionString文件扩展名
descriptionString描述
resourceLibraryString资源库
maintainerString维护者
statusString状态
categoryString分类
scrapeTypeString刮削类型
ratingInteger评分
tagsString标签
minioUrlStringMinIO文件访问URL
posterUrlString海报URL
createTimeString创建时间
updateTimeString更新时间
+
+
+
+ +
+
+ PUT + /api/drive/{driveId}/movies/{movieId} + 更新电影文件信息需Token +
+
+
+

路径参数

+ + + + +
参数类型说明
driveIdLong网盘ID
movieIdLong电影ID
+
+
+

请求体(部分更新)

+
{ + "title": "新电影名称", + "subtitle": "新副标题", + "description": "新描述", + "rating": 90, + "tags": "新标签", + "category": "新分类" +}
+
+
+
+ +
+
+ DELETE + /api/drive/{driveId}/movies/{movieId} + 删除电影文件需Token +
+
+
+

路径参数

+ + + + +
参数类型说明
driveIdLong网盘ID
movieIdLong电影ID
+
+
+

说明

+

逻辑删除(软删除),删除后自动更新网盘和文件夹的统计信息。

+
+
+
+ + +
+
+ GET + /api/drive/movies/duplicates + 检查重复电影需Token +
+
+
+

功能说明

+

查询当前用户所有网盘下所有文件夹中的电影,按标题(title)分组找出重复的电影。返回每组重复电影的详细信息,包括所在文件夹的完整路径,前端可让用户选择删除其中一个。

+
+
+

成功响应

+
{ + "code": 200, + "message": "查询成功", + "data": { + "duplicateGroups": [ + { + "title": "电影名称", + "count": 2, + "movies": [ + { + "id": 10, + "driveId": 1, + "folderId": 5, + "title": "电影名称", + "originalName": "电影名称.mp4", + "fileSize": 1073741824, + "extension": "mp4", + "category": "sci-fi", + "rating": 85, + "createTime": "2025-01-01T10:00:00", + "folderPath": "我的电影库/科幻/2024" + }, + { + "id": 20, + "driveId": 1, + "folderId": 8, + "title": "电影名称", + "originalName": "电影名称.mkv", + "fileSize": 2147483648, + "extension": "mkv", + "category": "sci-fi", + "rating": null, + "createTime": "2025-02-01T15:00:00", + "folderPath": "我的电影库/动作" + } + ] + } + ], + "totalGroups": 1, + "totalDuplicates": 2 + } +}
+
+
+

返回字段说明

+ + + + + + + + + + +
字段类型说明
duplicateGroupsArray重复电影分组列表
duplicateGroups[].titleString重复的电影标题
duplicateGroups[].countInteger该标题下的重复数量
duplicateGroups[].moviesArray该标题下的所有电影记录
duplicateGroups[].movies[].idLong电影ID(可用于删除接口)
duplicateGroups[].movies[].folderPathString所在文件夹完整路径,如"网盘名/文件夹1/文件夹2"
totalGroupsInteger重复组总数
totalDuplicatesInteger涉及重复的电影总数
+
+
+

错误码

+ + + + +
code说明
401未登录或 Token 无效
500服务器内部错误
+
+
+
+
+ + +
+
+

Icon 管理服务

+ wangpan-drive-service : 8082 +
+ + +
+
+ POST + /api/icon + 创建 Icon需Token +
+
+
+

请求体(JSON)

+ + + + + + + + +
字段类型必填说明
nameStringIcon 名称(最大100字符)
urlString条件必填外网链接 URL(最大500字符,linkType为external或both时必填)
internalUrlString条件必填内网链接 URL(最大500字符,linkType为internal或both时必填)
linkTypeString链接类型:external(仅外网,默认)/ internal(仅内网)/ both(内外网)
descriptionStringIcon 描述(最大500字符)
sortOrderInteger排序值,数字越小越靠前,默认0
+
+
+

请求示例(仅外网)

+
{ + "name": "百度", + "url": "https://www.baidu.com", + "linkType": "external", + "description": "搜索引擎", + "sortOrder": 1 +}
+
+
+

请求示例(内外网都有)

+
{ + "name": "内部系统", + "url": "https://system.example.com", + "internalUrl": "http://192.168.1.100:8080", + "linkType": "both", + "description": "内部管理系统", + "sortOrder": 2 +}
+
+
+

成功响应(200)

+
{ + "code": 200, + "message": "创建成功", + "data": { + "id": 1, + "name": "百度", + "url": "https://www.baidu.com", + "internalUrl": null, + "linkType": "external", + "description": "搜索引擎", + "sortOrder": 1, + "status": "active", + "logoUrl": "http://118.178.238.159:39000/wangpan/icons/2/1718265600000.png", + "createTime": "2026-06-13T16:00:00" + } +}
+
+
+
+ + +
+
+ GET + /api/icon + 查询当前用户的 Icon 列表需Token +
+
+
+

功能说明

+

查询当前登录用户的所有 Icon,按 sortOrder 升序、createTime 降序排列。每个用户只能看到自己创建的 Icon。

+
+
+

成功响应(200)

+
{ + "code": 200, + "message": "查询成功", + "data": { + "list": [ + { + "id": 1, + "name": "百度", + "url": "https://www.baidu.com", + "internalUrl": null, + "linkType": "external", + "description": "搜索引擎", + "sortOrder": 1, + "status": "active", + "logoUrl": "http://118.178.238.159:39000/wangpan/icons/2/1718265600000.png", + "createTime": "2026-06-13T16:00:00", + "updateTime": "2026-06-13T16:00:00" + } + ], + "total": 1 + } +}
+
+
+
+ + +
+
+ GET + /api/icon/{id} + 查询单个 Icon 详情需Token +
+
+
+

路径参数

+ + + +
参数类型说明
idLongIcon ID
+
+
+

成功响应(200)

+
{ + "code": 200, + "message": "查询成功", + "data": { + "id": 1, + "name": "百度", + "url": "https://www.baidu.com", + "internalUrl": null, + "linkType": "external", + "description": "搜索引擎", + "sortOrder": 1, + "status": "active", + "logoUrl": "http://118.178.238.159:39000/wangpan/icons/2/1718265600000.png", + "createTime": "2026-06-13T16:00:00", + "updateTime": "2026-06-13T16:00:00" + } +}
+
+
+

错误码

+ + + + +
code说明
401未登录或 Token 无效
500Icon 不存在或无权访问
+
+
+
+ + +
+
+ PUT + /api/icon/{id} + 更新 Icon需Token +
+
+
+

路径参数

+ + + +
参数类型说明
idLongIcon ID
+
+
+

请求体(JSON,所有字段可选)

+ + + + + + + + +
字段类型说明
nameStringIcon 名称
urlString外网链接
internalUrlString内网链接
linkTypeString链接类型:external / internal / both
descriptionStringIcon 描述
sortOrderInteger排序值
+
+
+

请求示例

+
{ + "name": "新名称", + "url": "https://new-url.com", + "internalUrl": "http://192.168.1.100:8080", + "linkType": "both", + "sortOrder": 2 +}
+
+
+

成功响应(200)

+
{ + "code": 200, + "message": "更新成功", + "data": { + "id": 1, + "name": "新名称", + "url": "https://new-url.com", + "internalUrl": "http://192.168.1.100:8080", + "linkType": "both", + "description": "搜索引擎", + "sortOrder": 2, + "status": "active", + "logoUrl": "http://118.178.238.159:39000/wangpan/icons/2/1718265600000.png", + "createTime": "2026-06-13T16:00:00", + "updateTime": "2026-06-13T17:00:00" + } +}
+
+
+

错误码

+ + + + +
code说明
401未登录或 Token 无效
500Icon 不存在或无权修改
+
+
+
+ + +
+
+ DELETE + /api/icon/{id} + 删除 Icon需Token +
+
+
+

路径参数

+ + + +
参数类型说明
idLongIcon ID
+
+
+

成功响应(200)

+
{ + "code": 200, + "message": "删除成功", + "data": null +}
+
+
+

错误码

+ + + + +
code说明
401未登录或 Token 无效
500Icon 不存在或无权删除
+
+
+
+
+ + +
+
+

TMDB 刮削服务

+ wangpan-tmdb-service : 8083 +
+ +
+
+ GET + /api/movies/recommend + 今日推荐(随机推荐3部电影)需Token +
+
+
+

功能说明

+

从当前用户的电影库中随机抽取3部电影用于首页展示,每次请求返回不同的推荐结果。

+
+
+

成功响应示例

+
{ + "code": 200, + "message": "查询成功", + "data": { + "list": [ + { + "id": 10, + "title": "电影名称1", + "subtitle": "副标题", + "posterUrl": "海报URL", + "rating": 85, + "category": "动作", + "tags": "标签1,标签2", + "createTime": "2025-01-01T10:00:00" + }, + { + "id": 20, + "title": "电影名称2", + ... + }, + { + "id": 30, + "title": "电影名称3", + ... + } + ], + "count": 3 + } +}
+
+
+

返回字段说明

+ + + + + + + + + + + + +
字段类型说明
listArray推荐的电影列表(最多3部)
list[].idLong电影ID
list[].titleString电影标题
list[].subtitleString副标题
list[].posterUrlString海报URL
list[].ratingInteger评分(0-100)
list[].categoryString分类
list[].tagsString标签
list[].createTimeString创建时间
countInteger实际返回的电影数量(可能小于3)
+
+
+

错误码

+ + + + +
code说明
401未登录或 Token 无效
500服务器内部错误
+
+
+
+ +
+
+ GET + /api/movies + 电影列表查询需Token +
+
+
+

查询参数

+ + + + + + + + +
参数类型必填说明
keywordString搜索关键词
statusStringpending / scraped / failed
categoryString分类
scrapeTypeString刮削类型
pageNoint页码,默认 1
pageSizeint每页条数,默认 20
+
+
+
+ +
+
+ GET + /api/movies/{id} + 电影详情查询需Token +
+
+
+ +
+
+ POST + /api/movies + 创建电影需Token +
+
+
+

请求体

+
{ "title": "电影名称" }
+
+
+
+ +
+
+ PUT + /api/movies/{id} + 更新电影需Token +
+
+
+ +
+
+ DELETE + /api/movies/{id} + 删除电影需Token +
+
+
+ +
+
+ POST + /api/movies/batch-delete + 批量删除电影需Token +
+
+
+

请求体

+
{ "ids": [1, 2, 3] }
+
+
+
+ +
+
+ GET + /api/movies/stats + 电影统计需Token +
+
+
+ +
+
+ GET + /api/tmdb/key + 获取 TMDB API Key需Token +
+
+
+ +
+
+ POST + /api/tmdb/key + 设置 TMDB API Key需Token +
+
+
+

请求体

+
{ "tmdbApiKey": "你的API Key" }
+
+
+
+ +
+
+ POST + /api/tmdb-scraper/scrape + 单部电影刮削需Token +
+
+
+

请求体

+
{ "movieId": 1 }
+
+
+
+ +
+
+ POST + /api/tmdb-scraper/batch-scrape + 批量刮削需Token +
+
+
+

请求体

+
{ "movieIds": [1, 2, 3] }
+
+
+
+ +
+
+ GET + /api/tmdb-scraper/caches + 查询缓存列表需Token +
+
+
+

查询参数

+ + + + +
参数默认值说明
pageNo1页码
pageSize20每页条数
+
+
+
+ +
+
+ GET + /api/tmdb-scraper/stats + 刮削统计需Token +
+
+
+ +
+
+ POST + /api/tmdb-scraper/trigger + 手动触发定时任务需Token +
+
+
+ +
+
+ GET + /api/tmdb-scraper/scraper-logs + 查询刮削日志需Token +
+
+
+

查询参数

+ + + + +
参数默认值说明
pageNo1页码
pageSize20每页条数
+
+
+
+ +
+
+ DELETE + /api/tmdb-scraper/cache/{movieId} + 删除缓存需Token +
+
+
+
+ + +
+
+

通用响应格式

+
{ + "code": 200, + "message": "操作结果描述", + "data": { ... } +}
+
+ +
+

状态码说明

+ + + + + + + + +
状态码说明
200请求成功
400请求参数错误
401未授权 / 令牌无效
403权限不足
404资源不存在
500服务器内部错误
+
+ +
+

认证说明

+

+ 除以下 3 个接口 外,所有接口都需要在请求头中携带访问令牌:

+ + 无需 Token 的接口:
+   POST /api/login  用户登录
+   POST /api/register  用户注册
+   GET /api/token/validate?token=xxx  令牌验证(token 通过 URL 参数传递)

+ + 需要 Token 的接口(带 需Token 标识):
+   在请求头中添加:Authorization: Bearer <token> +

+
+
+
+
+ + + + diff --git a/wangpan-gateway/target/classes/application.yml b/wangpan-gateway/target/classes/application.yml new file mode 100644 index 0000000..5d1ef64 --- /dev/null +++ b/wangpan-gateway/target/classes/application.yml @@ -0,0 +1,42 @@ +# 网关服务配置 +server: + port: 8888 + +spring: + application: + name: wangpan-gateway + cloud: + gateway: + routes: + # 用户服务路由 + - id: wangpan-user-service + uri: http://localhost:8081 + predicates: + - Path=/api/login,/api/register,/api/token/**,/api/login-logs/**,/api/users/** + filters: + - StripPrefix=0 + # 网盘服务路由 + - id: wangpan-drive-service + uri: http://localhost:8082 + predicates: + - Path=/api/drive/**,/api/icon/** + filters: + - StripPrefix=0 + # TMDB服务路由 + - id: wangpan-tmdb-service + uri: http://localhost:8083 + predicates: + - Path=/api/movies/**,/api/tmdb/**,/api/tmdb-scraper/** + filters: + - StripPrefix=0 + +# Jasypt 加密配置 +jasypt: + encryptor: + password: ${JASYPT_ENCRYPTOR_PASSWORD:wangpan-secret-key-2024} + algorithm: PBEWithMD5AndDES + +logging: + level: + com.wangpan: DEBUG + org.springframework.cloud.gateway: DEBUG diff --git a/wangpan-gateway/target/classes/com/wangpan/GatewayApplication.class b/wangpan-gateway/target/classes/com/wangpan/GatewayApplication.class new file mode 100644 index 0000000..0e117e0 Binary files /dev/null and b/wangpan-gateway/target/classes/com/wangpan/GatewayApplication.class differ diff --git a/wangpan-gateway/target/maven-archiver/pom.properties b/wangpan-gateway/target/maven-archiver/pom.properties new file mode 100644 index 0000000..c54caab --- /dev/null +++ b/wangpan-gateway/target/maven-archiver/pom.properties @@ -0,0 +1,3 @@ +artifactId=wangpan-gateway +groupId=com.wangpan +version=1.0-SNAPSHOT diff --git a/wangpan-gateway/target/maven-status/maven-compiler-plugin/compile/default-compile/createdFiles.lst b/wangpan-gateway/target/maven-status/maven-compiler-plugin/compile/default-compile/createdFiles.lst new file mode 100644 index 0000000..949ee9d --- /dev/null +++ b/wangpan-gateway/target/maven-status/maven-compiler-plugin/compile/default-compile/createdFiles.lst @@ -0,0 +1 @@ +com\wangpan\GatewayApplication.class diff --git a/wangpan-gateway/target/maven-status/maven-compiler-plugin/compile/default-compile/inputFiles.lst b/wangpan-gateway/target/maven-status/maven-compiler-plugin/compile/default-compile/inputFiles.lst new file mode 100644 index 0000000..3ebc6f5 --- /dev/null +++ b/wangpan-gateway/target/maven-status/maven-compiler-plugin/compile/default-compile/inputFiles.lst @@ -0,0 +1 @@ +D:\java2\wangpan\java\wangpan-gateway\src\main\java\com\wangpan\GatewayApplication.java diff --git a/wangpan-gateway/target/wangpan-gateway-1.0-SNAPSHOT.jar b/wangpan-gateway/target/wangpan-gateway-1.0-SNAPSHOT.jar new file mode 100644 index 0000000..24aad9c Binary files /dev/null and b/wangpan-gateway/target/wangpan-gateway-1.0-SNAPSHOT.jar differ diff --git a/wangpan-gateway/target/wangpan-gateway-1.0-SNAPSHOT.jar.original b/wangpan-gateway/target/wangpan-gateway-1.0-SNAPSHOT.jar.original new file mode 100644 index 0000000..8ad39b3 Binary files /dev/null and b/wangpan-gateway/target/wangpan-gateway-1.0-SNAPSHOT.jar.original differ diff --git a/wangpan-tmdb-service/pom.xml b/wangpan-tmdb-service/pom.xml new file mode 100644 index 0000000..10282b9 --- /dev/null +++ b/wangpan-tmdb-service/pom.xml @@ -0,0 +1,55 @@ + + + 4.0.0 + + + com.wangpan + wangpan-parent + 1.0-SNAPSHOT + + + wangpan-tmdb-service + jar + + WangPan TMDB Service + TMDB 刮削服务 - 电影元数据获取、缓存、定时任务 + + + + + com.wangpan + wangpan-common + + + + + org.springframework.boot + spring-boot-starter-web + + + + + com.github.ulisesbocchio + jasypt-spring-boot-starter + 3.0.5 + + + + + org.springframework.boot + spring-boot-starter-test + test + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + diff --git a/wangpan-tmdb-service/src/main/java/com/wangpan/TmdbServiceApplication.java b/wangpan-tmdb-service/src/main/java/com/wangpan/TmdbServiceApplication.java new file mode 100644 index 0000000..64516f9 --- /dev/null +++ b/wangpan-tmdb-service/src/main/java/com/wangpan/TmdbServiceApplication.java @@ -0,0 +1,24 @@ +package com.wangpan; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import com.ulisesbocchio.jasyptspringboot.annotation.EnableEncryptableProperties; + +/** + * TMDB 刮削服务启动类 + * 负责:TMDB API 调用、电影元数据刮削、缓存管理、定时任务 + * + * 注意:定时任务通过 TmdbScheduler(java.util.Timer)手动管理, + * 非 Spring @Scheduled 注解方式,因此无需 @EnableScheduling。 + */ +@SpringBootApplication +@EnableEncryptableProperties +public class TmdbServiceApplication { + + public static void main(String[] args) { + SpringApplication.run(TmdbServiceApplication.class, args); + System.out.println("========================================"); + System.out.println(" TMDB 刮削服务启动成功!"); + System.out.println("========================================"); + } +} diff --git a/wangpan-tmdb-service/src/main/java/com/wangpan/config/DatabaseInitializer.java b/wangpan-tmdb-service/src/main/java/com/wangpan/config/DatabaseInitializer.java new file mode 100644 index 0000000..c67217e --- /dev/null +++ b/wangpan-tmdb-service/src/main/java/com/wangpan/config/DatabaseInitializer.java @@ -0,0 +1,86 @@ +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; +import com.wangpan.common.util.SchemaUtil; +import com.wangpan.listener.TmdbScheduler; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Component; + +import jakarta.annotation.PostConstruct; +import java.sql.Connection; +import java.sql.SQLException; + +/** + * 数据库初始化器 + * 在应用启动时检查并创建 TMDB 相关表结构,启动定时刮削任务 + * + * 使用 Mapper 中定义的 CREATE_TABLE_SQL 常量 + SchemaUtil 工具类建表 + * 保留 DBUtil 原始 JDBC 连接用于 SchemaUtil 的 DDL 操作 + */ +@Component +public class DatabaseInitializer { + + /** 注入 TmdbScheduler(由 Spring 容器管理) */ + @Resource + private TmdbScheduler tmdbScheduler; + + /** 注入DBUtil Bean(用于获取JDBC连接执行DDL) */ + @Resource + private DBUtil dbUtil; + + @PostConstruct + public void init() { + System.out.println("========================================"); + System.out.println(" [DatabaseInitializer] TMDB服务数据库初始化..."); + System.out.println("========================================"); + + long startTime = System.currentTimeMillis(); + boolean initSuccess = true; + + try { + checkAndFixDatabaseSchema(); + System.out.println("✓ 数据库初始化检查完成!"); + + // 启动TMDB定时刮削调度器(使用Spring注入的实例) + tmdbScheduler.start(); + System.out.println(" ✓ TMDB定时刮削调度器已启动"); + + } catch (Exception e) { + System.err.println("⚠ 数据库初始化警告:" + e.getMessage()); + e.printStackTrace(); + initSuccess = false; + } + + long duration = System.currentTimeMillis() - startTime; + System.out.println("========================================"); + System.out.println(initSuccess + ? " ✓ 数据库初始化成功!耗时:" + duration + "ms" + : " ⚠ 数据库初始化存在警告,耗时:" + duration + "ms"); + System.out.println("========================================"); + } + + /** + * 检查并修复数据库表结构 + * 使用各 Mapper 中定义的 CREATE_TABLE_SQL 常量建表 + */ + private void checkAndFixDatabaseSchema() throws SQLException { + try (Connection conn = dbUtil.getConnection()) { + if (conn == null || conn.isClosed()) { + System.err.println("✗ 无法获取数据库连接,跳过表结构检查"); + return; + } + + // 创建TMDB相关表(使用各 Mapper 中定义的建表SQL) + SchemaUtil.createTableIfNotExists(conn, "wp_movie", MovieMapper.CREATE_TABLE_SQL); + 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] 表创建完成"); + } + } +} diff --git a/wangpan-tmdb-service/src/main/java/com/wangpan/controller/MovieController.java b/wangpan-tmdb-service/src/main/java/com/wangpan/controller/MovieController.java new file mode 100644 index 0000000..06fa991 --- /dev/null +++ b/wangpan-tmdb-service/src/main/java/com/wangpan/controller/MovieController.java @@ -0,0 +1,344 @@ +package com.wangpan.controller; + +import com.wangpan.common.Result; +import com.wangpan.common.annotation.RequireAuth; +import com.wangpan.common.dal.repository.MovieRepository; +import com.wangpan.common.entity.Movie; +import com.wangpan.common.entity.User; +import com.wangpan.common.interceptor.AuthInterceptor; +import jakarta.servlet.http.HttpServletRequest; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.web.bind.annotation.*; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * 电影管理控制器 + * + * 提供电影的增删改查接口 + */ +@RestController +@RequireAuth +@RequestMapping("/api/movies") +@CrossOrigin(origins = {"*"}, allowCredentials = "false") +public class MovieController { + + private static final Logger log = LoggerFactory.getLogger(MovieController.class); + + private final MovieRepository movieRepository; + + /** + * 构造函数注入 Repository 依赖 + */ + public MovieController(MovieRepository movieRepository) { + this.movieRepository = movieRepository; + } + + /** + * 从请求属性中获取当前登录用户(由 AuthInterceptor 注入) + */ + private User getCurrentUser(HttpServletRequest request) { + return (User) request.getAttribute(AuthInterceptor.CURRENT_USER_ATTR); + } + + /** + * 分页查询电影列表 + * GET /api/movies + */ + @GetMapping + public Result listMovies(@RequestParam(required = false) String keyword, + @RequestParam(required = false) String status, + @RequestParam(required = false) String category, + @RequestParam(required = false) String scrapeType, + @RequestParam(required = false) String sortBy, + @RequestParam(required = false) String sortOrder, + @RequestParam(defaultValue = "1") int pageNo, + @RequestParam(defaultValue = "20") int pageSize, + HttpServletRequest request) { + User user = getCurrentUser(request); + + log.info("用户 [{}] 查询电影列表 | 关键词={} 状态={} 页码={}", user.getUsername(), keyword, status, pageNo); + + Map resultData = movieRepository.queryByPage( + user.getId(), keyword, status, category, scrapeType, + sortBy, sortOrder, pageNo, pageSize + ); + + return Result.success("查询成功", resultData); + } + + /** + * 查询单个电影详情 + * GET /api/movies/{id} + */ + @GetMapping("/{id}") + public Result getMovie(@PathVariable("id") Long id, HttpServletRequest request) { + User user = getCurrentUser(request); + + Movie movie = null; + try { + movie = movieRepository.findById(id, user.getId()); + } catch (Exception e) { + log.error("查询电影详情失败", e); + return Result.error("查询电影详情失败"); + } + if (movie == null) { + return Result.notFound("电影不存在或无权访问"); + } + + return Result.success("查询成功", movieToMap(movie)); + } + + /** + * 新增电影 + * POST /api/movies + */ + @PostMapping + public Result createMovie(@RequestBody Map requestData, HttpServletRequest request) { + User user = getCurrentUser(request); + + if (requestData == null) { + return Result.badRequest("请求体不能为空"); + } + + Movie movie = new Movie(user.getId(), getStringParam(requestData, "title")); + + movie.setSubtitle(getStringParam(requestData, "subtitle")); + movie.setResourceLibrary(getStringParam(requestData, "resourceLibrary")); + movie.setMaintainer(getStringParam(requestData, "maintainer")); + movie.setStatus(getStringParam(requestData, "status")); + movie.setCategory(getStringParam(requestData, "category")); + movie.setScrapeType(getStringParam(requestData, "scrapeType")); + + // 评分(0-100范围校验) + String ratingStr = String.valueOf(requestData.get("rating")); + if (ratingStr != null && !"null".equals(ratingStr) && !ratingStr.isBlank()) { + try { + int rating = Integer.parseInt(ratingStr); + movie.setRating(Math.max(0, Math.min(100, rating))); + } catch (NumberFormatException ignored) {} + } + + movie.setPosterUrl(getStringParam(requestData, "posterUrl")); + movie.setTags(getStringParam(requestData, "tags")); + + Long newId = movieRepository.insert(movie); + if (newId > 0) { + movie.setId(newId); + log.info("用户 [{}] 新增电影:{}", user.getUsername(), movie.getTitle()); + return Result.success("新增成功", movieToMap(movie)); + } else { + return Result.error("新增失败"); + } + } + + /** + * 更新电影信息 + * PUT /api/movies/{id} + */ + @PutMapping("/{id}") + public Result updateMovie(@PathVariable("id") Long id, + @RequestBody Map requestData, + HttpServletRequest request) { + User user = getCurrentUser(request); + + if (requestData == null) { + return Result.badRequest("请求体不能为空"); + } + + Movie existingMovie = null; + try { + existingMovie = movieRepository.findById(id, user.getId()); + } catch (Exception e) { + log.error("查询电影失败", e); + return Result.error("查询电影失败"); + } + if (existingMovie == null) { + return Result.notFound("电影不存在或无权访问"); + } + + // 更新字段(只更新提供的字段) + updateField(existingMovie::setTitle, requestData, "title"); + updateField(existingMovie::setSubtitle, requestData, "subtitle"); + updateField(existingMovie::setResourceLibrary, requestData, "resourceLibrary"); + updateField(existingMovie::setMaintainer, requestData, "maintainer"); + updateField(existingMovie::setStatus, requestData, "status"); + updateField(existingMovie::setCategory, requestData, "category"); + updateField(existingMovie::setScrapeType, requestData, "scrapeType"); + + // 评分特殊处理 + Object ratingValue = requestData.get("rating"); + if (ratingValue != null && !"null".equals(String.valueOf(ratingValue))) { + try { + existingMovie.setRating(Math.max(0, Math.min(100, Integer.parseInt(String.valueOf(ratingValue))))); + } catch (NumberFormatException ignored) {} + } + + updateField(existingMovie::setPosterUrl, requestData, "posterUrl"); + updateField(existingMovie::setTags, requestData, "tags"); + + int rowsAffected = movieRepository.update(existingMovie); + if (rowsAffected > 0) { + log.info("用户 [{}] 更新电影 ID={}", user.getUsername(), id); + return Result.success("更新成功", movieToMap(existingMovie)); + } else { + return Result.error("更新失败"); + } + } + + /** + * 删除电影 + * DELETE /api/movies/{id} + */ + @DeleteMapping("/{id}") + public Result deleteMovie(@PathVariable("id") Long id, HttpServletRequest request) { + User user = getCurrentUser(request); + + int rowsAffected = movieRepository.deleteById(id, user.getId()); + if (rowsAffected > 0) { + log.info("用户 [{}] 删除电影 ID={}", user.getUsername(), id); + return Result.success("删除成功", null); + } else { + return Result.notFound("电影不存在或无权访问"); + } + } + + /** + * 批量删除 + * POST /api/movies/batch-delete + */ + @PostMapping("/batch-delete") + public Result batchDelete(@RequestBody Map requestData, HttpServletRequest request) { + User user = getCurrentUser(request); + + if (requestData == null) { + return Result.badRequest("请求体不能为空"); + } + + @SuppressWarnings("unchecked") + List idsList = (List) requestData.get("ids"); + if (idsList == null || idsList.isEmpty()) { + return Result.badRequest("请提供要删除的ID列表"); + } + + // 转换为Long列表 + List ids = new ArrayList<>(); + for (Object item : idsList) { + try { + ids.add(Long.valueOf(item.toString())); + } catch (NumberFormatException ignored) {} + } + + if (ids.isEmpty()) { + return Result.badRequest("没有有效的ID"); + } + + int deletedCount = movieRepository.batchDelete(ids, user.getId()); + log.info("用户 [{}] 批量删除 {} 条电影", user.getUsername(), deletedCount); + + Map result = new HashMap<>(); + result.put("deletedCount", deletedCount); + return Result.success("批量删除成功", result); + } + + /** + * 今日推荐(随机推荐3部电影) + * GET /api/movies/recommend + * 从当前用户的电影库中随机抽取3部电影用于首页展示 + */ + @GetMapping("/recommend") + public Result getRecommendations(HttpServletRequest request) { + User user = getCurrentUser(request); + + log.info("用户 [{}] 请求今日推荐", user.getUsername()); + + try { + // 随机查询3部电影 + List> recommendList = movieRepository.findRandomMovies(user.getId(), 3); + + Map result = new HashMap<>(); + result.put("list", recommendList); + result.put("count", recommendList.size()); + + return Result.success("查询成功", result); + } catch (Exception e) { + log.error("获取今日推荐失败", e); + return Result.error("获取推荐失败"); + } + } + + /** + * 获取统计信息 + * GET /api/movies/stats + */ + @GetMapping("/stats") + public Result getStats(HttpServletRequest request) { + User user = getCurrentUser(request); + + Map stats = null; + try { + stats = movieRepository.getStatistics(user.getId()); + } catch (Exception e) { + log.error("查询统计信息失败", e); + return Result.error("查询统计信息失败"); + } + return Result.success("查询成功", stats); + } + + // ==================== 辅助方法 ==================== + + /** + * 将Movie对象转换为Map + */ + private Map movieToMap(Movie movie) { + Map map = new HashMap<>(); + map.put("id", movie.getId()); + map.put("userId", movie.getUserId()); + map.put("title", movie.getTitle()); + map.put("subtitle", movie.getSubtitle()); + map.put("resourceLibrary", movie.getResourceLibrary()); + map.put("maintainer", movie.getMaintainer()); + map.put("status", movie.getStatus()); + map.put("category", movie.getCategory()); + map.put("scrapeType", movie.getScrapeType()); + map.put("rating", movie.getRating()); + map.put("posterUrl", movie.getPosterUrl()); + map.put("tags", movie.getTags()); + + if (movie.getUpdateTime() != null) { + map.put("updateTime", movie.getUpdateTime().toString().replace("T", " ")); + } + if (movie.getCreateTime() != null) { + map.put("createTime", movie.getCreateTime().toString().replace("T", " ")); + } + + return map; + } + + /** + * 从Map中安全获取字符串值 + */ + private String getStringParam(Map data, String key) { + Object value = data.get(key); + if (value == null || "null".equals(String.valueOf(value))) { + return null; + } + String strVal = String.valueOf(value).trim(); + return strVal.isBlank() ? null : strVal; + } + + /** + * 更新字段(如果请求中提供了该字段) + */ + private void updateField(java.util.function.Consumer setter, + Map data, String key) { + String value = getStringParam(data, key); + if (value != null) { + setter.accept(value); + } + } +} \ No newline at end of file diff --git a/wangpan-tmdb-service/src/main/java/com/wangpan/controller/TmdbController.java b/wangpan-tmdb-service/src/main/java/com/wangpan/controller/TmdbController.java new file mode 100644 index 0000000..857319b --- /dev/null +++ b/wangpan-tmdb-service/src/main/java/com/wangpan/controller/TmdbController.java @@ -0,0 +1,127 @@ +package com.wangpan.controller; + +import com.wangpan.common.Result; +import com.wangpan.common.annotation.RequireAuth; +import com.wangpan.common.dal.repository.UserRepository; +import com.wangpan.common.entity.User; +import com.wangpan.common.interceptor.AuthInterceptor; +import com.wangpan.common.util.EncryptUtil; +import jakarta.servlet.http.HttpServletRequest; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.web.bind.annotation.*; + +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.HashMap; +import java.util.Map; + +/** + * TMDB API Key 管理控制器 + * + * 提供 TMDB API Key 的获取和设置接口 + */ +@RestController +@RequireAuth +@RequestMapping("/api/tmdb") +@CrossOrigin(origins = {"*"}, allowCredentials = "false") +public class TmdbController { + + private static final Logger log = LoggerFactory.getLogger(TmdbController.class); + + private static final DateTimeFormatter TIME_FORMATTER = + DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); + + private final UserRepository userRepository; + + /** + * 构造函数注入 Repository 依赖 + */ + public TmdbController(UserRepository userRepository) { + this.userRepository = userRepository; + } + + /** + * 从请求属性中获取当前登录用户(由 AuthInterceptor 注入) + */ + private User getCurrentUser(HttpServletRequest request) { + return (User) request.getAttribute(AuthInterceptor.CURRENT_USER_ATTR); + } + + /** + * 获取当前用户的 TMDB API Key + * GET /api/tmdb/key + */ + @GetMapping("/key") + public Result getTmdbKey(HttpServletRequest request) { + try { + User user = getCurrentUser(request); + + String encryptedKey = user.getTmdbApiKey(); + Map resultData = new HashMap<>(); + + if (encryptedKey != null && !encryptedKey.isBlank()) { + try { + String decryptedKey = EncryptUtil.decrypt(encryptedKey); + resultData.put("tmdbApiKey", decryptedKey); + resultData.put("hasKey", true); + log.info("用户 [{}] 获取TMDB Key成功", user.getUsername()); + } catch (Exception e) { + log.error("解密TMDB Key失败", e); + resultData.put("tmdbApiKey", null); + resultData.put("hasKey", false); + resultData.put("error", "密钥解密失败"); + } + } else { + resultData.put("tmdbApiKey", null); + resultData.put("hasKey", false); + } + + resultData.put("userId", user.getId()); + resultData.put("username", user.getUsername()); + resultData.put("queryTime", LocalDateTime.now().format(TIME_FORMATTER)); + + return Result.success("查询成功", resultData); + } catch (Exception e) { + log.error("获取TMDB Key失败", e); + return Result.error("获取TMDB API Key失败"); + } + } + + /** + * 设置/更新 TMDB API Key + * POST /api/tmdb/key + */ + @PostMapping("/key") + public Result setTmdbKey(@RequestBody Map requestData, HttpServletRequest request) { + try { + User user = getCurrentUser(request); + + String tmdbApiKey = String.valueOf(requestData.get("tmdbApiKey")); + + if (tmdbApiKey == null || "null".equals(tmdbApiKey) || tmdbApiKey.isBlank()) { + return Result.badRequest("TMDB API Key不能为空"); + } + + String encryptedKey = EncryptUtil.encrypt(tmdbApiKey.trim()); + int rowsAffected = userRepository.updateTmdbApiKey(user.getId(), encryptedKey); + + if (rowsAffected > 0) { + log.info("用户 [{}] 设置TMDB Key成功", user.getUsername()); + + Map resultData = new HashMap<>(); + resultData.put("userId", user.getId()); + resultData.put("username", user.getUsername()); + resultData.put("hasKey", true); + resultData.put("updateTime", LocalDateTime.now().format(TIME_FORMATTER)); + + return Result.success("TMDB API Key设置成功", resultData); + } else { + return Result.error("TMDB API Key设置失败"); + } + } catch (Exception e) { + log.error("设置TMDB Key失败", e); + return Result.error("设置TMDB API Key失败"); + } + } +} \ No newline at end of file diff --git a/wangpan-tmdb-service/src/main/java/com/wangpan/controller/TmdbScraperController.java b/wangpan-tmdb-service/src/main/java/com/wangpan/controller/TmdbScraperController.java new file mode 100644 index 0000000..1c295d7 --- /dev/null +++ b/wangpan-tmdb-service/src/main/java/com/wangpan/controller/TmdbScraperController.java @@ -0,0 +1,236 @@ +package com.wangpan.controller; + +import com.wangpan.common.Result; +import com.wangpan.common.annotation.RequireAuth; +import com.wangpan.common.dal.repository.MovieRepository; +import com.wangpan.common.dal.repository.TmdbCacheRepository; +import com.wangpan.common.dal.repository.TmdbScraperLogRepository; +import com.wangpan.common.entity.Movie; +import com.wangpan.common.entity.User; +import com.wangpan.common.interceptor.AuthInterceptor; +import com.wangpan.service.TmdbScraperService; +import jakarta.servlet.http.HttpServletRequest; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.web.bind.annotation.*; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +/** + * TMDB 电影刮削控制器 + * + * 提供 TMDB 电影信息刮削相关接口 + */ +@RestController +@RequireAuth +@RequestMapping("/api/tmdb-scraper") +@CrossOrigin(origins = {"*"}, allowCredentials = "false") +public class TmdbScraperController { + + private static final Logger log = LoggerFactory.getLogger(TmdbScraperController.class); + + /** 最大分页大小 */ + private static final int MAX_PAGE_SIZE = 50; + + private final MovieRepository movieRepository; + private final TmdbCacheRepository tmdbCacheRepository; + private final TmdbScraperLogRepository scraperLogRepository; + private final TmdbScraperService scraperService; + + /** + * 构造函数注入 Repository 和 Service 依赖 + */ + public TmdbScraperController(MovieRepository movieRepository, + TmdbCacheRepository tmdbCacheRepository, + TmdbScraperLogRepository scraperLogRepository, + TmdbScraperService scraperService) { + this.movieRepository = movieRepository; + this.tmdbCacheRepository = tmdbCacheRepository; + this.scraperLogRepository = scraperLogRepository; + this.scraperService = scraperService; + } + + /** + * 从请求属性中获取当前登录用户(由 AuthInterceptor 注入) + */ + private User getCurrentUser(HttpServletRequest request) { + return (User) request.getAttribute(AuthInterceptor.CURRENT_USER_ATTR); + } + + /** + * 手动触发单部电影刮削 + * POST /api/tmdb-scraper/scrape + */ + @PostMapping("/scrape") + public Result scrape(@RequestParam(required = false) String movieId, + @RequestBody(required = false) Map body, + HttpServletRequest request) { + User user = getCurrentUser(request); + + // 从请求参数或请求体获取 movieId + String movieIdParam = movieId; + if (movieIdParam == null && body != null) { + movieIdParam = String.valueOf(body.get("movieId")); + } + + if (movieIdParam == null || "null".equals(movieIdParam)) { + return Result.badRequest("请提供 movieId 参数"); + } + + Long mid; + try { + mid = Long.parseLong(movieIdParam.trim()); + } catch (NumberFormatException e) { + return Result.badRequest("movieId 格式错误,需要数字"); + } + + log.info("用户 {} 触发刮削: movieId={}", user.getId(), mid); + + Map result = scraperService.scrapeMovie(mid, user.getId()); + + if (Boolean.TRUE.equals(result.get("success"))) { + return Result.success(result.get("message").toString(), result.get("data")); + } else { + return Result.error(result.get("message").toString()); + } + } + + /** + * 批量刮削多部电影 + * POST /api/tmdb-scraper/batch-scrape + */ + @PostMapping("/batch-scrape") + public Result batchScrape(@RequestBody(required = false) Map body, + HttpServletRequest request) { + User user = getCurrentUser(request); + + List movieIds = null; + if (body != null) { + Object idsObj = body.get("movieIds"); + if (idsObj instanceof List) { + @SuppressWarnings("unchecked") + List idList = (List) idsObj; + movieIds = new ArrayList<>(); + for (Object item : idList) { + if (item instanceof Number) { + movieIds.add(((Number) item).longValue()); + } + } + } + } + + List moviesToScrape; + + if (movieIds != null && !movieIds.isEmpty()) { + moviesToScrape = new ArrayList<>(); + for (Long mid : movieIds) { + try { + Movie movie = movieRepository.findById(mid); + if (movie != null) { + moviesToScrape.add(movie); + } + } catch (Exception ignored) {} + } + } else { + moviesToScrape = movieRepository.findMoviesNeedingScrape(user.getId()); + } + + if (moviesToScrape == null || moviesToScrape.isEmpty()) { + return Result.success("没有需要刮削的电影", Map.of( + "total", 0, "message", "所有电影已刮削完成或无需刮削" + )); + } + + log.info("用户 {} 触发批量刮削: 共{}部", user.getId(), moviesToScrape.size()); + + Map summary = scraperService.batchScrape(moviesToScrape); + + return Result.success("批量刮削完成", summary); + } + + /** + * 分页查询用户的所有缓存 + * GET /api/tmdb-scraper/caches + */ + @GetMapping("/caches") + public Result getCaches(@RequestParam(defaultValue = "1") int pageNo, + @RequestParam(defaultValue = "20") int pageSize, + HttpServletRequest request) { + User user = getCurrentUser(request); + + pageSize = Math.min(MAX_PAGE_SIZE, Math.max(1, pageSize)); + + Map cacheData = tmdbCacheRepository.queryByUserId(user.getId(), pageNo, pageSize); + + return Result.success("查询成功", cacheData); + } + + /** + * 刮削统计信息 + * GET /api/tmdb-scraper/stats + */ + @GetMapping("/stats") + public Result getStats(HttpServletRequest request) { + User user = getCurrentUser(request); + + Map stats = tmdbCacheRepository.getStatistics(user.getId()); + stats.put("userId", user.getId()); + + return Result.success("查询成功", stats); + } + + /** + * 手动触发定时扫描 + * POST /api/tmdb-scraper/trigger + */ + @PostMapping("/trigger") + public Result trigger(HttpServletRequest request) { + User user = getCurrentUser(request); + + log.info("用户 {} 手动触发定时扫描", user.getId()); + + Map result = Map.of( + "message", "扫描任务已触发", + "triggeredBy", user.getId(), + "triggerType", "manual" + ); + + return Result.success("扫描任务已触发", result); + } + + /** + * 分页查询定时任务执行记录 + * GET /api/tmdb-scraper/scraper-logs + */ + @GetMapping("/scraper-logs") + public Result getScraperLogs(@RequestParam(defaultValue = "1") int pageNo, + @RequestParam(defaultValue = "20") int pageSize, + HttpServletRequest request) { + User user = getCurrentUser(request); + + pageSize = Math.min(MAX_PAGE_SIZE, Math.max(1, pageSize)); + + Map logData = scraperLogRepository.queryRecent(pageNo, pageSize); + + return Result.success("查询成功", logData); + } + + /** + * 删除缓存 + * DELETE /api/tmdb-scraper/cache/{movieId} + */ + @DeleteMapping("/cache/{movieId}") + public Result deleteCache(@PathVariable("movieId") Long movieId, + HttpServletRequest request) { + User user = getCurrentUser(request); + + int rowsAffected = tmdbCacheRepository.deleteByMovieId(movieId); + if (rowsAffected > 0) { + return Result.success("缓存已删除,下次将重新刮削", null); + } else { + return Result.success("无缓存记录可删除", null); + } + } +} \ No newline at end of file diff --git a/wangpan-tmdb-service/src/main/java/com/wangpan/listener/TmdbScheduler.java b/wangpan-tmdb-service/src/main/java/com/wangpan/listener/TmdbScheduler.java new file mode 100644 index 0000000..35f6c00 --- /dev/null +++ b/wangpan-tmdb-service/src/main/java/com/wangpan/listener/TmdbScheduler.java @@ -0,0 +1,400 @@ +package com.wangpan.listener; + +import com.wangpan.common.dal.repository.MovieRepository; +import com.wangpan.common.dal.repository.TmdbScraperLogRepository; +import com.wangpan.service.TmdbScraperService; +import org.springframework.stereotype.Component; + +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Timer; +import java.util.TimerTask; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.logging.Level; +import java.util.logging.Logger; + +/** + * TMDB 定时刮削调度器 + * + * 功能说明: + * 1. 系统启动后自动开始定时扫描 + * 2. 定期查找有TMDB API Key且维护了电影名称的用户 + * 3. 对未刮削或缓存过期的电影进行自动刮削 + * 4. 遵守TMDB API速率限制(40次/10秒) + * + * 调度策略: + * - 首次延迟:启动后5分钟开始首次扫描 + * - 扫描间隔:每6小时执行一次完整扫描 + * - 单次最大处理数:每次最多处理50部电影(避免长时间占用) + * - 错误重试:失败的记录下次扫描时自动重试 + * + * 启动方式: + * 在 StartupListener.contextInitialized() 中调用 start() 方法 + */ +@Component +public class TmdbScheduler { + + /** 日志记录器 */ + private static final Logger LOGGER = Logger.getLogger(TmdbScheduler.class.getName()); + + /** 定时器实例 */ + private Timer timer; + + /** 刮削服务实例 */ + private final TmdbScraperService scraperService; + + /** 数据访问对象 */ + private final MovieRepository movieRepository; + + /** 执行记录 Repository */ + private final TmdbScraperLogRepository scraperLogRepository; + + /** 是否正在运行 */ + private final AtomicBoolean isRunning = new AtomicBoolean(false); + + // ==================== 配置常量 ==================== + + /** 首次执行延迟(毫秒):启动后5分钟 */ + private static final long INITIAL_DELAY_MS = 5 * 60 * 1000L; + + /** 执行间隔(毫秒):每6小时执行一次 */ + private static final long PERIOD_MS = 6 * 60 * 60 * 1000L; + + /** 单次扫描最大处理电影数量 */ + private static final int MAX_MOVIES_PER_SCAN = 50; + + /** 缓存过期天数(超过此天数未更新的缓存视为需重新刮削) */ + private static final int CACHE_EXPIRE_DAYS = 7; + + /** + * 构造方法:注入 Repository 和 Service 依赖 + */ + public TmdbScheduler(TmdbScraperService scraperService, + MovieRepository movieRepository, + TmdbScraperLogRepository scraperLogRepository) { + this.scraperService = scraperService; + this.movieRepository = movieRepository; + this.scraperLogRepository = scraperLogRepository; + } + + // ==================== 启动/停止控制 ==================== + + /** + * 启动定时调度器 + * 应在应用启动时(StartupListener)调用此方法 + */ + public void start() { + if (timer != null) { + LOGGER.warning("[TmdbScheduler] 调度器已在运行中,跳过重复启动"); + return; + } + + timer = new Timer("Tmdb-Scraper-Scheduler", true); // daemon线程 + + // 注册定时任务 + timer.scheduleAtFixedRate(new ScrapeTask(), INITIAL_DELAY_MS, PERIOD_MS); + + LOGGER.info("[TmdbScheduler] 定时刮削调度器已启动 | 首次延迟=" + (INITIAL_DELAY_MS / 60000) + + "分钟 | 间隔=" + (PERIOD_MS / 3600000) + "小时 | 单次上限=" + MAX_MOVIES_PER_SCAN + "部"); + } + + /** + * 停止定时调度器 + * 应在应用关闭时调用 + */ + public void stop() { + if (timer != null) { + timer.cancel(); + timer = null; + LOGGER.info("[TmdbScheduler] 定时刮削调度器已停止"); + } + } + + /** + * 检查调度器是否正在运行 + */ + public boolean isRunning() { + return timer != null; + } + + // ==================== 核心扫描逻辑 ==================== + + /** + * 执行一次完整的刮削扫描 + * + * 流程: + * 1. 查找所有配置了TMDB API Key的用户 + * 2. 对每个用户,查找需要刮削的电影(无缓存 or 缓存过期) + * 3. 按优先级排序(先刮削最近更新的) + * 4. 限制数量后批量执行刮削 + * 5. 记录日志和统计信息 + * + * 此方法是公共的,也可以手动调用(如通过管理接口触发) + * + * @return 本次扫描的统计结果Map + */ + public synchronized Map executeScan() { + return executeScan("auto"); + } + + /** + * 执行一次完整的刮削扫描 + * + * 流程: + * 1. 查找所有配置了TMDB API Key的用户 + * 2. 对每个用户,查找需要刮削的电影(无缓存 or 缓存过期) + * 3. 按优先级排序(先刮削最近更新的) + * 4. 限制数量后批量执行刮削 + * 5. 记录日志和统计信息 + * 6. 将执行结果保存到数据库 + * + * 此方法是公共的,也可以手动调用(如通过管理接口触发) + * + * @param triggerType 触发方式:auto=定时自动 / manual=手动触发 + * @return 本次扫描的统计结果Map + */ + public synchronized Map executeScan(String triggerType) { + // 防止并发执行 + if (!isRunning.compareAndSet(false, true)) { + LOGGER.warning("[TmdbScheduler] 上次扫描仍在进行中,跳过本次"); + return Map.of("skipped", true, "reason", "上次扫描仍在进行中"); + } + + var startTime = LocalDateTime.now(); + var scanLog = new StringBuilder(); + int totalProcessed = 0; + int totalSuccess = 0; + int totalSkipped = 0; + int totalFailed = 0; + int userCount = 0; + var userResults = new java.util.ArrayList>(); + + try { + scanLog.append("\n========== TMDB 自动刮削扫描 ").append(startTime.format(DateTimeFormatter.ISO_LOCAL_DATE_TIME)).append(" ==========\n"); + + // ========== 第1步:查找有TMDB API Key的用户 ========== + var usersWithKey = movieRepository.findUsersWithTmdbKey(); + + if (usersWithKey == null || usersWithKey.isEmpty()) { + scanLog.append("> 未找到配置了TMDB API Key的用户\n"); + LOGGER.info("[TmdbScheduler] 无用户配置TMDB Key,跳过扫描"); + return buildScanResult(0, 0, 0, 0, "无用户配置TMDB API Key", startTime, triggerType); + } + + scanLog.append("> 发现 ").append(usersWithKey.size()).append(" 个用户配置了TMDB Key\n"); + + // ========== 第2步:逐用户扫描待刮削电影 ========== + for (var user : usersWithKey) { + var userId = ((Number) user.get("id")).longValue(); + var username = user.get("username") != null ? user.get("username").toString() : "未知"; + + scanLog.append("\n--- 用户: ").append(username).append(" (ID=").append(userId).append(") ---\n"); + + // 查找该用户需要刮削的电影 + var moviesToScrape = movieRepository.findMoviesNeedingScrape(userId); + + if (moviesToScrape == null || moviesToScrape.isEmpty()) { + scanLog.append(" 无需刮削的电影\n"); + continue; + } + + // 限制本次处理数量 + var remainingQuota = MAX_MOVIES_PER_SCAN - totalProcessed; + if (remainingQuota <= 0) { + scanLog.append(" 已达单次扫描上限(").append(MAX_MOVIES_PER_SCAN).append("),停止\n"); + break; + } + + var batch = moviesToScrape.size() <= remainingQuota + ? moviesToScrape + : moviesToScrape.subList(0, remainingQuota); + + scanLog.append(" 待刮削: ").append(batch.size()).append(" 部电影\n"); + + // ========== 第3步:执行批量刮削 ========== + var result = scraperService.batchScrape(batch); + + // 统计结果 + var success = ((Number) result.get("success")).intValue(); + var skipped = ((Number) result.get("skipped")).intValue(); + var failed = ((Number) result.get("failed")).intValue(); + + totalProcessed += batch.size(); + totalSuccess += success; + totalSkipped += skipped; + totalFailed += failed; + userCount++; + + // 记录该用户的执行结果 + var userResult = new HashMap(); + userResult.put("username", username); + userResult.put("userId", userId); + userResult.put("processed", batch.size()); + userResult.put("success", success); + userResult.put("skipped", skipped); + userResult.put("failed", failed); + userResults.add(userResult); + + scanLog.append(" 结果: 成功=").append(success).append(", 跳过=").append(skipped) + .append(", 失败=").append(failed).append("\n"); + + // 打印失败详情 + @SuppressWarnings("unchecked") + var messages = (List) result.get("messages"); + if (messages != null && !messages.isEmpty()) { + for (var msg : messages) { + scanLog.append(" ✗ ").append(msg).append("\n"); + } + } + } + + // ========== 第4步:输出汇总 ========== + scanLog.append("\n========== 扫描完成 | 总计=").append(totalProcessed) + .append(" 成功=").append(totalSuccess).append(" 跳过=").append(totalSkipped) + .append(" 失败=").append(totalFailed).append(" ==========\n"); + + LOGGER.info(scanLog.toString()); + + var scanResult = buildScanResult(totalProcessed, totalSuccess, totalSkipped, totalFailed, + "扫描完成", startTime, triggerType); + + // 保存执行记录到数据库 + saveScanLogToDb(scanResult, userResults, null); + + return scanResult; + + } catch (Exception e) { + LOGGER.log(Level.SEVERE, "[TmdbScheduler] 扫描异常", e); + var scanResult = buildScanResult(totalProcessed, totalSuccess, totalSkipped, totalFailed, + "异常: " + e.getMessage(), startTime, triggerType); + + // 保存执行记录到数据库(包含异常信息) + saveScanLogToDb(scanResult, userResults, e.getMessage()); + + return scanResult; + } finally { + isRunning.set(false); + } + } + + // ==================== 内部类:定时任务 ==================== + + /** + * 定时刮削任务 + * 由Timer周期性调用 + */ + private class ScrapeTask extends TimerTask { + @Override + public void run() { + executeScan(); + } + } + + // ==================== 私有辅助方法 ==================== + + /** + * 构建扫描结果Map + */ + private Map buildScanResult(int processed, int success, int skipped, int failed, + String message, LocalDateTime startTime, String triggerType) { + var duration = java.time.Duration.between(startTime, LocalDateTime.now()).toMillis(); + return Map.of( + "processed", processed, + "success", success, + "skipped", skipped, + "failed", failed, + "message", message, + "startTime", startTime.format(DateTimeFormatter.ISO_LOCAL_DATE_TIME), + "endTime", LocalDateTime.now().format(DateTimeFormatter.ISO_LOCAL_DATE_TIME), + "durationMs", duration, + "triggerType", triggerType, + "timestamp", System.currentTimeMillis() + ); + } + + /** + * 将扫描结果保存到数据库 + * + * @param scanResult 扫描统计结果 + * @param userResults 各用户的执行结果 + * @param errorMessage 错误信息(可为null) + */ + private void saveScanLogToDb(Map scanResult, + List> userResults, + String errorMessage) { + try { + var log = new HashMap(); + log.put("triggerType", scanResult.get("triggerType")); + log.put("status", determineStatus(scanResult)); + log.put("totalProcessed", scanResult.get("processed")); + log.put("totalSuccess", scanResult.get("success")); + log.put("totalSkipped", scanResult.get("skipped")); + log.put("totalFailed", scanResult.get("failed")); + log.put("userCount", userResults.size()); + log.put("detailJson", buildDetailJson(userResults)); + log.put("errorMessage", errorMessage); + log.put("durationMs", scanResult.get("durationMs")); + log.put("startTime", scanResult.get("startTime")); + log.put("endTime", scanResult.get("endTime")); + + scraperLogRepository.insert(log); + + } catch (Exception e) { + LOGGER.warning("[TmdbScheduler] 保存执行记录到数据库失败: " + e.getMessage()); + } + } + + /** + * 根据统计结果确定执行状态 + */ + private String determineStatus(Map result) { + var processed = (int) result.get("processed"); + var success = (int) result.get("success"); + var failed = (int) result.get("failed"); + + if (processed == 0) return "skipped"; + if (failed == 0) return "success"; + if (success == 0) return "failed"; + return "partial"; + } + + /** + * 构建详细执行结果JSON(简单拼接,不依赖第三方库) + */ + private String buildDetailJson(List> userResults) { + if (userResults == null || userResults.isEmpty()) { + return null; + } + + var sb = new StringBuilder("["); + for (int i = 0; i < userResults.size(); i++) { + if (i > 0) sb.append(","); + var ur = userResults.get(i); + sb.append("{") + .append("\"username\":\"").append(escapeJson(ur.get("username").toString())).append("\"") + .append(",\"userId\":").append(ur.get("userId")) + .append(",\"processed\":").append(ur.get("processed")) + .append(",\"success\":").append(ur.get("success")) + .append(",\"skipped\":").append(ur.get("skipped")) + .append(",\"failed\":").append(ur.get("failed")) + .append("}"); + } + sb.append("]"); + return sb.toString(); + } + + /** + * 转义JSON字符串中的特殊字符 + */ + private String escapeJson(String s) { + if (s == null) return ""; + return s.replace("\\", "\\\\") + .replace("\"", "\\\"") + .replace("\n", "\\n") + .replace("\r", "\\r") + .replace("\t", "\\t"); + } +} diff --git a/wangpan-tmdb-service/src/main/java/com/wangpan/service/TmdbScraperService.java b/wangpan-tmdb-service/src/main/java/com/wangpan/service/TmdbScraperService.java new file mode 100644 index 0000000..1f6a6f4 --- /dev/null +++ b/wangpan-tmdb-service/src/main/java/com/wangpan/service/TmdbScraperService.java @@ -0,0 +1,670 @@ +package com.wangpan.service; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.wangpan.common.dal.repository.MovieRepository; +import com.wangpan.common.dal.repository.TmdbCacheRepository; +import com.wangpan.common.dal.repository.UserRepository; +import com.wangpan.common.entity.Movie; +import com.wangpan.common.entity.User; +import com.wangpan.common.util.EncryptUtil; +import com.wangpan.common.util.MinioUtil; +import org.springframework.stereotype.Service; + +import java.io.BufferedInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.net.HttpURLConnection; +import java.net.URL; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.TimeUnit; +import java.util.logging.Level; +import java.util.logging.Logger; + +/** + * TMDB 电影刮削服务 + * + * 功能说明: + * 1. 根据电影名称向TMDB API请求刮削数据(海报、评分、简介、类型等) + * 2. 将刮削结果缓存到本地数据库(避免重复请求) + * 3. 下载海报图片到本地文件系统(加速前端展示) + * + * TMDB API v3 使用说明: + * - 免费额度:40次/10秒,1000次/天 + * - 搜索接口:https://api.themoviedb.org/3/search/movie?query=xxx&api_key=xxx + * - 详情接口:https://api.themoviedb.org/3/movie/{id}?api_key=xxx + * - 图片基础URL:https://image.tmdb.org/t/p/original{poster_path} + * + * 使用方式: + * - 手动触发:通过 TmdbScraperApi 接口调用 + * - 定时任务:TmdbScheduler 自动扫描并刮削 + */ +@Service +public class TmdbScraperService { + + /** 日志记录器 */ + private static final Logger LOGGER = Logger.getLogger(TmdbScraperService.class.getName()); + + // ==================== TMDB API 配置常量 ==================== + + /** TMDB API v3 基础地址 */ + public static final String TMDB_API_BASE = "https://api.themoviedb.org/3"; + + /** TMDB 图片服务器基础地址(原始尺寸) */ + public static final String TMDB_IMAGE_BASE = "https://image.tmdb.org/t/p/original"; + + /** TMDB 图片服务器中等尺寸(推荐用于列表展示) */ + public static final String TMDB_IMAGE_W500 = "https://image.tmdb.org/t/p/w500"; + + /** TMDB API 请求超时时间(毫秒) */ + private static final int CONNECT_TIMEOUT = 10000; // 连接超时10秒 + private static final int READ_TIMEOUT = 15000; // 读取超时15秒 + + /** 图片下载超时时间(毫秒)- 海报图片可能较大 */ + private static final int IMAGE_TIMEOUT = 30000; // 图片下载30秒 + + /** API 速率限制:每次请求后等待的毫秒数(TMDB免费版40次/10秒,留余量) */ + private static final long RATE_LIMIT_DELAY_MS = 300; // 每次请求间隔300ms + + /** 搜索结果匹配阈值(相似度百分比,低于此值视为不匹配) */ + private static final double MATCH_THRESHOLD = 0.5; + + // ==================== MinIO 存储配置 ==================== + + /** 海报图片在 MinIO 中的存储前缀 */ + private static final String POSTER_PREFIX = "posters"; + + /** JSON解析器(线程安全,可复用) */ + private static final ObjectMapper objectMapper = new ObjectMapper(); + + // ==================== 依赖对象 ==================== + + private final MovieRepository movieRepository; + private final TmdbCacheRepository tmdbCacheRepository; + private final UserRepository userRepository; + + /** + * 构造方法:注入 Repository 依赖 + */ + public TmdbScraperService(MovieRepository movieRepository, TmdbCacheRepository tmdbCacheRepository, + UserRepository userRepository) { + this.movieRepository = movieRepository; + this.tmdbCacheRepository = tmdbCacheRepository; + this.userRepository = userRepository; + } + + // ==================== 核心刮削方法 ==================== + + /** + * 对单部电影执行TMDB刮削 + * + * 流程: + * 1. 检查是否已有有效缓存(7天内不重复刮削) + * 2. 获取用户的TMDB API Key + * 3. 向TMDB搜索电影 + * 4. 匹配最佳结果 + * 5. 获取电影详情 + * 6. 下载海报图片到本地 + * 7. 缓存刮削结果到数据库 + * 8. 更新wp_movie表的相关字段 + * + * @param movieId 要刮削的电影ID(wp_movie表主键) + * @param userId 用户ID(用于获取API Key) + * @return 刮削结果Map(包含success、message、data等) + */ + public Map scrapeMovie(Long movieId, Long userId) { + try { + // ========== 第1步:查询电影信息 ========== + var movie = movieRepository.findById(movieId); + if (movie == null) { + return errorResult("电影不存在(ID=" + movieId + ")"); + } + + // 检查电影标题是否为空 + if (movie.getTitle() == null || movie.getTitle().isBlank()) { + return errorResult("电影标题为空,无法刮削"); + } + + // ========== 第2步:检查缓存有效性(7天内已刮削则跳过) ========== + var existingCache = tmdbCacheRepository.findByMovieId(movieId); + if (existingCache != null && !isCacheExpired(existingCache.getCacheTime().toString())) { + return successResult("使用缓存数据(" + existingCache.getTitle() + ")", tmdbCacheRepository.toMap(existingCache)); + } + + // ========== 第3步:获取用户的TMDB API Key ========== + var user = userRepository.findById(userId); + if (user == null) { + return errorResult("用户不存在"); + } + + var encryptedKey = user.getTmdbApiKey(); + if (encryptedKey == null || encryptedKey.isBlank()) { + return errorResult("用户未配置TMDB API Key,请先设置"); + } + + // 解密API Key + var apiKey = EncryptUtil.decrypt(encryptedKey); + + // ========== 第4步:向TMDB搜索电影 ========== + LOGGER.info("[TmdbScraper] 开始搜索电影: " + movie.getTitle()); + var searchResult = searchMovie(movie.getTitle(), apiKey); + + if (searchResult == null || !searchResult.has("results")) { + return errorResult("TMDB搜索无结果: " + movie.getTitle()); + } + + var results = searchResult.get("results"); + if (results.isEmpty() || results.size() == 0) { + return errorResult("TMDB未找到匹配的电影: " + movie.getTitle()); + } + + // ========== 第5步:匹配最佳结果 ========== + var bestMatch = findBestMatch(movie.getTitle(), results); + if (bestMatch == null) { + return errorResult("未找到匹配度足够高的结果: " + movie.getTitle()); + } + + var tmdbId = bestMatch.get("id").asLong(); + LOGGER.info("[TmdbScraper] 匹配成功: " + bestMatch.get("title").asText() + " (TMDB ID=" + tmdbId + ")"); + + // ========== 第6步:获取电影详情(含完整信息) ========== + rateLimitDelay(); // 遵守速率限制 + var detailResult = getMovieDetail(tmdbId, apiKey); + + // ========== 第7步:下载海报图片 ========== + String localPosterPath = null; + if (detailResult != null && detailResult.has("poster_path") + && !detailResult.get("poster_path").isNull() + && !detailResult.get("poster_path").asText().isBlank()) { + + var posterPath = detailResult.get("poster_path").asText(); + localPosterPath = downloadPoster(posterPath, movieId, tmdbId); + } + + // ========== 第8步:构建缓存数据并保存 ========== + var cacheData = buildCacheData(detailResult != null ? detailResult : bestMatch, movieId, userId, localPosterPath); + tmdbCacheRepository.saveOrUpdate(cacheData); + + // ========== 第9步:更新wp_movie表的相关字段 ========== + updateMovieFromScrape(movie, cacheData, localPosterPath); + + LOGGER.info("[TmdbScraper] 刮削完成: " + cacheData.get("title")); + return successResult("刮削成功", cacheData); + + } catch (Exception e) { + LOGGER.log(Level.SEVERE, "[TmdbScraper] 刮削异常: movieId=" + movieId, e); + return errorResult("刮削异常: " + e.getMessage()); + } + } + + /** + * 批量刮削多部电影(供定时任务调用) + * + * @param movies 待刮削的电影列表 + * @return 刮削统计结果 + */ + public Map batchScrape(List movies) { + int total = movies.size(); + int successCount = 0; + int skipCount = 0; + int failCount = 0; + List messages = new ArrayList<>(); + + for (var movie : movies) { + try { + // 检查缓存 + var existingCache = tmdbCacheRepository.findByMovieId(movie.getId()); + if (existingCache != null && !isCacheExpired(existingCache.getCacheTime().toString())) { + skipCount++; + continue; // 已有有效缓存,跳过 + } + + // 执行刮削 + var result = scrapeMovie(movie.getId(), movie.getUserId()); + if (Boolean.TRUE.equals(result.get("success"))) { + successCount++; + } else { + failCount++; + messages.add(movie.getTitle() + ": " + result.get("message")); + } + + // 速率限制延迟 + rateLimitDelay(); + + } catch (Exception e) { + failCount++; + messages.add(movie.getTitle() + ": " + e.getMessage()); + } + } + + var summary = Map.of( + "total", total, + "success", successCount, + "skipped", skipCount, + "failed", failCount, + "messages", messages, + "scrapeTime", LocalDateTime.now().format(DateTimeFormatter.ISO_LOCAL_DATE_TIME) + ); + + LOGGER.info("[TmdbScraper] 批量刮削完成: 总计=" + total + ", 成功=" + successCount + ", 跳过=" + skipCount + ", 失败=" + failCount); + return summary; + } + + // ==================== TMDB API 调用方法 ==================== + + /** + * 向TMDB搜索电影 + * + * @param title 电影标题 + * @param apiKey 用户的TMDB API Key + * @return JSON搜索结果节点,失败返回null + */ + private JsonNode searchMovie(String title, String apiKey) throws IOException { + // URL编码处理中文等特殊字符 + var encodedTitle = URLEncoder.encode(title.trim(), StandardCharsets.UTF_8); + var urlString = TMDB_API_BASE + "/search/movie?query=" + encodedTitle + + "&api_key=" + apiKey + "&language=zh-CN"; + + var jsonText = httpGet(urlString); + if (jsonText == null) return null; + + var root = objectMapper.readTree(jsonText); + return root; + } + + /** + * 获取TMDB电影的详细信息 + * + * @param tmdbId TMDB电影ID + * @param apiKey 用户的TMDB API Key + * @return 电影详情JSON节点,失败返回null + */ + private JsonNode getMovieDetail(Long tmdbId, String apiKey) throws IOException { + var urlString = TMDB_API_BASE + "/movie/" + tmdbId + + "?api_key=" + apiKey + "&language=zh-CN&append_to_response=credits"; + + var jsonText = httpGet(urlString); + if (jsonText == null) return null; + + return objectMapper.readTree(jsonText); + } + + /** + * HTTP GET 请求工具方法 + * + * @param urlStr 请求URL + * @return 响应文本内容,失败返回null + */ + private String httpGet(String urlStr) throws IOException { + var url = new URL(urlStr); + var conn = (HttpURLConnection) url.openConnection(); + + try { + conn.setRequestMethod("GET"); + conn.setConnectTimeout(CONNECT_TIMEOUT); + conn.setReadTimeout(READ_TIMEOUT); + conn.setRequestProperty("Accept", "application/json"); + conn.setRequestProperty("User-Agent", "WangPan-TMDB-Scraper/1.0"); + + var responseCode = conn.getResponseCode(); + if (responseCode != HttpURLConnection.HTTP_OK) { + LOGGER.warning("[TmdbScraper] HTTP请求失败: " + responseCode + " URL=" + urlStr); + return null; + } + + // 读取响应内容 + try (var input = new BufferedInputStream(conn.getInputStream())) { + var content = new String(input.readAllBytes(), StandardCharsets.UTF_8); + return content; + } + + } finally { + conn.disconnect(); + } + } + + // ==================== 图片下载与上传方法 ==================== + + /** + * 从TMDB下载海报图片并上传到MinIO对象存储 + * + * 存储路径规则:{POSTER_PREFIX}/{movieId}_{tmdbId}.jpg + * 例如:posters/123_45678.jpg + * + * 上传成功后,返回的 URL 会使用 EncryptUtil 加密后存储到数据库。 + * + * @param posterPath TMDB返回的poster_path(如 /xxx.jpg) + * @param movieId 本地电影ID + * @param tmdbId TMDB电影ID + * @return MinIO 访问 URL(已加密),上传失败返回 null + */ + private String downloadPoster(String posterPath, Long movieId, Long tmdbId) { + // 构建完整的TMDB图片URL + var imageUrl = TMDB_IMAGE_BASE + posterPath; + + // 构建 MinIO 存储路径 + var objectName = POSTER_PREFIX + "/" + movieId + "_" + tmdbId + ".jpg"; + + try { + // 下载图片到内存 + LOGGER.info("[TmdbScraper] 正在下载海报: " + imageUrl); + var url = new URL(imageUrl); + var conn = (HttpURLConnection) url.openConnection(); + + try { + conn.setConnectTimeout(IMAGE_TIMEOUT); + conn.setReadTimeout(IMAGE_TIMEOUT); + conn.setRequestProperty("User-Agent", "WangPan-TMDB-Scraper/1.0"); + + if (conn.getResponseCode() == HttpURLConnection.HTTP_OK) { + try (var input = new BufferedInputStream(conn.getInputStream())) { + // 读取图片字节流 + var buffer = new ByteArrayOutputStream(); + input.transferTo(buffer); + var imageBytes = buffer.toByteArray(); + var fileSizeKB = imageBytes.length / 1024; + + // 上传到 MinIO + LOGGER.info("[TmdbScraper] 正在上传海报到 MinIO: " + objectName + " (" + fileSizeKB + "KB)"); + try (var inputStream = new java.io.ByteArrayInputStream(imageBytes)) { + var uploadedObjectName = MinioUtil.upload(inputStream, objectName, "image/jpeg"); + if (uploadedObjectName != null) { + // 获取 MinIO 访问 URL + var minioUrl = MinioUtil.getFileUrl(objectName); + if (minioUrl != null) { + // 加密 URL 后返回(与数据库存储保持一致) + var encryptedUrl = EncryptUtil.encrypt(minioUrl); + LOGGER.info("[TmdbScraper] 海报上传成功: " + objectName + " (URL已加密)"); + return encryptedUrl; + } else { + LOGGER.warning("[TmdbScraper] 获取 MinIO URL 失败: " + objectName); + return null; + } + } else { + LOGGER.warning("[TmdbScraper] MinIO 上传失败: " + objectName); + return null; + } + } + } + } else { + LOGGER.warning("[TmdbScraper] 海报下载HTTP错误: " + conn.getResponseCode()); + return null; + } + } finally { + conn.disconnect(); + } + } catch (Exception e) { + LOGGER.log(Level.WARNING, "[TmdbScraper] 海报下载/上传异常: " + objectName, e); + return null; + } + } + + // ==================== 数据处理辅助方法 ==================== + + /** + * 从搜索结果中找到与目标标题最佳匹配的电影 + * + * 匹配策略: + * 1. 精确匹配(忽略大小写和标点) + * 2. 包含匹配(标题包含关键词或反之) + * 3. 相似度计算(简单的字符重叠比例) + * + * @param targetTitle 目标电影标题 + * @param results TMDB搜索结果数组 + * @return 最佳匹配的JSON节点,无匹配返回null + */ + private JsonNode findBestMatch(String targetTitle, JsonNode results) { + JsonNode bestMatch = null; + double bestScore = 0; + + var normalizedTarget = normalizeTitle(targetTitle); + + for (var result : results) { + var resultTitle = result.has("title") ? result.get("title").asText() : ""; + var originalTitle = result.has("original_title") ? result.get("original_title").asText() : ""; + + // 计算两个标题的最高匹配分数 + double score1 = calculateSimilarity(normalizedTarget, normalizeTitle(resultTitle)); + double score2 = calculateSimilarity(normalizedTarget, normalizeTitle(originalTitle)); + double score = Math.max(score1, score2); + + // 精确匹配加分 + if (normalizedTarget.equalsIgnoreCase(normalizeTitle(resultTitle)) + || normalizedTarget.equalsIgnoreCase(normalizeTitle(originalTitle))) { + score = Math.max(score, 0.95); + } + + if (score > bestScore) { + bestScore = score; + bestMatch = result; + } + } + + // 返回匹配度超过阈值的结果 + return bestScore >= MATCH_THRESHOLD ? bestMatch : null; + } + + /** + * 标题标准化(去除空格、标点、转小写) + */ + private String normalizeTitle(String title) { + if (title == null) return ""; + return title.toLowerCase() + .replaceAll("[\\s\\p{Punct}]", "") + .trim(); + } + + /** + * 计算两个字符串的简单相似度(基于字符重叠比例) + * 使用Jaccard相似系数的简化版本 + */ + private double calculateSimilarity(String s1, String s2) { + if (s1.isBlank() || s2.isBlank()) return 0; + if (s1.equals(s2)) return 1.0; + + // 短字符串作为基准 + var shorter = s1.length() <= s2.length() ? s1 : s2; + var longer = shorter == s1 ? s2 : s1; + + // 计算较长字符串包含较短字符串字符的比例 + int matchCount = 0; + for (char c : shorter.toCharArray()) { + if (longer.indexOf(c) >= 0) matchCount++; + } + + return (double) matchCount / shorter.length(); + } + + /** + * 构建缓存数据Map(从TMDB响应提取关键字段) + */ + private Map buildCacheData(JsonNode tmdbData, Long movieId, Long userId, String localPosterPath) { + // 提取基本信息 + var data = new java.util.LinkedHashMap(); + + data.put("movieId", movieId); + data.put("userId", userId); + data.put("tmdbId", tmdbData.has("id") ? tmdbData.get("id").asLong() : null); + data.put("title", tmdbData.has("title") ? tmdbData.get("title").asText() : ""); + data.put("originalTitle", tmdbData.has("original_title") ? tmdbData.get("original_title").asText() : ""); + data.put("overview", tmdbData.has("overview") ? tmdbData.get("overview").asText() : ""); + data.put("releaseDate", tmdbData.has("release_date") ? tmdbData.get("release_date").asText() : ""); + data.put("posterPath", tmdbData.has("poster_path") && !tmdbData.get("poster_path").isNull() + ? tmdbData.get("poster_path").asText() : ""); + data.put("backdropPath", tmdbData.has("backdrop_path") && !tmdbData.get("backdrop_path").isNull() + ? tmdbData.get("backdrop_path").asText() : ""); + data.put("localPosterUrl", localPosterPath); // 本地路径 + + // 评分信息(TMDB 10分制转换为百分制) + double voteAvg = tmdbData.has("vote_average") ? tmdbData.get("vote_average").asDouble() : 0; + data.put("tmdbRating", Math.round(voteAvg * 10)); // 转为0-100分 + data.put("voteCount", tmdbData.has("vote_count") ? tmdbData.get("vote_count").asInt() : 0); + + // 运行时长 + data.put("runtime", tmdbData.has("runtime") && !tmdbData.get("runtime").isNull() + ? tmdbData.get("runtime").asInt() : null); + + // 类型(逗号分隔) + var genres = new ArrayList(); + if (tmdbData.has("genres")) { + for (var genre : tmdbData.get("genres")) { + genres.add(genre.get("name").asText()); + } + } + data.put("genres", String.join(", ", genres)); + + // 导演和主演(从credits中提取) + var directors = new ArrayList(); + var casts = new ArrayList(); + if (tmdbData.has("credits")) { + var credits = tmdbData.get("credits"); + if (credits.has("crew")) { + for (var crew : credits.get("crew")) { + if ("Director".equals(crew.get("job").asText())) { + directors.add(crew.get("name").asText()); + if (directors.size() >= 2) break; // 最多取2个导演 + } + } + } + if (credits.has("cast")) { + int count = 0; + for (var cast : credits.get("cast")) { + casts.add(cast.get("name").asText()); + if (++count >= 5) break; // 最多取5个主演 + } + } + } + data.put("directors", String.join(", ", directors)); + data.put("casts", String.join(", ", casts)); + + // 语言和国家 + data.put("originalLanguage", tmdbData.has("original_language") ? tmdbData.get("original_language").asText() : ""); + data.put("productionCountries", extractProductionCountries(tmdbData)); + + // 缓存元数据 + data.put("cacheTime", LocalDateTime.now().format(DateTimeFormatter.ISO_LOCAL_DATE_TIME)); + data.put("status", "active"); // 缓存状态:active/expired + + return data; + } + + /** + * 提取制片国家 + */ + private String extractProductionCountries(JsonNode tmdbData) { + if (!tmdbData.has("production_countries")) return ""; + var countries = new ArrayList(); + for (var country : tmdbData.get("production_countries")) { + countries.add(country.get("name").asText()); + } + return String.join(", ", countries); + } + + /** + * 根据刮削结果更新wp_movie表的相关字段 + */ + private void updateMovieFromScrape(Movie movie, Map cacheData, String encryptedPosterUrl) { + // 更新评分(如果之前没有或更低) + Integer tmdbRating = cacheData.get("tmdbRating") != null + ? ((Number) cacheData.get("tmdbRating")).intValue() : null; + + if (tmdbRating != null && (movie.getRating() == null || movie.getRating() < tmdbRating)) { + movieRepository.updateRating(movie.getId(), tmdbRating); + } + + // 更新海报URL(优先使用 MinIO 存储的加密 URL) + if (encryptedPosterUrl != null) { + // 解密后存储到 wp_movie 表(前端可直接使用) + try { + var decryptedUrl = EncryptUtil.decrypt(encryptedPosterUrl); + movieRepository.updatePosterUrl(movie.getId(), decryptedUrl); + } catch (Exception e) { + // 解密失败时使用 TMDB 远程地址作为降级方案 + System.err.println("[TmdbScraper] MinIO URL 解密失败,使用 TMDB 远程地址: " + e.getMessage()); + if (cacheData.get("posterPath") != null && !cacheData.get("posterPath").toString().isBlank()) { + movieRepository.updatePosterUrl(movie.getId(), TMDB_IMAGE_W500 + cacheData.get("posterPath")); + } + } + } else if (cacheData.get("posterPath") != null && !cacheData.get("posterPath").toString().isBlank()) { + // MinIO 上传失败时使用 TMDB 远程地址 + movieRepository.updatePosterUrl(movie.getId(), TMDB_IMAGE_W500 + cacheData.get("posterPath")); + } + + // 更新标签(添加TMDB类型信息) + var genres = cacheData.get("genres") != null ? cacheData.get("genres").toString() : ""; + if (!genres.isBlank()) { + var existingTags = movie.getTags(); + if (existingTags == null || existingTags.isBlank()) { + movieRepository.updateTags(movie.getId(), genres); + } else if (!existingTags.contains(genres)) { + movieRepository.updateTags(movie.getId(), existingTags + ", " + genres); + } + } + + // 更新刮削类型标记 + movieRepository.updateScrapeType(movie.getId(), "auto"); + } + + // ==================== 缓存过期判断 ==================== + + /** + * 判断缓存是否过期(默认7天有效期) + * + * @param cacheTimeString 缓存时间字符串 + * @return true=已过期需重新刮削,false=仍有效 + */ + private boolean isCacheExpired(String cacheTimeString) { + try { + var cacheTime = LocalDateTime.parse(cacheTimeString, DateTimeFormatter.ISO_LOCAL_DATE_TIME); + var expireTime = cacheTime.plusDays(7); // 缓存有效期7天 + return LocalDateTime.now().isAfter(expireTime); + } catch (Exception e) { + return true; // 解析失败则认为已过期 + } + } + + // ==================== 速率限制 ==================== + + /** + * 速率限制延迟(避免超出TMDB API调用限制) + * TMDB免费版限制:40次请求/10秒 + * 为安全起见,每次请求后等待300ms + */ + private void rateLimitDelay() { + try { + TimeUnit.MILLISECONDS.sleep(RATE_LIMIT_DELAY_MS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + + // ==================== 结果构建工具方法 ==================== + + /** + * 构建成功结果 + */ + private Map successResult(String message, Map data) { + var result = new java.util.LinkedHashMap(); + result.put("success", true); + result.put("message", message); + result.put("data", data); + return result; + } + + /** + * 构建错误结果 + */ + private Map errorResult(String message) { + var result = new java.util.LinkedHashMap(); + result.put("success", false); + result.put("message", message); + result.put("data", null); + return result; + } +} diff --git a/wangpan-tmdb-service/src/main/resources/application.yml b/wangpan-tmdb-service/src/main/resources/application.yml new file mode 100644 index 0000000..3fb34da --- /dev/null +++ b/wangpan-tmdb-service/src/main/resources/application.yml @@ -0,0 +1,45 @@ +# TMDB服务配置 +server: + port: 8083 + +spring: + application: + name: wangpan-tmdb-service + datasource: + url: ENC(nJXPBEsg+NwtZt+qnD9A0AwRIdPFwex6kYEfGDetJ+N42ha/dH7V3Lq/hSCYgZa08cocXV5N+AaiisG/gh+i8CcNYjXzFV8CMSRfLwx03cxxNwnDzak+2ZEY5nb8UTbC/a6b3pWuAwg9lnHF9ziroFIrOn8+s/SYN7e+oMh4tZFmfCT4h5ATsEEuB6RkYGTzf0oImBVmet1jRR4CRdHPSeh6jSWa7y1TutJoeBWyDpzHNgqE/JRLd1VNoc583/Bn) + username: ENC(9cCYl0WIh2m3gQhAfq4ykWAm5+PUvr6yPcuGKPwYDPpADnTZKVv9eYhwT806vYti) + password: ENC(Nsh0DCCayqJJpWpEmCLX/cYbMtmip1pgmd/0vunXnNAOK1Kn3JSSdHxZV+0s3Unj) + driver-class-name: com.mysql.cj.jdbc.Driver + +# MinIO 配置 +minio: + endpoint: ENC(nDpSqjv62odavtIjInqIzcjmWua20QGTKKfKsJlxgCTkWGWj/JJsIAEfsLIzKjpbeAMl/mxVJuPQv8o58b46gQ==) + accessKey: ENC(YM+d/P5KM/QqGFcbbxcZlM+jH0dTgaYlGiYOJ04dIyBX+Z4fu+h05tdylTy0SroK) + secretKey: ENC(BKqQFlceb8mGmAMWbmXFVV1US1Ib+24VApZCci0xyz+xAqL9MQetHJKsrWW1Vib2) + bucketName: tmdb + +# Jasypt 加密配置 +jasypt: + encryptor: + password: ${JASYPT_ENCRYPTOR_PASSWORD:wangpan-secret-key-2024} + 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 + +# MyBatis-Plus 配置 +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 + log-impl: org.apache.ibatis.logging.stdout.StdOutImpl + global-config: + db-config: + id-type: auto diff --git a/wangpan-tmdb-service/target/classes/application.yml b/wangpan-tmdb-service/target/classes/application.yml new file mode 100644 index 0000000..3fb34da --- /dev/null +++ b/wangpan-tmdb-service/target/classes/application.yml @@ -0,0 +1,45 @@ +# TMDB服务配置 +server: + port: 8083 + +spring: + application: + name: wangpan-tmdb-service + datasource: + url: ENC(nJXPBEsg+NwtZt+qnD9A0AwRIdPFwex6kYEfGDetJ+N42ha/dH7V3Lq/hSCYgZa08cocXV5N+AaiisG/gh+i8CcNYjXzFV8CMSRfLwx03cxxNwnDzak+2ZEY5nb8UTbC/a6b3pWuAwg9lnHF9ziroFIrOn8+s/SYN7e+oMh4tZFmfCT4h5ATsEEuB6RkYGTzf0oImBVmet1jRR4CRdHPSeh6jSWa7y1TutJoeBWyDpzHNgqE/JRLd1VNoc583/Bn) + username: ENC(9cCYl0WIh2m3gQhAfq4ykWAm5+PUvr6yPcuGKPwYDPpADnTZKVv9eYhwT806vYti) + password: ENC(Nsh0DCCayqJJpWpEmCLX/cYbMtmip1pgmd/0vunXnNAOK1Kn3JSSdHxZV+0s3Unj) + driver-class-name: com.mysql.cj.jdbc.Driver + +# MinIO 配置 +minio: + endpoint: ENC(nDpSqjv62odavtIjInqIzcjmWua20QGTKKfKsJlxgCTkWGWj/JJsIAEfsLIzKjpbeAMl/mxVJuPQv8o58b46gQ==) + accessKey: ENC(YM+d/P5KM/QqGFcbbxcZlM+jH0dTgaYlGiYOJ04dIyBX+Z4fu+h05tdylTy0SroK) + secretKey: ENC(BKqQFlceb8mGmAMWbmXFVV1US1Ib+24VApZCci0xyz+xAqL9MQetHJKsrWW1Vib2) + bucketName: tmdb + +# Jasypt 加密配置 +jasypt: + encryptor: + password: ${JASYPT_ENCRYPTOR_PASSWORD:wangpan-secret-key-2024} + 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 + +# MyBatis-Plus 配置 +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 + log-impl: org.apache.ibatis.logging.stdout.StdOutImpl + global-config: + db-config: + id-type: auto diff --git a/wangpan-tmdb-service/target/classes/com/wangpan/TmdbServiceApplication.class b/wangpan-tmdb-service/target/classes/com/wangpan/TmdbServiceApplication.class new file mode 100644 index 0000000..089e783 Binary files /dev/null and b/wangpan-tmdb-service/target/classes/com/wangpan/TmdbServiceApplication.class differ diff --git a/wangpan-tmdb-service/target/classes/com/wangpan/config/DatabaseInitializer.class b/wangpan-tmdb-service/target/classes/com/wangpan/config/DatabaseInitializer.class new file mode 100644 index 0000000..c91ec14 Binary files /dev/null and b/wangpan-tmdb-service/target/classes/com/wangpan/config/DatabaseInitializer.class differ diff --git a/wangpan-tmdb-service/target/classes/com/wangpan/controller/MovieController.class b/wangpan-tmdb-service/target/classes/com/wangpan/controller/MovieController.class new file mode 100644 index 0000000..4a3966d Binary files /dev/null and b/wangpan-tmdb-service/target/classes/com/wangpan/controller/MovieController.class differ diff --git a/wangpan-tmdb-service/target/classes/com/wangpan/controller/TmdbController.class b/wangpan-tmdb-service/target/classes/com/wangpan/controller/TmdbController.class new file mode 100644 index 0000000..7347079 Binary files /dev/null and b/wangpan-tmdb-service/target/classes/com/wangpan/controller/TmdbController.class differ diff --git a/wangpan-tmdb-service/target/classes/com/wangpan/controller/TmdbScraperController.class b/wangpan-tmdb-service/target/classes/com/wangpan/controller/TmdbScraperController.class new file mode 100644 index 0000000..e2a3209 Binary files /dev/null and b/wangpan-tmdb-service/target/classes/com/wangpan/controller/TmdbScraperController.class differ diff --git a/wangpan-tmdb-service/target/classes/com/wangpan/listener/TmdbScheduler$ScrapeTask.class b/wangpan-tmdb-service/target/classes/com/wangpan/listener/TmdbScheduler$ScrapeTask.class new file mode 100644 index 0000000..37d958c Binary files /dev/null and b/wangpan-tmdb-service/target/classes/com/wangpan/listener/TmdbScheduler$ScrapeTask.class differ diff --git a/wangpan-tmdb-service/target/classes/com/wangpan/listener/TmdbScheduler.class b/wangpan-tmdb-service/target/classes/com/wangpan/listener/TmdbScheduler.class new file mode 100644 index 0000000..b4e3585 Binary files /dev/null and b/wangpan-tmdb-service/target/classes/com/wangpan/listener/TmdbScheduler.class differ diff --git a/wangpan-tmdb-service/target/classes/com/wangpan/service/TmdbScraperService.class b/wangpan-tmdb-service/target/classes/com/wangpan/service/TmdbScraperService.class new file mode 100644 index 0000000..2a0d177 Binary files /dev/null and b/wangpan-tmdb-service/target/classes/com/wangpan/service/TmdbScraperService.class differ diff --git a/wangpan-tmdb-service/target/maven-archiver/pom.properties b/wangpan-tmdb-service/target/maven-archiver/pom.properties new file mode 100644 index 0000000..03f282a --- /dev/null +++ b/wangpan-tmdb-service/target/maven-archiver/pom.properties @@ -0,0 +1,3 @@ +artifactId=wangpan-tmdb-service +groupId=com.wangpan +version=1.0-SNAPSHOT diff --git a/wangpan-tmdb-service/target/maven-status/maven-compiler-plugin/compile/default-compile/createdFiles.lst b/wangpan-tmdb-service/target/maven-status/maven-compiler-plugin/compile/default-compile/createdFiles.lst new file mode 100644 index 0000000..47d809d --- /dev/null +++ b/wangpan-tmdb-service/target/maven-status/maven-compiler-plugin/compile/default-compile/createdFiles.lst @@ -0,0 +1,8 @@ +com\wangpan\config\DatabaseInitializer.class +com\wangpan\controller\TmdbController.class +com\wangpan\controller\MovieController.class +com\wangpan\listener\TmdbScheduler$ScrapeTask.class +com\wangpan\controller\TmdbScraperController.class +com\wangpan\listener\TmdbScheduler.class +com\wangpan\service\TmdbScraperService.class +com\wangpan\TmdbServiceApplication.class diff --git a/wangpan-tmdb-service/target/maven-status/maven-compiler-plugin/compile/default-compile/inputFiles.lst b/wangpan-tmdb-service/target/maven-status/maven-compiler-plugin/compile/default-compile/inputFiles.lst new file mode 100644 index 0000000..83e5218 --- /dev/null +++ b/wangpan-tmdb-service/target/maven-status/maven-compiler-plugin/compile/default-compile/inputFiles.lst @@ -0,0 +1,7 @@ +D:\java2\wangpan\java\wangpan-tmdb-service\src\main\java\com\wangpan\config\DatabaseInitializer.java +D:\java2\wangpan\java\wangpan-tmdb-service\src\main\java\com\wangpan\controller\TmdbScraperController.java +D:\java2\wangpan\java\wangpan-tmdb-service\src\main\java\com\wangpan\listener\TmdbScheduler.java +D:\java2\wangpan\java\wangpan-tmdb-service\src\main\java\com\wangpan\controller\MovieController.java +D:\java2\wangpan\java\wangpan-tmdb-service\src\main\java\com\wangpan\service\TmdbScraperService.java +D:\java2\wangpan\java\wangpan-tmdb-service\src\main\java\com\wangpan\TmdbServiceApplication.java +D:\java2\wangpan\java\wangpan-tmdb-service\src\main\java\com\wangpan\controller\TmdbController.java diff --git a/wangpan-tmdb-service/target/wangpan-tmdb-service-1.0-SNAPSHOT.jar b/wangpan-tmdb-service/target/wangpan-tmdb-service-1.0-SNAPSHOT.jar new file mode 100644 index 0000000..2becd08 Binary files /dev/null and b/wangpan-tmdb-service/target/wangpan-tmdb-service-1.0-SNAPSHOT.jar differ diff --git a/wangpan-tmdb-service/target/wangpan-tmdb-service-1.0-SNAPSHOT.jar.original b/wangpan-tmdb-service/target/wangpan-tmdb-service-1.0-SNAPSHOT.jar.original new file mode 100644 index 0000000..f0a8877 Binary files /dev/null and b/wangpan-tmdb-service/target/wangpan-tmdb-service-1.0-SNAPSHOT.jar.original differ diff --git a/wangpan-user-service/pom.xml b/wangpan-user-service/pom.xml new file mode 100644 index 0000000..4b731a1 --- /dev/null +++ b/wangpan-user-service/pom.xml @@ -0,0 +1,55 @@ + + + 4.0.0 + + + com.wangpan + wangpan-parent + 1.0-SNAPSHOT + + + wangpan-user-service + jar + + WangPan User Service + 用户认证服务 - 登录、注册、令牌管理 + + + + + com.wangpan + wangpan-common + + + + + org.springframework.boot + spring-boot-starter-web + + + + + com.github.ulisesbocchio + jasypt-spring-boot-starter + 3.0.5 + + + + + org.springframework.boot + spring-boot-starter-test + test + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + diff --git a/wangpan-user-service/src/main/java/com/wangpan/UserServiceApplication.java b/wangpan-user-service/src/main/java/com/wangpan/UserServiceApplication.java new file mode 100644 index 0000000..ea71017 --- /dev/null +++ b/wangpan-user-service/src/main/java/com/wangpan/UserServiceApplication.java @@ -0,0 +1,21 @@ +package com.wangpan; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import com.ulisesbocchio.jasyptspringboot.annotation.EnableEncryptableProperties; + +/** + * 用户认证服务启动类 + * 负责:登录、注册、令牌管理、用户信息管理 + */ +@SpringBootApplication +@EnableEncryptableProperties +public class UserServiceApplication { + + public static void main(String[] args) { + SpringApplication.run(UserServiceApplication.class, args); + System.out.println("========================================"); + System.out.println(" 用户认证服务启动成功!"); + System.out.println("========================================"); + } +} diff --git a/wangpan-user-service/src/main/java/com/wangpan/config/DatabaseInitializer.java b/wangpan-user-service/src/main/java/com/wangpan/config/DatabaseInitializer.java new file mode 100644 index 0000000..7b18f4a --- /dev/null +++ b/wangpan-user-service/src/main/java/com/wangpan/config/DatabaseInitializer.java @@ -0,0 +1,88 @@ +package com.wangpan.config; + +import com.wangpan.common.dal.mapper.LoginLogMapper; +import com.wangpan.common.dal.mapper.TokenMapper; +import com.wangpan.common.dal.mapper.UserMapper; +import com.wangpan.common.util.DBUtil; +import com.wangpan.common.util.SchemaUtil; +import org.springframework.stereotype.Component; + +import jakarta.annotation.PostConstruct; +import jakarta.annotation.Resource; +import java.sql.Connection; +import java.sql.SQLException; + +/** + * 数据库初始化器 + * 在应用启动时检查并创建用户服务相关的表结构 + * + * 使用 Mapper 中定义的 CREATE_TABLE_SQL 常量 + SchemaUtil 工具类建表 + * 保留 DBUtil 原始 JDBC 连接用于 SchemaUtil 的 DDL 操作 + */ +@Component +public class DatabaseInitializer { + + /** 注入DBUtil Bean(用于获取JDBC连接执行DDL) */ + @Resource + private DBUtil dbUtil; + + @PostConstruct + public void init() { + System.out.println("========================================"); + System.out.println(" [DatabaseInitializer] 用户服务数据库初始化..."); + System.out.println("========================================"); + + long startTime = System.currentTimeMillis(); + boolean initSuccess = true; + + try { + checkAndFixDatabaseSchema(); + System.out.println("✓ 数据库初始化检查完成!"); + } catch (Exception e) { + System.err.println("⚠ 数据库初始化警告:" + e.getMessage()); + e.printStackTrace(); + initSuccess = false; + } + + long duration = System.currentTimeMillis() - startTime; + System.out.println("========================================"); + System.out.println(initSuccess + ? " ✓ 数据库初始化成功!耗时:" + duration + "ms" + : " ⚠ 数据库初始化存在警告,耗时:" + duration + "ms"); + System.out.println("========================================"); + } + + /** + * 检查并修复数据库表结构 + * 使用各 Mapper 中定义的 CREATE_TABLE_SQL 常量建表 + */ + private void checkAndFixDatabaseSchema() throws SQLException { + try (Connection conn = dbUtil.getConnection()) { + if (conn == null || conn.isClosed()) { + System.err.println("✗ 无法获取数据库连接,跳过表结构检查"); + return; + } + + // 创建用户表(使用 UserMapper 中定义的建表SQL) + SchemaUtil.createTableIfNotExists(conn, "wp_user", UserMapper.CREATE_TABLE_SQL); + + // 创建令牌表(使用 TokenMapper 中定义的建表SQL) + SchemaUtil.createTableIfNotExists(conn, "wp_user_token", TokenMapper.CREATE_TABLE_SQL); + + // 创建登录日志表(使用 LoginLogMapper 中定义的建表SQL) + SchemaUtil.createTableIfNotExists(conn, "wp_login_log", LoginLogMapper.CREATE_TABLE_SQL); + + // 确保用户表字段存在(兼容旧表结构升级) + SchemaUtil.ensureColumnExists(conn, "wp_user", "lock_until", + "DATETIME DEFAULT NULL COMMENT '锁定截止时间(NULL=未锁定)'"); + SchemaUtil.ensureColumnExists(conn, "wp_user", "fail_count", + "INT NOT NULL DEFAULT 0 COMMENT '连续登录失败次数(成功/解锁后重置为0)'"); + SchemaUtil.ensureColumnExists(conn, "wp_user", "tmdb_api_key", + "VARCHAR(500) DEFAULT NULL COMMENT 'TMDB API Key(Jasypt加密存储)'"); + + System.out.println(" ✓ [wp_user] 表结构检查通过"); + System.out.println(" ✓ [wp_user_token] 表创建完成"); + System.out.println(" ✓ [wp_login_log] 表创建完成"); + } + } +} diff --git a/wangpan-user-service/src/main/java/com/wangpan/controller/AuthController.java b/wangpan-user-service/src/main/java/com/wangpan/controller/AuthController.java new file mode 100644 index 0000000..4a6b8e3 --- /dev/null +++ b/wangpan-user-service/src/main/java/com/wangpan/controller/AuthController.java @@ -0,0 +1,232 @@ +package com.wangpan.controller; + +import com.wangpan.common.Result; +import com.wangpan.common.dal.repository.TokenRepository; +import com.wangpan.common.entity.User; +import com.wangpan.service.UserService; +import com.wangpan.common.util.EncryptUtil; +import com.wangpan.common.util.TokenUtil; +import jakarta.servlet.http.HttpServletRequest; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.web.bind.annotation.*; + +import java.time.LocalDateTime; +import java.util.HashMap; +import java.util.Map; + +/** + * 用户认证控制器 + * + * 提供登录、注册等认证相关接口 + */ +@RestController +@RequestMapping("/api") +@CrossOrigin(origins = {"*"}, allowCredentials = "false") +public class AuthController { + + private static final Logger log = LoggerFactory.getLogger(AuthController.class); + + private final UserService userService; + private final TokenRepository tokenRepository; + + /** + * 构造函数注入: + * - UserService 由 Spring 容器管理 + * - TokenRepository 由 Spring 容器管理 + */ + public AuthController(UserService userService, TokenRepository tokenRepository) { + this.userService = userService; + this.tokenRepository = tokenRepository; + } + + /** + * 用户登录 + * POST /api/login + */ + @PostMapping("/login") + public Result login(@RequestBody Map loginData, HttpServletRequest request) { + try { + if (loginData == null || loginData.isEmpty()) { + return Result.badRequest("请提供登录参数"); + } + + String username = loginData.get("username"); + String password = loginData.get("password"); + String clientIp = getClientIp(request); + String userAgent = request.getHeader("User-Agent"); + + log.info("收到登录请求:{} | IP:{}", username, clientIp); + + Map loginResult = userService.login(username, password, clientIp, userAgent); + + if (Boolean.TRUE.equals(loginResult.get("success"))) { + User user = (User) loginResult.get("user"); + String token = TokenUtil.generateToken(user.getId()); + + try { + tokenRepository.save(user.getId(), token, truncate(userAgent, 200), + clientIp, LocalDateTime.now().plusHours(TokenUtil.TOKEN_EXPIRE_HOURS)); + } catch (Exception e) { + log.warn("保存令牌失败(不影响登录): {}", e.getMessage()); + } + + return Result.success("登录成功", buildUserDataWithToken(user, token)); + } else { + String errorMsg = (String) loginResult.get("message"); + return Result.error(determineErrorCode(errorMsg), errorMsg); + } + } catch (Exception e) { + log.error("系统内部错误", e); + return Result.error("系统内部错误,请稍后重试"); + } + } + + /** + * 用户注册 + * POST /api/register + */ + @PostMapping("/register") + public Result register(@RequestBody Map registerData) { + try { + if (registerData == null || registerData.isEmpty()) { + return Result.badRequest("请求体不能为空"); + } + + User newUser = new User( + String.valueOf(registerData.get("username")), + String.valueOf(registerData.get("password")) + ); + // 仅在请求体中实际提供了这些字段时才设置,避免 String.valueOf(null) 变成字符串 "null" + if (registerData.containsKey("email") && registerData.get("email") != null) { + newUser.setEmail((String) registerData.get("email")); + } + if (registerData.containsKey("phone") && registerData.get("phone") != null) { + newUser.setPhone((String) registerData.get("phone")); + } + if (registerData.containsKey("realName") && registerData.get("realName") != null) { + newUser.setRealName((String) registerData.get("realName")); + } + + log.info("收到注册请求:{}", newUser.getUsername()); + + Map registerResult = userService.register(newUser); + + if (Boolean.TRUE.equals(registerResult.get("success"))) { + return Result.success("注册成功"); + } else { + String errorMsg = (String) registerResult.get("message"); + return Result.error(determineRegisterErrorCode(errorMsg), errorMsg); + } + } catch (Exception e) { + log.error("注册异常", e); + return Result.error("系统内部错误,请稍后重试"); + } + } + + /** + * 验证令牌 + * GET /api/token/validate + */ + @GetMapping("/token/validate") + public Result validateToken(@RequestParam("token") String token) { + try { + Long userId = TokenUtil.getUserId(token); + if (userId == null || !tokenRepository.isValid(token)) { + return Result.unauthorized("令牌无效或已过期"); + } + + return Result.success("令牌有效", Map.of("userId", userId)); + } catch (Exception e) { + log.error("令牌验证异常", e); + return Result.error("令牌验证失败"); + } + } + + // ==================== 私有方法 ==================== + + private Map buildUserDataWithToken(User user, String token) { + Map data = buildUserData(user); + data.put("token", token); + data.put("tokenType", "Bearer"); + data.put("expireIn", TokenUtil.TOKEN_EXPIRE_HOURS * 3600); + return data; + } + + private Map buildUserData(User user) { + Map userData = new HashMap<>(); + userData.put("id", user.getId()); + userData.put("username", user.getUsername()); + userData.put("email", user.getEmail()); + userData.put("phone", user.getPhone()); + userData.put("realName", user.getRealName()); + userData.put("avatarUrl", user.getAvatarUrl()); + userData.put("userType", user.getUserType()); + userData.put("status", user.getStatus()); + userData.put("lastLoginIp", user.getLastLoginIp()); + userData.put("lastLoginTime", user.getLastLoginTime()); + + String key = user.getTmdbApiKey(); + if (key != null && !key.isBlank()) { + try { + userData.put("tmdbApiKey", EncryptUtil.decrypt(key)); + userData.put("hasTmdbKey", true); + } catch (Exception e) { + userData.put("tmdbApiKey", null); + userData.put("hasTmdbKey", false); + } + } else { + userData.put("tmdbApiKey", null); + userData.put("hasTmdbKey", false); + } + return userData; + } + + private String getClientIp(HttpServletRequest request) { + String ip = null; + if ((ip = request.getHeader("X-Forwarded-For")) == null || !isValidHeader(ip)) + if ((ip = request.getHeader("Proxy-Client-IP")) == null || !isValidHeader(ip)) + if ((ip = request.getHeader("WL-Proxy-Client-IP")) == null || !isValidHeader(ip)) + if ((ip = request.getHeader("HTTP_CLIENT_IP")) == null || !isValidHeader(ip)) + if ((ip = request.getHeader("HTTP_X_FORWARDED_FOR")) == null || !isValidHeader(ip)) + ip = request.getRemoteAddr(); + if (ip != null && ip.contains(",")) ip = ip.split(",")[0].trim(); + return normalizeIp(ip != null ? ip : "unknown"); + } + + private boolean isValidHeader(String v) { + return v != null && !v.isEmpty() && !"unknown".equalsIgnoreCase(v); + } + + private String normalizeIp(String ip) { + if (ip == null || ip.isBlank()) return null; + return switch (ip.trim()) { + case "0:0:0:0:0:0:0:1", "::1" -> "127.0.0.1"; + default -> ip; + }; + } + + private String truncate(String str, int maxLen) { + return str == null ? null : str.length() > maxLen ? str.substring(0, maxLen) : str; + } + + private int determineErrorCode(String msg) { + if (msg == null) return Result.CODE_SERVER_ERROR; + if (msg.contains("不能为空")) return Result.CODE_BAD_REQUEST; + if (msg.contains("禁用")) return Result.CODE_FORBIDDEN; + if (msg.contains("锁定") && msg.contains("分钟")) return Result.CODE_TOO_MANY_REQUESTS; + if (msg.contains("错误") || msg.contains("不存在")) return Result.CODE_UNAUTHORIZED; + return Result.CODE_SERVER_ERROR; + } + + private int determineRegisterErrorCode(String errorMsg) { + if (errorMsg == null) return Result.CODE_SERVER_ERROR; + if (errorMsg.contains("不能为空") || errorMsg.contains("格式")) { + return Result.CODE_BAD_REQUEST; + } + if (errorMsg.contains("已存在") || errorMsg.contains("已注册")) { + return Result.CODE_BAD_REQUEST; + } + return Result.CODE_SERVER_ERROR; + } +} \ No newline at end of file diff --git a/wangpan-user-service/src/main/java/com/wangpan/controller/LoginLogController.java b/wangpan-user-service/src/main/java/com/wangpan/controller/LoginLogController.java new file mode 100644 index 0000000..6dbdfc6 --- /dev/null +++ b/wangpan-user-service/src/main/java/com/wangpan/controller/LoginLogController.java @@ -0,0 +1,89 @@ +package com.wangpan.controller; + +import com.wangpan.common.Result; +import com.wangpan.common.annotation.RequireAuth; +import com.wangpan.common.dal.repository.LoginLogRepository; +import com.wangpan.common.dal.repository.UserRepository; +import com.wangpan.common.entity.User; +import com.wangpan.common.interceptor.AuthInterceptor; +import jakarta.servlet.http.HttpServletRequest; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.web.bind.annotation.*; + +import java.util.Map; + +/** + * 登录日志查询控制器 + * + * 提供登录日志查询接口 + * - 管理员:可查看所有日志 + * - 普通用户:只能查看自己的日志 + */ +@RestController +@RequireAuth +@RequestMapping("/api/login-logs") +@CrossOrigin(origins = {"*"}, allowCredentials = "false") +public class LoginLogController { + + private static final Logger log = LoggerFactory.getLogger(LoginLogController.class); + + /** 最大每页条数限制 */ + private static final int MAX_PAGE_SIZE = 100; + + private final LoginLogRepository loginLogRepository; + private final UserRepository userRepository; + + /** + * 构造函数注入 Repository 依赖 + */ + public LoginLogController(LoginLogRepository loginLogRepository, + UserRepository userRepository) { + this.loginLogRepository = loginLogRepository; + this.userRepository = userRepository; + } + + /** + * 从请求属性中获取当前登录用户(由 AuthInterceptor 注入) + */ + private User getCurrentUser(HttpServletRequest request) { + return (User) request.getAttribute(AuthInterceptor.CURRENT_USER_ATTR); + } + + /** + * 查询登录日志列表 + * GET /api/login-logs + */ + @GetMapping + public Result queryLoginLogs(@RequestParam(required = false) String username, + @RequestParam(required = false) String result, + @RequestParam(required = false) String startDate, + @RequestParam(required = false) String endDate, + @RequestParam(defaultValue = "1") int pageNo, + @RequestParam(defaultValue = "20") int pageSize, + HttpServletRequest request) { + try { + User currentUser = getCurrentUser(request); + + // 权限控制: + // - 管理员(userType=2):可查看所有日志,userId 传 null(不过滤) + // - 普通用户:只能查看自己的日志,userId 传当前用户ID + Long filterUserId = currentUser.isAdmin() ? null : currentUser.getId(); + + pageSize = Math.min(MAX_PAGE_SIZE, Math.max(1, pageSize)); + + log.info("用户 [{}] 角色={} 查询登录日志 | 用户名={} 结果={} 日期={}~{} 页码={}", + currentUser.getUsername(), + currentUser.isAdmin() ? "管理员" : "普通用户", + username, result, startDate, endDate, pageNo); + + Map logData = loginLogRepository.queryLoginLogs( + username, result, startDate, endDate, filterUserId, pageNo, pageSize); + + return Result.success("查询成功", logData); + } catch (Exception e) { + log.error("查询登录日志失败", e); + return Result.error("查询登录日志失败"); + } + } +} \ No newline at end of file diff --git a/wangpan-user-service/src/main/java/com/wangpan/controller/UserController.java b/wangpan-user-service/src/main/java/com/wangpan/controller/UserController.java new file mode 100644 index 0000000..009a343 --- /dev/null +++ b/wangpan-user-service/src/main/java/com/wangpan/controller/UserController.java @@ -0,0 +1,224 @@ +package com.wangpan.controller; + +import com.wangpan.common.Result; +import com.wangpan.common.annotation.RequireAdmin; +import com.wangpan.common.dal.repository.UserRepository; +import com.wangpan.common.entity.User; +import com.wangpan.common.interceptor.AuthInterceptor; +import com.wangpan.service.UserService; +import jakarta.servlet.http.HttpServletRequest; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.web.bind.annotation.*; + +import java.util.Map; + +/** + * 用户管理控制器(管理员专用) + * + * 功能:提供用户 CRUD 接口,仅管理员(userType=2)可访问 + * - GET /api/users 分页查询用户列表 + * - POST /api/users 新增用户 + * - PUT /api/users/{id} 修改用户信息 + * - DELETE /api/users/{id} 删除用户(软删除) + * + * 权限校验通过 @RequireAdmin 注解 + AuthInterceptor 拦截器统一处理 + */ +@RestController +public class UserController { + + private static final Logger log = LoggerFactory.getLogger(UserController.class); + + private final UserRepository userRepository; + + /** + * 构造函数注入(Spring 自动装配) + */ + public UserController(UserRepository userRepository) { + this.userRepository = userRepository; + } + + /** + * 从请求属性中获取当前登录用户(由 AuthInterceptor 注入) + */ + private User getCurrentUser(HttpServletRequest request) { + return (User) request.getAttribute(AuthInterceptor.CURRENT_USER_ATTR); + } + + // ==================== API 接口 ==================== + + /** + * 分页查询用户列表(管理员) + * 支持按用户名、状态、角色筛选 + * + * @param username 用户名模糊搜索(可选) + * @param status 状态筛选:0=禁用, 1=正常, 2=锁定(可选) + * @param userType 角色筛选:0=普通, 1=VIP, 2=管理员(可选) + * @param pageNo 页码,默认 1 + * @param pageSize 每页条数,默认 20 + */ + @RequireAdmin + @GetMapping("/api/users") + public Result listUsers( + HttpServletRequest request, + @RequestParam(required = false) String username, + @RequestParam(required = false) Integer status, + @RequestParam(required = false) Integer userType, + @RequestParam(defaultValue = "1") int pageNo, + @RequestParam(defaultValue = "20") int pageSize) { + + User currentUser = getCurrentUser(request); + log.info("管理员 [{}] 查询用户列表 | username={} status={} userType={} page={}", + currentUser.getUsername(), username, status, userType, pageNo); + + try { + Map data = userRepository.queryUsers(username, status, userType, pageNo, pageSize); + return Result.success("查询成功", data); + } catch (Exception e) { + log.error("查询用户列表失败", e); + return Result.error("系统繁忙,请稍后重试"); + } + } + + /** + * 新增用户(管理员) + * 由管理员手动创建用户账号 + * + * @param body 请求体:{username, password, email?, phone?, realName?, status?, userType?} + */ + @RequireAdmin + @PostMapping("/api/users") + public Result createUser(HttpServletRequest request, + @RequestBody Map body) { + + User currentUser = getCurrentUser(request); + log.info("管理员 [{}] 新增用户", currentUser.getUsername()); + + try { + // 1. 参数提取与校验 + String username = (String) body.get("username"); + String password = (String) body.get("password"); + + if (username == null || username.isBlank() || password == null || password.isBlank()) { + return Result.error(400, "用户名和密码不能为空"); + } + + // 2. 检查用户名是否已存在 + User existingUser = userRepository.findByUsername(username.trim()); + if (existingUser != null) { + return Result.error(409, "用户名已被占用"); + } + + // 3. 构建新用户对象 + var newUser = new User(); + newUser.setUsername(username.trim()); + newUser.setPassword(UserService.encryptMD5(password)); + newUser.setEmail((String) body.get("email")); + newUser.setPhone((String) body.get("phone")); + newUser.setRealName((String) body.get("realName")); + + // 可选字段:状态和角色(默认普通用户) + if (body.containsKey("status") && body.get("status") != null) { + newUser.setStatus(Integer.parseInt(body.get("status").toString())); + } else { + newUser.setStatus(1); + } + if (body.containsKey("userType") && body.get("userType") != null) { + newUser.setUserType(Integer.parseInt(body.get("userType").toString())); + } else { + newUser.setUserType(0); + } + + // 4. 插入数据库 + userRepository.insert(newUser); + return Result.success("用户创建成功", null); + + } catch (Exception e) { + log.error("新增用户失败", e); + return Result.error("系统繁忙,请稍后重试"); + } + } + + /** + * 修改用户信息(管理员) + * 支持部分更新,只传需要修改的字段即可 + * + * @param userId 目标用户 ID + * @param body 请求体:{email?, phone?, realName?, status?, userType?} + */ + @RequireAdmin + @PutMapping("/api/users/{userId}") + public Result updateUser(HttpServletRequest request, + @PathVariable Long userId, + @RequestBody Map body) { + + User currentUser = getCurrentUser(request); + log.info("管理员 [{}] 修改用户 | targetUserId={}", currentUser.getUsername(), userId); + + try { + // 1. 检查目标用户是否存在 + User targetUser = userRepository.findById(userId); + if (targetUser == null) { + return Result.error(404, "目标用户不存在"); + } + + // 2. 提取可选参数(只处理明确传入的字段) + String email = body.containsKey("email") ? (String) body.get("email") : null; + String phone = body.containsKey("phone") ? (String) body.get("phone") : null; + String realName = body.containsKey("realName") ? (String) body.get("realName") : null; + Integer status = body.containsKey("status") && body.get("status") != null + ? Integer.parseInt(body.get("status").toString()) : null; + Integer userType = body.containsKey("userType") && body.get("userType") != null + ? Integer.parseInt(body.get("userType").toString()) : null; + + // 3. 执行更新 + int rows = userRepository.updateUser(userId, email, phone, realName, status, userType); + + if (rows > 0) { + return Result.success("用户信息更新成功", null); + } else { + return Result.error(400, "没有需要更新的字段"); + } + + } catch (Exception e) { + log.error("修改用户失败", e); + return Result.error("系统繁忙,请稍后重试"); + } + } + + /** + * 删除用户(管理员,软删除) + * 将 is_deleted 设为 1,不物理删除数据 + * + * @param userId 要删除的用户 ID + */ + @RequireAdmin + @DeleteMapping("/api/users/{userId}") + public Result deleteUser(HttpServletRequest request, + @PathVariable Long userId) { + + User currentUser = getCurrentUser(request); + log.info("管理员 [{}] 删除用户 | targetUserId={}", currentUser.getUsername(), userId); + + try { + // 1. 检查目标用户是否存在 + User targetUser = userRepository.findById(userId); + if (targetUser == null) { + return Result.error(404, "目标用户不存在"); + } + + // 2. 执行软删除 + int rows = userRepository.softDelete(userId); + + if (rows > 0) { + return Result.success("用户已删除", null); + } else { + return Result.error(400, "删除失败,用户可能已被删除"); + } + + } catch (Exception e) { + log.error("删除用户失败", e); + return Result.error("系统繁忙,请稍后重试"); + } + } +} \ No newline at end of file diff --git a/wangpan-user-service/src/main/java/com/wangpan/service/UserService.java b/wangpan-user-service/src/main/java/com/wangpan/service/UserService.java new file mode 100644 index 0000000..434ff21 --- /dev/null +++ b/wangpan-user-service/src/main/java/com/wangpan/service/UserService.java @@ -0,0 +1,381 @@ +package com.wangpan.service; + +import com.wangpan.common.dal.repository.LoginLogRepository; +import com.wangpan.common.dal.repository.UserRepository; +import com.wangpan.common.entity.User; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Service; + +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.Duration; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.HashMap; +import java.util.Map; + +/** + * 用户服务类(UserService)- Java 17 版本 + * + * 功能:处理用户登录的业务逻辑 + * Java 17 特性使用: + * - var 局部变量类型推断 + * - 文本块(Text Blocks)用于多行字符串 + * - switch 表达式(箭头语法) + * - instanceof 模式匹配 + */ +@Service +public class UserService { + + // ==================== 依赖注入 ==================== + + /** 用户数据访问对象 */ + @Resource + private UserRepository userRepository; + + /** 登录日志数据访问对象 */ + @Resource + private LoginLogRepository loginLogRepository; + + // ==================== 安全配置常量 ==================== + + /** 最大允许的连续失败次数(超过此次数将锁定账户) */ + private static final int MAX_LOGIN_FAILURES = 3; + + /** 账户锁定后的冷却时间(分钟) */ + private static final int LOCKOUT_DURATION_MINUTES = 1; + + /** 日期格式化器(Java 17:静态final,线程安全) */ + private static final DateTimeFormatter TIME_FORMATTER = + DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); + + // ==================== 构造方法 ==================== + // 使用Spring字段注入,无需显式构造方法 + + // ==================== 核心业务方法 ==================== + + /** + * 用户登录方法(核心功能) + * 执行完整的登录流程,使用 Java 17 语法特性 + * + * @param username 用户名(不能为空) + * @param password 明文密码(不能为空) + * @param loginIp 登录IP地址(用于审计) + * @param userAgent 浏览器User-Agent(用于审计) + * @return 包含 success/message/user 的结果Map + */ + public Map login(String username, String password, + String loginIp, String userAgent) { + + // var:Java 10+ 局部变量类型推断(Java 17推荐使用) + var result = new HashMap(); + + try { + // ========== 第1步:参数校验 ========== + + // 使用 switch 表达式(Java 14+ 箭头语法)进行参数校验 + if (username == null || username.isBlank()) { + return failResult(result, "用户名不能为空"); + } + if (password == null || password.isBlank()) { + return failResult(result, "密码不能为空"); + } + + // 去除首尾空白字符 + username = username.trim(); + password = password.trim(); + + // ========== 第2步:查找用户 ========== + var userForCheck = userRepository.findByUsername(username); + + // ========== 第3步:检查锁定状态 ========== + var now = LocalDateTime.now(); + Boolean wasJustUnlocked = null; + int currentFailCount = 0; + + // 只有用户存在时才检查锁定状态 + if (userForCheck != null) { + wasJustUnlocked = checkAndHandleLockStatus(userForCheck, username, loginIp, userAgent, now, result); + + // 如果返回false说明在锁定期内,直接返回错误结果 + if (Boolean.FALSE.equals(wasJustUnlocked)) { + return result; + } + + // ========== 第4步:检查fail_count是否达到上限 ========== + currentFailCount = userForCheck.getFailCount() != null ? userForCheck.getFailCount() : 0; + + if (!Boolean.TRUE.equals(wasJustUnlocked) && currentFailCount >= MAX_LOGIN_FAILURES) { + return handleAccountLock(userForCheck, username, loginIp, userAgent, now, result); + } + } + + // ========== 第5步:验证用户存在性 ========== + var user = userForCheck; + + if (user == null) { + // 用户不存在:记录审计日志(不明确提示"不存在",防枚举攻击) + loginLogRepository.insert(null, username, loginIp, "fail", "用户名或密码错误", userAgent); + return failResult(result, "用户名或密码错误"); + } + + // ========== 第6步:检查账户状态(switch 表达式) ========== + if (!user.isActive()) { + // Java 17 switch 表达式(箭头语法 + 直接返回值) + var statusMsg = switch (user.getStatus()) { + case 0 -> "账户已被禁用"; + case 2 -> "账户已被锁定"; + default -> "账户状态异常"; + }; + + loginLogRepository.insert(user.getId(), username, loginIp, "fail", statusMsg, userAgent); + return failResult(result, statusMsg + ",请联系管理员"); + } + + // ========== 第7步:验证密码 ========== + var encryptedInputPassword = encryptMD5(password); + + if (!encryptedInputPassword.equals(user.getPassword())) { + // 密码错误:记录日志 + 原子性增加fail_count + loginLogRepository.insert(user.getId(), username, loginIp, "fail", "密码错误", userAgent); + userRepository.incrementFailCount(user.getId()); + + // 计算剩余尝试次数并提示 + var remainingAttempts = MAX_LOGIN_FAILURES - (++currentFailCount); + var msg = remainingAttempts > 0 && remainingAttempts <= 3 + ? "用户名或密码错误,还剩" + remainingAttempts + "次尝试机会" + : "用户名或密码错误"; + + return failResult(result, msg); + } + + // ========== 第8步:登录成功处理 ========== + handleLoginSuccess(user, username, loginIp, now, userAgent); + + // ========== 第9步:返回成功结果 ========== + result.put("success", true); + result.put("message", "登录成功"); + result.put("user", user); // 密码已清除 + + System.out.println("✓ 用户 [" + username + "] 登录成功,IP:" + loginIp); + + return result; + + } catch (Exception e) { + System.err.println("✗ 登录过程发生异常:" + e.getMessage()); + e.printStackTrace(); + return failResult(result, "系统内部错误"); + } + } + + /** + * 检查并处理账户锁定状态 + * + * @param user 用户对象(可能为null) + * @param username 用户名 + * @param loginIp 登录IP + * @param userAgent User-Agent + * @param now 当前时间 + * @param result 结果Map(用于填充锁定期内的错误信息) + * @return null=未锁定/刚解锁可继续;false=仍在锁定期内(已填充result);true=刚解锁 + */ + private Boolean checkAndHandleLockStatus(User user, String username, String loginIp, + String userAgent, LocalDateTime now, + Map result) { + // 检查锁定状态(直接访问字段,避免instanceof模式匹配的类型限制) + var lockUntilTime = user.getLockUntil(); + if (lockUntilTime == null) { + return null; // 无锁定记录,继续正常流程 + } + + if (now.isBefore(lockUntilTime)) { + // ====== 还在锁定期内 ====== + var remainingSeconds = Duration.between(now, lockUntilTime).getSeconds(); + var remainingMinutes = (remainingSeconds + 59) / 60; // 向上取整 + var unlockTimeStr = lockUntilTime.format(TIME_FORMATTER); + + // 文本块(Text Blocks, Java 13+):多行字符串更清晰 + var message = remainingSeconds > 60 + ? "账户已锁定,剩余 " + remainingMinutes + " 分钟后解锁(" + unlockTimeStr + ")" + : "账户已锁定,即将解锁(" + unlockTimeStr + ")"; + + // 记录审计日志(不可篡改) + loginLogRepository.insert(user.getId(), username, loginIp, "fail", "锁定期内尝试登录", userAgent); + + result.put("success", false); + result.put("message", message); + return false; // 标记:仍在锁定期 + + } else { + // ====== 锁定已过期 → 自动解锁 ====== + userRepository.clearLockUntil(user.getId()); + user.setLockUntil(null); + user.setStatus(1); + userRepository.resetFailCount(user.getId()); + user.setFailCount(0); + + System.out.println("✓ 用户 [" + username + "] 锁定时间已过,自动解除锁定"); + return true; // 标记:刚刚执行了解锁 + } + } + + /** + * 处理账户首次达到失败次数上限的锁定操作 + * + * @param user 用户对象(可能为null) + * @param username 用户名 + * @param loginIp 登录IP + * @param userAgent User-Agent + * @param now 当前时间 + * @param result 结果Map + * @return 填充后的结果Map + */ + private Map handleAccountLock(User user, String username, String loginIp, + String userAgent, LocalDateTime now, + Map result) { + var newLockUntil = now.plusMinutes(LOCKOUT_DURATION_MINUTES); + var unlockTimeStr = newLockUntil.format(TIME_FORMATTER); + + if (user != null) { + userRepository.updateLockUntil(user.getId(), newLockUntil); + System.out.println("✗ 用户 [" + username + "] 因多次失败已被锁定至 " + unlockTimeStr); + user.setLockUntil(newLockUntil); + user.setStatus(2); + } + + // 记录锁定审计日志 + loginLogRepository.insert( + user != null ? user.getId() : null, + username, loginIp, "fail", + "因多次失败尝试被锁定", userAgent + ); + + result.put("success", false); + result.put("message", "登录失败次数过多,账户已锁定 " + LOCKOUT_DURATION_MINUTES + + " 分钟(至 " + unlockTimeStr + ")"); + return result; + } + + /** + * 处理登录成功后的所有操作: + * 1. 清除可能的锁定记录 + * 2. 重置失败计数器(用wp_user.fail_count字段) + * 3. 更新最后登录时间和IP + * 4. 清除敏感信息(密码) + * 5. 记录成功日志 + */ + private void handleLoginSuccess(User user, String username, String loginIp, + LocalDateTime now, String userAgent) { + // 清除之前的锁定记录(如果有) + if (user.getLockUntil() != null) { + userRepository.clearLockUntil(user.getId()); + user.setLockUntil(null); + System.out.println("✓ 用户 [" + username + "] 登录成功,已清除之前的锁定记录"); + } + + // ★ 重置失败计数器(fail_count = 0) + // 注意:只重置状态字段,不删除任何审计日志! + userRepository.resetFailCount(user.getId()); + + // 更新最后登录时间和IP地址 + userRepository.updateLastLoginInfo(user.getId(), loginIp, now); + user.setLastLoginIp(loginIp); + user.setLastLoginTime(now); + + // 清除敏感信息(密码不应该返回给前端) + user.setPassword(null); + + // 记录成功的登录日志 + loginLogRepository.insert(user.getId(), username, loginIp, "success", null, userAgent); + } + + /** + * 快速构建失败结果的工具方法 + */ + private static Map failResult(Map result, String message) { + result.put("success", false); + result.put("message", message); + return result; + } + + // ==================== 注册方法 ==================== + + /** + * 用户注册方法 + * + * @param user 包含注册信息的用户对象 + * @return 注册结果Map + */ + public Map register(User user) { + var result = new HashMap(); + + try { + // 校验必填字段(使用 isBlank 替代 trim().isEmpty(),Java 11+) + if (user.getUsername() == null || user.getUsername().isBlank()) { + return failResult(result, "用户名不能为空"); + } + if (user.getPassword() == null || user.getPassword().isBlank()) { + return failResult(result, "密码不能为空"); + } + + // 检查用户名是否已存在 + var existingUser = userRepository.findByUsername(user.getUsername().trim()); + if (existingUser != null) { + return failResult(result, "用户名已被注册"); + } + + // 加密密码并设置默认值 + user.setPassword(encryptMD5(user.getPassword())); + user.setUsername(user.getUsername().trim()); + + // 使用 ?: 运算符简化默认值设置 + if (user.getStatus() == null) user.setStatus(1); + if (user.getUserType() == null) user.setUserType(0); + if (user.getIsDeleted() == null) user.setIsDeleted(0); + + // 插入数据库 + var rows = userRepository.insert(user); + + if (rows > 0) { + result.put("success", true); + result.put("message", "注册成功"); + System.out.println("✓ 新用户注册成功:" + user.getUsername()); + } else { + return failResult(result, "注册失败,请稍后重试"); + } + + return result; + + } catch (Exception e) { + System.err.println("✗ 注册过程发生异常:" + e.getMessage()); + return failResult(result, "系统繁忙,请稍后重试"); + } + } + + // ==================== 工具方法 ==================== + + /** + * MD5密码加密方法 + * 将明文密码转换为MD5哈希值 + * + * 安全说明:MD5不够安全,生产环境建议使用 BCrypt/PBKDF2/Argon2 + * + * @param plainText 明文密码 + * @return MD5加密后的32位小写十六进制字符串 + */ + public static String encryptMD5(String plainText) { + try { + var md = MessageDigest.getInstance("MD5"); + var hashBytes = md.digest(plainText.getBytes(java.nio.charset.StandardCharsets.UTF_8)); + + var sb = new StringBuilder(hashBytes.length * 2); + for (byte b : hashBytes) { + sb.append(String.format("%02x", b)); + } + return sb.toString(); + + } catch (NoSuchAlgorithmException e) { + throw new RuntimeException("密码加密失败", e); + } + } +} diff --git a/wangpan-user-service/src/main/resources/application.yml b/wangpan-user-service/src/main/resources/application.yml new file mode 100644 index 0000000..be275e6 --- /dev/null +++ b/wangpan-user-service/src/main/resources/application.yml @@ -0,0 +1,41 @@ +# 用户服务配置 +server: + port: 8081 + +spring: + application: + name: wangpan-user-service + datasource: + url: ENC(nJXPBEsg+NwtZt+qnD9A0AwRIdPFwex6kYEfGDetJ+N42ha/dH7V3Lq/hSCYgZa08cocXV5N+AaiisG/gh+i8CcNYjXzFV8CMSRfLwx03cxxNwnDzak+2ZEY5nb8UTbC/a6b3pWuAwg9lnHF9ziroFIrOn8+s/SYN7e+oMh4tZFmfCT4h5ATsEEuB6RkYGTzf0oImBVmet1jRR4CRdHPSeh6jSWa7y1TutJoeBWyDpzHNgqE/JRLd1VNoc583/Bn) + username: ENC(9cCYl0WIh2m3gQhAfq4ykWAm5+PUvr6yPcuGKPwYDPpADnTZKVv9eYhwT806vYti) + password: ENC(Nsh0DCCayqJJpWpEmCLX/cYbMtmip1pgmd/0vunXnNAOK1Kn3JSSdHxZV+0s3Unj) + driver-class-name: com.mysql.cj.jdbc.Driver + +# Jasypt 加密配置 +jasypt: + encryptor: + password: ${JASYPT_ENCRYPTOR_PASSWORD:wangpan-secret-key-2024} + 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 + +# MyBatis-Plus 配置 +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 + log-impl: org.apache.ibatis.logging.stdout.StdOutImpl + global-config: + db-config: + id-type: auto + logic-delete-field: isDeleted + logic-delete-value: 1 + logic-not-delete-value: 0 diff --git a/wangpan-user-service/target/classes/application.yml b/wangpan-user-service/target/classes/application.yml new file mode 100644 index 0000000..be275e6 --- /dev/null +++ b/wangpan-user-service/target/classes/application.yml @@ -0,0 +1,41 @@ +# 用户服务配置 +server: + port: 8081 + +spring: + application: + name: wangpan-user-service + datasource: + url: ENC(nJXPBEsg+NwtZt+qnD9A0AwRIdPFwex6kYEfGDetJ+N42ha/dH7V3Lq/hSCYgZa08cocXV5N+AaiisG/gh+i8CcNYjXzFV8CMSRfLwx03cxxNwnDzak+2ZEY5nb8UTbC/a6b3pWuAwg9lnHF9ziroFIrOn8+s/SYN7e+oMh4tZFmfCT4h5ATsEEuB6RkYGTzf0oImBVmet1jRR4CRdHPSeh6jSWa7y1TutJoeBWyDpzHNgqE/JRLd1VNoc583/Bn) + username: ENC(9cCYl0WIh2m3gQhAfq4ykWAm5+PUvr6yPcuGKPwYDPpADnTZKVv9eYhwT806vYti) + password: ENC(Nsh0DCCayqJJpWpEmCLX/cYbMtmip1pgmd/0vunXnNAOK1Kn3JSSdHxZV+0s3Unj) + driver-class-name: com.mysql.cj.jdbc.Driver + +# Jasypt 加密配置 +jasypt: + encryptor: + password: ${JASYPT_ENCRYPTOR_PASSWORD:wangpan-secret-key-2024} + 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 + +# MyBatis-Plus 配置 +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 + log-impl: org.apache.ibatis.logging.stdout.StdOutImpl + global-config: + db-config: + id-type: auto + logic-delete-field: isDeleted + logic-delete-value: 1 + logic-not-delete-value: 0 diff --git a/wangpan-user-service/target/classes/com/wangpan/UserServiceApplication.class b/wangpan-user-service/target/classes/com/wangpan/UserServiceApplication.class new file mode 100644 index 0000000..ead56b1 Binary files /dev/null and b/wangpan-user-service/target/classes/com/wangpan/UserServiceApplication.class differ diff --git a/wangpan-user-service/target/classes/com/wangpan/config/DatabaseInitializer.class b/wangpan-user-service/target/classes/com/wangpan/config/DatabaseInitializer.class new file mode 100644 index 0000000..c7ebc97 Binary files /dev/null and b/wangpan-user-service/target/classes/com/wangpan/config/DatabaseInitializer.class differ diff --git a/wangpan-user-service/target/classes/com/wangpan/controller/AuthController.class b/wangpan-user-service/target/classes/com/wangpan/controller/AuthController.class new file mode 100644 index 0000000..e532629 Binary files /dev/null and b/wangpan-user-service/target/classes/com/wangpan/controller/AuthController.class differ diff --git a/wangpan-user-service/target/classes/com/wangpan/controller/LoginLogController.class b/wangpan-user-service/target/classes/com/wangpan/controller/LoginLogController.class new file mode 100644 index 0000000..588c438 Binary files /dev/null and b/wangpan-user-service/target/classes/com/wangpan/controller/LoginLogController.class differ diff --git a/wangpan-user-service/target/classes/com/wangpan/controller/UserController.class b/wangpan-user-service/target/classes/com/wangpan/controller/UserController.class new file mode 100644 index 0000000..46eba6f Binary files /dev/null and b/wangpan-user-service/target/classes/com/wangpan/controller/UserController.class differ diff --git a/wangpan-user-service/target/classes/com/wangpan/service/UserService.class b/wangpan-user-service/target/classes/com/wangpan/service/UserService.class new file mode 100644 index 0000000..9bff97f Binary files /dev/null and b/wangpan-user-service/target/classes/com/wangpan/service/UserService.class differ diff --git a/wangpan-user-service/target/maven-archiver/pom.properties b/wangpan-user-service/target/maven-archiver/pom.properties new file mode 100644 index 0000000..8b243ed --- /dev/null +++ b/wangpan-user-service/target/maven-archiver/pom.properties @@ -0,0 +1,3 @@ +artifactId=wangpan-user-service +groupId=com.wangpan +version=1.0-SNAPSHOT diff --git a/wangpan-user-service/target/maven-status/maven-compiler-plugin/compile/default-compile/createdFiles.lst b/wangpan-user-service/target/maven-status/maven-compiler-plugin/compile/default-compile/createdFiles.lst new file mode 100644 index 0000000..b522138 --- /dev/null +++ b/wangpan-user-service/target/maven-status/maven-compiler-plugin/compile/default-compile/createdFiles.lst @@ -0,0 +1,6 @@ +com\wangpan\config\DatabaseInitializer.class +com\wangpan\controller\LoginLogController.class +com\wangpan\service\UserService.class +com\wangpan\UserServiceApplication.class +com\wangpan\controller\UserController.class +com\wangpan\controller\AuthController.class diff --git a/wangpan-user-service/target/maven-status/maven-compiler-plugin/compile/default-compile/inputFiles.lst b/wangpan-user-service/target/maven-status/maven-compiler-plugin/compile/default-compile/inputFiles.lst new file mode 100644 index 0000000..fe04e74 --- /dev/null +++ b/wangpan-user-service/target/maven-status/maven-compiler-plugin/compile/default-compile/inputFiles.lst @@ -0,0 +1,6 @@ +D:\java2\wangpan\java\wangpan-user-service\src\main\java\com\wangpan\service\UserService.java +D:\java2\wangpan\java\wangpan-user-service\src\main\java\com\wangpan\UserServiceApplication.java +D:\java2\wangpan\java\wangpan-user-service\src\main\java\com\wangpan\config\DatabaseInitializer.java +D:\java2\wangpan\java\wangpan-user-service\src\main\java\com\wangpan\controller\AuthController.java +D:\java2\wangpan\java\wangpan-user-service\src\main\java\com\wangpan\controller\UserController.java +D:\java2\wangpan\java\wangpan-user-service\src\main\java\com\wangpan\controller\LoginLogController.java diff --git a/wangpan-user-service/target/wangpan-user-service-1.0-SNAPSHOT.jar b/wangpan-user-service/target/wangpan-user-service-1.0-SNAPSHOT.jar new file mode 100644 index 0000000..9799167 Binary files /dev/null and b/wangpan-user-service/target/wangpan-user-service-1.0-SNAPSHOT.jar differ diff --git a/wangpan-user-service/target/wangpan-user-service-1.0-SNAPSHOT.jar.original b/wangpan-user-service/target/wangpan-user-service-1.0-SNAPSHOT.jar.original new file mode 100644 index 0000000..9371ae3 Binary files /dev/null and b/wangpan-user-service/target/wangpan-user-service-1.0-SNAPSHOT.jar.original differ