整体平台上传功能大改造

This commit is contained in:
2026-09-09 10:49:57 +08:00
parent d2643a3697
commit de7225b61e
21 changed files with 3835 additions and 2622 deletions
@@ -0,0 +1,93 @@
-- 文件存储分层改造:yz_system_files 新增归属与存储字段
-- 对应计划文档:go/docs/文件存储分层改造计划.md S1
--
-- 幂等脚本:用存储过程逐项判断,已存在的列/索引自动跳过,可反复执行,不会报
-- 1060 Duplicate column / 1061 Duplicate key。
--
-- 背景:md5 在部分历史库中是 TEXT 类型,MySQL 不允许对 TEXT/BLOB 建整列索引,
-- 因此 idx_file_dedup 使用前缀长度 md5(32)(MD5 十六进制串固定 32 字符,等价全值)。
DROP PROCEDURE IF EXISTS `yz_alter_system_files_storage`;
DELIMITER $$
CREATE PROCEDURE `yz_alter_system_files_storage`()
BEGIN
DECLARE v_db VARCHAR(64);
SET v_db = DATABASE();
-- 1. 字段:source
IF NOT EXISTS (
SELECT 1 FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = v_db AND TABLE_NAME = 'yz_system_files' AND COLUMN_NAME = 'source'
) THEN
ALTER TABLE `yz_system_files`
ADD COLUMN `source` varchar(16) NOT NULL DEFAULT 'backend'
COMMENT '来源端: backend-租户后台 platform-平台端';
END IF;
-- 2. 字段:scope
IF NOT EXISTS (
SELECT 1 FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = v_db AND TABLE_NAME = 'yz_system_files' AND COLUMN_NAME = 'scope'
) THEN
ALTER TABLE `yz_system_files`
ADD COLUMN `scope` varchar(16) NOT NULL DEFAULT 'tenant'
COMMENT '归属: tenant-租户共享 user-用户个人';
END IF;
-- 3. 字段:storage
IF NOT EXISTS (
SELECT 1 FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = v_db AND TABLE_NAME = 'yz_system_files' AND COLUMN_NAME = 'storage'
) THEN
ALTER TABLE `yz_system_files`
ADD COLUMN `storage` varchar(16) NOT NULL DEFAULT ''
COMMENT '存储类型: local/qiniu(冗余,便于迁移与排查)';
END IF;
-- 4. 字段:object_key
IF NOT EXISTS (
SELECT 1 FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = v_db AND TABLE_NAME = 'yz_system_files' AND COLUMN_NAME = 'object_key'
) THEN
ALTER TABLE `yz_system_files`
ADD COLUMN `object_key` varchar(512) NOT NULL DEFAULT ''
COMMENT '存储相对路径(不含域名),用于迁移与精确删除';
END IF;
-- 5. 索引:查重(source + scope + tid + tuid + md5 前缀)
-- 不加 UNIQUE:软删(delete_time)与并发上传下唯一索引会直接报错,去重在代码层完成
IF NOT EXISTS (
SELECT 1 FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = v_db AND TABLE_NAME = 'yz_system_files' AND INDEX_NAME = 'idx_file_dedup'
) THEN
ALTER TABLE `yz_system_files`
ADD KEY `idx_file_dedup` (`source`, `scope`, `tid`, `tuid`, `md5`(32));
END IF;
-- 6. 索引:归属列表过滤
IF NOT EXISTS (
SELECT 1 FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = v_db AND TABLE_NAME = 'yz_system_files' AND INDEX_NAME = 'idx_file_owner'
) THEN
ALTER TABLE `yz_system_files`
ADD KEY `idx_file_owner` (`source`, `tid`, `scope`, `tuid`, `delete_time`);
END IF;
END$$
DELIMITER ;
CALL `yz_alter_system_files_storage`();
DROP PROCEDURE IF EXISTS `yz_alter_system_files_storage`;
-- 7. 老数据初始化:迁移脚本执行前先打底,避免 source/scope 为空导致查询遗漏
UPDATE `yz_system_files` SET `source` = 'backend' WHERE `source` = '' OR `source` IS NULL;
UPDATE `yz_system_files` SET `scope` = 'tenant' WHERE `scope` = '' OR `scope` IS NULL;
-- 8. 校验(应看到 4 个字段 + 2 个索引)
-- SHOW COLUMNS FROM yz_system_files LIKE 'source';
-- SHOW COLUMNS FROM yz_system_files LIKE 'scope';
-- SHOW COLUMNS FROM yz_system_files LIKE 'storage';
-- SHOW COLUMNS FROM yz_system_files LIKE 'object_key';
-- SHOW INDEX FROM yz_system_files WHERE Key_name IN ('idx_file_dedup','idx_file_owner');
+403
View File
@@ -0,0 +1,403 @@
# 文件存储分层改造计划
> 创建日期: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 全部更新且可访问