11import path from 'node:path' ;
22import fs from 'node:fs/promises' ;
3+ import { AsyncLocalStorage } from 'node:async_hooks' ;
34import { eq , and , isNull , asc } from 'drizzle-orm' ;
45import { db } from '../../db' ;
56import { cmsChannels , cmsContents } from '../../db/schema' ;
@@ -17,7 +18,7 @@ import { triggerCdnPurge, triggerCdnPurgeAll } from './cms-cdn.service';
1718
1819// ─── 静态目录 ─────────────────────────────────────────────────────────────────
1920import {
20- CMS_STATIC_ROOT , isStrictlyWithin , resolveStaticFile , siteStaticDir ,
21+ CMS_STATIC_ROOT , isStrictlyWithin , pathToStaticFile , resolveStaticFile , siteStaticDir ,
2122} from './cms-static-path' ;
2223import { assertSiteAccess } from './cms-sites.service' ;
2324import { 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 ─────────────────────────────────────────────────────────
105176function xmlEscape ( s : string ) : string {
106177 return s . replaceAll ( '&' , '&' ) . replaceAll ( '<' , '<' ) . replaceAll ( '>' , '>' ) . replaceAll ( '"' , '"' ) . replaceAll ( "'" , ''' ) ;
@@ -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