Files
full/docs/fts.md
2026-09-22 18:53:53 +08:00

178 lines
22 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# fts文件传输与上传存储代码审计报告
| 项 | 内容 |
| --- | --- |
| 审计对象 | `module/base/fts` |
| 服务域 | 基础与平台服务 |
| 审计日期 | 2026-09-22 |
| 代码规模 | 手写 Go 文件 19 个、1101 行(含 `test/fts_test.go` 167 行、`internal/routers/register_test.go` 34 行、`internal/errors`+`internal/response` 262 行死包);**无 `proto`/`pb`**(本模块不提供 gRPC |
| 入口 | `cmd/main`Gin 单进程Port 16290、聚合入口 `pkgs/all``pkgs/all/internal/service/fts.go`,注入 `Engine: srv.HTTP` |
| 对外协议 | 仅原生 REST前缀 `/rest/fts` |
| 结论摘要 | 上传路由确实挂了 `middleware.JwtAuth(true)``internal/routers/uploader.go:13`**鉴权本身已配置**;真正的高危点是**本地存储路径穿越**`bucket` 来自表单、仅做 `ToLower`,直接参与 `filepath.Join`,可写出 `UploadDir` 之外)、`NewSubdir` 对 JWT `identity` 直接切片导致的 panic、以及上传体大小校验晚于 `FormFile` 造成的资源耗尽;此外路由前缀 `/rest/fts` 与自带单测期望的 `/rest/fts/v1` 不一致(`go test ./...` 必失败),本地分支存在**同一响应写两遍**,且 262 行的 `internal/errors`+`internal/response` 包整体未被引用。 |
## 1. 服务定位与职责
接收客户端上传的文件,按 `provider` 落到本地磁盘或 MinIO计算 sha256`fts_record` 写一条记录并返回可访问的 `ResultUrl`。它是文件**传输与存储**服务:不负责下载鉴权、病毒扫描、图片处理,也不提供文件列表/删除/闪传(这些接口在 `internal/logic/fetch.go` 中只留了注释)。
## 2. 代码结构与入口
| 路径 | 职责 |
| --- | --- |
| `cmd/main/main.go` | 独立进程入口:`config.New("fts")``impl.NewImpl``gin.Default()``routers.Register``app.Run(:Port)` |
| `cmd/cli/main.go` | CLI实际是**生成 licence 文件**的构建辅助(硬编码 `/data/app/etc/licence.key`),与 fts 业务无关 |
| `internal/config/config.go` | 配置结构:`Base`/`Databases`/`Rpc`/`APM`/`Etcd`/`MinioOss`/`Local`/`FtsConfig`,含 `conf.NotNil` 校验 |
| `internal/impl/impl.go` | 初始化 Memory / Redis / DB / Etcd |
| `internal/routers/register.go` | 路由注册:匿名组 `GET /rest/fts/ping``GET /rest/fts/config` |
| `internal/routers/uploader.go` | 鉴权组 `POST /rest/fts/uploader``middleware.JwtAuth(true)` |
| `internal/routers/register_test.go` | 路由断言单测,期望 `/rest/fts/v1/*`(与实现不一致) |
| `internal/logic/handler.go` | `Handler`:上传主流程(鉴权解析、参数校验、大小/后缀校验、sha256、分发 provider、落库 |
| `internal/logic/provider.go` | `LocalUpload`(本地写盘)、`OssUpload`MinIO`NewSubdir` |
| `internal/logic/config.go` | `GET /config`:直接返回 `FtsConfig` |
| `internal/logic/ping.go` | `GET /ping` |
| `internal/logic/fetch.go` | **整文件被注释**,历史 `List` 逻辑 |
| `internal/models/fts_record.go` | `fts_record` 表模型 + 迁移注册48-98 行为注释掉的旧实现 |
| `internal/models/query.go` | `InitData()` 空函数6 行) |
| `internal/errors/errors.go` | 118 行业务错误码定义,**仅被下方 response 包引用** |
| `internal/response/response.go` | 144 行统一响应封装,**全模块无调用点** |
| `service/{expose,dependencies}.go` | 聚合宿主注入接口(`ExposeOptions.Engine/Config` |
| `etc/{fts_dev,fts_prod,fts_test}.yaml` | 三份内容一致;`MinioOss`/`Local`/`FtsConfig` 三段配置 |
| `test/fts_test.go` | `//go:build integration` 集成用例URL 为历史路径 |
| `test/oss_provider_req.http` | 手工上传用例(`/fts/v1/uploader` |
## 3. 接口清单
| 方法 | 路径 | 功能 | 鉴权 | 实现位置 |
| --- | --- | --- | --- | --- |
| GET | `/rest/fts/ping` | 健康探测,返回 `{"message":"Pong"}` | 无(匿名组,`register.go:20-22` | `internal/logic/ping.go:9` |
| GET | `/rest/fts/config` | 返回 `FtsConfig``InputKey`/`MaxSize`/`Allows` | 无(匿名组,`register.go:23` | `internal/logic/config.go:9` |
| POST | `/rest/fts/uploader` | 上传文件到 local / minio | **JWT**`middleware.JwtAuth(true)``routers/uploader.go:13` | `internal/logic/handler.go:24` |
| HEAD | `/` | 健康检查(`infra.Health` | 无 | `cmd/main/main.go:36` |
**声明但未实现/占位的接口**
- `internal/logic/fetch.go:1-29` 整个文件被注释(且引用不存在的 `internal/svc``internal/types``exception`)→ 文件列表/详情等接口**未实现**。
- `test/fts_test.go` 仍在调用的历史路径**全部没有注册**`/fts/Transfer/Uploader``fts_test.go:26`)、`/fts/Get/Config``:87`)、`/fts/Transfer/Prepare``:105`)、`/fts/Transfer/FileList``:124`)、`/fts/Transfer/Details``:151``test/oss_provider_req.http:1` 用的是 `/fts/v1/uploader`。这些接口属历史残留,当前进程只注册上表 4 条路由。
> 路由前缀存在实现与测试的分歧,见 6.2。
## 4. 数据模型与表
### 表 `fts_record``internal/models/fts_record.go`
| 字段 | 类型 | 键/约束 | 说明 |
| --- | --- | --- | --- |
| `id` | uint | PK | 自增主键 |
| `identity` | varchar(36) | uniqueIndex | 记录标识,上传时由 `utils.UUID()`UUID v7生成 |
| `created_at`/`updated_at`/`deleted_at` | TIMESTAMP | - | 带软删 |
| `owner_id` | uint | Index | 来自 JWT `claims.ID` |
| `owner_identity` | varchar(36) | Index | 来自 JWT `claims.Identity`,用于生成子目录 |
| `hash` | varchar(255) | not null | 文件 sha256仅本地/OSS 上传前计算) |
| `name` | varchar(255) | not null | **用户上传的原始文件名**(未做字符过滤) |
| `ext` | varchar(255) | not null | 文件后缀(含点,如 `.png` |
| `size` | uint64 | default 0 | 文件字节数 |
| `handle_cmd` / `handle_args` | varchar(255) | default '' | 预留"上传后处理命令/参数"**无任何写入点** |
| `save_name` / `local_path` / `oss_path` / `result_url` | varchar(500) | default '' | 落盘文件名 / 本地绝对路径 / OSS 对象键 / 可访问 URL |
| `status` | int8 | default 0, index | 注释定义 -1…5 七种状态,**无状态流转逻辑** |
> `internal/models/query.go:4-6` 的 `InitData()` 为空函数且无调用点。
## 5. 核心流程
```mermaid
flowchart TD
A["POST /rest/fts/uploader"] --> B["middleware.JwtAuth(true) 校验签名与过期"]
B --> C["middleware.ParseAuth 取 claims"]
C --> D["读取表单 provider / bucket 并 ToLower"]
D --> E["c.FormFile(FtsConfig.InputKey) 解析上传体"]
E --> F["校验 fh.Size <= FtsConfig.MaxSize"]
F --> G["校验 filepath.Ext 在 FtsConfig.Allows 内"]
G --> H["sha256 计算 fileHash"]
H --> I["switch provider"]
I --> J["local: NewSubdir + Join(UploadDir, bucket, subdir) 写盘"]
I --> K["minio: PutObject(bucket, subdir/ULID+ext)"]
J --> L["DBService.Create(fts_record)"]
K --> L
L --> M["infra.Response.Success(record)"]
```
## 6. 审计发现
### 6.1 安全
| 级别 | 位置 | 问题 |
| --- | --- | --- |
| **高** | `internal/logic/provider.go:25-33`(关键:`:26` | **本地存储路径穿越**`saveDir := filepath.Join(config.Spec.Local.UploadDir, bucket, subdirpath)`,其中 `bucket` 来自请求表单 `c.PostForm("bucket")``handler.go:27`**只做了 `strings.ToLower`,没有字符集/白名单/长度校验**,也没有校验拼接结果仍位于 `UploadDir` 之下。`filepath.Join` 会对 `..` 做 Clean因此 `bucket=../../../../etc/cron.d` 之类即可把文件写到 `UploadDir` 之外(文件名是 `utils.ULID()+ext`,扩展名受 `Allows` 约束),属任意路径写入。`record.ResultUrl``provider.go:71`)同样把未校验的 `bucket` 拼进返回给客户端的 URL。 |
| 中 | `internal/logic/provider.go:88-99` | MinIO 分支的 `bucket` 同样未校验(`handler.go:27``ToLower`),直接传入 `minioClient.PutObject(ctx, bucket, savePath, ...)``provider.go:99`)→ 可用当前凭据写入**任意桶**(跨业务桶污染/覆盖),且 `record.OssPath`/`ResultUrl``provider.go:106-108`)记录的攻击者可控桶名会污染数据。 |
| 中 | `internal/logic/provider.go:113-116` | `NewSubdir(identity)` 直接 `identity[0:2]` 切片。入参是 JWT 里的 `claims.Identity``handler.go:80`),而 `middleware.JwtAuth(true)` 只校验签名与过期时间、**不校验 `identity` 非空或长度**`D:\work\bsm-sdk\core\middleware\jwt.go:20-63`)→ 当 token 的 `identity` 为空串或长度 <2 时,`identity[0:2]` 触发 **slice bounds out of range panic**`LocalUpload``OssUpload` 都会走到),请求 500 且 gin 仅记录堆栈。 |
| 中 | `internal/logic/handler.go:55-60` | 文件大小限制**滞后**`c.FormFile``:49`)已经把整个 multipart 体解析进内存/临时文件,`:57` 才比对 `file.Size``LocalUpload` 还会再次 `fh.Open()` 完整读一遍(`provider.go:48-56`)。配合 `cmd/main/main.go:23``gin.Default()` 未设置 `MaxMultipartMemory`、也没有 `http.MaxBytesReader`,攻击者可在校验生效前先打满磁盘/内存(配置上限 5GiB`fts_dev.yaml:33``MaxSize` 形同虚设。 |
| 中 | `internal/logic/handler.go:63-68` | 文件类型只按 `filepath.Ext` 后缀白名单判断,**不做 MIME/魔数校验**`Allows``.txt``fts_dev.yaml:36-37`)意味着可上传任意内容的文本文件,`Local.Site``fts_dev.yaml:28`)指向的目录若被前置静态服务直接暴露/解析,存在内容投毒与钓鱼托管风险。同时 `record.Name = fh.Filename``handler.go:81`)原样保存用户文件名(含控制字符/超长),无长度与字符过滤。 |
| 低 | `internal/logic/config.go:10` | `GET /rest/fts/config` 匿名返回完整 `FtsConfig`(上传字段名、大小上限、全部允许后缀),并在聚合配置中被显式放行(`pkgs/all/etc/default_dev.yaml:37`)→ 上传策略信息匿名可枚举,便于攻击者按白名单精准构造。 |
| 低 | `D:\work\bsm-sdk\core\infra\response.go:39-52` | `infra.Response.Error``err.Error()` 原样回给客户端,未做错误分级/脱敏fts 的错误多为 MinIO、SQL、文件系统错误`provider.go:83-85,101-104`)→ 内部实现细节外泄。且该方法恒以 HTTP 200 返回(`response.go:52`),状态码层面无法区分成败。 |
| 低 | `internal/logic/handler.go:43-47` | `provider` 的白名单校验被整段注释掉(原意图是只允许 `local`)。当前由 `switch provider``:88-96`+ `default` 分支兜底,功能上等价,属**冗余注释**;但注释与代码并存说明该校验曾被有意放开,需确认取舍。 |
### 6.2 正确性与逻辑缺陷
| 级别 | 位置 | 问题 |
| --- | --- | --- |
| **高** | `internal/routers/register.go:12``internal/routers/register_test.go:15-19` | **路由前缀与自带单测不一致**`v1_key := path.Join("/rest", srvKey)``srvKey = "fts"``cmd/main/main.go:15`)→ 实际前缀为 `/rest/fts``register.go:12-14``uploader.go:11-14`);而 `register_test.go:15-19` 断言的是 `GET /rest/fts/v1/ping``GET /rest/fts/v1/config``POST /rest/fts/v1/uploader`。该测试**没有 build tag**`go test ./...` 会以 `route not registered` 失败。旁证倾向于"前缀丢了 `v1` 段"`test/oss_provider_req.http:1``/fts/v1/uploader`,而聚合配置只放行了 `/rest/fts/ping``/rest/fts/config``pkgs/all/etc/default_dev.yaml:36-37`,与实现一致)。两侧必有一处需要修正,现状是**测试红、用例错**。 |
| **高** | `internal/logic/handler.go:88-104` | 本地分支先落盘、后落库:`:90``LocalUpload` 先写文件,`:101``DBService.Create(&record)`。DB 失败时文件已生成且**没有任何清理**(无 `os.Remove`、无事务)→ 孤儿文件持续堆积;反向的 `OssUpload` 也一样对象已上传DB 失败后对象残留)。 |
| 中 | `internal/logic/provider.go:31,43,51,58,65``internal/logic/handler.go:97-100` | **同一响应被写两次**`LocalUpload` 内部每个失败分支都调用了 `infra.Response.Error(ctx, err)`5 处),随后 `handler.go:97-100` 在拿到 error 后又调用一次 → 一个请求写出两份 JSON 响应体gin 会打印 `headers were already written` 警告,客户端可能读到被截断/拼接的响应。 |
| 中 | `D:\work\bsm-sdk\core\infra\response.go:12,25-34,39-53` | 所有 fts handler 通过**包级共享变量** `var Response Reply` 输出响应,`Success`/`Error` 直接改写该全局结构体的 `Code/Message/Details/Timeseq``ctx.JSON`。并发上传时两个请求会相互覆盖字段(数据竞争,`go test -race` 可复现),响应内容可能张冠李戴。缺陷在 SDK但 fts 是本仓库唯一使用方,需在 handler 侧规避或修 SDK。 |
| 低 | `internal/logic/provider.go:71,108` | `ResultUrl` 由字符串拼接生成(`Site + "/" + bucket + "/" + subdir + "/" + fileName`),未做 URL 转义,也未校验 `bucket` 是否含 `/``..`;配合 6.1 的桶名未校验问题,可产出畸形或可跳转到其它路径的下载地址。 |
| 低 | `internal/logic/provider.go:99` | MinIO 上传固定 `ContentType: "application/octet-stream"`,与按图片/视频内联展示的常见需求不符(浏览器会触发下载而不是渲染)。 |
| 低 | `internal/logic/provider.go:102,107,109` | 残留调试输出 `fmt.Println("err = ", err)``fmt.Println("savePath = ", savePath)``fmt.Println("record.ResultUrl = ", record.ResultUrl)`,会写入 stdout 被 supervisor 收集(`etc/supervisor.bsm-apps-fts.conf:8`)。 |
| 低 | `internal/logic/handler.go:85``internal/models/fts_record.go:36` | `record.Status = 0` 写死;`fts_record.status` 注释定义了 -1…5 共 7 种状态,`handle_cmd`/`handle_args` 也无写入点 → 状态机与"上传后处理"能力**未实现**,字段仅为预留。 |
| 低 | `internal/logic/provider.go:36,89` | 落盘/对象键用 `utils.ULID() + record.Ext`,扩展名直接取自用户文件名。风险有限(`filepath.Ext` 只返回最后一个 `.` 之后部分),但未做字符过滤。 |
### 6.3 未完成实现
- `internal/logic/fetch.go:1-29`:整文件被注释,文件列表等接口未实现(引用不存在的 `internal/svc``internal/types``exception` 包)。
- `internal/errors/errors.go`118 行)+ `internal/response/response.go`144 行)**整体未被引用**:全模块对 `internal/errors` 的唯一引用来自 `internal/response/response.go:11`而后者本身无任何调用点handler/provider 全部走 SDK 的 `infra.Response`。值得注意的是被弃用的那份实现**反而更正确**——`internal/response/response.go:44-85` 会按错误码返回真实 HTTP 状态404/401/403/400/429/500而 SDK 版本恒返回 200。
- `internal/models/query.go:4-6``InitData()` 空函数、无调用点。
- `internal/models/fts_record.go:48-98`:被注释的 `GetFileList`/`GetFileDetails`/`TableSql`(引用不存在的 `DBService``types.Record`)→ 死代码。
- `cmd/cli/main.go:12-18`:与 fts 无关,是生成 licence 文件的构建脚本,硬编码写入 `/data/app/etc/licence.key`
- `test/fts_test.go``//go:build integration`):调用 `/fts/Transfer/*``/fts/Get/Config` 等**已不存在的接口**,且端口 `12214` 与当前 `Port: 16290``fts_dev.yaml:3`)不一致 → 已过期。
- 未发现显式 TODO 注释。
### 6.4 健壮性与可维护性
| 级别 | 位置 | 问题 |
| --- | --- | --- |
| 中 | `internal/config/config.go:40` | `conf.NotNil(Spec.Service, Spec.Cache)` 未校验 `Databases`/`MinioOss`/`Local`/`FtsConfig`。其中 `Databases` 缺失时 `with.Databases` 直接 `panic("No Database Source Found !")``D:\work\bsm-sdk\core\with\databases.go:13-15``Local`/`MinioOss`/`FtsConfig` 缺失时则在 `provider.go:26``config.Spec.Local.UploadDir`)、`provider.go:79-81``config.Spec.MinioOss.*`)、`handler.go:57,128``config.Spec.FtsConfig.*`)空指针 panic——**都是运行期崩溃,且报错不指向配置项**。 |
| 中 | `internal/routers/register.go:20-25``uploader.go:11-14` | 匿名组与鉴权组用**同一个前缀**在两个文件分别 `engine.Group(v1_key)`,鉴权边界被拆散维护。本次 `/rest/fts` 与测试期望 `/rest/fts/v1` 的分歧正是这种写法的直接后果。 |
| 低 | `internal/impl/impl.go:13-24` | `MemorySerice`(拼写错误,应为 `MemoryService`)与 `RedisService` 初始化后本模块无任何使用点 → 死代码。 |
| 低 | `internal/logic/handler.go:32-37,39-42` | 错误码用 `errcode.NewError(400, ...)`/`errcode.NewError(501, ...)``handler.go:40,66`),但 `infra.Response.Error` 恒以 HTTP 200 返回 → 网关/监控无法按 HTTP 状态识别失败,客户端的 4xx 重试逻辑失效。 |
| 低 | `README.md:56-67,154-156,181-189` | README 声明的 `GET /health``GET /health/simple``GET /version``POST /fts/upload``GET /fts/download` 全部**未注册**;目录结构里列出的 `internal/health/``scripts/``build/``Dockerfile``docker-compose.yml``Makefile` 也都不存在 → 文档与实现严重脱节README.md:210 还写着 MIT 许可证,与仓库实际内部许可不符)。 |
| 低 | `internal/routers/register_test.go`34 行) | 唯一无 build tag 的单测,且当前**必然失败**(见 6.2),说明 CI 未执行 `go test ./...`(或已长期忽略)。 |
| 低 | `test/fts_test.go:52` | 表单字段名硬编码为 `"file"`,与 `FtsConfig.InputKey``fts_dev.yaml:32`)耦合,配置改名后用例静默失效。 |
## 7. 风险汇总
| 编号 | 级别 | 问题 | 影响面 |
| --- | --- | --- | --- |
| W1 | 高 | `bucket` 未校验 → 本地存储路径穿越(任意路径写入) | 服务器文件系统完整性 |
| W2 | 高 | 路由前缀 `/rest/fts` 与单测期望 `/rest/fts/v1` 矛盾,测试必失败 | 交付质量、路由契约不确定 |
| W3 | 高 | 先落盘后落库、失败不清理 | 磁盘/对象存储泄漏 |
| W4 | 中 | `NewSubdir``claims.Identity` 切片 → 空 identity 触发 panic | 接口可用性500 |
| W5 | 中 | 大小限制晚于 `FormFile`,且无请求体上限 | 磁盘/内存耗尽DoS |
| W6 | 中 | MinIO `bucket` 未校验 → 可写任意桶 | 跨业务数据污染 |
| W7 | 中 | `LocalUpload` 与 handler 重复写响应 + SDK 全局 `infra.Response` 并发覆盖 | 响应错乱、数据竞争 |
| W8 | 中 | 仅后缀白名单、无 MIME 校验,`Local.Site` 目录或可托管任意文本内容 | 内容投毒 |
| W9 | 中 | 配置项未校验,缺失即运行期 panic | 部署健壮性 |
| W10 | 低 | 262 行死包(`internal/errors`+`internal/response`)、`logic/fetch.go`、注释块、`fmt.Println`、README 失真、过期集成用例 | 可维护性 |
## 8. 修复建议(务实项)
1. **封死路径穿越(最高优先)**`handler.go:26-27``bucket` 加白名单(如 `^[a-zA-Z0-9_-]{1,64}$`),或改为服务端配置的枚举映射(不接收任意字符串);在 `provider.go:26` 拼出 `saveDir` 后追加一次校验——`filepath.Clean(saveDir)` 必须以 `filepath.Clean(config.Spec.Local.UploadDir)` 为前缀否则直接报错返回。MinIO 侧同样把 `bucket` 限制在允许列表内(`provider.go:92-99`)。
2. **修掉 `NewSubdir` 的 panic**`handler.go:32-37` 解析出 `claims` 后补一条 `claims.Identity == ""` 的校验并返回 401`provider.go:113-116``len(identity) < 2` 用固定占位(如 `"00"`)兜底。
3. **统一路由前缀**:确定 `/rest/fts``/rest/fts/v1` 哪个是目标值,然后**同时**修正 `register.go:12`(或 `register_test.go:15-19`)、`test/oss_provider_req.http:1``pkgs/all/etc/default_dev.yaml:36-37` 的匿名路径,避免三处各写一套。
4. **上传体前置限流**`cmd/main/main.go:23` 之后设置 `app.MaxMultipartMemory``handler.go:49` 之前用 `http.MaxBytesReader(c.Writer, c.Request.Body, config.Spec.FtsConfig.MaxSize)` 包裹请求体,再调用 `FormFile`
5. **响应只写一次**:删除 `provider.go:31,43,51,58,65` 内部的 `infra.Response.Error` 调用,只 `return err`,由 `handler.go:97-100` 统一输出;并把 `infra.Response` 换成 handler 内的局部响应结构(或在 SDK 侧把 `core/infra/response.go:12` 的全局变量改为按请求构造),消除并发覆盖。
6. **落盘与落库顺序**:把 `handler.go:90``:101` 调换(先建记录再用其 `identity` 落盘),或保留现顺序但在 `Create` 失败分支 `os.Remove(record.LocalPath)` / `minioClient.RemoveObject(...)` 回滚;顺手去掉 `provider.go:63` 多余的 `os.Chmod``OpenFile` 已用 0644
7. **文件类型加固**:在 `handler.go:63-68` 之后用 `http.DetectContentType` 读前 512 字节做魔数校验,与后缀白名单取交集;同时确认 `Local.Site` 指向的目录不参与脚本解析。
8. **配置校验**`internal/config/config.go:40``conf.NotNil` 补上 `Databases`,并在 `New` 中对 `MinioOss`/`Local`/`FtsConfig` 做非空判断(缺失时直接 Fatal 并打印配置项名,优于运行期 panic
9. **清理死代码与文档**:删除 `internal/errors``internal/response` 两个死包(或只把 `internal/response` 的 HTTP 状态码语义用于 `handler.go` 的输出)、`internal/logic/fetch.go``internal/models/fts_record.go:48-98` 的注释块、`internal/impl/impl.go` 未使用的 Redis/Memory 初始化、`internal/models/query.go` 的空 `InitData``provider.go:102-109``fmt.Println`;修正 `README.md:56-67,154-156,181-210` 的接口清单、目录结构与许可证描述;把 `test/fts_test.go` 的 URL/端口更新为 `/rest/fts/uploader` 与 16290或直接删除过期用例
10. **补测试**:为"`bucket=../../etc` 穿越被拒"、"JWT 无 identity 时上传被拒"、"超过 `MaxSize` 的请求体被拒"、"DB 失败不留下孤儿文件"各写一条用例;并在 CI 中真正执行 `go test ./...`(当前 `register_test.go` 是红的)。
> 本报告只列出与现有实现直接相关的修复项,不引入新的分层、抽象封装或 DTO/VO 改造。