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:
@@ -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