Skip to content
/ core Public

Commit 2b4e946

Browse files
committed
fix(file): orphan cleanup idempotency; remove orphan cleanup cron
- Stop scheduling cleanupOrphanImages in CronTaskScheduler; drop cron type and CronBusinessService wrapper for orphan images. - When deleting local orphan files, treat ENOENT as already removed so DB records are cleared and missing-file API errors are avoided. - Add unit test for ENOENT path in cleanupOrphanFiles. Made-with: Cursor
1 parent 96805c9 commit 2b4e946

File tree

5 files changed

+67
-37
lines changed

5 files changed

+67
-37
lines changed

apps/core/src/modules/cron-task/cron-business.service.ts

Lines changed: 0 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@ import { STATIC_FILE_TRASH_DIR, TEMP_DIR } from '~/constants/path.constant'
99
import { AggregateService } from '~/modules/aggregate/aggregate.service'
1010
import { AnalyzeModel } from '~/modules/analyze/analyze.model'
1111
import { ConfigsService } from '~/modules/configs/configs.service'
12-
import { FileReferenceService } from '~/modules/file/file-reference.service'
1312
import { SearchService } from '~/modules/search/search.service'
1413
import { HttpService } from '~/processors/helper/helper.http.service'
1514
import type { StoreJWTPayload } from '~/processors/helper/helper.jwt.service'
@@ -33,7 +32,6 @@ export class CronBusinessService {
3332
@InjectModel(AnalyzeModel)
3433
private readonly analyzeModel: MongooseModel<AnalyzeModel>,
3534
private readonly redisService: RedisService,
36-
private readonly fileReferenceService: FileReferenceService,
3735

3836
@Inject(forwardRef(() => AggregateService))
3937
private readonly aggregateService: AggregateService,
@@ -225,19 +223,6 @@ export class CronBusinessService {
225223
return { deletedCount: deleteCount }
226224
}
227225

228-
/**
229-
* 清理孤儿图片
230-
*/
231-
async cleanupOrphanImages() {
232-
this.logger.log('--> 开始清理孤儿图片')
233-
const { deletedCount, totalOrphan } =
234-
await this.fileReferenceService.cleanupOrphanFiles(60)
235-
this.logger.log(
236-
`--> 清理孤儿图片完成:删除了 ${deletedCount}/${totalOrphan} 个文件`,
237-
)
238-
return { deletedCount, totalOrphan }
239-
}
240-
241226
/**
242227
* 重建搜索索引
243228
*/

apps/core/src/modules/cron-task/cron-task.scheduler.ts

Lines changed: 0 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -93,17 +93,6 @@ export class CronTaskScheduler {
9393
)
9494
}
9595

96-
@CronOnce(CronExpression.EVERY_HOUR, { name: 'cleanupOrphanImages' })
97-
async scheduleCleanupOrphanImages() {
98-
this.logger.log('Scheduling cleanupOrphanImages task')
99-
const result = await this.cronTaskService.createCronTask(
100-
CronTaskType.CleanupOrphanImages,
101-
)
102-
this.logger.log(
103-
`cleanupOrphanImages task ${result.created ? 'created' : 'already exists'}: ${result.taskId}`,
104-
)
105-
}
106-
10796
@CronOnce(CronExpression.EVERY_DAY_AT_4AM, { name: 'rebuildSearchIndex' })
10897
async scheduleRebuildSearchIndex() {
10998
this.logger.log('Scheduling rebuildSearchIndex task')

apps/core/src/modules/cron-task/cron-task.types.ts

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@ export const CronTaskType = {
66
PushToBaiduSearch: 'cron:push-to-baidu-search',
77
PushToBingSearch: 'cron:push-to-bing-search',
88
DeleteExpiredJWT: 'cron:delete-expired-jwt',
9-
CleanupOrphanImages: 'cron:cleanup-orphan-images',
109
RebuildSearchIndex: 'cron:rebuild-search-index',
1110
} as const
1211

@@ -66,12 +65,6 @@ export const CronTaskMetas: Record<
6665
cronExpression: 'EVERY_DAY_AT_1AM',
6766
methodName: 'deleteExpiredJWT',
6867
},
69-
[CronTaskType.CleanupOrphanImages]: {
70-
name: 'cleanupOrphanImages',
71-
description: '清理孤儿图片',
72-
cronExpression: 'EVERY_HOUR',
73-
methodName: 'cleanupOrphanImages',
74-
},
7568
[CronTaskType.RebuildSearchIndex]: {
7669
name: 'rebuildSearchIndex',
7770
description: '重建搜索索引',

apps/core/src/modules/file/file-reference.service.ts

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,30 @@ interface ContentLike {
2626
export class FileReferenceService {
2727
private readonly logger = new Logger(FileReferenceService.name)
2828

29+
private isEnoent(err: unknown): boolean {
30+
return (
31+
typeof err === 'object' &&
32+
err !== null &&
33+
'code' in err &&
34+
(err as NodeJS.ErrnoException).code === 'ENOENT'
35+
)
36+
}
37+
38+
/**
39+
* 删除本地孤儿图片文件。若磁盘上已不存在(ENOENT),视为已清理成功,避免 DB 残留导致接口仍引用缺失文件。
40+
*/
41+
private async unlinkLocalOrphanImage(fileName: string): Promise<void> {
42+
const localPath = path.join(STATIC_FILE_DIR, 'image', fileName)
43+
try {
44+
await unlink(localPath)
45+
} catch (err) {
46+
if (this.isEnoent(err)) {
47+
return
48+
}
49+
throw err
50+
}
51+
}
52+
2953
constructor(
3054
@InjectModel(FileReferenceModel)
3155
private readonly fileReferenceModel: MongooseModel<FileReferenceModel>,
@@ -131,8 +155,7 @@ export class FileReferenceService {
131155
}
132156
await s3Uploader.deleteObject(file.s3ObjectKey)
133157
} else if (file.fileUrl.includes('/objects/image/')) {
134-
const localPath = path.join(STATIC_FILE_DIR, 'image', file.fileName)
135-
await unlink(localPath)
158+
await this.unlinkLocalOrphanImage(file.fileName)
136159
} else {
137160
continue
138161
}
@@ -175,8 +198,7 @@ export class FileReferenceService {
175198
return true
176199
}
177200
if (file.fileUrl.includes('/objects/image/')) {
178-
const localPath = path.join(STATIC_FILE_DIR, 'image', file.fileName)
179-
await unlink(localPath)
201+
await this.unlinkLocalOrphanImage(file.fileName)
180202
return true
181203
}
182204
return false

apps/core/test/src/modules/file/file-reference.service.spec.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { unlink } from 'node:fs/promises'
2+
13
import { Test } from '@nestjs/testing'
24
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
35

@@ -10,6 +12,11 @@ import {
1012
import { FileReferenceService } from '~/modules/file/file-reference.service'
1113
import { getModelToken } from '~/transformers/model.transformer'
1214

15+
vi.mock('node:fs/promises', async (importOriginal) => {
16+
const actual = await importOriginal<typeof import('node:fs/promises')>()
17+
return { ...actual, unlink: vi.fn(actual.unlink) }
18+
})
19+
1320
describe('FileReferenceService', () => {
1421
let fileReferenceService: FileReferenceService
1522
let mockReferences: any[]
@@ -140,6 +147,40 @@ describe('FileReferenceService', () => {
140147
mockReferences = []
141148
})
142149

150+
describe('cleanupOrphanFiles', () => {
151+
beforeEach(() => {
152+
vi.mocked(unlink).mockReset()
153+
vi.mocked(unlink).mockResolvedValue(undefined)
154+
})
155+
156+
it('should delete DB record when local file is already missing (ENOENT)', async () => {
157+
const orphan = {
158+
_id: 'ref_orphan',
159+
fileUrl: 'http://example.com/objects/image/gone.png',
160+
fileName: 'gone.png',
161+
status: FileReferenceStatus.Pending,
162+
created: new Date(Date.now() - 120 * 60 * 1000),
163+
}
164+
mockReferences.push(orphan)
165+
166+
const model = fileReferenceService['fileReferenceModel'] as {
167+
find: ReturnType<typeof vi.fn>
168+
deleteOne: ReturnType<typeof vi.fn>
169+
}
170+
vi.spyOn(model, 'find').mockResolvedValue([orphan] as never)
171+
const deleteOneSpy = vi.spyOn(model, 'deleteOne')
172+
173+
vi.mocked(unlink).mockRejectedValueOnce(
174+
Object.assign(new Error('ENOENT'), { code: 'ENOENT' }),
175+
)
176+
177+
const result = await fileReferenceService.cleanupOrphanFiles(60)
178+
179+
expect(deleteOneSpy).toHaveBeenCalledWith({ _id: 'ref_orphan' })
180+
expect(result.deletedCount).toBe(1)
181+
})
182+
})
183+
143184
describe('createPendingReference', () => {
144185
it('should create a pending reference for a new file', async () => {
145186
const fileUrl = 'http://example.com/objects/image/test.jpg'

0 commit comments

Comments
 (0)