# 上传垃圾资源清理 - 前端实施计划 ## 概述 延续上一会话已完成的后端基础(PendingUpload 实体、UploadTrackingService、UploadCleanupJob、各业务 Service 的 Consume/Delete 旧文件逻辑、SystemController 的 Upload/DELETE 端点),本计划聚焦**前端集成**,确保所有上传场景在「取消表单、替换文件、关闭弹窗」时主动调用后端 DELETE 清理 pending 文件,避免形成垃圾资源。 ## 当前状态分析 ### 后端(已完成,需验证编译) - `Common/Entities/Entities.cs`:新增 `PendingUpload` 实体(ObjectKey/Folder/UploadedBy/UploadedAt/ConsumedAt) - `Data/MilliSimDbContext.cs`:注册 `PendingUploads` DbSet + 两个索引 - `Data/DbSeeder.cs`:`PatchDatabaseSchemaAsync` 末尾新增建表补丁 - `Services/Infrastructure/UploadTrackingService.cs`:RegisterAsync / ConsumeAsync / DeletePendingAsync / CleanupExpiredAsync - `Services/UploadCleanupJob.cs`:IHostedService + Timer(启动 5 分钟后首次执行,之后每 6 小时一次,清理 24h 未消费文件) - `Program.cs`:注册 `IUploadTrackingService`(Scoped)+ `UploadCleanupJob`(Hosted) - `Controllers/SystemController.cs`: - Upload 端点:成功后 `RegisterAsync`,返回 `{ url, key }` - 新增 DELETE `/api/system/upload?key=xxx`:仅删 pending 状态文件 - `Services/ProjectService.cs`、`ContentService.cs`、`UserService.cs`、`AuthService.cs`、`SystemService.cs`: - Create:保存后 `ConsumeAsync(ExtractKey(field))` - Update:记录 oldKey/newKey,保存后对比,不同则 `DeleteAsync(oldKey)` + `ConsumeAsync(newKey)` ### 前端(未实施,本计划范围) - `api/index.ts`:`systemApi.upload` 返回类型仅 `{ url: string }`,缺 `key`;无 `deleteUpload` 方法 - `components/IconUpload.vue`:XHR 上传只用 `response.data.url`,丢弃 `key`;`handleDelete` 仅 `emit('update:modelValue', '')`,不调后端删除 - `components/VideoUpload.vue`:同 IconUpload 问题 - 9 个表单页面均未跟踪上传会话、未在取消/关闭时清理 pending 文件 ## 三层防御策略 1. **前端即时清理(本计划核心)**:`useUploadSession` composable 跟踪表单会话中的上传文件;取消表单时 `cleanupPending()` 批量删除;替换文件时 IconUpload/VideoUpload 自动调 DELETE 删除旧 pending 上传 2. **后端保存时清理(已完成)**:各 Service Update 方法对比新旧 key,删除旧文件 + 消费新文件 3. **后端 GC 兜底(已完成)**:`UploadCleanupJob` 每 6 小时清理 24h 未消费的 pending 文件 ## 关键设计决策 - **不修改各 Service 的存储格式** — 后端 `ExtractKey` 幂等(签名URL/key 都能归一化),前端只需把上传返回的 `key` 原样存入 form,提交时随业务字段一起发送 - **DELETE 端点只删 pending 文件** — `DeletePendingAsync` 检查 `ConsumedAt == null`,已保存到实体的文件不会被误删 - **前端通过 key 跟踪上传** — IconUpload/VideoUpload 捕获 `UploadResultDto.key`,通过 `uploadSession.register(key)` 注册到会话 - **替换文件即时清理** — IconUpload/VideoUpload 在 emit 新值前,若旧值是本次会话上传的 pending key,立即调 `deleteUpload(key)` 删除(不等表单取消) - **会话生命周期** — 弹窗页面:`openDialog` 时 `resetSession()`,`cancelDialog`/`closed` 时 `cleanupPending()`;页面级:`onMounted` 时创建,`onBeforeUnmount` 时 `cleanupPending()`,保存成功后 `resetSession()` 标记已消费 ## 实施步骤 ### 阶段 A:后端编译验证(前置) 1. 杀掉后端进程(PID 17372 或当前运行的 MilliSim.Api.exe) 2. `dotnet build backend/MilliSim.Api/MilliSim.Api.csproj -c Debug` 3. 预期:编译通过(可能有 2 个无害 nullable 警告:UserService.cs L186、ProjectService.cs L1021) 4. 启动后端:`dotnet run --project backend/MilliSim.Api/MilliSim.Api.csproj`,确认 PendingUploads 表已建表、Swagger 显示 DELETE `/api/system/upload` ### 阶段 B:前端基础设施 #### B1. `frontend/src/api/index.ts` 修改 `systemApi`: ```typescript // 通用文件上传接口(返回 url 用于预览 + key 用于跟踪) upload: (formData: FormData) => http.upload>('/system/upload', formData), // 删除未使用的上传文件(仅限 pending 状态) deleteUpload: (key: string) => http.delete('/system/upload', { key }), ``` #### B2. 创建 `frontend/src/composables/useUploadSession.ts` 需先创建 `composables` 目录。 ```typescript import { ref, onBeforeUnmount } from 'vue' import { systemApi } from '@/api' interface UploadSession { /** 注册一个上传的 key(emit 前调用) */ register(key: string): void /** 标记某个 key 已被保存消费(不再清理) */ consume(key: string): void /** 取消表单/关闭弹窗时调用:批量删除所有未消费的 pending 文件 */ cleanupPending(): Promise /** 保存成功后重置会话(清空跟踪列表,不删文件) */ reset(): void } export function useUploadSession() { const pendingKeys = ref([]) function register(key: string) { if (!key || pendingKeys.value.includes(key)) return pendingKeys.value.push(key) } function consume(key: string) { if (!key) return pendingKeys.value = pendingKeys.value.filter(k => k !== key) } async function cleanupPending() { const keys = [...pendingKeys.value] pendingKeys.value = [] await Promise.all( keys.map(key => systemApi.deleteUpload(key).catch(() => {/* 静默失败,不影响 UX */}) ) ) } function reset() { pendingKeys.value = [] } // 组件卸载时自动清理(防止页面级表单忘记调用) onBeforeUnmount(() => { cleanupPending() }) return { register, consume, cleanupPending, reset } } ``` ### 阶段 C:上传组件改造 #### C1. `frontend/src/components/IconUpload.vue` 修改点: 1. 新增 prop `uploadSession?`(可选,类型为 useUploadSession 返回的对象的方法集合) 2. `uploadFile` 成功后:用 `response.data.key` 调 `uploadSession?.register(key)`,再 emit url 3. `handleDelete`:若当前 `modelValue` 是本次会话上传的 key(即 uploadSession 中存在),调 `systemApi.deleteUpload(key)` 即时删除;emit `''` 4. 替换图片场景:`uploadFile` 成功后,若旧 `modelValue` 是 pending key,先 `deleteUpload(oldKey)` 再注册新 key 具体改造: - props 增加 `uploadSession?: { register: (k: string) => void; consume: (k: string) => void }` - 新增内部状态 `currentKey`(保存最近一次上传返回的 key) - `uploadFile` 成功分支: ```typescript if (response.code === 200 && response.data?.url) { const newKey = response.data.key || '' // 若旧值是 pending 上传,先清理(替换场景) if (props.uploadSession && currentKey.value && currentKey.value !== newKey) { // 旧的 currentKey 是上一次上传的 pending,本次替换前删除 // 注意:只能删本次会话上传的,不能删已保存的(即 form 中来自后端的值) // 由于 IconUpload 在初始化时 modelValue 是签名URL,currentKey 为空, // 只有用户主动上传后 currentKey 才有值,所以可以放心删 systemApi.deleteUpload(currentKey.value).catch(() => {}) } currentKey.value = newKey props.uploadSession?.register(newKey) emit('update:modelValue', response.data.url) ElMessage.success('上传成功') } ``` - `handleDelete`: ```typescript async function handleDelete() { if (currentKey.value) { // 仅删本次会话上传的,已保存的(currentKey 为空)不删 await systemApi.deleteUpload(currentKey.value).catch(() => {}) currentKey.value = '' } emit('update:modelValue', '') } ``` - 不再使用 `uploadSession.consume`,因为消费逻辑由后端 Service 在保存时通过 `ConsumeAsync` 处理;前端只需在取消表单时通过 `cleanupPending()` 批量清理剩余 pending #### C2. `frontend/src/components/VideoUpload.vue` 与 IconUpload 相同的改造:新增 `uploadSession` prop + `currentKey` + 替换时清理旧 pending + handleDelete 调 DELETE。 ### 阶段 D:9 个表单页面接入 所有页面统一模式: - 弹窗页面:在 setup 顶层 `const uploadSession = useUploadSession()`;handleAdd/handleEdit 时 `uploadSession.reset()`;取消按钮和 dialog 的 `closed` 事件调 `uploadSession.cleanupPending()`;handleSubmit 成功后 `uploadSession.reset()`;把 `:upload-session="uploadSession"` 传给所有 IconUpload/VideoUpload - 页面级:setup 顶层 `const uploadSession = useUploadSession()`;onMounted 时 `uploadSession.reset()`(清空上一次残留);保存成功后 `uploadSession.reset()`;离开页面前 onBeforeUnmount 由 composable 自动 cleanupPending #### 弹窗页面(7 个) | 文件 | 上传字段 | 改造点 | |------|---------|--------| | `views/admin/Categories.vue` | icon | useUploadSession + reset/cleanup/reset + 传 prop | | `views/admin/Platforms.vue` | icon | 同上 | | `views/admin/Tags.vue` | icon | 同上 | | `views/admin/Banners.vue` | image | 同上 | | `views/admin/Partners.vue` | logo | 同上 | | `views/admin/Employees.vue` | avatar | 同上 | | `views/admin/Customers.vue` | avatar | 同上 | 每个弹窗页面统一改造模式: 1. 导入:`import { useUploadSession } from '@/composables/useUploadSession'` 2. setup:`const uploadSession = useUploadSession()` 3. `handleAdd`:在 `resetForm()` 后加 `uploadSession.reset()` 4. `handleEdit`:在 `Object.assign(form, ...)` 后加 `uploadSession.reset()`(编辑模式不清理已保存的文件,仅开始新会话) 5. 取消按钮:`@click="handleCancel"`,新增 `handleCancel` 函数:`uploadSession.cleanupPending(); dialogVisible.value = false` 6. dialog 增加 `@closed="uploadSession.cleanupPending()"`(点击右上角 X 关闭时也触发) 7. `handleSubmit` 成功后:`uploadSession.reset()`(标记已消费,防止 onBeforeUnmount 重复清理) 8. 模板中所有 `` 增加 `:upload-session="uploadSession"` #### 页面级页面(2 个) **`views/admin/Settings.vue`** — 5 个 Logo 1. 导入 useUploadSession 2. setup:`const uploadSession = useUploadSession()` 3. `fetchSettings` 完成后 `uploadSession.reset()`(加载完成后开始新会话) 4. `saveLogo` 成功后 `uploadSession.reset()`(5 个 Logo 一起保存) 5. 模板中 5 个 IconUpload 都传 `:upload-session="uploadSession"` 6. onBeforeUnmount 由 composable 自动 cleanupPending **`views/admin/ProjectEdit.vue`** — coverImage + videoUrl 1. 导入 useUploadSession 2. setup:`const uploadSession = useUploadSession()` 3. `fetchProject` 完成后 `uploadSession.reset()` 4. `handleSave` 成功后(无论 create 还是 update):`uploadSession.reset()` 5. `goBack` 函数:先 `await uploadSession.cleanupPending()` 再 `router.push` 6. 模板中 IconUpload(coverImage)和 VideoUpload(videoUrl)都传 `:upload-session="uploadSession"` ## 验证步骤 ### 后端编译验证 1. 杀进程 → `dotnet build` → 启动后端 2. 浏览器访问 `http://localhost:5041/swagger`,确认 DELETE `/api/system/upload` 端点存在 3. 查看启动日志,确认 `PendingUploads` 表建表 SQL 执行成功 ### 前端 HMR 验证 1. Vite dev server 已运行(http://localhost:5173) 2. 改动文件后观察 HMR 日志无编译错误 3. `npm run build` 验证生产构建通过 ### 功能场景测试(关键) #### 场景 1:新增分类 - 取消表单 1. 进入分类管理 → 点击「新增分类」 2. 上传一个图标(看到预览) 3. **不点确定,点取消** 4. 预期:后端日志显示 DELETE `/api/system/upload` 调用,COS/本地存储中该文件被删除 #### 场景 2:新增分类 - 替换图标后取消 1. 新增分类 → 上传图标 A 2. 点击「更换图片」→ 上传图标 B 3. 预期:图标 A 立即被 DELETE(前端替换时清理) 4. 点击取消 5. 预期:图标 B 被 DELETE(cleanupPending 清理) #### 场景 3:编辑分类 - 替换图标后保存 1. 编辑已有分类 → 上传新图标 2. 点击确定保存 3. 预期:后端 Service Update 方法删除旧文件(已保存的 COS 文件)+ ConsumeAsync 新文件 #### 场景 4:编辑分类 - 替换图标后取消 1. 编辑已有分类(已有图标 A)→ 上传新图标 B 2. 点击取消 3. 预期:图标 B 被 DELETE(pending 清理);图标 A 保留(已消费,不在 pending 列表) #### 场景 5:编辑分类 - 删除图标后取消 1. 编辑已有分类(已有图标 A)→ 点击图标上的「删除」按钮 2. 预期:图标 A **不会被删除**(currentKey 为空,因为 A 是后端加载的已保存文件,不是本次会话上传的) 3. 点击取消 4. 预期:无 pending 文件需要清理(currentKey 为空) #### 场景 6:项目编辑 - 封面+视频,取消 1. 进入项目编辑 → 替换封面 + 上传新视频 2. 点击「取消」返回列表 3. 预期:goBack 调用 cleanupPending,新上传的封面和视频都被 DELETE #### 场景 7:系统设置 - Logo 上传后切换页面 1. 进入系统设置 → 上传新 Logo(未点保存) 2. 直接点击侧边栏跳转到其他页面 3. 预期:onBeforeUnmount 触发 cleanupPending,新 Logo 被 DELETE #### 场景 8:GC 兜底 1. 上传文件后强制关闭浏览器(模拟用户中途离开) 2. 等待 24 小时(或临时调整 `UploadCleanupJob.ExpireHours` 为 0 立即触发) 3. 预期:后端 GC 任务清理该 pending 文件 ## 假设与决策 - **假设 1**:后端编译只需通过,无新错误(之前已有 2 个无害 nullable 警告) - **假设 2**:前端 Vite dev server 在 5173 端口可用 - **决策 1**:IconUpload 内部维护 `currentKey`,仅当 currentKey 非空时(即用户本次会话主动上传)才在 delete/replace 时调 DELETE;从后端加载的已保存文件(currentKey 为空)不删,避免误删已消费文件 - **决策 2**:`useUploadSession` 通过 onBeforeUnmount 自动 cleanup,作为兜底;显式调用 cleanupPending 用于「取消按钮」「goBack」等需要立即清理的场景 - **决策 3**:保存成功后必须调 `reset()` 清空 pending 列表,防止 onBeforeUnmount 重复 DELETE 已被后端消费的文件(DeletePendingAsync 会因 ConsumedAt != null 而拒绝删除,但调用本身是无意义的网络请求) - **决策 4**:cleanupPending 内部对每个 DELETE 用 `.catch(() => {})` 静默失败,不阻塞 UX - **决策 5**:批量并发删除使用 `Promise.all`,不串行等待,避免取消表单时长时间卡顿 ## 实施顺序 1. 阶段 A:后端编译验证 2. 阶段 B1:api/index.ts 改造 3. 阶段 B2:useUploadSession composable 4. 阶段 C1:IconUpload.vue 改造 5. 阶段 C2:VideoUpload.vue 改造 6. 阶段 D:9 个页面逐一改造(先 Categories.vue 跑通,再批量套用模式) 7. 验证:HMR + 8 个功能场景测试