项目:X-Pan | 版本:v1.2 | 生成日期:2026-10-06 | 依据:源码分析
本文选三个文件逐段带码走读:FileChunkServiceImpl(分片写入与合并判定,208 行)、MinIOStorageEngine(composeObject 合并,681 行)、CommonLoginAspect(切面鉴权与续期,251 行)。选择标准有三:都在"上传→存储→鉴权"这条主干道上;都藏着本项目最精巧的并发与防御代码;且它们的"是什么"已在《05-核心流程》《10-性能设计与优化》讲过,本文回答"每一行为什么这样写"。走读方法:按源码行号顺序推进,关键片段原文摘录(行号以 2026-10-06 源码为准),每段之后给「设计要点」小结。走读前建议先打开对应文件对照阅读。三个文件中《05-核心流程》流程一已画过的时序不再重画,本文只补时序图无法承载的"行级理由"。
文件:x-pan-parent/server/src/main/java/com/xiaoye/pan/server/modules/file/service/impl/FileChunkServiceImpl.java。它继承 MyBatis-Plus 的 ServiceImpl<XPanFileChunkMapper, XPanFileChunk>,实现 IFileChunkService,对外唯一入口是 saveChunkFile。
类顶部先定义了全文件最值得读的东西——一张按 identifier_userId 粒度的锁表:
private static final ConcurrentHashMap<String, StripedLock> LOCK_MAP = new ConcurrentHashMap<>();
private static final int MAX_LOCK_ATTEMPTS = 3;
private static final class StripedLock {
private final Object lock = new Object();
private final java.util.concurrent.atomic.AtomicInteger holder = new java.util.concurrent.atomic.AtomicInteger(0);
}第 39-47 行的 Javadoc 是理解它的钥匙:这个结构解决的是"computeIfAbsent 新建锁 + finally 无条件 remove"的经典竞态——如果线程 A 用 computeIfAbsent 拿到锁对象后执行完任务,另一个线程 B 恰好也 computeIfAbsent 到了同一个锁对象并进入同步块,A 在 finally 里无条件 remove 掉这个键,之后线程 C 再 computeIfAbsent 会新建第三个锁对象——于是 B 和 C 持着不同的锁对象先后进入临界区,互斥失效。持有计数(holder)的解法是:只有最后一个使用该锁的线程退出时才真正移除,杜绝"一边有人持锁、一边换锁"的窗口。
「设计要点」锁对象与锁登记是两个概念:synchronized(锁对象) 只保证互斥,LOCK_MAP 的键值登记才是"这把锁还作数"的依据;两者必须原子地一起生灭,做不到原子就要用持有计数 + 条件移除兜底。
for (int attempt = 0; attempt < MAX_LOCK_ATTEMPTS; attempt++) {
StripedLock stripedLock = LOCK_MAP.computeIfAbsent(lockKey, k -> new StripedLock());
stripedLock.holder.incrementAndGet();
try {
synchronized (stripedLock.lock) {
if (LOCK_MAP.get(lockKey) != stripedLock) {
continue; // 当前持有的是已被替换的旧锁,释放后重试
}
doSaveChunkFile(context);
doJudgeMergeFile(context);
return;
}
} finally {
if (stripedLock.holder.decrementAndGet() == 0) {
LOCK_MAP.remove(lockKey, stripedLock);
}
}
}
throw new XPanBusinessException("文件分片保存繁忙,请稍后重试");注释在第 82-84 行交代了为什么进同步块后还要校验:computeIfAbsent 与 incrementAndGet 两步不是原子的,线程仍可能拿到一个刚被移除的旧锁对象并对其计数。所以拿锁 → 进临界区后先做 LOCK_MAP.get(lockKey) != stripedLock 的身份校验,不等就 continue 重走整个获取流程;三次都失败(极端竞态)则抛"文件分片保存繁忙"——这正是《12-FAQ与故障排查》第 4 节那条 FAQ 的源头。LOCK_MAP.remove(lockKey, stripedLock) 用的是 ConcurrentHashMap 的两参条件移除(键值都匹配才删),避免误删后来者登记的新锁。
「设计要点」"锁内校验 + 重试 + 有限次数"是把不可消除的竞态窗口压缩到可忽略的三板斧;错误信息"繁忙请稍后重试"给客户端留了无状态重试的出路,与分片幂等(重复上传撞唯一键无害)配套后,重试是安全的。
合并判定三个动作:查询条件带 queryWrapper.gt("expiration_time", new Date())(第 123 行,注释写明"仅统计未过期的分片,避免过期分片被计入而导致误判可合并");数量对齐 chunkNumberList.size() != totalChunks 即返回;最后第 129-139 行把 chunk_number 收进 Set 并逐一验证 contains(i)(i 从 1 到 totalChunks),注释原文:"校验 chunk_number 恰好为 [1..totalChunks] 的完整连续集合,防止客户端重复上传同一分片凑数"。判定通过才 context.setMergeFlagEnum(MergeFlagEnum.READY),由上层 chunkUpload 转成 mergeFlag 返回客户端。
「设计要点」"数量相等"与"编号连续"两个校验缺一不可:只有数量校验时,客户端把 3 号分片传四遍也能凑齐 3 片的数量,合并出的文件第 2 片缺失——这是典型的"不信任客户端计数"防御,与《05-核心流程》失败语义表中"merge 应以服务端校验为准"呼应。
doSaveChunkFile 第 155 行先判 context.getFile() == null 直接返回——simple-uploader 的 testChunks 探测请求不带文件体,此时只做合并判定不落任何数据(这个 null 容忍与 FileChunkUploadPO.file 不加 @NotNull 是一对契约)。doStoreFileChunk 经 MapStruct 转换出 StoreFileChunkContext、塞入 getInputStream() 后委托 storageEngine.storeChunk(...),再把引擎回填的 realPath 写回上下文——注意业务层拿到的是引擎返回的路径而不是自己拼的,保证对象命名规则只有一个权威出处(引擎侧 {identifier}/{chunkNumber},见走读二)。doSaveRecord 第 168-173 行先做分片下标范围校验(1 ≤ chunkNumber ≤ totalChunks,越界抛"分片下标越界"),然后登记记录:record.setId(IdUtil.get()) 雪花主键、expirationTime = DateUtil.offsetDay(now, config.getChunkFileExpirationDays())(过期天数走 PanServerConfig 配置)、createUser = userId。落库失败抛"文件分片上传失败"。
「设计要点」顺序是"先写对象、后写记录":对象写入失败抛异常即终止,不会产生"有记录无对象";反过来"有对象无记录"(对象写成功、落库失败)则由 CleanExpireChunkFileTask 与 OrphanFileGc 两道清理兜底(《04-数据模型》第 4 节)。这个方向选择让失败面落在"可自动回收"的一侧——存储泄漏比数据缺失更容易事后修补。
这个 208 行的类是"单实例并发防御"的教科书样本:细粒度 striped 锁、持有计数、条件移除、锁内校验、有限重试、服务端合并复验、下标白名单、幂等唯一键(uk_identifier_chunk_number_create_user 在表层兜底)层层叠加。它们的共同前提是"单 JVM"——多实例部署时 LOCK_MAP 各持一份,互斥失效,《13-技术债务与演进建议》D12 把它列为扩容前必须改造的三处 JVM 锁之一,改造时本文 1.1-1.2 的全部语义需在 Redis 分布式锁上重建。
文件:x-pan-parent/framework/storage-engine/storage-engine-minio/src/main/java/com/xiaoye/pan/storage/engine/minio/MinIOStorageEngine.java。类注释(第 30-47 行)先把设计要点和盘托出:对象名基于 identifier 实现秒传、分片路径 {identifier}/{chunkNumber}、合并后清理分片、私桶策略。第 57 行定义常量 MIN_COMPOSE_PART_SIZE = 5L * 1024 * 1024——整个快慢双路径的故事都从这行开始。
doStoreChunk 把分片写到 chunk 桶,对象名 context.getIdentifier() + "/" + context.getChunkNumber(),第 143 行注释明确"不依赖 uploadId(uploadId 在现有 context 中不存在;分片靠 identifier+chunkNumber 唯一定位)"。文件对象的命名则统一走 buildObjectName(第 593-607 行):先对 identifier 做 replaceAll("[/\\\\]+", "_") 清洗(注释:"防止客户端构造含 ../ 的对象 key"——纵深防御,第一层在 FileChunkUploadPO.identifier 的正则白名单),再对文件名清洗 [\\:*?"<>|],最后拼 {identifier}/{filename};identifier 为空时直接用 filename,注释解释了原因:"避免拼出 null/xxx 导致上传与读取对象名不一致"。
「设计要点」分片对象名不含 userId,同 MD5 的分片在桶内天然共享——这让"另一用户先传了同文件"时合并依然成立(秒传在合并层生效的数据基础),但要求 identifier 必须是可信的 MD5 值,这就是入参正则白名单被提到安全高度的原因(《08-安全与权限设计》第 3 节)。
合并入口先 listObjects(prefix = identifier + "/") 枚举 chunk 桶内全部分片(第 163-171 行),空列表抛"找不到任何分片";随后按 extractChunkNumber(第 609-616 行,取最后一段斜杠后的整数)升序排序。真正的路径选择在第 187-213 行:先逐分片 statObject 拿大小,任何非末分片小于 5MB 就置 needFallback(第 198-203 行循环到 sizes.size() - 1,刻意放过最后一片——composeObject 只对末源对象豁免大小限制);快路径走 mergeByComposeObject;任何异常(包括 compose 中途失败)在第 210-213 行统一降级:"MinIO composeObject 合并失败,回退到下载拼接方式"。合并成功后做两件收尾:ensureContentType(下节)与清理分片——第 229-236 行用 removeObjects 批量删并迭代消费删除结果,第 235 行注释"迭代消费删除结果,确保删除请求真正执行完成"(MinIO 批量删除是惰性提交的,不消费结果等于没删)。
「设计要点」"快路径失败自动落慢路径"的结构让 needFallback 同时承载两种语义(分片太小 / compose 异常),调用方无需区分;代价是异常被吞进降级分支只留 warn 日志,排障时要靠"MinIO merge fallback"日志区分路径(《12-FAQ与故障排查》"99% 卡住"条目关联此处)。
快路径先按分片数分岔:≤1000 片一次 composeObject 直达文件桶;超过则进 mergeInBatches——按批 1000 在 chunk 桶内合成中间对象 identifier + "/." + filename + ".tmp." + round + "." + batchIndex(第 638 行,这就是《10-性能设计与优化》说的 .tmp.{round} 机制),每轮生成的新中间对象进 intermediates 清单,下一轮开始时把"不在本轮 next 里的"中间对象删掉(第 653-665 行,注释:"收集每一轮生成的中间对象……避免中间对象在 chunk 桶中残留"),递归到只剩一个中间对象后 copyObject 到文件桶并删除中间产物。mergeByDownloadAndUpload(第 298-328 行)是慢路径:8192 字节缓冲逐片下载拼接进临时文件,putObject 整体上传,finally 里删除临时文件且删除失败打 warn(不复用 deleteOnExit——那种写法在长驻进程里永不触发,审计 C2 的教训同类)。
「设计要点」分批递归把"compose 一次最多 1000 源"的硬限制变成了对客户端透明的实现细节;中间对象放在 chunk 桶而非文件桶,让"未完成合并的中间态"永远与正式文件隔离——即使合并中途崩掉,文件桶也不会留下垃圾,残留物由 chunk 清理任务按过期时间回收。
composeObject 无法设置对象元数据,合并产物默认 application/octet-stream,浏览器会把本该内联预览的文件直接下载。ensureContentType 的解法是:statObject 读当前类型 → 与按文件名推断的目标类型相同则跳过(第 366-368 行,"仅当当前 Content-Type 不正确时才执行拷贝,避免无意义的对象重写")→ 不同则 copyObject 覆盖自身(source 与 target 同桶同对象,第 369-377 行,headers 里带新 Content-Type),第 217 行注释强调"copyObject 覆盖自身为服务端内部操作,不回源";失败仅 warn 降级,注释写明"补充失败不影响文件可用性,仅影响浏览器预览体验"。
「设计要点」用"服务端内部拷贝"补元数据是对象存储场景的常用技巧,但这里做了两个减损设计:条件跳过避免无谓重写、失败降级不阻塞主流程——把一个"锦上添花"的步骤牢牢按在非关键路径上。
doDelete 第 424-430 行的注释块值得整段读:原实现固定删 fileBucket,而分片的 realPath({identifier}/{chunkNumber})在 chunkBucket——"导致 MinIO 模式下过期分片永不清理"。修复方式是给 DeleteFileContext 增加 bucket 维度,由调用方指定目标桶(如 CleanExpireChunkFileTask 传分片桶),缺省仍是文件桶以保持既有调用点行为不变。这是 DEV_GUIDELINES.md 红线 10("清理任务必须 bucket/路径维度删对目标")的源码出处。
「设计要点」引擎接口的参数设计决定上游的犯错空间:当"删什么"出现第二种含义(文件对象 vs 分片对象)时,把维度显式化进上下文对象,比让调用方各自绕过引擎拼 SDK 调用安全得多——后者正是这条红线最初想堵住的路。
MinIOStorageEngine 的 681 行里有近一半是兜底与收尾(慢路径、分批递归、元数据补充、中间对象清理、桶修复),主线 composeObject 只占几十行。这个比例本身就是设计结论:对象存储集成的大部分工作量在"失败时怎么办"与"限制触顶时怎么办"。熔断与重试(第 113-114 行 @CircuitBreaker(name="minioStorage") + @Retry)在 doStore 上的叠加及 onCircuitOpen fallback(第 677-681 行)见 ADR-009,此处不赘。
文件:x-pan-parent/server/src/main/java/com/xiaoye/pan/server/common/aspect/CommonLoginAspect.java。它不是 Filter 而是环绕切面——这个选择的理由(《01-系统架构》第 6 节表格)是:切点按包路径写,Controller 落位即被保护,且能拿到方法级注解做免登录开关。
切点常量 POINT_CUT = "execution(* com.xiaoye.pan.server.modules.*.controller..*(..))"(第 46 行)——一个星号匹配模块名,两个点匹配 controller 子包全部方法。环绕方法 loginAuthAround 的结构是:checkNeedCheckLoginInfo(第 243-248 行,反射查 @LoginIgnore 注解)判定是否需要校验 → 需要则 checkAndSaveUserId,失败返回 R.fail(ResponseCode.NEED_LOGIN)(code=10,全端统一重登录信号)→ 无论是否校验,proceed() 放进 try,finally 里 UserIdUtil.remove()(第 91-95 行)——线程上下文清理与鉴权成败无关,这是防 Tomcat 线程复用身份泄漏的关键(WebDAV 控制器第 136-140 行的 finally 也是同一习惯,审计 🔴#1 修复链)。
「设计要点」"注解开关 + 包路径切点"的组合把安全边界做成了结构性事实:新增免登录接口只需加注解,而新增受保护接口不需要做任何事——忘记做事不再等于裸奔,放错包才会。
逐行读:第 105-113 行取 token,Authorization 头优先,空则回退 Cookie login_token(第 106-108 行注释:"浏览器原生跳转/下载时自动携带同源 Cookie,避免把 token 拼进 URL query 造成泄露"——/file/download 这类 <a href> 下载场景的刚需);第 114-117 行 JwtUtil.analyzeToken 解析 userId,null 即拒(JwtUtil 的解析器统一吞异常返回 null,把"验签失败/过期/格式错"合并为一个出口);第 119-124 行解析 clientId 并构造缓存键 USER_LOGIN_{userId}_{clientId}(buildLoginCacheKey 第 149-154 行对空 clientId 回退不带后缀的旧格式),从 R_PAN_CACHE 读当前 token;第 126-128 行缓存不存在即拒——签名合法但已吊销的 token 在这里被拦下,这就是"服务端可吊销"语义的实现点;第 131-132 行同时比对主键与 _PREV 键(Objects.equals 两次短路),命中任一才放行;放行后 saveUserId(第 227-235 行)把 userId 转成 Long 放入线程上下文,转型失败也拒绝(第 133-136 行注释:"userId 解析失败(非数字):拒绝请求……不能放行")。
「设计要点」校验链的顺序有讲究:先验签再查缓存,让伪造 token 在碰到 Redis 之前就被拒(省一次网络往返);先查主键再查 _PREV,让正常路径(token 未轮换)只付一次缓存读。每一步的拒绝都统一收敛到 code=10,客户端不需要理解失败细节只需要重登录。
续期是本类并发上最细的部分。第 165-167 行先无锁短路:analyzeRenewalTime 取 JWT 里的续期点,未到直接 return——99% 的请求在这一行零成本离开。到达续期点后:第 180 行 RENEW_KEY_LOCKS.computeIfAbsent(loginCacheKey, ...) 取按会话槽位粒度的锁(第 52-55 行字段注释解释粒度选择:"key 数量以用户数×端数为上限,内存占用可忽略");锁内第 182-185 行二次检查——重新读缓存,若当前值与请求 token 不同,说明已有并发请求完成续期,直接复用既有新 token(注释:"避免并发续期时后写者覆盖前写者、双方客户端各持一个互不认可的新 token 而互踢");否则第 187-190 行签发新 token,先写 _PREV 再写主键(顺序保证:任何时刻读到的至少是一个有效 token),第 188 行注释说明 _PREV 的用途:"兼容续期窗口内仍在途的旧 token 请求,避免 401"。第 193-199 行把新 token 写进 New-Access-Token 响应头并配套 Access-Control-Expose-Headers(跨域下前端才能读到该头)。整个方法包在 try/catch 里,任何异常只 warn 不中断业务(第 200-202 行)——续期失败的最坏后果是 token 到期后重登录,不应让它波及本次请求。
「设计要点」滑动续期的三个不变量:并发请求只签发一个新 token(锁 + 二次检查)、轮换瞬间新旧 token 同时有效(_PREV,宽度 = 在途请求的最大耗时)、客户端感知为零(响应头 + 两端拦截器)。写缓存的顺序(先 _PREV 后主键)是最后一个容易被忽视但出了事最难查的细节。
CommonLoginAspect 用 251 行实现了过滤器链方案通常要 400+ 行才能做对的事,秘诀是把"认证"拆成两个正交关注点:每请求的"是不是你"(验签 + 槽位比对)与生命周期性的"要不要换钥匙"(续期)。前者五步直线,后者锁内幂等。与《08-安全与权限设计》第 1 节互为印证:那篇讲威胁模型与 TTL 链条,本文讲这些保证在代码里由哪几行兑现。
走读收尾,把三个文件反复出现的写法收敛成四条可迁移的语汇。其一,防御性校验靠近不可信边界:identifier 正则在 PO、路径清洗在引擎、下标校验在落库前——每层只信自己验证过的东西。其二,锁粒度业务化:三处锁都按业务键(identifier_userId、缓存 key、identifier)而非方法签名,冲突面被压到"同一文件/同一会话";配套的持有计数与二次检查是 JVM 锁的正确姿势,也是多实例化时必须原样迁移的语义。其三,降级有日志、失败有出口:compose 失败落慢路径、Content-Type 补充失败仅 warn、续期异常不中断业务——每个"可以不失败"的环节都被明确设计为不失败,且留下可检索的日志短语("MinIO merge fallback""token 续期失败")。其四,注释写"为什么"而不写"是什么":本文引用的十余处注释几乎全部在解释动机(竞态窗口、威胁模型、历史缺陷),这正是它们在审计与文档考古中反复被当成一手资料的原因——也是对后续维护者最实际的期待:改这段代码时,保持注释与行为同步。