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

17 KiB
Raw Permalink Blame History

垃圾资源清理机制实现计划

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. 新增实体 PendingUploadbackend/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.csPatchDatabaseSchemaAsync

在 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. 新增 UploadTrackingServicebackend/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逐条删文件 + 删记录,返回清理数量
}

依赖注入:MilliSimDbContextIFileStorageServiceILogger<UploadTrackingService>

5. 修改 SystemControllerbackend/MilliSim.Api/Controllers/SystemController.cs

构造函数:注入 IUploadTrackingServiceICurrentUserService

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→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 五项):

// 保存前查询旧 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. 新增 Composablefrontend/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 改造点

  1. 新增可选 propuploadSession?: ReturnType<typeof useUploadSession>
  2. uploadFile 成功后:
    • 如果当前 modelValue 在 session 跟踪中(替换场景),先 uploadSession.deleteByUrl(modelValue)
    • 如果有 sessionuploadSession.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

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.vue5 个 Logo 共享一个 uploadSessionsaveLogo 成功后 clear()。

ProjectEdit.vueCoverImage + 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. 后端 GCUploadCleanupJob → 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. 确认已有数据的图标显示正常(未改存储格式,不应受影响)