fix(build): 哈希链含尾换行;media 块 first-wins 确定性;job 复用只认 succeeded+崩溃回收;重写器边界锚定
审查六修(Fix 2/3 先于 Task 3 消费六文件落形): - Fix 1 三哈希 = sha256(文件字节含尾换行)——哈希链与落盘文件互证;测试改按 原始文件字节独立复算对拍 - Fix 2 media_usages orderBy(fieldPath, publicPath) + first-wins:同 fieldPath 多资产引用取字典序首位、其余丢弃记 issue;两次物化字节一致有测试钉死 - Fix 3 job 复用只认 succeeded;failed/cancelled/stale 死行与非终态崩溃残留 (旧行置 stale 宣判)都以 <key>#a<attempt+1> 后缀键建新行自动重跑(attempt 延续谱系);失败终态化时清走半途 input/ 目录;两路径各有测试 - Fix 4 git rev-parse 等环境探测移出快照事务(单次探测传入) - Fix 5 属性正则边界锚定 (^|\s):data-src 等连字属性不卷入(\b 会误判 '-s' 交界)——data-src 命中索引也不改写的用例钉死 - Fix 6 media 引用面只 join ready 资产;非 ready 残留引用单独查出记 issue、 不进 content.json media 块(quarantined 翻转测试) 354/354(350+4);typecheck 四段、lint 全绿。 Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -6,12 +6,15 @@
|
||||
// → 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_jobs.idempotency_key UNIQUE——重复提交同 key:**只有 succeeded 复用**
|
||||
// (产物完整);failed/cancelled/stale 终态死行与 queued/snapshotting/building/
|
||||
// validating 崩溃残留(非终态,旧行置 stale 宣判)都以 `<key>#a<attempt+1>` 后缀键
|
||||
// 建新行自动重跑(审查 Fix 3——import"换 key 手动重试"纪律的构建域演进:单进程就地
|
||||
// 推进下非终态滞留必为崩溃残留,自动恢复优于阻塞操作者);并发同 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 前占位**
|
||||
@@ -85,7 +88,11 @@ export interface CreateBuildJobResult {
|
||||
jobId: string
|
||||
/** job 终态(或复用时既有状态) */
|
||||
status: BuildJobStatus
|
||||
/** true = 命中既有 job(幂等键复用,未重跑、未改行) */
|
||||
/**
|
||||
* true = 命中既有 **succeeded** job(幂等键复用,未重跑、未改行——唯一可复用终态)。
|
||||
* false = 新建行:键空闲,或撞 failed/cancelled/stale(终态死行)/非终态崩溃残留
|
||||
* (旧行置 stale 宣判)——两种死行都以 `<key>#a<attempt+1>` 后缀键建新行重跑。
|
||||
*/
|
||||
reused: boolean
|
||||
siteKey: string
|
||||
siteId: string
|
||||
@@ -173,15 +180,21 @@ async function writeAtomically(path: string, content: string): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
/** 既有 job 的复用返回(不动行、不重跑;三哈希取 build_input_snapshots 行值——不可变权威);无则 null */
|
||||
/** 既有 job 的复用查询(不动行;三哈希取 build_input_snapshots 行值——不可变权威);无则 null */
|
||||
async function reuseExisting(
|
||||
db: BuildDb,
|
||||
idempotencyKey: string,
|
||||
): Promise<Pick<CreateBuildJobResult, 'jobId' | 'status' | 'sourceHashes'> | null> {
|
||||
): Promise<{
|
||||
jobId: string
|
||||
status: BuildJobStatus
|
||||
attempt: number
|
||||
sourceHashes: { content: string; route: string; asset: string }
|
||||
} | null> {
|
||||
const found = await db
|
||||
.select({
|
||||
id: buildJobs.id,
|
||||
status: buildJobs.status,
|
||||
attempt: buildJobs.attempt,
|
||||
contentSnapshotHash: buildInputSnapshots.contentSnapshotHash,
|
||||
routeSnapshotHash: buildInputSnapshots.routeSnapshotHash,
|
||||
assetManifestHash: buildInputSnapshots.assetManifestHash,
|
||||
@@ -197,6 +210,7 @@ async function reuseExisting(
|
||||
return {
|
||||
jobId: row.id,
|
||||
status: row.status,
|
||||
attempt: row.attempt,
|
||||
sourceHashes: {
|
||||
content: row.contentSnapshotHash ?? '',
|
||||
route: row.routeSnapshotHash ?? '',
|
||||
@@ -205,12 +219,17 @@ async function reuseExisting(
|
||||
}
|
||||
}
|
||||
|
||||
/** 后缀键派生上界(守卫异常并发/病态键空间——正常重试谱系个位数即收敛) */
|
||||
const RETRY_KEY_BOUND = 100
|
||||
|
||||
/**
|
||||
* 创建(并就地推进到底)一个 build job:
|
||||
* 解析(site → published 检查 → config 检查)→ 幂等建行(queued)→ snapshotting
|
||||
* 解析(site → published 检查 → config 检查)→ 幂等建行(queued;**只有 succeeded
|
||||
* 可复用**——终态死行/崩溃残留按阶段 3 注记的策略自动恢复)→ snapshotting
|
||||
* (repeatable read 单事务:物化 + 六文件落盘 + build_input_snapshots 行)→
|
||||
* validating(空——Task 6)→ 报告落盘 → succeeded。
|
||||
* dist 产物与 build_artifacts 登记是 Task 5 职责(本任务 dist 尚空)。
|
||||
* 快照后失败 = job 行 failed 终态 + 半途 input/ 目录清场 + 原样上抛。
|
||||
*/
|
||||
export async function createBuildJob(db: BuildDb, input: CreateBuildJobInput): Promise<CreateBuildJobResult> {
|
||||
const startedAt = performance.now()
|
||||
@@ -258,67 +277,84 @@ export async function createBuildJob(db: BuildDb, input: CreateBuildJobInput): P
|
||||
)
|
||||
}
|
||||
|
||||
// 阶段 3:幂等建 job(queued)——命中既有 key 直接复用返回(不重跑)
|
||||
// 阶段 3:幂等建 job(queued)——**只有 succeeded 可复用**(产物完整且报告在)。
|
||||
// 复用策略(审查 Fix 3 钉死):
|
||||
// - succeeded → 原样复用返回(不重跑、不动行);
|
||||
// - failed/cancelled/stale(终态)→ 不复用:以 `<key>#a<attempt+1>` 后缀键建新行
|
||||
// (attempt 延续记重试谱系);
|
||||
// - queued/snapshotting/building/validating(非终态 = 崩溃残留——本服务单进程就地
|
||||
// 推进,库里滞留非终态即上个进程未走完)→ 旧行置 stale 宣判(finished_at 记宣判
|
||||
// 时刻)+ 同上后缀新行;
|
||||
// - 后缀键自身再撞非 succeeded 行 → 同策略继续递增(上界 RETRY_KEY_BOUND 守卫);
|
||||
// - 并发插行撞 23505 → 回循环头重查(对端 succeeded 则复用,否则按上述策略推进)。
|
||||
// 显式 idempotencyKey 同策略——操作者显式键撞死行同样自动换新行重跑。
|
||||
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 jobKey = idempotencyKey
|
||||
let attempt = 1
|
||||
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,
|
||||
}
|
||||
for (let round = 0; ; round++) {
|
||||
if (round > RETRY_KEY_BOUND) {
|
||||
throw new Error(`幂等键重试派生超界(${idempotencyKey}#a… > ${RETRY_KEY_BOUND})——疑似异常并发`)
|
||||
}
|
||||
const found = await reuseExisting(db, jobKey)
|
||||
if (found !== null && found.status === 'succeeded') {
|
||||
return {
|
||||
jobId: found.jobId,
|
||||
status: found.status,
|
||||
reused: true,
|
||||
siteKey: site.key,
|
||||
siteId: site.id,
|
||||
inputDir: join(artifactsRoot, 'jobs', found.jobId, 'input'),
|
||||
reportPath: join(artifactsRoot, 'jobs', found.jobId, 'reports', 'build.json'),
|
||||
sourceHashes: found.sourceHashes,
|
||||
durationMs: performance.now() - startedAt,
|
||||
}
|
||||
}
|
||||
throw err
|
||||
if (found !== null) {
|
||||
if (found.status !== 'failed' && found.status !== 'cancelled' && found.status !== 'stale') {
|
||||
await db
|
||||
.update(buildJobs)
|
||||
.set({ status: 'stale', finishedAt: new Date() })
|
||||
.where(eq(buildJobs.id, found.jobId))
|
||||
}
|
||||
attempt = found.attempt + 1
|
||||
jobKey = `${idempotencyKey}#a${attempt}`
|
||||
continue
|
||||
}
|
||||
try {
|
||||
const inserted = await db
|
||||
.insert(buildJobs)
|
||||
.values({ siteId: site.id, status: 'queued', idempotencyKey: jobKey })
|
||||
.returning({ id: buildJobs.id })
|
||||
jobId = inserted[0]!.id
|
||||
break
|
||||
} catch (err) {
|
||||
// 并发同 key:唯一约束收敛 → 回循环头重查(23505 = 行已存在)
|
||||
if (isUniqueViolation(err)) continue
|
||||
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 纪律
|
||||
// 失败收口:job 行保留(failed 终态 + error 面 + input/ 目录清场)后原样上抛——镜像 import 纪律
|
||||
try {
|
||||
// 阶段 4:snapshotting(attempt=1、本地 worker 就地推进)
|
||||
// 阶段 4:snapshotting(attempt 延续重试谱系:新建 1 / 撞死行重跑 prev+1;本地 worker 就地推进)
|
||||
await db
|
||||
.update(buildJobs)
|
||||
.set({ status: 'snapshotting', startedAt: new Date(), attempt: 1, workerId: 'local' })
|
||||
.set({ status: 'snapshotting', startedAt: new Date(), attempt, workerId: 'local' })
|
||||
.where(eq(buildJobs.id, jobId))
|
||||
input.onStatus?.('snapshotting', jobId)
|
||||
|
||||
// 阶段 5:repeatable read 单事务——一致性读取(published 集/路由/资产同一时点)+
|
||||
// 六文件落盘 + build_input_snapshots 行(读取面与登记面原子:行在即文件内容可复算)
|
||||
// 六文件落盘 + build_input_snapshots 行(读取面与登记面原子:行在即文件内容可复算)。
|
||||
// 版本/环境探测在事务外单次完成(审查 Fix 4:git rev-parse 是进程派生,不进事务窗口)
|
||||
const buildTimestamp = new Date()
|
||||
const sourceCommit = detectSourceCommit()
|
||||
const pnpmVersion = detectPnpmVersion()
|
||||
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC'
|
||||
const mat = await db.transaction(
|
||||
async (tx) => {
|
||||
const m = await materializeRenderSnapshot(tx, { siteId: site.id, generatedAt: buildTimestamp.toISOString() })
|
||||
@@ -337,12 +373,12 @@ export async function createBuildJob(db: BuildDb, input: CreateBuildJobInput): P
|
||||
themeVersion: THEME_VERSION,
|
||||
themeSourceHash: THEME_SOURCE_HASH_PLACEHOLDER,
|
||||
dependencyLockHash: DEPENDENCY_LOCK_HASH_PLACEHOLDER,
|
||||
sourceCommit: detectSourceCommit(),
|
||||
sourceCommit,
|
||||
nodeVersion: process.versions.node,
|
||||
pnpmVersion: detectPnpmVersion(),
|
||||
pnpmVersion,
|
||||
astroVersion: ASTRO_VERSION_PLACEHOLDER,
|
||||
locale: site.defaultLocale,
|
||||
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC',
|
||||
timezone,
|
||||
buildTimestamp,
|
||||
manifestPath: join(inputDir, 'render-manifest.json'),
|
||||
})
|
||||
@@ -362,7 +398,7 @@ export async function createBuildJob(db: BuildDb, input: CreateBuildJobInput): P
|
||||
status: 'succeeded',
|
||||
siteKey: site.key,
|
||||
siteId: site.id,
|
||||
idempotencyKey,
|
||||
idempotencyKey: jobKey,
|
||||
theme: { key: THEME_KEY, version: THEME_VERSION },
|
||||
themePlaceholders: {
|
||||
themeSourceHash: true,
|
||||
@@ -371,10 +407,10 @@ export async function createBuildJob(db: BuildDb, input: CreateBuildJobInput): P
|
||||
},
|
||||
buildEnv: {
|
||||
node: process.versions.node,
|
||||
pnpm: detectPnpmVersion(),
|
||||
sourceCommit: detectSourceCommit(),
|
||||
pnpm: pnpmVersion,
|
||||
sourceCommit,
|
||||
locale: site.defaultLocale,
|
||||
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC',
|
||||
timezone,
|
||||
},
|
||||
snapshot: {
|
||||
siteConfigRevisionId: mat.siteConfigRevisionId,
|
||||
@@ -427,6 +463,15 @@ export async function createBuildJob(db: BuildDb, input: CreateBuildJobInput): P
|
||||
.update(buildJobs)
|
||||
.set({ status: 'failed', finishedAt: new Date(), errorCode: code, errorSummary: `[build] ${summary}` })
|
||||
.where(eq(buildJobs.id, jobId))
|
||||
// 失败清场(审查 Fix 3):半途落盘的 input/ 目录移除——failed 终态无产物语义,
|
||||
// 不留"行说 failed 而目录半存"的歧义现场。快照行若已插入则由不可变触发器保留为
|
||||
// 审计记录(manifest_path 悬空可接受:job 已 failed,无下游消费方)。清场失败
|
||||
// 不掩盖原始错误(残留目录由操作者处置)。
|
||||
try {
|
||||
await rm(inputDir, { recursive: true, force: true })
|
||||
} catch {
|
||||
// best-effort:保留原错误上抛
|
||||
}
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,8 +15,9 @@
|
||||
// data:/纯片段/相对形态无路径可比对,原样越过不计(与 media-writer 的 data: 口径一致)。
|
||||
//
|
||||
// canonical JSON 确定性:六文件全部以 canonicalJson(键递归字典序、无空白)+ 换行落盘
|
||||
// ——文件字节即哈希输入(content/route/asset 三哈希 = 各自 canonicalJson 的 sha256),
|
||||
// 同输入(含 generatedAt 时点参数)两次物化字节一致。集合排序在 JS 侧钉死(UTF-16
|
||||
// ——文件字节(含尾换行)即哈希输入(content/route/asset 三哈希 = 各自**文件字节**的
|
||||
// sha256——哈希链与落盘文件互证),同输入(含 generatedAt 时点参数)两次物化字节一致。
|
||||
// 集合排序在 JS 侧钉死(UTF-16
|
||||
// 序,不依赖 PG collation):内容按 (typeKey, slug, locale, contentId)、分类按 (key,
|
||||
// objectType)、term 按 (sourceId, slug, id)、路由按 (routePath, queryKey nulls-first,
|
||||
// host)、资产按 publicPath。
|
||||
@@ -29,7 +30,7 @@ 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 { and, desc, eq, inArray, isNotNull, ne } from 'drizzle-orm'
|
||||
import type { NodePgDatabase } from 'drizzle-orm/node-postgres'
|
||||
import {
|
||||
ADAPTER_VERSION,
|
||||
@@ -121,12 +122,15 @@ export interface RewriteStats {
|
||||
/**
|
||||
* bodyHtml 的 src/href URL 重写(正则提取——清洗信任链上游已保证属性形态规整;
|
||||
* 门禁期的 HTML 解析复用清洗管线是 Task 6 职责,此处不引入解析器依赖)。
|
||||
* 属性名边界锚定(审查 Fix 5):前缀必须是空白或串首——`data-src`/`data-href`
|
||||
* 等连字属性不匹配不卷入(\b 会把 data-src 的 '-s' 交界误判为词边界;懒加载
|
||||
* 属性的改写是渲染域决策,不是快照域义务)。
|
||||
* 只替换属性值本身,引号/空白原样保留;命中值与原值相等时计数但零改写。
|
||||
*/
|
||||
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 ATTR_RE = /(^|\s)(src|href)\s*=\s*(["'])([^"']*)\3/gi
|
||||
const out = html.replace(ATTR_RE, (full, _prefix: string, _attr: string, quote: string, value: string) => {
|
||||
const path = comparablePathOf(value)
|
||||
if (path === null) return full
|
||||
const hit = index.get(path)
|
||||
@@ -184,9 +188,9 @@ function cmpNull(a: string | null, b: string | null): number {
|
||||
return cmp(a, b)
|
||||
}
|
||||
|
||||
/** sha256 of canonicalJson(三哈希的单一算法来源) */
|
||||
/** 三哈希的单一算法来源:sha256(文件字节)——canonicalJson + 尾换行后散列(与落盘文件互证) */
|
||||
function hashOf(payload: unknown): string {
|
||||
return createHash('sha256').update(canonicalJson(payload)).digest('hex')
|
||||
return createHash('sha256').update(`${canonicalJson(payload)}\n`).digest('hex')
|
||||
}
|
||||
|
||||
/** 仓库存储根的 incoming 媒体兜底目录(真实数据:storage/incoming/<目录>/<扁平 GUID 文件>) */
|
||||
@@ -258,7 +262,11 @@ export async function materializeRenderSnapshot(
|
||||
assetRows.sort((a, b) => cmp(a.publicPath, b.publicPath))
|
||||
const mediaIndex = buildMediaPathIndex(assetRows)
|
||||
|
||||
// published 修订的 media_usages 引用面(fieldPath → public_path;content.json 的 media 块)
|
||||
// published 修订的 media_usages 引用面(fieldPath → public_path;content.json 的 media 块)。
|
||||
// 确定性(审查 Fix 2):fieldPath/publicPath 双键 orderBy 钉死 first-wins 的"首"——
|
||||
// 同 fieldPath 多资产引用(PK 是 资产×修订×fieldPath,跨资产可重)时取字典序首位,
|
||||
// 其余丢弃并记 issue(§7.6 禁静默);只 join ready 资产(media 块只呈现可服务引用,
|
||||
// 审查 Fix 6)——非 ready 资产的残留引用单独查出版 issue,不进块。
|
||||
const publishedIds = published.map((r) => r.revisionId!)
|
||||
const usageMap = new Map<string, Map<string, string>>()
|
||||
if (publishedIds.length > 0) {
|
||||
@@ -270,14 +278,50 @@ export async function materializeRenderSnapshot(
|
||||
})
|
||||
.from(mediaUsages)
|
||||
.innerJoin(mediaAssets, eq(mediaUsages.mediaAssetId, mediaAssets.id))
|
||||
.where(and(eq(mediaUsages.siteId, input.siteId), inArray(mediaUsages.contentRevisionId, publishedIds)))
|
||||
.where(
|
||||
and(
|
||||
eq(mediaUsages.siteId, input.siteId),
|
||||
inArray(mediaUsages.contentRevisionId, publishedIds),
|
||||
eq(mediaAssets.status, 'ready'),
|
||||
),
|
||||
)
|
||||
.orderBy(mediaUsages.fieldPath, mediaAssets.publicPath)
|
||||
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 first = byField.get(u.fieldPath)
|
||||
if (first === undefined) {
|
||||
byField.set(u.fieldPath, u.publicPath)
|
||||
} else if (first !== u.publicPath) {
|
||||
issues.push(
|
||||
`同 fieldPath 多资产引用(revision=${u.contentRevisionId} fieldPath=${u.fieldPath}):first-wins 取 ${first},丢弃 ${u.publicPath}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
// 非 ready 资产的残留引用(登记后状态翻转等数据面事实):不进 media 块,如实记 issue
|
||||
const nonReady = await db
|
||||
.select({
|
||||
contentRevisionId: mediaUsages.contentRevisionId,
|
||||
fieldPath: mediaUsages.fieldPath,
|
||||
publicPath: mediaAssets.publicPath,
|
||||
status: mediaAssets.status,
|
||||
})
|
||||
.from(mediaUsages)
|
||||
.innerJoin(mediaAssets, eq(mediaUsages.mediaAssetId, mediaAssets.id))
|
||||
.where(
|
||||
and(
|
||||
eq(mediaUsages.siteId, input.siteId),
|
||||
inArray(mediaUsages.contentRevisionId, publishedIds),
|
||||
ne(mediaAssets.status, 'ready'),
|
||||
),
|
||||
)
|
||||
for (const u of nonReady) {
|
||||
issues.push(
|
||||
`media 引用非 ready 资产(revision=${u.contentRevisionId} fieldPath=${u.fieldPath} public_path=${u.publicPath} status=${u.status})——该引用不进 content.json media 块`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user