Files
yunzerwebsiteallinone/go/docs/文件存储分层改造计划.md

404 lines
24 KiB
Markdown
Raw Permalink 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.
# 文件存储分层改造计划
> 创建日期:2026-09-09
> 当前状态:**进行中**(改造中断时,从「进度总表」里第一个未勾选的条目继续)
> 适用范围:`go/`(服务端)+ `backend/`(租户后台前端)+ `platform/`(平台端前端)
---
## 0. 进度总表
> 每完成一项,把 `- [ ]` 改成 `- [x]` 并更新日期。中断后从第一个 `- [ ]` 继续。
| # | 阶段 | 内容 | 状态 |
|---|---|---|---|
| S1 | 数据库 | `yz_system_files` 新增 `source`/`scope`/`storage`/`object_key` + 索引 + `EnsureSystemFileStorageColumns()` | ✅ 2026-09-09 |
| S2 | services | `storage_service.go`:新增 `UploadContext`/`BuildObjectKey()`/`Stage()`/`Commit()`/`Move()` | ✅ 2026-09-09 |
| S3 | services | 新增 `services/file_service.go`:MD5 精确查重、入库、路径解析、文件类型推断 | ✅ 2026-09-09 |
| S4 | controllers | `backend_file.go`:上传走新目录规则 + 先算 MD5 后落盘 + 列表/删除 scope 隔离 | ✅ 2026-09-09 |
| S5 | controllers | `platform_file.go`:固定 `platform/日期`,不做租户/用户分层 | ✅ 2026-09-09 |
| S6 | controllers | `qiniu_upload.go`:token 的 `keyPrefix` 带 `platform/` 前缀,入库写 `source` | ✅ 2026-09-09 |
| S7 | 前端 | backend 端上传点接入新参数(含修复 5 处 404 的 `/admin/uploadfiles`) | ✅ 2026-09-09 |
| S8 | 前端 | platform 端上传点接入新参数(含修复 `/platform/upload`、资质上传) | ✅ 2026-09-09 |
| S9 | 迁移 | `cmd/migrate_storage`(默认 dry-run,`--apply` 执行,七牛走 Move)— **脚本已就绪,尚未执行** | ✅ 2026-09-09(待执行) |
| S10 | 验收 | 上传/去重/隔离/迁移全链路验证 | ☐ |
---
## 1. 目标与非目标
### 目标
1. 两个端的文件物理隔离:`backend/` 与 `platform/` 分目录。
2. backend 端按租户隔离:`backend/{tid}/`。
3. 租户内区分**租户共享文件**与**用户个人文件**:个人文件落到 `backend/{tid}/{tuid}/`。
4. 常规按 `年/月/日` 分目录。
5. MD5 去重**精确到归属**:
- 租户共享文件 → 同 `tid` 内 MD5 相同才算重复;
- 用户个人文件 → 同 `tid` + 同 `tuid` 内 MD5 相同才算重复;
- 「租户已有 a 文件」与「用户 c 上传同样文件」**不冲突**,可并存。
6. 去重改为**先算 MD5 再落盘**,重复文件不产生物理垃圾。
### 非目标(本次不做)
- 不做文件访问鉴权(`/uploads` 仍为公开静态目录),仅做目录隔离。
- 不迁移 `themes/` 下的官网模板(`POST /platform/template/upload` 保持原样,不进文件表)。
- 不改组织架构 CSV 导入(`importOrganization` 是临时解析文件,不入库 `yz_system_files`)。
---
## 2. 现状分析
### 2.1 服务端链路
| 入口 | 代码位置 | 说明 |
|---|---|---|
| `POST /backend/uploadfile`、`/backend/uploadfiles` | `controllers/backend_file.go:463` | backend 端,服务端中转 |
| `POST /platform/uploadfile`、`/platform/uploadfiles` | `controllers/platform_file.go:463` | platform 端,服务端中转 |
| `GET /platform/qiniu/token` + `POST /platform/qiniu/save` | `controllers/qiniu_upload.go` | 七牛前端直传,**仅 platform 端有路由** |
| `POST /platform/template/upload` | `controllers/platform_template.go` | 模板 zip → `themes/`,不进文件表 |
- 存储实现:`services/storage_service.go`,`LocalStorage`(`BaseDir=uploads`、`BaseURL=/`)与 `QiniuStorage`。
- 当前路径规则:`2006/01/02/{UnixNano}{ext}`,**无任何端/租户/用户维度**。
- 静态映射:`beego.SetStaticPath("/uploads","uploads")`(`main.go:23`)。
- `backend_file.go` 与 `platform_file.go` 是**复制粘贴的两份**(约 900 行几乎全同),改规则必须同步两处。
### 2.2 数据库
`yz_system_files`(`models/system_file.go`)现有字段:`tid / uid / tuid / name / type / cate / size / src / uploader / md5 / create_time / update_time / delete_time`。
语义现状(重要):
- `uid` = 上传者 ID(controller 里直接写 `claims.UserID`)。
- `tuid` = 表单可选参数,**前端从未传过,库中全为 NULL**。
- 没有「来源端」「共享/个人」「存储 key」字段 → 无法做目录归属与迁移。
ID 语义(已确认):
- **backend 端登录**:`services/platform_auth.go:122` 用 `tenantUser.Uid` 签发 JWT → `claims.UserID` 就是**租户用户 uid(8 位,如 67091493)**,`claims.TenantId` = 租户 ID(如 234573)。
- **platform 端登录**:`services/platform_auth.go:79` 用 `AdminUser.ID` 签发 → `claims.UserID` 是平台管理员 ID,`claims.TenantId = 0`。
**结论:backend 端 JWT 里天然就带着「租户 ID + 用户 uid」,个人文件目录名可直接用 `claims.UserID`;前端传 `tuid` 只在"代别人上传"场景才需要。**
### 2.3 前端上传点全量清单
#### backend 端(`backend/src`)—— 21 处代码点,19 处真实发请求
| # | 文件(相对 `backend/src`) | 功能 | 接口 | 现状参数 |
|---|---|---|---|---|
| B1 | `views/system/fileManager/components/uploadFile.vue` | 系统文件库上传 | `/backend/uploadfile` | `cate`(重复 append 两次) |
| B2 | `views/components/UmoEditor.vue` | 富文本图片/视频 | `/backend/uploadfile` | 无 |
| B3 | `views/apps/cms/article/index/components/edit.vue` | 文章封面 | `/backend/uploadfile` | `cate=article` |
| B4 | `views/apps/cms/article/type/components/edit.vue` | 文章分类默认图 | `/backend/uploadfile` | 无 |
| B5 | `views/moduleshop/center/index.vue` | 模块中心缩略图 | `/backend/uploadfile` | `cate=module` |
| B6 | `views/basicSettings/siteSettings/components/normalSettings.vue` | 站点 Logo | `/backend/uploadfile` | `cate=site` |
| B7 | 同上 | 站点白色 Logo | `/backend/uploadfile` | `cate=site` |
| B8 | 同上 | 站点 ico 图标 | `/backend/uploadfile` | `cate=site` |
| B9 | `views/apps/oa/schedule/components/detail.vue` | 日程相关图片/粘贴截图 | `/backend/uploadfile` | 无 |
| B10 | `views/apps/oa/reimburse/components/detail.vue` | 报销发票/票据 | `/backend/uploadfile` | `cate=reimbursement-invoice` |
| B11 | `views/apps/oa/employeefile/components/recordEditDialog.vue` | 员工档案记录附件 | `/backend/uploadfile` | 无 |
| B12 | `views/apps/oa/employeefile/components/fileDetailDrawer.vue` | 员工证照-学历照片 | `/backend/uploadfile` | 无 |
| B13 | `views/apps/cms/banner/components/edit.vue` | Banner 图片 | **`{BASE}/admin/uploadfiles`** ⚠️ | 无 |
| B14 | `views/apps/cms/solution/index/components/edit.vue` | 方案图片 | **`{BASE}/admin/uploadfiles`** ⚠️ | 无 |
| B15 | `views/apps/cms/product/index/components/edit.vue` | 产品图片 | **`{BASE}/admin/uploadfiles`** ⚠️ | 无 |
| B16 | `views/apps/cms/frontMenu/components/edit.vue` | 前端菜单图片 | **`{BASE}/admin/uploadfiles`** ⚠️ | 无 |
| B17 | `views/apps/cms/friendlink/components/edit.vue` | 友情链接 Logo | **`{BASE}/admin/uploadfiles`** ⚠️ | 无 |
| B18 | `views/basicSettings/tenants/components/qualification.vue` | 租户资质文件 | `/api/platform/common/upload` ⚠️ | 无,提交为 mock |
| B19 | `views/apps/organization/components/ImportExportDialog.vue` | 组织架构 CSV 导入 | `/backend/.../importOrganization` | CSV,不入库文件表 |
| B20 | `views/moduleshop/publish/index.vue` | 模块 zip | 无(TODO 死代码) | — |
| B21 | `views/moduleshop/components/createModules.vue` | 模块 zip | 无(TODO 死代码) | — |
#### platform 端(`platform/src`)—— 14 处代码点,9 处真实发请求
| # | 文件(相对 `platform/src`) | 功能 | 接口 | 通道 |
|---|---|---|---|---|
| P1 | `views/system/fileManager/components/uploadFile.vue` | 平台文件管理 | `/platform/uploadfile` | 服务端中转 |
| P2 | `views/components/UmoEditor.vue` | 富文本(笔记本) | `/platform/uploadfile` | 服务端中转 |
| P3 | `views/basicSettings/siteSettings/components/normalSettings.vue` | 站点 Logo | `/platform/uploadfile` | 服务端中转 |
| P4 | 同上 | 站点白色 Logo | `/platform/uploadfile` | 服务端中转 |
| P5 | 同上 | 站点 ico 图标 | `/platform/uploadfile` | 服务端中转 |
| P6 | `views/moduleshop/center/index.vue` | 模块中心缩略图 | `/platform/uploadfile` | 服务端中转 |
| P7 | `views/platform/softwareupgrade/components/edit.vue` | 软件升级包(4 平台) | `smartUpload()` | **七牛直传 / 本地自适应** |
| P8 | `views/template/index.vue` | 官网模板 zip | `/platform/template/upload` | 服务端中转(不入库) |
| P9 | `views/apps/babyhealth/users/components/userEdit.vue` | 用户头像 | **`{BASE}/platform/upload`** ⚠️ | 服务端中转 |
| P10 | `views/apps/babyhealth/users/components/userEdit.vue` | 头像(裁剪后) | `uploadAvatar()` | ❌ 死代码(函数未定义) |
| P11 | `views/basicSettings/tenants/components/qualification.vue` | 租户资质图片 | `/api/platform/common/upload` ⚠️ | 悬空(无代理/无 token) |
| P12 | `views/apps/babyhealth/babys/components/edit.vue` | 宝贝头像 | `uploadAvatar()` | ❌ 死代码 |
| P13 | `views/moduleshop/components/createModules.vue` | 创建模块包 | 无(调用被注释) | ❌ 不发请求 |
| P14 | `views/moduleshop/publish/index.vue` | 发布模块包 | 无(TODO) | ❌ 不发请求 |
### 2.4 已发现的问题(本次一并处理)
| 级别 | 问题 | 说明 |
|---|---|---|
| 🔴 | **`/admin/uploadfiles` 路由不存在** | `go/routers` 全量搜索无 `/admin` 前缀路由;B13~B17 五处 CMS 上传实际会 404(`VITE_API_BASE=https://api.yunzer.cn`)。需收敛到 `/backend/uploadfile`。 |
| 🔴 | **`/platform/upload` 路由不存在** | P9 头像上传会 404。需收敛到 `/platform/uploadfile`。 |
| 🟡 | **`/api/platform/common/upload` 路由不存在** | B18 / P11 悬空,且提交逻辑是 mock。 |
| 🔴 | **去重先落盘后判断** | 命中重复时物理文件已写入磁盘/七牛且未删除 → 产生孤儿垃圾。 |
| 🔴 | **去重维度只有 `tid`** | 不区分端、不区分用户,与"精确到用户"要求不符。 |
| 🟡 | **七牛直传的 `md5` 存的是 etag** | `qiniu_upload.go:168` 把 `hash`(etag)当 md5 存,与本地真 MD5 不同源,跨存储去重会失准。 |
| 🟡 | **物理删除路径脆弱** | `removePhysicalBySrc` 直接 `os.Remove(TrimPrefix(src,"/"))`,依赖进程 CWD。 |
| 🟡 | **删除语义不一致** | 单条 `DeleteFile` 只软删,`BatchDeleteFiles` 却真删物理文件。 |
| 🟢 | `cate` 被重复 append | `api/file.js` 的 `options.cate` 与调用处手动 append 各一次 → multipart 里两个 `cate`。 |
---
## 3. 目标目录规范
```
{存储根}/ 本地: uploads/ 七牛: bucket 根
├── backend/
│ └── {tid}/ 例: 234573
│ ├── 2026/09/09/{ts}_{rand}.{ext} ← scope=tenant(租户共享)
│ └── {tuid}/ 例: 67091493
│ └── 2026/09/09/{ts}_{rand}.{ext} ← scope=user(用户个人)
└── platform/
└── 2026/09/09/{ts}_{rand}.{ext} ← 平台端,不分层
```
- `{ts}_{rand}`:`UnixNano` + 6 位随机(避免同纳秒并发冲突)。
- 七牛用同样的 key 字符串(`/` 即逻辑目录)。
- `tid=0`(platform 端或缺失租户上下文)时 backend 端路径退化为 `backend/0/...`,并在日志告警。
### 访问 URL
- 本地:`/uploads/backend/234573/2026/09/09/xxx.png`
- 七牛:`{domain}/backend/234573/2026/09/09/xxx.png`
---
## 4. 数据库改造(S1)
### 4.1 新增字段
```sql
ALTER TABLE `yz_system_files`
ADD COLUMN `source` varchar(16) NOT NULL DEFAULT 'backend' COMMENT '来源端: backend-租户后台 platform-平台端',
ADD COLUMN `scope` varchar(16) NOT NULL DEFAULT 'tenant' COMMENT '归属: tenant-租户共享 user-用户个人',
ADD COLUMN `storage` varchar(16) NOT NULL DEFAULT '' COMMENT '存储类型: local/qiniu(冗余,便于迁移与排查)',
ADD COLUMN `object_key` varchar(512) NOT NULL DEFAULT '' COMMENT '存储相对路径(不含域名),用于迁移与精确删除';
ALTER TABLE `yz_system_files`
ADD KEY `idx_file_dedup` (`source`, `scope`, `tid`, `tuid`, `md5`),
ADD KEY `idx_file_owner` (`source`, `tid`, `scope`, `tuid`, `delete_time`);
```
> 不建议加 UNIQUE:`delete_time` 软删 + 并发上传下唯一索引会直接报错,改用普通索引 + 代码层查重。
### 4.2 字段语义(改造后明确)
| 字段 | 语义 |
|---|---|
| `tid` | 租户 ID |
| `uid` | **上传者** ID(保持不变) |
| `tuid` | **归属用户** ID(个人文件必填,共享文件为 NULL) |
| `source` | `backend` / `platform` |
| `scope` | `tenant` / `user` |
| `object_key` | 存储相对路径,如 `backend/234573/67091493/2026/09/09/xxx.png` |
### 4.3 落地方式
在 `models/system_file.go` 增加 `EnsureSystemFileStorageColumns()`(参照 `EnsureTenantUserGroupColumn` 的既有模式,`ALTER` 报错忽略),并在 `BackendFileController.Prepare()` / `PlatformFileController.Prepare()` 中调用。SQL 脚本同步落到 `go/docs/sql/`。
---
## 5. 服务端改造
### S2 `services/storage_service.go`
```go
// UploadContext 上传上下文,决定最终落盘路径
type UploadContext struct {
Source string // backend / platform
Tid uint64 // 租户 ID
Tuid uint64 // 归属用户 ID,0 = 租户共享
Ext string // 扩展名
}
// BuildObjectKey 生成存储相对路径(不含域名、不含 BaseDir)
// backend 共享: backend/234573/2026/09/09/xxx.png
// backend 个人: backend/234573/67091493/2026/09/09/xxx.png
// platform : platform/2026/09/09/xxx.png
func BuildObjectKey(ctx UploadContext) (key, datePath string)
```
新增方法(保留原 `Upload` 内部复用):
- `StageToTemp(file, header) (tmpPath string, md5 string, size int64, err error)` — 流式算 MD5 并写入**同磁盘**临时目录 `uploads/.tmp/`(保证后续 `os.Rename` 不跨盘)。
- `CommitTemp(tmpPath, objectKey) (*UploadResult, error)` — `MkdirAll` + `Rename`(跨盘失败则回退 `io.Copy`)。
- `UploadWithContext(file, header, ctx)` — 组合上面两步,供迁移脚本等简单场景使用。
> 七牛同样"先算 MD5":先 `StageToTemp` 得到 md5 + 临时文件 → 查重 → 命中则删临时文件返回已存在,未命中才 `Put` 到新 key。
### S3 新增 `services/file_service.go`
- `FindDuplicate(source, scope string, tid, tuid uint64, md5 string) (*models.SystemFile, error)`
- `CreateFileRecord(...) (uint64, error)` — 统一写入 `source/scope/object_key/storage`
- `ListFiles(source, scope string, tid, tuid uint64, ...) ` — 列表的 scope 隔离
- `RemovePhysical(storageType, objectKey, src string) error` — 用 `object_key` 精确删除,替代脆弱的 `removePhysicalBySrc`
### S4 `controllers/backend_file.go`
1. `Prepare()` 里调 `models.EnsureSystemFileStorageColumns()`。
2. `UploadFile` 流程改为:
```
鉴权 → effectiveTid → 解析 tuid(form > X-Tenant-User-Id 头 > claims.UserID)
→ StageToTemp(拿到 md5/size)
→ FindDuplicate(source=backend, scope, tid, tuid, md5)
命中 → 删临时文件 → 返回 code 201(文件已存在)
未命中 → CommitTemp(objectKey) → 入库 → 返回 code 200
```
3. `scope` 判定:`tuid > 0` → `user`,否则 `tenant`。
4. 列表接口 `GetAllFiles` / `GetCateFiles` / `GetUserCate` 增加 `scope` 过滤:
- 默认只返回 `scope=tenant`;
- `?scope=user` 时按当前 `tuid` 过滤,只返回本人文件。
5. 删除:统一用 `object_key` 删除物理文件;统一软删与批量删除的语义(批量删除不再误删物理文件,彻底删除才删)。
### S5 `controllers/platform_file.go`
- `source=platform`,路径固定 `platform/日期`,**忽略 `tuid` 与租户分层**。
- 其余(先算 MD5 再落盘、`object_key` 入库、删除修复)与 S4 保持一致。
### S6 `controllers/qiniu_upload.go`
- `GetUploadToken` 返回的 `keyPrefix` 改为 `platform/2026/09/09/{ts}`(原来是 `2026/09/09/{ts}`)。
- `SaveFileRecord` 入库时写 `source=platform`、`scope=tenant`、`object_key=req.Key`。
- 明确 `md5` 字段:直传场景无法拿到真 MD5,**保留 etag 但写入时打标**(在 `md5` 为空时用 etag,并在注释中说明;后续如需精确去重,此通道需改为服务端中转)。
### S7 / S8 前端改造
统一封装:
- `backend/src/api/file.js` 与 `platform/src/api/file.js` 的 `uploadFile(formData, options)` 增加 `options.tuid`,并**移除调用处重复的 `cate` append**(保留 options 里那次)。
- 个人文件场景传 `tuid`,公共场景不传。
---
## 6. 前端上传点归属判定表
> **`tenant` = 租户共享(`backend/{tid}/日期/`);`user` = 用户个人(`backend/{tid}/{tuid}/日期/`)**
### backend 端
| # | 功能 | 判定 | 依据 |
|---|---|---|---|
| B1 | 系统文件库上传 | `tenant` | ✅ 已确认:素材库不分「共享/我的」,全部走租户共享;个人文件只来自 B9/B10 |
| B2 | 富文本(文章/方案/产品正文) | `tenant` | 业务内容,全租户可见 |
| B3 | CMS 文章封面 | `tenant` | 业务数据 |
| B4 | CMS 文章分类默认图 | `tenant` | 业务数据 |
| B5 | 模块中心缩略图 | `tenant` | 模块市场资源 |
| B6/B7/B8 | 站点 Logo / 白色 Logo / ico | `tenant` | 租户级配置 |
| B9 | OA 日程图片 | `user` | ✅ 已确认:按日程创建人归属 |
| B10 | OA 报销发票 | `user` | ✅ 已确认:挂在员工个人报销单下 |
| B11 | 员工档案记录附件 | `tenant` | ✅ 已确认:HR 需跨员工查看 |
| B12 | 员工证照(学历照片) | `tenant` | ✅ 已确认:HR 需跨员工查看 |
| B13~B17 | CMS Banner/方案/产品/菜单/友链 | `tenant` | 业务数据 + **需修 404**(✅ 已确认要修) |
| B18 | 租户资质文件 | `tenant` | **需修 404**(✅ 已确认要修,接口需重新设计) |
| B19 | 组织架构 CSV 导入 | 不涉及 | 临时解析,不入库 |
### platform 端(全部 `platform/日期/`,不做租户/用户分层)
| # | 功能 | 处理 |
|---|---|---|
| P1~P6 | 文件管理 / 富文本 / 站点 Logo ×3 / 模块缩略图 | 保持调用 `/platform/uploadfile`,无需传 `tuid` |
| P7 | 软件升级包 | `smartUpload` → 七牛直传时 key 也要带 `platform/` 前缀(S6 已覆盖) |
| P8 | 模板 zip | 不动(走 `/platform/template/upload`,不入库) |
| P9 | 用户头像 | **需修**:`{BASE}/platform/upload` → `/platform/uploadfile` |
| P10 / P12 | `uploadAvatar()` 死代码 | 本次不启用(或删除) |
| P11 | 租户资质图片 | ⚠️ **待确认**,见 §8-Q3 |
| P13 / P14 | 模块包(未发请求) | 不动 |
---
## 7. 存量数据迁移方案(S9)
### 7.1 当前存储类型
✅ **已确认:`storage_type = qiniu`(七牛云)** → 迁移走 `BucketManager.Move`(服务端改名,不走流量、与文件大小无关)。
> 若后续切回本地存储,脚本自动改为 `os.Rename` 分支。
### 7.2 迁移原理
- **七牛云**:`BucketManager.Move(srcBucket, srcKey, destBucket, destKey)` — 同 bucket 内**服务端原子改名**,不走流量、秒级完成,只计 API 调用次数。与文件大小无关。
- **本地**:`os.MkdirAll` + `os.Rename`(同盘,不搬数据)。
> 注意:现有 `services/storage_migration.go` 的 `MigrateFile` 是"下载再上传"的旧实现,**本次要替换为 Move/Rename**。
### 7.3 迁移步骤
1. 遍历 `yz_system_files WHERE delete_time IS NULL`。
2. 从 `src` 解析出老 key:
- 本地:`/uploads/2026/09/09/xxx.png` → `2026/09/09/xxx.png`
- 七牛:`{domain}/2026/09/09/xxx.png` → `2026/09/09/xxx.png`
3. 按新规则生成目标 key:**老数据一律按 `scope=tenant`(租户共享)迁移**(原因见 7.4)。
4. Move / Rename。
5. `UPDATE yz_system_files SET src=新URL, object_key=新key, source=..., scope='tenant', storage=... WHERE id=?`。
### 7.4 老数据的两个硬限制(必须知悉)
1. **还原不出"个人/共享"归属**:老数据 `tuid` 全为 NULL,`uid` 是上传者(管理员)ID,没有任何字段能说明"这是谁的个人文件"。因此老数据**统一按租户共享迁移**;个人目录只对改造后新增的文件生效。
2. **`md5` 可能混了七牛 etag**:判断方法
```sql
SELECT COUNT(*) FROM yz_system_files WHERE LENGTH(md5) <> 32;
```
非 32 位的记录是 etag,不是真 MD5,这部分去重会失准(本次不修复,仅记录)。
### 7.5 硬编码 URL 风险
Move 后旧 URL 会 404。需排查是否有业务把上传 URL 写死在 `yz_system_files.src` 之外的地方(CMS 正文 `content`、官网模板配置 `yz_tenant_site_setting` 等)。**执行迁移前先做全库扫描**,这部分在 S9 里做。
### 7.6 脚本形态
`go/cmd/migrate_storage/main.go`:
- 默认 `--dry-run`:只打印 `老key → 新key` 计划与统计,不改动任何数据。
- `--apply`:真正执行。
- `--tid=234573`:可选,只迁移指定租户。
- 幂等:目标 key 已存在则跳过,可重复执行。
---
## 8. 待确认事项(**未答复前这些模块不改动**)
### 已确认
| 编号 | 结论 | 影响范围 |
|---|---|---|
| Q2 | **日程图片、报销发票 → `user`(个人);员工档案附件、员工证照 → `tenant`(共享)** | B9~B12 |
| Q3 | **三组坏链全部修**:①`/admin/uploadfiles`(B13~B17)→ `/backend/uploadfile`;②`/platform/upload`(P9)→ `/platform/uploadfile`;③`/api/platform/common/upload`(B18、P11 资质)→ 重新设计 | B13~B18、P9、P11 |
| Q4 | **存储类型 = 七牛云 qiniu** → 迁移走 `BucketManager.Move` | S9 |
| Q1 | **素材库不分 Tab,全部走租户共享。** 个人文件只来自 B9 日程图片、B10 报销发票。后端已备好 `scope=user` 能力,以后要加「我的文件」Tab 只需前端加 Tab + 传参。 | B1 |
### 待确认
无(Q1~Q4 全部已确认)。
### 后续单列任务(不在本次改造范围)
| 编号 | 事项 | 说明 |
|---|---|---|
| T1 | **租户资质业务落库** | B18/P11 的上传通道已修(改为走通用上传接口、带上 token),但 `submitForm` 仍是前端 mock,后端**没有资质表也没有保存接口**。需要新表(tid/type/file_url/expire_time/remark)+ 保存/详情接口,属新功能,另开任务。 |
| T2 | 七牛直传通道的 MD5 是 etag | 软件升级包走七牛直传,`md5` 存的是 etag 与服务端中转算出的真 MD5 不同源,该通道查重只在通道内有效。如需全局精确去重,需把直传改为服务端中转(大文件代价高)。 |
---
## 9. 风险与回滚
| 风险 | 应对 |
|---|---|
| 新目录规则上线后老 URL 失效 | 迁移前先全库扫描硬编码引用;迁移脚本先 dry-run |
| `effectiveTid()` 拿到 0 | backend 端 tid=0 时落 `backend/0/` 并打 WARN 日志,不阻断 |
| 并发上传同文件 | 临时文件名带随机后缀;查重与入库之间的极短窗口允许少量重复(后续可加分布式锁) |
| `os.Rename` 跨盘失败 | 回退 `io.Copy` |
| 改造中断 | 按 §0 进度总表从第一个未勾选项继续 |
| 回滚 | 服务端改动集中在 `storage_service.go` + 两个 file controller,回滚即恢复这 3 个文件的旧版本;数据库新增列可保留(不影响旧逻辑) |
---
## 10. 验收清单
- [ ] backend 上传图片 → 落盘到 `uploads/backend/{tid}/2026/09/09/`
- [ ] backend 传 `tuid` 上传 → 落盘到 `uploads/backend/{tid}/{tuid}/2026/09/09/`
- [ ] platform 上传 → 落盘到 `uploads/platform/2026/09/09/`
- [ ] 同一租户重复上传同一文件 → 返回 201,且**磁盘上没有新增文件**
- [ ] 租户已有 a 文件,用户 c 上传同样文件 → 正常入库,不冲突(两处物理文件并存)
- [ ] 用户 c 再传同一文件 → 返回 201
- [ ] 文件列表:`scope=user` 只看到自己的;默认只看到租户共享
- [ ] 删除文件 → 物理文件按 `object_key` 精确删除
- [ ] 七牛模式:`keyPrefix` 带 `platform/` 前缀
- [ ] 迁移 dry-run 输出正确,apply 后旧 URL 全部更新且可访问