384 lines
17 KiB
Markdown
384 lines
17 KiB
Markdown
# 垃圾资源清理机制实现计划
|
||
|
||
## 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 原值,前端 `<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` 末尾)
|
||
|
||
```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→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<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 共享一个 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. 确认已有数据的图标显示正常(未改存储格式,不应受影响)
|