295 lines
15 KiB
Markdown
295 lines
15 KiB
Markdown
# 上传垃圾资源清理 - 前端实施计划
|
||
|
||
## 概述
|
||
|
||
延续上一会话已完成的后端基础(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 {
|
||
/** 注册一个上传的 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`
|
||
|
||
修改点:
|
||
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. 模板中所有 `<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. 模板中 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 个功能场景测试
|