24 KiB
文件存储分层改造计划
创建日期: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. 目标与非目标
目标
- 两个端的文件物理隔离:
backend/与platform/分目录。 - backend 端按租户隔离:
backend/{tid}/。 - 租户内区分租户共享文件与用户个人文件:个人文件落到
backend/{tid}/{tuid}/。 - 常规按
年/月/日分目录。 - MD5 去重精确到归属:
- 租户共享文件 → 同
tid内 MD5 相同才算重复; - 用户个人文件 → 同
tid+ 同tuid内 MD5 相同才算重复; - 「租户已有 a 文件」与「用户 c 上传同样文件」不冲突,可并存。
- 租户共享文件 → 同
- 去重改为先算 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/storageListFiles(source, scope string, tid, tuid uint64, ...)— 列表的 scope 隔离RemovePhysical(storageType, objectKey, src string) error— 用object_key精确删除,替代脆弱的removePhysicalBySrc
S4 controllers/backend_file.go
Prepare()里调models.EnsureSystemFileStorageColumns()。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 200scope判定:tuid > 0→user,否则tenant。- 列表接口
GetAllFiles/GetCateFiles/GetUserCate增加scope过滤:- 默认只返回
scope=tenant; ?scope=user时按当前tuid过滤,只返回本人文件。
- 默认只返回
- 删除:统一用
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,并移除调用处重复的cateappend(保留 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 迁移步骤
- 遍历
yz_system_files WHERE delete_time IS NULL。 - 从
src解析出老 key:- 本地:
/uploads/2026/09/09/xxx.png→2026/09/09/xxx.png - 七牛:
{domain}/2026/09/09/xxx.png→2026/09/09/xxx.png
- 本地:
- 按新规则生成目标 key:老数据一律按
scope=tenant(租户共享)迁移(原因见 7.4)。 - Move / Rename。
UPDATE yz_system_files SET src=新URL, object_key=新key, source=..., scope='tenant', storage=... WHERE id=?。
7.4 老数据的两个硬限制(必须知悉)
- 还原不出"个人/共享"归属:老数据
tuid全为 NULL,uid是上传者(管理员)ID,没有任何字段能说明"这是谁的个人文件"。因此老数据统一按租户共享迁移;个人目录只对改造后新增的文件生效。 md5可能混了七牛 etag:判断方法非 32 位的记录是 etag,不是真 MD5,这部分去重会失准(本次不修复,仅记录)。SELECT COUNT(*) FROM yz_system_files WHERE LENGTH(md5) <> 32;
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 全部更新且可访问