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

384 lines
17 KiB
Markdown
Raw Permalink 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.

# 垃圾资源清理机制实现计划
## Context
当前系统所有上传文件图标、图片、视频、Logo 等)一旦上传到 COS/本地存储,就永远不会被删除。即使用户取消表单、替换文件、或删除实体,存储中的文件仍然残留,形成垃圾资源。本方案通过三层防御彻底解决此问题:
1. **前端即时清理**:取消表单时删除本次上传的文件;替换文件时立即删除旧的上传
2. **后端保存时清理**:更新实体时如果文件 URL 变化,删除旧文件
3. **后端 GC 兜底**PendingUploads 表 + 定时任务24 小时后清理未消费的孤立文件(应对浏览器关闭/刷新等极端情况)
## 关键架构决策(基于代码验证)
### 决策 1不修改各 Service 的存储格式
经代码验证,当前有两类存储格式:
- **存对象键**ProjectServiceCoverImage/VideoUrl 调 ExtractKey、ContentServiceBanner.Image/Partner.Logo 调 ExtractKey
- **存签名URL/原值**ProjectServiceCategory.Icon/Platform.Icon/Tag.Icon 直接赋值、UserServiceAvatar、AuthServiceAvatar、SystemServiceLogo 直接存值)
`FileStorageService.ExtractKey` 是幂等的传入签名URL→提取key传入key→原样返回传入本地URL→去前导斜杠。因此**在清理/消费时调用 ExtractKey 即可归一化**,无需修改存储格式。修改存储格式会破坏前端读取路径(如 GetCategoriesAsync 返回 Icon 原值,前端 `<img :src>` 直接渲染),风险过大。
### 决策 2DELETE 端点只删 pending 文件
为防止前端误删已被实体引用的文件DELETE `/api/system/upload` 只删除 `ConsumedAt == null` 的 PendingUpload 记录对应文件。已被消费(保存到实体)的文件通过 Service 层 Update 方法在替换时删除。
### 决策 3前端通过 key 跟踪上传
`UploadResultDto` 返回 `{ Url, Key }`。当前 IconUpload/VideoUpload 只用 Url丢弃 Key。改造后捕获 Key传给 `useUploadSession` 进行跟踪。DELETE 端点接收 key 参数。
## 垃圾场景覆盖
| 场景 | 前端 | 后端 | GC 兜底 |
|------|------|------|---------|
| 新建上传→取消弹窗 | `cleanupPending()` 调 DELETE key 删除文件 | — | 24h 后 GC |
| 编辑上传新文件→取消弹窗 | `cleanupPending()` 删除新文件(旧文件不受影响) | — | 24h 后 GC |
| 上传 A→上传 B 替换 | `deleteByUrl(A)` 立即调 DELETE 删 A | — | — |
| 上传→关闭浏览器 | 无法处理 | — | 24h 后 GC |
| 保存时替换文件 | `clear()` 不删文件 | Update 对比新旧ExtractKey(old)≠ExtractKey(new) 则删旧文件 | — |
| 保存新增文件 | `clear()` 不删文件 | ConsumeAsync(ExtractKey(new)) 标记消费 | — |
| 删除实体(软删除) | — | 保留文件(支持恢复) | — |
## 后端实现
### 1. 新增实体 PendingUpload`backend/MilliSim.Api/Common/Entities/Entities.cs` 末尾)
```csharp
/// <summary>
/// 待消费上传文件记录(用于垃圾资源清理跟踪)
/// </summary>
public class PendingUpload : BaseEntity
{
/// <summary>COS 对象键或本地访问 URL</summary>
[Required, StringLength(500)]
public string ObjectKey { get; set; } = string.Empty;
/// <summary>上传目录icons/videos/logos/packages 等)</summary>
[StringLength(50)]
public string Folder { get; set; } = string.Empty;
/// <summary>上传者用户 ID</summary>
public long? UploadedBy { get; set; }
/// <summary>上传时间</summary>
public DateTime UploadedAt { get; set; } = DateTime.Now;
/// <summary>消费时间null=待消费非null=已保存到实体)</summary>
public DateTime? ConsumedAt { get; set; }
}
```
> 注意:用 `ConsumedAt` 是否为 null 区分状态。BaseEntity 的全局软删除过滤器不影响IsDeleted 始终为 false
### 2. DbContext 注册(`backend/MilliSim.Api/Data/MilliSimDbContext.cs`
- L32 附近添加:`public DbSet<PendingUpload> PendingUploads => Set<PendingUpload>();`
- OnModelCreating 中添加索引:
```csharp
modelBuilder.Entity<PendingUpload>().HasIndex(p => p.ObjectKey);
modelBuilder.Entity<PendingUpload>().HasIndex(p => p.ConsumedAt);
```
### 3. DbSeeder 建表补丁(`backend/MilliSim.Api/Data/DbSeeder.cs` 的 `PatchDatabaseSchemaAsync`
在 try 块末尾添加 PendingUploads 表检测建表EnsureCreated 不更新已有库结构,需手动建表):
```csharp
// ===== PendingUploads 表:垃圾资源清理跟踪 =====
var pendingTableExists = await db.Database.SqlQueryRaw<string>(
"SELECT TABLE_NAME AS `Value` FROM INFORMATION_SCHEMA.TABLES " +
"WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'PendingUploads'"
).FirstOrDefaultAsync();
if (pendingTableExists == null)
{
await db.Database.ExecuteSqlRawAsync(@"
CREATE TABLE `PendingUploads` (
`Id` BIGINT NOT NULL AUTO_INCREMENT,
`ObjectKey` VARCHAR(500) NOT NULL,
`Folder` VARCHAR(50) NOT NULL DEFAULT '',
`UploadedBy` BIGINT NULL,
`UploadedAt` DATETIME NOT NULL,
`ConsumedAt` DATETIME NULL,
`CreatedAt` DATETIME NOT NULL,
`UpdatedAt` DATETIME NOT NULL,
`IsDeleted` BIT NOT NULL DEFAULT 0,
PRIMARY KEY (`Id`),
INDEX `IX_PendingUploads_ObjectKey` (`ObjectKey`),
INDEX `IX_PendingUploads_ConsumedAt` (`ConsumedAt`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;");
}
```
### 4. 新增 UploadTrackingService`backend/MilliSim.Api/Services/Infrastructure/UploadTrackingService.cs`
```csharp
public interface IUploadTrackingService
{
Task RegisterAsync(string objectKey, string folder, long? uploadedBy);
Task ConsumeAsync(string objectKey);
Task<bool> DeletePendingAsync(string objectKey); // 返回是否实际删除(仅删 pending
Task<int> CleanupExpiredAsync(int expireHours = 24);
}
public class UploadTrackingService : IUploadTrackingService
{
// RegisterAsync: 插入 PendingUpload 记录ObjectKey, Folder, UploadedBy, UploadedAt=now, ConsumedAt=null
// ConsumeAsync: 按 ObjectKey 查找 ConsumedAt==null 的记录,设 ConsumedAt=now找不到则静默跳过
// DeletePendingAsync: 查 ConsumedAt==null && ObjectKey==key 的记录,调 _fileStorage.DeleteAsync(key) 删文件 + 删记录;找不到返回 false
// CleanupExpiredAsync: 查 ConsumedAt==null && UploadedAt < now-expireHours逐条删文件 + 删记录,返回清理数量
}
```
依赖注入:`MilliSimDbContext``IFileStorageService``ILogger<UploadTrackingService>`
### 5. 修改 SystemController`backend/MilliSim.Api/Controllers/SystemController.cs`
**构造函数**:注入 `IUploadTrackingService``ICurrentUserService`
**Upload 方法**L147-175上传成功后注册 PendingUpload
```csharp
var fileUrl = await _fileStorage.UploadAsync(file, folder);
var signedUrl = _fileStorage.GetSignedUrl(fileUrl);
// 注册到 PendingUpload 跟踪表
await _uploadTracking.RegisterAsync(fileUrl, folder, _currentUser.UserId);
return ApiResponse<UploadResultDto>.Success(new UploadResultDto { Url = signedUrl, Key = fileUrl });
```
**新增 DELETE 端点**(在 Upload 方法后):
```csharp
[HttpDelete("upload")]
[Authorize(Policy = "Staff")]
public async Task<ApiResponse> DeleteUpload([FromQuery] string key)
{
if (string.IsNullOrWhiteSpace(key))
return ApiResponse.Fail("文件 key 不能为空");
var deleted = await _uploadTracking.DeletePendingAsync(key);
return ApiResponse.Success(deleted ? "已删除" : "文件不存在或已使用");
}
```
### 6. 修改各业务 Service消费 + 旧文件删除)
**核心模式**:每个 Service 注入 `IUploadTrackingService`。Create 方法保存后调 `ConsumeAsync(ExtractKey(fileValue))`Update 方法先记录旧值,保存后对比新旧,不同则删旧文件 + 消费新文件。
> `ExtractKey` 幂等签名URL→keykey→key。因此无论存储格式如何都能正确归一化。
| Service | 方法 | 文件字段 | 处理 |
|---------|------|---------|------|
| `ProjectService` | CreateProjectAsync L128 | CoverImage, VideoUrl | 保存后 ConsumeAsync(ExtractKey(CoverImage))、ConsumeAsync(ExtractKey(VideoUrl)) |
| `ProjectService` | UpdateProjectAsync L157 | CoverImage, VideoUrl | 记录旧 CoverImage/VideoUrl保存后 ExtractKey(旧)≠ExtractKey(新) 则 DeleteAsync(旧)ConsumeAsync(新)。VideoUrl 跳过 `/api/projects/` 代理 URL |
| `ProjectService` | CreateCategoryAsync L449 | Icon | 保存后 ConsumeAsync(ExtractKey(Icon)) |
| `ProjectService` | UpdateCategoryAsync L467 | Icon | 记录旧 Icon保存后 ExtractKey(旧)≠ExtractKey(新) 则 DeleteAsync(ExtractKey(旧))ConsumeAsync(ExtractKey(新)) |
| `ProjectService` | Create/UpdatePlatformAsync | Icon | 同 Category |
| `ProjectService` | Create/UpdateTagAsync | Icon | 同 Category |
| `ContentService` | CreateBannerAsync L63 | Image | 保存后 ConsumeAsync(ExtractKey(Image)) |
| `ContentService` | UpdateBannerAsync L89 | Image | 记录旧 ImageExtractKey(旧)≠ExtractKey(新) 则 DeleteAsyncConsumeAsync(新) |
| `ContentService` | Create/UpdatePartnerAsync | Logo | 同 Banner |
| `UserService` | CreateUserAsync L139 | Avatar | 保存后 ConsumeAsync(ExtractKey(Avatar)) |
| `UserService` | UpdateUserAsync L199 | Avatar | 记录旧 AvatarExtractKey(旧)≠ExtractKey(新) 则 DeleteAsyncConsumeAsync(新) |
| `AuthService` | UpdateProfileAsync L140 | Avatar | 注入 IFileStorageService + IUploadTrackingService同 UpdateUserAsync |
| `SystemService` | UpdateSettingsAsync L54 | Logo* 五项 | 查旧值→保存→对比 LogoFrontend/LogoBackend/LogoFavicon/LogoLogin/LogoFooter不同则删旧+消费新 |
**SystemService.UpdateSettingsAsync 特殊处理**Logo 五项):
```csharp
// 保存前查询旧 Logo 值
var logoKeys = new[] { "LogoFrontend", "LogoBackend", "LogoFavicon", "LogoLogin", "LogoFooter" };
var oldLogos = await _db.SystemSettings
.Where(s => logoKeys.Contains(s.Key))
.ToDictionaryAsync(s => s.Key, s => s.Value);
// ... 原有保存逻辑 ...
// 保存后对比 Logo 变化
foreach (var logoKey in logoKeys)
{
if (request.Settings.TryGetValue(logoKey, out var newValue))
{
var oldKey = _fileStorage.ExtractKey(oldLogos.GetValueOrDefault(logoKey) ?? "");
var newKey = _fileStorage.ExtractKey(newValue);
if (!string.IsNullOrEmpty(oldKey) && oldKey != newKey)
{
await _fileStorage.DeleteAsync(oldKey);
}
if (!string.IsNullOrEmpty(newKey))
{
await _uploadTracking.ConsumeAsync(newKey);
}
}
}
```
### 7. 新增 GC 后台任务(`backend/MilliSim.Api/Services/UploadCleanupJob.cs`
参照 `AuthorizationNotificationJob` 模式IHostedService + Timer + CreateScope
- 每 6 小时执行一次 `CleanupExpiredAsync(24)`
- 清理 `ConsumedAt == null && UploadedAt < now - 24h` 的记录及对应文件
### 8. Program.cs 注册
```csharp
builder.Services.AddScoped<IUploadTrackingService, UploadTrackingService>();
builder.Services.AddHostedService<UploadCleanupJob>();
```
## 前端实现
### 9. 新增 API 方法(`frontend/src/api/index.ts` 的 systemApi
```typescript
// 删除未使用的上传文件(仅限 pending 状态)
deleteUpload: (key: string) => http.delete<ApiResponse>('/system/upload', { params: { key } }),
```
同时修正 upload 方法的返回类型:
```typescript
upload: (formData: FormData) => http.upload<ApiResponse<{ url: string; key: string }>>('/system/upload', formData),
```
### 10. 新增 Composable`frontend/src/composables/useUploadSession.ts`
需先创建 `frontend/src/composables/` 目录。
```typescript
import { reactive } from 'vue'
import { systemApi } from '@/api'
interface TrackedUpload {
key: string
url: string
}
export function useUploadSession() {
const tracked = reactive<TrackedUpload[]>([])
/** 上传成功后注册跟踪 */
function track(key: string, url: string) {
if (key) tracked.push({ key, url })
}
/** 替换时删除旧上传(仅在本次会话跟踪中的) */
async function deleteByUrl(url: string) {
if (!url) return
const idx = tracked.findIndex(t => t.url === url)
if (idx === -1) return // 不在跟踪列表中(可能是已保存的旧文件),不删
const item = tracked[idx]
try {
await systemApi.deleteUpload(item.key)
} catch { /* 忽略删除失败 */ }
tracked.splice(idx, 1)
}
/** 取消表单时删除全部跟踪文件 */
async function cleanupPending() {
for (const item of [...tracked]) {
try {
await systemApi.deleteUpload(item.key)
} catch { /* 忽略 */ }
}
tracked.length = 0
}
/** 保存成功后清空跟踪(不删文件,后端标记消费) */
function clear() {
tracked.length = 0
}
return { tracked, track, deleteByUrl, cleanupPending, clear }
}
```
### 11. 修改 IconUpload.vue / VideoUpload.vue
**IconUpload.vue 改造点**
1. 新增可选 prop`uploadSession?: ReturnType<typeof useUploadSession>`
2. `uploadFile` 成功后:
- 如果当前 `modelValue` 在 session 跟踪中(替换场景),先 `uploadSession.deleteByUrl(modelValue)`
- 如果有 session`uploadSession.track(response.data.key, response.data.url)`
- `emit('update:modelValue', response.data.url)`
3. `handleDelete`:如果有 session 且当前值在跟踪中,`uploadSession.deleteByUrl(modelValue)` 后再 emit 空
4. XHR 响应类型增加 `key` 字段:`{ code: number; data?: { url: string; key: string } }`
**VideoUpload.vue**:同样改造(如有)。
> 不传 uploadSession 时行为完全不变(向后兼容)。
### 12. 修改表单页面
**弹窗模式页面**Categories/Banners/Partners/Platforms/Tags/Employees/Customers
```typescript
import { useUploadSession } from '@/composables/useUploadSession'
const uploadSession = useUploadSession()
function handleAdd() {
uploadSession.clear() // 重置跟踪
resetForm()
// ...
}
function handleEdit(row) {
uploadSession.clear() // 重置跟踪(编辑已有数据,旧文件不在跟踪中)
// 填充表单...
}
// 取消按钮改为 async
async function handleCancel() {
await uploadSession.cleanupPending()
dialogVisible.value = false
}
async function handleSubmit() {
// ... 保存逻辑 ...
uploadSession.clear() // 保存成功,不删文件
dialogVisible.value = false
}
```
模板传入:`<IconUpload v-model="form.icon" :upload-session="uploadSession" folder="..." />`
**页面级模式**Settings/ProjectEdit
```typescript
const uploadSession = useUploadSession()
// 保存成功后
function onSaveSuccess() {
uploadSession.clear()
}
// 离开页面时清理onBeforeRouteLeave 或 onBeforeUnmount
import { onBeforeRouteLeave } from 'vue-router'
onBeforeRouteLeave(async () => {
await uploadSession.cleanupPending()
})
```
**Settings.vue**5 个 Logo 共享一个 uploadSessionsaveLogo 成功后 clear()。
**ProjectEdit.vue**CoverImage + VideoUpload 共享一个 uploadSession保存成功后 clear()goBack 前 cleanupPending()。
### 需修改的页面清单
| 页面 | 类型 | 上传组件 | 字段 |
|------|------|---------|------|
| Categories.vue | 弹窗 | IconUpload | icon |
| Platforms.vue | 弹窗 | IconUpload | icon |
| Tags.vue | 弹窗 | IconUpload | icon |
| Banners.vue | 弹窗 | IconUpload | image |
| Partners.vue | 弹窗 | IconUpload | logo |
| Employees.vue | 弹窗 | IconUpload | avatar |
| Customers.vue | 弹窗 | IconUpload | avatar |
| Settings.vue | 页面级 | IconUpload×5 | Logo*5 |
| ProjectEdit.vue | 页面级 | IconUpload + VideoUpload | coverImage + video |
## 实现顺序
1. **后端基础**PendingUpload 实体 → DbContext → DbSeeder → UploadTrackingService → Program.cs 注册
2. **后端端点**SystemController Upload 注册 + DELETE 端点
3. **后端 GC**UploadCleanupJob → Program.cs 注册
4. **后端业务**:各 Service 注入 + 消费 + 旧文件删除ProjectService、ContentService、UserService、AuthService、SystemService
5. **前端基础**API 方法 → useUploadSession composable
6. **前端组件**IconUpload + VideoUpload 改造
7. **前端页面**9 个页面统一接入
## 验证方案
1. 新建分类→上传图标→取消→检查 DELETE `/api/system/upload?key=xxx` 被调用,文件已删
2. 新建分类→上传 A→上传 B 替换→检查 A 的 DELETE 被调用
3. 新建分类→上传图标→保存→检查无 DELETE 调用PendingUpload 记录 ConsumedAt 已设置
4. 编辑分类→替换图标→保存→检查旧文件被删(后端 Service 层 DeleteAsync
5. 上传图标→关闭浏览器→等待 GC 执行→检查文件已清理
6. 后端编译通过 + 前端 HMR 无报错
7. 确认已有数据的图标显示正常(未改存储格式,不应受影响)