17 KiB
垃圾资源清理机制实现计划
Context
当前系统所有上传文件(图标、图片、视频、Logo 等)一旦上传到 COS/本地存储,就永远不会被删除。即使用户取消表单、替换文件、或删除实体,存储中的文件仍然残留,形成垃圾资源。本方案通过三层防御彻底解决此问题:
- 前端即时清理:取消表单时删除本次上传的文件;替换文件时立即删除旧的上传
- 后端保存时清理:更新实体时如果文件 URL 变化,删除旧文件
- 后端 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 原值,前端 <img :src> 直接渲染),风险过大。
决策 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 末尾)
/// <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 中添加索引:
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 不更新已有库结构,需手动建表):
// ===== 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)
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:
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 方法后):
[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→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 五项):
// 保存前查询旧 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 注册
builder.Services.AddScoped<IUploadTrackingService, UploadTrackingService>();
builder.Services.AddHostedService<UploadCleanupJob>();
前端实现
9. 新增 API 方法(frontend/src/api/index.ts 的 systemApi)
// 删除未使用的上传文件(仅限 pending 状态)
deleteUpload: (key: string) => http.delete<ApiResponse>('/system/upload', { params: { key } }),
同时修正 upload 方法的返回类型:
upload: (formData: FormData) => http.upload<ApiResponse<{ url: string; key: string }>>('/system/upload', formData),
10. 新增 Composable(frontend/src/composables/useUploadSession.ts)
需先创建 frontend/src/composables/ 目录。
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 改造点:
- 新增可选 prop:
uploadSession?: ReturnType<typeof useUploadSession> uploadFile成功后:- 如果当前
modelValue在 session 跟踪中(替换场景),先uploadSession.deleteByUrl(modelValue) - 如果有 session,
uploadSession.track(response.data.key, response.data.url) emit('update:modelValue', response.data.url)
- 如果当前
handleDelete:如果有 session 且当前值在跟踪中,uploadSession.deleteByUrl(modelValue)后再 emit 空- XHR 响应类型增加
key字段:{ code: number; data?: { url: string; key: string } }
VideoUpload.vue:同样改造(如有)。
不传 uploadSession 时行为完全不变(向后兼容)。
12. 修改表单页面
弹窗模式页面(Categories/Banners/Partners/Platforms/Tags/Employees/Customers):
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):
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 |
实现顺序
- 后端基础:PendingUpload 实体 → DbContext → DbSeeder → UploadTrackingService → Program.cs 注册
- 后端端点:SystemController Upload 注册 + DELETE 端点
- 后端 GC:UploadCleanupJob → Program.cs 注册
- 后端业务:各 Service 注入 + 消费 + 旧文件删除(ProjectService、ContentService、UserService、AuthService、SystemService)
- 前端基础:API 方法 → useUploadSession composable
- 前端组件:IconUpload + VideoUpload 改造
- 前端页面:9 个页面统一接入
验证方案
- 新建分类→上传图标→取消→检查 DELETE
/api/system/upload?key=xxx被调用,文件已删 - 新建分类→上传 A→上传 B 替换→检查 A 的 DELETE 被调用
- 新建分类→上传图标→保存→检查无 DELETE 调用,PendingUpload 记录 ConsumedAt 已设置
- 编辑分类→替换图标→保存→检查旧文件被删(后端 Service 层 DeleteAsync)
- 上传图标→关闭浏览器→等待 GC 执行→检查文件已清理
- 后端编译通过 + 前端 HMR 无报错
- 确认已有数据的图标显示正常(未改存储格式,不应受影响)