adult and update go.mod

This commit is contained in:
2026-09-22 18:53:53 +08:00
parent db931a7c40
commit 9f86366638
74 changed files with 5796 additions and 1018 deletions

177
docs/fts.md Normal file
View File

@@ -0,0 +1,177 @@
# 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 改造。