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

295 lines
15 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 上传垃圾资源清理 - 前端实施计划
## 概述
延续上一会话已完成的后端基础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<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` 目录。
```typescript
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.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 是签名URLcurrentKey 为空,
// 只有用户主动上传后 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。
### 阶段 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. 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. 模板中所有 `<IconUpload>` 增加 `: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. 模板中 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 端口可用
- **决策 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. 阶段 B1api/index.ts 改造
3. 阶段 B2useUploadSession composable
4. 阶段 C1IconUpload.vue 改造
5. 阶段 C2VideoUpload.vue 改造
6. 阶段 D9 个页面逐一改造(先 Categories.vue 跑通,再批量套用模式)
7. 验证HMR + 8 个功能场景测试