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

24 KiB
Raw Permalink Blame History

文件存储分层改造计划

创建日期: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 新增字段

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

// 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:判断方法
    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 全部更新且可访问