网盘项目笔记

图里“开发:Vite Proxy 直连各服务”对应当前前端实际配置; **图里“后端:Spring Cloud Gateway 8081”对应项目中存在的网关模块,但当前开发链路没有主要使用它。
本项目普通业务服务使用 Spring Boot 内嵌 Web 服务器。由于 networkdisk-web 引入了 spring-boot-starter-web,而 starter-web 默认包含内嵌 Tomcat,所以 files、share、user 等业务模块启动时会通过 SpringApplication.run(…) 启动 Spring Boot 应用,并自动拉起内嵌 Tomcat 对外提供 HTTP 接口。项目没有采用传统外部 Tomcat 部署 war 包的方式。
在这个项目的设计习惯里,跨微服务 Dubbo 调用通常走 Facade 层;但不是“所有微服务调用必须天然叫 Facade”,而是这个项目把对外 RPC 接口统一命名和封装成 Facade。当前项目的分层大概是:Controller:给前端 HTTP 调用。Service:本模块内部业务逻辑。Mapper:本模块查数据库。Facade:给其他后端服务通过 Dubbo 调用。
所以如果是:前端 → files 服务上传文件走的是:UserFileController → UserFileService不需要 Facade。 如果是:auth 服务 → user 服务创建用户。auth 服务 → files 服务创建根目录。user 服务 → files 服务查询根目录。ai 服务 → files 服务读取文件信息。 这种一个后端服务调用另一个后端服务,当前项目就用:
@DubboReference 调接口
↓
@DubboService 的 FacadeImpl 实现
↓
@Facade 切面统一处理
↓
内部 Service
当前项目约定:前端访问后端走 Controller;后端服务之间通过 Dubbo 调用走 Facade。Facade 层是微服务对外暴露的 RPC 门面,负责接收其他服务传来的 Request DTO,调用本模块内部 Service,并返回统一 Response。Facade 方法上通常标
@Facade,用于触发FacadeAspect做日志、参数校验、异常包装和响应补全。
1.技术栈介绍
1.1 Vite Proxy、Gateway、Tomcat、Spring MVC、Dubbo 的关系
开发环境前端通常运行在 localhost:5173,后端微服务运行在不同端口,例如 auth 是 8090、files 是 8082、share 是 8085、user 是 8086。浏览器直接跨端口请求后端会有跨域问题,所以 Vite 开发服务器可以配置 Proxy,把前端的 /api/... 请求转发到对应后端服务。
Vite Proxy 是前端开发环境的代理。它不是后端业务服务,也不是 Tomcat,只是在本地开发时帮前端把请求转发给后端。当前项目开发环境里,vite.config.js 已经把 /api/v1/auth、/api/v1/files、/api/v1/shares、/api/v1/users 分别代理到不同微服务端口,所以日常调试时很多请求并没有经过 Gateway。
Spring Cloud Gateway 是后端微服务的统一 HTTP 入口,模块是 networkdisk-gateway。它的作用是根据 URL 路径把请求转发到具体服务。需要注意:Gateway 不是普通 Spring MVC 服务,它走的是 Spring Cloud Gateway / WebFlux / Reactor Netty 体系,不能按普通 Controller + Tomcat 来理解。
Tomcat 是普通 Spring Boot Web 微服务内部默认内嵌的 Web 服务器。比如 auth、user、files、share 这类业务服务,收到 HTTP 请求后,先由内嵌 Tomcat 接收请求,再交给 Spring MVC 的 DispatcherServlet。
Spring MVC 负责在具体业务服务内部根据 URL 找到 Controller,完成参数绑定、参数校验、方法调用,并把返回对象转成 JSON 响应。
Dubbo 是微服务之间的 RPC 调用框架,不是给前端调用的。比如 auth 服务登录/注册时需要调用 user 服务、files 服务,就会通过 @DubboReference 调远程 Java 接口;服务提供方通过 @DubboService 暴露接口。
最简关系:
Vite Proxy:开发环境前端代理,把 /api 请求直接转发到具体后端服务。
Gateway:后端统一 HTTP 入口,生产/统一入口场景下负责路由转发。
Tomcat:普通 Spring Boot MVC 微服务内部默认内嵌的 Web 服务器。
Spring MVC:在具体业务服务里找到 Controller 并执行。
Dubbo:后端微服务之间互相调用 Java 接口。
当前开发环境常见请求链路:
浏览器
→ Vue / Axios
→ Vite Proxy
→ 某个业务微服务的内嵌 Tomcat
→ Spring MVC / DispatcherServlet
→ Controller
→ AOP 登录切面
→ Service
→ Mapper / Redis / Dubbo / MQ
→ Spring MVC 转 JSON
→ Tomcat 返回 HTTP 响应
→ Vite Proxy
→ 浏览器
如果走 Gateway,则链路是:
浏览器
→ Gateway
→ 具体业务微服务
→ 内嵌 Tomcat
→ Spring MVC
→ Controller
→ Service
→ Mapper / Redis / Dubbo / MQ
→ 返回响应
开发时你更多看到的是 Vite Proxy;生产或统一入口设计里才强调 Gateway。普通业务服务是 Spring Boot 内嵌 Tomcat + Spring MVC;Gateway 不是 Tomcat/MVC;Dubbo 只负责后端服务之间互调
开发时:Vue → Vite Proxy → 具体微服务 → Tomcat → Spring MVC → Controller
统一入口时:Vue → Gateway → 具体微服务 → Tomcat → Spring MVC → Controller
服务互调时:Service → Dubbo → 另一个微服务的 Facade
1.1. Vite Proxy:前端开发代理
位置:disk-by-cursor/vite.config.js
作用:开发环境下,前端 localhost:5173 把 /api/v1/... 请求转发到不同后端服务端口。
当前项目配置大概是:
/api/v1/auth -> http://localhost:8090
/api/v1/files -> http://localhost:8082
/api/v1/shares -> http://localhost:8085
/api/v1/users -> http://localhost:8086
/api/v1/ai -> http://localhost:8087
作用:只在前端开发环境生效。浏览器请求 localhost:5173/api/v1/auth/login,Vite 开发服务器收到后转发到 localhost:8090/api/v1/auth/login。浏览器认为自己一直在请求 5173,所以避免跨域问题。
当前 rewrite 基本是原样转发,不改变路径。独立开发时,只要后端服务端口和 Controller 前缀确定,就在 Vite 里加一条代理即可。
所以你日常开发调接口时,很多请求是:
Vue / Axios
→ Vite Proxy
→ 具体后端服务
不一定经过 Gateway。
1.2. Gateway:后端统一入口
位置:networkdisk-gateway/src/main/resources/application.yml
作用:统一接收外部 HTTP 请求,再按路径转发到对应微服务。
配置在networkdisk-gateway/src/main/resources/application.yml,端口是 8081。
项目里 Gateway 用的是 Spring Cloud Gateway:
spring:
application:
name: @application.name@
config:
import: classpath:base.yml,classpath:cache.yml,classpath:config.yml,classpath:limiter.yml
cloud:
gateway:
# 全局过滤器:去重响应头(解决跨域时的重复Header问题)
default-filters:
- DedupeResponseHeader=Access-Control-Allow-Origin, RETAIN_UNIQUE
globalcors:
cors-configurations:
'[/**]':# 对所有路径生效
allowedHeaders: '*' # 允许所有请求头
allowedMethods: '*' # 允许所有请求方法
allowedOrigins: '*' # 允许所有跨域域名
routes:
# lb:// = 微服务负载均衡转发
# 路由1:匹配/auth/** /token/**路径,转发到networkdisk-auth微服务(负载均衡)
- id: networkdisk-auth
uri: lb://networkdisk-auth
predicates:
- Path=/auth/**,/token/**
- id: networkdisk-business
uri: lb://networkdisk-business
predicates:
- Path=/trade/**,/order/**,/user/**,/collection/**,/wxPay/**,/box/**
- id: networkdisk-ai
uri: lb://networkdisk-ai
predicates:
- Path=/api/v1/ai/**
server:
port: 8081
作用:生产或统一入口场景下,外部请求先进入 Gateway,再由 Gateway 按路径转发到具体服务。lb://networkdisk-auth 表示从 Nacos 找服务名为 networkdisk-auth 的实例,再负载均衡转发。
注意:当前项目 Controller 路径是 /api/v1/auth、/api/v1/token、/api/v1/files 等,而 Gateway 里 auth 写的是 /auth/**,/token/**,files/share/users 也没有按当前服务名完整配置。也就是说,日常开发主要靠 Vite Proxy;如果以后要统一走 Gateway,需要把 Gateway 路由补成类似:
/api/v1/auth/** -> lb://networkdisk-auth
/api/v1/token/** -> lb://networkdisk-auth
/api/v1/files/** -> lb://networkdisk-files
/api/v1/shares/** -> lb://networkdisk-share
/api/v1/users/** -> lb://networkdisk-user
注意:Gateway 是 Spring Cloud Gateway,走 WebFlux / Reactor Netty,不是普通 Spring MVC + Tomcat。
1.3. Tomcat:业务服务内部 Web 服务器
位置:普通业务服务里隐式存在,比如:
networkdisk-auth
networkdisk-business/networkdisk-files
networkdisk-business/networkdisk-share
networkdisk-business/networkdisk-user
这些 Spring Boot Web 服务默认内嵌 Tomcat。请求进入具体服务后,先到 Tomcat,再交给 Spring MVC。
1.4. Spring MVC:找到 Controller 并执行
Controller 路径在各模块里,例如 AuthController.java
@RestController
@RequestMapping("/api/v1/auth")
public class AuthController {
@PostMapping("/login")
public Result<String> login(@Valid @RequestBody LoginParamVO loginParam) {
...
}
}
作用流程:
Tomcat
-> DispatcherServlet
-> 根据 @RequestMapping / @PostMapping 找方法
-> 参数绑定:@RequestBody / @RequestParam / @PathVariable
-> 参数校验:@Valid / @Validated
-> 调 Controller 方法
-> 返回 Result<T>
-> 自动转 JSON
项目里 Controller 主要负责接收前端参数、做 VO 到 Context 的转换、调用 Service。登录校验在 networkdisk-web 的 AOP 里处理:未标记 @LoginIgnore 的接口会校验 token,解析出 userId 后放进 UserIdUtil,业务代码再通过 UserIdUtil.get() 获取当前用户。
独立开发时:路径前缀要和 Vite Proxy / Gateway 对齐;前端 JSON 用 @RequestBody,URL 参数用 @RequestParam,统一返回 Result<T>。
1.5. Dubbo:后端服务之间互调
配置位置
networkdisk-common/networkdisk-rpc/src/main/resources/rpc.yml
核心配置:
dubbo:
consumer:
timeout: 3000
check: false
protocol:
name: dubbo
port: -1
registry:
# 前缀 nacos:// 表示注册中心类型是 Nacos,后面跟 Nacos 的地址;
# 服务提供者(比如文件服务、用户服务)启动时,会把自己提供的 RPC 接口注册到 Nacos;
# 服务消费者(比如分享服务要调用文件服务)启动时,会从 Nacos 拉取可用的服务提供者地址,然后通过 Dubbo 协议发起远程调用。
address: nacos://127.0.0.1:8848 #首次启动前务必修改成你自己的
parameters:
namespace: networkdisk-dev # 自己到nacos上创建一个给dubbo用的namespce,然后和这里保持一致,首次启动前务必修改成个人的
group: networkdisk-dev #首次启动前务必修改为个人的
application:
name: ${spring.application.name}
qos-enable: true
qos-accept-foreign-ip: false
使用位置: 消费者:
@DubboReference(version = "1.0.0")
private UserFacadeService userFacadeService;
提供者:
@DubboService(version = "1.0.0")
public class UserFacadeServiceImpl implements UserFacadeService {
}
作用:auth 服务调用 user 服务、files 服务时,不走 HTTP Controller,而是通过 Dubbo 调 Java 接口。
1. 2 nacos
Nacos 在项目里主要做两件事:
1. Spring Cloud 服务发现 / 配置中心
2. Dubbo 注册中心
只要 Nacos 服务启动、地址配置正确、服务正常注册,业务代码主要通过 Dubbo 注解使用它。
Spring Cloud Nacos 配置
spring:
cloud:
# 只要 Nacos 服务启动、地址配置正确、服务正常注册,业务代码主要通过 Dubbo 注解使用它。
nacos:
#discovery(服务注册与发现):server-addr 指定 Nacos 服务的地址(本地 8848 端口)。
# 作用:所有微服务启动后,都会把自己的 IP、端口、服务名注册到 Nacos 里;
# 同时也能从 Nacos 查到其他微服务的地址,实现服务之间互相调用,不用硬编码对方的 IP 和端口。
discovery:
server-addr: 127.0.0.1:8848 #首次启动前务必修改成你自己的
# config(分布式配置中心):同样指向同一个 Nacos 地址。
# 作用:把各个微服务的配置文件(数据库地址、参数开关等)统一放到 Nacos 上管理,
# 不用每个服务本地写死配置;还支持配置动态刷新,改完配置不用重启服务就能生效
config:
server-addr: 127.0.0.1:8848 #首次启动前务必修改成你自己的
file-extension: properties
name: ${spring.application.name}
Dubbo 注册中心配置
dubbo:
consumer:
timeout: 3000
check: false
protocol:
name: dubbo
port: -1
registry:
# 前缀 nacos:// 表示注册中心类型是 Nacos,后面跟 Nacos 的地址;
# 服务提供者(比如文件服务、用户服务)启动时,会把自己提供的 RPC 接口注册到 Nacos;
# 服务消费者(比如分享服务要调用文件服务)启动时,会从 Nacos 拉取可用的服务提供者地址,然后通过 Dubbo 协议发起远程调用。
address: nacos://127.0.0.1:8848 #首次启动前务必修改成你自己的
parameters:
namespace: networkdisk-dev # 自己到nacos上创建一个给dubbo用的namespce,然后和这里保持一致,首次启动前务必修改成个人的
group: networkdisk-dev #首次启动前务必修改为个人的
application:
name: ${spring.application.name}
qos-enable: true
qos-accept-foreign-ip: false
1. 3 Reids+Redission+JetCache
- Redis 是什么:一个基于内存的键值对数据库,读写速度比 MySQL 快上百倍,专门用来存「短期、高频访问、丢了也没关系」的数据,核心业务的永久数据依然存在 MySQL 里。
- 缓存是什么:把经常查的数据放到更快的地方存着,下次查直接拿,不用再去慢的数据库里查,核心作用是减轻数据库压力、提升接口响应速度。
- 序列化是什么:Java 里的对象、数字,要存进 Redis 必须先转成 Redis 能识别的格式(字符串 / 字节),这个转换过程叫序列化;取出来再转回 Java 对象叫反序列化。
1. 3.1 Reids缓存和临时状态存储
1.引入 Maven 依赖
在 pom.xml 中引入 spring-boot-starter-data-redis 起步依赖后,Spring Boot 会自动完成连接装配,无需手动写连接代码。
配置文件位置:networkdisk-common/networkdisk-cache/src/main/resources/cache.yml
spring:
# Spring Data Redis 基础配置
data:
redis:
host: 127.0.0.1 # Redis 服务端IP地址
port: 6379 # Redis 服务端口,默认6379
password: 123456 # Redis 连接密码,无密码可留空
ssl:
enabled: false # 不启用SSL加密连接,本地开发默认关闭
# Redisson 高级客户端配置
Redis 在项目里主要存“短期、频繁访问、可快速失效”的数据,不代替 MySQL。常用入口是 StringRedisTemplate、RedisTemplate、CacheManager。
Spring Data Redis 提供了两个最常用的操作模板,区别在于序列化方式:
| 模板类 | 适用场景 | 序列化规则 | 推荐程度 |
|---|---|---|---|
StringRedisTemplate |
存字符串、JSON、数字等文本数据 | key 和 value 均使用字符串序列化,Redis 中可读性好 | 绝大多数业务场景首选 |
RedisTemplate<Object, Object> |
直接存 Java 对象 | 默认 JDK 二进制序列化,Redis 中是乱码,需手动配置 JSON 序列化 | 需直接存对象时使用 |
绝大多数业务场景用 StringRedisTemplate 即可:存对象时先把对象转成 JSON 字符串,读取时再转回对象,可读性好、兼容性强。 |
|||
| 使用方式非常简单,直接在 Service/Controller 中注入: |
@Service
public class ShareService {
// 直接注入,Spring Boot 自动装配
@Autowired
private StringRedisTemplate stringRedisTemplate;
}
Redis 项目里主要在登录鉴权里。项目里的典型用法:
1.登录态缓存 场景:用户登录成功后,将 JWT Token 存入 Redis,后续请求鉴权时取出比对;登出时删除 Token 实现立即失效。
💡 补充概念:为什么 JWT 本身有有效期,还要存 Redis? JWT 天生是「无状态」的 —— 签发出去之后,只要没过期就一直有效,服务端没法主动让它作废。如果只靠 JWT,用户点「退出登录」后,旧 Token 其实还能用,没法真的下线。 存一份到 Redis 相当于做了「登录白名单」:只有 Redis 里有的 Token 才是有效的,登出时直接删掉,就算 JWT 本身没过期,也会判定为未登录,还能实现踢人下线、账号封禁立即生效等功能。
// 登录:存入Token,Key规则:user:login: + 用户ID
// 调用 opsForValue(),拿到「字符串专属操作对象」
stringRedisTemplate.opsForValue()
// 在专属对象上调用 set 命令,执行 Redis 的 SET 指令
//Key规则:登录前缀(USER_LOGIN_PREFIX = "user:login:") + 用户ID,例如:user:login:1001
//Value:生成的JWT令牌,Redis中存储的有效期与JWT令牌一致(1天)
.set(BaseConstant.USER_LOGIN_PREFIX + userInfo.getUserId(), accessToken, 1, TimeUnit.DAYS);
// 鉴权:取出缓存Token与请求头比对
String cacheToken = stringRedisTemplate.opsForValue()
.get(BaseConstant.USER_LOGIN_PREFIX + userId);
// 登出:删除Token,实现立即下线
stringRedisTemplate.delete(BaseConstant.USER_LOGIN_PREFIX + userId);
**2.一次性临时业务 Token
业务场景:创建分享、删除文件等敏感操作前,先申请一个临时凭证,用来防止重复提交、抵御 CSRF 攻击,Token 只能用一次,30 分钟自动过期。
💡 补充概念:Lua 脚本是什么?为什么要用它?
- Lua 是一种轻量的脚本语言,Redis 原生支持执行 Lua 脚本。
- 原子性:把多个 Redis 命令写在一段 Lua 脚本里,Redis 会一口气、不间断地执行完,中间不会插入其他请求,就像一个不可拆分的原子操作,这就是「原子性」。
- 项目里的需求:Token 要保证「一次性」—— 取出来验证的同时就要删掉,不能让两个请求都拿到同一个 Token。 如果不用 Lua,分两步写:先查有没有 → 再删除。并发场景下,两个请求可能同时查到 Token,都通过校验,就出现「一个 Token 用两次」的 bug。 用 Lua 把「查 + 删」打包成一个原子操作,就能彻底避免这个问题。
TokenController 用 Redis 生成一次性临时 token:
// 生成随机唯一Token
String token = UUID.randomUUID().toString();
// 拼接Redis的key:前缀 + 业务场景 + 分隔符 + 随机Token
// 形如:biz:token:create_share:xxxx-xxxx-xxxx
String tokenKey = TOKEN_PREFIX + scene + CACHE_KEY_SEPARATOR + token;
// 存入Redis,30分钟自动过期
redisTemplate.opsForValue().set(tokenKey, token, 30, TimeUnit.MINUTES);
项目里的 Lua 脚本逻辑(在 TokenFilter` 中执行):
local value = redis.call('GET', KEYS[1])
redis.call('DEL', KEYS[1])
return value
作用:取出 token 的同时删除,保证一次性使用。 TokenFilter 校验时用 Lua 做 get + delete,保证这个 token 只能消费一次。
3. 文件分片上传缓存 业务场景:OSS 分片上传时,需要临时保存 uploadId、objectKey 等上传进度信息,上传合并完成后就删除。
💡 补充概念:CacheManager 是什么?
CacheManager 是 Spring 自带的缓存抽象接口,比 StringRedisTemplate 更高一层封装。你不用自己写序列化、不用自己写 get/set,只要指定缓存名和 key,它自动帮你完成存取,代码更简洁,但灵活度比 StringRedisTemplate 低。
// 获取公共缓存实例
Cache cache = cacheManager.getCache(CacheConstant.CACHE_COMMON_NAME);
// 存入缓存
cache.put(cacheKey, entity);
// 合并完成后,删除对应缓存
getCache().evict(cacheKey);
独立开发时,普通字符串就用 StringRedisTemplate;对象缓存用 RedisTemplate / CacheManager;方法级缓存用 @Cached。
1. 3.1 Redisson:Redis 高级客户端
Redisson 是什么:基于 Redis 的 Java 高级客户端,它不做普通的 key-value 存取(这是 Spring Data Redis 的事),而是利用 Redis 的特性,封装好了大量分布式场景的现成工具,比如分布式锁、延迟队列、限流器、布隆过滤器等,不用你自己从零造轮子。
和 Spring Data Redis 的关系:两者连接同一个 Redis 服务,互不冲突,定位不同 —— 一个做基础存取,一个做高级分布式工具。
1.项目配置
配置文件位置:同 cache.yml,Redisson 有独立的连接配置,不共用 Spring Data Redis 的连接池。
spring:
redis:
redisson:
config: |
singleServerConfig:
address: "redis://127.0.0.1:6379"
password: "123456"
database: 0
connectionPoolSize: 64
1. 分布式锁 💡 补充概念:什么是分布式锁?为什么不用 synchronized?
- 单机锁(synchronized):只在同一个服务、同一个 JVM 里生效。如果项目部署了多台服务器,两台机器上的请求同时过来,单机锁管不到另一台,就锁不住。
- 分布式锁:把锁存在 Redis 里,所有服务器都看这同一把锁,不管部署多少台,同一时间只有一个请求能拿到锁,适用于微服务集群场景。
- Redisson 的分布式锁还自带「看门狗机制」:如果业务执行时间超过了锁的过期时间,会自动给锁续期,不会出现业务还没做完锁就失效的情况,比自己手写的锁更可靠。
项目封装了 @DistributeLock 注解,通过 AOP 切面自动加锁解锁,不用手动写加锁代码。
使用示例(文件分片上传):
@DistributeLock(
scene = "FILE_CHUNK_UPLOAD",
keyExpression = "#context.userId + '-' + #context.identifier"
)
public Integer chunkUpload(FileChunkUploadContext context) {
// 分片上传业务逻辑
}
- 最终的锁 key 形如:
FILE_CHUNK_UPLOAD#用户ID-文件MD5 - 作用:同一个用户上传同一个文件时,同一时间只有一个请求能执行初始化逻辑,避免多个分片并发重复创建上传任务、重复写入数据。
底层切面的核心逻辑(DistributeLockAspect):
RLock rLock = redissonClient.getLock(lockKey);
try {
rLock.lock(); // 加锁
response = pjp.proceed(); // 执行业务方法
} finally {
rLock.unlock(); // 释放锁(必须写在finally里,防止异常导致死锁)
}
最终锁 key 类似:FILE_CHUNK_UPLOAD#用户ID-文件MD5 作用:同一个用户上传同一个文件时,避免多个分片请求并发重复初始化、重复写入。
3. 分享过期延迟队列
💡 补充概念:什么是延迟队列?和定时任务有什么区别?
- 定时任务:每隔一段时间扫一次数据库,找出到期的数据处理。缺点是不精准(有时间差)、数据量大了扫库很耗性能。
- 延迟队列:把任务放进去,设置好延迟时间,到点了自动被取出来执行,不用轮询数据库,精准又省资源。
- Redisson 的延迟队列基于 Redis 的有序集合实现,适合大量、短周期的延时任务。
项目用法:创建限时分享时,把删除任务投递到延迟队列,到期后自动执行删除分享的逻辑。
// 投递延迟任务:任务内容、延迟时长、时间单位、队列名
delayQueueHolder.addJob(
message,
delayInMillis,
TimeUnit.MILLISECONDS,
ShareConstant.DELETE_SHARE_INFO_DELAY_QUEUE_NAME
);
底层实现:
RBlockingDeque<Object> blockingDeque = redissonClient.getBlockingDeque(queueName);
RDelayedQueue<Object> delayedQueue = redissonClient.getDelayedQueue(blockingDeque);
// 把任务放进延迟队列,到时间自动进入阻塞队列等待消费
delayedQueue.offer(value, delay, timeUnit);
到期后由 DeleteShareInfoDelayQueueExecutor 消费消息,执行删除分享的业务。
4. 其他 Redisson 用法
- 布隆过滤器(RBloomFilter):用来快速判断「一个值绝对不存在」,有极小概率误判存在。项目里用来判断昵称、邀请码是否已被使用,大部分请求直接在 Redis 里拦截,不用查数据库,大幅降低数据库压力。
- 分布式限流(RRateLimiter):控制接口单位时间内的访问次数,防止恶意刷接口、爬虫把服务打挂。
- 有序集合排行榜(RScoredSortedSet):自动按分数排序,用来实现邀请排行榜、下载排行榜等功能,不用自己写排序逻辑。
1.4 JetCache方法级缓存
JetCache 是阿里巴巴开源的注解式两级缓存框架,通俗说就是:你不用手动写 Redis 的 get/set 代码,只要在查询方法上加一个注解@Cached,框架就自动帮你做缓存 —— 先查缓存,有就直接返回,没有就查数据库,再自动存进缓存。
它的核心特色是支持两级缓存:本地内存缓存 + Redis 远程缓存,兼顾极致速度和集群数据共享,比 Spring 自带的 @Cacheable 功能更强大。
JetCache(阿里开源的缓存框架),它本身不是缓存存储,而是一个「缓存统一管理框架」—— 它自己不存数据,而是把「JVM 本地缓存」和「远程 Redis 缓存」两层组合到一起,自动联动调度,不用你手动写 “先查本地、再查 Redis、再查库、回填缓存” 的重复代码。 跑在 JVM 里的也不是 JetCache 本身,是它管理的本地缓存组件 Caffeine,功能上类似一个极简版 Redis,但只在当前服务实例的内存里生效;JetCache 的核心价值,就是把它和 Redis 自动联动起来,形成「本地 + 远程」的二级缓存体系。
- 什么是 AOP 切面?为什么加个注解就生效? AOP(面向切面编程)可以理解为:在不修改你原方法代码的前提下,在方法执行前后自动插入额外逻辑。JetCache 就是通过 AOP,在你的查询方法外面套了一层缓存逻辑 —— 方法执行前先查缓存,方法执行后自动存缓存,全程不用你写代码。
- 本地缓存 vs 远程缓存
- 本地缓存(Caffeine):存在当前服务的 JVM 内存里,读取速度极快(微秒级),但多台服务器之间不共享,每台都有自己的一份。
- 远程缓存(Redis):存在 Redis 里,所有服务器共享,数据一致,但需要走网络请求,速度稍慢(毫秒级)。
- 什么是缓存穿透? 有人恶意用大量不存在的参数请求接口,缓存里永远没有,请求就全部打到数据库上,把数据库打挂,这就是缓存穿透。
1.JetCache 在项目里的位置
- 配置文件位置:
networkdisk-common/networkdisk-cache/src/main/resources/cache.yml - 功能开启位置:项目启动类上,通过
@EnableMethodCache(basePackages = "com.disk")注解开启方法级缓存能力 - 代码使用位置:Service 层的查询方法上,比如
UserService里的findById、findByEmail方法
配置:
jetcache:
statIntervalMinutes: 1 # 缓存统计间隔(分钟),每1分钟输出一次缓存命中统计
areaInCacheName: false # 关闭缓存名里的区域前缀
# ====== 本地缓存(一级缓存) ======
local:
default:
type: caffeine # 本地缓存实现:Caffeine(Java高性能本地缓存库)
keyConvertor: fastjson2 # Key的序列化转换工具:FastJSON2
# ====== 远程缓存(二级缓存) ======
remote:
default:
type: redisson # 远程缓存实现:基于Redisson操作Redis
keyConvertor: fastjson2 # Key的序列化转换工具:FastJSON2
broadcastChannel: ${spring.application.name} # 本地缓存失效广播通道,服务名区分
keyPrefix: ${spring.application.name} # 远程缓存Key前缀,用服务名隔离
valueEncoder: java # 值编码方式:Java序列化
valueDecoder: java # 值解码方式:Java序列化
defaultExpireInMillis: 5000 # 默认过期时间(毫秒),默认5秒过期
含义:
local:本地 Caffeine 一级缓存,速度快
remote:Redis/Redisson 二级缓存,多实例共享
cacheType = BOTH:本地缓存 + 远程缓存都用
项目例子: 请求不存在的用户 ID → 缓存没命中 → 查数据库 → 结果为 null → 把「这个 ID 对应结果为空」也写进缓存 → 后续相同请求直接命中缓存返回空,不再查数据库
/**
* 根据用户ID查询用户信息(标准二级缓存写法)
*/
@Cached(
name = "user:cached:id:", // 缓存key前缀:按ID查询的用户信息缓存
expire = 3000, // 缓存过期时间:3000毫秒 = 3秒
cacheType = CacheType.BOTH, // 启用二级缓存:本地Caffeine + 远程Redis
key = "#userId", // 缓存唯一key:取方法参数userId的值,最终形如 user:cached:id:1001
cacheNullValue = true // 空值也缓存:数据库查不到结果时,也把「空」存进缓存
)
@CacheRefresh(
refresh = 60,
timeUnit = TimeUnit.HOURS
)
public UserDO findById(Long userId) {
return userMapper.findById(userId);
}
登录查用户时,会走 findByEmail(),所以这里理论上就会用 JetCache。
项目例子:
/**
* 根据邮箱查询用户信息
* ⚠️ 历史遗留问题:方法参数名为 email,但缓存 key 写的是 #telephone
* 参数名不匹配会导致缓存 key 生成异常,缓存无法正常生效
*/
// 核心缓存注解:声明该方法开启结果缓存
@Cached(
name = "user:cached:telephone:", // 缓存 key 的统一前缀,用于业务分类,避免不同功能的 key 冲突
expire = 3000, // 缓存过期时间,单位为毫秒,即 3 秒后缓存自动失效
cacheType = CacheType.BOTH, // 缓存类型:BOTH = 同时启用「本地Caffeine缓存 + 远程Redis缓存」两级联动
key = "#telephone" // 缓存唯一 key 的 SpEL 表达式:取方法中名为 telephone 的参数值拼接最终 key
// ❌ 错误点:方法形参叫 email,这里写 telephone,框架取不到值,缓存会失效
)
// 缓存刷新注解:后台定时自动刷新缓存,不用等缓存过期、用户请求时再查库
@CacheRefresh(
refresh = 60, // 刷新间隔数值
timeUnit = TimeUnit.HOURS // 时间单位:小时,即每 60 小时自动后台刷新一次缓存
)
public UserDO findByEmail(String email) {
// 方法本体:只负责查数据库,缓存读写逻辑由 AOP 在方法执行前后自动处理
return userMapper.findByEmail(email);
}
调用过程:
第一次 findById(1001):查缓存没有 -> 查 MySQL -> 写入本地缓存和 Redis
第二次 findById(1001):直接走缓存,减少数据库查询
注意:项目里 findByEmail(String email) 写的是 key = "#telephone",但方法参数叫 email,这像是历史遗留问题,独立开发时 key 名必须和参数名一致。
最后记这三句话:
Redis:直接存 key-value,比如登录 token、临时 token、上传状态。
Redisson:用 Redis 做高级并发工具,比如分布式锁、限流、延迟队列。
JetCache:用注解缓存方法结果,减少重复查库。
1.4 Spring Cloud Stream + RocketMQ
项目把 RocketMQ 封装在 networkdisk-common/networkdisk-mq,配置在 stream.yml
spring:
cloud:
stream:
rocketmq:
binder:
name-server: 127.0.0.1:9876
Spring Cloud Stream 是消息抽象层,RocketMQ 是底层消息中间件。业务代码不直接操作 RocketMQ Producer/Consumer,而是通过 StreamBridge 和函数式 Consumer 收发消息。
发送方在 files 服务。文件落库后,UserFileServiceImpl 调用:
documentAiInitializer.scheduleInitialize(userId, entity.getId(), filename);
DocumentAiInitializer 会在事务提交后发送 AI 预热消息:
streamProducer.send(
"aiWarmup-out-0",
"document-ai-warmup",
objectMapper.writeValueAsString(message)
);
这里要分清:
aiWarmup-out-0:Spring Cloud Stream 的发送 binding 名
ai-document-warmup:RocketMQ topic,在 application.yml 里配置
document-ai-warmup:消息 tag,通过 header "TAGS" 传入
files 服务配置:
spring:
cloud:
stream:
bindings:
aiWarmup-out-0:
destination: ai-document-warmup
AI 服务消费同一个 topic:
spring:
cloud:
function:
definition: aiWarmupConsumer
stream:
bindings:
aiWarmupConsumer-in-0:
destination: ai-document-warmup
group: networkdisk-ai
content-type: application/json
消费者代码:
@Bean("aiWarmupConsumer")
public Consumer<Message<?>> aiWarmupConsumer() {
return message -> {
AiDocumentWarmupMessage warmupMessage = readWarmupMessage(message);
aiApplicationService.indexFile(indexRequest);
aiApplicationService.summarize(summaryRequest);
aiApplicationService.generateTags(tagRequest);
};
}
一句话:文件服务上传文档后发 MQ,AI 服务异步消费,自动完成索引、摘要、标签生成。
独立开发搭法:
引入 networkdisk-mq
-> import stream.yml
-> 生产者配置 xxx-out-0 destination
-> StreamProducer.send("xxx-out-0", tag, json)
-> 消费者配置 function.definition 和 xxx-in-0
-> 写 @Bean("xxxConsumer") Consumer<Message<?>>
1.5 StorageEngine + local / oss / fastdfs
文件存储抽象在 StorageEngine.java
public interface StorageEngine {
void store(StoreFileContext context);
void delete(DeleteFileContext context);
void storeChunk(StoreFileChunkContext context);
void mergeFile(MergeFileContext context);
void realFile(ReadFileContext context);
}
StorageEngine 是统一接口,上层 files 服务只依赖它,不关心底层是本地磁盘、OSS 还是 FastDFS。
AbstractStorageEngine 使用模板方法模式:
store()
-> 校验参数
-> doStore()
storeChunk()
-> 校验参数
-> doStoreChunk()
mergeFile()
-> 校验参数
-> doMergeFile()
具体实现:
LocalStorageEngine:本地磁盘存储,当前 @Primary,默认优先使用
OssStorageEngine:阿里云 OSS,配置 endpoint 后启用
FastDFSStorageEngine:FastDFS,支持普通上传/下载/删除,不支持分片上传
本地存储:
@Primary
@Component
public class LocalStorageEngine extends AbstractStorageEngine {
protected void doStore(StoreFileContext context) {
// 写入本地完整文件目录
}
protected void doStoreChunk(StoreFileChunkContext context) {
// 写入本地分片目录:chunks / 文件MD5 / 分片号
}
protected void doMergeFile(MergeFileContext context) {
// 按顺序追加分片,合成完整文件
}
}
OSS 分片上传核心:
第一次分片:初始化 multipart upload,拿 uploadId
每个分片:uploadPart
全部完成:completeMultipartUpload
中间状态:uploadId/objectKey 缓存在 Redis/CacheManager
并发控制:@DistributeLock 防止重复初始化
FastDFS:
protected void doStore(StoreFileContext context) {
StorePath storePath = fastFileStorageClient.uploadFile(...);
context.setRealPath(storePath.getFullPath());
}
protected void doStoreChunk(...) {
throw new SystemException("FastDFS不支持分片上传");
}
独立开发时记住:Controller/Service 不要直接写 OSS/FastDFS 代码,统一注入 StorageEngine。以后换存储,只换实现类和配置。
1.6 Apache Tika + OpenAI-compatible Provider
AI 服务配置在 application.yml
com:
disk:
ai:
provider:
type: openai-compatible
base-url: https://dashscope.aliyuncs.com/compatible-mode/v1
chat-path: /chat/completions
embeddings-path: /embeddings
chat-model: qwen3.5-plus
embedding-model: qwen3-vl-embedding
embedding-dimension: 768
这里的 OpenAI-compatible 指“接口格式兼容 OpenAI”,不一定是 OpenAI 官方。当前项目实际接的是阿里 DashScope compatible-mode。
AI 文件处理链路:
AI 消费 MQ / 前端调用 AI 接口
-> AiApplicationServiceImpl
-> AiSourceFileLoader
-> Dubbo 调 files 服务拿 realPath
-> StorageEngine.realFile() 读取文件字节
-> Apache Tika 解析为纯文本
-> ParagraphTextChunker 切块
-> EmbeddingClient 调模型生成向量
-> pgvector 入库
Tika 解析代码核心:
AutoDetectParser parser = new AutoDetectParser();
BodyContentHandler handler = new BodyContentHandler(-1);
parser.parse(inputStream, handler, metadata, new ParseContext());
String plainText = normalize(handler.toString());
Tika 的作用:
PDF / Word / Excel / PPT / txt / md / html / json 等文件
-> 自动识别格式
-> 提取纯文本
-> 提取 mediaType、metadata
OpenAI-compatible 调用核心:
restClient.post()
.uri(baseUrl + chatPath)
.headers(headers -> headers.setBearerAuth(apiKey))
.body(request)
.retrieve()
.body(ChatCompletionResponse.class);
Embedding 也是同一个客户端:
request.setModel(properties.getEmbeddingModel());
request.setInput(texts);
request.setDimensions(properties.getEmbeddingDimension());
独立开发注意:
chat-model:用于摘要、标签、问答
embedding-model:用于生成向量
embedding-dimension:必须和 pgvector.dimension 一致
api-key:真实项目不要写死在仓库里,放环境变量或配置中心
1.7 PostgreSQL + pgvector
pgvector 配置:
com:
disk:
ai:
pgvector:
enabled: true
init-schema: true
url: jdbc:postgresql://127.0.0.1:5432/networkdisk_ai
username: postgres
password: 123456
dimension: 768
项目单独创建了 pgvector 数据源:
@Bean(name = "pgVectorDataSource")
public DataSource pgVectorDataSource(PgVectorProperties properties) {
HikariDataSource dataSource = new HikariDataSource();
dataSource.setDriverClassName("org.postgresql.Driver");
dataSource.setJdbcUrl(properties.getUrl());
return dataSource;
}
init-schema=true 时自动建表:
create extension if not exists vector;
核心表:
ai_document_index:每个文档的索引摘要信息
ai_document_chunk_vector:每个文本块及其 embedding 向量
ai_document_result:AI 摘要、标签结果缓存
向量字段:
embedding vector(768) not null
写入流程:
vectorStore.replaceDocument(sourceFile, parsedDocument, vectorChunks);
内部做三件事:
删除旧 chunk
upsert ai_document_index
批量插入 ai_document_chunk_vector
向量搜索:
select chunk_text, 1 - (embedding <=> ?) as similarity
from ai_document_chunk_vector
where user_id = ? and user_file_id = ?
order by embedding <=> ?
limit ?
embedding <=> queryVector 是 pgvector 的相似度距离计算。项目把它转成:
similarity = 1 - distance
问答时流程:
用户提问
-> 对问题生成 embedding
-> pgvector 找 topK 相关文本块
-> 把相关文本块拼成上下文
-> 调 chat model 生成回答
最终完整链路可以记成:
文件上传
-> StorageEngine 保存文件
-> files 服务发 RocketMQ 消息
-> AI 服务消费消息
-> Tika 解析文档
-> OpenAI-compatible 生成 embedding / 摘要 / 标签
-> PostgreSQL + pgvector 保存索引和结果
-> 用户问答时从 pgvector 检索相关片段再调用模型回答
1. 8Facade 和 FacadeAspect:Dubbo 门面调用的统一切面
@Facade 是当前项目里给 Dubbo 门面方法使用的标记注解。它本身不执行业务逻辑,真正执行公共逻辑的是 FacadeAspect。
当一个方法上标了:@Facade并且它被 Spring AOP 代理调用时,就会匹配这个切面:
@Around("@annotation(com.disk.rpc.facade.Facade)")
public Object facade(ProceedingJoinPoint pjp) throws Exception {
...
}
@Facade:给方法贴标签,表示这是一个 RPC 门面方法,FacadeAspect:看到这个标签后,统一拦截处理,pjp.proceed():真正放行,执行原来的业务方法。
FacadeAspect 主要做这些事:
1. 记录方法开始日志
2. 统计方法执行耗时
3. 统一校验请求参数
4. 调用 pjp.proceed() 执行真正的目标方法
5. 如果返回值是 BaseResponse 子类,补全 responseCode
6. 如果目标方法抛异常,统一包装成失败响应
7. 打印结束日志或异常日志
所以带 @Facade 的方法,实际执行顺序不是直接进入业务方法,而是:
调用方发起 Dubbo 调用
→ 目标服务找到 @DubboService 实现类
→ 目标方法上有 @Facade
→ 先进入 FacadeAspect
→ 参数校验、日志记录
→ pjp.proceed()
→ 真正执行业务方法
→ 返回 BaseResponse 子类
→ FacadeAspect 补全响应信息
→ 返回调用方
注意:@Facade 主要用于“后端服务调用后端服务”的 Dubbo RPC 场景,不是普通前端 HTTP Controller 场景。
2.数据模型
业务代码每次只关心部分字段;剩下字段要么由代码主动补默认值,要么由 MyBatis-Plus 自动填充,要么由数据库 DEFAULT / GENERATED 字段自动生成。
1 user 用户表
作用:保存用户账号主数据,也就是“这个用户是谁”。注册成功后,系统会先生成一条 user 记录,后续文件、分享、回收站等业务都通过 user_id 和它关联。
作用:保存用户账号的主数据,也就是“这个用户是谁”。注册时,后端会往 user 表插入一条用户记录。注册请求大概是:
{
"email": "[已隐藏邮箱]",
"password": "123456",
"nickName": "小白"
}
经过后端处理后,数据库里大概会保存成:
| 字段名 | 说明 | 来源 / 默认值 | 备注 |
|---|---|---|---|
id |
用户主键 ID | IdUtil.get() |
后端内部代表用户,JWT 里也会放用户 ID |
nick_name |
用户昵称 | 前端传;为空时用邮箱前缀 | 前端展示用户信息时使用 |
use_space |
已使用空间,单位字节 | 注册时设 0L,数据库默认 0 |
新用户初始为 0 |
total_space |
总空间,单位字节 | UserConstants.USER_INIT_SPACE,数据库默认 1073741824 |
当前为 1GB |
email |
用户邮箱 | 前端注册传入 | 唯一索引 uk_email,也是登录账号 |
password_hash |
密码哈希 | PasswordUtil.encryptPassword(password) |
当前项目是 MD5,不保存明文密码 |
invite_code |
邀请码 | 默认 NULL |
兼容旧查询,当前主注册流程不重点使用 |
telephone |
手机号 | 默认 NULL |
兼容旧查询,当前主注册流程不重点使用 |
last_login_time |
最近登录时间 | 注册时设当前时间 | 后续登录可更新 |
profile_photo_url |
头像地址 | 注册时设置默认头像 | 前端展示用户头像 |
gmt_create |
创建时间 | 数据库默认 CURRENT_TIMESTAMP / 自动填充 |
通用审计字段 |
gmt_modified |
最后更新时间 | 数据库默认并 ON UPDATE 自动更新 / 自动填充 |
通用审计字段 |
deleted |
逻辑删除标记 | 注册时设 0,数据库默认 0 |
0 正常,非 0 已删除 |
lock_version |
乐观锁版本号 | 数据库默认 0 / 自动填充 |
用于并发更新控制 |
password_hash密码加密后的结果。数据库里不直接保存明文密码,而是保存处理后的密码值。项目里注册时大概是:PasswordUtil.encryptPassword(password)
前端传来的 password = 123456。数据库保存的是 password_hash,不应该直接保存 123456项目里用的是 MD5 方式,适合学习理解,但真实企业项目一般会用更安全的加盐哈希,比如 BCrypt。
查询用户时一般会带上:where deleted = 0
user 表保存的是“用户账号本身”。
注册成功后,系统先有了这个用户,后面所有文件、分享、回收站数据都可以通过 user_id 和这个用户关联起来。
user 表只表示用户账号本身。注册成功后,系统还会额外创建该用户的根目录,这条根目录不在 user 表,而在 user_file 表。
2 user_file 用户文件表
作用:保存用户看到的“文件目录视图”。这个表非常重要,它不是只保存普通文件,也保存文件夹,包括用户注册后自动创建的根目录。注册时除了插入 user 表,还会额外创建一条根目录记录:
作用:保存用户看到的“文件目录视图”。用户页面上看到的每一个文件、文件夹,本质上都是一条 user_file 记录。
注册时除了插入 user 表,还会为用户创建一条根目录记录:
filename = 全部文件
parent_id = 0
folder_flag = 1
real_file_id = NULL
这个根目录记录的 id 会作为当前用户的 rootFileId 返回给前端。
| 字段名 | 说明 | 来源 / 默认值 | 备注 |
|---|---|---|---|
id |
用户文件视图记录 ID | IdUtil.get() |
文件或文件夹在用户目录里的唯一 ID |
file_id |
兼容旧 Mapper 字段 | 生成列,等同 id |
GENERATED ALWAYS AS (id) |
user_id |
所属用户 ID | 当前登录用户 / 注册用户 | 关联 user.id |
parent_id |
上级文件夹 ID | 当前目录 ID;根目录为 0 |
决定这条记录在哪个文件夹下 |
real_file_id |
真实文件 ID | 普通文件指向 file.id;文件夹为 NULL |
文件夹没有真实物理文件 |
filename |
文件名 / 文件夹名 | 新建、上传、重命名时设置 | 用户界面看到的名称 |
folder_flag |
是否文件夹 | 文件夹 1,普通文件 0 |
根目录一定是 1 |
file_size_desc |
文件大小展示字符 | 文件来自 file.file_size_desc;数据库默认 -- |
文件夹一般无实际大小 |
file_type |
文件类型编码 | 上传时根据后缀计算;默认 0 |
用于图片、文档、视频等分类 |
create_user |
创建人 | 当前用户 ID | 审计字段 |
gmt_create |
创建时间 | 数据库默认 / 自动填充 | 通用审计字段 |
gmt_modified |
最后更新时间 | 数据库默认并自动更新 / 自动填充 | 通用审计字段 |
update_time |
兼容旧 Mapper 字段 | 生成列,等同 gmt_modified |
GENERATED ALWAYS AS (gmt_modified) |
update_user |
更新人 | 当前用户 ID | 重命名、移动等操作可更新 |
deleted |
逻辑删除标记 | 默认 0 |
删除文件时主要改这个字段 |
lock_version |
乐观锁版本号 | 默认 0 / 自动填充 |
并发控制 |
对单个登录用户来说,rootFileId 就是他固定不变的最外层文件夹 ID;但它不是整个系统全局统一的固定值,每个用户都有专属的根目录 ID,数据库里的 parent_id=0 只是「无上级」的标记,不是真实的文件夹 ID。
**前端视角下:在正常浏览文件夹的场景下,前端的 parentId 在数值上就等于「你当前正在浏览的文件夹」的 id,和 currentFolderId 的值完全一致。为什么名字叫 parentId,不直接叫当前文件夹 ID?
你现在正在看「文件夹 A」,你想查「文件夹 A 下面有哪些子文件 / 子文件夹」;
- 发给后端的 SQL 是:
select * from user_file where parent_id = 文件夹A的ID; - 对这些子文件来说,文件夹 A 就是它们的「爸爸(parent)」,所以这个查询条件变量就叫
parentId。 因为这个变量的核心用途是当查询条件,是站在「子文件」的视角命名的: 换个角度说:
站在用户的视角:我当前在文件夹 A → 这个 ID 叫
currentFolderId站在查询子文件的视角:要查爸爸是 A 的所有文件 → 这个 ID 叫parentId
因为你永远都是在「查当前文件夹的子级」,所以这两个值永远相等,只是命名的出发点不同。
rootFileId 是后端返回给前端时“临时组装出来的字段”,它的真实来源是:rootFileId = user_file.id
只不过要求这条 user_file 记录满足:
user_id = 当前用户ID
parent_id = 0
folder_flag = 1
deleted = 0
rootFileId 不是 user 表里的 ID。rootFileId 是 user_file 表里根目录那条记录的 ID。 数据库存的是 user_file.id; 后端返回给前端时,把“当前用户根目录那条 user_file.id”命名为 rootFileId。 也就是:
user.id = 用户 ID
user_file.id = 文件或文件夹 ID
rootFileId = 当前用户根目录那条 user_file.id
根目录示例:
| 字段名 | 值 | 说明 |
|---|---|---|
id |
20001 |
当前用户根目录 ID,也就是 rootFileId |
user_id |
10001 |
属于用户 10001 |
parent_id |
0 |
无上级目录 |
real_file_id |
NULL |
根目录是文件夹,不是物理文件 |
filename |
全部文件 |
根目录名称 |
folder_flag |
1 |
是文件夹 |
deleted |
0 |
正常 |
| 如果用户在根目录下创建“我的资料”: |
| 字段名 | 值 |
|---|---|
id |
20002 |
user_id |
10001 |
parent_id |
20001 |
real_file_id |
NULL |
filename |
我的资料 |
folder_flag |
1 |
| “我的资料”这个文件夹,属于用户 10001,位于 rootFileId = 20001 的目录下面。 | |
| parent_id = 0 只是“无上级”的标记。每个用户自己的根目录都有真实 id,这个 id 才是前端的 rootFileId。 | |
real_file_id表示真实文件 ID。如果这一条是文件夹:real_file_id = NULL 因为文件夹没有真实物理文件。 |
|
如果这一条是普通文件:real_file_id = 30001它会关联到真正的文件元数据表 file。 |
|
parentId 和 currentFolderId 的区别: |
currentFolderId:站在用户视角,我当前正在看的文件夹 ID。
parentId:站在查询视角,要查 parent_id = 当前文件夹 ID 的子文件。
正常浏览目录时,两者数值通常相同,只是命名视角不同。
常见操作对 user_file 的影响:
| 操作 | 对 user_file 的影响 |
|---|---|
| 注册用户 | 新增一条根目录记录 |
| 新建文件夹 | 新增一条 folder_flag = 1 的记录 |
| 上传文件 | 新增一条 folder_flag = 0 的记录,real_file_id 指向 file.id |
| 秒传 | 不新增 file,只新增 user_file 并复用已有 real_file_id |
| 重命名 | 修改 filename |
| 删除 | 修改 deleted |
| 转移 | 修改 parent_id |
| 复制文件 | 新增一条 user_file,复用原 real_file_id |
| 复制文件夹 | 递归新增整棵 user_file 目录树 |
3 file 物理文件表
作用:保存真实物理文件元数据。它不表示用户目录里的展示项,而是表示“真实文件存在哪里、大小多少、怎么预览”。
file 表不是新建文件夹时创建的,而是在真实文件上传成功,或者分片合并成功后创建。
| 字段名 | 说明 | 来源 / 默认值 | 备注 |
|---|---|---|---|
id |
真实文件 ID | IdUtil.get() |
被 user_file.real_file_id 引用 |
filename |
文件名称 | 上传时的文件名 | 物理文件元数据名称 |
real_path |
文件物理路径 | 存储引擎保存后得到 | 下载/预览时靠它读取真实文件 |
file_size |
文件实际大小 | 上传参数 totalSize |
字节数,字段类型是字符串 |
file_size_desc |
文件大小展示字符 | FileUtil.byteCountToDisplaySize(totalSize) |
如 12.5 MB |
file_suffix |
文件后缀 | FileUtil.getFileSuffix(filename) |
如 pdf、jpg、mp4 |
file_preview_content_type |
预览响应类型 | FileUtil.getContentType(realPath) |
如 application/pdf、image/png |
identifier |
文件唯一标识 | 前端计算后传入,通常类似 MD5 | 用于秒传判断 |
create_user |
创建人 | 当前上传用户 ID | 可配合 identifier 查找已有文件 |
gmt_create |
创建时间 | 数据库默认 / 自动填充 | 通用审计字段 |
gmt_modified |
最后更新时间 | 数据库默认并自动更新 / 自动填充 | 通用审计字段 |
create_time |
兼容旧 Mapper 字段 | 生成列,等同 gmt_create |
GENERATED ALWAYS AS (gmt_create) |
deleted |
逻辑删除标记 | 默认 0, 0 = 未删除 |
当前普通删除流程不直接删除 file |
lock_version |
乐观锁版本号 | 默认 0 / 自动填充 |
并发控制,假设冲突很少,不加真实锁,而是通过版本号检测冲突。 |
上传文件时,通常会先创建 file,再创建 user_file。
示例:用户上传 a.pdf
file 表:
| 字段名 | 值 |
|---|---|
id |
30001 |
filename |
a.pdf |
real_path |
/storage/xxx/a.pdf |
file_size |
102400 |
file_size_desc |
100 KB |
file_suffix |
pdf |
file_preview_content_type |
application/pdf |
identifier |
md5xxx |
create_user |
10001 |
user_file 表:
| 字段名 | 值 |
|---|---|
id |
20003 |
user_id |
10001 |
parent_id |
20001 |
real_file_id |
30001 |
filename |
a.pdf |
folder_flag |
0 |
含义:
file 表记录真实文件;
user_file 表记录用户在根目录下看到一个叫 a.pdf 的文件;
user_file.real_file_id = file.id,把用户视图和真实文件关联起来。
常见操作对 file 的影响:
| 操作 | 对 file 的影响 |
|---|---|
| 新建文件夹 | 不新增 |
| 普通上传 | 新增一条 file 记录 |
| 分片合并成功 | 新增一条 file 记录 |
| 秒传 | 不新增,复用已有 file |
| 重命名 | 不修改 file.filename |
| 删除 | 当前主流程不直接删除 file |
| 复制文件 | 不复制 file,复用原 real_file_id |
| 下载/预览 | 通过 real_path 读取真实文件 |
一句话:
file 管“真实文件本体的元数据”,user_file 管“用户看到的文件入口”。
4 Redis 登录态
当前项目没有单独的“登录会话表”,登录状态主要保存在 Redis。登录成功后,后端会生成 JWT token,然后存入 Redis。 Redis 里的 key 规则大概是:
USER_LOGIN_{userId}
例如用户 ID 是:10001 那么 Redis 里可能是:
key = USER_LOGIN_10001
value = 当前登录签发的 JWT token
登录成功后,返回给前端的也是这个 token:
{
"success": true,
"data": "eyJhbGciOiJIUzI1NiJ9..."
}
前端拿到 token 后,会保存到 Cookie / Pinia 中。
后续请求时,Axios 请求拦截器会自动加请求头:
Authorization: eyJhbGciOiJIUzI1NiJ9...
后端收到需要登录的请求后,登录切面会做校验:
1. 从 Authorization 请求头取出 token
2. 解析 JWT,得到 userId
3. 用 userId 拼 Redis key:USER_LOGIN_{userId}
4. 去 Redis 取出服务端保存的 token
5. 比较请求头 token 和 Redis token 是否一致
6. 一致,说明登录有效
7. 把 userId 放入 UserIdUtil
8. 放行 Controller 方法
所以 Redis 的作用不是保存用户基础信息,而是保存:
这个用户当前有效的登录 token 是什么
如果 Redis 里没有 token,或者 token 不一致,就认为用户未登录。
5 三者在登录注册流程里的关系
注册时:
前端提交邮箱、密码、昵称
→ auth 服务接收注册请求
→ 调 user 服务
→ user 服务插入 user 表
→ auth 服务再调 files 服务
→ files 服务插入 user_file 根目录记录
→ 注册成功
可以记成:
注册 = 创建用户账号 + 创建用户根目录
对应数据:
user 表新增一条用户记录
user_file 表新增一条根目录记录
登录时:
前端提交邮箱、密码
→ auth 服务查询 user 表
→ 登录成功后生成 JWT
→ 把 JWT 存 Redis
→ 把 JWT 返回给前端
对应数据:
user 表用来确认用户是谁
Redis 用来保存登录态
登录后获取用户信息时:
前端带 token 请求 /api/v1/users/get-user-info
→ 后端切面校验 Redis 登录态
→ 校验通过后得到 userId
→ 查询 user 表拿用户信息
→ 查询 user_file 表拿根目录信息
→ 返回 userInfo 和 rootFileId
对应数据:
Redis 校验是否登录
user 表查用户资料
user_file 表查根目录
小结
user 表:
保存用户账号信息,比如邮箱、昵称、密码哈希、头像、容量等。它回答的是“这个用户是谁”。
user_file 表:
保存用户看到的文件和文件夹目录结构。注册时会自动创建一条根目录记录,后端返回给前端的 rootFileId,本质上就是这条根目录 user_file 记录的加密 ID。它回答的是“这个用户的文件目录从哪里开始”。
Redis:
保存登录态。登录成功后,后端生成 JWT,并以 USER_LOGIN_{userId} 为 key 存到 Redis。之后每次需要登录的请求,后端都会用请求头里的 token 和 Redis 里的 token 做一致性校验。它回答的是“这个请求的用户现在是否还处于登录状态”。
最简单记忆:
user:用户是谁
user_file:用户有哪些文件夹和文件
Redis:用户现在有没有登录

3.注册登录

架构图里的 Spring Cloud Gateway 8081 对应项目中的 networkdisk-gateway 模块。它是后端统一网关服务,理论上可以作为所有 HTTP 接口的统一入口,由 Gateway 根据路由规则把请求转发到 auth、user、files、share 等微服务。
但当前开发环境下,前端 vite.config.js 主要把 /api/v1/auth、/api/v1/users、/api/v1/files 等路径直接代理到对应微服务端口,例如 auth 直接到 8090,files 直接到 8082,user 直接到 8086。所以当前实际开发链路更多是:
浏览器 → Vite Proxy → 具体微服务
而不是:
浏览器 → Vite Proxy → Spring Cloud Gateway → 具体微服务
因此结论是:项目有 Gateway,但当前前端开发代理没有统一走 Gateway。
外部请求:前端 / 浏览器 → Gateway → 某个微服务 内部调用:一个微服务 → Dubbo3 → 另一个微服务
注册流程总览
注册不是只插入一条用户记录。卡码网盘注册至少做两件事:第一,创建用户账号,插入 user 表;第二,为这个用户创建网盘根目录,插入 user_file 表。auth 服务负责接收注册请求和编排流程,user 服务负责用户表,files 服务负责文件表。
注册主流程:
Register.vue
→ 表单校验
→ userStore.registerAction()
→ api/auth.js 发送 POST /api/v1/auth/register
→ Vite Proxy / Gateway 转发请求
→ networkdisk-auth 的 Tomcat 接收请求
→ Spring MVC 找到 AuthController.register()
→ AuthController 把 RegisterParamVO 转成 RegisterContext
→ AuthServiceImpl.register() 编排注册流程
→ Dubbo 调 networkdisk-user 创建用户
→ Dubbo 调 networkdisk-files 创建用户根目录
→ AuthServiceImpl 返回 userId
→ AuthController 包装 Result 返回前端
→ 前端提示注册成功,跳转登录页
注册接口通常不需要登录,所以 Controller 方法上应该加 @LoginIgnore,否则还没登录就会被登录切面拦截。
files 服务:创建用户根目录
用户注册成功后,auth 服务会继续调用 files 服务创建用户根目录。根目录不是物理磁盘目录,也不是一个真实文件,而是 user_file 表里的一条“文件夹记录”。
为什么注册时要创建根目录?因为网盘里所有文件都需要挂在某个目录下面。用户刚注册时还没有任何文件,但系统需要给他一个顶层目录,后续首页加载文件列表时就从这个目录开始查。
根目录可以理解成:
用户自己的网盘首页目录
在数据库里大概是一条这样的记录:
user_file
├── id:根目录 ID,也就是 rootFileId
├── user_id:归属哪个用户
├── parent_id:顶级父节点 ID
├── filename:根目录名称,例如“全部文件”或“我的网盘”
├── folder_flag:是文件夹
├── real_file_id:为空,因为文件夹没有真实文件内容
├── deleted:未删除
创建根目录流程:
AuthServiceImpl
→ 构造 UserFileOperateRequest
- name = 用户根目录名称
- userId = 新注册用户 ID
- parentId = ROOT_PARENT_ID
→ Dubbo 调 userFileFacadeService.createUserRootFile()
→ files 服务的 Facade 接收请求
→ 转成 CreateFolderContext
→ UserFileServiceImpl.createFolder()
→ assembleUserFolder() 组装 UserFileDO
→ save(UserFileDO) 插入 user_file 表
→ 返回根目录 ID
→ auth 服务确认创建成功
→ 注册流程结束
注册返回给前端的一般不是根目录 ID,而是注册成功结果或加密后的 userId。根目录 ID 会在登录后获取用户信息时返回给前端。
注册返回链路
注册成功后的返回链路遵循“谁调用我,我就返回给谁”:
user 服务:
UserService.register()
→ 插入 user 表
→ 返回 UserDO
→ 转成 UserInfo
→ 包装 UserOperatorResponse<UserInfo>
→ 返回 auth 服务
files 服务:
UserFileServiceImpl.createFolder()
→ 插入 user_file 表
→ 返回 rootFileId
→ 包装 UserFileOperateResponse<Long>
→ 返回 auth 服务
auth 服务:
AuthServiceImpl.register()
→ 确认用户创建成功、根目录创建成功
→ 返回 userId 给 AuthController
Controller:
AuthController.register()
→ IdUtil.encrypt(userId)
→ Result.success(...)
→ Spring MVC 转 JSON
→ Tomcat 返回 HTTP 响应
→ Vite Proxy / Gateway 返回前端
前端:
Axios 响应拦截器处理响应
→ Pinia registerAction 返回 result
→ Register.vue 显示注册成功
→ router.push('/login')
登录流程总览
登录和注册不同。注册是创建账号和根目录;登录是校验账号密码,生成 token,并把登录态保存起来。
登录主流程:
Login.vue
→ 表单校验
→ userStore.loginAction()
→ api/auth.js 发送 POST /api/v1/auth/login
→ Vite Proxy / Gateway 转发请求
→ networkdisk-auth 的 Tomcat 接收请求
→ Spring MVC 找到 AuthController.login()
→ AuthServiceImpl 校验邮箱和密码
→ 校验成功后生成 JWT token
→ Redis 保存登录态
→ token 返回给前端
→ 前端保存 token 到 Pinia / Cookie
→ 前端调用 getUserInfoAction()
→ 请求 user 服务获取用户信息和 rootFileId
→ fileStore.initRoot() 初始化根目录
→ 进入首页
登录接口也应该是公开接口,通常需要 @LoginIgnore,否则登录前没有 token,会被登录切面拦截。
登录后获取用户信息和根目录
登录成功后,前端通常还会立刻调用:
getUserInfoAction()
它会请求用户服务,例如:
GET /api/v1/users/get-user-info
Authorization: JWT token字符串
这个接口不是公开接口,所以进入 UserController 前会先经过 UserInfoLoginAspect 登录切面。
切面流程:
1. AOP 捕获匹配切点的 Controller 方法执行。
2. 判断方法上有没有 @LoginIgnore。
3. 没有 @LoginIgnore,说明需要登录校验。
4. 从请求头 Authorization 或请求参数 authorization 取 token。
5. JWTUtil 解析 token,得到 userId。
6. 根据 USER_LOGIN_PREFIX + userId 去 Redis 查询服务端保存的 token。
7. 比较请求 token 和 Redis token 是否一致。
8. 不一致:返回未登录错误,Controller 不执行。
9. 一致:UserIdUtil.set(userId),保存当前请求用户 ID。
10. proceedingJoinPoint.proceed() 放行,Controller 正式执行。
Controller 里就可以直接取当前用户:
Long userId = UserIdUtil.get()
然后调用:
userService.getUserInfo(userId)
UserService.getUserInfo(userId) 会做两件事:
1. 根据 userId 查询 user 表,拿到昵称、头像等用户基础信息。
2. 通过 Dubbo 调 files 服务,查询该用户的网盘根目录信息。
最后组装返回:
UserInfoVO
├── userId
├── nickname
├── profilePhotoUrl
├── rootFileId
└── rootFilename
前端拿到 rootFileId 后,会保存到 userStore。然后 fileStore.initRoot() 使用这个 rootFileId 初始化文件模块,让首页可以从用户根目录开始加载文件列表。
rootFileId 到底是什么?
rootFileId 是用户根目录在 user_file 表中的 ID。它不是用户 ID,也不是物理磁盘路径。
可以这样理解:
userId:表示当前用户是谁。
rootFileId:表示这个用户网盘的顶层文件夹是哪一条记录。
比如用户进入首页时,前端不是直接查询“所有文件”,而是查询:
parentId = rootFileId
意思是:加载当前用户根目录下面的文件和文件夹。
所以登录后必须获取用户信息,不只是为了拿昵称和头像,更重要的是拿到 rootFileId。没有 rootFileId,前端不知道从哪个目录开始加载文件列表。
项目里的几种“加密/校验”不要混
第一,密码处理。注册时把明文密码通过 PasswordUtil.encryptPassword() 处理后保存到 password_hash。登录时对用户输入的密码做同样处理,再和数据库保存值比较。这更准确叫密码哈希,不是可逆解密。
第二,JWT token。登录成功后生成 token,前端后续请求放到 Authorization 请求头里。JWT 主要用于证明“当前请求是谁发的”,不是单纯为了隐藏数据。后端还能从 token 里解析 userId。
第三,Redis 登录态。Redis 里保存 USER_LOGIN_PREFIX + userId → token,后续接口会比对请求 token 和 Redis token 是否一致。这样可以实现退出登录、登录过期、踢下线等控制。
第四,ID 加密。IdUtil.encrypt(userId/rootFileId) 通常是为了避免直接把数据库 Long ID 暴露给前端,属于 ID 加密/混淆。前端看到的是加密后的字符串,后端需要时再解回来。
第五,文件 MD5。上传文件时计算 MD5 主要用于秒传、去重、完整性校验,不是登录安全里的密码加密。
一句话记忆:
密码哈希:保护密码。
JWT:证明登录身份。
Redis token:保存服务端登录态。
ID 加密:避免直接暴露数据库 ID。
文件 MD5:文件校验和秒传。
注册登录最终精简版
注册:
Register.vue 表单校验
→ Pinia registerAction
→ Axios POST /api/v1/auth/register
→ auth 服务 AuthController.register
→ RegisterParamVO 转 RegisterContext
→ AuthServiceImpl 编排流程
→ Dubbo 调 user 服务创建用户
→ UserService.register 插入 user 表,密码哈希存入 password_hash
→ Dubbo 调 files 服务创建用户根目录
→ UserFileService.createFolder 插入 user_file 表
→ auth 确认两步成功
→ 返回注册成功
→ 前端跳转登录页
登录:
Login.vue 表单校验
→ Pinia loginAction
→ Axios POST /api/v1/auth/login
→ auth 服务校验邮箱和密码
→ 密码正确则生成 JWT token
→ Redis 保存 USER_LOGIN_PREFIX + userId 对应 token
→ token 返回前端
→ 前端保存 token 到 Pinia / Cookie
→ 调 getUserInfoAction
→ 请求 user 服务获取用户信息
→ UserInfoLoginAspect 校验 Authorization token
→ UserService 查询用户表 + Dubbo 查询根目录
→ 返回 userId、昵称、头像、rootFileId、rootFilename
→ fileStore.initRoot 使用 rootFileId 加载首页文件列表
最核心一句话:
注册负责创建账号和根目录;登录负责校验密码、生成 token、保存登录态;登录后获取用户信息负责拿到 rootFileId,前端再用 rootFileId 加载网盘首页文件。
3.1前端注册页面
1.Register.vue
Register.vue 是卡码网盘的用户注册页面,主要负责前端注册流程:展示注册表单、收集用户输入、进行基础校验、调用用户注册逻辑,并在注册成功后跳转到登录页。它本身不直接操作数据库,也不直接处理真正的用户创建逻辑,只是注册流程的前端入口。
页面中使用 registerForm 保存用户输入的数据,包括 email、nickName、password、confirmPassword。输入框通过 v-model 和这些字段绑定,用户在页面输入内容时,数据会自动同步到 registerForm 中。confirmPassword 只用于前端确认两次密码是否一致,一般不会传给后端。
注册页使用 Element Plus 的 el-form 组件实现表单校验。registerRules 定义邮箱、昵称、密码、确认密码的校验规则,el-form-item 的 prop 用来把表单项和规则字段对应起来。例如 prop="email" 会关联 registerForm.email 和 registerRules.email。这里要注意,registerRules 本身只是一个规则配置对象,不会自己监听输入框;真正负责监听输入、失焦、内容变化和执行校验的是 Element Plus 的 el-form、el-form-item、el-input 组件。
采用 Element Plus el-form 实现表单校验:registerRules 仅为校验配置对象,自身无监听执行能力,依靠 el-form/el-form-item/el-input 组件完成校验监听与渲染;:model 绑定表单数据源,:rules 绑定校验规则,el-form-item 的 prop 作为字段映射标识,关联同名字段数据与规则。
单字段实时校验流程:el-input 触发 blur/change 事件 → el-form-item 通过 prop 匹配对应表单值与规则 → 按 trigger 执行校验,失败则下方展示红色提示。
全局提交校验:点击注册执行 registerFormRef.value.validate(),通过 ref 获取表单实例,一次性遍历所有表单项全量校验;任意字段校验失败直接阻断后端请求,全部通过才执行注册逻辑。
// 用户输入 el-input
// → el-input 内部触发 change / input / blur 事件
// → el-form-item 收到这个字段发生了变化或失焦
// → el-form-item 通过 prop="email" 知道自己管 email 字段
// → 它去 el-form 的 model 中取 registerForm.email// → 它去 el-form 的 rules 中找 registerRules.email// → 按 trigger 判断哪些规则该执行
// → 执行 required/type/validator 等校验
// → 成功就不提示,失败就在表单项下面显示 message
规则校验的核心配合关系是:
<el-form
ref="registerFormRef"
:model="registerForm"
:rules="registerRules"
>
<el-form-item prop="email">
<el-input v-model="registerForm.email" />
</el-form-item>
</el-form>
其中,:model="registerForm" 告诉表单数据在哪里,:rules="registerRules" 告诉表单规则在哪里,prop="email" 告诉当前表单项负责哪个字段,v-model="registerForm.email" 负责把输入框内容同步到对应字段。校验时,Element Plus 会根据 prop="email" 去 registerForm.email 中取当前值,再去 registerRules.email 中找规则,然后按照规则进行校验。
规则触发主要有两种方式:一种是输入框自身触发,例如规则中写了 trigger: 'blur',表示输入框失去焦点时校验;写了 trigger: 'change',表示内容变化时校验。另一种是点击注册按钮时,在 handleRegister() 中手动调用 registerFormRef.value.validate(),它会触发整个表单的统一校验。校验失败时,后续注册逻辑不会继续执行,也不会请求后端;只有所有字段校验通过后,才会继续调用 userStore.registerAction()。
registerFormRef.value.validate() 可以理解为:通过 ref="registerFormRef" 拿到页面上的 el-form 表单组件实例,.value 取出这个组件对象,validate() 调用 Element Plus 表单提供的整体校验方法。它会遍历所有 el-form-item,根据每个表单项的 prop 找到对应的表单数据和校验规则。
// handleRegister()
// → registerFormRef.value.validate()
// → el-form 找到自己下面所有 el-form-item// → 每个 el-form-item 根据自己的 prop 找对应字段
// → email 检查 registerRules.email
// → nickName 检查 registerRules.nickName
// → password 检查 registerRules.password
// → confirmPassword 检查 registerRules.confirmPassword
// → 只要一个失败,validate() 就失败
// → 全部成功,才继续执行后面的注册请求
点击注册按钮后,会执行 handleRegister()。这个函数先校验表单,校验成功后将 email、nickName、password 传给 userStore.registerAction(),由用户 store 继续调用 api 请求后端注册接口。注册成功后,页面会调用 ElMessage.success() 显示成功提示,然后通过 router.push('/login') 跳转到登录页;如果注册失败,则通过 ElMessage.error() 显示错误信息。loading 用来控制按钮加载状态,防止用户重复点击注册按钮。
核心流程可以概括为:
用户输入注册信息
→ v-model 同步到 registerForm
→ el-form-item 根据 prop 关联字段和规则
→ 输入框 blur/change 时自动触发局部校验
→ 点击注册按钮
→ handleRegister()
→ registerFormRef.value.validate() 触发整体验证
→ 校验通过后调用 userStore.registerAction({ email, nickName, password })
→ 调用后端注册接口
→ 注册成功后提示并跳转登录页
一句话总结:Register.vue 是注册功能的前端入口,负责页面交互、表单数据收集和规则校验;registerRules 只是规则配置,真正触发校验的是 Element Plus 表单组件;真正的注册请求从 userStore.registerAction() 开始继续向后传递。
Register.vue 承载页面交互、数据收集、前端校验;真实接口请求、后端交互逻辑交由 userStore 统一处理。
2. Pinia Store 处理(stores/user.js)
Pinia store 可以理解成:前端里的一个“全局数据仓库 + 业务方法管理处”。 在 Vue 页面里,每个组件都有自己的数据,比如注册页有:
registerForm.email
registerForm.password
这些是页面自己的局部数据。 但有些数据很多页面都要用,比如:
登录 token
当前用户信息
用户根目录 ID
是否已登录
这些如果只放在某一个页面里,其他页面就不好拿。所以要放到一个全局位置,这个位置就是 Pinia store。
在你项目里,stores/user.js (line 1) 就是用户相关的 store。
它里面有全局状态:
const token = ref(Cookies.get('token') || '') 登录凭证
const userInfo = ref({}) 当前用户信息
const rootFileId = ref('') 用户根目录 ID
const isLoggedIn = computed(() => !!token.value) 是否已登录
它里面也封装了用户相关方法:
const loginAction = async (loginData) => { ... }
const registerAction = async (registerData) => { ... }
const getUserInfoAction = async () => { ... }
const logout = () => { ... }
也就是说,Pinia store 不只是存数据,还会集中管理一些“和用户有关的前端业务逻辑”。
比如注册页没有直接写:
await register(registerForm)
而是写:
const result = await userStore.registerAction(...)
这里的 userStore 就是从 Pinia 取出来的用户仓库:
const userStore = useUserStore()
完整关系是:
Register.vue 页面
→ 调 userStore.registerAction()
→ store 内部调 api/auth.js 的 register()
→ axios 请求后端
→ store 把结果整理成 { success, data/message }
→ 页面根据 result 提示成功或失败
为什么不在页面里直接调接口? 因为这样更清楚:
页面组件:负责展示 UI、收集输入、提示结果
Pinia store:负责用户相关状态和业务动作
api/auth.js:负责具体 HTTP 请求
举个生活化理解:
Register.vue = 前台窗口,负责跟用户交互
Pinia userStore = 用户业务办事处,负责注册/登录/保存 token
api/auth.js = 打电话的人,负责真正联系后端接口
后端 = 真正办理业务的系统
你项目里的注册流程是:
Register.vue
→ useUserStore() 拿到用户仓库
→ userStore.registerAction()
→ register(registerData)
→ Axios POST /api/v1/auth/register
→ 后端返回
→ registerAction 整理结果
→ Register.vue 拿到 result
defineStore() 是创建 store 的方法:
export const useUserStore = defineStore('user', () => {
...
return {
token,
userInfo,
rootFileId,
isLoggedIn,
loginAction,
registerAction,
getUserInfoAction,
logout
}
})
这里的 'user' 是这个 store 的名字。
return 里面暴露出去的东西,页面才能用:
return {
token,
userInfo,
registerAction
}
所以页面可以:
const userStore = useUserStore()
userStore.registerAction(...)
userStore.token
userStore.userInfo
一句话总结:
Pinia store 是 Vue 前端的全局状态仓库。它把多个页面都要用的数据和方法集中管理起来,例如 token、用户信息、登录、注册、退出。页面通过 useUserStore() 拿到这个仓库,再调用里面的方法或读取里面的数据。
Pinia:Vue3官方状态管理库,用于跨组件/跨页面共享数据、统一封装接口业务逻辑;defineStore() 是Pinia创建独立数据仓库的专用API。
注册相关核心逻辑
- 提前导入接口:
import { register } from '@/api/auth',register是基于axios封装的注册请求函数,负责发送网络请求。 registerAction异步方法- 入参:registerData,接收Register.vue传递的邮箱、昵称、密码表单数据;
- 内部流程:通过
await register()调用接口;依靠success字段判断业务结果;使用try/catch捕获网络异常;统一包装固定格式返回对象; - 返回格式:成功
{success:true, data:后端返回数据};失败{success:false, message:错误提示}。
- 导出规则:文件末尾return中暴露registerAction,页面才能引入调用。
完整调用链路
Register.vue点击注册 → 全局表单校验全部通过 → 实例化仓库const userStore = useUserStore() → const result = await userStore.registerAction(表单数据) → 内部执行await register(registerData)(axios发起请求/api/v1/auth/register)→ 后端数据逐层回传给页面result。
3. Axios 请求
发送POST 请求
上一步 registerAction 内部调用的 register() 接口函数来自 src/api/auth.js,该文件为认证模块专属请求层。
- 核心实例
authRequest使用axios.create()创建独立Axios实例,与全局Axios隔离,各业务模块配置互不冲突;实例配置baseURL: '/api/v1/auth'、timeout: 10000,并引入/utils/attachInterceptors挂载统一拦截器,集中处理Token、GET缓存、业务响应报错等通用逻辑。- baseURL:接口统一基础前缀,调用时仅需填写短路径,
/register会自动拼接为完整地址/api/v1/auth/register; - timeout:设置10秒请求超时,超时无响应直接中断请求并抛出异常。
- baseURL:接口统一基础前缀,调用时仅需填写短路径,
register(data)请求函数 接收前端传来的注册表单数据,内部配置method: 'post',通过data携带表单参数,调用authRequest发起网络请求。
完整执行链路:
- 代码执行
register(表单数据),函数返回authRequest({url:'/register', method:'post', data}); - Axios实例接收配置,自动发起POST网络请求;
- 请求发送前先走
attachInterceptors的请求拦截器:自动为GET接口追加防缓存时间戳、携带登录Token(注册无Token不影响流程)、补全请求头; - 请求发送至后端
/api/v1/auth/register; - 后端返回数据后,先走响应拦截器统一校验业务状态、弹出错误提示;
- 处理完成的数据逐层回传,先给到Pinia仓库的
registerAction,最终传递到Register.vue页面。
4. 拦截器核心逻辑
该方法接收Axios实例与日志标识,为实例统一挂载请求拦截器、响应拦截器,所有经过authRequest的请求都会自动执行通用预处理、后置处理逻辑。
(/utils/attachInterceptors.js)
前置概念说明
-
config 来源与作用 调用axios实例发起请求时传入的
url、method、data、params、headers等配置,会被Axios整合生成config对象,自动传入请求拦截器回调。 config存储本次请求全部信息,拦截器可修改参数、请求头,修改后的config才是最终发给后端的真实请求配置。 -
拦截器获取 userStore 的方式 文件头部导入仓库:
import { useUserStore } from '@/stores/user'; 必须在拦截器回调函数内部执行const userStore = useUserStore(),才能正常获取全局用户状态; 禁止在文件顶层直接实例化store,会因Vue上下文缺失报错。 补充:仓库内token初始化时从Cookie读取,拦截器不直接操作Cookie,仅读取仓库中已缓存的token。 -
浏览器GET缓存原理与解决方案
- 原理:GET 请求天然更容易被浏览器或中间缓存复用,尤其在 URL 完全相同且缓存策略允许时,可能拿到旧响应;因此项目给 GET 统一追加
_t=Date.now(),让每次 URL 不同,降低缓存命中概率。 - 业务弊端:文件列表、分页查询等页面会长期展示过期数据。
- 统一处理方案:请求拦截器识别GET请求,通过
_t=Date.now()添加动态时间戳改变URL,同时配置Cache-Control、Pragma请求头强制禁用缓存;使用展开运算符保留原有查询参数,不影响分页、筛选功能。 - 生效范围:仅GET查询接口执行,注册、登录、上传等POST请求不触发该逻辑。
一、请求拦截器(请求发送至后端前执行)
- 容错兜底:判断
config.headers不存在时初始化为空对象,防止赋值报错; - 防GET缓存:统一小写匹配请求方式,追加时间戳参数与禁用缓存请求头;
- 自动携带鉴权Token:通过userStore读取全局token,存在则写入
Authorization请求头;注册请求无登录凭证,该逻辑不会执行; - 异常转发:请求阶段出现错误直接抛出,交由上层捕获处理。
前端的 userStore 本身只存储「当前登录用户」,不支持同时登录多个用户(这是前端会话的特性,浏览器同一域名下 localStorage/cookie 是共享的)。如果要支持 “多用户切换”,需要额外设计。注册时用户还没登录,userStore.token 是空,不会往请求头塞 token,后端不会校验登录态,正常接收注册参数。
二、响应拦截器(后端数据返回后统一处理)
- 登录失效拦截:识别业务码
NOT_LOGIN、HTTP状态码401,调用userStore.logout()清空所有登录状态,强制跳转登录页; - 适配登录特殊返回:后端返回字符串
SUCCESS时,自动从响应头提取token并封装标准格式返回;登录失败弹出提示并抛出错误; - 标准业务数据判断:
- 请求成功(标准成功判断主要依赖
success === true,代码里也兼容了code === 200这种旧格式):直接返回后端原始数据; - 业务失败:全局弹窗展示错误信息;文件秒传不存在属于正常业务分支,直接放行不抛出异常;其余失败抛出错误;
- 请求成功(标准成功判断主要依赖
- 格式兜底:无法匹配上述规则的响应,原样返回给上层;
- 网络异常统一处理:捕获请求报错,弹出网络错误提示并抛出异常。
5.Vite 开发代理转发机制
一、基础概念
Vite 是前端开发与构建工具,执行 npm run dev 会基于 Node.js 启动内置开发服务器(默认地址 http://localhost:5173),提供页面热更新、实时编译、接口代理转发能力。
Node.js:JavaScript 服务端运行环境,npm、Vite、ESLint 等前端工具均依赖它运行;浏览器跨域限制只针对浏览器发起的请求,服务器之间互相转发请求不受同源策略约束。Node.js 是基于 Chrome V8 引擎的 JavaScript 运行环境,打破 JS 只能在浏览器运行的限制,让 JS 可以直接在电脑、服务器的命令行执行。浏览器只能处理页面渲染、用户交互;Node.js 拥有文件读写、网络请求、启动本地服务等系统能力,是现代前端工程化的底层基础。
二、本地开发完整请求链路
前端 Axios 统一使用相对路径发起注册请求:POST /api/v1/auth/register
- 浏览器自动拼接当前页面域名,真实请求地址:
http://localhost:5173/api/v1/auth/register - Vite 开发服务器捕获请求,匹配
vite.config.js内 proxy 代理规则
'/api/v1/auth': {
target: 'http://localhost:8090', // 后端认证微服务地址
changeOrigin: true // 修改请求源标识,避免后端校验Origin拦截请求
}
- Vite 将请求转发至后端完整地址
http://localhost:8090/api/v1/auth/register - 请求进入 SpringBoot 内置 Tomcat,匹配
AuthController中@PostMapping("/register")接口执行业务逻辑
本地开发时,前端 Axios 使用相对路径,例如 POST /api/v1/auth/register。浏览器会先请求当前前端地址 http://localhost:5173/api/v1/auth/register,然后由 Vite 开发服务器根据 vite.config.js 的 proxy 规则转发到后端。
如果 proxy target 配的是 http://localhost:8090,链路是:
浏览器
→ Vite 开发服务器 5173
→ Vite Proxy 转发到 networkdisk-auth 8090
→ auth 服务内置 Tomcat
→ Spring MVC
→ AuthController.register()
如果 proxy target 配的是 http://localhost:8081,链路是:
浏览器
→ Vite 开发服务器 5173
→ Vite Proxy 转发到 Spring Cloud Gateway 8081
→ Gateway 根据 /api/v1/auth 转发到 networkdisk-auth 8090
→ auth 服务内置 Tomcat
→ Spring MVC
→ AuthController.register()
所以,Vite Proxy 是前端开发环境代理;Spring Cloud Gateway 是后端微服务统一入口。二者不是一个东西,只是都能做“转发”。
三、配置代理核心作用:解决开发环境跨域 浏览器同源策略规则:协议、域名、端口任意一项不一致,就判定为跨域,浏览器会直接拦截请求。
- 不配置代理:页面运行在5173,直接请求8090后端,端口不同触发跨域报错;
- 使用Vite代理:浏览器仅访问同源5173服务,跨域转发由Vite服务端完成,绕开浏览器限制。
四、开发环境与生产环境区别
-
开发环境(npm run dev) 依靠 Vite 内置 proxy 转发接口,仅本地调试生效,执行打包后该代理配置完全失效。
-
生产环境(npm run build 打包上线)
-
打包生成
dist纯静态资源(仅HTML/CSS/JS,无Vite、无Node服务); -
静态托管与接口转发不再依赖Vite,两种主流部署方案: - 方案1:Nginx反向代理(中小型项目首选) 浏览器 → Nginx - 匹配
/:返回dist前端静态页面 - 匹配/api/v1/*:转发至对应后端微服务 额外能力:HTTPS证书、静态缓存、负载均衡、统一异常页面
- 方案2:微服务网关(大型分布式项目) 浏览器 → Spring Cloud Gateway / Kong / APISIX 网关 网关统一路由分发接口,额外提供全局鉴权、限流、访问日志、灰度发布等能力 -
生产通用请求链路: 浏览器 → Nginx/网关 → 后端微服务 → Controller 接口
五、项目配置补充说明 本项目所有代理的 rewrite 配置为原样转发路径,开发、生产环境接口路由规则保持一致,上线无需修改前端接口地址,减少维护成本。
3.2注册后端流程
每个需要对外提供 HTTP 接口的微服务,通常都有自己的端口。端口一般写在各模块的:
src/main/resources/application.yml
注册请求到达 networkdisk-auth 后,auth 服务只负责接收注册 HTTP 请求和编排注册流程,它自己不直接写 user 表,也不直接写 user_file 表。真正创建用户由 networkdisk-user 完成,真正创建用户根目录由 networkdisk-files 完成。auth 服务通过 Dubbo 远程调用这两个服务:
networkdisk-auth
→ Dubbo 调 networkdisk-user 创建用户
→ Dubbo 调 networkdisk-files 创建用户根目录
这里要注意:Gateway 负责“前端 HTTP 请求进入哪个微服务”,Dubbo 负责“微服务之间互相调用”。注册请求进入 auth 服务之后,auth 再调 user/files,这一步走的是 Dubbo,不是 Gateway。
这里的 8090 是 networkdisk-auth 服务的 HTTP 端口。Spring Boot 启动时会启动内嵌 Tomcat,Tomcat 监听 8090 端口,等待浏览器或代理服务器发来的 HTTP 请求。
整体流程可以先记成一条主线:
浏览器 / Vite
-> spring gate way
→ networkdisk-auth 的 Tomcat
→ Spring MVC
→ AuthController.register()
→ AuthServiceImpl.register()
→ Dubbo 调 networkdisk-user 创建用户
→ Dubbo 调 networkdisk-files 创建用户根目录
→ 返回 Result 给前端
3.2.1. AuthController.register
请求经过前端 Axios 拦截器处理后,会发送 POST /api/v1/auth/register。开发环境下,Vite 根据代理配置把该请求转发到认证服务 networkdisk-auth,端口为 8090。后端由 AuthController 中的 @PostMapping("/register") 方法接收。
注册接口入参是 RegisterParamVO,通过 @Valid @RequestBody 接收并校验 JSON 请求体。@RequestBody 把前端传来的 JSON 字符串,转换成 RegisterParamVO 类型的对象,然后把这个对象赋值给 registerParam 变量。 @Valid 触发检查,告诉Spring先校验数据再进方法。具体怎么检查看 RegisterParamVO 类里的 @NotBlank、@Email、@Size 等注解,注解怎么写就怎么查
参数校验通过后,才会真正进入:AuthController.register()
Controller 层一般不写复杂业务,它主要负责:
接收请求
校验参数
转换对象
调用 Service
包装返回结果
注册方法中,先把前端参数对象转换成业务上下文对象:
RegisterContext registerContext = authConvertor.registerParamToRegisterContext(registerParam);
authConvertor 的接口是我们定义的,但具体实现类不是手写的,而是 MapStruct 在编译期自动生成的。Spring 会把生成的实现类注册成 Bean,然后注入到 AuthController 中。接口上使用了 MapStruct 的核心注解 @Mapper。MapStruct 是 Java 中常用的对象映射工具,主要用于 VO、DTO、Entity、Context 等对象之间的属性转换。它不是反射,也不是运行时动态代理,而是在编译期根据接口方法自动生成实现类。生成的实现类本质上就是普通 Java 代码,底层通过 get/set 完成字段赋值,所以性能接近手写代码,通常高于基于反射的 BeanUtils。开发时只需要定义转换接口和必要的映射规则,具体属性拷贝逻辑由 MapStruct 自动生成,从而减少大量重复的 set 代码。
使用 MapStruct 把前端 VO 对象转换成业务上下文 RegisterContext。View Object,视图对象。 专门用来接收前端传过来的请求 JSON,或者给前端返回数据。 registerParam 是前端JSON转出来的VO对象,只适合Controller接收前端参数。
这一行核心作用:
第一,对象转换。把 RegisterParamVO 里的字段复制拷贝到 RegisterContext,避免手写大量 set。
第二,分层隔离。Controller 认识前端参数,Service 认识业务上下文。Service 不直接依赖前端 VO,后续前端字段变化时,不一定影响业务层。
第三,方便业务流程扩展。注册流程可能不只是保存用户,还可能包括验证码校验、密码加密、创建用户、生成 token、发送邮件、记录日志。RegisterContext 可以作为整个注册流程的上下文对象一路传下去。
大型项目经常会这样提前分层,因为以后可能变成:
RegisterParamVO:跟着前端页面变
RegisterContext:跟着后端业务流程变
UserEntity:跟着数据库表结构变
UserVO:跟着前端展示结果变
它们现在可能字段相似,但职责不同。
RegisterParamVO:前端传什么,我就接什么,属于 Controller 层入参对象。
RegisterContext:注册业务需要什么,我就放什么,属于 Service 层业务上下文。
MapStruct:负责把一个对象的字段复制到另一个对象,避免手写 set。这行代码:把“前端注册表单”转换成“后端注册业务上下文”。
然后调用认证服务的业务逻辑:
Long userId = authService.register(registerContext);
authService 是按接口类型注入的依赖。Spring 会在容器中寻找 AuthService 的实现类,并将 AuthServiceImpl 注入进来;因此 Controller 不直接处理注册逻辑,而是调用 authService.register(),把注册任务交给 AuthServiceImpl 执行。
3.2.2. AuthServiceImpl 是注册流程的业务调度器
AuthServiceImpl.register() 是注册流程的核心编排层。它自己不直接操作数据库,而是调用其他微服务完成具体工作。
它依赖两个 Dubbo 远程服务:
@DubboReference(version = "1.0.0")
private UserFacadeService userFacadeService;
@DubboReference(version = "1.0.0")
private UserFileFacadeService userFileFacadeService;
这两个对象看起来像普通 Java 对象,但实际上不是本地实现类,而是 Dubbo 生成的远程代理对象。 代码看起来是:
userFacadeService.register(userRegisterRequest);
但真实发生的是:
networkdisk-auth
→ Dubbo 网络调用
→ networkdisk-user
→ UserFacadeServiceImpl.register()
所以 AuthServiceImpl 的作用不是“亲自创建用户”,而是“安排注册流程”:
1. 调 user 服务创建用户
2. 调 files 服务创建用户根目录
3. 两步都成功后,把 userId 返回给 Controller
2.1 AuthServiceImpl.register
AuthServiceImpl.register() 是注册流程的服务业务层。它本身不直接操作 user 表,而是通过 Dubbo 调用用户服务完成用户创建,再通过 Dubbo 调用文件服务创建用户根目录。
根目录是用户网盘的顶层文件夹,不是物理磁盘目录,而是 user_file 表中的一条文件夹记录。它在用户注册时创建,登录后查询出来返回给前端,前端用 rootFileId 作为起点加载用户的文件列表。
根目录不是服务器磁盘上的真实目录,也不是一个真实文件,而是 user_file 表中的一条文件夹记录。它表示当前用户网盘的顶层目录。后续前端加载首页文件列表时,需要先拿到这个根目录 ID,也就是 rootFileId,再查询这个目录下面的文件。
核心步骤:
-
判断
RegisterContext是否为空,为空则抛出注册失败异常。 -
创建
UserRegisterRequest,把邮箱、密码、昵称放进去。- RegisterContext 是 auth 服务内部使用的业务上下文对象;不同层关注点不一样。
-
前端 JSON → RegisterParamVO → RegisterContext → UserRegisterRequest
-
调用
userFacadeService.register(userRegisterRequest),远程请求用户服务创建用户。 -
校验用户服务返回结果,如果失败或没有用户数据,则抛出注册失败异常。
-
取出注册成功后的
userId。 -
创建
UserFileOperateReqest,准备为新用户创建根目录。 -
调用
userFileFacadeService.createUserRootFile(),远程请求文件服务创建用户根文件夹。 -
文件根目录创建成功后,返回新用户 ID。
这里要注意:注册流程跨了两个服务,用户服务负责创建用户,文件服务负责创建根目录。代码注释里也写了 TODO,后续应该加事务控制,否则可能出现“用户创建成功,但根目录创建失败”的数据不一致问题。
2.1.1 UserFacadeService.register
这里要补充一点:主动发起调用的是 AuthServiceImpl.register()。它内部调用 userFacadeService.register(...)。userFacadeService 是 @DubboReference 注入的 Dubbo 远程代理对象,所以这个调用最终会通过 Dubbo 发送到 user 服务,并落到 user 服务里的 UserFacadeServiceImpl.register(...) 执行。该目标方法上标了 @Facade,所以按项目设计,在真正执行 UserFacadeServiceImpl.register(...) 前,会先经过 FacadeAspect。
完整链路是:
AuthController.register()
↓
AuthServiceImpl.register()
↓ 主动调用
userFacadeService.register(userRegisterRequest)
↓ Dubbo 远程调用
UserFacadeServiceImpl.register(...)
↓ 因为目标方法上有 @Facade
FacadeAspect.facade(...)
↓
pjp.proceed()
↓
真正执行 UserFacadeServiceImpl.register(...)
所以不是 FacadeAspect 拦截 AuthServiceImpl.register()。
它拦截的是 user 服务里的:
@Facade
@Override
public UserOperatorResponse<UserInfo> register(UserRegisterRequest request) {
...
}
这一层叫 facade 层,可以理解成“微服务对外暴露的接口层”
1. 接收 auth 服务传来的 UserRegisterRequest
2. 调用 userService.register() 真正创建用户
3. 把 UserDO 转成 UserInfo 返回给 auth 服务
UserDO 是数据库实体对象,对应数据库里的 user 表。
DO 是Data Object,数据库表对应的 Java 对象,所以 UserDO 主要用于 user 服务内部操作数据库。
UserInfo 是返回给其他服务用的 DTO,不是数据库表实体,而是服务之间传输的用户信息对象。Data Transfer Object:数据传输对象。
因为 UserDO 里有数据库字段,这些不一定适合暴露给其他服务。UserInfo 可以只返回外部需要的数据。
`UserDO:数据库实体,user 服务内部使用`
`UserInfo:远程调用返回 DTO,给 auth 等其他服务使用`
2.1.1.1 UserService.register
UserService.register 真正创建用户,真正写数据库的是 networkdisk-user 里的:
public UserDO register(UserRegisterRequest request)
这段才是用户创建的核心。
public UserDO register(UserRegisterRequest request) {
if (request == null || StringUtils.isBlank(request.getEmail()) || StringUtils.isBlank(request.getPassword())) {
throw new UserException(USER_OPERATE_FAILED);
}
if (userMapper.findByEmail(request.getEmail()) != null) {
throw new UserException(DUPLICATE_TELEPHONE_NUMBER);
}
String nickName = StringUtils.isBlank(request.getNickname())
? StringUtils.defaultIfBlank(StringUtils.substringBefore(request.getEmail(), "@"), request.getEmail())
: request.getNickname();
UserDO insertDO = new UserDO();
insertDO.setEmail(request.getEmail());
insertDO.setNickName(nickName);
//生成唯一用户ID(雪花算法/Snowflake)
//IdUtil.get() 生成一个全局唯一的数字ID,作为用户主键,不依赖数据库自增,适合分布式系统
insertDO.setId(IdUtil.get());
//密码不能明文存储,用工具类加密后再存
//当前项目使用 MD5 把明文密码转换成 password_hash 后入库。
//真实生产项目更推荐 BCrypt、PBKDF2、Argon2 等带盐哈希算法,单纯 MD5 安全性较弱。
insertDO.setPasswordHash(PasswordUtil.encryptPassword(request.getPassword()));
//置已用存储空间 = 0(新用户还没存任何文件)
//0L 表示 long 类型的 0 insertDO.setUseSpace(0L);
//设置总可用存储空间 = 初始配额(1GB)
//从常量类里读取,方便统一修改
insertDO.setTotalSpace(UserConstants.USER_INIT_SPACE);
//设置逻辑删除标记 = 未删除(0 或 false)
//不会真正删数据,只是打标记,方便数据恢复
insertDO.setDeleted(DeleteEnum.NO.getCode());
// 这里设置的是 lastLoginTime 字段,字段含义是最后登录时间。
//当前代码在注册时直接设置为当前时间,可以理解为初始化该字段;真正的创建时间由 gmt_create 字段记录。
//new Date() 获取当前系统时间
insertDO.setLastLoginTime(new Date());
//设置默认头像(一个 GitHub 的公共头像地址)
//新用户注册时给个默认头像,后续可以自己修改
insertDO.setProfilePhotoUrl("https://avatars.githubusercontent.com/u/25891014?v=4");
userMapper.insert(insertDO);
return insertDO;
}
步骤拆开:
1. 判断请求对象是否为空
2. 判断 email/password 是否为空
3. 根据 email 查询数据库,检查用户是否已存在
4. 生成昵称
5. 创建 UserDO 数据库实体
6. 生成用户 ID
7. 加密密码
8. 初始化用户空间
9. 设置默认头像
10. 调用 userMapper.insert() 插入 user 表
11. 返回插入后的 UserDO
2.1.1.2 userMapper.insert()
插入用户用的是:
userMapper.insert(insertDO);
这里没有自己写 XML SQL,因为 UserMapper 继承了:
BaseMapper<UserDO>
这是 MyBatis-Plus 提供的通用 Mapper。
BaseMapper 自带常见 CRUD 方法,比如:
insert
deleteById
updateById
selectById
selectList
所以:
userMapper.insert(insertDO)
会根据 UserDO 和 @TableName("user") 自动生成类似 SQL:
insert into user (
id,
nick_name,
use_space,
total_space,
email,
password_hash,
last_login_time,
profile_photo_url,
deleted
) values (?, ?, ?, ?, ?, ?, ?, ?, ?)
字段名为什么能从 nickName 对应到 nick_name?
因为 MyBatis 配置了驼峰转下划线:
mybatis:
configuration:
map-underscore-to-camel-case: true
并且 UserDO 上有:
@TableName("user")// 告诉 MyBatis-Plus:这个类对应数据库里的 user 表
操作流程:
UserService
→ UserMapper
→ MyBatis / MyBatis-Plus
→ Druid 数据库连接池
→ MySQL network_disk.user 表
最后返回插入后的 UserDO对象。
2.1.1.3 UserOperatorResponse
在得到userService.register返回的UserDO对象后,再包一层响应对象:`UserOperatorResponse<UserInfo>。
响应对象就是统一封装后端返回给前端数据的标准实体类,固定包含状态码 code、提示消息 msg、业务数据 data三部分,用来规范所有接口返回格式,方便前端统一解析成功 / 失败、错误提示、业务主体数据。
UserOperatorResponse<UserInfo> response = new UserOperatorResponse<>();
UserOperatorResponse
├── success: true
└── data: UserInfo
├── userId
├── nickName
├── email
└── profilePhotoUrl
然后把数据库实体 UserDO 转成对外传输的 UserInfo,再放进统一响应对象里返回给调用方。
UserDO 是数据库实体,对应 user 表,但远程调用方 auth 服务不一定需要这些全部字段,尤其不应该拿到 passwordHash。所以这里用 UserConvertor 转成 UserInfo。
//`register`是数据库查出来的`UserDO`,通过 MapStruct 转换器,
//把数据库实体转换成前端专用的`UserInfo`视图 VO,过滤密码、乐观锁等敏感内部字段
UserInfo userInfoVO = UserConvertor.INSTANCE.mapToVo(register);
response.setSuccess(true);//表示这次注册调用成功
response.setData(userInfoVO);//把真正要返回的数据放进响应对象的 `data` 字段里
2.1.2 userFileFacadeService
在得到UserOperatorResponse<UserInfo> register = userFacadeService.register(userRegisterRequest)的结果后,
网盘系统里,一个用户注册后,不只是有账号,还应该有自己的“根文件夹”。
所以AuthService拿到用户 ID 后,继续构建用户根文件创建请求
// UserFileOperateReqest:入参请求实体(DTO),用来封装创建文件夹需要的全部参数
UserFileOperateReqest userFileOperateReqest = new UserFileOperateReqest();
// 设置文件夹名称:常量统一规定用户根目录名称(比如“我的网盘”)
userFileOperateReqest.setName(BaseConstant.USER_ROOT_FILE);
// 设置当前注册用户ID,标记这个文件夹归属哪个用户
userFileOperateReqest.setUserId(userInfo.getUserId());
// 设置父目录ID:常量ROOT_PARENT_ID代表顶级根节点,说明这是用户第一层根目录
userFileOperateReqest.setParentId(BaseConstant.ROOT_PARENT_ID);
然后调用:
userFileFacadeService.createUserRootFile(userFileOperateReqest);
这一步同样会触发 FacadeAspect。因为 files 服务里的目标方法是:
@Facade
@Override
public UserFileOperateResponse<Long> createUserRootFile(UserFileOperateReqest request) {
...
}
所以创建根目录的实际顺序是:
AuthServiceImpl.register()
→ userFileFacadeService.createUserRootFile(...)
→ Dubbo 调 files 服务
→ UserFileFacadeImpl.createUserRootFile()
→ @Facade 触发 FacadeAspect
→ pjp.proceed()
→ 真正执行 createUserRootFile()
→ userFileService.createFolder()
→ 插入 user_file 根目录记录
→ 返回 UserFileOperateResponse<Long>
这又是一次 Dubbo 调用:
networkdisk-auth
→ Dubbo
→ networkdisk-files
→ UserFileFacadeImpl.createUserRootFile()
→ UserFileServiceImpl.createFolder()
→ 插入 user_file 表
@Facade//门面注解,分层开发规范注解:标识当前类是门面层 Facade,介于业务注册层和底层文件 userFileService 之间;
// 入参:UserFileOperateReqest 上层传过来的创建文件夹请求DTO
@Override
public UserFileOperateResponse<Long> createUserRootFile(UserFileOperateReqest request) {
// 1. 创建上下文对象 CreateFolderContext(领域上下文,内部业务传参载体)
CreateFolderContext createFolderContext = new CreateFolderContext();
// 把外层请求DTO的参数,转存到内部业务上下文
createFolderContext.setFolderName(request.getName());
createFolderContext.setUserId(request.getUserId());
createFolderContext.setParentId(request.getParentId());
// 2. 调用底层文件核心service,真正执行创建文件夹逻辑,返回新建文件夹ID
Long folder = userFileService.createFolder(createFolderContext);
// 3. 实例化统一包装响应对象(就是前面讲的外层包裹对象)
UserFileOperateResponse<Long> response = new UserFileOperateResponse();
// 标记接口执行成功
response.setSuccess(true);
// 将生成的文件夹ID塞入响应的data中
response.setData(folder);
// 返回包装好的标准响应给上层注册业务
return response;
}
}
UserFileServiceImpl.createUserRootFile完整链路:
- 注册业务层 → 调用
userFileFacadeService.createUserRootFile(请求DTO) - Facade 层接收 DTO,转成内部上下文
CreateFolderContext(隔离对外入参和内部业务参数) - 调用底层
userFileService执行数据库插入,生成文件夹 ID - 把 ID 封装进统一响应对象
UserFileOperateResponse(包一层标准返回体) - 回到注册代码,判断
response.success是否成功,失败抛注册异常
带@Facade门面层方法接收上层创建文件夹请求 DTO,将参数转为内部业务上下文后调用底层服务生成用户根目录,最后把文件夹 ID 封装进统一响应对象返回,给上层提供标准化成功结果
2.1.2 .1 userFileService.createFolder
一、createFolder 主方法逐行解读
这是底层 UserFileService 核心创建文件夹逻辑,Facade 层调用它,负责组装数据库实体、入库、异常兜底,最终返回新建文件夹主键 ID,操作数据库 DO 对象。
@Override
public Long createFolder(CreateFolderContext context) {
// 1. 上下文参数 → 组装数据库实体 UserFileDO
UserFileDO entity = assembleUserFolder(context);
// 2. MyBatis-Plus 自带 save() 方法,执行insert插入数据库
if (!save((entity))) {
// 插入失败(数据库报错/影响行数0),抛出系统自定义异常
throw new SystemException("保存文件信息失败");
}
// 插入成功,返回自生成的文件夹唯一ID,给上层Facade封装进响应data
return entity.getId();
}
save(entity)继承ServiceImpl,底层调用 Mapper 的 insert,插入 user_file 表;- 插入失败直接抛系统异常,上层 Facade 拿到完整响应,注册流程检测失败会抛出注册异常;
- 返回文件夹 ID,作为创建成功标识。
UserFileServiceImpl 继承了 MyBatis-Plus 的 ServiceImpl:
public class UserFileServiceImpl extends ServiceImpl<UserFileMapper, UserFileDO>
所以它可以直接调用:
save(entity)
save(entity) 底层会调用 MyBatis-Plus 的 Mapper 插入方法,把 UserFileDO 写入 user_file 表。
类似 SQL:
insert into user_file (
id,
user_id,
parent_id,
real_file_id,
filename,
folder_flag,
file_size_desc,
file_type,
deleted,
create_user,
update_user
) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
创建成功后,方法返回:
return entity.getId();
也就是新建根目录的 ID。
二、私有方法 assembleUserFolder 组装 DO(核心数据封装)
作用:接收业务上下文,填充数据库实体 UserFileDO 所有字段,统一初始化文件夹默认值。
private UserFileDO assembleUserFolder(CreateFolderContext context) {
UserFileDO entity = new UserFileDO();
// 雪花算法生成全局唯一主键ID(不用数据库自增)
entity.setId(IdUtil.get());
// 当前文件夹归属用户ID
entity.setUserId(context.getUserId());
// 父级文件夹ID(用户根目录就是顶级父ID)
entity.setParentId(context.getParentId());
// 文件夹无真实存储文件,置空
entity.setRealFileId(null);
// 文件夹名称(我的网盘)
entity.setFilename(context.getFolderName());
// 标记该记录是文件夹类型(区分文件)
entity.setFolderFlag(FolderFlagEnum.YES.getCode());
// 文件夹无文件大小,置空
entity.setFileSizeDesc(null);
// 文件类型为空(只有文件才有后缀类型)
entity.setFileType(null);
// 逻辑删除:0=未删除
entity.setDeleted(DeleteEnum.NO.getCode());
// 创建人、修改人都为当前用户
entity.setCreateUser(context.getUserId());
entity.setUpdateUser(context.getUserId());
// 处理同目录下重名文件夹逻辑(防重名校验)
handleDuplicateFilename(entity);
return entity;
}
IdUtil.get():工具类生成分布式唯一 ID,替代数据库自增主键;- 枚举统一状态:文件夹标识、逻辑删除状态,不用硬编码数字;
- 区分文件 / 文件夹:
folderFlag是核心区分字段; - 创建人 / 修改人默认当前操作用户;
handleDuplicateFilename(entity):内部校验当前用户同父目录下是否存在同名文件夹,重复则自动改名 / 抛重名异常。
三、完整调用链路串联
注册业务层 → Facade 层 → userFileService.createFolder ()
- Facade:DTO 转 CreateFolderContext,调用 service;
- Service:assembleUserFolder 把上下文转为数据库 DO;
- mybatis-plus save () 插入 user_file 表;
- 插入失败抛系统异常,成功返回文件夹 ID;
- Facade 把 ID 塞进统一响应对象
UserFileOperateResponse返回上层; - 注册代码判断响应 success,失败抛出注册失败异常中断流程。
2.1.3 完整链路
AuthController.register()
→ AuthServiceImpl.register()
第一段:创建用户
→ userFacadeService.register()
→ UserFacadeServiceImpl.register()
→ UserService.register()
→ userMapper.findByEmail()
→ userMapper.insert()
→ UserDO 转 UserInfo
→ UserOperatorResponse<UserInfo> 返回 auth
第二段:创建根目录
→ AuthServiceImpl 取出 UserInfo.userId
→ 构造 UserFileOperateReqest
→ userFileFacadeService.createUserRootFile()
→ UserFileFacadeImpl.createUserRootFile()
→ UserFileServiceImpl.createFolder()
→ assembleUserFolder()
→ save(UserFileDO)
→ 插入 user_file 表
→ UserFileOperateResponse<Long> 返回 auth
第三段:返回前端
→ AuthServiceImpl 返回 userId
→ AuthController 加密 userId
→ Result.success(...)
→ 前端提示注册成功
注册流程不是只插入 user 表。它先通过 user 服务创建账号,再通过 files 服务创建该用户的网盘根目录。auth 服务负责接收请求和编排流程,user 服务负责用户表,files 服务负责文件表。
3.3返回链路
注册结果是怎么一层层返回的 前面已经完成了两件事:
1. user 服务创建用户,插入 user 表
2. files 服务创建用户根目录,插入 user_file 表
接下来就是返回结果。返回链路遵循一个原则:谁调用了我,我就把结果返回给谁。
第一层返回:user 服务创建用户后返回给 auth 服务
`UserOperatorResponse<UserInfo>`
├── success = true
└── data = UserInfo
├── userId
├── nickName
├── email
└── profilePhotoUrl
第二层:files 服务返回给 auth 服务 files 服务返回给 auth 服务的是:
UserFileOperateResponse<Long>
├── success = true
└── data = 根目录ID
第三层:AuthServiceImpl 返回给 AuthController 当 auth 服务确认:
user 服务创建用户成功
files 服务创建根目录成功
之后,AuthServiceImpl.register() 最后执行:
return userInfo.getUserId();
这个返回值会回到调用它的地方,也就是 AuthController.register():
Long userId = authService.register(registerContext);
这一层是:
AuthServiceImpl.register()
→ 返回 Long 类型 userId
→ AuthController.register() 中的 Long userId 接收
第四层:AuthController 包装 Result 返回给 Spring MVC
Controller 拿到 userId 后,不会直接返回明文 Long,而是:
return Result.success(IdUtil.encrypt(userId));
这里做了两件事:
1. IdUtil.encrypt(userId):把 Long 类型用户 ID 加密成字符串
2. Result.success(...):把结果包装成统一返回格式
所以 Controller 返回的不是:
10001
而是类似:
{
"success": true,
"code": "SUCCESS",
"message": "SUCCESS",
"data": "加密后的用户ID"
}
这里的 data 才是真正返回给前端的数据。
这一层返回给 Spring MVC。因为 Controller 方法是由 Spring MVC 调用的,所以 Controller 的返回值会交还给 Spring MVC 框架。
第五层:Spring MVC 把 Java 对象转成 JSON
AuthController 上有:
@RestController
@RestController 的意思可以简单理解为:
方法返回值不是页面名,而是响应体数据。
所以当 Controller 返回:
Result.success(...)
Spring MVC 会使用消息转换器,例如 Jackson,把 Java 对象转换成 JSON 字符串。
Java 对象:
Result<String>
会被转换成 HTTP 响应体里的 JSON:
{
"code": "SUCCESS",
"success": true,
"message": "SUCCESS",
"data": "加密后的用户ID"
}
然后 Spring MVC 把这个 JSON 交给 Tomcat。
第六层:Tomcat 返回 HTTP 响应
Tomcat 最开始负责接收 HTTP 请求,现在负责把 HTTP 响应写回去。 响应大概包含:
HTTP 状态码:200
响应头:Content-Type: application/json
响应体:Result 对象转成的 JSON 字符串
可以理解成:
Spring MVC 负责生成响应内容
Tomcat 负责通过网络把响应发回去
第七层:开发环境下返回给 Vite
因为开发环境请求是这样来的:
浏览器
→ Vite dev server:5173
→ networkdisk-auth:8090
所以响应也会反向返回:
networkdisk-auth:8090
→ Vite dev server:5173
→ 浏览器
Vite 在这里就是代理转发者。请求进来时它把请求转发到后端,响应回来时它再把后端响应转回给浏览器。
第八层:浏览器把响应交给 Axios
浏览器收到 HTTP 响应后,Axios 会拿到响应对象。 Axios 拿到的原始响应大概是:
response = {
status: 200,
headers: {...},
data: {
code: 'SUCCESS',
success: true,
message: 'SUCCESS',
data: '加密后的用户ID'
}
}
然后进入 Axios 响应拦截器:
instance.interceptors.response.use((response) => {
if (response.data && response.data.code !== undefined) {
if (response.data.code === 200 || response.data.success === true) {
return response.data
}
}
})
因为后端返回:
response.data.success === true
所以响应拦截器会返回:
response.data
也就是:
{
code: 'SUCCESS',
success: true,
message: 'SUCCESS',
data: '加密后的用户ID'
}
注意:拦截器返回什么,await register(registerData) 拿到的就是什么。
第九层:api/auth.js 返回给 Pinia store
api/auth.js 中注册接口是:
export function register(data) {
return authRequest({
url: '/register',
method: 'post',
data
})
}
它调用的是 authRequest。由于响应拦截器已经处理过,所以 register(data) 最终返回给调用方的就是拦截器返回的对象:
{
success: true,
code: 'SUCCESS',
message: 'SUCCESS',
data: '加密后的用户ID'
}
谁调用了 register(data)?
是Pinia store 中的 registerAction():
const response = await register(registerData)
所以这里的:
response
就是接收后端注册结果的变量。
第十层:Pinia store 返回给 Register.vue
registerAction() 拿到响应后判断:
if (response?.success) {
return { success: true, data: response.data }
}
return { success: false, message: response?.message || '注册失败' }
如果注册成功,它返回给页面:
{
success: true,
data: '加密后的用户ID'
}
谁调用了 registerAction()?
是 Register.vue:
const result = await userStore.registerAction({
email: registerForm.email,
nickName: registerForm.nickName,
password: registerForm.password
})
所以这里的:
result
就是页面接收注册结果的变量。
第十一层:Register.vue 根据结果更新页面 页面最后判断:
if (result.success) {
ElMessage.success('注册成功,请登录')
router.push('/login')
} else {
ElMessage.error(result.message || '注册失败')
}
也就是说,最终前端页面只关心:
注册成功:提示成功,跳转登录页
注册失败:提示错误信息
返回链路总结
第一段:user 服务创建用户后返回 auth 服务
UserService.register()
→ userMapper.insert(insertDO) 插入 user 表
→ 返回 UserDO insertDO
→ UserFacadeServiceImpl.register() 中的 UserDO register 接收
UserFacadeServiceImpl.register()
→ UserDO 转 UserInfo
→ 包装 UserOperatorResponse<UserInfo>
→ 返回给 auth 服务
→ AuthServiceImpl.register() 中的 register 接收
AuthServiceImpl.register()
→ 校验 register 是否为空、success 是否为 true、data 是否存在
→ register.getData() 取出 UserInfo
→ userInfo.getUserId() 得到新用户 ID
第二段:auth 服务调用 files 服务创建根目录后返回 auth 服务
AuthServiceImpl.register()
→ 构造 UserFileOperateReqest
→ 调用 userFileFacadeService.createUserRootFile()
UserFileFacadeImpl.createUserRootFile()
→ UserFileServiceImpl.createFolder()
→ save(UserFileDO) 插入 user_file 表
→ 返回 Long folder 根目录 ID
→ 包装 UserFileOperateResponse<Long>
→ 返回给 auth 服务
→ AuthServiceImpl.register() 中的 userRootFile 接收
AuthServiceImpl.register()
→ 校验 userRootFile 是否为空、success 是否为 true
→ 确认根目录创建成功
第三段:auth 服务返回给 Controller
AuthServiceImpl.register()
→ 返回 Long userId
→ AuthController.register() 中的 userId 接收
AuthController.register()
→ IdUtil.encrypt(userId) 加密用户 ID
→ Result.success(加密后的 userId)
→ 返回 Result<String>
→ Spring MVC 接收
第四段:Spring MVC / Tomcat 返回 HTTP 响应
Spring MVC
→ 把 Result<String> 转成 JSON
→ 交给 Tomcat
Tomcat
→ 生成 HTTP 响应
→ 返回给 Vite 代理
Vite
→ 把后端响应转发给浏览器
第五段:前端 Axios / Pinia / 页面接收结果
浏览器
→ Axios 接收 HTTP 响应
Axios 响应拦截器
→ 判断 response.data.success === true
→ 返回 response.data
api/auth.js 的 register()
→ 返回给 Pinia 的 registerAction()
Pinia registerAction()
→ const response = await register(registerData)
→ 判断 response.success
→ 返回 { success: true, data: response.data }
Register.vue
→ const result = await userStore.registerAction(...)
→ result 接收 Pinia 返回结果
→ result.success 为 true
→ ElMessage.success('注册成功,请登录')
→ router.push('/login')
一句话总结:
注册的返回不是直接从 files 服务回前端,而是先 user 服务返回用户信息给 auth,auth 再调用 files 创建根目录,根目录创建成功后 auth 才把最终注册结果返回给 Controller,最后由 Spring MVC/Tomcat/Vite/Axios 一层层回到页面。
user 服务返回:
UserService → UserFacadeServiceImpl → AuthServiceImpl
files 服务返回:
UserFileServiceImpl → UserFileFacadeImpl → AuthServiceImpl
HTTP 返回:
AuthServiceImpl → AuthController → Spring MVC → Tomcat → Vite → 浏览器 → Axios → Pinia → Register.vue
3.4前端登录页面
登录和注册很像,但核心区别是:
注册:创建 user + 创建 user_file 根目录
登录:查询 user + 生成 token + 存 Redis + 前端保存 token + 拉取用户信息
登录请求整体链路:
Login.vue
→ userStore.loginAction()
→ api/auth.js 的 login()
→ Axios POST /api/v1/auth/login
→ Vite 代理到 networkdisk-auth:8090
→ AuthController.login()
→ AuthServiceImpl.login()
→ Dubbo 调 user 服务查询用户
→ 生成 JWT token
→ token 写入 Redis
→ 返回 token 给前端
→ Pinia 保存 token 到 Cookie
→ 立即请求用户信息
→ 登录成功后跳转首页
1. Login.vue 前端入口
登录页面中使用 loginForm 保存用户输入:
const loginForm = reactive({
email: '',
password: ''
})
点击登录按钮后执行:
const result = await userStore.loginAction(loginForm)
页面本身主要负责:
1. 收集邮箱和密码
2. 使用 Element Plus 表单校验
3. 调用 Pinia 的 loginAction()
4. 根据 result 显示成功/失败提示
5. 登录成功后跳转首页
登录成功后:
ElMessage.success('登录成功')
fileStore.initRoot()
router.push('/')
其中 ElMessage.success() 只是页面提示;router.push('/') 是跳转首页;fileStore.initRoot() 用于初始化文件列表。
2. Pinia 的 loginAction
登录核心前端逻辑在 stores/user.js:
const loginAction = async (loginData) => {
try {
const response = await login(loginData)
if (!response?.success) {
return { success: false, message: response?.message || '登录失败' }
}
const tokenValue = response.data
if (!tokenValue || typeof tokenValue !== 'string') {
return { success: false, message: '登录返回token无效' }
}
//把后端返回的登录 token 保存到 Pinia 状态和 Cookie 中,后续请求才能携带登录凭证。
setToken(tokenValue)
// 等待 Vue 把 `token` 状态更新完成,确保后面立刻发请求时能拿到最新 token。
await nextTick()
//用刚保存好的 token 请求后端用户信息接口,获取当前用户资料和根目录 ID。
await getUserInfoAction()
return { success: true }
} catch (error) {
return { success: false, message: error.message || '登录失败' }
}
}
这段做了几件事:
1. 调用 login(loginData) 请求后端登录接口
2. 判断后端响应是否成功
3. 从 response.data 中取出 token
4. 调用 setToken(tokenValue) 保存 token
5. 调用 getUserInfoAction() 获取当前用户信息
6. 返回 { success: true } 给 Login.vue
const setToken = (newToken) => {
token.value = newToken || ''
if (token.value) {
Cookies.set('token', token.value, { expires: 7 })
} else {
Cookies.remove('token')
}
}
setToken() 中
- 把 token 保存到 Pinia 的 token 状态中
- 把 token 保存到浏览器 Cookie 中,有效期 7 天
为什么要保存到 Cookie? 因为刷新页面后,Pinia 内存状态会丢失,但 Cookie 还在。项目初始化时会从 Cookie 恢复 token:
const token = ref(Cookies.get('token') || '')
所以登录态可以在刷新页面后继续保留。
3. api/auth.js 发送登录请求
loginAction() 调用的是:
const response = await login(loginData)
login() 来自 api/auth.js:
export function login(data) {
return authRequest({
url: '/login',
method: 'post',
data
})
}
因为 authRequest 的基础路径是:
baseURL: '/api/v1/auth'
所以最终请求路径是:
POST /api/v1/auth/login
开发环境下,Vite 代理到:
http://localhost:8090/api/v1/auth/login
3.5后端登录流程
1. AuthController.login 接收请求
这段登录代码的核心作用是:根据用户邮箱查到用户信息,然后生成登录态和 JWT Token,把 Token 存到 Redis,最后把 Token 返回给前端。
它不是直接查数据库,而是通过 Dubbo 远程调用去调用 networkdisk-user 用户服务里的 UserFacadeServiceImpl.query() 查询用户信息。这个 query() 方法是用户服务暴露给其他服务调用的 Facade 门面方法,方法上标了 @Facade。按项目设计,调用进入该方法前后会经过 FacadeAspect,统一做日志、参数校验、异常捕获和响应包装。
后端入口:
@PostMapping("/login")
public Result<String> login(@Valid @RequestBody LoginParamVO loginParam) {
LoginContext loginContext = authConvertor.loginParamToLoginContext(loginParam);
String loginToken = authService.login(loginContext);
return Result.success(loginToken);
}
这一层做三件事:
1. @RequestBody 把 JSON 转成 LoginParamVO
2. @Valid 校验邮箱、密码格式
3. LoginParamVO 转成 LoginContext
4. 调用 AuthServiceImpl.login()
5. 把 token 包装成 Result 返回前端
LoginParamVO 中有:
@NotBlank
@Email
private String email;
@NotBlank
@Size(min = 6, max = 64)
private String password;
所以登录前,后端会检查:
邮箱不能为空
邮箱格式正确
密码不能为空
密码长度 6-64
注意:这里只是格式校验,不等于密码正确性校验。
2. AuthServiceImpl.login 核心逻辑
核心代码:
@Override
public String login(LoginContext loginContext) {
// 1. 构建 用户查询请求对象:基于登录上下文的邮箱,创建"按邮箱查询用户"的请求
//从 `loginContext` 里取出邮箱,然后构造一个用户查询请求对象。
UserQueryRequest userQueryRequest = new UserQueryRequest(loginContext.getEmail());
// 2. 调用用户服务查询用户信息:通过用户门面服务执行查询,返回包含用户信息的响应对象
// UserQueryResponse<UserInfo>:泛型响应体,Data字段封装具体的用户信息(UserInfo)
UserQueryResponse<UserInfo> userQueryResponse = userFacadeService.query(userQueryRequest);
// 从响应体中提取核心的用户信息对象(包含用户ID、昵称、邮箱等核心字段)
UserInfo userInfo = userQueryResponse.getData();
// 3. 校验用户是否存在:若用户信息为空(未查询到),抛出"用户不存在"的认证异常
// EmptyUtil.isEmpty:自定义工具类,判断对象是否为空(null/空值)
// AuthException:自定义认证异常,携带错误码(USER_NOT_EXIST)便于前端/日志定位问题
if (EmptyUtil.isEmpty(userInfo)) {
throw new AuthException(AuthErrorCode.USER_NOT_EXIST);
}
// 4. Sa-Token登录:基于用户ID初始化登录态,框架自动维护会话(如Session/上下文)
// StpUtil:Sa-Token核心工具类,login方法会将用户ID存入当前会话,标记用户为"已登录"
StpUtil.login(userInfo.getUserId());
// 5. 生成JWT令牌:用于前后端分离场景的身份认证,有效期配置为1天
// JWTUtil.generateToken:自定义JWT工具类,参数说明:
// - 第1个参数:用户昵称(JWT载荷自定义字段)
// - 第2个参数:JWT载荷中"用户ID"的key(BaseConstant.LOGIN_USER_ID = "login_user_id")
// - 第3个参数:用户ID(JWT载荷核心字段,用于后续解析令牌获取用户身份)
// - 第4个参数:令牌有效期(AuthConstant.ONE_DAY_TIME_MILLS = 86400000毫秒,即24小时)
String accessToken = JWTUtil.generateToken(
userInfo.getNickName(),
BaseConstant.LOGIN_USER_ID,
userInfo.getUserId(),
AuthConstant.ONE_DAY_TIME_MILLS
);
// 6. 将JWT令牌存入Redis:做令牌持久化,便于后续校验令牌有效性、登出时删除令牌
// Key规则:登录前缀(USER_LOGIN_PREFIX = "user:login:") + 用户ID,例如:user:login:1001
// Value:生成的JWT令牌,Redis中存储的有效期与JWT令牌一致(1天)
stringRedisTemplate.opsForValue().set(BaseConstant.USER_LOGIN_PREFIX + userInfo.getUserId(), accessToken);
// 7. 返回JWT令牌:前端拿到令牌后,需在请求头中携带(如Authorization: Bearer {token})
return accessToken;
}
拆开看:
1. 根据邮箱创建 UserQueryRequest
2. 通过 Dubbo 调 user 服务查询用户
3. 如果查不到用户,抛出 USER_NOT_EXIST
4. 调用 Sa-Token 记录登录态
5. 生成 JWT token
6. 把 token 存入 Redis
7. 返回 token 给 Controller
3. userFacadeService.query
这里也会触发 FacadeAspect。因为 Dubbo 最终调用到的是 user 服务里的:
@Facade
@Override
public UserQueryResponse<UserInfo> query(UserQueryRequest request) {
...
}
所以登录查用户不是直接执行 query(),而是:
AuthServiceImpl.login()
→ userFacadeService.query(userQueryRequest)
→ Dubbo 调 user 服务
→ UserFacadeServiceImpl.query()
→ @Facade 触发 FacadeAspect
→ 参数校验、日志记录
→ pjp.proceed()
→ 真正执行 query()
→ 根据邮箱调用 userService.findByEmail()
→ 查询 user 表
→ 返回 UserQueryResponse<UserInfo>
→ FacadeAspect 补全响应
→ 返回 auth 服务
UserQueryResponse<UserInfo> userQueryResponse =
userFacadeService.query(userQueryRequest);
这行代码表面上是在调用 userFacadeService.query(),但实际是一次 Dubbo 远程调用:
networkdisk-auth → Dubbo → networkdisk-user → UserFacadeServiceImpl.query()
当前代码运行在 networkdisk-auth 认证服务中,userFacadeService 不是本地实现类,而是通过 @DubboReference 注入的远程代理对象。当执行 query(userQueryRequest) 时,Dubbo 会把 UserQueryRequest 序列化后通过网络发送到 networkdisk-user 用户服务,最终调用用户服务中的:
UserFacadeServiceImpl.query(UserQueryRequest request)
3.1UserQueryRequest
登录时这里会构造查询请求:
UserQueryRequest userQueryRequest =
new UserQueryRequest(loginContext.getEmail());
因为登录场景是根据邮箱查用户,所以这里调用的是 UserQueryRequest(String email) 构造方法。
UserQueryRequest 的核心代码可以压缩理解为:
public class UserQueryRequest extends BaseRequest {
// 查询条件父类型,实际可以保存不同的查询条件子类
private UserQueryCondition userQueryCondition;
// 按用户ID查询
public UserQueryRequest(Long userId) {
// 实例化ID查询条件实现类
UserIdQueryCondition userIdQueryCondition = new UserIdQueryCondition();
// 填充查询参数
userIdQueryCondition.setUserId(userId);
// 将具体条件对象赋值给父接口字段(多态),this指代当前UserQueryRequest实例
this.userQueryCondition = userIdQueryCondition;
}
// 按邮箱查询
public UserQueryRequest(String email) {
// 实例化邮箱查询条件实现类
UserEmailQueryCondition userEmailQueryCondition = new UserEmailQueryCondition();
// 填充查询参数
userEmailQueryCondition.setEmail(email);
// 多态赋值:具体实现类存入父接口字段
this.userQueryCondition = userEmailQueryCondition;
}
}
这里的设计重点是:UserQueryRequest 只暴露一个统一字段 userQueryCondition,字段类型是父类型 UserQueryCondition,但实际运行时可以存不同子类对象。
例如:
new UserQueryRequest(1001L)
内部保存的是:
UserIdQueryCondition
表示按用户 ID 查询。
new UserQueryRequest("[已隐藏邮箱]")
内部保存的是:
UserEmailQueryCondition
表示按邮箱查询。
这就是一个典型的多态设计:字段声明成父类型,实际赋值成子类对象。这样 UserQueryRequest 不需要写很多字段,比如 userId、email、phone 全部堆在一个类里,而是把不同查询方式封装成不同条件对象,结构更清楚,后续扩展也方便。
3.2 用户服务真正执行的查询逻辑
Dubbo 调用到 networkdisk-user 服务后,真正执行的是:
@Facade
@Override
public UserQueryResponse<UserInfo> query(UserQueryRequest request) {
// 取出请求中的查询条件,根据实际子类类型决定怎么查
UserDO userDO = switch (request.getUserQueryCondition()) {
//判断:刚才拿到的条件对象 是不是 UserIdQueryCondition(按 ID 查询的条件)
//如果是:自动把这个对象强制转成 UserIdQueryCondition,起个名字叫 userIdQueryCondition
// 如果是按用户ID查询,就取出 userId,调用 userService.findById()
case UserIdQueryCondition condition:
// yield:把查到的 UserDO 返回出去,整个 switch 表达式的值就是这个 UserDO
yield userService.findById(condition.getUserId());
// 如果是按邮箱查询,就取出 email,调用 userService.findByEmail()
// 登录流程一般走这个分支
case UserEmailQueryCondition condition:
yield userService.findByEmail(condition.getEmail());
// 如果传入了当前不支持的查询条件,直接抛异常
default:
throw new UnsupportedOperationException(
request.getUserQueryCondition() + " is not supported"
);
};
// 创建统一响应对象
UserQueryResponse<UserInfo> response = new UserQueryResponse<>();
// 数据库实体 UserDO 转成对外传输对象 UserInfo
// 避免直接暴露数据库实体和 password、salt 等敏感字段
UserInfo userInfo = UserConvertor.INSTANCE.mapToVo(userDO);
// 标记本次 RPC 调用成功
response.setSuccess(true);
// 把用户信息放入响应 data
response.setData(userInfo);
// 返回给 Dubbo 调用方,也就是 auth 服务
return response;
}
这里最核心的是这句:
switch (request.getUserQueryCondition())
request.getUserQueryCondition() 取出来的是一个 UserQueryCondition 类型的对象,但它的实际类型可能是 UserIdQueryCondition,也可能是 UserEmailQueryCondition。所以 switch 会根据对象的真实子类类型进入不同分支。
例如当前登录流程中,请求是这样构造的:
new UserQueryRequest(loginContext.getEmail())
所以内部实际保存的是:
UserEmailQueryCondition
因此 switch 会进入这个分支:
case UserEmailQueryCondition condition:
yield userService.findByEmail(condition.getEmail());
也就是说,登录流程这里真正执行的是:根据邮箱查询用户
3.3 findByEmail 与 JetCache 缓存
登录流程进入邮箱查询分支后,会调用:
case UserEmailQueryCondition condition:
yield userService.findByEmail(condition.getEmail());
findByEmail() 在 UserService 中:
@Cached(
name = "user:cached:telephone:",
expire = 3000,
cacheType = CacheType.BOTH,
key = "#telephone",
cacheNullValue = true
)
@CacheRefresh(refresh = 60, timeUnit = TimeUnit.HOURS)
public UserDO findByEmail(String email) {
return userMapper.findByEmail(email);
}
它的设计意图是:登录时按邮箱查用户,第一次查数据库,后续相同邮箱优先走缓存,减少数据库压力。
但当前代码有一个问题:
public UserDO findByEmail(String email)
方法参数叫 email,缓存 key 却写成了:
key = "#telephone"
telephone 这个参数不存在,所以这里应该改成:
@Cached(
name = "user:cached:email:",
expire = 3000,
cacheType = CacheType.BOTH,
key = "#email",
cacheNullValue = true
)
@CacheRefresh(refresh = 60, timeUnit = TimeUnit.HOURS)
public UserDO findByEmail(String email) {
return userMapper.findByEmail(email);
}
登录查询用户的完整链路应该补成:
AuthServiceImpl.login()
-> new UserQueryRequest(email)
-> Dubbo 调 UserFacadeServiceImpl.query()
-> switch 识别 UserEmailQueryCondition
-> userService.findByEmail(email)
-> JetCache AOP 拦截 @Cached
-> 查本地 Caffeine / Redis 缓存
-> miss 才执行 userMapper.findByEmail(email)
-> JetCache 判断缓存中是否有该 email 对应的 UserDO
-> 有:直接返回缓存
-> 没有:查 userMapper.findByEmail(email),再写入本地 Caffeine + Redis 缓存
-> UserDO 转 UserInfo
-> 返回 auth 服务
你笔记里还有一个小地方也要顺手改:Redis 登录 key 当前项目实际是:
BaseConstant.USER_LOGIN_PREFIX = "USER_LOGIN_"
所以不是:
user:login:1001
而是类似:
USER_LOGIN_2016448846836494336
这次漏的是“调用链背后的缓存注解”和“注解 key 写错”这两个点。你抓得很准,这种藏在 Service 方法头上的东西,最容易在流程笔记里漏掉。
userFacadeService.query(userQueryRequest) 是认证服务通过 Dubbo 调用用户服务查询用户信息。UserQueryRequest 内部用 UserQueryCondition 父类型保存不同查询条件子类,登录时封装的是 UserEmailQueryCondition,所以用户服务中的 switch 会识别出“按邮箱查询”,然后调用 userService.findByEmail() 查数据库,最后把 UserDO 转成对外安全的 UserInfo,包装成 UserQueryResponse 返回给认证服务。
4. Sa-Token,JWT,Redis
4.1Sa-Token
先说结论:**这个项目不是纯 Sa-Token 鉴权,而是 JWT + Redis 为主,Sa-Token 为辅。项目里真正负责普通接口登录校验的是:JWT + Redis + UserInfoLoginAspect + UserIdUtil,Sa-Token 在项目里有引入、有调用,但它不是当前普通业务接口的主鉴权入口。
1.前置知识
token 是一段登录凭证字符串。用户登录成功后,后端返回 token,前端后续请求都带上它。
JWT 是 token 的一种格式。它里面可以保存用户 ID、过期时间等信息,并且带签名,能防止用户篡改内容。
Redis 在这里用来保存当前有效 token。这样后端不仅看 JWT 是否能解析,还会看 Redis 里是否认可这个 token。
Sa-Token 是一个 Java 登录认证框架。它本身可以做登录、登出、会话管理、权限校验、角色校验等。
UserInfoLoginAspect 是当前项目真正拦截 Controller、校验登录的地方。
UserIdUtil 是当前项目保存当前请求用户 ID 的工具。业务代码常用:UserIdUtil.get()来拿当前登录用户 ID。
2.Sa-Token 是什么
Sa-Token 是一款轻量级的 Java 权限认证框架,专门用来解决项目里「登录认证、权限校验、会话管理」这类通用鉴权需求。
StpUtil 是 Sa-Token 最核心的工具类,所有登录鉴权操作都通过它调用,可以理解为「登录态总控制台」。
常见用法:
StpUtil.login(userId); // 标记用户登录
StpUtil.logout(userId); // 用户登出
StpUtil.isLogin(); // 判断是否登录
StpUtil.getLoginId(); // 获取登录用户 ID
如果是纯 Sa-Token 项目,通常会让 Sa-Token 生成 token、校验 token、管理登录态。
| 组件 | 角色定位 | 负责的事情 |
|---|---|---|
| JWT | 前端身份凭证 | 生成带用户信息的字符串,返回给前端,作为后续请求的身份令牌,前后端传递身份用 |
| Redis | 有效 Token 白名单 | 存储当前生效的 JWT,解决 JWT 无法主动作废的问题,实现登出、踢人立即生效 |
| Sa-Token | 服务端登录上下文管理者 | 在当前请求线程内绑定用户身份,给业务代码提供统一的获取用户、校验登录、权限控制的入口 |
| 3.项目里的 Sa-Token 配置 | ||
项目里依赖在 networkdisk-common/networkdisk-sa-token,auth 服务通过公共模块引入它。auth 服务配置在 application.yml: |
sa-token:
token-name: satoken
timeout: 2592000
active-timeout: -1
is-concurrent: true
is-share: true
token-style: uuid
is-log: true
这些配置的意思大概是:
token-name:Sa-Token 默认 token 名称
timeout:Sa-Token 登录态有效期,2592000 秒,也就是 30 天
is-concurrent:允许同一账号多端同时登录
is-share:多端登录时复用同一个 token
token-style:生成 uuid 风格 token
is-log:输出 Sa-Token 日志
4. 登录时 Sa-Token 做了什么
登录流程中,auth 服务查到用户后,会调用:
StpUtil.login(userInfo.getUserId());
这句代码的意思是:
告诉 Sa-Token:这个 userId 已经登录
可以理解为:
StpUtil.login(userId)
= 在 Sa-Token 框架内部登记登录状态
= 后续理论上可以用 StpUtil.isLogin() 判断是否登录
= 理论上也可以用 StpUtil.getLoginId() 获取登录用户 ID
但是当前项目真正返回给前端的不是 Sa-Token 生成的 token,而是项目自己生成的 JWT。
5. 项目真正返回给前端的是 JWT
AuthServiceImpl.login() 后面会生成 JWT:
String accessToken = JWTUtil.generateToken(
userInfo.getNickName(),
BaseConstant.LOGIN_USER_ID,
userInfo.getUserId(),
AuthConstant.ONE_DAY_TIME_MILLS
);
然后把 JWT 存入 Redis:
stringRedisTemplate.opsForValue().set(
BaseConstant.USER_LOGIN_PREFIX + userInfo.getUserId(),
accessToken
);
当前项目 Redis key 前缀实际是:
BaseConstant.USER_LOGIN_PREFIX = "USER_LOGIN_"
所以 Redis 里大概是:
USER_LOGIN_2016448846836494336 -> JWT token字符串
注意:当前代码这里没有设置 Redis 过期时间。JWT 自己有 1 天过期时间,但 Redis key 本身没有 TTL。更严谨的写法应该加过期时间。
最后返回给前端的是:
return accessToken;
所以前端后续请求携带的是 JWT,不是 Sa-Token token。
6. 后续接口怎么鉴权
普通接口鉴权主要走 UserInfoLoginAspect。
流程是:
前端请求接口
-> 请求头 Authorization 携带 JWT
-> UserInfoLoginAspect 拦截 Controller 方法
-> 从 Authorization 取出 JWT
-> JWTUtil 解析 JWT,得到 userId
-> 根据 userId 拼 Redis key:USER_LOGIN_{userId}
-> 从 Redis 取出服务端保存的 token
-> 比对 Redis token 和请求头 token 是否一致
-> 一致:说明登录有效
-> UserIdUtil.set(userId)
-> Controller / Service 继续执行
核心逻辑可以理解为:
String accessToken = request.getHeader("Authorization");
Object userId = JWTUtil.analyzeToken(
accessToken,
BaseConstant.LOGIN_USER_ID
);
String cacheToken = stringRedisTemplate.opsForValue().get(
BaseConstant.USER_LOGIN_PREFIX + userId
);
if (!Objects.equals(accessToken, cacheToken)) {
return false;
}
UserIdUtil.set(Long.valueOf(String.valueOf(userId)));
return true;
所以当前项目真正判断普通接口是否登录的是:
UserInfoLoginAspect + JWT + Redis
不是 Sa-Token。
7. 登出时 Sa-Token 和 Redis 都会处理
登出时项目会删除 Redis 里的 JWT:
stringRedisTemplate.delete(BaseConstant.USER_LOGIN_PREFIX + userId);
同时也调用 Sa-Token 登出:
StpUtil.logout(userId);
所以登出流程可以理解为:
删除 Redis 中的有效 JWT
-> 旧 JWT 立刻失效
-> 同时清理 Sa-Token 登录状态
对当前项目来说,最关键的是删除 Redis 里的 JWT。因为后续接口校验主要看 Redis 里还有没有这个 token。
8. Sa-Token 在项目里的实际地位
项目里 Sa-Token 目前主要用在这几处:
1. AuthServiceImpl.login():StpUtil.login(userId)
2. AuthServiceImpl.logout():StpUtil.logout(userId)
3. TokenController.getToken():StpUtil.isLogin()
但普通业务接口,比如文件、分享、用户接口,主要不是靠:
StpUtil.checkLogin()
StpUtil.getLoginId()
而是靠:
UserInfoLoginAspect
JWTUtil
StringRedisTemplate
UserIdUtil
9. 最终总结
这个项目的鉴权分工是:
| 组件 | 项目里的真实作用 |
|---|---|
| JWT | 返回给前端,后续请求放在 Authorization 请求头 |
| Redis | 保存当前有效 JWT,实现登出后 token 立刻失效 |
| UserInfoLoginAspect | 普通接口真正的登录校验入口 |
| UserIdUtil | 当前请求线程里保存 userId,业务层通过它拿当前用户 |
| Sa-Token | 登录、登出时有调用,但不是普通接口主鉴权链路 |
一句话记:
这个项目不是纯 Sa-Token,也不是纯 JWT。
真正跑通普通接口鉴权的是:
JWT + Redis + UserInfoLoginAspect + UserIdUtil。
Sa-Token 当前更像辅助登录态组件:
登录时登记一下,登出时清理一下,但普通接口不是主要靠它校验。
11:01
4.2. 生成 JWT token
// 5. 生成 JWT 令牌:前端后续请求会携带这个 token 访问接口
// 5. 生成JWT令牌:用于前后端分离场景的身份认证,有效期配置为1天
// JWTUtil.generateToken:自定义JWT工具类,参数说明:
// - 第1个参数:用户昵称(JWT载荷自定义字段)
// - 第2个参数:JWT载荷中"用户ID"的key(BaseConstant.LOGIN_USER_ID = "login_user_id")
// - 第3个参数:用户ID(JWT载荷核心字段,用于后续解析令牌获取用户身份)
// - 第4个参数:令牌有效期(AuthConstant.ONE_DAY_TIME_MILLS = 86400000毫秒,即24小时)
String accessToken = JWTUtil.generateToken(
userInfo.getNickName(),
BaseConstant.LOGIN_USER_ID,
userInfo.getUserId(),
AuthConstant.ONE_DAY_TIME_MILLS
);
JWT 可以理解成一段带签名的字符串,里面保存了一些用户身份信息。它通常由三部分组成:
Header.Payload.Signature
简单理解
header:说明 token 类型和签名算法
payload:保存业务数据,比如 userId、过期时间
signature:用密钥对 header + payload 算出来的签名
签名的作用是防篡改。比如用户拿到 token 后,不能随便把里面的 userId 改成别人的 userId,因为一改 payload,签名就对不上了,后端解析时会失败。
这个项目里生成 JWT 时放入了:
subject:用户昵称,也就是 userInfo.getNickName()
login_user_id:用户ID,也就是 userInfo.getUserId()
expireTime:过期时间,这里是 1 天
token 是统称,JWT 是 token 的一种具体格式。
token 本质上就是一段字符串,用来证明“我是谁、我已经登录过”。
用户登录成功后,后端返回一个 token 给前端。之后前端访问接口时,每次都把 token 带上:
所以后端后续只要解析 token,就可以取出里面的用户 ID,知道当前请求是谁发的。
后续前端请求接口时,会把 token 放到请求头里:
Authorization: token字符串
有些项目会写成:
Authorization: Bearer token字符串
但你这个项目看起来是直接放 token,具体格式要以 Axios 请求拦截器和后端解析代码为准。
4.3. JWT令牌存Redis
生成 JWT 后,auth 服务还会把 token 写入 Redis:
// 6. 将 JWT 令牌存入 Redis
// key:登录前缀 + 用户ID,例如 user:login:1001
// value:JWT token
stringRedisTemplate.opsForValue().set(
BaseConstant.USER_LOGIN_PREFIX + userInfo.getUserId(),
accessToken
);
Redis 中大概是:
key: user:login:1001
value: JWT token字符串
这里容易疑惑:JWT 本身已经包含用户 ID,为什么还要存 Redis?
原因是:纯 JWT 一旦发出去,在过期之前通常都有效,服务端不好主动让它失效。 比如用户点击退出登录,如果只是前端删除 token,但后端不记录 token 状态,那么别人只要还拿着旧 token,在过期前理论上仍然可能继续访问接口。 所以这个项目把 token 也存到 Redis 中。后端校验登录时不只是解析 JWT,而是:
1. 从请求头 Authorization 中取出 token
2. 解析 JWT,拿到 userId
3. 根据 userId 拼 Redis key
4. 去 Redis 查服务端保存的 token
5. 判断 Redis 中的 token 是否和请求头 token 一致
6. 一致,说明当前 token 仍然有效
7. 不一致或 Redis 中没有,说明未登录或 token 已失效
这样做的好处是
可以主动控制 token 是否有效
比如退出登录时,只需要删除 Redis 中的 token:
删除 user:login:1001
那么即使前端还拿着旧 JWT,后端校验 Redis 时也会失败。
这里有一个小问题要注意:你这段 Redis 代码只写了:
opsForValue().set(key, value);
它没有设置过期时间。如果项目里没有其他地方设置 TTL,那么 Redis 里的 token 可能不会自动过期。更严谨的写法一般是:
stringRedisTemplate.opsForValue().set(
BaseConstant.USER_LOGIN_PREFIX + userInfo.getUserId(),
accessToken,
AuthConstant.ONE_DAY_TIME_MILLS,
TimeUnit.MILLISECONDS
);
这样 Redis 中 token 的有效期才会和 JWT 的有效期保持一致。
5. 后端返回 token 给前端
AuthServiceImpl.login() 最后返回:
// 返回JWT令牌:前端拿到令牌后,需在请求头中携带(如Authorization: Bearer {token})
return accessToken;
回到 Controller:
String loginToken = authService.login(loginContext);
return Result.success(loginToken);
最终返回给前端的 JSON 大概是:
{
"success": true,
"code": "SUCCESS",
"message": "SUCCESS",
"data": "JWT token字符串"
}
这里的 data 就是前端要保存的 token。
Axios 响应拦截器看到:
response.data.success === true
就会返回:
response.data
所以 Pinia 的 loginAction() 拿到的结果类似:
const response = await login(loginData)
其中:
response.data
就是后端返回的 token 字符串。
然后执行:
setToken(tokenValue)
把 token 保存到:
Pinia:前端状态管理,页面运行期间使用
Cookie:浏览器本地存储,刷新页面后还能恢复登录态
可以理解为:
Pinia 保存当前页面运行时的 token
Cookie 保存可持久化的 token
后续前端发请求时,Axios 请求拦截器会自动带上 token。
6. 登录后立即获取用户信息
登录成功后,前端已经拿到了后端返回的 token,并执行了:
setToken(tokenValue)
此时前端内存和 Cookie 中大概是:
Pinia userStore.token = "JWT token字符串"
Cookie token = "JWT token字符串"
然后前端继续执行:
await getUserInfoAction()
getUserInfoAction() 内部会调用:
const response = await getUserInfo()
getUserInfo() 会发送请求:
GET /api/v1/users/get-user-info
这一步的作用是:登录后立刻获取当前用户的展示信息和网盘根目录 ID。
因为登录时已经把 token 存到了 Pinia:
这次请求已经有 token 了,所以 Axios 请求拦截器会自动加请求头:
config.headers.Authorization = `${userStore.token}`
请求发出去时大概是:
GET /api/v1/users/get-user-info
请求头:
Authorization: JWT token字符串
此时前端内存中的关键对象:
userStore = {
token: 'JWT token字符串',
userInfo: {},
rootFileId: ''
}
请求经过 Vite 代理后,会转发到用户服务:
浏览器
→ Vite dev server:5173
→ networkdisk-user:8086
→ UserController.getUserInfo()
后端收到请求后,公共登录校验逻辑会执行:
/api/v1/users/get-user-info 不是登录、注册这种公开接口,所以进入 Controller 前,公共登录切面会先执行。
切面核心逻辑在 UserInfoLoginAspect:
1. 从 Authorization 请求头取出 token
2. 解析 JWT,得到 userId
3. 根据 userId 去 Redis 查 token
4. 判断 Redis 中 token 是否和请求头 token 一致
5. 校验通过后,把 userId 放入 UserIdUtil
然后用户接口中就可以直接取当前登录用户 ID:
Long userId = UserIdUtil.get();// 工具类从请求token中解析当前登录用户ID
UserInfoVO userInfo = userService.getUserInfo(userId);// 调用业务层,根据用户ID查询组装用户展示信息
return Result.success(userInfo);// 封装成功统一响应返回前端
这里的 UserIdUtil 可以理解为一个“当前请求用户 ID 的临时保存工具”。公共登录切面先把 userId 放进去,后面的 Controller 或 Service 就可以直接取出来用,不用每个接口都手动解析 token。
前端拿到用户信息后,会保存:
userInfo.value = response.data || {}
rootFileId.value = userInfo.value.rootFileId
这样前端就能得到:
用户昵称
头像
用户根目录 ID
根目录名称
7. UserInfoLoginAspect登录校验流程
UserInfoLoginAspect 是项目里的登录校验切面。切面可以理解成**:在正常业务方法执行前后,统一插入的一段公共逻辑。**它不是 Controller,也不是 Service,而是 Spring AOP 在请求进入 Controller 方法前自动触发的一层拦截逻辑。
普通请求流程是:
浏览器请求
→ Controller 方法
→ Service
→ Mapper / 远程服务
加了登录切面后变成:
浏览器请求
→ Spring AOP 代理对象
→ UserInfoLoginAspect 先检查登录状态
→ 校验通过
→ Controller 方法
→ Service
→ Mapper / 远程服务
如果校验不通过:
浏览器请求
→ UserInfoLoginAspect
→ 返回未登录错误 Result.error(...)
→ Controller 方法不会执行
→ Service 也不会执行
所以切面适合处理很多接口都需要的公共逻辑,比如登录校验、权限校验、日志记录、限流、事务等。这里的 UserInfoLoginAspect 主要负责统一校验 token,校验通过后把解析出来的 userId 保存到 UserIdUtil,后续 Controller 或 Service 就可以拿到当前登录用户 ID。
1. 这个切面怎么被 Spring 识别?
@Slf4j
@Aspect
@Component
public class UserInfoLoginAspect {
}
@Component 表示把这个类交给 Spring 管理,让它成为 Spring 容器里的 Bean。没有这个注解,Spring 不会创建这个对象,切面也不会生效。
@Aspect 表示这是一个切面类,Spring AOP 会读取里面的 @Pointcut、@Around 等注解,判断哪些方法需要被拦截,以及拦截后执行什么增强逻辑。
简单记:
@Component:让 Spring 创建这个对象
@Aspect:告诉 Spring 这个对象是切面
但切面要真正生效,还要满足两个条件:第一,当前微服务启动时能扫描到这个切面类;第二,切点表达式能匹配到目标 Controller 方法。也就是说,Spring 扫描决定“切面有没有加载进容器”,切点表达式决定“加载后拦截哪些方法”。
2. 切点表达式是什么意思?
private final static String POINT_CUT = "execution(* com.disk.*.controller..*(..))";
@Pointcut(value = POINT_CUT)
public void loginAuth() {
}
POINT_CUT 是切点表达式,用来描述哪些方法会被这个切面拦截。
这个表达式可以拆开理解:
execution(* com.disk.*.controller..*(..))
含义是:
execution:匹配方法执行
第一个 *:任意返回值类型
com.disk.*.controller:匹配 com.disk 下任意一级子包里的 controller 包
..*:controller 包及其子包下的所有类、所有方法
(..):方法参数任意,0 个、1 个、多个都可以
例如这些方法理论上都能匹配:
com.disk.user.controller.UserController.getUserInfo(...)
com.disk.files.controller.FileController.list(...)
com.disk.auth.controller.AuthController.login(...)
但是能不能真的拦截,还要看当前微服务启动类有没有扫描到 com.disk.web.aspect.UserInfoLoginAspect。如果某个服务只扫描自己的包,比如只扫描 com.disk.auth,那它可能扫描不到公共模块里的 com.disk.web.aspect,这个切面就不会进入该服务的 Spring 容器。微服务之间的 Spring 容器是独立的,不是一个服务加载了 Bean,所有服务都共享。
3. @Around("loginAuth()") 是什么?
@Around("loginAuth()")
public Object loginAuthAround(ProceedingJoinPoint proceedingJoinPoint) throws Throwable {
if (checkNeedCheckLoginInfo(proceedingJoinPoint)) {
...
if (!checkAndSaveUserId(request)) {
return Result.error(NOT_LOGIN_ERROR.getCode(), NOT_LOGIN_ERROR.getMessage());
}
}
return proceedingJoinPoint.proceed();
}
@Around 表示环绕增强,也就是目标 Controller 方法执行前后都可以插入逻辑。这个项目主要用它在 Controller 方法执行前做登录校验。
loginAuth() 是前面定义的切点名称,表示这个增强逻辑只作用于 loginAuth 切点匹配到的方法。
执行逻辑是:
请求进入 Controller 前
→ 先进入 loginAuthAround()
→ 判断这个接口是否需要登录
→ 如果不需要登录,直接放行
→ 如果需要登录,就检查 token
→ token 校验失败,直接返回未登录错误
→ token 校验成功,调用 proceedingJoinPoint.proceed()
→ 真正执行原来的 Controller 方法
最关键的一句是:
return proceedingJoinPoint.proceed();
它表示“放行,继续执行原本要执行的 Controller 方法”。如果没有调用 proceed(),原始 Controller 方法就不会执行。
4. ProceedingJoinPoint proceedingJoinPoint 是什么?
ProceedingJoinPoint 可以理解成“当前被拦截方法的执行现场”。它不是前端传来的参数,也不是 Controller 的业务参数,而是 Spring AOP 自动传进来的对象,里面保存了当前这次被拦截方法的信息。
它通常可以拿到这些内容:
proceedingJoinPoint.getSignature(); // 当前被拦截方法的签名信息,比如方法名、返回值、参数类型
proceedingJoinPoint.getArgs(); // 当前被拦截方法的实参,也就是 Controller 方法原本收到的参数
proceedingJoinPoint.getTarget(); // 当前被代理的目标对象,也就是原始 Controller 对象
proceedingJoinPoint.proceed(); // 继续执行原始 Controller 方法
这里的 proceedingJoinPoint 是 Spring AOP 自动传入的,不需要你手动传参。浏览器请求进入 Controller 时,Spring 发现这个 Controller 方法被切点匹配了,就会先执行切面方法,并把当前方法的执行信息封装成 ProceedingJoinPoint 传进来。
可以这样理解:
用户没有调用 loginAuthAround()
Controller 也没有调用 loginAuthAround()
是 Spring AOP 代理对象自动调用 loginAuthAround()
所以调用关系更准确是:
浏览器请求
→ DispatcherServlet 找到 Controller 方法
→ 发现 Controller Bean 被 AOP 代理
→ 先进入 UserInfoLoginAspect.loginAuthAround(proceedingJoinPoint)
→ 校验通过后 proceedingJoinPoint.proceed()
→ 执行真正的 Controller 方法
5. JoinPoint 和 ProceedingJoinPoint 有什么区别?
JoinPoint 表示连接点,也就是程序执行过程中可以被切面插入的位置。在 Spring AOP 里,最常见的连接点就是“某个方法的执行”。
ProceedingJoinPoint 是 JoinPoint 的增强版,只能用于 @Around 环绕通知。它比普通 JoinPoint 多了一个非常重要的方法:
proceed()
proceed() 的作用是继续执行原始目标方法。普通的 JoinPoint 只能查看当前方法信息,不能控制目标方法是否继续执行;ProceedingJoinPoint 可以决定是否放行。
区别可以这样记:
JoinPoint:只能看当前被拦截的方法信息
ProceedingJoinPoint:既能看方法信息,又能调用 proceed() 决定是否继续执行原方法
这个项目用的是 @Around,所以参数类型必须是 ProceedingJoinPoint,因为登录校验失败时要阻止 Controller 执行,登录校验成功时要通过 proceed() 放行。
6. 怎么判断接口是否需要登录?
private boolean checkNeedCheckLoginInfo(ProceedingJoinPoint proceedingJoinPoint) {
Signature signature = proceedingJoinPoint.getSignature();
MethodSignature methodSignature = (MethodSignature) signature;
Method method = methodSignature.getMethod();
return !method.isAnnotationPresent(LoginIgnore.class);
}
这段代码的作用是:拿到当前被拦截的 Controller 方法,检查这个方法上有没有 @LoginIgnore 注解。
执行过程是:
proceedingJoinPoint.getSignature()
→ 获取当前被拦截方法的签名信息
(MethodSignature) signature
→ 转成方法签名对象,方便拿到 Method
methodSignature.getMethod()
→ 拿到 Java 反射里的 Method 对象
method.isAnnotationPresent(LoginIgnore.class)
→ 判断当前方法上有没有 @LoginIgnore 注解
返回值逻辑是:
return !method.isAnnotationPresent(LoginIgnore.class);
意思是:
如果方法上有 @LoginIgnore:
isAnnotationPresent = true
!true = false
不需要登录校验
如果方法上没有 @LoginIgnore:
isAnnotationPresent = false
!false = true
需要登录校验
所以项目里的规则是:
默认所有被切点匹配到的 Controller 方法都需要登录
只有方法上显式加了 @LoginIgnore,才跳过登录校验
注意:这段代码只检查“方法上”有没有 @LoginIgnore,没有检查 Controller 类上有没有这个注解。如果把 @LoginIgnore 加在类上,而方法上没有加,按照这段代码是不生效的。
7. 登录校验具体做了什么?
登录校验核心方法是:
private boolean checkAndSaveUserId(HttpServletRequest request) {
String accessToken = request.getHeader(LOGIN_AUTH_REQUEST_HEADER_NAME);
if (StringUtils.isBlank(accessToken)) {
accessToken = request.getParameter(LOGIN_AUTH_PARAM_NAME);
}
if (StringUtils.isBlank(accessToken)) {
return false;
}
Object userId = JWTUtil.analyzeToken(accessToken, BaseConstant.LOGIN_USER_ID);
if (EmptyUtil.isEmpty(userId)) {
return false;
}
String cacheToken = stringRedisTemplate.opsForValue().get(BaseConstant.USER_LOGIN_PREFIX + userId);
if (StringUtils.isBlank(cacheToken)) {
return false;
}
if (!Objects.equals(accessToken, cacheToken)) {
return false;
}
UserIdUtil.set(Long.valueOf(String.valueOf(userId)));
return true;
}
它的完整流程是:
1. 从请求头 Authorization 里取 token
2. 如果请求头没有,就从请求参数 authorization 里取 token
3. 如果 token 为空,说明没登录,返回 false
4. 用 JWTUtil 解析 token,取出 userId
5. 如果 userId 解析失败,返回 false
6. 用 USER_LOGIN_PREFIX + userId 拼 Redis key
7. 从 Redis 中查询服务端保存的 token
8. 如果 Redis 没有 token,说明登录状态不存在或已过期,返回 false
9. 对比请求带来的 token 和 Redis 里的 token
10. 如果不一致,说明 token 无效,返回 false
11. 如果一致,把 userId 存入 UserIdUtil
12. 返回 true,表示登录校验通过
这里为什么还要查 Redis?因为 JWT 本身只能证明 token 格式和签名是否有效,但项目还需要判断这个 token 是否是当前服务端认可的登录状态。比如用户退出登录、token 被刷新、token 被踢下线,仅靠 JWT 解析不一定能知道,所以要和 Redis 中保存的 token 再比对一次。
8. token 是怎么传过来的?有传参吗?
前端一般会把 token 放在请求头里:
Authorization: xxxxx.yyyyy.zzzzz
代码里优先从请求头取:
String accessToken = request.getHeader("Authorization");
如果请求头里没有,再从请求参数里取:
accessToken = request.getParameter("authorization");
也就是说,这个切面支持两种传 token 的方式:
方式一:请求头 Authorization,推荐
方式二:请求参数 authorization,兜底
但是这个 token 不是作为 Controller 方法参数直接传进去的。它是从 HttpServletRequest 里读取的。Controller 原本该接收什么业务参数,还是正常接收什么业务参数。切面只是额外从当前请求对象里取 token,并不会改变 Controller 方法原本的参数。
例如 Controller 原本方法可能是:
public Result<UserInfoVO> getUserInfo() {
Long userId = UserIdUtil.get();
return Result.success(userService.getUserInfo(userId));
}
前端请求时并不一定要显式传 userId,因为用户 ID 是切面从 token 里解析出来,然后放到 UserIdUtil 里的。这样可以避免前端伪造别人的 userId。
9. UserIdUtil.set(...) 是干什么的?
UserIdUtil.set(Long.valueOf(String.valueOf(userId)));
这句代码的作用是把当前登录用户的 ID 保存起来,供当前请求后续代码使用。
通常这种工具类底层会用 ThreadLocal。ThreadLocal 可以理解成“当前线程自己的变量”。一次 HTTP 请求通常由一个线程处理,所以切面把 userId 存进去后,同一个请求后面进入 Controller、Service 时都可以通过 UserIdUtil.get() 拿到这个用户 ID。
大致流程是:
请求进入
→ 切面解析 token 得到 userId
→ UserIdUtil.set(userId)
→ Controller 通过 UserIdUtil.get() 拿 userId
→ Service 根据 userId 查询用户信息
这样做的好处是:后面的业务代码不用每个接口都手动解析 token,只需要拿当前登录用户 ID 即可。
10. 登录通过后,后面怎么查用户信息?
在 UserService 里有这个方法:
public UserInfoVO getUserInfo(Long userId) {
UserDO userDO = this.findById(userId);
if (EmptyUtil.isEmpty(userDO)) {
throw new UserException(USER_NOT_EXIST);
}
UserFileQueryRequest userFileQueryRequest = new UserFileQueryRequest(userId);
UserFileQueryResponse<UserFileData> userFileInfo =
userFileFacadeService.getUserFileInfo(userFileQueryRequest);
UserFileData data = userFileInfo.getData();
if (EmptyUtil.isEmpty(data)) {
throw new UserException(USER_INFO_FAIL);
}
UserInfoVO userInfoVO = new UserInfoVO();
userInfoVO.setNickname(userDO.getNickName());
userInfoVO.setRootFileId(data.getId());
userInfoVO.setRootFilename(data.getFilename());
userInfoVO.setUserId(data.getUserId());
userInfoVO.setProfilePhotoUrl(userDO.getProfilePhotoUrl());
return userInfoVO;
}
这个方法不是切面调用的,而是 Controller 登录校验通过后,业务流程继续执行时由 Controller 调用。
它的作用是根据当前登录用户 ID 组装前端需要展示的用户信息。流程是:
1. 根据 userId 查询用户表,得到 UserDO
2. 如果用户不存在,抛出 USER_NOT_EXIST
3. 创建 UserFileQueryRequest
4. 通过 Dubbo 调用文件微服务 userFileFacadeService.getUserFileInfo(...)
5. 查询该用户的网盘根目录信息
6. 如果文件服务返回为空,抛出 USER_INFO_FAIL
7. 把用户表信息 + 网盘根目录信息组装成 UserInfoVO
8. 返回给 Controller
这里的 UserInfoVO 是给前端展示用的对象,里面包含用户昵称、头像、用户 ID、根目录文件 ID、根目录名称等信息。
11. 这个切面和 UserService 的整体关系
UserInfoLoginAspect 和 UserService 不是直接调用关系,而是请求链路上的前后关系。
完整流程可以理解成:
前端请求 /user/info
→ 请求头携带 Authorization token
→ Spring MVC 找到对应 Controller 方法
→ Spring AOP 发现该 Controller 方法匹配切点
→ 先执行 UserInfoLoginAspect.loginAuthAround()
→ 判断方法有没有 @LoginIgnore
→ 没有 @LoginIgnore,需要登录校验
→ 从请求头 Authorization 获取 token
→ JWTUtil 解析 token 得到 userId
→ Redis 查询 USER_LOGIN_PREFIX + userId 对应的 token
→ 比对请求 token 和 Redis token
→ 校验失败:返回未登录错误,Controller 不执行
→ 校验成功:UserIdUtil.set(userId)
→ proceedingJoinPoint.proceed() 放行
→ Controller 方法正式执行
→ Controller 从 UserIdUtil 获取 userId
→ Controller 调用 userService.getUserInfo(userId)
→ UserService 查询用户表
→ UserService 远程调用文件服务查询根目录
→ 组装 UserInfoVO
→ 返回前端
一句话总结:
UserInfoLoginAspect 负责“你是谁、有没有登录”;
UserService.getUserInfo 负责“根据这个 userId 查用户信息并组装返回数据”。
12. proceedingJoinPoint.proceed() 为什么这么重要?
proceed() 是环绕通知里的“放行按钮”。
如果登录成功:
return proceedingJoinPoint.proceed();
表示继续执行原来的 Controller 方法。
如果登录失败:
return Result.error(...);
表示直接返回错误结果,不再执行 Controller 方法。
所以环绕切面可以控制目标方法是否执行,这就是 @Around 比 @Before 更强的地方。
可以记成:
@Before:只能在方法前执行一些逻辑,不能方便地阻止原方法
@After:只能在方法后执行
@Around:可以在方法前后执行,还可以决定是否调用 proceed() 放行
13. 这段代码里需要注意的小点
第一,@LoginIgnore 只检查方法上有没有,不检查类上有没有。所以登录、注册这类接口最好直接在具体 Controller 方法上加 @LoginIgnore。
第二,token 优先从请求头 Authorization 获取,如果没有才从请求参数 authorization 获取。实际项目里推荐统一放请求头,不建议把 token 放 URL 参数里,因为 URL 更容易被日志记录或泄露。
第三,切面校验通过后只是把 userId 存进 UserIdUtil,不会自动传给 Service。Controller 还是需要自己从 UserIdUtil 取出 userId,再调用 Service。
第四,切面能不能生效,要看当前微服务的 Spring 扫描范围。每个微服务都有自己的 Spring 容器,哪个服务扫描到了这个切面 Bean,哪个服务里匹配切点的 Controller 才会被拦截。
第五,UserService.findById(userId) 上有缓存注解,所以根据用户 ID 查用户信息时可能会先走缓存,减少数据库压力。getUserInfo(userId) 还会通过 Dubbo 调用文件微服务,把用户基础信息和网盘根目录信息一起组装返回。
Spring AOP 捕获到的是:
某个 Controller 方法即将执行
然后切面做这些事:
1. 拿到当前被拦截的方法
2. 判断方法上有没有 @LoginIgnore
3. 如果有,说明不用登录,直接 proceed() 放行
4. 如果没有,说明需要登录
5. 从当前 HTTP request 里拿 Authorization token
6. 解析 token 得到 userId
7. 去 Redis 校验 token 是否和服务端保存的一致
8. 校验通过,把 userId 存进 UserIdUtil
9. 调用 proceedingJoinPoint.proceed(),继续执行原 Controller 方法
10. 校验失败,直接返回未登录错误,Controller 不执行
Spring AOP 捕获的是“匹配切点的 Spring Bean 方法执行”,在你这里就是 Controller 方法执行;它通过 ProceedingJoinPoint 拿到这个方法的执行信息,并可以用 proceed() 决定是否继续执行原方法。HTTP request 不是 AOP 捕获的,而是切面从 RequestContextHolder 里主动取出来的。
AOP 管方法; RequestContextHolder 管当前请求; ProceedingJoinPoint 管当前被拦截方法的执行现场; proceed() 管是否放行执行原方法。
8.UserController.getUserInfo
进入 networkdisk-user 服务,对应 Controller 方法是:
@GetMapping("/get-user-info")
public Result<UserInfoVO> getUserInfo() {
Long userId = UserIdUtil.get();
UserInfoVO userInfo = userService.getUserInfo(userId);
return Result.success(userInfo);
}
注意:这个方法执行前,UserInfoLoginAspect 已经完成了登录校验,并且把从 token 中解析出的 userId 放入了 UserIdUtil。
所以这个 Controller 方法里不需要再手动解析 token。
整体流程:
前端请求 /api/v1/users/get-user-info
→ UserInfoLoginAspect 先执行
→ 校验 token
→ UserIdUtil.set(userId)
→ 放行 Controller
→ UserController.getUserInfo()
→ UserIdUtil.get()
→ userService.getUserInfo(userId)
→ Result.success(userInfo)
8.1 从 UserIdUtil 获取当前用户 ID
第一行:
Long userId = UserIdUtil.get();
这里不是从请求参数拿 userId,也不是从前端传来的 JSON 拿 userId。
它取的是切面刚才放入 ThreadLocal 的 userId。
前面切面执行过:
UserIdUtil.set(Long.valueOf(String.valueOf(userId)));
所以这里可以直接取:
UserIdUtil.get();
此时内存中大概是:
UserIdUtil.threadLocal = 2016448846836494336
所以:
Long userId = 2016448846836494336
UserIdUtil 的作用可以理解为:当前请求线程中临时保存当前登录用户 ID 的工具类
这样每个需要用户 ID 的接口都不用重复写:
从 Authorization 取 token
解析 JWT
查 Redis
得到 userId
这些公共逻辑已经由切面完成。
8.2 调用 UserService.getUserInfo
UserInfoVO userInfo = userService.getUserInfo(userId);
Controller 不直接查数据库,而是把业务交给 Service。 这一步不是简单查一张表,而是组装“当前用户完整展示信息”。
它最终要返回给前端:
用户ID
用户昵称
用户头像
网盘根目录ID
网盘根目录名称
这些数据来自两个服务、两张表:
networkdisk-user 服务:
查询 user 表,拿用户昵称、头像等基础信息。
networkdisk-files 服务:
查询 user_file 表,拿用户根目录 ID、根目录名称。
整体流程:
UserService.getUserInfo(userId)
→ this.findById(userId)
→ 查询 user 表,得到 UserDO
→ new UserFileQueryRequest(userId)
→ Dubbo 调 files 服务 getUserFileInfo()
→ files 服务查询 user_file 表,得到 UserFileDO
→ UserFileDO 转 UserFileData
→ 返回给 user 服务
→ user 服务组装 UserInfoVO
→ 返回给 Controller
8.2.1 根据 userId 查询用户基础信息
代码第一段:
UserDO userDO = this.findById(userId);
if (EmptyUtil.isEmpty(userDO)) {
throw new UserException(USER_NOT_EXIST);
}
this.findById(userId) 调的是当前 UserService 自己的方法:
public UserDO findById(Long userId) {
UserDO userDO = userMapper.findById(userId);
return userDO;
}
这里真正查数据库的是:
userMapper.findById(userId)
查询的是 MySQL 的 user 表。
查出来的对象是:
UserDO
UserDO 是数据库实体对象,对应 user 表。
内存里大概是:
UserDO userDO = {
id: 2016448846836494336,
nickName: "testUser",
email: "[已隐藏邮箱]",
passwordHash: "加密后的密码",
useSpace: 0,
totalSpace: 1073741824,
profilePhotoUrl: "头像地址",
deleted: 0,
gmtCreate: 创建时间,
gmtModified: 修改时间
}
这里 UserDO 里有数据库内部字段,比如 passwordHash,所以不会直接返回给前端。
如果 userDO 为空,说明 token 里的 userId 对应不到真实用户,直接抛:
USER_NOT_EXIST
8.2.2 构造 UserFileQueryRequest
用户基础信息查完后,还要查询用户的网盘根目录。
代码:
UserFileQueryRequest userFileQueryRequest = new UserFileQueryRequest(userId);
UserFileQueryRequest 是 user 服务调用 files 服务时用的请求 DTO。
它的构造方法:
public UserFileQueryRequest(Long userId) {
UserRootFolderQueryCondition userRootFolderQueryCondition = new UserRootFolderQueryCondition();
userRootFolderQueryCondition.setUserId(userId);
this.queryCondition = userRootFolderQueryCondition;
}
意思是:传入 userId 后,内部会创建一个查询条件对象:
UserRootFolderQueryCondition
内存中大概是:
UserFileQueryRequest = {
queryCondition: UserRootFolderQueryCondition {
userId: 2016448846836494336
}
}
这里的设计是为了以后扩展不同查询条件。
现在只有“查用户根目录”这一种:
UserRootFolderQueryCondition:查询用户根文件夹
以后如果要扩展,也可以增加:
UserFileIdQueryCondition:按文件ID查
UserParentFolderQueryCondition:按父目录查
所以这里不是直接传一个 Long,而是包成了请求对象 + 查询条件对象。
8.2.3 Dubbo 调 files 服务查询根目录
这里也涉及 @Facade。userFileFacadeService.getUserFileInfo(...) 最终调用的是 files 服务里的:
@Facade
@Override
public UserFileQueryResponse<UserFileData> getUserFileInfo(UserFileQueryRequest request) {
...
}
所以登录后获取用户信息的完整链路是:
前端请求 /api/v1/users/get-user-info
→ UserInfoLoginAspect 校验 token
→ UserController.getUserInfo()
→ UserService.getUserInfo(userId)
→ 查询 user 表,拿用户昵称、头像
→ userFileFacadeService.getUserFileInfo(...)
→ Dubbo 调 files 服务
→ UserFileFacadeImpl.getUserFileInfo()
→ @Facade 触发 FacadeAspect
→ pjp.proceed()
→ userFileService.getUserRootInfo()
→ 查询 user_file 表中的根目录
→ 返回 UserFileQueryResponse<UserFileData>
→ UserService 组装 UserInfoVO
→ 返回前端
接着执行:
UserFileQueryResponse<UserFileData> userFileInfo =
userFileFacadeService.getUserFileInfo(userFileQueryRequest);
userFileFacadeService 是 Dubbo 远程代理对象。
所以这行代码看起来像本地方法调用,实际是远程调用:
networkdisk-user
→ Dubbo
→ networkdisk-files
→ UserFileFacadeImpl.getUserFileInfo()
注意这里的调用方向:
登录时:
auth 服务 → user 服务
登录后获取用户信息时:
user 服务 → files 服务
因为用户信息接口需要把用户基础信息和根目录信息拼在一起,所以 user 服务需要去 files 服务查根目录。
8.2.4 UserFileFacadeImpl.getUserFileInfo
files 服务中被调用的方法是:
@Facade
@Override
public UserFileQueryResponse<UserFileData> getUserFileInfo(UserFileQueryRequest request) {
UserFileDO userFileDO = switch (request.getQueryCondition()) {
case UserRootFolderQueryCondition queryCondition:
yield userFileService.getUserRootInfo(queryCondition.getUserId(), FolderFlagEnum.YES);
default:
throw new UnsupportedOperationException(request.getQueryCondition() + "'' is not supported");
};
UserFileQueryResponse<UserFileData> response = new UserFileQueryResponse();
response.setSuccess(true);
UserFileData userFileData = fileConvertor.userFileDOToUserFileData(userFileDO);
response.setData(userFileData);
return response;
}
这一层是 files 服务对外暴露的 Facade 层。
它的作用:
1. 接收 user 服务传来的 UserFileQueryRequest
2. 判断 request 里的查询条件类型
3. 如果是 UserRootFolderQueryCondition,就查询用户根目录
4. 得到 UserFileDO
5. 把 UserFileDO 转成 UserFileData
6. 包装成 UserFileQueryResponse<UserFileData>
7. 返回给 user 服务
8.2.5 switch + yield 是什么意思
这一段:
UserFileDO userFileDO = switch (request.getQueryCondition()) {
case UserRootFolderQueryCondition queryCondition:
yield userFileService.getUserRootInfo(queryCondition.getUserId(), FolderFlagEnum.YES);
default:
throw new UnsupportedOperationException(request.getQueryCondition() + "'' is not supported");
};
可以理解成:
根据 queryCondition 的实际类型,决定怎么查询。
当前 request.getQueryCondition() 实际是:
UserRootFolderQueryCondition
因为前面构造 UserFileQueryRequest(userId) 时放进去的就是它。
所以会进入:
case UserRootFolderQueryCondition queryCondition:
此时 queryCondition 里面有:
userId = 当前登录用户ID
然后执行:
yield userFileService.getUserRootInfo(queryCondition.getUserId(), FolderFlagEnum.YES);
yield 是 Java switch 表达式里的返回值写法。
这段 switch 最终会把 userFileService.getUserRootInfo(...) 的结果赋值给:
UserFileDO userFileDO
等价理解:
UserFileDO userFileDO;
if (request.getQueryCondition() instanceof UserRootFolderQueryCondition queryCondition) {
userFileDO = userFileService.getUserRootInfo(queryCondition.getUserId(), FolderFlagEnum.YES);
} else {
throw new UnsupportedOperationException(...);
}
所以这里的核心就是:
如果查询条件是“用户根目录查询条件”,就去查用户根目录。
8.2.6 UserFileServiceImpl.getUserRootInfo
进入 files 服务业务层:
public UserFileDO getUserRootInfo(Long userId, FolderFlagEnum folderFlag) {
QueryFileContext context = new QueryFileContext();
context.setUserId(userId);
context.setFileFolderType(folderFlag.getCode());
context.setParentId(FileConstant.TOP_PARENT_ID);
UserFileDO userRootInfo = userFileMapper.getUserRootInfo(context);
return userRootInfo;
}
这里又构造了一个内部查询上下文:
QueryFileContext
内存中大概是:
QueryFileContext = {
userId: 2016448846836494336,
fileFolderType: 1,
parentId: 0
}
字段含义:
userId:查哪个用户的文件
fileFolderType:是否文件夹,FolderFlagEnum.YES 表示文件夹
parentId:顶级父目录 ID,这里用 TOP_PARENT_ID
这三个条件组合起来就是:
查询这个用户的顶级文件夹,也就是用户根目录。
然后调用:
userFileMapper.getUserRootInfo(context)
8.2.7 Mapper 查询 user_file 表
对应 XML SQL:
<select id="getUserRootInfo" resultType="com.disk.files.domain.entity.UserFileDO">
SELECT
id AS id,
parent_id AS parentId,
filename AS filename,
folder_flag AS folderFlag,
user_id AS userId,
file_type AS fileType
FROM
user_file
WHERE
user_id = #{param.userId}
AND folder_flag = #{param.fileFolderType}
AND parent_id = #{param.parentId}
AND deleted = 0
</select>
实际查询逻辑:
select id, parent_id, filename, folder_flag, user_id, file_type
from user_file
where user_id = 当前用户ID
and folder_flag = 1
and parent_id = 0
and deleted = 0;
意思是:
查 user_file 表中:
属于当前用户、
是文件夹、
父目录是顶级父目录、
未删除的那条记录。
这条记录就是注册时创建的用户根目录。
查出来后得到:
UserFileDO
内存中大概是:
UserFileDO userFileDO = {
id: 30001,
userId: 2016448846836494336,
parentId: 0,
filename: "全部文件",
folderFlag: 1,
fileType: null
}
8.2.8 UserFileDO 转 UserFileData
回到 Facade 层:
UserFileData userFileData = fileConvertor.userFileDOToUserFileData(userFileDO);
UserFileDO 是 files 服务内部数据库实体,对应 user_file 表。
UserFileData 是跨服务返回给 user 服务的 DTO。
转换前:
UserFileDO = {
id: 30001,
userId: 2016448846836494336,
parentId: 0,
filename: "全部文件",
folderFlag: 1
}
转换后:
UserFileData = {
id: 30001,
userId: 2016448846836494336,
parentId: 0,
filename: "全部文件",
folderFlag: 1
}
字段看起来差不多,但职责不同:
UserFileDO:数据库实体,files 服务内部使用
UserFileData:跨服务传输对象,返回给 user 服务使用
不要因为字段类似就觉得没必要分。项目分层中常常会把“数据库对象”和“对外传输对象”分开,避免直接暴露内部表结构。
8.2.9 包装 UserFileQueryResponse 返回
files 服务最后包装响应:
UserFileQueryResponse<UserFileData> response = new UserFileQueryResponse();
response.setSuccess(true);
response.setData(userFileData);
return response;
返回给 user 服务的是:
UserFileQueryResponse<UserFileData> = {
success: true,
data: {
id: 30001,
userId: 2016448846836494336,
parentId: 0,
filename: "全部文件",
folderFlag: 1
}
}
这里:
success:表示 files 服务查询成功
data:真正的根目录数据
user 服务用这个变量接收:
UserFileQueryResponse<UserFileData> userFileInfo =
userFileFacadeService.getUserFileInfo(userFileQueryRequest);
所以:
userFileInfo = files 服务返回的响应对象
userFileInfo.getData() = 用户根目录数据 UserFileData
8.2.10 user 服务取出根目录数据
回到 UserService.getUserInfo():
UserFileData data = userFileInfo.getData();
此时:
data = {
id: 30001,
userId: 2016448846836494336,
filename: "全部文件",
folderFlag: 1
}
如果 data 为空:
throw new UserException(USER_INFO_FAIL);
因为用户没有根目录,前端就无法进入网盘空间。
8.2.11 组装 UserInfoVO
user 服务现在手里有两份数据:
第一份:来自 user 表的 UserDO
userDO = {
id: 2016448846836494336,
nickName: "testUser",
profilePhotoUrl: "头像地址"
}
第二份:来自 files 服务的 UserFileData
data = {
id: 30001,
userId: 2016448846836494336,
filename: "全部文件"
}
然后组装前端展示对象:
UserInfoVO userInfoVO = new UserInfoVO();
userInfoVO.setNickname(userDO.getNickName());
userInfoVO.setRootFileId(data.getId());
userInfoVO.setRootFilename(data.getFilename());
userInfoVO.setUserId(data.getUserId());
userInfoVO.setProfilePhotoUrl(userDO.getProfilePhotoUrl());
return userInfoVO;
最终得到:
UserInfoVO = {
userId: 2016448846836494336,
nickname: "testUser",
rootFileId: 30001,
rootFilename: "全部文件",
profilePhotoUrl: "头像地址"
}
字段来源:
nickname:来自 userDO.nickName
profilePhotoUrl:来自 userDO.profilePhotoUrl
rootFileId:来自 UserFileData.id
rootFilename:来自 UserFileData.filename
userId:来自 UserFileData.userId
这个对象再返回给 Controller:
UserInfoVO userInfo = userService.getUserInfo(userId);
Controller 再执行:
return Result.success(userInfo);
8.2.12 这一段完整链路总结
UserController.getUserInfo()
→ UserIdUtil.get() 得到当前登录用户 ID
→ userService.getUserInfo(userId)
UserService.getUserInfo()
→ this.findById(userId)
→ userMapper.findById(userId)
→ 查询 user 表
→ 得到 UserDO
UserService.getUserInfo()
→ new UserFileQueryRequest(userId)
→ 内部包装 UserRootFolderQueryCondition
UserService.getUserInfo()
→ userFileFacadeService.getUserFileInfo(request)
→ Dubbo 调 networkdisk-files
UserFileFacadeImpl.getUserFileInfo()
→ switch 判断 queryCondition 类型
→ UserRootFolderQueryCondition
→ userFileService.getUserRootInfo(userId, FolderFlagEnum.YES)
UserFileServiceImpl.getUserRootInfo()
→ 构造 QueryFileContext
→ userFileMapper.getUserRootInfo(context)
→ 查询 user_file 表
→ 得到 UserFileDO
UserFileFacadeImpl.getUserFileInfo()
→ UserFileDO 转 UserFileData
→ 包装 UserFileQueryResponse<UserFileData>
→ 返回 user 服务
UserService.getUserInfo()
→ userFileInfo.getData()
→ 得到 UserFileData
→ 结合 UserDO 组装 UserInfoVO
→ 返回 Controller
UserController.getUserInfo()
→ Result.success(UserInfoVO)
→ 返回前端
一句话总结:
UserService.getUserInfo 不是单纯查用户表,而是先查 user 表拿用户基础信息,再通过 Dubbo 调 files 服务查 user_file 表中的根目录信息,最后把两部分数据合并成 UserInfoVO 返回给前端。
8.3 Result.success 包装返回
第三行:
return Result.success(userInfo);
这里不会直接返回 UserInfoVO,而是用统一响应对象包起来。
返回结构大概是:
{
"success": true,
"code": "SUCCESS",
"message": "SUCCESS",
"data": {
"userId": "加密后的用户ID",
"nickname": "testUser",
"rootFileId": "加密后的根目录ID",
"rootFilename": "全部文件",
"profilePhotoUrl": "头像地址"
}
}
其中:
success/code/message:本次接口调用状态
data:真正的业务数据,也就是 UserInfoVO
8.4 userId 和 rootFileId 为什么返回的是加密字符串
UserInfoVO 里有两个字段加了注解:
@JsonSerialize(using = IdEncryptSerializer.class)
private Long userId;
@JsonSerialize(using = IdEncryptSerializer.class)
private Long rootFileId;
意思是:
Java 内存里 userId/rootFileId 是 Long
返回 JSON 给前端时,会使用 IdEncryptSerializer 转成加密字符串
所以后端内部使用:
2016448846836494336
前端收到的可能是:
"加密后的ID字符串"
这样可以避免直接把数据库 Long ID 暴露给前端。
前端后续请求文件列表时,会把加密后的 rootFileId 传回后端。后端再用 IdUtil.decrypt() 解密成真实 Long ID。
8.5 这一层内存对象变化
进入 Controller 前,切面已经准备好:
UserIdUtil.threadLocal = 当前登录用户ID
进入 Controller 后:
Long userId = UserIdUtil.get();
内存中:
userId = 2016448846836494336
调用 Service 后:
UserInfoVO userInfo = userService.getUserInfo(userId);
内存中:
userInfo = {
userId: 2016448846836494336,
nickname: "testUser",
rootFileId: 30001,
rootFilename: "全部文件",
profilePhotoUrl: "头像地址"
}
返回前端时,因为 JSON 序列化器生效:
userId: Long → 加密字符串
rootFileId: Long → 加密字符串
最终响应:
Result<UserInfoVO> = {
success: true,
code: "SUCCESS",
message: "SUCCESS",
data: {
userId: "加密后的用户ID",
nickname: "testUser",
rootFileId: "加密后的根目录ID",
rootFilename: "全部文件",
profilePhotoUrl: "头像地址"
}
}
8.6 UserController.getUserInfo 总结
UserController.getUserInfo()
→ 从 UserIdUtil 取当前登录用户 ID
→ 调用 userService.getUserInfo(userId)
→ 得到 UserInfoVO
→ 用 Result.success(userInfo) 包装
→ 返回给前端
一句话总结:
UserController.getUserInfo 本身不解析 token,也不直接查数据库。token 校验和 userId 提取由 UserInfoLoginAspect 完成,Controller 只从 UserIdUtil 取当前用户 ID,然后调用 Service 组装用户信息并返回。
3.6返回链路
登录和注册的区别:注册是创建用户和根目录;登录是校验账号密码、生成 token、保存登录态。登录成功后,前端拿到 token,再用 token 请求用户信息接口,后端通过登录切面解析 token 得到 userId,然后查询用户信息和根目录信息返回给前端。
登录主链路:
Login.vue
→ 表单校验
→ userStore.loginAction()
→ api/auth.js 发送 POST /api/v1/auth/login
→ Vite Proxy / Gateway 转发到 networkdisk-auth
→ auth 服务的 Tomcat 接收请求
→ Spring MVC 找到 AuthController.login()
→ AuthService 校验邮箱和密码
→ 密码正确后生成 JWT token
→ Redis 保存 USER_LOGIN_PREFIX + userId → token
→ token 返回前端
→ 前端保存 token 到 Pinia / Cookie
→ 前端请求用户信息接口
→ UserInfoLoginAspect 校验 Authorization token
→ UserService 查询用户表 + Dubbo 查询根目录
→ 返回 userInfo + rootFileId
→ 前端用 rootFileId 初始化网盘首页文件列表
1. user 服务查 user 表,得到 UserDO
2. files 服务查 user_file 表,得到用户根目录 UserFileDO
1 files 服务返回给 user 服务
files 服务中,UserFileFacadeImpl.getUserFileInfo() 查到根目录后:
UserFileDO userFileDO = userFileService.getUserRootInfo(...);
此时内存中大概是:
UserFileDO = {
id: 30001,
userId: 2016448846836494336,
parentId: 0,
filename: "全部文件",
folderFlag: 1
}
UserFileDO 是 files 服务内部数据库实体,不能直接作为跨服务返回对象,所以先转成 UserFileData:
UserFileData userFileData = fileConvertor.userFileDOToUserFileData(userFileDO);
转换后:
UserFileData = {
id: 30001,
userId: 2016448846836494336,
parentId: 0,
filename: "全部文件",
folderFlag: 1
}
然后包装响应对象:
UserFileQueryResponse<UserFileData> response = new UserFileQueryResponse();
response.setSuccess(true);
response.setData(userFileData);
return response;
返回给 user 服务的是:
UserFileQueryResponse<UserFileData> = {
success: true,
data: {
id: 30001,
userId: 2016448846836494336,
parentId: 0,
filename: "全部文件",
folderFlag: 1
}
}
user 服务中接收它的是:
UserFileQueryResponse<UserFileData> userFileInfo =
userFileFacadeService.getUserFileInfo(userFileQueryRequest);
所以这一层返回关系是:
UserFileFacadeImpl.getUserFileInfo()
→ 返回 UserFileQueryResponse<UserFileData>
→ UserService.getUserInfo() 中的 userFileInfo 接收
2 user 服务取出 UserFileData
user 服务拿到响应对象后:
UserFileData data = userFileInfo.getData();
此时:
data = {
id: 30001,
userId: 2016448846836494336,
filename: "全部文件",
folderFlag: 1
}
如果 data 为空:
throw new UserException(USER_INFO_FAIL);
如果不为空,说明根目录查询成功,可以继续组装返回给前端的用户信息。
3 user 服务组装 UserInfoVO
user 服务此时有两份数据:
第一份,来自 user 表:
UserDO userDO = {
id: 2016448846836494336,
nickName: "testUser",
profilePhotoUrl: "头像地址"
}
第二份,来自 files 服务:
UserFileData data = {
id: 30001,
userId: 2016448846836494336,
filename: "全部文件"
}
然后组装:
UserInfoVO userInfoVO = new UserInfoVO();
userInfoVO.setNickname(userDO.getNickName());
userInfoVO.setRootFileId(data.getId());
userInfoVO.setRootFilename(data.getFilename());
userInfoVO.setUserId(data.getUserId());
userInfoVO.setProfilePhotoUrl(userDO.getProfilePhotoUrl());
return userInfoVO;
得到:
UserInfoVO = {
userId: 2016448846836494336,
nickname: "testUser",
rootFileId: 30001,
rootFilename: "全部文件",
profilePhotoUrl: "头像地址"
}
返回给谁?
返回给调用它的 Controller:
UserInfoVO userInfo = userService.getUserInfo(userId);
所以这一层是:
UserService.getUserInfo()
→ 返回 UserInfoVO
→ UserController.getUserInfo() 中的 userInfo 接收
4 Controller 包装 Result 返回
Controller 收到 UserInfoVO 后:
return Result.success(userInfo);
包装成统一响应对象:
Result<UserInfoVO> = {
success: true,
code: "SUCCESS",
message: "SUCCESS",
data: UserInfoVO
}
注意:UserInfoVO 里有两个字段:
@JsonSerialize(using = IdEncryptSerializer.class)
private Long userId;
@JsonSerialize(using = IdEncryptSerializer.class)
private Long rootFileId;
所以 Java 内存中是 Long:
userId = 2016448846836494336
rootFileId = 30001
但返回 JSON 给前端时,会被序列化成加密字符串:
{
"success": true,
"code": "SUCCESS",
"message": "SUCCESS",
"data": {
"userId": "加密后的用户ID",
"nickname": "testUser",
"rootFileId": "加密后的根目录ID",
"rootFilename": "全部文件",
"profilePhotoUrl": "头像地址"
}
}
这一层是:
UserController.getUserInfo()
→ 返回 Result<UserInfoVO>
→ Spring MVC 接收
5 Spring MVC / Tomcat 返回 HTTP 响应
Controller 返回的是 Java 对象:
Result<UserInfoVO>
Spring MVC 会使用 Jackson 把它转换成 JSON。
然后交给 Tomcat 返回 HTTP 响应:
HTTP 200
Content-Type: application/json
响应体:JSON 字符串
开发环境下响应链路:
networkdisk-user:8086
→ Vite dev server:5173
→ 浏览器
6 Axios 响应拦截器处理
浏览器收到 HTTP 响应后,Axios 拿到:
response = {
status: 200,
data: {
success: true,
code: 'SUCCESS',
message: 'SUCCESS',
data: {
userId: '加密后的用户ID',
nickname: 'testUser',
rootFileId: '加密后的根目录ID',
rootFilename: '全部文件',
profilePhotoUrl: '头像地址'
}
}
}
响应拦截器判断:
if (response.data.code === 200 || response.data.success === true) {
return response.data
}
所以 getUserInfo() 最终返回给 Pinia 的不是 Axios 原始响应,而是:
{
success: true,
code: 'SUCCESS',
message: 'SUCCESS',
data: {
userId: '加密后的用户ID',
nickname: 'testUser',
rootFileId: '加密后的根目录ID',
rootFilename: '全部文件',
profilePhotoUrl: '头像地址'
}
}
7 Pinia 保存用户信息
回到 getUserInfoAction():
const response = await getUserInfo()
此时 response 是:
response = {
success: true,
code: 'SUCCESS',
message: 'SUCCESS',
data: {
userId: '加密后的用户ID',
nickname: 'testUser',
rootFileId: '加密后的根目录ID',
rootFilename: '全部文件',
profilePhotoUrl: '头像地址'
}
}
然后保存:
// 将接口返回的用户信息赋值给页面响应式变量
userInfo.value = response.data || {}
// 如果用户存在根目录ID,同步赋值给全局根文件ID变量
if (userInfo.value.rootFileId) {
rootFileId.value = userInfo.value.rootFileId
}
保存后,Pinia 中大概是:
userStore = {
token: 'JWT token字符串',
userInfo: {
userId: '加密后的用户ID',
nickname: 'testUser',
rootFileId: '加密后的根目录ID',
rootFilename: '全部文件',
profilePhotoUrl: '头像地址'
},
rootFileId: '加密后的根目录ID'
}
8 fileStore.initRoot 流程
登录成功后,Login.vue 中会执行:
fileStore.initRoot()
它的作用是:根据当前用户的 rootFileId 初始化文件模块,让首页能显示用户根目录下的文件列表。
前面 getUserInfoAction() 已经把用户信息保存到了 Pinia:
userInfo.value = response.data || {}
rootFileId.value = userInfo.value.rootFileId
此时 userStore 里大概是:
userStore = {
token: 'JWT token字符串',
userInfo: {
userId: '加密后的用户ID',
nickname: 'testUser',
rootFileId: '加密后的根目录ID',
rootFilename: '全部文件',
profilePhotoUrl: '头像地址'
},
rootFileId: '加密后的根目录ID'
}
fileStore.initRoot() 就是使用这个 rootFileId。
initRoot代码:
async function initRoot() {
const userStore = useUserStore()
if (userStore.rootFileId) {
parentId.value = userStore.rootFileId
currentFolderId.value = userStore.rootFileId
fileTypes.value = '-1'
await loadFiles(userStore.rootFileId, '-1')
}
}
流程:
1. 通过 useUserStore() 拿到用户仓库
2. 判断 userStore.rootFileId 是否存在
3. 把 fileStore.parentId 设置成 rootFileId
4. 把 fileStore.currentFolderId 设置成 rootFileId
5. 把 fileTypes 设置成 -1,表示查询全部类型
6. 调用 loadFiles(rootFileId, '-1') 加载根目录文件列表
这里几个状态含义:
parentId:当前正在查看的文件夹 ID
currentFolderId:当前文件夹 ID,上传文件时会用
fileTypes:文件类型筛选,-1 表示全部
files:当前目录下的文件列表
breadcrumbs:当前目录的面包屑路径
初始化后,fileStore 内存中大概是:
fileStore = {
parentId: '加密后的根目录ID',
currentFolderId: '加密后的根目录ID',
fileTypes: '-1',
files: [],
breadcrumbs: []
}
initRoot() 最后调用:
await loadFiles(userStore.rootFileId, '-1')
loadFiles() 核心代码:
async function loadFiles(newParentId, newFileTypes) {
// 自增当前请求序列号,标记本次加载请求
const currentSeq = ++latestLoadSeq.value
// 如果传入了文件类型筛选参数,更新全局筛选条件
if (typeof newFileTypes !== 'undefined') fileTypes.value = newFileTypes
// 如果传入了父文件夹ID,更新当前目录ID
//你点击某个文件夹时,就会把这个文件夹的 ID 传进函数里,更新全局的「当前目录 ID」
//后面拉文件列表、拉面包屑路径,都是基于这个 ID 去查询的。
if (typeof newParentId !== 'undefined') {
parentId.value = newParentId
currentFolderId.value = newParentId
}
// 调用后端接口,查询当前目录下的文件列表
const res = await getFileList({
parentId: parentId.value,
fileTypes: fileTypes.value
})
// 关键防抖逻辑:如果后续又发起了新加载请求,本次旧请求结果直接丢弃,不渲染
if (currentSeq !== latestLoadSeq.value) return
// 赋值文件列表到页面响应式数据
files.value = res.data || []
// 存在父目录,需要查询面包屑导航路径
if (parentId.value) {
// 请求获取当前文件夹的层级面包屑
//后端会从这个文件夹开始,**一层层往上找它的父级、父级的父级…… 一直找到根目录**,最后返回一个完整的路径数组。
const bcRes = await getBreadcrumbs({ fileId: parentId.value })
// 再次校验序列号,防止旧请求覆盖最新数据
if (currentSeq !== latestLoadSeq.value) return
breadcrumbs.value = bcRes.data || []
} else {
// 根目录,清空面包屑
breadcrumbs.value = []
}
}
它做两件主要的事:
1. 请求当前目录下的文件列表
2. 请求当前目录的面包屑路径
latestLoadSeq 是什么
const currentSeq = ++latestLoadSeq.value
这个变量是为了避免异步请求乱序。
比如用户快速切换目录,可能出现:
第一次请求 A 目录
第二次请求 B 目录
B 请求先返回
A 请求后返回
如果不处理,后返回的 A 会覆盖 B,页面就显示错了。
所以代码用 latestLoadSeq 做版本号:
每次 loadFiles 都生成一个新的 currentSeq
请求返回后,判断 currentSeq 是否还是最新
不是最新就直接 return,不更新页面
简单理解:
只接受最后一次 loadFiles 的结果,旧请求返回了也丢弃。
8.1 getFileList 请求文件列表
loadFiles() 先调用:
const res = await getFileList({
parentId: parentId.value,
fileTypes: fileTypes.value
})
getFileList() 在 api/file.js 中:
export function getFileList(params) {
return fileRequest({
url: '/folders-files',
method: 'get',
params
})
}
因为 fileRequest 的基础路径是:
baseURL: '/api/v1/files'
所以最终请求:
GET /api/v1/files/folders-files?parentId=加密后的根目录ID&fileTypes=-1
开发环境下 Vite 代理到:
http://localhost:8082/api/v1/files/folders-files
请求头中会带上 token:
Authorization: JWT token字符串
因为 fileStore.initRoot() 是登录成功后执行的,此时 token 已经保存到 userStore。
8.2 后端 UserFileController.list 接收请求
后端入口:
@GetMapping("/folders-files")
public Result<List<UserFileVO>> list(
@RequestParam(value = "parentId", required = false) String parentId,
@RequestParam(value = "fileTypes", required = false, defaultValue = FileConstant.ALL_FILE_TYPE) String fileTypes
) {
Long realParentId = -1L;
if (!Objects.equals(FileConstant.ALL_FILE_TYPE, parentId)) {
realParentId = IdUtil.decrypt(parentId);
}
List<Integer> fileTypeArray = null;
if (!Objects.equals(FileConstant.ALL_FILE_TYPE, fileTypes)) {
fileTypeArray = Arrays.stream(fileTypes.split(BaseConstant.COMMA))
.map(Integer::valueOf)
.collect(Collectors.toList());
}
QueryFileContext request = new QueryFileContext();
request.setParentId(realParentId);
request.setFileTypeArray(fileTypeArray);
request.setUserId(UserIdUtil.get());
request.setDeleted(DeleteEnum.NO.getCode());
List<UserFileDO> result = userFileService.getUserFileList(request);
List<UserFileVO> userFileVOList = fileConvertor.mapToVo(result);
return Result.success(userFileVOList);
}
这里 files 服务同样会先经过 UserInfoLoginAspect 登录校验:
解析 Authorization token
→ Redis 校验 token
→ UserIdUtil.set(userId)
→ 放行 Controller
所以 Controller 中可以直接:
request.setUserId(UserIdUtil.get());
拿到当前登录用户 ID。
8.3 parentId 为什么要解密
前端传来的:
parentId = 加密后的根目录ID
后端需要真实数据库 ID 查询,所以执行:
realParentId = IdUtil.decrypt(parentId);
转换关系:
前端加密 rootFileId 字符串
→ 后端 IdUtil.decrypt()
→ 真实 Long 类型 rootFileId
比如:
parentId = "加密字符串"
realParentId = 30001
这样后端才能查:
parent_id = 30001
8.4 QueryFileContext 是什么
后端会组装:
QueryFileContext request = new QueryFileContext();
request.setParentId(realParentId);
request.setFileTypeArray(fileTypeArray);
request.setUserId(UserIdUtil.get());
request.setDeleted(DeleteEnum.NO.getCode());
内存中大概是:
QueryFileContext = {
parentId: 30001,
fileTypeArray: null,
userId: 2016448846836494336,
deleted: 0
}
含义:
查询当前用户 userId 下,
父目录是 parentId 的文件和文件夹,
只查未删除数据,
fileTypeArray 为 null 表示不限制文件类型。
fileTypes = -1 表示全部类型,所以不会设置 fileTypeArray。
8.5 查询 user_file 表
Controller 调用:
List<UserFileDO> result = userFileService.getUserFileList(request);
Service 中:
return baseMapper.listUserFiles(context);
对应 SQL
<select id="listUserFiles" resultType="com.disk.files.domain.entity.UserFileDO">
SELECT
id,
parent_id AS parentId,
filename AS filename,
file_size_desc AS fileSizeDesc,
folder_flag AS folderFlag,
file_type AS fileType,
gmt_modified AS gmtModified
FROM user_file
WHERE user_id = #{param.userId}
<if test="param.parentId != null and param.parentId != -1">
AND parent_id = #{param.parentId}
</if>
<if test="param.fileTypeArray != null">
AND file_type IN (...)
</if>
AND deleted = #{param.deleted}
</select>
实际查询类似:
select id, parent_id, filename, file_size_desc, folder_flag, file_type, gmt_modified
from user_file
where user_id = 当前用户ID
and parent_id = 当前根目录ID
and deleted = 0;
意思是:
查询当前用户根目录下面的所有文件和文件夹。
查出来的是:
List<UserFileDO>
例如:
[
{
id: 40001,
parentId: 30001,
filename: "学习资料",
folderFlag: 1,
fileSizeDesc: null,
fileType: null
},
{
id: 40002,
parentId: 30001,
filename: "简历.pdf",
folderFlag: 0,
fileSizeDesc: "120KB",
fileType: 5
}
]
如果用户刚注册,还没有上传文件,可能返回空数组:
[]
8.6 UserFileDO 转 UserFileVO
后端不会直接把 UserFileDO 返回给前端,而是转换成:
List<UserFileVO> userFileVOList = fileConvertor.mapToVo(result);
UserFileVO 是返回给前端文件列表展示用的对象:
public class UserFileVO {
private Long id;
private Long parentId;
private String filename;
private String fileSizeDesc;
private Integer folderFlag;
private Integer fileType;
private Date updateTime;
}
其中:
@JsonSerialize(using = IdEncryptSerializer.class)
private Long id;
@JsonSerialize(using = IdEncryptSerializer.class)
private Long parentId;
所以返回给前端时,文件 ID 和父目录 ID 也会被加密成字符串。 转换后大概是:
List<UserFileVO> = [
{
id: "加密后的文件ID",
parentId: "加密后的根目录ID",
filename: "学习资料",
folderFlag: 1,
fileSizeDesc: null,
fileType: null,
updateTime: "2026-06-28 ..."
}
]
最后返回:
return Result.success(userFileVOList);
8.7 前端保存文件列表
Axios 响应拦截器返回 response.data 后,回到 loadFiles():
files.value = res.data || []
此时 fileStore 中:
fileStore.files = [
{
id: '加密后的文件ID',
parentId: '加密后的根目录ID',
filename: '学习资料',
folderFlag: 1,
fileSizeDesc: null,
fileType: null,
updateTime: '2026-06-28 ...'
}
]
如果根目录为空:
fileStore.files = []
页面文件列表就根据这个数组渲染。
8.8 getBreadcrumbs 请求面包屑
文件列表加载后,loadFiles() 还会执行:
const bcRes = await getBreadcrumbs({ fileId: parentId.value })
breadcrumbs.value = bcRes.data || []
请求:
GET /api/v1/files/file/breadcrumbs?fileId=加密后的根目录ID
面包屑用于显示当前路径,例如:
全部文件 / 学习资料 / Java
根目录时通常就是:
全部文件
返回后保存到:
fileStore.breadcrumbs
9 initRoot 完整链路总结
Login.vue 登录成功
→ getUserInfoAction() 已经拿到 rootFileId
→ fileStore.initRoot()
fileStore.initRoot()
→ 从 userStore 读取 rootFileId
→ parentId = rootFileId
→ currentFolderId = rootFileId
→ fileTypes = -1
→ loadFiles(rootFileId, -1)
loadFiles()
→ getFileList({ parentId: rootFileId, fileTypes: -1 })
→ GET /api/v1/files/folders-files
→ Vite 转发到 networkdisk-files:8082
后端 files:
UserInfoLoginAspect 校验 token
→ UserFileController.list()
→ 解密 parentId
→ UserIdUtil.get() 获取当前用户 ID
→ 组装 QueryFileContext
→ userFileService.getUserFileList()
→ UserFileMapper.listUserFiles()
→ 查询 user_file 表
→ UserFileDO 转 UserFileVO
→ Result.success(List<UserFileVO>)
前端:
Axios 响应拦截器返回 response.data
→ loadFiles() 中 files.value = res.data || []
→ 继续 getBreadcrumbs()
→ breadcrumbs.value = bcRes.data || []
→ 首页渲染文件列表和路径
一句话总结:
fileStore.initRoot 是登录成功后的文件模块初始化操作。它使用登录后获取到的 rootFileId 作为当前目录 ID,请求 files 服务查询该根目录下的文件列表和面包屑,并把结果保存到 fileStore.files 和 fileStore.breadcrumbs,供首页文件列表渲染。
简单说:项目打开后显示什么,主要看 Vue Router;退出按钮出现在登录后的主布局 Layout 里。
3.7页面怎么显示
Vue 项目的入口是:
main.js
→ App.vue
→ router/index.js
App.vue 很简单:
<router-view />
router-view 的意思是:
当前 URL 匹配哪个路由,就显示哪个页面组件。
比如:
/login → 显示 Login.vue
/register → 显示 Register.vue
/ → 显示 Layout.vue,里面默认显示 Home.vue
/files → 显示 Layout.vue,里面显示 Files.vue
/shares → 显示 Layout.vue,里面显示 Shares.vue
/recycle → 显示 Layout.vue,里面显示 Recycle.vue
打开项目先去哪里
路由配置里 / 是主布局:
{
path: '/',
name: 'Layout',
component: () => import('@/views/Layout.vue'),
meta: { requiresAuth: true },
children: [
{
path: '',
name: 'Home',
component: () => import('@/views/Home.vue')
}
]
}
所以访问:
http://localhost:5173/
理论上会进入:
Layout.vue + Home.vue
但是 / 需要登录:
meta: { requiresAuth: true }
所以路由守卫会检查:
if (to.meta.requiresAuth && !isLoggedIn.value) {
next('/login')
}
如果没 token,就跳到:
/login
如果已登录,就进入:
/
→ Layout.vue
→ Home.vue
退出按钮在哪里
退出按钮不在 Login.vue,而是在登录后的主布局里。
Layout.vue 结构是:
<pan-header />
<pan-navbar />
<pan-app-main />
其中:
pan-header:顶部栏
pan-navbar:左侧菜单
pan-app-main:中间页面内容
顶部栏 components/header/index.vue 里引用了:
<pan-user-info />
pan-user-info 就是:
components/user-info/index.vue
里面有退出菜单:
<el-dropdown-item divided command="logout">
退出登录
</el-dropdown-item>
所以你能看到“退出登录”的前提是:
已经登录
→ 进入 Layout.vue
→ 顶部 Header 显示
→ Header 里显示 UserInfo 下拉菜单
→ 下拉菜单里有退出登录
点击退出后发生什么
点击退出:
case 'logout':
await ElMessageBox.confirm('确定要退出登录吗?', '提示', ...)
userStore.logout()
router.push('/login')
当前代码做的是:
弹确认框
→ 清空前端 token/userInfo/rootFileId
→ 跳转登录页
注意:当前页面退出没有调用后端 /api/v1/auth/logout,只是前端本地清理登录状态。
3.8登出
退出按钮在:components/user-info/index.vue 这里:
<el-dropdown-item divided command="logout">
<el-icon><SwitchButton /></el-icon>
退出登录
</el-dropdown-item>
点击后进入:
case 'logout':
await ElMessageBox.confirm('确定要退出登录吗?', '提示', ...)
userStore.logout()
router.push('/login')
所以当前点击“退出登录”执行的是:
弹确认框
→ userStore.logout()
→ 跳转 /login
Pinia 里的 logout在:stores/user.js
const logout = () => {
clearToken()
}
clearToken() 做的是:
setToken('')
userInfo.value = {}
rootFileId.value = ''
setToken('') 会删除 Cookie:
Cookies.remove('token')
所以前端退出做了:
清空 Pinia token
删除 Cookie token
清空 userInfo
清空 rootFileId
跳转登录页
后端 logout 接口
后端也有接口:AuthController.java
@PostMapping("/logout")
public Result<Boolean> logout() {
authService.logout();
return Result.success(true);
}
api/auth.js 里也封装了:
export function logout() {
return authRequest({
url: '/logout',
method: 'post'
})
}
但是当前 stores/user.js 没有 import/use 这个后端 logout(),所以页面退出时并没有请求后端。
后端 logout 目前还有问题
后端实现是:AuthServiceImpl.java
public void logout() {
Long userId = IdUtil.get();
stringRedisTemplate.delete(BaseConstant.USER_LOGIN_PREFIX + userId);
StpUtil.logout(userId);
}
这里用了:
IdUtil.get()
但 IdUtil.get() 是生成新 ID 的工具,不是获取当前登录用户 ID。
所以它大概率会生成一个新的随机雪花 ID,然后删除:
USER_LOGIN_随机ID
而不是删除真正的:
USER_LOGIN_当前用户ID
因此即使前端调用了后端 logout,现在后端也不一定能正确删除 Redis 中的登录 token。
登出流程
当前项目的退出登录入口在 components/user-info/index.vue 的用户下拉菜单中。点击“退出登录”后,前端先弹出确认框,确认后执行 userStore.logout(),再跳转到 /login。
userStore.logout() 位于 stores/user.js,它实际调用 clearToken(),会清空 Pinia 中的 token、userInfo、rootFileId,并删除浏览器 Cookie 中保存的 token。因此当前页面上的退出登录主要是“前端本地退出”。
项目后端也提供了 POST /api/v1/auth/logout 接口,api/auth.js 中也封装了 logout() 请求方法,但当前页面退出时没有调用这个接口。
另外,后端 AuthServiceImpl.logout() 当前使用 IdUtil.get() 获取 userId,这是不正确的,因为 IdUtil.get() 是生成新 ID,不是获取当前登录用户 ID。因此后端 logout 目前无法可靠删除 Redis 中当前用户的 token。更合理的做法是:前端退出时调用后端 logout 接口,后端从请求 token 中解析当前 userId,再删除 USER_LOGIN_userId 对应的 Redis token。
一句话:现在登出主要是前端清 token;后端登出接口存在,但没被页面调用,而且实现里获取 userId 的方式也有问题。
Vue 页面显示和登出入口
Vue 项目通过 router-view 决定当前显示哪个页面。App.vue 中只有 <router-view />,所以浏览器 URL 匹配到哪个路由,就渲染哪个组件。比如 /login 显示 Login.vue,/register 显示 Register.vue,/ 显示主布局 Layout.vue,并在主布局内部默认显示 Home.vue。
项目配置了路由守卫。/、/files、/shares、/recycle 都属于需要登录的页面,如果用户没有 token,访问这些页面会被重定向到 /login。如果用户已登录,访问 / 会进入 Layout.vue。
Layout.vue 是登录后的整体页面框架,里面包含顶部栏 pan-header、左侧导航 pan-navbar 和主体区域 pan-app-main。顶部栏中引入了 pan-user-info,也就是 components/user-info/index.vue,用户头像和“退出登录”菜单就在这里。
点击“退出登录”后,会先弹出确认框,确认后执行 userStore.logout(),清空 Pinia 和 Cookie 中的 token,同时清空用户信息和 rootFileId,然后通过 router.push('/login') 跳转到登录页。当前项目页面上的退出登录只做了前端本地清理,没有真正调用后端 /api/v1/auth/logout 接口。
4.文件基础管理

主入口是 App.vue ,里面只有一个顶层 <router-view />,由 Vue Router 决定显示登录页、主布局或分享页。登录后的主页面使用 Layout.vue,布局分三块:
- 顶部栏:
PanHeader - 左侧菜单:
PanNavbar - 中间内容区:
PanAppMain真正切换首页、文件管理、分享、回收站的是 app-main/index.vue 里的子路由出口:<router-view :key="$route.fullPath" />。用户在主页面点击左侧「全部文件」时,触发 navbar/index.vue 里的<a @click="handleChange('Files')">,然后再执行router.push({ name: 'Files', query: {} })。首页 /→ 点击左侧「全部文件」 →handleChange('Files')→router.push({ name: 'Files' })→ URL 变成/files→PanAppMain渲染Files.vue分类入口也是跳到同一个文件管理页,只是带 query: - 图片:
/files?type=image - 文档:
/files?type=document - 视频:
/files?type=video - 音乐:
/files?type=music全部文件、图片、文档、视频、音乐等分类共用同一个 Files 页面,通过路由参数type来区分展示不同类型的文件:当 URL 中没有type参数时,展示所有文件;当type=image时只展示图片,type=document时只展示文档,以此类推。例如,点击左侧【图片】菜单后,浏览器地址栏跳转为/files?type=image,Files 页面读取查询参数query.type的值为image,随后向后端请求只筛选图片类型的文件数据并渲染展示。
文件列表数据由 Pinia 管理,核心 store 是 stores/file.js 。它维护:
parentId:当前目录 IDcurrentFolderId:上传时使用的当前目录 IDfileTypes:文件类型筛选files:当前表格文件列表breadcrumbs:面包屑路径 进入/files后,Files.vue会监听路由 query 和用户根目录 ID:
watch([() => route.query.type, () => userStore.rootFileId], ...)
然后调用:
fileStore.loadFiles(rootFileId, fileTypes)
最终由 stores/file.js 请求后端文件列表和面包屑。
前后端接口对应
前端文件接口统一在 api/file.js
baseURL: '/api/v1/files'
主要接口:
- 文件列表:
GET /api/v1/files/folders-files - 面包屑:
GET /api/v1/files/file/breadcrumbs - 新建文件夹:
POST /api/v1/files/folder - 重命名:
PUT /api/v1/files/file - 删除:
DELETE /api/v1/files/file - 搜索:
POST /api/v1/files/file/search - 下载:
GET /api/v1/files/file/download - 预览:
GET /api/v1/files/file/preview
后端对应控制器是 UserFileController.java 类上路径:
@RequestMapping("/api/v1/files")
文件列表接口在 UserFileController.java
@GetMapping("/folders-files")
整体一句话:主页面并不是直接包含文件管理,而是通过 Layout.vue 中间的子路由出口切换;点击左侧「全部文件」后,路由从 / 跳到 /files,于是 Files.vue 被渲染,并通过 Pinia store 调 /api/v1/files/folders-files 加载文件列表。
4.1.文件列表与目录导航
1.1 当前前端入口
1.进入文件页
当前文件页位于:
disk-by-cursor/src/views/Files.vue
文件页的目录状态统一由:
disk-by-cursor/src/stores/file.js
路由配置在:
- `disk-by-cursor/src/router/index.js 文件页对应路由:
{
path: 'files',
name: 'Files',
component: () => import('@/views/Files.vue')
}
?type=xxx 是查询参数,不是路径的一部分,不需要额外配置路由。? 后面的东西叫查询参数,不改路由配置,同一个 path: 'files' 就能接住所有情况。页面里用 route.query.type 判断该展示什么类型的文件。
所以访问:
/files
/files?type=image
/files?type=document
/files?type=video
/files?type=music
都会进入同一个 Files.vue,区别只是 route.query.type 不同。
页面创建后,Files.vue 会实例化两个全局 store:
- `const fileStore = useFileStore()
- `const userStore = useUserStore()
userStore.rootFileId:当前用户的根目录 IDfileStore:维护当前目录、文件列表、面包屑、文件类型筛选
2.前端根据路由参数决定查什么文件
Files.vue 里有一个路由监听:
// TODO 路由驱动数据加载(整个页面的灵魂)
// 监听两个依赖:路由上的 type 筛选参数、用户根目录ID
// 点击侧边栏「图片」 → handleChange 跳转路由 /files?type=image// → 这里 watch 监听到 type 变化 → 调用仓库加载图片类型文件 → 表格自动渲染新数据 → 侧边栏同步高亮。
// watch(
// 要监听的数据, // 第1个参数:侦听源
// 回调函数, // 第2个参数:变化时执行的操作
// 配置选项 // 第3个参数:额外配置
// )
watch(
// 用数组包裹多个侦听源
// 每个元素都是箭头函数 () => xxx,这叫 getter 函数
// Vue 会自动追踪这些函数返回的响应式数据
// 当 route.query.type 或 userStore.rootFileId 任一发生变化,就会触发回调
// / 侦听源数组:2个源,顺序是 [type参数, 根目录ID]
[() => route.query.type, () => userStore.rootFileId],
// 回调参数数组:2个值,顺序和上面严格对应
// 第 1 个侦听源是 route.query.type → 回调数组第 1 位 newType 就是它的最新值
// 第 2 个侦听源是 userStore.rootFileId → 回调数组第 2 位 rootFileId 就是它的最新值
async ([newType, rootFileId]) => {
// 根目录ID还没拿到(用户未登录/登录信息未加载),直接退出
if (!rootFileId) return
// 切换分类/目录时,先重置搜索状态,避免搜索结果和新目录混淆
isSearchMode.value = false
searchKeyword.value = ''
loading.value = true
try {
// 先判断 newType 有没有值(地址栏有没有带 type 参数);
// 有值:去 typeMap 映射表里查对应的后端编码,比如 'image' → '7';
// 后面加 || '-1' 是兜底:如果用户手动改地址栏输入了非法值(比如 ?type=abc),typeMap[newType] 会是 undefined,这时候兜底为 '-1'(全部文件),防止接口参数报错;
// 没值:直接取 typeMap.all(值也是 -1,代表全部类型)。
// 根据路由 type,从映射表拿到后端需要的文件类型编码
const fileTypes = newType ? typeMap[newType] || '-1' : typeMap.all
// 调用仓库的统一加载方法,拉取文件列表,同时更新面包屑
await fileStore.loadFiles(rootFileId, fileTypes)
// 切换目录后清空选中,防止带着上一页的选中做批量操作
selectedFiles.value = []
} finally {
// 无论成功失败,都关闭加载遮罩,避免页面卡死
loading.value = false
}
},
{ immediate: true }
// immediate: 页面一创建就立刻执行一次
// 作用:刷新页面、外链跳转进来,都能自动加载对应数据,不用等手动点击
)
它监听两个东西:
route.query.type:侧边栏文件类型,比如imageuserStore.rootFileId:用户根目录 ID
immediate: true 表示页面一创建就立即执行一次,所以进入文件页后不用用户再点按钮,会自动加载列表。
类型映射关系:
const typeMap = {
all: '-1',
image: '7',
document: '3,4,5,6,10,11,12',
video: '9',
music: '8',
other: '1,2'
}
例如:
/files -> fileTypes = -1
/files?type=image -> fileTypes = 7
/files?type=music -> fileTypes = 8
作用是把前端语义化分类转换成后端识别的数字类型编码。
3. Pinia Store 统一管理当前目录状态
文件状态在:
- `disk-by-cursor/src/stores/file.js
核心状态:
const parentId = ref('')
const currentFolderId = ref('')
const fileTypes = ref('-1')
const files = ref([])
const breadcrumbs = ref([])
const latestLoadSeq = ref(0)
含义:
parentId:当前正在查询的目录 ID,也就是“查谁下面的文件”currentFolderId:当前用户所在目录 ID,上传文件时会用到fileTypes:当前文件类型过滤条件files:当前表格要展示的文件列表breadcrumbs:当前目录的面包屑路径latestLoadSeq:请求序号,用来防止旧请求覆盖新请求
加载入口是:
async function loadFiles(newParentId, newFileTypes) {
const currentSeq = ++latestLoadSeq.value
if (typeof newFileTypes !== 'undefined') {
fileTypes.value = newFileTypes
}
if (typeof newParentId !== 'undefined') {
parentId.value = newParentId
currentFolderId.value = newParentId
}
const res = await getFileList({
parentId: parentId.value,
fileTypes: fileTypes.value
})
if (currentSeq !== latestLoadSeq.value) return
files.value = res.data || []
if (parentId.value) {
const bcRes = await getBreadcrumbs({ fileId: parentId.value })
if (currentSeq !== latestLoadSeq.value) return
breadcrumbs.value = bcRes.data || []
} else {
breadcrumbs.value = []
}
}
这里的核心原理是:页面不直接维护文件列表,而是把目录状态统一交给 fileStore。这样上传完成、切换目录、搜索清空、面包屑跳转,都可以复用同一套加载逻辑。
latestLoadSeq 的作用是处理并发请求乱序。例如用户快速点击多个目录:
请求 A:进入目录 1
请求 B:进入目录 2
如果 A 比 B 后返回,理论上它会覆盖 B 的结果。latestLoadSeq 可以判断当前返回的是不是最新请求,不是最新就直接丢弃。
维护。核心状态包括:
parentIdcurrentFolderIdfileTypesfilesbreadcrumbs
当前页面打开后,会先通过用户信息里的 rootFileId 进入根目录,再根据当前侧边栏类型筛选加载文件列表。
1.2 文件列表接口
当前文件列表接口为:
GET /api/v1/files/folders-files
请求参数包括:
parentIdfileTypes
1.后端 Controller 接收请求
@GetMapping("/folders-files")
public Result<List<UserFileVO>> list(
@RequestParam(value = "parentId", required = false) String parentId,
@RequestParam(value = "fileTypes", required = false, defaultValue = FileConstant.ALL_FILE_TYPE) String fileTypes
) {
、、、
return Result.success(userFileVOList);
}
后端处理重点
- 前端传的是加密
parentId,后端用IdUtil.decrypt(parentId)解密成数据库 Long 主键。 fileTypes = -1表示全部类型,不加类型过滤。fileTypes != -1时,按逗号拆成数组,例如"3,4,5"转成[3, 4, 5]。UserIdUtil.get()从登录上下文拿当前用户 ID,保证只能查自己的文件。用当前登录用户userId、parentId、deleted = 0、fileTypes组装查询条件。deleted = 0只查未删除的文件。从user_file表查询当前目录下的有效记录。- 查询数据库实体
UserFileDO。 - 转换成前端展示对象
UserFileVO。 - 返回统一结果
Result.success(...)。 这里的安全设计是:前端看不到真实数据库 ID,只拿加密字符串;后端每次操作都用当前登录用户 ID 参与查询,避免越权访问别人的文件。
当前 UserFileVO 里最关键的字段包括:
id:当前文件或文件夹 ID,加密字符串parentId:父目录 ID,加密字符串filename:展示名称fileSizeDesc:可读文件大小,比如12.3 MBfolderFlag:是否文件夹fileType:文件类型编码updateTime:更新时间**其中id和parentId返回给前端时都是加密字符串。** 前端不会直接接触数据库真实 ID,后续打开目录、下载、删除、重命名时,也继续把加密id` 传回后端,由后端解密和校验权限。
2. 前端接收结果并渲染表格
接口返回后,fileStore.loadFiles() 执行:
files.value = res.data || [] 而Files.vue` 表格绑定的是:<el-table :data="fileStore.files"> 所以一旦files.value` 更新,Vue 响应式系统会自动触发表格重新渲染。
表格主要展示:
<el-table-column label="文件名" prop="filename" />
<el-table-column prop="fileSizeDesc" label="大小" />
<el-table-column prop="updateTime" label="修改日期" />
文件名列里还会根据 fileType 显示不同图标:
function getFileFontElement(fileType) {
const fileTypeMap = {
0: 'fa fa-folder-o',
5: 'fa fa-file-pdf-o',
7: 'fa fa-file-image-o',
8: 'fa fa-file-audio-o',
9: 'fa fa-file-video-o'
}
return fileTypeMap[fileType] || 'fa fa-file'
}
3. 面包屑加载与显示
加载完文件列表后,如果当前 parentId 存在,会继续请求:
- `getBreadcrumbs({ fileId: parentId.value })
前端接口:
- `GET /api/v1/files/file/breadcrumbs?fileId=xxx
后端接口:
@GetMapping("/file/breadcrumbs")
public Result<List<BreadcrumbVO>> getBreadcrumbs(String fileId)
作用是根据当前目录 ID 查出从根目录到当前目录的路径。
前端渲染:
<el-breadcrumb>
<el-breadcrumb-item @click="navigateToFolder('')">
根目录
</el-breadcrumb-item>
<el-breadcrumb-item
v-for="item in fileStore.breadcrumbs"
@click="navigateToFolder(item.id)"
>
{{ item.name }}
</el-breadcrumb-item>
</el-breadcrumb>
所以用户可以点击面包屑快速回到上级目录或根目录。
1.3 目录切换与点击行为
fileStore.files 是 Pinia 仓库里的文件数组,里面装着当前目录下所有的文件 / 文件夹对象,每一个对象就是一条完整的文件数据(包含 id、文件名、类型、大小、时间等所有字段);
这个数组就是从后端接口拿回来的 UserFileVO 列表,数组里有多少条数据,表格就会渲染多少行。
你可以把它理解成:
fileStore.files = [文件1, 文件2, 文件3, ...]表格拿到这个数组,自动循环,一条数据对应一行。
el-table 是 Element Plus 封装好的组件,它内部会自动遍历 :data 绑定的数组,每循环一条数据,就生成一行表格。
循环到第 1 条 → 生成第 1 行 → 这一行的数据就是数组里的第 1 个文件对象
循环到第 2 条 → 生成第 2 行 → 这一行的数据就是数组里的第 2 个文件对象
…… 以此类推
现在表格内部知道每一行对应什么数据了,写在 <el-table-column> 里的自定义模板,默认拿不到这个数据。
所以 Element Plus 提供了默认插槽(#default),把当前行的数据主动传给你,这个传出来的数据对象,默认名字就叫 row。
1. 点击文件夹进入下一级目录 文件名点击入口:
- `<div class=“file-name-content” @click=“clickFilename(row)”>
逻辑:
function clickFilename(file) {
if (file?.folderFlag) {
openFolder(file)
return
}
if (supportsAi(file)) {
openAiDrawer(file, 'insight')
return
}
continueFileAction(file)
}
如果是文件夹,进入:
async function openFolder(folder) {
const folderId = getFileId(folder)
await fileStore.loadFiles(folderId, fileStore.fileTypes)
}
这里传入的 folderId 是当前点击文件夹的加密 ID。
openFolder 本身没有“打开一个新页面”,也没有手动操作 DOM。它所谓的“打开文件夹”,本质就是:
把当前目录 ID 改成被点击文件夹的 ID
-> 重新请求这个目录下的文件列表
-> 更新 fileStore.files
-> Vue 自动重新渲染表格
核心在:
async function loadFiles(newParentId, newFileTypes) {
const currentSeq = ++latestLoadSeq.value
if (typeof newFileTypes !== 'undefined') {
fileTypes.value = newFileTypes
}
if (typeof newParentId !== 'undefined') {
parentId.value = newParentId
currentFolderId.value = newParentId
}
const res = await getFileList({
parentId: parentId.value,
fileTypes: fileTypes.value
})
files.value = res.data || []
const bcRes = await getBreadcrumbs({ fileId: parentId.value })
breadcrumbs.value = bcRes.data || []
}
这里发生了三件事:
parentId.value = folderId表示当前要查询的目录变成了你点击的那个文件夹。- 调接口:
getFileList({
parentId: folderId,
fileTypes: fileStore.fileTypes
})
也就是请求: GET /api/v1/files/folders-files?parentId=当前文件夹ID&fileTypes=当前筛选类型 后端收到 parentId 后,就查询:parent_id = 当前文件夹ID 的文件。 3. 更新:files.value = res.data || []
而页面表格绑定的是:
<el-table :data="fileStore.files">
所以 fileStore.files 一变,表格就自动刷新了。
这就是你没看到“重新显示代码”的原因:Vue 的响应式渲染帮你做了。
可以把它理解成:
不是 openFolder 主动把新文件一行行塞进表格
而是 openFolder 改变数据源 fileStore.files
表格一直盯着这个数据源
数据源变了,表格自然重新显示
点击文件夹的入口是:
<div class="file-name-content" @click="clickFilename(row)">
然后:
function clickFilename(file) {
if (file?.folderFlag) {
openFolder(file)
return
}
}
完整链路就是:
点击文件夹名称
-> clickFilename(row)
-> 判断 row.folderFlag 是 true
-> openFolder(row)
-> 取出 row.id
-> fileStore.loadFiles(row.id, 当前 fileTypes)
-> 后端查询 parent_id = row.id 的文件
-> 返回子文件列表
-> fileStore.files = 新列表
-> el-table 因为 :data="fileStore.files" 自动重渲染
所以“打开文件夹”的本质不是路由跳转,而是“换 parentId 后重新加载列表”。这也是网盘、文件管理器里很常见的做法。
流程:
openFolder
-> fileStore.loadFiles(folderId, fileTypes)
-> GET /api/v1/files/folders-files?parentId=folderId&fileTypes=...
-> 后端解密 parentId
-> 查询该目录下文件
-> 返回 UserFileVO
-> files 更新
-> 表格刷新
-> breadcrumbs 更新
-> 面包屑刷新
这就是目录切换的核心闭环。
2. 点击普通文件的处理 如果不是文件夹,先判断是否支持 AI:
function supportsAi(file) {
return !file?.folderFlag && AI_SUPPORTED_FILE_TYPES.has(Number(file?.fileType))
}
支持 AI 的类型:
new Set([3, 4, 5, 6, 10, 11, 12])
如果支持,会打开:
openAiDrawer(file, 'insight')
也就是智能分析抽屉。
如果不走 AI,则进入普通文件行为:
function continueFileAction(file) {
if (canInlinePreview(file?.fileType)) {
previewFile(file)
return
}
downloadFile(file)
}
可内联预览类型:
[5, 6, 7, 8, 9, 11, 12]
例如:
- PDF、图片、音频、视频、文本:优先预览
- 不支持预览的文件:直接下载
当前页面中:
- 点击文件夹名称,会调用
openFolder(...)进入下一级目录。 - 点击普通文件时,如果文件类型支持内联预览,则直接预览;否则直接下载。
- 如果文件类型属于当前 AI 支持范围,则点击文件名会优先打开
AiFileDrawer的insight模式,显示智能的摘要与标签。 因此当前"点击文件名"的行为会按文件夹、AI 文件、普通文件分别处理。
4.2 新建文件夹
2.1 前端页面行为
入口在 Files.vue 的工具栏按钮:
<el-button class="create-folder-btn" @click="createFolder">
新建文件夹
</el-button>
点击后进入前端函数:
async function createFolder() {
const { value: folderName } = await ElMessageBox.prompt('请输入文件夹名称', '新建文件夹', {
inputValidator: (value) => {
if (!value) return '文件夹名称不能为空'
if (value.length > 255) return '文件夹名称不能超过 255 个字符'
if (/[<>:"/\\|?*]/.test(value)) {
return '文件夹名称不能包含以下字符: < > : " / \\ | ? *'
}
return true
}
})
await createFolderAPI({
parentId: fileStore.parentId,
folderName
})
ElMessage.success('文件夹创建成功')
await refreshCurrentFolder()
}
前端做了三件事:
- 弹窗拿用户输入的文件夹名。
- 做本地校验:非空、长度、非法字符。
- 调接口创建成功后,刷新当前目录。
接口封装在 api/file.js:
export function createFolder(data) {
return fileRequest({
url: '/folder',
method: 'post',
data
})
}
最终请求是:
POST /api/v1/files/folder
Content-Type: application/json
{
"parentId": "当前目录加密ID",
"folderName": "新建文件夹"
}
这里的 parentId 来自:fileStore.parentId 。它表示“当前正在看的目录”。所以新建文件夹不是固定建在根目录,而是建在当前目录下面。
@PostMapping("/folder")
// @Validated 开启 VO 参数校验。
//在 CreateFolderParamVO 实体类上的注解(@NotBlank、长度校验、非法字符校验)会生效,
// 如果校验不通过直接抛出异常,不会往下执行业务代码,返回错误信息给前端。
public Result<String> createFolder(@Validated @RequestBody CreateFolderParamVO createFolder) {
// CreateFolderParamVO createFolder VO(视图层对象),专门用来接收前端的 JSON 参数,内部属性:
// parentId:父文件夹 id// folderName:文件夹名字
CreateFolderContext context = fileConvertor.createFolderParamToCreateFolderContext(createFolder);
Long fileId = userFileService.createFolder(context);
// IdUtil.encrypt(fileId):对数据库原生 Long 型 id 做加密。
// 数据库存明文数字 id;
// 前后端交互一律使用加密后的字符串 ID,避免 id 泄露被爬虫遍历、越权访问。
// 包装成统一返回体 Result.success,返回给前端。
return Result.success(IdUtil.encrypt(fileId));
}
2.2 后端页面行为
后端 Controller:
@PostMapping("/folder")
public Result<String> createFolder(@Validated @RequestBody CreateFolderParamVO createFolder) {
CreateFolderContext context = fileConvertor.createFolderParamToCreateFolderContext(createFolder);
Long fileId = userFileService.createFolder(context);
return Result.success(IdUtil.encrypt(fileId));
}
CreateFolderParamVO 只校验:
@NotBlank
private String parentId;
@NotBlank
private String folderName;
也就是说,后端只保证字段非空,没有重复做 < > : " / \ | ? * 过滤。这个过滤目前主要依赖前端。
转换器会做两件关键事:
@Mapping(target = "parentId", expression = "java(IdUtil.decrypt(createFolderParam.getParentId()))")
@Mapping(target = "userId", expression = "java(UserIdUtil.get())")
CreateFolderContext createFolderParamToCreateFolderContext(...)
也就是:
这个方法的作用是:将请求参数对象 CreateFolderParam 转换为业务层使用的上下文对象 CreateFolderContext,转换过程中:
| 源字段 | 目标字段 | 处理方式 |
|---|---|---|
createFolderParam.getParentId()(加密) |
parentId |
解密后赋值 |
| (无源字段) | userId |
从工具类获取当前用户ID |
加密 parentId -> 解密成数据库 Long
当前登录用户 -> 填入 userId
后端创建实体 服务层:
@Override
public Long createFolder(CreateFolderContext context) {
// 1. 上下文参数 → 组装数据库实体 UserFileDO UserFileDO entity = assembleUserFolder(context);
// 2. MyBatis-Plus 自带 save() 方法,执行insert插入数据库
if (!save((entity))) {
// 插入失败(数据库报错/影响行数0),抛出系统自定义异常
throw new SystemException("保存文件信息失败");
}
// 插入成功,返回自生成的文件夹唯一ID,给上层Facade封装进响应data
// 插入数据库后 MyBatis‑Plus 会把主键回填到 entity 的 id 属性,controller 拿到 id 后再进行 ID 加密,返回前端。
return entity.getId();
}
真正组装文件夹记录:
private UserFileDO assembleUserFolder(CreateFolderContext context) {
// 新建数据库实体对象,对应user_file数据表的一条记录
UserFileDO entity = new UserFileDO();
entity.setId(IdUtil.get()); // 生成雪花ID/自定义ID,作为这条文件夹记录的主键
entity.setUserId(context.getUserId()); // 当前操作人用户ID,归属人ID
entity.setParentId(context.getParentId()); // 父文件夹ID,标记该文件夹挂载在哪个目录下
entity.setRealFileId(null); // realFileId为实际文件存储记录ID;文件夹不存在实际文件,赋值null
entity.setFilename(context.getFolderName()); // 文件夹名称,对应前端传入的folderName
entity.setFolderFlag(FolderFlagEnum.YES.getCode()); // 标记该条记录是文件夹(区分普通文件)
entity.setFileSizeDesc(null); // 文件夹无文件大小,置空,只有文件才会记录大小
entity.setFileType(null); // 文件类型只针对文档、图片等文件,文件夹不需要文件类型
entity.setDeleted(DeleteEnum.NO.getCode()); // 逻辑删除标识:0‑未删除、1‑已删除,默认不删除
entity.setCreateUser(context.getUserId()); // 创建人id
entity.setUpdateUser(context.getUserId()); // 修改人id,新建时创建人和修改人为同一人
// 核心方法:处理同级目录下文件夹重名问题(自动重命名或者直接抛异常)
handleDuplicateFilename(entity);
return entity;
}
这说明文件夹只存在于 user_file 表中。
关键字段含义:
id 新文件夹自己的 ID
user_id 当前用户 ID
parent_id 当前目录 ID
real_file_id null,文件夹没有真实物理文件
filename 文件夹名
folder_flag 1,表示文件夹
file_size_desc null,文件夹没有文件大小
file_type null,文件夹没有文件类型
deleted 0,正常状态
创建成功后,返回新文件夹 ID,不过前端当前没有直接拿这个 ID 去追加列表,而是重新刷新当前目录。
前端为什么能显示新文件夹
创建成功后执行:
await refreshCurrentFolder()
刷新逻辑:
async function refreshCurrentFolder() {
const targetFolderId = fileStore.parentId || userStore.rootFileId
await fileStore.loadFiles(targetFolderId, fileStore.fileTypes)
selectedFiles.value = []
}
也就是:
重新请求当前目录文件列表
-> 后端查 user_file
-> 刚插入的新文件夹也被查出来
-> fileStore.files = 最新列表
-> el-table 自动重新渲染
所以这里不是前端手动把新文件夹 push 到表格里,而是创建成功后重新拉一遍列表。
4.3 同名冲突规则
当前项目里,同名冲突的真实规则需要单独说明,因为不同操作并不完全一致。
3.1 自动改名的场景
当前项目的同名处理分两类:自动改名和直接报错。 自动改名入口是:
- `UserFIleServiceImpl.java
private void handleDuplicateFilename(UserFileDO entity) {
}
它被这些流程调用:
创建文件夹
普通上传完成后保存 user_file
秒传命中后保存 user_file
分片合并完成后保存 user_file
文件转移
文件复制
创建文件夹里调用:
handleDuplicateFilename(entity);
上传、秒传、分片合并最终都会走:
saveUserFile(...)
而 saveUserFile() 内部也调用:
handleDuplicateFilename(entity);
转移时:
record.setParentId(context.getTargetParentId());
handleDuplicateFilename(record);
复制时:
record.setParentId(targetParentId);
record.setId(newFileId);
handleDuplicateFilename(record);
自动改名原理 核心逻辑:
private void handleDuplicateFilename(UserFileDO entity) {
// 取出原始名称
String filename = entity.getFilename();
// 定义变量:不带后缀的文件名、文件的后缀/扩展名
String newFilenameWithoutSuffix, newFilenameSuffix;
// 查找最后一个小数点 "." 的下标,用来分割文件名和后缀(例:test.txt,小数点用来拆分 test 和 .txt)
int newFilenamePointPosition = filename.lastIndexOf(BaseConstant.POINT_STR);
// -1:代表字符串里不存在小数点,说明是文件夹或者无后缀文件
if (newFilenamePointPosition == BaseConstant.MINUS_ONE_INT) {
newFilenameWithoutSuffix = filename; // 主名称 = 原名称
newFilenameSuffix = StringUtils.EMPTY; // 后缀为空
} else {
// 存在小数点:截取小数点前面的文字作为主名称
newFilenameWithoutSuffix = filename.substring(BaseConstant.ZERO_INT, newFilenamePointPosition);
// 后缀就是小数点及后面的内容,如 .txt、.png
newFilenameSuffix = filename.replace(newFilenameWithoutSuffix, StringUtils.EMPTY);
}
// 查询:同一父目录、同一用户下,所有以该主名称开头的已存在记录
List<UserFileDO> existRecords = getDuplicateFilename(entity, newFilenameWithoutSuffix);
// 该目录下没有重名文件/文件夹,直接结束方法,名称保持原样
if (CollectionUtils.isEmpty(existRecords)) {
return;
}
// 将数据库查出来的记录,只提取文件名称,转为字符串集合,方便后续比对
List<String> existFilenames = existRecords.stream()
.map(UserFileDO::getFilename)
.collect(Collectors.toList());
int count = 1;
String newFilename;
// do‑while循环:不断拼接新名称,只要名称已存在就继续自增count
do {
// 拼接成:主名称+(数字)+后缀,示例:文档(1)
newFilename = assembleNewFilename(newFilenameWithoutSuffix, count, newFilenameSuffix);
count++;
} while (existFilenames.contains(newFilename));
// 将最终不重复的新名称回写到DO实体,后续插入数据库用新名字
entity.setFilename(newFilename);
}
它先把文件名拆成:
文档.pdf
-> 主体:文档
-> 后缀:.pdf
然后查当前目录下是否已有同类同名前缀文件:
queryWrapper.eq("parent_id", entity.getParentId());
queryWrapper.eq("folder_flag", entity.getFolderFlag());
queryWrapper.eq("user_id", entity.getUserId());
queryWrapper.eq("deleted", 0);
queryWrapper.likeRight("filename", newFilenameWithoutSuffix);
注意这里还比较了 folder_flag。也就是说:同目录下,文件夹和普通文件的重名判断是分开的。
如果冲突,就拼新名字:
private String assembleNewFilename(String name, int count, String suffix) {
return name + "(" + count + ")" + suffix;
}
例子:
新建:资料
已有:资料
结果:资料(1)
上传:文档.pdf
已有:文档.pdf
结果:文档(1).pdf
再上传:文档.pdf
已有:文档.pdf、文档(1).pdf
结果:文档(2).pdf
这里用的是全角中文括号:(1)。不是英文括号:(1)
4.4 重命名
文件重命名
前端入口是文件行里的重命名按钮:
<el-button icon="Edit" @click.stop="renameFile(row)" />
进入前端函数:
async function renameFile(file) {
const { value: newName } = await ElMessageBox.prompt('请输入新的文件名', '重命名', {
inputValue: file?.filename || '',
inputValidator: (value) => {
if (!value) return '文件名不能为空'
if (value.length > 255) return '文件名不能超过 255 个字符'
return true
}
})
await renameFileAPI({
fileId: String(getFileId(file)),
newFilename: newName
})
ElMessage.success('文件重命名成功')
await refreshCurrentFolder()
}
请求接口:
export function renameFile(data) {
return fileRequest({
url: '/file',
method: 'put',
data
})
}
最终请求:
PUT /api/v1/files/file
Content-Type: application/json
{
"fileId": "当前文件加密ID",
"newFilename": "新的文件名"
}
后端重命名入口
Controller:
@PutMapping("/file")
public Result updateFilename(@Validated @RequestBody UpdateFilenameParamVO updateFilenameParam) {
UpdateFilenameContext context = fileConvertor.updateFilenameParamToUpdateFilenameContext(updateFilenameParam);
userFileService.updateFilename(context);
return Result.success();
}
参数只校验:
@NotBlank
private String fileId;
@NotBlank
private String newFilename;
转换器做:
fileId 解密成 Long
userId 从登录上下文获取
后端重命名校验
服务层:
public void updateFilename(UpdateFilenameContext context) {
checkUpdateFilenameCondition(context);
UserFileDO entity = context.getEntity();
entity.setFilename(context.getNewFilename());
if (!updateById(entity)) {
throw new FileException(FILE_RENAME_ERROR);
}
}
校验逻辑:
private void checkUpdateFilenameCondition(UpdateFilenameContext context) {
//entity:从数据库查出来的旧数据(文件原本的信息:原来的名字、归属人、父目录)
//context:本次前端提交的新操作信息(解密后的文件 ID、当前登录用户、用户输入的新文件名)
// context 里的 fileId:前端传加密 ID,后端解密得到,代表用户想要修改哪一个文件
// 拿着这个 ID 去数据库查询,得到 entity:这个文件在数据库里真实存在的一行数据
// 获取待修改文件ID(解密后的明文主键id)
Long fileId = context.getFileId();
// 根据ID查询数据库文件记录
UserFileDO entity = getById(fileId);
// 校验1:文件不存在
if (EmptyUtil.isEmpty(entity)) {
throw new FileException(FILE_NOT_EXIT);
}
// 校验2:越权校验,只能修改自己上传/创建的文件
if (!Objects.equals(entity.getUserId(), context.getUserId())) {
throw new FileException("abc", FILE_NOT_CUR_USER);
}
// 校验3:新名称和原有名称完全一样,无需修改,直接报错
if (Objects.equals(entity.getFilename(), context.getNewFilename())) {
throw new FileException(FILE_NEW_NAME_EQUALS);
}
// 校验4:同级目录下不能存在同名文件/文件夹
// 查询逻辑:在同一个父目录下,有没有其他文件叫这个新名字
QueryWrapper queryWrapper = new QueryWrapper<>();
// 同一父目录 entity.getParentId ():从数据库实体拿到当前文件所在的文件夹 ID(它现在在哪一级目录)
queryWrapper.eq("parent_id", entity.getParentId());
// 精准匹配新文件名
queryWrapper.eq("filename", context.getNewFilename());
// 统计满足条件的记录数量
long count = count(queryWrapper);
// 存在同名记录,抛出名称已存在异常
if (count > 0) {
throw new FileException(FILE_NEW_NAME_EXIST);
}
// 把查询出来的原始文件实体存入上下文,供上层更新使用
context.setEntity(entity);
}
重命名和创建/上传不一样:它不会自动改名。
冲突时直接报错:
文件不存在 -> FILE_NOT_EXIT
不是当前用户文件 -> FILE_NOT_CUR_USER
新旧名称相同 -> FILE_NEW_NAME_EQUALS
同目录已有同名 -> FILE_NEW_NAME_EXIST
校验通过后,只更新:
user_file.filename
不会更新:
file.filename
file.real_path
物理存储文件名
原因是这个系统区分了两张概念:
file 表:真实物理文件/存储记录
user_file 表:用户网盘目录视图
重命名只是改“用户看到的名字”,不改底层真实文件。这样同一个真实文件可以被多个用户、多个目录引用,每个引用记录可以有自己的展示名。 当前"重命名"接口不会自动加编号,而是会做显式校验:
- 新旧名称不能相同
- 同目录下不能已有同名记录
如果冲突,会直接返回业务错误,而不是自动改名。
4.5 删除实现
5.1 前端页面行为
当前 Files.vue 中删除分两种:
- 单个删除:点击某一行的删除按钮
- 批量删除:勾选多行后点击“批量删除”
单删入口:
<!-- 删除按钮 -->
<el-tooltip effect="light" content="删除" placement="top">
<el-button icon="Delete" type="danger" size="small" circle @click.stop="deleteFile(row)" />
</el-tooltip>
async function deleteFile(file) {
await ElMessageBox.confirm(`确定删除 "${file.filename}" 吗?`, '提示')
await deleteFilesAPI({
fileIds: [String(getFileId(file))]
})
ElMessage.success('文件删除成功')
await refreshCurrentFolder()
}
批量删除入口:
<!-- v-if 搜索模式才显示清空搜索按钮,退出搜索恢复全部文件,选中文件数量大于0才显示 -->
<el-button v-if="isSearchMode" @click="clearSearch">清空搜索</el-button>
<!-- 批量删除按钮:选中文件数量大于0才显示 -->
<el-button
v-if="selectedFiles.length > 0"
type="danger"
@click="batchDelete"
>
<el-icon><Delete /></el-icon> 批量删除
</el-button>
async function batchDelete() {
// selectedFiles.value:页面勾选的文件数组
// 校验:如果数组长度为0,代表没有勾选任何文件
if (!selectedFiles.value.length) {
ElMessage.warning('请选择要删除的文件')
return // 终止函数,不执行删除逻辑
}
try {
// ElementPlus 确认弹窗,二次确认防止误操作
await ElMessageBox.confirm(
// 弹窗提示文案:展示勾选数量
`确定删除选中的 ${selectedFiles.value.length} 个文件吗?`,
'提示', // 弹窗标题
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning' // 警告样式弹窗
}
)
// map遍历选中文件列表,提取每个文件加密ID,转字符串组成数组传给后端
// 最终得到 ["id1","id2","id3"] 数组,一次性传给后端批量接口。
// .map((file) => ...) JavaScript 数组的 map 方法
// 遍历数组中的每一个元素,对每个元素执行回调函数
// 返回一个新数组,长度与原数组相同
const idList = selectedFiles.value.map((file) => String(getFileId(file)))
// 调用批量删除接口,一次性传递全部id,只发起一次网络请求(批量接口优势,不用循环逐个删)
await deleteFilesAPI({
fileIds: idList
})
// 接口无异常,弹出成功提示
ElMessage.success('文件批量删除成功')
// 刷新当前目录,重新拉取文件列表,删除的条目消失
await refreshCurrentFolder()
} catch (error) {
// 弹窗点取消会抛出固定字符串 cancel,这种情况不提示错误
if (error !== 'cancel') {
// 网络错误、后端业务异常(权限不足、文件不存在)弹出错误信息
ElMessage.error(error?.message || '文件批量删除失败')
}
}
}
两者本质一样:都是把文件 ID 数组传给同一个接口。
接口封装:
export function deleteFiles(data) {
return fileRequest({
url: '/file',
method: 'delete',
data
})
}
最终请求:
DELETE /api/v1/files/file
{
"fileIds": ["加密文件ID1", "加密文件ID2"]
}
删除成功后前端不会手动从 fileStore.files 中移除记录,而是调用:
refreshCurrentFolder()
重新加载当前目录文件列表。后端已逻辑删除的记录不会再查出来,所以页面自然消失。
5.2 后端删除流程
Controller 入口:
@DeleteMapping("/file")
public Result deleteFile(@Validated @RequestBody DeleteFileParamVO deleteFileParam) {
// 1. 对象转换器:前端VO → 业务上下文DeleteUserFileContext,基础封装参数、注入登录用户等信息
DeleteUserFileContext context = fileConvertor.deleteFileParamToDeleteFileContext(deleteFileParam);
// 2. Stream流式处理:对前端传来的加密ID数组做解密、去重
List<Long> fileIdList = deleteFileParam.getFileIds()
// 遍历每一个加密ID字符串
.stream()
// 调用工具解密:加密字符串 → 数据库明文Long主键
.map(IdUtil::decrypt)
// 去重:防止前端重复传同一个ID,避免重复删除
.distinct()
// 收集处理后的明文ID,转为Long集合
.collect(Collectors.toList());
// 3. 将解密、去重后的明文ID列表存入上下文,供Service层使用
context.setFileIdList(fileIdList);
// 4. 调用业务层执行批量删除核心逻辑
userFileService.deleteFile(context);
// 5. 全部逻辑执行完成,返回统一成功响应
return Result.success();
}
这里做了两件关键事:
- 前端传来的加密
fileId被解密成数据库 Long。 distinct()去重,避免重复 ID 导致重复处理。
服务层:
@Override
public void deleteFile(DeleteUserFileContext context) {
// 从上下文取出待删除的明文文件ID列表(已解密、去重)
List<Long> fileIdList = context.getFileIdList();
// MyBatis-Plus 批量根据主键ID查询多条记录
// 查询出所有待删除文件完整数据库实体 UserFileDO List<UserFileDO> userFiles = listByIds(fileIdList);
// 流式处理:提取所有文件的归属用户ID,放入Set自动去重
Set<Long> userIdSet = userFiles.stream()
.map(UserFileDO::getUserId) // 遍历每条文件,取出归属人userId
.collect(Collectors.toSet()); // Set特性:自动剔除重复userId
// 校验1:选中的所有文件必须全部属于同一个用户
// 如果Set长度不等于1,说明勾选的文件分属多个不同用户,禁止删除
if (userIdSet.size() != 1) {
throw new FileException(FILE_DELETE_ERROR);
}
// 获取唯一的文件归属用户ID(Set只有一个元素)
Long creatUser = userIdSet.stream().findFirst().get();
// 校验2:校验当前登录人 == 文件归属人,防止越权删除别人文件
if (!Objects.equals(creatUser, context.getUserId())) {
throw new FileException(FILE_DELETE_ERROR);
}
// 校验全部通过,执行真正的批量删除逻辑
doDeleteFile(context);
// TODO 后续可扩展:发布文件删除事件,用于日志、消息通知、对象存储清理等
}
真实校验流程:
- 根据 ID 批量查
user_file。 - 检查这些记录是否都属于同一个
user_id。 - 检查这个
user_id是否等于当前登录用户。 - 校验通过后执行
doDeleteFile(...)。
需要强调:
当前删除的是 user_file 目录视图记录,不是 file 表里的真实物理文件。
也就是说,删除只是让用户目录里这条记录进入删除状态。真实物理文件元数据、真实文件内容不会在这里直接删除。
另一个容易写错点:
当前 doDeleteFile(...) 只处理传入记录本身,没有在这个删除流程里递归删除文件夹全部子孙节点。
所以从当前实现角度看,删除文件夹时,要特别注意子级记录是否仍然存在,以及回收站/恢复时是否会出现目录断层问题。
4.6 预览与下载
6.1 前端页面行为
文件行里有“预览”和“下载”按钮:
<el-button icon="View" @click.stop="previewFile(row)" />
<el-button icon="Download" @click.stop="downloadFile(row)" />
预览入口:
async function previewFile(file) {
if (file?.folderFlag) {
ElMessage.warning('文件夹暂不支持预览')
return
}
await previewSingleFile(file)
}
下载入口:
async function downloadFile(file) {
if (file?.folderFlag) {
ElMessage.warning('文件夹暂不支持下载')
return
}
await downloadSingleFile(file)
ElMessage.success('文件下载已开始')
}
批量下载:
async function batchDownload() {
const targetFiles = selectedFiles.value.filter((file) => !file.folderFlag)
for (const file of targetFiles) {
await downloadSingleFile(file)
}
}
批量下载只处理普通文件,文件夹会被过滤掉。
responseType: 'blob' 是前端请求后端时,告诉浏览器以二进制大对象(Blob)格式接收响应数据。
responseType: 'blob'= 告诉浏览器:后端返回的是二进制文件(图片/视频/压缩包等),不要当字符串解析,原样保存成文件。
// 文件下载.md
export function downloadFile(params) {
return fileRequest({
url: '/file/download',
method: 'get',
params,
responseType: 'blob'
})
}
// 文件预览
export function previewFile(params) {
return fileRequest({
url: '/file/preview',
method: 'get',
params,
// responseType: 'blob' 是前端请求后端时,告诉浏览器以二进制大对象(Blob)格式接收响应数据。
responseType: 'blob'
})
}
最终接口:
GET /api/v1/files/file/download?fileId=xxx
GET /api/v1/files/file/preview?fileId=xxx
前端用 blob 接收文件流,因为后端返回的不是普通 JSON,而是二进制文件内容。
6.2 后端预览/下载入口
下载 Controller:
@Override
public void download(FileDownloadContext context) {
// 1. 根据解密后的文件主键ID,查询数据库该文件完整记录
UserFileDO record = getById(context.getFileId());
// 2. 权限校验:校验当前登录用户是否有权限操作该文件(文件是否存在、是否属于本人、是否已删除)
checkOperatePermission(record, context.getUserId());
// 3. 判断当前记录是否为文件夹,文件夹不支持单独下载,直接抛出异常
if (isFolder(record)) {
throw new FileException(FOLDER_NOT_DOWNLOAD);
}
// 4. 执行真实下载逻辑:读取OSS文件流、设置下载响应头、流式输出二进制到前端浏览器
doDownload(record, context.getResponse());
}
业务上下文对象
public class FileDownloadContext {
/**
* 文件ID
*/ private Long fileId;
/**
* 请求响应对象
*/
private HttpServletResponse response;
/**
* 当前登录的用户ID
*/ private Long userId;
}
预览 Controller:
@GetMapping("/file/preview")
public void preview(
// 参数校验:fileId不能为空,为空直接抛出参数异常
@NotBlank(message = "文件ID不能为空")
@RequestParam(value = "fileId", required = false)
String fileId,
// 注入原生HttpServletResponse,用于输出文件字节流到前端
HttpServletResponse response
) {
// 构建预览业务上下文载体
FilePreviewContext context = new FilePreviewContext();
// 1. 解密加密文件ID,转为数据库明文Long主键
context.setFileId(IdUtil.decrypt(fileId));
// 2. 把响应对象存入上下文,service层用来写二进制流、设置header
// 图片 image/png、视频 video/mp4、文本 text/plain context.setResponse(response);
// 3. 工具类从登录上下文获取当前操作用户ID,用于权限校验
context.setUserId(UserIdUtil.get());
// 调用业务层执行预览逻辑:权限校验、读取文件二进制、输出到response
userFileService.preview(context);
}
这里做校验:
- 根据
fileId查user_file。 - 文件记录必须存在。
- 文件必须属于当前用户。
- 不能是文件夹。
预览业务上下文载体,这里组装了三个东西:
- fileId:要预览哪条 user_file 记录
- userId:当前登录用户是谁
- response:待会儿把文件内容写回浏览器的通道
public class FilePreviewContext {
/**
* 文件ID
*/ private Long fileId;
/**
* 请求响应对象
*/
private HttpServletResponse response;
/**
* 当前登录的用户ID
*/ private Long userId;
}
两者共同点:
- 解密前端传来的
fileId - 绑定当前登录用户
userId - 把
HttpServletResponse传给服务层,让服务层直接写文件流
HttpServletResponse response 不是前端传来的参数。它是 Spring MVC 自动给你的对象。可以理解成:fileId 是浏览器传来的业务参数,
HttpServletResponse 是服务器用来给浏览器写响应的工具。
后端收到请求后,需要给浏览器一个响应。这个响应包括:
状态码:200 / 404 / 500
响应头:Content-Type、Content-Disposition 等
响应体:JSON 字符串 / 图片二进制 / PDF 二进制 / 视频二进制
HttpServletResponse 就是后端操作“响应”的对象。
你可以用它做这些事:
response.setContentType("application/pdf");
response.setHeader("Content-Disposition", "inline");
response.getOutputStream().write(bytes);
也就是说,HttpServletResponse 是后端拿来“控制返回给浏览器的东西”的对象。普通 JSON 接口里你看不到它,是因为 Spring 帮你封装了。
例如:return Result.success(data);
Spring 会自动帮你:
把 Result 对象转成 JSON
设置 Content-Type: application/json
写入 response 输出流
但是文件预览/下载更特殊,你需要自己控制:
返回的是图片还是 PDF?
是浏览器打开还是下载?
文件内容从哪里读?
怎么写到浏览器?
所以代码里显式拿到了 HttpServletResponse。
文件预览要返回的是图片、PDF、视频、文本这些真实文件内容。 例如你预览一张图片,后端不能返回:
{
"data": "一张图片"
}
它必须把图片的二进制内容直接写给浏览器。浏览器收到后,才能按图片、PDF、视频去展示。所以预览接口返回值是:void 不是 Result。
因为它不是用 return Result.success(...) 返回 JSON,而是直接往 response 里写文件流。
为什么需要“流”
文件可能很大。如果是普通字符串,可以一次性返回:return "hello";
但文件可能是:
10MB 图片
200MB 视频
2GB 压缩包
如果一次性把整个文件读进内存,再返回,很容易占满内存。所以文件传输一般用“流”。 流可以理解成水管:
磁盘里的文件 -> 输入流/读取过程 -> 后端 -> 输出流 -> 浏览器
不是一口气把整桶水搬过去,而是通过管道一点点传。 在这个项目里,最终写流的位置是:
private void realFile2OutputStream(String realPath, HttpServletResponse response) {
ReadFileContext context = new ReadFileContext();
context.setRealPath(realPath);
context.setOutputStream(response.getOutputStream());
storageEngine.realFile(context);
}
重点是:response.getOutputStream() 。这个就是“写给浏览器的输出流”。
storageEngine.realFile(context) 会根据真实文件路径 realPath 读取文件,然后把内容写入这个输出流。
可以理解成:
realPath:文件在服务器磁盘/对象存储里的地址
response.getOutputStream():通向浏览器的水管
storageEngine.realFile(...):把文件内容倒进水管
文件预览接口 接口响应不是 JSON,而是图片 / 视频 / 文档原始二进制字节,需要手动控制响应输出:
- 设置
Content-Type:告诉浏览器这是图片 /pdf/ 视频 / 文本 - 设置文件名、缓存头、跨域头
- 获取输出流
response.getOutputStream(),把文件字节一段一段写给前端 这些操作全部依赖HttpServletResponse对象,Spring 自动封装 JSON 的机制完全不适用。如果把文件字节读到内存 byte [] 再返回数组:- 大文件(几百 MB 视频)会把服务器内存占满,引发内存溢出; - 无法灵活设置响应头(文件类型、下载文件名、缓存策略);
- 而
HttpServletResponse支持流式边读边输出:读一段 OSS 文件,立刻写一段到前端,不用把整个文件加载到内存,适合大文件预览。
下载和预览后端主线基本一致:
前端传 fileId
↓
后端 IdUtil.decrypt(fileId)
↓
查询 user_file 表
↓
校验文件存在、属于当前用户、不是文件夹
↓
通过 user_file.real_file_id 查询 file 表
↓
拿到 file.real_path
↓
设置响应头
↓
storageEngine.realFile(...)
↓
写入 response.getOutputStream()
↓
前端用 Blob 接收
这里要注意,前端传来的 fileId 对应的是 user_file.id,不是 file.id。因为页面展示的是用户目录记录,真正的物理文件记录要通过 user_file.real_file_id 再去 file 表查询。
6.3 后端预览
private void doPreview(UserFileDO record, HttpServletResponse response) {
// 1. 根据用户文件记录中的realFileId,查询底层真实文件存储记录
FileDO realFileRecord = fileService.getById(record.getRealFileId());
// 校验:底层物理文件记录丢失,无法预览
if (EmptyUtil.isEmpty(realFileRecord)) {
throw new SystemException("当前的文件记录不存在");
}
// 2. 获取预览专用MIME类型,为空则兜底通用二进制流
String previewContentType = StringUtils.defaultIfBlank(//第一个参数能用就用,不能用就用第二个凑数,保证永远不会返回 null 或空字符串。
// 优先取数据库预存的预览MIME(image/png / application/pdf / text/plain等)
//文件上传时,系统就识别好文件的真实 MIME 类型(比如 image/png、application/pdf、video/mp4),提前存进数据库;
//预览时直接取出来用,是最准确的类型,浏览器拿到后就能直接渲染显示图片、播放视频、打开 PDF。
realFileRecord.getFilePreviewContentType(),
// 兜底:二进制通用类型
MediaType.APPLICATION_OCTET_STREAM_VALUE
);
// 3. 设置通用响应头:跨域、重置响应、绑定文件MIME类型
addCommonResponseHeader(response, previewContentType);
// 4. 根据文件真实存储路径,流式读取文件并输出到前端浏览器
realFile2OutputStream(realFileRecord.getRealPath(), response);
}
这里开始从“用户视图文件”切到“真实物理文件”。
record 是 user_file:用户看到的文件:名字、目录、用户、real_file_id
realFileRecord 是 file:真实文件:真实路径、大小、后缀、预览类型
通过:record.getRealFileId()找到真实文件记录。 然后设置响应类型:
addCommonResponseHeader(response, previewContentType);
private void addCommonResponseHeader(HttpServletResponse response, String contentTypeValue) {
// 清空response原有内容、状态码、旧头部,避免前面逻辑残留数据干扰文件输出
response.reset();
// 添加跨域允许头部,解决前端浏览器跨域请求报错
HttpUtil.addCorsResponseHeaders(response);
// 手动追加Content-Type响应头
response.addHeader(FileConstant.CONTENT_TYPE_STR, contentTypeValue);
// 设置response主体内容类型,标准写法,和addHeader配套使用
response.setContentType(contentTypeValue);
}
比如:
PDF -> application/pdf
图片 -> image/jpeg
文本 -> text/plain
视频 -> video/mp4
浏览器就是根据 Content-Type 判断怎么展示的。
最后:
realFile2OutputStream(realFileRecord.getRealPath(), response);
把真实文件内容写回浏览器。
为什么预览要设置 Content-Type 览器收到一段二进制内容,本身不知道这是什么。同样是一串二进制:
可能是 PDF
可能是 PNG
可能是 MP4
可能是 TXT
所以后端必须告诉浏览器:Content-Type: application/pdf 或者:Content-Type: image/png 这个项目里预览使用:
realFileRecord.getFilePreviewContentType()
如果没有,就兜底:
application/octet-stream
application/octet-stream 的意思是:
我不知道具体是什么文件,就当通用二进制流处理。
如果是这个类型,浏览器通常不会很好地内联预览,可能会下载或无法展示。
前端 Blob 是什么
后端把文件流写回浏览器后,前端 axios 用:responseType: 'blob' 接收。Blob 是浏览器里的二进制文件对象。可以理解为:
后端返回了一坨文件二进制
浏览器用 Blob 把这坨二进制包起来
前端再决定是打开它,还是下载它
根据文件真实存储路径,读取文件并流式写入HTTP响应输出流,供前端下载/预览 该方法封装文件流式输出逻辑,将文件真实路径和 HTTP 输出流封装到上下文交给统一存储引擎,分段读取本地或 OSS 文件直接推送给前端,避免大文件内存溢出;捕获文件读取、网络 IO 异常并抛出自定义提示,下载、预览功能共用此 IO 逻辑实现代码复用。
private void realFile2OutputStream(String realPath, HttpServletResponse response) {
try {
// 构建文件读取上下文载体
ReadFileContext context = new ReadFileContext();
// 设置文件真实存储路径,存储引擎通过该路径定位文件
context.setRealPath(realPath);
// 获取response底层输出流,存入上下文;存储引擎读取文件后直接写入该流
// response.getOutputStream()
// 获取 HTTP 原生二进制输出流,所有文件字节会直接写入这个流,一路传递到前端浏览器。
// 全程流式读写:读取一块文件,立刻写出一块,不会把整个大文件加载到服务器内存,避免内存溢出 OOM。
context.setOutputStream(response.getOutputStream());
// 调用统一存储引擎,执行文件读取+流式输出逻辑
// 项目分层设计,统一封装文件读取逻辑,屏蔽底层存储差异:
// 如果是本地磁盘存储:引擎内部打开本地文件输入流,循环拷贝到 outputStream;
// 如果是对象存储 OSS(阿里云 / 腾讯云):引擎调用 OSS SDK 流式拉取文件,边拉边输出;
storageEngine.realFile(context);
} catch (IOException e) {
// 打印IO异常堆栈日志,方便排查文件读取、网络流错误
e.printStackTrace();
// 封装系统自定义异常,交给全局异常处理器返回前端提示
throw new SystemException("文件下载失败");
}
}
预览和下载的核心区别在响应头。下载会设置:
Content-Type: application/octet-stream
Content-Disposition: attachment
意思是告诉浏览器:这是通用二进制文件,请作为附件下载。 预览更关注:
Content-Type: image/png / application/pdf / video/mp4 / text/plain
意思是告诉浏览器:这段二进制内容是什么类型,浏览器可以尝试直接打开。
所以预览时不应该强制设置 attachment,否则浏览器可能不会内联展示,而是直接下载。
6.4 前端预览
async function previewSingleFile(file) {
// 1. 提取文件唯一加密ID
const fileId = getFileId(file)
// ID为空直接抛出错误,上层catch捕获提示用户
if (!fileId) {
throw new Error('文件 ID 无效')
}
// 2. 打开空白新标签页 _blank const previewWindow = window.open('', '_blank')
// 浏览器弹窗拦截判断:window.open返回null代表被拦截
if (!previewWindow) {
throw new Error('浏览器拦截了预览窗口,请允许弹窗后重试')
}
try {
// 3. 请求后端预览接口,传入文件加密ID,后端返回文件二进制流(Blob)
const response = await previewFileAPI({ fileId: String(fileId) })
// 解析响应,提取二进制Blob对象;解析失败抛出统一提示文案
const rawBlob = await extractBlobFromResponse(response, '文件预览失败')
// 分支1:文本类文件(txt、md、代码等)自定义页面渲染,不用浏览器原生
if (isTextPreviewType(file?.fileType)) {
// 将二进制Blob转成可读文本(处理中文编码)
const decodedText = await decodeTextBlob(rawBlob)
// 往空白新窗口写入自定义HTML,展示文本内容、文件名
renderTextPreview(previewWindow, decodedText, file?.filename)
// 文本预览逻辑执行完毕,终止函数,不走下方图片/PDF逻辑
return
}
// 分支2:图片/视频/PDF 浏览器原生预览逻辑
// 1. 确定文件标准MIME类型
// 优先用后端流自带的文件类型;为空就根据文件类型码手动匹配对应格式
const previewType = rawBlob.type || getPreviewMimeType(file?.fileType)
// 2. 补全Blob的MIME标识,浏览器才能识别怎么渲染
// 原有Blob自带type就直接复用;没有则包一层新Blob并绑定刚才算出的type
const previewBlob = rawBlob.type ? rawBlob : new Blob([rawBlob], { type: previewType })
// 3. 把内存里的二进制Blob生成浏览器临时本地URL(仅当前页面可用)
const previewUrl = window.URL.createObjectURL(previewBlob)
// 4. 空白预览窗口跳转这个临时链接,浏览器自动用自带工具渲染图片/PDF/视频
previewWindow.location.href = previewUrl
// 定时器1分钟后释放临时Blob内存,避免内存泄漏
setTimeout(() => window.URL.revokeObjectURL(previewUrl), 60000)
} catch (error) {
// 任何接口/解析/渲染异常:关闭空白预览窗口,再把错误抛出给外层捕获提示
previewWindow.close()
throw error
}
}
在真正请求后端之前,前端先执行:const previewWindow = window.open('', '_blank') 也就是先打开一个空白新标签页。 为什么要提前打开?浏览器通常只允许“用户点击事件直接触发的新窗口”。如果你等接口返回后再 window.open()`,浏览器可能认为这是异步弹窗,把它拦截掉。
所以流程是:
用户点击预览
-> 立刻打开空白窗口
-> 再异步请求后端文件流
-> 文件流回来后,把空白窗口改成预览页面
如果新窗口被拦截:
if (!previewWindow) {
throw new Error('浏览器拦截了预览窗口,请允许弹窗后重试')
}
后端返回后,前端调用:
const rawBlob = await extractBlobFromResponse(response, '文件预览失败')
这个函数做两件事: 第一,兼容 axios 返回结构:
const blob = response?.data instanceof Blob ? response.data : response
正常 axios 响应通常是:`response.data才是真正的 Blob。
第二,判断后端是不是返回了错误 JSON。 因为即使前端写了:`responseType: ‘blob’ 如果后端报错,可能返回的是:
{
"success": false,
"message": "文件不存在"
}
但 axios 仍然会把它包成 Blob。 所以前端要判断:
if (blob.type?.includes('application/json')) {
const text = await blob.text()
const result = JSON.parse(text)
throw new Error(result?.message || defaultErrorMessage)
}
作用是:
正常文件流 -> 继续预览
错误 JSON -> 解析错误信息,弹出提示
否则用户可能会预览/下载到一个奇怪的 JSON 文件。
如果是文本类文件:
function isTextPreviewType(fileType) {
return [6, 11, 12].includes(Number(fileType))
}
包括: txt ,源码文件, csv 前端不会直接把 Blob 丢给浏览器,而是自己渲染一个文本预览页。 流程:
const decodedText = await decodeTextBlob(rawBlob)
renderTextPreview(previewWindow, decodedText, file?.filename)
return
先解码:
async function decodeTextBlob(blob) {
const bytes = await blob.arrayBuffer()
try {
return new TextDecoder('utf-8', { fatal: true }).decode(bytes)
} catch (_) {
try {
return new TextDecoder('gb18030').decode(bytes)
} catch (_) {
return new TextDecoder().decode(bytes)
}
}
}
为什么要这么做? 文本文件可能不是 UTF-8 编码,尤其中文旧文件可能是 GBK / GB18030。如果直接按 UTF-8 读,可能乱码。 所以它按顺序尝试:
UTF-8
-> GB18030
-> 浏览器默认解码
然后写入新窗口:
renderTextPreview(previewWindow, decodedText, file?.filename)
内部是:
previewWindow.document.open()
previewWindow.document.write(`完整 HTML`)
previewWindow.document.close()
也就是把空白窗口变成一个自定义 HTML 页面。 文本内容渲染前还会做 HTML 转义:
function escapeHtml(rawText = '') {
return rawText
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''')
}
作用是防止上传的文本里有:
<script>alert(1)</script>
被浏览器当成脚本执行。 所以文本预览的核心是:
Blob 二进制
-> ArrayBuffer
-> TextDecoder 解码成字符串
-> HTML 转义
-> document.write 写入新窗口
图片 / PDF / 视频 / 音频走浏览器原生预览 如果不是文本类文件,就走通用 Blob 预览:
const previewType = rawBlob.type || getPreviewMimeType(file?.fileType)
const previewBlob = rawBlob.type ? rawBlob : new Blob([rawBlob], { type: previewType })
const previewUrl = window.URL.createObjectURL(previewBlob)
previewWindow.location.href = previewUrl
setTimeout(() => window.URL.revokeObjectURL(previewUrl), 60000)
第一步,确定 MIME 类型:
const previewType = rawBlob.type || getPreviewMimeType(file?.fileType)
如果后端响应头有 Content-Type,Blob 自己会带 type。
如果没有,就前端根据 fileType 兜底:
function getPreviewMimeType(fileType) {
const normalized = Number(fileType)
if (normalized === 5) return 'application/pdf'
if ([6, 11, 12].includes(normalized)) return 'text/plain;charset=utf-8'
if (normalized === 7) return 'image/*'
if (normalized === 8) return 'audio/*'
if (normalized === 9) return 'video/*'
return 'application/octet-stream'
}
第二步,补全 Blob 类型:
const previewBlob = rawBlob.type
? rawBlob
: new Blob([rawBlob], { type: previewType })
浏览器需要知道这个 Blob 是 PDF、图片还是视频,才能正确打开。
第三步,创建临时 URL:
const previewUrl = window.URL.createObjectURL(previewBlob)
Blob 本身是内存里的二进制对象,不能直接塞给地址栏。createObjectURL 会生成一个临时地址,例如:
blob:http://localhost:5173/xxxx-xxxx-xxxx
这个地址只在当前浏览器环境里有效。
第四步,让新窗口跳转到这个临时 URL:
previewWindow.location.href = previewUrl
浏览器看到这个地址后,会根据 Blob 的 MIME 类型自动预览:
application/pdf -> 浏览器 PDF 阅读器
image/png -> 图片预览
video/mp4 -> 视频播放器
audio/mpeg -> 音频播放器
第五步,释放内存:
setTimeout(() => window.URL.revokeObjectURL(previewUrl), 60000)
createObjectURL 创建的临时 URL 会占用内存,所以用完要释放。这里延迟 60 秒释放,避免刚跳转就释放导致预览失败。
6.5 后端下载实现
private void doDownload(UserFileDO record, HttpServletResponse response) {
// 1. 根据用户文件记录里的realFileId,查询底层真实文件存储记录
// realFileId 对应真实文件表,存文件物理路径、大小、存储桶等OSS/本地存储信息
FileDO fileRecord = fileService.getById(record.getRealFileId());
// 校验:底层真实文件存储记录不存在(源文件丢失、已清理)
if (EmptyUtil.isEmpty(fileRecord)) {
throw new FileException(FILE_NOT_EXIT);
}
// 2. 添加通用基础响应头
// 默认二进制流类型,浏览器识别为可下载文件
// 通用公共响应头工具方法,内部一般设置:
// 跨域头 Access-Control// 缓存策略
// Content-Type = application/octet-stream 二进制流,通用下载类型
addCommonResponseHeader(response, MediaType.APPLICATION_OCTET_STREAM_VALUE);
// 3. 添加下载专属头部:文件名、编码、文件长度等,触发浏览器下载弹窗
addDownloadAttribute(response, record, fileRecord);
// 4. 根据文件真实存储路径,读取文件并流式写入response输出流给到前端
realFile2OutputStream(fileRecord.getRealPath(), response);
}
核心逻辑:
通过 user_file.real_file_id 找到 file 表真实文件记录
设置 Content-Type = application/octet-stream
设置 Content-Disposition = attachment,告诉浏览器这是下载
根据 file.real_path 读取真实文件
把文件内容写入 response.getOutputStream()
也就是:`file.real_path -> 存储引擎读取 -> response 输出流 -> 浏览器
private void addDownloadAttribute(HttpServletResponse response, UserFileDO record, FileDO realFileRecord) {
// 1. 对中文文件名做URL编码,解决Chrome、Edge、Firefox多浏览器中文乱码问题
String encodedFilename = URLEncoder.encode(record.getFilename(), StandardCharsets.UTF_8)
// URLEncoder会把空格转为+,浏览器识别空格需要替换为标准URL空格编码%20
.replace("+", "%20");
// 2. 设置核心下载头 Content-Disposition
// attachment:强制浏览器下载,不在页面内直接打开 `attachment` 的意思是:浏览器不要直接打开,作为附件下载保存。
// filename:兼容旧浏览器,带编码文件名
// filename*:新标准UTF-8文件名,兼容现代浏览器
response.setHeader(
FileConstant.CONTENT_DISPOSITION_STR,
"attachment;filename=\"" + encodedFilename + "\";filename*=UTF-8''" + encodedFilename
);
// 3. 设置文件总字节长度,浏览器可显示下载进度条
response.setContentLengthLong(Long.parseLong(realFileRecord.getFileSize()));
}
这里从 user_file.real_file_id 找到 file.id,也就是找到真实物理文件记录。
下载响应头主要有两个:
Content-Type: application/octet-stream
Content-Disposition: attachment;filename="xxx"
application/octet-stream 表示通用二进制流。
Content-Disposition: attachment 表示:不要内联打开,作为附件下载。
文件名来自:`record.getFilename()
也就是 user_file.filename,用户界面里看到的名字。
最后:
realFile2OutputStream(fileRecord.getRealPath(), response)
把真实文件写回浏览器:
private void realFile2OutputStream(String realPath, HttpServletResponse response) {
ReadFileContext context = new ReadFileContext();
context.setRealPath(realPath);
context.setOutputStream(response.getOutputStream());
storageEngine.realFile(context);
}
可以理解为:
file.real_path 是服务器里真实文件位置
response.getOutputStream() 是通向浏览器的输出流
storageEngine.realFile(...) 把真实文件内容写进这个输出流
后端写完后,前端拿到 Blob,downloadSingleFile(file) 会把 Blob 转成本地临时 URL,再创建一个隐藏的 <a> 标签模拟点击下载:
前端拿到响应后:
const blob = await extractBlobFromResponse(response, '文件下载失败')
这一步会区分:
正常文件流 -> 返回 Blob
后端错误 JSON -> 解析 message 并抛错
然后触发浏览器下载:
function saveBlobToLocal(blob, filename) {
const url = window.URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = filename
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
window.URL.revokeObjectURL(url)
}
核心逻辑:
Blob 是浏览器内存里的文件对象
createObjectURL 把 Blob 转成临时 URL
a.download 指定下载文件名
link.click() 模拟点击下载
revokeObjectURL 释放临时 URL
完整前端链路:
收到后端文件流
-> axios 按 Blob 接收
-> 校验是不是错误 JSON
-> 生成 blob 临时 URL
-> 创建 a 标签
-> 设置 download 文件名
-> 模拟点击
-> 浏览器保存文件
-> 释放临时 URL
注意:后端设置 Content-Disposition: attachment 只是告诉浏览器“这是一个附件下载响应”。但当前项目是通过 axios 请求接口,并且设置了 responseType: 'blob',所以浏览器不会自动弹出保存框。
真正触发本地保存的是前端:
saveBlobToLocal(blob, filename)
它的大致逻辑是:
Blob 二进制对象
↓
URL.createObjectURL(blob)
↓
创建隐藏 a 标签
↓
设置 a.download = filename
↓
模拟点击 a.click()
↓
浏览器开始保存文件
↓
URL.revokeObjectURL(...) 释放内存
所以下载链路里,后端负责输出文件流,前端负责把 Blob 保存成本地文件。
本项目当前下载能力可以总结为:
个人空间单文件下载:已实现
个人空间文件预览:已实现
分享页单文件下载:已实现
前端批量下载普通文件:已实现,本质是循环单文件下载
后端多文件 zip 打包下载:未实现
文件夹下载:未实现
下载阶段不会重新处理上传分片。上传时分片已经通过 /file/merge 合并成完整文件,并保存到了 file.real_path。下载时只需要通过 user_file 校验用户权限,再通过 real_file_id 找到 file 表真实路径,最后把完整文件流式写给浏览器。
上传链路:file_chunk -> file -> user_file
下载链路:user_file -> file -> real_path -> response 输出流 -> 前端 Blob
4.7 文件夹树、转移与复制
7.1 文件夹树
文件夹树就是把用户所有文件夹按父子关系组织成树形结构。比如数据库 user_file 里有这些文件夹:
| id | parent_id | filename |
|---|---|---|
| 1 | 0 | 我的资料 |
| 2 | 1 | Java |
| 3 | 1 | 前端 |
| 4 | 2 | SpringBoot |
| 5 | 0 | 图片 |
普通列表看起来是平的:
我的资料
Java
前端
SpringBoot
图片
但文件夹树会组装成:
我的资料
├── Java
│ └── SpringBoot
└── 前端
图片
返回给前端大概是这种结构:
[
{
"id": "1",
"parentId": "0",
"name": "我的资料",
"children": [
{
"id": "2",
"parentId": "1",
"name": "Java",
"children": [
{
"id": "4",
"parentId": "2",
"name": "SpringBoot",
"children": []
}
]
},
{
"id": "3",
"parentId": "1",
"name": "前端",
"children": []
}
]
},
{
"id": "5",
"parentId": "0",
"name": "图片",
"children": []
}
]
这个 children 就是树形结构的关键。
文件夹树接口:
GET /api/v1/files/file/folder/tree
作用:一次性返回当前用户所有未删除文件夹,并组装成树形结构,主要给“移动到 / 复制到”这类选择目标目录的弹窗使用。
Controller:
@GetMapping("/file/folder/tree")
public Result<List<FolderTreeNodeVO>> getFolderTree() {
QueryFolderTreeContext context = new QueryFolderTreeContext();
context.setUserId(UserIdUtil.get());
List<FolderTreeNodeVO> result = userFileService.getFolderTree(context);
return Result.success(result);
}
Controller 做的事情很少:
获取当前登录用户 userId
-> 封装 QueryFolderTreeContext
-> 调用 userFileService.getFolderTree(...)
-> 返回树形节点列表
Service:
public List<FolderTreeNodeVO> getFolderTree(QueryFolderTreeContext context) {
List<UserFileDO> folderRecords = queryFolderRecords(context.getUserId());
return assembleFolderTreeNodeVOList(folderRecords);
}
它分两步:
1. 查询当前用户所有有效文件夹
2. 在内存中组装成 children 树
查询文件夹:
// 从数据库查出当前用户所有的、未删除的文件夹记录,为后续内存组装路径提供全量数据。
private List<UserFileDO> queryFolderRecords(Long userId) {
// 这是 MyBatis-Plus 的条件构造器,用来动态拼接 SQL 查询条件,不用手写 XML 里的 SQL 片段。
QueryWrapper queryWrapper = Wrappers.query();
//用户数据隔离:只查当前登录用户的文件记录,和之前 Controller、XML 里的逻辑完全一致,保证数据安全。
queryWrapper.eq("user_id", userId);
// 只筛选「文件夹」类型的记录。面包屑是目录层级路径,只有文件夹才有上下级关系,普通文件不需要参与路径组装。这里用枚举类代替硬编码数字,是规范的工程写法。
queryWrapper.eq("folder_flag", FolderFlagEnum.YES.getCode());
// 逻辑删除过滤:只查未被删除的文件夹,回收站里的文件夹不会出现在正常路径里。
queryWrapper.eq("deleted", DeleteEnum.NO.getCode());
// 调用 MyBatis-Plus 内置的 list 方法,执行查询并返回 UserFileDO 列表。DO = Data Object,和数据库表一一对应,是数据库层的实体对象。
return list(queryWrapper);
}
查询条件:
| 条件 | 作用 |
|---|---|
user_id = 当前用户 |
只查自己的目录 |
folder_flag = 1 |
只查文件夹,不查普通文件 |
deleted = 0 |
不查已删除目录 |
组装树:
private List<FolderTreeNodeVO> assembleFolderTreeNodeVOList(List<UserFileDO> folderRecords) {
// 1. 判空:无文件夹数据直接返回空集合
if (CollectionUtils.isEmpty(folderRecords)) {
return Lists.newArrayList();
}
// 2. DO转VO:把数据库实体UserFileDO 批量转换为前端树形节点VO
//执行完这一步:我们得到装满树形对象的一维列表,每个对象的children都是空集合。
List<FolderTreeNodeVO> mappedFolderTreeNodeVOList =
// 把文件夹列表转为流,开启流式操作;
folderRecords.stream()
// 循环每一条UserFileDO,调用MapStruct转换方法
.map(fileConvertor::userFile2FolderTreeNodeVO)
// 把转换后的所有VO收集成一个List
.toList();
// 3. 分组:以父ID为key,把所有子文件夹归类,Map<父ID, 该父下所有子节点>
Map<Long, List<FolderTreeNodeVO>> mappedFolderTreeNodeVOMap = mappedFolderTreeNodeVOList.stream()
//groupingBy 核心作用(重点)
//以每条 VO 的parentId作为 Map 的 key,把同一个父文件夹下的所有子文件夹分到一组。key=1 → [id=2, id=3]
.collect(Collectors.groupingBy(FolderTreeNodeVO::getParentId));
// 4. 遍历全部节点,把子节点挂载到父节点的children属性,生成嵌套树
// mappedFolderTreeNodeVOList 是全局的原始 VO 集合,循环里只是原地修改每个对象内部的 children 字段;
// 所有父子嵌套关系全部绑定在原有对象上,不需要生成、返回新节点;
// 循环走完后,这个 list 里的所有 VO 已经自带完整树形嵌套关系,直接拿来过滤顶层节点即可。
for (FolderTreeNodeVO node : mappedFolderTreeNodeVOList) {
// node.getId() = 当前文件夹的ID,用这个ID去分组Map查:哪些文件夹的parentId等于我
List<FolderTreeNodeVO> children = mappedFolderTreeNodeVOMap.get(node.getId());
// 判断是否存在子文件夹
if (CollectionUtils.isNotEmpty(children)) {
// 把查到的子文件夹全部放进当前节点的children列表,形成嵌套
node.getChildren().addAll(children);
}
}
原理:
先把所有文件夹转成 VO
-> 按 parentId 分组
-> 遍历每个节点,把 parentId = 当前节点 id 的节点挂到 children
-> 最后只返回顶层节点
顶层判断:
FileConstant.TOP_PARENT_ID = 0L
所以:
parentId = 0 的文件夹是顶层目录
返回节点结构:
| 字段 | 含义 |
|---|---|
id |
当前文件夹 ID,加密后返回前端 |
parentId |
父文件夹 ID |
name |
文件夹名称 |
children |
子文件夹列表 |
一句话总结:
文件夹树不是递归查数据库,而是一次查出当前用户所有有效文件夹,再在内存里按 parentId 组装成树。
7.2 文件转移
移动文件夹时,只需要修改文件夹本身的 parentId,它里面的子文件、子文件夹不需要改 —— 因为它们的父级是这个文件夹本身,文件夹挪走了,里面的内容自然跟着走,这就是树形目录的特性。
文件转移接口:
POST /api/v1/files/file/transfer
请求体:
{
"fileIds": ["加密ID1", "加密ID2"],
"targetParentId": "目标文件夹加密ID"
}
注意:转移接口里的 fileIds 是数组。
Controller:
@PostMapping("/file/transfer")
public Result transfer(@Validated @RequestBody TransferFileParamVO transferFileParam) {
// 1. 前端传加密后的文件ID数组,循环解密转为真实Long主键
// IdUtil.decrypt 前后端分离安全设计,前端不直接传数据库真实 ID,全部加密传输,后端解密拿到真实主键;
List<Long> fileIdList = transferFileParam.getFileIds()
.stream()
.map(IdUtil::decrypt)
.collect(Collectors.toList());
// 2. 获取目标文件夹加密ID
String targetParentId = transferFileParam.getTargetParentId();
// 3. 封装转移上下文载体,统一存放本次操作全部参数
TransferFileContext context = new TransferFileContext();
context.setFileIdList(fileIdList);
// 解密目标文件夹ID
context.setTargetParentId(IdUtil.decrypt(targetParentId));
// 获取当前登录用户ID
context.setUserId(UserIdUtil.get());
// 4. 调用业务层执行文件移动逻辑
userFileService.transfer(context);
// 5. 无返回数据,返回成功提示
return Result.success("");
}
public class TransferFileParamVO {
/**
* 要转移的文件ID集合,多个使用公用分隔符隔开
*/
@NotEmpty(message = "请选择要转移的文件")
private List<String> fileIds;
/**
* 要转移到的目标文件夹的ID
*/ @NotBlank(message = "请选择要转移到哪个文件夹下面")
private String targetParentId;
}
@Getter
@Setter
public class TransferFileContext {
/**
* 要转移的文件ID集合
*/
private List<Long> fileIdList;
/**
* 目标文件夹ID
*/ private Long targetParentId;
/**
* 当前登录的用户ID
*/ private Long userId;
/**
* 要转移的文件列表
*/
private List<UserFileDO> prepareRecords;
}
Controller 主要做参数转换:
fileIds:加密字符串列表 -> Long 列表
targetParentId:加密字符串 -> Long
userId:从登录上下文获取
Service:
@Override
public void transfer(TransferFileContext context) {
// 第一步:前置校验(权限校验、不能移动到自身子目录、目标文件夹是否存在等)
checkTransferCondition(context);
// 根据文件ID批量查询本次要移动的所有文件/文件夹记录(上下文内部封装查询逻辑)
List<UserFileDO> prepareRecords = context.getPrepareRecords();
// 遍历每一条待移动文件记录,修改归属信息
prepareRecords.forEach(record -> {
// 修改父文件夹ID:核心逻辑,移动就是改parentId
record.setParentId(context.getTargetParentId());
// 归属用户ID(网盘多用户隔离)
record.setUserId(context.getUserId());
// 创建人、更新人改为当前操作者
record.setCreateUser(context.getUserId());
record.setUpdateUser(context.getUserId());
// 处理重名:目标文件夹存在同名文件时,自动重命名(xxx(1)、xxx(2))
handleDuplicateFilename(record);
});
// 批量更新数据库所有修改后的记录
if (!updateBatchById(prepareRecords)) {
// 数据库更新行数为0,抛出异常提示转移失败
throw new SystemException("文件转移失败");
}
}
转移前校验:
private void checkTransferCondition(TransferFileContext context) {
// 从上下文中取出本次移动的目标位置ID(也就是要把文件移到哪个文件夹下面)
Long targetParentId = context.getTargetParentId();
// ========== 校验1:目标位置必须是文件夹,不能是普通文件 ========== // 先根据目标ID查询数据库记录,再判断这条记录是不是文件夹类型
// 业务规则:文件/文件夹只能存放在文件夹(容器)里,不能移动到一个普通文件的“内部”
if (!isFolder(getById(targetParentId))) {
// 目标不是文件夹,直接抛出「目标文件夹类型错误」的业务异常,终止流程
throw new FileException(TARGET_FOLDER_TYPE_ERROR);
}
// ========== 预加载:批量查出所有待移动的文件记录 ========== // 根据前端传的文件ID列表,一次性查出全部待移动的文件/文件夹实体
// 这里提前查出来,后面的子目录校验、主方法里的属性修改都能直接用,不用重复查数据库
List<UserFileDO> prepareRecords = listByIds(context.getFileIdList());
// 把查到的记录存入上下文对象,实现「一次查询、多处复用」
context.setPrepareRecords(prepareRecords);
// ========== 校验2:禁止把文件夹移动到它自己的子目录里 ========== // 这是树形目录的核心边界校验:防止出现「A文件夹包含B文件夹,B文件夹又包含A文件夹」的循环嵌套
// 内部逻辑:从目标文件夹开始,一层层向上找父级,检查目标文件夹是不是待移动文件夹的子孙级
// 如果不拦截,后续查询面包屑、遍历文件夹时会陷入无限递归,直接导致系统卡死
if (checkIsChildFolder(prepareRecords, targetParentId, context.getUserId())) {
throw new FileException(FILE_NOT_EXIT);
}
}
校验逻辑:
| 校验 | 作用 |
|---|---|
| 目标节点必须是文件夹 | 不能移动到普通文件下面 |
| 查询待转移记录 | 拿到要移动的 user_file |
| 目标不能是自身或子目录 | 防止把文件夹移动进自己内部,形成死循环目录 |
为什么不能移动到自己或子目录? 例如:
A
└── B
如果把 A 移动到 B 下面,就会变成:
A -> B -> A -> B ...
目录结构会形成环,面包屑、文件夹树、递归复制都会出问题。 转移真正修改的是:
record.setParentId(context.getTargetParentId());
也就是说,转移的本质是:
修改 user_file.parent_id
不会动:
file 表
真实物理文件
real_file_id
如果目标目录下已有同名文件,会调用:
handleDuplicateFilename(record);
自动改名,例如:
文档.pdf -> 文档(1).pdf
一句话总结:
文件转移不是移动物理文件,而是把 user_file 记录的 parent_id 改成目标文件夹 ID;如果同名则自动改名。
7.3 文件复制
文件复制接口:
POST /api/v1/files/file/copy
请求体:
{
"fileIds": "加密ID1,加密ID2",
"targetParentId": "目标文件夹加密ID"
}
注意:复制接口里的 fileIds 是逗号拼接字符串,不是数组。这一点和转移接口不同。
Controller:
@PostMapping("/file/copy")
public Result copy(@Validated @RequestBody CopyFileParamVO copyFilePO) {
// 前端传逗号分隔的加密ID字符串,不是数组
String fileIds = copyFilePO.getFileIds();
// 目标文件夹加密ID
String targetParentId = copyFilePO.getTargetParentId();
// 1. 按分隔符切割字符串 → 字符串列表 → 循环解密为真实Long主键
List<Long> fileIdList = Splitter.on(BaseConstant.COMMON_SEPARATOR)
.splitToList(fileIds)
.stream()
.map(IdUtil::decrypt)
.collect(Collectors.toList());
// 2. 封装复制操作上下文,统一承载所有参数
CopyFileContext context = new CopyFileContext();
context.setFileIdList(fileIdList);
context.setTargetParentId(IdUtil.decrypt(targetParentId));
context.setUserId(UserIdUtil.get()); // 当前登录用户
// 3. 调用业务层执行复制逻辑
userFileService.copy(context);
return Result.success("");
}
Controller 做的事:
把 "id1,id2,id3" 拆成列表
-> 每个 ID 解密
-> 解密 targetParentId
-> 补充当前 userId
-> 调用复制服务
Service:
@Override
public void copy(CopyFileContext context) {
// 前置校验:目标是否文件夹、不能复制到子目录、权限校验等
checkCopyCondition(context);
// 上下文中取出待复制的原文件/文件夹数据库记录(校验阶段已批量查询存入)
List<UserFileDO> prepareRecords = context.getPrepareRecords();
// 没有选中文件直接结束方法
if (EmptyUtil.isEmpty(prepareRecords)) {
return;
}
// 存放所有要新增的复制副本记录(包括文件夹下所有子文件、子文件夹)
List<UserFileDO> allRecords = Lists.newArrayList();
// 遍历每一个选中的源文件/文件夹,递归生成副本记录存入allRecords
prepareRecords.forEach(record ->
assembleCopyChildRecord(allRecords, record, context.getTargetParentId(), context.getUserId())
);
// 批量插入所有副本到数据库
if (!saveBatch(allRecords)) {
throw new SystemException("文件复制失败");
}
}
复制前校验和转移类似:
目标必须是文件夹
目标不能是被复制文件夹本身或其子文件夹
核心复制逻辑:
private void assembleCopyChildRecord(List<UserFileDO> allRecords, UserFileDO record, Long targetParentId, Long userId) {
// 生成全新唯一主键,作为复制后文件的新id
Long newFileId = IdUtil.get();
// 保存原始文件id,后面查它的子文件夹要用
Long oldFileId = record.getId();
// 1. 修改当前这条副本的属性
record.setParentId(targetParentId); // 副本放到指定父目录
record.setId(newFileId); // 覆盖id,变成全新文件记录
record.setUserId(userId); // 文件归属当前操作人
record.setCreateUser(userId);
record.setUpdateUser(userId);
handleDuplicateFilename(record); // 重名自动加后缀,避免同目录重名冲突
// 把处理好的新文件记录加入待插入列表
allRecords.add(record);
// 2. 如果当前复制的是文件夹,需要递归复制它所有子文件、子文件夹
if (isFolder(record)) {
// 根据原始文件夹id,查询它所有直接子级
// 给一个文件夹 ID,去数据库查出它所有直接下级(文件、子文件夹),只拿没删除的数据。
// 只查一级子项,不会递归查孙子、曾孙。
List<UserFileDO> childRecords = findChildRecords(oldFileId);
// 没有子内容直接终止递归
if (CollectionUtils.isEmpty(childRecords)) {
return;
}
// 遍历每一个子项,递归复制
// 注意第二个参数newFileId:子文件的父目录是刚复制出来的新文件夹id
// 查询原文件夹所有一级子内容,逐个递归复制,并且让复制后的子内容挂载到新复制出来的父文件夹下,完整复刻整套目录树。
childRecords.forEach(childRecord -> assembleCopyChildRecord(allRecords, childRecord, newFileId, userId));
}
}
复制普通文件时:
生成新的 user_file.id
parent_id 改成目标目录 ID
real_file_id 保持不变
保存为一条新的 user_file 记录
复制文件夹时:
先复制文件夹本身
-> 生成新文件夹 ID
-> 查询原文件夹的子节点
-> 子节点 parent_id 指向新文件夹 ID
-> 递归复制整棵 user_file 树
关键点:
复制的是 user_file 目录视图,不复制 file 物理文件。
普通文件复制后:
原文件 user_file.real_file_id = 100
复制后 user_file.real_file_id = 100
它们指向同一个 file 表物理文件记录。
所以复制很轻量:
只复制数据库目录记录,不复制真实文件内容。
一句话总结:
文件复制会递归复制 user_file 树,为每条新记录生成新 id,但普通文件仍复用原来的 real_file_id,不会复制物理文件。
7.4前端入口实现
文件夹树是为“移动/复制时选择目标目录”准备的;文件转移和复制后端也写好了,但当前前端没有按钮、弹窗或调用逻辑,所以现阶段它们是未启用功能。
可以这样设计:在 Files.vue 现有操作体系里补两个入口:“移动到”和“复制到”。单文件可以放在每行悬浮操作按钮里,批量可以放在顶部工具栏里,和“批量删除 / 批量下载”并列。用户点击入口后,先记录当前要操作的文件列表,比如单文件就是 [row],批量就是 selectedFiles.value,再打开一个“选择目标文件夹”的弹窗。
弹窗里用 Element Plus 的 el-tree 展示文件夹树。打开弹窗时调用 getFolderTree(),把后端返回的树赋值给 folderTree。树节点显示文件夹名称,点击某个节点时保存它的 id 作为 targetParentId。弹窗底部放“取消”和“确定”按钮;点确定时,如果当前模式是移动,就调用 transferFiles({ fileIds: [...], targetParentId });如果当前模式是复制,就调用 copyFiles({ fileIds: 'id1,id2', targetParentId })。注意这两个接口参数不完全一样:转移的 fileIds 当前后端是 List<String>,复制的 fileIds 是逗号拼接字符串。
状态可以这样设计:
const showFolderTreeDialog = ref(false)
const folderTree = ref([])
const targetParentId = ref('')
const operationMode = ref('transfer') // transfer / copy
const operationFiles = ref([])
打开弹窗时:
async function openFolderDialog(mode, files) {
operationMode.value = mode
operationFiles.value = files
targetParentId.value = ''
const response = await getFolderTree()
folderTree.value = response.data || []
showFolderTreeDialog.value = true
}
确认时:
async function confirmFolderOperation() {
if (!targetParentId.value) {
ElMessage.warning('请选择目标文件夹')
return
}
const ids = operationFiles.value.map(file => String(getFileId(file)))
if (operationMode.value === 'transfer') {
await transferFiles({
fileIds: ids,
targetParentId: targetParentId.value
})
ElMessage.success('移动成功')
} else {
await copyFiles({
fileIds: ids.join(','),
targetParentId: targetParentId.value
})
ElMessage.success('复制成功')
}
showFolderTreeDialog.value = false
await refreshCurrentFolder()
}
模板大概是:
<el-dialog v-model="showFolderTreeDialog" title="选择目标文件夹">
<el-tree
:data="folderTree"
node-key="id"
:props="{ label: 'name', children: 'children' }"
highlight-current
@node-click="node => targetParentId = node.id"
/>
<template #footer>
<el-button @click="showFolderTreeDialog = false">取消</el-button>
<el-button type="primary" @click="confirmFolderOperation">确定</el-button>
</template>
</el-dialog>
需要注意两个体验细节:第一,不要允许用户把文件夹移动/复制到它自己或它的子目录,后端已经会拦,但前端最好也能禁用或提示;第二,操作成功后不要手动改表格数组,直接复用 refreshCurrentFolder() 重新拉当前目录列表,和新建、删除、重命名保持一致。这样三个功能就能自然接进现有架构:getFolderTree() 负责展示目标目录,transferFiles() 负责移动,copyFiles() 负责复制。
4.8 数据模型
8.1 user_file 表
user_file 是用户目录视图表。用户在页面上看到的每一个文件或文件夹,本质上都是一条 user_file 记录。
| 字段 | 含义 | 典型值 / 说明 |
|---|---|---|
id |
用户文件视图 ID | 文件/文件夹在用户目录里的唯一 ID |
user_id |
所属用户 ID | 用于用户数据隔离 |
parent_id |
父目录 ID | 决定当前记录在哪个文件夹下 |
real_file_id |
真实文件 ID | 普通文件指向 file.id;文件夹为 null |
filename |
展示名称 | 用户界面看到的名字 |
folder_flag |
是否文件夹 | 1 表示文件夹,0 表示普通文件 |
file_size_desc |
文件大小展示值 | 如 12.5 MB;文件夹一般为空 |
file_type |
文件类型编码 | 图片、视频、文档等分类依据 |
deleted |
逻辑删除标记 | 0 正常,删除后不再出现在普通列表 |
create_user |
创建人 | 通常等于当前用户 |
update_user |
更新人 | 最近操作人 |
常见操作对 user_file 的影响:
| 操作 | 对 user_file 的影响 |
|---|---|
| 新建文件夹 | 新增一条 folder_flag = 1 的记录 |
| 上传文件 | 新增一条 folder_flag = 0 的记录,并关联 real_file_id |
| 秒传 | 不新增物理文件,只新增 user_file |
| 重命名 | 修改 filename |
| 删除 | 修改 deleted |
| 转移 | 修改 parent_id |
| 复制文件 | 新增一条 user_file,复用 real_file_id |
| 复制文件夹 | 递归新增整棵 user_file 树 |
一句话:
user_file 管“用户看到的目录结构和文件名”。
8.2 file 表
file 表保存真实物理文件元数据。只有真正上传并落盘/入对象存储的文件,才会有 file 记录。
| 字段 | 含义 | 典型值 / 说明 |
|---|---|---|
id |
真实文件 ID | 被 user_file.real_file_id 引用 |
filename |
原始文件名 | 上传时的文件名 |
real_path |
真实存储路径 | 本地磁盘路径 / OSS 路径等 |
file_size |
文件真实大小 | 字节数,字符串存储 |
file_size_desc |
文件大小展示值 | 如 12.5 MB |
file_suffix |
文件后缀 | 如 pdf、jpg、mp4 |
file_preview_content_type |
预览 MIME 类型 | 如 application/pdf、image/png |
identifier |
文件唯一标识 | 通常用于秒传判断,如 MD5 |
create_user |
上传人 | 创建该真实文件记录的用户 |
常见操作对 file 的影响:
| 操作 | 对 file 的影响 |
|---|---|
| 新建文件夹 | 不影响 |
| 普通上传 | 新增一条 file 记录 |
| 分片合并成功 | 新增一条 file 记录 |
| 秒传 | 复用已有 file,不新增 |
| 重命名 | 不改 file.filename,只改 user_file.filename |
| 删除 | 当前删除逻辑不直接删 file |
| 复制文件 | 不复制 file,复用 real_file_id |
| 下载/预览 | 通过 real_path 读取真实文件 |
一句话:
file 管“真实文件存在哪里、大小多少、怎么预览”。
8.3 两张表的关系
| 关系 | 说明 |
|---|---|
user_file.real_file_id -> file.id |
普通文件通过这个字段关联真实文件 |
| 文件夹没有真实文件 | 文件夹的 real_file_id = null |
一个 file 可以对应多个 user_file |
秒传、复制都会复用同一个真实文件 |
页面列表主要查 user_file |
因为页面展示的是用户目录视图 |
下载/预览需要查 file |
因为要拿 real_path 读取真实文件 |
示例:
| 场景 | user_file | file |
|---|---|---|
新建文件夹 资料 |
新增:filename=资料, folder_flag=1, real_file_id=null |
不新增 |
上传 a.pdf |
新增:filename=a.pdf, real_file_id=10 |
新增:id=10, real_path=xxx |
复制 a.pdf |
新增另一条:filename=a(1).pdf, real_file_id=10 |
不新增 |
重命名为 合同.pdf |
修改:user_file.filename=合同.pdf |
不修改 |
下载 合同.pdf |
用 real_file_id=10 查 file.real_path |
读取真实文件 |
最终模型可以这样记:
user_file = 用户网盘视图
file = 真实物理文件元数据
更形象一点:
file 像仓库里的真实货物
user_file 像用户货架上的标签和摆放位置
复制、移动、重命名,大多是在改货架标签;
上传、预览、下载,才会真正碰到仓库里的货物。
4.9 文件夹树、转移与复制
9.1 文件夹树
接口:
GET /api/v1/files/file/folder/tree
Controller:
@GetMapping("/file/folder/tree")
public Result<List<FolderTreeNodeVO>> getFolderTree() {
QueryFolderTreeContext context = new QueryFolderTreeContext();
context.setUserId(UserIdUtil.get());
List<FolderTreeNodeVO> result = userFileService.getFolderTree(context);
return Result.success(result);
}
服务层:
public List<FolderTreeNodeVO> getFolderTree(QueryFolderTreeContext context) {
List<UserFileDO> folderRecords = queryFolderRecords(context.getUserId());
return assembleFolderTreeNodeVOList(folderRecords);
}
先查当前用户所有有效文件夹:
private List<UserFileDO> queryFolderRecords(Long userId) {
QueryWrapper queryWrapper = Wrappers.query();
queryWrapper.eq("user_id", userId);
queryWrapper.eq("folder_flag", FolderFlagEnum.YES.getCode());
queryWrapper.eq("deleted", DeleteEnum.NO.getCode());
return list(queryWrapper);
}
然后在内存中组装树:
private List<FolderTreeNodeVO> assembleFolderTreeNodeVOList(List<UserFileDO> folderRecords) {
List<FolderTreeNodeVO> nodes = folderRecords.stream()
.map(fileConvertor::userFile2FolderTreeNodeVO)
.toList();
Map<Long, List<FolderTreeNodeVO>> map = nodes.stream()
.collect(Collectors.groupingBy(FolderTreeNodeVO::getParentId));
for (FolderTreeNodeVO node : nodes) {
List<FolderTreeNodeVO> children = map.get(node.getId());
if (CollectionUtils.isNotEmpty(children)) {
node.getChildren().addAll(children);
}
}
return nodes.stream()
.filter(node -> Objects.equals(node.getParentId(), FileConstant.TOP_PARENT_ID))
.collect(Collectors.toList());
}
其中:
FileConstant.TOP_PARENT_ID = 0L
所以顶层节点是:
parentId = 0
返回节点字段:
id
parentId
name
children
作用:给转移、复制选择目标目录时使用。前端可以用这个树展示“移动到哪个文件夹”。
9.2 文件转移
接口:
POST /api/v1/files/file/transfer
请求体:
{
"fileIds": ["加密ID1", "加密ID2"],
"targetParentId": "目标文件夹加密ID"
}
Controller:
@PostMapping("/file/transfer")
public Result transfer(@Validated @RequestBody TransferFileParamVO transferFileParam) {
List<Long> fileIdList = transferFileParam.getFileIds()
.stream()
.map(IdUtil::decrypt)
.collect(Collectors.toList());
TransferFileContext context = new TransferFileContext();
context.setFileIdList(fileIdList);
context.setTargetParentId(IdUtil.decrypt(transferFileParam.getTargetParentId()));
context.setUserId(UserIdUtil.get());
userFileService.transfer(context);
return Result.success("");
}
服务层:
public void transfer(TransferFileContext context) {
checkTransferCondition(context);
List<UserFileDO> prepareRecords = context.getPrepareRecords();
prepareRecords.forEach(record -> {
record.setParentId(context.getTargetParentId());
record.setUserId(context.getUserId());
record.setCreateUser(context.getUserId());
record.setUpdateUser(context.getUserId());
handleDuplicateFilename(record);
});
if (!updateBatchById(prepareRecords)) {
throw new SystemException("文件转移失败");
}
}
校验:
private void checkTransferCondition(TransferFileContext context) {
Long targetParentId = context.getTargetParentId();
if (!isFolder(getById(targetParentId))) {
throw new FileException(TARGET_FOLDER_TYPE_ERROR);
}
List<UserFileDO> prepareRecords = listByIds(context.getFileIdList());
context.setPrepareRecords(prepareRecords);
if (checkIsChildFolder(prepareRecords, targetParentId, context.getUserId())) {
throw new FileException(FILE_NOT_EXIT);
}
}
转移规则:
- 目标必须是文件夹。
- 如果转移的是文件夹,目标不能是它自己或它的子文件夹。
- 只修改选中记录本身的
parent_id。 - 如果目标目录下重名,则自动改名。
- 不复制物理文件,不改
real_file_id。
转移本质:
把 user_file.parent_id 从原目录 ID 改成目标目录 ID。
9.3 文件复制
接口:
POST /api/v1/files/file/copy
请求体注意和转移不同:
{
"fileIds": "加密ID1,加密ID2",
"targetParentId": "目标文件夹加密ID"
}
这里 fileIds 是逗号拼接的字符串,不是数组。
Controller:
@PostMapping("/file/copy")
public Result copy(@Validated @RequestBody CopyFileParamVO copyFilePO) {
String fileIds = copyFilePO.getFileIds();
List<Long> fileIdList = Splitter.on(BaseConstant.COMMON_SEPARATOR)
.splitToList(fileIds)
.stream()
.map(IdUtil::decrypt)
.collect(Collectors.toList());
CopyFileContext context = new CopyFileContext();
context.setFileIdList(fileIdList);
context.setTargetParentId(IdUtil.decrypt(copyFilePO.getTargetParentId()));
context.setUserId(UserIdUtil.get());
userFileService.copy(context);
return Result.success("");
}
服务层:
public void copy(CopyFileContext context) {
checkCopyCondition(context);
List<UserFileDO> prepareRecords = context.getPrepareRecords();
if (EmptyUtil.isEmpty(prepareRecords)) {
return;
}
List<UserFileDO> allRecords = Lists.newArrayList();
prepareRecords.forEach(record ->
assembleCopyChildRecord(allRecords, record, context.getTargetParentId(), context.getUserId())
);
if (!saveBatch(allRecords)) {
throw new SystemException("文件复制失败");
}
}
复制核心:
private void assembleCopyChildRecord(List<UserFileDO> allRecords, UserFileDO record, Long targetParentId, Long userId) {
Long newFileId = IdUtil.get();
Long oldFileId = record.getId();
record.setParentId(targetParentId);
record.setId(newFileId);
record.setUserId(userId);
record.setCreateUser(userId);
record.setUpdateUser(userId);
handleDuplicateFilename(record);
allRecords.add(record);
if (isFolder(record)) {
List<UserFileDO> childRecords = findChildRecords(oldFileId);
childRecords.forEach(childRecord ->
assembleCopyChildRecord(allRecords, childRecord, newFileId, userId)
);
}
}
复制规则:
- 目标必须是文件夹。
- 如果复制的是文件夹,目标不能是它自己或它的子文件夹。
- 复制会递归复制整棵
user_file目录树。 - 每条新记录都会生成新的
id。 - 子节点的
parent_id会指向复制出来的新父节点 ID。 - 普通文件的
real_file_id保持不变。 - 不会复制真实物理文件内容。
- 同名时自动改名。
所以复制本质是:
复制 user_file 目录视图树,不复制 file 物理文件。
5.文件上传

普通文件上传不涉及 @Facade。因为它是前端直接请求 files 服务的 Controller:
5.1.功能说明
当前页面上传主入口是:disk-by-cursor/src/components/UploadButton.vue
Files.vue 中实际接入的是:<UploadButton />
当前上传链路的真实特点:
- 前端默认所有文件都走分片上传链路,因为
getChunkUploadSwitch()固定返回true。 - 上传前会先计算文件 MD5,作为
identifier。 - 前端会先调用秒传接口,秒传命中就不再上传物理内容。
- 秒传未命中后,才进入分片上传。
- 分片全部上传完成后,前端调用合并接口。
- 后端合并完成后,会保存
file表和user_file表。 - 上传完成后,前端刷新当前目录文件列表。
- 当前页面没有真正走
/file/upload单文件上传主链路,主链路是“秒传 + 分片 + 合并”。
完整上传流程可以概括为:
用户点击上传 / 拖拽文件
-> UploadButton.vue 把文件加入 simple-uploader 队列
-> 前端暂停上传,先计算 MD5
-> 调用秒传接口 /file/sec-upload
-> 秒传命中:后端直接新增 user_file,前端刷新列表
-> 秒传未命中:前端恢复上传,开始分片上传
-> simple-uploader 上传每个分片到 /file/chunk-upload
-> 后端保存分片物理文件和 file_chunk 记录
-> 后端返回 mergeFlag,表示是否可以合并
-> 前端发现 mergeFlag = READY 后调用 /file/merge
-> 后端合并分片,保存 file 表,再保存 user_file 表
-> 前端刷新当前目录文件列表,移除上传任务
一句话:上传不是一次请求完成,而是先尝试秒传;不命中时分片传输;分片齐全后再合并成真实文件并写入用户目录。
上传功能在业务上通常要同时满足几类目标:
- 对已经上传过的相同内容文件,尽量走秒传,减少重复传输。
- 对大文件和不稳定网络场景,支持分片上传和断点续传。
- 在页面层面提供拖拽、多选、进度展示和任务控制,避免只靠浏览器原生上传框。
- 上传完成后不仅要有物理文件,还要能落下用户目录视图,也就是
user_file记录。 - 上传链路结束后,还要允许后续业务继续接力,例如当前项目里的文档 AI 预热。
结合当前仓库代码,以上目标中已经落地的主要是:秒传、默认分片上传、断点续传、任务面板、上传完成后目录落库与 AI 预热;
5.2. 前端上传入口
前端上传完整全链路时序:页面渲染 → 弹窗打开 → 拖拽 / 点击上传 → 分片 / 普通上传完成
-
文件选择方式:点击选择文件 / 拖拽文件,点击上传、拖拽上传只是两种“把本地文件交给 uploader”的入口。点击和拖拽最终都会进入:
uploader.addFiles(files),然后触发:uploader.on('filesAdded', filesAdded)真正上传逻辑是从filesAdded开始的。 -
上传前置判断:MD5 秒传秒传是在真正上传前,先问后端:这个文件服务器上是不是已经有了。
-
真正传输方式: 普通整文件上传 / 切片上传才是真正把文件内容传到后端的方式。当前项目虽然保留了两套上传接口:但是前端配置里:
getChunkUploadSwitch() { return true}。所以当前主线是:秒传 + 切片上传 + 合并。也就是说,当前项目正常情况下不会走普通整文件上传。哪怕文件小于 1MB,也不是自动切换普通上传,而是走切片上传,只不过它只有 1 个分片。
点击上传和拖拽上传,最后都会调用 uploader.addFiles(files)。它们只是拿文件的方式不同,后面的上传链路完全一致。
@click、@drop:浏览器事件,负责拿到本地文件。
uploader.on('filesAdded'):上传库事件,负责进入上传业务流程(不是浏览器 DOM 事件,而是 simple-uploader 自己的事件系统)。
fileUploaded 不是接口名,也不是上传方式。fileUploaded 是“上传请求成功后的统一处理函数”。 切片上传:/chunk-upload 成功 → 进入 fileUploaded → res.data 有值 → 判断是否 merge。 普通上传:/upload 成功 → 进入 fileUploaded → res.data 没值 → 直接刷新列表。
真正发 /file/chunk-upload 请求的是 simple-uploader。触发点是 f.resume()。上传地址来自 fileOptions.target。上传参数来自 fileOptions.query / headers / fileParameterName。
正确流程是:
用户点击 / 拖拽选择文件
↓
uploader.addFiles(files)
↓
文件加入 simple-uploader 队列
↓
触发 filesAdded 回调
↓
f.pause() 先暂停自动上传
↓
前端计算文件 MD5
↓
请求秒传接口 /file/sec-upload
↓
如果秒传成功:
后端复用已有 file 记录
新增 user_file 记录
前端取消当前上传任务
前端刷新文件列表
↓
如果秒传失败:
f.resume() 恢复上传
↓
simple-uploader 根据 fileOptions 自动切片并上传
↓
POST /api/v1/files/file/chunk-upload
↓
每个分片成功后触发 fileUploaded
↓
fileUploaded 判断所有分片是否上传完成
↓
全部完成后调用 doMerge(file)
↓
请求 merge 合并接口 /file/merge
↓
后端合并分片
↓
后端生成 file 记录
↓
后端生成 user_file 记录
↓
前端刷新文件列表
- chunk-upload 只保存分片,不代表文件最终完成。
- merge 成功后保存 user_file,文件才会出现在页面列表。
分片上传的底层请求由 simple-uploader 封装完成,项目代码不直接手写 axios.post(‘/chunk-upload’)。 本项目只负责配置上传规则、添加文件、控制暂停/恢复、处理上传成功/失败回调。 理解时不需要深入 simple-uploader 源码,但要知道它内部基于 File.slice() 切片,并用 multipart/form-data 把每个分片 POST 到 target 指定的后端接口。
步骤 1 页面初始化渲染(组件加载最先执行,仅 1 次)
1.1 Vue 模板渲染顺序
1. 加载 <template>,生成页面 DOM:
- 两个上传按钮(roundFlag/circleFlag 控制显示)
- 上传弹窗盒子 upload-dialog-overlay,默认 v-if="uploadDialogVisible=false",此时弹窗 DOM 不存在
- 弹窗内部:拖拽区域 .drag-content、隐藏 <input type="file" ref="fileInput">
2. 执行 `<script setup>` 自上而下代码:
- 导入所有依赖、定义 props、实例化 pinia 仓库
- 定义全部响应式变量:`uploadDialogVisible、uploader、assignFlag、fileInput、isAlive`
- 定义配置 `fileOptions`、所有方法函数(此时只是声明,不执行)
1.2 生命周期 onMounted(DOM 全部渲染完后执行)(内存 DOM 挂载到真实页面后执行,仅执行 1 次)
Vue 生命周期是 Vue 框架给组件预设的"人生阶段"——从创建、挂载到更新、销毁,每个阶段 Vue 会自动调用对应的钩子函数,让你插入自己的代码。onMounted 是其中一个钩子,意思是 “组件的 DOM 已经渲染到页面上了”,这时候你才能安全地操作 DOM 元素(比如初始化一个上传组件)。它不是 Vue 最早执行的——最早的是 onBeforeCreate、onCreated,但那些时候 DOM 还不存在,所以操作 DOM 必须等 onMounted。
**组件代码分两阶段:
- 编译渲染阶段:解析 template 模板、生成页面 DOM 元素,此时 DOM 只存在内存里,页面上看不见;
- 挂载 onMounted:内存中的 DOM 真正插入真实网页文档,用户能看到页面按钮、弹窗盒子。 onMounted = 挂载完成钩子 只有页面全部 DOM 渲染插入完毕,才会执行里面的代码。
拖拽事件不是 Vue 现成的——drag、drop、dragover 这些是浏览器原生的 DOM 事件,任何网页都能用。但 Vue 提供了 v-on(简写 @)语法糖让你方便地绑定,比如 @drop="handleDrop"。不过你代码里的 assignBrowse / assignDrop 是上传库自己的 API,它内部帮你封装了原生拖拽事件的绑定,不是 Vue 直接提供的。
onMounted(() => { initUploader() })
进入 initUploader:
- 清空任务列表
taskStore.clear() new Uploader(fileOptions)创建上传库实例uploader- 给
uploader注册全局内置事件监听(库内部自带事件系统,不是 DOM 原生监听)
这里只是创建好了上传工具的 “内核”,还没和页面上的弹窗绑定 —— 因为弹窗默认是隐藏的,DOM 还不存在,没法绑定拖拽 / 点击事件。
const initUploader = () => {
// 1. 清空全局上传任务列表,防止上次上传残留进度
taskStore.clear()
// 2. 创建上传实例,把上面一大段fileOptions配置传给上传库
uploader = new Uploader(fileOptions)
// 3. 判断浏览器是否支持分片上传,不支持直接弹窗提示
if (!uploader.support) {
alert('本浏览器不支持simple-uploader,请更换浏览器重试')
return
}
// 4. 给上传实例绑定【上传库自己的生命周期事件】
// uploader.on("事件名", 自己写的回调函数)
uploader.on("filesAdded", filesAdded) // 用户选中/拖拽文件后触发
uploader.on("fileProgress", uploadProgress) // 文件分片上传时,持续返回进度
uploader.on("fileSuccess", fileUploaded) // 某一个分片上传请求成功
uploader.on("complete", uploadComplete) // 所有文件全部上传结束(预留)
uploader.on("fileError", uploadError) // 某个分片上传失败
}
这里是库自身的事件总线,和页面 DOM 拖拽监听是两套东西,分开记:
- uploader.on:文件上传生命周期回调(分片、进度、成功失败)
- assignBrowse /assignDrop:绑定页面 DOM 原生拖拽 / 点击监听(步骤 4)
simple-uploader 上传库自身事件(filesAdded/fileProgress…)和页面 DOM 无关,只管文件上传整个流程:选文件→上传分片→上传成功 / 失败。 simple-uploader是专门做大文件分片上传的纯 JS 上传库, 底层封装了浏览器原生文件 API、AJAX 分片请求、拖拽监听、断点续传逻辑。
fileOptions:上传规则配置(接口地址、分片 1MB、并发 3、token 请求头、断点续传校验、进度节流 500ms 等);;
uploader = new Uploader(配置):创建一个独立的上传实例,拥有自己的文件队列、请求、事件系统。
uploader.on("事件名", 回调):提前告诉上传库,对应上传阶段执行我们写的业务代码。
使用 uploader.on() 注册监听,上传过程中库自动触发对应函数。
步骤 2 用户点击上传按钮,执行 openDialog 打开弹窗
/** * 点击上传按钮,唤起上传弹窗 */
const openDialog = () => {
// 防止重复弹窗:弹窗已经显示就直接退出函数,不执行后面逻辑
if (uploadDialogVisible.value) { return }
// 重置绑定标记,代表当前还没有给DOM绑定拖拽/点击监听
assignFlag.value = false
// 控制弹窗显示:响应式变量修改,Vue会触发DOM更新
uploadDialogVisible.value = true
/** * nextTick 作用: * 修改 uploadDialogVisible 只是告诉Vue要更新页面,
DOM不会立刻生成 * nextTick 内部回调会等待Vue完成DOM渲染、页面刷新后再执行
这里必须等弹窗#upload-content盒子真实存在,才能执行绑定拖拽 */
//等 DOM 渲染完成后,把上传器绑定到上传区域
nextTick(() => { rebindUploader() }) }
uploadDialogVisible = true→ Vue 把弹窗 DOM 插入页面nextTick保证弹窗、拖拽容器#upload-content真实存在后,执行绑定
nextTick 核心作用
Vue 更新 DOM 是异步操作,修改 uploadDialogVisible 不会立刻生成弹窗 DOM;
nextTick 回调延迟执行,保证 #upload-content 容器真实存在,再绑定拖拽 / 点击监听。
步骤 3 rebindUploader:给弹窗 DOM 绑定浏览器原生拖拽 / 点击监听
- 先判断:弹窗已经开着就直接返回,防止重复弹出
- 把
uploadDialogVisible改成true,Vue 开始渲染弹窗 DOM nextTick(() => { rebindUploader() })- 为什么要用 nextTick?Vue 更新 DOM 是异步的,改了变量不会立刻弹出弹窗。nextTick 会等弹窗 DOM 真的渲染到页面上了,再执行绑定逻辑,不然会找不到 DOM 元素报错。
const rebindUploader = () => {
if (uploader && !uploader.support) return alert('浏览器不支持')
// 仅未绑定时执行,避免重复绑定
if (uploader && !assignFlag.value) {
const uploadContent = document.getElementById('upload-content')
if (uploadContent) {
// 先解绑历史残留DOM监听,防止点击多次弹窗、重复上传
uploader.unAssignBrowse()
uploader.unAssignDrop()
// 绑定当前弹窗DOM原生事件
uploader.assignBrowse(uploadContent) // 绑定click,点击唤起文件选择框
uploader.assignDrop(uploadContent) // 绑定drag系列拖拽事件
assignFlag.value = true // 标记已绑定,不再重复执行
}
}
}
核心解释:assignDrop / assignBrowse 底层做了什么?
simple-uploader 库内部封装了原生DOM事件监听,你不用手写 dragover/drop:
assignBrowse(dom):给传入的DOM节点绑定click原生监听,点击时自动唤起隐藏file输入框,捕获本地文件assignDrop(dom)- 给DOM绑定四组浏览器原生拖拽内置事件(就是你模板写的那几个)
dragenter:鼠标拖入区域dragover:悬浮在区域上方dragleave:鼠标拖出区域drop:松开鼠标放下文件
- 库内部自动执行
.prevent()阻止浏览器默认行为(防止文件直接在浏览器打开) - 监听
drop事件,读取event.dataTransfer.files文件列表
- 给DOM绑定四组浏览器原生拖拽内置事件(就是你模板写的那几个)
- 解绑方法
unAssignDrop/unAssignBrowse:移除DOM上绑定的所有拖拽/点击监听,防止多次打开弹窗重复绑定,造成重复上传。
模板上你写的
@dragover.prevent只是备用兜底;真正捕获拖拽文件的逻辑,是uploader.assignDrop()内部封装的原生监听。
步骤 4 用户两种操作分支:点击上传框 / 拖拽文件,最终调用 uploader.addFiles (),都加入上传队列 分支 A:点击拖拽区域 → triggerFileSelect 唤起隐藏 input
- 点击拖拽区域 → 触发
triggerFileSelect()→ 自动点击隐藏的<input type="file"> - 选完文件触发原生
change事件 → 执行handleFileSelect() - 拿到文件列表,调用
uploader.addFiles(files)交给上传工具
<div class="drag-content"
@click="triggerFileSelect"
@dragover.prevent
@drop.prevent="handleDrop"
>
将文件拖到此处,或点击上传
</div>
const triggerFileSelect = () => fileInput.value.click()
const handleFileSelect = (event) => {
const files = Array.from(event.target.files)
if (uploader) uploader.addFiles(files) // 核心:交给上传库处理文件
event.target.value = '' // 清空input,支持重复选择同一文件
}
选择文件后触发原生 change 事件:
const handleFileSelect = (event) => {
// 把 FileList 转成数组
const files = Array.from(event.target.files)
if (files.length > 0 && uploader) {
// 把文件交给 simple-uploader 管理
// 加入上传队列
uploader.addFiles(files)
// 清空 input,允许下次选择同名文件也能触发 change
event.target.value = ''
}
}
uploader.addFiles() 是库内置方法,添加文件后立刻触发 uploader 全局监听事件 filesAdded
分支B:本地文件拖拽到框内松手 → handleDrop
- 拖入文件松手 → 触发原生
drop事件 → 执行handleDrop() - 拿到拖拽的文件列表,调用
uploader.addFiles(files)交给上传工
<input
ref="fileInput"
type="file"
multiple
style="display: none;"
@change="handleFileSelect"
/>
拖拽上传时:
const handleDrop = (event) => {
// 阻止浏览器默认打开文件行为
event.preventDefault()
// 从拖拽事件中取出文件
const files = Array.from(event.dataTransfer.files)
if (files.length > 0 && uploader) {
// 加入上传队列
uploader.addFiles(files)
}
}
uploader.addFiles() 执行后,上传工具会自动触发 filesAdded 事件,正式进入上传业务流程。
simple-uploader 源码内部执行:this._trigger('filesAdded', _files, newFileList, evt)。自动执行我们提前用 uploader.on("filesAdded", filesAdded) 注册的业务函数,所有上传业务从 filesAdded 开始。
simple-uploader 配置
上传器配置在 UploadButton.vue 的 fileOptions:
当前项目 getChunkUploadSwitch() 固定返回 true,所以实际主要请求的是切片上传接口
target 决定请求哪个上传接口。当前开关固定 true,所以主线是 /chunk-upload。
const fileOptions = {
// target:动态定义上传请求的后端接口地址
// 开启分片上传 → 请求分片上传接口;否则普通单文件上传接口
// file:当前正在上传的完整文件对象
// chunk:当前正在传输的分片对象(不分片时为 null)
target: function (file, chunk) {
if (panUtil.getChunkUploadSwitch()) {
return '/api/v1/files/file/chunk-upload'
}
return '/api/v1/files/file/upload'
},
singleFile: false, // false=支持一次性多选多个文件上传
chunkSize: panUtil.getChunkSize(), // 单块分片大小,比如5MB、10MB,大文件自动切成多块
testChunks: panUtil.getChunkUploadSwitch(),
// true开启分片校验:上传前先询问后端哪些分片已经传完,实现断点续传
forceChunkSize: false, // 最后一块文件允许小于分片大小,不用强制补齐
simultaneousUploads: 3, // 同时并发3个分片请求,控制上传并发,防止请求太多卡死
fileParameterName: 'file',
// 每次上传请求自动拼接 URL 查询参数(?parentId=xxx)
// 业务含义:parentId 文件上传到哪个文件夹,后端根据这个 ID 存入对应目录
// query:每一次上传请求,自动携带额外GET参数
query: function (file, chunk) {
return {
parentId: fileStore.currentFolderId || '0'
// parentId = 文件要上传到哪个文件夹,传给后端做目录存储
}
},
// headers:每次上传请求携带请求头,用于登录鉴权
// 后端拦截器读取 Authorization 校验登录状态,没 token 直接返回 401 无权限
headers: function () {
return {
Authorization: `${userStore.token}`
// token凭证,后端校验当前登录用户是否有权限上传
}
},
// 断点续传核心:后端返回已上传分片,判断当前分片是否不需要重复上传
// chunk:当前待上传分片对象,chunk.offset = 当前分片序号(从 0 开始)
// message:后端分片预校验接口返回的原始字符串
// 解析后端返回的 JSON;解析失败直接判定分片不存在,需要上传
// 后端返回 uploadedChunks: [1,2,3] 代表 1、2、3 号分片已上传
// chunk.offset 是 0 开始,所以 + 1 和后端分片编号对齐
// 找到当前分片编号 → return true,库自动跳过本次分片上传;找不到则正常上传
// true:分片已存在,跳过不上传
// false:分片缺失,正常发起上传请求
checkChunkUploadedByResponse: function (chunk, message) {
let objMessage = {}
try {
objMessage = JSON.parse(message)
} catch (e) {
// 后端返回不是标准JSON,代表没有已上传分片
}
// 后端返回数组 uploadedChunks,存着已经上传完成的分片编号
if (objMessage.data) {
// 当前分片编号是否存在数组里,存在=true 跳过上传
return (objMessage.data.uploadedChunks || []).indexOf(chunk.offset + 1) >= 0
}
return false
},
maxChunkRetries: 0, // 分片上传失败,不自动重试
chunkRetryInterval: null,
progressCallbacksInterval: 500,
// 进度更新节流,每500毫秒才执行一次进度回调,避免频繁刷新页面DOM造成卡顿
successStatuses: [200, 201, 202],
// HTTP状态码是这三个,代表本次分片请求成功
permanentErrors: [404, 415, 500, 501],
// 遇到这些错误码,判定永久失败,不再重复上传分片
initialPaused: false // 文件添加后自动开始上传,不会暂停
}
分片配置来自 common.js:
getChunkSize() {
// 当前开启分片上传,所以每片 1MB
if (this.getChunkUploadSwitch()) {
return 1024 * 1024 * 1
}
// 如果不开分片,就把最大文件大小作为整文件大小
return this.getMaxFileSize()
},
getMaxFileSize() {
// 前端单文件最大 3GB
return 1024 * 1024 * 1024 * 3
},
getChunkUploadSwitch() {
// 当前固定开启分片上传
return true
}
步骤 5 filesAdded:文件加入队列后的核心业务入口,真正上传流程的起点
当点击或拖拽调用 uploader.addFiles(files) 后,上传库会触发:filesAdded(files)
这个函数做了几件关键事:
1. 暂停上传
2. 校验文件大小
3. 创建前端上传任务,显示进度面板
4. 计算文件 MD5
5. 请求秒传接口
6. 秒传成功就不上传内容
7. 秒传失败才恢复切片上传
这里有一个关键设计:文件加入队列后,不是马上上传,而是先 pause,计算 MD5,尝试秒传。
* @param {Array} files 当前本次新增的文件实例数组(库封装的File对象)
* @param {Array} fileList 上传器全部文件队列
* @param {Event} event 原生拖拽/文件选择事件对象
* @returns {boolean} 返回true代表允许加入上传队列,false拒绝加入
*/
const filesAdded = (files, fileList, event) => {
// 防护判断:组件已经被销毁、上传实例不存在,直接终止逻辑,防止控制台报错、内存泄漏
if (!isAlive || !uploader) return false
// 选中文件后自动关闭上传弹窗,不需要停留在弹窗页面
uploadDialogVisible.value = false
try {
// 循环处理每一个选中的文件
files.forEach((f) => {
// 1. 先暂停自动上传,阻塞分片传输
// 原因:需要先算MD5、调用秒传接口,确认没有云端相同文件再开启上传
f.pause()
// 2. 文件大小校验,超出系统限制直接抛异常拦截全部上传
if (f.size > panUtil.getMaxFileSize()) {
throw new Error('文件:' + f.name + '大小超过了最大上传文件的限制(' + panUtil.translateFileSize(panUtil.getMaxFileSize()) + ')')
}
// 3. 组装上传任务对象,存入全局任务仓库,底部上传面板读取该数据展示进度
let taskItem = {
target: f, // 当前文件上传实例,用于暂停/取消上传
filename: f.name, // 文件名称
fileSize: panUtil.translateFileSize(f.size), // 总大小,字节转成易读单位(MB/GB)
uploadedSize: panUtil.translateFileSize(0), // 已上传大小,初始0
status: panUtil.fileStatus.PARSING.code, // 任务状态:正在解析MD5
statusText: panUtil.fileStatus.PARSING.text,
timeRemaining: panUtil.translateTime(Number.POSITIVE_INFINITY), // 剩余时间初始无穷大
speed: panUtil.translateSpeed(f.averageSpeed), // 上传速度
percentage: 0, // 上传进度百分比初始0
parentId: String(fileStore.currentFolderId || '0') // 目标上传文件夹ID
}
// 将当前文件任务添加到全局上传任务列表
taskStore.add(taskItem)
// 自动展开页面底部上传任务面板,展示所有上传进度
taskStore.updateViewFlag(true)
// 4. 读取本地文件二进制,计算MD5哈希值(文件唯一指纹)
MD5(f.file, (e, md5) => {
// 将MD5赋值给文件内置唯一标识,用于分片断点续传、后端秒传匹配
f['uniqueIdentifier'] = md5
// 5. 请求后端秒传接口,校验云端是否存在相同MD5文件
// 只要函数返回 Promise,就能链式调用 .then()、.catch()。
// secUpload 发网络请求是异步,不会阻塞代码,请求成功后自动执行 .then 里的回调函数。
secUpload({
filename: f.name,
identifier: md5,
// 如果为空 /undefined,兜底默认根目录 '0'。
parentId: fileStore.currentFolderId || '0'
}).then(res => {
// 后端返回成功,代表服务器已存在一模一样的文件,直接秒传完成
if (res.code === 0 || res.success === true) {
f.cancel() // 取消当前文件分片上传队列,无需发起HTTP请求
taskStore.remove(f.name) // 从底部任务列表移除该任务
fileStore.loadFileList() // 刷新当前文件夹文件列表,秒传文件立刻展示
// 判断上传队列是否已无待上传文件,关闭底部上传面板
if (uploader && Array.isArray(uploader.files) && uploader.files.length === 0) {
taskStore.updateViewFlag(false)
}
} else {
// 云端无匹配文件,需要正常分片上传
f.resume() // 恢复文件上传,库自动切割分片并发请求
// 更新任务状态为等待上传
taskStore.updateStatus({
filename: f.name,
status: panUtil.fileStatus.WAITING.code,
statusText: panUtil.fileStatus.WAITING.text
})
}
}).catch(err => {
// 秒传接口网络/服务异常,放弃秒传逻辑,正常执行分片上传
f.resume()
taskStore.updateStatus({
filename: f.name,
status: panUtil.fileStatus.WAITING.code,
statusText: panUtil.fileStatus.WAITING.text
})
})
})
})
} catch (e) {
// 捕获文件大小超限抛出的异常
alert(e.message)
// 终止所有正在进行的上传请求
if (uploader && typeof uploader.cancel === 'function') {
try { uploader.cancel() } catch (err) {}
}
// 清空全部上传任务
taskStore.clear()
// 返回false,拒绝文件加入上传队列
return false
}
// 确保底部上传面板保持展开
taskStore.updateViewFlag(true)
// 返回true,允许文件加入上传队列
return true
}
MD5 的两个作用
- 秒传:后端根据 MD5 检索是否已存储相同二进制文件,有则直接生成目录记录,不用上传;
- 分片断点续传:同一文件所有分片共用同一个 uniqueIdentifier,后端区分不同文件的分片。
MD5(f.file, (e, md5) => {})第二个参数就是把一段函数代码,当成普通参数传给 MD5 工具方法,和传数字、字符串本质一样,JS 函数是一等公民,可以随便当参数传递。不是传类,JS 这里传的就是「可执行函数」,业内叫回调函数 callback。
前端 MD5 计算
MD5 工具在 src/utils/md5.js:
export function MD5(file, callback) {
// 分片读取大小:2MB = 2 * 1024 * 1024 = 2097152 字节
const chunkSize = 2097152
// 计算文件一共要拆分成多少块读取
const chunks = Math.ceil(file.size / chunkSize)
// SparkMD5:专门用来计算ArrayBuffer二进制数据MD5的工具实例
const spark = new SparkMD5.ArrayBuffer()
// 浏览器内置文件读取对象,异步读取本地文件二进制
const fileReader = new FileReader()
// 当前读到第几块分片,初始0
let currentChunk = 0
// 文件单块读取成功完成的回调
fileReader.onload = function (e) {
// 将本次读取到的二进制数据追加到MD5计算器中
spark.append(e.target.result)
// 读完一块,块计数+1
currentChunk++
// 判断是否还有剩余分片没读
if (currentChunk < chunks) {
// 还有分片,继续读取下一块
loadNext()
} else {
// 所有分片读取完毕,算出最终MD5
// 第一个参数null=无错误,第二个是md5结果字符串,传给外部回调
callback(null, spark.end())
}
}
// 文件读取发生错误(文件损坏、权限不足等)
fileReader.onerror = function (e) {
// 直接把错误对象传给外部回调,只传一个参数
callback(e)
}
//读取下一块文件分片的内部函数
function loadNext() {
// 计算本次分片起始字节位置
const start = currentChunk * chunkSize
// 结束位置,防止超出文件总大小
const end = Math.min(start + chunkSize, file.size)
// file.slice:截取文件指定区间二进制分片
// readAsArrayBuffer:以二进制数组形式读取分片
// FileReader 是浏览器自带读取文件的 API,读取文件是异步操作:
// // 调用 fileReader.readAsArrayBuffer(分片) 只是告诉浏览器 “去读这块文件”,代码不会卡住等待读取完成;
// 浏览器读完二进制分片后,会自动触发 onload 事件;
// 你提前给 onload 赋值一个函数,浏览器读完后自动执行这个函数,把读取结果通过参数 e 传给你;
// 参数 e 是浏览器原生事件对象,e.target.result 就是本次读取到的文件二进制数组。
fileReader.readAsArrayBuffer(file.slice(start, end))
}
// 函数入口,直接启动读取第一块分片
loadNext()
}
fileReader.onload = function(e){} —— 浏览器原生事件回调
FileReader 是浏览器自带读取文件的 API,读取文件是异步操作:
- 你调用
fileReader.readAsArrayBuffer(分片)只是告诉浏览器 “去读这块文件”,代码不会卡住等待读取完成; - 浏览器读完二进制分片后,会自动触发
onload事件; - 你提前给
onload赋值一个函数,浏览器读完后自动执行这个函数,把读取结果通过参数e传给你; - 参数
e是浏览器原生事件对象,e.target.result就是本次读取到的文件二进制数组。
内部最后 callback(null, spark.end()) —— 你业务传入的 MD5 回调
这个 callback 是最外层调用 MD5(file, (e, md5)=>{}) 时,你传进去的箭头函数。
流程串联:
- 浏览器文件读取完成 → 进入
onload回调; - 判断所有分片全部读取完毕;
- 手动调用
callback(无错误, MD5结果); - 触发你页面写的
(e, md5) => {}逻辑,拿到 md5 做秒传校验。
注意:MD5 计算块大小是 2MB。上传分片大小是 1MB。这两个不是同一个概念。
MD5 的作用是生成 identifier:同一个文件内容 -> 同一个 MD5 -> 同一个 identifier。后端用它判断秒传和断点续传。
uploader 内部事件触发,参数携带选中的所有文件:
- 关闭上传弹窗
uploadDialogVisible.value = false - 循环每个文件:
- 先暂停上传
f.pause(),等待MD5和秒传校验 - 判断文件大小超限,抛出异常拦截
- 组装上传任务对象,存入
taskStore(全局进度面板)
- 先暂停上传
- 计算文件MD5唯一值(用来秒传、分片标识)
- 调用后端秒传接口
secUpload:- 后端存在相同MD5文件:直接秒传,取消上传、刷新文件列表
- 无匹配文件:
f.resume()恢复分片上传,等待分片自动发送
步骤 6 分片自动传输 + 实时进度监听 uploadProgress
秒传失败后执行:f.resume()。这时 simple-uploader 会自动做这些事:
- 把文件按 1MB 切成多个分片
- 并发上传 3 个分片
- 每个分片请求 /file/chunk-upload
- 上传过程中触发 fileProgress
- 单个分片成功后触发 fileSuccess
分片上传接口只负责保存“文件的一部分”。它不会马上生成最终文件,也不会马上让文件出现在文件列表里。因为这时后端只有:
第 1 块
第 2 块
第 3 块
...
还没有完整文件。
上传过程中会触发:uploader.on('fileProgress', uploadProgress)。它主要更新任务面板:
上传百分比
上传速度
已上传大小
剩余时间
上传状态
这一步只影响前端显示,不会改变后端数据。
const uploadProgress = (rootFile, file, chunk) => {
if (!isAlive) return
const task = taskStore.getUploadTask(file.name)
if (file.isUploading()) {
// 更新任务状态为上传中
taskStore.updateStatus({status:上传中})
// 更新进度、速度、已上传大小、剩余时间存入全局任务仓库
taskStore.updateProcess({percentage, speed, uploadedSize, timeRemaining})
}
}
用户拖拽 / 点击选文件 → uploader.addFiles() → 库触发 filesAdded
→ MD5 计算 + 秒传判断 → f.resume() 开启分片自动上传
→ simple-uploader 内部自动切割分片、并发发请求
- 分片传输中 → 持续触发
fileProgress→uploadProgress更新进度面板 - 单块分片 HTTP 请求成功 → 触发
fileSuccess→fileUploaded - 判断全部分片上传完毕 → 调用
doMerge请求后端合并接口 - 合并成功:刷新文件列表、移除上传任务
步骤 7 单个分片上传成功回调 fileUploaded(判断是否需要合并) 每一块分片 HTTP 请求成功都会触发此函数,不是等全部分片传完才执行
每个分片上传成功后,会触发:uploader.on('fileSuccess', fileUploaded)。注意:fileUploaded 不是“整个文件上传成功”才触发。
在切片模式下,它是:每个分片成功一次,就触发一次。所以它要判断:是不是所有分片都传完了?如果传完了,就请求 merge。
fileUploaded 本身不是推动下一个切片上传的发动机,它只是一个“监听成功结果后的业务回调”。
切片上传时真正负责连续上传的是:simple-uploader 内部队列
fileUploaded 不是接口名,也不是上传方式。fileUploaded 是“上传请求成功后的统一处理函数”。 切片上传: /chunk-upload 成功 → 进入 fileUploaded → res.data 有值 → 判断是否 merge。 普通上传: /upload 成功 → 进入 fileUploaded → res.data 没值 → 直接刷新列表。
* 作用:解析后端返回结果,判断是否全部分片上传完成,需要调用合并分片接口
* @param rootFile 顶层文件封装对象(多文件队列顶层容器,业务基本不用)
* @param file 当前完整文件实例(整个大文件对象,包含所有分片信息)
* @param message 后端分片上传接口返回的原始响应文本字符串
* @param chunk 当前刚上传完成的单个分片对象
*/
const fileUploaded = (rootFile, file, message, chunk) => {
// 防护拦截:组件已经销毁,直接终止,防止页面卸载后回调报错
if (!isAlive) return
// 初始化接收后端返回数据的对象
let res = {}
try {
// 将后端返回的字符串转成JSON对象,方便读取code、data等字段
res = JSON.parse(message)
} catch (e) {
// 转换失败:后端返回不是标准JSON,直接跳过后续判断
}
// 判断后端返回业务成功(code=0 / success=true 代表本次分片上传成功)
if (res.code === 0 || res.success === true) {
// 后端有返回data字段,代表开启了分片上传逻辑
if (res.data) {
// 两种条件满足任意一种,说明所有分片已经上传完毕,需要后端拼接文件
// 条件1:后端主动标记需要合并文件 mergeFlag=true
if (res.data.mergeFlag) {
doMerge(file)
}
// 条件2:后端返回已上传分片列表长度 = 文件总分片数量,代表全部传完
else if (res.data.uploadedChunks && res.data.uploadedChunks.length === file.chunks.length) {
doMerge(file)
}
} else {
// 无data数据 = 未开启分片,小文件一次性上传完成,无需合并
// 从上传队列移除当前文件
if (uploader && typeof uploader.removeFile === 'function') {
uploader.removeFile(file)
}
// 刷新当前目录文件列表,新文件立刻展示在页面
fileStore.loadFileList()
// 修改任务状态为上传成功
taskStore.updateStatus({
filename: file.name,
status: panUtil.fileStatus.SUCCESS.code,
statusText: panUtil.fileStatus.SUCCESS.text
})
// 从全局上传任务列表删除本条任务
taskStore.remove(file.name)
// 如果上传队列没有剩余文件,自动关闭底部上传任务面板
if (uploader && Array.isArray(uploader.files) && uploader.files.length === 0) {
taskStore.updateViewFlag(false)
}
}
} else {
// 后端返回业务失败(比如参数错误、存储失败等)
file.pause() // 暂停该文件所有分片上传
// 更新任务状态为上传失败
taskStore.updateStatus({
filename: file.name,
status: panUtil.fileStatus.FAIL.code,
statusText: panUtil.fileStatus.FAIL.text
})
}
}
doMerge 合并分片函数
- 修改任务状态为「分片中」,进度固定 99%;
- 请求
mergeChunks后端接口,传入文件名、MD5、目标文件夹、文件总大小; - 后端拼接所有分片生成完整文件;
- 合并成功:移除上传任务、刷新文件列表;合并失败标记上传失败。
/**
* 所有分片上传完成后,调用后端接口合并分片
* 触发时机:fileUploaded 判断全部分片上传完毕时调用
* @param {File} file simple-uploader封装的当前完整文件实例
*/
const doMerge = (file) => {
// 组件已销毁直接退出,防止异步回调执行时报错、操作已销毁的页面仓库
if (!isAlive) return
// 根据文件名从全局Pinia任务仓库取出这条文件的上传任务
let uploadTaskItem = taskStore.getUploadTask(file.name)
// 更新上传任务状态:变更为【分片中】
taskStore.updateStatus({
filename: file.name,
status: panUtil.fileStatus.MERGE.code,
statusText: panUtil.fileStatus.MERGE.text
})
// 更新进度面板数值:固定99%,代表分片全部传完正在后台拼接
taskStore.updateProcess({
filename: file.name,
speed: panUtil.translateSpeed(file.averageSpeed),
percentage: 99,
uploadedSize: panUtil.translateFileSize(file.sizeUploaded()),
timeRemaining: panUtil.translateTime(file.timeRemaining())
})
// 调用后端分片合并接口,传递拼接需要的全部参数
mergeChunks({
filename: uploadTaskItem.filename, // 文件原始名称
identifier: uploadTaskItem.target.uniqueIdentifier, // 文件MD5标识,后端匹配分片
parentId: uploadTaskItem.parentId, // 文件存放目标文件夹ID
totalSize: uploadTaskItem.target.size // 文件完整总字节大小
}).then(res => {
// 后端合并分片业务成功
if (res.code === 0 || res.success === true) {
// 从上传器队列移除该文件,不再占用上传队列
if (uploader && typeof uploader.removeFile === 'function') {
uploader.removeFile(file)
}
// 刷新当前目录文件列表,展示刚上传完成的文件
fileStore.loadFileList()
// 修改任务状态为上传成功
taskStore.updateStatus({
filename: file.name,
status: panUtil.fileStatus.SUCCESS.code,
statusText: panUtil.fileStatus.SUCCESS.text
})
// 从底部上传任务面板删除本条任务
taskStore.remove(file.name)
// 如果上传队列没有剩余文件,自动收起底部上传面板
if (uploader && Array.isArray(uploader.files) && uploader.files.length === 0) {
taskStore.updateViewFlag(false)
}
} else {
// 后端返回业务失败(分片缺失、拼接异常等)
file.pause() // 暂停该文件所有上传流程
taskStore.updateStatus({
filename: file.name,
status: panUtil.fileStatus.FAIL.code,
statusText: panUtil.fileStatus.FAIL.text
})
}
}).catch(err => {
// 合并接口网络异常、500、404等网络层面错误
file.pause()
taskStore.updateStatus({
filename: file.name,
status: panUtil.fileStatus.FAIL.code,
statusText: panUtil.fileStatus.FAIL.text
})
})
}
**fileUploaded 是每一个分片上传成功都会触发,不是等全部分片传完才调
4. 什么时候才会执行合并 doMerge(file)?
每一次进入函数,都会判断两个条件之一:
- 后端返回 mergeFlag: true
- 后端返回 uploadedChunks 已上传分片数组长度 = 文件总分片数量
- 为什么会出现
res.data为空、不需要合并的场景 根源来自你配置的两套上传接口:
target: function (file, chunk) {
if (panUtil.getChunkUploadSwitch()) {
return '/api/v1/files/file/chunk-upload' // 分片上传接口
}
return '/api/v1/files/file/upload' // 普通整文件上传接口
}
- 有 res.data → 当前走的是分片上传接口,存在多块分片,传完必须合并;
- 无 res.data → 当前走的是普通单文件上传,一次性上传完成,直接标记成功,跳过合并逻辑。
步骤 8 关闭弹窗 closeDialog
const closeDialog = () => {
uploadDialogVisible.value = false
assignFlag.value = false
// 解绑DOM拖拽、点击监听
uploader.unAssignBrowse()
uploader.unAssignDrop()
}
销毁DOM绑定的拖拽监听,避免内存堆积。
步骤8:页面/组件销毁 onUnmounted(最后执行)
isAlive = false:所有上传回调第一行判断,组件销毁后不再执行逻辑,防止控制台报错uploader.cancel():终止所有正在上传的分片请求- 再次解绑所有DOM拖拽监听
uploader.off():清空uploader自身所有文件生命周期监听(filesAdded/progress等)- 置空
uploader = undefined,释放内存
关键问题解答 拖拽是怎么被代码捕获的?内置了什么监听? 两层监听分开:
- DOM原生拖拽监听(绑定在上传框div上)
由
uploader.assignDrop(dom)内部自动绑定4个原生拖拽事件:dragenter、dragover、dragleave、drop浏览器原生内置事件,只要文件拖入松开,就能拿到文件列表。 - 上传库自定义事件监听(uploader.on)
不属于DOM事件,是库内部的事件总线;
调用
addFiles()添加文件后,主动发射filesAdded事件,执行你写的业务函数。
渲染、监听执行先后顺序
- Vue渲染静态模板DOM → script变量初始化
- onMounted → 创建uploader、注册上传生命周期监听(uploader.on)
- 点击按钮修改响应式变量,Vue新增弹窗DOM
- nextTick等DOM渲染完成 → assignDrop/assignBrowse 绑定DOM拖拽监听
- 用户拖拽/点击 → DOM原生事件捕获文件 → addFiles触发uploader自定义事件
- 执行业务上传逻辑
- 关闭/销毁 → 先解绑DOM拖拽监听,再清空uploader自身事件
5.3. 功能实现
3.1当前前端交互形态
当前页面上传不是一个简单的原生 input 元素,而是基于 simple-uploader.js 包装后的上传组件,页面层面已经具备这些行为:
- 支持点击上传。
- 支持拖拽文件到上传区域。
- 支持一次选择多个文件。
- 上传任务会进入"传输列表"面板。
- 支持暂停、继续、取消和失败后重试。
这里需要明确两个边界:
- 当前上传对话框标题是"文件上传",实际能力也确实只覆盖文件,不包含目录层级上传。
- 当前任务面板状态是由
task.js本地维护的,属于前端任务视图,不是后端单独保存了一份上传任务表。
3.2. 秒传实现
3.2.1. 秒传入口
接口封装:
export function secUpload(data) {
return fileRequest({
url: '/file/sec-upload',
method: 'post',
data
})
}
请求体:
{
"filename": "a.pdf",
"identifier": "文件MD5",
"parentId": "当前目录ID"
}
3.2.2 后端秒传入口
Controller:
@PostMapping("/file/sec-upload")
public Result secUpload(@Validated @RequestBody SecUploadFileParamVO secUploadFileParam) {
// 前端参数转换成业务上下文,同时会解密 parentId、补充当前 userId
SecUploadFileContext context = fileConvertor.secUploadFileParamToSecUploadFileContext(secUploadFileParam);
// 调用业务层判断是否能秒传
boolean result = userFileService.secUpload(context);
if (!result) {
// 秒传未命中,返回 FILE_NOT_EXIT,前端把它当成正常分支继续上传
return Result.error(FILE_NOT_EXIT.getCode(), FILE_NOT_EXIT.getMessage());
}
// 秒传命中
return Result.success("");
}
Service:
/**
* 秒传核心业务方法
* @Override 重写上层接口定义的secUpload方法
* @param context 秒传请求上下文对象,封装前端传参:文件名、md5(identifier)、目标文件夹parentId、当前登录userId
* @return boolean true=秒传成功,false=无匹配文件,需要前端走分片上传
*/
@Override
public boolean secUpload(SecUploadFileContext context) {
// 1. 校验登录用户ID不能为空,无用户直接抛业务异常
if (Objects.isNull(context.getUserId())) {
throw new SystemException("秒传失败:用户ID不能为空");
}
// 2. 根据 用户ID + 文件MD5标识 查询物理文件表FileDO
// 查询:当前系统内有没有已经上传过的一模一样的文件
List<FileDO> fileList = getFilesByUserIdAndIdentifier(context.getUserId(), context.getIdentifier());
// 3. 查询结果为空 → 不存在相同文件,返回false,前端需要正常分片上传
if (EmptyUtil.isEmpty(fileList)) {
return false;
}
// 4. 存在相同MD5文件,取第一条物理文件记录
// 正常设计里同一个 MD5 只会对应一条物理文件记录,
// 即便因为历史原因出现多条,它们指向的文件二进制也完全相同,不影响秒传结果,因此直接取下标 0 的第一条即可,不需要额外排序筛选。
FileDO record = fileList.get(BaseConstant.ZERO_INT);
// 5. 新增用户网盘目录记录 user_file(核心秒传逻辑,不用传文件)
// 不用接收二进制分片,直接复制已有物理文件记录到目标文件夹
// 不是创建数据表,是往已经存在的 user_file(用户文件目录表)里插入一行新数据。
// 它的作用是:给当前登录用户的目标文件夹,挂上一份已经存在的物理文件引用 ——// 用户在网盘里能看到这个文件,但服务器不需要重复存储文件内容,这就是 “秒传” 的核心:只写数据库,不传文件二进制。
saveUserFile(
context.getParentId(), // 文件上传到哪个文件夹ID
context.getFilename(), // 前端上传的文件原名
FolderFlagEnum.NO, // 是否文件夹:NO=普通文件
FileTypeEnum.getFileTypeCode(FileUtil.getFileSuffix(context.getFilename())), // 根据后缀获取文件类型编码
record.getId(), // 已有物理文件主键ID(关联底层文件)
context.getUserId(), // 当前登录用户ID
record.getFileSizeDesc() // 文件大小展示文本
);
// 6. 秒传完成,返回true,前端收到后直接取消上传、刷新文件列表
return true;
}
秒传本质:
不上传文件内容
-> 复用已有 file 记录
-> 新增一条 user_file
当前秒传范围:
同一用户 + 相同 identifier
不是全站跨用户秒传。
3.3. 分片上传实现
当前前端上传参数
当前上传参数集中在 disk-by-cursor/src/utils/common.js 和 UploadButton.vue:
- 分片上传开关:始终开启
- 单片大小:1MB
- 单文件前端大小限制:3GB
- 并发上传分片数:3
testChunks = true
后端 networkdisk-files 模块 application.yml 里还配置了:
spring.servlet.multipart.max-file-size = 4096MBspring.servlet.multipart.max-request-size = 4096MB
所以当前代码层面的真实约束是:
- 页面层面单文件限制:3GB
- 服务端
Multipart上限:4096MB
当前项目上传主线是:
前端选择文件
↓
计算 MD5
↓
秒传失败
↓
进入分片上传
↓
每个分片单独 POST 到后端
↓
后端保存临时分片文件 + file_chunk 分片记录
↓
所有分片上传完成
↓
前端调用 merge 接口
↓
后端合并分片,生成真实文件
↓
保存 file 表
↓
保存 user_file 表
↓
前端刷新文件列表
这里要先分清楚:
chunk-upload:只保存某一个分片,不代表完整文件上传完成。
merge:把所有分片拼成完整文件,才是真正上传完成。
所以文件能不能在页面列表里看到,关键不是 chunk-upload 成功,而是:merge 成功后,后端新增了 user_file 记录,前端又刷新了文件列表。
3.3.1 已上传分片查询
因为 testChunks = true,simple-uploader 在上传分片前,会先请求同一个上传地址的 GET 方法。
前端 API 中也有封装:
export function getUploadedChunks(params) {
return fileRequest({
url: '/file/chunk-upload',
method: 'get',
params
})
}
它的目的不是上传,而是问后端:这个文件的哪些分片已经上传过? 后端查询条件核心是:identifier = 文件 MD5 create_user = 当前用户 expiration_time > 当前时间 也就是说,后端只承认:同一个用户,同一个文件 MD5,还没有过期的分片 返回示例:
{
"success": true,
"data": {
"uploadedChunks": [1, 2, 3]
}
}
后端接口:
@GetMapping("/file/chunk-upload")
public Result<UploadedChunkListVO> getUploadedChunks(@Validated QueryUploadedChunkListParamVO queryUploadedChunkListParam) {
// 参数转换,前端查询参数转业务上下文
QueryUploadedChunksContext context = fileConvertor.queryUploadedChunksParam2QueryUploadedChunksContext(queryUploadedChunkListParam);
// 业务层查询数据库,拿到已上传分片编号集合
List<Integer> uploadedChunkList = userFileService.getUploadedChunkList(context);
// 封装返回给前端
UploadedChunkListVO uploadedChunkListVO = new UploadedChunkListVO();
uploadedChunkListVO.setUploadedChunks(uploadedChunkList);
return Result.success(uploadedChunkListVO);
}
Service:
@Override
public List<Integer> getUploadedChunkList(QueryUploadedChunksContext context) {
// 1. 创建通用查询条件构造器
QueryWrapper queryWrapper = Wrappers.query();
// 只查询数据库chunk_number分片编号字段,减少数据传输
queryWrapper.select("chunk_number");
// 条件1:匹配文件MD5唯一标识,区分不同文件分片
queryWrapper.eq("identifier", context.getIdentifier());
// 条件2:匹配当前登录用户,只查自己上传的分片,隔离多用户数据
queryWrapper.eq("create_user", context.getUserId());
// 条件3:分片有效期大于当前时间,过滤已过期清理的临时分片
queryWrapper.gt("expiration_time", new Date());
// 执行查询,只取出chunk_number字段并强转为Integer分片编号
List<Integer> uploadedChunks = fileChunkService.listObjs(queryWrapper, value -> (Integer) value);
return uploadedChunks;
}
返回示例:
{
"success": true,
"data": {
"uploadedChunks": [1, 2, 3]
}
}
前端用这个判断:
return (objMessage.data.uploadedChunks || []).indexOf(chunk.offset + 1) >= 0
意思是:
如果后端说第 1、2、3 片已经有了,前端本次就跳过这些分片,只传缺失的分片。
3.3.2 分片上传请求
真正上传分片走:
POST /api/v1/files/file/chunk-upload
请求类型是 multipart/form-data,因为它既要传普通字段,也要传文件二进制内容。这里的 file 不是完整文件,而是当前这一小片分片。
| 参数 | 说明 |
|---|---|
file |
当前分片的二进制内容 |
filename |
原始文件名 |
identifier |
文件 MD5,用来标识同一个文件 |
chunkNumber |
当前第几片 |
totalChunks |
总共有多少片 |
currentChunkSize |
当前分片大小 |
totalSize |
完整文件总大小 |
parentId |
上传到哪个目录 |
这里最重要的是:identifier + chunkNumber + userId。它们可以定位“某个用户上传的某个文件的某一个分片”。其中 identifier 和 chunkNumber 来自前端,userId 通常由后端从登录上下文中补充,不能完全相信前端传用户 ID。
Controller 层只做三件事:接收参数、转换上下文、调用业务层。它不负责具体保存分片,也不直接操作数据库。
1. 把前端参数转换成业务上下文
2. 调用 UserFileServiceImpl.chunkUpload(...)
3. 返回 mergeFlag,告诉前端是否可以合并
@PostMapping("/file/chunk-upload")
public Result<FileChunkUploadVO> chunkUpload(
@Validated FileChunkUploadParamVO fileChunkUploadParam
) {
// 参数转换为业务上下文,补充 userId 等信息
FileChunkUploadContext context =
fileConvertor.fileChunkUploadParamToFileChunkUploadContext(fileChunkUploadParam);
// 保存当前分片,并判断是否可以合并
Integer code = userFileService.chunkUpload(context);
// 返回 mergeFlag 给前端
FileChunkUploadVO vo = new FileChunkUploadVO();
vo.setMergeFlag(code);
return Result.success(vo);
}
这里可以看出项目里的典型分层习惯:前端参数对象叫 ParamVO,进入业务层前会被转换成 Context。Context 可以理解成“业务上下文对象”,它把这次业务操作需要的数据都装起来,然后往 Service 里传。这样 Controller 不需要知道太多业务细节。
mergeFlag 是返回给前端的合并标记:
0 = 当前还不能合并
1 = 所有分片已上传完成,可以调用 merge 接口
所以 chunk-upload 的成功只表示“某一片保存成功”,不代表完整文件已经上传完成。只有当前端收到 mergeFlag = 1 后,再调用合并接口,完整文件才会真正生成。
3.3.3 Service 层和分布式锁
进入 Service 后,核心方法是 UserFileServiceImpl.chunkUpload(...):
/**
* 接收前端单块分片上传,保存临时分片记录
* @DistributeLock 分布式锁注解,防止并发重复上传同一块分片
* @param context 分片上传上下文:用户ID、文件MD5、分片序号、分片二进制等
* @return 数字标记mergeFlag,告诉前端是否所有分片已上传完成
*/
// 分布式锁:场景标识FILE_CHUNK_UPLOAD
// 锁key = 当前用户ID + "-" + 文件MD5,同一个用户同一个文件互斥
@DistributeLock(scene = "FILE_CHUNK_UPLOAD", keyExpression = "#context.userId + '-' + #context.identifier")
@Override
public Integer chunkUpload(FileChunkUploadContext context) {
// 转换器:把接口入参上下文转为分片存储专用上下文
FileChunkSaveContext fileChunkSaveContext = fileConvertor.fileChunkUploadContextToFileChunkSaveContext(context);
// 业务层:保存当前这块分片(存文件二进制 + 插入file_chunk分片表记录)
fileChunkService.saveChunkFile(fileChunkSaveContext);
// 返回是否需要合并分片的标识给前端
return fileChunkSaveContext.getMergeFlagEnum().getCode();
}
这里又做了一次转换:FileChunkUploadContext 转成 FileChunkSaveContext。可以这样理解:UploadContext 更靠近接口上传请求,SaveContext 更靠近“保存分片”这个内部业务动作。项目里经常会有这种转换,不是为了绕,而是为了让不同层只关心自己需要的数据。
@DistributeLock 是项目自定义的分布式锁注解,底层通过 AOP + Redisson 实现。它的作用是让“同一个用户上传同一个文件”的分片处理流程串行化,避免并发请求同时改同一批分片数据。
锁 key 的核心是:FILE_CHUNK_UPLOAD#userId-identifier
比如用户 10001 上传 MD5 为 abcxxx 的文件,最终锁 key 类似:FILE_CHUNK_UPLOAD#10001-abcxxx
因为前端配置了并发上传,可能同时传 3 个分片。如果没有锁,就可能出现多个请求同时判断“这个分片不存在”,然后都去写文件、插记录、判断是否合并。加锁后,同一个用户的同一个文件会排队处理;不同用户、不同文件的上传不受影响。
当前锁切面的默认逻辑是:没有配置 waitTime 时会调用 rLock.lock(),也就是等待锁释放后继续执行,不是简单地马上失败。因此这里更准确的说法是:它让同一个文件的分片保存逻辑串行执行,降低并发写入导致的数据混乱风险。
3.3.4 DistributeLockAspect 分布式锁 AOP 切面
@DistributeLock 是项目自定义的分布式锁注解,它不是 Spring 自带注解。它的作用是:在方法执行前自动加锁,方法执行结束后自动释放锁,从而避免多个请求同时操作同一份业务数据。
在执行这个方法前,先去 Redis 抢一把锁。锁的名字由两部分组成:FILE_CHUNK_UPLOAD + 当前用户ID + 当前文件MD5。 同一个用户上传同一个文件时,大家抢的是同一把锁;不同用户或不同文件,抢的是不同锁。
Redis 分布式锁本质上就是“抢着创建同一个 key”:比如锁名是 FILE_CHUNK_UPLOAD#10001-abcmd5,多个请求同时执行类似 SET key value NX PX 30000 的命令,NX 表示“只有这个 key 不存在时才允许创建”,而 Redis 单条命令是原子执行的,所以同一时刻只有一个请求能创建成功,这个请求就算拿到锁;其他请求发现 key 已经存在,就等待或重试。拿到锁的请求执行业务,执行完再删除这个 key,后面的请求才能继续抢。为了防止误删别人的锁,value 通常会放当前线程或请求的唯一标识,释放锁时会先判断“这个锁是不是我的”,是自己的才删除。Redisson 的 rLock.lock() 就是把这些创建 key、等待、自动续期、校验释放等细节封装好了。
/**
* 接收前端单块分片上传,保存临时分片记录
* @DistributeLock 分布式锁注解,防止并发重复上传同一块分片
* @param context 分片上传上下文:用户ID、文件MD5、分片序号、分片二进制等
* @return 数字标记mergeFlag,告诉前端是否所有分片已上传完成
*/
// 分布式锁:场景标识FILE_CHUNK_UPLOAD
// 锁key = 当前用户ID + "-" + 文件MD5,同一个用户同一个文件互斥
@DistributeLock(scene = "FILE_CHUNK_UPLOAD", keyExpression = "#context.userId + '-' + #context.identifier")
@Override
public Integer chunkUpload(FileChunkUploadContext context) {
// 转换器:把接口入参上下文转为分片存储专用上下文
FileChunkSaveContext fileChunkSaveContext = fileConvertor.fileChunkUploadContextToFileChunkSaveContext(context);
// 业务层:保存当前这块分片(存文件二进制 + 插入file_chunk分片表记录)
fileChunkService.saveChunkFile(fileChunkSaveContext);
// 返回是否需要合并分片的标识给前端
return fileChunkSaveContext.getMergeFlagEnum().getCode();
}
这里要先理解一个点:前端配置了并发上传,可能同时上传同一个文件的多个分片。如果多个请求同时进入后端,都去判断分片是否存在、保存分片、插入 file_chunk 记录,就可能出现重复保存、重复插入、合并判断混乱等问题。所以后端用分布式锁把“同一个用户 + 同一个文件 MD5”的上传流程保护起来。
scene 表示锁的业务场景:scene = "FILE_CHUNK_UPLOAD"。它相当于锁 key 的前缀,用来区分不同业务。比如上传分片的锁、删除文件的锁、分享文件的锁,不能都混在一起,否则不同业务可能互相影响。
keyExpression 是真正决定锁粒度的地方:keyExpression = "#context.userId + '-' + #context.identifier"。这里用的是 SpEL 表达式。#context 代表当前方法参数 FileChunkUploadContext context,#context.userId 取当前用户 ID,#context.identifier 取文件 MD5。假设用户 ID 是 10001,文件 MD5 是 abcxxx,最后生成的业务 key 就是:10001-abcxxx
切面里最终会把 scene 和这个 key 拼起来:String lockKey = scene + “#” + key;所以最终 Redis/Redisson 里的锁 key 类似:FILE_CHUNK_UPLOAD#10001-abcxxx。
锁粒度就是“这把锁锁住的范围有多大”。范围越大,越安全,但并发越差。 范围越小,并发越好,但更容易漏掉冲突。
这就决定了锁粒度:同一个用户上传同一个文件时会互斥;不同用户、不同文件的上传不会互相阻塞。注意,这个锁 key 里没有 chunkNumber,所以它锁的不是“同一块分片”,而是“同一个文件的整个分片上传处理流程”。这样更稳,但代价是同一个文件的多个分片在后端会排队处理,前端的并发上传在这段业务逻辑里会被部分串行化。
切面的核心逻辑大概是这样:
@Around("@annotation(com.disk.lock.DistributeLock)")
public Object process(ProceedingJoinPoint pjp) throws Exception {
// 1. 拿到当前被拦截的方法,比如 chunkUpload(...)
Method method = ((MethodSignature) pjp.getSignature()).getMethod();
// 2. 读取方法上的 @DistributeLock 注解
DistributeLock distributeLock = method.getAnnotation(DistributeLock.class);
// 3. 先取固定 key;如果没写固定 key,就解析 keyExpression
String key = distributeLock.key();
if (DistributeLockConstant.NONE_KEY.equals(key)) {
// 4. 解析 SpEL 表达式,例如:
// #context.userId + '-' + #context.identifier
SpelExpressionParser parser = new SpelExpressionParser();
Expression expression = parser.parseExpression(distributeLock.keyExpression());
// 5. 创建表达式上下文,用来存放方法参数
EvaluationContext context = new StandardEvaluationContext();
// 6. 获取方法实参,比如 chunkUpload(FileChunkUploadContext context)
Object[] args = pjp.getArgs();
// 7. 获取方法参数名,比如 ["context"]
StandardReflectionParameterNameDiscoverer discoverer =
new StandardReflectionParameterNameDiscoverer();
String[] parameterNames = discoverer.getParameterNames(method);
// 8. 把参数名和参数值绑定到 SpEL 上下文中
// 这样 SpEL 才能识别 #context.userId
if (parameterNames != null) {
for (int i = 0; i < parameterNames.length; i++) {
context.setVariable(parameterNames[i], args[i]);
}
}
// 9. 执行表达式,得到真正的业务 key
// 例如:10001-abcxxxmd5
key = String.valueOf(expression.getValue(context));
}
// 10. 拼接最终锁 key
// 例如:FILE_CHUNK_UPLOAD#10001-abcxxxmd5
String lockKey = distributeLock.scene() + "#" + key;
// 11. 从 Redisson 中获取一把分布式锁
RLock rLock = redissonClient.getLock(lockKey);
// 12. 加锁;当前项目默认是 lock(),拿不到锁会等待
rLock.lock();
try {
// 13. 真正执行原来的业务方法,也就是 chunkUpload(...)
return pjp.proceed();
} finally {
// 14. 无论业务成功还是失败,最后都释放锁
rLock.unlock();
}
}
这段代码不用死背,理解流程就行:
请求调用 chunkUpload
↓
AOP 发现方法上有 @DistributeLock
↓
读取注解里的 scene 和 keyExpression
↓
解析 SpEL,拿到 userId 和 identifier
↓
拼出 lockKey
↓
通过 Redisson 获取 Redis 分布式锁
↓
拿到锁后,执行真正的 chunkUpload 方法
↓
方法执行完,无论成功还是异常,都在 finally 中释放锁
这里的 pjp.proceed() 就是“继续执行原来的业务方法”。也就是说,chunkUpload(...) 并不是一上来就直接执行,而是先被 AOP 包了一层。AOP 像一个外壳:先加锁,再执行原方法,最后释放锁。
还要注意:这个锁不是数据库事务。锁解决的是“不要让多个请求同时执行同一段逻辑”;事务解决的是“数据库操作要么一起成功,要么一起回滚”。比如这里加了分布式锁,可以避免并发重复保存分片,但如果写物理文件成功了、插入数据库失败了,锁本身不会自动回滚物理文件。事务一般要看有没有 @Transactional,以及数据库操作是否在同一个事务里。
当前项目的锁默认行为也要说准确:如果没有配置 waitTime,切面里调用的是:rLock.lock();这表示拿不到锁时会等待,不是马上失败。只有使用 tryLock(...) 并且等待超时还拿不到锁时,才会抛出获取锁失败异常。
@DistributeLock 通过 AOP 在 chunkUpload 外面包了一层 Redis 分布式锁,让同一个用户上传同一个文件时,分片保存逻辑按顺序执行,避免并发请求同时写分片文件和 file_chunk 记录。
@DistributeLock 是项目自定义分布式锁注解,基于 Redis/Redisson + AOP 实现,作用:多服务器集群环境下,给方法加互斥锁,防止并发重复操作分片。
@DistributeLock(scene = "FILE_CHUNK_UPLOAD", keyExpression = "#context.userId + '-' + #context.identifier")
scene = "FILE_CHUNK_UPLOAD"锁场景分类,用来区分不同业务的锁,方便 Redis 中区分 key,避免不同业务锁冲突。keyExpression = "#context.userId + '-' + #context.identifier"#context是SpEL 表达式语法,代表当前方法入参FileChunkUploadContext context;#context.userId取当前登录用户 ID;#context.identifier取文件 MD5;- 最终拼接锁 Key:
10001-xxxxmd5xxxx。
锁粒度作用 只有同一个用户 + 同一个文件 MD5 才会互斥:
- 用户 A 上传文件 1,并发多次上传同一块分片 → 锁住,只能串行执行,防止重复插入分片、重复写磁盘文件;
- 用户 A 传文件 1、用户 B 传文件 2 → Key 不一样,互不阻塞,不影响性能。
底层执行流程(AOP 环绕通知)
- 调用
chunkUpload方法前,AOP 切面拦截; - 解析 SpEL 表达式,拼接出 Redis 锁 key;
- 尝试获取 Redis 分布式锁:
- 拿到锁:执行分片保存逻辑
saveChunkFile; - 拿不到锁:抛出异常 / 直接返回,拒绝重复上传;
- 拿到锁:执行分片保存逻辑
- 方法执行完毕(正常 / 异常),AOP 自动释放锁,不用手动写 unlock 代码,无代码侵入。
3.3.5 后端保存分片
真正保存分片在 FileChunkServiceImpl.saveChunkFile(...)。这个方法的核心思想是“幂等”。幂等就是:同一个分片重复上传多次,最终效果应该和上传一次差不多。因为上传过程中可能网络重试、前端重复请求,所以后端不能每次收到分片都无脑写文件和插数据库。
保存分片的核心方法可以理解成:
清理无效旧记录
↓
判断当前分片是否已经上传
↓
如果已上传:不重复保存,只判断是否可以合并
↓
如果未上传:保存物理分片,再插入 file_chunk 记录
↓
统计有效分片数量,判断是否全部上传完成
核心代码:
@Override
public void saveChunkFile(FileChunkSaveContext context) {
// 第一步:清理当前文件对应的无效/过期分片记录(比如传了一半放弃、超时过期的分片)
clearInvalidChunkRecord(context);
// 第二步:检查当前这个分片,是不是已经上传过了(断点续传:避免重复上传同一个分片)
if (checkChunkUploaded(context)) {
// 分片已经存在 → 不用重复存文件,直接判断是否所有分片都传完了
doJudgeMergeFile(context);
return; // 直接结束,不执行后面的存文件逻辑
}
// 第三步:分片没传过 → 真正保存这个分片
// 包含两步:1. 把分片的二进制文件存到磁盘/云存储;2. 往数据库插一条分片记录
doSaveChunkFile(context);
// 第四步:保存完当前分片后,再次判断:所有分片是不是都传齐了?要不要合并?
doJudgeMergeFile(context);
}
判断分片是否已上传,本质上就是查 file_chunk 表:
where identifier = 当前文件MD5
and chunk_number = 当前分片编号
and create_user = 当前用户
and expiration_time > 当前时间
如果查到了记录,说明这个分片已经有效存在,本次请求就不再重复写物理文件,也不再重复插入 file_chunk。这就是断点续传和重复上传保护的基础。
判断是否可以合并,则是统计当前文件已经有多少个有效分片
如果数据库里有效分片数量等于前端传来的 totalChunks,说明所有分片都齐了,于是设置 mergeFlag = READY。前端收到这个标记后,才会调用 /file/merge 合并接口。
/**
* 检查【当前这个分片】是否已经上传过了
* 核心作用:实现断点续传——同一个分片不用重复传,节省时间
* @return true=已经传过了;false=还没传
*/
private boolean checkChunkUploaded(FileChunkSaveContext context) {
// MyBatis-Plus 条件构造器:拼接查询SQL的条件
QueryWrapper<FileChunkDO> queryWrapper = Wrappers.query();
// 条件1:同一个文件(MD5标识相同,就是同一个文件的分片)
queryWrapper.eq("identifier", context.getIdentifier());
// 条件2:同一个分片号(比如第3块)
queryWrapper.eq("chunk_number", context.getChunkNumber());
// 条件3:同一个用户上传的
queryWrapper.eq("create_user", context.getUserId());
// 条件4:分片记录还没过期(过期的分片是无效的,不算)
queryWrapper.gt("expiration_time", new Date());
// 统计符合条件的记录数,大于0就说明这个分片已经传过了
return count(queryWrapper) > 0L;
}
/**
* 判断:当前文件的所有分片是不是都传完了?要不要触发合并?
* 逻辑:数一下有效分片总数 = 前端传的总分片数吗?相等就说明传齐了
*/
private void doJudgeMergeFile(FileChunkSaveContext context) {
QueryWrapper<FileChunkDO> queryWrapper = Wrappers.query();
// 同一个文件(同一个MD5标识)
queryWrapper.eq("identifier", context.getIdentifier());
// 同一个用户
queryWrapper.eq("create_user", context.getUserId());
// 只统计没过期的有效分片
queryWrapper.gt("expiration_time", new Date());
// 查询当前文件一共已经传了多少个有效分片
long count = count(queryWrapper);
// 如果已传分片数 = 文件总分片数 → 说明全传完了,标记为「可以合并」
// 前端收到这个标记,就会调用合并接口
if (count == context.getTotalChunks().longValue()) {
context.setMergeFlagEnum(MergeFlagEnum.READY);
}
}
保存新分片时,代码又拆成两步:
doStoreFileChunk(context) 负责把分片二进制内容写到存储系统里;doSaveRecord(context) 负责往 file_chunk 表插入分片记录。也就是说,先有物理文件路径,再把这个路径保存到数据库。
/**
* 保存分片的总调度:先存文件到磁盘,再存记录到数据库
*/
private void doSaveChunkFile(FileChunkSaveContext context) {
// 1. 把分片的二进制文件,真正存到磁盘/云存储里
doStoreFileChunk(context);
// 2. 往数据库的分片记录表,插一条记录,标记「这个分片存好了、存在哪」
doSaveRecord(context);
}
物理保存时,业务层不会直接写本地文件,而是调用统一的存储引擎接口:
这里的 context.getFile().getInputStream() 是从 MultipartFile 中拿到分片输入流。输入流可以理解为“边读边写”的文件数据通道,不需要一次性把整个分片全部加载到内存里。
private void doStoreFileChunk(FileChunkSaveContext context) {
try {
// 类型转换:把分片上传上下文,转成存储引擎需要的上下文对象
StoreFileChunkContext storeFileChunkContext = fileConvertor.fileChunkSaveContext2StoreFileChunkContext(context);
// 把分片的文件输入流(前端传过来的二进制数据)放进上下文
// .getInputStream()MultipartFile 提供的方法,生成文件输入流 InputStream。
// 作用:以流式读取分片的二进制数据,不会一次性把整个分片加载进内存,适合大分片,避免 OOM 内存溢出。
// storeFileChunkContext.setInputStream(输入流)
// 把上面拿到的分片二进制输入流,赋值给存储上下文对象的流字段。
// 后面底层保存分片的方法会读取这个 inputStream,循环读字节写入服务器本地临时文件。
storeFileChunkContext.setInputStream(context.getFile().getInputStream());
// 调用存储引擎,把分片写入磁盘/云存储
// 和之前下载的 storageEngine 是同一个,支持本地、OSS等多种存储方式
storageEngine.storeChunk(storeFileChunkContext);
// 存储完成后,把分片的真实存储路径,放回上下文,后面存数据库要用
context.setRealPath(storeFileChunkContext.getRealPath());
} catch (IOException e) {
// 文件读写异常,打印日志并抛出业务异常
e.printStackTrace();
throw new SystemException("文件分片上传失败");
}
}
storageEngine.storeChunk(...) 是一个接口调用。它表面上看不出具体写到哪里,真正走哪个实现类取决于 Spring 运行时注入的 StorageEngine Bean。当前项目里 LocalStorageEngine 上有 @Primary,所以默认会优先注入本地存储实现。
/**
* 存储引擎层:保存分片的入口
* 模板方法模式:先校验参数,再由具体子类实现真正的存储逻辑
*/
@Override
public void storeChunk(StoreFileChunkContext context) throws IOException {
// 先校验参数:路径、输入流不能为空
checkStoreFileChunkContext(context);
// 真正写文件:由子类实现(本地存储/阿里云OSS/MinIO等不同存储方式)
doStoreChunk(context);
}
//`doStoreChunk` 具体会执行哪个实现,取决于 `storageEngine` 在运行时实际注入的是哪个 `StorageEngine` Bean。
// 当前项目中,`LocalStorageEngine` 类上同时标注了 `@Component` 和 `@Primary`,
// 因此当 Spring 容器中存在多个 `StorageEngine` 实现类时,会优先选择 `LocalStorageEngine` 注入到 `storageEngine` 字段中。
// 所以在默认情况下,调用 `storageEngine.storeChunk(...)` 最终会走到 `LocalStorageEngine#doStoreChunk(...)`。
protected abstract void doStoreChunk(StoreFileChunkContext context) throws IOException;
这段代码做了四件事:先拿到分片存储根目录;再用 identifier + chunkNumber 生成当前分片的真实路径;然后把输入流写入这个文件;最后把 realPath 回写到上下文里。这个回写很关键,因为上层还要把 realPath 保存到 file_chunk 表,后续合并文件时就靠这些路径找到每个分片。
/**
* 本地存储引擎:真正把分片文件写入服务器硬盘
* 这是父类抽象方法 doStoreChunk 的具体实现(模板方法模式)
* @param context 存储分片的上下文,包含分片输入流、MD5标识、分片号、总大小等信息
*/
@Override
protected void doStoreChunk(StoreFileChunkContext context) throws IOException {
// 1. 从配置里读取:分片文件的根目录(所有分片都存在这个总文件夹下面)
String basePath = config.getRootFileChunkPath();
// 2. 生成当前分片的完整存储路径
// 规则:根目录 / 文件MD5值 / 分片编号
// 好处:同一个文件的所有分片都放在同一个MD5文件夹里,后续合并文件时好找
String realFilePath = FileUtil.generateStoreFileChunkRealPath(
basePath,
context.getIdentifier(), // 文件MD5唯一标识
context.getChunkNumber() // 当前是第几号分片
);
// 3. 核心动作:把前端传过来的分片输入流(二进制数据),写入到硬盘的目标文件里
FileUtil.writeStreamToFile(
context.getInputStream(), // 分片的二进制输入流(从前端请求里拿到的)
new File(realFilePath), // 要写入的目标文件(硬盘上的位置)
context.getTotalSize() // 分片总大小(工具内部用来控制读写缓冲区)
);
// 4. 把生成好的真实存储路径放回上下文,后面存数据库分片记录要用
context.setRealPath(realFilePath);
}
分片根目录来自配置类:
@Data
@Component
//ConfigurationProperties:Spring Boot 的注解,自动从配置文件里读参数。
// 比如你在 application.yml 里写 com.disk.file.storage.engine.local.rootFileChunkPath = /data/chunks,它就会用你配置的路径,不写就用默认值。
@ConfigurationProperties("com.disk.file.storage.engine.local")
public class LocalStorageEngineConfig {
/**
/** * 完整文件的存储根路径(合并后的完整文件存在这)
* 不配置的话,用工具类生成的默认路径
*/
private String rootFilePath = FileUtil.generateDefaultStoreFileRealPath();
/**
* 分片文件的存储根路径(所有分片都存在这个总目录下)
* 不配置的话,默认存在 用户主目录/coder-pan/chunks
*/ private String rootFileChunkPath = FileUtil.generateDefaultStoreFileChunkRealPath();
}
如果 application.yml 里没有配置路径,就走默认路径。默认分片路径大致是:用户主目录 / coder-pan / chunks
/**
* 生成默认的文件分片的存储路径前缀
*默认位置:系统用户主目录 / coder-pan / chunks
* @return
*/
public static String generateDefaultStoreFileChunkRealPath() {
// System.getProperty("user.home") 获取当前操作系统的用户主目录
return new StringBuffer(System.getProperty("user.home"))
.append(File.separator) // 系统文件分隔符(Windows是\,Linux是/,自动适配)
.append("coder-pan")// 项目总文件夹
.append(File.separator)
.append("chunks") // 分片专属文件夹
.toString();
}
最后保存 file_chunk 记录:
private void doSaveRecord(FileChunkSaveContext context) {
FileChunkDO record = new FileChunkDO();
// 分片记录主键
record.setId(IdUtil.get());
// 文件唯一标识
record.setIdentifier(context.getIdentifier());
// 分片真实存储路径
record.setRealPath(context.getRealPath());
// 分片编号
record.setChunkNumber(context.getChunkNumber());
// 分片过期时间,默认 1 天
record.setExpirationTime(
DateUtil.offsetDay(new Date(), config.getChunkFileExpirationDays())
);
// 创建人/更新人
record.setCreateUser(context.getUserId());
record.setUpdateUser(context.getUserId());
// 插入 file_chunk 表
if (!save(record)) {
throw new SystemException("文件分片上传失败");
}
}
file_chunk 表不是最终文件表,它只是临时记录表。它记录“某个用户的某个文件 MD5,已经上传了哪些分片,每个分片存在哪里,什么时候过期”。等后面合并成功后,这些临时分片记录会被删除,真正长期保存的是 file 表和 user_file 表。
3.5 后端合并入口
前面分片上传阶段,后端只是把每一块分片保存下来,并在 file_chunk 表里记录每个分片的存储路径。这个阶段并不会生成完整文件。真正把多个分片拼成完整文件,是在前端确认所有分片都上传完成后,额外调用合并接口:
POST /api/v1/files/file/merge
所以要先分清楚两个接口的职责:
/file/chunk-upload:保存单个分片,只负责“攒分片”
/file/merge:合并所有分片,生成完整文件,并保存用户文件记录
分片上传接口里的 doJudgeMergeFile(...) 只是判断“分片数量是否已经齐了”,如果齐了就返回 mergeFlag = READY 给前端。它不是真正合并。真正合并发生在 /file/merge 接口。
完整合并流程可以理解成:
前端所有分片上传完成
↓
前端调用 /file/merge
↓
Controller 接收 filename、identifier、parentId、totalSize 等参数
↓
UserFileService.mergeFile(...)
↓
FileService 查询 file_chunk 表,拿到所有分片路径
↓
按 chunkNumber 排序
↓
调用 StorageEngine.mergeFile(...)
↓
本地存储引擎按顺序拼接分片,生成完整文件
↓
保存 file 表,记录真实物理文件
↓
保存 user_file 表,让文件出现在用户目录
↓
删除 file_chunk 临时分片记录
3.5.1 Controller 合并入口
后端合并入口在 UserFileController:
/**
* 文件分片合并
*/
@PostMapping("/file/merge")
public Result mergeFile(@Validated @RequestBody FileChunkMergeParamVO fileChunkMergeParam) {
// 1. 前端请求参数转换成后端业务上下文
FileChunkMergeContext context =
fileConvertor.fileChunkMergeParamVOToFileChunkMergeContext(fileChunkMergeParam);
// 2. 调用业务层执行合并
userFileService.mergeFile(context);
// 3. 合并成功,返回统一成功响应
return Result.success("");
}
Controller 仍然不处理复杂业务,它只做参数接收和业务转发。前端传来的参数一般包括:
filename:原始文件名
identifier:文件 MD5
parentId:上传到哪个目录
totalSize:完整文件大小
这里的 identifier 非常重要,因为后端要靠它去 file_chunk 表里找到当前文件的所有分片。
3.5.2 UserFileService 合并主流程
真正的业务入口是 UserFileServiceImpl.mergeFile(...):
@Override
public void mergeFile(FileChunkMergeContext context) {
// 1. 合并物理分片,并保存 file 表记录
mergeFileChunkAndSaveFile(context);
// 2. 保存 user_file 表记录,让文件出现在用户目录中
saveUserFile(
context.getParentId(),
context.getFilename(),
FolderFlagEnum.NO,
FileTypeEnum.getFileTypeCode(FileUtil.getFileSuffix(context.getFilename())),
context.getRecord().getId(),
context.getUserId(),
context.getRecord().getFileSizeDesc()
);
}
这段代码是合并流程的总入口,可以拆成两件事:
第一件事:生成真实文件,并保存 file 表
第二件事:保存 user_file 表,让用户目录能看到这个文件
这里一定要理解 file 表和 user_file 表的区别:
file 表:记录真实物理文件,比如 real_path、size、MD5、后缀
user_file 表:记录用户目录中的文件,比如 user_id、parent_id、filename、real_file_id
如果只保存 file 表,说明服务器上确实有这个文件,但用户页面看不到。只有保存了 user_file 表,文件才会出现在用户的目录列表中。
3.5.3 合并分片并保存 file 表
UserFileServiceImpl 自己不直接合并文件,而是先把上下文转换成 FileChunkMergeAndSaveContext,再交给 FileService:
/**
* 合并分片主入口方法
* @param context 合并分片请求上下文,包含文件MD5、用户ID、文件名、总大小等
*/
private void mergeFileChunkAndSaveFile(FileChunkMergeContext context) {
// 转换器:对外合并参数实体 → 内部合并存储专用上下文
FileChunkMergeAndSaveContext fileChunkMergeAndSaveContext = fileConvertor.fileChunkMergeContextToSaveContext(context);
// 执行分片合并+物理文件入库完整逻辑
fileService.mergeFileChunkAndSaveFile(fileChunkMergeAndSaveContext);
// 将生成的物理文件记录回写给上层上下文,供外层创建user_file目录记录使用
context.setRecord(fileChunkMergeAndSaveContext.getRecord());
}
这里的 context.setRecord(...) 很关键。因为后面保存 user_file 表时,需要用到 file 表的主键 ID:
user_file.real_file_id = file.id
也就是说,user_file 通过 real_file_id 关联到真实物理文件。
3.5.4 FileService 查询分片并合并
FileServiceImpl.mergeFileChunkAndSaveFile(...) 做两件事:
@Override
public void mergeFileChunkAndSaveFile(FileChunkMergeAndSaveContext context) {
// 1. 查询分片并合并成完整文件
doMergeFileChunk(context);
// 2. 保存完整文件的 file 表记录
FileDO record = doSaveFile(
context.getFilename(),
context.getRealPath(),
context.getTotalSize(),
context.getIdentifier(),
context.getUserId()
);
// 3. 回写 file 表记录
context.setRecord(record);
}
先合并物理文件,再保存 file 表。原因是:file 表里的 real_path 必须是合并后的完整文件路径,如果文件没有合并成功,就不应该保存完整文件记录。
真正合并分片的方法是 doMergeFileChunk(...):
/**
* 1. 查询当前用户该文件所有有效分片
* 2. 按分片序号排序,拿到所有分片磁盘路径
* 3. 调用存储引擎按顺序拼接成分整文件
* 4. 拼接成功后物理删除所有临时分片文件 + 删除数据库分片记录
* @param context 分片合并上下文
*/
private void doMergeFileChunk(FileChunkMergeAndSaveContext context) {
// 构建分片查询条件
QueryWrapper<FileChunkDO> queryWrapper = Wrappers.query();
// 匹配文件唯一MD5标识
queryWrapper.eq("identifier", context.getIdentifier());
// 匹配当前登录用户,隔离他人分片
queryWrapper.eq("create_user", context.getUserId());
// 只查未过期分片,过期分片失效不参与合并
queryWrapper.ge("expiration_time", new Date());
// 查询该文件全部分片数据库记录
List<FileChunkDO> chunkRecoredList = fileChunkService.list(queryWrapper);
// 没有分片直接抛异常,无法合并
if (CollectionUtils.isEmpty(chunkRecoredList)) {
throw new SystemException("该文件未找到分片记录");
}
// 流式处理:按分片编号升序排序 → 提取每个分片的磁盘真实路径
List<String> realPathList = chunkRecoredList.stream()
.sorted(Comparator.comparing(FileChunkDO::getChunkNumber)) // 分片从小到大排序,顺序不能乱
.map(FileChunkDO::getRealPath) // 取出每个分片存在服务器的文件路径
.collect(Collectors.toList());
try {
// 组装存储引擎合并文件入参
MergeFileContext mergeFileContext = new MergeFileContext();
mergeFileContext.setFilename(context.getFilename());
mergeFileContext.setIdentifier(context.getIdentifier());
mergeFileContext.setUserId(context.getUserId());
mergeFileContext.setRealPathList(realPathList); // 有序分片路径集合
// 调用底层存储引擎,按顺序读取所有分片,拼接生成完整大文件
storageEngine.mergeFile(mergeFileContext);
// 将合并完成后的完整文件磁盘路径回填上下文,后续入库使用
context.setRealPath(mergeFileContext.getRealPath());
} catch (IOException e) {
e.printStackTrace();
// IO异常:磁盘读写失败、分片缺失、权限不足等,抛出业务异常给前端
throw new SystemException("文件分片合并失败");
}
// 收集所有分片数据库主键ID
List<Long> fileChunkRecordIdList = chunkRecoredList.stream()
.map(FileChunkDO::getId)
.collect(Collectors.toList());
// 物理删除临时分片文件 + 删除file_chunk表分片记录,释放磁盘与数据库空间
fileChunkService.removeChunkRecordsPhysically(fileChunkRecordIdList);
}
这里最重要的是这一步:
.sorted(Comparator.comparing(FileChunkDO::getChunkNumber))
分片必须按 chunkNumber 从小到大排序后再合并。如果顺序乱了,最终文件内容也会乱,文件可能打不开或损坏。
可以把合并理解成:
第 1 片内容 + 第 2 片内容 + 第 3 片内容 + ... = 完整文件
所以顺序绝对不能错。
3.5.5 StorageEngine 真正拼接文件
storageEngine.mergeFile(...) 仍然是调用存储引擎接口。当前项目默认注入的是 LocalStorageEngine,所以最终会走本地存储的 doMergeFile(...):
@Override
protected void doMergeFile(MergeFileContext context) throws IOException {
// 1. 读取配置:文件存储根目录(服务器统一文件存放根路径)
String basePath = config.getRootFilePath();
// 2. 根据根目录+原始文件名,生成合并后完整文件的最终磁盘绝对路径
String realFilePath = FileUtil.generateStoreFileRealPath(basePath, context.getFilename());
// 3. 在磁盘创建空白完整文件,准备后续追加写入分片内容
FileUtil.createFile(new File(realFilePath));
// 4. 获取排好序的所有分片磁盘路径(已按chunkNumber从小到大排序,顺序不能乱)
List<String> chunkPaths = context.getRealPathList();
// 5. 循环每一块分片,依次把分片二进制追加写入完整文件末尾
for (String chunkPath : chunkPaths) {
// 把当前分片文件内容,追加到目标完整文件尾部
FileUtil.appendWrite(Paths.get(realFilePath), new File(chunkPath).toPath());
}
// 6. 全部分片拼接完成,批量删除磁盘上所有临时分片文件,释放磁盘空间
FileUtil.deleteFiles(chunkPaths);
// 7. 将合并后完整文件的磁盘路径回填上下文,供上层入库FileDO表使用
context.setRealPath(realFilePath);
}
这段代码做的事情非常直观:先创建一个空的最终文件,然后按顺序读取每个分片,把分片内容追加到最终文件末尾。所有分片追加完成后,一个完整文件就生成了。
注意这里有两个“删除”:
FileUtil.deleteFiles(chunkPaths):删除磁盘上的临时分片文件
removeChunkRecordsPhysically(...):删除数据库 file_chunk 表里的临时记录
一个删物理文件,一个删数据库记录。两者都属于清理临时分片资源。
3.5.6 保存 file 表
userFileService.mergeFile(...) 里先保存 file,再保存 user_file,在 UserFileServiceImpl.java 里调用保存
物理文件合并成功后,后端会保存 file 表:
private FileDO doSaveFile(
String filename,
String realPath,
Long totalSize,
String identifier,
Long userId
) {
// 1. 组装 file 表实体
FileDO record = assembleFileDO(
filename,
realPath,
totalSize,
identifier,
userId
);
// 2. 插入 file 表
if (!save(record)) {
// 这里项目预留了失败后删除物理文件、记录日志的处理
}
return record;
}
file 表保存的是“真实物理文件”的信息,常见字段包括:
id:物理文件 ID
filename:文件名
real_path:完整文件真实存储路径
file_size:文件大小
file_size_desc:格式化后的文件大小
file_suffix:文件后缀
file_preview_content_type:预览类型
identifier:文件 MD5
create_user:创建人
identifier 也就是文件 MD5。它不仅用于分片上传,还可以用于秒传。比如另一个用户上传相同文件时,后端可以根据 MD5 判断这个文件已经存在,直接复用已有 file 记录,不必重复上传物理文件。
3.5.7 保存 user_file 表
file 表是在 FileServiceImpl.java 保存的:
file 表保存完后,还要保存 user_file 表:
private Long saveUserFile(
Long parentId,
String filename,
FolderFlagEnum folderFlagEnum,
Integer fileType,
Long realFileId,
Long userId,
String fileSizeDesc
) {
UserFileDO entity = new UserFileDO();
entity.setId(IdUtil.get());
entity.setUserId(userId);
entity.setParentId(parentId);
entity.setRealFileId(realFileId);
entity.setFilename(filename);
entity.setFolderFlag(folderFlagEnum.getCode());
entity.setFileSizeDesc(fileSizeDesc);
entity.setFileType(fileType);
entity.setDeleted(DeleteEnum.NO.getCode());
entity.setCreateUser(userId);
entity.setUpdateUser(userId);
// 处理同目录重名文件
handleDuplicateFilename(entity);
if (!save(entity)) {
throw new SystemException("保存文件信息失败");
}
return entity.getId();
}
user_file 表保存的是“用户目录里的文件”。用户页面看到的文件列表,主要查的是这张表,而不是直接查 file 表。
它的关键字段可以这样理解:
user_id:这个文件属于哪个用户
parent_id:这个文件在哪个目录下
real_file_id:关联 file 表的真实物理文件 ID
filename:用户看到的文件名
folder_flag:是否文件夹
file_type:文件类型
deleted:是否删除
所以文件上传完成后,页面能看到文件,是因为:
完整文件已经合并
↓
file 表保存了真实物理文件
↓
user_file 表保存了用户目录记录
↓
前端刷新文件列表
↓
列表接口查到 user_file
↓
页面展示新文件
3.5.8 AI 预热
AI 预热是在文件服务保存文件并提交事务后,给 MQ 发送一条文档预热消息;AI 服务的 aiWarmupConsumer() 消费这条消息后,后台依次执行建向量索引、生成默认摘要、生成默认标签,并把结果写入 PostgreSQL:索引写入 ai_document_index 和 ai_document_chunk_vector,摘要/标签写入 ai_document_result。预热完成后不会主动通知前端或生产者,前端打开 AI 抽屉时仍然是主动请求摘要和标签;如果预热已完成就直接读缓存返回,如果还没完成就现场生成,因此预热本质上是提升首次打开速度的后台优化。
当前项目后端保留了普通上传 upload(...) 方法,并且 saveUserFile(...) 也支持普通上传完成后的目录落库。但按当前前端实现,getChunkUploadSwitch() 固定返回 true,所以页面上传实际不会走普通 /file/upload 主链路。
当前真实使用的上传路径是:
- 前端选择文件后,先计算 MD5。
- 调用秒传接口
/file/sec-upload。 - 如果秒传命中,后端
secUpload(...)直接复用已有file记录,并调用saveUserFile(...)新增user_file。 - 如果秒传未命中,前端恢复 simple-uploader 分片上传,分片请求走
/file/chunk-upload。 - 所有分片上传完成后,前端调用
/file/merge。 - 后端
mergeFile(...)合并分片,生成真实file记录,再调用saveUserFile(...)新增user_file。 saveUserFile(...)成功插入普通文件记录后,触发DocumentAiInitializer.scheduleInitialize(...),进入 AI 预热链路。
因此,按当前页面实际行为,AI 预热主要由两条路径触发:
- 秒传命中:
secUpload -> saveUserFile -> scheduleInitialize - 分片合并完成:
mergeFile -> saveUserFile -> scheduleInitialize
普通上传:upload -> saveUserFile -> scheduleInitialize 这条链路在后端存在,但当前前端默认不会走,除非以后把getChunkUploadSwitch() 改成 false,或者有其他入口直接调用 /file/upload。
核心理解
saveUserFile(...) 是上传链路的统一收口点。普通上传、秒传、分片合并虽然前面的物理文件处理过程不同,但最终都要新增一条 user_file 记录,让文件出现在用户网盘目录中。
三条链路分别是:
- 普通上传:先保存真实文件,生成
file表记录,再调用saveUserFile(...)新增用户目录记录,项目没调用。 - 秒传:发现相同 MD5 的真实文件已经存在,不再上传物理内容,直接复用已有
file.id,再调用saveUserFile(...)新增用户目录记录。 - 分片合并:先把多个分片合并成完整真实文件,生成
file表记录,再调用saveUserFile(...)新增用户目录记录。
普通上传、秒传、分片合并虽然前面的过程不同,但最终都要做同一件事:新增一条 user_file 记录,也就是让这个文件出现在用户网盘目录里。 三条链路区别是:
| 场景 | 前面做了什么 | 最后为什么都调 saveUserFile |
|---|---|---|
| upload 普通上传 | 保存真实文件,生成 file 表记录 | 要新增 user_file,让用户看见这个文件 |
| secUpload 秒传 | 发现同 MD5 文件已存在,不重复上传 | 复用已有 file.id,新增 user_file |
| mergeFile 分片合并 | 把分片合并成完整文件,生成 file 表记录 | 要新增 user_file,让用户看见这个文件 |
所以:saveUserFile(…)不是“保存物理文件”,而是保存用户目录视图。
因此 saveUserFile(...) 的职责不是保存文件二进制,而是保存“用户看到的文件入口”,也就是插入 user_file 表。
当前 AI 预热也挂在 saveUserFile(...) 里面:
// 4. 判断:如果当前不是文件夹,是普通文件,触发AI文档初始化任务
// 文件夹不需要AI解析,只有文档类文件需要向量化、智能检索预处理
if (FolderFlagEnum.NO.equals(folderFlagEnum)) {
// 异步调度AI初始化任务:用户ID、文件ID、文件名称
documentAiInitializer.scheduleInitialize(userId, entity.getId(), filename);
}
这里传给 AI 预热的是 entity.getId(),也就是新创建的 user_file.id,不是 realFileId。这是因为 AI 服务后续处理文档时,需要基于 userId + userFileId 校验权限并定位用户文件,再通过 user_file.real_file_id 找到真实物理文件。
把 AI 预热放在 saveUserFile(…) 后面的好处是:无论文件来自普通上传、秒传还是分片合并,只要最终成功创建了普通文件的 user_file 记录,就都会统一触发 AI 预热,不需要在三个入口分别写重复逻辑。
完整链路可以概括为:
普通上传 / 秒传 / 分片合并
-> saveUserFile(...)
-> 插入 user_file
-> 如果是普通文件
-> documentAiInitializer.scheduleInitialize(userId, userFileId, filename)
-> 判断后缀是否支持 AI
-> 事务提交后发送 AI 预热 MQ
-> networkdisk-ai 消费消息
-> 建索引
-> 生成默认摘要
-> 生成默认标签
这里还有一个重要细节:DocumentAiInitializer 不会立刻在事务未提交时发送 MQ,而是优先注册 afterCommit 回调,等数据库事务真正提交成功后再发送消息。这样可以避免 AI 服务先消费消息、但 user_file 记录还没有提交,导致读取不到文件或权限校验失败。
为什么 AI 预热放在这里
代码在:
private Long saveUserFile(...) {
UserFileDO entity = new UserFileDO();
entity.setId(IdUtil.get());
entity.setUserId(userId);
entity.setParentId(parentId);
entity.setRealFileId(realFileId);
entity.setFilename(filename);
entity.setFolderFlag(folderFlagEnum.getCode());
entity.setFileType(fileType);
...
// 2. 核心:处理同一父目录下重名文件
// 逻辑:查询当前用户+当前parentId下是否存在同名文件,存在则自动重命名(xxx(1).txt)
handleDuplicateFilename(entity);
// 3. 执行数据库插入,save返回布尔值标识是否插入成功
if (!save((entity))) {
// 插入失败,抛全局系统异常,上层统一捕获返回前端错误提示
throw new SystemException("保存文件信息失败");
}
// 4. 判断:如果当前不是文件夹,是普通文件,触发AI文档初始化任务
// 文件夹不需要AI解析,只有文档类文件需要向量化、智能检索预处理
if (FolderFlagEnum.NO.equals(folderFlagEnum)) {
// 异步调度AI初始化任务:用户ID、文件ID、文件名称
documentAiInitializer.scheduleInitialize(userId, entity.getId(), filename);
}
// 返回新增记录主键
return entity.getId();
}
最关键的是这句:documentAiInitializer.scheduleInitialize(userId, entity.getId(), filename);它传的是:entity.getId()也就是新创建的 user_file.id,不是 realFileId。
原因是 AI 分析的对象是“用户网盘里的这个文件条目”,不是单纯的物理文件。AI 服务后续读取文件时,会拿 userId + userFileId 去校验权限、获取真实文件路径。
为什么不分别写在 upload/secUpload/mergeFile 里 如果分别写,会变成:
upload 里写一次 AI 预热
secUpload 里写一次 AI 预热
mergeFile 里写一次 AI 预热
这样容易漏,也容易逻辑不一致。现在统一放在 saveUserFile(…) 后面,就表示:只要普通文件成功落到 user_file 表就尝试触发 AI 预热 这才是正确的业务时机。因为 AI 预热依赖的是:user_file 记录已经存在,如果 user_file 还没保存成功,AI 服务提前消费 MQ,就可能读不到这个文件。
public void scheduleInitialize(Long userId, Long userFileId, String filename) {
// 1. 参数校验 + 文件格式过滤
// 用户ID、文件ID为空 或 文件后缀不支持AI解析,直接终止,不发送MQ消息
if (userId == null || userFileId == null || !supports(filename)) {
return;
}
// 2. 判断当前线程是否存在活跃数据库事务
// 原因是:saveUserFile(...) 刚插入 user_file,如果事务还没提交,
// AI 服务立刻消费 MQ 去读这个文件,可能查不到 user_file 记录。
if (TransactionSynchronizationManager.isActualTransactionActive()) {
// 注册事务同步回调,等待事务执行完成
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
/**
* 事务成功提交后执行
* 只有数据库数据真正落库,才发送AI解析消息
*/
// 等 user_file 真正提交成功。再发送 AI 预热消息
@Override
public void afterCommit() {
publishWarmupMessage(userId, userFileId, filename);
}
});
// 注册完回调直接返回,不再往下执行同步发消息逻辑
return;
}
// 3. 当前无事务环境,文件记录已持久化,直接发送MQ消息触发AI预热
publishWarmupMessage(userId, userFileId, filename);
}
这段代码的意思是:
if (TransactionSynchronizationManager.isActualTransactionActive()) {
// 当前方法正在一个 Spring 事务里面
// 不能立刻发 MQ,要等数据库事务提交成功后再发
register afterCommit 回调
return;
}
// 当前没有 Spring 事务
// 数据库操作一般已经自动提交了,可以直接发 MQ
publishWarmupMessage(...)
1. 什么叫“有事务”
比如:
@Transactional(rollbackFor = Exception.class)
public void upload(UploadFileContext context) {
saveFile(context);
saveUserFile(...);
}
当外部调用 upload(...) 时,Spring 会开启事务:
开始事务
-> saveFile 插入 file 表
-> saveUserFile 插入 user_file 表
-> 方法正常结束
提交事务
在这个事务提交之前,数据库里的数据还不算真正稳定。
所以如果 saveUserFile(...) 里面立刻发 MQ,AI 服务可能马上消费消息,然后去查 user_file,结果发现:
事务还没提交
AI 服务查不到这条 user_file
所以有事务时,不能立刻发 MQ。
要这样:
saveUserFile 插入 user_file
-> 注册 afterCommit 回调
-> 等事务提交成功
-> 再发 MQ
2. 什么叫“没事务”
当前这两个方法没有 @Transactional:
public boolean secUpload(SecUploadFileContext context) {
...
saveUserFile(...);
}
public void mergeFile(FileChunkMergeContext context) {
mergeFileChunkAndSaveFile(context);
saveUserFile(...);
}
它们没有被 Spring 开启一个大的事务包起来。这不代表数据库不提交。 而是说:每次 MyBatis / MyBatis-Plus 执行 SQL 时,通常就是一次独立操作,执行完就提交。 比如秒传:
查询已有 file
-> 插入 user_file
-> 插入成功后,这条 user_file 已经提交了
-> 可以直接发 MQ
所以没事务时,直接发:
publishWarmupMessage(userId, userFileId, filename);
是可以的。
3. TransactionSynchronizationManager 是什么
它是 Spring 提供的事务同步管理器:
org.springframework.transaction.support.TransactionSynchronizationManager
你可以把它理解成:
Spring 当前线程事务状态的管理工具
它能回答几个问题:
当前线程有没有事务?
当前事务绑定了哪些数据库连接?
有没有注册事务提交后的回调?
事务提交后要执行哪些动作?
这里用的是:
TransactionSynchronizationManager.isActualTransactionActive()
意思是:当前线程是否存在真实事务,如果返回 true,说明当前代码运行在类似下面这种方法里面:
@Transactional
public void xxx() {
...
}
如果返回 false,说明当前代码没有被 Spring 事务包住。
4. registerSynchronization 是什么
代码:
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
@Override
public void afterCommit() {
publishWarmupMessage(userId, userFileId, filename);
}
});
意思是:
给当前事务注册一个回调
等事务提交成功后
Spring 自动调用 afterCommit()
这里的 afterCommit() 不是你手动调用的,是 Spring 在事务提交成功后自动回调。
流程:
@Transactional 方法开始
-> Spring 开启事务
-> 插入 user_file
-> registerSynchronization 注册 afterCommit
-> 方法结束
-> Spring 提交事务
-> 提交成功
-> Spring 调用 afterCommit()
-> 发送 AI 预热 MQ
如果事务回滚了:
插入失败 / 抛异常
-> Spring 回滚事务
-> afterCommit 不会执行
-> 不会发送 AI 预热 MQ
这就是它的价值。 5. 为什么不管有没有事务都直接发 MQ 因为有事务时会出问题。 错误情况:
saveUserFile 插入 user_file
-> 事务还没提交
-> 立刻发 MQ
-> AI 服务消费消息
-> AI 服务通过 userFileId 查询文件
-> 查不到,因为事务未提交
所以要判断:
if (TransactionSynchronizationManager.isActualTransactionActive()) {
// 有事务,等提交后发
} else {
// 没事务,直接发
}
6. 为什么 secUpload 和 mergeFile 没事务 按当前代码看:
upload(...)
有事务,因为它是普通整文件上传:
@Transactional(rollbackFor = Exception.class)
public void upload(...)
它里面先保存真实文件,再保存用户文件,两步需要一起成功。但是当前实际前端主要走:
secUpload
mergeFile
这两个方法当前没有标 @Transactional。
原因可能是项目当时这么设计:
secUpload逻辑比较简单,只是查已有真实文件,然后新增user_file。mergeFile前面涉及分片文件合并、真实文件保存、临时分片处理,里面可能已经在别的服务方法里处理了一部分一致性。- 上传文件涉及磁盘 / 对象存储,不完全是数据库事务能覆盖的事情。
但不管这是设计还是遗漏,DocumentAiInitializer 这段判断都能兼容:
有事务:afterCommit 后发 MQ
没事务:直接发 MQ
3.5.9 发送消息
- Spring Cloud Stream 架构分层
StreamBridge:Stream 官方统一发送 API,屏蔽底层中间件(RocketMQ/Kafka)差异;- binding 通道
aiWarmup-out-0:配置文件绑定输出,映射真实 RocketMQai-document-warmupTopic; - Header
TAGS:RocketMQ 专用头,消费者通过 selectorExpression 过滤同 Topic 不同业务消息。 -
- 双层消息结构
- 内层:
AiDocumentWarmupMessage业务实体,序列化为 JSON 放到MessageBody.body,因为Send对应未知参数是string,所以这里必须把业务对象转成 JSON 字符串; - 外层:统一包装
MessageBody,携带全局唯一identifier用于链路日志追踪; - 这不是 RocketMQ 必须这样,而是这个项目为了统一消息格式这么封装的。
-
- 和原生 RocketMQTemplate 区别
- Stream 是标准化抽象,切换 MQ 中间件无需改动发送业务代码;
- 统一封装 MessageBody,所有消息固定携带唯一追踪 ID;
- 通过 binding 隔离 Topic 硬编码,配置中心化管理;
- 返回值仅标记投递是否成功,无同步回执详细信息,失败仅日志告警,无重试逻辑。
4.通道本身不是 Topic,只是程序内部抽象管道;
通过 application.yml 配置绑定,把通道映射到真实 RocketMQ Topic:
spring.cloud.stream.rocketmq.bindings.aiWarmup-out-0: destination: ai-document-warmup # 真实RocketMQ主题名
- 代码里写通道名
AI_WARMUP_BINDING = "aiWarmup-out-0"; - StreamBridge 根据通道名读取配置,自动发到
ai-document-warmup这个真实 Topic。
/**
* 推送AI文档预热消息
* 场景:文件上传后发送预热通知,AI服务提前加载文档做解析/向量缓存
* @param userId 用户ID
* @param userFileId 文件原始主键ID
* @param filename 文件名称
*/
private void publishWarmupMessage(Long userId, Long userFileId, String filename) {
try {
// 封装轻量预热消息:仅携带AI服务必需的定位字段,减少消息体积
AiDocumentWarmupMessage message = new AiDocumentWarmupMessage();
message.setUserId(userId);
message.setUserFileId(userFileId);
// 对外统一使用加密fileId,和前端/AI接口参数格式保持一致,避免ID泄露
message.setFileId(IdUtil.encrypt(userFileId));
message.setFilename(filename);
// StreamBridge发送消息,binding绑定输出通道aiWarmup-out-0,映射RocketMQ主题ai-document-warmup
// 参数1:绑定通道名;参数2:消息TAG;参数3:消息实体序列化JSON字符串
// 通道(binding)是 Stream 的抽象管道 xxx-out-0,通过配置映射真实 RocketMQ Topic,作用是解耦主题、屏蔽中间件差异。
boolean sent = streamProducer.send(
AI_WARMUP_BINDING,
AI_WARMUP_TAG,
objectMapper.writeValueAsString(message)
);
// send返回false代表投递未成功,打印告警日志便于排查
if (!sent) {
log.warn("publish document ai warmup message returned false, userId={}, userFileId={}", userId, userFileId);
}
} catch (Exception e) {
// 序列化/网络/中间件异常捕获,仅告警不阻断主流程,可搭配监控告警
log.warn("publish document ai warmup message failed, userId={}, userFileId={}", userId, userFileId, e);
}
}
public boolean send(String bingingName, String tag, String msg) {
// 统一外层包装MessageBody载体
MessageBody message = new MessageBody()
.setIdentifier(UUID.randomUUID().toString()) // 全局唯一消息流水ID,链路追踪
.setBody(msg); // 存放真实业务JSON字符串
logger.info("send message : {} , {}", bingingName, JSON.toJSONString(message));
// Spring标准消息构建器
// withPayload:外层统一载体MessageBody
// setHeader("TAGS", tag):RocketMQ识别TAG头,用于消息过滤
boolean result = streamBridge.send(
bingingName,
MessageBuilder.withPayload(message)
.setHeader("TAGS", tag)
.build()
);
logger.info("send result : {} , {}", bingingName, result);
return result;
}
3.5.10 消费消息
消费在这个文件: networkdisk-business/networkdisk-ai/src/main/java/com/disk/ai/infrastructure/mq/AiWarmupStreamConfiguration.java
核心代码:
@Bean("aiWarmupConsumer")
public Consumer<Message<?>> aiWarmupConsumer() {
return message -> {
AiDocumentWarmupMessage warmupMessage = readWarmupMessage(message);
...
aiApplicationService.indexFile(indexRequest);
aiApplicationService.summarize(summaryRequest);
aiApplicationService.generateTags(tagRequest);
};
}
你没看到“谁调用它”,是因为不是业务代码手动调用的。 只要配置和 Bean 都对上,服务启动后就会自动监听,不需要你手动调用消费者方法。配置 + Bean 对上后,Spring Cloud Stream 启动时就会帮你创建消费者并监听 RocketMQ topic。 它不是这样调用:aiWarmupConsumer().accept(message);而是 Spring Cloud Stream 自动调用。配置在 AI 服务的 application.yml:
spring:
# 当前 Spring Boot 服务名,@application.name@ 通常由 Maven/构建配置替换
application:
name: @application.name@
# 额外加载公共配置文件:数据库、缓存、RPC、消息流
config:
import: classpath:datasource.yml,classpath:cache.yml,classpath:rpc.yml,classpath:stream.yml
# Spring Cloud 相关配置
cloud:
# 声明当前服务里的消息消费函数名
function:
definition: aiWarmupConsumer
# 消息队列/事件流绑定配置
stream:
bindings:
# aiWarmupConsumer 函数的输入通道
aiWarmupConsumer-in-0:
# 消费的消息主题,用于接收文档 AI 预热任务
destination: ai-document-warmup
# 消费组,同一组内多个实例会分摊消费
group: networkdisk-ai
# 消息内容格式
content-type: application/json
这个配置的意思是:找一个名字叫 aiWarmupConsumer 的函数 Bean,把它绑定到输入通道 aiWarmupConsumer-in-0,让它订阅 RocketMQ topic:ai-document-warmup,消费组:networkdisk-ai。 所以调用关系是框架完成的:
RocketMQ 收到 ai-document-warmup 消息
-> Spring Cloud Stream RocketMQ Binder 拉取消息
-> 根据配置找到 aiWarmupConsumer-in-0
-> 根据 function.definition 找到 Bean:aiWarmupConsumer
-> 自动调用 Consumer<Message<?>> 的 accept(message)
-> 执行 return message -> { ... } 里面的代码
发送端和消费端怎么对上,发送端 files 服务:AI_WARMUP_BINDING = “aiWarmup-out-0” 配置:
aiWarmup-out-0:
destination: ai-document-warmup
意思是:发送到 topic:ai-document-warmup 消费端 ai 服务:
aiWarmupConsumer-in-0:
destination: ai-document-warmup
意思是:从 topic:ai-document-warmup 消费所以对上了:
files 服务
aiWarmup-out-0
-> ai-document-warmup topic
ai 服务
aiWarmupConsumer-in-0
<- ai-document-warmup topic
消费时怎么解析 JSON,消费者里先调:
AiDocumentWarmupMessage warmupMessage = readWarmupMessage(message);
readWarmupMessage(...) 做两步:
第一步,把外层 payload 转成 MessageBody:
MessageBody messageBody = objectMapper.convertValue(message.getPayload(), MessageBody.class);
第二步,把 messageBody.getBody() 里的 JSON 字符串转成真正业务对象:
return objectMapper.readValue(messageBody.getBody(), AiDocumentWarmupMessage.class);
也就是:
Message<?>
-> payload
-> MessageBody
-> body 字符串
-> AiDocumentWarmupMessage
消费后哪里调用 AI就在 AiWarmupStreamConfiguration 里面:
aiApplicationService.indexFile(indexRequest);建立索引。
aiApplicationService.summarize(summaryRequest);生成默认摘要。
aiApplicationService.generateTags(tagRequest);生成默认标签。
所以完整链路是:
DocumentAiInitializer.publishWarmupMessage
-> streamProducer.send("aiWarmup-out-0", "document-ai-warmup", json)
-> StreamBridge
-> RocketMQ topic: ai-document-warmup
-> AiWarmupStreamConfiguration.aiWarmupConsumer
-> readWarmupMessage
-> aiApplicationService.indexFile
-> aiApplicationService.summarize
-> aiApplicationService.generateTags
一句话:这里没有显式调用消费者方法,因为消费者是 Spring Cloud Stream 根据配置自动监听 RocketMQ topic 后触发的。
3.6. 上传完成后返回前端处理
上传完成后,前端不会手动把新文件 push 到表格里,而是统一重新请求当前目录文件列表。无论是秒传成功、普通上传成功,还是分片合并成功,最终目标都是让后端先创建好 user_file 记录,然后前端调用 fileStore.loadFileList() 重新查询目录。
秒传成功时,后端已经通过 saveUserFile(...) 给当前用户目录新增了记录,所以前端直接取消真实上传任务、移除任务面板记录、刷新文件列表:
if (res.code === 0 || res.success === true) {
f.cancel()
taskStore.remove(f.name)
fileStore.loadFileList()
}
分片上传完成后,前端不是立刻刷新列表,而是先调用 doMerge(file) 请求后端合并分片。只有 /file/merge 成功后,后端才会保存 file 表和 user_file 表,此时前端再刷新目录:
if (res.code === 0 || res.success === true) {
uploader.removeFile(file)
fileStore.loadFileList()
taskStore.updateStatus({
filename: file.name,
status: panUtil.fileStatus.SUCCESS.code,
statusText: panUtil.fileStatus.SUCCESS.text
})
taskStore.remove(file.name)
}
核心链路是:
分片全部上传完成
↓
fileUploaded(...) 判断需要合并
↓
doMerge(file)
↓
POST /file/merge
↓
后端合并分片 + 保存 file + 保存 user_file
↓
前端 fileStore.loadFileList()
↓
GET /api/v1/files/folders-files
↓
页面重新渲染文件列表
所以页面能看到新文件,不是因为前端本地加了一条数据,而是因为:
后端创建了 user_file 记录
↓
前端刷新当前目录
↓
列表接口查到 user_file
↓
表格重新渲染
fileUploaded 要特别注意:它不是上传接口,也不是推动下一个分片上传的核心引擎。真正负责分片队列调度的是 simple-uploader。fileUploaded 更像是“上传成功后的业务回调”,负责解析后端返回值,判断是否需要调用合并接口。
if (res.data.mergeFlag) {
doMerge(file)
}
这里的 mergeFlag 来自后端 chunk-upload 接口,含义是:
0:分片还没齐,不需要合并
1:分片已齐,可以调用 /file/merge
再次强调:mergeFlag = 1 只代表“可以合并”,不代表“已经合并”。
3.6.1. 任务面板状态
上传任务面板由前端 Pinia 状态管理,不是后端任务表。刷新页面后,任务面板状态不会自动恢复。
核心状态是:
const taskList = ref([])
const viewFlag = ref(false)
taskList 保存当前页面内存中的上传任务,viewFlag 控制任务面板显示隐藏。任务状态更新主要包括:添加任务、更新上传进度、暂停、继续、取消、上传成功后移除。
add(...):加入上传任务
updateStatus(...):更新等待中、上传中、合并中、成功、失败等状态
updateProcess(...):更新速度、百分比、已上传大小、剩余时间
pause(...):调用 simple-uploader 的 pause()
resume(...):调用 simple-uploader 的 resume()
cancel(...):调用 cancel() 并从 taskList 移除
这里要分清楚:
断点续传能力:依赖后端 file_chunk 表 + uploadedChunks 查询
任务面板恢复:当前没有持久化,刷新页面后不会恢复
也就是说,刷新页面后任务面板没了,但只要分片记录没过期,再次上传同一个文件时,前端重新计算 MD5,调用已上传分片查询接口,仍然可以跳过已上传分片。
3.7 StorageEngine
StorageEngine 接口
│
┌─────────────┼─────────────┐
▼ ▼ ▼
LocalStorageEngine FastDFSStorageEngine OssStorageEngine
本机磁盘 FastDFS 集群 阿里云 OSS
StorageEngine 是真实文件存储的统一入口:业务层不关心文件在本机磁盘、OSS 还是 FastDFS,只调用同一套接口。
| 接口 | 含义 | 当前业务调用 |
|---|---|---|
store() |
保存一个完整普通文件 | 未实际调用 |
storeChunk() |
保存一个上传分片 | 分片上传调用 |
mergeFile() |
合并全部分片为完整文件 | 分片合并调用 |
realFile() |
读取真实文件到输出流 | 下载、预览、AI 解析调用 |
delete() |
删除真实文件 | 未实际调用,切片是在mergefile后调用FileUtils工具类删的 |
三种 StorageEngine 实现
| 实现 | 完整文件 store() |
分片 storeChunk() / 合并 |
读取 realFile() |
删除 delete() |
|---|---|---|---|---|
LocalStorageEngine |
将输入流写入 rootFilePath 下的本地文件 |
分片写入 rootFileChunkPath/{MD5}/{chunkNo};合并时按序追加到完整文件后删除分片 |
本地 FileInputStream 流式输出 |
删除本地文件 |
OssStorageEngine |
putObject 上传一个 OSS Object |
使用 OSS Multipart Upload:初始化 uploadId、每片 uploadPart、最后 completeMultipartUpload |
getObject 后流式输出 |
普通文件 deleteObject;未完成分片上传则 abort |
FastDFSStorageEngine |
调 FastDFS uploadFile |
不支持,直接抛“FastDFS不支持分片上传” | 下载字节后写入输出流 | 调 FastDFS 删除文件 |
当前 Spring 默认选中 LocalStorageEngine,因为它标记了 @Primary。即使 OSS/FastDFS Bean 已存在,未使用 @Qualifier 或条件切换时,注入的仍是本地实现。
3.8. 断点续传边界
断点续传就是:文件传到一半中断后,下次继续上传同一个文件时,不从头传,而是跳过已经上传成功的分片,只上传缺失的分片。
本项目的断点续传依赖 file_chunk 表。前端重新上传同一个文件时,会先用文件 MD5 查询后端已上传分片列表;后端根据 identifier、create_user、expiration_time 查询 file_chunk 表,返回已上传的 chunk_number。前端拿到 uploadedChunks 后跳过这些分片,只上传缺失分片,从而实现断点续传。
当前断点续传依赖 file_chunk 表,核心判断条件是:
identifier 文件 MD5
create_user 当前用户
chunk_number 分片编号
expiration_time 分片是否过期
只有同时满足:
同一用户
同一文件内容 MD5
同一分片编号
分片未过期
后端才认为这个分片已经上传过,前端才能跳过它。
当前实现的关键边界:
1. 前端主链路默认走分片上传,秒传命中时才取消真实上传。
2. /file/upload 单文件上传接口存在,但当前页面主上传链路主要走分片上传。
3. 秒传按当前用户 + identifier 查询 file 表,不是全站共享秒传。
4. 分片记录有过期时间,默认 1 天,过期后不能继续复用。
5. chunk-upload 成功只代表某个分片保存成功,不代表文件已上传完成。
6. mergeFlag 只代表“可以合并”,真正合并必须再调用 /file/merge。
7. 文件出现在列表里的前提是后端成功保存 user_file。
8. 上传任务面板是前端内存状态,不是后端持久化任务。
9. 前端上传完成后通过 loadFileList() 重新查询列表,不是本地手动追加文件。
排查上传问题可以按这个顺序:
1. 秒传是否命中
2. chunk-upload 是否成功
3. file_chunk 是否插入分片记录
4. 最后一个分片后 mergeFlag 是否为 1
5. 前端是否调用 /file/merge
6. file 表是否保存完整文件记录
7. user_file 表是否保存目录记录
8. 前端是否调用 loadFileList()
5.4 数据模型
4.1 file 表
file 表保存真实物理文件元数据,描述的是“服务器上真正存着的那个文件”。
核心字段:
id 物理文件 ID
filename 文件名
real_path 真实存储路径
file_size 文件大小
file_size_desc 文件大小展示文本
file_suffix 文件后缀
file_preview_content_type 预览类型
identifier 文件 MD5
create_user 创建人
上传链路里,file 表主要在两个地方使用:
1. merge 成功后,保存完整文件记录
2. 秒传时,根据 create_user + identifier 查询是否已有相同文件
当前秒传查询条件是当前用户维度:
create_user = 当前用户
identifier = 文件 MD5
所以它不是全站共享秒传,而是“当前用户上传过相同文件后,再次上传可秒传”。
4.2 user_file 表
user_file 表保存用户目录视图,描述的是“用户网盘页面里能看到什么文件”。
核心字段:
id 用户文件记录 ID
user_id 所属用户
parent_id 所在目录
real_file_id 关联 file.id
filename 页面展示文件名
folder_flag 是否文件夹
file_size_desc 文件大小展示
file_type 文件类型
deleted 是否删除
上传完成后,真正决定页面能不能看到文件的是 user_file,不是 file。
file:文件真实存在
user_file:用户目录里能看到这个文件
如果只保存 file,不保存 user_file,服务器上有文件,但用户列表查不出来。
4.3 file_chunk 表
file_chunk 表保存分片上传阶段的临时记录,描述的是“某个文件的哪些分片已经上传,分片文件存在哪里”。
核心字段:
identifier 文件 MD5
real_path 分片真实路径
chunk_number 分片编号
expiration_time 分片过期时间
create_user 上传用户
关键唯一约束:
uk_identifier_chunk_number_create_user
它保证同一个用户、同一个文件 MD5、同一个分片编号,只能有一条有效记录,避免重复插入分片记录。
三张表的关系可以这样记:
file_chunk:上传过程中的临时分片
file:合并后的真实物理文件
user_file:用户目录中展示的文件
完整生命周期:
上传分片
↓
写入 file_chunk
↓
调用 merge
↓
分片合并成完整文件
↓
写入 file
↓
写入 user_file
↓
删除 file_chunk 临时记录
↓
前端刷新文件列表
5.文件下载

5.0. 核心结论
文件下载和文件上传是反向流程,但下载阶段不会重新处理分片。上传时,分片已经通过 /file/merge 合并成完整文件,并把完整文件路径保存到 file.real_path。所以下载时,后端只需要根据用户点击的文件 ID 找到 user_file 记录,再通过 user_file.real_file_id 找到 file 表中的真实文件路径,最后把这个文件以二进制流写回浏览器。
上传链路:
分片 -> file_chunk -> merge -> file -> user_file
下载链路:
user_file -> file -> real_path -> response 输出流 -> 前端 Blob
这里最重要的是两张表的分工:
user_file:用户视图文件,决定当前用户能不能看到、能不能下载这个文件
file:真实物理文件,保存 real_path、size、identifier 等真实文件信息
前端传给下载接口的 fileId 对应的是 user_file.id,不是 file.id。后端必须先查 user_file 做权限校验,再通过 real_file_id 找到真实文件。
当前项目下载相关能力包括:
个人空间单文件下载:已实现
个人空间文件预览:已实现
分享页单文件下载:已实现
前端批量下载普通文件:已实现,本质是循环单文件下载
后端多文件 zip 打包下载:未实现
文件夹下载:未实现
所以笔记里要区分“已实现”和“扩展设计”。多文件 zip、文件夹递归下载、服务端临时压缩目录这些属于后续扩展,不要写成当前项目已经实现。
5.1. 个人文件下载主流程
个人文件下载接口:
GET /api/v1/files/file/download?fileId=xxx
整体流程:
前端点击下载
↓
调用 downloadFileAPI,responseType = blob
↓
后端解密 fileId
↓
查询 user_file
↓
校验文件存在、属于当前用户、不是文件夹
↓
通过 real_file_id 查询 file
↓
拿到 file.real_path
↓
设置下载响应头
↓
storageEngine.realFile(...)
↓
把文件写入 response.getOutputStream()
↓
前端拿到 Blob
↓
saveBlobToLocal(blob, filename)
后端设置的关键响应头是:
Content-Type: application/octet-stream
Content-Disposition: attachment;filename="xxx"
Content-Length: 文件大小
Content-Type = application/octet-stream 表示通用二进制流。
Content-Disposition = attachment 表示作为附件下载。
Content-Length 让浏览器知道文件大小,可以显示下载进度。
但是当前项目是前端 axios 请求文件接口,所以真正触发本地保存的不是后端响应头,而是前端 saveBlobToLocal(...)。后端只负责返回文件流,前端把 Blob 转成临时 URL,再模拟点击 <a download> 完成保存。
1. 本来浏览器下载是什么样的 如果你直接让浏览器访问下载地址,比如:
<a href="/api/v1/files/file/download?fileId=xxx">下载</a>
或者:
window.location.href = '/api/v1/files/file/download?fileId=xxx'
这时浏览器自己接管这个请求。后端返回:
Content-Disposition: attachment; filename="a.txt"
Content-Type: application/octet-stream
浏览器看到 attachment,就会自动触发下载。这个模式下,前端 JS 基本不碰文件内容。
但你项目现在不是这样,而是用 axios:
const response = await downloadFileAPI({ fileId })
axios 是 JS 在后台发请求。浏览器不会把这个请求当成“页面跳转下载”,而是把响应交给 JS。也就是说,后端虽然返回了文件流,但文件流落到了 JS 手里,不会自动弹出保存框。
所以 axios 下载时必须这样做:
后端返回文件二进制
↓
axios 接收成 Blob
↓
前端把 Blob 变成临时 URL
↓
前端创建 a 标签
↓
模拟点击 a 标签
↓
浏览器才开始保存文件
2. Blob 是什么
Blob 可以理解成浏览器内存里的一坨二进制文件数据。
比如后端返回了一个 PDF,axios 拿到后不是字符串,也不是 JSON,而是:
Blob { size: 102400, type: "application/pdf" }
它像一个“临时文件对象”,但它还没有真正保存到用户电脑里。
3. 临时 URL 是什么 Blob 在内存里,不能直接写进地址栏。浏览器需要一个“地址”才能下载或预览它,所以前端调用:
const url = window.URL.createObjectURL(blob)
生成一个临时地址:
blob:http://localhost:5173/xxxx-xxxx
这个地址不是服务器地址,而是浏览器给内存 Blob 创建的临时访问入口。
4. 模拟点击 <a download> 是什么
项目里的 saveBlobToLocal(...) 本质是:
function saveBlobToLocal(blob, filename) {
const url = window.URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = filename
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
window.URL.revokeObjectURL(url)
}
意思是:
创建一个隐藏下载链接
↓
链接地址指向 Blob 临时 URL
↓
download 属性指定保存文件名
↓
用 JS 模拟用户点击
↓
浏览器执行下载
↓
删掉临时链接,释放 Blob URL
浏览器不允许 JS 随便把文件写到用户磁盘某个路径,这是安全限制。前端能做的是“触发浏览器下载”,具体保存到哪里由浏览器和用户决定。
所以你笔记里可以这样写:
如果用普通 a 标签直接访问下载接口,浏览器会根据后端 Content-Disposition 自动下载。
但当前项目用 axios 请求文件流,响应会先进入 JS,不会自动触发下载。
因此前端必须把 Blob 转成 ObjectURL,再创建 a 标签并模拟点击,才能让浏览器保存文件。
2.1. 预览和下载的区别
预览和下载共用大部分后端链路:
解密 fileId
查询 user_file
校验权限
查询 file
读取 file.real_path
写入 response 输出流
区别主要在响应头和前端处理方式。
下载关注“保存到本地”:
Content-Type: application/octet-stream
Content-Disposition: attachment
前端:Blob -> a.download -> 保存
预览关注“浏览器能识别并展示”:
Content-Type: image/png / application/pdf / video/mp4 / text/plain
前端:Blob -> ObjectURL -> 新窗口打开
如果 Content-Type 不准确,浏览器可能无法正确预览。例如 PDF 应该是 application/pdf,图片应该是 image/png 或 image/jpeg,视频应该是 video/mp4。如果兜底成 application/octet-stream,浏览器更倾向于下载,而不是预览。
2.2. 为什么要用流式输出
文件可能很大,不能一次性读成 byte[] 返回,否则容易造成内存溢出。项目里通过:
file.real_path
↓
StorageEngine.realFile(...)
↓
response.getOutputStream()
实现流式输出。可以理解成:
服务器磁盘文件 -> 输入流/Channel -> HTTP 输出流 -> 浏览器
不是把整个文件一次性放进内存,而是边读边写。当前本地存储实现中,底层使用 FileChannel.transferTo(...) 把文件内容写到响应流,适合大文件下载。
5.2. 实现思路
网盘采用压缩下载的方案,在服务器端把文件按照对应的分块合并成独立的文件,再把这些独立的文件压缩成一个完整的压缩包,客户下载压缩包后解压则会保留完整的目录结构和文件内容。在文件夹过大时会提示内容过多,重新选择下载。
如果在客户端进行文件的合并,能够减轻服务器压力,但是开发内容较多,需要进行前后端的交互导致内存压力大,造成服务器假死。
- 在服务器本地生成一个临时目录专门存放合并的文件和生成的压缩包,为了达到不互相干扰,通过创建 userid 目录和 downloadname 目录去隔离。
- 第二步:文件合并,根据文件Id获取完整的文件信息,并且做判断 a. 如果是文件,再根据文件md5获取切块记录集合,并且按顺序输出到临时目录,自动合并成完整的文件 b. 如果是文件夹,先在临时目录下创建对应文件夹,并且递归查询其下面的子文件/文件夹,然后做处理
- 第三步:压缩文件(夹)
- 第四步:把压缩包的下载路径拼接好并返回给客户端。
当前项目已实现:单文件下载、单文件预览、分享页单文件下载。 当前项目未实现:后端多文件 zip 打包下载、文件夹递归下载、临时目录生成压缩包、客户端分片合并下载。
上面原文档这段话可以拆成两层意思。 第一层:如果用户下载的是多个文件或文件夹,不能像单文件那样直接返回一个文件流。
比如用户勾选:
文件 A
文件 B
文件夹 C
浏览器一次下载请求最好返回一个整体结果,所以常见做法是后端生成一个 zip:
download.zip
├── 文件A.txt
├── 文件B.jpg
└── 文件夹C/
├── c1.pdf
└── c2.png
用户下载 zip 后,解压还能看到原来的目录结构。
第二层:如果系统上传时文件还只是分片状态,那么下载前要先把分片合并成完整文件,再压缩。
但是你当前项目里,上传完成后已经在 /file/merge 阶段把分片合并成完整文件了,并且保存到 file.real_path。所以当前项目下载时不需要再根据 MD5 查分片重新合并。
原文档中提到的“服务器端合并文件并压缩成 zip”是多文件/文件夹下载的设计方案,不是当前项目已经实现的代码。当前项目个人空间下载只支持单文件流式下载,文件夹会直接拒绝下载;前端批量下载只是循环调用单文件下载接口,不是后端打包 zip。
由于当前项目上传完成后已经通过 /file/merge 把分片合并成完整文件,并保存到 file.real_path,所以下载阶段不需要再根据 MD5 查询 file_chunk 分片重新合并。真正要实现多文件/文件夹下载时,应当基于 user_file 递归收集目录下的文件,再根据 real_file_id 查询 file.real_path,最后用 ZipOutputStream 按目录结构写入 zip,并把 zip 流返回给前端。
原文档提到的客户端合并只是对比方案:客户端合并可以减轻部分服务器处理压力,但前后端交互复杂,浏览器端处理大文件也容易带来内存和稳定性问题,因此更常见的网盘方案是服务器端生成 zip,客户端只负责下载最终压缩包。
项目文档中提到的服务器端压缩下载方案,目前在当前代码主线中没有落地。当前下载只支持单文件流式输出;前端批量下载只是循环调用单文件下载接口。如果后续要实现文件夹或多文件下载,可以按文档方案:后端递归收集文件,按目录结构写入 ZipOutputStream,再把 zip 文件或 zip 流返回给前端。文件下载底层重点不是“怎么把文件读出来”,而是“不要一次性把整个文件读到内存”。当前项目个人下载使用 FileChannel.transferTo,分享下载使用 InputStream.transferTo,本质都是把磁盘文件流式写入 HTTP 响应输出流。
2.1 文件 IO 技术选择
文件下载、预览、压缩,本质都是在处理 IO。IO 可以理解成“数据流动”:从磁盘读文件叫输入流,把数据写到磁盘、网络、浏览器响应里叫输出流。
磁盘文件 -> InputStream -> Java 程序 -> OutputStream -> 磁盘 / 网络 / 浏览器
在网盘项目里,最重要的原则是:不要把整个大文件一次性读进内存,要边读边写。
2.1.1 基础读取:FileInputStream
FileInputStream 是最基础的磁盘文件输入流,用来从本地磁盘读取文件内容。
教学写法:
@Test
public void readSmallFile() throws Exception {
// 1. 创建文件字节输入流,绑定磁盘上指定文件,用于读取文件二进制
FileInputStream input = new FileInputStream("/home/data/test.txt");
// 2. input.available() 返回当前流可读总字节,新建等大byte数组
// 致命缺陷:文件多大,数组就占多大堆内存,大文件直接OOM内存溢出
// 仅适合KB级小文本,网盘分片/大文件绝对不能用这种写法
byte[] bytes = new byte[input.available()];
// 3. 一次性将全部文件字节读取进byte数组
input.read(bytes);
// 4. 将二进制字节数组以UTF-8编码转为可读字符串
String text = new String(bytes, "UTF-8");
System.out.println(text);
// 5. 手动关闭输入流,释放操作系统文件句柄,不关闭会导致文件占用
input.close();
}
这里的核心是:
FileInputStream:从磁盘读文件
byte[]:内存中的字节数组
new String(bytes):把字节转成文本
但是这段代码不能用于大文件。比如文件是 2GB,new byte[2GB] 会直接占用大量内存,很容易 OOM。
更安全的写法是用缓冲区循环读:
@Test
public void copyFileByBuffer() throws Exception {
// try-with-resources 语法:实现AutoCloseable接口的流,代码块结束自动关闭,不用手动close()
try (
// 源文件输入流:读取原文件
FileInputStream input = new FileInputStream("/home/data/test.txt");
// 目标文件输出流:写入复制后的新文件,文件不存在自动创建
FileOutputStream output = new FileOutputStream("/home/data/copy.txt")
) {
// 自定义8KB缓冲区,每次只加载8KB字节到内存,内存占用恒定,适配大文件
byte[] buffer = new byte[8192];
// len:存储每次read实际读到的字节数量
int len;
// 循环读取:input.read(buffer)
// 有数据返回>0,读到文件末尾返回-1,循环终止
while ((len = input.read(buffer)) != -1) {
// write(buffer, 0, len):只写入本次读取到的有效字节,避免末尾缓冲区脏数据
output.write(buffer, 0, len);
}
// 强制刷新输出流缓冲区,把内存中残留字节全部刷入磁盘
output.flush();
}
// 出try代码块自动关闭input、output,无需手动调用close
}
try-with-resources Java7 新增语法,只要类实现 AutoCloseable,声明在 try () 中,无论正常 / 异常退出都会自动关闭资源,杜绝流忘记关闭导致文件句柄泄露;
这就是流式处理:
读一小块 -> 写一小块 -> 再读一小块 -> 再写一小块
2.1.2 BufferedInputStream 的作用
BufferedInputStream 是在普通输入流外面加一层缓冲区。它的作用是减少频繁访问磁盘,提高普通流拷贝效率。
带缓冲包装流 copyFileByBufferedStream(性能优化版)
BufferedInputStream/BufferedOutputStream装饰流,自带内置缓冲区;- 减少频繁和磁盘交互的系统调用,文件越大、读写次数越多,性能优势越明显;
- 底层依然是分段 buffer 读写逻辑,和分片合并
appendWrite实现思路一致。
@Test
public void copyFileByBufferedStream() throws Exception {
try (
// BufferedInputStream:装饰器模式,包装原始FileInputStream,内置默认8KB缓冲区
// 减少频繁磁盘IO系统调用,提升读取性能
BufferedInputStream input =
new BufferedInputStream(new FileInputStream("/home/data/test.txt"));
// BufferedOutputStream:包装输出流,内置缓冲区,减少频繁磁盘写入操作
BufferedOutputStream output =
new BufferedOutputStream(new FileOutputStream("/home/data/copy.txt"))
) {
// 自定义缓冲区数组,可和内置缓冲配合使用
byte[] buffer = new byte[8192];
int len;
// 分段循环读取写入逻辑和原生流完全一致
while ((len = input.read(buffer)) != -1) {
output.write(buffer, 0, len);
}
// 刷新缓冲,将缓冲区残留数据落地磁盘
output.flush();
}
}
可以这样理解:
FileInputStream:直接从磁盘一口一口读
BufferedInputStream:先用缓冲区攒一批,再给程序读
对于普通文件复制、压缩写入,BufferedInputStream 是最容易理解、也比较稳的方案。
2.1.3 NIO / FileChannel.transferTo
FileChannel 是 Java NIO 里的文件通道,适合大文件传输。它可以把文件内容直接从文件通道传到目标通道。
当前项目个人下载底层用的就是类似逻辑:
- 零拷贝 Zero-Copy 传统 IO:磁盘→内核缓冲区→应用堆内存→内核缓冲区→网卡; transferTo:磁盘内核缓冲区直接转发至网卡,跳过应用内存拷贝,大文件下载性能碾压普通 Buffer 循环;
- 适用场景:文件下载接口、服务器文件转发;
- 缺点:只适合完整文件一次性传输,不适合分片分段追加写入(分片合并不用这套)。
/**
* NIO FileChannel零拷贝传输文件,适合大文件下载、文件转发
* @param fileInputStream 本地文件输入流
* @param outputStream 输出流(如HTTP响应输出流,返回文件给前端下载)
* @param length 需要传输的文件总字节长度
*/
public static void writeFileToOutputStream(
FileInputStream fileInputStream,
OutputStream outputStream,
long length
) throws IOException {
// 1. 从文件输入流获取NIO文件通道FileChannel,负责读取磁盘文件
FileChannel fileChannel = fileInputStream.getChannel();
// 2. 将普通OutputStream包装成NIO可写字节通道WritableByteChannel
WritableByteChannel targetChannel = Channels.newChannel(outputStream);
// 3. 核心零拷贝方法 transferTo
// 内核态直接把磁盘文件数据传输到目标通道,不经过应用堆内存,极大减少内存开销、CPU消耗
// 参数:起始偏移0、传输总长度、目标通道
fileChannel.transferTo(0, length, targetChannel);
// 4. 刷新输出流,确保所有数据全部输出
outputStream.flush();
// 5. 手动关闭全部IO资源,释放文件句柄、网络连接
fileInputStream.close();
outputStream.close();
fileChannel.close();
targetChannel.close();
}
这段代码在项目下载中的意义是:
服务器磁盘文件
↓
FileChannel
↓
HTTP response 输出流
↓
浏览器
它不是先把整个文件读成 byte[],而是流式传输,更适合网盘文件下载。
分享下载里用的是:
inputStream.transferTo(outputStream);
它也是同类思想:把输入流内容直接传到输出流。
| 方法 | 内存占用 | 适用文件 | 项目中使用场景 |
|---|---|---|---|
| readSmallFile | 高,一次性加载全部 | 极小文本 | 禁止在上传 / 合并分片使用 |
| copyFileByBuffer | 恒定固定内存 | 任意大小文件 | 分片 appendWrite、分片合并拼接 |
| copyFileByBufferedStream | 恒定 + 内置缓冲 | 大文件拷贝 | 后台批量迁移文件 |
| transferTo NIO 通道 | 极低(零拷贝) | 大文件下载 | 前端文件下载接口返回数据流 |
2.2 文件压缩读写
压缩文件时,核心类是 ZipOutputStream。它可以把多个文件写进一个 .zip 包里。每放一个文件,都要创建一个 ZipEntry,表示 zip 包里的一个文件条目。
可以理解成:
ZipOutputStream = 压缩包输出流
ZipEntry = 压缩包里的一个文件
FileInputStream = 从磁盘读取原文件
- compressWithFileInputStream:该压缩方式采用FileInputStream一次性将整个文件读取为等大byte数组再写入压缩流,代码逻辑极简,但文件全部加载进堆内存,超大文件会直接触发内存溢出OOM,仅适用于极小文本测试,生产环境文件上传、批量压缩业务严禁使用。
- compressWithBufferedInputStream:采用BufferedInputStream缓冲流搭配固定8KB缓冲区循环分段读写,每次仅加载少量字节到内存,内存占用恒定不受文件大小影响,分批写入ZipOutputStream生成压缩条目,兼顾性能与内存安全,是分片、批量文件压缩场景标准生产写法。
- compressWithNio:基于NIO FileChannel通道搭配transferTo零拷贝接口实现文件写入压缩流,不借助堆内存缓冲区循环读写,减少CPU内存拷贝开销,适合超大文件打包压缩,依靠通道直接传输文件二进制数据,相比传统缓冲压缩在大文件场景下读写性能更优。
2.2.1 不推荐的一次性读取写法
@Test
public void compressWithFileInputStream() throws Exception {
// 定义最终生成的压缩包文件
File zipFile = new File("/home/data/test.zip");
// try-with-resources:自动关闭ZipOutputStream压缩输出流
try (ZipOutputStream zipOut = new ZipOutputStream(new FileOutputStream(zipFile))) {
// 获取待压缩文件夹下所有子文件/文件夹
File[] files = new File("/home/data/files").listFiles();
for (File file : files) {
// 跳过文件夹,只压缩普通文件
if (!file.isFile()) {
continue;
}
try (FileInputStream input = new FileInputStream(file)) {
// 在压缩包内新建一个文件条目,指定压缩包内文件名
zipOut.putNextEntry(new ZipEntry(file.getName()));
// 缺陷:一次性读取整个文件到byte数组,文件过大会堆内存溢出
byte[] bytes = new byte[input.available()];
input.read(bytes);
// 将完整字节数组写入压缩流
zipOut.write(bytes);
// 结束当前文件条目,准备写入下一个文件
zipOut.closeEntry();
}
}
}
}
这段适合理解 zip 原理,但不适合生产大文件,因为还是用了 available() 一次性读。
缺点:文件多大,内存数组就多大,网盘上传 / 批量压缩大文件严禁使用。
2.2.2 推荐:BufferedInputStream 压缩
@Test
public void compressWithBufferedInputStream() throws Exception {
File zipFile = new File("/home/data/test.zip");
File[] files = new File("/home/data/files").listFiles();
try (ZipOutputStream zipOut = new ZipOutputStream(new FileOutputStream(zipFile))) {
// 固定8KB缓冲区,内存占用恒定,不随文件大小增长
byte[] buffer = new byte[8192];
for (File file : files) {
if (!file.isFile()) {
continue;
}
// 带缓冲包装输入流,减少磁盘IO次数,提升读取性能
try (BufferedInputStream input = new BufferedInputStream(new FileInputStream(file))) {
zipOut.putNextEntry(new ZipEntry(file.getName()));
int len;
// 循环分段读取,读到末尾返回-1结束
while ((len = input.read(buffer)) != -1) {
// 只写入本次读到的有效字节
zipOut.write(buffer, 0, len);
}
zipOut.closeEntry();
}
}
}
}
这才是比较适合网盘的基础压缩方式:
每次读 8KB
↓
写入 zip
↓
循环直到当前文件读完
↓
再处理下一个文件
2.2.3 NIO 压缩写法
@Test
public void compressWithNio() throws Exception {
File zipFile = new File("/home/data/test.zip");
File[] files = new File("/home/data/files").listFiles();
try (ZipOutputStream zipOut = new ZipOutputStream(new FileOutputStream(zipFile))) {
// 将传统OutputStream转为NIO可写通道
WritableByteChannel zipChannel = Channels.newChannel(zipOut);
for (File file : files) {
if (!file.isFile()) {
continue;
}
// 获取文件NIO通道
try (FileChannel fileChannel = new FileInputStream(file).getChannel()) {
zipOut.putNextEntry(new ZipEntry(file.getName()));
// NIO通道传输,transferTo底层利用零拷贝机制
fileChannel.transferTo(0, fileChannel.size(), zipChannel);
zipOut.closeEntry();
}
}
}
}
NIO 写法适合理解项目里的 transferTo。不过压缩场景下,BufferedInputStream 已经够用了,代码也更好理解。
2.3 和当前项目的关系
当前项目个人下载不是返回 zip,而是单文件下载。核心是:
file.real_path
↓
FileInputStream
↓
FileChannel.transferTo(...)
↓
response.getOutputStream()
↓
浏览器 Blob
分享下载用的是:
Files.newInputStream(realFilePath)
↓
inputStream.transferTo(outputStream)
↓
浏览器
原文档里提到的 ZipOutputStream 更适合“多文件/文件夹打包下载”,但当前项目没有实现后端 zip 下载。
你可以这样总结:
FileInputStream:从磁盘读文件,基础但不能一次性读大文件。
BufferedInputStream:加缓冲区,适合普通文件复制和压缩。
FileChannel.transferTo:通道传输,适合大文件下载输出。
ZipOutputStream:生成 zip 包,适合多文件/文件夹打包下载。
当前项目下载用的是流式输出,不是压缩包下载。
重点记住一句:网盘文件 IO 的核心不是“能不能读出来”,而是“大文件不能一次性读进内存,要用流一块一块传”。
以下是3种方式,在不同容量下文件压缩时间测试,可以看到总和来看对于常规意义上的文件而言,采用 BufferedInputStream 即可。
| 方式 | 容量 | 平均耗时(ms) |
|---|---|---|
| FileInputStream | 5M | 197 |
| BufferedInputStream | 104 | |
| NIO | 113 | |
| FileInputStream | 20M | 516 |
| BufferedInputStream | 398 | |
| NIO | 462 | |
| FileInputStream | 100M | 2328 |
| BufferedInputStream | 1959 | |
| NIO | 2039 | |
FileInputStream:最基础的文件输入流,从磁盘读文件。缺点是如果你写成 byte[] bytes = new byte[input.available()],就会一次性把文件读进内存,大文件容易 OOM。这个写法只适合教学小文件,不适合网盘。 |
BufferedInputStream:在普通输入流外面加缓冲区,减少频繁磁盘 IO。适合普通流式拷贝。
NIO / FileChannel.transferTo:基于通道传输,适合文件到输出流的大文件传输。你项目个人下载底层用的就是:fileChannel.transferTo(0, length, writableByteChannel); 。分享下载用的是:inputStream.transferTo(outputStream);它们的共同目标都是:流式传输,不把整个文件一次性读进内存。
5.3. 代码实现
目前已实现:
- 用户私有空间单文件下载。
- 用户私有空间单文件预览。
- 分享页单文件下载(带提取码校验与过期校验)。
- 后端流式输出,前端 Blob 保存/展示。
3.1. 个人下载与预览
个人下载和预览共用同一条后端读取链路,核心都是:
前端传 fileId
↓
后端解密 fileId
↓
查询 user_file
↓
校验文件存在、属于当前用户、不是文件夹
↓
通过 user_file.real_file_id 查询 file
↓
拿到 file.real_path
↓
storageEngine.realFile(...)
↓
写入 response.getOutputStream()
↓
前端用 Blob 接收
下载接口:
GET /api/v1/files/file/download
预览接口:
GET /api/v1/files/file/preview
两者区别主要在响应头:
下载:
Content-Type = application/octet-stream
Content-Disposition = attachment
作用:告诉浏览器这是附件下载。
预览:
Content-Type = file.file_preview_content_type
没有附件下载头
作用:告诉浏览器这是 PDF / 图片 / 视频 / 文本,让浏览器尝试直接展示。
底层读取由存储引擎统一处理:
StorageEngine.realFile(ReadFileContext)
↓
ReadFileContext(realPath + outputStream)
↓
LocalStorageEngine#doReadFile
↓
FileUtil.writeFileToOutputStream(...)
↓
FileChannel.transferTo(...)
这部分详细解释已经记录在:
6.2 后端预览/下载入口
6.3 后端预览
6.5 后端下载实现
2.1 / 2.2 文件 IO 技术选择
3.2 分享创建
分享下载接口用于“别人通过分享链接下载文件”。它和个人空间下载最大的区别是:个人下载依赖当前登录用户权限,而分享下载可能不需要登录,所以它校验的重点不是“文件是不是我的”,而是“这个分享是否有效、提取码是否正确、这个文件是否属于该分享”。
前端分享页点击下载时,会先判断当前行是不是文件夹。如果是文件夹,直接提示暂不支持下载;如果是普通文件,就把 shareId、fileId、私密分享的 shareCode 一起传给后端。
3.1 创建分享
入口:Files.vue 分享按钮
用户在个人文件列表中点击分享按钮
↓
打开分享弹窗
↓
填写分享名称、分享类型、有效期
↓
confirmShare()
↓
POST /api/v1/shares/share
↓
后端创建 share 主表记录
↓
后端创建 share_file 关联记录
↓
返回加密 shareId
↓
用户到“我的分享”页面复制分享链接
分享创建:Files.vue
文件列表每一行都有分享按钮:
<el-button
icon="Share"
type="success"
size="small"
circle
@click.stop="shareFile(row)"
/>
点击后执行 shareFile(file):
function shareFile(file) {
currentShareFile.value = file
shareForm.value.shareName = file.filename
showShareDialog.value = true
}
这一步只是打开分享弹窗,并记录当前要分享的是哪个文件。currentShareFile 保存当前文件对象,showShareDialog = true 控制弹窗显示。
分享弹窗里用户可以填写:
分享名称
分享类型:公开分享 / 私密分享
有效期:永久、1天、7天、30天
点击“确认分享”后,执行 confirmShare():
async function confirmShare() {
await shareFormRef.value.validate()
const shareType = shareForm.value.shareType === 'withCode' ? 1 : 0
const validityMap = {
permanent: '永久有效',
'1day': '1天',
'7days': '7天',
'30days': '30天'
}
await createShare({
shareName: shareForm.value.shareName,
shareType,
shareDayType: validityMap[shareForm.value.validity],
shareFileIds: [String(getFileId(currentShareFile.value))]
})
showShareDialog.value = false
ElMessage.success('分享创建成功,正在跳转到我的分享')
setTimeout(() => {
router.push('/shares')
}, 800)
}
这里前端做了几件事:
校验表单
↓
把分享类型转成后端需要的数字
↓
把有效期转成后端需要的中文枚举
↓
取当前文件 fileId
↓
调用 createShare 接口
↓
创建成功后跳转到“我的分享”页面
接口封装在 api/share.js:POST /api/v1/shares/share
以分享创建阶段可以总结为:
Files.vue 点击分享
↓
打开分享弹窗
↓
填写分享名称、类型、有效期
↓
confirmShare()
↓
POST /share
↓
后端创建 share、share_file 记录
↓
跳转到 /shares 我的分享页面
分享后端
后端入口在 UserShareController.addUserShare(...):
/**
* 创建文件分享接口
* @param createShareParam 前端提交分享参数(分享类型、提取码、过期时间、选中文件id数组等)
* @return 加密后的分享ID,作为前端访问分享链接唯一标识
*/
@PostMapping("/share")
public Result<String> addUserShare(@Validated @RequestBody CreateShareParamVO createShareParam) {
// 打印前端传来的完整分享参数日志,方便排查问题
log.info("createShareParam: [{}]", createShareParam.toString());
// 前端VO参数转换为业务层专用上下文实体,统一内部字段格式
CreateShareContext context = shareConvertor.createShareParamToCreateShareContext(createShareParam);
// 调用业务层新增分享主记录+关联文件
// share_file是分享主表share和物理文件表file之间的多对多关联中间表,
// 业务逻辑是一条分享可以勾选多个文件,同一个文件也能被多次创建不同分享,无法只用单表外键存储这种多对多关系;
Long shareId = shareService.addUserShareInfo(context);
// 对分享主键ID加密后返回前端,避免暴露数据库自增ID,安全防护
Controller 做的事情很简单:接收前端分享参数,把 VO 转成业务上下文,然后调用 Service 创建分享。最后返回加密后的 shareId,避免直接暴露数据库真实 ID。
真正创建分享在 ShareServiceImpl.addUserShareInfo(...):
@Transactional 很重要。因为创建分享不是只插一张表,而是要插 share 和 share_file。如果主表保存成功,但关联表保存失败,就会出现“有分享,但分享里没有文件”的脏数据。所以这里加事务,任意一步失败都回滚。
save() 和 saveBatch(),都来自 MyBatis-Plus 封装的顶级 IService 接口,是 MP 给我们封装好的通用增删改查方法,不用自己手写 SQL。
save 和 saveBatch 是 MyBatis-Plus 封装的数据库插入方法,前者执行单条 SQL 插入一条数据,适合单条记录新增;后者执行批量 SQL 一次插入多条数据,适合批量数据新增,性能更高,二者都不用我们手写 SQL 语句,直接调用方法即可完成数据入库。这两个参数分别是封装好数据的实体对象和对象列表。不是建表,而是调用 MyBatis-Plus 封装好的插入方法,最终生成并执行 INSERT INTO ... SQL。
@Override
// @Transactional作用:如果保存主记录成功、批量存中间表失败,会整体回滚,不会出现只有分享无关联文件的脏数据;
// rollbackFor 指定哪些异常触发事务回滚。 Exception.class 回滚范围:所有 Exception 及其子类
// 强制 Spring 在任何异常(包括受检异常)发生时回滚事务
@Transactional(rollbackFor = Exception.class)
public Long addUserShareInfo(CreateShareContext context) {
// 获取本次分享选中的多个文件id集合
List<Long> shareFileIds = context.getShareFileIds();
log.info("fileIdList: [{}]", shareFileIds);
// 组装分享主表ShareDO实体:分享类型、提取码、过期时间、创建用户等基础信息
ShareDO shareDO = assembleShareInfo(context);
// 流式遍历所有选中文件id,批量组装分享-文件关联中间表实体
// map 是一对一映射转换方法,接收集合里每一个原始元素,执行自定义转换逻辑生成全新对象,返回装有新对象的流,最后 collect 转成集合;
List<ShareFileDO> shareFileDOList = shareFileIds.stream().map(fileId -> {
ShareFileDO shareFileDO = new ShareFileDO();
shareFileDO.setFileId(fileId); // 底层物理文件ID
shareFileDO.setShareId(shareDO.getId()); // 待保存的分享主记录id
shareFileDO.setCreateUser(shareDO.getCreateUser()); // 创建分享的用户
shareFileDO.setId(IdUtil.get()); // 中间表唯一雪花ID
return shareFileDO;
}).collect(Collectors.toList());
// 第一步:插入分享主表一条记录,生成shareDO主键id
save(shareDO);
// 第二步:批量插入分享和文件关联中间表多条记录
shareFileService.saveBatch(shareFileDOList);
// 第三步:根据分享过期时间,创建定时任务,到期自动清理失效分享数据
scheduleShareExpireDeleteJob(shareDO);
// 返回分享主键给控制器,加密后返回前端
return shareDO.getId();
} return shareDO.getId();
}
这里后端主要保存两张表:
share:分享主表,保存分享名称、分享类型、提取码、有效期、分享链接、创建人
share_file:分享文件关联表,保存这个分享里包含哪些 user_file
share 表可以理解为“这次分享本身”,share_file 表可以理解为“这次分享包含哪些文件”。
为什么要有 share_file?因为一个分享理论上可以包含多个文件。如果只存在 share 表里,就很难表达“一次分享对应多个文件”。业务逻辑是一条分享可以勾选多个文件,同一个文件也能被多次创建不同分享,无法只用单表外键存储这种多对多关系;所以用中间表:
share.id
↓
share_file.share_id
share_file.file_id
↓
user_file.id
assembleShareInfo(...) 会组装分享主表:
/**
* 组装分享主表ShareDO实体,填充创建分享所需全部字段
* @param context 创建分享上下文(前端解密后的完整分享参数)
* @return 填充完毕的分享主表实体
*/
private ShareDO assembleShareInfo(CreateShareContext context) {
// 雪花算法生成分享记录唯一主键ID
Long shareId = IdUtil.get();
// 根据前端传入的过期类型(永久/1天/7天/30天),解析出有效天数数字
Integer shareDay = ShareDayTypeEnum.getDayByType(context.getShareDayType());
// 初始化分享主表实体
ShareDO shareDO = new ShareDO();
// 设置分享主键
shareDO.setId(shareId);
// 有效分享天数
shareDO.setShareDay(shareDay);
// 获取当前登录用户ID,作为分享创建人
shareDO.setCreateUser(UserIdUtil.get());
// 分享类型:0公开分享 / 1带提取码分享(前面三元表达式转换而来)
shareDO.setShareType(context.getShareType());
// 过期类型标识(永久/1天/7天等枚举编码)
shareDO.setShareDayType(context.getShareDayType());
// 用户自定义分享名称
shareDO.setShareName(context.getShareName());
// 计算分享过期时间:shareDay < 0代表永久有效,过期时间设为null;否则当前日期往后顺延对应天数
shareDO.setShareEndTime(shareDay < 0 ? null : DateUtil.offsetDay(new Date(), shareDay));
// 分享状态:正常可用
shareDO.setShareStatus(ShareStatusEnum.NORMAL.getCode());
// 随机生成4/6位提取码(仅shareType=1时生效)
shareDO.setShareCode(createShareCode());
// 拼接完整分享访问链接,传入未加密的shareId,后续接口返回加密后的ID给前端
shareDO.setShareUrl(buildShareUrl(shareId));
return shareDO;
}
这里做了几件事:
生成 shareId
计算分享有效期
记录创建人
保存分享类型:公开 / 私密
生成提取码
生成分享访问链接
设置分享状态为正常
实际上,在有事务的方法里,所有数据库操作都是「临时暂存」的,要等整个方法全部执行完、没有报错,才会统一提交生效。
换句话说:能执行到 scheduleShareExpireDeleteJob 这一行,只代表前面的代码没报错,不代表数据已经真正存进数据库了。
前面的 save、saveBatch 如果报错抛异常,确实走不到这一步;❌ 但风险不在前面,而在这行代码执行完之后,后面的代码报错了。
为什么和事务关联就解决了这个问题?
afterCommit 的意思就是:等整个事务彻底提交成功、所有数据都永久落库了,再执行把任务放进队列的操作。执行到 scheduleShareExpireDeleteJob 时,不会立刻把任务加进队列,只是给事务注册了一个「提交后回调」; 如果后面报错,事务回滚,整个事务直接失败,这个回调根本不会触发,任务也就不会被加进去;只有整个方法全部执行完、没有任何错误、事务成功提交了,回调才会执行,延时任务才会被真正放进队列。
这样就保证了一个原则:有数据,才有对应的删除任务;数据不存在,任务就绝对不会产生。
不是怕前面的创建失败(前面失败确实走不到这),而是怕这行代码执行完之后,后面的逻辑报错导致整个事务回滚—— 数据撤销了,但延时任务已经发出去了,就会产生脏任务。和事务绑定就是为了等数据真正落库后再创建任务,彻底保证数据和任务的一致性。
/**
* 创建分享到期自动删除的延时任务
* 核心作用:给有有效期的分享定一个“闹钟”,到点自动清理分享记录和关联数据
* @param shareDO 分享主记录实体
*/
private void scheduleShareExpireDeleteJob(ShareDO shareDO) {
// ========== 前置判断:不需要定时删除的情况,直接退出 ========== // 三种情况不用删:分享记录为空 / 是永久分享 / 没有设置过期时间
if (shareDO == null || isPermanentShare(shareDO) || shareDO.getShareEndTime() == null) {
return;
}
// ========== 计算延时时间:还有多少毫秒到期 ========== // 过期时间戳 - 当前时间戳 = 距离到期还有多少毫秒
// Math.max 保证最少延时1秒,避免过期时间已过、出现负数导致异常
final long delayInMillis = Math.max(1000L, shareDO.getShareEndTime().getTime() - System.currentTimeMillis());
// ========== 封装删除任务的消息体 ========== // 把要删除的分享ID打包成消息对象,延时队列到点后,拿着这个ID去删数据
DeleteShareInfoMessage message = new DeleteShareInfoMessage();
message.setShareId(shareDO.getId());
// ========== 定义入队操作:把任务放进延时队列 ==========
// Runnable:你可以理解成「一段打包好的代码」,现在不执行,等合适的时机再调用 run() 执行。这里面包的逻辑就是「把删除任务放进延时队列」。
// delayQueueHolder.addJob(四个参数):把任务放进延时队列的核心方法,四个参数分别是:
// message:要执行的任务内容(删哪个分享)
// delayInMillis:延时多久执行
// TimeUnit.MILLISECONDS:时间单位是毫秒
// 队列名称:指定放到哪个延时队列里(项目里可能有多个不同业务的延时队列,用名字区分)
Runnable enqueue = () -> delayQueueHolder.addJob(
message, // 任务消息体
delayInMillis, // 延时多久执行
TimeUnit.MILLISECONDS, // 时间单位:毫秒
ShareConstant.DELETE_SHARE_INFO_DELAY_QUEUE_NAME // 延时队列的名称
);
// ========== 核心设计:事务提交成功后,再把任务放进队列 ==========
// 判断当前是不是正处在事务中。创建分享的方法有事务,所以这里会返回 true。
if (TransactionSynchronizationManager.isSynchronizationActive()) {
// 有事务:注册一个事务同步器,等事务提交成功之后,再执行入队操作
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
@Override
public void afterCommit() {
// 事务提交成功 → 分享记录已经真实存在数据库了 → 再加延时任务
enqueue.run();
}
});
return;
}
// 没有事务:直接把任务放进延时队列
enqueue.run();
}
6.文档搜索
6.1.功能介绍
本项目的“文档搜索”其实更准确地说是“文件名搜索”。它不是搜索 Word/PDF/TXT 的正文内容,也不是 AI 语义检索,而是在用户网盘文件记录 user_file.filename 上做关键词搜索。
当前搜索入口集成在 disk-by-cursor/src/views/Files.vue 文件页顶部搜索框里,没有单独做搜索页面。
<!-- 搜索框两种触发方式:回车、点击右侧放大镜按钮 -->
<el-input v-model="searchKeyword" clearable @keyup.enter="handleSearch">
<template #append>
<el-button :loading="searchLoading" @click="handleSearch"><Search /></el-button>
</template>
</el-input>
clearable:输入框悬浮出现叉号一键清空文字;- 表格绑定
:data="fileStore.files",数组一变页面自动刷新,插槽row对应数组单条文件数据。
// 执行搜索:回车或点击搜索按钮触发
async function handleSearch() {
...
try {
// 调用后端搜索接口,传两个参数:搜索词、当前文件分类
// fileStore.files = 后端搜索出来的数组
const response = await searchFiles({
keyword,
fileTypes: fileStore.fileTypes
})
// 接口业务成功
if (response?.success) {
// 表格列表替换成后端返回的搜索结果 兼容后端没返回数组,防止页面报错
fileStore.files = Array.isArray(response.data) ? response.data : []
// 清空之前勾选的文件
selectedFiles.value = []
// 标记页面现在处于【搜索结果模式】
isSearchMode.value = true
}
}
...
这里要先分清几个概念:
搜索:用户输入关键词,系统找出匹配结果。比如输入“简历”,找出文件名里带“简历”的文件。
检索:更偏技术说法,本质也是“按条件找数据”,但通常强调匹配规则、索引、排序、召回、高亮等能力。
ES / Elasticsearch:一个专门做搜索的引擎。它不是替代 MySQL 存业务数据,而是把 MySQL 里的部分数据同步过去,建立适合搜索的索引,让查询更快、更适合模糊匹配和分词搜索。
索引 index:在 ES 里类似 MySQL 的表。比如本项目的 user_file_index,可以理解成“专门给用户文件搜索准备的一张搜索表”。
文档 document:ES 里的一条数据,类似 MySQL 表里的一行记录。
分词:把一个文件名拆成可搜索的小词。比如“代码随想录笔记.pdf”可以被拆成“代码”“随想”“笔记”等词,用户搜其中一部分也可能命中。
| ES 概念 | 关系型数据库类比 | 说明 |
|---|---|---|
| Index(索引) | Database(数据库) | 数据逻辑集合 |
| Type(类型) | Table(表) | ES 7.x 已废弃,8.x 移除 |
| Document(文档) | Row(行) | 一条 JSON 记录 |
| Field(字段) | Column(列) | JSON 键值对 |
| Mapping(映射) | Schema(结构定义) | 字段类型、分词器配置 |
| Shard(分片) | Partition(分区) | 数据物理分片,主分片 + 副本 |
| Inverted Index(倒排索引) | B+Tree 索引 | 词项 → 文档 ID 列表,全文搜索核心 |
一句话理解 ES:
MySQL 更擅长保存业务数据,ES 更擅长搜索文本。
如果只用 MySQL:
where filename like '%关键字%'
简单可用,但数据量大时性能一般,中文模糊搜索能力也有限。
ES 的思路更像书后面的索引:
不是每次从第一页翻到最后一页找“简历”
而是提前建立“简历 -> 出现在哪些文件里”的索引
搜索时直接根据索引找到结果
6.2. 项目搜索能力
当前搜索能力有几个边界要先写清楚:
1. 搜的是当前登录用户自己的文件名。
2. 不搜索文件正文内容。
3. 不搜索分享、回收站、标签、AI 内容。
4. 搜索范围不是当前目录,而是当前文件分类下的所有文件。
5. 搜索结果复用 Files.vue 原来的文件列表表格展示。
6. 搜索结果会把命中的文件名片段高亮。
7. 后端优先查 ES,如果 ES 没结果,再回退查 MySQL。
为什么说“不限制当前目录”?
因为搜索请求只传了:
{
keyword,
fileTypes: fileStore.fileTypes
}
没有传 parentId,所以后端不知道当前打开的是哪个文件夹,也就不是“当前文件夹内搜索”。
fileStore.fileTypes 是当前文件页的文件类型筛选状态,由路由 type 参数通过 typeMap 转换后,在 loadFiles 时保存到 Pinia。搜索时传入 fileTypes,是为了让后端知道当前应该在“全部/图片/文档/视频/音乐/其他”哪个分类范围内搜索;它不是搜索框输入值,也不是当前目录 ID。当你点击左侧分类,比如“图片”“文档”,路由上的 type 会变化,Files.vue 的 watch 会重新计算当前分类
当前前端文件类型映射在 Files.vue:
const typeMap = {
all: '-1',
image: '7',
document: '3,4,5,6,10,11,12',
video: '9',
music: '8',
other: '1,2'
}
所以:
当前在“图片”分类下搜索,只搜 file_type = 7 的文件
当前在“文档”分类下搜索,只搜 3,4,5,6,10,11,12 这些文档类型
当前在“全部”分类下搜索,fileTypes = -1,不限制类型
文件搜索功能的目标,是让用户在当前文件分类下快速找到目标文件,并且在结果列表里直接继续原有的文件操作。
从业务角度看,这类能力通常需要满足以下几点:
- 用户可以在文件页直接输入关键字并触发搜索,不需要跳转到新页面。
- 搜索要支持模糊匹配,而不是只支持完整文件名精确命中。
- 中文关键字需要具备一定的部分匹配能力,例如输入"代码随想录"时,也能命中只包含其中部分词片段的文件名。
- 搜索结果最好附带高亮,方便用户快速判断命中位置。
- 搜索在 ES 数据未同步完成或 ES 结果为空时,仍然要有数据库回退能力。
- 搜索结果返回后,用户还能像普通文件列表一样继续点击文件夹、预览文件、下载文件或进入 AI 面板。
6.3.功能实现
3.1整体思路
当前项目里的搜索链路可以概括为:
- 用户在
Files.vue顶部工具栏输入关键字。 - 点击搜索按钮或按回车后,前端调用
POST /api/v1/files/file/search。 - 前端把
keyword和当前文件分类对应的fileTypes一起提交给后端。 - 后端从登录态获取当前用户
userId,组装成FileSearchContext。 - 服务层先根据关键字构建搜索词集合
searchTerms。 - 搜索服务优先查 ES 索引
user_file_index。 - 如果 ES 没有结果,则回退到 MySQL
user_file表做like搜索。 - 后端补齐每条结果的
parentFilename,并统一生成highlightFilename。 - 前端把结果直接放进当前文件列表,切换到
isSearchMode = true。 - 用户点击"清空搜索",或者切换分类、切换目录、点击面包屑后,页面退出搜索模式并恢复普通目录列表。
当前这条链路涉及的关键位置包括:
- 前端页面:
disk-by-cursor/src/views/Files.vue - 前端文件接口:
disk-by-cursor/src/api/file.js - 前端文件状态:
disk-by-cursor/src/stores/file.js - 后端控制器:
networkdisk-business/networkdisk-files/src/main/java/com/disk/files/controller/UserFileController.java - 后端搜索请求对象:
networkdisk-business/networkdisk-files/src/main/java/com/disk/files/domain/request/FileSearchParamVO.java - 后端搜索上下文:
networkdisk-business/networkdisk-files/src/main/java/com/disk/files/domain/context/FileSearchContext.java - 后端返回对象:
networkdisk-business/networkdisk-files/src/main/java/com/disk/files/domain/response/FileSearchVO.java - 后端服务实现:
networkdisk-business/networkdisk-files/src/main/java/com/disk/files/domain/service/impl/UserFileServiceImpl.java - ES 文档实体:
networkdisk-business/networkdisk-files/src/main/java/com/disk/files/infrastructure/es/entity/UserFileESEntity.java - ES Mapper:
networkdisk-business/networkdisk-files/src/main/java/com/disk/files/infrastructure/es/mapper/UserFileESMapper.java - Canal 同步任务:
networkdisk-business/networkdisk-files/src/main/java/com/disk/files/infrastructure/canal/sync/CanalUserFileEsSyncRunner.java
MyBatis-Plus QueryWrapper 最后生成 SQL,交给 MySQL 执行。 Easy-ES LambdaEsQueryWrapper 最后生成 ES 查询 DSL,交给 Elasticsearch 执行。 完整过程是这样```
MySQL user_file 新增/修改文件记录
↓
Canal 监听 MySQL binlog
↓
项目把 user_file 数据写入 ES
↓
ES 根据 UserFileESEntity 的字段配置建立 user_file_index
↓
filename 是 TEXT + IK_SMART,所以 ES 对文件名分词
↓
ES 内部生成倒排索引
ES 的关键确实是倒排索引。但倒排索引不体现在 search() 方法的业务代码里,而是在 ES 引擎内部完成。项目代码里的 LambdaEsQueryWrapper 只是构造 ES 查询条件,fileEsMapper.selectList(wrapper) 只是把查询发给 ES。真正体现 ES 特性的地方是 UserFileESEntity 上的 @IndexName("user_file_index") 和 filename 字段的 FieldType.TEXT + IK_SMART 分词配置;数据写入 ES 时,ES 会根据这些配置对文件名分词并建立倒排索引,查询时再利用倒排索引快速找到匹配文件。
ES 的 user_file_index 不是每次搜索临时创建的,而是长期存在的搜索索引。MySQL 的 user_file 表数据通过 Canal 同步到 ES 后,ES 会根据 filename 的 TEXT + IK_SMART 配置提前分词并维护倒排索引。用户搜索时只是查询这个已经维护好的索引,因此搜索速度比临时扫描数据库更快。
3.1.1当前前端交互形态
当前搜索交互完全发生在文件页内部,没有单独的搜索结果页。
在当前页面里:
- 顶部搜索框支持回车触发,也支持点击按钮触发。
- 搜索请求调用的是
searchFiles(data),也就是POST /api/v1/files/file/search。 - 提交参数中的
fileTypes来自fileStore.fileTypes,不是当前目录 ID。 - 文件分类和
fileTypes的前端映射当前是:all -> -1image -> 7document -> 3,4,5,6,10,11,12video -> 9music -> 8other -> 1,2
- 只要处于搜索模式,表格里优先渲染
row.highlightFilename,通过v-html展示高亮。 - 点击"清空搜索"后,页面会重新按当前目录和当前文件分类拉取普通文件列表。
- 监听文件分类或根目录变化的
watch会自动把isSearchMode置为false。 openFolder()、navigateToFolder()、refreshCurrentFolder()这些动作也都会退出搜索模式。
需要特别说明的是:
- 当前前端提示文案写的是"搜索当前类型下的文件名",这和代码是一致的。
- 当前搜索不是"当前目录内搜索",因为请求里根本没有
parentId。 - 后端会返回
parentFilename,但当前前端列表没有把它展示出来。
点击搜索按钮或回车后,执行 handleSearch():
async function handleSearch() {
const keyword = searchKeyword.value?.trim()
if (!keyword) {
await clearSearch()
return
}
const response = await searchFiles({
keyword,
fileTypes: fileStore.fileTypes
})
if (response?.success) {
fileStore.files = Array.isArray(response.data) ? response.data : []
selectedFiles.value = []
isSearchMode.value = true
}
}
这段做了几件事:
1. 从搜索框拿关键词。
2. 如果关键词为空,就清空搜索并恢复普通文件列表。
3. 调用搜索接口 searchFiles。
4. 把后端返回的搜索结果直接赋值给 fileStore.files。
5. 设置 isSearchMode = true,表示页面进入搜索模式。
接口封装在 disk-by-cursor/src/api/file.js:
POST /api/v1/files/file/search
搜索结果展示时,前端会判断:
<span
v-if="isSearchMode && row.highlightFilename"
class="file-name"
v-html="row.highlightFilename"
/>
<span v-else class="file-name">{{ row.filename }}</span>
也就是说:
普通模式:展示 row.filename
搜索模式:优先展示 row.highlightFilename
highlightFilename 是后端生成好的 HTML,比如:
<span class="search-highlight">简历</span>2026.pdf
前端通过 CSS 控制高亮样式:
:deep(.search-highlight) {
background: #fef3c7;
color: #92400e;
font-weight: 700;
}
3.2搜索接口
当前后端提供的搜索接口为:
POST /api/v1/files/file/search
当前请求参数为:
keyword:必填,搜索关键字fileTypes:可选,多个文件类型用逗号分隔,-1表示全部
控制器层的真实处理方式是:
- 从登录态获取当前用户
userId - 把前端传来的
keyword写入FileSearchContext - 如果
fileTypes不为空且不等于-1,则拆成List<Integer> - 调用
userFileService.search(context)
当前返回结果主要包括:
fileIdparentIdparentFilenamefilenamehighlightFilenamefileSizeDescfolderFlagfileTypeupdateTime
其中:
fileId和parentId对外返回时会经过IdEncryptSerializer序列化成加密字符串highlightFilename是后端拼装好的 HTML 字符串updateTime来自 `gmt_modified
搜索入口在 UserFileController.search(...):
- 之前学的
BaseMapper、IService是 MyBatis-Plus 提供的,用来操作 MySQL 关系型数据库,查的是数据库里的表。这里的fileEsMapper是 Easy-ES 框架提供的,用来操作 Elasticsearch(简称 ES)搜索引擎,查的是 ES 里的「索引」(你可以理解成 ES 版的 “表”)。 - Easy-ES 就是照着 MyBatis-Plus 的语法设计的,目的就是让会 MyBatis-Plus 的开发者,不用重新学复杂的 ES 查询语法,上手就能用。
类比一下:
- MyBatis-Plus Mapper → 操作 MySQL 表 → 一行行数据叫「记录」
- Easy-ES Mapper → 操作 ES 索引 → 一条条数据叫「文档」
LambdaEsQueryWrapper就是 ES 版的「条件构造器」,和你之前学的LambdaQueryWrapper用法几乎一模一样。用来拼接查询条件:等于、模糊匹配、包含、排序、in 查询等等,不用手写 ES 原生的复杂 DSL 语句,用 Java 代码就能拼查询条件。- 为什么要判断
fileEsMapper != null?这是降级兜底设计:- 如果项目没配置 ES、ES 服务挂了、项目启动时 ES 初始化失败,这个
fileEsMapper对象就会是null。 - 加这个判断就是:ES 能用的时候,优先用 ES(搜索速度快);ES 用不了的时候,直接去查 MySQL(速度慢一点,但搜索功能不会直接废掉)。
- 保证用户不管 ES 正不正常,都能搜到文件,这叫「高可用降级」。
- 如果项目没配置 ES、ES 服务挂了、项目启动时 ES 初始化失败,这个
/**
* 核心搜索业务方法
* 策略:优先走ES全文检索,ES不可用/查不到结果时降级走MySQL
* @param context 搜索上下文(关键词、用户ID、文件类型过滤)
* @return 格式化后的搜索结果列表
*/
@Override
public List<FileSearchVO> search(FileSearchContext context) {
// 1. 根据用户输入的关键词构造多个搜索词
// 例如输入“代码随想录”,可能生成“代码随想录”“代码”“随想”“想录”等
List<String> searchTerms = buildSearchTerms(context.getKeyword());
List<FileSearchVO> result;
// 2. 优先走 ES 搜索
// fileEsMapper 是操作 ES 索引 user_file_index 的 Mapper
// 可以类比 MyBatis-Plus 的 Mapper,只不过它查的是 ES,不是 MySQL
// wrapper.and():把下面所有条件用一个括号包起来,作为一组整体条件
// 括号里 w -> { ... } 是 Lambda 表达式,w 就是内部的条件构造器
//这里的 w 也是一个 Wrapper 条件构造器对象,只是它是 wrapper.and(...) 里面临时传进来的“内部条件构造器”。
//给外层 wrapper 添加一组 AND 条件。这组条件内部怎么写,由 w 来继续拼。当前这组 AND 括号里面的条件构造器
if (fileEsMapper != null) {
LambdaEsQueryWrapper<UserFileESEntity> wrapper = new LambdaEsQueryWrapper<>();
// 3. 构造文件名匹配条件
// 多个搜索词之间用 OR:命中任意一个词就算匹配
if (CollectionUtils.isNotEmpty(searchTerms)) {
wrapper.and(w -> {
boolean first = true;
for (String term : searchTerms) {
if (StringUtils.isBlank(term)) {
continue;
}
if (first) {
w.like(UserFileESEntity::getFilename, term);
first = false;
} else {
w.or().like(UserFileESEntity::getFilename, term);
}
}
if (first) {
w.like(UserFileESEntity::getFilename, context.getKeyword());
}
});
} else {
wrapper.like(UserFileESEntity::getFilename, context.getKeyword());
}
// 4. 基础过滤条件
// userId 限制只能搜索当前登录用户自己的文件
wrapper.eq(UserFileESEntity::getUserId, context.getUserId());
// deleted = 0 表示只查未删除文件
wrapper.eq(UserFileESEntity::getDeleted, DeleteEnum.NO.getCode());
// 5. 文件类型过滤
// 如果当前在“图片/文档/视频”等分类下搜索,就只搜对应类型
if (!EmptyUtil.isEmpty(context.getFileTypeArray())) {
wrapper.in(UserFileESEntity::getFileType, context.getFileTypeArray());
}
// 6. 最近修改的文件排前面
wrapper.orderByDesc(UserFileESEntity::getGmtModified);
// 7. 执行 ES 查询
List<UserFileESEntity> docs = fileEsMapper.selectList(wrapper);
// 8. 把 ES 文档对象转换成前端需要的 VO result = docs.stream().map(doc -> {
FileSearchVO vo = new FileSearchVO();
vo.setFileId(doc.getId());
vo.setFilename(doc.getFilename());
vo.setParentId(doc.getParentId());
vo.setFolderFlag(doc.getFolderFlag());
vo.setFileType(doc.getFileType());
vo.setFileSizeDesc(doc.getFileSizeDesc());
vo.setUpdateTime(doc.getGmtModified());
return vo;
}).toList();
// 9. 如果 ES 没查到结果,回退查 MySQL user_file 表
// 目的是防止 ES 数据未同步、索引为空时搜索直接失效
if (CollectionUtils.isEmpty(result)) {
result = doSearch(context, searchTerms);
}
} else {
// 10. 如果没有 ES Mapper,则直接查 MySQL result = doSearch(context, searchTerms);
}
// 11. 补充父目录名称
fillParentFilename(result);
// 12. 生成高亮文件名 highlightFilename applyHighlight(result, searchTerms);
return result;
}
举个简单例子。
wrapper.eq(UserFileESEntity::getUserId, userId);
wrapper.and(w -> {
w.like(UserFileESEntity::getFilename, "简历")
.or()
.like(UserFileESEntity::getFilename, "合同");
});
大概相当于:
WHERE user_id = ?
AND (
filename LIKE '%简历%'
OR filename LIKE '%合同%'
)
注意这个括号:AND ( ... OR ... )。w 就是在拼这个括号里面的内容。
这里的重点是:
1. keyword 来自前端搜索框。
2. userId 来自当前登录态,不由前端传,防止越权搜索别人的文件。
3. fileTypes 如果是 -1,表示全部类型,不做类型过滤。
4. fileTypes 如果不是 -1,就按逗号拆成 List<Integer>。
5. 最后交给 userFileService.search(context)。
3.3搜索时的查询策略
搜索功能最核心的点,不在于"发一个关键字到后端",而在于"如何在保证可用性的前提下做尽量合理的召回和展示"。 当前项目采用的是"关键字拆词 + ES 优先 + MySQL 回退 + 服务端统一高亮"的策略。
3.3.1. 搜索词构建
UserFileServiceImpl.buildSearchTerms(...) 当前会这样处理关键字:
- 对原始关键字做
trim - 先把完整关键字本身加入搜索词集合
- 再按空白字符拆词,把非空 token 加进去
- 从关键字里提取纯中文串
- 如果纯中文长度大于 2,则继续生成:
- 所有 2 字子串
- 所有 3 字子串
- 最终去重,并按词长度从长到短排序
这意味着当前像"代码随想录"这样的关键字,不只会搜完整词,也会生成"代码"“随想”“想录”“代码随”"随想录"这类子串命中词。
这里不用 IK 分词器,核心是「场景适配 + 成本权衡」:针对文件名短、命名不规则的搜索场景,搭配 MySQL 兜底的高可用架构,轻量的 n-gram 字符切分方案,部署更简单、容错率更高、维护成本更低,比重型的 IK 词典分词性价比更高。
后端不是只拿用户输入的原始关键词去搜,而是先构造搜索词集合:
/**
* 构建搜索词项列表
* 处理逻辑:标准化 → 空格分词 → 中文n-gram切分 → 去重 → 按长度倒序
* @param keyword 用户原始输入的搜索关键词
* @return 处理后的多粒度搜索词项列表,长词在前、短词在后
*/
private List<String> buildSearchTerms(String keyword) {
// 第一步:关键词标准化 —— 去除前后空格,null自动转成空字符串
String normalized = StringUtils.trimToEmpty(keyword);
// 关键词为空,直接返回空列表,不做任何搜索
if (StringUtils.isBlank(normalized)) {
return List.of();
}
// 第二步:用 LinkedHashSet 存储词项
// 特性1:Set 自动去重,避免重复词项浪费查询性能
// 特性2:Linked 保证插入顺序,先加的长词排在前面
LinkedHashSet<String> terms = new LinkedHashSet<>();
// 先把「完整的原始关键词」加进去 —— 最高优先级,精准匹配
terms.add(normalized);
// 按「任意空白字符(空格、多个空格、制表符等)」拆分关键词
// 处理用户输入「2024 项目报告」这种空格分隔多关键词的场景
for (String token : normalized.split("\\s+")) {
// 跳过拆分后产生的空串
if (StringUtils.isNotBlank(token)) {
terms.add(token.trim());
}
}
// 第三步:中文n-gram切分(二元、三元连续字组合)—— 提升中文模糊匹配的召回率
// 先把关键词里的非中文字符全部去掉,只保留纯中文内容
String chineseOnly = normalized.replaceAll("[^\\u4e00-\\u9fa5]", StringUtils.EMPTY);
// 纯中文长度大于2,才做n-gram切分(太短切分没意义)
if (chineseOnly.length() > 2) {
// 二元切分(bi-gram):每2个连续汉字组成一个词项
// 比如「项目报告」→ 项目、目报、报告
for (int i = 0; i <= chineseOnly.length() - 2; i++) {
terms.add(chineseOnly.substring(i, i + 2));
}
// 三元切分(tri-gram):每3个连续汉字组成一个词项
// 比如「项目报告」→ 项目报、目报告
for (int i = 0; i <= chineseOnly.length() - 3; i++) {
terms.add(chineseOnly.substring(i, i + 3));
}
}
// 第四步:最终整理输出
return terms.stream()
// 再过滤一遍空字符串,兜底防护
.filter(StringUtils::isNotBlank)
// 按「字符串长度倒序」排序:长词在前,短词在后
// 原因:长词匹配更精准,高亮时优先匹配长词,避免短词提前占位
.sorted(Comparator.comparingInt(String::length).reversed())
.toList();
}
用户输入一个关键词,后端会把它拆成多个可能命中的词。例如输入:代码随想录 会得到类似:
代码随想录
代码随
随想录
代码
随想
想录
这样做的好处是:
不只依赖完整关键词命中,也能通过部分片段命中文件名。
这对中文搜索比较有用,因为中文不像英文天然有空格分隔单词。
3.3.2. ES 优先查询
当前 UserFileServiceImpl.search(...) 会优先使用 UserFileESMapper 查询 ES。
当前 ES 查询条件包括:
filenamelike 搜索词集合中的任一词userId =当前登录用户deleted = 0- 如果前端传了
fileTypes,则额外fileType in (...) - 按
gmt_modified desc排序,按文件更新时间倒序:最近修改过的文件排在最前
查询命中后,服务层会把 ES 文档映射成 FileSearchVO,填充:
fileIdparentIdfilenamefolderFlagfileTypefileSizeDescupdateTime
需要特别说明的是:
- 当前即使走 ES,返回高亮仍然不是直接依赖 ES 的高亮结果
- 当前服务层还是会在后面统一跑一遍
applyHighlight(...)
3.3.3. MySQL 回退
如果 ES 结果为空,当前代码会回退到 doSearch(...)。
这个回退查询不是走 UserFileMapper.xml 里的老 searchFile SQL,而是直接在服务层使用 MyBatis-Plus QueryWrapper 组装条件。
当前回退查询条件包括:
- 只查当前登录用户
- 只查
deleted = 0 - 如果有
fileTypes,则按file_type in (...)过滤 - 文件名对搜索词集合做多组
likeOR 匹配 - 按
gmt_modified desc排序
因此,当前搜索即使 ES 没有结果,也不会直接失效。
3.3.4. 父目录名补齐
ES 或 MySQL 拿到初步结果之后,服务层还会执行 fillParentFilename(...)。
它的做法是:
- 收集结果里的所有
parentId - 再用
listByIds(parentIdList)回查这些父级目录记录 - 把父目录名称写回每条
FileSearchVO.parentFilename
不过当前前端并没有把这个字段展示出来,所以它目前主要是后端结果里的补充信息。
这段的核心目的:
搜索结果里只有 parentId
↓
批量查询这些 parentId 对应的父文件夹记录
↓
整理成 id -> filename 的 Map
↓
给每条搜索结果补 parentFilename
搜索结果:
文件 A:parentId = 100
文件 B:parentId = 100
文件 C:parentId = 200
查父文件夹后:
100 -> Java资料
200 -> 项目文档
最后补成:
文件 A:parentFilename = Java资料
文件 B:parentFilename = Java资料
文件 C:parentFilename = 项目文档
3.3.5. 服务端高亮
当前高亮逻辑由 buildHighlightFilename(...) 完成,核心步骤是:
- 对每个搜索词在文件名中做不区分大小写匹配
- 收集所有命中区间
- 对重叠命中区间做合并
- 对普通文本做 HTML 转义
- 对命中片段包裹
<span class="search-highlight">...</span>
也就是说,当前高亮能力是后端统一输出的,因此:
- ES 查询结果和 MySQL 回退结果的高亮样式是一致的
- 即使 ES 没返回高亮字段,前端也照样能显示黄色高亮效果
假设:
filename = "代码随想录.pdf";
searchTerms = ["代码随想录", "代码", "随想", "想录"];
它先找到这些命中区间:
代码随想录 -> [0, 5)
代码 -> [0, 2)
随想 -> [2, 4)
想录 -> [3, 5)
这些区间彼此重叠或相邻:
[0,5) 覆盖了 [0,2)、[2,4)、[3,5)
排序 + 合并后,只剩:
[0,5)
所以最后生成:
<span class="search-highlight">代码随想录</span>.pdf
也就是整个“代码随想录”高亮。 命中的关键词区间如果重叠或首尾相接,就会合并成一整段高亮;如果中间隔着未命中的普通文字,就分成多段高亮。
前端里:
fileStore.files = Array.isArray(response.data) ? response.data : []
但关键是:后端返回的数据里已经带着高亮后的字段 highlightFilename 了。
前端只是把搜索结果塞进原来的文件列表:
fileStore.files = response.data
然后页面表格在渲染文件名时,会判断当前是不是搜索模式。
关键代码在 Files.vue:
<span
v-if="isSearchMode && row.highlightFilename"
class="file-name"
v-html="row.highlightFilename"
/>
<span v-else class="file-name">{{ row.filename }}</span>
也就是说:
普通模式:显示 row.filename
搜索模式:如果 row.highlightFilename 有值,就用 v-html 渲染 row.highlightFilename
所以前端并不是在赋值那一行高亮,而是在页面模板渲染文件名时高亮。
举个完整例子。
用户搜索:代码 后端返回:
[
{
"fileId": "加密后的id",
"filename": "代码随想录.pdf",
"highlightFilename": "<span class=\"search-highlight\">代码</span>随想录.pdf",
"fileSizeDesc": "1.2MB",
"folderFlag": 0,
"fileType": 5,
"updateTime": "2026-07-06 21:00:00"
}
]
前端执行:
fileStore.files = response.data
isSearchMode.value = true
此时表格里的 row 就是这一条数据。
渲染文件名时:
v-if="isSearchMode && row.highlightFilename"
成立,于是用:
v-html="row.highlightFilename"
把这段 HTML 渲染出来:
<span class="search-highlight">代码</span>随想录.pdf
然后 CSS 生效:
:deep(.search-highlight) {
background: #fef3c7;
color: #92400e;
border-radius: 4px;
padding: 0 2px;
font-weight: 700;
}
所以页面上看到“代码”被黄色背景高亮。
result 返回的东西可以这样理解:
response.data 是文件列表数组
数组里每一项是一行文件数据
表格每一行 row 就是其中一个对象
例如:
fileStore.files = [
{
filename: '代码随想录.pdf',
highlightFilename: '<span class="search-highlight">代码</span>随想录.pdf',
fileSizeDesc: '1.2MB'
},
{
filename: 'SpringBoot笔记.docx',
highlightFilename: '<span class="search-highlight">Spring</span>Boot笔记.docx',
fileSizeDesc: '800KB'
}
]
el-table 会循环 fileStore.files:
第 1 行 row = 第 1 个对象
第 2 行 row = 第 2 个对象
第 3 行 row = 第 3 个对象
所以模板里写的:
row.highlightFilename
就是当前这一行文件的高亮文件名。
完整链路是:
用户输入关键词
↓
前端请求搜索接口
↓
后端查 ES / MySQL
↓
后端 buildHighlightFilename 生成 highlightFilename
↓
后端返回 FileSearchVO 列表
↓
前端 fileStore.files = response.data
↓
表格重新渲染
↓
搜索模式下使用 row.highlightFilename
↓
v-html 把 span 标签渲染成 HTML
↓
.search-highlight CSS 让命中词变黄
一句话记:
高亮不是在 fileStore.files = response.data 这一行完成的,而是后端提前生成 highlightFilename,前端把结果放进文件列表后,表格模板在搜索模式下用 v-html 渲染 highlightFilename,再通过 .search-highlight CSS 显示高亮效果。
3.4. ES 索引与 Canal 同步
MySQL user_file 表:业务主数据,真正保存用户文件记录
ES user_file_index:搜索副本,专门用来提高文件名搜索能力
Canal:同步工具,负责把 MySQL 的变化同步到 ES
也就是说,ES 不是代替 MySQL。项目里上传文件、改文件名、删除文件,最终仍然先写 MySQL 的 user_file 表。ES 只是为了搜索更快、更适合分词检索,所以额外维护了一份搜索索引。Canal 的作用就是:监听 MySQL 的数据变化,然后把变化通知给项目,项目再同步到 ES。这样业务代码只需要正常写 MySQL,Canal 同步线程在旁边负责把变化搬到 ES。
Canal 是阿里开源的 MySQL 增量数据订阅工具。
Canal 假装自己是 MySQL 的一个从库,MySQL 主库发生 INSERT / UPDATE / DELETE,MySQL 会把这些变化写进 binlog,Canal 像从库一样读取 binlog,然后把变化解析成 Java 能处理的数据,项目拿到这些变化后,同步到 ES。binlog 可以理解成 MySQL 的“操作日志”。
比如执行:
update user_file set filename = '代码随想录.pdf' where id = 100;
MySQL 不只会改表数据,还会记录一条 binlog,大概表达:
user_file 表
id = 100
filename 从旧值变成了 代码随想录.pdf
这是一次 UPDATE 操作
Canal 读取的就是这种变更日志。
整体流程:
用户上传 / 改名 / 删除文件
↓
业务代码写 MySQL user_file
↓
MySQL 产生 binlog
↓
Canal Server 读取 binlog
↓
项目里的 CanalUserFileEsSyncRunner 拉取变更事件
↓
把变更数据组装成 UserFileESEntity
↓
写入 / 更新 / 删除 ES 的 user_file_index
3.4.1. ES 索引结构
当前 ES 文档实体是 UserFileESEntity,在这个文件:UserFileESEntity.java索引名为:
user_file_index
当前主要索引字段包括:
iduser_idparent_idreal_file_idfilenamefolder_flagfile_size_descfile_typecreate_usergmt_creategmt_modifiedupdate_userdeletedlock_version
其中:
filename当前使用TEXT + IK_SMART分词- 其它过滤字段主要按
KEYWORD / INTEGER / DATE保存
1.当前配置 项目代码里写的是 Canal 客户端消费逻辑,不是 Canal Server 本身。 当前配置networkdisk-business/networkdisk-files/src/main/resources/canal.yml:
com:
disk:
canal:
enable: true
host: 127.0.0.1
port: 11111
destination: example
subscribe-filter: network_disk\\.user_file
含义是:
host: 127.0.0.1
port: 11111
说明项目会去连接本机 11111 端口上的 Canal Server。也就是说你本地要真的有 Canal Server 在跑,否则这个同步线程连不上。
destination: example 是 Canal Server 里的实例名。
subscribe-filter: network_disk\\.user_file 表示只订阅:network_disk 数据库里的 user_file 表
所以本项目搜索同步只关心 user_file 表变化,不关心 share、file、user 等其他表。
2. 配置文件怎么生效
networkdisk-files 的 application.yml 引入了:
spring:
config:
import: classpath:datasource.yml,classpath:cache.yml,classpath:rpc.yml,classpath:es.yml,classpath:canal.yml,classpath:stream.yml
所以:
es.yml 负责 ES / Easy-ES 配置
canal.yml 负责 Canal 配置
canal.yml 里的配置会绑定到:
@ConfigurationProperties(prefix = "com.disk.canal")
public class CanalSyncProperties {
private boolean enable = false;
private String host = "127.0.0.1";
private int port = 11111;
private String destination = "example";
private int batchSize = 200;
private long pollingIntervalMs = 500L;
private String subscribeFilter = "network_disk\\.user_file";
}
这就是 Spring Boot 常见的配置绑定:
canal.yml
↓
CanalSyncProperties
↓
CanalUserFileEsSyncRunner 使用这些配置连接 Canal
3 同步线程什么时候启动
同步线程类是:
@Component
//@EnableConfigurationProperties:开启配置类,自动读取配置文件里 Canal 的地址、端口、订阅表等配置。
@EnableConfigurationProperties(CanalSyncProperties.class)
//@ConditionalOnProperty:条件注解—— 只有配置文件里 com.disk.canal.enable = true 时,这个类才会生效。
@ConditionalOnProperty(prefix = "com.disk.canal", name = "enable", havingValue = "true")
//@ConditionalOnBean:条件注解—— 只有 ES 的 Mapper 存在(也就是 ES 配置成功、可用)时,才启动同步
@ConditionalOnBean(UserFileESMapper.class)
//implements Runnable:实现 Runnable 接口,因为同步是死循环监听,必须开单独后台线程跑,不能阻塞主程序。
public class CanalUserFileEsSyncRunner implements Runnable {
启动条件:
1. com.disk.canal.enable = true
2. Spring 容器里存在 UserFileESMapper
也就是:
Canal 开关打开
ES Mapper 也加载成功
才启动 user_file -> ES 的同步线程
构造方法里会启动后台线程:
// 项目一启动,这个 Bean 初始化时就会自动开一个后台线程,开始跑同步逻辑;
// 守护线程的作用:程序关闭时不用手动停线程,不会阻止程序退出。
// 构造方法:Spring 注入配置和 Mapper,启动同步线程
public CanalUserFileEsSyncRunner(CanalSyncProperties properties, UserFileESMapper userFileESMapper) {
this.properties = properties;
this.userFileESMapper = userFileESMapper;
startWorker();
}
// 启动后台工作线程
private void startWorker() {
workerThread = new Thread(this, "canal-user-file-es-sync");
workerThread.setDaemon(true); // 设置为守护线程:主程序关闭,线程自动跟着关闭
workerThread.start();
}
这里为什么要开新线程?因为 Canal 同步是一个持续监听过程:
一直拉取
一直处理
没有数据就等一会
有数据就同步
如果不放到后台线程里,会阻塞项目正常启动和接口请求。
4. Canal 客户端怎么拉数据
核心代码在 run():
// 第一步:创建 Canal 连接器,指定 Canal 服务地址、端口、实例名、账号密码
connector = CanalConnectors.newSingleConnector(
new InetSocketAddress(properties.getHost(), properties.getPort()),
properties.getDestination(),
StringUtils.defaultString(properties.getUsername()),
StringUtils.defaultString(properties.getPassword())
);
// 第二步:连接 Canal 服务
connector.connect();
// 订阅要监听的表(配置里一般是 "coder_pan.user_file",只监听文件表)
connector.subscribe(properties.getSubscribeFilter());
// 回滚到上次确认的位置,从上次没处理的地方开始,避免丢数据
connector.rollback();
这几行意思是:
创建 Canal 连接器
连接 Canal Server
订阅指定表
回滚到上次确认的位置,避免漏处理
然后进入循环:
// 第三步:死循环,持续拉取 binlog 消息
while (running) {
// 拉取一批消息,不自动确认(getWithoutAck)
// batchSize:一次最多拉多少条
Message message = connector.getWithoutAck(properties.getBatchSize());
long batchId = message.getId();
List<CanalEntry.Entry> entries = message.getEntries();
// 没拉到数据,睡一会再拉,避免空转浪费CPU
if (batchId == -1 || CollectionUtils.isEmpty(entries)) {
sleepQuietly(properties.getPollingIntervalMs());
continue;
}
boolean success = false;
try {
// 第四步:处理这一批消息(解析增删改,同步到ES)
handleEntries(entries);
success = true;
} catch (Exception e) {
log.error("canal sync batch handle error, batchId={}", batchId, e);
}
// 第五步:消息确认机制
if (success) {
// 处理成功 → 确认 ack,Canal 就不会再发这批消息了
connector.ack(batchId);
} else {
// 处理失败 → 回滚 rollback,下次还会重新拉这批消息,保证不丢数据
connector.rollback(batchId);
sleepQuietly(properties.getPollingIntervalMs());
}
}
getWithoutAck 很关键。它的意思是:先把一批消息拿过来,但我暂时不确认处理成功
后面如果同步 ES 成功:connector.ack(batchId);告诉 Canal:这批我处理好了,下次给我新的
如果失败:connector.rollback(batchId);告诉 Canal:这批我没处理成功,下次还给我这批
所以 ack / rollback 是为了尽量保证数据不丢。
ack = 确认成功
rollback = 处理失败,下次重试
5. Canal 消息里有什么 Canal 拉到的不是普通 JSON,而是 MySQL binlog 解析后的结构化数据。
代码里处理的是:
private void handleEntries(List<CanalEntry.Entry> entries) throws Exception {
// 遍历每一条 binlog 条目
for (CanalEntry.Entry entry : entries) {
// 只处理「行数据变化」类型,事务开头/结尾这类事件直接跳过
if (entry.getEntryType() != CanalEntry.EntryType.ROWDATA) {
continue;
}
CanalEntry.Header header = entry.getHeader();
// 只处理 user_file 表的变化,其他表不关心
if (!"user_file".equalsIgnoreCase(header.getTableName())) {
continue;
}
// 解析行变化数据
CanalEntry.RowChange rowChange = CanalEntry.RowChange.parseFrom(entry.getStoreValue());
// 获取事件类型:新增 / 修改 / 删除
CanalEntry.EventType eventType = rowChange.getEventType();
// 遍历每一行变化的数据
for (CanalEntry.RowData rowData : rowChange.getRowDatasList()) {
if (eventType == CanalEntry.EventType.DELETE) {
// 删除操作:拿删除前的数据,去 ES 删对应文档
handleDelete(rowData.getBeforeColumnsList());
continue;
}
if (eventType == CanalEntry.EventType.INSERT || eventType == CanalEntry.EventType.UPDATE) {
// 新增/修改操作:拿最新的数据,更新或插入 ES handleUpsert(rowData.getAfterColumnsList());
}
}
}
}
逐层理解:
Entry:一条 binlog 事件
Header:事件头信息,比如库名、表名
RowChange:行数据变化
EventType:操作类型,INSERT / UPDATE / DELETE
RowData:具体某一行变化的数据
beforeColumns:变化前的列值
afterColumns:变化后的列值
为什么 DELETE 用 beforeColumns?因为删除后,这行已经不存在了,只能拿删除前的数据知道它的 id。
为什么 INSERT / UPDATE 用 afterColumns?因为新增或修改后,要同步的是最新数据。
6. INSERT / UPDATE 怎么同步到 ES 新增或修改走:
handleUpsert(rowData.getAfterColumnsList());
核心代码:
为什么要「先更新,失败再插入」? 这就是经典的 Upsert(更新或插入) 逻辑:
如果是 MySQL 更新操作,ES 里本来就有这条数据,直接更新就行; 如果是 MySQL 新增操作,ES 里没有,更新会返回 0,这时再执行插入; 一套逻辑同时兼容新增和修改,不用判断事件类型,简单可靠,保证数据最终一致。
UserFileESEntity entity = new UserFileESEntity();
entity.setId(id);
entity.setUserId(asLong(values.get("user_id")));
entity.setParentId(asLong(values.get("parent_id")));
entity.setRealFileId(asLong(values.get("real_file_id")));
entity.setFilename(values.get("filename"));
entity.setFolderFlag(asInteger(values.get("folder_flag")));
entity.setFileSizeDesc(values.get("file_size_desc"));
entity.setFileType(asInteger(values.get("file_type")));
entity.setCreateUser(values.get("create_user"));
entity.setUpdateUser(values.get("update_user"));
entity.setGmtCreate(asDate(values.get("gmt_create")));
entity.setGmtModified(asDate(values.get("gmt_modified")));
entity.setDeleted(asInteger(values.get("deleted")));
entity.setLockVersion(asInteger(values.get("lock_version")));
// 先尝试按 ID 更新 ES 文档
Integer updated = userFileESMapper.updateById(entity);
// 更新失败(说明 ES 里没有这条数据,是新增的),就执行插入
if (updated == null || updated <= 0) {
userFileESMapper.insert(entity);
}
这里做了三件事:
1. 把 Canal 给的列数据转成 Map
2. 从 Map 里取 user_file 各个字段,组装成 UserFileESEntity
3. 同步到 ES
updateById + insert 是典型 upsert 思路:
如果 ES 里已经有这条文档,就更新
如果 ES 里没有这条文档,就插入
所以:
INSERT -> ES 里没有,最后 insert
UPDATE -> ES 里有,updateById 成功
但代码不强行区分新增和修改,而是统一用 upsert,更省事。
7.DELETE 和逻辑删除要分清楚
代码里物理删除走:
private void handleDelete(List<CanalEntry.Column> columns) {
// 把列列表转成 <字段名:字段值> 的 Map,方便取值
Map<String, String> values = toValueMap(columns);
// 拿到数据主键 ID Long id = asLong(values.get("id"));
if (id == null) {
return;
}
// 根据 ID 删除 ES 里对应的文档
userFileESMapper.deleteById(id);
}
这表示:
MySQL 发生真正 DELETE 语句
↓
ES 删除对应文档
但是项目业务里很多删除其实是逻辑删除:
update user_file set deleted = 1 where id = ...
这种对 Canal 来说不是 DELETE,而是 UPDATE。所以逻辑删除会走:
handleUpsert(afterColumns)
然后把:
deleted = 1
同步到 ES。搜索时又有条件:
wrapper.eq(UserFileESEntity::getDeleted, DeleteEnum.NO.getCode());
所以逻辑删除的数据即使还在 ES,也不会被搜出来。 这个点很重要:
物理 DELETE:ES 删除文档
逻辑删除 UPDATE deleted=1:ES 更新文档状态,搜索时通过 deleted=0 过滤掉
8. Canal 和 ES 的最终关系
可以画成这样:
业务接口
↓
写 MySQL user_file
↓
MySQL 记录 binlog
↓
Canal Server 读取 binlog
↓
CanalUserFileEsSyncRunner 拉取变更
↓
handleEntries 判断 INSERT / UPDATE / DELETE
↓
handleUpsert / handleDelete
↓
UserFileESMapper 操作 ES
↓
user_file_index 更新
↓
搜索接口优先查 ES
搜索时:
用户搜索关键词
↓
后端查 ES user_file_index
↓
ES 用倒排索引快速找文件名
↓
返回搜索结果
所以 Canal 不参与搜索请求本身。
Canal 只是提前把数据同步好,让 ES 搜索时有数据可查。
4. Canal 配置类在哪里
在:networkdisk-business/networkdisk-files/src/main/java/com/disk/files/infrastructure/canal/config/CanalSyncProperties.java
@ConfigurationProperties(prefix = "com.disk.canal")
public class CanalSyncProperties {
它会把 canal.yml 里的配置绑定到 Java 对象上:
private boolean enable = false;
private String host = "127.0.0.1";
private int port = 11111;
private String destination = "example";
private int batchSize = 200;
private long pollingIntervalMs = 500L;
private String subscribeFilter = "network_disk\\.user_file";
也就是:
canal.yml 配置文件
↓
CanalSyncProperties Java 配置对象
↓
CanalUserFileEsSyncRunner 使用这些配置连接 Canal
6. Canal 怎么订阅 user_file
在:networkdisk-business/networkdisk-files/src/main/java/com/disk/files/infrastructure/canal/sync/CanalUserFileEsSyncRunner.java
// 第一步:创建 Canal 连接器,指定 Canal 服务地址、端口、实例名、账号密码
connector = CanalConnectors.newSingleConnector(
new InetSocketAddress(properties.getHost(), properties.getPort()),
properties.getDestination(),
StringUtils.defaultString(properties.getUsername()),
StringUtils.defaultString(properties.getPassword())
);
// 第二步:连接 Canal 服务
connector.connect();
// 订阅要监听的表(配置里一般是 "coder_pan.user_file",只监听文件表)
connector.subscribe(properties.getSubscribeFilter());
properties.getSubscribeFilter() 就来自:
subscribe-filter: network_disk\\.user_file
意思是只监听:
network_disk 数据库里的 user_file 表
ES 索引结构定义在 UserFileESEntity.java。类上的 @IndexName(“user_file_index”) 指定 ES 索引名,字段上的 @IndexField 指定 ES 字段名和字段类型,其中 filename 是 TEXT + IK_SMART,说明它是可分词搜索字段。
ES 操作入口是 UserFileESMapper,它继承 BaseEsMapper<UserFileESEntity>,可以类比 MyBatis-Plus 的 BaseMapper,只不过它操作的是 ES 索引,不是 MySQL 表。
Canal 配置来自 canal.yml,并通过 CanalSyncProperties 绑定成 Java 配置对象。CanalUserFileEsSyncRunner 在 com.disk.canal.enable=true 且存在 UserFileESMapper 时启动后台线程,连接 Canal Server,订阅 network_disk.user_file 表变更。
当监听到 user_file 的 INSERT 或 UPDATE 时,代码会把变更后的行数据组装成 UserFileESEntity,并 updateById,更新不到则 insert,实现 upsert;当监听到 DELETE 时,会根据 id 删除 ES 文档。每批同步成功后 ack,失败则 rollback,等待下一轮重试。
3.4.2. Canal 增量同步
Canal 是阿里巴巴开源的数据库增量数据订阅与消费中间件,核心功能是伪装成 MySQL 从库(Slave),实时捕获主库的 Binlog 变更,并将这些变更同步到其他系统(如 Elasticsearch、Redis、Kafka 等)。
MySQL 主库 Canal 服务器 消费端
┌─────────┐ ┌─────────────┐ ┌─────────────┐
│ 业务写入 │ → Binlog 记录 → │ 伪装 Slave │ → 解析 Binlog │ → ES / Redis │
│ INSERT │ (二进制日志) │ 实时拉取 │ 为结构化数据 │ / Kafka │
│ UPDATE │ │ │ │ │
│ DELETE │ │ │ │ │
└─────────┘ └─────────────┘ └─────────────┘
| 步骤 | 说明 |
|---|---|
| 1. MySQL 开启 Binlog | 记录所有数据变更(ROW 模式记录行级变化) |
| 2. Canal 伪装 Slave | 向 MySQL 主库发送 dump 协议请求 |
| 3. 实时拉取 Binlog | 像 MySQL 从库一样接收主库推送的日志 |
| 4. 解析 Binlog | 将二进制日志解析为结构化数据(表名、操作类型、变更前后的值) |
| 5. 投递消费端 | 通过 TCP/MQ/Kafka 等方式推送给业务系统 |
在网盘项目中的角色
MySQL user_file 表
│
▼ 数据变更(INSERT/UPDATE/DELETE)
MySQL Binlog
│
▼ Canal 实时捕获
Canal Server (host: 127.0.0.1, port: 11111)
│
▼ 解析为变更事件
CanalUserFileEsSyncRunner
│
▼ 同步到 ES
Elasticsearch user_file_index
当前项目里已经有 user_file -> ES 的 Canal 同步实现。
代码位置包括:
networkdisk-business/networkdisk-files/src/main/java/com/disk/files/infrastructure/canal/config/CanalSyncProperties.javanetworkdisk-business/networkdisk-files/src/main/java/com/disk/files/infrastructure/canal/sync/CanalUserFileEsSyncRunner.java
同步线程启动条件是:
com.disk.canal.enable = true- Spring 容器中存在
UserFileESMapper
//@ConditionalOnProperty:条件注解—— 只有配置文件里 com.disk.canal.enable = true 时,这个类才会生效。
@ConditionalOnProperty(prefix = "com.disk.canal", name = "enable", havingValue = "true")
//@ConditionalOnBean:条件注解—— 只有 ES 的 Mapper 存在(也就是 ES 配置成功、可用)时,才启动同步
@ConditionalOnBean(UserFileESMapper.class)
当前 networkdisk-files/src/main/resources/application.yml 已经引入:
classpath:es.ymlclasspath:canal.yml
当前 canal.yml 默认配置为:
enable: truehost: 127.0.0.1port: 11111destination: examplesubscribe-filter: network_disk\\.user_file
当前同步规则是:
INSERT / UPDATE:把user_file行数据 upsert 到 ESDELETE:按id删除 ES 文档- 当前批次处理成功后
ack - 处理失败则
rollback并等待下一轮重试
3.5. 当前实现边界
为了保证文档和代码一致,当前搜索能力还需要明确这些边界:
- 当前搜索只搜索文件名,不搜索文件内容。
- 当前搜索不限制在当前目录,而是按"当前用户 + 当前文件类型"全局搜索。
- 当前搜索结果既可能是文件,也可能是文件夹。
- 当前前端不会展示
parentFilename,虽然服务端已经返回了这个字段。 - 当前没有分页参数,搜索结果会直接按
gmt_modified desc全量返回。 - 当前没有搜索历史、热门关键字、最近搜索等独立数据表或接口。
- 当前服务里仍然保留了
UserFileMapper.xml里的老搜索 SQL,但搜索主链路实际不走那段 XML,而是走 ES 查询和服务层QueryWrapper回退。
6.4.数据模型
4.1. user_file_index
作用:保存用户文件在 ES 中的索引文档,用于文件名搜索。
当前索引里的核心字段包括:
iduser_idparent_idreal_file_idfilenamefolder_flagfile_size_descfile_typegmt_creategmt_modifieddeleted
这份索引的意义在于告诉系统:“当前用户有哪些文件,它们叫什么,属于什么类型,最近什么时候更新,能不能被搜索出来”。
4.2. user_file
作用:保存用户自己的文件记录,也是搜索回退查询和 Canal 同步的源表。
当前搜索链路里实际会用到:
iduser_idparent_idfilenamefolder_flagfile_size_descfile_typegmt_modifieddeleted
也就是说:
- ES 查询用的是
user_file的同步副本 - MySQL 回退则直接查 `user_file
7.分享链接功能

7.1. 功能说明
- 当前项目的分享能力由
networkdisk-business/networkdisk-share模块提供,前端主要有 3 个入口:文件页单项分享、我的分享页管理、匿名分享访问页。 - 当前分享的本质,是把一个或多个
user_file记录挂到一条share记录下面,并通过share_file做关联,而不是直接把物理文件暴露给外部。 - 当前分享支持两种类型:
shareType = 0:公开分享shareType = 1:私密分享,需要提取码
- 当前无论公开还是私密分享,后端创建分享时都会生成一个 4 位小写字母提取码;只是公开分享在访问时不会强制校验该提取码。
- 当前分享链接默认由
com.disk.share.access-url-prefix组装,仓库默认配置为http://localhost:5173/share,因此默认分享地址形如http://localhost:5173/share?shareId=加密后的分享ID。 - 当前过期处理采用"双保险":
- 访问分享时实时校验是否过期
- 创建分享后为非永久分享注册延迟删除任务,到期后自动逻辑删除
share和share_file
- 当前匿名分享页只支持展示"这条分享直接挂载的文件项",并支持单文件下载;如果分享项本身是文件夹,页面会展示出来,但当前不能继续下钻,也不能直接下载文件夹。
7.2. 功能需求
分享功能的目标,是让登录用户可以把自己的网盘文件以链接形式发给别人,并根据业务需要控制是否要提取码、分享多久、过期后如何失效。
从业务角度看,这类能力通常需要满足以下几点:
- 登录用户可以创建分享,并得到可复制的分享链接。
- 分享可以区分公开访问和私密访问,私密访问必须先校验提取码。
- 分享要支持有限期和永久有效两种模式。
- 分享拥有者要能查看、复制、取消自己创建的分享。
- 匿名访问者不需要登录,也可以根据链接查看分享基础信息并下载允许下载的文件。
- 分享到期后不能继续访问,同时后台要有自动失效机制,避免无效分享长期残留。
- 分享链路不能绕过原始文件记录,下载时仍然要回到
user_file和file记录校验实际可读文件。
7.3. 功能实现
这里要先区分三个页面的职责:
Files.vue 登录用户在文件列表里点某一行的“分享”,偏快捷入口:一次分享当前点击的文件/文件夹
Shares.vue 登录用户进入“我的分享”, 偏管理入口:查看、复制、取消分享,也可以创建分享
ShareAccess.vue 别人打开分享链接, 偏匿名访问入口:不要求登录,但要校验分享是否存在、是否过期、提取码是否正确
这三个页面虽然都和“分享”有关,但角色不一样:
Files.vue/Shares.vue:分享创建者使用,需要登录。ShareAccess.vue:分享访问者使用,不要求登录。- 后端统一入口都是 UserShareController.java
- 当前分享流程不涉及
@Facade/ Dubbo。它不是跨微服务 RPC 调用,而是普通 HTTP 请求进入 share 服务自己的 Controller。
下面是之前的创建分享
用户操作Files.vue文件列表行【分享】悬浮按钮
↓
弹出创建分享弹窗,弹窗携带穿梭框多选待分享文件,填写表单:分享名称、选择多文件、分享类型(公开/私密)、有效期
↓
点击弹窗底部【确定】按钮,先执行表单校验,校验通过后调用前端 createShare()
↓
前端组装请求参数:原始加密文件ID数组、分享名称、数字shareType、时效编码shareDayType,发起POST请求 /api/v1/shares/share
↓
后端接口接收前端加密参数
↓
MapStruct自动解密文件ID,转换时效天数
↓
开启@Transactional数据库事务
├─ 1. 雪花ID生成分享主键,组装数据插入share分享主表单条记录
└─ 2. 根据分享ID批量生成关联实体,批量插入share_file 文件关联中间表
↓
事务全部执行无异常,事务提交落库;任意步骤报错则整体回滚两张表数据
↓
判断是否为限时分享
├─ 限时分享:注册延时删除任务,通过事务同步机制,事务提交完成后再推入延时队列,到期自动清理分享
└─ 永久分享:无需注册延时删除任务,直接跳过
↓
后端对分享ID加密处理,封装Result返回加密shareId给到前端
↓
前端收到业务成功响应,关闭创建分享弹窗,路由跳转至 /shares 我的分享页面
↓
页面加载当前用户全部未删除分享,表格展示所有分享记录,可查看详情、续期、取消分享、复制分享链接/复制提取码对外分发
3.0核心表设计
分享功能主要涉及两张表:
share:分享主表,表示“这一次分享本身”
share_file:分享文件关联表,表示“这一次分享包含哪些用户文件”
在 Java 里,对应关系是:
@TableName(value = "share")
public class ShareDO extends BaseEntity {
@TableField(value = "share_name")
private String shareName;
}
数据库表:真正存数据的地方 DO 实体类:Java 里表示数据库一行数据的对象 @TableName:告诉 MyBatis-Plus 这个 DO 对应哪张表 @TableField:告诉 MyBatis-Plus Java 字段对应数据库哪个字段 BaseEntity:公共字段,比如 id、gmt_create、gmt_modified、deleted 等
@TableName(value = "share_file")
public class ShareFileDO extends BaseEntity {
@TableField(value = "share_id")
private Long shareId;
@TableField(value = "file_id")
private Long fileId;
}
share 表保存:
核心定位:每生成一个分享链接,就新增一条记录,存储这次分享的所有全局属性,是分享功能的主体表。
| 字段名 | 字段类型 | 字段说明 | 备注 |
|---|---|---|---|
id |
bigint | 主键 ID | 分享唯一标识,全局唯一 |
share_name |
varchar | 分享名称 | 用户自定义的分享标题,用于列表展示 |
share_type |
tinyint | 分享类型 | 0 = 公开分享(无需提取码),1 = 私密分享(需提取码) |
share_day_type |
int | 有效期天数类型 | 如 1 天、7 天、30 天、-1 = 永久 |
share_end_time |
datetime | 过期时间 | 分享到期的具体时间点,永久分享为 null |
share_url |
varchar | 分享链接 | 生成的对外可访问链接 |
share_code |
varchar | 提取码 | 私密分享的访问密码,公开分享可为空 |
share_status |
tinyint | 分享状态 | 0 = 正常,1 = 即将过期,2 = 已过期,3 = 已取消 |
create_user |
bigint | 创建人 ID | 发起分享的用户 ID,用于数据权限隔离 |
create_time |
datetime | 创建时间 | 分享创建时间 |
update_time |
datetime | 更新时间 | 分享最后修改时间 |
deleted |
tinyint | 逻辑删除标记 | 0 = 未删除,1 = 已删除 |
share_file 表保存:
核心定位:纯联结表,专门维护「一条分享 ↔ 多个用户文件」的对应关系,不存储多余业务信息。
| 字段名 | 字段类型 | 字段说明 | 备注 |
|---|---|---|---|
id |
bigint | 主键 ID | 关联记录唯一标识 |
share_id |
bigint | 分享 ID | 关联 share 表主键,标记属于哪一次分享 |
file_id |
bigint | 用户文件 ID | 关联 user_file 表主键,标记分享的是哪个用户文件 |
create_user |
bigint | 创建人 ID | 冗余字段,方便权限校验、批量清理用户数据 |
create_time |
datetime | 创建时间 | 关联记录创建时间 |
注意:share_file.file_id 关联的是用户虚拟目录表 user_file.id,不是物理文件表 file.id。
原因:用户分享的不是服务器硬盘里的“物理文件本体”,而是用户网盘里看到的“文件条目”。这个条目包含用户归属、文件名、目录位置、是否文件夹、删除状态等业务信息。
下载时才继续通过:
share_file.file_id
→ user_file.id
→ user_file.real_file_id
→ file.id
→ file.real_path
所以表关系要记成:
share:分享主体
share_file:分享和用户文件的关联清单
user_file:用户网盘里的文件/文件夹条目
file:服务器真实存储的物理文件
为什么要有 share_file?因为一条分享可以包含多个文件,一个文件也可以被多次分享。这个关系不是简单一对一,所以需要中间表。
第 1 层:share.id → share_file.share_id。作用:通过分享 ID,找到这次分享里包含的所有文件。
第 2 层:share_file.file_id → user_file.id。作用:找到「用户网盘里的文件条目」。
为什么不直接连物理文件表?user_file 是用户的虚拟目录表:存的是用户视角的文件 —— 文件名、所在文件夹、是不是文件夹、创建时间,相当于你网盘里看到的文件树。同一个物理文件,可能被多个用户保存(秒传原理),也可能被同一个用户存在不同文件夹里。分享分享的是「用户自己网盘里的那个文件条目」,不是直接分享物理文件。举个例子:你和我网盘里都存了同一份《Java 面试题.pdf》,物理上是同一个文件,但我们各自的目录条目是独立的。你分享你的,我分享我的,互不影响。
第 3 层:user_file.real_file_id → file.id。作用:找到真正的物理文件,拿到文件的真实存储路径、大小、MD5、存储位置等信息。user_file 是「目录索引」,只负责展示和目录结构;file 表是「物理文件本体」,管文件真实存在哪、多大、MD5 是多少;多个用户的 user_file 可以指向同一个 file 物理文件,实现秒传、节省存储空间,是网盘系统的核心优化设计。
3.1. 整体思路
分享创建的核心不是“生成一个链接”这么简单,而是做了三件事:
1. 创建 share 主记录
2. 创建 share_file 关联记录
3. 如果有限期,注册过期自动删除任务
后端数据流可以记成:
CreateShareParamVO
前端请求参数,里面的 shareFileIds 是加密字符串
↓
ShareConvertor
把加密 ID 解密,把前端参数转成业务上下文
↓
CreateShareContext
Service 层真正使用的参数对象
↓
ShareDO / ShareFileDO
准备入库的数据库实体对象
↓
share / share_file
真正保存到数据库
当前项目里的分享链路可以概括为:
- 登录用户在
disk-by-cursor/src/views/Files.vue或disk-by-cursor/src/views/Shares.vue发起创建分享。 - 前端把分享名称、分享类型、有效期类型和加密后的
shareFileIds提交到POST /api/v1/shares/share。 - 后端通过
ShareConvertor把前端传来的加密文件 ID 解密成内部user_file.id,组装为CreateShareContext。 ShareServiceImpl生成一条share主记录,并为每个被分享文件生成一条share_file关联记录。- 如果本次分享不是永久有效,则在事务提交后把"删除该分享"的消息放入延迟队列,等待过期时自动触发逻辑删除。
- 登录用户可以通过
/api/v1/shares/list查看自己的分享列表,通过/api/v1/shares/share查询单条详情,通过DELETE /api/v1/shares/share取消分享。 - 匿名访问者打开分享链接后,前端路由会进入
disk-by-cursor/src/views/ShareAccess.vue,从 URL 里的shareId拉取基础信息。 - 如果是私密分享,前端会先调用
/api/v1/shares/share/code/check校验提取码;如果是公开分享,则直接进入文件列表加载。 - 前端通过
/api/v1/shares/share/files获取这条分享下直接挂载的文件列表。 - 匿名访问者点击下载时,前端调用
/api/v1/shares/share/file/download,后端在校验分享可用、提取码、文件映射和物理文件都存在之后,直接把文件流写回响应。
当前这条链路涉及的关键位置包括:
- 前端接口封装:
disk-by-cursor/src/api/share.js - 前端文件页分享入口:
disk-by-cursor/src/views/Files.vue - 前端我的分享页:
disk-by-cursor/src/views/Shares.vue - 前端匿名访问页:
disk-by-cursor/src/views/ShareAccess.vue - 前端分享访问路由:
disk-by-cursor/src/router/index.js - 后端控制器:
networkdisk-business/networkdisk-share/src/main/java/com/disk/share/controller/UserShareController.java - 后端领域服务:
networkdisk-business/networkdisk-share/src/main/java/com/disk/share/domain/service/impl/ShareServiceImpl.java - 分享参数转换:
networkdisk-business/networkdisk-share/src/main/java/com/disk/share/domain/entity/convertor/ShareConvertor.java - 延迟删除执行器:
networkdisk-business/networkdisk-share/src/main/java/com/disk/share/job/delay/DeleteShareInfoDelayQueueExecutor.java - 延迟队列工厂:
networkdisk-common/networkdisk-delay-message/src/main/java/com/disk/delayqueue/executor/DelayQueueExecutorFactory.java
Files.vue 是快捷分享入口。用户在文件列表点某一行分享按钮后,前端只把当前这一项放进 shareFileIds:
await createShare({
shareName: shareForm.value.shareName,
shareType,
shareDayType: validityMap[shareForm.value.validity],
shareFileIds: [String(getFileId(currentShareFile.value))]
})
Shares.vue 是管理页。它会先加载当前用户的分享列表,也可以打开创建弹窗,选择多个文件后创建分享:
const response = await getShareList()
const response = await createShareApi(data)
ShareAccess.vue 是公开访问页。它不在主布局里,路由配置是:
{
path: '/share',
name: 'ShareAccess',
meta: { requiresAuth: false }
}
所以别人打开:
http://localhost:5173/share?shareId=xxxx
不会被前端路由拦到登录页。
3.1.1. 当前前端交互形态
当前前端并不是只有一个分享页面,而是把"创建分享"“管理分享”"匿名访问分享"拆成了三个场景。
在当前页面里:
Files.vue的文件行操作区始终提供"分享"按钮。Files.vue里的分享弹窗一次只分享当前点击的这一项,既可以分享文件,也可以分享文件夹。Files.vue的分享类型用withCode / withoutCode表示,有效期用permanent / 1day / 7days / 30days表示,提交前会转成旧式中文字符串。Shares.vue提供"我的分享"管理页,支持查看列表、复制链接、复制分享码、查看详情、取消分享。Shares.vue的"创建分享"弹窗支持一次选择多个分享项,但当前候选列表来自根目录getFileList(parentId = rootFileId),并不是整棵目录树。Shares.vue页面上有"续期"按钮,但当前只是前端提示"开发中",后端没有对应续期接口。ShareAccess.vue对应公开路由/share,不要求登录。ShareAccess.vue对私密分享会先展示提取码输入框;公开分享则直接拉文件列表。ShareAccess.vue当前对folderFlag = 1的项会禁用下载,并提示暂不支持从分享页直接下载文件夹。
登录用户接口:
GET /api/v1/shares/list
GET /api/v1/shares/share
POST /api/v1/shares/share
DELETE /api/v1/shares/share
这些接口默认需要登录,因为它们没有 @LoginIgnore。登录态来自请求头里的 Authorization,后端通过登录拦截/切面拿到当前用户 ID。
匿名接口:
GET /api/v1/shares/share/simple
POST /api/v1/shares/share/code/check
GET /api/v1/shares/share/files
GET /api/v1/shares/share/file/download
这些接口上有 @LoginIgnore,表示不要求登录。否则别人打开分享链接时没有 token,会直接被拦住。
还要注意:外部传输的 shareId 和 fileId 都是加密字符串,不是数据库裸 ID。
Long id = IdUtil.decrypt(shareId);
return Result.success(IdUtil.encrypt(shareId));
这样做的作用是:避免用户从链接里直接猜数据库自增 ID。
3.1.2. ShareAccess.vue 的定位
ShareAccess.vue 是匿名分享访问页。别人拿到分享链接之后,访问的是:/share?shareId=加密后的分享ID
这个页面不要求登录,所以路由里是:
{
path: '/share',
name: 'ShareAccess',
meta: { requiresAuth: false }
}
意思是:前端路由守卫不会拦截它,不会强制跳登录页。
前端整体流程
ShareAccess.vue 页面加载时,会先执行:
onMounted(() => {
loadShareInfo()
})
也就是页面一打开,先根据 URL 里的 shareId 查询分享基础信息。前端从路由参数里取 shareId:
const shareId = computed(() => route.query.shareId || '')
这里的 shareId 不是数据库真实 ID,而是后端加密后的字符串。这样别人看链接时看不到真实数据库主键。
整体前端流程可以记成:
打开 /share?shareId=xxx
↓
ShareAccess.vue 从 URL 读取 shareId
↓
调用 getShareSimpleDetail 查询分享基础信息
↓
判断分享类型
├─ 公开分享:直接加载文件列表
└─ 私密分享:先显示提取码输入框
↓
提取码正确后,加载分享文件列表
↓
用户点击下载
↓
调用 downloadShareFile 下载文件 Blob
↓
浏览器创建临时 a 标签触发下载
第一步:加载分享基础信息
前端调用:const response = await getShareSimpleDetail({ shareId: shareId.value })
真实请求地址是:GET /api/v1/shares/share/simple?shareId=xxx
后端对应接口:
@LoginIgnore表示这个接口免登录,匿名访问分享页时没有 token 也能访问。
@GetMapping("/share/simple")
public Result<UserShareInfoVO> getSimpleShare(@RequestParam("shareId") String shareId) {
Long id = IdUtil.decrypt(shareId);把前端传来的加密 shareId 解密成数据库真实 Long ID。
ShareDO shareDO = shareService.getByShareId(id);查询 share 表,确认分享记录存在且未逻辑删除。
...
infoVO.setShareCode(null);安全处理,不能把真实提取码返回给匿名访问者。
return Result.success(infoVO);
}
所以 /share/simple 只负责返回分享基础信息,比如分享名称、分享类型、过期时间、状态等,不返回提取码。
第二步:判断公开分享还是私密分享
前端用这个计算属性判断:const isPrivateShare = computed(() => Number(shareInfo.value?.shareType) === 1)
含义是:
shareType = 0:公开分享,不需要提取码
shareType = 1:私密分享,需要提取码
如果是公开分享:
if (!isPrivateShare.value) {
shareVerified.value = true
await loadShareFiles()
}
也就是直接认为已验证,然后加载文件列表。 如果是私密分享,页面会显示提取码输入框:
<div v-if="isPrivateShare && !shareVerified" class="share-code-panel">
<el-input v-model="shareCodeInput" placeholder="请输入提取码" />
<el-button @click="handleCheckCode">验证提取码</el-button>
</div>
第三步:校验提取码 私密分享点击“验证提取码”后,前端执行:
const response = await checkShareCode({
shareId: shareId.value,
shareCode
})
真实请求:POST /api/v1/shares/share/code/check 后端对应:
assertShareAvailable:
检查分享是否存在、是否已删除、是否过期。
validateShareCodeIfNeeded:
如果是公开分享,直接放行;
如果是私密分享,比较前端传来的 shareCode 是否正确
当前提取码比较是大小写不敏感的:
StringUtils.equalsIgnoreCase(shareDO.getShareCode(), normalizedCode)
注意:这个接口只返回 true,没有生成“分享访问 token”。所以前端后面加载文件列表、下载文件时,仍然要继续带上 shareCode。
checkShareCode 只是个 “试密码” 的接口,用来给用户即时反馈;真正的文件查询、下载接口,都要求每次请求都携带提取码并独立校验,所以前端后续请求必须一直带上 shareCode。
第四步:加载分享文件列表 提取码通过后,或者公开分享直接进入文件列表加载:await loadShareFiles()
// 构造接口参数(必传shareId)
const params = { shareId: shareId.value }
// 私有分享:追加提取码参数
if (isPrivateShare.value) {
params.shareCode = shareCodeInput.value.trim()
}
// 调用接口获取文件列表
const response = await getShareFiles(params)
真实请求:GET /api/v1/shares/share/files?shareId=xxx&shareCode=abcd
文件列表查询链路是:
share.id
↓
share_file.share_id
↓
share_file.file_id
↓
user_file.id
返回给前端的是 ShareFileInfoVO:
fileId
filename
folderFlag
fileType
fileSizeDesc
updateTime
前端拿到以后放进表格:
shareFiles.value = Array.isArray(response.data) ? response.data : []
第五步:分享文件下载
前端点击下载:
async function handleDownload(file) {
const response = await downloadShareFile(params)
const blob = response?.data
saveBlob(blob, filename)
}
接口封装里设置了:responseType: ‘blob’
因为下载接口返回的不是普通 JSON,而是文件二进制流。
真实请求:GET /api/v1/shares/share/file/download?shareId=xxx&fileId=yyy&shareCode=abcd
Service 下载流程是:
1. assertShareAvailable 校验分享存在、未删除、未过期
2. validateShareCodeIfNeeded 校验提取码
3. 查 share_file,确认这个 fileId 确实属于这条分享
4. 查 user_file,确认用户文件条目存在且未删除
5. 如果是文件夹,直接拒绝下载
6. 通过 user_file.real_file_id 查 file 表
7. 从 file.real_path 找到磁盘真实文件
8. 设置响应头 Content-Disposition
9. 把文件输入流写到 response 输出流
这一步非常关键:后端不会只因为你传了一个 fileId 就下载。它必须先确认:这个文件确实在这条分享里面
这就是防止别人拿到一个分享链接后,随便猜别的 fileId 下载不属于这个分享的文件。
前端下载为什么要 saveBlob
浏览器收到 blob 后,不会自动弹出保存框。前端需要手动创建临时下载链接:
const url = window.URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = filename
link.click()
window.URL.revokeObjectURL(url)
这段的作用是:
把后端返回的二进制文件流,临时变成浏览器可下载的 URL,然后模拟点击下载。
3.2. 分享接口
当前后端控制器的统一前缀是:
/api/v1/shares
其中,登录后使用的接口有:
GET /api/v1/shares/listGET /api/v1/shares/sharePOST /api/v1/shares/shareDELETE /api/v1/shares/share
其中,匿名可访问的接口有:
GET /api/v1/shares/share/simplePOST /api/v1/shares/share/code/checkGET /api/v1/shares/share/filesGET /api/v1/shares/share/file/download
当前外部可见的 shareId 和 fileId 都不是数据库里的裸 Long,而是经过加密序列化后的字符串:
UserShareInfoVO.id会被序列化为加密后的shareIdShareFileInfoVO.fileId会被序列化为加密后的user_file.id- 创建分享时前端传入的
shareFileIds也是加密后的user_file.id
3.2.1. 创建分享接口
当前创建分享接口为:
POST /api/v1/shares/share
当前请求参数为:
shareName:必填,分享名称shareType:必填,0公开分享,1私密分享shareDayType:必填,分享有效期类型shareFileIds:必填,加密后的user_file.id列表
当前 shareDayType 的后端兼容范围比较宽,源码支持以下形式:
-1、1、7、30permanent、1day、7days、30days- 旧式中文值,例如"永久有效"“1天”“7天”“30天”
这也是为什么当前 Files.vue 和 Shares.vue 虽然传值格式不同,但都能创建成功。
创建分享入口是:
@PostMapping("/share")
public Result<String> addUserShare(@Validated @RequestBody CreateShareParamVO createShareParam) {
CreateShareContext context =
shareConvertor.createShareParamToCreateShareContext(createShareParam);
Long shareId = shareService.addUserShareInfo(context);
return Result.success(IdUtil.encrypt(shareId));
}
这里 Controller 只做三件事:
1. 接收前端参数
2. 转成 Service 能用的 Context
3. 调 Service 创建分享,最后返回加密后的 shareId
真正的业务逻辑在 ShareServiceImpl.addUserShareInfo(...)。
3.2.2. 分享详情与管理接口
当前管理侧常用接口为:
GET /api/v1/shares/list- 返回当前登录用户创建的分享列表
GET /api/v1/shares/share?shareId=...- 按分享 ID 查询一条分享详情
DELETE /api/v1/shares/share- 请求体只需要
shareId - 实际执行的是逻辑删除
share和share_file,不是物理删除文件
- 请求体只需要
3.2.3. 匿名访问接口
当前匿名访问链路对应的接口为:
GET /api/v1/shares/share/simple- 返回分享基础信息
- 控制器会主动把
shareCode置空,避免把提取码直接暴露给匿名访问者 - 所以
/share/simple只负责返回分享基础信息,比如分享名称、分享类型、过期时间、状态等,不返回提取码。
POST /api/v1/shares/share/code/check- 校验私密分享提取码
- 通过则只返回
true,不下发单独的访问令牌
GET /api/v1/shares/share/files- 返回分享下直接挂载的文件列表
GET /api/v1/shares/share/file/download- 下载分享中的单个文件
3.3. 创建分享时的真实处理
当前 ShareServiceImpl.addUserShareInfo(...) 在创建分享时的核心流程是:
- 取前端传来的
shareFileIds - 生成新的
shareId - 通过
ShareDayTypeEnum.getDayByType(...)把shareDayType解析成实际天数 - 组装
share主记录 - 为每个分享项生成一条
share_file映射记录 - 保存
share - 批量保存
share_file - 如果不是永久分享,则注册延迟删除任务
当前组装出来的 share 主记录有这些关键字段:
shareNameshareTypeshareDayTypeshareDayshareEndTimeshareStatusshareCodeshareUrlcreateUser
其中:
shareEndTime的计算规则是:永久分享写null,非永久分享写"当前时间 + N 天"shareStatus创建时固定写成ShareStatusEnum.NORMAL,也就是0shareCode当前固定由RandomStringUtils.randomAlphabetic(4).toLowerCase()生成shareUrl当前由buildShareUrl(...)根据配置前缀和加密后的shareId拼出来
3.4. 匿名访问时的校验与下载
匿名访问的核心不是"拿到分享链接就直接读文件",而是每一步都要重新确认分享是否还可用。
匿名访问不是“有链接就能直接下载”。真实流程是:
打开 /share?shareId=xxx
↓
getShareSimpleDetail 查询分享基础信息
↓
如果公开分享:直接加载文件列表
如果私密分享:先输入提取码
↓
checkShareCode 校验提取码
↓
getShareFiles 查询分享里的文件
↓
downloadShareFile 下载单个文件
getSimpleShare(...) 里有一个安全细节:
infoVO.setShareCode(null);
也就是说,匿名访问者能看到分享名称、类型、有效期等信息,但后端不会把真正的提取码返回给他。
3.4.1. 分享可用性校验
当前真正负责"分享是否还能访问"的方法是 assertShareAvailable(...)。它会:
- 根据
shareId查询share记录,要求deleted = 0 - 如果查不到,则抛出
SHARE_NOT_FOUND - 如果不是永久分享,且
share_end_time已早于当前时间,则抛出SHARE_EXPIRED
需要特别说明的是:
GET /share/simple当前只检查"分享记录是否存在且未逻辑删除",不会调用assertShareAvailable(...)- 因此如果某条分享已经过期、但延迟删除任务还没把它逻辑删除,匿名页仍然可能先看到基础信息
- 真正决定"还能不能继续访问文件列表或下载"的,是后面的提取码校验、文件列表和下载接口
真正判断分享还能不能继续访问的是:
private ShareDO assertShareAvailable(Long shareId)
它主要检查:
1. share 是否存在
2. deleted 是否为 0
3. 是否已经过期
注意:/share/simple 当前主要查基础信息,不是最终下载权限。最终能不能看文件、能不能下载,还是要看 /share/files 和 /share/file/download 里的校验。
3.4.2. 提取码校验
当前提取码校验逻辑是:
- 只有
shareType = 1时才要求校验提取码 - 公开分享直接跳过提取码判断
- 私密分享会把前端传入的
shareCode做trim - 当前比较逻辑使用
StringUtils.equalsIgnoreCase(...)
也就是说,当前提取码大小写不敏感。
3.4.3. 文件列表
当前 listShareFiles(...) 的真实行为是:
- 先校验分享是否可用
- 如果是私密分享,再校验提取码
- 查询这条分享下所有
share_file映射,按gmt_create desc排序 - 取出映射里的
file_id - 批量查询对应的
user_file记录 - 过滤掉已经逻辑删除的
user_file - 转成
ShareFileInfoVO返回给前端
当前返回给匿名页的文件项主要包括:
fileIdfilenamefolderFlagfileTypefileSizeDescupdateTime
需要特别说明的是:
- 当前接口没有
parentId参数 - 当前实现返回的是"分享时直接挂载的项",不是某个分享目录的下一级列表
- 因此前端虽然可以显示被分享的文件夹,但当前没有继续浏览文件夹内部内容的后端接口
listShareFiles(...) 里有两次查询:
List<ShareFileDO> mappings = shareFileService.list(mappingQuery);
List<UserFileDO> fileRecords = userFileMapper.selectBatchIds(fileIds);
第一句查的是:
share_file 表
找出这条分享包含哪些 user_file.id
第二句查的是:
user_file 表
根据这些 user_file.id 批量查文件名、类型、大小、是否文件夹等展示信息
所以它不是直接查物理文件表。文件列表展示只需要用户文件信息,不需要真实磁盘路径。
3.4.4. 单文件下载
当前 downloadShareFile(...) 的处理流程是:
- 校验分享是否可用
- 私密分享时校验提取码
- 校验
share_file中确实存在shareId + fileId这条映射 - 根据
fileId查询user_file - 校验
user_file没有被逻辑删除 - 如果当前项是文件夹,则直接抛
INVALID_ARGS - 根据
user_file.real_file_id查询物理文件表file - 校验
file没有被逻辑删除,且real_path在磁盘上真实存在 - 设置下载响应头
- 直接从物理路径打开输入流,把文件内容写到 HTTP 响应输出流
因此当前分享下载走的是"数据库记录校验 + 本地物理文件流式输出"模式,而不是走对象存储签名 URL。
下载时才会继续查物理文件:
share_id + file_id 校验 share_file 映射存在
↓
查 user_file,确认这个用户文件条目存在且未删除
↓
判断 folderFlag,文件夹不允许直接下载
↓
通过 user_file.real_file_id 查 file 表
↓
拿 file.real_path 找磁盘真实文件
↓
写入 HttpServletResponse 输出流
这就是为什么前面说 share_file.file_id 不能直接存物理文件 ID。因为下载之前必须先确认:这个文件确实属于这条分享。
8.回收站功能
1. 功能说明
- 当前项目的回收站能力由
networkdisk-business/networkdisk-recycle模块提供,前端页面是disk-by-cursor/src/views/Recycle.vue。 - 当前回收站没有单独的业务表,本质上是对
user_file.deleted != 0记录的一层查询和操作封装。 - 当前回收站支持三类能力:
- 查看回收站列表
- 还原文件
- 彻底删除文件
- 当前前端支持单项操作和批量操作。
- 当前回收站列表页显示的"删除时间"实际来自
user_file.gmt_modified,并没有单独的delete_time字段。 - 当前后端在执行还原和彻底删除时,会先把"当前用户回收站里已经存在的已删除记录"展开成可操作集合;如果选中的是文件夹,则会把这批已删除子孙节点一并纳入操作范围。
- 当前的"彻底删除"只会从
user_file表删除记录,不会删除物理文件表file记录,也不会删除磁盘上的真实文件。
2. 功能需求
回收站功能的目标,是让用户在误删文件后还有一次可恢复机会,同时允许用户明确执行不可恢复的删除操作。
从业务角度看,这类能力通常需要满足以下几点:
- 用户可以查看自己已经删除的文件列表。
- 用户可以把回收站中的文件还原回正常状态。
- 用户可以对回收站中的文件执行不可恢复的彻底删除。
- 用户可以一次选择多条记录做批量操作。
- 回收站操作必须严格限定在当前登录用户自己的已删除文件记录内。
- 如果用户选中了一个已经进入回收站的文件夹,系统最好能把这个文件夹下面同样已经进入回收站的子节点一起处理。
3. 功能实现
3.1 整体思路
当前项目里的回收站链路可以概括为:
- 用户在文件页执行删除操作,调用
DELETE /api/v1/files/file。 - 文件服务把被删除的
user_file记录的deleted字段改成非 0。 - 回收站页面在加载时调用
GET /api/v1/recycles/list,查询当前用户所有deleted != 0的文件记录。 - 用户在回收站页面可以单个还原、单个彻底删除,也可以批量还原、批量彻底删除。
- 前端把选中的
id作为fileIds提交给回收站接口。 - 回收站控制器会把这些
fileIds解析成 Long。- 当前兼容两种格式:明文 long 和加密字符串
- 服务层会先从当前用户回收站记录里筛出真正可操作的选中项。
- 如果选中项里有文件夹,服务层会基于当前回收站记录做一次 BFS 展开,把已经删除的子孙节点一起加入操作集合。
- 还原时执行批量
update user_file set deleted = 0 - 彻底删除时执行批量
delete from user_file
当前这条链路涉及的关键位置包括:
- 前端接口封装:
disk-by-cursor/src/api/recycle.js - 前端页面:
disk-by-cursor/src/views/Recycle.vue - 前端路由:
disk-by-cursor/src/router/index.js - 后端控制器:
networkdisk-business/networkdisk-recycle/src/main/java/com/disk/recycle/controller/RecycleController.java - 后端服务接口:
networkdisk-business/networkdisk-recycle/src/main/java/com/disk/recycle/domain/service/RecycleService.java - 后端服务实现:
networkdisk-business/networkdisk-recycle/src/main/java/com/disk/recycle/domain/service/impl/RecycleServiceImpl.java - 后端请求对象:
networkdisk-business/networkdisk-recycle/src/main/java/com/disk/recycle/domain/request/RestoreFileParamVO.java - 后端请求对象:
networkdisk-business/networkdisk-recycle/src/main/java/com/disk/recycle/domain/request/DeleteFileParamVO.java - 回收站实体:
networkdisk-business/networkdisk-recycle/src/main/java/com/disk/recycle/domain/entity/UserFileDO.java - Mapper 接口:
networkdisk-business/networkdisk-recycle/src/main/java/com/disk/recycle/infrastructure/mapper/UserFileMapper.java - Mapper XML:
networkdisk-business/networkdisk-recycle/src/main/resources/mapper/UserFileMapper.xml
3.1.1 当前前端交互形态
当前回收站前端是一个独立页面,路由位于 Layout 下,路径为:
/recycle
在当前页面里:
- 页面初始化时会执行
loadRecycleList() - 顶部提供"批量还原"和"彻底删除"按钮
- 当没有勾选任何行时,批量按钮会被禁用
- 每一行在 hover 时会显示"还原"和"彻底删除"两个操作按钮
- 表格当前展示:
- 文件名
- 文件大小
gmtModified格式化后的时间
- 页面底部没有分页器,也没有搜索框、筛选器、排序条件切换
- 当前前端发送给后端的
fileIds值,来自返回数据中的row.id
需要特别说明的是:
- 回收站页拿到的
id是加密后的字符串,因为UserFileDO.id当前使用了IdEncryptSerializer - 但控制器层兼容明文 Long,所以历史调用方如果传明文 ID 也能被解析
3.2 回收站接口
当前后端控制器的统一前缀是:
/api/v1/recycles
当前后端提供的接口为:
GET /api/v1/recycles/listPUT /api/v1/recycles/recycle/restoreDELETE /api/v1/recycles/recycle
3.2.1 回收站列表接口
当前回收站列表接口为:
GET /api/v1/recycles/list
控制器参数里保留了:
pageNopageSize
但是当前实现并没有真正分页,而是直接把当前用户的全部回收站记录按修改时间倒序返回。
当前返回对象是 List<UserFileDO>,主要字段包括:
idparentIdfilenamefolderFlagfileSizeDescfileTypegmtCreategmtModifiedcreateUserupdateUserdeleted
其中:
id和parentId对外返回时会被加密序列化- 前端当前主要使用的是
id、filename、folderFlag、fileSizeDesc、fileType、gmtModified
3.2.2 还原接口
当前还原接口为:
PUT /api/v1/recycles/recycle/restore
当前请求参数为:
fileIds:必填,List<String>
控制器的真实处理方式是:
- 从登录态获取当前用户
userId - 调用
parseFileIds(...)把字符串列表转成List<Long> - 调用
recycleService.restore(userId, fileIds)
3.2.3 彻底删除接口
当前彻底删除接口为:
DELETE /api/v1/recycles/recycle
当前请求参数为:
fileIds:必填,List<String>
控制器的真实处理方式和还原类似:
- 从登录态获取当前用户
userId - 解析
fileIds - 调用
recycleService.hardDelete(userId, fileIds)
3.3 回收站服务的核心逻辑
回收站模块最核心的点,不在于"查一张回收站表",而在于"如何只对当前用户已经删除的记录进行安全操作"。
当前项目采用的是"先拉当前用户已删除全集,再在这份全集中做筛选和递归展开"的策略。
3.3.1 列表查询
RecycleServiceImpl.list(userId) 当前直接调用:
baseMapper.listDeletedByUser(userId)
对应 SQL 是:
where deleted != 0and user_id = #{userId}order by gmt_modified desc
因此当前回收站列表就是"当前用户所有已逻辑删除记录"的倒序列表。
3.3.2 统一的操作 ID 展开
无论是还原还是彻底删除,当前都会先执行 collectOperationIds(userId, selectedIds)。
collectOperationIds(userId, selectedIds) 的目的,就是根据用户在回收站里选中的文件 ID,整理出后续真正需要还原或彻底删除的完整 ID 列表;它先过滤掉不属于当前用户回收站的非法 ID,保证只能操作当前用户已经删除的记录,然后如果选中的是文件夹,就根据 parentId 关系继续向下查找这个文件夹里已经进入回收站的子文件、子文件夹,把它们一起加入操作列表,最后返回这批安全、完整、可操作的文件 ID。
这个方法的处理流程是:
- 先查出当前用户全部
deleted != 0的记录 - 取出这些记录的
id,形成deletedIdSet - 只保留"本次选中的 ID 中,确实存在于回收站里的部分"
- 以当前回收站记录为全集,按
parentId -> children建立映射 - 用 BFS 从选中节点往下展开
- 如果某个子节点也已经在回收站记录里,就继续加入操作集合
这意味着当前的递归展开有两个重要特征:
- 只会处理当前用户自己的记录
- 只会处理"已经进入回收站"的子孙节点,不会额外把正常状态的子节点纳入操作
方法定义如下:
private List<Long> collectOperationIds(Long userId, List<Long> selectedIds) {
这里的 userId 是当前登录用户的 ID,用来保证只能处理当前用户自己的回收站记录;selectedIds 是前端传来的文件 ID 列表,也就是用户在回收站页面勾选的文件或文件夹 ID。方法返回值是 List<Long>,表示最终要操作的完整 ID 列表。
方法第一步会查出当前用户回收站里的全部记录:
List<UserFileDO> deletedRecords = baseMapper.listDeletedByUser(userId);
if (CollectionUtils.isEmpty(deletedRecords)) {
return new ArrayList<>();
}
deletedRecords 的类型是 List<UserFileDO>,里面存的是完整的文件记录对象,不只是 ID。每个 UserFileDO 里至少包含 id、parentId、filename、folderFlag 等信息。这里必须保留完整对象,因为后面不仅要知道“有哪些文件在回收站里”,还要根据 parentId 判断文件夹下面有哪些子文件。
接着代码会从这些完整记录中提取出所有 ID:
Set<Long> deletedIdSet = deletedRecords.stream()
.map(UserFileDO::getId)
.collect(Collectors.toSet());
deletedIdSet 的类型是 Set<Long>,里面只存当前用户回收站里所有文件记录的 ID。比如回收站里有 10、11、12、13,那么它大概就是 [10, 11, 12, 13]。这个集合的作用是做快速判断:前端传来的某个 ID,到底是不是当前用户回收站里的记录。它只保存 ID,不保存父子关系,所以后面不能只靠它完成全部逻辑。
然后代码会过滤前端传来的 selectedIds:
Set<Long> operationIdSet = selectedIds.stream()
.filter(deletedIdSet::contains)
.collect(Collectors.toCollection(LinkedHashSet::new));
operationIdSet 的类型是 Set<Long>,实际创建出来的是 LinkedHashSet<Long>。它表示“最终准备操作的 ID 集合”。一开始,它只包含用户选中的、并且确实存在于当前用户回收站里的 ID。比如前端传来 [10, 999],但是 999 不在当前用户回收站里,那么过滤后只会留下 [10]。这样做是为了防止前端传错 ID、传正常文件 ID,或者恶意传入别人的文件 ID。
如果过滤之后没有任何有效 ID,方法就直接返回空列表:
if (operationIdSet.isEmpty()) {
return new ArrayList<>();
}
接下来代码会根据 deletedRecords 构建父子关系:
Map<Long, List<UserFileDO>> childrenMap = new HashMap<>();
for (UserFileDO record : deletedRecords) {
childrenMap.computeIfAbsent(record.getParentId(), key -> new ArrayList<>()).add(record);
}
childrenMap 的类型是 Map<Long, List<UserFileDO>>。它存的是“父文件夹 ID 到子文件列表”的对应关系。比如回收站里有文件夹 A,它的 id = 10,下面有文件 11 和文件夹 12,那么 childrenMap 里就会有类似这样的数据:10 -> [11, 12]。这里不能用 deletedIdSet 替代,因为 deletedIdSet 只知道哪些 ID 存在,不知道哪个文件属于哪个父文件夹。
有了 childrenMap 以后,代码就可以从用户选中的 ID 开始,继续往下找子节点:
ArrayDeque<Long> queue = new ArrayDeque<>(operationIdSet);
while (!queue.isEmpty()) {
Long parentId = queue.poll();
List<UserFileDO> children = childrenMap.get(parentId);
if (CollectionUtils.isEmpty(children)) {
continue;
}
for (UserFileDO child : children) {
if (operationIdSet.add(child.getId())) {
queue.offer(child.getId());
}
}
}
这里的 queue 是一个队列,类型是 ArrayDeque<Long>,里面存的是“接下来还要继续向下查找子节点的 ID”。一开始队列里放的是用户选中的有效 ID。每次循环时,代码从队列里取出一个 ID,把它当作父节点 ID,然后去 childrenMap 里查它有没有子文件。如果有子文件,就把这些子文件的 ID 加入 operationIdSet,同时也放进队列里,后面继续检查这些子文件自己下面还有没有更深层的子节点。
这就是 BFS,也就是广度优先搜索。它的效果是从选中的文件夹开始,一层一层往下找。假设用户选中了文件夹 10,而回收站里有这样的关系:10 下面有 11 和 12,12 下面又有 13,那么一开始 operationIdSet 是 [10],处理完 10 后变成 [10, 11, 12],继续处理 12 后变成 [10, 11, 12, 13]。
最后方法会把 operationIdSet 转成 List<Long> 返回:
return new ArrayList<>(operationIdSet);
所以这个方法的核心不是删除,也不是还原,而是先安全地整理操作范围。deletedRecords 保存当前用户回收站里的完整文件对象;deletedIdSet 用来判断前端传来的 ID 是否合法;operationIdSet 保存最终要操作的 ID,并且会随着子节点展开不断增加;childrenMap 保存父子关系,用来根据父文件夹 ID 找到子文件;queue 是临时队列,用来一层层向下查找。最终返回的 ID 列表,只包含当前用户自己回收站里已经删除的记录,不会把别人的文件、正常状态的文件,或者不存在的 ID 纳入操作。
3.3.3 还原逻辑
批量还原要开@Transactional(rollbackFor = Exception.class) // 加事务:批量操作要么全成功要么全失败,出错全部回滚
当前 restore(userId, fileIds) 的处理流程是:
- 如果
fileIds为空,直接返回 - 调用
collectOperationIds(...)生成实际要还原的 ID 列表 - 如果展开后的列表为空,直接返回
- 执行
restoreByIds(userId, restoreIds)
对应 SQL 会:
set deleted = 0set update_user = 当前用户set gmt_modified = now()
因此当前还原逻辑本质上只是把逻辑删除标记改回正常状态。
3.3.4 彻底删除逻辑
批量还原和删除要开@Transactional(rollbackFor = Exception.class) // 事务保证:批量删除要么全删成功要么全不删
当前 hardDelete(userId, fileIds) 的处理流程和还原类似:
- 如果
fileIds为空,直接返回 - 调用
collectOperationIds(...) - 如果展开后的列表为空,直接返回
- 执行
hardDeleteByIds(userId, hardDeleteIds)
对应 SQL 是直接:
delete from user_file
因此当前所谓"彻底删除",本质上是"彻底删除这批 user_file 记录"。
3.3.5 与文件删除入口的关系
当前回收站入口来自文件模块的删除操作,也就是:
DELETE /api/v1/files/file
文件模块当前的 deleteFile(...) 会:
- 校验这些文件记录都属于同一个用户
- 校验这个用户就是当前登录用户
- 通过
UpdateWrapper把选中记录的deleted字段改成已删除
需要特别说明的是:
- 当前文件模块的删除方法本身没有在这个方法里递归展开子节点
- 回收站模块的 BFS 展开,是在"回收站操作阶段"基于现有已删除记录再做的
3.4 当前实现边界
为了保证文档和代码一致,当前回收站能力还需要明确这些边界:
- 当前没有独立的
recycle表,完全基于user_file.deleted实现。 - 当前列表接口虽然接收
pageNo和pageSize,但实际上没有做分页。 - 当前页面展示的"删除时间"并不是独立字段,而是
gmtModified。 - 当前还原逻辑不会做"重名冲突处理""目标目录有效性校验"等额外业务判断,只是把
deleted改回 0。 - 当前彻底删除只删
user_file记录,不会清理file表、分片表、物理文件、ES 索引或其他关联业务数据。 - 当前回收站没有自动过期清理、定时清空、保留天数等机制。
- 当前回收站页面没有搜索、分页、文件预览、下载、批量全选以外的高级管理能力。
4. 数据模型
4.1 user_file
作用:保存用户文件记录,也是回收站的唯一数据来源。
当前回收站链路里实际会用到:
iduser_idparent_idreal_file_idfilenamefolder_flagfile_size_descfile_typecreate_userupdate_usergmt_creategmt_modifieddeletedlock_version
其中:
deleted = 0表示正常文件deleted != 0表示回收站中的文件
4.2 当前没有独立的回收站表
需要特别说明的是,当前项目并没有专门保存:
- 回收站主表
- 删除原因
- 删除时间独立字段
- 回收站保留策略
- 回收站操作日志
因此当前回收站能力更接近"基于 user_file.deleted 的管理视图",而不是"独立生命周期的回收站系统"。
4.3 当前没有物理删除链路数据模型
当前项目里所谓"彻底删除",只作用在 user_file 这一层。
这意味着当前并没有在回收站模块里额外维护:
- 物理文件删除任务表
- 文件引用计数表
- 垃圾回收补偿表
因此回收站模块当前负责的是"用户文件记录层面的恢复与移除",不是"底层存储层面的真正垃圾回收"。
9.(AI)智能摘要/标签生成
1. 功能说明
详情见AI摘要标签笔记
- 当前摘要和标签入口在
AiFileDrawer的insight模式中,点击支持 AI 的文件名即可进入。 - 当前系统在文件上传完成后,会对支持后缀的文件异步发送预热消息,尝试提前完成索引、摘要和标签生成。
- 当前默认摘要和标签都优先复用
ai_document_result中的已落库结果,而不是每次重新生成。 - 当前自定义摘要提示词只影响本次生成结果,不会覆盖默认摘要缓存。
- 当前标签会根据
topK做复用判断、重新生成和结果清洗,不是简单原样返回模型输出。 - 当前索引、摘要、标签三者是同一条文档处理链路上的不同阶段,底层都依赖文件读取、Tika 解析、文本切分和向量存储。
2. 功能需求
智能摘要和智能标签的目标,是让用户在不先通读全文的前提下,快速知道"这份文件大概讲什么、重点是什么、适合归到哪一类"。
从业务角度看,这类能力通常需要满足以下几点:
- 上传完成后,最好能自动为文档准备默认摘要和默认标签。
- 用户再次打开同一文件时,应优先读取已有结果,而不是每次都重新调用模型。
- 用户如果希望从不同角度重新理解文档,还应支持基于自定义提示词重新生成摘要。
- 标签数量应可控,方便列表卡片、搜索提示和分类展示复用。
- 如果索引过期或文档内容发生变化,应允许手动重建索引。
3. 功能实现
3.1 整体思路
当前项目里的智能摘要与智能标签链路,可以概括为:
- 用户上传文件成功后,文件服务保存
user_file记录。 - 如果该文件后缀属于当前 AI 预热支持范围,文件服务会在事务提交后发送 AI 预热消息。
networkdisk-ai模块消费预热消息,依次执行:建立索引 -> 生成默认摘要 -> 生成默认标签。- 用户在文件列表中点击支持 AI 的文件名时,前端会打开
AiFileDrawer的insight模式。 - 抽屉打开后会自动读取 AI 能力状态,并自动并行请求摘要与标签。
- 摘要接口优先复用已落库的默认摘要,标签接口优先复用已落库标签。
- 如果用户要求自定义摘要视角,或者标签数量超过当前已缓存数量,系统会重新调用模型生成结果。
- 用户也可以手动点击"重建索引",重新构建该文件的向量索引,然后刷新摘要与标签结果。
当前相关关键位置包括:
- 前端入口页面:
disk-by-cursor/src/views/Files.vue - 前端抽屉组件:
disk-by-cursor/src/components/AiFileDrawer.vue - 前端 AI 接口封装:
disk-by-cursor/src/api/ai.js - 后端控制器:
networkdisk-business/networkdisk-ai/src/main/java/com/disk/ai/controller/AiController.java - 后端应用服务:
networkdisk-business/networkdisk-ai/src/main/java/com/disk/ai/domain/service/impl/AiApplicationServiceImpl.java - AI 预热消费者:
networkdisk-business/networkdisk-ai/src/main/java/com/disk/ai/infrastructure/mq/AiWarmupStreamConfiguration.java - 文件上传后的预热调度:
networkdisk-business/networkdisk-files/src/main/java/com/disk/files/infrastructure/ai/DocumentAiInitializer.java - 摘要标签落库实现:
networkdisk-business/networkdisk-ai/src/main/java/com/disk/ai/infrastructure/result/PgDocumentResultStore.java
3.1.1 当前前端交互形态
当前摘要和标签功能集中在 AiFileDrawer.vue 的 insight 模式中。
当前页面交互大致如下:
- 用户点击支持 AI 的文件名时,会直接打开摘要和标签模式。
- 抽屉打开后会自动调用
GET /api/v1/ai/capabilities,展示当前 AI 服务状态。 - 如果当前文件类型受支持,摘要和标签会自动并行加载,不需要用户手动点第一次按钮。
- 摘要区域支持 4 个预设视角:
默认、结构化重点、行动项、管理视角
- 摘要区域还支持手动输入自定义提示词。
- 标签区域支持在
5 / 8 / 12个标签之间切换。 - 页面还提供"读取 AI 结果"“重建索引”"预览文件/查看文件"这些操作按钮。
需要注意的点是:
- 摘要和标签虽然共用一个抽屉,但它们走的是两个独立接口,不是一个混合接口一起返回。
- 当前自动加载只发生在
insight模式;问答模式不会自动发起摘要/标签读取。
3.2 上传后默认预热
当前项目里,摘要和标签不是等用户第一次点开文件才开始准备,而是上传完成后就会尝试做异步预热。
AI 预热是在文件服务保存文件并提交事务后,给 MQ 发送一条文档预热消息;AI 服务的 aiWarmupConsumer() 消费这条消息后,后台依次执行建向量索引、生成默认摘要、生成默认标签,并把结果写入 PostgreSQL:索引写入 ai_document_index 和 ai_document_chunk_vector,摘要/标签写入 ai_document_result。预热完成后不会主动通知前端或生产者,前端打开 AI 抽屉时仍然是主动请求摘要和标签;如果预热已完成就直接读缓存返回,如果还没完成就现场生成,因此预热本质上是提升首次打开速度的后台优化。
3.2.1 预热触发时机
文件上传完成后,无论是秒传命中,还是分片合并成功,只要最终创建了普通文件的 user_file 记录,UserFileServiceImpl.saveUserFile(...) 都会调用:
documentAiInitializer.scheduleInitialize(userId, entity.getId(), filename)
这里传入的 entity.getId() 实际就是当前用户文件记录的 userFileId。
3.2.2 预热不是对所有文件都触发
DocumentAiInitializer 里维护了一份支持后缀白名单,只有后缀命中时才会发送预热消息。
当前白名单包括:
.pdf.doc.docx.txt.md.markdown.csv.xls.xlsx.ppt.pptx.html.htm.xml.json.sql.java.js.css
因此当前文档描述时不能写成"所有上传文件都会自动生成摘要和标签",更准确的说法应该是:
- 当前只有命中支持后缀白名单的文档,才会进入 AI 预热链路。
3.2.3 预热消息发送时机
DocumentAiInitializer 会优先判断当前事务是否还处于活动状态:
- 如果还在事务中,则注册
afterCommit回调,在事务提交后再真正发送消息 - 如果当前没有事务,则直接发送消息
这样做的目的是避免数据库记录还没真正提交成功,AI 服务就先去读文件,造成读取不到或状态不一致。
3.2.4 预热消息内容
发送的消息体是 AiDocumentWarmupMessage,当前包含:
userIduserFileIdfileIdfilenametopK
其中:
fileId是把userFileId加密后的值topK默认值是5
3.2.5 AI 服务消费后的执行顺序
AiWarmupStreamConfiguration.aiWarmupConsumer() 收到消息后,会顺序执行:
indexFile(...)
/**
* 构建/重建文档向量索引核心方法
* 完整流程:校验向量库状态 → 判断是否已建索引且无需强制重建 → 加载并解析文件 → 文本分块 → 批量生成向量Embedding
* → 组装向量数据 → 替换库内旧向量并更新索引摘要 → 清空过期缓存 → 封装结果返回
* @param request 建索引请求:携带用户、文件ID、是否强制重建标识
* @return 索引结果摘要(对外返回,不含原始向量)
*/
@Override
public AiDocumentIndexData indexFile(AiDocumentIndexRequest request) {
// 建索引必须依赖向量库;如果当前是 DisabledVectorStore,就直接报错。
// 1. 前置校验:向量存储是否启用可用
// VectorStore有两个实现:PgVectorVectorStore(真实pg向量库实现) / DisabledVectorStore(空实现、关闭向量功能)
// 只有注入真实PgVector实现,isReady()才返回true;禁用实现直接返回false
// isReady() 不是“数据库状态检测”,它只是“当前 Spring 注入的是 pgvector 实现,还是 disabled 实现”的判断
if (!vectorStore.isReady()) {
throw new AiException(AiErrorCode.VECTOR_STORE_DISABLED);
}
// 2. 查询当前文件是否已存在向量索引记录
// 先查这个文件是否已经建过索引;不是强制重建时,直接返回已有索引信息。
DocumentIndexSummary existingSummary = vectorStore.getIndexSummary(request.getUserId(), request.getUserFileId());
// 分支:已有索引 且 不强制重建forceReindex=false,直接复用旧索引,跳过全部解析、向量化逻辑
if (existingSummary != null && !Boolean.TRUE.equals(request.getForceReindex())) {
// indexed=true:已有索引;reindexed=false:本次没有重新构建
return toIndexData(request, existingSummary, Boolean.TRUE, Boolean.FALSE);
}
// 3. 加载云盘原始文件资源(二进制、文件名、后缀等元数据)
// 读取原始文件 -> 解析成纯文本和段落块 -> 按配置切成多个 TextChunk。
AiSourceFile sourceFile = aiSourceFileLoader.load(request.getUserId(), request.getFileId(), request.getUserFileId());
// 4. Tika通用文档解析:pdf/word/txt等转结构化文档对象,提取纯文本、分段落块
ParsedDocument parsedDocument = tikaDocumentParser.parse(sourceFile);
// 5. 文本切块:长文本按照固定token长度切分成多个小TextChunk,一段对应一条向量
List<TextChunk> chunks = paragraphTextChunker.chunk(parsedDocument);
if (chunks.isEmpty()) {
throw new AiException(AiErrorCode.DOCUMENT_EMPTY);
}
// 批量把每个文本块转成 embedding 向量;向量数量必须和文本块数量一致。
List<float[]> embeddings = embeddingClient.embedAll(chunks.stream() // 1. 将集合转成 Stream .map(TextChunk::getText)// 2. 提取每个 TextChunk 对象的 text 字段
.toList()); // 3. 将结果收集成一个 List if (embeddings.size() != chunks.size()) {
throw new AiException(AiErrorCode.DOCUMENT_INDEX_FAILED);
}
// 把“文本块 + 向量 + 文件元信息”组装成 PgVectorDocumentChunk,准备写入 pgvector 表。
// 最终入库实体集合:每条记录对应数据库 pgvector 向量表一行数据
List<PgVectorDocumentChunk> vectorChunks = new ArrayList<>();
// 循环遍历切块列表,下标i用来同步匹配「文本块」和「对应向量」
for (int i = 0; i < chunks.size(); i++) {
// 当前循环第i个文本分片(前面chunk方法切出来的TextChunk)
TextChunk chunk = chunks.get(i);
// 数据库存储实体:整合文件信息、段落偏移、文本、向量、元数据
PgVectorDocumentChunk vectorChunk = new PgVectorDocumentChunk();
// 归属用户、文件唯一标识:用于分库分表/权限过滤,查询时只查当前用户文件
vectorChunk.setUserId(sourceFile.getUserId());
vectorChunk.setUserFileId(sourceFile.getUserFileId());
vectorChunk.setRealFileId(sourceFile.getRealFileId());
// 文件基础信息,用于前端展示、文件筛选
vectorChunk.setFilename(sourceFile.getFilename());
vectorChunk.setFileSuffix(sourceFile.getFileSuffix());
vectorChunk.setMediaType(parsedDocument.getMediaType());
// 记录本次使用的Tika解析器,日志排查解析异常用
vectorChunk.setParser(parsedDocument.getParser());
// 段落、分片序号:用于区分同一文件内不同段落、不同切块
vectorChunk.setBlockIndex(chunk.getBlockIndex());
vectorChunk.setChunkIndex(chunk.getChunkIndex());
// 文本在全文中的起止偏移:检索命中后,定位原文、高亮片段
vectorChunk.setStartOffset(chunk.getStartOffset());
vectorChunk.setEndOffset(chunk.getEndOffset());
// 粗略token数量,用于前端展示、模型入参长度预估
vectorChunk.setTokenEstimate(chunk.getTokenEstimate());
// 存入切块原始文本,向量检索命中后直接返回原文片段
vectorChunk.setChunkText(chunk.getText());
// 核心:embeddings和chunks顺序一一对应,i下标同步取本条文本对应的向量
vectorChunk.setEmbedding(embeddings.get(i));
// 组装完成,加入入库列表,批量插入数据库
vectorChunks.add(vectorChunk);
}
// 替换旧索引:先删这个文件旧的向量块,再插入新的向量块和索引摘要。
// ① sourceFile:AiSourceFile 原始文件信息。
// ② parsedDocument:ParsedDocument 文档解析结果
// ③ vectorChunks:List<PgVectorDocumentChunk> 待入库向量分片集合
vectorStore.replaceDocument(sourceFile, parsedDocument, vectorChunks);
// 文件内容变了,旧摘要/旧标签可能过期,所以清掉结果缓存。
documentResultStore.clearDocumentResult(sourceFile.getUserId(), sourceFile.getUserFileId());
// 返回给前端的索引结果摘要,不返回具体向量内容。
DocumentIndexSummary summary = new DocumentIndexSummary();
summary.setFilename(sourceFile.getFilename());
summary.setMediaType(parsedDocument.getMediaType());
summary.setParser(parsedDocument.getParser());
summary.setBlockCount(parsedDocument.getBlocks().size());
summary.setChunkCount(chunks.size());
summary.setVectorDimension(embeddingClient.dimension());
summary.setContentLength((long) sourceFile.getBytes().length);
return toIndexData(request, summary, Boolean.TRUE, existingSummary != null);
}
summarize(...)
/**
* 生成文档总结核心方法
* @param request 总结请求参数
* @return 总结结果数据
*/
@Override
public AiSummaryData summarize(AiDocumentSummaryRequest request) {
// ========== 第一步:先查缓存,有现成结果就直接返回 ========== // 判断当前场景是否可以使用已存储的总结结果
if (canUseStoredSummary(request)) {
// 从结果存储库中,取出该用户该文件的历史总结
StoredDocumentSummary storedSummary = documentResultStore.getSummary(
request.getUserId(),
request.getUserFileId()
);
// 缓存存在且内容不为空,就直接封装成返回结果,不用调大模型
if (storedSummary != null && StringUtils.isNotBlank(storedSummary.getSummary())) {
return buildStoredSummaryResponse(request, storedSummary);
}
}
// ========== 第二步:没有缓存,加载文档的纯文本内容 ========== // 把PDF/Word/Excel等文件里的文字提取出来,变成纯文本,才能传给大模型
// 内部会做:文件类型校验、文本提取、长度截断、缓存文本内容
DocumentPromptContext documentContext = loadDocumentContext(
request.getUserId(),
request.getFileId(),
request.getUserFileId()
);
// 把文件名补充到prompt指令里,让大模型知道总结的是什么文件
enrichFilename(request, documentContext.filename());
// ========== 第三步:调用大模型客户端,生成总结内容 ========== AiSummaryData response = aiProviderClient.summarize(request, documentContext.content());
// ========== 第四步:判断是否需要把生成的结果存进缓存 ========== if (canPersistSummary(request, response)) {
// 把总结结果保存到存储库,下次同一个用户同一个文件就直接走缓存
documentResultStore.saveSummary(
request.getUserId(),
request.getUserFileId(),
response.getFilename(),
response.getSummary(),
response.getModel(),
response.getMocked()
);
}
// 返回最终结果
return response;
}
generateTags(...)
@Override
public AiTagData generateTags(AiDocumentTagRequest request) {
// 标签也先尝试复用已保存结果;标签数量足够时不用再次调用模型。
if (canUseStoredTags(request)) {
StoredDocumentTags storedTags = documentResultStore.getTags(request.getUserId(), request.getUserFileId());
if (canSatisfyRequestedTagCount(storedTags, request.getTopK())) {
return buildStoredTagResponse(request, storedTags);
}
}
// 没有可用缓存时,加载文档文本并调用模型生成标签。
DocumentPromptContext documentContext = loadDocumentContext(request.getUserId(), request.getFileId(), request.getUserFileId());
enrichFilename(request, documentContext.filename());
AiTagData response = aiProviderClient.generateTags(request, documentContext.content());
// 默认标签结果会落库,下次同一文件可直接复用。
if (canPersistTags(request, response)) {
documentResultStore.saveTags(
request.getUserId(),
request.getUserFileId(),
response.getFilename(),
response.getTags(),
response.getModel(),
response.getMocked()
);
}
return response;
}
也就是说,当前项目里"默认摘要"和"默认标签"的基础,仍然是先把文件索引好,再做后续 AI 处理。
3.3 摘要实现
当前摘要接口为:
POST /api/v1/ai/files/summary
当前请求参数为:
fileId:必填filename:可选prompt:可选
当前返回字段主要包括:
fileIdfilenamesummarymodelmocked
3.3.1 默认摘要优先走已落库结果
AiApplicationServiceImpl.summarize(...) 中,只有在下面条件同时满足时,系统才会优先尝试读取已存储摘要:
DocumentResultStore.isReady()为true- 请求里存在
userId - 请求里存在
userFileId - 当前请求没有传自定义
prompt
如果满足条件,系统会从 ai_document_result 中查询:
summary_textsummary_modelsummary_mocked
只要查到非空摘要内容,就会直接返回,不再重新调用模型。
这意味着当前"默认摘要"的真实行为更接近:
- 优先读缓存化的落库结果
- 没有结果时再现场生成
3.3.2 自定义提示词摘要不会覆盖默认摘要缓存
当前前端允许用户手动填写 summaryPrompt,例如让模型从"行动项"或"管理视角"出发总结文档。
但服务端在保存摘要结果时,明确要求:
- 只有
prompt为空时,才允许把结果写回ai_document_result
因此:
- 默认摘要会被缓存并复用
- 用户临时输入的自定义摘要,不会覆盖系统默认摘要缓存
这点需要在文档中说清楚,否则容易误以为"任何一次摘要生成都会更新数据库中的默认摘要"。
3.3.3 摘要生成时的上下文来源
摘要生成时会先走 loadDocumentContext(...),上下文来源优先级是:
- 如果 pgvector 可用且该文件已完成索引,则优先读取
ai_document_chunk_vector中按chunk_index排序后的整份分片文本,并在服务层合并 - 如果没有可用索引,则回退为直接读取文件字节内容,用 Apache Tika 解析原文文本
当前最大上下文长度受 com.disk.ai.provider.max-document-chars 控制,配置值为 120000。
3.4 标签实现
当前标签接口为:
POST /api/v1/ai/files/tags
当前请求参数为:
fileId:必填filename:可选topK:可选,默认5,并且受参数校验限制在1 ~ 20
当前返回字段主要包括:
fileIdfilenametagsmodelmocked
3.4.1 标签优先复用已落库标签
和摘要类似,标签也会优先尝试读取 ai_document_result 中已保存的标签 JSON。
但标签比摘要多了一层"数量满足判断":
- 如果已有标签列表为空,则不能复用
- 如果已有标签数量小于本次请求的
topK,也不能直接复用 - 只有当已落库标签数量大于等于本次
topK时,系统才会直接截取前topK个返回
例如:
- 默认已经落库了
5个标签 - 用户这次想看
8个标签
那么当前服务端不会简单返回原来的 5 个,而是会重新生成更多标签并再次落库。
3.4.2 当前标签的生成与清洗规则
当前真实标签生成不是后端自己从文档里抽关键词,而是由 AI Provider 生成,再由服务端做一层结果清洗。
OpenAiCompatibleAiProviderClient 的标签提示词要求是:
- 返回简洁中文标签
- 最多返回
topK个 - 只返回标签本身,用逗号分隔,不要解释
拿到模型返回后,服务端还会做这些处理:
- 把英文逗号和分号统一视为分隔符
- 去掉前导编号、短横线等格式符号
- 过滤空值
- 截断单个标签长度,最长不超过
30个字符 - 去重
- 最终再按
topK截断
因此当前标签结果不是"模型原样输出即最终结果",而是"模型输出 + 服务端清洗后的结果"。
3.5 文档索引与预处理
摘要和标签功能虽然最终表现为两个简单接口,但它们底层依赖的是同一套文档索引和文本预处理链路。
3.5.1 文件读取
AI 服务会通过 AiSourceFileLoader 读取文件内容。其内部真实流程是:
- 把加密
fileId解密成userFileId - 通过 Dubbo 调用
FileFacadeService.getFileReadInfo(...) - 文件服务校验当前用户是否有权访问该文件
- 文件服务返回真实路径、后缀、内容类型等信息
- AI 服务调用存储引擎读取文件字节数组
3.5.2 文档解析
拿到原始字节后,TikaDocumentParser 会:
- 先按后缀白名单校验文件是否允许解析
- 使用 Apache Tika 自动识别并提取正文
- 统一换行、去除空字符、压缩过多空行
- 把文档按段落切成多个 block
- 如果文本过长,则截断到
com.disk.ai.index.max-text-chars
当前 max-text-chars 配置值为 200000。
3.5.3 文本切分
解析后的正文不会整体直接入向量库,而是会经过 ParagraphTextChunker 做分片。
当前配置为:
chunk-size = 1200chunk-overlap = 200
也就是说,系统会尽量按段落文本做长度约为 1200 的分片,并在相邻分片之间保留一定重叠,便于后续检索时减少语义断裂。
3.5.4 向量生成与入库
当前仓库默认配置中:
- Provider 类型:
openai-compatible - 向量维度:
768 - pgvector:已开启
索引建立时,系统会:
- 对每个分片文本生成 embedding
- 把索引摘要写入
ai_document_index - 把每个分片及其 embedding 写入
ai_document_chunk_vector - 清空该文件之前的摘要/标签缓存结果
这里有一个很关键的实际行为:
- 重新建索引后,旧的
ai_document_result会被删除
因此索引重建后,再次读取摘要和标签时,会基于新的文档索引重新生成并重新落库。
3.6 手动重建索引
当前前端抽屉提供了"重建索引"按钮,对应接口为:
POST /api/v1/ai/files/index
请求参数包括:
fileIdfilenameforceReindex = true
如果该文件已经存在索引且没有强制重建,后端会直接返回已有索引摘要信息;如果显式传了 forceReindex = true,则会重新读取文件、重新解析、重新切分、重新写入向量数据。
前端在重建索引成功后,会继续调用 loadInsights(),也就是重新加载摘要和标签。
3.7 AI 能力状态展示
当前抽屉顶部的"AI 服务状态"区域,对应接口为:
GET /api/v1/ai/capabilities
这个接口当前返回的信息包括:
serviceproviderchatModelmockEnabledvectorStoreEnabledembeddingDimensionchunkSizechunkOverlapfeatures
前端当前主要展示的是:
- Provider
- Chat Model
- 向量检索是否启用
需要注意的是,这个接口被标注了 @LoginIgnore,主要作用是用于前端展示服务能力状态,而摘要和标签本身仍然需要登录态来获取当前用户文件。
4. 数据模型
4.1 ai_document_index
作用:保存一个用户文件的索引摘要信息,标识"这个文件是否已经完成过 AI 索引"。
核心字段包括:
user_iduser_file_idreal_file_idfilenamefile_suffixmedia_typeparsercontent_lengthplain_text_charsblock_countchunk_count
4.2 ai_document_chunk_vector
作用:保存文档文本分片和向量,支撑摘要上下文复用和后续问答检索。
核心字段包括:
user_iduser_file_idblock_indexchunk_indexstart_offsetend_offsettoken_estimatechunk_textembedding
4.3 ai_document_result
作用:保存摘要和标签的落库结果,避免每次都重新调用模型。
核心字段包括:
user_iduser_file_idfilenamesummary_textsummary_modelsummary_mockedtags_jsontags_modeltags_mocked
从这张表的结构可以看出,当前实现更偏向"每个文件保留一份最新摘要和一份最新标签"。
10.(AI) 文件问答功能
1. 功能说明
- 当前页面层的单文件问答入口,来自
disk-by-cursor/src/views/Files.vue中的 AI 按钮,按钮点击后会打开disk-by-cursor/src/components/AiFileDrawer.vue的问答模式。 - 当前单文件问答是严格围绕"当前用户当前这一个文件"展开。
- 当前问答链路优先使用 pgvector 检索命中的相关分片作为上下文;如果没有可用索引或没有检索命中,则回退为直接读取并解析整份文件内容后再提问。
- 当前答案生成要求模型只能基于提供的文档上下文作答,并在上下文不足时明确说明。
- 当前引用片段来自检索命中的文本分片,因此只有命中检索时才更容易拿到有效引用。
- 当前问答历史只保存在前端抽屉状态里,没有做后端持久化。
2. 功能需求
单文件问答功能的目标,是希望用户能够围绕某一份文档直接追问,并快速得到更贴近语义的问题答案。
从业务角度看,这类能力通常需要满足以下几点:
- 只能围绕当前选中的一份文件提问,避免上下文范围失控。
- 要尽量优先返回与问题最相关的文档片段,而不是每次都把整份文档原文直接丢给模型。
- 如果系统已经提前对文档做过索引,应复用索引结果,提高问答效率。
- 如果当前文档还没有索引结果,也不能完全不可用,需要具备回退能力。
- 前端需要保留用户本轮提问历史,支持连续追问。
- 问答结果最好能附带引用片段,帮助用户判断答案依据来自哪里。
3. 功能实现
3.1 整体思路
当前项目的单文件问答链路可以概括为:
- 用户在文件列表页点击某个文件的 AI 按钮。
- 前端打开
AiFileDrawer,并进入qa模式。 - 用户输入问题后,前端调用
POST /api/v1/ai/files/question。 - 后端从登录态中取当前用户
userId,并把前端传入的加密fileId解密成内部userFileId。 - AI 服务优先尝试基于该文件已有的向量索引做相似片段检索。
- 如果检索命中,则把命中的多个文本分片合并后作为问答上下文。
- 如果没有命中,或者当前向量存储不可用,则回退为直接读取文件原文、用 Apache Tika 解析文本,再把解析结果作为问答上下文。
- AI Provider 基于上下文生成中文答案。
- 如果前端要求附带引用,且向量检索确实命中了分片,则服务端会把命中分片整理成引用片段返回给前端。
- 前端把答案追加到抽屉中的问答历史列表里,用户可以继续追问。
当前这条链路涉及的关键位置包括:
- 前端页面入口:
disk-by-cursor/src/views/Files.vue - 前端问答抽屉:
disk-by-cursor/src/components/AiFileDrawer.vue - 前端 AI 接口封装:
disk-by-cursor/src/api/ai.js - 后端控制器:
networkdisk-business/networkdisk-ai/src/main/java/com/disk/ai/controller/AiController.java - 后端应用服务:
networkdisk-business/networkdisk-ai/src/main/java/com/disk/ai/domain/service/impl/AiApplicationServiceImpl.java - 向量存储实现:
networkdisk-business/networkdisk-ai/src/main/java/com/disk/ai/infrastructure/vector/PgVectorVectorStore.java - 文件读取入口:
networkdisk-business/networkdisk-ai/src/main/java/com/disk/ai/infrastructure/file/AiSourceFileLoader.java
3.1.1 当前前端交互形态
当前前端的单文件问答,不是单独做了一整个页面,而是和"智能摘要/标签"共用同一个 AiFileDrawer 抽屉组件,只是通过 mode 区分使用场景。
在当前页面里:
- 文件列表的 AI 按钮点击后,默认调用
openAiDrawer(file),默认模式是qa,也就是单文件问答模式。 - 用户直接点击支持 AI 的文件名时,页面会优先进
insight模式,也就是摘要和标签模式,而不是问答模式。 - 问答模式下,抽屉不会自动发起提问,而是等待用户输入问题。
- 页面提供了 3 个内置问题建议,方便用户快速开始提问。
- 输入框支持
Ctrl + Enter快捷发送。 - 前端默认开启"附带引用"开关,即
includeReferences = true。 - 每次提问都会追加到当前抽屉的本地问答历史里,包含提问时间、答案、模型名和引用片段。
当前页面层面对 AI 的文件类型限制是:
3:Excel4:Word5:PDF6:文本10:PPT11:代码12:CSV
因此当前页面不会对文件夹、图片、音频、视频等类型开放单文件问答入口。
3.2 问答接口
当前后端提供的单文件问答接口为:
POST /api/v1/ai/files/question
当前请求参数为:
fileId:必填。前端传的是加密后的文件 ID,本质上对应user_file.idfilename:可选。前端通常会一并传过来,便于模型提示词中带上文件名question:必填。用户输入的问题includeReferences:可选,默认true
控制器层的真实处理方式是:
- 从登录态获取当前用户
userId - 把前端传入的
fileId解密为userFileId - 组装成
AiFileQuestionRequest - 调用
AiApplicationService.answerSingleFileQuestion(...)
@Override
public AiFileAnswerData answerSingleFileQuestion(AiFileQuestionRequest request) {
// RAG 第一步:把问题转向量,去 pgvector 查当前文件最相关的文本块。
// 得到这个TOP k个对象
// chunkIndex:命中的第几个文本切块
// blockIndex:这个 chunk 属于原文第几个段落块
// chunkText:命中的原文片段内容
// similarity:相似度分数,越高越相关
List<PgVectorSearchResult> hits = searchRelevantChunks(request);
// RAG 第二步:如果查到相关块,就只把相关块给模型;查不到才退回整篇文档上下文。
DocumentPromptContext questionContext = resolveQuestionContext(request, hits);
enrichFilename(request, questionContext.filename());
// RAG 第三步:将用户提问 + 筛选后的文档上下文 送入大模型生成回答
AiFileAnswerData data = aiProviderClient.answerSingleFileQuestion(request, questionContext.content());
// 分支1:前端配置不需要展示原文引用,直接清空引用列表返回结果,引用就是检索到的文档
if (Boolean.FALSE.equals(request.getIncludeReferences())) {
data.setReferences(List.of());
return data;
}
// 分支2:存在检索命中的文本块,组装引用信息(段落、分片、相似度、原文片段)返回前端
if (!hits.isEmpty()) {
data.setReferences(buildReferences(hits));
}
return data;
}
当前返回结果主要包括:
fileIdfilenamequestionanswerreferencesmodelmocked
其中:
model表示本次生成答案所使用的模型名mocked表示当前结果是否来自 Mock Provider
3.3 问答时的上下文选择
单文件问答最核心的点,不在于"把问题发给模型",而在于"到底给模型喂什么上下文"。
当前项目采用的是"优先检索相关分片,检索失败再回退全文解析"的策略。
3.3.1 优先使用向量检索
AiApplicationServiceImpl.answerSingleFileQuestion(...) 中会先调用 searchRelevantChunks(...)。只有满足下面条件时,系统才会进入真正的向量检索流程:
vectorStore.isReady()为true- 当前请求中存在
userId - 当前请求中存在
userFileId - 该文件已经存在索引摘要记录,也就是
ai_document_index中能查到对应记录
满足条件后,系统会:
- 使用
EmbeddingClient.embed(question)对问题本身生成向量 - 在
ai_document_chunk_vector中按向量相似度检索当前文件最相关的文本分片 - 检索条数由
com.disk.ai.index.retrieval-top-k控制,当前配置值为6 - 把命中的分片文本按顺序合并,作为本次问答的文档上下文
这意味着当前单文件问答并不是"盲目把整份文档交给模型",而是优先做"当前问题 -> 当前文件分片"的语义召回。
/**
* 向量相似度检索:根据用户提问向量,查询指定用户、指定文件下语义最相似的文本切块
* @param userId 当前操作用户ID,数据隔离
* @param userFileId 目标文件唯一ID,只检索该文件内向量
* @param queryVector 用户问题向量化后的浮点数组,用来做相似度匹配
* @param topK 返回相似度最高的前N条结果
* @return 相似度匹配结果列表,携带切块编号、原文、相似度分数
*/
@Override
public List<PgVectorSearchResult> search(Long userId, Long userFileId, float[] queryVector, int topK) {
// 数据库pgvector字段是vector类型,Java float[]不能直接传入SQL,需要包装成PGvector驱动对象
PGvector vector = new PGvector(queryVector);
// jdbcTemplate.query 重载:两个参数
// 1. ConnectionCallback:手动创建PreparedStatement、填充SQL占位符
// 2. RowMapper:遍历查询结果集,把每行数据映射为业务实体 PgVectorSearchResult// 这个 <=> 也是 pgvector 提供的向量距离运算符。
// 距离转相似度分数:距离 0 → 相似度 1.0;距离越大,相似度越趋近 0,业务上直观展示 0~1 匹配度
return jdbcTemplate.query(
connection -> {
// 手写查询SQL,pgvector专属向量相似度语法
PreparedStatement ps = connection.prepareStatement("""
select chunk_index, block_index, chunk_text, 1 - (embedding <=> ?) as similarity
from ai_document_chunk_vector
where user_id = ? and user_file_id = ?
order by embedding <=> ?
limit ?
""");
// 填充5个?占位符,序号从1开始
ps.setObject(1, vector); // 计算相似度用的查询向量
ps.setLong(2, userId);
ps.setLong(3, userFileId);
ps.setObject(4, vector); // 排序依据用的查询向量
ps.setInt(5, topK);
return ps;
},
// RowMapper 行映射器:每查到一行数据,回调此方法封装实体
(rs, rowNum) -> {
PgVectorSearchResult result = new PgVectorSearchResult();
result.setChunkIndex(rs.getInt("chunk_index"));
result.setBlockIndex(rs.getInt("block_index"));
result.setChunkText(rs.getString("chunk_text"));
// 取出SQL计算好的相似度分数
result.setSimilarity(rs.getDouble("similarity"));
return result;
}
);
}
3.3.2 没有检索结果时回退全文
如果出现下面这些情况,系统就不会继续走向量检索结果,而是直接回退为全文读取:
- pgvector 没有启用
- 当前文件还没有建立索引
- 检索没有命中任何分片
回退路径是:
- 通过
AiSourceFileLoader读取当前用户当前文件的真实文件内容 AiSourceFileLoader内部会通过 Dubbo 调用FileFacadeService.getFileReadInfo(...)校验权限并获取真实路径- 再通过存储引擎读取文件字节数组
- 使用
TikaDocumentParser解析文档正文 - 把解析出的纯文本截断到
com.disk.ai.provider.max-document-chars范围内
当前 max-document-chars 配置值为 120000,也就是全文回退时也不会无上限地把所有内容都交给模型。
3.3.3 当前不是多文件问答
当前项目是单文件级别的,因此当前检索和回退都限定在:
- 当前用户
- 当前
userFileId - 当前这一份文件
3.4 文档读取与解析
单文件问答要成立,首先要能安全读取当前文件。
当前链路中,AI 服务并不是自己直接查数据库拿路径,而是通过 FileFacadeService 获取文件读取信息。这么做的作用是:
- 复用文件服务已有的权限校验逻辑
- 避免 AI 服务直接耦合过多文件业务表结构
- 明确"AI 处理的目标对象其实是用户自己的普通文件,而不是任意物理文件"
AiSourceFileLoader 当前实际会得到这些信息:
userIduserFileIdrealFileIdfilenamerealPathfileSuffixfilePreviewContentTypeidentifierfileSizefileType
之后由存储引擎读取真实文件内容。当前仓库里默认主存储实现仍然是 LocalStorageEngine,因此在默认配置下,AI 读取的是本地存储的物理文件内容。
3.5 AI Provider 的真实行为
问答上下文准备好之后,系统会调用 AiProviderClient.answerSingleFileQuestion(...)。
当前仓库中主要有两种实现:
OpenAiCompatibleAiProviderClientMockAiProviderClient
当前 networkdisk-ai 模块 application.yml 中配置的是:
com.disk.ai.provider.type = openai-compatiblechat-model = qwen3.5-plusembedding-model = qwen3-vl-embeddingembedding-dimension = 768
也就是说,按当前仓库默认配置,问答会走 OpenAI 兼容协议的真实模型调用,而不是 mock。
当前问答提示词的要求是:
- 用中文回答
- 只能基于提供的文档上下文回答
- 如果上下文不足,要明确说明上下文不足
这一点很重要,因为它决定了当前单文件问答模型受当前文件内容约束,避免幻觉。
3.6 引用片段实现
用户提问
-> 把问题转成向量
-> 去当前文件的 pgvector 表里找最相似的几个 chunk
-> 把这些 chunkText 拼起来给大模型回答
-> 同时把这些命中的 chunk 简短返回给前端当 references
"附带引用"是当前前端问答抽屉里的一个显式开关,当前项目里引用片段的行为是:
- 如果前端把
includeReferences设为false,服务端会主动把返回结果中的references清空 - 如果
includeReferences为true,且本次问答确实命中了向量检索结果,服务端会基于检索命中的分片构造引用片段 - 如果没有任何检索命中,即使
includeReferences为true,也可能没有引用片段返回
当前引用片段的格式来自服务层拼装,类似:
chunk#分片编号score=相似度text=截断后的分片内容
因此这里的"引用片段"本质上是"问答前检索命中的文本块摘要",并不是单独做了一套更复杂的原文定位系统。
4. 数据模型
4.1 ai_document_index
作用:保存某个用户文件已经完成 AI 索引后的摘要信息。
当前表里记录的核心信息包括:
user_iduser_file_idreal_file_idfilenamefile_suffixmedia_typeparsercontent_lengthplain_text_charsblock_countchunk_count
这张表的意义在于告诉系统:“这个文件是否已经完成过索引,以及索引后的基本统计信息是什么”。
4.2 ai_document_chunk_vector
作用:保存文档分片及其向量,用于单文件问答时做语义检索。
当前表里记录的核心信息包括:
user_iduser_file_idchunk_indexblock_indexstart_offsetend_offsettoken_estimatechunk_textembedding
单文件问答时,真正参与"召回相关上下文"的就是这张表。
4.3 当前没有单独的问答历史表
需要特别说明的是,当前项目并没有专门保存:
- 用户每次提问内容
- 模型答案
- 问答会话 ID
- 多轮上下文
因此单文件问答虽然已经可用,但仍然是"即时问答能力",不是"完整会话产品",可以作为后续的拓展方向之一。