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. 优化重启脚本,支持传入加密密码参数
This commit is contained in:
liRQ 2026-08-31 18:04:09 +08:00
parent d8aab63d85
commit 9a3ef4f1ca
51 changed files with 898 additions and 151 deletions

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.

View File

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

View File

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

View File

@ -39,6 +39,12 @@ public class DriveDO implements Serializable {
/** 网盘 Logo URLMinIO 存储链接) */ /** 网盘 Logo URLMinIO 存储链接) */
private String logoUrl; private String logoUrl;
/** Logo 最后访问时间(用于回收策略) */
private LocalDateTime logoLastAccessTime;
/** Logo 状态normal正常/recycling回收中/deleted已删除 */
private String logoStatus;
/** OpenList 存储ID关联 OpenList 中的存储配置 */ /** OpenList 存储ID关联 OpenList 中的存储配置 */
private String openlistStorageId; private String openlistStorageId;
@ -82,6 +88,12 @@ public class DriveDO implements Serializable {
public String getLogoUrl() { return logoUrl; } public String getLogoUrl() { return logoUrl; }
public void setLogoUrl(String logoUrl) { this.logoUrl = logoUrl; } public void setLogoUrl(String logoUrl) { this.logoUrl = logoUrl; }
public LocalDateTime getLogoLastAccessTime() { return logoLastAccessTime; }
public void setLogoLastAccessTime(LocalDateTime logoLastAccessTime) { this.logoLastAccessTime = logoLastAccessTime; }
public String getLogoStatus() { return logoStatus; }
public void setLogoStatus(String logoStatus) { this.logoStatus = logoStatus; }
public String getOpenlistStorageId() { return openlistStorageId; } public String getOpenlistStorageId() { return openlistStorageId; }
public void setOpenlistStorageId(String openlistStorageId) { this.openlistStorageId = openlistStorageId; } public void setOpenlistStorageId(String openlistStorageId) { this.openlistStorageId = openlistStorageId; }

View File

@ -16,19 +16,22 @@ public interface DriveMapper extends BaseMapper<DriveDO> {
/** 建表SQL保留供 DatabaseInitializer 使用) */ /** 建表SQL保留供 DatabaseInitializer 使用) */
String CREATE_TABLE_SQL = """ String CREATE_TABLE_SQL = """
CREATE TABLE IF NOT EXISTS wp_drive ( CREATE TABLE IF NOT EXISTS wp_drive (
id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '网盘ID', id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '网盘ID',
user_id BIGINT NOT NULL COMMENT '所属用户ID', user_id BIGINT NOT NULL COMMENT '所属用户ID',
name VARCHAR(100) NOT NULL COMMENT '网盘名称', name VARCHAR(100) NOT NULL COMMENT '网盘名称',
description VARCHAR(500) DEFAULT NULL COMMENT '网盘描述', description VARCHAR(500) DEFAULT NULL COMMENT '网盘描述',
logo_url TEXT DEFAULT NULL COMMENT '网盘Logo URLMinIO存储链接', logo_url TEXT DEFAULT NULL COMMENT '网盘Logo URLMinIO存储链接',
folder_count INT NOT NULL DEFAULT 0 COMMENT '文件夹数量', logo_last_access_time DATETIME DEFAULT NULL COMMENT 'Logo最后访问时间用于回收策略',
file_count INT NOT NULL DEFAULT 0 COMMENT '文件数量', logo_status VARCHAR(20) NOT NULL DEFAULT 'normal' COMMENT 'Logo状态normal正常/recycling回收中/deleted已删除',
total_size BIGINT NOT NULL DEFAULT 0 COMMENT '总大小字节', folder_count INT NOT NULL DEFAULT 0 COMMENT '文件夹数量',
status VARCHAR(20) NOT NULL DEFAULT 'active' COMMENT '状态active/disabled', file_count INT NOT NULL DEFAULT 0 COMMENT '文件数量',
update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', total_size BIGINT NOT NULL DEFAULT 0 COMMENT '总大小字节',
create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', status VARCHAR(20) NOT NULL DEFAULT 'active' COMMENT '状态active/disabled',
update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
INDEX idx_user_id (user_id), INDEX idx_user_id (user_id),
INDEX idx_status (status) INDEX idx_status (status),
INDEX idx_logo_status (logo_status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='网盘表' ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='网盘表'
"""; """;
} }

View File

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

View File

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

View File

@ -40,6 +40,12 @@ public class Drive implements Serializable {
/** 网盘 Logo URL存储在 MinIO 中的图片链接 */ /** 网盘 Logo URL存储在 MinIO 中的图片链接 */
private String logoUrl; private String logoUrl;
/** Logo 最后访问时间(用于回收策略) */
private LocalDateTime logoLastAccessTime;
/** Logo 状态normal正常/recycling回收中/deleted已删除 */
private String logoStatus;
// ==================== 统计属性 ==================== // ==================== 统计属性 ====================
/** 文件夹数量 */ /** 文件夹数量 */
@ -116,6 +122,12 @@ public class Drive implements Serializable {
public String getLogoUrl() { return logoUrl; } public String getLogoUrl() { return logoUrl; }
public void setLogoUrl(String logoUrl) { this.logoUrl = logoUrl; } public void setLogoUrl(String logoUrl) { this.logoUrl = logoUrl; }
public LocalDateTime getLogoLastAccessTime() { return logoLastAccessTime; }
public void setLogoLastAccessTime(LocalDateTime logoLastAccessTime) { this.logoLastAccessTime = logoLastAccessTime; }
public String getLogoStatus() { return logoStatus; }
public void setLogoStatus(String logoStatus) { this.logoStatus = logoStatus; }
public Integer getFolderCount() { return folderCount; } public Integer getFolderCount() { return folderCount; }
public void setFolderCount(Integer folderCount) { this.folderCount = folderCount; } public void setFolderCount(Integer folderCount) { this.folderCount = folderCount; }

View File

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

View File

@ -169,6 +169,36 @@ public class OpenListClient {
return result; 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使用用户级连接 ==================== // ==================== 文件操作 API使用用户级连接 ====================
/** /**
@ -207,18 +237,52 @@ public class OpenListClient {
/** /**
* 检查响应是否因 Token 失效而失败 * 检查响应是否因 Token 失效而失败
* OpenList 返回格式{"code": 401, "message": "token is invalidated", ...} * OpenList 返回格式{"code": 401, "message": "token is invalidated", ...}
*
* @param response 响应体 Map
* @return 是否 Token 失效
*/ */
private boolean isTokenInvalidated(Map<String, Object> response) { private boolean isTokenInvalidated(Map<String, Object> response) {
if (response == null) return false; if (response == null) return false;
Object code = response.get("code"); Object code = response.get("code");
Object message = response.get("message"); Object message = response.get("message");
return code != null && (int) code == 401 && message != null && message.toString().contains("token is invalidated");
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 请求带自动重试 * 执行 POST 请求带自动重试
* Token 失效时自动刷新并重试一次 * Token 失效时自动刷新并重试一次
* *
* 重要OpenList Token 失效时可能返回 HTTP 401 状态码
* RestTemplate 会抛出 HttpClientErrorException 异常
* 而不是返回响应体因此需要捕获异常并检查状态码
*
* @param userId 用户ID * @param userId 用户ID
* @param url 请求 URL * @param url 请求 URL
* @param requestBody 请求体 * @param requestBody 请求体
@ -229,25 +293,110 @@ public class OpenListClient {
HttpHeaders headers = new HttpHeaders(); HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON); headers.setContentType(MediaType.APPLICATION_JSON);
headers.setBearerAuth(token); headers.set("Authorization", 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); Map<String, Object> result = null;
Map<String, Object> result = response.getBody();
// 如果 Token 失效刷新后重试一次 try {
if (isTokenInvalidated(result)) { ResponseEntity<Map> response = restTemplate.postForEntity(url, entity, Map.class);
log.info("用户 {} 的 OpenList Token 失效,自动刷新并重试", userId);
token = getValidToken(userId); // 强制刷新
headers.setBearerAuth(token);
entity = new HttpEntity<>(requestBody, headers);
response = restTemplate.postForEntity(url, entity, Map.class);
result = response.getBody(); 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; 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;
}
/** /**
* 列出指定路径下的文件 * 列出指定路径下的文件
* *
@ -350,54 +499,66 @@ public class OpenListClient {
return config != null ? config.getOpenlistUrl() : null; return config != null ? config.getOpenlistUrl() : null;
} }
// ==================== 存储管理 API ==================== // ==================== 同步相关 API ====================
/** /**
* 获取用户的所有存储列表 * 获取用户的所有存储驱动器列表
* OpenList 支持多个存储storage每个存储有独立的挂载路径 * OpenList 中的每个存储如阿里云盘百度网盘等都是一个独立的驱动器
* 注意 API 需要管理员权限普通用户调用会失败
* *
* @param userId 用户ID * @param userId 用户ID
* @return 存储列表每个存储包含 idmount_pathdriver 等信息 * @return 存储驱动器列表每个驱动器包含 idmount_pathdriver 等信息
* @throws RuntimeException API 调用失败时抛出异常
*/ */
public List<Map<String, Object>> listStorages(Long userId) { public List<Map<String, Object>> getStorageList(Long userId) {
UserOpenlist config = getUserOpenlist(userId); UserOpenlist config = getUserOpenlist(userId);
if (config == null) { if (config == null) {
throw new RuntimeException("用户未绑定 OpenList"); throw new RuntimeException("用户未绑定 OpenList");
} }
String url = config.getOpenlistUrl() + "/api/me/storages"; String url = config.getOpenlistUrl() + "/api/admin/storage/list";
log.info("用户 {} 尝试获取存储列表需要管理员权限URL: {}", userId, url);
try { Map<String, Object> requestBody = new HashMap<>();
String token = getValidToken(userId); Map<String, Object> result = postWithRetry(userId, url, requestBody);
HttpHeaders headers = new HttpHeaders(); log.info("用户 {} 存储列表 API 响应: {}", userId, result);
headers.setBearerAuth(token);
HttpEntity<Void> entity = new HttpEntity<>(headers); if (result == null) {
ResponseEntity<Map> response = restTemplate.exchange(url, HttpMethod.GET, entity, Map.class); throw new RuntimeException("存储列表 API 响应为 null");
Map<String, Object> result = response.getBody();
if (result == null || (int) result.get("code") != 200) {
log.warn("用户 {} 获取存储列表失败: {}", userId, result);
return new ArrayList<>();
}
@SuppressWarnings("unchecked")
List<Map<String, Object>> storages = (List<Map<String, Object>>) result.get("data");
if (storages == null) {
return new ArrayList<>();
}
log.info("用户 {} 共有 {} 个存储", userId, storages.size());
return storages;
} catch (Exception e) {
log.error("用户 {} 获取存储列表异常", userId, e);
return new ArrayList<>();
} }
}
// ==================== 同步相关 API ==================== 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;
}
/** /**
* 获取指定路径下的所有内容自动处理分页返回全部结果 * 获取指定路径下的所有内容自动处理分页返回全部结果
@ -406,6 +567,7 @@ public class OpenListClient {
* @param userId 用户ID * @param userId 用户ID
* @param path OpenList 路径 / /movies * @param path OpenList 路径 / /movies
* @return 内容列表包含文件和文件夹信息 * @return 内容列表包含文件和文件夹信息
* @throws RuntimeException API 调用失败时抛出异常
*/ */
public List<Map<String, Object>> listAll(Long userId, String path) { public List<Map<String, Object>> listAll(Long userId, String path) {
UserOpenlist config = getUserOpenlist(userId); UserOpenlist config = getUserOpenlist(userId);
@ -424,29 +586,46 @@ public class OpenListClient {
requestBody.put("path", path); requestBody.put("path", path);
requestBody.put("page", page); requestBody.put("page", page);
requestBody.put("per_page", perPage); requestBody.put("per_page", perPage);
requestBody.put("refresh", false);
log.info("用户 {} 请求列出路径 {} 第 {} 页,参数: {}", userId, path, page, requestBody);
Map<String, Object> result = postWithRetry(userId, url, requestBody); Map<String, Object> result = postWithRetry(userId, url, requestBody);
if (result == null || (int) result.get("code") != 200) { if (result == null) {
log.warn("用户 {} 列出路径 {} 第 {} 页失败: {}", userId, path, page, result); throw new RuntimeException("列出路径 " + path + " 失败: API 响应为 null");
break;
} }
Map<String, Object> data = (Map<String, Object>) result.get("data"); Object codeObj = result.get("code");
if (data == null) break; 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);
}
Map<String, Object> content = (Map<String, Object>) data.get("content"); Object dataObj = result.get("data");
if (content == null) break; 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") @SuppressWarnings("unchecked")
List<Map<String, Object>> items = (List<Map<String, Object>>) content.get("content"); List<Map<String, Object>> items = (List<Map<String, Object>>) data.get("content");
if (items == null || items.isEmpty()) break; if (items == null || items.isEmpty()) {
log.info("用户 {} 路径 {} 第 {} 页 content 为空(目录可能为空)", userId, path, page);
break;
}
allContent.addAll(items); allContent.addAll(items);
// 判断是否还有下一页 // 判断是否还有下一页
int total = (int) content.getOrDefault("total", 0); int total = (int) data.getOrDefault("total", 0);
log.info("用户 {} 路径 {} 第 {} 页获取 {} 项,总计 {} 项", userId, path, page, items.size(), total);
if (page * perPage >= total) { if (page * perPage >= total) {
break; break;
} }

View File

@ -32,6 +32,7 @@ com\wangpan\common\util\AuthUtil.class
com\wangpan\common\dal\dataobject\TmdbScraperLogDO.class com\wangpan\common\dal\dataobject\TmdbScraperLogDO.class
com\wangpan\common\entity\UserOpenlist.class com\wangpan\common\entity\UserOpenlist.class
com\wangpan\common\dal\dataobject\TokenDO.class com\wangpan\common\dal\dataobject\TokenDO.class
com\wangpan\common\config\MybatisPlusConfig.class
com\wangpan\common\dal\mapper\UserMapper.class com\wangpan\common\dal\mapper\UserMapper.class
com\wangpan\common\dal\dataobject\IconDO.class com\wangpan\common\dal\dataobject\IconDO.class
com\wangpan\common\dal\mapper\LoginLogMapper.class com\wangpan\common\dal\mapper\LoginLogMapper.class

View File

@ -22,6 +22,7 @@ D:\java2\wangpan\java\wangpan-common\src\main\java\com\wangpan\common\dal\mapper
D:\java2\wangpan\java\wangpan-common\src\main\java\com\wangpan\common\entity\UserOpenlist.java D:\java2\wangpan\java\wangpan-common\src\main\java\com\wangpan\common\entity\UserOpenlist.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\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\TmdbScraperLogRepository.java
D:\java2\wangpan\java\wangpan-common\src\main\java\com\wangpan\common\config\MybatisPlusConfig.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\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\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\dal\dataobject\TmdbCacheDO.java

View File

@ -136,6 +136,14 @@ public class DatabaseInitializer {
SchemaUtil.ensureColumnExists(conn, "wp_user_openlist", "heartbeat_error", SchemaUtil.ensureColumnExists(conn, "wp_user_openlist", "heartbeat_error",
"VARCHAR(500) DEFAULT NULL COMMENT '心跳检测失败原因'"); "VARCHAR(500) DEFAULT NULL COMMENT '心跳检测失败原因'");
// 检查 wp_drive 表是否有 logo_last_access_time 字段Logo 最后访问时间
SchemaUtil.ensureColumnExists(conn, "wp_drive", "logo_last_access_time",
"DATETIME DEFAULT NULL COMMENT 'Logo最后访问时间用于回收策略'");
// 检查 wp_drive 表是否有 logo_status 字段Logo 状态
SchemaUtil.ensureColumnExists(conn, "wp_drive", "logo_status",
"VARCHAR(20) NOT NULL DEFAULT 'normal' COMMENT 'Logo状态normal正常/recycling回收中/deleted已删除'");
// 移除已废弃的 icon 已被 logo_url 替代 // 移除已废弃的 icon 已被 logo_url 替代
try (var checkStmt = conn.createStatement(); try (var checkStmt = conn.createStatement();
var checkRs = checkStmt.executeQuery("SHOW COLUMNS FROM wp_drive LIKE 'icon'")) { var checkRs = checkStmt.executeQuery("SHOW COLUMNS FROM wp_drive LIKE 'icon'")) {

View File

@ -87,6 +87,9 @@ public class DriveController {
try { try {
String logoUrl = uploadDriveLogo(logoData); String logoUrl = uploadDriveLogo(logoData);
drive.setLogoUrl(logoUrl); drive.setLogoUrl(logoUrl);
// 设置 Logo 初始状态为 normal并记录首次访问时间
drive.setLogoStatus("normal");
drive.setLogoLastAccessTime(java.time.LocalDateTime.now());
} catch (Exception e) { } catch (Exception e) {
log.error("Logo上传失败", e); log.error("Logo上传失败", e);
return Result.error("创建网盘失败Logo图片上传失败"); return Result.error("创建网盘失败Logo图片上传失败");
@ -117,6 +120,15 @@ public class DriveController {
var list = driveRepository.queryByUserId(user.getId(), pageNo, pageSize); var list = driveRepository.queryByUserId(user.getId(), pageNo, pageSize);
var total = driveRepository.countByUserId(user.getId()); var total = driveRepository.countByUserId(user.getId());
// 更新所有有 Logo 的网盘的最后访问时间触发回收策略重置
for (var driveMap : list) {
Long driveId = (Long) driveMap.get("id");
String logoUrl = (String) driveMap.get("logoUrl");
if (logoUrl != null && !logoUrl.isBlank()) {
driveRepository.updateLogoAccessTime(driveId);
}
}
var result = new LinkedHashMap<String, Object>(); var result = new LinkedHashMap<String, Object>();
result.put("list", list); result.put("list", list);
result.put("total", total); result.put("total", total);
@ -139,6 +151,11 @@ public class DriveController {
return Result.error("查询失败:网盘不存在或无权访问"); return Result.error("查询失败:网盘不存在或无权访问");
} }
// 更新 Logo 最后访问时间触发回收策略重置
if (drive.getLogoUrl() != null && !drive.getLogoUrl().isBlank()) {
driveRepository.updateLogoAccessTime(id);
}
return Result.success("查询成功", driveToMap(drive)); return Result.success("查询成功", driveToMap(drive));
} }
@ -170,6 +187,8 @@ public class DriveController {
if (logoData != null && !logoData.isBlank()) { if (logoData != null && !logoData.isBlank()) {
try { try {
logoUrl = uploadDriveLogo(logoData); logoUrl = uploadDriveLogo(logoData);
// 更新 Logo 时重置状态为 normal并刷新访问时间
driveRepository.updateLogoAccessTime(id);
} catch (Exception e) { } catch (Exception e) {
log.error("Logo更新失败", e); log.error("Logo更新失败", e);
return Result.error("更新网盘失败Logo图片上传失败"); return Result.error("更新网盘失败Logo图片上传失败");
@ -696,6 +715,8 @@ public class DriveController {
map.put("name", drive.getName()); map.put("name", drive.getName());
map.put("description", drive.getDescription()); map.put("description", drive.getDescription());
map.put("logoUrl", drive.getLogoUrl()); map.put("logoUrl", drive.getLogoUrl());
map.put("logoStatus", drive.getLogoStatus());
map.put("logoLastAccessTime", drive.getLogoLastAccessTime() != null ? drive.getLogoLastAccessTime().toString() : null);
map.put("folderCount", drive.getFolderCount()); map.put("folderCount", drive.getFolderCount());
map.put("fileCount", drive.getFileCount()); map.put("fileCount", drive.getFileCount());
map.put("totalSize", drive.getTotalSize()); map.put("totalSize", drive.getTotalSize());

View File

@ -2,6 +2,7 @@ package com.wangpan.controller;
import com.wangpan.common.Result; import com.wangpan.common.Result;
import com.wangpan.common.annotation.RequireAuth; import com.wangpan.common.annotation.RequireAuth;
import com.wangpan.common.dal.repository.UserOpenlistRepository;
import com.wangpan.common.entity.User; import com.wangpan.common.entity.User;
import com.wangpan.common.entity.UserOpenlist; import com.wangpan.common.entity.UserOpenlist;
import com.wangpan.common.interceptor.AuthInterceptor; import com.wangpan.common.interceptor.AuthInterceptor;
@ -46,6 +47,9 @@ public class OpenListController {
@Autowired @Autowired
private OpenListSyncService openListSyncService; private OpenListSyncService openListSyncService;
@Autowired
private UserOpenlistRepository userOpenlistRepository;
/** /**
* 从请求属性中获取当前登录用户 AuthInterceptor 注入 * 从请求属性中获取当前登录用户 AuthInterceptor 注入
*/ */
@ -256,16 +260,35 @@ public class OpenListController {
return Result.success(null); return Result.success(null);
} }
// 实际测试 OpenList 连接验证账号密码是否仍然可用 // 检查当前 Token 是否有效如果有效就不重新登录
// 避免每次查看 info 都登录 OpenList导致之前的 Token 被作废
// OpenList "最后登录有效"模式每次登录会使旧 Token 失效
UserOpenlist config = openListClient.getUserOpenlist(user.getId()); UserOpenlist config = openListClient.getUserOpenlist(user.getId());
if (config != null) { if (config != null) {
var testResult = openListClient.testConnection( if (config.isTokenValid()) {
config.getOpenlistUrl(), // Token 缓存仍然有效不需要重新登录
config.getOpenlistUsername(), info.put("connected", true);
config.getOpenlistPassword() info.put("connectMessage", "连接正常");
); log.info("用户 {} 的 OpenList Token 缓存有效,无需重新登录", user.getId());
info.put("connected", testResult.get("success")); } else {
info.put("connectMessage", testResult.get("message")); // 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 { } else {
info.put("connected", false); info.put("connected", false);
info.put("connectMessage", "未找到连接配置"); info.put("connectMessage", "未找到连接配置");
@ -306,12 +329,25 @@ public class OpenListController {
} }
log.info("用户 {} 手动触发 OpenList 同步", user.getId()); 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()); var syncResult = openListSyncService.syncFromOpenList(user.getId());
// 根据是否有错误返回不同的结果
if (syncResult.errors.isEmpty()) { if (syncResult.errors.isEmpty()) {
// 同步完全成功
return Result.success("同步完成", syncResult.toMap()); return Result.success("同步完成", syncResult.toMap());
} else { } else {
return Result.success("同步完成(部分失败)", syncResult.toMap()); // 同步失败将错误信息拼接在 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

@ -58,7 +58,16 @@ public class OpenListHeartbeatScheduler {
for (UserOpenlist config : configs) { for (UserOpenlist config : configs) {
Long userId = config.getUserId(); Long userId = config.getUserId();
try { try {
// 调用 OpenList 登录接口验证连接 // 心跳检测如果 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( Map<String, Object> testResult = openListClient.testConnection(
config.getOpenlistUrl(), config.getOpenlistUrl(),
config.getOpenlistUsername(), config.getOpenlistUsername(),

View File

@ -77,7 +77,7 @@ public class OpenListSyncService {
/** /**
* 执行全量同步 * 执行全量同步
* OpenList 根目录开始递归遍历并同步到本地数据库 * OpenList 根目录列出所有文件夹每个文件夹作为一个独立的网盘
* *
* @param userId 用户ID * @param userId 用户ID
* @return 同步结果统计 * @return 同步结果统计
@ -94,55 +94,139 @@ public class OpenListSyncService {
log.info("用户 {} 开始从 OpenList 同步: {}", userId, config.getOpenlistUrl()); log.info("用户 {} 开始从 OpenList 同步: {}", userId, config.getOpenlistUrl());
// 2. 获取 OpenList 的所有存储 // 2. 直接从根目录列出所有文件夹不再使用需要管理员权限的存储API
List<Map<String, Object>> storages = openListClient.listStorages(userId); log.info("用户 {} 尝试从根目录列出所有内容", userId);
if (storages.isEmpty()) { return syncFromRootDirectory(userId, config, result);
log.warn("用户 {} 的 OpenList 没有可用存储,尝试从根目录同步", userId); }
// 降级处理从根目录同步创建单个 Drive
Drive drive = findOrCreateDrive(userId, config, null, null); /**
if (drive == null) { * 从根目录列表识别存储并同步
result.errors.add("创建网盘失败"); * 列出 OpenList 根目录下的所有文件夹每个文件夹作为一个独立的网盘
return result; */
} private SyncResult syncFromRootDirectory(Long userId, UserOpenlist config, SyncResult result) {
result.driveCount = 1; List<Map<String, Object>> rootItems;
syncDirectory(userId, drive.getId(), "/", 0L, 0, result); try {
updateDriveStats(drive.getId(), userId); log.info("用户 {} 开始列出 OpenList 根目录内容", userId);
rootItems = openListClient.listAll(userId, "/");
} catch (Exception e) {
log.error("用户 {} 列出 OpenList 根目录失败", userId, e);
result.errors.add("列出 OpenList 根目录失败: " + e.getMessage());
return result; return result;
} }
log.info("用户 {} 的 OpenList 共有 {} 个存储", userId, storages.size()); if (rootItems.isEmpty()) {
result.errors.add("OpenList 根目录为空,请检查 OpenList 是否已添加存储");
return result;
}
// 3. 为每个存储创建或查找对应的 Drive并同步内容 log.info("用户 {} 的 OpenList 根目录有 {} 个项目", userId, rootItems.size());
for (Map<String, Object> storage : storages) {
String storageId = String.valueOf(storage.get("id"));
String mountPath = (String) storage.get("mount_path");
String driver = (String) storage.get("driver");
log.info("用户 {} 同步存储: id={}, mountPath={}, driver={}", userId, storageId, mountPath, driver); // 分离存储文件夹和文件
List<Map<String, Object>> storages = new ArrayList<>();
List<Map<String, Object>> rootFiles = new ArrayList<>();
// 查找或创建该存储对应的 Drive for (var item : rootItems) {
Drive drive = findOrCreateDrive(userId, config, storageId, mountPath); 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) { if (drive == null) {
result.errors.add("创建存储 " + mountPath + " 的网盘失败"); result.errors.add("创建存储 " + storageName + " 的网盘失败");
continue; continue;
} }
result.driveCount++; result.driveCount++;
// 从该存储的挂载路径开始同步 syncDirectory(userId, drive.getId(), storagePath, 0L, 0, result);
String rootPath = mountPath != null ? mountPath : "/";
syncDirectory(userId, drive.getId(), rootPath, 0L, 0, result);
// 更新该网盘的统计信息
updateDriveStats(drive.getId(), userId); updateDriveStats(drive.getId(), userId);
} }
log.info("用户 {} 同步完成: 网盘={}, 文件夹={}, 电影={}, 跳过文件={}, 跳过文件夹={}, 错误={}", // 处理根目录文件
userId, result.driveCount, result.folderCount, result.movieCount, if (!rootFiles.isEmpty()) {
result.skippedFileCount, result.skippedFolderCount, result.errors.size()); Drive defaultDrive = findOrCreateDrive(userId, config);
if (defaultDrive != null) {
result.driveCount++;
syncRootFiles(userId, defaultDrive.getId(), rootFiles, result);
updateDriveStats(defaultDrive.getId(), userId);
}
}
return result; 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++;
}
}
}
/** /**
* 更新网盘统计信息 * 更新网盘统计信息
*/ */
@ -154,34 +238,30 @@ public class OpenListSyncService {
/** /**
* 查找或创建关联网盘 * 查找或创建关联网盘
* 根据 storageId 查找已有的 Drive如果没有则创建新的 * 查找用户已有的 OpenList 网盘如果没有则创建新的
* *
* @param userId 用户ID * @param userId 用户ID
* @param config OpenList 配置 * @param config OpenList 配置
* @param storageId OpenList 存储ID可为 null
* @param mountPath 挂载路径可为 null
* @return Drive 对象 * @return Drive 对象
*/ */
private Drive findOrCreateDrive(Long userId, UserOpenlist config, String storageId, String mountPath) { private Drive findOrCreateDrive(Long userId, UserOpenlist config) {
// 查询用户所有网盘查找匹配 storageId // 查询用户所有网盘查找已关联网盘
var drives = driveRepository.queryByUserId(userId, 1, 100); var drives = driveRepository.queryByUserId(userId, 1, 100);
for (var driveMap : drives) { for (var driveMap : drives) {
String existingStorageId = (String) driveMap.get("openlistStorageId"); String mountPath = (String) driveMap.get("openlistMountPath");
if (storageId != null && storageId.equals(existingStorageId)) { if (mountPath != null && !mountPath.isBlank()) {
// 复用已有网盘 // 复用已有网盘
log.info("复用已有网盘: id={}, storageId={}", driveMap.get("id"), storageId); log.info("复用已有网盘: id={}", driveMap.get("id"));
return driveRepository.findById(((Number) driveMap.get("id")).longValue()); return driveRepository.findById(((Number) driveMap.get("id")).longValue());
} }
} }
// 创建新网盘 // 创建新网盘
String driveName = mountPath != null ? "OpenList: " + mountPath : "OpenList 网盘"; Drive drive = new Drive(userId, "OpenList 网盘", "从 OpenList 同步的网盘");
Drive drive = new Drive(userId, driveName, "从 OpenList 同步的网盘"); drive.setOpenlistMountPath(config.getMountPath() != null ? config.getMountPath() : "/");
drive.setOpenlistStorageId(storageId);
drive.setOpenlistMountPath(mountPath != null ? mountPath : "/");
Long driveId = driveRepository.insert(drive); Long driveId = driveRepository.insert(drive);
drive.setId(driveId); drive.setId(driveId);
log.info("创建新网盘: id={}, storageId={}, mountPath={}", driveId, storageId, mountPath); log.info("创建新网盘: id={}", driveId);
return drive; return drive;
} }
@ -200,17 +280,35 @@ public class OpenListSyncService {
try { try {
// 获取目录下所有内容 // 获取目录下所有内容
List<Map<String, Object>> items = openListClient.listAll(userId, openlistPath); List<Map<String, Object>> items = openListClient.listAll(userId, openlistPath);
log.info("用户 {} 路径 {} 获取到 {} 个内容项", userId, openlistPath, items.size());
if (items.isEmpty()) { if (items.isEmpty()) {
log.warn("用户 {} 路径 {} 为空,跳过同步", userId, openlistPath);
return; 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>> folders = new ArrayList<>();
List<Map<String, Object>> videoFiles = new ArrayList<>(); List<Map<String, Object>> videoFiles = new ArrayList<>();
for (var item : items) { for (var item : items) {
String name = (String) item.get("name"); String name = (String) item.get("name");
boolean isDir = (boolean) item.getOrDefault("is_dir", false);
// 处理 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) { if (isDir) {
folders.add(item); folders.add(item);

View File

@ -7,4 +7,5 @@ com\wangpan\controller\OpenListController.class
com\wangpan\controller\DriveController.class com\wangpan\controller\DriveController.class
com\wangpan\DriveServiceApplication.class com\wangpan\DriveServiceApplication.class
com\wangpan\listener\IconCleanupScheduler$CleanupTask.class com\wangpan\listener\IconCleanupScheduler$CleanupTask.class
com\wangpan\listener\LogoRecycleScheduler.class
com\wangpan\listener\OpenListHeartbeatScheduler.class com\wangpan\listener\OpenListHeartbeatScheduler.class

View File

@ -6,3 +6,4 @@ D:\java2\wangpan\java\wangpan-drive-service\src\main\java\com\wangpan\service\Op
D:\java2\wangpan\java\wangpan-drive-service\src\main\java\com\wangpan\controller\IconController.java D:\java2\wangpan\java\wangpan-drive-service\src\main\java\com\wangpan\controller\IconController.java
D:\java2\wangpan\java\wangpan-drive-service\src\main\java\com\wangpan\controller\OpenListController.java D:\java2\wangpan\java\wangpan-drive-service\src\main\java\com\wangpan\controller\OpenListController.java
D:\java2\wangpan\java\wangpan-drive-service\src\main\java\com\wangpan\listener\OpenListHeartbeatScheduler.java D:\java2\wangpan\java\wangpan-drive-service\src\main\java\com\wangpan\listener\OpenListHeartbeatScheduler.java
D:\java2\wangpan\java\wangpan-drive-service\src\main\java\com\wangpan\listener\LogoRecycleScheduler.java

View File

@ -75,8 +75,10 @@ public class UserController {
Map<String, Object> data = userRepository.queryUsers(username, status, userType, pageNo, pageSize); Map<String, Object> data = userRepository.queryUsers(username, status, userType, pageNo, pageSize);
return Result.success("查询成功", data); return Result.success("查询成功", data);
} catch (Exception e) { } catch (Exception e) {
log.error("查询用户列表失败", e); // 日志带上查询条件便于定位是哪次查询触发的异常
return Result.error("系统繁忙,请稍后重试"); log.error("查询用户列表失败 | username={} status={} userType={} page={}",
username, status, userType, pageNo, e);
return Result.error(500, "系统繁忙,请稍后重试");
} }
} }
@ -94,13 +96,14 @@ public class UserController {
User currentUser = getCurrentUser(request); User currentUser = getCurrentUser(request);
log.info("管理员 [{}] 新增用户", currentUser.getUsername()); log.info("管理员 [{}] 新增用户", currentUser.getUsername());
// 提取用户名/密码供 catch 块日志使用
String username = (String) body.get("username");
String password = (String) body.get("password");
try { try {
// 1. 参数提取与校验 // 1. 参数提取与校验
String username = (String) body.get("username");
String password = (String) body.get("password");
if (username == null || username.isBlank() || password == null || password.isBlank()) { if (username == null || username.isBlank() || password == null || password.isBlank()) {
return Result.error(400, "用户名和密码不能为空"); return Result.badRequest("用户名和密码不能为空");
} }
// 2. 检查用户名是否已存在 // 2. 检查用户名是否已存在
@ -129,13 +132,22 @@ public class UserController {
newUser.setUserType(0); newUser.setUserType(0);
} }
// 设置数据库 NOT NULL 字段的默认值
newUser.setFailCount(0);
newUser.setIsDeleted(0);
// 4. 插入数据库 // 4. 插入数据库
userRepository.insert(newUser); userRepository.insert(newUser);
return Result.success("用户创建成功", null); return Result.success("用户创建成功", null);
} catch (NumberFormatException e) {
// status/userType 传了非数字值属于客户端参数格式错误返回 400 而非 500
log.warn("新增用户参数格式错误 | username={} err={}", username, e.getMessage());
return Result.badRequest("参数格式错误status/userType 必须为整数");
} catch (Exception e) { } catch (Exception e) {
log.error("新增用户失败", e); // 未知系统异常日志记录完整堆栈含用户名便于追溯对外返回笼统提示
return Result.error("系统繁忙,请稍后重试"); log.error("新增用户失败 | username={}", username, e);
return Result.error(500, "系统繁忙,请稍后重试");
} }
} }
@ -159,7 +171,7 @@ public class UserController {
// 1. 检查目标用户是否存在 // 1. 检查目标用户是否存在
User targetUser = userRepository.findById(userId); User targetUser = userRepository.findById(userId);
if (targetUser == null) { if (targetUser == null) {
return Result.error(404, "目标用户不存在"); return Result.notFound("目标用户不存在");
} }
// 2. 提取可选参数只处理明确传入的字段 // 2. 提取可选参数只处理明确传入的字段
@ -177,12 +189,16 @@ public class UserController {
if (rows > 0) { if (rows > 0) {
return Result.success("用户信息更新成功", null); return Result.success("用户信息更新成功", null);
} else { } else {
return Result.error(400, "没有需要更新的字段"); return Result.badRequest("没有需要更新的字段");
} }
} catch (NumberFormatException e) {
// status/userType 传了非数字值属于客户端参数格式错误
log.warn("修改用户参数格式错误 | userId={} err={}", userId, e.getMessage());
return Result.badRequest("参数格式错误status/userType 必须为整数");
} catch (Exception e) { } catch (Exception e) {
log.error("修改用户失败", e); log.error("修改用户失败 | userId={}", userId, e);
return Result.error("系统繁忙,请稍后重试"); return Result.error(500, "系统繁忙,请稍后重试");
} }
} }
@ -204,7 +220,7 @@ public class UserController {
// 1. 检查目标用户是否存在 // 1. 检查目标用户是否存在
User targetUser = userRepository.findById(userId); User targetUser = userRepository.findById(userId);
if (targetUser == null) { if (targetUser == null) {
return Result.error(404, "目标用户不存在"); return Result.notFound("目标用户不存在");
} }
// 2. 执行软删除 // 2. 执行软删除
@ -213,12 +229,12 @@ public class UserController {
if (rows > 0) { if (rows > 0) {
return Result.success("用户已删除", null); return Result.success("用户已删除", null);
} else { } else {
return Result.error(400, "删除失败,用户可能已被删除"); return Result.badRequest("删除失败,用户可能已被删除");
} }
} catch (Exception e) { } catch (Exception e) {
log.error("删除用户失败", e); log.error("删除用户失败 | userId={}", userId, e);
return Result.error("系统繁忙,请稍后重试"); return Result.error(500, "系统繁忙,请稍后重试");
} }
} }
} }