Skip to content

feat(system): 文件落盘名改用唯一 ID,原始文件名仅作展示名 - #236

Open
luoqiz wants to merge 4 commits into
continew-org:devfrom
luoqiz:feat/file-uuid-name
Open

luoqiz wants to merge 4 commits into
continew-org:devfrom
luoqiz:feat/file-uuid-name

Conversation

@luoqiz

@luoqiz luoqiz commented Oct 21, 2026 •

Copy link
Copy Markdown
Contributor

变更类型

  • 新特性(feat)

破坏性变更

  • 本 PR 包含破坏性变更(BREAKING CHANGE),已在变更目的中说明影响与迁移方式

变更目的

当前文件的落盘名直接使用用户上传时的原始文件名(重名时 generateUniqueName 追加 (1) 序号),由此带来三类问题:

  1. 物理覆盖风险:sys_file 的 idx_file_path 是普通索引而非唯一索引,FileRecorderImpl#save 也没有唯一性校验,落盘时 REPLACE_EXISTING 会静默覆盖。并发上传同名文件、或回收站还原到已存在同名的位置时,两条记录会指向同一个物理对象,内容互相覆盖且无法恢复。
  2. 重名探测是 TOCTOU:generateUniqueName 先查询再决定名字,查询与写入之间没有任何锁,并发下两个请求仍可能算出同一个名字。
  3. 分片上传的名额浪费:initMultipartUpload 在生成唯一名前先做了一次 exists 查重并直接抛「文件名已存在」,导致后面的去重逻辑成为死代码——同名文件连分片上传都不允许。

把落盘名换成与业务无关的唯一 ID + 原扩展名后,上述三个问题都在源头消失:物理名天然唯一,不需要查询、不需要重试、不存在覆盖;原始文件名退化为纯展示信息。

解决方案

1. 落盘名 = 唯一 ID + 原扩展名

FileNameGenerator 重写为 generateStoredName(String originalFileName):IdUtil.fastSimpleUUID()(32 位十六进制)+ 原扩展名(保留大小写,沿用 FileNameUtil.extName 行为),无扩展名则不加点。零查询、零重试。原 generateUniqueName / parseFileName / buildFileNameWithCounter / existsByName / selectNamesByParentPath 一并删除(全仓仅两处调用点、无其他引用)。

2. 单文件上传(FileServiceImpl#doUpload)

落盘名改调 generateStoredName;originalName 仍取用户上传的文件名(starter 用 Part#getOriginalFilename 填充,不受 .fileName() 影响)。日志改打原始文件名,避免日志里全是十六进制 ID。

3. 分片上传(MultipartUploadServiceImpl)

starter 在完成分片时用会话里的 fileName(此时已是存储名)回填 originalName,因此原始文件名必须在 init 时单独留存、在 complete 时回填:

  • 新增 MultipartOriginalNameStore:init 成功后以 setIfAbsent 写入 multipart:original-name:{uploadId}(MD5 复用同一会话时保留首个名字),TTL 与分片会话一致;写入失败只 warning 不阻断 init(降级为展示名回落存储名)。
  • complete 在调用 starter 之前把名字取到局部变量(starter 返回后会清理 session key),兜底补录分支用它覆盖 originalName。
  • FileRecorderImpl#update(分片唯一的落库点)在构造 FileDO 后、落库前回填;更新已有记录时若暂存缺失,沿用原记录的 originalName,避免用存储名覆盖。deleteMultipartSession 同步清理暂存键。

4. 移除同名探测

单文件与分片上传都不再按名字查重,同一目录允许同名文件(展示名可以重复,物理名不会)。

5. 禁止通过修改接口移动文件

beforeUpdate 只在上级目录实际发生变化时拒绝:parentPath 为 null 表示未提交该字段(仅重命名),不参与校验;任何非 null 值(含空串)都先规范化为根目录 /(末尾斜杠一并去除,user/、/user 统一视为同一目录(写回时补齐前导 /)),再与原记录比较,目录未变化则放行并把规范化后的值写回请求。这样既不会误伤「整对象回传」式的重命名,也堵住了用空串把记录路径改到根目录、实体文件却不移动的漏洞(CRUD 更新用的是 ignoreNullValue,空串不会被跳过)。根本原因是不搬实体文件、只改记录中的 path 会让记录指向不存在的对象;FileReq 只有 originalName / parentPath 两个字段,重命名不受影响。

6. 顺带修正分片扩展名校验

initMultipartUpload 改用 FileTypeEnum.getByExtension(与 doUpload 在 #235 之后的形态一致),避免 toLowerCase() 在 tr 等地区把 GIF 判成 gıf 而误拒,也避免文件无扩展名时 NPE。

7. 名称长度上限收到入口

StoragePathValidator#validateName 增加 255 字符上限,与 sys_file.name / sys_file.original_name 的列长对齐。此前单文件上传的 MultipartFile#getOriginalFilename() 与分片上传的 fileName 都没有长度校验,超长名称会在落库时才抛 DataTooLong;此时存储实体已写入、启动器的会话清理不会执行,会留下无法重试的孤儿会话。validateName 是两个上传入口共用的校验点,一处生效即全覆盖。

行为变更(已知、已评估)

场景 变化
浏览器直链访问 保存名为存储名(唯一 ID + 扩展名);页面内下载仍按 originalName
FileResp#name 语义变为存储名,展示请用 originalName(前端已全部使用 originalName,无需改动)
分片 init 响应 fileName 返回存储名,不再是原始文件名(前端未消费该字段)
上传同名文件 不再返回「文件名已存在」,允许同目录同名
PUT /system/file/{id} 变更 parentPath 会被拒绝;携带原目录的请求放行(移动功能本就未开放),user/、/user 统一视为同一目录(写回时补齐前导 /)
名称长度 超过 255 字符直接返回 400(原先会在落库时抛 DataTooLong 并留下孤儿会话)
存量文件 不迁移,新旧命名共存;URL 按记录所属存储装配,天然兼容

回滚只需回退代码:新命名与旧命名文件的读取路径完全一致,不存在数据形态依赖。

已知限制(不影响本 PR 合并)

  • MD5 复用会话时,同一内容的不同文件名(或不同上级目录)会共用同一个 uploadId、存储对象与文件记录,展示名以首次初始化为准(setIfAbsent);后一次上传会落到首次的目录。根因在启动器的复用判定只校验 platform / bucket,已另提 continew-starter 修复(会话携带 originalFileName,并把原始文件名与上级目录纳入复用判定);待新版本发布后可改为向启动器传入原始文件名并移除本仓库的 Redis 暂存。
  • sys_file.path / parent_path 为 varchar(512),parentPath 组合长度仍未设上限,超深目录 + 长名称理论上仍会触发同类 DataTooLong,建议后续单独处理。

测试情况

新增 4 个测试类、20 个用例,全部通过:

测试类 用例数 覆盖点
FileNameGeneratorTest 6 ID 格式、大写扩展名保留、a.tar.gz 取 .gz、无扩展名、null/空串
MultipartOriginalNameStoreTest 4 不访问 Redis 的降级分支(该方法每次上传都会调用,一旦抛异常等于所有上传失败)
FileRecorderImplTest 4 新增/更新两条落库分支都回填 originalName;暂存缺失时保留存储名或已有展示名,不写入空值
StoragePathValidatorTest 6 名称 255 长度边界、非法名称/路径、user/ 与 /user 归一化、同目录判定
  • ./mvnw verify:11 个模块全部 SUCCESS,Spotless / Checkstyle / SpotBugs 均 0 违规
  • 前端 continew-admin-ui 未改动:列表、网格、回收站、搜索、重命名、下载名均使用 originalName;唯一可见面是详情弹窗的「路径」行会显示存储名

Changelog

模块 Changelog Related issues
system 文件落盘名改用唯一 ID + 原扩展名,原始文件名仅作展示名;分片上传的原始文件名经 Redis 暂存后在完成时回填;移除同名探测,同一目录允许同名文件;修改接口仅允许重命名,变更 parentPath 会被拒绝 —
system 分片上传扩展名校验改用 FileTypeEnum.getByExtension,修复大小写地区敏感与空扩展名 NPE #235
system 名称校验统一 255 字符上限,避免超长名称在落库时抛 DataTooLong 留下孤儿会话 —

提交前确认

  • 一个 PR 只解决一个 Issue,不夹带无关改动
  • 本地 ./mvnw verify 四道静态门禁与单元测试全部通过(被 Spotless 拦截时使用 -Pformat 修复)
  • 已完整填写 Changelog,并关联相关 Issue
  • commit message 符合 Conventional Commits(约定式提交)规范
  • 如包含 AI 生成的较大改动,相关 commit 已添加 Assisted-by: <智能体> 标记(两个提交均已追加 Assisted-by: DeepSeek Harness)
  • 已签署 CLA(首次贡献可在 PR 创建后按机器人提示完成签署)
  • 目标分支正确:dev

@mochou-opencontinew-cr-bot

mochou-opencontinew-cr-bot Bot commented Oct 21, 2026 •

Copy link
Copy Markdown

✅ OpenCodeReview: Review complete: 0 finding(s) across 10 selected item(s).

@luoqiz
luoqiz force-pushed the feat/file-uuid-name branch from 1f56b3e to f87b3e5 Compare October 21, 2026 00:51
@luoqiz
luoqiz force-pushed the feat/file-uuid-name branch from aa342b1 to 93ba86f Compare October 21, 2026 09:31
- FileNameGenerator 重写为「32 位唯一 ID + 原扩展名」,删除同名探测与序号重命名
- 单文件与分片上传的落盘名统一走 generateStoredName;分片上传新增 MultipartOriginalNameStore 暂存原始文件名,完成时回填展示名
- 分片初始化改用 FileTypeEnum.getByExtension 校验扩展名,顺带消除 toLowerCase 的地区问题与无扩展名 NPE
- 文件记录、上传响应与文档同步区分存储名与原始名
- 补充 FileNameGenerator、MultipartOriginalNameStore、FileRecorderImpl 回填测试

Assisted-by: DeepSeek Harness
- beforeUpdate 对非 null 的 parentPath 归一后与原记录比较,目录实际变化直接拒绝,避免只改记录路径导致实体文件失联
- 将 normalizeParentPath / isSameParentPath 抽到 StoragePathValidator,供服务与测试复用
- validateName 增加 255 字符上限,与 sys_file.name / original_name 列宽对齐,避免落库时 DataTooLong 留下孤儿会话
- 补充名称长度、非法名称/路径、路径归一化与同目录判定测试

Assisted-by: DeepSeek Harness
- 父 POM 2.17.0 → 3.0.0-SNAPSHOT,同步 Spring Boot 4.1 基线
- ServerProperties 迁移至 org.springframework.boot.web.server.autoconfigure
- Checkstyle 14.3 的 NonEmptyAtclauseDescription token 由 PARAM_LITERAL 改为 PARAM_BLOCK_TAG

Assisted-by: DeepSeek Harness
- MultipartUploadCreateResp 增加 originalFileName,save/get 双向拷贝,保证 Redis 会话往返不丢
- init 向 starter 传入 originalFileName,复用判定与清理键都按原始名 + 上级目录计算
- 完成分片由 starter 从会话回填原始名,删除 MultipartOriginalNameStore、常量、记录器手工回填与相关测试
- 保留原始名为空时沿用已有展示名的兜底
- 补充会话 JSON 往返与展示名兜底测试

Assisted-by: DeepSeek Harness
@luoqiz
luoqiz force-pushed the feat/file-uuid-name branch from 93ba86f to 5569a39 Compare October 23, 2026 00:32
@luoqiz

luoqiz commented Oct 23, 2026

Copy link
Copy Markdown
Contributor Author

CI 失败原因:依赖尚未发布的 continew-starter:3.0.0-SNAPSHOT

Build and verify 在解析 parent POM 阶段就退出了,与业务改动无关:

[FATAL] Non-resolvable parent POM for top.continew.admin:continew-admin:${revision}:
The following artifacts could not be resolved:
top.continew.starter:continew-starter:pom:3.0.0-SNAPSHOT (absent)

涉及的提交

提交 内容 与本次失败的关联
0fbe14a9 build: 升级 starter 至 3.0.0-SNAPSHOT(Spring Boot 4.1) parent POM 由 2.17.0 改为 3.0.0-SNAPSHOT
5569a39c fix(system): 分片上传改用 starter 会话原始名并移除 Redis 暂存 调用 continew-starter#28 新增的 MultipartUploadInitReq#originalFileName

continew-starter:3.0.0-SNAPSHOT 目前尚未发布到公共仓库,CI 无法解析 parent POM,因此构建必然失败,重跑不会改变结果。

这两个提交互相绑定:5569a39c 用到的 originalFileName 只在 3.0.0 中提供,所以不能只回退 0fbe14a9。

顺带同步一处正文的过期描述:下方「已知限制」中写的“待新版本发布后可改为向启动器传入原始文件名并移除本仓库的 Redis 暂存”,已由 5569a39c 落地,只待版本发布。

解除条件与备选方案

  • 待 starter 3.0.0 正式发布后,把 parent 切换到正式版即可转绿,本 PR 无需改动。
  • 若希望不等待版本发布就先合并:可将分支回退到 55a12d3e,仅保留两个与 starter 版本无关的特性提交(落盘名改用唯一 ID、拒绝变更上级目录),CI 即可通过;分片上传改造与 starter 升级待 3.0.0 发布后另行提交。

需要按哪种方式处理,请告知。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant