# 文件存储分层改造计划 > 创建日期: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 全部更新且可访问