分片上传的大文件人人可下载:文件模块 isPrivate 在合并时被抹成 false,另有 4 个静默坑

前阵子给一个项目做等保整改,清单里有一项"上传下载功能是否存在越权"。我本来打算半小时过完,结果在文件模块里蹲了两天。

起因很滑稽:我盯着 /api/file/download/{fileId} 看了半天,确认权限校验写得很完整------私有文件、过期时间、上传者比对,一个不缺。然后我往上传方向看了一眼,发现有一个口子,传进去的文件压根没走到那套校验里。

更离谱的是这个口子在代码里非常"自然",自然到作者自己都不会觉得有问题:分片上传的合并方法里,文件可见性被硬编码成了"公开"。也就是说,你走普通接口传的合同是私有的,走分片传的 1GB 视频是全公司可见的。

下面是这次扒出来的 5 个坑,全部有源码行号,可复现。


TL;DR:六条结论

  1. 分片上传路径没有 isPrivate 入参 ,FileManager.completeMultipartUpload 拿到的 metadata 里 isPrivate 恒为 false → 走分片的大文件 100% 是公开的,而数据库字段默认值也是 0。
  2. 本地存储的 getAccessUrl 把 expires 参数扔了 → 你以为签了个"1 小时有效"的临时链接,实际返回的是永久地址;COS 那条链路反而是对的。
  3. getFileBytes 没有做权限校验 ,而它唯一的调用方是 Excel 模块用反射调的------编译期、IDE、重构工具全都看不见这条调用链。
  4. 走 InputStream 上传时,文件大小校验整段被跳过 ,Files.copy 无上限写盘 → 配置里写的 100MB 对流上传完全不起作用。
  5. 秒传复用的是同一个 fileId,删除时没有引用计数 → 一个人删文件,所有秒传过同一份文件的人全部 404;且 getByMd5 是全表 LIMIT 1。
  6. 分片上传的上下文在单机 ConcurrentHashMap 里 → 多实例部署时,第二台机器认不出第一台发的 uploadId。

一、全景:请求是怎么走到磁盘的

bash 复制代码
POST /api/file/upload  (MultipartFile)
POST /api/file/multipart/*  (分片)
GET  /api/file/download/{fileId}
        │
        ▼
FileController  ── 全部标注 @ApiPermissionIgnore(登录即可访问)
        │
        ▼
FileManager(649 行)
   ├─ validateFilePolicy()  扩展名黑名单 + MIME 匹配 + 大小校验
   │                        ↑ fileSize == null 时整段跳过(坑 4)
   ├─ doUpload()            算 MD5 → 查秒传 → storage.upload()
   │                        ↑ 命中复用同一 fileId(坑 5)
   ├─ upload(InputStream)   直接 storage.upload(),不落 MD5
   └─ completeMultipartUpload()  ← 全程没有 isPrivate(坑 1)
        │
        ▼
FileStorage SPI  ── LocalFileStorage / RustfsFileStorage / TencentCosFileStorage
        │
        ▼
FileMetadataPersistence SPI ── SystemFileMetadataPersistence(sys_file_metadata)

整个 starter 主代码 2912 行,FileManager + 三个存储实现占了 2000 行出头,剩下的全是 SPI 和模型。设计本身是干净的:存储策略可插拔、元数据持久化交给业务方,两个 SPI 切得很利索。问题都出在"两条上传路径不对称"上。


二、两个 SPI:看懂这两张接口,模块就通了

文件模块把所有"和具体存储有关的事"和"和业务表有关的事"都抽走了,业务侧要接入只需要实现这两个接口(不过 plugin-system 已经给了默认实现,一般不用自己写)。

java 复制代码
// 存储策略 SPI:local / rustfs / cos 各实现一份
public interface FileStorage {
    String getStorageType();
    void init(StorageConfig config);
    FileMetadata upload(MultipartFile file, String businessType, String businessId);
    FileMetadata upload(InputStream inputStream, String fileName, String contentType,
                        String businessType, String businessId);
    InputStream download(String fileId);
    String getAccessUrl(String fileId, Integer expires);   // ← 坑 2 在这
    boolean delete(String fileId);
    boolean exists(String fileId);
    boolean testConnection();
    boolean createBucket(String bucketName);
    boolean deleteBucket(String bucketName);
    boolean bucketExists(String bucketName);
    String initMultipartUpload(String fileName, String businessType, String businessId);
    String uploadPart(String uploadId, int partNumber, InputStream inputStream);
    FileMetadata completeMultipartUpload(String uploadId, List<String> partETags);
}
java 复制代码
// 元数据持久化 SPI:由业务模块实现,决定文件记录存哪张表
public interface FileMetadataPersistence {
    void save(FileMetadata metadata);
    FileMetadata getById(String fileId);
    FileMetadata getByMd5(String md5);          // 秒传用,坑 5 在这
    void incrementDownloadCount(String fileId);
    void delete(String fileId);
    boolean checkPermission(String fileId, Long userId);
    boolean canModify(String fileId, Long userId);
}

这套 SPI 的边界划得不错:FileStorage 只管字节,FileMetadataPersistence 只管记录,权限判断放在持久化实现里 (checkPermission),所以业务方换表不用改权限逻辑。这个思路值得抄。

但正因为"权限判断在 SPI 实现里",一旦上层漏调 assertReadPermission,下面再严谨也白搭------坑 3 就是这么来的。


三、坑 1:分片上传的文件永远是公开的

现象 :同一个用户,用 /api/file/upload 传一份 100KB 的合同,is_private = 1;用 /api/file/multipart/* 传一个 1GB 的视频,is_private = 0。后者任何登录用户拿到 fileId 就能下载。

源码 :先看普通上传路径,FileManager.java:225-231:

java 复制代码
FileMetadata metadata = storage.upload(file, businessType, businessId);
metadata.setMd5(md5);
if (isPrivate != null) {
    metadata.setIsPrivate(isPrivate);     // ← 这里补了一次
}
applyUploader(metadata);

再看分片合并路径,FileManager.java:449-463:

java 复制代码
public FileMetadata completeMultipartUpload(String uploadId, List<String> partETags, String storageType) {
    FileStorage storage = getStorage(storageType);
    if (storage == null) {
        throw new RuntimeException("不支持的存储类型: " + storageType);
    }
    FileMetadata metadata = storage.completeMultipartUpload(uploadId, partETags);
    applyUploader(metadata);              // 只补了上传者,没有 isPrivate

    if (metadataPersistence != null) {
        metadataPersistence.save(metadata);
    }
    return metadata;
}

方法签名里压根没有 isPrivate 这个参数,自然也就无从设置。而存储实现构造 metadata 时直接写死,LocalFileStorage.java:255-268:

java 复制代码
return FileMetadata.builder()
        .fileId(IdUtil.fastSimpleUUID())
        .originalName(context.getFileName())
        ...
        .isPrivate(false)      // ← 硬编码
        .downloadCount(0)
        .build();

为什么 :两条路径分别被不同的人在不同时期加的。普通上传后来补了"可见范围"参数,分片上传没跟上。而 assertReadPermission 的第一道门是:

java 复制代码
if (metadataPersistence == null || !Boolean.TRUE.equals(metadata.getIsPrivate())) {
    return;      // 不是私有 → 直接放行
}

false 走的就是这条放行分支。再叠加数据库字段默认值 is_private tinyint(1) DEFAULT '0',就算哪天有人把硬编码删了,落库还是 0。

怎么改:两处都要动,缺一不可。

java 复制代码
// 1) FileManager:签名补参数,并改成 fail-closed(不传按私有处理)
public FileMetadata completeMultipartUpload(String uploadId, List<String> partETags,
                                            String storageType, Boolean isPrivate) {
    FileMetadata metadata = storage.completeMultipartUpload(uploadId, partETags);
    metadata.setIsPrivate(isPrivate != null ? isPrivate : Boolean.TRUE);
    applyUploader(metadata);
    if (metadataPersistence != null) {
        metadataPersistence.save(metadata);
    }
    return metadata;
}

// 2) Controller:init 时把 isPrivate 存进上下文,complete 时取出(见坑 6 的上下文外置)

这里我特意选 fail-closed :文件可见性这种字段,默认应该是"最小可见",而不是"默认公开"。现在数据库默认值 0 也是反的,建议改成 DEFAULT '1'。


四、坑 2:本地存储下,"1 小时有效"的链接是永久的

现象 :调 /api/file/url/{fileId}?expires=3600 拿到一个地址,以为一小时后失效。用 local 存储时这个地址 30 天后还能用。

源码 :LocalFileStorage.java:316-329:

java 复制代码
@Override
public String getAccessUrl(String fileId, Integer expires) {   // expires 完全没被使用
    FileMetadata metadata = getFileMetadata(fileId);
    if (metadata == null) {
        return null;
    }
    String domain = config.getDomain();
    if (domain != null && !domain.isEmpty()) {
        return domain + "/api/file/download/" + fileId;
    }
    return "/api/file/download/" + fileId;
}

对比 TencentCosFileStorage.java:300-302,人家是老老实实用的:

java 复制代码
Date expiration = new Date(System.currentTimeMillis() + (expires != null ? expires : 3600) * 1000L);
return cosClient.generatePresignedUrl(defaultBucket, key, expiration, HttpMethodName.GET).toString();

为什么 :本地存储没有"预签名"这个概念,只能把下载接口地址拼出来。作者大概是想"反正下发给前端的是接口地址,权限在接口里校验",但调用方的契约已经被破坏了------上层代码写着"我要一个 3600 秒有效的地址",拿到的是一个永久地址,这个认知偏差会往上传染。

配合坑 1 吃会更疼:分片上传的文件 isPrivate=false,/api/file/download/{fileId} 的 assertReadPermission 第一道门直接 return,于是这条"永久链接"真的永久可下载。

怎么改:本地存储要么显式抛"不支持签名 URL,请走下载接口",要么自己造一个带时效的 token:

java 复制代码
@Override
public String getAccessUrl(String fileId, Integer expires) {
    long ttl = (expires == null || expires <= 0) ? 3600L : expires;
    String token = signUrl(fileId, System.currentTimeMillis() + ttl * 1000); // HMAC
    return "/api/file/download/" + fileId + "?token=" + token + "&exp=" + (System.currentTimeMillis() + ttl * 1000);
}

并在 FileManager.getAccessUrl 的 javadoc 里写明:返回地址不一定具备时效性,取决于存储实现。契约要写在接口上,不能靠调用方猜。


五、坑 3:getFileBytes 没做权限校验,还是被反射调用的

现象 :FileManager 里有两个"读文件内容"的方法,一个校验权限,一个不校验。不校验的那个被 Excel 模块用反射调走了。

源码 :先是有校验的,FileManager.java:336-345:

java 复制代码
public String getFileContentBase64(String fileId) {
    ...
    FileMetadata metadata = metadataPersistence.getById(fileId);
    if (metadata == null) {
        return null;
    }
    assertReadPermission(fileId, metadata);      // ← 有
    ...
}

再是没校验的,FileManager.java:364-382:

java 复制代码
/**
 * 获取文件内容的字节数组(用于服务端内部消费,如 Excel 图片导出)。
 */
public byte[] getFileBytes(String fileId) {
    if (metadataPersistence == null) {
        throw new RuntimeException("未配置FileMetadataPersistence");
    }
    FileMetadata metadata = metadataPersistence.getById(fileId);
    if (metadata == null) {
        return null;
    }
    FileStorage storage = getStorage(metadata.getStorageType());   // 没有 assertReadPermission
    if (storage == null) {
        return null;
    }
    try (InputStream inputStream = storage.download(fileId)) {
        return inputStream.readAllBytes();
    } catch (Exception e) {
        log.error("获取文件字节失败: {}", fileId, e);
        return null;
    }
}

注释写的是"服务端内部消费",听着没问题。问题在调用方------ExcelImageWriteHandler.java:128-136:

java 复制代码
// 通过反射调用 FileManager.getFileBytes(fileId) 获取文件字节
...
Method getFileBytes = fileManager.getClass().getMethod("getFileBytes", String.class);
return (byte[]) getFileBytes.invoke(fileManager, fileId);

为什么这是坑而不是"设计取舍":

  1. 反射调用意味着 IDE 的 Find Usages 找不到、编译期不检查、重命名不报错 。哪天有人把 getFileBytes 改成 readFileBytes,Excel 导出会在运行期炸,而且是在用户点"导出"那一刻炸。
  2. 更关键的是,fileId 是从 Excel 单元格里出来的------业务数据里的 fileId 是用户可控的。只要能往导出数据里塞一个别人的 fileId,导出的 Excel 里就带着别人的私有图片。
  3. 而且 readAllBytes() 没有大小上限,配合大文件直接吃内存。

怎么改:

java 复制代码
public byte[] getFileBytes(String fileId) {
    FileMetadata metadata = metadataPersistence.getById(fileId);
    if (metadata == null) {
        return null;
    }
    assertReadPermission(fileId, metadata);       // 补上
    if (metadata.getFileSize() != null && metadata.getFileSize() > MAX_IN_MEMORY_BYTES) {
        throw new BusinessException(413, "文件过大,不支持内存读取");
    }
    ...
}

反射那条也要收拾掉:在 excel starter 里定义一个函数式 SPI,或者干脆把 getFileBytes 提到 FileManager 的公开接口契约中(把 FileManager 抽成接口 + 实现类)。跨 starter 的依赖不能用反射兜底,这是低代码框架里最常见的技术债来源之一:省了 5 分钟,后面每次重构都埋雷。


六、坑 4:走 InputStream 上传,100MB 的限制等于没写

现象 :配置表里 max_file_size = 100,MultipartFile 路径传 200MB 会被拦;但换成 fileManager.upload(inputStream, ...) 传 2GB,顺利落盘。

源码 :校验入口 FileManager.java:567-581:

java 复制代码
private void validateFilePolicy(String fileName, String storageType, Long fileSize, String contentType) {
    if (fileName == null || fileName.isBlank()) {
        throw new RuntimeException("文件名不能为空");
    }
    StorageConfig config = resolveValidationConfig(storageType);
    long maxFileSizeMb = config != null && config.getMaxFileSize() != null && config.getMaxFileSize() > 0
            ? config.getMaxFileSize()
            : DEFAULT_MAX_FILE_SIZE_MB;
    if (fileSize != null && fileSize >= 0) {        // ← null 就整段跳过
        long maxSize = maxFileSizeMb * 1024L * 1024L;
        if (fileSize > maxSize) {
            throw new RuntimeException("文件大小超过限制: " + maxFileSizeMb + "MB");
        }
    }
    ...
}

而流上传的重载允许 fileSize 为 null,FileManager.java:160-179:

java 复制代码
public FileMetadata upload(InputStream inputStream, String fileName, String contentType,
                           String businessType, String businessId,
                           String storageType, Boolean isPrivate, Long fileSize) {
    ...
    validateFileName(fileName, storageType, fileSize);   // fileSize 可能是 null
    ...
}

落到存储层,LocalFileStorage.java:112 就一行:

java 复制代码
Files.copy(inputStream, targetFile, StandardCopyOption.REPLACE_EXISTING);   // 无字节上限

为什么 :InputStream 的长度本来就是未知的(网络流、解压流都没法预知),所以作者"合理地"允许传 null。但允许未知 ≠ 允许无限 。MultipartFile 路径之所以安全,是因为 Spring 的 spring.servlet.multipart.max-file-size 在更外层拦了一道;流上传路径没有任何外层保护。

怎么改:包一层计数流,超了就中断并清理:

java 复制代码
public static InputStream limit(InputStream in, long maxBytes, Path target) {
    return new FilterInputStream(in) {
        private long total = 0;
        @Override
        public int read() throws IOException {
            int b = super.read();
            if (b != -1 && ++total > maxBytes) {
                throw new IOException("文件大小超过限制: " + maxBytes);
            }
            return b;
        }
        @Override
        public int read(byte[] b, int off, int len) throws IOException {
            int n = super.read(b, off, len);
            if (n != -1 && (total += n) > maxBytes) {
                throw new IOException("文件大小超过限制: " + maxBytes);
            }
            return n;
        }
    };
}

同时把校验改成 fail-closed:fileSize == null 时启用"边写边查"的兜底限制,而不是直接跳过。"未知大小"不能成为"放弃限制"的理由。


七、坑 5:秒传复用同一个 fileId,却没有任何引用计数

现象 :A 传了一份报价单,B 传同一份文件(同一 businessType + businessId)命中秒传,拿到的是同一个 fileId。之后 A 删除自己的附件,B 的附件也打不开了。

源码 :秒传分支,FileManager.java:207-217:

java 复制代码
String md5 = FileUtil.calculateMd5(file);
if (metadataPersistence != null && !isAvatarBusinessType(businessType)) {
    FileMetadata existing = metadataPersistence.getByMd5(md5);
    if (existing != null
            && java.util.Objects.equals(existing.getBusinessType(), businessType)
            && java.util.Objects.equals(normalizeBusinessId(existing.getBusinessId()), normalizeBusinessId(businessId))) {
        assertReadPermission(existing.getFileId(), existing);
        log.info("文件秒传: md5={}, businessType={}", md5, businessType);
        return withFreshAccessUrl(existing);      // 返回的是别人那条记录
    }
}

删除路径,FileManager.java:401-421:

java 复制代码
public boolean delete(String fileId) {
    FileMetadata metadata = metadataPersistence.getById(fileId);
    if (metadata == null) {
        return false;
    }
    if (!metadataPersistence.canModify(fileId, null)) {
        throw new BusinessException(403, "无权删除该文件");
    }
    FileStorage storage = getStorage(metadata.getStorageType());
    if (storage != null) {
        storage.delete(fileId);          // 物理文件没了
    }
    metadataPersistence.delete(fileId);  // 逻辑删除(status = 0)
    return true;
}

canModify 只判断"是不是上传者或管理员"------A 是上传者,通过;但没人去查"还有没有别人在引用这个文件"。sys_file_metadata 表里也确实没有任何引用计数字段(我数了一遍:id / tenant_id / file_id / group_id / ... / download_count / status,没有 ref_count)。

为什么:秒传(dedup)本质是"共享物理文件 + 共享元数据行"。共享就得有生命周期管理,要么引用计数,要么禁止删除。现在两个都没有。

顺带还有两个小问题:

  • getByMd5 用的是全表 LIMIT 1(SystemFileMetadataPersistence.java:55-60),命中哪一条取决于数据库返回顺序。如果第一条命中的记录 businessType 不匹配,就直接放弃秒传,而不是继续找下一条------秒传命中率比你以为的低。
  • 这条手写查询没有带 tenant_id 条件。表结构里是有 tenant_id 的,是否隔离完全依赖 TenantLineInnerInterceptor 有没有覆盖这张表(默认会覆盖)。一旦有人把 sys_file_metadata 加进 ignoreTable,秒传立刻跨租户 ------这属于典型的"隐式契约",一旦被改,坏了都不知道是哪坏的。这个套路我在数据权限 × 多租户共存那篇里写过了,不重复。

怎么改 :最省事且不出错的做法是去掉共享,保留去重收益的另一半------秒传命中时不复用旧记录,而是新增一条元数据指向同一物理路径,删除时按元数据行各自逻辑删除,物理文件由独立的清理任务按"无引用"条件回收:

java 复制代码
// 删除时先查还有没有别人在引用同一个物理文件
long refs = metadataPersistence.countByStoragePath(metadata.getStorageType(), metadata.getFilePath());
if (refs <= 1) {
    storage.delete(fileId);     // 最后一个引用者才能删物理文件
}
metadataPersistence.delete(fileId);

或者更彻底:加 ref_count 字段,秒传命中时 +1,删除时 -1,到 0 才落物理删除。共享资源必须有生命周期归属,这条在任何模块都成立。


八、顺手挖到的两个

① DEFAULT_ALLOWED_TYPES 是个死常量。 FileManager.java:39-40 定义了 18 种默认允许类型,全项目零引用:

java 复制代码
public static final String DEFAULT_ALLOWED_TYPES =
        "jpg,jpeg,png,gif,webp,pdf,doc,docx,xls,xlsx,txt,csv,zip,rar,mp4,mp3";

而 resolveAllowedTypes(FileManager.java:609-622)在配置缺失时是直接抛异常:

java 复制代码
if (config == null || config.getAllowedTypes() == null || config.getAllowedTypes().isBlank()) {
    throw new RuntimeException("文件存储配置未设置允许的文件类型");
}

对比 maxFileSize 有 DEFAULT_MAX_FILE_SIZE_MB 兜底,allowedTypes 却没有------同一个方法里的两种配置,一种降级一种硬失败。这个常量的存在说明作者本来是想兜底的,只是没接上。修起来一行:

java 复制代码
if (config == null || config.getAllowedTypes() == null || config.getAllowedTypes().isBlank()) {
    allowedTypes = DEFAULT_ALLOWED_TYPES;   // 而不是抛异常
}

(当然要保留后面的 DANGEROUS_EXTENSIONS 过滤。)

② 分片上传状态在单机内存里。 LocalFileStorage.java:57:

java 复制代码
private final Map<String, MultipartUploadContext> multipartUploads = new ConcurrentHashMap<>();

多实例部署时,init 打到 A 机、uploadPart 打到 B 机 → B 机 multipartUploads.get(uploadId) 返回 null → "无效的上传ID"。要把分片上传做成能用的,这个 Map 必须换成 Redis(或者让 uploadId 自带路由信息,网关按 uploadId 粘会话)。

顺带说一句,它的过期清理是 @Scheduled(LocalFileStorage.java:275),而这个 starter 自己没有 @EnableScheduling ------全项目只有 forge-starter-config 和 forge-starter-auth 里有。也就是说清理任务生不生效,取决于你恰好引了哪个 starter。好在 initMultipartUpload 里手动调了一次 cleanupExpiredMultipartUploads() 兜底(LocalFileStorage.java:158),所以不会真泄漏。这个"靠别人开调度"的套路我在 WebSocket 那篇里也遇到过,属于框架层的共性问题。


九、可带走的 7 条诀窍

  1. 文件可见性这种字段,默认值必须是"最小可见"。 代码硬编码 false + 数据库 DEFAULT '0' 是双重错误。凡是"安全相关布尔值",写死的那个值应该是更严格的那个。
  2. 同一个能力有两条入口路径时,把参数列表摆在一起对一遍。 这次 5 个坑里有 3 个(isPrivate、fileSize、权限校验)都是"多文件不对称"造成的。Code Review 时专门做这一件事,性价比极高。
  3. 可选参数等于不确定性,null 分支要写测试。 fileSize == null 跳过校验、expires 被丢弃、isPrivate == null 不设置------三个坑都是同一个模式:参数为 null 时"什么都不做",而"什么都不做"恰好是最危险的行为。
  4. 跨模块调用不要用反射。 省下的编译期检查,会在某次重构后变成线上事故。要么提接口,要么定义 SPI。
  5. 共享资源(秒传复用、缓存复用、连接池)必须有引用计数或明确的生命周期归属。 没有就去查一遍 sys_file_metadata,看看你的表里有没有类似隐患。
  6. 契约要写在接口上,不能靠调用方猜。 getAccessUrl(fileId, expires) 的 expires 在本地实现里无效,这个事实至少要在 javadoc 写明。否则上层按"临时链接"做的所有安全假设都是空的。
  7. 检查你的 LIMIT 1 查询。 凡是 selectOne / LIMIT 1 后面还跟着业务条件判断的,都是"命中率取决于数据库心情"。要么把条件写进 SQL,要么接受它只是个优化提示。

十、实战接入:4 步接上文件模块

如果你要在自己的项目里用这个 starter(或者照着抄一个),步骤是:

第 1 步:引依赖(plugin-system 已经提供了两个 SPI 的默认实现)

xml 复制代码
<dependency>
    <groupId>com.mdframe.forge</groupId>
    <artifactId>forge-starter-file</artifactId>
</dependency>

第 2 步:建存储配置 。往 sys_file_storage_config 插一条,关键是 allowed_types 必须填(见坑 ①),max_file_size 默认 100:

sql 复制代码
INSERT INTO sys_file_storage_config
  (config_name, storage_type, is_default, enabled, base_path, max_file_size, allowed_types)
VALUES
  ('本地存储', 'local', 1, 1, '/data/forge-files', 100, 'jpg,jpeg,png,pdf,docx,xlsx,zip,mp4');

第 3 步:上传 。通用接口已经开了(@ConditionalOnProperty(matchIfMissing = true)):

bash 复制代码
curl -X POST http://localhost:8580/api/file/upload \
  -H "Authorization: Bearer <token>" \
  -F "file=@contract.pdf" \
  -F "businessType=contract" \
  -F "businessId=1001" \
  -F "isPrivate=true"        # ← 一定显式传;分片路径传不了,得改代码(坑 1)

第 4 步:业务侧直接注入 FileManager,别走 HTTP 绕一圈:

java 复制代码
@Service
@RequiredArgsConstructor
public class ContractService {
    private final FileManager fileManager;

    public String attach(MultipartFile file, Long contractId) {
        FileMetadata meta = fileManager.upload(file, "contract", String.valueOf(contractId), null, true);
        return meta.getFileId();     // 存 fileId,别存 accessUrl(坑 2)
    }
}

存 fileId 不存 URL,这是我自己踩过之后定的规矩:URL 会变、会失效、可能压根没有时效性,fileId 才是稳定的引用。


十一、写在最后

这个模块的设计底子是好的:SPI 切得干净、危险扩展名和 MIME 双重校验、路径穿越(resolveInsideBase + 符号链接检查)做得很扎实------resolveInsideBase 里那句 candidate.startsWith(baseDirectory) 加 rejectSymbolicLinks,比很多商业项目都严谨。

坏就坏在**"两条入口路径"的对称性**上。功能后来被加过(可见范围、流式上传、分片上传),每次加的人都只改了自己那条路。5 个坑里 3 个是这个原因。

所以这次我最想让你带走的其实不是这 5 个坑,而是一个检查动作:给你的模块列一张"入口矩阵",横轴是所有公开方法,纵轴是安全相关参数(权限、大小、时效、可见性),把空格填满。 我现在每接一个新模块都先做这张表,10 分钟,能省两天。

项目地址(可以直接看 forge-starter-file 这段源码):

这 5 个坑我已经在自己的分支上修了 3 个(isPrivate 透传、getFileBytes 权限、流上传限流),剩两个(引用计数、分片状态外置)改动比较大,得等业务侧确认删除语义才敢动。

如果你也写了文件模块,欢迎在评论区对一下:你们的 isPrivate 默认值是 0 还是 1? 我猜一多半是 0。

觉得有用的话点个赞吧------这篇我扒了两天,光是为了确认"秒传到底复不复用同一个 fileId"就翻了三遍 doUpload。


下一篇预告 :forge-starter-orm ------ MyBatis-Plus 的拦截器链在这个框架里被改成了 List<InnerInterceptor> 注入,而分页插件把 COUNT(*) 悄悄改成了 COUNT(1)。为什么要改?因为两个 SQL 解析器要都能解析嵌套查询。下一篇扒这个,顺带聊拦截器顺序为什么是个"没有断言的隐式契约"。

系列进度:① datascope ② tenant ③ 共存 ④ 幂等 ⑤ log ⑥ auth ⑦ excel ⑧ websocket ⑨ config ⑩ cache ⑪ Redisson 锁 ⑫ openapi-security ⑬ file(本篇) ⑭ orm(预告)

相关推荐
钱栈up1 小时前
工业时序数据中台架构演进:从 IoTDB + MySQL 到去 DWD 层简化实践
架构
SWAGGY..1 小时前
【C++进阶】:(7)红黑树的原理与 C++ 实现:结构设计、插入调整及性质验证
android·java·开发语言·c++·算法
颜进强1 小时前
18 · NestJS动态模块与 forRoot:`imports: [ConfigModule.forRoot({...})]` 到底在 import 什么
前端·后端·ai编程
xn71331 小时前
Personal AI Agent 架构实战:Memory、权限、跨 App 与本地/云端设计
人工智能·后端·agent
Wang's Blog1 小时前
Java框架 SpringCloud 快速入门: Eureka 服务发现与服务名调用改造
java·spring cloud·eureka
夕除1 小时前
redis--OpenResty
java·redis
Wang's Blog1 小时前
Java框架 SpringCloud 快速入门: Eureka 注册中心原理分析
java·spring cloud·eureka
谢亮_vipxieliang1 小时前
Go defer、panic与recover核心知识点
开发语言·后端·golang
xianghongtao01161 小时前
麦肯锡2026技术趋势04_AI基础设施与模型架构_研究解读
大数据·人工智能·架构