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
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) {
Write-Host " Started $name (PID: $($process.Id))" -ForegroundColor Green
} 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 存储链接) */
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;
@ -76,6 +88,18 @@ public class DriveDO implements Serializable {
public String getLogoUrl() { return 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 void setFolderCount(Integer folderCount) { this.folderCount = folderCount; }

View File

@ -95,6 +95,18 @@ public class DriveMovieDO implements Serializable {
/** 标签(逗号分隔) */
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()")
private LocalDateTime updateTime;
@ -177,6 +189,18 @@ public class DriveMovieDO implements Serializable {
public String getTags() { return 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 void setUpdateTime(LocalDateTime updateTime) { this.updateTime = updateTime; }

View File

@ -68,6 +68,12 @@ public class MovieDO implements Serializable {
/** 标签(逗号分隔) */
private String tags;
/** 主演逗号分隔TMDB刮削 */
private String casts;
/** 导演逗号分隔TMDB刮削 */
private String directors;
/** 更新时间 */
@TableField(update = "NOW()")
private LocalDateTime updateTime;
@ -123,6 +129,12 @@ public class MovieDO implements Serializable {
public String getTags() { return 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 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 '网盘名称',
description VARCHAR(500) DEFAULT NULL COMMENT '网盘描述',
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 '文件夹数量',
file_count INT 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 '更新时间',
create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
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='网盘表'
""";
}

View File

@ -4,6 +4,7 @@ import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.wangpan.common.dal.dataobject.DriveMovieDO;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Update;
import java.util.List;
import java.util.Map;
@ -44,6 +45,8 @@ public interface DriveMovieMapper extends BaseMapper<DriveMovieDO> {
poster_url TEXT DEFAULT NULL COMMENT '海报URL竖版加密存储',
backdrop_url TEXT DEFAULT NULL COMMENT '背景图URL横版加密存储',
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 '更新时间',
create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
INDEX idx_drive_id (drive_id),
@ -60,4 +63,16 @@ public interface DriveMovieMapper extends BaseMapper<DriveMovieDO> {
* 复杂SQL在XML中实现
*/
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}")
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;
}
/**
* 查询用户所有网盘下所有未删除的电影分页支持关键词搜索和排序
* 适用于大数据量场景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 构建更新条件
* 注意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,
Integer rating, String tags, String category) {
public boolean update(Long id, String title, String description,
Integer rating, String tags, String posterUrl, String backdropUrl,
String resourceLibrary, String maintainer) {
var wrapper = new LambdaUpdateWrapper<DriveMovieDO>()
.eq(DriveMovieDO::getId, id)
// 基础信息字段
.set(title != null, DriveMovieDO::getTitle, title)
.set(originalName != null, DriveMovieDO::getOriginalName, originalName)
.set(subtitle != null, DriveMovieDO::getSubtitle, subtitle)
.set(description != null, DriveMovieDO::getDescription, description)
.set(rating != null, DriveMovieDO::getRating, rating)
.set(tags != null, DriveMovieDO::getTags, tags)
.set(category != null, DriveMovieDO::getCategory, category)
// 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());
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 构建更新条件
@ -319,6 +492,10 @@ public class DriveMovieRepository extends ServiceImpl<DriveMovieMapper, DriveMov
movieDO.setPosterUrl(movie.getPosterUrl());
movieDO.setBackdropUrl(movie.getBackdropUrl());
movieDO.setTags(movie.getTags());
movieDO.setCasts(movie.getCasts());
movieDO.setDirectors(movie.getDirectors());
movieDO.setOpenlistFilePath(movie.getOpenlistFilePath());
movieDO.setStorageType(movie.getStorageType());
movieDO.setUpdateTime(movie.getUpdateTime());
return movieDO;
}
@ -352,6 +529,10 @@ public class DriveMovieRepository extends ServiceImpl<DriveMovieMapper, DriveMov
movie.setPosterUrl(movieDO.getPosterUrl());
movie.setBackdropUrl(movieDO.getBackdropUrl());
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.setCreateTime(movieDO.getCreateTime());
return movie;
@ -366,13 +547,11 @@ public class DriveMovieRepository extends ServiceImpl<DriveMovieMapper, DriveMov
map.put("driveId", movieDO.getDriveId());
map.put("folderId", movieDO.getFolderId());
map.put("title", movieDO.getTitle());
map.put("subtitle", movieDO.getSubtitle());
map.put("originalName", movieDO.getOriginalName());
map.put("mimeType", movieDO.getMimeType());
map.put("fileSize", movieDO.getFileSize());
map.put("extension", movieDO.getExtension());
map.put("description", movieDO.getDescription());
map.put("resourceLibrary", movieDO.getResourceLibrary());
map.put("maintainer", movieDO.getMaintainer());
map.put("status", movieDO.getStatus());
map.put("category", movieDO.getCategory());
@ -381,9 +560,13 @@ public class DriveMovieRepository extends ServiceImpl<DriveMovieMapper, DriveMov
map.put("scrapeError", movieDO.getScrapeError());
map.put("rating", movieDO.getRating());
map.put("tags", movieDO.getTags());
map.put("casts", movieDO.getCasts());
map.put("directors", movieDO.getDirectors());
map.put("posterUrl", movieDO.getPosterUrl());
map.put("backdropUrl", movieDO.getBackdropUrl());
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("updateTime", movieDO.getUpdateTime() != null ? movieDO.getUpdateTime().toString() : null);
return map;

View File

@ -90,6 +90,61 @@ public class DriveRepository extends ServiceImpl<DriveMapper, DriveDO> {
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 构建更新条件
@ -143,6 +198,10 @@ public class DriveRepository extends ServiceImpl<DriveMapper, DriveDO> {
driveDO.setName(drive.getName());
driveDO.setDescription(drive.getDescription());
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.setFileCount(drive.getFileCount());
driveDO.setTotalSize(drive.getTotalSize());
@ -162,6 +221,8 @@ public class DriveRepository extends ServiceImpl<DriveMapper, DriveDO> {
drive.setName(driveDO.getName());
drive.setDescription(driveDO.getDescription());
drive.setLogoUrl(driveDO.getLogoUrl());
drive.setOpenlistStorageId(driveDO.getOpenlistStorageId());
drive.setOpenlistMountPath(driveDO.getOpenlistMountPath());
drive.setFolderCount(driveDO.getFolderCount());
drive.setFileCount(driveDO.getFileCount());
drive.setTotalSize(driveDO.getTotalSize());
@ -181,6 +242,10 @@ public class DriveRepository extends ServiceImpl<DriveMapper, DriveDO> {
map.put("name", driveDO.getName());
map.put("description", driveDO.getDescription());
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("fileCount", driveDO.getFileCount());
map.put("totalSize", driveDO.getTotalSize());

View File

@ -72,16 +72,19 @@ public class MovieRepository extends ServiceImpl<MovieMapper, MovieDO> {
/**
* 根据电影ID列表批量查询电影用于每日推荐缓存回查
* 自动过滤已被逻辑删除status = deleted的电影
*
* @param movieIds 电影ID列表
* @return 电影Map列表保持传入ID的顺序
* @return 电影Map列表保持传入ID的顺序不含已删除的电影
*/
public List<Map<String, Object>> findByIds(List<Long> movieIds) {
if (movieIds == null || movieIds.isEmpty()) {
return new ArrayList<>();
}
// 查询时排除已被逻辑删除的电影
var wrapper = new LambdaQueryWrapper<MovieDO>()
.in(MovieDO::getId, movieIds);
.in(MovieDO::getId, movieIds)
.ne(MovieDO::getStatus, "deleted");
var movieDOs = list(wrapper);
// 按传入的ID顺序排序
var movieMap = movieDOs.stream().collect(Collectors.toMap(MovieDO::getId, m -> m));
@ -227,14 +230,17 @@ public class MovieRepository extends ServiceImpl<MovieMapper, MovieDO> {
/**
* 随机查询指定数量的电影用于今日推荐
* 使用 MySQL RAND() 函数实现随机排序
* 自动过滤已被逻辑删除status = deleted的电影
*
* @param userId 用户ID
* @param limit 返回数量默认3
* @return 随机电影列表
* @return 随机电影列表不含已删除的电影
*/
public List<Map<String, Object>> findRandomMovies(Long userId, int limit) {
// 查询时排除已被逻辑删除的电影
var wrapper = new LambdaQueryWrapper<MovieDO>()
.eq(MovieDO::getUserId, userId)
.ne(MovieDO::getStatus, "deleted")
.last("ORDER BY RAND() LIMIT " + limit);
var movieDOs = list(wrapper);
@ -308,6 +314,27 @@ public class MovieRepository extends ServiceImpl<MovieMapper, MovieDO> {
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 转换 ====================
/**
@ -330,8 +357,9 @@ public class MovieRepository extends ServiceImpl<MovieMapper, MovieDO> {
movieDO.setPosterUrl(movie.getPosterUrl());
movieDO.setBackdropUrl(movie.getBackdropUrl());
movieDO.setTags(movie.getTags());
movieDO.setCasts(movie.getCasts());
movieDO.setDirectors(movie.getDirectors());
movieDO.setUpdateTime(movie.getUpdateTime());
movieDO.setCreateTime(movie.getCreateTime());
return movieDO;
}
@ -355,8 +383,9 @@ public class MovieRepository extends ServiceImpl<MovieMapper, MovieDO> {
movie.setPosterUrl(movieDO.getPosterUrl());
movie.setBackdropUrl(movieDO.getBackdropUrl());
movie.setTags(movieDO.getTags());
movie.setCasts(movieDO.getCasts());
movie.setDirectors(movieDO.getDirectors());
movie.setUpdateTime(movieDO.getUpdateTime());
movie.setCreateTime(movieDO.getCreateTime());
return movie;
}
@ -368,8 +397,6 @@ public class MovieRepository extends ServiceImpl<MovieMapper, MovieDO> {
map.put("id", movieDO.getId());
map.put("userId", movieDO.getUserId());
map.put("title", movieDO.getTitle());
map.put("subtitle", movieDO.getSubtitle());
map.put("resourceLibrary", movieDO.getResourceLibrary());
map.put("maintainer", movieDO.getMaintainer());
map.put("status", movieDO.getStatus());
map.put("category", movieDO.getCategory());
@ -380,6 +407,8 @@ public class MovieRepository extends ServiceImpl<MovieMapper, MovieDO> {
map.put("posterUrl", movieDO.getPosterUrl());
map.put("backdropUrl", movieDO.getBackdropUrl());
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("createTime", movieDO.getCreateTime() != null ? movieDO.getCreateTime().toString() : null);
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 转换
*
* 时间字段兜底说明
* 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) {
var userDO = new UserDO();
@ -240,8 +249,10 @@ public class UserRepository extends ServiceImpl<UserMapper, UserDO> {
userDO.setLockUntil(user.getLockUntil());
userDO.setFailCount(user.getFailCount());
userDO.setTmdbApiKey(user.getTmdbApiKey());
userDO.setCreateTime(user.getCreateTime());
userDO.setUpdateTime(user.getUpdateTime());
// 时间字段兜底未设置时使用当前时间避免 NOT NULL 约束报错
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.setRemark(user.getRemark());
return userDO;

View File

@ -40,6 +40,12 @@ public class Drive implements Serializable {
/** 网盘 Logo URL存储在 MinIO 中的图片链接 */
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;
// ==================== 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 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 void setFolderCount(Integer folderCount) { this.folderCount = folderCount; }
@ -126,6 +146,12 @@ public class Drive implements Serializable {
public LocalDateTime getCreateTime() { return 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方法重写 ====================
@Override

View File

@ -121,6 +121,20 @@ public class DriveMovie implements Serializable {
/** 标签:逗号分隔,如 "科幻,IMAX,杜比视界" */
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 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 void setUpdateTime(LocalDateTime updateTime) { this.updateTime = updateTime; }
public LocalDateTime getCreateTime() { return 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方法重写 ====================
@Override

View File

@ -93,6 +93,12 @@ public class Movie implements Serializable {
/** 标签:逗号分隔,如"科幻, IMAX, 杜比视界" */
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 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 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 = ")";
/**
* 默认加密算法 - PBEWithMD5AndDES
* PBE = Password Based Encryption基于密码的加密
* MD5 + DES 是经典组合兼容性好
* 如需更高安全性可改为 PBEWITHSHA256AND256BITAES-CBC-BC
* 默认加密算法 - Spring Jasypt 配置保持一致
* 必须与 application.yml jasypt.encryptor.algorithm 相同
*/
private static final String DEFAULT_ALGORITHM = "PBEWithMD5AndDES";
private static final String DEFAULT_ALGORITHM = "PBEWITHHMACSHA512ANDAES_256";
/**
* 默认加密密钥仅用于开发环境
* 生产环境必须通过环境变量覆盖此值
* -Djasypt.encryptor.password=你的密钥
* 或设置环境变量JASYPT_ENCRYPTOR_PASSWORD=你的密钥
* 默认加密密钥 - Spring Jasypt 配置保持一致
* 必须与 application.yml 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() {
StandardPBEStringEncryptor newEncryptor = new StandardPBEStringEncryptor();
// 创建配置对象使用SimplePBEConfig实现类
SimplePBEConfig config = new SimplePBEConfig();
config.setAlgorithm(DEFAULT_ALGORITHM);
config.setPassword(getEncryptionPassword());
config.setKeyObtentionIterations(1000); // 密钥派生迭代次数
config.setKeyObtentionIterations(1000);
config.setIvGeneratorClassName("org.jasypt.iv.RandomIvGenerator");
newEncryptor.setConfig(config);
newEncryptor.setStringOutputType("base64");
return newEncryptor;
}

View File

@ -51,8 +51,8 @@ public class MinioUtil {
@Value("${minio.bucketName}")
private String bucketName;
/** 预签名 URL 有效期(7天 */
private static final int PRESIGNED_URL_EXPIRY_DAYS = 7;
/** 预签名 URL 有效期(365天确保在资源生命周期内 URL 始终有效 */
private static final int PRESIGNED_URL_EXPIRY_DAYS = 365;
/** MinIO 客户端实例(单例模式) */
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.autoconfigure.SpringBootApplication;
import org.springframework.scheduling.annotation.EnableScheduling;
import com.ulisesbocchio.jasyptspringboot.annotation.EnableEncryptableProperties;
/**
@ -10,6 +11,7 @@ import com.ulisesbocchio.jasyptspringboot.annotation.EnableEncryptableProperties
*/
@SpringBootApplication
@EnableEncryptableProperties
@EnableScheduling
public class DriveServiceApplication {
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.DriveMovieMapper;
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.SchemaUtil;
import jakarta.annotation.Resource;
@ -64,11 +65,12 @@ public class DatabaseInitializer {
return;
}
// 创建网盘相关表使用各 Mapper 中定义的建表SQL
// 创建网盘相关表使用各 Mapper 中定义的建表 SQL
SchemaUtil.createTableIfNotExists(conn, "wp_drive", DriveMapper.CREATE_TABLE_SQL);
SchemaUtil.createTableIfNotExists(conn, "wp_drive_folder", DriveFolderMapper.CREATE_TABLE_SQL);
SchemaUtil.createTableIfNotExists(conn, "wp_drive_movie", DriveMovieMapper.CREATE_TABLE_SQL);
SchemaUtil.createTableIfNotExists(conn, "wp_icon", IconMapper.CREATE_TABLE_SQL);
SchemaUtil.createTableIfNotExists(conn, "wp_user_openlist", UserOpenlistMapper.CREATE_TABLE_SQL);
// 检查并添加新字段兼容旧表结构升级
SchemaUtil.ensureColumnExists(conn, "wp_drive", "logo_url",
@ -86,6 +88,30 @@ public class DatabaseInitializer {
SchemaUtil.ensureColumnExists(conn, "wp_drive_movie", "backdrop_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 字段
SchemaUtil.ensureColumnExists(conn, "wp_icon", "logo_url",
"VARCHAR(500) DEFAULT NULL COMMENT 'Logo 图片 URLMinIO 存储)'");
@ -98,6 +124,26 @@ public class DatabaseInitializer {
SchemaUtil.ensureColumnExists(conn, "wp_icon", "link_type",
"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 替代
try (var checkStmt = conn.createStatement();
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();
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.interceptor.AuthInterceptor;
import com.wangpan.common.util.MinioUtil;
import com.wangpan.common.util.OpenListClient;
import jakarta.servlet.http.HttpServletRequest;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import java.io.ByteArrayInputStream;
@ -41,6 +43,9 @@ public class DriveController {
private final DriveFolderRepository folderRepository;
private final DriveMovieRepository movieRepository;
@Autowired
private OpenListClient openListClient;
/**
* 构造函数注入 Repository 依赖
*/
@ -82,6 +87,9 @@ public class DriveController {
try {
String logoUrl = uploadDriveLogo(logoData);
drive.setLogoUrl(logoUrl);
// 设置 Logo 初始状态为 normal并记录首次访问时间
drive.setLogoStatus("normal");
drive.setLogoLastAccessTime(java.time.LocalDateTime.now());
} catch (Exception e) {
log.error("Logo上传失败", e);
return Result.error("创建网盘失败Logo图片上传失败");
@ -112,6 +120,15 @@ public class DriveController {
var list = driveRepository.queryByUserId(user.getId(), pageNo, pageSize);
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>();
result.put("list", list);
result.put("total", total);
@ -134,6 +151,11 @@ public class DriveController {
return Result.error("查询失败:网盘不存在或无权访问");
}
// 更新 Logo 最后访问时间触发回收策略重置
if (drive.getLogoUrl() != null && !drive.getLogoUrl().isBlank()) {
driveRepository.updateLogoAccessTime(id);
}
return Result.success("查询成功", driveToMap(drive));
}
@ -165,6 +187,8 @@ public class DriveController {
if (logoData != null && !logoData.isBlank()) {
try {
logoUrl = uploadDriveLogo(logoData);
// 更新 Logo 时重置状态为 normal并刷新访问时间
driveRepository.updateLogoAccessTime(id);
} catch (Exception e) {
log.error("Logo更新失败", e);
return Result.error("更新网盘失败Logo图片上传失败");
@ -386,6 +410,16 @@ public class DriveController {
movie.setMimeType(detectMimeType(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);
if (id != null) {
movie.setId(id);
@ -459,18 +493,21 @@ public class DriveController {
* 更新电影信息
* PUT /api/drive/{driveId}/movies/{movieId}
*
* 请求体所有字段可选
* 请求体所有字段可选只传需要更新的字段
* {
* "title": "新标题",
* "originalName": "新文件名.mp4",
* "subtitle": "副标题",
* "description": "描述",
* "rating": 85,
* "tags": "标签1,标签2",
* "category": "分类"
* "posterUrl": "海报URL",
* "backdropUrl": "背景图URL",
* "casts": "主演列表",
* "directors": "导演列表",
* "resourceLibrary": "资源库来源",
* "maintainer": "维护者"
* }
*
* 说明用于 TMDB 刮削有误时用户自定义修改文件名等信息
* 说明用于 TMDB 刮削有误时用户自定义修改文件名等信息也支持手动补充刮削字段
*/
@PutMapping("/{driveId}/movies/{movieId}")
public Result<?> updateMovie(@PathVariable("driveId") Long driveId,
@ -484,19 +521,38 @@ public class DriveController {
return Result.error("更新失败:电影不存在或无权访问");
}
// 基础信息字段
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 tags = (String) data.get("tags");
// 评分字段需要类型转换
Object ratingObj = data.get("rating");
Integer rating = null;
if (ratingObj != null) {
rating = ((Number) ratingObj).intValue();
}
String tags = (String) data.get("tags");
String category = (String) data.get("category");
boolean success = movieRepository.update(movieId, title, 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) {
return Result.success("更新成功");
} else {
@ -619,6 +675,37 @@ public class DriveController {
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
*/
@ -628,6 +715,8 @@ public class DriveController {
map.put("name", drive.getName());
map.put("description", drive.getDescription());
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("fileCount", drive.getFileCount());
map.put("totalSize", drive.getTotalSize());
@ -682,6 +771,11 @@ public class DriveController {
/**
* DriveMovie 实体转 Map
*
* TMDB 刮削字段castsdirectorsposterUrlbackdropUrlrating
* 仅在刮削成功scrapeStatus = "success"时返回实际值否则返回 null
*
* 已移除字段subtitleoriginalNamecategoryoverview不再对外暴露
*/
private Map<String, Object> movieToMap(DriveMovie movie) {
var map = new LinkedHashMap<String, Object>();
@ -689,8 +783,6 @@ public class DriveController {
map.put("driveId", movie.getDriveId());
map.put("folderId", movie.getFolderId());
map.put("title", movie.getTitle());
map.put("subtitle", movie.getSubtitle());
map.put("originalName", movie.getOriginalName());
map.put("mimeType", movie.getMimeType());
map.put("fileSize", movie.getFileSize());
map.put("extension", movie.getExtension());
@ -698,16 +790,159 @@ public class DriveController {
map.put("resourceLibrary", movie.getResourceLibrary());
map.put("maintainer", movie.getMaintainer());
map.put("status", movie.getStatus());
map.put("category", movie.getCategory());
map.put("scrapeType", movie.getScrapeType());
map.put("scrapeStatus", movie.getScrapeStatus());
map.put("scrapeError", movie.getScrapeError());
map.put("rating", movie.getRating());
map.put("tags", movie.getTags());
map.put("minioUrl", movie.getMinioUrl());
map.put("posterUrl", movie.getPosterUrl());
map.put("openlistFilePath", movie.getOpenlistFilePath());
map.put("storageType", movie.getStorageType());
map.put("createTime", movie.getCreateTime() != null ? movie.getCreateTime().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;
}
// ==================== 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:
db-config:
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