Compare commits

...

10 Commits

Author SHA1 Message Date
2b538c4520 build(global): 添加 .gitignore 并移除编译产物和依赖 JAR | 移除 target/ 目录和 wangpan-common/deps/ 下的依赖 JAR 文件,避免仓库体积过大导致推送失败(HTTP 413) 2026-08-31 18:31:39 +08:00
9a3ef4f1ca feat(drive): 新增Logo回收定时任务与相关功能优化
1. 新增Logo回收定时任务,实现30天未访问进入回收、7天未访问则删除的策略
2. 扩展Drive实体与DO类,新增logoLastAccessTime和logoStatus字段
3. 为wp_drive表新增logo相关字段与索引
4. 优化OpenList心跳检测逻辑,缓存Token避免重复登录
5. 重构OpenList同步逻辑,直接从根目录识别存储而非调用存储API
6. 修复UserRepository时间字段插入空值问题
7. 调整MinIO预签名URL有效期为365天
8. 优化重启脚本,支持传入加密密码参数
2026-08-31 18:04:09 +08:00
d8aab63d85 opt(openlist): 新增OpenList多存储同步支持,完善心跳检测字段
1. 为wp_user_openlist表新增心跳检测相关字段
2. 新增获取OpenList存储列表API
3. 重构OpenList同步逻辑,支持多存储同步
4. 优化网盘创建逻辑,按存储ID匹配复用已有网盘
2026-06-30 17:44:06 +08:00
bd921cc161 opt(common): 重构通用模块实体、仓储及工具类代码
更新通用模块下的实体类、数据对象、仓储层和工具类代码,优化整体结构
2026-06-29 10:21:49 +08:00
5b93174c8b build(gateway): 更新网关模块的打包jar文件 | 同步构建产出的二进制包变更 2026-06-27 17:32:11 +08:00
9f19d64afb feat: 新增OpenList集成功能,完善TMDB刮削与配置加密
1.  新增用户OpenList连接配置模块,包括数据库表、实体类与相关服务
2.  为驱动服务添加定时心跳检测功能,监控OpenList连接状态
3.  重构电影实体与数据库字段,移除冗余的overview字段,统一使用description存储简介
4.  完善TMDB刮削逻辑,增加空指针校验与详细失败日志
5.  配置文件敏感信息加密,添加加密脚本工具
6.  新增网关路由规则支持OpenList接口
7.  完善刮削日志查询功能,支持查看详细执行结果
2026-06-27 17:29:36 +08:00
ac0d94bd00 feat(openlist): 集成OpenList多网盘聚合服务,新增相关接口与文档
新增OpenList集成模块,支持80+种网盘接入,添加创建关联网盘、文件同步、获取播放链接等接口,更新API文档完善相关说明
2026-06-27 15:22:52 +08:00
04e57473b4 opt(common): 优化电影相关实体、工具类及仓储逻辑 2026-06-26 18:01:04 +08:00
fbd345564f feat(openlist): 集成OpenList网盘挂载与影视刮削功能
1. 新增OpenListClient客户端,封装OpenList REST API调用,支持自动刷新Token
2. 为Drive和Movie实体新增OpenList关联字段与影视刮削字段
3. 新增数据库表字段初始化脚本,新增所需数据库列
4. 新增网盘挂载OpenList的配置与接口
5. 新增从OpenList同步文件、获取影视播放链接的接口
6. 优化MovieRepository查询逻辑,过滤已删除的影视数据
2026-06-26 17:59:58 +08:00
83c4e08856 fix: 修复TMDB API Key解密问题
- TmdbScraperService: 添加EncryptUtil.decrypt()调用解密API Key

- 修复tmdb_api_key字段未自动解密导致API调用失败的问题
2026-06-26 14:52:44 +08:00
154 changed files with 6235 additions and 3331 deletions

24
.gitignore vendored Normal file
View File

@ -0,0 +1,24 @@
# 编译输出目录
target/
# 依赖 JAR 包
wangpan-common/deps/
# IDE 配置文件
.idea/
*.iml
.vscode/
.settings/
.classpath
.project
# 日志文件
*.log
/opt/wangpan/logs/
# 系统文件
.DS_Store
Thumbs.db
# 环境配置
.env

0
drive-service-error.log Normal file
View File

73
drive-service.log Normal file
View File

@ -0,0 +1,73 @@
. ____ _ __ _ _
/\\ / ___'_ __ _ _(_)_ __ __ _ \ \ \ \
( ( )\___ | '_ | '_| | '_ \/ _` | \ \ \ \
\\/ ___)| |_)| | | | | || (_| | ) ) ) )
' |____| .__|_| |_|_| |_\__, | / / / /
=========|_|==============|___/=/_/_/_/
:: Spring Boot :: (v3.2.5)
2026-07-02T14:26:40.211+08:00 INFO 151212 --- [wangpan-drive-service] [ main] com.wangpan.DriveServiceApplication : Starting DriveServiceApplication v1.0-SNAPSHOT using Java 17.0.8 with PID 151212 (D:\java2\wangpan\java\wangpan-drive-service\target\wangpan-drive-service-1.0-SNAPSHOT.jar started by 李荣强 in D:\java2\wangpan\java)
2026-07-02T14:26:40.217+08:00 INFO 151212 --- [wangpan-drive-service] [ main] com.wangpan.DriveServiceApplication : No active profile set, falling back to 1 default profile: "default"
2026-07-02T14:26:41.606+08:00 INFO 151212 --- [wangpan-drive-service] [ main] ptablePropertiesBeanFactoryPostProcessor : Post-processing PropertySource instances
2026-07-02T14:26:41.608+08:00 INFO 151212 --- [wangpan-drive-service] [ main] c.u.j.EncryptablePropertySourceConverter : Skipping PropertySource configurationProperties [class org.springframework.boot.context.properties.source.ConfigurationPropertySourcesPropertySource
2026-07-02T14:26:41.610+08:00 INFO 151212 --- [wangpan-drive-service] [ main] c.u.j.EncryptablePropertySourceConverter : Skipping PropertySource servletConfigInitParams [class org.springframework.core.env.PropertySource$StubPropertySource
2026-07-02T14:26:41.610+08:00 INFO 151212 --- [wangpan-drive-service] [ main] c.u.j.EncryptablePropertySourceConverter : Skipping PropertySource servletContextInitParams [class org.springframework.core.env.PropertySource$StubPropertySource
2026-07-02T14:26:41.610+08:00 INFO 151212 --- [wangpan-drive-service] [ main] c.u.j.EncryptablePropertySourceConverter : Converting PropertySource systemProperties [org.springframework.core.env.PropertiesPropertySource] to EncryptableMapPropertySourceWrapper
2026-07-02T14:26:41.611+08:00 INFO 151212 --- [wangpan-drive-service] [ main] c.u.j.EncryptablePropertySourceConverter : Converting PropertySource systemEnvironment [org.springframework.boot.env.SystemEnvironmentPropertySourceEnvironmentPostProcessor$OriginAwareSystemEnvironmentPropertySource] to EncryptableSystemEnvironmentPropertySourceWrapper
2026-07-02T14:26:41.611+08:00 INFO 151212 --- [wangpan-drive-service] [ main] c.u.j.EncryptablePropertySourceConverter : Converting PropertySource random [org.springframework.boot.env.RandomValuePropertySource] to EncryptablePropertySourceWrapper
2026-07-02T14:26:41.611+08:00 INFO 151212 --- [wangpan-drive-service] [ main] c.u.j.EncryptablePropertySourceConverter : Converting PropertySource Config resource 'class path resource [application.yml]' via location 'optional:classpath:/' [org.springframework.boot.env.OriginTrackedMapPropertySource] to EncryptableMapPropertySourceWrapper
2026-07-02T14:26:41.842+08:00 INFO 151212 --- [wangpan-drive-service] [ main] c.u.j.filter.DefaultLazyPropertyFilter : Property Filter custom Bean not found with name 'encryptablePropertyFilter'. Initializing Default Property Filter
2026-07-02T14:26:41.854+08:00 INFO 151212 --- [wangpan-drive-service] [ main] c.u.j.r.DefaultLazyPropertyResolver : Property Resolver custom Bean not found with name 'encryptablePropertyResolver'. Initializing Default Property Resolver
2026-07-02T14:26:41.856+08:00 INFO 151212 --- [wangpan-drive-service] [ main] c.u.j.d.DefaultLazyPropertyDetector : Property Detector custom Bean not found with name 'encryptablePropertyDetector'. Initializing Default Property Detector
2026-07-02T14:26:42.108+08:00 INFO 151212 --- [wangpan-drive-service] [ main] o.s.b.w.embedded.tomcat.TomcatWebServer : Tomcat initialized with port 8082 (http)
2026-07-02T14:26:42.121+08:00 INFO 151212 --- [wangpan-drive-service] [ main] o.apache.catalina.core.StandardService : Starting service [Tomcat]
2026-07-02T14:26:42.122+08:00 INFO 151212 --- [wangpan-drive-service] [ main] o.apache.catalina.core.StandardEngine : Starting Servlet engine: [Apache Tomcat/10.1.20]
2026-07-02T14:26:42.179+08:00 INFO 151212 --- [wangpan-drive-service] [ main] o.a.c.c.C.[Tomcat].[localhost].[/] : Initializing Spring embedded WebApplicationContext
2026-07-02T14:26:42.180+08:00 INFO 151212 --- [wangpan-drive-service] [ main] w.s.c.ServletWebServerApplicationContext : Root WebApplicationContext: initialization completed in 1857 ms
2026-07-02T14:26:42.353+08:00 INFO 151212 --- [wangpan-drive-service] [ main] c.u.j.encryptor.DefaultLazyEncryptor : String Encryptor custom Bean not found with name 'jasyptStringEncryptor'. Initializing Default String Encryptor
2026-07-02T14:26:42.367+08:00 INFO 151212 --- [wangpan-drive-service] [ main] c.u.j.c.StringEncryptorBuilder : Encryptor config not found for property jasypt.encryptor.provider-name, using default value: null
2026-07-02T14:26:42.368+08:00 INFO 151212 --- [wangpan-drive-service] [ main] c.u.j.c.StringEncryptorBuilder : Encryptor config not found for property jasypt.encryptor.provider-class-name, using default value: null
? 数据库配置加载成功: jdbc:mysql://118.178.238.159:33306/wangpan?useUnicode=true&characterEncoding=utf-8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true
========================================
[DatabaseInitializer] 网盘服务数据库初始化...
========================================
? 表已存在wp_drive
? 表已存在wp_drive_folder
? 表已存在wp_drive_movie
? 表已存在wp_icon
? 表已存在wp_user_openlist
? [wp_drive] logo_url 字段已存在
? [wp_drive] 表创建完成
? [wp_drive_folder] 表创建完成
? [wp_drive_movie] 表创建完成
? 数据库初始化检查完成!
========================================
? 数据库初始化成功耗时743ms
========================================
_ _ |_ _ _|_. ___ _ | _
| | |\/|_)(_| | |_\ |_)||_|_\
/ |
3.5.7
2026-07-02T14:26:44.370+08:00 INFO 151212 --- [wangpan-drive-service] [ main] c.wangpan.listener.IconCleanupScheduler : [IconCleanupScheduler] 定时清理任务已启动 | 首次延迟=10分钟 | 间隔=24小时
2026-07-02T14:26:44.963+08:00 INFO 151212 --- [wangpan-drive-service] [ main] com.wangpan.common.util.MinioUtil : [MinioUtil] 存储桶已存在: wangpan
2026-07-02T14:26:44.964+08:00 INFO 151212 --- [wangpan-drive-service] [ main] com.wangpan.common.util.MinioUtil : [MinioUtil] MinIO 客户端初始化成功: http://118.178.238.159:39000
2026-07-02T14:26:45.450+08:00 INFO 151212 --- [wangpan-drive-service] [ main] o.s.b.a.e.web.EndpointLinksResolver : Exposing 3 endpoint(s) beneath base path '/actuator'
2026-07-02T14:26:45.539+08:00 WARN 151212 --- [wangpan-drive-service] [ main] ConfigServletWebServerApplicationContext : Exception encountered during context initialization - cancelling refresh attempt: org.springframework.context.ApplicationContextException: Failed to start bean 'webServerStartStop'
2026-07-02T14:26:45.572+08:00 INFO 151212 --- [wangpan-drive-service] [ main] .s.b.a.l.ConditionEvaluationReportLogger :
Error starting ApplicationContext. To display the condition evaluation report re-run your application with 'debug' enabled.
2026-07-02T14:26:45.604+08:00 ERROR 151212 --- [wangpan-drive-service] [ main] o.s.b.d.LoggingFailureAnalysisReporter :
***************************
APPLICATION FAILED TO START
***************************
Description:
Web server failed to start. Port 8082 was already in use.
Action:
Identify and stop the process that's listening on port 8082 or configure this application to listen on another port.

2
encrypt.bat Normal file
View File

@ -0,0 +1,2 @@
@echo off
java -cp wangpan-common\target\wangpan-common-1.0-SNAPSHOT.jar;D:\maven_repository\org\jasypt\jasypt\1.9.3\jasypt-1.9.3.jar com.wangpan.common.util.EncryptUtil encrypt Djl1022!

5
encrypt.ps1 Normal file
View File

@ -0,0 +1,5 @@
$cp = "wangpan-common\target\wangpan-common-1.0-SNAPSHOT.jar;D:\maven_repository\org\jasypt\jasypt\1.9.3\jasypt-1.9.3.jar"
$env:JASYPT_ENCRYPTOR_PASSWORD = "wangpan-secret-key-2024"
java -cp $cp com.wangpan.common.util.EncryptUtil encrypt "http://ldfun.asia:13000"
Write-Host "---"
java -cp $cp com.wangpan.common.util.EncryptUtil encrypt "djl1022"

View File

@ -56,7 +56,7 @@ foreach ($service in $services) {
Write-Host " ERROR: Jar not found: $jarPath" -ForegroundColor Red Write-Host " ERROR: Jar not found: $jarPath" -ForegroundColor Red
continue continue
} }
$process = Start-Process -FilePath "java" -ArgumentList "-jar", $jarPath -WindowStyle Hidden -PassThru $process = Start-Process -FilePath "java" -ArgumentList "-DJASYPT_ENCRYPTOR_PASSWORD=$JASYPT_PASSWORD", "-jar", $jarPath -WindowStyle Hidden -PassThru
if ($process) { if ($process) {
Write-Host " Started $name (PID: $($process.Id))" -ForegroundColor Green Write-Host " Started $name (PID: $($process.Id))" -ForegroundColor Green
} else { } else {

View File

@ -0,0 +1,26 @@
package com.wangpan.common.config;
import com.baomidou.mybatisplus.annotation.DbType;
import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* MyBatis-Plus 配置
* 注册分页插件使 Page 分页查询生效
*/
@Configuration
public class MybatisPlusConfig {
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
// 注册分页插件指定数据库类型为 MySQL
PaginationInnerInterceptor paginationInterceptor = new PaginationInnerInterceptor(DbType.MYSQL);
// 单页最大条数限制防止误传超大 pageSize 导致 OOM
paginationInterceptor.setMaxLimit(500L);
interceptor.addInnerInterceptor(paginationInterceptor);
return interceptor;
}
}

View File

@ -39,6 +39,18 @@ public class DriveDO implements Serializable {
/** 网盘 Logo URLMinIO 存储链接) */ /** 网盘 Logo URLMinIO 存储链接) */
private String logoUrl; private String logoUrl;
/** Logo 最后访问时间(用于回收策略) */
private LocalDateTime logoLastAccessTime;
/** Logo 状态normal正常/recycling回收中/deleted已删除 */
private String logoStatus;
/** OpenList 存储ID关联 OpenList 中的存储配置 */
private String openlistStorageId;
/** OpenList 挂载路径:如 /movies */
private String openlistMountPath;
/** 文件夹数量 */ /** 文件夹数量 */
private Integer folderCount; private Integer folderCount;
@ -76,6 +88,18 @@ public class DriveDO implements Serializable {
public String getLogoUrl() { return logoUrl; } public String getLogoUrl() { return logoUrl; }
public void setLogoUrl(String logoUrl) { this.logoUrl = logoUrl; } public void setLogoUrl(String logoUrl) { this.logoUrl = logoUrl; }
public LocalDateTime getLogoLastAccessTime() { return logoLastAccessTime; }
public void setLogoLastAccessTime(LocalDateTime logoLastAccessTime) { this.logoLastAccessTime = logoLastAccessTime; }
public String getLogoStatus() { return logoStatus; }
public void setLogoStatus(String logoStatus) { this.logoStatus = logoStatus; }
public String getOpenlistStorageId() { return openlistStorageId; }
public void setOpenlistStorageId(String openlistStorageId) { this.openlistStorageId = openlistStorageId; }
public String getOpenlistMountPath() { return openlistMountPath; }
public void setOpenlistMountPath(String openlistMountPath) { this.openlistMountPath = openlistMountPath; }
public Integer getFolderCount() { return folderCount; } public Integer getFolderCount() { return folderCount; }
public void setFolderCount(Integer folderCount) { this.folderCount = folderCount; } public void setFolderCount(Integer folderCount) { this.folderCount = folderCount; }

View File

@ -95,6 +95,18 @@ public class DriveMovieDO implements Serializable {
/** 标签(逗号分隔) */ /** 标签(逗号分隔) */
private String tags; private String tags;
/** 主演逗号分隔TMDB刮削 */
private String casts;
/** 导演逗号分隔TMDB刮削 */
private String directors;
/** OpenList 文件路径:如 /movies/阿凡达.mp4 */
private String openlistFilePath;
/** 存储类型metadata仅元数据/ openlistOpenList 管理实际文件) */
private String storageType;
/** 更新时间 */ /** 更新时间 */
@TableField(update = "NOW()") @TableField(update = "NOW()")
private LocalDateTime updateTime; private LocalDateTime updateTime;
@ -177,6 +189,18 @@ public class DriveMovieDO implements Serializable {
public String getTags() { return tags; } public String getTags() { return tags; }
public void setTags(String tags) { this.tags = tags; } public void setTags(String tags) { this.tags = tags; }
public String getCasts() { return casts; }
public void setCasts(String casts) { this.casts = casts; }
public String getDirectors() { return directors; }
public void setDirectors(String directors) { this.directors = directors; }
public String getOpenlistFilePath() { return openlistFilePath; }
public void setOpenlistFilePath(String openlistFilePath) { this.openlistFilePath = openlistFilePath; }
public String getStorageType() { return storageType; }
public void setStorageType(String storageType) { this.storageType = storageType; }
public LocalDateTime getUpdateTime() { return updateTime; } public LocalDateTime getUpdateTime() { return updateTime; }
public void setUpdateTime(LocalDateTime updateTime) { this.updateTime = updateTime; } public void setUpdateTime(LocalDateTime updateTime) { this.updateTime = updateTime; }

View File

@ -68,6 +68,12 @@ public class MovieDO implements Serializable {
/** 标签(逗号分隔) */ /** 标签(逗号分隔) */
private String tags; private String tags;
/** 主演逗号分隔TMDB刮削 */
private String casts;
/** 导演逗号分隔TMDB刮削 */
private String directors;
/** 更新时间 */ /** 更新时间 */
@TableField(update = "NOW()") @TableField(update = "NOW()")
private LocalDateTime updateTime; private LocalDateTime updateTime;
@ -123,6 +129,12 @@ public class MovieDO implements Serializable {
public String getTags() { return tags; } public String getTags() { return tags; }
public void setTags(String tags) { this.tags = tags; } public void setTags(String tags) { this.tags = tags; }
public String getCasts() { return casts; }
public void setCasts(String casts) { this.casts = casts; }
public String getDirectors() { return directors; }
public void setDirectors(String directors) { this.directors = directors; }
public LocalDateTime getUpdateTime() { return updateTime; } public LocalDateTime getUpdateTime() { return updateTime; }
public void setUpdateTime(LocalDateTime updateTime) { this.updateTime = updateTime; } public void setUpdateTime(LocalDateTime updateTime) { this.updateTime = updateTime; }

View File

@ -0,0 +1,120 @@
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;
/**
* 用户 OpenList 连接配置数据对象Data Object
* 对应数据库表wp_user_openlist
*
* 设计说明
* - 每个用户只能绑定一个 OpenList 实例user_id 唯一索引
* - 敏感信息URL账号密码加密存储
* - Token 缓存到数据库避免每次调用 OpenList API 都重新登录
* - DO 字段名使用 Java 驼峰命名MyBatis-Plus 自动映射为数据库 snake_case
*/
@TableName(value = "wp_user_openlist", autoResultMap = true)
public class UserOpenlistDO implements Serializable {
private static final long serialVersionUID = 1L;
/** 主键ID */
@TableId(type = IdType.AUTO)
private Long id;
/** 用户ID关联 wp_user 表) */
private Long userId;
/** OpenList 服务地址(加密存储) */
private String openlistUrl;
/** OpenList 账号(加密存储) */
private String openlistUsername;
/** OpenList 密码(加密存储) */
private String openlistPassword;
/** OpenList 存储ID用户在 OpenList 后台配置的存储) */
private String storageId;
/** 挂载路径,如 /uc */
private String mountPath;
/** OpenList API Token缓存避免重复登录 */
private String token;
/** Token 过期时间戳(毫秒) */
private Long tokenExpireTime;
/** 状态active / disabled */
private String status;
/** 最后一次心跳检测时间 */
private LocalDateTime lastHeartbeatTime;
/** 心跳状态success / failed */
private String heartbeatStatus;
/** 心跳检测失败原因 */
private String heartbeatError;
/** 更新时间 */
@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 getOpenlistUrl() { return openlistUrl; }
public void setOpenlistUrl(String openlistUrl) { this.openlistUrl = openlistUrl; }
public String getOpenlistUsername() { return openlistUsername; }
public void setOpenlistUsername(String openlistUsername) { this.openlistUsername = openlistUsername; }
public String getOpenlistPassword() { return openlistPassword; }
public void setOpenlistPassword(String openlistPassword) { this.openlistPassword = openlistPassword; }
public String getStorageId() { return storageId; }
public void setStorageId(String storageId) { this.storageId = storageId; }
public String getMountPath() { return mountPath; }
public void setMountPath(String mountPath) { this.mountPath = mountPath; }
public String getToken() { return token; }
public void setToken(String token) { this.token = token; }
public Long getTokenExpireTime() { return tokenExpireTime; }
public void setTokenExpireTime(Long tokenExpireTime) { this.tokenExpireTime = tokenExpireTime; }
public String getStatus() { return status; }
public void setStatus(String status) { this.status = status; }
public LocalDateTime getLastHeartbeatTime() { return lastHeartbeatTime; }
public void setLastHeartbeatTime(LocalDateTime lastHeartbeatTime) { this.lastHeartbeatTime = lastHeartbeatTime; }
public String getHeartbeatStatus() { return heartbeatStatus; }
public void setHeartbeatStatus(String heartbeatStatus) { this.heartbeatStatus = heartbeatStatus; }
public String getHeartbeatError() { return heartbeatError; }
public void setHeartbeatError(String heartbeatError) { this.heartbeatError = heartbeatError; }
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; }
}

View File

@ -21,6 +21,8 @@ public interface DriveMapper extends BaseMapper<DriveDO> {
name VARCHAR(100) NOT NULL COMMENT '网盘名称', name VARCHAR(100) NOT NULL COMMENT '网盘名称',
description VARCHAR(500) DEFAULT NULL COMMENT '网盘描述', description VARCHAR(500) DEFAULT NULL COMMENT '网盘描述',
logo_url TEXT DEFAULT NULL COMMENT '网盘Logo URLMinIO存储链接', logo_url TEXT DEFAULT NULL COMMENT '网盘Logo URLMinIO存储链接',
logo_last_access_time DATETIME DEFAULT NULL COMMENT 'Logo最后访问时间用于回收策略',
logo_status VARCHAR(20) NOT NULL DEFAULT 'normal' COMMENT 'Logo状态normal正常/recycling回收中/deleted已删除',
folder_count INT NOT NULL DEFAULT 0 COMMENT '文件夹数量', folder_count INT NOT NULL DEFAULT 0 COMMENT '文件夹数量',
file_count INT NOT NULL DEFAULT 0 COMMENT '文件数量', file_count INT NOT NULL DEFAULT 0 COMMENT '文件数量',
total_size BIGINT NOT NULL DEFAULT 0 COMMENT '总大小字节', total_size BIGINT NOT NULL DEFAULT 0 COMMENT '总大小字节',
@ -28,7 +30,8 @@ public interface DriveMapper extends BaseMapper<DriveDO> {
update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
INDEX idx_user_id (user_id), INDEX idx_user_id (user_id),
INDEX idx_status (status) INDEX idx_status (status),
INDEX idx_logo_status (logo_status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='网盘表' ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='网盘表'
"""; """;
} }

View File

@ -4,6 +4,7 @@ import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.wangpan.common.dal.dataobject.DriveMovieDO; import com.wangpan.common.dal.dataobject.DriveMovieDO;
import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param; import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Update;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
@ -44,6 +45,8 @@ public interface DriveMovieMapper extends BaseMapper<DriveMovieDO> {
poster_url TEXT DEFAULT NULL COMMENT '海报URL竖版加密存储', poster_url TEXT DEFAULT NULL COMMENT '海报URL竖版加密存储',
backdrop_url TEXT DEFAULT NULL COMMENT '背景图URL横版加密存储', backdrop_url TEXT DEFAULT NULL COMMENT '背景图URL横版加密存储',
tags VARCHAR(500) DEFAULT NULL COMMENT '标签逗号分隔', tags VARCHAR(500) DEFAULT NULL COMMENT '标签逗号分隔',
casts VARCHAR(500) DEFAULT NULL COMMENT '主演逗号分隔TMDB刮削',
directors VARCHAR(200) DEFAULT NULL COMMENT '导演逗号分隔TMDB刮削',
update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
INDEX idx_drive_id (drive_id), INDEX idx_drive_id (drive_id),
@ -60,4 +63,16 @@ public interface DriveMovieMapper extends BaseMapper<DriveMovieDO> {
* 复杂SQL在XML中实现 * 复杂SQL在XML中实现
*/ */
List<Map<String, Object>> findDuplicateMovies(@Param("userId") Long userId); List<Map<String, Object>> findDuplicateMovies(@Param("userId") Long userId);
/**
* 更新电影主演
*/
@Update("UPDATE wp_drive_movie SET casts = #{casts}, update_time = NOW() WHERE id = #{id}")
int updateCasts(@Param("id") Long id, @Param("casts") String casts);
/**
* 更新电影导演
*/
@Update("UPDATE wp_drive_movie SET directors = #{directors}, update_time = NOW() WHERE id = #{id}")
int updateDirectors(@Param("id") Long id, @Param("directors") String directors);
} }

View File

@ -71,4 +71,22 @@ public interface MovieMapper extends BaseMapper<MovieDO> {
*/ */
@Update("UPDATE wp_drive_movie SET scrape_status = #{scrapeStatus}, scrape_error = #{scrapeError}, update_time = NOW() WHERE id = #{id}") @Update("UPDATE wp_drive_movie SET scrape_status = #{scrapeStatus}, scrape_error = #{scrapeError}, update_time = NOW() WHERE id = #{id}")
int updateScrapeStatus(@Param("id") Long id, @Param("scrapeStatus") String scrapeStatus, @Param("scrapeError") String scrapeError); int updateScrapeStatus(@Param("id") Long id, @Param("scrapeStatus") String scrapeStatus, @Param("scrapeError") String scrapeError);
/**
* 更新电影简介description 字段
*/
@Update("UPDATE wp_drive_movie SET description = #{description}, update_time = NOW() WHERE id = #{id}")
int updateDescription(@Param("id") Long id, @Param("description") String description);
/**
* 更新电影主演
*/
@Update("UPDATE wp_drive_movie SET casts = #{casts}, update_time = NOW() WHERE id = #{id}")
int updateCasts(@Param("id") Long id, @Param("casts") String casts);
/**
* 更新电影导演
*/
@Update("UPDATE wp_drive_movie SET directors = #{directors}, update_time = NOW() WHERE id = #{id}")
int updateDirectors(@Param("id") Long id, @Param("directors") String directors);
} }

View File

@ -0,0 +1,38 @@
package com.wangpan.common.dal.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.wangpan.common.dal.dataobject.UserOpenlistDO;
import org.apache.ibatis.annotations.Mapper;
/**
* 用户 OpenList 连接配置 Mapper 接口
*
* 继承 MyBatis-Plus BaseMapper自动获得 CRUD 能力
* 每个用户只能绑定一个 OpenList 实例user_id 唯一索引
*/
@Mapper
public interface UserOpenlistMapper extends BaseMapper<UserOpenlistDO> {
/** 建表SQL保留供 DatabaseInitializer 使用) */
String CREATE_TABLE_SQL = """
CREATE TABLE IF NOT EXISTS wp_user_openlist (
id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID',
user_id BIGINT NOT NULL COMMENT '用户ID关联 wp_user ',
openlist_url VARCHAR(200) NOT NULL COMMENT 'OpenList 服务地址加密存储',
openlist_username VARCHAR(100) NOT NULL COMMENT 'OpenList 账号加密存储',
openlist_password VARCHAR(200) NOT NULL COMMENT 'OpenList 密码加密存储',
storage_id VARCHAR(50) DEFAULT NULL COMMENT 'OpenList 存储ID用户在 OpenList 后台配置的存储',
mount_path VARCHAR(200) DEFAULT NULL COMMENT '挂载路径 /uc',
token VARCHAR(500) DEFAULT NULL COMMENT 'OpenList API Token缓存避免重复登录',
token_expire_time BIGINT DEFAULT NULL COMMENT 'Token 过期时间戳毫秒',
status VARCHAR(20) NOT NULL DEFAULT 'active' COMMENT '状态active/disabled',
last_heartbeat_time DATETIME DEFAULT NULL COMMENT '最后一次心跳检测时间',
heartbeat_status VARCHAR(20) DEFAULT NULL COMMENT '心跳状态success/failed',
heartbeat_error VARCHAR(500) DEFAULT NULL COMMENT '心跳检测失败原因',
create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
UNIQUE KEY uk_user_id (user_id),
INDEX idx_status (status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户 OpenList 连接配置表'
""";
}

View File

@ -204,27 +204,200 @@ public class DriveMovieRepository extends ServiceImpl<DriveMovieMapper, DriveMov
return list; return list;
} }
/**
* 查询用户所有网盘下所有未删除的电影分页支持关键词搜索和排序
* 适用于大数据量场景1万+使用索引优化查询
*
* @param userId 用户ID
* @param keyword 关键词可选搜索标题
* @param sortBy 排序字段可选createTime/updateTime/rating/title
* @param sortOrder 排序方向可选asc/desc
* @param pageNo 页码
* @param pageSize 每页大小
* @return 分页结果包含总数总页数当前页数据
*/
public Map<String, Object> queryAllMoviesByUser(Long userId, String keyword, String sortBy, String sortOrder,
int pageNo, int pageSize) {
var page = new Page<DriveMovieDO>(pageNo, pageSize);
var wrapper = new LambdaQueryWrapper<DriveMovieDO>()
.eq(DriveMovieDO::getUserId, userId)
.eq(DriveMovieDO::getStatus, "active");
// 关键词搜索标题模糊匹配
if (keyword != null && !keyword.isBlank()) {
wrapper.like(DriveMovieDO::getTitle, keyword);
}
// 排序字段
if ("rating".equals(sortBy)) {
wrapper.orderBy(true, "asc".equalsIgnoreCase(sortOrder), DriveMovieDO::getRating);
} else if ("title".equals(sortBy)) {
wrapper.orderBy(true, "asc".equalsIgnoreCase(sortOrder), DriveMovieDO::getTitle);
} else if ("updateTime".equals(sortBy)) {
wrapper.orderBy(true, "asc".equalsIgnoreCase(sortOrder), DriveMovieDO::getUpdateTime);
} else {
// 默认按创建时间倒序
wrapper.orderByDesc(DriveMovieDO::getCreateTime);
}
var result = page(page, wrapper);
var list = new ArrayList<Map<String, Object>>();
for (var movieDO : result.getRecords()) {
list.add(toMap(movieDO));
}
// 构建分页信息
var pageInfo = new HashMap<String, Object>();
pageInfo.put("total", result.getTotal());
pageInfo.put("pages", result.getPages());
pageInfo.put("pageNo", result.getCurrent());
pageInfo.put("pageSize", result.getSize());
pageInfo.put("list", list);
return pageInfo;
}
/**
* 随机查询指定数量的电影用于每日推荐
* 优化方案 COUNT 总数再随机计算偏移量避免 ORDER BY RAND() 全表扫描
*
* @param userId 用户ID
* @param limit 查询数量
* @return 电影列表
*/
public List<DriveMovieDO> queryRandomMovies(Long userId, int limit) {
// 1. 统计符合条件的电影总数
var countWrapper = new LambdaQueryWrapper<DriveMovieDO>()
.eq(DriveMovieDO::getUserId, userId)
.eq(DriveMovieDO::getStatus, "active");
long total = count(countWrapper);
if (total == 0) {
return Collections.emptyList();
}
// 2. 随机计算偏移量确保不越界
int offset = 0;
if (total > limit) {
offset = new Random().nextInt((int) (total - limit));
}
// 3. 使用 LIMIT offset, limit 查询避免全表排序
var wrapper = new LambdaQueryWrapper<DriveMovieDO>()
.eq(DriveMovieDO::getUserId, userId)
.eq(DriveMovieDO::getStatus, "active")
.last(String.format("LIMIT %d, %d", offset, limit));
return list(wrapper);
}
/**
* 查询用户所有网盘下所有未删除的电影排除指定ID分页
* 用于每日推荐场景先随机20部再查询剩余电影
*
* @param userId 用户ID
* @param excludeIds 排除的电影ID列表
* @param keyword 关键词可选
* @param sortBy 排序字段可选
* @param sortOrder 排序方向可选
* @param pageNo 页码
* @param pageSize 每页大小
* @return 分页结果
*/
public Map<String, Object> queryAllMoviesExcludeIds(Long userId, List<Long> excludeIds, String keyword,
String sortBy, String sortOrder, int pageNo, int pageSize) {
var page = new Page<DriveMovieDO>(pageNo, pageSize);
var wrapper = new LambdaQueryWrapper<DriveMovieDO>()
.eq(DriveMovieDO::getUserId, userId)
.eq(DriveMovieDO::getStatus, "active");
// 排除指定ID
if (excludeIds != null && !excludeIds.isEmpty()) {
wrapper.notIn(DriveMovieDO::getId, excludeIds);
}
// 关键词搜索
if (keyword != null && !keyword.isBlank()) {
wrapper.like(DriveMovieDO::getTitle, keyword);
}
// 排序字段
if ("rating".equals(sortBy)) {
wrapper.orderBy(true, "asc".equalsIgnoreCase(sortOrder), DriveMovieDO::getRating);
} else if ("title".equals(sortBy)) {
wrapper.orderBy(true, "asc".equalsIgnoreCase(sortOrder), DriveMovieDO::getTitle);
} else if ("updateTime".equals(sortBy)) {
wrapper.orderBy(true, "asc".equalsIgnoreCase(sortOrder), DriveMovieDO::getUpdateTime);
} else {
wrapper.orderByDesc(DriveMovieDO::getCreateTime);
}
var result = page(page, wrapper);
var list = new ArrayList<Map<String, Object>>();
for (var movieDO : result.getRecords()) {
list.add(toMap(movieDO));
}
var pageInfo = new HashMap<String, Object>();
pageInfo.put("total", result.getTotal());
pageInfo.put("pages", result.getPages());
pageInfo.put("pageNo", result.getCurrent());
pageInfo.put("pageSize", result.getSize());
pageInfo.put("list", list);
return pageInfo;
}
// ==================== 更新 ==================== // ==================== 更新 ====================
/** /**
* 更新电影基本信息 * 更新电影基本信息支持所有可更新字段所有字段可选
* 使用 MyBatis-Plus LambdaUpdateWrapper 构建更新条件 * 使用 MyBatis-Plus LambdaUpdateWrapper 构建更新条件
* 注意casts directors 字段通过单独的 updateCasts/updateDirectors 方法更新
*
* @param id 电影ID
* @param title 电影标题可选
* @param description 描述可选
* @param rating 评分可选
* @param tags 标签可选
* @param posterUrl 海报URL可选
* @param backdropUrl 背景图URL可选
* @param resourceLibrary 资源库来源可选
* @param maintainer 维护者可选
*/ */
public boolean update(Long id, String title, String originalName, String subtitle, String description, public boolean update(Long id, String title, String description,
Integer rating, String tags, String category) { Integer rating, String tags, String posterUrl, String backdropUrl,
String resourceLibrary, String maintainer) {
var wrapper = new LambdaUpdateWrapper<DriveMovieDO>() var wrapper = new LambdaUpdateWrapper<DriveMovieDO>()
.eq(DriveMovieDO::getId, id) .eq(DriveMovieDO::getId, id)
// 基础信息字段
.set(title != null, DriveMovieDO::getTitle, title) .set(title != null, DriveMovieDO::getTitle, title)
.set(originalName != null, DriveMovieDO::getOriginalName, originalName)
.set(subtitle != null, DriveMovieDO::getSubtitle, subtitle)
.set(description != null, DriveMovieDO::getDescription, description) .set(description != null, DriveMovieDO::getDescription, description)
.set(rating != null, DriveMovieDO::getRating, rating) .set(rating != null, DriveMovieDO::getRating, rating)
.set(tags != null, DriveMovieDO::getTags, tags) .set(tags != null, DriveMovieDO::getTags, tags)
.set(category != null, DriveMovieDO::getCategory, category) // TMDB 刮削相关字段用户可手动补充或修正
.set(posterUrl != null, DriveMovieDO::getPosterUrl, posterUrl)
.set(backdropUrl != null, DriveMovieDO::getBackdropUrl, backdropUrl)
.set(resourceLibrary != null, DriveMovieDO::getResourceLibrary, resourceLibrary)
.set(maintainer != null, DriveMovieDO::getMaintainer, maintainer)
// 更新时间
.set(DriveMovieDO::getUpdateTime, LocalDateTime.now()); .set(DriveMovieDO::getUpdateTime, LocalDateTime.now());
return update(wrapper); return update(wrapper);
} }
/**
* 更新电影主演
*/
public int updateCasts(Long id, String casts) {
return baseMapper.updateCasts(id, casts);
}
/**
* 更新电影导演
*/
public int updateDirectors(Long id, String directors) {
return baseMapper.updateDirectors(id, directors);
}
/** /**
* 更新电影刮削信息 * 更新电影刮削信息
* 使用 MyBatis-Plus LambdaUpdateWrapper 构建更新条件 * 使用 MyBatis-Plus LambdaUpdateWrapper 构建更新条件
@ -319,6 +492,10 @@ public class DriveMovieRepository extends ServiceImpl<DriveMovieMapper, DriveMov
movieDO.setPosterUrl(movie.getPosterUrl()); movieDO.setPosterUrl(movie.getPosterUrl());
movieDO.setBackdropUrl(movie.getBackdropUrl()); movieDO.setBackdropUrl(movie.getBackdropUrl());
movieDO.setTags(movie.getTags()); movieDO.setTags(movie.getTags());
movieDO.setCasts(movie.getCasts());
movieDO.setDirectors(movie.getDirectors());
movieDO.setOpenlistFilePath(movie.getOpenlistFilePath());
movieDO.setStorageType(movie.getStorageType());
movieDO.setUpdateTime(movie.getUpdateTime()); movieDO.setUpdateTime(movie.getUpdateTime());
return movieDO; return movieDO;
} }
@ -352,6 +529,10 @@ public class DriveMovieRepository extends ServiceImpl<DriveMovieMapper, DriveMov
movie.setPosterUrl(movieDO.getPosterUrl()); movie.setPosterUrl(movieDO.getPosterUrl());
movie.setBackdropUrl(movieDO.getBackdropUrl()); movie.setBackdropUrl(movieDO.getBackdropUrl());
movie.setTags(movieDO.getTags()); movie.setTags(movieDO.getTags());
movie.setCasts(movieDO.getCasts());
movie.setDirectors(movieDO.getDirectors());
movie.setOpenlistFilePath(movieDO.getOpenlistFilePath());
movie.setStorageType(movieDO.getStorageType());
movie.setUpdateTime(movieDO.getUpdateTime()); movie.setUpdateTime(movieDO.getUpdateTime());
movie.setCreateTime(movieDO.getCreateTime()); movie.setCreateTime(movieDO.getCreateTime());
return movie; return movie;
@ -366,13 +547,11 @@ public class DriveMovieRepository extends ServiceImpl<DriveMovieMapper, DriveMov
map.put("driveId", movieDO.getDriveId()); map.put("driveId", movieDO.getDriveId());
map.put("folderId", movieDO.getFolderId()); map.put("folderId", movieDO.getFolderId());
map.put("title", movieDO.getTitle()); map.put("title", movieDO.getTitle());
map.put("subtitle", movieDO.getSubtitle());
map.put("originalName", movieDO.getOriginalName()); map.put("originalName", movieDO.getOriginalName());
map.put("mimeType", movieDO.getMimeType()); map.put("mimeType", movieDO.getMimeType());
map.put("fileSize", movieDO.getFileSize()); map.put("fileSize", movieDO.getFileSize());
map.put("extension", movieDO.getExtension()); map.put("extension", movieDO.getExtension());
map.put("description", movieDO.getDescription()); map.put("description", movieDO.getDescription());
map.put("resourceLibrary", movieDO.getResourceLibrary());
map.put("maintainer", movieDO.getMaintainer()); map.put("maintainer", movieDO.getMaintainer());
map.put("status", movieDO.getStatus()); map.put("status", movieDO.getStatus());
map.put("category", movieDO.getCategory()); map.put("category", movieDO.getCategory());
@ -381,9 +560,13 @@ public class DriveMovieRepository extends ServiceImpl<DriveMovieMapper, DriveMov
map.put("scrapeError", movieDO.getScrapeError()); map.put("scrapeError", movieDO.getScrapeError());
map.put("rating", movieDO.getRating()); map.put("rating", movieDO.getRating());
map.put("tags", movieDO.getTags()); map.put("tags", movieDO.getTags());
map.put("casts", movieDO.getCasts());
map.put("directors", movieDO.getDirectors());
map.put("posterUrl", movieDO.getPosterUrl()); map.put("posterUrl", movieDO.getPosterUrl());
map.put("backdropUrl", movieDO.getBackdropUrl()); map.put("backdropUrl", movieDO.getBackdropUrl());
map.put("minioUrl", movieDO.getMinioUrl()); map.put("minioUrl", movieDO.getMinioUrl());
map.put("openlistFilePath", movieDO.getOpenlistFilePath());
map.put("storageType", movieDO.getStorageType());
map.put("createTime", movieDO.getCreateTime() != null ? movieDO.getCreateTime().toString() : null); map.put("createTime", movieDO.getCreateTime() != null ? movieDO.getCreateTime().toString() : null);
map.put("updateTime", movieDO.getUpdateTime() != null ? movieDO.getUpdateTime().toString() : null); map.put("updateTime", movieDO.getUpdateTime() != null ? movieDO.getUpdateTime().toString() : null);
return map; return map;

View File

@ -90,6 +90,61 @@ public class DriveRepository extends ServiceImpl<DriveMapper, DriveDO> {
return update(wrapper); return update(wrapper);
} }
/**
* 更新网盘 Logo 最后访问时间访问时调用刷新回收策略计时
* 如果 Logo 处于回收模式recycling则恢复正常模式normal并重新计时
*/
public boolean updateLogoAccessTime(Long id) {
var wrapper = new LambdaUpdateWrapper<DriveDO>()
.eq(DriveDO::getId, id)
.isNotNull(DriveDO::getLogoUrl)
.set(DriveDO::getLogoLastAccessTime, LocalDateTime.now())
.set(DriveDO::getLogoStatus, "normal");
return update(wrapper);
}
/**
* 查询所有有 Logo 且状态为 normal 的网盘用于回收策略检查
*/
public List<DriveDO> findDrivesWithLogoNormal() {
var wrapper = new LambdaQueryWrapper<DriveDO>()
.isNotNull(DriveDO::getLogoUrl)
.eq(DriveDO::getLogoStatus, "normal");
return list(wrapper);
}
/**
* 查询所有处于回收模式recycling的网盘用于删除策略检查
*/
public List<DriveDO> findDrivesWithLogoRecycling() {
var wrapper = new LambdaQueryWrapper<DriveDO>()
.isNotNull(DriveDO::getLogoUrl)
.eq(DriveDO::getLogoStatus, "recycling");
return list(wrapper);
}
/**
* Logo 状态更新为回收模式recycling
*/
public boolean updateLogoStatusToRecycling(Long id) {
var wrapper = new LambdaUpdateWrapper<DriveDO>()
.eq(DriveDO::getId, id)
.set(DriveDO::getLogoStatus, "recycling");
return update(wrapper);
}
/**
* 删除 Logo清空 logo_url设置状态为 deleted
*/
public boolean deleteLogo(Long id) {
var wrapper = new LambdaUpdateWrapper<DriveDO>()
.eq(DriveDO::getId, id)
.set(DriveDO::getLogoUrl, null)
.set(DriveDO::getLogoStatus, "deleted")
.set(DriveDO::getLogoLastAccessTime, null);
return update(wrapper);
}
/** /**
* 更新网盘统计信息 * 更新网盘统计信息
* 使用 MyBatis-Plus LambdaUpdateWrapper 构建更新条件 * 使用 MyBatis-Plus LambdaUpdateWrapper 构建更新条件
@ -143,6 +198,10 @@ public class DriveRepository extends ServiceImpl<DriveMapper, DriveDO> {
driveDO.setName(drive.getName()); driveDO.setName(drive.getName());
driveDO.setDescription(drive.getDescription()); driveDO.setDescription(drive.getDescription());
driveDO.setLogoUrl(drive.getLogoUrl()); driveDO.setLogoUrl(drive.getLogoUrl());
driveDO.setLogoLastAccessTime(drive.getLogoLastAccessTime());
driveDO.setLogoStatus(drive.getLogoStatus());
driveDO.setOpenlistStorageId(drive.getOpenlistStorageId());
driveDO.setOpenlistMountPath(drive.getOpenlistMountPath());
driveDO.setFolderCount(drive.getFolderCount()); driveDO.setFolderCount(drive.getFolderCount());
driveDO.setFileCount(drive.getFileCount()); driveDO.setFileCount(drive.getFileCount());
driveDO.setTotalSize(drive.getTotalSize()); driveDO.setTotalSize(drive.getTotalSize());
@ -162,6 +221,8 @@ public class DriveRepository extends ServiceImpl<DriveMapper, DriveDO> {
drive.setName(driveDO.getName()); drive.setName(driveDO.getName());
drive.setDescription(driveDO.getDescription()); drive.setDescription(driveDO.getDescription());
drive.setLogoUrl(driveDO.getLogoUrl()); drive.setLogoUrl(driveDO.getLogoUrl());
drive.setOpenlistStorageId(driveDO.getOpenlistStorageId());
drive.setOpenlistMountPath(driveDO.getOpenlistMountPath());
drive.setFolderCount(driveDO.getFolderCount()); drive.setFolderCount(driveDO.getFolderCount());
drive.setFileCount(driveDO.getFileCount()); drive.setFileCount(driveDO.getFileCount());
drive.setTotalSize(driveDO.getTotalSize()); drive.setTotalSize(driveDO.getTotalSize());
@ -181,6 +242,10 @@ public class DriveRepository extends ServiceImpl<DriveMapper, DriveDO> {
map.put("name", driveDO.getName()); map.put("name", driveDO.getName());
map.put("description", driveDO.getDescription()); map.put("description", driveDO.getDescription());
map.put("logoUrl", driveDO.getLogoUrl()); map.put("logoUrl", driveDO.getLogoUrl());
map.put("logoLastAccessTime", driveDO.getLogoLastAccessTime() != null ? driveDO.getLogoLastAccessTime().toString() : null);
map.put("logoStatus", driveDO.getLogoStatus());
map.put("openlistStorageId", driveDO.getOpenlistStorageId());
map.put("openlistMountPath", driveDO.getOpenlistMountPath());
map.put("folderCount", driveDO.getFolderCount()); map.put("folderCount", driveDO.getFolderCount());
map.put("fileCount", driveDO.getFileCount()); map.put("fileCount", driveDO.getFileCount());
map.put("totalSize", driveDO.getTotalSize()); map.put("totalSize", driveDO.getTotalSize());

View File

@ -72,16 +72,19 @@ public class MovieRepository extends ServiceImpl<MovieMapper, MovieDO> {
/** /**
* 根据电影ID列表批量查询电影用于每日推荐缓存回查 * 根据电影ID列表批量查询电影用于每日推荐缓存回查
* 自动过滤已被逻辑删除status = deleted的电影
* *
* @param movieIds 电影ID列表 * @param movieIds 电影ID列表
* @return 电影Map列表保持传入ID的顺序 * @return 电影Map列表保持传入ID的顺序不含已删除的电影
*/ */
public List<Map<String, Object>> findByIds(List<Long> movieIds) { public List<Map<String, Object>> findByIds(List<Long> movieIds) {
if (movieIds == null || movieIds.isEmpty()) { if (movieIds == null || movieIds.isEmpty()) {
return new ArrayList<>(); return new ArrayList<>();
} }
// 查询时排除已被逻辑删除的电影
var wrapper = new LambdaQueryWrapper<MovieDO>() var wrapper = new LambdaQueryWrapper<MovieDO>()
.in(MovieDO::getId, movieIds); .in(MovieDO::getId, movieIds)
.ne(MovieDO::getStatus, "deleted");
var movieDOs = list(wrapper); var movieDOs = list(wrapper);
// 按传入的ID顺序排序 // 按传入的ID顺序排序
var movieMap = movieDOs.stream().collect(Collectors.toMap(MovieDO::getId, m -> m)); var movieMap = movieDOs.stream().collect(Collectors.toMap(MovieDO::getId, m -> m));
@ -227,14 +230,17 @@ public class MovieRepository extends ServiceImpl<MovieMapper, MovieDO> {
/** /**
* 随机查询指定数量的电影用于今日推荐 * 随机查询指定数量的电影用于今日推荐
* 使用 MySQL RAND() 函数实现随机排序 * 使用 MySQL RAND() 函数实现随机排序
* 自动过滤已被逻辑删除status = deleted的电影
* *
* @param userId 用户ID * @param userId 用户ID
* @param limit 返回数量默认3 * @param limit 返回数量默认3
* @return 随机电影列表 * @return 随机电影列表不含已删除的电影
*/ */
public List<Map<String, Object>> findRandomMovies(Long userId, int limit) { public List<Map<String, Object>> findRandomMovies(Long userId, int limit) {
// 查询时排除已被逻辑删除的电影
var wrapper = new LambdaQueryWrapper<MovieDO>() var wrapper = new LambdaQueryWrapper<MovieDO>()
.eq(MovieDO::getUserId, userId) .eq(MovieDO::getUserId, userId)
.ne(MovieDO::getStatus, "deleted")
.last("ORDER BY RAND() LIMIT " + limit); .last("ORDER BY RAND() LIMIT " + limit);
var movieDOs = list(wrapper); var movieDOs = list(wrapper);
@ -308,6 +314,27 @@ public class MovieRepository extends ServiceImpl<MovieMapper, MovieDO> {
return baseMapper.updateScrapeStatus(id, scrapeStatus, scrapeError); return baseMapper.updateScrapeStatus(id, scrapeStatus, scrapeError);
} }
/**
* 更新电影简介description 字段
*/
public int updateDescription(Long id, String description) {
return baseMapper.updateDescription(id, description);
}
/**
* 更新电影主演
*/
public int updateCasts(Long id, String casts) {
return baseMapper.updateCasts(id, casts);
}
/**
* 更新电影导演
*/
public int updateDirectors(Long id, String directors) {
return baseMapper.updateDirectors(id, directors);
}
// ==================== Entity DO 转换 ==================== // ==================== Entity DO 转换 ====================
/** /**
@ -330,8 +357,9 @@ public class MovieRepository extends ServiceImpl<MovieMapper, MovieDO> {
movieDO.setPosterUrl(movie.getPosterUrl()); movieDO.setPosterUrl(movie.getPosterUrl());
movieDO.setBackdropUrl(movie.getBackdropUrl()); movieDO.setBackdropUrl(movie.getBackdropUrl());
movieDO.setTags(movie.getTags()); movieDO.setTags(movie.getTags());
movieDO.setCasts(movie.getCasts());
movieDO.setDirectors(movie.getDirectors());
movieDO.setUpdateTime(movie.getUpdateTime()); movieDO.setUpdateTime(movie.getUpdateTime());
movieDO.setCreateTime(movie.getCreateTime());
return movieDO; return movieDO;
} }
@ -355,8 +383,9 @@ public class MovieRepository extends ServiceImpl<MovieMapper, MovieDO> {
movie.setPosterUrl(movieDO.getPosterUrl()); movie.setPosterUrl(movieDO.getPosterUrl());
movie.setBackdropUrl(movieDO.getBackdropUrl()); movie.setBackdropUrl(movieDO.getBackdropUrl());
movie.setTags(movieDO.getTags()); movie.setTags(movieDO.getTags());
movie.setCasts(movieDO.getCasts());
movie.setDirectors(movieDO.getDirectors());
movie.setUpdateTime(movieDO.getUpdateTime()); movie.setUpdateTime(movieDO.getUpdateTime());
movie.setCreateTime(movieDO.getCreateTime());
return movie; return movie;
} }
@ -368,8 +397,6 @@ public class MovieRepository extends ServiceImpl<MovieMapper, MovieDO> {
map.put("id", movieDO.getId()); map.put("id", movieDO.getId());
map.put("userId", movieDO.getUserId()); map.put("userId", movieDO.getUserId());
map.put("title", movieDO.getTitle()); map.put("title", movieDO.getTitle());
map.put("subtitle", movieDO.getSubtitle());
map.put("resourceLibrary", movieDO.getResourceLibrary());
map.put("maintainer", movieDO.getMaintainer()); map.put("maintainer", movieDO.getMaintainer());
map.put("status", movieDO.getStatus()); map.put("status", movieDO.getStatus());
map.put("category", movieDO.getCategory()); map.put("category", movieDO.getCategory());
@ -380,6 +407,8 @@ public class MovieRepository extends ServiceImpl<MovieMapper, MovieDO> {
map.put("posterUrl", movieDO.getPosterUrl()); map.put("posterUrl", movieDO.getPosterUrl());
map.put("backdropUrl", movieDO.getBackdropUrl()); map.put("backdropUrl", movieDO.getBackdropUrl());
map.put("tags", movieDO.getTags()); map.put("tags", movieDO.getTags());
map.put("casts", movieDO.getCasts());
map.put("directors", movieDO.getDirectors());
map.put("updateTime", movieDO.getUpdateTime() != null ? movieDO.getUpdateTime().toString() : null); map.put("updateTime", movieDO.getUpdateTime() != null ? movieDO.getUpdateTime().toString() : null);
map.put("createTime", movieDO.getCreateTime() != null ? movieDO.getCreateTime().toString() : null); map.put("createTime", movieDO.getCreateTime() != null ? movieDO.getCreateTime().toString() : null);
return map; return map;

View File

@ -0,0 +1,275 @@
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.UserOpenlistDO;
import com.wangpan.common.dal.mapper.UserOpenlistMapper;
import com.wangpan.common.entity.UserOpenlist;
import com.wangpan.common.util.EncryptUtil;
import org.springframework.stereotype.Repository;
import java.time.LocalDateTime;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* 用户 OpenList 连接配置 Repository
*
* 继承 MyBatis-Plus ServiceImpl自动获得 CRUD 能力
* 职责
* 1. 对外提供业务维度的数据访问接口使用 Entity/Map
* 2. 处理敏感信息的加密/解密URL账号密码
* 3. 处理 Entity UserOpenlistDO 的转换
*/
@Repository
public class UserOpenlistRepository extends ServiceImpl<UserOpenlistMapper, UserOpenlistDO> {
// ==================== 查询 ====================
/**
* 根据用户ID查询 OpenList 连接配置返回 Entity
* 敏感信息自动解密
*/
public UserOpenlist findByUserId(Long userId) {
var wrapper = new LambdaQueryWrapper<UserOpenlistDO>()
.eq(UserOpenlistDO::getUserId, userId);
var doObj = getOne(wrapper);
return doObj != null ? toEntity(doObj) : null;
}
/**
* 查询所有活跃状态的 OpenList 配置用于定时心跳检测
* 敏感信息自动解密
*/
public List<UserOpenlist> findAllActive() {
var wrapper = new LambdaQueryWrapper<UserOpenlistDO>()
.eq(UserOpenlistDO::getStatus, "active");
var list = list(wrapper);
var result = new java.util.ArrayList<UserOpenlist>();
for (var doObj : list) {
result.add(toEntity(doObj));
}
return result;
}
/**
* 根据用户ID查询 OpenList 连接配置返回 Map
* 敏感信息不返回明文仅返回脱敏后的信息
*/
public Map<String, Object> findInfoByUserId(Long userId) {
var wrapper = new LambdaQueryWrapper<UserOpenlistDO>()
.eq(UserOpenlistDO::getUserId, userId);
var doObj = getOne(wrapper);
if (doObj == null) {
return null;
}
// 先解密敏感字段再脱敏显示
String decryptedUrl = EncryptUtil.decrypt(doObj.getOpenlistUrl());
String decryptedUsername = EncryptUtil.decrypt(doObj.getOpenlistUsername());
var map = new LinkedHashMap<String, Object>();
map.put("id", doObj.getId());
map.put("openlistUrl", maskUrl(decryptedUrl));
map.put("openlistUsername", maskString(decryptedUsername));
map.put("storageId", doObj.getStorageId());
map.put("mountPath", doObj.getMountPath());
map.put("status", doObj.getStatus());
map.put("tokenValid", doObj.getToken() != null && doObj.getTokenExpireTime() != null
&& System.currentTimeMillis() < doObj.getTokenExpireTime() - 3600000);
map.put("createTime", doObj.getCreateTime() != null ? doObj.getCreateTime().toString() : null);
map.put("updateTime", doObj.getUpdateTime() != null ? doObj.getUpdateTime().toString() : null);
return map;
}
// ==================== 插入/更新 ====================
/**
* 绑定或更新用户的 OpenList 连接配置
* 敏感信息自动加密后存储
*
* @param entity 用户 OpenList 配置明文
* @return 记录ID
*/
public Long saveOrUpdateConfig(UserOpenlist entity) {
var doObj = toDO(entity);
// 加密敏感信息
doObj.setOpenlistUrl(EncryptUtil.encrypt(entity.getOpenlistUrl()));
doObj.setOpenlistUsername(EncryptUtil.encrypt(entity.getOpenlistUsername()));
doObj.setOpenlistPassword(EncryptUtil.encrypt(entity.getOpenlistPassword()));
// 检查是否已存在
var existing = findByUserId(entity.getUserId());
if (existing != null) {
// 更新保留已有的 Token 缓存
doObj.setId(existing.getId());
doObj.setToken(existing.getToken());
doObj.setTokenExpireTime(existing.getTokenExpireTime());
updateById(doObj);
return doObj.getId();
} else {
// 新增
save(doObj);
return doObj.getId();
}
}
/**
* 更新 Token 缓存
*
* @param userId 用户ID
* @param token OpenList API Token
* @param expireTime Token 过期时间戳毫秒
*/
public void updateToken(Long userId, String token, Long expireTime) {
var wrapper = new LambdaUpdateWrapper<UserOpenlistDO>()
.eq(UserOpenlistDO::getUserId, userId)
.set(UserOpenlistDO::getToken, token)
.set(UserOpenlistDO::getTokenExpireTime, expireTime)
.set(UserOpenlistDO::getUpdateTime, LocalDateTime.now());
update(wrapper);
}
/**
* 更新存储配置storageIdmountPath
*
* @param userId 用户ID
* @param storageId 存储ID
* @param mountPath 挂载路径
*/
public void updateStorageConfig(Long userId, String storageId, String mountPath) {
var wrapper = new LambdaUpdateWrapper<UserOpenlistDO>()
.eq(UserOpenlistDO::getUserId, userId)
.set(UserOpenlistDO::getStorageId, storageId)
.set(UserOpenlistDO::getMountPath, mountPath)
.set(UserOpenlistDO::getUpdateTime, LocalDateTime.now());
update(wrapper);
}
/**
* 更新心跳检测结果
*
* @param userId 用户ID
* @param heartbeatStatus 心跳状态success / failed
* @param heartbeatError 失败原因成功时为 null
*/
public void updateHeartbeat(Long userId, String heartbeatStatus, String heartbeatError) {
var wrapper = new LambdaUpdateWrapper<UserOpenlistDO>()
.eq(UserOpenlistDO::getUserId, userId)
.set(UserOpenlistDO::getLastHeartbeatTime, LocalDateTime.now())
.set(UserOpenlistDO::getHeartbeatStatus, heartbeatStatus)
.set(UserOpenlistDO::getHeartbeatError, heartbeatError)
.set(UserOpenlistDO::getUpdateTime, LocalDateTime.now());
update(wrapper);
}
// ==================== 删除 ====================
/**
* 解绑用户的 OpenList 连接配置
*
* @param userId 用户ID
* @return 是否删除成功
*/
public boolean unbind(Long userId) {
var wrapper = new LambdaQueryWrapper<UserOpenlistDO>()
.eq(UserOpenlistDO::getUserId, userId);
return remove(wrapper);
}
// ==================== Entity DO 转换 ====================
/**
* DO Entity 转换
* 敏感信息自动解密
*/
private UserOpenlist toEntity(UserOpenlistDO doObj) {
var entity = new UserOpenlist();
entity.setId(doObj.getId());
entity.setUserId(doObj.getUserId());
// 解密敏感信息
entity.setOpenlistUrl(EncryptUtil.decrypt(doObj.getOpenlistUrl()));
entity.setOpenlistUsername(EncryptUtil.decrypt(doObj.getOpenlistUsername()));
entity.setOpenlistPassword(EncryptUtil.decrypt(doObj.getOpenlistPassword()));
entity.setStorageId(doObj.getStorageId());
entity.setMountPath(doObj.getMountPath());
entity.setToken(doObj.getToken());
entity.setTokenExpireTime(doObj.getTokenExpireTime());
entity.setStatus(doObj.getStatus());
entity.setLastHeartbeatTime(doObj.getLastHeartbeatTime());
entity.setHeartbeatStatus(doObj.getHeartbeatStatus());
entity.setHeartbeatError(doObj.getHeartbeatError());
entity.setCreateTime(doObj.getCreateTime());
entity.setUpdateTime(doObj.getUpdateTime());
return entity;
}
/**
* Entity DO 转换
* 注意敏感信息加密由 saveOrUpdateConfig 方法处理这里不加密
*/
private UserOpenlistDO toDO(UserOpenlist entity) {
var doObj = new UserOpenlistDO();
doObj.setId(entity.getId());
doObj.setUserId(entity.getUserId());
// 这里不加密 saveOrUpdateConfig 统一处理
doObj.setOpenlistUrl(entity.getOpenlistUrl());
doObj.setOpenlistUsername(entity.getOpenlistUsername());
doObj.setOpenlistPassword(entity.getOpenlistPassword());
doObj.setStorageId(entity.getStorageId());
doObj.setMountPath(entity.getMountPath());
doObj.setToken(entity.getToken());
doObj.setTokenExpireTime(entity.getTokenExpireTime());
doObj.setStatus(entity.getStatus());
doObj.setLastHeartbeatTime(entity.getLastHeartbeatTime());
doObj.setHeartbeatStatus(entity.getHeartbeatStatus());
doObj.setHeartbeatError(entity.getHeartbeatError());
doObj.setCreateTime(entity.getCreateTime());
doObj.setUpdateTime(entity.getUpdateTime());
return doObj;
}
/**
* URL 脱敏显示
* 例如http://ldfun.asia:13000 http://ld***:13000
*/
private String maskUrl(String url) {
if (url == null || url.isBlank()) {
return "";
}
try {
// 提取域名部分进行脱敏
int start = url.indexOf("://");
if (start < 0) return url;
String protocol = url.substring(0, start + 3);
String rest = url.substring(start + 3);
int colonIdx = rest.indexOf(':');
int slashIdx = rest.indexOf('/');
int endIdx = colonIdx > 0 ? colonIdx : (slashIdx > 0 ? slashIdx : rest.length());
String host = rest.substring(0, endIdx);
String suffix = rest.substring(endIdx);
if (host.length() > 3) {
return protocol + host.substring(0, 2) + "***" + suffix;
}
return protocol + "***" + suffix;
} catch (Exception e) {
return "***";
}
}
/**
* 字符串脱敏显示
* 保留前2位和后2位中间用 *** 替换
* 例如djl1022 dj***22
* 例如Djl1022! Dj***2!
*/
private String maskString(String str) {
if (str == null || str.isBlank()) {
return "";
}
if (str.length() <= 4) {
return str.substring(0, 1) + "***" + str.substring(str.length() - 1);
}
return str.substring(0, 2) + "***" + str.substring(str.length() - 2);
}
}

View File

@ -223,6 +223,15 @@ public class UserRepository extends ServiceImpl<UserMapper, UserDO> {
/** /**
* Entity DO 转换 * Entity DO 转换
*
* 时间字段兜底说明
* UserDO.createTime 标注了 @TableField(fill = FieldFill.INSERT)
* 期望由 MyBatis-Plus MetaObjectHandler 自动填充
* 但本项目未配置 MetaObjectHandler参考 IconRepository.toIconDO 的处理
* 若调用方未显式设置时间会导致 INSERT 语句带上 create_time = null
* wp_user.create_time NOT NULL会抛出
* SQLIntegrityConstraintViolationException: Column 'create_time' cannot be null
* 因此在此处统一兜底时间为 null 时取当前时间保证写入安全
*/ */
private UserDO toUserDO(User user) { private UserDO toUserDO(User user) {
var userDO = new UserDO(); var userDO = new UserDO();
@ -240,8 +249,10 @@ public class UserRepository extends ServiceImpl<UserMapper, UserDO> {
userDO.setLockUntil(user.getLockUntil()); userDO.setLockUntil(user.getLockUntil());
userDO.setFailCount(user.getFailCount()); userDO.setFailCount(user.getFailCount());
userDO.setTmdbApiKey(user.getTmdbApiKey()); userDO.setTmdbApiKey(user.getTmdbApiKey());
userDO.setCreateTime(user.getCreateTime()); // 时间字段兜底未设置时使用当前时间避免 NOT NULL 约束报错
userDO.setUpdateTime(user.getUpdateTime()); var now = LocalDateTime.now();
userDO.setCreateTime(user.getCreateTime() != null ? user.getCreateTime() : now);
userDO.setUpdateTime(user.getUpdateTime() != null ? user.getUpdateTime() : now);
userDO.setIsDeleted(user.getIsDeleted()); userDO.setIsDeleted(user.getIsDeleted());
userDO.setRemark(user.getRemark()); userDO.setRemark(user.getRemark());
return userDO; return userDO;

View File

@ -40,6 +40,12 @@ public class Drive implements Serializable {
/** 网盘 Logo URL存储在 MinIO 中的图片链接 */ /** 网盘 Logo URL存储在 MinIO 中的图片链接 */
private String logoUrl; private String logoUrl;
/** Logo 最后访问时间(用于回收策略) */
private LocalDateTime logoLastAccessTime;
/** Logo 状态normal正常/recycling回收中/deleted已删除 */
private String logoStatus;
// ==================== 统计属性 ==================== // ==================== 统计属性 ====================
/** 文件夹数量 */ /** 文件夹数量 */
@ -51,6 +57,14 @@ public class Drive implements Serializable {
/** 总大小(字节) */ /** 总大小(字节) */
private Long totalSize; private Long totalSize;
// ==================== OpenList 关联属性 ====================
/** OpenList 存储ID关联 OpenList 中的存储配置 */
private String openlistStorageId;
/** OpenList 挂载路径:如 /movies */
private String openlistMountPath;
// ==================== 状态属性 ==================== // ==================== 状态属性 ====================
/** /**
@ -108,6 +122,12 @@ public class Drive implements Serializable {
public String getLogoUrl() { return logoUrl; } public String getLogoUrl() { return logoUrl; }
public void setLogoUrl(String logoUrl) { this.logoUrl = logoUrl; } public void setLogoUrl(String logoUrl) { this.logoUrl = logoUrl; }
public LocalDateTime getLogoLastAccessTime() { return logoLastAccessTime; }
public void setLogoLastAccessTime(LocalDateTime logoLastAccessTime) { this.logoLastAccessTime = logoLastAccessTime; }
public String getLogoStatus() { return logoStatus; }
public void setLogoStatus(String logoStatus) { this.logoStatus = logoStatus; }
public Integer getFolderCount() { return folderCount; } public Integer getFolderCount() { return folderCount; }
public void setFolderCount(Integer folderCount) { this.folderCount = folderCount; } public void setFolderCount(Integer folderCount) { this.folderCount = folderCount; }
@ -126,6 +146,12 @@ public class Drive implements Serializable {
public LocalDateTime getCreateTime() { return createTime; } public LocalDateTime getCreateTime() { return createTime; }
public void setCreateTime(LocalDateTime createTime) { this.createTime = createTime; } public void setCreateTime(LocalDateTime createTime) { this.createTime = createTime; }
public String getOpenlistStorageId() { return openlistStorageId; }
public void setOpenlistStorageId(String openlistStorageId) { this.openlistStorageId = openlistStorageId; }
public String getOpenlistMountPath() { return openlistMountPath; }
public void setOpenlistMountPath(String openlistMountPath) { this.openlistMountPath = openlistMountPath; }
// ==================== Object方法重写 ==================== // ==================== Object方法重写 ====================
@Override @Override

View File

@ -121,6 +121,20 @@ public class DriveMovie implements Serializable {
/** 标签:逗号分隔,如 "科幻,IMAX,杜比视界" */ /** 标签:逗号分隔,如 "科幻,IMAX,杜比视界" */
private String tags; private String tags;
/** 主演逗号分隔TMDB刮削 */
private String casts;
/** 导演逗号分隔TMDB刮削 */
private String directors;
// ==================== OpenList 关联属性 ====================
/** OpenList 文件路径:如 /movies/阿凡达.mp4 */
private String openlistFilePath;
/** 存储类型metadata仅元数据/ openlistOpenList 管理实际文件) */
private String storageType;
// ==================== 时间戳属性 ==================== // ==================== 时间戳属性 ====================
/** 更新时间 */ /** 更新时间 */
@ -229,12 +243,24 @@ public class DriveMovie implements Serializable {
public String getTags() { return tags; } public String getTags() { return tags; }
public void setTags(String tags) { this.tags = tags; } public void setTags(String tags) { this.tags = tags; }
public String getCasts() { return casts; }
public void setCasts(String casts) { this.casts = casts; }
public String getDirectors() { return directors; }
public void setDirectors(String directors) { this.directors = directors; }
public LocalDateTime getUpdateTime() { return updateTime; } public LocalDateTime getUpdateTime() { return updateTime; }
public void setUpdateTime(LocalDateTime updateTime) { this.updateTime = updateTime; } public void setUpdateTime(LocalDateTime updateTime) { this.updateTime = updateTime; }
public LocalDateTime getCreateTime() { return createTime; } public LocalDateTime getCreateTime() { return createTime; }
public void setCreateTime(LocalDateTime createTime) { this.createTime = createTime; } public void setCreateTime(LocalDateTime createTime) { this.createTime = createTime; }
public String getOpenlistFilePath() { return openlistFilePath; }
public void setOpenlistFilePath(String openlistFilePath) { this.openlistFilePath = openlistFilePath; }
public String getStorageType() { return storageType; }
public void setStorageType(String storageType) { this.storageType = storageType; }
// ==================== Object方法重写 ==================== // ==================== Object方法重写 ====================
@Override @Override

View File

@ -93,6 +93,12 @@ public class Movie implements Serializable {
/** 标签:逗号分隔,如"科幻, IMAX, 杜比视界" */ /** 标签:逗号分隔,如"科幻, IMAX, 杜比视界" */
private String tags; private String tags;
/** 主演逗号分隔TMDB刮削 */
private String casts;
/** 导演逗号分隔TMDB刮削 */
private String directors;
// ==================== 时间戳属性 ==================== // ==================== 时间戳属性 ====================
/** 更新时间:资源最后更新时间 */ /** 更新时间:资源最后更新时间 */
@ -167,6 +173,12 @@ public class Movie implements Serializable {
public String getTags() { return tags; } public String getTags() { return tags; }
public void setTags(String tags) { this.tags = tags; } public void setTags(String tags) { this.tags = tags; }
public String getCasts() { return casts; }
public void setCasts(String casts) { this.casts = casts; }
public String getDirectors() { return directors; }
public void setDirectors(String directors) { this.directors = directors; }
public LocalDateTime getUpdateTime() { return updateTime; } public LocalDateTime getUpdateTime() { return updateTime; }
public void setUpdateTime(LocalDateTime updateTime) { this.updateTime = updateTime; } public void setUpdateTime(LocalDateTime updateTime) { this.updateTime = updateTime; }

View File

@ -0,0 +1,194 @@
package com.wangpan.common.entity;
import java.io.Serializable;
import java.time.LocalDateTime;
import java.util.Objects;
/**
* 用户 OpenList 连接配置实体类
* 对应数据库表wp_user_openlist
*
* 功能
* 1. 存储用户绑定的 OpenList 实例信息地址账号密码
* 2. 缓存 OpenList API Token避免每次调用都重新登录
* 3. 每个用户只能绑定一个 OpenList 实例user_id 唯一
*
* 设计说明
* - 敏感信息URL账号密码在数据库中加密存储
* - Token 有有效期过期后需要重新登录获取
* - 支持禁用/启用状态
*/
public class UserOpenlist implements Serializable {
/** 序列化版本号 */
private static final long serialVersionUID = 1L;
// ==================== 基础属性 ====================
/** 主键ID */
private Long id;
/** 用户ID关联 wp_user 表) */
private Long userId;
/** OpenList 服务地址(明文,用于调用 API */
private String openlistUrl;
/** OpenList 账号(明文,用于登录) */
private String openlistUsername;
/** OpenList 密码(明文,用于登录) */
private String openlistPassword;
// ==================== OpenList 配置属性 ====================
/** OpenList 存储ID用户在 OpenList 后台配置的存储) */
private String storageId;
/** 挂载路径,如 /uc */
private String mountPath;
// ==================== Token 缓存属性 ====================
/** OpenList API Token缓存 */
private String token;
/** Token 过期时间戳(毫秒) */
private Long tokenExpireTime;
// ==================== 状态属性 ====================
/** 状态active / disabled */
private String status;
/** 最后一次心跳检测时间 */
private LocalDateTime lastHeartbeatTime;
/** 心跳状态success / failed */
private String heartbeatStatus;
/** 心跳检测失败原因 */
private String heartbeatError;
// ==================== 时间戳属性 ====================
/** 创建时间 */
private LocalDateTime createTime;
/** 更新时间 */
private LocalDateTime updateTime;
// ==================== 构造方法 ====================
/** 无参构造方法 */
public UserOpenlist() {}
/**
* 基础构造方法
*
* @param userId 用户ID
* @param openlistUrl OpenList 服务地址
* @param openlistUsername OpenList 账号
* @param openlistPassword OpenList 密码
*/
public UserOpenlist(Long userId, String openlistUrl, String openlistUsername, String openlistPassword) {
this.userId = userId;
this.openlistUrl = openlistUrl;
this.openlistUsername = openlistUsername;
this.openlistPassword = openlistPassword;
this.status = "active";
this.createTime = LocalDateTime.now();
}
// ==================== 业务方法 ====================
/**
* 检查 Token 是否有效
* Token 有效期 24 小时提前 1 小时视为过期
*
* @return true 表示 Token 有效false 表示已过期或不存在
*/
public boolean isTokenValid() {
if (token == null || token.isBlank()) {
return false;
}
if (tokenExpireTime == null) {
return false;
}
// 提前 1 小时刷新
return System.currentTimeMillis() < tokenExpireTime - 3600000;
}
// ==================== 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 getOpenlistUrl() { return openlistUrl; }
public void setOpenlistUrl(String openlistUrl) { this.openlistUrl = openlistUrl; }
public String getOpenlistUsername() { return openlistUsername; }
public void setOpenlistUsername(String openlistUsername) { this.openlistUsername = openlistUsername; }
public String getOpenlistPassword() { return openlistPassword; }
public void setOpenlistPassword(String openlistPassword) { this.openlistPassword = openlistPassword; }
public String getStorageId() { return storageId; }
public void setStorageId(String storageId) { this.storageId = storageId; }
public String getMountPath() { return mountPath; }
public void setMountPath(String mountPath) { this.mountPath = mountPath; }
public String getToken() { return token; }
public void setToken(String token) { this.token = token; }
public Long getTokenExpireTime() { return tokenExpireTime; }
public void setTokenExpireTime(Long tokenExpireTime) { this.tokenExpireTime = tokenExpireTime; }
public String getStatus() { return status; }
public void setStatus(String status) { this.status = status; }
public LocalDateTime getLastHeartbeatTime() { return lastHeartbeatTime; }
public void setLastHeartbeatTime(LocalDateTime lastHeartbeatTime) { this.lastHeartbeatTime = lastHeartbeatTime; }
public String getHeartbeatStatus() { return heartbeatStatus; }
public void setHeartbeatStatus(String heartbeatStatus) { this.heartbeatStatus = heartbeatStatus; }
public String getHeartbeatError() { return heartbeatError; }
public void setHeartbeatError(String heartbeatError) { this.heartbeatError = heartbeatError; }
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;
UserOpenlist that = (UserOpenlist) o;
return Objects.equals(id, that.id);
}
@Override
public int hashCode() {
return Objects.hash(id);
}
@Override
public String toString() {
return "UserOpenlist{" +
"id=" + id +
", userId=" + userId +
", openlistUrl='" + openlistUrl + '\'' +
", status='" + status + '\'' +
", createTime=" + createTime +
'}';
}
}

View File

@ -33,20 +33,16 @@ public class EncryptUtil {
private static final String ENCRYPT_SUFFIX = ")"; private static final String ENCRYPT_SUFFIX = ")";
/** /**
* 默认加密算法 - PBEWithMD5AndDES * 默认加密算法 - Spring Jasypt 配置保持一致
* PBE = Password Based Encryption基于密码的加密 * 必须与 application.yml jasypt.encryptor.algorithm 相同
* MD5 + DES 是经典组合兼容性好
* 如需更高安全性可改为 PBEWITHSHA256AND256BITAES-CBC-BC
*/ */
private static final String DEFAULT_ALGORITHM = "PBEWithMD5AndDES"; private static final String DEFAULT_ALGORITHM = "PBEWITHHMACSHA512ANDAES_256";
/** /**
* 默认加密密钥仅用于开发环境 * 默认加密密钥 - Spring Jasypt 配置保持一致
* 生产环境必须通过环境变量覆盖此值 * 必须与 application.yml jasypt.encryptor.password 相同
* -Djasypt.encryptor.password=你的密钥
* 或设置环境变量JASYPT_ENCRYPTOR_PASSWORD=你的密钥
*/ */
private static final String DEFAULT_PASSWORD = "wangpan2024"; private static final String DEFAULT_PASSWORD = "wangpan-secret-key-2024";
// ==================== 单例实例 ==================== // ==================== 单例实例 ====================
@ -183,13 +179,14 @@ public class EncryptUtil {
private static StandardPBEStringEncryptor createEncryptor() { private static StandardPBEStringEncryptor createEncryptor() {
StandardPBEStringEncryptor newEncryptor = new StandardPBEStringEncryptor(); StandardPBEStringEncryptor newEncryptor = new StandardPBEStringEncryptor();
// 创建配置对象使用SimplePBEConfig实现类
SimplePBEConfig config = new SimplePBEConfig(); SimplePBEConfig config = new SimplePBEConfig();
config.setAlgorithm(DEFAULT_ALGORITHM); config.setAlgorithm(DEFAULT_ALGORITHM);
config.setPassword(getEncryptionPassword()); config.setPassword(getEncryptionPassword());
config.setKeyObtentionIterations(1000); // 密钥派生迭代次数 config.setKeyObtentionIterations(1000);
config.setIvGeneratorClassName("org.jasypt.iv.RandomIvGenerator");
newEncryptor.setConfig(config); newEncryptor.setConfig(config);
newEncryptor.setStringOutputType("base64");
return newEncryptor; return newEncryptor;
} }

View File

@ -51,8 +51,8 @@ public class MinioUtil {
@Value("${minio.bucketName}") @Value("${minio.bucketName}")
private String bucketName; private String bucketName;
/** 预签名 URL 有效期(7天 */ /** 预签名 URL 有效期(365天确保在资源生命周期内 URL 始终有效 */
private static final int PRESIGNED_URL_EXPIRY_DAYS = 7; private static final int PRESIGNED_URL_EXPIRY_DAYS = 365;
/** MinIO 客户端实例(单例模式) */ /** MinIO 客户端实例(单例模式) */
private static volatile MinioClient client; private static volatile MinioClient client;

View File

@ -0,0 +1,638 @@
package com.wangpan.common.util;
import com.wangpan.common.dal.repository.UserOpenlistRepository;
import com.wangpan.common.entity.UserOpenlist;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.*;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestTemplate;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* OpenList API 客户端用户级连接
*
* 每个用户绑定自己的 OpenList 实例地址账号密码互不干扰
* Token 缓存到数据库wp_user_openlist 避免每次调用都重新登录
*
* 重要OpenList 使用内存缓存验证 Token服务重启后所有 Token 失效
* 本客户端实现了自动重试机制 API 返回 401 "token is invalidated"
* 自动刷新 Token 并重试请求保证 API 调用的连续性
*
* 使用方式
* 1. 前端调用 /api/openlist/bind 绑定 OpenList传入地址账号密码
* 2. 后端验证连接 获取 Token 缓存到数据库
* 3. 后续 API 调用自动使用用户的 OpenList 配置
*/
@Component
public class OpenListClient {
private static final Logger log = LoggerFactory.getLogger(OpenListClient.class);
@Autowired
private UserOpenlistRepository userOpenlistRepository;
private final RestTemplate restTemplate = new RestTemplate();
/**
* 测试 OpenList 连接不保存配置
* 用于前端绑定前验证地址账号密码是否正确
*
* @param openlistUrl OpenList 服务地址 http://ldfun.asia:13000
* @param openlistUsername OpenList 账号
* @param openlistPassword OpenList 密码
* @return 连接结果包含 success是否成功message提示信息tokenToken成功时返回
*/
public Map<String, Object> testConnection(String openlistUrl, String openlistUsername, String openlistPassword) {
var result = new HashMap<String, Object>();
try {
// 调用登录接口
String url = openlistUrl + "/api/auth/login";
Map<String, Object> requestBody = new HashMap<>();
requestBody.put("username", openlistUsername);
requestBody.put("password", openlistPassword);
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
HttpEntity<Map<String, Object>> entity = new HttpEntity<>(requestBody, headers);
ResponseEntity<Map> response = restTemplate.postForEntity(url, entity, Map.class);
Map<String, Object> body = response.getBody();
if (body != null && (int) body.get("code") == 200) {
Map<String, Object> data = (Map<String, Object>) body.get("data");
String token = (String) data.get("token");
result.put("success", true);
result.put("message", "连接成功");
result.put("token", token);
log.info("OpenList 连接测试成功: {}", openlistUrl);
} else {
result.put("success", false);
result.put("message", "连接失败:" + (body != null ? body.get("message") : "未知错误"));
log.warn("OpenList 连接测试失败: {}", openlistUrl);
}
} catch (Exception e) {
result.put("success", false);
result.put("message", "连接异常:" + e.getMessage());
log.error("OpenList 连接测试异常: {}", openlistUrl, e);
}
return result;
}
/**
* 绑定用户的 OpenList 连接配置
* 验证连接 保存配置 缓存 Token
*
* @param userId 用户ID
* @param openlistUrl OpenList 服务地址
* @param openlistUsername OpenList 账号
* @param openlistPassword OpenList 密码
* @return 绑定结果包含 success是否成功message提示信息
*/
public Map<String, Object> bindConnection(Long userId, String openlistUrl, String openlistUsername, String openlistPassword) {
var result = new HashMap<String, Object>();
// 1. 测试连接
var testResult = testConnection(openlistUrl, openlistUsername, openlistPassword);
if (!(boolean) testResult.get("success")) {
result.put("success", false);
result.put("message", testResult.get("message"));
return result;
}
// 2. 保存配置敏感信息自动加密
UserOpenlist entity = new UserOpenlist(userId, openlistUrl, openlistUsername, openlistPassword);
userOpenlistRepository.saveOrUpdateConfig(entity);
// 3. 缓存 TokenToken 有效期 24 小时
String token = (String) testResult.get("token");
long expireTime = System.currentTimeMillis() + 86400000; // 24 小时
userOpenlistRepository.updateToken(userId, token, expireTime);
result.put("success", true);
result.put("message", "绑定成功");
log.info("用户 {} 绑定 OpenList 成功: {}", userId, openlistUrl);
return result;
}
/**
* 获取用户的 OpenList 连接配置Entity
* 敏感信息自动解密
*
* @param userId 用户ID
* @return 用户 OpenList 配置未绑定时返回 null
*/
public UserOpenlist getUserOpenlist(Long userId) {
return userOpenlistRepository.findByUserId(userId);
}
/**
* 获取用户的 OpenList 连接信息脱敏
* 用于前端展示不返回明文密码
*
* @param userId 用户ID
* @return 连接信息 Map未绑定时返回 null
*/
public Map<String, Object> getUserOpenlistInfo(Long userId) {
return userOpenlistRepository.findInfoByUserId(userId);
}
/**
* 解绑用户的 OpenList 连接配置
*
* @param userId 用户ID
* @return 解绑结果包含 success是否成功message提示信息
*/
public Map<String, Object> unbindConnection(Long userId) {
var result = new HashMap<String, Object>();
boolean deleted = userOpenlistRepository.unbind(userId);
if (deleted) {
result.put("success", true);
result.put("message", "解绑成功");
log.info("用户 {} 解绑 OpenList 成功", userId);
} else {
result.put("success", false);
result.put("message", "解绑失败:未找到绑定记录");
log.warn("用户 {} 解绑 OpenList 失败:未找到绑定记录", userId);
}
return result;
}
/**
* 强制刷新并验证 Token公开方法 Controller 调用
* 用于在同步前确保 Token 有效
*
* @param userId 用户ID
* @return 是否成功刷新并验证 Token
*/
public boolean refreshAndVerifyToken(Long userId) {
UserOpenlist config = getUserOpenlist(userId);
if (config == null) {
log.warn("用户 {} 未绑定 OpenList无法刷新 Token", userId);
return false;
}
log.info("用户 {} 开始强制刷新并验证 OpenList Token", userId);
var testResult = testConnection(config.getOpenlistUrl(), config.getOpenlistUsername(), config.getOpenlistPassword());
boolean success = (boolean) testResult.get("success");
if (success && testResult.get("token") != null) {
String newToken = (String) testResult.get("token");
long expireTime = System.currentTimeMillis() + 86400000; // 24 小时
userOpenlistRepository.updateToken(userId, newToken, expireTime);
log.info("用户 {} 的 OpenList Token 已强制刷新并验证成功", userId);
return true;
} else {
log.warn("用户 {} 的 OpenList Token 刷新失败: {}", userId, testResult.get("message"));
return false;
}
}
// ==================== 文件操作 API使用用户级连接 ====================
/**
* 获取有效的 Token自动刷新
* 从数据库读取 Token如果过期则重新登录获取
*
* @param userId 用户ID
* @return JWT Token
*/
private String getValidToken(Long userId) {
UserOpenlist config = getUserOpenlist(userId);
if (config == null) {
throw new RuntimeException("用户未绑定 OpenList");
}
// 检查 Token 是否有效
if (config.isTokenValid()) {
return config.getToken();
}
// Token 过期重新登录获取
log.info("用户 {} 的 OpenList Token 已过期,自动刷新", userId);
var testResult = testConnection(config.getOpenlistUrl(), config.getOpenlistUsername(), config.getOpenlistPassword());
if (!(boolean) testResult.get("success")) {
throw new RuntimeException("OpenList Token 刷新失败:" + testResult.get("message"));
}
// 缓存新 Token
String token = (String) testResult.get("token");
long expireTime = System.currentTimeMillis() + 86400000; // 24 小时
userOpenlistRepository.updateToken(userId, token, expireTime);
return token;
}
/**
* 检查响应是否因 Token 失效而失败
* OpenList 返回格式{"code": 401, "message": "token is invalidated", ...}
*
* @param response 响应体 Map
* @return 是否 Token 失效
*/
private boolean isTokenInvalidated(Map<String, Object> response) {
if (response == null) return false;
Object code = response.get("code");
Object message = response.get("message");
log.info("检查 Token 失效: code={}, message={}", code, message);
// 更健壮的类型转换支持 NumberString 等多种类型
int codeValue = 0;
if (code instanceof Number) {
codeValue = ((Number) code).intValue();
} else if (code instanceof String) {
try {
codeValue = Integer.parseInt((String) code);
} catch (NumberFormatException e) {
log.warn("无法解析 code 值: {}", code);
return false;
}
} else if (code != null) {
log.warn("未知的 code 类型: {}, 值: {}", code.getClass().getName(), code);
return false;
}
// 大小写不敏感匹配兼容 "token is invalidated""Token is invalidated"
boolean isInvalidated = codeValue == 401 && message != null
&& message.toString().toLowerCase().contains("token is invalidated");
if (isInvalidated) {
log.info("检测到 Token 失效: code={}, message={}", code, message);
}
return isInvalidated;
}
/**
* 执行 POST 请求带自动重试
* Token 失效时自动刷新并重试一次
*
* 重要OpenList Token 失效时可能返回 HTTP 401 状态码
* RestTemplate 会抛出 HttpClientErrorException 异常
* 而不是返回响应体因此需要捕获异常并检查状态码
*
* @param userId 用户ID
* @param url 请求 URL
* @param requestBody 请求体
* @return 响应结果
*/
private Map<String, Object> postWithRetry(Long userId, String url, Map<String, Object> requestBody) {
String token = getValidToken(userId);
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
headers.set("Authorization", token);
HttpEntity<Map<String, Object>> entity = new HttpEntity<>(requestBody, headers);
Map<String, Object> result = null;
try {
ResponseEntity<Map> response = restTemplate.postForEntity(url, entity, Map.class);
result = response.getBody();
} catch (org.springframework.web.client.HttpClientErrorException e) {
// 捕获 HTTP 4xx 错误包括 401 Unauthorized
log.warn("用户 {} 请求 OpenList API 失败: HTTP {} - {}", userId, e.getStatusCode(), e.getMessage());
// 检查是否是 401 Token 失效
if (e.getStatusCode() == HttpStatus.UNAUTHORIZED) {
log.info("用户 {} 的 OpenList Token 失效HTTP 401强制刷新并重试", userId);
token = refreshToken(userId);
headers.set("Authorization", token);
entity = new HttpEntity<>(requestBody, headers);
try {
ResponseEntity<Map> retryResponse = restTemplate.postForEntity(url, entity, Map.class);
result = retryResponse.getBody();
log.info("用户 {} Token 刷新后重试成功", userId);
} catch (Exception retryEx) {
log.error("用户 {} Token 刷新后重试仍然失败", userId, retryEx);
throw new RuntimeException("OpenList API 调用失败(重试后): " + retryEx.getMessage(), retryEx);
}
} else {
// 401 错误直接抛出
throw new RuntimeException("OpenList API 调用失败: HTTP " + e.getStatusCode() + " - " + e.getMessage(), e);
}
} catch (Exception e) {
// 其他异常网络错误等
log.error("用户 {} 请求 OpenList API 异常", userId, e);
throw new RuntimeException("OpenList API 调用异常: " + e.getMessage(), e);
}
// 检查响应体中的 Token 失效标志某些情况下 OpenList 可能返回 HTTP 200 body code=401
if (isTokenInvalidated(result)) {
log.info("用户 {} 的 OpenList Token 失效(响应体 code=401强制刷新并重试", userId);
token = refreshToken(userId);
// 验证新获取的 Token 是否有效
log.info("用户 {} 新 Token: {}", userId, token != null ? token.substring(0, Math.min(20, token.length())) + "..." : "null");
headers.set("Authorization", token);
entity = new HttpEntity<>(requestBody, headers);
try {
ResponseEntity<Map> retryResponse = restTemplate.postForEntity(url, entity, Map.class);
result = retryResponse.getBody();
// 检查重试后的结果是否仍然 Token 失效
if (isTokenInvalidated(result)) {
log.error("用户 {} Token 刷新后重试仍然返回 code=401新 Token 无效!", userId);
throw new RuntimeException("OpenList Token 刷新后仍然失效,请检查用户名和密码是否正确");
}
log.info("用户 {} Token 刷新后重试成功", userId);
} catch (org.springframework.web.client.HttpClientErrorException e) {
log.error("用户 {} Token 刷新后重试 HTTP 错误: {}", userId, e.getStatusCode(), e);
throw new RuntimeException("OpenList API 调用失败(重试后): HTTP " + e.getStatusCode() + " - " + e.getMessage(), e);
} catch (Exception retryEx) {
log.error("用户 {} Token 刷新后重试仍然失败", userId, retryEx);
throw new RuntimeException("OpenList API 调用失败(重试后): " + retryEx.getMessage(), retryEx);
}
}
// 最终检查确保不会返回 Token 失效的响应
// 如果经过多次重试后仍然返回 code=401说明 Token 刷新机制存在问题
if (isTokenInvalidated(result)) {
log.error("用户 {} 经过多次重试后仍然返回 Token 失效,请检查 OpenList 用户名和密码", userId);
throw new RuntimeException("OpenList Token 持续失效,请检查用户名和密码是否正确,或 OpenList 服务是否正常运行");
}
return result;
}
/**
* 强制刷新 Token忽略缓存重新登录获取
*
* @param userId 用户ID
* @return 新的 JWT Token
*/
private String refreshToken(Long userId) {
UserOpenlist config = getUserOpenlist(userId);
if (config == null) {
throw new RuntimeException("用户未绑定 OpenList");
}
log.info("用户 {} 强制刷新 OpenList Token", userId);
var testResult = testConnection(config.getOpenlistUrl(), config.getOpenlistUsername(), config.getOpenlistPassword());
if (!(boolean) testResult.get("success")) {
throw new RuntimeException("OpenList Token 刷新失败:" + testResult.get("message"));
}
// 缓存新 Token
String token = (String) testResult.get("token");
long expireTime = System.currentTimeMillis() + 86400000; // 24 小时
userOpenlistRepository.updateToken(userId, token, expireTime);
return token;
}
/**
* 列出指定路径下的文件
*
* @param userId 用户ID
* @param path OpenList 路径 /movies
* @param page 页码 1 开始
* @param perPage 每页数量
* @return 文件列表响应
*/
public Map<String, Object> listFiles(Long userId, String path, int page, int perPage) {
UserOpenlist config = getUserOpenlist(userId);
if (config == null) {
throw new RuntimeException("用户未绑定 OpenList");
}
String url = config.getOpenlistUrl() + "/api/fs/list";
Map<String, Object> requestBody = new HashMap<>();
requestBody.put("path", path);
requestBody.put("page", page);
requestBody.put("per_page", perPage);
requestBody.put("refresh", false);
return postWithRetry(userId, url, requestBody);
}
/**
* 获取文件详情和下载链接
*
* @param userId 用户ID
* @param path 文件路径 /movies/阿凡达.mp4
* @return 文件详情响应包含 raw_url
*/
public Map<String, Object> getFileDetail(Long userId, String path) {
UserOpenlist config = getUserOpenlist(userId);
if (config == null) {
throw new RuntimeException("用户未绑定 OpenList");
}
String url = config.getOpenlistUrl() + "/api/fs/get";
Map<String, Object> requestBody = new HashMap<>();
requestBody.put("path", path);
return postWithRetry(userId, url, requestBody);
}
/**
* 创建文件夹
*
* @param userId 用户ID
* @param path 文件夹路径 /movies/科幻
* @return 创建结果
*/
public Map<String, Object> createFolder(Long userId, String path) {
UserOpenlist config = getUserOpenlist(userId);
if (config == null) {
throw new RuntimeException("用户未绑定 OpenList");
}
String url = config.getOpenlistUrl() + "/api/fs/mkdir";
Map<String, Object> requestBody = new HashMap<>();
requestBody.put("path", path);
return postWithRetry(userId, url, requestBody);
}
/**
* 删除文件或文件夹
*
* @param userId 用户ID
* @param path 文件路径
* @param names 文件名列表
* @return 删除结果
*/
public Map<String, Object> deleteFiles(Long userId, String path, String[] names) {
UserOpenlist config = getUserOpenlist(userId);
if (config == null) {
throw new RuntimeException("用户未绑定 OpenList");
}
String url = config.getOpenlistUrl() + "/api/fs/remove";
Map<String, Object> requestBody = new HashMap<>();
requestBody.put("dir", path);
requestBody.put("names", names);
return postWithRetry(userId, url, requestBody);
}
/**
* 获取用户的 OpenList 服务地址
*
* @param userId 用户ID
* @return OpenList base URL未绑定时返回 null
*/
public String getUserBaseUrl(Long userId) {
UserOpenlist config = getUserOpenlist(userId);
return config != null ? config.getOpenlistUrl() : null;
}
// ==================== 同步相关 API ====================
/**
* 获取用户的所有存储驱动器列表
* OpenList 中的每个存储如阿里云盘百度网盘等都是一个独立的驱动器
* 注意 API 需要管理员权限普通用户调用会失败
*
* @param userId 用户ID
* @return 存储驱动器列表每个驱动器包含 idmount_pathdriver 等信息
* @throws RuntimeException API 调用失败时抛出异常
*/
public List<Map<String, Object>> getStorageList(Long userId) {
UserOpenlist config = getUserOpenlist(userId);
if (config == null) {
throw new RuntimeException("用户未绑定 OpenList");
}
String url = config.getOpenlistUrl() + "/api/admin/storage/list";
log.info("用户 {} 尝试获取存储列表需要管理员权限URL: {}", userId, url);
Map<String, Object> requestBody = new HashMap<>();
Map<String, Object> result = postWithRetry(userId, url, requestBody);
log.info("用户 {} 存储列表 API 响应: {}", userId, result);
if (result == null) {
throw new RuntimeException("存储列表 API 响应为 null");
}
Object codeObj = result.get("code");
int code = codeObj != null ? (int) codeObj : -1;
if (code != 200) {
String message = result.get("message") != null ? result.get("message").toString() : "未知错误";
throw new RuntimeException("存储列表 API 返回错误: code=" + code + ", message=" + message);
}
Object dataObj = result.get("data");
log.info("用户 {} 存储列表 data 类型: {}, 内容: {}",
userId,
dataObj != null ? dataObj.getClass().getSimpleName() : "null",
dataObj != null ? dataObj.toString() : "null");
if (dataObj == null) {
throw new RuntimeException("存储列表 data 为空");
}
@SuppressWarnings("unchecked")
List<Map<String, Object>> storages = (List<Map<String, Object>>) dataObj;
log.info("用户 {} 共有 {} 个存储驱动器", userId, storages.size());
// 打印每个存储的详细信息
for (int i = 0; i < storages.size(); i++) {
var storage = storages.get(i);
log.info("存储 {}: id={}, mount_path={}, driver={}, status={}",
i, storage.get("id"), storage.get("mount_path"),
storage.get("driver"), storage.get("status"));
}
return storages;
}
/**
* 获取指定路径下的所有内容自动处理分页返回全部结果
* 用于同步场景需要获取目录下所有文件和文件夹
*
* @param userId 用户ID
* @param path OpenList 路径 / /movies
* @return 内容列表包含文件和文件夹信息
* @throws RuntimeException API 调用失败时抛出异常
*/
public List<Map<String, Object>> listAll(Long userId, String path) {
UserOpenlist config = getUserOpenlist(userId);
if (config == null) {
throw new RuntimeException("用户未绑定 OpenList");
}
String url = config.getOpenlistUrl() + "/api/fs/list";
List<Map<String, Object>> allContent = new ArrayList<>();
int page = 1;
int perPage = 100; // 每页 100
while (true) {
Map<String, Object> requestBody = new HashMap<>();
requestBody.put("path", path);
requestBody.put("page", page);
requestBody.put("per_page", perPage);
log.info("用户 {} 请求列出路径 {} 第 {} 页,参数: {}", userId, path, page, requestBody);
Map<String, Object> result = postWithRetry(userId, url, requestBody);
if (result == null) {
throw new RuntimeException("列出路径 " + path + " 失败: API 响应为 null");
}
Object codeObj = result.get("code");
int code = codeObj != null ? (int) codeObj : -1;
if (code != 200) {
String message = result.get("message") != null ? result.get("message").toString() : "未知错误";
throw new RuntimeException("列出路径 " + path + " 失败: code=" + code + ", message=" + message);
}
Object dataObj = result.get("data");
log.info("用户 {} 路径 {} 第 {} 页响应 data 类型: {}, 内容: {}",
userId, path, page,
dataObj != null ? dataObj.getClass().getSimpleName() : "null",
dataObj != null ? dataObj.toString().substring(0, Math.min(500, dataObj.toString().length())) : "null");
if (dataObj == null) {
throw new RuntimeException("列出路径 " + path + " 失败: data 为 null");
}
Map<String, Object> data = (Map<String, Object>) dataObj;
// data.content 是文件/文件夹数组data.total 是总数
@SuppressWarnings("unchecked")
List<Map<String, Object>> items = (List<Map<String, Object>>) data.get("content");
if (items == null || items.isEmpty()) {
log.info("用户 {} 路径 {} 第 {} 页 content 为空(目录可能为空)", userId, path, page);
break;
}
allContent.addAll(items);
// 判断是否还有下一页
int total = (int) data.getOrDefault("total", 0);
log.info("用户 {} 路径 {} 第 {} 页获取 {} 项,总计 {} 项", userId, path, page, items.size(), total);
if (page * perPage >= total) {
break;
}
page++;
}
log.info("用户 {} 路径 {} 共获取 {} 个内容项", userId, path, allContent.size());
return allContent;
}
}

View File

@ -1,8 +0,0 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<!-- 网盘文件夹 Mapper XML存放复杂SQL简单CRUD由MyBatis-Plus BaseMapper自动提供 -->
<mapper namespace="com.wangpan.common.dal.mapper.DriveFolderMapper">
</mapper>

View File

@ -1,8 +0,0 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<!-- 网盘 Mapper XML存放复杂SQL简单CRUD由MyBatis-Plus BaseMapper自动提供 -->
<mapper namespace="com.wangpan.common.dal.mapper.DriveMapper">
</mapper>

View File

@ -1,18 +0,0 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<!-- 网盘电影 Mapper XML存放复杂SQL简单CRUD由MyBatis-Plus BaseMapper自动提供 -->
<mapper namespace="com.wangpan.common.dal.mapper.DriveMovieMapper">
<!-- 查找用户所有网盘下的重复电影(按 title 分组,出现次数 > 1 -->
<select id="findDuplicateMovies" resultType="java.util.Map">
SELECT title, COUNT(*) AS cnt
FROM wp_drive_movie
WHERE user_id = #{userId} AND status = 'active'
GROUP BY title
HAVING COUNT(*) > 1
ORDER BY cnt DESC
</select>
</mapper>

View File

@ -1,8 +0,0 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<!-- 登录日志 Mapper XML存放复杂SQL简单CRUD由MyBatis-Plus BaseMapper自动提供 -->
<mapper namespace="com.wangpan.common.dal.mapper.LoginLogMapper">
</mapper>

View File

@ -1,39 +0,0 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<!-- 电影 Mapper XML存放复杂SQL简单CRUD由MyBatis-Plus BaseMapper自动提供 -->
<mapper namespace="com.wangpan.common.dal.mapper.MovieMapper">
<!-- 查找用户所有网盘下的重复电影(按 title 分组,出现次数 > 1 -->
<select id="findDuplicateMovies" resultType="java.util.Map">
SELECT title, COUNT(*) AS cnt
FROM wp_drive_movie
WHERE user_id = #{userId} AND status = 'active'
GROUP BY title
HAVING COUNT(*) > 1
ORDER BY cnt DESC
</select>
<!-- 查找所有配置了TMDB API Key的用户供定时刮削使用 -->
<select id="findUsersWithTmdbKey" resultType="java.util.Map">
SELECT u.id, u.username
FROM wp_user u
WHERE u.tmdb_api_key IS NOT NULL
AND u.tmdb_api_key != ''
AND u.status = 1
</select>
<!-- 查找指定用户需要刮削的电影(包含 scrape_type='none' 的未刮削电影) -->
<select id="findMoviesNeedingScrape" resultType="com.wangpan.common.dal.dataobject.MovieDO">
SELECT m.* FROM wp_drive_movie m
LEFT JOIN wp_tmdb_cache c ON m.id = c.movie_id AND c.status = 'active'
WHERE m.user_id = #{userId}
AND m.title IS NOT NULL AND m.title != ''
AND (c.id IS NULL OR c.cache_time &lt; DATE_SUB(NOW(), INTERVAL #{expireDays} DAY))
AND m.status = 'active'
ORDER BY m.update_time DESC
LIMIT 100
</select>
</mapper>

View File

@ -1,90 +0,0 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<!-- TMDB刮削缓存 Mapper XML存放复杂SQL简单CRUD由MyBatis-Plus BaseMapper自动提供 -->
<mapper namespace="com.wangpan.common.dal.mapper.TmdbCacheMapper">
<!-- 根据电影ID查询有效缓存 -->
<select id="findByMovieId" resultType="com.wangpan.common.dal.dataobject.TmdbCacheDO">
SELECT * FROM wp_tmdb_cache WHERE movie_id = #{movieId} AND status = 'active'
</select>
<!-- 根据TMDB ID查询有效缓存列表 -->
<select id="findByTmdbId" resultType="com.wangpan.common.dal.dataobject.TmdbCacheDO">
SELECT * FROM wp_tmdb_cache WHERE tmdb_id = #{tmdbId} AND status = 'active'
</select>
<!-- 分页查询用户缓存(含关联电影标题) -->
<select id="queryByUserId" resultType="java.util.Map">
SELECT c.*, m.title AS movie_title
FROM wp_tmdb_cache c
LEFT JOIN wp_drive_movie m ON c.movie_id = m.id
WHERE c.user_id = #{userId} AND c.status = 'active'
ORDER BY c.cache_time DESC
LIMIT #{pageSize} OFFSET #{pageNo}
</select>
<!-- 查询所有过期的缓存ID列表 -->
<select id="findExpiredCaches" resultType="java.lang.Long">
SELECT id FROM wp_tmdb_cache WHERE cache_time &lt; #{expireBefore} AND status = 'active'
</select>
<!-- 新增或更新缓存记录UPSERT -->
<insert id="saveOrUpdate" parameterType="map">
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()
</insert>
<!-- 批量将缓存标记为过期 -->
<update id="markAsExpired">
UPDATE wp_tmdb_cache SET status = 'expired' WHERE id IN
<foreach collection="cacheIds" item="id" open="(" separator="," close=")">
#{id}
</foreach>
</update>
<!-- 删除指定电影的缓存 -->
<delete id="deleteByMovieId">
DELETE FROM wp_tmdb_cache WHERE movie_id = #{movieId}
</delete>
<!-- 获取用户的缓存统计信息 -->
<select id="getStatistics" resultType="java.util.Map">
SELECT
COUNT(*) AS totalCached,
SUM(CASE WHEN cache_time >= DATE_SUB(NOW(), INTERVAL 1 DAY) THEN 1 ELSE 0 END) AS todayCached,
SUM(CASE WHEN cache_time &lt; DATE_SUB(NOW(), INTERVAL 7 DAY) THEN 1 ELSE 0 END) AS expiredCount
FROM wp_tmdb_cache
WHERE user_id = #{userId}
</select>
</mapper>

View File

@ -1,42 +0,0 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<!-- TMDB定时刮削执行记录 Mapper XML存放复杂SQL简单CRUD由MyBatis-Plus BaseMapper自动提供 -->
<mapper namespace="com.wangpan.common.dal.mapper.TmdbScraperLogMapper">
<!-- 插入一条执行记录 -->
<insert id="insertLog" parameterType="map">
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}
)
</insert>
<!-- 查询执行记录总数 -->
<select id="countAll" resultType="long">
SELECT COUNT(*) FROM wp_tmdb_scraper_log
</select>
<!-- 分页查询最近的执行记录 -->
<select id="queryRecent" resultType="java.util.Map">
SELECT * FROM wp_tmdb_scraper_log ORDER BY start_time DESC
LIMIT #{pageSize} OFFSET #{offset}
</select>
<!-- 查询最近的N条执行记录 -->
<select id="queryRecentByLimit" resultType="java.util.Map">
SELECT * FROM wp_tmdb_scraper_log ORDER BY start_time DESC LIMIT #{limit}
</select>
<!-- 根据ID查询单条记录 -->
<select id="findById" resultType="java.util.Map">
SELECT * FROM wp_tmdb_scraper_log WHERE id = #{id}
</select>
</mapper>

View File

@ -1,8 +0,0 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<!-- 用户令牌 Mapper XML存放复杂SQL简单CRUD由MyBatis-Plus BaseMapper自动提供 -->
<mapper namespace="com.wangpan.common.dal.mapper.TokenMapper">
</mapper>

View File

@ -1,8 +0,0 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<!-- 用户 Mapper XML存放复杂SQL简单CRUD由MyBatis-Plus BaseMapper自动提供 -->
<mapper namespace="com.wangpan.common.dal.mapper.UserMapper">
</mapper>

View File

@ -1,3 +0,0 @@
artifactId=wangpan-common
groupId=com.wangpan
version=1.0-SNAPSHOT

View File

@ -1,52 +0,0 @@
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\mapper\DailyRecommendMapper.class
com\wangpan\common\dal\repository\TmdbScraperLogRepository.class
com\wangpan\common\entity\Movie.class
com\wangpan\common\dal\dataobject\DailyRecommendDO.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\dal\repository\DailyRecommendRepository.class
com\wangpan\common\Result.class
com\wangpan\common\dal\repository\UserRepository.class

View File

@ -1,51 +0,0 @@
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\TmdbScraperLogMapper.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\repository\LoginLogRepository.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\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\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\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\repository\TmdbScraperLogRepository.java
D:\java2\wangpan\java\wangpan-common\src\main\java\com\wangpan\common\dal\repository\DailyRecommendRepository.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\dal\dataobject\TmdbCacheDO.java
D:\java2\wangpan\java\wangpan-common\src\main\java\com\wangpan\common\util\MinioUtil.java
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\dal\mapper\TmdbCacheMapper.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\dataobject\DailyRecommendDO.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\dataobject\TmdbScraperLogDO.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\dal\mapper\TokenMapper.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\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\dal\mapper\DailyRecommendMapper.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\dataobject\IconDO.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\util\EncryptUtil.java
D:\java2\wangpan\java\wangpan-common\src\main\java\com\wangpan\common\util\DBUtil.java

View File

@ -2,6 +2,7 @@ package com.wangpan;
import org.springframework.boot.SpringApplication; import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.scheduling.annotation.EnableScheduling;
import com.ulisesbocchio.jasyptspringboot.annotation.EnableEncryptableProperties; import com.ulisesbocchio.jasyptspringboot.annotation.EnableEncryptableProperties;
/** /**
@ -10,6 +11,7 @@ import com.ulisesbocchio.jasyptspringboot.annotation.EnableEncryptableProperties
*/ */
@SpringBootApplication @SpringBootApplication
@EnableEncryptableProperties @EnableEncryptableProperties
@EnableScheduling
public class DriveServiceApplication { public class DriveServiceApplication {
public static void main(String[] args) { public static void main(String[] args) {

View File

@ -4,6 +4,7 @@ import com.wangpan.common.dal.mapper.DriveFolderMapper;
import com.wangpan.common.dal.mapper.DriveMapper; import com.wangpan.common.dal.mapper.DriveMapper;
import com.wangpan.common.dal.mapper.DriveMovieMapper; import com.wangpan.common.dal.mapper.DriveMovieMapper;
import com.wangpan.common.dal.mapper.IconMapper; import com.wangpan.common.dal.mapper.IconMapper;
import com.wangpan.common.dal.mapper.UserOpenlistMapper;
import com.wangpan.common.util.DBUtil; import com.wangpan.common.util.DBUtil;
import com.wangpan.common.util.SchemaUtil; import com.wangpan.common.util.SchemaUtil;
import jakarta.annotation.Resource; import jakarta.annotation.Resource;
@ -69,6 +70,7 @@ public class DatabaseInitializer {
SchemaUtil.createTableIfNotExists(conn, "wp_drive_folder", DriveFolderMapper.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_drive_movie", DriveMovieMapper.CREATE_TABLE_SQL);
SchemaUtil.createTableIfNotExists(conn, "wp_icon", IconMapper.CREATE_TABLE_SQL); SchemaUtil.createTableIfNotExists(conn, "wp_icon", IconMapper.CREATE_TABLE_SQL);
SchemaUtil.createTableIfNotExists(conn, "wp_user_openlist", UserOpenlistMapper.CREATE_TABLE_SQL);
// 检查并添加新字段兼容旧表结构升级 // 检查并添加新字段兼容旧表结构升级
SchemaUtil.ensureColumnExists(conn, "wp_drive", "logo_url", SchemaUtil.ensureColumnExists(conn, "wp_drive", "logo_url",
@ -86,6 +88,30 @@ public class DatabaseInitializer {
SchemaUtil.ensureColumnExists(conn, "wp_drive_movie", "backdrop_url", SchemaUtil.ensureColumnExists(conn, "wp_drive_movie", "backdrop_url",
"TEXT DEFAULT NULL COMMENT '背景图URL横版加密存储'"); "TEXT DEFAULT NULL COMMENT '背景图URL横版加密存储'");
// 检查 wp_drive 表是否有 openlist_storage_id 字段OpenList 存储ID
SchemaUtil.ensureColumnExists(conn, "wp_drive", "openlist_storage_id",
"VARCHAR(50) DEFAULT NULL COMMENT 'OpenList 存储ID'");
// 检查 wp_drive 表是否有 openlist_mount_path 字段OpenList 挂载路径
SchemaUtil.ensureColumnExists(conn, "wp_drive", "openlist_mount_path",
"VARCHAR(200) DEFAULT NULL COMMENT 'OpenList 挂载路径,如 /movies'");
// 检查 wp_drive_movie 表是否有 openlist_file_path 字段OpenList 文件路径
SchemaUtil.ensureColumnExists(conn, "wp_drive_movie", "openlist_file_path",
"VARCHAR(500) DEFAULT NULL COMMENT 'OpenList 文件路径,如 /movies/阿凡达.mp4'");
// 检查 wp_drive_movie 表是否有 storage_type 字段存储类型
SchemaUtil.ensureColumnExists(conn, "wp_drive_movie", "storage_type",
"VARCHAR(20) NOT NULL DEFAULT 'metadata' COMMENT '存储类型metadata仅元数据/ openlistOpenList 管理)'");
// 检查 wp_drive_movie 表是否有 casts 字段主演
SchemaUtil.ensureColumnExists(conn, "wp_drive_movie", "casts",
"VARCHAR(500) DEFAULT NULL COMMENT '主演逗号分隔TMDB刮削'");
// 检查 wp_drive_movie 表是否有 directors 字段导演
SchemaUtil.ensureColumnExists(conn, "wp_drive_movie", "directors",
"VARCHAR(200) DEFAULT NULL COMMENT '导演逗号分隔TMDB刮削'");
// 检查 wp_icon 表是否有 logo_url 字段 // 检查 wp_icon 表是否有 logo_url 字段
SchemaUtil.ensureColumnExists(conn, "wp_icon", "logo_url", SchemaUtil.ensureColumnExists(conn, "wp_icon", "logo_url",
"VARCHAR(500) DEFAULT NULL COMMENT 'Logo 图片 URLMinIO 存储)'"); "VARCHAR(500) DEFAULT NULL COMMENT 'Logo 图片 URLMinIO 存储)'");
@ -98,6 +124,26 @@ public class DatabaseInitializer {
SchemaUtil.ensureColumnExists(conn, "wp_icon", "link_type", SchemaUtil.ensureColumnExists(conn, "wp_icon", "link_type",
"VARCHAR(20) NOT NULL DEFAULT 'external' COMMENT '链接类型external仅外网/ internal仅内网/ both内外网'"); "VARCHAR(20) NOT NULL DEFAULT 'external' COMMENT '链接类型external仅外网/ internal仅内网/ both内外网'");
// 检查 wp_user_openlist 表是否有 last_heartbeat_time 字段心跳检测时间
SchemaUtil.ensureColumnExists(conn, "wp_user_openlist", "last_heartbeat_time",
"DATETIME DEFAULT NULL COMMENT '最后一次心跳检测时间'");
// 检查 wp_user_openlist 表是否有 heartbeat_status 字段心跳状态
SchemaUtil.ensureColumnExists(conn, "wp_user_openlist", "heartbeat_status",
"VARCHAR(20) DEFAULT NULL COMMENT '心跳状态success/failed'");
// 检查 wp_user_openlist 表是否有 heartbeat_error 字段心跳失败原因
SchemaUtil.ensureColumnExists(conn, "wp_user_openlist", "heartbeat_error",
"VARCHAR(500) DEFAULT NULL COMMENT '心跳检测失败原因'");
// 检查 wp_drive 表是否有 logo_last_access_time 字段Logo 最后访问时间
SchemaUtil.ensureColumnExists(conn, "wp_drive", "logo_last_access_time",
"DATETIME DEFAULT NULL COMMENT 'Logo最后访问时间用于回收策略'");
// 检查 wp_drive 表是否有 logo_status 字段Logo 状态
SchemaUtil.ensureColumnExists(conn, "wp_drive", "logo_status",
"VARCHAR(20) NOT NULL DEFAULT 'normal' COMMENT 'Logo状态normal正常/recycling回收中/deleted已删除'");
// 移除已废弃的 icon 已被 logo_url 替代 // 移除已废弃的 icon 已被 logo_url 替代
try (var checkStmt = conn.createStatement(); try (var checkStmt = conn.createStatement();
var checkRs = checkStmt.executeQuery("SHOW COLUMNS FROM wp_drive LIKE 'icon'")) { var checkRs = checkStmt.executeQuery("SHOW COLUMNS FROM wp_drive LIKE 'icon'")) {
@ -107,6 +153,15 @@ public class DatabaseInitializer {
} }
} }
// 移除已废弃的 overview 已与 description 合并不再单独存储
try (var checkStmt = conn.createStatement();
var checkRs = checkStmt.executeQuery("SHOW COLUMNS FROM wp_drive_movie LIKE 'overview'")) {
if (checkRs.next()) {
conn.createStatement().executeUpdate("ALTER TABLE wp_drive_movie DROP COLUMN overview");
System.out.println(" ✓ [wp_drive_movie] 已移除废弃的 overview 列(已合并到 description");
}
}
// 验证字段是否添加成功 // 验证字段是否添加成功
try (var checkStmt = conn.createStatement(); try (var checkStmt = conn.createStatement();
var checkRs = checkStmt.executeQuery("SHOW COLUMNS FROM wp_drive LIKE 'logo_url'")) { var checkRs = checkStmt.executeQuery("SHOW COLUMNS FROM wp_drive LIKE 'logo_url'")) {

View File

@ -11,9 +11,11 @@ import com.wangpan.common.entity.DriveMovie;
import com.wangpan.common.entity.User; import com.wangpan.common.entity.User;
import com.wangpan.common.interceptor.AuthInterceptor; import com.wangpan.common.interceptor.AuthInterceptor;
import com.wangpan.common.util.MinioUtil; import com.wangpan.common.util.MinioUtil;
import com.wangpan.common.util.OpenListClient;
import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletRequest;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import java.io.ByteArrayInputStream; import java.io.ByteArrayInputStream;
@ -41,6 +43,9 @@ public class DriveController {
private final DriveFolderRepository folderRepository; private final DriveFolderRepository folderRepository;
private final DriveMovieRepository movieRepository; private final DriveMovieRepository movieRepository;
@Autowired
private OpenListClient openListClient;
/** /**
* 构造函数注入 Repository 依赖 * 构造函数注入 Repository 依赖
*/ */
@ -82,6 +87,9 @@ public class DriveController {
try { try {
String logoUrl = uploadDriveLogo(logoData); String logoUrl = uploadDriveLogo(logoData);
drive.setLogoUrl(logoUrl); drive.setLogoUrl(logoUrl);
// 设置 Logo 初始状态为 normal并记录首次访问时间
drive.setLogoStatus("normal");
drive.setLogoLastAccessTime(java.time.LocalDateTime.now());
} catch (Exception e) { } catch (Exception e) {
log.error("Logo上传失败", e); log.error("Logo上传失败", e);
return Result.error("创建网盘失败Logo图片上传失败"); return Result.error("创建网盘失败Logo图片上传失败");
@ -112,6 +120,15 @@ public class DriveController {
var list = driveRepository.queryByUserId(user.getId(), pageNo, pageSize); var list = driveRepository.queryByUserId(user.getId(), pageNo, pageSize);
var total = driveRepository.countByUserId(user.getId()); var total = driveRepository.countByUserId(user.getId());
// 更新所有有 Logo 的网盘的最后访问时间触发回收策略重置
for (var driveMap : list) {
Long driveId = (Long) driveMap.get("id");
String logoUrl = (String) driveMap.get("logoUrl");
if (logoUrl != null && !logoUrl.isBlank()) {
driveRepository.updateLogoAccessTime(driveId);
}
}
var result = new LinkedHashMap<String, Object>(); var result = new LinkedHashMap<String, Object>();
result.put("list", list); result.put("list", list);
result.put("total", total); result.put("total", total);
@ -134,6 +151,11 @@ public class DriveController {
return Result.error("查询失败:网盘不存在或无权访问"); return Result.error("查询失败:网盘不存在或无权访问");
} }
// 更新 Logo 最后访问时间触发回收策略重置
if (drive.getLogoUrl() != null && !drive.getLogoUrl().isBlank()) {
driveRepository.updateLogoAccessTime(id);
}
return Result.success("查询成功", driveToMap(drive)); return Result.success("查询成功", driveToMap(drive));
} }
@ -165,6 +187,8 @@ public class DriveController {
if (logoData != null && !logoData.isBlank()) { if (logoData != null && !logoData.isBlank()) {
try { try {
logoUrl = uploadDriveLogo(logoData); logoUrl = uploadDriveLogo(logoData);
// 更新 Logo 时重置状态为 normal并刷新访问时间
driveRepository.updateLogoAccessTime(id);
} catch (Exception e) { } catch (Exception e) {
log.error("Logo更新失败", e); log.error("Logo更新失败", e);
return Result.error("更新网盘失败Logo图片上传失败"); return Result.error("更新网盘失败Logo图片上传失败");
@ -386,6 +410,16 @@ public class DriveController {
movie.setMimeType(detectMimeType(title)); movie.setMimeType(detectMimeType(title));
movie.setExtension(getExtension(title)); movie.setExtension(getExtension(title));
// 如果用户绑定了 OpenList设置文件路径和存储类型
var userOpenlist = openListClient.getUserOpenlist(user.getId());
if (userOpenlist != null && userOpenlist.getMountPath() != null) {
String openlistPath = buildOpenlistPath(userOpenlist.getMountPath(), folderId, title.trim());
movie.setOpenlistFilePath(openlistPath);
movie.setStorageType("openlist");
} else {
movie.setStorageType("metadata");
}
Long id = movieRepository.insert(movie); Long id = movieRepository.insert(movie);
if (id != null) { if (id != null) {
movie.setId(id); movie.setId(id);
@ -459,18 +493,21 @@ public class DriveController {
* 更新电影信息 * 更新电影信息
* PUT /api/drive/{driveId}/movies/{movieId} * PUT /api/drive/{driveId}/movies/{movieId}
* *
* 请求体所有字段可选 * 请求体所有字段可选只传需要更新的字段
* { * {
* "title": "新标题", * "title": "新标题",
* "originalName": "新文件名.mp4",
* "subtitle": "副标题",
* "description": "描述", * "description": "描述",
* "rating": 85, * "rating": 85,
* "tags": "标签1,标签2", * "tags": "标签1,标签2",
* "category": "分类" * "posterUrl": "海报URL",
* "backdropUrl": "背景图URL",
* "casts": "主演列表",
* "directors": "导演列表",
* "resourceLibrary": "资源库来源",
* "maintainer": "维护者"
* } * }
* *
* 说明用于 TMDB 刮削有误时用户自定义修改文件名等信息 * 说明用于 TMDB 刮削有误时用户自定义修改文件名等信息也支持手动补充刮削字段
*/ */
@PutMapping("/{driveId}/movies/{movieId}") @PutMapping("/{driveId}/movies/{movieId}")
public Result<?> updateMovie(@PathVariable("driveId") Long driveId, public Result<?> updateMovie(@PathVariable("driveId") Long driveId,
@ -484,19 +521,38 @@ public class DriveController {
return Result.error("更新失败:电影不存在或无权访问"); return Result.error("更新失败:电影不存在或无权访问");
} }
// 基础信息字段
String title = (String) data.get("title"); String title = (String) data.get("title");
String originalName = (String) data.get("originalName");
String subtitle = (String) data.get("subtitle");
String description = (String) data.get("description"); String description = (String) data.get("description");
String tags = (String) data.get("tags");
// 评分字段需要类型转换
Object ratingObj = data.get("rating"); Object ratingObj = data.get("rating");
Integer rating = null; Integer rating = null;
if (ratingObj != null) { if (ratingObj != null) {
rating = ((Number) ratingObj).intValue(); rating = ((Number) ratingObj).intValue();
} }
String tags = (String) data.get("tags");
String category = (String) data.get("category");
boolean success = movieRepository.update(movieId, title, originalName, subtitle, description, rating, tags, category); // TMDB 刮削相关字段用户可手动补充或修正
String posterUrl = (String) data.get("posterUrl");
String backdropUrl = (String) data.get("backdropUrl");
String casts = (String) data.get("casts");
String directors = (String) data.get("directors");
String resourceLibrary = (String) data.get("resourceLibrary");
String maintainer = (String) data.get("maintainer");
// 调用扩展后的 Repository 更新方法基础字段
boolean success = movieRepository.update(movieId, title, description,
rating, tags, posterUrl, backdropUrl, resourceLibrary, maintainer);
// 显式更新 casts directors 字段使用 @Update 注解直接执行 SQL
if (casts != null) {
movieRepository.updateCasts(movieId, casts);
}
if (directors != null) {
movieRepository.updateDirectors(movieId, directors);
}
if (success) { if (success) {
return Result.success("更新成功"); return Result.success("更新成功");
} else { } else {
@ -619,6 +675,37 @@ public class DriveController {
return fileName.substring(fileName.lastIndexOf(".") + 1).toLowerCase(); return fileName.substring(fileName.lastIndexOf(".") + 1).toLowerCase();
} }
/**
* 构建 OpenList 文件路径
* 例如/uc/科幻/阿凡达.mp4
*
* @param mountPath 挂载路径 /uc
* @param folderId 文件夹 ID0 表示根目录
* @param fileName 文件名
* @return OpenList 文件路径
*/
private String buildOpenlistPath(String mountPath, Long folderId, String fileName) {
StringBuilder path = new StringBuilder();
// 添加挂载路径
if (mountPath != null && !mountPath.equals("/")) {
path.append(mountPath);
}
// 添加文件夹路径
if (folderId > 0) {
DriveFolder folder = folderRepository.findById(folderId);
if (folder != null) {
path.append("/").append(folder.getName());
}
}
// 添加文件名
path.append("/").append(fileName);
return path.toString();
}
/** /**
* Drive 实体转 Map * Drive 实体转 Map
*/ */
@ -628,6 +715,8 @@ public class DriveController {
map.put("name", drive.getName()); map.put("name", drive.getName());
map.put("description", drive.getDescription()); map.put("description", drive.getDescription());
map.put("logoUrl", drive.getLogoUrl()); map.put("logoUrl", drive.getLogoUrl());
map.put("logoStatus", drive.getLogoStatus());
map.put("logoLastAccessTime", drive.getLogoLastAccessTime() != null ? drive.getLogoLastAccessTime().toString() : null);
map.put("folderCount", drive.getFolderCount()); map.put("folderCount", drive.getFolderCount());
map.put("fileCount", drive.getFileCount()); map.put("fileCount", drive.getFileCount());
map.put("totalSize", drive.getTotalSize()); map.put("totalSize", drive.getTotalSize());
@ -682,6 +771,11 @@ public class DriveController {
/** /**
* DriveMovie 实体转 Map * DriveMovie 实体转 Map
*
* TMDB 刮削字段castsdirectorsposterUrlbackdropUrlrating
* 仅在刮削成功scrapeStatus = "success"时返回实际值否则返回 null
*
* 已移除字段subtitleoriginalNamecategoryoverview不再对外暴露
*/ */
private Map<String, Object> movieToMap(DriveMovie movie) { private Map<String, Object> movieToMap(DriveMovie movie) {
var map = new LinkedHashMap<String, Object>(); var map = new LinkedHashMap<String, Object>();
@ -689,8 +783,6 @@ public class DriveController {
map.put("driveId", movie.getDriveId()); map.put("driveId", movie.getDriveId());
map.put("folderId", movie.getFolderId()); map.put("folderId", movie.getFolderId());
map.put("title", movie.getTitle()); map.put("title", movie.getTitle());
map.put("subtitle", movie.getSubtitle());
map.put("originalName", movie.getOriginalName());
map.put("mimeType", movie.getMimeType()); map.put("mimeType", movie.getMimeType());
map.put("fileSize", movie.getFileSize()); map.put("fileSize", movie.getFileSize());
map.put("extension", movie.getExtension()); map.put("extension", movie.getExtension());
@ -698,16 +790,159 @@ public class DriveController {
map.put("resourceLibrary", movie.getResourceLibrary()); map.put("resourceLibrary", movie.getResourceLibrary());
map.put("maintainer", movie.getMaintainer()); map.put("maintainer", movie.getMaintainer());
map.put("status", movie.getStatus()); map.put("status", movie.getStatus());
map.put("category", movie.getCategory());
map.put("scrapeType", movie.getScrapeType()); map.put("scrapeType", movie.getScrapeType());
map.put("scrapeStatus", movie.getScrapeStatus()); map.put("scrapeStatus", movie.getScrapeStatus());
map.put("scrapeError", movie.getScrapeError()); map.put("scrapeError", movie.getScrapeError());
map.put("rating", movie.getRating());
map.put("tags", movie.getTags()); map.put("tags", movie.getTags());
map.put("minioUrl", movie.getMinioUrl()); map.put("openlistFilePath", movie.getOpenlistFilePath());
map.put("posterUrl", movie.getPosterUrl()); map.put("storageType", movie.getStorageType());
map.put("createTime", movie.getCreateTime() != null ? movie.getCreateTime().toString() : null); map.put("createTime", movie.getCreateTime() != null ? movie.getCreateTime().toString() : null);
map.put("updateTime", movie.getUpdateTime() != null ? movie.getUpdateTime().toString() : null); map.put("updateTime", movie.getUpdateTime() != null ? movie.getUpdateTime().toString() : null);
// TMDB 刮削字段仅在刮削成功时返回实际值未刮削/刮削失败返回 null
boolean scrapeSuccess = "success".equals(movie.getScrapeStatus());
map.put("rating", scrapeSuccess ? movie.getRating() : null);
map.put("casts", scrapeSuccess ? movie.getCasts() : null);
map.put("directors", scrapeSuccess ? movie.getDirectors() : null);
map.put("posterUrl", scrapeSuccess ? movie.getPosterUrl() : null);
map.put("backdropUrl", scrapeSuccess ? movie.getBackdropUrl() : null);
return map; return map;
} }
// ==================== OpenList 集成接口 ====================
/**
* 获取电影播放链接 OpenList 获取真实下载地址
* GET /api/drive/{driveId}/movies/{movieId}/play-url
*
* 返回 OpenList 的真实下载链接用于前端播放
* 使用当前用户的 OpenList 连接配置
*/
@GetMapping("/{driveId}/movies/{movieId}/play-url")
public Result<?> getMoviePlayUrl(@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("获取失败:电影不存在或无权访问");
}
// 检查是否是 OpenList 管理的文件
if (!"openlist".equals(movie.getStorageType())) {
return Result.error("获取失败:该电影不是 OpenList 管理的文件");
}
try {
// 调用 OpenList API 获取下载链接使用用户级连接
Map<String, Object> result = openListClient.getFileDetail(user.getId(), movie.getOpenlistFilePath());
if (result != null && (int) result.get("code") == 200) {
Map<String, Object> data = (Map<String, Object>) result.get("data");
String rawUrl = (String) data.get("raw_url");
Map<String, Object> response = new LinkedHashMap<>();
response.put("playUrl", rawUrl);
response.put("fileName", movie.getTitle());
response.put("fileSize", movie.getFileSize());
return Result.success("获取成功", response);
} else {
return Result.error("获取播放链接失败:" + result.get("message"));
}
} catch (Exception e) {
log.error("获取播放链接异常", e);
return Result.error("获取播放链接失败:系统异常");
}
}
/**
* OpenList 同步文件夹下的文件列表
* POST /api/drive/{driveId}/folders/{folderId}/sync-files
*
* OpenList 获取实际文件列表并同步到数据库
* 使用当前用户的 OpenList 连接配置
*/
@PostMapping("/{driveId}/folders/{folderId}/sync-files")
public Result<?> syncFilesFromOpenList(@PathVariable("driveId") Long driveId,
@PathVariable("folderId") Long folderId,
HttpServletRequest request) {
User user = getCurrentUser(request);
Drive drive = driveRepository.findById(driveId);
if (drive == null || !drive.getUserId().equals(user.getId())) {
return Result.error("同步失败:网盘不存在或无权访问");
}
DriveFolder folder = folderRepository.findById(folderId);
if (folder == null || !folder.getUserId().equals(user.getId())) {
return Result.error("同步失败:文件夹不存在或无权访问");
}
// 检查用户是否绑定了 OpenList
var userOpenlist = openListClient.getUserOpenlist(user.getId());
if (userOpenlist == null || userOpenlist.getMountPath() == null) {
return Result.error("同步失败:用户未绑定 OpenList请先在设置中绑定 OpenList 连接");
}
try {
// 构建 OpenList 路径
String openlistPath = buildOpenlistPath(userOpenlist.getMountPath(), folderId, "");
// 调用 OpenList API 获取文件列表使用用户级连接
Map<String, Object> result = openListClient.listFiles(user.getId(), openlistPath, 1, 100);
if (result != null && (int) result.get("code") == 200) {
Map<String, Object> data = (Map<String, Object>) result.get("data");
@SuppressWarnings("unchecked")
java.util.List<Map<String, Object>> files = (java.util.List<Map<String, Object>>) data.get("content");
int syncCount = 0;
for (Map<String, Object> file : files) {
String fileName = (String) file.get("name");
boolean isDir = (boolean) file.get("is_dir");
long fileSize = ((Number) file.get("size")).longValue();
if (!isDir) {
// 检查文件是否已存在通过 title folderId 判断
var existingList = movieRepository.queryByDriveAndFolder(driveId, folderId, user.getId(), 1, 1000);
boolean exists = existingList.stream()
.anyMatch(m -> fileName.equals(m.get("title")));
if (!exists) {
// 创建新的电影记录
DriveMovie movie = new DriveMovie(driveId, folderId, user.getId(), fileName, fileName);
movie.setMimeType(detectMimeType(fileName));
movie.setExtension(getExtension(fileName));
movie.setFileSize(fileSize);
movie.setStorageType("openlist");
movie.setOpenlistFilePath(openlistPath + "/" + fileName);
movieRepository.insert(movie);
syncCount++;
}
}
}
// 更新统计信息
updateDriveStats(driveId, user.getId());
if (folderId > 0) {
updateFolderStats(folderId, user.getId());
}
Map<String, Object> response = new LinkedHashMap<>();
response.put("syncCount", syncCount);
response.put("totalFiles", files.size());
return Result.success("同步成功", response);
} else {
return Result.error("同步失败:" + result.get("message"));
}
} catch (Exception e) {
log.error("同步文件异常", e);
return Result.error("同步失败:系统异常");
}
}
} }

View File

@ -0,0 +1,353 @@
package com.wangpan.controller;
import com.wangpan.common.Result;
import com.wangpan.common.annotation.RequireAuth;
import com.wangpan.common.dal.repository.UserOpenlistRepository;
import com.wangpan.common.entity.User;
import com.wangpan.common.entity.UserOpenlist;
import com.wangpan.common.interceptor.AuthInterceptor;
import com.wangpan.common.util.OpenListClient;
import com.wangpan.service.OpenListSyncService;
import jakarta.servlet.http.HttpServletRequest;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.scheduling.annotation.Async;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
/**
* OpenList 连接管理控制器
*
* 提供用户级 OpenList 实例的绑定解绑测试连接查询信息接口
* 每个用户可以绑定自己的 OpenList 实例地址账号密码互不干扰
*
* 核心流程
* 1. 前端传入 OpenList 地址账号密码
* 2. 后端验证连接 获取 Token 加密存储到数据库
* 3. 后续文件操作同步播放自动使用用户的 OpenList 配置
*
* 安全设计
* - 敏感信息URL账号密码使用 Jasypt 加密存储
* - Token 缓存到数据库避免每次调用都重新登录
* - 查询接口返回脱敏信息不暴露明文密码
*/
@RestController
@RequireAuth
@RequestMapping("/api/openlist")
@CrossOrigin(origins = {"*"}, allowCredentials = "false")
public class OpenListController {
private static final Logger log = LoggerFactory.getLogger(OpenListController.class);
@Autowired
private OpenListClient openListClient;
@Autowired
private OpenListSyncService openListSyncService;
@Autowired
private UserOpenlistRepository userOpenlistRepository;
/**
* 从请求属性中获取当前登录用户 AuthInterceptor 注入
*/
private User getCurrentUser(HttpServletRequest request) {
return (User) request.getAttribute(AuthInterceptor.CURRENT_USER_ATTR);
}
// ==================== 连接管理接口 ====================
/**
* 测试 OpenList 连接不保存配置
* 用于前端绑定前验证地址账号密码是否正确
*
* POST /api/openlist/test
*
* 请求体
* {
* "openlistUrl": "http://ldfun.asia:13000",
* "openlistUsername": "djl1022",
* "openlistPassword": "Djl1022!"
* }
*
* 响应
* {
* "code": 200,
* "message": "连接成功",
* "data": {
* "success": true,
* "message": "连接成功"
* }
* }
*/
@PostMapping("/test")
public Result<?> testConnection(@RequestBody Map<String, Object> data) {
String openlistUrl = (String) data.get("openlistUrl");
String openlistUsername = (String) data.get("openlistUsername");
String openlistPassword = (String) data.get("openlistPassword");
// 参数校验
if (openlistUrl == null || openlistUrl.isBlank()) {
return Result.error("测试连接失败OpenList 地址不能为空");
}
if (openlistUsername == null || openlistUsername.isBlank()) {
return Result.error("测试连接失败:账号不能为空");
}
if (openlistPassword == null || openlistPassword.isBlank()) {
return Result.error("测试连接失败:密码不能为空");
}
// 去除 URL 末尾的斜杠
openlistUrl = openlistUrl.trim().replaceAll("/+$", "");
var result = openListClient.testConnection(openlistUrl, openlistUsername, openlistPassword);
boolean success = (boolean) result.get("success");
String message = (String) result.get("message");
if (success) {
return Result.success(message);
} else {
return Result.error(message);
}
}
/**
* 绑定 OpenList 连接
* 验证连接 保存配置加密 缓存 Token
* 每个用户只能绑定一个 OpenList 实例重复绑定会更新配置
*
* POST /api/openlist/bind
*
* 请求体
* {
* "openlistUrl": "http://ldfun.asia:13000",
* "openlistUsername": "djl1022",
* "openlistPassword": "Djl1022!"
* }
*
* 响应
* {
* "code": 200,
* "message": "绑定成功",
* "data": {
* "success": true,
* "message": "绑定成功"
* }
* }
*/
@PostMapping("/bind")
public Result<?> bindConnection(@RequestBody Map<String, Object> data, HttpServletRequest request) {
User user = getCurrentUser(request);
String openlistUrl = (String) data.get("openlistUrl");
String openlistUsername = (String) data.get("openlistUsername");
String openlistPassword = (String) data.get("openlistPassword");
// 参数校验
if (openlistUrl == null || openlistUrl.isBlank()) {
return Result.error("绑定失败OpenList 地址不能为空");
}
if (openlistUsername == null || openlistUsername.isBlank()) {
return Result.error("绑定失败:账号不能为空");
}
if (openlistPassword == null || openlistPassword.isBlank()) {
return Result.error("绑定失败:密码不能为空");
}
// 去除 URL 末尾的斜杠
openlistUrl = openlistUrl.trim().replaceAll("/+$", "");
var result = openListClient.bindConnection(user.getId(), openlistUrl, openlistUsername, openlistPassword);
boolean success = (boolean) result.get("success");
String message = (String) result.get("message");
if (success) {
// 绑定成功后异步触发同步
triggerAsyncSync(user.getId());
return Result.success(message + ",正在后台同步文件...");
} else {
return Result.error(message);
}
}
/**
* 异步触发同步不阻塞绑定响应
*/
private void triggerAsyncSync(Long userId) {
// 使用新线程异步执行同步避免阻塞 HTTP 响应
new Thread(() -> {
try {
log.info("用户 {} 绑定成功,开始异步同步 OpenList 文件", userId);
var syncResult = openListSyncService.syncFromOpenList(userId);
log.info("用户 {} 异步同步完成: 文件夹={}, 电影={}, 错误={}",
userId, syncResult.folderCount, syncResult.movieCount, syncResult.errors.size());
} catch (Exception e) {
log.error("用户 {} 异步同步失败", userId, e);
}
}, "openlist-sync-" + userId).start();
}
/**
* 解绑 OpenList 连接
* 删除用户的 OpenList 配置包括加密的 URL账号密码和 Token 缓存
*
* DELETE /api/openlist/unbind
*
* 响应
* {
* "code": 200,
* "message": "解绑成功",
* "data": {
* "success": true,
* "message": "解绑成功"
* }
* }
*/
@DeleteMapping("/unbind")
public Result<?> unbindConnection(HttpServletRequest request) {
User user = getCurrentUser(request);
var result = openListClient.unbindConnection(user.getId());
boolean success = (boolean) result.get("success");
String message = (String) result.get("message");
if (success) {
return Result.success(message);
} else {
return Result.error(message);
}
}
/**
* 查询当前用户的 OpenList 连接信息脱敏
* 返回脱敏后的 URL账号存储配置Token 状态连接测试结果等不返回明文密码
* 会实际调用 OpenList 登录接口验证连接是否可用
*
* GET /api/openlist/info
*
* 响应已绑定
* {
* "code": 200,
* "data": {
* "id": 1,
* "openlistUrl": "http://ld***:13000",
* "openlistUsername": "dj***22",
* "storageId": "1",
* "mountPath": "/uc",
* "status": "active",
* "tokenValid": true,
* "connected": true,
* "connectMessage": "连接成功",
* "createTime": "2026-06-26T15:00:00",
* "updateTime": "2026-06-26T15:00:00"
* }
* }
*
* 响应未绑定
* {
* "code": 200,
* "data": null
* }
*/
@GetMapping("/info")
public Result<?> getConnectionInfo(HttpServletRequest request) {
User user = getCurrentUser(request);
var info = openListClient.getUserOpenlistInfo(user.getId());
if (info == null) {
return Result.success(null);
}
// 检查当前 Token 是否有效如果有效就不重新登录
// 避免每次查看 info 都登录 OpenList导致之前的 Token 被作废
// OpenList "最后登录有效"模式每次登录会使旧 Token 失效
UserOpenlist config = openListClient.getUserOpenlist(user.getId());
if (config != null) {
if (config.isTokenValid()) {
// Token 缓存仍然有效不需要重新登录
info.put("connected", true);
info.put("connectMessage", "连接正常");
log.info("用户 {} 的 OpenList Token 缓存有效,无需重新登录", user.getId());
} else {
// Token 已过期或不存在需要重新登录验证
var testResult = openListClient.testConnection(
config.getOpenlistUrl(),
config.getOpenlistUsername(),
config.getOpenlistPassword()
);
boolean connected = (boolean) testResult.get("success");
info.put("connected", connected);
info.put("connectMessage", testResult.get("message"));
// 如果连接成功将新获取的 token 保存到数据库
if (connected && testResult.get("token") != null) {
String newToken = (String) testResult.get("token");
long expireTime = System.currentTimeMillis() + 86400000; // 24 小时
userOpenlistRepository.updateToken(user.getId(), newToken, expireTime);
log.info("用户 {} 的 OpenList Token 已更新到数据库", user.getId());
}
}
} else {
info.put("connected", false);
info.put("connectMessage", "未找到连接配置");
}
return Result.success(info);
}
/**
* 手动触发 OpenList 文件同步
* OpenList 根目录递归遍历同步文件夹层级和视频文件到本地数据库
* 只同步视频格式文件mp4, mkv, avi 跳过非视频文件
*
* POST /api/openlist/sync
*
* 响应
* {
* "code": 200,
* "data": {
* "driveCount": 1,
* "folderCount": 10,
* "movieCount": 50,
* "skippedFileCount": 5,
* "skippedFolderCount": 2,
* "errorCount": 0,
* "errors": []
* }
* }
*/
@PostMapping("/sync")
public Result<?> syncFiles(HttpServletRequest request) {
User user = getCurrentUser(request);
// 检查用户是否已绑定 OpenList
UserOpenlist config = openListClient.getUserOpenlist(user.getId());
if (config == null) {
return Result.error("同步失败:用户未绑定 OpenList请先绑定");
}
log.info("用户 {} 手动触发 OpenList 同步", user.getId());
// 在同步前强制刷新 Token确保使用的是最新有效的 Token
// 避免因为 info 接口登录后 Token 被更新 sync 接口使用的是旧 Token
boolean tokenRefreshed = openListClient.refreshAndVerifyToken(user.getId());
if (!tokenRefreshed) {
return Result.error("同步失败:无法刷新 OpenList Token请检查用户名和密码");
}
var syncResult = openListSyncService.syncFromOpenList(user.getId());
// 根据是否有错误返回不同的结果
if (syncResult.errors.isEmpty()) {
// 同步完全成功
return Result.success("同步完成", syncResult.toMap());
} else {
// 同步失败将错误信息拼接在 message 中返回
String errorDetails = String.join("; ", syncResult.errors);
String message = "同步失败: " + errorDetails;
return Result.error(message);
}
}
}

View File

@ -0,0 +1,180 @@
package com.wangpan.listener;
import com.wangpan.common.dal.dataobject.DriveDO;
import com.wangpan.common.dal.repository.DriveRepository;
import com.wangpan.common.util.MinioUtil;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import java.time.LocalDateTime;
import java.time.temporal.ChronoUnit;
/**
* Logo 回收定时任务
*
* 回收策略
* 1. 超过 30 天没有访问的 Logo 进入回收模式recycling
* 2. 回收模式下 7 天内访问一次则恢复正常模式normal并重新计时 30
* 3. 否则将直接从 MinIO 中删除
*/
@Component
public class LogoRecycleScheduler {
private static final Logger log = LoggerFactory.getLogger(LogoRecycleScheduler.class);
/** 正常模式30 天未访问进入回收 */
private static final int NORMAL_MODE_DAYS = 30;
/** 回收模式7 天未访问则删除 */
private static final int RECYCLE_MODE_DAYS = 7;
@Autowired
private DriveRepository driveRepository;
/**
* 每天凌晨 2 点执行回收检查
*/
@Scheduled(cron = "0 0 2 * * ?")
public void checkAndRecycleLogos() {
log.info("========== Logo 回收检查开始 ==========");
try {
// 检查正常模式的 Logo超过 30 天未访问则进入回收模式
checkNormalModeLogos();
// 检查回收模式的 Logo超过 7 天未访问则删除
checkRecycleModeLogos();
} catch (Exception e) {
log.error("Logo 回收检查失败", e);
}
log.info("========== Logo 回收检查完成 ==========");
}
/**
* 检查正常模式的 Logo
* 超过 30 天未访问则进入回收模式
*/
private void checkNormalModeLogos() {
var drives = driveRepository.findDrivesWithLogoNormal();
log.info("检查正常模式 Logo共 {} 个", drives.size());
int recycledCount = 0;
LocalDateTime now = LocalDateTime.now();
for (var drive : drives) {
if (drive.getLogoLastAccessTime() == null) {
// 从未记录访问时间使用创建时间
if (drive.getCreateTime() != null) {
long daysSinceCreation = ChronoUnit.DAYS.between(drive.getCreateTime(), now);
if (daysSinceCreation >= NORMAL_MODE_DAYS) {
// 进入回收模式
driveRepository.updateLogoStatusToRecycling(drive.getId());
log.info("网盘 {} 的 Logo 进入回收模式(创建超过 {} 天未访问)", drive.getId(), daysSinceCreation);
recycledCount++;
}
}
} else {
long daysSinceLastAccess = ChronoUnit.DAYS.between(drive.getLogoLastAccessTime(), now);
if (daysSinceLastAccess >= NORMAL_MODE_DAYS) {
// 进入回收模式
driveRepository.updateLogoStatusToRecycling(drive.getId());
log.info("网盘 {} 的 Logo 进入回收模式({} 天未访问)", drive.getId(), daysSinceLastAccess);
recycledCount++;
}
}
}
log.info("正常模式检查完成:{} 个 Logo 进入回收模式", recycledCount);
}
/**
* 检查回收模式的 Logo
* 超过 7 天未访问则从 MinIO 删除
*/
private void checkRecycleModeLogos() {
var drives = driveRepository.findDrivesWithLogoRecycling();
log.info("检查回收模式 Logo共 {} 个", drives.size());
int deletedCount = 0;
LocalDateTime now = LocalDateTime.now();
for (var drive : drives) {
if (drive.getLogoLastAccessTime() != null) {
long daysSinceLastAccess = ChronoUnit.DAYS.between(drive.getLogoLastAccessTime(), now);
if (daysSinceLastAccess >= RECYCLE_MODE_DAYS) {
// MinIO 删除 Logo
deleteLogoFromMinIO(drive);
deletedCount++;
}
} else {
// 回收模式下没有访问时间记录直接删除
deleteLogoFromMinIO(drive);
deletedCount++;
}
}
log.info("回收模式检查完成:{} 个 Logo 被删除", deletedCount);
}
/**
* MinIO 删除 Logo 并更新数据库状态
*/
private void deleteLogoFromMinIO(DriveDO drive) {
String logoUrl = drive.getLogoUrl();
if (logoUrl == null || logoUrl.isBlank()) {
driveRepository.deleteLogo(drive.getId());
return;
}
try {
// URL 中提取 objectName
// URL 格式http://endpoint/bucket/objectName
String objectName = extractObjectNameFromUrl(logoUrl);
if (objectName != null && !objectName.isBlank()) {
// 调用 MinIO 删除文件
boolean deleted = MinioUtil.deleteFile(objectName);
if (deleted) {
log.info("MinIO 文件删除成功:{}", objectName);
} else {
log.warn("MinIO 文件删除失败:{}", objectName);
}
}
// 更新数据库状态
driveRepository.deleteLogo(drive.getId());
log.info("网盘 {} 的 Logo 已删除", drive.getId());
} catch (Exception e) {
log.error("删除网盘 {} 的 Logo 失败", drive.getId(), e);
}
}
/**
* MinIO URL 中提取 objectName
* 例如http://minio.example.com/bucket/path/to/file.jpg -> path/to/file.jpg
*/
private String extractObjectNameFromUrl(String url) {
try {
// 简单解析找到第三个 / 之后的部分
int firstSlash = url.indexOf("://");
if (firstSlash == -1) return null;
int secondSlash = url.indexOf("/", firstSlash + 3);
if (secondSlash == -1) return null;
int thirdSlash = url.indexOf("/", secondSlash + 1);
if (thirdSlash == -1) return null;
return url.substring(thirdSlash + 1);
} catch (Exception e) {
log.error("解析 MinIO URL 失败:{}", url, e);
return null;
}
}
}

View File

@ -0,0 +1,108 @@
package com.wangpan.listener;
import com.wangpan.common.dal.repository.UserOpenlistRepository;
import com.wangpan.common.entity.UserOpenlist;
import com.wangpan.common.util.OpenListClient;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import java.util.List;
import java.util.Map;
/**
* OpenList 心跳检测定时任务
*
* 24 小时自动检测所有已绑定 OpenList 用户的连接状态
* 检测结果记录到数据库last_heartbeat_timeheartbeat_statusheartbeat_error
* 前端可通过 /api/openlist/info 接口查看心跳状态
*
* 检测逻辑
* 1. 查询所有状态为 active 的用户 OpenList 配置
* 2. 逐个调用 OpenList 登录接口验证连接
* 3. 将检测结果成功/失败失败原因检测时间写入数据库
*/
@Component
public class OpenListHeartbeatScheduler {
private static final Logger log = LoggerFactory.getLogger(OpenListHeartbeatScheduler.class);
@Autowired
private UserOpenlistRepository userOpenlistRepository;
@Autowired
private OpenListClient openListClient;
/**
* 24 小时执行一次心跳检测
* cron 表达式0 0 0/24 * * ? 表示每 24 小时整点执行
* 首次启动后延迟 5 分钟执行避免启动时集中检测
*/
@Scheduled(fixedDelay = 86400000, initialDelay = 300000)
public void checkHeartbeat() {
log.info("========== OpenList 心跳检测开始 ==========");
List<UserOpenlist> configs = userOpenlistRepository.findAllActive();
if (configs.isEmpty()) {
log.info("没有活跃的用户 OpenList 配置,跳过心跳检测");
return;
}
log.info("共检测到 {} 个用户 OpenList 配置,开始逐个检测", configs.size());
int successCount = 0;
int failCount = 0;
for (UserOpenlist config : configs) {
Long userId = config.getUserId();
try {
// 心跳检测如果 Token 仍然有效说明连接正常不需要重新登录
// 避免心跳登录导致正在使用的 Token 被作废
if (config.isTokenValid()) {
userOpenlistRepository.updateHeartbeat(userId, "success", null);
successCount++;
log.info("用户 {} OpenList 心跳检测成功Token 缓存有效): {}", userId, config.getOpenlistUrl());
continue;
}
// Token 已过期需要重新登录验证连接
Map<String, Object> testResult = openListClient.testConnection(
config.getOpenlistUrl(),
config.getOpenlistUsername(),
config.getOpenlistPassword()
);
boolean success = (boolean) testResult.get("success");
String message = (String) testResult.get("message");
if (success) {
// 连接成功更新心跳记录
userOpenlistRepository.updateHeartbeat(userId, "success", null);
// 同时刷新 Token 缓存
String token = (String) testResult.get("token");
if (token != null) {
long expireTime = System.currentTimeMillis() + 86400000;
userOpenlistRepository.updateToken(userId, token, expireTime);
}
successCount++;
log.info("用户 {} OpenList 心跳检测成功: {}", userId, config.getOpenlistUrl());
} else {
// 连接失败记录失败原因
userOpenlistRepository.updateHeartbeat(userId, "failed", message);
failCount++;
log.warn("用户 {} OpenList 心跳检测失败: {} - {}", userId, config.getOpenlistUrl(), message);
}
} catch (Exception e) {
// 检测过程异常记录异常信息
String errorMsg = "检测异常:" + e.getMessage();
userOpenlistRepository.updateHeartbeat(userId, "failed", errorMsg);
failCount++;
log.error("用户 {} OpenList 心跳检测异常: {}", userId, config.getOpenlistUrl(), e);
}
}
log.info("========== OpenList 心跳检测完成:成功 {} 个,失败 {} 个 ==========", successCount, failCount);
}
}

View File

@ -0,0 +1,427 @@
package com.wangpan.service;
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.UserOpenlist;
import com.wangpan.common.util.OpenListClient;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.util.*;
/**
* OpenList 同步服务
*
* 负责将用户 OpenList 中的文件夹层级和视频文件同步到本地数据库
* 同步策略
* 1. 自动为用户创建一个关联 OpenList 的网盘Drive
* 2. 递归遍历 OpenList 目录树在本地创建对应的文件夹DriveFolder
* 3. 只同步视频格式文件创建电影记录DriveMovie
* 4. 非视频文件和非视频文件夹会被跳过
*
* 视频格式白名单mp4, mkv, avi, mov, wmv, flv, webm, m4v, mpg, mpeg, 3gp, ts, rm, rmvb, vob, iso
*/
@Service
public class OpenListSyncService {
private static final Logger log = LoggerFactory.getLogger(OpenListSyncService.class);
/** 支持的视频文件扩展名(小写) */
private static final Set<String> VIDEO_EXTENSIONS = Set.of(
"mp4", "mkv", "avi", "mov", "wmv", "flv", "webm",
"m4v", "mpg", "mpeg", "3gp", "ts", "rm", "rmvb",
"vob", "iso"
);
@Autowired
private OpenListClient openListClient;
@Autowired
private DriveRepository driveRepository;
@Autowired
private DriveFolderRepository folderRepository;
@Autowired
private DriveMovieRepository movieRepository;
/**
* 同步结果统计
*/
public static class SyncResult {
public int driveCount = 0;
public int folderCount = 0;
public int movieCount = 0;
public int skippedFileCount = 0;
public int skippedFolderCount = 0;
public List<String> errors = new ArrayList<>();
public Map<String, Object> toMap() {
var map = new LinkedHashMap<String, Object>();
map.put("driveCount", driveCount);
map.put("folderCount", folderCount);
map.put("movieCount", movieCount);
map.put("skippedFileCount", skippedFileCount);
map.put("skippedFolderCount", skippedFolderCount);
map.put("errorCount", errors.size());
map.put("errors", errors);
return map;
}
}
/**
* 执行全量同步
* OpenList 根目录列出所有文件夹每个文件夹作为一个独立的网盘
*
* @param userId 用户ID
* @return 同步结果统计
*/
public SyncResult syncFromOpenList(Long userId) {
SyncResult result = new SyncResult();
// 1. 检查用户是否已绑定 OpenList
UserOpenlist config = openListClient.getUserOpenlist(userId);
if (config == null) {
result.errors.add("用户未绑定 OpenList");
return result;
}
log.info("用户 {} 开始从 OpenList 同步: {}", userId, config.getOpenlistUrl());
// 2. 直接从根目录列出所有文件夹不再使用需要管理员权限的存储API
log.info("用户 {} 尝试从根目录列出所有内容", userId);
return syncFromRootDirectory(userId, config, result);
}
/**
* 从根目录列表识别存储并同步
* 列出 OpenList 根目录下的所有文件夹每个文件夹作为一个独立的网盘
*/
private SyncResult syncFromRootDirectory(Long userId, UserOpenlist config, SyncResult result) {
List<Map<String, Object>> rootItems;
try {
log.info("用户 {} 开始列出 OpenList 根目录内容", userId);
rootItems = openListClient.listAll(userId, "/");
} catch (Exception e) {
log.error("用户 {} 列出 OpenList 根目录失败", userId, e);
result.errors.add("列出 OpenList 根目录失败: " + e.getMessage());
return result;
}
if (rootItems.isEmpty()) {
result.errors.add("OpenList 根目录为空,请检查 OpenList 是否已添加存储");
return result;
}
log.info("用户 {} 的 OpenList 根目录有 {} 个项目", userId, rootItems.size());
// 分离存储文件夹和文件
List<Map<String, Object>> storages = new ArrayList<>();
List<Map<String, Object>> rootFiles = new ArrayList<>();
for (var item : rootItems) {
Object isDirObj = item.get("is_dir");
boolean isDir = false;
if (isDirObj instanceof Boolean) {
isDir = (Boolean) isDirObj;
} else if (isDirObj instanceof Integer) {
isDir = ((Integer) isDirObj) == 1;
} else if (isDirObj instanceof String) {
isDir = "true".equalsIgnoreCase((String) isDirObj) || "1".equals(isDirObj);
}
if (isDir) {
storages.add(item);
} else {
rootFiles.add(item);
}
}
log.info("用户 {} 的 OpenList 根目录有 {} 个文件夹,{} 个文件",
userId, storages.size(), rootFiles.size());
// 为每个文件夹创建网盘
for (var storage : storages) {
String storageName = (String) storage.get("name");
String storagePath = "/" + storageName;
Drive drive = findOrCreateDriveForStorage(userId, config, storageName, storagePath);
if (drive == null) {
result.errors.add("创建存储 " + storageName + " 的网盘失败");
continue;
}
result.driveCount++;
syncDirectory(userId, drive.getId(), storagePath, 0L, 0, result);
updateDriveStats(drive.getId(), userId);
}
// 处理根目录文件
if (!rootFiles.isEmpty()) {
Drive defaultDrive = findOrCreateDrive(userId, config);
if (defaultDrive != null) {
result.driveCount++;
syncRootFiles(userId, defaultDrive.getId(), rootFiles, result);
updateDriveStats(defaultDrive.getId(), userId);
}
}
return result;
}
/**
* 为指定存储创建或查找网盘
*/
private Drive findOrCreateDriveForStorage(Long userId, UserOpenlist config, String storageName, String storagePath) {
// 查询用户所有网盘查找匹配该存储路径的
var drives = driveRepository.queryByUserId(userId, 1, 100);
for (var driveMap : drives) {
String mountPath = (String) driveMap.get("openlistMountPath");
if (storagePath.equals(mountPath)) {
log.info("复用已有网盘: id={}, mountPath={}", driveMap.get("id"), mountPath);
return driveRepository.findById(((Number) driveMap.get("id")).longValue());
}
}
// 创建新网盘
Drive drive = new Drive(userId, storageName, "从 OpenList 同步的网盘: " + storagePath);
drive.setOpenlistMountPath(storagePath);
Long driveId = driveRepository.insert(drive);
drive.setId(driveId);
log.info("创建新网盘: id={}, name={}, mountPath={}", driveId, storageName, storagePath);
return drive;
}
/**
* 同步根目录的文件不属于任何存储的文件
*/
private void syncRootFiles(Long userId, Long driveId, List<Map<String, Object>> rootFiles, SyncResult result) {
for (var fileItem : rootFiles) {
String fileName = (String) fileItem.get("name");
if (isVideoFile(fileName)) {
long fileSize = ((Number) fileItem.getOrDefault("size", 0L)).longValue();
String extension = getFileExtension(fileName);
String openlistFilePath = "/" + fileName;
DriveMovie movie = new DriveMovie(driveId, 0L, userId,
getMovieTitle(fileName), fileName);
movie.setFileSize(fileSize);
movie.setExtension(extension);
movie.setMimeType("video/" + extension);
movie.setOpenlistFilePath(openlistFilePath);
movie.setStorageType("openlist");
movie.setScrapeType("none");
movie.setScrapeStatus("pending");
movieRepository.insert(movie);
result.movieCount++;
} else {
result.skippedFileCount++;
}
}
}
/**
* 更新网盘统计信息
*/
private void updateDriveStats(Long driveId, Long userId) {
int totalFolders = folderRepository.countByDrive(driveId, userId);
int totalMovies = movieRepository.countByDrive(driveId, userId);
driveRepository.updateStats(driveId, totalFolders, totalMovies, 0L);
}
/**
* 查找或创建关联网盘
* 查找用户已有的 OpenList 网盘如果没有则创建新的
*
* @param userId 用户ID
* @param config OpenList 配置
* @return Drive 对象
*/
private Drive findOrCreateDrive(Long userId, UserOpenlist config) {
// 查询用户所有网盘查找已关联网盘的
var drives = driveRepository.queryByUserId(userId, 1, 100);
for (var driveMap : drives) {
String mountPath = (String) driveMap.get("openlistMountPath");
if (mountPath != null && !mountPath.isBlank()) {
// 复用已有网盘
log.info("复用已有网盘: id={}", driveMap.get("id"));
return driveRepository.findById(((Number) driveMap.get("id")).longValue());
}
}
// 创建新网盘
Drive drive = new Drive(userId, "OpenList 网盘", "从 OpenList 同步的网盘");
drive.setOpenlistMountPath(config.getMountPath() != null ? config.getMountPath() : "/");
Long driveId = driveRepository.insert(drive);
drive.setId(driveId);
log.info("创建新网盘: id={}", driveId);
return drive;
}
/**
* 递归同步目录
*
* @param userId 用户ID
* @param driveId 网盘ID
* @param openlistPath OpenList 中的路径
* @param parentFolderId 本地父文件夹ID0 表示根目录
* @param depth 当前层级深度
* @param result 同步结果统计
*/
private void syncDirectory(Long userId, Long driveId, String openlistPath,
Long parentFolderId, int depth, SyncResult result) {
try {
// 获取目录下所有内容
List<Map<String, Object>> items = openListClient.listAll(userId, openlistPath);
log.info("用户 {} 路径 {} 获取到 {} 个内容项", userId, openlistPath, items.size());
if (items.isEmpty()) {
log.warn("用户 {} 路径 {} 为空,跳过同步", userId, openlistPath);
return;
}
// 打印前几个项目用于调试
for (int i = 0; i < Math.min(3, items.size()); i++) {
var item = items.get(i);
log.info("项目 {}: name={}, is_dir={}, type={}", i, item.get("name"), item.get("is_dir"), item.get("type"));
}
// 分离文件夹和视频文件
List<Map<String, Object>> folders = new ArrayList<>();
List<Map<String, Object>> videoFiles = new ArrayList<>();
for (var item : items) {
String name = (String) item.get("name");
// 处理 is_dir 字段可能是 BooleanInteger String 类型
Object isDirObj = item.get("is_dir");
boolean isDir = false;
if (isDirObj instanceof Boolean) {
isDir = (Boolean) isDirObj;
} else if (isDirObj instanceof Integer) {
isDir = ((Integer) isDirObj) == 1;
} else if (isDirObj instanceof String) {
isDir = "true".equalsIgnoreCase((String) isDirObj) || "1".equals(isDirObj);
}
if (isDir) {
folders.add(item);
} else {
// 判断是否为视频文件
if (isVideoFile(name)) {
videoFiles.add(item);
} else {
result.skippedFileCount++;
}
}
}
// 如果该目录下没有任何视频文件且没有子文件夹跳过整个目录
if (folders.isEmpty() && videoFiles.isEmpty()) {
result.skippedFolderCount++;
return;
}
// 同步文件夹递归
for (var folderItem : folders) {
String folderName = (String) folderItem.get("name");
String childPath = normalizePath(openlistPath + "/" + folderName);
// 在本地创建文件夹记录
DriveFolder folder = new DriveFolder(driveId, userId, parentFolderId, folderName, null);
folder.setDepth(depth + 1);
Long folderId = folderRepository.insert(folder);
result.folderCount++;
log.debug("同步文件夹: {} -> 本地ID={}", childPath, folderId);
// 递归同步子目录
syncDirectory(userId, driveId, childPath, folderId, depth + 1, result);
}
// 同步视频文件
for (var fileItem : videoFiles) {
String fileName = (String) fileItem.get("name");
long fileSize = ((Number) fileItem.getOrDefault("size", 0L)).longValue();
String extension = getFileExtension(fileName);
// 构建 OpenList 文件路径
String openlistFilePath = normalizePath(openlistPath + "/" + fileName);
// 创建电影记录
DriveMovie movie = new DriveMovie(driveId, parentFolderId, userId,
getMovieTitle(fileName), fileName);
movie.setFileSize(fileSize);
movie.setExtension(extension);
movie.setMimeType("video/" + extension);
movie.setOpenlistFilePath(openlistFilePath);
movie.setStorageType("openlist");
movie.setScrapeType("none");
movie.setScrapeStatus("pending");
movieRepository.insert(movie);
result.movieCount++;
log.debug("同步视频: {} ({} bytes) -> 本地ID={}", openlistFilePath, fileSize, movie.getId());
}
} catch (Exception e) {
String errorMsg = "同步目录 " + openlistPath + " 失败: " + e.getMessage();
result.errors.add(errorMsg);
log.error("用户 {} 同步目录 {} 失败", userId, openlistPath, e);
}
}
/**
* 判断文件是否为视频格式
*/
private boolean isVideoFile(String fileName) {
if (fileName == null || fileName.isBlank()) return false;
String ext = getFileExtension(fileName).toLowerCase();
return VIDEO_EXTENSIONS.contains(ext);
}
/**
* 获取文件扩展名
*/
private String getFileExtension(String fileName) {
if (fileName == null || !fileName.contains(".")) return "";
return fileName.substring(fileName.lastIndexOf('.') + 1);
}
/**
* 从文件名提取电影标题去掉扩展名
*/
private String getMovieTitle(String fileName) {
if (fileName == null) return "";
String title = fileName;
if (title.contains(".")) {
title = title.substring(0, title.lastIndexOf('.'));
}
return title.trim();
}
/**
* 规范化路径去除多余斜杠确保以 / 开头
*/
private String normalizePath(String path) {
if (path == null) return "/";
// 替换多个连续斜杠为单个斜杠
path = path.replaceAll("/+", "/");
// 确保以 / 开头
if (!path.startsWith("/")) {
path = "/" + path;
}
// 去除末尾斜杠根目录除外
if (path.length() > 1 && path.endsWith("/")) {
path = path.substring(0, path.length() - 1);
}
return path;
}
}

View File

@ -61,3 +61,9 @@ mybatis-plus:
global-config: global-config:
db-config: db-config:
id-type: auto id-type: auto
# OpenList 配置
openlist:
base-url: ENC(nAus1Dw7kxDsHnaqF+0g1Np+4wsYAcRlIqXPxaag1knMYJlBQsf/U3AKhetuIJywsGvENyNH7cscCtlDsv4oLA==)
username: ENC(s4yi9ARKcY1dY1whS0MKMr7RjM6FMzePB6FYfmCeEilAbV927glsrdP8EUZblGun)
password: ENC(TUkBEpl8iK5kIYcuFE5tLsMeozUDfqdeep1se21GTxE9D+HfcdfsVTJEgNuol2N+)

Some files were not shown because too many files have changed in this diff Show More