MilliSim/.trae/documents/upload-garbage-cleanup-frontend.md

15 KiB
Raw Blame History

上传垃圾资源清理 - 前端实施计划

概述

延续上一会话已完成的后端基础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.csPatchDatabaseSchemaAsync 末尾新增建表补丁
  • Services/Infrastructure/UploadTrackingService.csRegisterAsync / ConsumeAsync / DeletePendingAsync / CleanupExpiredAsync
  • Services/UploadCleanupJob.csIHostedService + Timer启动 5 分钟后首次执行,之后每 6 小时一次,清理 24h 未消费文件)
  • Program.cs:注册 IUploadTrackingServiceScoped+ UploadCleanupJobHosted
  • Controllers/SystemController.cs
    • Upload 端点:成功后 RegisterAsync,返回 { url, key }
    • 新增 DELETE /api/system/upload?key=xxx:仅删 pending 状态文件
  • Services/ProjectService.csContentService.csUserService.csAuthService.csSystemService.cs
    • Create保存后 ConsumeAsync(ExtractKey(field))
    • Update记录 oldKey/newKey保存后对比不同则 DeleteAsync(oldKey) + ConsumeAsync(newKey)

前端(未实施,本计划范围)

  • api/index.tssystemApi.upload 返回类型仅 { url: string },缺 key;无 deleteUpload 方法
  • components/IconUpload.vueXHR 上传只用 response.data.url,丢弃 keyhandleDeleteemit('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) 删除(不等表单取消)
  • 会话生命周期 — 弹窗页面:openDialogresetSession()cancelDialog/closedcleanupPending();页面级:onMounted 时创建,onBeforeUnmountcleanupPending(),保存成功后 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

// 通用文件上传接口(返回 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 {
  /** 注册一个上传的 keyemit 前调用) */
  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

修改点:

  1. 新增 prop uploadSession?(可选,类型为 useUploadSession 返回的对象的方法集合)
  2. uploadFile 成功后:用 response.data.keyuploadSession?.register(key),再 emit url
  3. handleDelete:若当前 modelValue 是本次会话上传的 key即 uploadSession 中存在),调 systemApi.deleteUpload(key) 即时删除emit ''
  4. 替换图片场景:uploadFile 成功后,若旧 modelValue 是 pending keydeleteUpload(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 是签名URLcurrentKey 为空,
        // 只有用户主动上传后 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。

阶段 D9 个表单页面接入

所有页面统一模式:

  • 弹窗页面:在 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. setupconst 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. 模板中所有 <IconUpload> 增加 :upload-session="uploadSession"

页面级页面2 个)

views/admin/Settings.vue — 5 个 Logo

  1. 导入 useUploadSession
  2. setupconst 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. setupconst uploadSession = useUploadSession()
  3. fetchProject 完成后 uploadSession.reset()
  4. handleSave 成功后(无论 create 还是 updateuploadSession.reset()
  5. goBack 函数:先 await uploadSession.cleanupPending()router.push
  6. 模板中 IconUploadcoverImage和 VideoUploadvideoUrl都传 :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 被 DELETEcleanupPending 清理)

场景 3编辑分类 - 替换图标后保存

  1. 编辑已有分类 → 上传新图标
  2. 点击确定保存
  3. 预期:后端 Service Update 方法删除旧文件(已保存的 COS 文件)+ ConsumeAsync 新文件

场景 4编辑分类 - 替换图标后取消

  1. 编辑已有分类(已有图标 A→ 上传新图标 B
  2. 点击取消
  3. 预期:图标 B 被 DELETEpending 清理);图标 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

场景 8GC 兜底

  1. 上传文件后强制关闭浏览器(模拟用户中途离开)
  2. 等待 24 小时(或临时调整 UploadCleanupJob.ExpireHours 为 0 立即触发)
  3. 预期:后端 GC 任务清理该 pending 文件

假设与决策

  • 假设 1:后端编译只需通过,无新错误(之前已有 2 个无害 nullable 警告)
  • 假设 2:前端 Vite dev server 在 5173 端口可用
  • 决策 1IconUpload 内部维护 currentKey,仅当 currentKey 非空时(即用户本次会话主动上传)才在 delete/replace 时调 DELETE从后端加载的已保存文件currentKey 为空)不删,避免误删已消费文件
  • 决策 2useUploadSession 通过 onBeforeUnmount 自动 cleanup作为兜底显式调用 cleanupPending 用于「取消按钮」「goBack」等需要立即清理的场景
  • 决策 3:保存成功后必须调 reset() 清空 pending 列表,防止 onBeforeUnmount 重复 DELETE 已被后端消费的文件DeletePendingAsync 会因 ConsumedAt != null 而拒绝删除,但调用本身是无意义的网络请求)
  • 决策 4cleanupPending 内部对每个 DELETE 用 .catch(() => {}) 静默失败,不阻塞 UX
  • 决策 5:批量并发删除使用 Promise.all,不串行等待,避免取消表单时长时间卡顿

实施顺序

  1. 阶段 A后端编译验证
  2. 阶段 B1api/index.ts 改造
  3. 阶段 B2useUploadSession composable
  4. 阶段 C1IconUpload.vue 改造
  5. 阶段 C2VideoUpload.vue 改造
  6. 阶段 D9 个页面逐一改造(先 Categories.vue 跑通,再批量套用模式)
  7. 验证HMR + 8 个功能场景测试