Old-School Backend / 2026 ・ No.12

古法后端入门

第12讲 文件 ── 收据照片,名字和内容都不相信地接收

发布

终端画面。NaiveReceiptService.java 第 27 行用 Files.copy 按收到的名字存到 dir.resolve(name),第 35 行读取 dir.resolve(name)。show12.py name 的结果:先放 arai 的 receipt.jpg(a67175fb),再放 furui 的 receipt.jpg(7260a48c)后,磁盘上的 receipt.jpg 变成 7260a48c;数据库目录有 2 行,磁盘上只有 1 个。../written-outside.jpg 为 200,被放到 naive 之外的 store。fixed 版用编号命名存放
按收到的文件名存放,同名收据会被覆盖,带 ../ 的名字会写到存放位置之外(朴素版)。fixed 版存放时的名字由自己来取(笔者于 2026 年 10 月 31 日在云端 Linux 上截图。窗口顶部的标题栏是为了说明而后加的)

报销需要收据,想把手机拍的照片附到申请上。可文件和文字、数字不同,大小、名字、内容都由发送方随意决定。
这一讲先写一个按收到的名字存放的朴素接收,在自己的练习用应用上实测覆盖、写到存放位置之外、内容不符的文件、同一收据重复使用、数据库与磁盘的偏差。然后改成:名字自己取、用内容开头判断、用指纹发现重复使用。

一

想附上收据

新井只是附张收据照片吧。收下来,放进文件夹。5 分钟搞定。

古井名字谁定?大小呢?内容真的是照片吗?

新井……发送的人定。

古井没错。所以全都要检查。

全 15 讲路线图(当前:第 12 讲)

1 全景图 2 Java 准备 3 裸写 HTTP 4 裸写 JDBC 5 Spring Boot 6 输入检查 7 异常与回复 8 数据库访问 9 事务 10 状态流转 11 认证与授权 12 文件 13 日志与监控 14 测试 15 运行起来 当前位置 基础 徒手 入口 数据 业务与防护 运维
  • 大小 ── 默认设置下 3.5 MB 的手机照片被拒,12 MB 连回复都没有(实测)
  • 名字 ── 同名被覆盖,带 ../ 的名字写到存放位置之外(实测)
  • 内容 ── 内容是文字的 .jpg 被当作“图片”收下(实测)
  • 重复使用・偏差 ── 同一收据附到 2 份申请,没进数据库的文件留了下来(实测)

先朴素地写。窗口用 multipart/form-data 接收文件,负责人按收到的名字放进文件夹,数据库里写一行目录。登录沿用上一讲的。

# 朴素版负责人(NaiveReceiptService.java)
@Transactional
public Receipt store(String expenseId, MultipartFile file, String user) throws IOException {
    String name = file.getOriginalFilename();
    Files.copy(file.getInputStream(), dir.resolve(name), StandardCopyOption.REPLACE_EXISTING);  // 原样使用收到的名字
    Receipt r = new Receipt(UUID.randomUUID().toString(), expenseId, name, file.getContentType(), file.getSize(),
            "-", name, user);
    receipts.insert(r);  // 即使这里失败,文件也已经放下了
    return r;
}

public byte[] read(String name) throws IOException {
    return Files.readAllBytes(dir.resolve(name));  // 按收到的名字读取
}
# 目录表(setup12.sql)
-- 第 12 讲:收据目录(文件本身在磁盘上),以及用于比较的 bytea 版
create table if not exists receipt (
  id            uuid          primary key,
  expense_id    varchar(20)   not null references expense(id),
  original_name varchar(100)  not null,
  content_type  varchar(100),
  size          bigint        not null,
  sha256        char(64)      not null,
  stored_name   varchar(255)  not null,
  uploaded_by   varchar(30)   not null,
  created_at    timestamptz   not null default now()
);
二

大小的上限

发送了 3 个文件:手边的收据图片(0.04 MB)、手机照片大小的文件(3.54 MB)、大照片(12.05 MB)。

# 用 3 种设置发送 3 种大小(新井,朴素版窗口)
                   receipt-small 0.04 MB     photo-phone 3.54 MB       photo-big 12.05 MB
  默认             200                       413                       没有回复(中途被切断)
  提高到 10MB      200                       200                       没有回复(中途被切断)
  + swallow 20MB  200                       200                       413

大小上限,以及收不到回复的照片(实测)

3 种大小 × 3 种设置(实测) 收据 0.04 MB 手机照片 3.54 MB 大照片 12.05 MB 默认 200 413 没有回复 提高到 10 MB 200 200 没有回复 + swallow 20 MB 200 200 413 Spring 默认每个文件 1 MB。手机照片光这一条就会被拒 超过拒绝前读掉丢弃的量(Tomcat 为 2 MB),连回复都收不到

Spring Boot 默认每个文件最多 1 MB。手机照片光这一条就得到 413(太大)。上限提高到 10 MB 后手机照片通过了,但 12 MB 的照片连回复都没有。Tomcat 在拒绝前会把剩余的正文读掉丢弃,默认最多 2 MB;超过就会在写回复之前切断连接。画面上只会显示“通信错误”。

# 在 application.properties 里加的 3 行
spring.servlet.multipart.max-file-size=10MB
spring.servlet.multipart.max-request-size=10MB
server.tomcat.max-swallow-size=20MB

把读掉丢弃的量(swallow)放宽到 20 MB 后,12 MB 的照片也收到了 413 和理由。上限应按“业务需要的大小”来定;在画面一侧发送前也检查大小,会更友好。

三

不相信文件名

手机和扫描仪起的名字大多差不多。如果新井和古井都发了 receipt.jpg 呢?如果名字里带着 ../ 呢?

# 按收到的名字存放(朴素版)
  arai   receipt.jpg (md5 a67175fb) → EXP-2026-5020  200   disk receipt.jpg = a67175fb
  furui  receipt.jpg (md5 7260a48c) → EXP-2026-5101  200   disk receipt.jpg = 7260a48c  ← 新井的收据没了
  DB:   EXP-2026-5020|receipt.jpg|receipt.jpg   EXP-2026-5101|receipt.jpg|receipt.jpg
  disk: receipt.jpg
  arai   ../written-outside.jpg → 200   store/naive/ = ['receipt.jpg']   store/ = ['written-outside.jpg', 'outside.txt']
# 同样的名字,用 fixed 版
  arai   ../written-outside.jpg → 200   originalName = ../written-outside.jpg   store/receipts/ = ['32005963-9319-4470-877a-0aaae252428a']

存放位置里发生的事

存放位置(store/)里发生的事(实测) store/ outside.txt ← 用 ?name=../outside.txt 读到了 written-outside.jpg ← 用名字 ../written-outside.jpg 写进去了 naive/ receipt.jpg ← 被第 2 个人的 receipt.jpg 覆盖 receipts/ 32005963-9319-… ← fixed 版:名字是自己取的编号 文件名是发送方可以随意写的字符串,不要直接当作路径 原名作为显示用留在目录(数据库)里,存放时的名字用编号

目录有 2 行,磁盘上只有 1 个。新井的收据被古井的收据悄悄覆盖了。../written-outside.jpg 这个名字,Tomcat 和 Spring 都原样交了过来,文件被写到了存放位置之外。

解决办法是存放时的名字自己取。fixed 版用 UUID(不会冲突的编号)作名字,原名只作为“显示用”留在目录里。

四

检查内容

随文件一起送来的 Content-Type(是图片还是 PDF),和名字一样是发送方的自我申报,扩展名也一样。把内容是文字的 fake.jpg 以“image/jpeg”发送。

终端画面。show12.py type 的结果:fake.jpg 开头为 e3 81 93 e3,naive 为 200 image/jpeg,fixed 为 415 不接收。receipt-small.jpg(ff d8 ff e0)、receipt.png(89 50 4e 47)、receipt.pdf(25 50 44 46)两边都是 200。以 image/jpeg 发送的 receipt.png,naive 记为 image/jpeg,fixed 记为 image/png。同一张 receipt-small.jpg 附到 EXP-2026-5021 为 200,附到 5022 为 409 同じ領収書が EXP-2026-5021 に付いています
内容是文字的 fake.jpg,朴素版当作“图片”收下了。fixed 版用开头字节判断,返回 415。想把同一张收据附到另一份申请,会用指纹(sha256)发现,返回 409(笔者于 2026 年 10 月 31 日在云端 Linux 上截图。窗口顶部的标题栏是为了说明而后加的)
# 内容与发来的种类(Content-Type)
  fake.jpg          image/jpeg       e3 81 93 e3   naive 200 image/jpeg       fixed 415 不接收
  receipt-small.jpg image/jpeg       ff d8 ff e0   naive 200 image/jpeg       fixed 200 image/jpeg
  receipt.png       image/png        89 50 4e 47   naive 200 image/png        fixed 200 image/png
  receipt.pdf       application/pdf  25 50 44 46   naive 200 application/pdf  fixed 200 application/pdf
  receipt.png       image/jpeg       89 50 4e 47   naive 200 image/jpeg       fixed 200 image/png
# 用内容开头判断(FileType.java)
public static String detect(byte[] h) {
    if (h.length >= 3 && (h[0] & 0xff) == 0xFF && (h[1] & 0xff) == 0xD8 && (h[2] & 0xff) == 0xFF) return "image/jpeg";
    if (h.length >= 8 && (h[0] & 0xff) == 0x89 && h[1] == 'P' && h[2] == 'N' && h[3] == 'G') return "image/png";
    if (h.length >= 5 && h[0] == '%' && h[1] == 'P' && h[2] == 'D' && h[3] == 'F' && h[4] == '-') return "application/pdf";
    return null;  // 不作为收据接收
}

朴素版把文字文件当作图片收下,PNG 被说成“是 JPEG”就原样记成 JPEG。fixed 版用内容开头的字节判断:JPEG 以 ff d8 ff、PNG 以 89 50 4e 47、PDF 以 %PDF- 开头,都不是就 415。种类也不按申报,而按内容来记录。

开头正确,不代表整个内容都安全。业务中还会加上病毒检查,或把图片读进来重新绘制一遍的处理。

五

同一张收据不用两次

报销特有的问题:同一张收据附到另一份申请上呢?这就是重复申请。fixed 版在接收时取内容的指纹(SHA-256),记进目录。

# 把同一张收据附到 2 份申请上
  receipt-small.jpg → EXP-2026-5021   200  sha256 1b3b466198a4c17d…
  receipt-small.jpg → EXP-2026-5022   409  同じ領収書が EXP-2026-5021 に付いています

想把同一张照片附到 5022 时,指纹与 5021 的收据一致,得到 409。改了名字,指纹也不会变。不过重新拍的照片指纹不同,所以这是防止“不小心”的机制。审批人核对金额和日期的工作不会因此消失。

六

返回时也用编号

收下之后,还需要显示的窗口。朴素版做成了“收到名字,就返回那个文件”的窗口。

终端画面。show12.py download 的结果:naive 的 ?name=receipt.jpg 为 200、37850 字节;?name=../outside.txt 也是 200、85 字节,内容为“これは、領収書の置き場所の外にあるファイルです(練習用)”。fixed 中 arai 打开自己的收据为 200 image/jpeg nosniff,带 attachment 和 filename*。arai 打开古井的收据为 404,古井打开新井的收据为 200,不存在的编号为 404
按名字返回的窗口,用 ../ 连存放位置之外的文件都返回了(自己的练习环境)。按编号返回的窗口,只返回可看申请的收据,其余为 404(笔者于 2026 年 10 月 31 日在云端 Linux 上截图。窗口顶部的标题栏是为了说明而后加的)
# 按名字返回的窗口(朴素版)
  GET /api/v12/naive/files?name=receipt.jpg     → 200  37850 字节
  GET /api/v12/naive/files?name=../outside.txt  → 200  85 字节  これは、領収書の置き場所の外にあるファイルです(練習用)
# 按编号返回的窗口(fixed 版)
  arai   → arai's   receipt  200
           Content-Type: image/jpeg
           Content-Disposition: attachment; filename="________.jpg"; filename*=UTF-8''%E9%A0%98%E5%8F%8E%E6%9B%B8%EF%BC%88%E6%96%B0%E5%B9%B9%E7%B7%9A%EF%BC%89.jpg
           X-Content-Type-Options: nosniff
           Cache-Control: no-cache, no-store, max-age=0, must-revalidate
  arai   → furui's  receipt  404
  furui  → arai's   receipt  200
           Content-Type: image/jpeg
           Content-Disposition: attachment; filename="________.jpg"; filename*=UTF-8''%E9%A0%98%E5%8F%8E%E6%9B%B8%EF%BC%88%E6%96%B0%E5%B9%B9%E7%B7%9A%EF%BC%89.jpg
           X-Content-Type-Options: nosniff
           Cache-Control: no-cache, no-store, max-age=0, must-revalidate
  arai   → none     receipt  404
# 按编号返回的窗口(V12Controller.java)
@GetMapping("/api/v12/receipts/{rid}")
public ResponseEntity<FileSystemResource> download(@PathVariable String rid, @AuthenticationPrincipal LoginUser u) {
    Receipt r = receipts.find(rid, u);
    return ResponseEntity.ok()
            .contentType(MediaType.parseMediaType(r.contentType()))  // 由内容决定的种类
            .header(HttpHeaders.CONTENT_DISPOSITION, ContentDisposition.attachment()
                    .filename(r.originalName(), StandardCharsets.UTF_8).build().toString())  // 保存时的名字
            .body(new FileSystemResource(receipts.path(r)));
}

按名字返回的窗口,用 ../outside.txt 连存放位置之外的文件都返回了。用同样的办法把配置文件或密钥文件返回出去,就是被称为路径遍历(path traversal)的漏洞(这里只在自己的练习环境中、用放好的无害文件确认)。

fixed 版按目录编号接收。由编号查目录,再用第 11 讲的负责人确认能否看这份收据所属的申请,不行就 404。回复里带有由内容决定的种类、attachment(不打开、直接保存)、用于日文名字的 filename*,以及不让浏览器猜测种类的 nosniff。nosniff 和不缓存的指示是 Spring Security 加上的。

七

接收的流程与偏差

文件在数据库之外,进不了数据库的事务。没写进目录的文件留下来会怎样?把名字设成 150 个字符(目录列最多 100 个字符),故意让写目录的 insert 失败。

# 用 150 个字符的名字发送(目录列最多 100 个字符)
  naive  → 500   receipt rows = 0   files on disk = 1
  fixed  → 500   receipt rows = 0   files on disk = 0
  ERROR: value too long for type character varying(100)

朴素版先放文件再写目录,所以 500 之后,磁盘上留下了 1 个不属于任何人的文件。fixed 版先写临时文件、检查、写目录,最后才改成自己的名字。写目录失败时临时文件被删掉,磁盘上什么也没留下(名字长度本该在入口就返回 400)。

# fixed 版负责人(ReceiptService.java 的 store)
@Transactional
public Receipt store(String expenseId, MultipartFile file, LoginUser u) throws IOException {
    claims.find(expenseId, u);  // 是否可看的申请(第 11 讲),不是则 404
    String type;
    try (InputStream in = file.getInputStream()) {
        type = FileType.detect(in.readNBytes(8));  // 用开头 8 字节判断种类
    }
    if (type == null) throw new ReceiptRejectedException(HttpStatus.UNSUPPORTED_MEDIA_TYPE, "JPEG・PNG・PDF だけ受け付けます");
    Path tmp = Files.createTempFile(dir, "up-", ".part");
    try {
        String sha = copyWithSha256(file, tmp);  // 一边写临时文件,一边取内容指纹
        Receipt same = receipts.findBySha(sha);
        if (same != null) throw new ReceiptRejectedException(HttpStatus.CONFLICT, "同じ領収書が " + same.expenseId() + " に付いています");
        String id = UUID.randomUUID().toString();  // 存放时的名字由自己来取
        Receipt r = new Receipt(id, expenseId, file.getOriginalFilename(), type, file.getSize(), sha, id, u.getUsername());
        receipts.insert(r);
        Path dest = dir.resolve(id);
        Files.move(tmp, dest, StandardCopyOption.ATOMIC_MOVE);
        deleteIfRolledBack(dest);  // 数据库回滚时,文件也删掉
        return r;
    } finally {
        Files.deleteIfExists(tmp);
    }
}

fixed 版的接收流程(实测)

fixed 版的接收(从上到下。实测例子) ① 大小在上限以内? 413 12 MB 的照片(放宽 swallow 时) ② 可看的申请?(第 11 讲) 404 别人的申请 ③ 开头 8 字节是 JPEG・PNG・PDF? 415 fake.jpg(内容是文字) ④ 写临时文件,取 sha256 ⑤ 没有同样指纹的收据? 409 同一张照片也附到 5022 ⑥ 把目录写入数据库 500 名字 150 字符,文件不留下 ⑦ 以自己取的名字移动 → 提交 200 receipts/32005963-… 在动文件之前,先把能检查的都检查完 数据库和磁盘不会成为一个事务。要写好出现偏差时的善后处理

为了应对移动之后提交失败的情况,还登记了“回滚时也删除文件”的善后处理。即便如此,遇到断电这类情况,偏差也不会是零。另外准备一个深夜去找出目录里没有的文件并清理的任务,是常见做法。

八

放在哪里

也可以把文件本身放进数据库(bytea)。用约 0.94 MB 的照片 20 张比较了一下。

# 把约 0.94 MB 的照片 20 张放到磁盘和数据库(bytea)
  disk      468 ms     18,753,622 字节
  bytea    1043 ms     19,529,728 字节  (pg_total_relation_size)
  src                  18,753,622 字节
存放位置适合的场合注意
服务器磁盘(这一讲)服务器只有 1 台,文件不算太多服务器变成 2 台就需要共享。备份要与数据库分开做
数据库(bytea)件数少,希望一定和数据库一起删除这一讲用了 2.2 倍的时间。数据库和备份会按照片的量变胖
对象存储(S3 兼容等)服务器有多台,量大,长期保存与数据库“偏差”的善后一样要做。显示时的权限检查也由应用负责
POINT

文件的大小、名字、内容都由发送方决定。名字自己取,内容看开头判断,用指纹发现重复使用。返回时也用编号和权限。数据库和磁盘不会成为一个事务,所以连偏差的善后也要写好。

九

在自己的电脑上验证

想试的事写法
查看文件开头的字节head -c 8 receipt.jpg | xxd
取文件的指纹sha256sum receipt.jpg
找出把收到的名字当作路径的地方grep -rn 'getOriginalFilename' src/main/java
找出目录里没有的文件把磁盘上的列表与 select stored_name from receipt 对照

在浏览器开发者工具里看一看下载回复的 Content-Type・Content-Disposition・X-Content-Type-Options,就能明白“如何显示”是由服务器决定的。

结 — 一页总结与下一讲

  1. 默认上限是每个文件 1 MB,手机照片会被拒。Tomcat 读掉丢弃最多 2 MB。
  2. 按收到的名字存放,会覆盖,还会写到存放位置之外。名字自己取。
  3. 种类看内容开头。Content-Type 和扩展名是自我申报。
  4. 用内容的指纹(SHA-256)发现同一收据的重复使用。
  5. 返回时用编号和权限。按名字返回,会连存放位置之外都返回。
  6. 不要先放文件。把偏差的善后写好。

新井以为 5 分钟,结果花了一整天。

古井干得好。对了,12 MB 的照片没收到回复时,你觉得服务器日志里留下了什么?下一讲是日志与监控。出问题时,要能事后读出发生了什么。

参考

记录是笔者于 2026 年 10 月 31 日在云端 Linux(Ubuntu 24.04,内核 6.18)上,用 Spring Boot 4.1.2(Spring Framework 7.0.10、Tomcat 11.0.27、Spring Security 7.1.2、MyBatis 3.5.19)、OpenJDK 25.0.4、PostgreSQL 16.15 取得的。申请服务器与数据库服务器是在一台机器里建的不同网络空间(network namespace),往返约 0.04 ms。写到存放位置之外和读取存放位置之外的文件,只在笔者的练习用应用和为此放置的无害文件上确认。照片是用随机数生成的练习用图片,收据是为练习画的。文中的数字和输出,都是从当时命令输出里读到的实际值。登场人物均为虚构。

古法后端入门 第12讲 — 文件 / 2026.10

← 返回文章列表