feat: 新增OpenList集成功能,完善TMDB刮削与配置加密

1.  新增用户OpenList连接配置模块,包括数据库表、实体类与相关服务
2.  为驱动服务添加定时心跳检测功能,监控OpenList连接状态
3.  重构电影实体与数据库字段,移除冗余的overview字段,统一使用description存储简介
4.  完善TMDB刮削逻辑,增加空指针校验与详细失败日志
5.  配置文件敏感信息加密,添加加密脚本工具
6.  新增网关路由规则支持OpenList接口
7.  完善刮削日志查询功能,支持查看详细执行结果
This commit is contained in:
liRQ 2026-06-27 17:29:36 +08:00
parent ac0d94bd00
commit 9f19d64afb
155 changed files with 3794 additions and 518 deletions

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"

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -95,9 +95,6 @@ public class DriveMovieDO implements Serializable {
/** 标签(逗号分隔) */ /** 标签(逗号分隔) */
private String tags; private String tags;
/** 电影简介TMDB刮削 */
private String overview;
/** 主演逗号分隔TMDB刮削 */ /** 主演逗号分隔TMDB刮削 */
private String casts; private String casts;
@ -192,9 +189,6 @@ public class DriveMovieDO implements Serializable {
public String getTags() { return tags; } public String getTags() { return tags; }
public void setTags(String tags) { this.tags = tags; } public void setTags(String tags) { this.tags = tags; }
public String getOverview() { return overview; }
public void setOverview(String overview) { this.overview = overview; }
public String getCasts() { return casts; } public String getCasts() { return casts; }
public void setCasts(String casts) { this.casts = casts; } public void setCasts(String casts) { this.casts = casts; }

View File

@ -68,9 +68,6 @@ public class MovieDO implements Serializable {
/** 标签(逗号分隔) */ /** 标签(逗号分隔) */
private String tags; private String tags;
/** 电影简介TMDB刮削 */
private String overview;
/** 主演逗号分隔TMDB刮削 */ /** 主演逗号分隔TMDB刮削 */
private String casts; private String casts;
@ -132,9 +129,6 @@ public class MovieDO implements Serializable {
public String getTags() { return tags; } public String getTags() { return tags; }
public void setTags(String tags) { this.tags = tags; } public void setTags(String tags) { this.tags = tags; }
public String getOverview() { return overview; }
public void setOverview(String overview) { this.overview = overview; }
public String getCasts() { return casts; } public String getCasts() { return casts; }
public void setCasts(String casts) { this.casts = casts; } public void setCasts(String casts) { this.casts = casts; }

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

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

View File

@ -73,9 +73,9 @@ public interface MovieMapper extends BaseMapper<MovieDO> {
int updateScrapeStatus(@Param("id") Long id, @Param("scrapeStatus") String scrapeStatus, @Param("scrapeError") String scrapeError); int updateScrapeStatus(@Param("id") Long id, @Param("scrapeStatus") String scrapeStatus, @Param("scrapeError") String scrapeError);
/** /**
* 更新电影简介overview description * 更新电影简介description 字段
*/ */
@Update("UPDATE wp_drive_movie SET description = #{description}, overview = #{description}, update_time = NOW() WHERE id = #{id}") @Update("UPDATE wp_drive_movie SET description = #{description}, update_time = NOW() WHERE id = #{id}")
int updateDescription(@Param("id") Long id, @Param("description") String description); int updateDescription(@Param("id") Long id, @Param("description") String description);
/** /**

View File

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

View File

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

View File

@ -315,7 +315,7 @@ public class MovieRepository extends ServiceImpl<MovieMapper, MovieDO> {
} }
/** /**
* 更新电影简介overview description * 更新电影简介description 字段
*/ */
public int updateDescription(Long id, String description) { public int updateDescription(Long id, String description) {
return baseMapper.updateDescription(id, description); return baseMapper.updateDescription(id, description);
@ -357,11 +357,9 @@ public class MovieRepository extends ServiceImpl<MovieMapper, MovieDO> {
movieDO.setPosterUrl(movie.getPosterUrl()); movieDO.setPosterUrl(movie.getPosterUrl());
movieDO.setBackdropUrl(movie.getBackdropUrl()); movieDO.setBackdropUrl(movie.getBackdropUrl());
movieDO.setTags(movie.getTags()); movieDO.setTags(movie.getTags());
movieDO.setOverview(movie.getOverview());
movieDO.setCasts(movie.getCasts()); movieDO.setCasts(movie.getCasts());
movieDO.setDirectors(movie.getDirectors()); movieDO.setDirectors(movie.getDirectors());
movieDO.setUpdateTime(movie.getUpdateTime()); movieDO.setUpdateTime(movie.getUpdateTime());
movieDO.setCreateTime(movie.getCreateTime());
return movieDO; return movieDO;
} }
@ -385,11 +383,9 @@ public class MovieRepository extends ServiceImpl<MovieMapper, MovieDO> {
movie.setPosterUrl(movieDO.getPosterUrl()); movie.setPosterUrl(movieDO.getPosterUrl());
movie.setBackdropUrl(movieDO.getBackdropUrl()); movie.setBackdropUrl(movieDO.getBackdropUrl());
movie.setTags(movieDO.getTags()); movie.setTags(movieDO.getTags());
movie.setOverview(movieDO.getOverview());
movie.setCasts(movieDO.getCasts()); movie.setCasts(movieDO.getCasts());
movie.setDirectors(movieDO.getDirectors()); movie.setDirectors(movieDO.getDirectors());
movie.setUpdateTime(movieDO.getUpdateTime()); movie.setUpdateTime(movieDO.getUpdateTime());
movie.setCreateTime(movieDO.getCreateTime());
return movie; return movie;
} }
@ -411,7 +407,6 @@ public class MovieRepository extends ServiceImpl<MovieMapper, MovieDO> {
map.put("posterUrl", movieDO.getPosterUrl()); map.put("posterUrl", movieDO.getPosterUrl());
map.put("backdropUrl", movieDO.getBackdropUrl()); map.put("backdropUrl", movieDO.getBackdropUrl());
map.put("tags", movieDO.getTags()); map.put("tags", movieDO.getTags());
map.put("overview", movieDO.getOverview());
map.put("casts", movieDO.getCasts()); map.put("casts", movieDO.getCasts());
map.put("directors", movieDO.getDirectors()); map.put("directors", movieDO.getDirectors());
map.put("updateTime", movieDO.getUpdateTime() != null ? movieDO.getUpdateTime().toString() : null); map.put("updateTime", movieDO.getUpdateTime() != null ? movieDO.getUpdateTime().toString() : null);

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

@ -121,9 +121,6 @@ public class DriveMovie implements Serializable {
/** 标签:逗号分隔,如 "科幻,IMAX,杜比视界" */ /** 标签:逗号分隔,如 "科幻,IMAX,杜比视界" */
private String tags; private String tags;
/** 电影简介TMDB刮削 */
private String overview;
/** 主演逗号分隔TMDB刮削 */ /** 主演逗号分隔TMDB刮削 */
private String casts; private String casts;
@ -246,9 +243,6 @@ public class DriveMovie implements Serializable {
public String getTags() { return tags; } public String getTags() { return tags; }
public void setTags(String tags) { this.tags = tags; } public void setTags(String tags) { this.tags = tags; }
public String getOverview() { return overview; }
public void setOverview(String overview) { this.overview = overview; }
public String getCasts() { return casts; } public String getCasts() { return casts; }
public void setCasts(String casts) { this.casts = casts; } public void setCasts(String casts) { this.casts = casts; }

View File

@ -93,9 +93,6 @@ public class Movie implements Serializable {
/** 标签:逗号分隔,如"科幻, IMAX, 杜比视界" */ /** 标签:逗号分隔,如"科幻, IMAX, 杜比视界" */
private String tags; private String tags;
/** 电影简介TMDB刮削 */
private String overview;
/** 主演逗号分隔TMDB刮削 */ /** 主演逗号分隔TMDB刮削 */
private String casts; private String casts;
@ -176,9 +173,6 @@ public class Movie implements Serializable {
public String getTags() { return tags; } public String getTags() { return tags; }
public void setTags(String tags) { this.tags = tags; } public void setTags(String tags) { this.tags = tags; }
public String getOverview() { return overview; }
public void setOverview(String overview) { this.overview = overview; }
public String getCasts() { return casts; } public String getCasts() { return casts; }
public void setCasts(String casts) { this.casts = casts; } public void setCasts(String casts) { this.casts = casts; }

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

@ -1,89 +1,207 @@
package com.wangpan.common.util; 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.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.*; import org.springframework.http.*;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import org.springframework.web.client.RestTemplate; import org.springframework.web.client.RestTemplate;
import java.util.ArrayList;
import java.util.HashMap; import java.util.HashMap;
import java.util.List;
import java.util.Map; import java.util.Map;
/** /**
* OpenList API 客户端 * OpenList API 客户端用户级连接
* *
* 封装 OpenList REST API 调用提供统一的文件操作接口 * 每个用户绑定自己的 OpenList 实例地址账号密码互不干扰
* Token 缓存到数据库wp_user_openlist 避免每次调用都重新登录
* *
* 重要OpenList 使用内存缓存验证 Token服务重启后所有 Token 失效 * 重要OpenList 使用内存缓存验证 Token服务重启后所有 Token 失效
* 本客户端实现了自动重试机制 API 返回 401 "token is invalidated" * 本客户端实现了自动重试机制 API 返回 401 "token is invalidated"
* 自动刷新 Token 并重试请求保证 API 调用的连续性 * 自动刷新 Token 并重试请求保证 API 调用的连续性
* *
* 配置项application.yml * 使用方式
* openlist: * 1. 前端调用 /api/openlist/bind 绑定 OpenList传入地址账号密码
* base-url: http://localhost:5244 * 2. 后端验证连接 获取 Token 缓存到数据库
* username: admin * 3. 后续 API 调用自动使用用户的 OpenList 配置
* password: your_password
*/ */
@Component @Component
public class OpenListClient { public class OpenListClient {
private static final Logger log = LoggerFactory.getLogger(OpenListClient.class); private static final Logger log = LoggerFactory.getLogger(OpenListClient.class);
@Value("${openlist.base-url:http://localhost:5244}") @Autowired
private String baseUrl; private UserOpenlistRepository userOpenlistRepository;
@Value("${openlist.username:admin}")
private String username;
@Value("${openlist.password:}")
private String password;
private final RestTemplate restTemplate = new RestTemplate(); private final RestTemplate restTemplate = new RestTemplate();
private String cachedToken;
private long tokenExpireTime;
/** /**
* 获取有效的 Token自动刷新 * 测试 OpenList 连接不保存配置
* Token 有效期 24 小时提前 1 小时刷新 * 用于前端绑定前验证地址账号密码是否正确
* *
* @return JWT Token * @param openlistUrl OpenList 服务地址 http://ldfun.asia:13000
* @param openlistUsername OpenList 账号
* @param openlistPassword OpenList 密码
* @return 连接结果包含 success是否成功message提示信息tokenToken成功时返回
*/ */
private String getValidToken() { public Map<String, Object> testConnection(String openlistUrl, String openlistUsername, String openlistPassword) {
if (cachedToken == null || System.currentTimeMillis() > tokenExpireTime - 3600000) { var result = new HashMap<String, Object>();
refreshToken();
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 cachedToken;
return result;
} }
/** /**
* 刷新 Token * 绑定用户的 OpenList 连接配置
* 调用 OpenList 登录接口获取新的 JWT Token * 验证连接 保存配置 缓存 Token
*
* @param userId 用户ID
* @param openlistUrl OpenList 服务地址
* @param openlistUsername OpenList 账号
* @param openlistPassword OpenList 密码
* @return 绑定结果包含 success是否成功message提示信息
*/ */
private void refreshToken() { public Map<String, Object> bindConnection(Long userId, String openlistUrl, String openlistUsername, String openlistPassword) {
String url = baseUrl + "/api/auth/login"; var result = new HashMap<String, Object>();
Map<String, Object> requestBody = new HashMap<>(); // 1. 测试连接
requestBody.put("username", username); var testResult = testConnection(openlistUrl, openlistUsername, openlistPassword);
requestBody.put("password", password); if (!(boolean) testResult.get("success")) {
result.put("success", false);
HttpHeaders headers = new HttpHeaders(); result.put("message", testResult.get("message"));
headers.setContentType(MediaType.APPLICATION_JSON); return result;
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");
cachedToken = (String) data.get("token");
// Token 有效期设为 24 小时
tokenExpireTime = System.currentTimeMillis() + 86400000;
log.info("OpenList Token 刷新成功");
} else {
throw new RuntimeException("OpenList 登录失败:" + (body != null ? body.get("message") : "未知错误"));
} }
// 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;
}
// ==================== 文件操作 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;
} }
/** /**
@ -100,11 +218,18 @@ public class OpenListClient {
/** /**
* 执行 POST 请求带自动重试 * 执行 POST 请求带自动重试
* Token 失效时自动刷新并重试一次 * Token 失效时自动刷新并重试一次
*
* @param userId 用户ID
* @param url 请求 URL
* @param requestBody 请求体
* @return 响应结果
*/ */
private Map<String, Object> postWithRetry(String url, Map<String, Object> requestBody) { private Map<String, Object> postWithRetry(Long userId, String url, Map<String, Object> requestBody) {
String token = getValidToken(userId);
HttpHeaders headers = new HttpHeaders(); HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON); headers.setContentType(MediaType.APPLICATION_JSON);
headers.setBearerAuth(getValidToken()); headers.setBearerAuth(token);
HttpEntity<Map<String, Object>> entity = new HttpEntity<>(requestBody, headers); HttpEntity<Map<String, Object>> entity = new HttpEntity<>(requestBody, headers);
ResponseEntity<Map> response = restTemplate.postForEntity(url, entity, Map.class); ResponseEntity<Map> response = restTemplate.postForEntity(url, entity, Map.class);
@ -112,9 +237,9 @@ public class OpenListClient {
// 如果 Token 失效刷新后重试一次 // 如果 Token 失效刷新后重试一次
if (isTokenInvalidated(result)) { if (isTokenInvalidated(result)) {
log.info("OpenList Token 失效,自动刷新并重试"); log.info("用户 {} 的 OpenList Token 失效,自动刷新并重试", userId);
refreshToken(); token = getValidToken(userId); // 强制刷新
headers.setBearerAuth(cachedToken); headers.setBearerAuth(token);
entity = new HttpEntity<>(requestBody, headers); entity = new HttpEntity<>(requestBody, headers);
response = restTemplate.postForEntity(url, entity, Map.class); response = restTemplate.postForEntity(url, entity, Map.class);
result = response.getBody(); result = response.getBody();
@ -125,14 +250,20 @@ public class OpenListClient {
/** /**
* 列出指定路径下的文件 * 列出指定路径下的文件
* *
* @param path OpenList 路径 /movies * @param userId 用户ID
* @param page 页码 1 开始 * @param path OpenList 路径 /movies
* @param page 页码 1 开始
* @param perPage 每页数量 * @param perPage 每页数量
* @return 文件列表响应 * @return 文件列表响应
*/ */
public Map<String, Object> listFiles(String path, int page, int perPage) { public Map<String, Object> listFiles(Long userId, String path, int page, int perPage) {
String url = baseUrl + "/api/fs/list"; UserOpenlist config = getUserOpenlist(userId);
if (config == null) {
throw new RuntimeException("用户未绑定 OpenList");
}
String url = config.getOpenlistUrl() + "/api/fs/list";
Map<String, Object> requestBody = new HashMap<>(); Map<String, Object> requestBody = new HashMap<>();
requestBody.put("path", path); requestBody.put("path", path);
@ -140,62 +271,142 @@ public class OpenListClient {
requestBody.put("per_page", perPage); requestBody.put("per_page", perPage);
requestBody.put("refresh", false); requestBody.put("refresh", false);
return postWithRetry(url, requestBody); return postWithRetry(userId, url, requestBody);
} }
/** /**
* 获取文件详情和下载链接 * 获取文件详情和下载链接
* *
* @param path 文件路径 /movies/阿凡达.mp4 * @param userId 用户ID
* @param path 文件路径 /movies/阿凡达.mp4
* @return 文件详情响应包含 raw_url * @return 文件详情响应包含 raw_url
*/ */
public Map<String, Object> getFileDetail(String path) { public Map<String, Object> getFileDetail(Long userId, String path) {
String url = baseUrl + "/api/fs/get"; UserOpenlist config = getUserOpenlist(userId);
if (config == null) {
throw new RuntimeException("用户未绑定 OpenList");
}
String url = config.getOpenlistUrl() + "/api/fs/get";
Map<String, Object> requestBody = new HashMap<>(); Map<String, Object> requestBody = new HashMap<>();
requestBody.put("path", path); requestBody.put("path", path);
return postWithRetry(url, requestBody); return postWithRetry(userId, url, requestBody);
} }
/** /**
* 创建文件夹 * 创建文件夹
* *
* @param path 文件夹路径 /movies/科幻 * @param userId 用户ID
* @param path 文件夹路径 /movies/科幻
* @return 创建结果 * @return 创建结果
*/ */
public Map<String, Object> createFolder(String path) { public Map<String, Object> createFolder(Long userId, String path) {
String url = baseUrl + "/api/fs/mkdir"; UserOpenlist config = getUserOpenlist(userId);
if (config == null) {
throw new RuntimeException("用户未绑定 OpenList");
}
String url = config.getOpenlistUrl() + "/api/fs/mkdir";
Map<String, Object> requestBody = new HashMap<>(); Map<String, Object> requestBody = new HashMap<>();
requestBody.put("path", path); requestBody.put("path", path);
return postWithRetry(url, requestBody); return postWithRetry(userId, url, requestBody);
} }
/** /**
* 删除文件或文件夹 * 删除文件或文件夹
* *
* @param path 文件路径 * @param userId 用户ID
* @param names 文件名列表 * @param path 文件路径
* @param names 文件名列表
* @return 删除结果 * @return 删除结果
*/ */
public Map<String, Object> deleteFiles(String path, String[] names) { public Map<String, Object> deleteFiles(Long userId, String path, String[] names) {
String url = baseUrl + "/api/fs/remove"; UserOpenlist config = getUserOpenlist(userId);
if (config == null) {
throw new RuntimeException("用户未绑定 OpenList");
}
String url = config.getOpenlistUrl() + "/api/fs/remove";
Map<String, Object> requestBody = new HashMap<>(); Map<String, Object> requestBody = new HashMap<>();
requestBody.put("dir", path); requestBody.put("dir", path);
requestBody.put("names", names); requestBody.put("names", names);
return postWithRetry(url, requestBody); return postWithRetry(userId, url, requestBody);
} }
/** /**
* 获取 OpenList 服务地址 * 获取用户的 OpenList 服务地址
* *
* @return OpenList base URL * @param userId 用户ID
* @return OpenList base URL未绑定时返回 null
*/ */
public String getBaseUrl() { public String getUserBaseUrl(Long userId) {
return baseUrl; UserOpenlist config = getUserOpenlist(userId);
return config != null ? config.getOpenlistUrl() : null;
}
// ==================== 同步相关 API ====================
/**
* 获取指定路径下的所有内容自动处理分页返回全部结果
* 用于同步场景需要获取目录下所有文件和文件夹
*
* @param userId 用户ID
* @param path OpenList 路径 / /movies
* @return 内容列表包含文件和文件夹信息
*/
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);
requestBody.put("refresh", false);
Map<String, Object> result = postWithRetry(userId, url, requestBody);
if (result == null || (int) result.get("code") != 200) {
log.warn("用户 {} 列出路径 {} 第 {} 页失败: {}", userId, path, page, result);
break;
}
Map<String, Object> data = (Map<String, Object>) result.get("data");
if (data == null) break;
Map<String, Object> content = (Map<String, Object>) data.get("content");
if (content == null) break;
@SuppressWarnings("unchecked")
List<Map<String, Object>> items = (List<Map<String, Object>>) content.get("content");
if (items == null || items.isEmpty()) break;
allContent.addAll(items);
// 判断是否还有下一页
int total = (int) content.getOrDefault("total", 0);
if (page * perPage >= total) {
break;
}
page++;
}
log.info("用户 {} 路径 {} 共获取 {} 个内容项", userId, path, allContent.size());
return allContent;
} }
} }

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