listObjects(String bucketName) {
if (bucketExists(bucketName)) {
ListObjectsV2Result result = amazonS3Client.listObjectsV2(bucketName);
return result.getObjectSummaries();
}
return List.of();
}
@Override
public boolean doesObjectExist(String bucketName, String objectKey) {
return amazonS3Client.doesObjectExist(bucketName, objectKey);
}
@Override
public boolean upload(String bucketName, String objectName, String localFileName) {
try {
File file = new File(localFileName);
amazonS3Client.putObject(bucketName, objectName, file);
return true;
} catch (Exception e) {
log.error("errorMsg={}", e.getMessage());
return false;
}
}
@Override
public boolean upload(String bucketName, String objectKey, MultipartFile file) {
try {
ObjectMetadata objectMetadata = new ObjectMetadata();
objectMetadata.setContentLength(file.getSize());
objectMetadata.setContentType(file.getContentType());
amazonS3Client.putObject(bucketName, objectKey, file.getInputStream(), objectMetadata);
return true;
} catch (Exception e) {
log.error("errorMsg={}", e.getMessage());
return false;
}
}
@Override
public boolean delete(String bucketName, String objectKey) {
try {
amazonS3Client.deleteObject(bucketName, objectKey);
return true;
} catch (Exception e) {
log.error("errorMsg={}", e);
return false;
}
}
@Override
public String getDownloadUrl(String bucketName, String remoteFileName, long timeout, TimeUnit unit) {
try {
Date expiration = new Date(System.currentTimeMillis() + unit.toMillis(timeout));
return amazonS3Client.generatePresignedUrl(bucketName, remoteFileName, expiration).toString();
} catch (Exception e) {
log.error("errorMsg {}", e);
return null;
}
}
@Override
@SneakyThrows
public void download2Response(String bucketName, String objectKey, HttpServletResponse response) {
S3Object s3Object = amazonS3Client.getObject(bucketName, objectKey);
response.setHeader("Content-Disposition", "attachment;filename=" + objectKey.substring(objectKey.lastIndexOf("/") + 1));
response.setContentType("application/force-download");
response.setCharacterEncoding("UTF-8");
IOUtils.copy(s3Object.getObjectContent(), response.getOutputStream());
}
}
```
* **思考:上面代码有什么问题?哪里可以优化的**
#### AI大模型编码效能提升-一键优化代码案例实战
* 上述潜在问题与风险
* 异常处理不一致:
* 多个方法中使用了不同的异常处理逻辑,部分方法直接捕获 Exception,而没有具体处理特定的异常类型。这可能导致隐藏
* 潜在的错误信息。
* 异常日志记录不完整,只记录了 e.getMessage(),而没有记录完整的堆栈信息,不利于调试。
* 资源未关闭:
* 在 download2Response 方法中,s3Object.getObjectContent() 返回的输入流没有关闭,可能会导致资源泄漏。
* 硬编码的响应头:
* download2Response 方法中的响应头设置是硬编码的,缺乏灵活性和可配置性。
* 空返回值:
* 多个方法在异常情况下返回 null 或 false,这可能会导致调用方需要额外的空值检查,增加了复杂性。
* 缺少边界条件检查:
* upload 方法中没有对 localFileName 和 file 进行有效性检查,可能会导致 NullPointerException。
* S3 客户端实例化:
* amazonS3Client 的实例化方式未明确,如果每次调用都创建新实例,可能会导致性能问题。
* 更多....
* **AI一键优化代码案例实战**
* **注意**
* **并非AI优化的代码可以直接使用,关系到Prompt编写、上下文等,务必要结合实际情况和代码审查再使用**
* **可以辅助工程师更好的优化代码和发现问题,提高程序的健壮性**
> /optimize 补充接口文档和参数注释,优化代码,统一异常和日志打印,不要使用自定义异常,出错的话log记录即可
### AI智能化云盘数据库设计和逆向工程
#### AI智能化云盘文件存储设计和核心关系
* **思考:文件存储,如果老板让去负责,你会如何设计?假如你没接触过这个领域,看同行竞品**
* 百度网盘
* 智能云盘
* 云盘存储相关设计说明
* 任何文件都有一个唯一标识,我们统一命名为 **identifier**,同个文件产生的标识是不变的
* 唯一标识(identifier)可以采用多个方案,也有对应的类库
* 哈希函数(如MD5、SHA-256)
* 优点:
* 唯一性:理论上 不同的文件内容会产生不同的哈希值,保证了标识的唯一性。
* 快速计算:哈希函数可以快速计算出文件的哈希值。
* 安全性:对于SHA-256等哈希算法,抗碰撞性较强,不易被篡改。
* 缺点:
* 安全性问题:对于MD5,由于其抗碰撞性较弱,已经不推荐用于安全敏感的应用。
* 存储和比较:哈希值需要存储和比较,对于非常大的文件系统,这可能会增加存储和计算开销。
* 基于内容的指纹(如SimHash、Locality-Sensitive Hashing)
* 优点:
* 相似性检测:适用于检测相似或重复的文件,可以容忍文件内容的微小变化。
* 减少存储:通过减少哈希值的位数来减少存储需求。
* 缺点:
* 计算复杂性:相比于简单的哈希函数,这些算法可能需要更复杂的计算。
* 误判率:在某些情况下可能会有误判,即不同的文件产生相同的指纹。
* 文件元数据组合
* 优点:
* 简单易实现:通过文件的大小、创建时间、修改时间等元数据生成标识。
* 快速检索:基于元数据的检索通常很快。
* 缺点:
* 非唯一性:不同的文件可能具有相同的元数据,特别是在文件被复制或修改的情况下。
* 不稳定性:文件的元数据(如修改时间)可能会改变,导致标识失效
* 方案:采用MD5, 相关标识可以前端和后端保持一定规则,前端上传的时候生成标识传递给后端
#### 账号表-文件表和关联关系表设计说明
* 三个关键表说明
* **account表**:存储用户的基本信息,如用户名、密码、头像等。这是用户身份验证和个性化设置的基础。
* **file表**:存储文件的元数据,包括文件名、大小、后缀、唯一标识符(MD5)等。主要用于跟踪文件的属性和文件的唯一性
* **account_file表**:
* 存储用户与文件之间的关系,包括文件的层级结构(文件夹和子文件),以及文件的类型和大小等信息。
* 这个表允许一个用户有多个子文件和文件夹,并且可以表示文件的层级关系
* 如果没有`account_file`表,
* 每个用户都重复上传,随着文件数量的增加,没有`account_file`表来组织文件结构,`file`表会变得非常大,性能问题
* 无法有效地表示文件和文件夹的层级结构
* 实现文件的移动、复制、删除等操作会变得复杂,因为没有一个明确的结构来跟踪文件的层级和用户关系
* 权限管理也会变得更加复杂,因为没有一个清晰的结构来定义哪些文件可以被哪些用户访问。

* 智能化云盘设计的3个表理解清楚
* 账号表
* 记录账号相关基础信息
* 关键字段
```
id 即后续用的 account_id
username
password
role 用户角色 COMMON, ADMIN
```
* 账号文件关系表
* 记录对应账号下的文件和文件夹、关系等
* 关键字段
```
id
account_id 账号ID
is_dir 是否是目录,0不是文件夹,1是文件夹
parent_id 上层文件夹ID,顶层文件夹为0
file_id 文件ID,真正存储的文件
file_name 文件名和实际存储的文件名区分开来,可能重命名
```
* 文件表
* 记录文件相关的物理存储信息
```
id 即file_id
account_id 哪个账号上传的
file_name 文件名
object_key 文件的key, 格式 日期/md5.拓展名,比如 2024/11/13/921674fd-cdaf-459a-be7b-109469e7050d.png
identifier 唯一标识,文件MD5
```
#### AI智能化云盘数据库设计和字段说明
* 数据库ER图设计(**后续还有调整相关表结构**)

* 导入建表语句
#### 智能化云盘数据库逆向工程配置生成
* 配置数据库
```java
public class MyBatisPlusGenerator {
public static void main(String[] args) {
String userName = "root";
String password = "xx";
String serverInfo = "127.0.0.1:3306";
String targetModuleNamePath = "/";
String dbName = "ycloud-aipan";
String[] tables = {
"account", "file","account_file","file_chunk", "file_suffix","file_type", "share", "share_file", "storage"
};
// 使用 FastAutoGenerator 快速配置代码生成器
FastAutoGenerator.create("jdbc:mysql://"+serverInfo+"/"+dbName+"?useUnicode=true&characterEncoding=utf-8&useSSL=false&serverTimezone=Asia/Shanghai&tinyInt1isBit=true", userName, password)
.globalConfig(builder -> {
builder.author("everyone") // 设置作者
.commentDate("yyyy-MM-dd")
.enableSpringdoc()
.disableOpenDir() //禁止打开输出目录
.dateType(DateType.ONLY_DATE) //定义生成的实体类中日期类型 DateType.ONLY_DATE 默认值: DateType.TIME_PACK
.outputDir(System.getProperty("user.dir") + targetModuleNamePath + "/src/main/java"); // 指定输出目录
})
.packageConfig(builder -> {
builder.parent("org.ycloud.aipan") // 父包模块名
.entity("model") //Entity 包名 默认值:entity
.mapper("mapper") //Mapper 包名 默认值:mapper
.pathInfo(Collections.singletonMap(OutputFile.xml, System.getProperty("user.dir") + targetModuleNamePath + "/src/main/resources/mapper")); // 设置mapperXml生成路,默认存放在mapper的xml下
})
.dataSourceConfig(builder -> {//Mysql下tinyint字段转换
builder.typeConvertHandler((globalConfig, typeRegistry, metaInfo) -> {
if (JdbcType.TINYINT == metaInfo.getJdbcType()) {
return DbColumnType.BOOLEAN;
}
return typeRegistry.getColumnType(metaInfo);
});
})
.strategyConfig(builder -> {
builder.addInclude(tables) // 设置需要生成的表名 可变参数
.entityBuilder()// Entity策略配置
.enableFileOverride() // 开启生成Entity层文件覆盖
.idType(IdType.ASSIGN_ID)//主键策略 雪花算法自动生成的id
.enableLombok() //开启lombok
.logicDeleteColumnName("del")// 说明逻辑删除是哪个字段
.enableTableFieldAnnotation()// 属性加上注解说明
.formatFileName("%sDO") //格式化生成的文件名称
.controllerBuilder().disable()// Controller策略配置,这里不生成Controller层
.serviceBuilder().disable()// Service策略配置,这里不生成Service层
.mapperBuilder()// Mapper策略配置
.enableFileOverride() // 开启生成Mapper层文件覆盖
.formatMapperFileName("%sMapper")// 格式化Mapper文件名称
.superClass(BaseMapper.class) //继承的父类
.enableBaseResultMap() // 开启生成resultMap,
.enableBaseColumnList() // 开启生成Sql片段
.formatXmlFileName("%sMapper"); // 格式化xml文件名称
})
.templateConfig(builder -> {
// 不生成Controller
builder.disable(TemplateType.CONTROLLER,TemplateType.SERVICE,TemplateType.SERVICE_IMPL);
})
.execute(); // 执行生成
}
}
```
### 账号模块开发和Knife4j接口文档配置
#### Knife4j接口文档工具
* 什么是Knife4j
* 一个为Java MVC框架集成Swagger生成Api文档的增强解决方案,前身是swagger-bootstrap-ui。
* 提供了新的Web页面,更符合使用习惯和审美;补充了一些注解,扩展了原生Swagger的功能;
* 是一个更小巧、轻量且功能强悍的接口文档管理工具
* 核心功能
* **文档说明**:详细列出接口文档的说明,包括接口地址、类型、请求示例、请求参数、响应示例、响应参数、响应码等信息。
* **在线调试**:提供在线接口联调功能,自动解析当前接口参数,返回接口响应内容、headers、响应时间、响应状态码等信息。
* **接口搜索**:提供强大的接口搜索功能,支持按接口地址、请求方法、接口描述等关键字进行搜索。
* **接口过滤**:提供接口过滤功能,可以根据接口分组、接口标签、接口地址等条件进行过滤。
* **自定义主题**:支持自定义主题,定制个性化的API文档界面。
* **丰富的扩展功能**:如接口排序、接口分组、接口标签等,进一步丰富了API文档管理的功能。
* 配置实战
* 添加依赖
```xml
com.github.xiaoymin
knife4j-openapi3-jakarta-spring-boot-starter
4.4.0
```
* 创建配置类
```java
/**
* Knife4j配置 ,默认是下面
*
* knife4j 访问地址:http://localhost:8080/doc.html
* Swagger2.0访问地址:http://localhost:8080/swagger-ui.html
* Swagger3.0访问地址:http://localhost:8080/swagger-ui/index.html
*/
@Slf4j
@Configuration
public class Knife4jConfig {
@Bean
public OpenAPI customOpenAPI() {
return new OpenAPI()
.info(new Info()
.title("AI智能云盘系统 API")
.version("1.0-SNAPSHOT")
.description("AI智能云盘系统")
.termsOfService("https://www.xxx.net")
.license(new License().name("Apache 2.0").url("https://www.xxx.net"))
// 添加作者信息
.contact(new Contact()
.name("anonymity") // 替换为作者的名字
.email("anonymity@qq.com") // 替换为作者的电子邮件
.url("https://www.xxx.net") // 替换为作者的网站或个人资料链接
)
);
}
}
```
* 配置Spring Boot控制台打印
```java
@Slf4j
@SpringBootApplication
public class CloudApplication {
public static void main(String[] args) throws Exception {
ConfigurableApplicationContext application = SpringApplication.run(CloudApplication.class, args);
Environment env = application.getEnvironment();
log.info("\n----------------------------------------------------------\n\t" +
"Application '{}' is running! Access URLs:\n\t" +
"Local: \t\thttp://localhost:{}\n\t" +
"External: \thttp://{}:{}\n\t" +
"API文档: \thttp://{}:{}/doc.html\n" +
"----------------------------------------------------------",
env.getProperty("spring.application.name"),
env.getProperty("server.port"),
InetAddress.getLocalHost().getHostAddress(),
env.getProperty("server.port"),
InetAddress.getLocalHost().getHostAddress(),
env.getProperty("server.port"));
}
}
```
#### 账号注册相关模块接口开发实战
* 需求
* 开发用户注册相关接口,手机号注册
* 内部使用, 不加验证码,如果需要对外则可以加入验证码逻辑
* 用户板块不做复杂权限或者多重校验处理等
* 逻辑说明
* 根据手机号查询是否重复(或者唯一索引)
* 密码加密处理
* 保存用户注册逻辑
* 其他逻辑(创建默认的存储空间,初始化根目录)
* 编码实战:
> 编写`AccountController,AccountRegisterReq,AccountService,AccountConfig`...
```sql
CREATE TABLE `account` (
`id` bigint NOT NULL COMMENT 'ID',
`username` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT '用户名',
`password` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT '密码',
`avatar_url` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '用户头像',
`phone` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '手机号',
`role` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT 'COMMON' COMMENT '用户角色 COMMON, ADMIN',
`del` tinyint DEFAULT '0' COMMENT '逻辑删除(1删除 0未删除)',
`gmt_create` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`gmt_modified` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`) USING BTREE,
UNIQUE KEY `idx_phone_uni` (`phone`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci ROW_FORMAT=DYNAMIC COMMENT='用户信息表';
```
#### 头像上传接口开发和MinIO权限配置
* 需求
* 开发头像上传接口,用户注册时候需要把头像url进行上传
* **存储到minio需要可以公开访问,和文件存储分开bucket**
* 逻辑说明
* 文件上传接口
* 返回文件访问路径
* **配置minio的头像存储bucket存储权限为public**
#### 网盘存储容量设计和根目录初始化配置
* 需求
* **问题一:新用户注册,有默认网盘存储容量,什么时候进行初始化?**
* 答案
* 用户注册的时候一并配置相关的初始化内容
* 如果是简单场景:直接调用; 复杂场景:结合消息队列
* 类似场景大家可以思考下还有哪些,各大公司拉新活动折扣
* **问题二:网盘文件存储有个根目录,这个如何进行设计?**
* 上传文件的到根目录,这个相关的parent_id是怎么填写?
* 答案:参考Linux操作系统,根目录也是一个目录

* 开发编码实战:创建文件夹
```java
//3.创建默认的存储空间
StorageDO storageDO = new StorageDO();
storageDO.setAccountId(accountDO.getId());
storageDO.setUsedSize(0L);
storageDO.setTotalSize(AccountConfig.DEFAULT_STORAGE_SIZE);
storageMapper.insert(storageDO);
//4.初始化根目录
FolderCreateReq createRootFolderReq = FolderCreateReq.builder()
.accountId(accountDO.getId())
.parentId(AccountConfig.ROOT_PARENT_ID)
.folderName(AccountConfig.ROOT_FOLDER_NAME)
.build();
accountFileService.createFolder(createRootFolderReq);
```
#### 账号登录相关模块设计和开发实战
* 需求
* 开发用户登录模块
* 配置生成JWT
* 编码实战
```java
//业务逻辑
public AccountDTO login(AccountLoginReq req) {
String encryptPassword = DigestUtils.md5DigestAsHex(( AccountConfig.ACCOUNT_SALT+ req.getPassword()).getBytes());
QueryWrapper queryWrapper = new QueryWrapper<>();
queryWrapper.eq("phone", req.getPhone()).eq("password", encryptPassword);
AccountDO accountDO = accountMapper.selectOne(queryWrapper);
return SpringBeanUtil.copyProperties(accountDO, AccountDTO.class);
}
//JWT工具
@Slf4j
public class JwtUtil {
// JWT的主题
private static final String LOGIN_SUBJECT = "XDCLASS";
/**
* token有效期1小时
*/
private static final Long SHARE_TOKEN_EXPIRE = 1000L * 60 * 60L;
//注意这个密钥长度需要足够长, 推荐:JWT的密钥,从环境变量中获取
private final static String SECRET_KEY = "xdclass.net168xdclass.net168xdclass.net168xdclass.net168";
// 签名算法
private final static SecureDigestAlgorithm ALGORITHM = Jwts.SIG.HS256;
// 使用密钥
private final static SecretKey KEY = Keys.hmacShaKeyFor(SECRET_KEY.getBytes());
// token过期时间,30天
private static final long EXPIRED = 1000 * 60 * 60 * 24 * 7;
/**
* 生成JWT
* @param accountDTO 登录账户信息
* @return 生成的JWT字符串
* @throws NullPointerException 如果传入的accountDTO为空
*/
public static String geneLoginJWT(AccountDTO accountDTO) {
if (accountDTO == null) {
throw new NullPointerException("对象为空");
}
// 创建 JWT token
String token = Jwts.builder()
.subject(LOGIN_SUBJECT)
.claim("accountId", accountDTO.getId())
.claim("username", accountDTO.getUsername())
.issuedAt(new Date())
.expiration(new Date(System.currentTimeMillis() + EXPIRED))
.signWith(KEY, ALGORITHM) // 直接使用KEY即可
.compact();
// 添加自定义前缀
return addPrefix(token);
}
/**
* 校验JWT
* @param token JWT字符串
* @return JWT的Claims部分
* @throws IllegalArgumentException 如果传入的token为空或只包含空白字符
* @throws RuntimeException 如果JWT签名验证失败、JWT已过期或JWT解密失败
*/
public static Claims checkLoginJWT(String token) {
try {
log.debug("开始校验 JWT: {}", token);
// 校验 Token 是否为空
if (token == null || token.trim().isEmpty()) {
log.error("Token 不能为空");
throw new IllegalArgumentException("Token 不能为空");
}
token = token.trim();
// 移除前缀
token = removePrefix(token);
log.debug("移除前缀后的 Token: {}", token);
// 解析 JWT
Claims payload = Jwts.parser()
.verifyWith(KEY) //设置签名的密钥, 使用相同的 KEY
.build()
.parseSignedClaims(token).getPayload();
log.info("JWT 解密成功,Claims: {}", payload);
return payload;
} catch (IllegalArgumentException e) {
log.error("JWT 校验失败: {}", e.getMessage(), e);
throw e;
} catch (io.jsonwebtoken.security.SignatureException e) {
log.error("JWT 签名验证失败: {}", e.getMessage(), e);
throw new RuntimeException("JWT 签名验证失败", e);
} catch (io.jsonwebtoken.ExpiredJwtException e) {
log.error("JWT 已过期: {}", e.getMessage(), e);
throw new RuntimeException("JWT 已过期", e);
} catch (Exception e) {
log.error("JWT 解密失败: {}", e.getMessage(), e);
throw new RuntimeException("JWT 解密失败", e);
}
}
/**
* 给token添加前缀
* @param token 原始token字符串
* @return 添加前缀后的token字符串
*/
private static String addPrefix(String token) {
return LOGIN_SUBJECT + token;
}
/**
* 移除token的前缀
* @param token 带前缀的token字符串
* @return 移除前缀后的token字符串
*/
private static String removePrefix(String token) {
if (token.startsWith(LOGIN_SUBJECT)) {
return token.replace(LOGIN_SUBJECT, "").trim();
}
return token;
}
}
```
#### 拦截器开发和ThreadLocal传递用户信息
* 需求
* 开发登录拦截器 解密JWT
* 传递登录用户信息
* request的attribute传递
* threadLocal传递
* 配置拦截器放行路径开发配置
* ThreadLocal知识点说明
* 全称thread local variable(线程局部变量)功用非常简单,使用场合主要解决多线程中数据因并发产生不一致问题。
* ThreadLocal为每一个线程都提供了变量的副本,使得每个线程在某时间访问到的并不是同一个对象
* 注意:ThreadLocal不能使用原子类型,只能使用Object类型

* 应用场景
* ThreadLocal 用作每个线程内需要独立保存信息,方便同个线程的其他方法获取该信息的场景。
* 每个线程获取到的信息可能都是不一样的,前面执行的方法保存了信息后,后续方法可以通过 ThreadLocal 直接获取到
* 类似于全局变量的概念 比如用户登录令牌解密后的信息传递(用户权限信息、从用户系统获取到的用户名、用户ID)
* 编码实战
* 开发登录拦截器 解密JWT
```java
@Component
public class LoginInterceptor implements HandlerInterceptor {
public static ThreadLocal threadLocal = new ThreadLocal<>();
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// 处理OPTIONS请求
if (HttpMethod.OPTIONS.toString().equalsIgnoreCase(request.getMethod())) {
response.setStatus(HttpStatus.NO_CONTENT.value());
return true;
}
// 从请求头或参数中获取token
String token = request.getHeader("token");
if (StringUtils.isBlank(token)) {
token = request.getParameter("token");
}
// 如果token存在,解析JWT
if (StringUtils.isNotBlank(token)) {
Claims claims = JwtUtil.checkLoginJWT(token);
if (claims == null) {
// 如果token无效,返回未登录的错误信息
CommonUtil.sendJsonMessage(response, JsonData.buildResult(BizCodeEnum.ACCOUNT_UNLOGIN));
return false;
}
// 从JWT中提取用户信息
Long accountId = Long.valueOf( claims.get("accountId")+"");
String userName = (String) claims.get("username");
// 创建 AccountDTO 对象
AccountDTO accountDTO = AccountDTO.builder()
.id(accountId)
.username(userName)
.build();
// 将用户信息存入 ThreadLocal
threadLocal.set(accountDTO);
return true;
}
// 如果没有token,返回未登录的错误信息
CommonUtil.sendJsonMessage(response, JsonData.buildResult(BizCodeEnum.ACCOUNT_UNLOGIN));
return false;
}
@Override
public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
// 清理 ThreadLocal 中的用户信息
threadLocal.remove();
}
}
```
* 配置拦截器放行路径开发配置
```java
@Configuration
@Slf4j
public class InterceptorConfig implements WebMvcConfigurer {
@Resource
private LoginInterceptor loginInterceptor;
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(loginInterceptor)
//添加拦截的路径
.addPathPatterns("/api/account/*/**","/api/file/*/**","/api/share/*/**")
//排除不拦截
.excludePathPatterns("/api/account/*/register","/api/account/*/login","/api/account/*/upload_avatar",
"/api/share/*/check_share_code","/api/share/*/visit","/api/share/*/detail_no_code","/api/share/*/detail_with_code");
}
}
```
#### 首页前后端交互逻辑和账号详情接口开发
* 需求
* 网盘存储首页进入,会触发哪些请求?
* 逻辑说明
* 步骤一
* 进入首页需要先获取用户的根目录文件夹ID
* 通过根目录文件夹ID去获取对应的文件列表
* 步骤二
* 首页需要显示用户的存储空间
* 编码实战
```java
public AccountDTO queryDetail(Long accountId) {
//账号详情
AccountDO accountDO = accountMapper.selectById(accountId);
AccountDTO accountDTO = SpringBeanUtil.copyProperties(accountDO, AccountDTO.class);
//存储信息
StorageDO storageDO = storageMapper.selectOne(new QueryWrapper().eq("account_id", accountId));
StorageDTO storageDTO = SpringBeanUtil.copyProperties(storageDO, StorageDTO.class);
accountDTO.setStorageDTO(storageDTO);
//根文件相关信息
AccountFileDO accountFileDO = accountFileMapper.selectOne(new QueryWrapper()
.eq("account_id", accountId)
.eq("parent_id", AccountConfig.ROOT_PARENT_ID));
// bug处理
if (accountFileDO != null) {
accountDTO.setRootFileId(accountFileDO.getId());
accountDTO.setRootFileName(accountFileDO.getFileName());
}
return accountDTO;
}
```
#### AI编码-账号注册和登录单元测试生成
* 需求
* 利用AI编写账号注册和登录
* 验证相关接口逻辑
* 单元测试实战
> 操作:复制controller对应的接口,右键,选择生成测试
### 技术架构图答案+AI接口文档快速生成
* 技术架构图
AI补充接口文档和注释字段操作
* AI补充API接口文档
* `补充knife4j的接口文档配置内容,@Tag @Operation等注解,使用v3`
* AI补充字段解释说明
* `补充knife4j接口文档信息,使用@Schema,使用v3,添加参数举例`
### 网盘文件模块基础设计和开发
#### 资源访问安全之web常见越权攻击和防范
* **越权攻击介绍**
* 是Web应用程序中一种常见的漏洞,由于其存在范围广、危害 大, 列为Web应用十大安全隐患的第二名
* 指应用在检查授权时存在纰漏,使得攻击者在获得低权限用户账户后,利用一些方式绕过权限检查,访问或者操作其他用户
* 产生原因:主要是因为开发人员在对数据进行增、删、改、查询时对客户端请求的数据过分相信,而遗漏了权限的判定
* 比如网盘里面:分享、转存、查看文件的时候都容易触发
* **水平越权攻击**
- 指的是攻击者通过某种手段获取了与自己权限相同的其他账户的访问权限。
- 用户A能够访问用户B的账户信息,尽管他们都是普通用户,但A不应该能够访问B的数据。
- 技术实现方式
- **参数篡改**:
- 攻击者通过修改请求中的用户ID参数,尝试访问其他同级别用户的资源。
- 在电商系统中,用户A通过修改订单ID参数,尝试查看或修改用户B的订单信息。
- **会话劫持**:
- 攻击者通过某种方式获取了其他用户的会话信息,从而冒充该用户进行操作,这可能导致水平越权问题。
- **利用前端安全漏洞**:
- 如果前端安全措施不当,攻击者可能会通过修改前端显示的界面元素,如隐藏的URL或参数,来访问其他用户的数据。
* **水平越权攻击的防范**:
- **权限验证**:确保每次数据访问都进行严格的权限验证。
- **数据隔离**:不同用户的数据应该在数据库层面进行隔离。
- **会话管理**:使用安全的会话管理机制,如HTTPS、Token等。
* **垂直越权攻击**
- 指的是攻击者通过某种手段获取了更高权限的账户的访问权限。
- 普通用户获取了管理员账户或者更高的权限。
- 技术实现方式
- **权限配置错误**:
- 由于系统配置不当,普通用户能够执行管理员级别的操作,例如通过修改请求中的权限参数来提升权限。
- **利用系统漏洞**:
- 攻击者利用系统或应用程序的漏洞提升权限,例如通过SQL注入攻击来执行管理员级别的数据库操作。
- **多阶段功能滥用**:
- 在多阶段功能实现中,如果后续阶段不再验证用户身份,攻击者可能通过抓包修改参数值,实现越权操作,如修改任意用户密码
- **垂直越权攻击的防范**:
- **最小权限原则**:用户和系统组件应该只拥有完成其任务所必需的最小权限。
- **权限审查**:定期审查权限设置,确保没有不必要的权限提升。
- **安全编码**:遵循安全编码实践,避免常见的安全漏洞,如SQL注入、跨站脚本(XSS)等。
- **安全审计**:实施安全审计,监控和记录关键操作,以便在发生安全事件时进行追踪。
* 智能化网盘项目里面的避免越权处理方案
* 相关文件数据处理,加入account_id确认
* 角色权限通过role进行确认操作
#### 文件模块开发之查询文件列表接口开发
* 需求
* 网盘存储首页进入,会触发哪些请求?**获取当前用户根目录文件夹**
* 根据根目录文件夹查询对应的文件列表
* 进入相关的指定文件夹,查询对应的子文件
* 注意事项
* 查询的时候都需要加入账号相关进行确认
**前面代码相对会简单点,逐步代码封装和抽取就会上升难度,**
* 编码实战
```java
@GetMapping("list")
public JsonData list(@RequestParam(value = "parent_id")Long parentId){
Long accountId = LoginInterceptor.threadLocal.get().getId();
List list = fileService.listFile(accountId,parentId);
return JsonData.buildSuccess(list);
}
public List listFile(Long accountId, Long parentId) {
List accountFileDOList = accountFileMapper.selectList(new QueryWrapper()
.eq("account_id", accountId).eq("parent_id", parentId)
.orderByDesc("is_dir")
.orderByDesc("gmt_create")
);
return SpringBeanUtil.copyProperties(accountFileDOList, AccountFileDTO.class);
}
```
#### 创建文件夹相关接口设计和开发
* 需求
* 开发网盘里面可以创建文件夹

* 业务逻辑方法梳理(**哪些方法会其他地方复用**)
* 检查父文件ID是否存在(抽)
* 生成账号文件信息
* 检查文件名是否重复(抽)
* 保存相关账号文件夹信息
* 编码实战
```java
@PostMapping("/create_folder")
public JsonData createFolder(@RequestBody FolderCreateReq req){
req.setAccountId(LoginInterceptor.threadLocal.get().getId());
fileService.createFolder(req);
return JsonData.buildSuccess();
}
AccountFileDTO accountFileDTO = AccountFileDTO.builder().accountId(req.getAccountId())
.parentId(req.getParentId())
.fileName(req.getFolderName())
.isDir(FolderFlagEnum.YES.getCode()).build();
return saveAccountFile(accountFileDTO);
```
* 需求
* 处理用户和文件的映射存储,存储文件和文件夹都可以
* 编码实战
```java
/**
* 处理用户和文件的映射存储,存储文件和文件夹都可以
*
* 1、检查父文件ID是否存在,避免越权
* 2、检查文件名是否重复
* 3、保存文件信息
*
* @return
*/
private Long saveAccountFile(AccountFileDTO accountFileDTO) {
//检查父文件ID是否存在
checkParentFileId(accountFileDTO);
//存储文件信息
AccountFileDO accountFileDO = SpringBeanUtil.copyProperties(accountFileDTO, AccountFileDO.class);
//检查文件名是否重复
processFileNameDuplicate(accountFileDO);
accountFileMapper.insert(accountFileDO);
return accountFileDO.getId();
}
```
#### 网盘文件重命名相关接口
* 需求
* 开发网盘文件重命名接口,包括文件夹和文件一样适用
* 业务逻辑方法梳理
* 文件ID是否存在,避免越权
* 新旧文件名称不能一样
* 也不能用同层文件夹的名称,通过parent_id进行查询
* 编码实战
```java
@Override
public void renameFile(FileUpdateReq req) {
//文件ID是否存在,避免越权
AccountFileDO accountFileDO = accountFileMapper.selectOne(new QueryWrapper()
.eq("id", req.getFileId())
.eq("account_id", req.getAccountId()));
if (accountFileDO == null) {
log.error("文件ID不存在,请检查:{}", req);
throw new BizException(BizCodeEnum.FILE_NOT_EXISTS);
} else {
//新旧文件名称不能一样
if (Objects.equals(accountFileDO.getFileName(), req.getNewFilename())) {
log.error("新旧文件名称不能一样,{}", req);
throw new BizException(BizCodeEnum.FILE_RENAME_REPEAT);
} else {
//同层的文件或者文件夹也不能一样
Long selectCount = accountFileMapper.selectCount(new QueryWrapper()
.eq("account_id", req.getAccountId())
.eq("parent_id", accountFileDO.getParentId())
.eq("file_name", req.getNewFilename()));
if (selectCount > 0) {
log.error("同层的文件或者文件夹也不能一样,{}", req);
throw new BizException(BizCodeEnum.FILE_RENAME_REPEAT);
} else {
accountFileDO.setFileName(req.getNewFilename());
accountFileMapper.updateById(accountFileDO);
}
}
}
}
```
#### 接口测试工具-文件夹创建-查询-重命名接口测试
* 接口测试工具
* Apifox和Postman都是流行的API接口管理工具
* 选择哪个工具取决于具体的使用场景和需求
* 接口工具核心功能
* 支持多种HTTP请求方法(如GET、POST、PUT、DELETE等),允许用户设置请求头、请求体、查询参数等
* 环境变量允许用户存储和管理多个环境(如开发、测试、生产环境)的配置信息,便于在不同环境间切换
* 我们采用ApiFox录入相关接口进行测试
* 配置全局环境变量
* 录入相关接口模块
* bug修复
```JAVA
//bug1
/**
* 检查父文件是否存在
* @param accountFileDTO
*/
private void checkParentFileId(AccountFileDTO accountFileDTO) {
if(accountFileDTO.getParentId()!=0){
AccountFileDO accountFileDO = accountFileMapper.selectOne(
new QueryWrapper()
.eq("id", accountFileDTO.getParentId())
.eq("account_id", accountFileDTO.getAccountId()));
if(accountFileDO == null){
throw new BizException(BizCodeEnum.FILE_NOT_EXISTS);
}
}
}
//bug2
@AllArgsConstructor
@NoArgsConstructor
public class AccountFileDTO
//bug3
private void processFileNameDuplicate(AccountFileDO accountFileDO) {
Long selectCount = accountFileMapper.selectCount(new QueryWrapper()
.eq("account_id", accountFileDO.getAccountId())
.eq("parent_id", accountFileDO.getParentId())
.eq("is_dir", accountFileDO.getIsDir())
.eq("file_name", accountFileDO.getFileName()));
if(selectCount>0){
//处理重复文件夹
if(Objects.equals(accountFileDO.getIsDir(), FolderFlagEnum.YES.getCode())){
accountFileDO.setFileName(accountFileDO.getFileName()+"_"+System.currentTimeMillis());
}else {
//处理重复文件名,提取文件拓展名
String[] split = accountFileDO.getFileName().split("\\.");
accountFileDO.setFileName(split[0]+"_"+System.currentTimeMillis()+"."+split[1]);
}
}
}
```
#### Swagger+Apifox
1. 使用AI将源码中的Controller接口和Req对象生成knife4j注释 :参考: AI补充接口文档和注释字段操作
2. 注册Apifox账号,配置 API 访问令牌
3. 在idea中安装Apifox插件,通过插件将对应的接口同步到Apifox。[快速上手 - Apifox 帮助文档](https://docs.apifox.com/doc-5743620)
### 查询文件树接口设计和文件操作进阶
#### 【难点】查询文件树接口应用场景和流程设计讲解
* 什么是文件树和应用场景
* 多层级展示文件夹列表和子文件夹
* 用途包括移动、复制、转存文件
* 开发这个接口有多种方式
* 递归和非递归,我们采用非递归,内存里面操作的方式
* 内存里面操作也有多种实现方式,比如分组或者遍历处理

* 后端接口协议分析,倒推代码处理逻辑
```json
{
"code": 0,
"data": [
{
"id": 1871837581885325314,
"parentId": 0,
"label": "全部文件夹",
"children": [
{
"id": 1871838400252755969,
"parentId": 1871837581885325314,
"label": "a2",
"children": [
{
"id": 1872208466167484418,
"parentId": 1871838400252755969,
"label": "b2",
"children": []
},
{
"id": 1872208451487420418,
"parentId": 1871838400252755969,
"label": "b1",
"children": [
{
"id": 1872208603140870145,
"parentId": 1872208451487420418,
"label": "c2(1)",
"children": []
}
},
{
"id": 1872208573759770626,
"parentId": 1872208451487420418,
"label": "c1",
"children": []
}
]
},
{
"id": 1872208480121933825,
"parentId": 1871838400252755969,
"label": "b3",
"children": []
}
]
},
{
"id": 1871838384587030529,
"parentId": 1871837581885325314,
"label": "a1",
"children": []
}
]
}
],
"msg": null,
"success": true
}
```
* 代码逻辑思路
* 查询用户的全部文件夹列表

* 构建一个Map,key为文件夹ID,value为FolderTreeNodeDTO对象

* 构建文件夹树,遍历文件夹映射,为每个文件夹找到其子文件夹

* 返回根节点(parentId为0的节点)过滤出根文件夹即可

#### 【难点】查询文件树接口编码案例实战
* 编码实战
```json
/**
* 获取文件树接口,非递归方式
* 1、查询当前用户的所有文件夹
* 2、拼装文件夹树
* @param accountId
* @return
*/
@Override
public List fileTree(Long accountId) {
// 查询当前用户的所有文件夹
List folderList = accountFileMapper.selectList(new QueryWrapper()
.eq("account_id", accountId)
.eq("is_dir", FolderFlagEnum.YES.getCode()));
// 拼装文件夹树列表
if (CollectionUtils.isEmpty(folderList)) {
return List.of();
}
// 构建一个Map,key为文件夹ID,value为FolderTreeNodeDTO对象
Map folderMap = folderList.stream().collect(Collectors.toMap(
AccountFileDO::getId,
file -> FolderTreeNodeDTO.builder()
.id(file.getId())
.label(file.getFileName())
.parentId(file.getParentId())
.children(new ArrayList<>())
.build()
));
// 构建文件夹树,遍历文件夹映射,为每个文件夹找到其子文件夹
for (FolderTreeNodeDTO node : folderMap.values()) {
// 获取当前文件夹的父ID
Long parentId = node.getParentId();
// 如果父ID不为空且父ID在文件夹映射中存在,则将当前文件夹添加到其父文件夹的子文件夹列表中
if (parentId != null && folderMap.containsKey(parentId)) {
// 获取父文件夹
FolderTreeNodeDTO folderTreeNodeDTO = folderMap.get(parentId);
// 获取父文件夹的子文件夹列表
List children = folderTreeNodeDTO.getChildren();
// 将当前文件夹添加到子文件夹列表中
children.add(node);
}
}
// 返回根节点(parentId为0的节点)过滤出根文件夹即可,里面包括多个
List folderTreeNodeDTOS = folderMap.values().stream()
.filter(node -> Objects.equals(node.getParentId(), 0L))
.collect(Collectors.toList());
return folderTreeNodeDTOS;
}
```
#### 查询文件树接断点调试和另外一种实现方式
**简介: 查询文件树接断点调试和另外一种实现方式**
* 需求
* 断点调试查询文件树接口逻辑
* 编写另外一种文件树实现代码(思考哪种方式好)
* 对比不同方式,多数据和少数据的优缺点
* 另一种文件树实现代码
```json
//查询当前用户的所有文件夹
List folderList = accountFileMapper.selectList(new QueryWrapper()
.eq("account_id", accountId)
.eq("is_dir", FolderFlagEnum.YES.getCode()));
//拼装文件夹树列表
if (CollectionUtils.isEmpty(folderList)) {
return List.of();
}
List folderTreeNodeDTOS = folderList.stream().map(file->{
return FolderTreeNodeDTO.builder()
.id(file.getId())
.label(file.getFileName())
.parentId(file.getParentId())
.children(new ArrayList<>())
.build();
}).toList();
//根据父文件夹进行分组 key是当前文件夹ID,value是当前文件夹下的所有子文件夹
Map> folderTreeNodeVOMap = folderTreeNodeDTOS.stream()
.collect(Collectors.groupingBy(FolderTreeNodeDTO::getParentId));
for (FolderTreeNodeDTO node : folderTreeNodeDTOS) {
List children = folderTreeNodeVOMap.get(node.getId());
//判断列表是否为空
if (!CollectionUtils.isEmpty(children)) {
node.getChildren().addAll(children);
}
}
return folderTreeNodeDTOS.stream().filter(node -> Objects.equals(node.getParentId(), 0L)).collect(Collectors.toList());
```
#### 网盘小文件上传接口设计和开发
* 需求
* 文件上传分三部分接口:小文件上传、大文件上传、文件秒传
* 先开发:小文件上传接口
* 上传到存储引擎
* 保存文件信息
* 保存文件映射关系
* 编码实战
* 上传文件到存储引擎,返回存储的文件路径
```json
private String storeFile(FileUploadReq req) {
String objectKey = CommonUtil.getFilePath(req.getFilename());
fileStoreEngine.upload(minioConfig.getBucketName(), objectKey, req.getFile());
return objectKey;
}
```
* 保存文件信息
```json
private FileDO saveFile(FileUploadReq req, String storeFileObjectKey) {
FileDO fileDO = new FileDO();
fileDO.setAccountId(req.getAccountId());
fileDO.setFileName(req.getFilename());
fileDO.setFileSize(req.getFile() != null ? req.getFile().getSize() : req.getFileSize());
fileDO.setFileSuffix(CommonUtil.getFileSuffix(req.getFilename()));
fileDO.setIdentifier(req.getIdentifier());
fileDO.setObjectKey(storeFileObjectKey);
fileMapper.insert(fileDO);
return fileDO;
}
```
* 保存文件映射关系
```json
AccountFileDTO accountFileDTO = AccountFileDTO.builder().fileName(req.getFilename())
.accountId(req.getAccountId())
.fileId(fileDO.getId())
.fileSize(fileDO.getFileSize())
.fileSuffix(fileDO.getFileSuffix())
.parentId(req.getParentId())
.isDir(FolderFlagEnum.NO.getCode())
.fileType(FileTypeEnum.fromExtension(fileDO.getFileSuffix()).name())
.build();
saveAccountFile(accountFileDTO);
```
* 文件枚举
```json
@Getter
public enum FileTypeEnum {
COMMON("common"),
COMPRESS("compress"),
EXCEL("excel"),
WORD("word"),
PDF("pdf"),
TXT("txt"),
IMG("img"),
AUDIO("audio"),
VIDEO("video"),
PPT("ppt"),
CODE("code"),
CSV("csv");
private final String type;
private static final Map EXTENSION_MAP = new HashMap<>();
static {
for (FileTypeEnum fileType : values()) {
switch (fileType) {
case COMPRESS:
EXTENSION_MAP.put("zip", fileType);
EXTENSION_MAP.put("rar", fileType);
EXTENSION_MAP.put("7z", fileType);
break;
case EXCEL:
EXTENSION_MAP.put("xls", fileType);
EXTENSION_MAP.put("xlsx", fileType);
break;
case WORD:
EXTENSION_MAP.put("doc", fileType);
EXTENSION_MAP.put("docx", fileType);
break;
case PDF:
EXTENSION_MAP.put("pdf", fileType);
break;
case TXT:
EXTENSION_MAP.put("txt", fileType);
break;
case IMG:
EXTENSION_MAP.put("jpg", fileType);
EXTENSION_MAP.put("jpeg", fileType);
EXTENSION_MAP.put("png", fileType);
EXTENSION_MAP.put("gif", fileType);
EXTENSION_MAP.put("bmp", fileType);
break;
case AUDIO:
EXTENSION_MAP.put("mp3", fileType);
EXTENSION_MAP.put("wav", fileType);
EXTENSION_MAP.put("aac", fileType);
break;
case VIDEO:
EXTENSION_MAP.put("mp4", fileType);
EXTENSION_MAP.put("avi", fileType);
EXTENSION_MAP.put("mkv", fileType);
break;
case PPT:
EXTENSION_MAP.put("ppt", fileType);
EXTENSION_MAP.put("pptx", fileType);
break;
case CODE:
EXTENSION_MAP.put("java", fileType);
EXTENSION_MAP.put("c", fileType);
EXTENSION_MAP.put("cpp", fileType);
EXTENSION_MAP.put("py", fileType);
EXTENSION_MAP.put("js", fileType);
EXTENSION_MAP.put("html", fileType);
EXTENSION_MAP.put("css", fileType);
break;
case CSV:
EXTENSION_MAP.put("csv", fileType);
break;
default:
break;
}
}
}
FileTypeEnum(String type) {
this.type = type;
}
public static FileTypeEnum fromExtension(String extension) {
if (extension == null || extension.isEmpty() || !isValidExtension(extension)) {
return COMMON;
}
try {
return EXTENSION_MAP.getOrDefault(extension.toLowerCase(), COMMON);
} catch (NullPointerException e) {
// 记录日志
System.err.println("Unexpected null pointer exception: " + e.getMessage());
return COMMON;
}
}
private static boolean isValidExtension(String extension) {
// 确保扩展名只包含字母和数字
return extension.matches("[a-zA-Z0-9]+");
}
}
```
#### 网盘小文件上传接口测试验证
* 需求
* ApiFox测试文件上传接口
* 测试创建多个文件夹
* 对应的文件上传多个类型的文件
* 测试实战
#### 文件批量移动接口设计和开发
* 需求
* 批量操作对应的文件列表,移动到对应的目录下面
* 需要考虑什么?如何实现相关功能?
* 业务逻辑设计(哪些方法会复用)
* 检查被转移的文件ID是否合法(复用)
* 检查目标文件夹ID是否合法(复用)
* 目标文件夹ID必须是当前用户的文件夹,不能是文件
* 要操作(移动、复制)的文件列表不能包含是目标文件夹的子文件夹,递归处理
* 批量转移文件到目标文件夹
* 处理重复文件名
* 更新文件或文件夹的parentId为目标文件夹ID
* 编码实战
```java
@Transactional(rollbackFor = Exception.class)
public void moveBatch(FileBatchReq req) {
//检查被转移的文件ID是否合法
List accountFileDOList = checkFileIdLegal(req.getFileIds(), req.getAccountId());
//检查目标文件夹ID是否合法,需要包括子文件夹
checkTargetParentIdLegal(req);
//批量转移文件到目标文件夹
//处理重复文件名
accountFileDOList.forEach(this::processFileNameDuplicate);
// 更新文件或文件夹的parentId为目标文件夹ID
UpdateWrapper updateWrapper = new UpdateWrapper<>();
updateWrapper.in("id", req.getFileIds())
.set("parent_id", req.getTargetParentId());
int updatedCount = accountFileMapper.update(null, updateWrapper);
if (updatedCount != req.getFileIds().size()) {
throw new RuntimeException("部分文件或文件夹移动失败");
}
}
public List checkFileIdLegal(List fileIdList, Long accountId) {
List accountFileDOList = accountFileMapper.selectList(new QueryWrapper()
.in("id", fileIdList)
.eq("account_id", accountId));
if (accountFileDOList.size() != fileIdList.size()) {
log.error("文件ID数量不合法,请检查:accountId={},fileIdList={}", accountId, fileIdList);
throw new BizException(BizCodeEnum.FILE_DEL_BATCH_ILLEGAL);
}
return accountFileDOList;
}
```
* 需求
* 批量整理对应的文件列表,移动到对应的目录下面
* 需要考虑什么?如何实现相关功能?
* 编码实现
* 检查父ID是否合法
```java
private void checkTargetParentIdLegal(FileBatchReq req) {
//1、目标文件夹ID 必须是当前用户的文件夹,不能是文件
AccountFileDO targetParentFolder = accountFileMapper.selectOne(new QueryWrapper()
.eq("id", req.getTargetParentId())
.eq("account_id", req.getAccountId())
.eq("is_dir", FolderFlagEnum.YES.getCode()));
if (targetParentFolder == null) {
log.error("目标文件夹不存在,目标文件夹ID:{}", req.getTargetParentId());
throw new BizException(BizCodeEnum.FILE_TARGET_PARENT_ILLEGAL);
}
/**
* 2、要操作(移动、复制)的文件列表不能包含是目标文件夹的子文件夹
* 思路:
* 1、查询批量操作中的文件夹和子文件夹,递归处理
* 2、判断是否在里面
*/
//查询待批量操作中的文件夹和子文件夹
List prepareAccountFileDOList = accountFileMapper.selectList(new QueryWrapper()
.in("id", req.getFileIds())
.eq("account_id", req.getAccountId()));
List allAccountFileDOList = new ArrayList<>();
findAllAccountFileDOWithRecur(allAccountFileDOList, prepareAccountFileDOList, false);
// 判断allAccountFileDOList是否包含目标夹的id
if (allAccountFileDOList.stream().anyMatch(file -> file.getId().equals(req.getTargetParentId()))) {
log.error("目标文件夹不能是源文件列表中的文件夹,目标文件夹ID:{},文件列表:{}", req.getTargetParentId(), req.getFileIds());
throw new BizException(BizCodeEnum.FILE_TARGET_PARENT_ILLEGAL);
}
}
```
#### 文件批量操作-递归接口设计和开发实战
* 什么是递归
* 允许函数调用自身来解决问题。
* 递归的基本思想是将一个复杂的问题分解成更小的、相似的子问题,直到这些子问题足够简单,可以直接解决
* **优点:包括代码简洁和优雅,比如对于某些问题(如树结构遍历、分治算法等)的处理逻辑更加简单**
* 缺点:比如可能导致较大的内存消耗(因为每次函数调用都需要在调用栈上保存信息),在某些情况下可能不如迭代方法高效
* 递归通常包含两个主要部分:
* **基本情况(Base Case)**:停止的条件,在每个递归调用中,都会检查是否达到了基本情况,如果是则停止递归返回结果。
* **递归步骤(Recursive Step)**:函数调用自身的过程。在这一步中问题被分解成更小的子问题,递归地解决这些子问题。
* 编码设计和逻辑说明
* 遍历文件列表:对传入的 accountFileDOList 进行遍历。
* 判断是否为文件夹:如果当前项是文件夹,则递归获取其子文件,并继续递归处理。
* 添加到结果列表:根据 onlyFolder 参数决定是否只添加文件夹,或者同时添加文件和文件夹
* `findAllAccountFileDOWithRecur` 递归逻辑处理(多个地方会使用,封装方法)
```java
@Override
public void findAllAccountFileDOWithRecur(List allAccountFileDOList, List accountFileDOList, boolean onlyFolder) {
for (AccountFileDO accountFileDO : accountFileDOList) {
if (Objects.equals(accountFileDO.getIsDir(), FolderFlagEnum.YES.getCode())) {
//文件夹,递归获取子文件ID
List childFileList = accountFileMapper.selectList(new QueryWrapper()
.eq("parent_id", accountFileDO.getId()));
findAllAccountFileDOWithRecur(allAccountFileDOList, childFileList, onlyFolder);
}
//如果通过onlyFolder是true只存储文件夹到allAccountFileDOList,否则都存储到allAccountFileDOList
if (!onlyFolder || Objects.equals(accountFileDO.getIsDir(), FolderFlagEnum.YES.getCode())) {
allAccountFileDOList.add(accountFileDO);
}
}
//return allAccountFileDOList;
}
```
#### 文件批量移动接口测试验证实战
* 需求
* 测试文件批量移动
* bug修复

* 数据准备
* 创建m1和m2文件夹,
* m1文件夹上传多个文件,然后里面创建子文件夹
* 查看m1和m2文件夹相关数据
* 移动m1到m2文件夹
* 查看m1和m2文件夹相关数据