# 垃圾资源清理机制实现计划 ## 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. 确认已有数据的图标显示正常(未改存储格式,不应受影响)