15 KiB
15 KiB
上传垃圾资源清理 - 前端实施计划
概述
延续上一会话已完成的后端基础(PendingUpload 实体、UploadTrackingService、UploadCleanupJob、各业务 Service 的 Consume/Delete 旧文件逻辑、SystemController 的 Upload/DELETE 端点),本计划聚焦前端集成,确保所有上传场景在「取消表单、替换文件、关闭弹窗」时主动调用后端 DELETE 清理 pending 文件,避免形成垃圾资源。
当前状态分析
后端(已完成,需验证编译)
Common/Entities/Entities.cs:新增PendingUpload实体(ObjectKey/Folder/UploadedBy/UploadedAt/ConsumedAt)Data/MilliSimDbContext.cs:注册PendingUploadsDbSet + 两个索引Data/DbSeeder.cs:PatchDatabaseSchemaAsync末尾新增建表补丁Services/Infrastructure/UploadTrackingService.cs:RegisterAsync / ConsumeAsync / DeletePendingAsync / CleanupExpiredAsyncServices/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 状态文件
- Upload 端点:成功后
Services/ProjectService.cs、ContentService.cs、UserService.cs、AuthService.cs、SystemService.cs:- Create:保存后
ConsumeAsync(ExtractKey(field)) - Update:记录 oldKey/newKey,保存后对比,不同则
DeleteAsync(oldKey)+ConsumeAsync(newKey)
- Create:保存后
前端(未实施,本计划范围)
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 文件
三层防御策略
- 前端即时清理(本计划核心):
useUploadSessioncomposable 跟踪表单会话中的上传文件;取消表单时cleanupPending()批量删除;替换文件时 IconUpload/VideoUpload 自动调 DELETE 删除旧 pending 上传 - 后端保存时清理(已完成):各 Service Update 方法对比新旧 key,删除旧文件 + 消费新文件
- 后端 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:后端编译验证(前置)
- 杀掉后端进程(PID 17372 或当前运行的 MilliSim.Api.exe)
dotnet build backend/MilliSim.Api/MilliSim.Api.csproj -c Debug- 预期:编译通过(可能有 2 个无害 nullable 警告:UserService.cs L186、ProjectService.cs L1021)
- 启动后端:
dotnet run --project backend/MilliSim.Api/MilliSim.Api.csproj,确认 PendingUploads 表已建表、Swagger 显示 DELETE/api/system/upload
阶段 B:前端基础设施
B1. frontend/src/api/index.ts
修改 systemApi:
// 通用文件上传接口(返回 url 用于预览 + key 用于跟踪)
upload: (formData: FormData) =>
http.upload<ApiResponse<{ url: string; key: string }>>('/system/upload', formData),
// 删除未使用的上传文件(仅限 pending 状态)
deleteUpload: (key: string) =>
http.delete<ApiResponse>('/system/upload', { key }),
B2. 创建 frontend/src/composables/useUploadSession.ts
需先创建 composables 目录。
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<void>
/** 保存成功后重置会话(清空跟踪列表,不删文件) */
reset(): void
}
export function useUploadSession() {
const pendingKeys = ref<string[]>([])
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
修改点:
- 新增 prop
uploadSession?(可选,类型为 useUploadSession 返回的对象的方法集合) uploadFile成功后:用response.data.key调uploadSession?.register(key),再 emit urlhandleDelete:若当前modelValue是本次会话上传的 key(即 uploadSession 中存在),调systemApi.deleteUpload(key)即时删除;emit''- 替换图片场景:
uploadFile成功后,若旧modelValue是 pending key,先deleteUpload(oldKey)再注册新 key
具体改造:
- props 增加
uploadSession?: { register: (k: string) => void; consume: (k: string) => void } - 新增内部状态
currentKey(保存最近一次上传返回的 key) uploadFile成功分支: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: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 | 同上 |
每个弹窗页面统一改造模式:
- 导入:
import { useUploadSession } from '@/composables/useUploadSession' - setup:
const uploadSession = useUploadSession() handleAdd:在resetForm()后加uploadSession.reset()handleEdit:在Object.assign(form, ...)后加uploadSession.reset()(编辑模式不清理已保存的文件,仅开始新会话)- 取消按钮:
@click="handleCancel",新增handleCancel函数:uploadSession.cleanupPending(); dialogVisible.value = false - dialog 增加
@closed="uploadSession.cleanupPending()"(点击右上角 X 关闭时也触发) handleSubmit成功后:uploadSession.reset()(标记已消费,防止 onBeforeUnmount 重复清理)- 模板中所有
<IconUpload>增加:upload-session="uploadSession"
页面级页面(2 个)
views/admin/Settings.vue — 5 个 Logo
- 导入 useUploadSession
- setup:
const uploadSession = useUploadSession() fetchSettings完成后uploadSession.reset()(加载完成后开始新会话)saveLogo成功后uploadSession.reset()(5 个 Logo 一起保存)- 模板中 5 个 IconUpload 都传
:upload-session="uploadSession" - onBeforeUnmount 由 composable 自动 cleanupPending
views/admin/ProjectEdit.vue — coverImage + videoUrl
- 导入 useUploadSession
- setup:
const uploadSession = useUploadSession() fetchProject完成后uploadSession.reset()handleSave成功后(无论 create 还是 update):uploadSession.reset()goBack函数:先await uploadSession.cleanupPending()再router.push- 模板中 IconUpload(coverImage)和 VideoUpload(videoUrl)都传
:upload-session="uploadSession"
验证步骤
后端编译验证
- 杀进程 →
dotnet build→ 启动后端 - 浏览器访问
http://localhost:5041/swagger,确认 DELETE/api/system/upload端点存在 - 查看启动日志,确认
PendingUploads表建表 SQL 执行成功
前端 HMR 验证
- Vite dev server 已运行(http://localhost:5173)
- 改动文件后观察 HMR 日志无编译错误
npm run build验证生产构建通过
功能场景测试(关键)
场景 1:新增分类 - 取消表单
- 进入分类管理 → 点击「新增分类」
- 上传一个图标(看到预览)
- 不点确定,点取消
- 预期:后端日志显示 DELETE
/api/system/upload调用,COS/本地存储中该文件被删除
场景 2:新增分类 - 替换图标后取消
- 新增分类 → 上传图标 A
- 点击「更换图片」→ 上传图标 B
- 预期:图标 A 立即被 DELETE(前端替换时清理)
- 点击取消
- 预期:图标 B 被 DELETE(cleanupPending 清理)
场景 3:编辑分类 - 替换图标后保存
- 编辑已有分类 → 上传新图标
- 点击确定保存
- 预期:后端 Service Update 方法删除旧文件(已保存的 COS 文件)+ ConsumeAsync 新文件
场景 4:编辑分类 - 替换图标后取消
- 编辑已有分类(已有图标 A)→ 上传新图标 B
- 点击取消
- 预期:图标 B 被 DELETE(pending 清理);图标 A 保留(已消费,不在 pending 列表)
场景 5:编辑分类 - 删除图标后取消
- 编辑已有分类(已有图标 A)→ 点击图标上的「删除」按钮
- 预期:图标 A 不会被删除(currentKey 为空,因为 A 是后端加载的已保存文件,不是本次会话上传的)
- 点击取消
- 预期:无 pending 文件需要清理(currentKey 为空)
场景 6:项目编辑 - 封面+视频,取消
- 进入项目编辑 → 替换封面 + 上传新视频
- 点击「取消」返回列表
- 预期:goBack 调用 cleanupPending,新上传的封面和视频都被 DELETE
场景 7:系统设置 - Logo 上传后切换页面
- 进入系统设置 → 上传新 Logo(未点保存)
- 直接点击侧边栏跳转到其他页面
- 预期:onBeforeUnmount 触发 cleanupPending,新 Logo 被 DELETE
场景 8:GC 兜底
- 上传文件后强制关闭浏览器(模拟用户中途离开)
- 等待 24 小时(或临时调整
UploadCleanupJob.ExpireHours为 0 立即触发) - 预期:后端 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,不串行等待,避免取消表单时长时间卡顿
实施顺序
- 阶段 A:后端编译验证
- 阶段 B1:api/index.ts 改造
- 阶段 B2:useUploadSession composable
- 阶段 C1:IconUpload.vue 改造
- 阶段 C2:VideoUpload.vue 改造
- 阶段 D:9 个页面逐一改造(先 Categories.vue 跑通,再批量套用模式)
- 验证:HMR + 8 个功能场景测试