feat(build): 冻结 Render Snapshot 物化与 build job
Plan 4 Task 2:build_jobs 生命周期(queued→snapshotting→validating(空)→succeeded) + build_input_snapshots 行(三哈希/主题/config 引用/真实版本列)+ 六文件物化 (site/content/taxonomies/routes/assets/render-manifest——canonicalJson 确定性落盘)。 - render-snapshot.ts:纯物化(db 只读)——published 修订(ADR-010 冻结语义)+ 路由/媒体 ready 集合 → 六文件;bodyHtml URL 重写(剥 host 按 path 与 ready 集合 public_path/source_url 双源比对,命中根相对化、未命中保留+计数);媒体实体路径 三候选解析(快照 storageRoot→仓库存储根→incoming 兜底) - build.service.ts:幂等键 = site+published 集合哈希+主题版本(复用不重跑); repeatable read 单事务一致性读取+落盘+快照行;报告 artifacts/jobs/<id>/reports/ build.json 先于终态落行;解析期失败零 job 行(NOT_PUBLISHED/SITE_NOT_FOUND) - sf.ts + build:create;build.module.ts Nest 薄壳 - tests/integration/build-snapshot.test.ts:10 测试(状态机/六文件面/URL 重写/ 字节确定性/NOT_PUBLISHED/ADR-010 双修订/重写器纯函数四例) Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,28 @@
|
||||
// build 域 Nest 模块(Plan 4 Task 2):注册 BuildJobService 供 standalone context
|
||||
// 使用(NestFactory.createApplicationContext(BuildModule)——本切片 API 不监听端口,ADR-002)。
|
||||
// 与 source.module.ts / import.module.ts / publish.module.ts 同构薄壳:逻辑全在
|
||||
// build.service.ts 的零装饰器独立函数里,本类只提供 DI 语境下的调用面;
|
||||
// 'reflect-metadata' 置顶(CLI/测试直接 import build.service.ts 纯函数文件,
|
||||
// 不经本模块,故无需提前加载 reflect-metadata)。
|
||||
import 'reflect-metadata'
|
||||
import { Injectable, Module } from '@nestjs/common'
|
||||
import {
|
||||
createBuildJob,
|
||||
type BuildDb,
|
||||
type CreateBuildJobInput,
|
||||
type CreateBuildJobResult,
|
||||
} from './build.service.js'
|
||||
|
||||
/** createBuildJob 的 Nest 注入壳(standalone context / 后续 REST 通道复用同一实现) */
|
||||
@Injectable()
|
||||
export class BuildJobService {
|
||||
create(db: BuildDb, input: CreateBuildJobInput): Promise<CreateBuildJobResult> {
|
||||
return createBuildJob(db, input)
|
||||
}
|
||||
}
|
||||
|
||||
@Module({
|
||||
providers: [BuildJobService],
|
||||
exports: [BuildJobService],
|
||||
})
|
||||
export class BuildModule {}
|
||||
@@ -0,0 +1,432 @@
|
||||
// build_jobs 生命周期服务(Plan 4 Task 2,V2 spec §9、ADR-010/ADR-011):
|
||||
// 解析站点 → publish 状态检查(快照只读 published——空发布集无物化意义)→ 幂等建
|
||||
// job(queued)→ snapshotting(render-snapshot.ts 纯物化六文件 + build_input_snapshots
|
||||
// 行,repeatable read 单事务一致性读取)→ validating(**空门禁**——Task 6 填真实
|
||||
// 门禁;Task 5 接真实构建时**新建 job 不改行**:build_input_snapshots 有不可变触发器)
|
||||
// → succeeded;报告落 <artifactsRoot>/jobs/<jobId>/reports/build.json(temp+rename
|
||||
// 原子写、**先于终态落行**——import 同款纪律)。
|
||||
//
|
||||
// 幂等:build_jobs.idempotency_key UNIQUE——重复提交同 key 复用既有 job 不重跑(含
|
||||
// 失败 job:重试须换 key——与 import_jobs 同纪律);并发同 key 由唯一约束收敛(插入
|
||||
// 撞 23505 → 重查复用)。默认幂等键 = site + **published 集合哈希** + 主题键@版本——
|
||||
// published 集合不变(发布后导入产新 current 不动 published,ADR-010)→ 同键复用;
|
||||
// 重新 publish 指针前移 → 集合变 → 新 job。显式 idempotencyKey 可强制新物化(测试
|
||||
// 与操作者重跑通道)。
|
||||
//
|
||||
// 版本钉死(build_input_snapshots 的可复现性义务):node/pnpm 运行时真实探测
|
||||
// (process.versions / npm_config_user_agent);theme/astro 是 **Task 3/5 前占位**
|
||||
// (theme.manifest 落地后由 manifest 供给——本任务先钉键形与列面,占位值常量单源)。
|
||||
//
|
||||
// 失败口径:解析阶段失败(SITE_NOT_FOUND/NOT_PUBLISHED/NO_SITE_CONFIG)零 job 行;
|
||||
// 快照/落盘阶段失败 job 行保留(failed 终态 + error_code/error_summary)后原样上抛。
|
||||
//
|
||||
// 本文件与 register/import/publish 服务同纪律保持 **零装饰器**:CLI(scripts/sf.ts
|
||||
// 经 tsx)与集成测试直接 import 本文件;Nest 侧由 build.module.ts 薄包装。
|
||||
import { createHash } from 'node:crypto'
|
||||
import { execSync } from 'node:child_process'
|
||||
import { mkdir, rename, rm, writeFile } from 'node:fs/promises'
|
||||
import { dirname, join, resolve } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { desc, eq } from 'drizzle-orm'
|
||||
import type { NodePgDatabase } from 'drizzle-orm/node-postgres'
|
||||
import { canonicalJson } from '../../common/canonical.js'
|
||||
import { buildInputSnapshots, buildJobs, entryLocales, siteConfigRevisions, sites } from '../../db/schema.js'
|
||||
import type * as schema from '../../db/schema.js'
|
||||
import { isUniqueViolation } from '../source/register.service.js'
|
||||
import { materializeRenderSnapshot, type RewriteStats } from './render-snapshot.js'
|
||||
|
||||
/** 本文件位于 <repo>/apps/api/src/modules/build/ → 上五级是仓库根 */
|
||||
const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../../../../..')
|
||||
|
||||
export type BuildDb = NodePgDatabase<typeof schema>
|
||||
|
||||
export type BuildJobStatus = (typeof schema.buildJobStatus.enumValues)[number]
|
||||
|
||||
export type BuildErrorCode =
|
||||
| 'SITE_NOT_FOUND'
|
||||
| 'NOT_PUBLISHED'
|
||||
| 'NO_SITE_CONFIG'
|
||||
| 'SNAPSHOT_FAILED'
|
||||
| 'REPORT_WRITE_FAILED'
|
||||
|
||||
/** 构建失败(可预期错误):CLI 以 code 区分呈现;不裹程序 bug(那些原样上抛) */
|
||||
export class BuildError extends Error {
|
||||
readonly code: BuildErrorCode
|
||||
constructor(code: BuildErrorCode, message: string) {
|
||||
super(message)
|
||||
this.name = 'BuildError'
|
||||
this.code = code
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 主题钉死(Plan 4 唯一主题):theme_key 落 build_input_snapshots 与报告。
|
||||
* THEME_VERSION 是 Task 3 前占位——theme.manifest 落地后由 manifest 供给真值。
|
||||
*/
|
||||
export const THEME_KEY = 'pilot-minimal'
|
||||
export const THEME_VERSION = '0.0.0'
|
||||
|
||||
/** Task 3/5 前占位(列 NOT NULL 先钉面,占位值单源常量——manifest 落地后替换) */
|
||||
const THEME_SOURCE_HASH_PLACEHOLDER = '0'.repeat(64)
|
||||
const DEPENDENCY_LOCK_HASH_PLACEHOLDER = '0'.repeat(64)
|
||||
const ASTRO_VERSION_PLACEHOLDER = '0.0.0'
|
||||
|
||||
export interface CreateBuildJobInput {
|
||||
siteKey: string
|
||||
/** 幂等键;缺省 = site + published 集合哈希 + 主题键@版本 自动派生 */
|
||||
idempotencyKey?: string
|
||||
/** 产物根(落 <root>/jobs/<jobId>/{input,reports});缺省 repoRoot/artifacts */
|
||||
artifactsRoot?: string
|
||||
/** 状态机观察钩子(测试/CLI 通道;每态一调,queued 起步) */
|
||||
onStatus?: (status: BuildJobStatus, jobId: string) => void
|
||||
}
|
||||
|
||||
export interface CreateBuildJobResult {
|
||||
jobId: string
|
||||
/** job 终态(或复用时既有状态) */
|
||||
status: BuildJobStatus
|
||||
/** true = 命中既有 job(幂等键复用,未重跑、未改行) */
|
||||
reused: boolean
|
||||
siteKey: string
|
||||
siteId: string
|
||||
/** 六文件目录 <artifactsRoot>/jobs/<jobId>/input(复用时按本次调用根推导——文件在原 job 落盘根下) */
|
||||
inputDir: string
|
||||
/** 报告路径 <artifactsRoot>/jobs/<jobId>/reports/build.json */
|
||||
reportPath: string
|
||||
/** 三源哈希(新建 = 本次物化;复用 = build_input_snapshots 行值——不可变权威) */
|
||||
sourceHashes: { content: string; route: string; asset: string }
|
||||
/** 以下仅新建路径填充(复用口径忠实化——权威记录在既有报告) */
|
||||
counts?: { contents: number; taxonomies: number; routes: number; assets: number }
|
||||
urlRewrite?: RewriteStats
|
||||
issues?: string[]
|
||||
durationMs: number
|
||||
}
|
||||
|
||||
/** build 报告对象(reports/build.json 固化形状) */
|
||||
export interface BuildReport {
|
||||
jobId: string
|
||||
generatedAt: string
|
||||
status: BuildJobStatus
|
||||
siteKey: string
|
||||
siteId: string
|
||||
idempotencyKey: string
|
||||
theme: { key: string; version: string }
|
||||
/** 占位声明面(Task 3/5 替换为 manifest 真值——报告读者须知晓当前非真实主题哈希) */
|
||||
themePlaceholders: { themeSourceHash: boolean; dependencyLockHash: boolean; astroVersion: boolean }
|
||||
buildEnv: {
|
||||
node: string
|
||||
pnpm: string
|
||||
sourceCommit: string
|
||||
locale: string
|
||||
timezone: string
|
||||
}
|
||||
snapshot: {
|
||||
siteConfigRevisionId: string
|
||||
manifestPath: string
|
||||
sourceHashes: { content: string; route: string; asset: string }
|
||||
counts: { contents: number; taxonomies: number; routes: number; assets: number }
|
||||
}
|
||||
files: Array<{ name: string; path: string; sha256: string; byteSize: number }>
|
||||
urlRewrite: RewriteStats
|
||||
issues: string[]
|
||||
durationMs: number
|
||||
}
|
||||
|
||||
/** pnpm 版本真实探测:npm_config_user_agent(pnpm 生命周期内必设)→ 解析失败 'unknown' */
|
||||
function detectPnpmVersion(): string {
|
||||
const ua = process.env.npm_config_user_agent
|
||||
if (typeof ua === 'string') {
|
||||
const m = /pnpm\/([0-9]+(?:\.[0-9]+)*)/.exec(ua)
|
||||
if (m !== null) return m[1]!
|
||||
}
|
||||
return 'unknown'
|
||||
}
|
||||
|
||||
/** 源提交探测:git rev-parse HEAD(非 git 环境回退 'unknown'——列 NOT NULL 不空) */
|
||||
function detectSourceCommit(): string {
|
||||
try {
|
||||
return execSync('git rev-parse HEAD', {
|
||||
cwd: REPO_ROOT,
|
||||
encoding: 'utf8',
|
||||
stdio: ['ignore', 'pipe', 'ignore'],
|
||||
}).trim()
|
||||
} catch {
|
||||
return 'unknown'
|
||||
}
|
||||
}
|
||||
|
||||
/** 默认幂等键:site + published 集合哈希 + 主题键@版本(定义钉死——报告可复算) */
|
||||
export function defaultBuildIdempotencyKey(siteKey: string, publishedRevisionIds: string[]): string {
|
||||
const setHash = createHash('sha256').update(canonicalJson([...publishedRevisionIds].sort())).digest('hex')
|
||||
return `build:${siteKey}:pub-${setHash}:${THEME_KEY}@${THEME_VERSION}`
|
||||
}
|
||||
|
||||
/** temp+rename 原子写(失败清走 tmp 不留半成品——import 同款共用形状) */
|
||||
async function writeAtomically(path: string, content: string): Promise<void> {
|
||||
const tmpPath = `${path}.tmp`
|
||||
try {
|
||||
await writeFile(tmpPath, content, 'utf8')
|
||||
await rename(tmpPath, path)
|
||||
} catch (err) {
|
||||
await rm(tmpPath, { force: true })
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
/** 既有 job 的复用返回(不动行、不重跑;三哈希取 build_input_snapshots 行值——不可变权威);无则 null */
|
||||
async function reuseExisting(
|
||||
db: BuildDb,
|
||||
idempotencyKey: string,
|
||||
): Promise<Pick<CreateBuildJobResult, 'jobId' | 'status' | 'sourceHashes'> | null> {
|
||||
const found = await db
|
||||
.select({
|
||||
id: buildJobs.id,
|
||||
status: buildJobs.status,
|
||||
contentSnapshotHash: buildInputSnapshots.contentSnapshotHash,
|
||||
routeSnapshotHash: buildInputSnapshots.routeSnapshotHash,
|
||||
assetManifestHash: buildInputSnapshots.assetManifestHash,
|
||||
})
|
||||
.from(buildJobs)
|
||||
.leftJoin(buildInputSnapshots, eq(buildInputSnapshots.buildJobId, buildJobs.id))
|
||||
.where(eq(buildJobs.idempotencyKey, idempotencyKey))
|
||||
.limit(1)
|
||||
if (found.length === 0) return null
|
||||
const row = found[0]!
|
||||
// 快照行与 job 一对一(build_job_id UNIQUE);终态 job 无快照行 = 上次执行未走到落快照
|
||||
// (failed/stale)——三哈希以空串占位复用返回(权威在 job 行 error 面)
|
||||
return {
|
||||
jobId: row.id,
|
||||
status: row.status,
|
||||
sourceHashes: {
|
||||
content: row.contentSnapshotHash ?? '',
|
||||
route: row.routeSnapshotHash ?? '',
|
||||
asset: row.assetManifestHash ?? '',
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建(并就地推进到底)一个 build job:
|
||||
* 解析(site → published 检查 → config 检查)→ 幂等建行(queued)→ snapshotting
|
||||
* (repeatable read 单事务:物化 + 六文件落盘 + build_input_snapshots 行)→
|
||||
* validating(空——Task 6)→ 报告落盘 → succeeded。
|
||||
* dist 产物与 build_artifacts 登记是 Task 5 职责(本任务 dist 尚空)。
|
||||
*/
|
||||
export async function createBuildJob(db: BuildDb, input: CreateBuildJobInput): Promise<CreateBuildJobResult> {
|
||||
const startedAt = performance.now()
|
||||
const artifactsRoot = resolve(input.artifactsRoot ?? join(REPO_ROOT, 'artifacts'))
|
||||
|
||||
// 阶段 0:站点解析(不自动建站——建站是 register 的职责)
|
||||
const siteRows = await db
|
||||
.select({ id: sites.id, key: sites.key, defaultLocale: sites.defaultLocale })
|
||||
.from(sites)
|
||||
.where(eq(sites.key, input.siteKey))
|
||||
.limit(1)
|
||||
if (siteRows.length === 0) {
|
||||
throw new BuildError('SITE_NOT_FOUND', `站点不存在:${input.siteKey}(先运行 sf source:register)`)
|
||||
}
|
||||
const site = siteRows[0]!
|
||||
|
||||
// 阶段 1:publish 状态检查(建行前——零发布集无物化意义,不留悬挂 job 行)。
|
||||
// 幂等键输入 = published 修订 id 集合(ADR-010:发布后导入产新 current 不入键)。
|
||||
const publishedRows = await db
|
||||
.select({ publishedRevisionId: entryLocales.publishedRevisionId })
|
||||
.from(entryLocales)
|
||||
.where(eq(entryLocales.siteId, site.id))
|
||||
const publishedIds = publishedRows
|
||||
.map((r) => r.publishedRevisionId)
|
||||
.filter((id): id is string => id !== null)
|
||||
.sort()
|
||||
if (publishedIds.length === 0) {
|
||||
throw new BuildError(
|
||||
'NOT_PUBLISHED',
|
||||
`站点 ${input.siteKey} 没有任何已发布条目(entry_locales.published_revision_id 全空)——先运行 sf content:publish`,
|
||||
)
|
||||
}
|
||||
|
||||
// 阶段 2:config 修订检查(site.json 输入;Task 1 保证首发布即有 v1——防御缺席)
|
||||
const configRows = await db
|
||||
.select({ id: siteConfigRevisions.id })
|
||||
.from(siteConfigRevisions)
|
||||
.where(eq(siteConfigRevisions.siteId, site.id))
|
||||
.orderBy(desc(siteConfigRevisions.version))
|
||||
.limit(1)
|
||||
if (configRows.length === 0) {
|
||||
throw new BuildError(
|
||||
'NO_SITE_CONFIG',
|
||||
`站点 ${input.siteKey} 无 site_config_revisions(publish 首版不变量破裂——site.json 无输入)`,
|
||||
)
|
||||
}
|
||||
|
||||
// 阶段 3:幂等建 job(queued)——命中既有 key 直接复用返回(不重跑)
|
||||
const idempotencyKey = input.idempotencyKey ?? defaultBuildIdempotencyKey(site.key, publishedIds)
|
||||
const existing = await reuseExisting(db, idempotencyKey)
|
||||
if (existing !== null) {
|
||||
const inputDir = join(artifactsRoot, 'jobs', existing.jobId, 'input')
|
||||
return {
|
||||
jobId: existing.jobId,
|
||||
status: existing.status,
|
||||
reused: true,
|
||||
siteKey: site.key,
|
||||
siteId: site.id,
|
||||
inputDir,
|
||||
reportPath: join(artifactsRoot, 'jobs', existing.jobId, 'reports', 'build.json'),
|
||||
sourceHashes: existing.sourceHashes,
|
||||
durationMs: performance.now() - startedAt,
|
||||
}
|
||||
}
|
||||
let jobId: string | undefined
|
||||
try {
|
||||
const inserted = await db
|
||||
.insert(buildJobs)
|
||||
.values({ siteId: site.id, status: 'queued', idempotencyKey })
|
||||
.returning({ id: buildJobs.id })
|
||||
jobId = inserted[0]!.id
|
||||
} catch (err) {
|
||||
// 并发同 key:唯一约束收敛——重查复用(插入撞 23505 说明行已存在,重查必命中)
|
||||
if (isUniqueViolation(err)) {
|
||||
const raced = await reuseExisting(db, idempotencyKey)
|
||||
if (raced !== null) {
|
||||
return {
|
||||
jobId: raced.jobId,
|
||||
status: raced.status,
|
||||
reused: true,
|
||||
siteKey: site.key,
|
||||
siteId: site.id,
|
||||
inputDir: join(artifactsRoot, 'jobs', raced.jobId, 'input'),
|
||||
reportPath: join(artifactsRoot, 'jobs', raced.jobId, 'reports', 'build.json'),
|
||||
sourceHashes: raced.sourceHashes,
|
||||
durationMs: performance.now() - startedAt,
|
||||
}
|
||||
}
|
||||
}
|
||||
throw err
|
||||
}
|
||||
const inputDir = join(artifactsRoot, 'jobs', jobId, 'input')
|
||||
const reportDir = join(artifactsRoot, 'jobs', jobId, 'reports')
|
||||
const reportPath = join(reportDir, 'build.json')
|
||||
input.onStatus?.('queued', jobId)
|
||||
|
||||
// 失败收口:job 行保留(failed 终态 + error 面)后原样上抛——镜像 import 纪律
|
||||
try {
|
||||
// 阶段 4:snapshotting(attempt=1、本地 worker 就地推进)
|
||||
await db
|
||||
.update(buildJobs)
|
||||
.set({ status: 'snapshotting', startedAt: new Date(), attempt: 1, workerId: 'local' })
|
||||
.where(eq(buildJobs.id, jobId))
|
||||
input.onStatus?.('snapshotting', jobId)
|
||||
|
||||
// 阶段 5:repeatable read 单事务——一致性读取(published 集/路由/资产同一时点)+
|
||||
// 六文件落盘 + build_input_snapshots 行(读取面与登记面原子:行在即文件内容可复算)
|
||||
const buildTimestamp = new Date()
|
||||
const mat = await db.transaction(
|
||||
async (tx) => {
|
||||
const m = await materializeRenderSnapshot(tx, { siteId: site.id, generatedAt: buildTimestamp.toISOString() })
|
||||
await mkdir(inputDir, { recursive: true })
|
||||
for (const name of Object.keys(m.files) as Array<keyof typeof m.files>) {
|
||||
await writeAtomically(join(inputDir, name), m.files[name])
|
||||
}
|
||||
await tx.insert(buildInputSnapshots).values({
|
||||
buildJobId: jobId!,
|
||||
siteId: site.id,
|
||||
contentSnapshotHash: m.sourceHashes.content,
|
||||
routeSnapshotHash: m.sourceHashes.route,
|
||||
assetManifestHash: m.sourceHashes.asset,
|
||||
siteConfigRevisionId: m.siteConfigRevisionId,
|
||||
themeKey: THEME_KEY,
|
||||
themeVersion: THEME_VERSION,
|
||||
themeSourceHash: THEME_SOURCE_HASH_PLACEHOLDER,
|
||||
dependencyLockHash: DEPENDENCY_LOCK_HASH_PLACEHOLDER,
|
||||
sourceCommit: detectSourceCommit(),
|
||||
nodeVersion: process.versions.node,
|
||||
pnpmVersion: detectPnpmVersion(),
|
||||
astroVersion: ASTRO_VERSION_PLACEHOLDER,
|
||||
locale: site.defaultLocale,
|
||||
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC',
|
||||
buildTimestamp,
|
||||
manifestPath: join(inputDir, 'render-manifest.json'),
|
||||
})
|
||||
return m
|
||||
},
|
||||
{ isolationLevel: 'repeatable read' },
|
||||
)
|
||||
|
||||
// 阶段 6:validating(**空门禁**——Task 6 填真实门禁;状态机占位完整)
|
||||
await db.update(buildJobs).set({ status: 'validating' }).where(eq(buildJobs.id, jobId))
|
||||
input.onStatus?.('validating', jobId)
|
||||
|
||||
// 阶段 7:报告先行落盘(temp+rename 原子替换),终态落行在后——行内即文件在
|
||||
const report: BuildReport = {
|
||||
jobId,
|
||||
generatedAt: buildTimestamp.toISOString(),
|
||||
status: 'succeeded',
|
||||
siteKey: site.key,
|
||||
siteId: site.id,
|
||||
idempotencyKey,
|
||||
theme: { key: THEME_KEY, version: THEME_VERSION },
|
||||
themePlaceholders: {
|
||||
themeSourceHash: true,
|
||||
dependencyLockHash: true,
|
||||
astroVersion: true,
|
||||
},
|
||||
buildEnv: {
|
||||
node: process.versions.node,
|
||||
pnpm: detectPnpmVersion(),
|
||||
sourceCommit: detectSourceCommit(),
|
||||
locale: site.defaultLocale,
|
||||
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC',
|
||||
},
|
||||
snapshot: {
|
||||
siteConfigRevisionId: mat.siteConfigRevisionId,
|
||||
manifestPath: join(inputDir, 'render-manifest.json'),
|
||||
sourceHashes: mat.sourceHashes,
|
||||
counts: mat.counts,
|
||||
},
|
||||
files: Object.entries(mat.files).map(([name, content]) => ({
|
||||
name,
|
||||
path: join(inputDir, name),
|
||||
sha256: createHash('sha256').update(content, 'utf8').digest('hex'),
|
||||
byteSize: Buffer.byteLength(content, 'utf8'),
|
||||
})),
|
||||
urlRewrite: mat.urlRewrite,
|
||||
issues: mat.issues,
|
||||
durationMs: performance.now() - startedAt,
|
||||
}
|
||||
try {
|
||||
await mkdir(reportDir, { recursive: true })
|
||||
await writeAtomically(reportPath, `${JSON.stringify(report, null, 2)}\n`)
|
||||
} catch (err) {
|
||||
throw new BuildError(
|
||||
'REPORT_WRITE_FAILED',
|
||||
`构建报告落盘失败(${reportPath}):${err instanceof Error ? err.message : String(err)}`,
|
||||
)
|
||||
}
|
||||
|
||||
// 阶段 8:succeeded 终态
|
||||
await db.update(buildJobs).set({ status: 'succeeded', finishedAt: new Date() }).where(eq(buildJobs.id, jobId))
|
||||
input.onStatus?.('succeeded', jobId)
|
||||
|
||||
return {
|
||||
jobId,
|
||||
status: 'succeeded',
|
||||
reused: false,
|
||||
siteKey: site.key,
|
||||
siteId: site.id,
|
||||
inputDir,
|
||||
reportPath,
|
||||
sourceHashes: mat.sourceHashes,
|
||||
counts: mat.counts,
|
||||
urlRewrite: mat.urlRewrite,
|
||||
issues: mat.issues,
|
||||
durationMs: performance.now() - startedAt,
|
||||
}
|
||||
} catch (err) {
|
||||
const code = err instanceof BuildError ? err.code : 'SNAPSHOT_FAILED'
|
||||
const summary = err instanceof Error ? `${err.name}: ${err.message}` : String(err)
|
||||
await db
|
||||
.update(buildJobs)
|
||||
.set({ status: 'failed', finishedAt: new Date(), errorCode: code, errorSummary: `[build] ${summary}` })
|
||||
.where(eq(buildJobs.id, jobId))
|
||||
throw err
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,479 @@
|
||||
// Render Snapshot 纯物化(Plan 4 Task 2,ADR-011、V2 spec §9.2、快线 §8.4):
|
||||
// 从 published 修订 + route_registry + media ready 集合物化冻结六文件
|
||||
// (RENDER_SNAPSHOT_FILES 契约清单),**db 只读、零写入**(磁盘仅做副本存在性探测)。
|
||||
// 构建编排(job 状态机/落盘/快照行)在 build.service.ts——本文件只负责"读出什么"。
|
||||
//
|
||||
// 发布语义(ADR-010):只读 entry_locales.published_revision_id 指向的修订——current
|
||||
// 分叉(发布后再导入产新修订)不影响本快照;发布后的人工/导入变化须重新 publish +
|
||||
// 新 build 才进产物。published 指针悬空(目标修订行缺席,指针列无 FK)→ issue 如实
|
||||
// 报告(§7.6 禁静默),该条目不进 content.json。
|
||||
//
|
||||
// bodyHtml URL 重写(计划 Task 2 钉死匹配键):正则提取 src/href → 剥 host 取 path →
|
||||
// 与 media ready 集合的 public_path / source_url 路径段**双源**比对(覆盖绝对/根相对/
|
||||
// 协议相对变体与查询串)→ 命中改写为根相对 public_path;未命中保留原样 + unresolved
|
||||
// 计数(render-manifest.urlRewrite——内链 miss 是常态统计不是错误;外域热链同理)。
|
||||
// data:/纯片段/相对形态无路径可比对,原样越过不计(与 media-writer 的 data: 口径一致)。
|
||||
//
|
||||
// canonical JSON 确定性:六文件全部以 canonicalJson(键递归字典序、无空白)+ 换行落盘
|
||||
// ——文件字节即哈希输入(content/route/asset 三哈希 = 各自 canonicalJson 的 sha256),
|
||||
// 同输入(含 generatedAt 时点参数)两次物化字节一致。集合排序在 JS 侧钉死(UTF-16
|
||||
// 序,不依赖 PG collation):内容按 (typeKey, slug, locale, contentId)、分类按 (key,
|
||||
// objectType)、term 按 (sourceId, slug, id)、路由按 (routePath, queryKey nulls-first,
|
||||
// host)、资产按 publicPath。
|
||||
//
|
||||
// 媒体实体路径解析(assets.json 的 copyPath):register 快照 metadata.storageRoot +
|
||||
// storage_path(content-addressed 副本)为首选,仓库存储根同路径次之,storage/incoming
|
||||
// 按清单文件名兜底(真实数据扁平 GUID 命名,basename 即唯一键);全部缺席 → issue
|
||||
// 如实报告(复制防线在 Task 5 构建管线,此处不静默也不阻断)。
|
||||
import { createHash } from 'node:crypto'
|
||||
import { existsSync } from 'node:fs'
|
||||
import { basename, dirname, join, resolve } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { and, desc, eq, inArray, isNotNull } from 'drizzle-orm'
|
||||
import type { NodePgDatabase } from 'drizzle-orm/node-postgres'
|
||||
import {
|
||||
ADAPTER_VERSION,
|
||||
MAPPING_VERSION,
|
||||
PARSER_VERSION,
|
||||
SANITIZER_VERSION,
|
||||
} from '@sf/adapter-wp'
|
||||
import { RENDER_SNAPSHOT_VERSION, type RenderSnapshotFile } from '@sf/contracts'
|
||||
import { canonicalJson } from '../../common/canonical.js'
|
||||
import {
|
||||
contentEntries,
|
||||
contentRevisions,
|
||||
contentTerms,
|
||||
entryLocales,
|
||||
mediaAssets,
|
||||
mediaUsages,
|
||||
routeRegistry,
|
||||
siteConfigRevisions,
|
||||
sourceSnapshots,
|
||||
taxonomies,
|
||||
terms,
|
||||
} from '../../db/schema.js'
|
||||
import type * as schema from '../../db/schema.js'
|
||||
|
||||
/** 本文件位于 <repo>/apps/api/src/modules/build/ → 上五级是仓库根 */
|
||||
const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../../../../..')
|
||||
|
||||
/** 读取面:db 池与事务句柄都满足(build.service 以 repeatable read 事务传入) */
|
||||
export type SnapshotDb = NodePgDatabase<typeof schema>
|
||||
export type SnapshotReader = Pick<SnapshotDb, 'select'>
|
||||
|
||||
// ---- URL 重写(纯函数,测试直测) ----
|
||||
|
||||
/** media ready 集合的最小匹配面(public_path 与 source_url 双源建键) */
|
||||
export interface MediaPathSource {
|
||||
publicPath: string
|
||||
sourceUrl: string
|
||||
}
|
||||
|
||||
/**
|
||||
* URL → 可比对路径:剥 host(绝对/协议相对)、剥查询/锚、保证首斜杠;
|
||||
* data:/纯片段/相对形态返回 null(无路径可比对,原样越过)。
|
||||
*/
|
||||
function comparablePathOf(raw: string): string | null {
|
||||
if (raw === '' || raw.startsWith('data:')) return null
|
||||
let rest: string
|
||||
if (raw.startsWith('//')) {
|
||||
const pathStart = raw.indexOf('/', 2)
|
||||
if (pathStart === -1) return null // //host 无路径证据——不比对('/' 会误伤首页)
|
||||
rest = raw.slice(pathStart)
|
||||
} else {
|
||||
const abs = /^https?:\/\/[^/]+(.*)$/i.exec(raw)
|
||||
if (abs !== null) {
|
||||
rest = abs[1] === '' ? '/' : abs[1]!
|
||||
} else if (raw.startsWith('/')) {
|
||||
rest = raw
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
}
|
||||
const cut = rest.search(/[?#]/)
|
||||
const path = cut === -1 ? rest : rest.slice(0, cut)
|
||||
return path === '' ? '/' : path
|
||||
}
|
||||
|
||||
/**
|
||||
* media ready 路径索引:path(public_path 本形 + source_url 剥 host 的 path 段)→
|
||||
* 该资产的 public_path(命中改写目标)。同 path 双源冲突时先登记者胜(register 的
|
||||
* public_path 唯一键保证本形不冲突;跨源同 path 是数据面事实,任一命中即本地化)。
|
||||
*/
|
||||
export function buildMediaPathIndex(assets: MediaPathSource[]): Map<string, string> {
|
||||
const index = new Map<string, string>()
|
||||
for (const a of assets) {
|
||||
index.set(a.publicPath, a.publicPath)
|
||||
const src = comparablePathOf(a.sourceUrl)
|
||||
if (src !== null && !index.has(src)) index.set(src, a.publicPath)
|
||||
}
|
||||
return index
|
||||
}
|
||||
|
||||
/** 重写统计(入 render-manifest.urlRewrite 与 build 报告 issues 面) */
|
||||
export interface RewriteStats {
|
||||
/** 命中 ready 集合的引用数(含已是根相对形态的幂等命中) */
|
||||
rewritten: number
|
||||
/** 有路径可比对但未命中 ready 集合的引用数(保留原样) */
|
||||
unresolved: number
|
||||
}
|
||||
|
||||
/**
|
||||
* bodyHtml 的 src/href URL 重写(正则提取——清洗信任链上游已保证属性形态规整;
|
||||
* 门禁期的 HTML 解析复用清洗管线是 Task 6 职责,此处不引入解析器依赖)。
|
||||
* 只替换属性值本身,引号/空白原样保留;命中值与原值相等时计数但零改写。
|
||||
*/
|
||||
export function rewriteBodyHtml(html: string, index: Map<string, string>): { html: string; stats: RewriteStats } {
|
||||
const stats: RewriteStats = { rewritten: 0, unresolved: 0 }
|
||||
const ATTR_RE = /\b(src|href)\s*=\s*(["'])([^"']*)\2/gi
|
||||
const out = html.replace(ATTR_RE, (full, _attr: string, quote: string, value: string) => {
|
||||
const path = comparablePathOf(value)
|
||||
if (path === null) return full
|
||||
const hit = index.get(path)
|
||||
if (hit === undefined) {
|
||||
stats.unresolved++
|
||||
return full
|
||||
}
|
||||
stats.rewritten++
|
||||
if (hit === value) return full
|
||||
// match 尾部 = value + 收引号——只换值,保留原引号与属性的原始空白
|
||||
return `${full.slice(0, full.length - value.length - 1)}${hit}${quote}`
|
||||
})
|
||||
return { html: out, stats }
|
||||
}
|
||||
|
||||
// ---- 物化 ----
|
||||
|
||||
/** 六文件的 canonical JSON 字符串(含尾换行——落盘字节即哈希输入 + '\n') */
|
||||
export type RenderSnapshotFileContents = Record<RenderSnapshotFile, string>
|
||||
|
||||
export interface MaterializeInput {
|
||||
siteId: string
|
||||
/** 快照生成时点(ISO 字符串)——render-manifest.generatedAt;同输入 + 同时点 → 字节一致 */
|
||||
generatedAt: string
|
||||
}
|
||||
|
||||
export interface MaterializeResult {
|
||||
files: RenderSnapshotFileContents
|
||||
sourceHashes: {
|
||||
content: string
|
||||
route: string
|
||||
asset: string
|
||||
}
|
||||
counts: {
|
||||
contents: number
|
||||
taxonomies: number
|
||||
routes: number
|
||||
assets: number
|
||||
}
|
||||
urlRewrite: RewriteStats
|
||||
/** 物化期如实报告的问题(published 悬空/媒体副本缺席/不可服务路由形态)——进 build 报告 */
|
||||
issues: string[]
|
||||
/** site.json 来源的 config 修订(build_input_snapshots.site_config_revision_id 落值) */
|
||||
siteConfigRevisionId: string
|
||||
}
|
||||
|
||||
/** UTF-16 字典序比较器(与 canonicalJson 键排序同语义——钉死不依赖 PG collation) */
|
||||
function cmp(a: string, b: string): number {
|
||||
return a < b ? -1 : a > b ? 1 : 0
|
||||
}
|
||||
function cmpNull(a: string | null, b: string | null): number {
|
||||
if (a === null && b === null) return 0
|
||||
if (a === null) return -1
|
||||
if (b === null) return 1
|
||||
return cmp(a, b)
|
||||
}
|
||||
|
||||
/** sha256 of canonicalJson(三哈希的单一算法来源) */
|
||||
function hashOf(payload: unknown): string {
|
||||
return createHash('sha256').update(canonicalJson(payload)).digest('hex')
|
||||
}
|
||||
|
||||
/** 仓库存储根的 incoming 媒体兜底目录(真实数据:storage/incoming/<目录>/<扁平 GUID 文件>) */
|
||||
const REPO_STORAGE = join(REPO_ROOT, 'storage')
|
||||
|
||||
/**
|
||||
* 物化冻结 Render Snapshot 六文件(纯读 + 副本存在性探测)。
|
||||
* 前置不变量:站点已有 published 指针与 config 修订(build.service 建行前检查;
|
||||
* 直接调用方破坏不变量时以明错拒绝,不产残缺快照)。
|
||||
*/
|
||||
export async function materializeRenderSnapshot(
|
||||
db: SnapshotReader,
|
||||
input: MaterializeInput,
|
||||
): Promise<MaterializeResult> {
|
||||
const issues: string[] = []
|
||||
const urlRewrite: RewriteStats = { rewritten: 0, unresolved: 0 }
|
||||
|
||||
// site.json:config 修订最新版(Task 1 首发布生成 v1——data={name,origin,locale,brand})
|
||||
const cfgRows = await db
|
||||
.select({ id: siteConfigRevisions.id, data: siteConfigRevisions.data })
|
||||
.from(siteConfigRevisions)
|
||||
.where(eq(siteConfigRevisions.siteId, input.siteId))
|
||||
.orderBy(desc(siteConfigRevisions.version))
|
||||
.limit(1)
|
||||
const config = cfgRows[0]
|
||||
if (config === undefined) {
|
||||
throw new Error(`站点 ${input.siteId} 无 site_config_revisions(publish 首版不变量破裂)——拒绝物化`)
|
||||
}
|
||||
|
||||
// content.json:published 修订(leftJoin 显式暴露悬空指针——§7.6 禁静默)
|
||||
const localeRows = await db
|
||||
.select({
|
||||
contentId: contentEntries.id,
|
||||
typeKey: contentEntries.typeKey,
|
||||
locale: entryLocales.locale,
|
||||
localeSlug: entryLocales.slug,
|
||||
revisionId: contentRevisions.id,
|
||||
checksum: contentRevisions.checksum,
|
||||
data: contentRevisions.data,
|
||||
})
|
||||
.from(entryLocales)
|
||||
.innerJoin(contentEntries, eq(entryLocales.contentEntryId, contentEntries.id))
|
||||
.leftJoin(contentRevisions, eq(entryLocales.publishedRevisionId, contentRevisions.id))
|
||||
.where(and(eq(entryLocales.siteId, input.siteId), isNotNull(entryLocales.publishedRevisionId)))
|
||||
const published = localeRows.filter((r) => r.revisionId !== null)
|
||||
for (const row of localeRows) {
|
||||
if (row.revisionId === null) {
|
||||
issues.push(
|
||||
`published 指针悬空(entry=${row.contentId}/${row.locale}):published_revision_id 指向的修订行缺席(指针列无 FK)——该条目不进 content.json`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// media ready 集合:资产面(assets.json)与 URL 重写索引共用的单一查询
|
||||
const assetRows = await db
|
||||
.select({
|
||||
publicPath: mediaAssets.publicPath,
|
||||
sourceUrl: mediaAssets.sourceUrl,
|
||||
storagePath: mediaAssets.storagePath,
|
||||
sha256: mediaAssets.sha256,
|
||||
detectedMimeType: mediaAssets.detectedMimeType,
|
||||
byteSize: mediaAssets.byteSize,
|
||||
width: mediaAssets.width,
|
||||
height: mediaAssets.height,
|
||||
altText: mediaAssets.altText,
|
||||
})
|
||||
.from(mediaAssets)
|
||||
.where(and(eq(mediaAssets.siteId, input.siteId), eq(mediaAssets.status, 'ready')))
|
||||
assetRows.sort((a, b) => cmp(a.publicPath, b.publicPath))
|
||||
const mediaIndex = buildMediaPathIndex(assetRows)
|
||||
|
||||
// published 修订的 media_usages 引用面(fieldPath → public_path;content.json 的 media 块)
|
||||
const publishedIds = published.map((r) => r.revisionId!)
|
||||
const usageMap = new Map<string, Map<string, string>>()
|
||||
if (publishedIds.length > 0) {
|
||||
const usages = await db
|
||||
.select({
|
||||
contentRevisionId: mediaUsages.contentRevisionId,
|
||||
fieldPath: mediaUsages.fieldPath,
|
||||
publicPath: mediaAssets.publicPath,
|
||||
})
|
||||
.from(mediaUsages)
|
||||
.innerJoin(mediaAssets, eq(mediaUsages.mediaAssetId, mediaAssets.id))
|
||||
.where(and(eq(mediaUsages.siteId, input.siteId), inArray(mediaUsages.contentRevisionId, publishedIds)))
|
||||
for (const u of usages) {
|
||||
let byField = usageMap.get(u.contentRevisionId)
|
||||
if (byField === undefined) {
|
||||
byField = new Map()
|
||||
usageMap.set(u.contentRevisionId, byField)
|
||||
}
|
||||
byField.set(u.fieldPath, u.publicPath)
|
||||
}
|
||||
}
|
||||
|
||||
const contentPayload = published.map((row) => {
|
||||
const data = (row.data ?? {}) as Record<string, unknown>
|
||||
let entryData = data
|
||||
if (typeof data.bodyHtml === 'string') {
|
||||
const r = rewriteBodyHtml(data.bodyHtml, mediaIndex)
|
||||
urlRewrite.rewritten += r.stats.rewritten
|
||||
urlRewrite.unresolved += r.stats.unresolved
|
||||
entryData = { ...data, bodyHtml: r.html }
|
||||
}
|
||||
// slug 以修订 data.slug(发布时点冻结值)为准,缺席回退 entry_locales.slug(current 跟随面)
|
||||
const slug = typeof data.slug === 'string' && data.slug !== '' ? data.slug : row.localeSlug
|
||||
const media = usageMap.get(row.revisionId!)
|
||||
const entry: Record<string, unknown> = {
|
||||
contentId: row.contentId,
|
||||
typeKey: row.typeKey,
|
||||
locale: row.locale,
|
||||
slug,
|
||||
data: entryData,
|
||||
revisionId: row.revisionId,
|
||||
checksum: row.checksum,
|
||||
}
|
||||
if (media !== undefined && media.size > 0) {
|
||||
entry.media = Object.fromEntries([...media.entries()].sort(([a], [b]) => cmp(a, b)))
|
||||
}
|
||||
return entry
|
||||
})
|
||||
contentPayload.sort((a, b) =>
|
||||
cmp(a.typeKey as string, b.typeKey as string)
|
||||
|| cmp(a.slug as string, b.slug as string)
|
||||
|| cmp(a.locale as string, b.locale as string)
|
||||
|| cmp(a.contentId as string, b.contentId as string),
|
||||
)
|
||||
|
||||
// taxonomies.json:taxonomies × terms 全量 + content_terms 关联(auto: 自建行 source_id 原样)
|
||||
const taxonomyRows = await db
|
||||
.select({
|
||||
id: taxonomies.id,
|
||||
key: taxonomies.key,
|
||||
objectType: taxonomies.objectType,
|
||||
publicBasePath: taxonomies.publicBasePath,
|
||||
})
|
||||
.from(taxonomies)
|
||||
.where(eq(taxonomies.siteId, input.siteId))
|
||||
const termRows = await db
|
||||
.select({
|
||||
id: terms.id,
|
||||
taxonomyId: terms.taxonomyId,
|
||||
sourceId: terms.sourceId,
|
||||
slug: terms.slug,
|
||||
name: terms.name,
|
||||
parentTermId: terms.parentTermId,
|
||||
data: terms.data,
|
||||
})
|
||||
.from(terms)
|
||||
.where(eq(terms.siteId, input.siteId))
|
||||
const relationRows = await db
|
||||
.select({ termId: contentTerms.termId, contentEntryId: contentTerms.contentEntryId })
|
||||
.from(contentTerms)
|
||||
.where(eq(contentTerms.siteId, input.siteId))
|
||||
const entriesByTerm = new Map<string, string[]>()
|
||||
for (const rel of relationRows) {
|
||||
const list = entriesByTerm.get(rel.termId) ?? []
|
||||
list.push(rel.contentEntryId)
|
||||
entriesByTerm.set(rel.termId, list)
|
||||
}
|
||||
const termsByTaxonomy = new Map<string, typeof termRows>()
|
||||
for (const t of termRows) {
|
||||
const list = termsByTaxonomy.get(t.taxonomyId) ?? []
|
||||
list.push(t)
|
||||
termsByTaxonomy.set(t.taxonomyId, list)
|
||||
}
|
||||
const taxonomiesPayload = taxonomyRows.map((tax) => {
|
||||
const group = (termsByTaxonomy.get(tax.id) ?? []).map((t) => ({
|
||||
id: t.id,
|
||||
sourceId: t.sourceId,
|
||||
slug: t.slug,
|
||||
name: t.name,
|
||||
parentTermId: t.parentTermId,
|
||||
data: (t.data ?? {}) as Record<string, unknown>,
|
||||
contentEntryIds: (entriesByTerm.get(t.id) ?? []).sort(cmp),
|
||||
}))
|
||||
group.sort((a, b) => cmp(a.sourceId, b.sourceId) || cmp(a.slug, b.slug) || cmp(a.id, b.id))
|
||||
return { key: tax.key, objectType: tax.objectType, publicBasePath: tax.publicBasePath, terms: group }
|
||||
})
|
||||
taxonomiesPayload.sort((a, b) => cmp(a.key, b.key) || cmp(a.objectType, b.objectType))
|
||||
|
||||
// routes.json:可服务路由形态(html/asset/redirect/gone);dynamic/unresolved 不进产物
|
||||
const routeRows = await db
|
||||
.select({
|
||||
host: routeRegistry.host,
|
||||
routePath: routeRegistry.routePath,
|
||||
routeKind: routeRegistry.routeKind,
|
||||
queryPolicy: routeRegistry.queryPolicy,
|
||||
queryKey: routeRegistry.queryKey,
|
||||
targetStatus: routeRegistry.targetStatus,
|
||||
targetContentId: routeRegistry.targetContentId,
|
||||
targetFile: routeRegistry.targetFile,
|
||||
redirectStatus: routeRegistry.redirectStatus,
|
||||
redirectLocation: routeRegistry.redirectLocation,
|
||||
indexable: routeRegistry.indexable,
|
||||
priority: routeRegistry.priority,
|
||||
verificationStatus: routeRegistry.verificationStatus,
|
||||
})
|
||||
.from(routeRegistry)
|
||||
.where(eq(routeRegistry.siteId, input.siteId))
|
||||
const SERVICABLE = new Set(['html', 'asset', 'redirect', 'gone'])
|
||||
const unservicable = routeRows.filter((r) => !SERVICABLE.has(r.routeKind))
|
||||
if (unservicable.length > 0) {
|
||||
issues.push(
|
||||
`路由表含 ${unservicable.length} 条不可服务形态(dynamic/unresolved)——不进 routes.json(处置是 SP3+ 查询路由域)`,
|
||||
)
|
||||
}
|
||||
const routesPayload = routeRows.filter((r) => SERVICABLE.has(r.routeKind))
|
||||
routesPayload.sort(
|
||||
(a, b) => cmp(a.routePath, b.routePath) || cmpNull(a.queryKey, b.queryKey) || cmp(a.host, b.host),
|
||||
)
|
||||
|
||||
// assets.json:ready 资产 + 实体副本路径解析(content-addressed 首选 → 仓库存储根 → incoming 兜底)
|
||||
const bundleRows = await db
|
||||
.select({ metadata: sourceSnapshots.metadata })
|
||||
.from(sourceSnapshots)
|
||||
.where(and(eq(sourceSnapshots.siteId, input.siteId), eq(sourceSnapshots.kind, 'media_bundle')))
|
||||
.orderBy(desc(sourceSnapshots.capturedAt), desc(sourceSnapshots.createdAt))
|
||||
.limit(1)
|
||||
const bundleRootRaw = bundleRows[0]?.metadata as { storageRoot?: unknown } | undefined
|
||||
const bundleRoot = typeof bundleRootRaw?.storageRoot === 'string' ? bundleRootRaw.storageRoot : null
|
||||
|
||||
const assetsPayload = assetRows.map((a) => {
|
||||
const candidates: string[] = []
|
||||
if (bundleRoot !== null && bundleRoot !== '') candidates.push(join(bundleRoot, a.storagePath))
|
||||
const repoCopy = join(REPO_STORAGE, a.storagePath)
|
||||
candidates.push(repoCopy)
|
||||
// incoming 兜底:清单文件名(真实数据扁平 GUID 命名,basename 唯一)
|
||||
candidates.push(join(REPO_STORAGE, 'incoming', basename(a.storagePath)))
|
||||
let copyPath = candidates[0] ?? repoCopy
|
||||
for (const c of candidates) {
|
||||
if (existsSync(c)) {
|
||||
copyPath = c
|
||||
break
|
||||
}
|
||||
}
|
||||
if (!existsSync(copyPath)) {
|
||||
issues.push(
|
||||
`媒体副本缺席(public_path=${a.publicPath}):候选 ${candidates.join(' ; ')} 均不存在——assets.json 记录首选路径,复制阶段(Task 5 管线)将失败`,
|
||||
)
|
||||
}
|
||||
return {
|
||||
publicPath: a.publicPath,
|
||||
storagePath: a.storagePath,
|
||||
copyPath,
|
||||
sha256: a.sha256,
|
||||
detectedMimeType: a.detectedMimeType,
|
||||
byteSize: a.byteSize,
|
||||
width: a.width,
|
||||
height: a.height,
|
||||
altText: a.altText,
|
||||
}
|
||||
})
|
||||
|
||||
// 三哈希(各文件 canonicalJson 的 sha256——字节可复算)+ render-manifest 汇聚
|
||||
const sourceHashes = {
|
||||
content: hashOf(contentPayload),
|
||||
route: hashOf(routesPayload),
|
||||
asset: hashOf(assetsPayload),
|
||||
}
|
||||
const counts = {
|
||||
contents: contentPayload.length,
|
||||
taxonomies: taxonomiesPayload.length,
|
||||
routes: routesPayload.length,
|
||||
assets: assetsPayload.length,
|
||||
}
|
||||
const manifestPayload = {
|
||||
renderSnapshotVersion: RENDER_SNAPSHOT_VERSION,
|
||||
versions: {
|
||||
adapter: ADAPTER_VERSION,
|
||||
mapping: MAPPING_VERSION,
|
||||
sanitizer: SANITIZER_VERSION,
|
||||
parser: PARSER_VERSION,
|
||||
},
|
||||
counts,
|
||||
sourceHashes,
|
||||
urlRewrite,
|
||||
generatedAt: input.generatedAt,
|
||||
}
|
||||
|
||||
const file = (payload: unknown): string => `${canonicalJson(payload)}\n`
|
||||
const files: RenderSnapshotFileContents = {
|
||||
'site.json': file(config.data),
|
||||
'content.json': file(contentPayload),
|
||||
'taxonomies.json': file(taxonomiesPayload),
|
||||
'routes.json': file(routesPayload),
|
||||
'assets.json': file(assetsPayload),
|
||||
'render-manifest.json': file(manifestPayload),
|
||||
}
|
||||
return { files, sourceHashes, counts, urlRewrite, issues, siteConfigRevisionId: config.id }
|
||||
}
|
||||
Reference in New Issue
Block a user