# 垃圾资源清理机制实现计划
## Context
当前系统所有上传文件(图标、图片、视频、Logo 等)一旦上传到 COS/本地存储,就永远不会被删除。即使用户取消表单、替换文件、或删除实体,存储中的文件仍然残留,形成垃圾资源。本方案通过三层防御彻底解决此问题:
1. **前端即时清理**:取消表单时删除本次上传的文件;替换文件时立即删除旧的上传
2. **后端保存时清理**:更新实体时如果文件 URL 变化,删除旧文件
3. **后端 GC 兜底**:PendingUploads 表 + 定时任务,24 小时后清理未消费的孤立文件(应对浏览器关闭/刷新等极端情况)
## 关键架构决策(基于代码验证)
### 决策 1:不修改各 Service 的存储格式
经代码验证,当前有两类存储格式:
- **存对象键**:ProjectService(CoverImage/VideoUrl 调 ExtractKey)、ContentService(Banner.Image/Partner.Logo 调 ExtractKey)
- **存签名URL/原值**:ProjectService(Category.Icon/Platform.Icon/Tag.Icon 直接赋值)、UserService(Avatar)、AuthService(Avatar)、SystemService(Logo 直接存值)
`FileStorageService.ExtractKey` 是幂等的:传入签名URL→提取key,传入key→原样返回,传入本地URL→去前导斜杠。因此**在清理/消费时调用 ExtractKey 即可归一化**,无需修改存储格式。修改存储格式会破坏前端读取路径(如 GetCategoriesAsync 返回 Icon 原值,前端 `
` 直接渲染),风险过大。
### 决策 2:DELETE 端点只删 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
///
/// 待消费上传文件记录(用于垃圾资源清理跟踪)
///
public class PendingUpload : BaseEntity
{
/// COS 对象键或本地访问 URL
[Required, StringLength(500)]
public string ObjectKey { get; set; } = string.Empty;
/// 上传目录(icons/videos/logos/packages 等)
[StringLength(50)]
public string Folder { get; set; } = string.Empty;
/// 上传者用户 ID
public long? UploadedBy { get; set; }
/// 上传时间
public DateTime UploadedAt { get; set; } = DateTime.Now;
/// 消费时间(null=待消费,非null=已保存到实体)
public DateTime? ConsumedAt { get; set; }
}
```
> 注意:用 `ConsumedAt` 是否为 null 区分状态。BaseEntity 的全局软删除过滤器不影响(IsDeleted 始终为 false)。
### 2. DbContext 注册(`backend/MilliSim.Api/Data/MilliSimDbContext.cs`)
- L32 附近添加:`public DbSet PendingUploads => Set();`
- OnModelCreating 中添加索引:
```csharp
modelBuilder.Entity().HasIndex(p => p.ObjectKey);
modelBuilder.Entity().HasIndex(p => p.ConsumedAt);
```
### 3. DbSeeder 建表补丁(`backend/MilliSim.Api/Data/DbSeeder.cs` 的 `PatchDatabaseSchemaAsync`)
在 try 块末尾添加 PendingUploads 表检测建表(EnsureCreated 不更新已有库结构,需手动建表):
```csharp
// ===== PendingUploads 表:垃圾资源清理跟踪 =====
var pendingTableExists = await db.Database.SqlQueryRaw(
"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 DeletePendingAsync(string objectKey); // 返回是否实际删除(仅删 pending)
Task 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`。
### 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.Success(new UploadResultDto { Url = signedUrl, Key = fileUrl });
```
**新增 DELETE 端点**(在 Upload 方法后):
```csharp
[HttpDelete("upload")]
[Authorize(Policy = "Staff")]
public async Task 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→key,key→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 | 记录旧 Image;ExtractKey(旧)≠ExtractKey(新) 则 DeleteAsync;ConsumeAsync(新) |
| `ContentService` | Create/UpdatePartnerAsync | Logo | 同 Banner |
| `UserService` | CreateUserAsync L139 | Avatar | 保存后 ConsumeAsync(ExtractKey(Avatar)) |
| `UserService` | UpdateUserAsync L199 | Avatar | 记录旧 Avatar;ExtractKey(旧)≠ExtractKey(新) 则 DeleteAsync;ConsumeAsync(新) |
| `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();
builder.Services.AddHostedService();
```
## 前端实现
### 9. 新增 API 方法(`frontend/src/api/index.ts` 的 systemApi)
```typescript
// 删除未使用的上传文件(仅限 pending 状态)
deleteUpload: (key: string) => http.delete('/system/upload', { params: { key } }),
```
同时修正 upload 方法的返回类型:
```typescript
upload: (formData: FormData) => http.upload>('/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([])
/** 上传成功后注册跟踪 */
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`
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
}
```
模板传入:``
**页面级模式**(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 共享一个 uploadSession,saveLogo 成功后 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. 确认已有数据的图标显示正常(未改存储格式,不应受影响)