Skip to content

Commit 1156cd5

Browse files
iwangbowenCopilot
andcommitted
fix(cms): 整站重建后自动清扫孤儿静态产物
问题 静态产物路径由栏目路径、详情页归档规则、内容 slug/staticPath 共同决定,任一变更都会 让旧路径下的文件失去归属。此前只有内容级增量刷新会按快照 deletePaths 精确删除旧路径, 栏目改路径/改归档规则、站点改主题等触发的整站重建只写新文件、不清旧文件,孤儿会持续 堆积;在 hybrid/static 模式下旧 URL 还会一直返回过期内容。clearSiteStatic() 虽已存在 但从未被调用,说明该问题长期无人兜底。 方案(mark & sweep) - mark:buildSiteStatic 用 AsyncLocalStorage 收集本次写入的全部相对路径。选择 AsyncLocalStorage 而非层层透传,是因为写文件散落在 writeRenderedPath / regenerateChannelPages / refreshHomeStaticForChannel 等多个 helper 中,透传易漏, 漏一处就会把有效产物误判成孤儿删掉;该模式与 cms-publish-artifact-tracker 一致, 且天然按调用栈隔离,多站点串行重建互不串扰 - sweep:构建成功结束时递归遍历站点静态目录,删除不在写入集合中的文件, 并自底向上回收空目录。删除走 deleteStaticFile,因此受发布围栏保护, 且每个被删文件都会落一条 deleted 产物记录可供审计 - 清扫数量回填任务结果 prunedArtifacts 安全边界 - 断点续跑(resumeAfterKey 非空)跳过清扫:续跑只写了后半程,集合不完整 - 取消的构建一律 pruned: 0 - 增量刷新路径不受影响,仍按快照精确删除 路径归一化复用 pathToStaticFile:写入侧记的是 URL 形态(`news/`、``),磁盘上是 `news/index.html`、`index.html`。实测中两侧映射不一致会导致首页与全部栏目页被误判成 孤儿删光,已加回归用例锁定;另加接线断言防止后人移除续跑守卫。 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
1 parent 520cf1e commit 1156cd5

5 files changed

Lines changed: 258 additions & 16 deletions

File tree

docs/cms/static-and-render.md

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,29 @@
6969
- 日期类规则取 `publishedAt`,未发布时回退 `createdAt`;两者都缺失则退化为不归档,不会产生 `undefined` 目录
7070
- 规则由 `contentUrl()` 统一计算,静态化写文件与模板生成链接共用同一函数,不存在两侧算不一致的可能
7171

72-
> ⚠️ **改动规则会使旧产物成为孤儿**。栏目保存会触发整站重建,新路径产物即时生成;旧路径下的文件不会自动删除(与「修改栏目 slug/path」的既有行为一致),需要时用 `cms_redirects` 配 301 或手工清理静态目录。
72+
> ⚠️ **改动规则会使旧产物成为孤儿**。栏目保存会触发整站重建,新路径产物即时生成;旧路径下的文件由重建结束时的**孤儿清扫**自动删除(见下),无需手工清理。若需保留旧 URL 可访问,请在「SEO 管理 → 301 重定向」中配置跳转。
73+
74+
### 孤儿产物清扫
75+
76+
静态产物的路径由栏目路径、归档规则、内容 slug/staticPath 等共同决定,任何一项变更都会让旧路径下的文件失去归属。**整站重建结束时会自动清扫这些孤儿**
77+
78+
- **mark**:构建过程中用 AsyncLocalStorage 收集本次写入的全部相对路径(含各发布通道的 `__{code}/` 子树)
79+
- **sweep**:递归遍历站点静态目录,删除不在写入集合中的文件,并自底向上回收空目录
80+
- 删除走 `deleteStaticFile`,因此同样受发布围栏保护,且每个被删文件都会落一条 `deleted` 产物记录,可在发布中心审计
81+
- 清扫数量回填任务结果 `prunedArtifacts`
82+
83+
**触发时机**:任意整站重建。栏目改路径/改归档规则、站点改主题等操作本身就会触发整站重建,因此保存后即自愈,无需额外动作。
84+
85+
**安全边界**
86+
87+
| 情形 | 行为 |
88+
| --- | --- |
89+
| 断点续跑的构建(`resumeAfterKey` 非空) | **跳过清扫** —— 续跑只写了后半程,集合不完整,清扫会误删前半程的有效产物 |
90+
| 被取消的构建 | **跳过清扫**(提前返回一律 `pruned: 0`|
91+
| 内容级 / 栏目级增量刷新 | 不清扫,仅按快照 `deletePaths` 精确删除旧路径 |
92+
| hybrid 按需回写的深分页(如标签页第 2 页) | 会被清扫;下次访问自动重新生成。`static` 模式下这类页面本就不生成,无影响 |
93+
94+
> 路径归一化复用 `pathToStaticFile`:写入侧记录的是 URL 形态(`news/`、``),磁盘上是 `news/index.html``index.html`。两侧若不用同一套映射,首页与全部栏目页会被误判成孤儿。
7395
7496
### 页面区块展示条件的静态安全策略
7597

packages/server/src/services/cms/cms-publishing.service.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -620,6 +620,7 @@ export function registerCmsPublishingTaskHandler(): void {
620620
eq(cmsPublishArtifacts.path, `@target/${input.targetType}`),
621621
));
622622
const tracked = await trackingContext(input, ctx.taskId, ctx);
623+
let prunedArtifacts = 0;
623624
try {
624625
if (input.targetType === 'content' || input.targetType === 'contents') {
625626
const snapshots = [...(input.contentSnapshots ?? [])].sort((a, b) => a.contentId - b.contentId);
@@ -667,7 +668,7 @@ export function registerCmsPublishingTaskHandler(): void {
667668
}));
668669
await ctx.progress({ processed: 1, failed: tracked.artifactProgress.failed, total: 1, note: '搭建页面重建完成', checkpoint: { phase: 'page', lastId: input.pageId ?? null, pageSlug: slug } });
669670
} else {
670-
await withCmsPublishArtifactTracking(tracked.context, () => buildSiteStatic(input.siteId, async (progress) => {
671+
const build = await withCmsPublishArtifactTracking(tracked.context, () => buildSiteStatic(input.siteId, async (progress) => {
671672
const state = await ctx.progress({
672673
processed: progress.processed,
673674
failed: tracked.artifactProgress.failed,
@@ -677,6 +678,7 @@ export function registerCmsPublishingTaskHandler(): void {
677678
});
678679
return state.cancelRequested;
679680
}, { resumeAfterKey: typeof ctx.checkpoint?.lastKey === 'string' ? ctx.checkpoint.lastKey : null }));
681+
prunedArtifacts = build.pruned;
680682
}
681683
} catch (error) {
682684
if (error instanceof TaskCancelledError) throw error;
@@ -690,6 +692,7 @@ export function registerCmsPublishingTaskHandler(): void {
690692
return {
691693
artifacts: tracked.artifactProgress.count,
692694
failedArtifacts: tracked.artifactProgress.failed,
695+
prunedArtifacts,
693696
targetType: input.targetType,
694697
};
695698
}).catch(async (error) => {
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
import fs from 'node:fs/promises';
2+
import { readFile } from 'node:fs/promises';
3+
import path from 'node:path';
4+
import { afterEach, describe, expect, it, vi } from 'vitest';
5+
6+
const { recordCmsPublishArtifact } = vi.hoisted(() => ({ recordCmsPublishArtifact: vi.fn() }));
7+
vi.mock('./cms-publish-artifact-tracker', () => ({ recordCmsPublishArtifact }));
8+
9+
import { pruneOrphanStaticFiles, resolveStaticFile, siteStaticDir } from './cms-static.service';
10+
11+
const SITE_CODE = 'prune-orphan-test';
12+
13+
async function seed(files: string[]): Promise<void> {
14+
for (const rel of files) {
15+
const abs = resolveStaticFile(SITE_CODE, rel)!;
16+
await fs.mkdir(path.dirname(abs), { recursive: true });
17+
await fs.writeFile(abs, `<html>${rel}</html>`);
18+
}
19+
}
20+
21+
async function listAll(): Promise<string[]> {
22+
const dir = siteStaticDir(SITE_CODE);
23+
const walk = async (d: string, prefix = ''): Promise<string[]> => {
24+
const entries = await fs.readdir(d, { withFileTypes: true }).catch(() => []);
25+
const out: string[] = [];
26+
for (const e of entries) {
27+
const rel = prefix ? `${prefix}/${e.name}` : e.name;
28+
if (e.isDirectory()) out.push(...await walk(path.join(d, e.name), rel));
29+
else out.push(rel);
30+
}
31+
return out;
32+
};
33+
return (await walk(dir)).sort();
34+
}
35+
36+
afterEach(async () => {
37+
vi.clearAllMocks();
38+
await fs.rm(siteStaticDir(SITE_CODE), { recursive: true, force: true });
39+
});
40+
41+
describe('CMS 孤儿静态产物清扫', () => {
42+
it('删除本次未写入的文件,保留写入集合内的文件', async () => {
43+
await seed(['index.html', 'news/2024/1.html', 'news/1.html', 'news/index.html']);
44+
45+
// 改归档规则后:新产物在 news/2024/,旧的 news/1.html 成为孤儿
46+
const kept = new Set(['index.html', 'news/2024/1.html', 'news/index.html']);
47+
await expect(pruneOrphanStaticFiles(SITE_CODE, kept)).resolves.toBe(1);
48+
49+
expect(await listAll()).toEqual(['index.html', 'news/2024/1.html', 'news/index.html']);
50+
});
51+
52+
it('清扫后回收空目录,不残留空壳', async () => {
53+
await seed(['index.html', 'news/2023/9.html']);
54+
55+
await expect(pruneOrphanStaticFiles(SITE_CODE, new Set(['index.html']))).resolves.toBe(1);
56+
57+
expect(await listAll()).toEqual(['index.html']);
58+
// 2023/ 与 news/ 均已随之回收
59+
await expect(fs.readdir(path.join(siteStaticDir(SITE_CODE), 'news'))).rejects.toThrow();
60+
});
61+
62+
it('非默认通道子树同样纳入清扫范围', async () => {
63+
await seed(['__h5/news/1.html', '__h5/news/2024/1.html']);
64+
65+
await expect(pruneOrphanStaticFiles(SITE_CODE, new Set(['__h5/news/2024/1.html']))).resolves.toBe(1);
66+
67+
expect(await listAll()).toEqual(['__h5/news/2024/1.html']);
68+
});
69+
70+
it('写入集合覆盖全部文件时不做任何删除', async () => {
71+
const files = ['index.html', 'news/index.html'];
72+
await seed(files);
73+
74+
await expect(pruneOrphanStaticFiles(SITE_CODE, new Set(files))).resolves.toBe(0);
75+
76+
expect(await listAll()).toEqual(files.sort());
77+
expect(recordCmsPublishArtifact).not.toHaveBeenCalled();
78+
});
79+
80+
it('每个被删文件都落一条 deleted 产物记录,保证可审计', async () => {
81+
await seed(['a.html', 'b.html']);
82+
83+
await expect(pruneOrphanStaticFiles(SITE_CODE, new Set())).resolves.toBe(2);
84+
85+
expect(recordCmsPublishArtifact).toHaveBeenCalledTimes(2);
86+
expect(recordCmsPublishArtifact).toHaveBeenCalledWith({ relPath: 'a.html', status: 'deleted' });
87+
expect(recordCmsPublishArtifact).toHaveBeenCalledWith({ relPath: 'b.html', status: 'deleted' });
88+
});
89+
90+
it('目录不存在时安全返回 0(站点从未发布过)', async () => {
91+
await expect(pruneOrphanStaticFiles(SITE_CODE, new Set(['index.html']))).resolves.toBe(0);
92+
});
93+
94+
// 回归:写入侧记的是 URL 形态(`news/`、``),磁盘上却是 `news/index.html`、`index.html`。
95+
// 归一化若不复用 pathToStaticFile,首页与所有栏目页会被误判成孤儿删光。
96+
it('URL 形态的目录路径与磁盘 index.html 视为同一文件', async () => {
97+
await seed(['index.html', 'news/index.html', 'tag/release/index.html', 'news/1.html']);
98+
99+
const kept = new Set(['', 'news/', 'tag/release/']);
100+
await expect(pruneOrphanStaticFiles(SITE_CODE, kept)).resolves.toBe(1);
101+
102+
expect(await listAll()).toEqual(['index.html', 'news/index.html', 'tag/release/index.html']);
103+
});
104+
105+
it('非默认通道的目录形态路径同样正确归一', async () => {
106+
await seed(['__h5/index.html', '__h5/news/index.html', '__h5/stale.html']);
107+
108+
await expect(pruneOrphanStaticFiles(SITE_CODE, new Set(['__h5/', '__h5/news/']))).resolves.toBe(1);
109+
110+
expect(await listAll()).toEqual(['__h5/index.html', '__h5/news/index.html']);
111+
});
112+
});
113+
114+
describe('CMS 孤儿清扫的接线约束', () => {
115+
it('仅在未断点续跑时清扫(续跑集合不完整,误删将丢失有效产物)', async () => {
116+
const text = await readFile(new URL('./cms-static.service.ts', import.meta.url), 'utf8');
117+
expect(text).toMatch(/if \(resumeAfterKey == null\) \{[\s\S]{0,200}?pruneOrphanStaticFiles\(site\.code, written\)/);
118+
});
119+
120+
it('取消构建的提前返回一律不清扫', async () => {
121+
const text = await readFile(new URL('./cms-static.service.ts', import.meta.url), 'utf8');
122+
// 所有 report(...) 触发的提前返回都必须是 pruned: 0
123+
const earlyReturns = text.match(/\)\) return \{ pages[^}]*\}/g) ?? [];
124+
expect(earlyReturns.length).toBeGreaterThan(0);
125+
for (const ret of earlyReturns) expect(ret).toContain('pruned: 0');
126+
});
127+
});

packages/server/src/services/cms/cms-static.service.ts

Lines changed: 99 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import path from 'node:path';
22
import fs from 'node:fs/promises';
3+
import { AsyncLocalStorage } from 'node:async_hooks';
34
import { eq, and, isNull, asc } from 'drizzle-orm';
45
import { db } from '../../db';
56
import { cmsChannels, cmsContents } from '../../db/schema';
@@ -17,7 +18,7 @@ import { triggerCdnPurge, triggerCdnPurgeAll } from './cms-cdn.service';
1718

1819
// ─── 静态目录 ─────────────────────────────────────────────────────────────────
1920
import {
20-
CMS_STATIC_ROOT, isStrictlyWithin, resolveStaticFile, siteStaticDir,
21+
CMS_STATIC_ROOT, isStrictlyWithin, pathToStaticFile, resolveStaticFile, siteStaticDir,
2122
} from './cms-static-path';
2223
import { assertSiteAccess } from './cms-sites.service';
2324
import { resolveEffectiveCmsSiteRow } from './cms-site-inheritance.service';
@@ -57,6 +58,7 @@ export async function writeStaticFile(siteCode: string, relPath: string, html: s
5758
await fs.writeFile(tmp, html, 'utf8');
5859
await assertCmsStaticWriteFence();
5960
await fs.rename(tmp, abs);
61+
buildWriteCollector.getStore()?.add(normalizeStaticRelPath(relPath));
6062
await recordCmsPublishArtifact({ relPath, status: 'generated', content: html });
6163
} catch (error) {
6264
await fs.rm(tmp, { force: true }).catch(() => undefined);
@@ -101,6 +103,75 @@ export async function clearSiteStatic(siteCode: string): Promise<void> {
101103
await fs.rm(dir, { recursive: true, force: true });
102104
}
103105

106+
// ─── 孤儿产物清扫(全量重建的 mark & sweep)─────────────────────────────────────
107+
/**
108+
* 本次构建写入的相对路径集合。
109+
*
110+
* 用 AsyncLocalStorage 而非层层透传:写文件散落在 writeRenderedPath / regenerateChannelPages /
111+
* refreshHomeStaticForChannel 等多个 helper 中,透传参数既啰嗦又容易漏,漏一处就会把有效产物
112+
* 误判成孤儿删掉。与 cms-publish-artifact-tracker 采用同一模式,且天然按调用栈隔离,
113+
* 多站点/并发构建互不串扰。
114+
*/
115+
const buildWriteCollector = new AsyncLocalStorage<Set<string>>();
116+
117+
/**
118+
* 统一成磁盘上的实际相对路径,供集合比对。
119+
*
120+
* 必须复用 `pathToStaticFile`:写入侧记的是 URL 形态(`news/`、``),
121+
* 磁盘上却是 `news/index.html`、`index.html`。只做斜杠归一会让首页/栏目页
122+
* 在集合里查不到,被当成孤儿全部删掉。
123+
*/
124+
function normalizeStaticRelPath(relPath: string): string {
125+
try {
126+
return pathToStaticFile(relPath).replaceAll('\\', '/');
127+
} catch {
128+
return relPath.replaceAll('\\', '/').replace(/^\/+/, '');
129+
}
130+
}
131+
132+
/** 递归列出站点静态目录下的全部文件(返回归一化相对路径) */
133+
async function listSiteStaticFiles(dir: string, prefix = ''): Promise<string[]> {
134+
const entries = await fs.readdir(dir, { withFileTypes: true }).catch(() => []);
135+
const files: string[] = [];
136+
for (const entry of entries) {
137+
const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
138+
if (entry.isDirectory()) files.push(...await listSiteStaticFiles(path.join(dir, entry.name), rel));
139+
else if (entry.isFile()) files.push(rel);
140+
}
141+
return files;
142+
}
143+
144+
/** 自底向上删除空目录(清扫后残留的空壳目录,如改归档规则后的旧年份目录) */
145+
async function removeEmptyDirs(dir: string): Promise<void> {
146+
const entries = await fs.readdir(dir, { withFileTypes: true }).catch(() => []);
147+
for (const entry of entries) {
148+
if (entry.isDirectory()) await removeEmptyDirs(path.join(dir, entry.name));
149+
}
150+
const rest = await fs.readdir(dir).catch(() => ['keep']);
151+
if (rest.length === 0) await fs.rmdir(dir).catch(() => undefined);
152+
}
153+
154+
/**
155+
* 清扫孤儿产物:删除站点静态目录下本次全量构建未写入的文件。
156+
*
157+
* 只在「未断点续跑、且完整跑完」的整站重建后调用 —— 续跑/取消时 kept 集合不完整,
158+
* 清扫会误删有效产物。删除走 deleteStaticFile,因此同样受发布围栏保护并留下 deleted 产物记录。
159+
*/
160+
export async function pruneOrphanStaticFiles(siteCode: string, kept: ReadonlySet<string>): Promise<number> {
161+
const dir = siteStaticDir(siteCode);
162+
if (!isStrictlyWithin(CMS_STATIC_ROOT, dir)) throw new Error('CMS 站点静态目录越界');
163+
// 入参可能混用 URL 形态(`news/`)与磁盘形态(`news/index.html`),统一归一后再比对
164+
const keptFiles = new Set([...kept].map(normalizeStaticRelPath));
165+
const existing = await listSiteStaticFiles(dir);
166+
let removed = 0;
167+
for (const relPath of existing) {
168+
if (keptFiles.has(relPath)) continue;
169+
if (await deleteStaticFile(siteCode, relPath)) removed += 1;
170+
}
171+
if (removed > 0) await removeEmptyDirs(dir);
172+
return removed;
173+
}
174+
104175
// ─── sitemap / robots ─────────────────────────────────────────────────────────
105176
function xmlEscape(s: string): string {
106177
return s.replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;').replaceAll('"', '&quot;').replaceAll("'", '&apos;');
@@ -460,7 +531,17 @@ export async function buildSiteStatic(
460531
siteId: number,
461532
onProgress?: (p: FullBuildProgress) => Promise<boolean | void>,
462533
options?: { resumeAfterKey?: string | null },
463-
): Promise<{ pages: number }> {
534+
): Promise<{ pages: number; pruned: number }> {
535+
const written = new Set<string>();
536+
return buildWriteCollector.run(written, () => buildSiteStaticInner(siteId, written, onProgress, options));
537+
}
538+
539+
async function buildSiteStaticInner(
540+
siteId: number,
541+
written: Set<string>,
542+
onProgress?: (p: FullBuildProgress) => Promise<boolean | void>,
543+
options?: { resumeAfterKey?: string | null },
544+
): Promise<{ pages: number; pruned: number }> {
464545
const site = await resolveEffectiveCmsSiteRow(siteId).catch(() => null);
465546
if (!site) throw new Error(`站点不存在(id=${siteId})`);
466547

@@ -508,7 +589,7 @@ export async function buildSiteStatic(
508589
if (await refreshHomeStaticForChannel(site, publishChannel)) pages += 1;
509590
if (await report(`${channelLabel}首页已生成`, {
510591
phase: 'home', lastKey: homeKey, lastId: null, publishChannelCode: publishChannel.code,
511-
})) return { pages };
592+
})) return { pages, pruned: 0 };
512593
}
513594

514595
for (const channel of channels) {
@@ -517,7 +598,7 @@ export async function buildSiteStatic(
517598
pages += await regenerateChannelPages(site, channel, publishChannel);
518599
if (await report(`${channelLabel}栏目「${channel.name}」已生成`, {
519600
phase: 'channel', lastKey: key, lastId: channel.id, publishChannelCode: publishChannel.code,
520-
})) return { pages };
601+
})) return { pages, pruned: 0 };
521602
}
522603

523604
for (const row of contents) {
@@ -533,7 +614,7 @@ export async function buildSiteStatic(
533614
}
534615
if (await report(`${channelLabel}内容 ${row.id} 已生成`, {
535616
phase: 'content', lastKey: key, lastId: row.id, publishChannelCode: publishChannel.code,
536-
})) return { pages };
617+
})) return { pages, pruned: 0 };
537618
}
538619

539620
// 标签聚合页(仅首屏分页;深分页访问时由 hybrid 模式按需回写)
@@ -547,7 +628,7 @@ export async function buildSiteStatic(
547628
}
548629
if (await report(`${channelLabel}标签「${tag.name}」已生成`, {
549630
phase: 'tag', lastKey: key, lastId: tag.id, publishChannelCode: publishChannel.code,
550-
})) return { pages };
631+
})) return { pages, pruned: 0 };
551632
}
552633

553634
// 可视化搭建页面 /p/{slug}/
@@ -561,25 +642,32 @@ export async function buildSiteStatic(
561642
}
562643
if (await report(`${channelLabel}搭建页「${page.name}」已生成`, {
563644
phase: 'page', lastKey: key, lastId: page.id, publishChannelCode: publishChannel.code,
564-
})) return { pages };
645+
})) return { pages, pruned: 0 };
565646
}
566647
}
567648

568649
const sitemapKey = cmsStaticTargetKey('~meta', 0, 1);
569650
if (!skipCompleted(sitemapKey)) {
570651
await writeStaticFile(site.code, 'sitemap.xml', await generateSitemapXml(site));
571-
if (await report('sitemap.xml 已生成', { phase: 'meta', lastKey: sitemapKey, lastId: 1, publishChannelCode: null })) return { pages };
652+
if (await report('sitemap.xml 已生成', { phase: 'meta', lastKey: sitemapKey, lastId: 1, publishChannelCode: null })) return { pages, pruned: 0 };
572653
}
573654
const rssKey = cmsStaticTargetKey('~meta', 0, 2);
574655
if (!skipCompleted(rssKey)) {
575656
await writeStaticFile(site.code, 'rss.xml', await generateRssXml(site));
576-
if (await report('rss.xml 已生成', { phase: 'meta', lastKey: rssKey, lastId: 2, publishChannelCode: null })) return { pages };
657+
if (await report('rss.xml 已生成', { phase: 'meta', lastKey: rssKey, lastId: 2, publishChannelCode: null })) return { pages, pruned: 0 };
577658
}
578659
const robotsKey = cmsStaticTargetKey('~meta', 0, 3);
579660
if (!skipCompleted(robotsKey)) {
580661
await writeStaticFile(site.code, 'robots.txt', buildRobotsTxt(site));
581-
if (await report('robots.txt 已生成', { phase: 'meta', lastKey: robotsKey, lastId: 3, publishChannelCode: null })) return { pages };
662+
if (await report('robots.txt 已生成', { phase: 'meta', lastKey: robotsKey, lastId: 3, publishChannelCode: null })) return { pages, pruned: 0 };
582663
}
583664
triggerCdnPurgeAll(site);
584-
return { pages };
665+
// 孤儿清扫:仅在「从头完整跑完」时执行。断点续跑的 written 集合只含续跑段,
666+
// 直接清扫会把前半程的有效产物当成孤儿删掉,故明确跳过。
667+
let pruned = 0;
668+
if (resumeAfterKey == null) {
669+
pruned = await pruneOrphanStaticFiles(site.code, written);
670+
if (pruned > 0) logger.info(`[CMS] 站点 ${site.code} 全量重建清理孤儿产物 ${pruned} 个`);
671+
}
672+
return { pages, pruned };
585673
}

0 commit comments

Comments
 (0)