gongxiangp2/docs/material-preview-health.md
WuYongJie a890660853 fix(materials): audit historical previews and gate production releases
Preserve known legacy failures by reviewed identity, probe material delivery before and after releases, and retain originals and downloads.
2026-10-23 19:47:58 +08:00

16 KiB
Raw Blame History

素材预览只读验收

正式发布入口 scripts/git-incremental-deploy.ps1 已接入只读预览门禁,Mac 入口抽取同一份模板。它不修复图片、不修改引用、不执行 Schema DDL、不调用模型,也不改原素材或下载文件。新增脚本提交并推送只证明代码已纳入发布;是否已在生产安装首份审核基线、实际执行哪个阶段,必须以对应私有回执为准,不能用离线测试代替生产验收。

实际发布门禁

每次真实发布均执行三阶段:capture 在旧容器停止前,candidate 在切流前,post 在代理目标验证通过后、写入当前版本记录前。direct replacement 的 candidate/post 失败调用现有 fail_direct_replace_with_restore;保留的蓝绿代码分别调用已有的切流前失败处理和切流后回滚处理。没有跳过开关。Mac 与 PowerShell 都从构建机精确 Git checkout生成门禁模块,并通过现有 SSH stdin 执行,不要求旧镜像内已有新脚本,不向应用目录复制代码。

material-preview-release-gate.mjs 复用现有清点器、直连网络防护和 Sharp 全像素解码。在只读 RepeatableRead 事务中枚举全部未删除 Material.previewUrl,包含缺地址或不支持检查的记录,不用 HISTORY_IMPORT。全元数据索引与 SHA 保留在每份回执;图片 GET 仅覆盖:

  • 全部仍指向独立旧桶的活跃引用,本轮预计 472 项,实时枚举,不硬编码数量掩盖新增或遗漏。
  • 十二项关键材质,按固定素材 ID 核对,不允许减少或改成其它样本。
  • 与上次已接受全元数据索引相比,新增或预览源地址改变的当前素材。

其余未变化的当前源与原有不受支持的预览明确登记为 unverified,不能记健康。新增或变化后变成不可检查的引用直接阻止发布。并发上传/删除造成候选或 post 库存 SHA 与 capture 不同,也停止并保留旧版本;不会通过重新抽样、忽略新记录或无限重试来抹平差异。

固定预算为每阶段最多 768 次单次 GET、4 并发、正文总量 128 MiB、总时长 360 秒;单项仍受 8 MiB、10 秒、2500 万像素限制,不重试、不跟随跳转。范围超过预算时停止,需另行安排独立全量验收,不能临时提高预算或清空基线。

候选与 post 使用 capture 的同一份探测清单及 SHA,逐项核对素材 ID、源地址 SHA、角色、结果数量、解码与正文 SHA;同时比较规范化索引和原始登记值的库存摘要。既有健康 URL 的内容在未改地址时变化、新失败、原失败类型或完整失败正文改变、漏项、检测器不可用均不通过。472 项既有 FRP 404 可以作为人工审核的事故基线保留,但每个阶段均实际重读并明确报告 knownFailures、allSelectedHealthy=false;它们不能成为正常图片,也不能自动容纳新失败。某项已恢复健康后若下一阶段再次失败,会阻止发布。

首次故障准入还绑定版本化 scripts/fixtures/material-preview-known-legacy-20261023.json 的 472 对精确素材 ID + 规范化源地址 SHA,并固定 489 字节、正文 SHA 967e369d3be4fab5eb1229ec015b2923abf6bc81ebe3aaa662cb095cbec81023 的已核实 FRP 页。集合 SHA 固定为 953e6b1c9e10ea05e30e289ebbd5364569d918b5dd4a1273af52abc531fe475c,生成依据是独立只读逐项审计原文件 SHA 40eb8d834d5c6bd9edcdc49e69ae227a83be2374bb9b525a0cec0bcd652c4e0d。proof 只有 ID/摘要,没有名称、URL 或凭据;构建时校验后内联,不能在首次 bootstrap 时根据现场新失败自动重建。第 473 项、删一项换入另一 ID、同 ID 变更源,均不能混入已知故障,即使返回完全相同的 FRP 页。

登记源与实际同桶 canonical 交付地址分别探测、分别保留结果。只复用现有“当前桶的旧兼容域名交付到当前公开入口”语义,不改变桶或 key;独立旧桶永远保持原址,不使用域名替换掩盖故障。新花色01的旧登记域名网络失败,只能在其固定素材 ID 的 canonical 图片完整解码为 768×1600、原正文 SHA b40687cac0cbd537e209f8caa56c642bf501a658d0c3585140e8b9950937da53 一致时单列;不能把前者改写为健康,也不能将其它当前源失败一概豁免。

URL 重写、代理、S3 或网络识别五个源码文件的精确指纹,与上次接受基线、当前容器、目标 checkout三方比较。即使现场已被绕过门禁改成 B、此次目标也为 B,旧基线 A 仍会触发扩大范围。非秘密配置 S3_ENDPOINT、S3_BUCKET、S3_FILE_PATH、S3_PUBLIC_DOMAIN、S3_REGION、SERVER_IP、MINIO_API_PORT、APP_PUBLIC_URL 同样做三方指纹比较;目标来自真实部署 env 文件的白名单,脚本控制的公开 URL 按真正 Docker 参数覆盖,不读取或记录 access key/secret 字段,拒绝 URL userinfo、query、片段和重复目标键。缺少完整目标配置直接停止;candidate/post 实际配置必须与 capture 记录的目标一致,不能只靠公开 URL 仍然可读放行内部回源变化。这些交付代码/配置变化会扩大为全部当前预览;超预算会 fail-closed,要求独立全量验收后再安排该类发布。本入口不提供跳过保护的维护参数。

审计位置固定属于本项目:

/mnt/PoolOne/apps/youyidian-3d/local-proxy/youyidian-3d-material/material-preview-health/
  accepted-pointer.json
  bootstrap-<审核编号>/bootstrap.json
  release-<commit前12位>-<随机串>/
    accepted.json
    accepted-pointer-before.json
    gate-module.url
    capture.json
    candidate.json
    post.json

根目录和单次目录为 0700,文件为 0600。accepted-pointer.json 是唯一原子更新的指针,包含已审核/已接受文件的相对路径和原文件字节 SHA。读取时拒绝软链接、越界路径、摘要不符或超过 32 MiB 的基线。首次缺少指针或基线时,在停止旧容器前失败;不自动创建“全部正常”的基线。完整发布验收后才原子推进指针;旧回执、旧指针快照、失败报告仍留存,不混入素材或客户端临时目录。

首次审核基线(独立、显式步骤)

首次不把两万余条未探测的当前源伪装为健康,也不因它们全是“新索引项”而自动下载两万张图片。由已核对的项目运维操作者完成一次 bootstrap:读取全库存元数据,探测全部独立旧桶和十二项关键材质,审核其结果后固定原文件 SHA。正常发布永远不能自动重建 bootstrap。

  1. 在已审核的精确 checkout 中生成模块 URL:node scripts/material-preview-release-gate.mjs bundle。模块不含 Key 或数据库 URL。通过已核对的本项目 SSH 将输出作为 0600 文件存入新的私有 bootstrap-<审核编号>/gate-module.url,不要写入镜像或源码目录。
  2. 使用本项目当前容器的依赖和环境运行下面的只读 bootstrap。调用前仍需核对 NAS、当前容器、部署 SHA 和目录身份;<已核对40位SHA> 必须替换为真实值。创建新目录与审计文件属于保存证据,不是修改数据库。
python3 - <新的bootstrap目录>/gate-module.url <已核对40位SHA> <<'PY' | docker exec -i -w /app <已核对当前容器> node --input-type=module - > <新的bootstrap目录>/bootstrap.json
import json, pathlib, sys
module = pathlib.Path(sys.argv[1]).read_text()
request = dict(version=1, stage='bootstrap', commit=sys.argv[2])
print('const {runReleaseStage}=await import('+json.dumps(module)+');')
print('try {const r=await runReleaseStage('+json.dumps(request)+');console.log(JSON.stringify(r));process.exitCode=r.passed?0:1;} catch(e) {console.log(JSON.stringify({passed:false,code:"bootstrap_failed"}));process.exitCode=1;}')
PY
  1. 独立审核该文件的库存数量、完整性、十二项材质、旧桶全部引用数量、每项失败类型、正文 SHA/尺寸、knownFailures、unverified 和预算。passed=true 只表示满足有明确已知事故基线的发布比较要求,不代表旧桶已恢复。原登记 URL 或数据库链接不应粘贴到公开日志。
  2. 用 sha256sum bootstrap.json 获得精确原文件 SHA,审核后以相对路径及该已核对 SHA写入 0600 的 accepted-pointer.json。指针结构示例:{"version":1,"file":"bootstrap-审核编号/bootstrap.json","sha256":"<已核对64位SHA>"}。不得把示例 SHA、空结果或重新运行后的不同文件替代已审核基线;正常发布只读取,不自动安装。
  3. 用相同精确门禁模块执行当前容器的 capture→candidate→post 只读演练,并保留三个实际回执。程序化调用后三阶段时除 accepted、capture、candidate 对应回执外,还必须传 targetDeliveryConfig;当前容器演练可用审核后的 accepted.deliveryConfig,正式发布则由 PS1 白名单解析待部署 env 与实际 Docker 覆盖值。演练没有替换镜像/切换代理,不能写成真实候选切流验收。正式发布时同三个函数自动执行,审计清单由 capture 固定。

跨平台演练须核对目标 checkout 与同一 Git 提交的原始 blob 字节。Windows 的 CRLF 工作副本可能与生产 LF 不同,应在新的受限目录按 Git blob 构建匹配字节快照;不能通过忽略或归一化行尾放宽门禁。正式构建的模块指纹与 Docker 上下文均来自同一精确 checkout。

导出并逐批检查

在已核对数据库身份、具备项目运行依赖的环境执行,输出目录必须是新目录;不要将连接凭据写入命令或报告。

node scripts/export-material-preview-health-manifest.mjs --output-dir /tmp/material-preview-inventory-20261023 --baseline-host s3.1080p.hk --baseline-count 483
node scripts/check-material-preview-health.mjs --manifest <新目录>/manifest-000001.json

导出器在 PostgreSQL RepeatableRead 事务中设置 SET TRANSACTION READ ONLY,读取全部 deletedAt: null 素材,不按 HISTORY_IMPORT 标记筛选,不导出人员字段。产物为 audit.json 和 manifest-000001.json 等清单,每批最多 50 项;须逐批运行检查器,并另存每批 JSON 输出及退出码。

输出路径须为运行环境内的绝对路径,父目录须已存在。导出不发图片 GET。先核对 audit.json 的 activeCount、exportedCount、uncheckableCount、inventoryComplete 和 legacyHostCounts。inventoryComplete 只说明库存枚举完整,不代表图片健康;缺少预览、源地址不受支持、未检查或检查失败的项目均不能记为健康。2026-10-23 恢复前基线为 483 条活跃旧域名预览引用,只作对照,不能代替本次实时导出。11 项材质精确恢复后,若同期没有其它变动,应为 472;legacyBaseline.delta = -11 是预期的本轮变化,并非其余 472 项已恢复。基线不匹配只报告差额,不自动写库或隐藏引用。

检查器按清单中的精确登记地址执行有界单次 GET,不重试、不跟随跳转,并核对 HTTP、MIME、图片魔数及 Sharp 完整像素解码;仅头部可识别不足以通过。8 MiB、10 秒、2500 万像素上限保持固定,解码器按运行环境惰性 createRequire 加载,不执行安装。保留清单、清单哈希和全部批次报告。audit.batches[].sha256 对应原文件字节,manifestSha256 对应检查器的同名字段(紧凑 JSON 哈希),二者须分别核对。单批 50 项通过、或 12 项材质通过,均不能写成“全库存通过”。

完整图片验收需另行安排流量预算,逐一运行所有 audit.batches 并对齐哈希、素材 ID、数量及退出码。2026-10-23 已完成 472 项旧源单次 GET,以及发布检查三阶段每次 485 个固定地址的实际探测;没有执行两万余张当前源全量 GET。不因导出了两万余项元数据而将它们标记为健康,未运行的批次保留为未检查。首次基线安装、三阶段原文件 SHA、已知失败与现场范围见 docs/material-preview-incident-2026-10-23.md。

尚未部署脚本时的 stdin 适配

当前生产镜像尚未复制这些新脚本及其 registry 依赖。可从已核对本地源码生成以下 stdin 载荷,在目标容器 /app 的依赖环境执行;不向运行目录复制源码,不新增依赖。下例只导出元数据,不读取图片。/tmp 输出目录仍必须新建且不与历史验收重名。载荷中不包含数据库 URL、Token 或 Key。

在仓库根目录运行以下 JavaScript(本地 Node 的 --input-type=module -)。将 stdout 通过已核对的本项目 SSH/docker exec -i -w /app <当前容器> node --input-type=module - 管道传入,不要在尚未核对容器身份时执行:

import { readFile } from 'node:fs/promises'
import { LEGACY_DOMAINS } from './scripts/check-legacy-storage-boundaries.mjs'
const data = s => 'data:text/javascript;base64,' + Buffer.from(s).toString('base64')
const runtimeBase = 'file:///app/package.json'
const adapt = s => s.replaceAll('import.meta.url', JSON.stringify(runtimeBase))
const registry = data(`export const LEGACY_DOMAINS=${JSON.stringify(LEGACY_DOMAINS)}`)
const health = data(adapt(await readFile('scripts/check-material-preview-health.mjs', 'utf8'))
  .replace("'./check-legacy-storage-boundaries.mjs'", JSON.stringify(registry)))
const exporter = data(adapt(await readFile('scripts/export-material-preview-health-manifest.mjs', 'utf8'))
  .replace("'./check-legacy-storage-boundaries.mjs'", JSON.stringify(registry))
  .replace("'./check-material-preview-health.mjs'", JSON.stringify(health)))
const args = ['--output-dir', '/tmp/material-preview-inventory-20261023-after11',
  '--baseline-host', LEGACY_DOMAINS[0], '--baseline-count', '483']
process.stdout.write(`const {main}=await import(${JSON.stringify(exporter)});
try { const r=await main(${JSON.stringify(args)});
 console.log(JSON.stringify({readOnly:true,outputDirectory:r.outputDirectory,
 activeCount:r.audit.activeCount,exportedCount:r.audit.exportedCount,
 uncheckableCount:r.audit.uncheckableCount,legacyHostCounts:r.audit.legacyHostCounts,
 legacyBaseline:r.audit.legacyBaseline,inventoryComplete:true,healthChecksExecuted:false}));
 process.exitCode=r.audit.readyForHealthChecks?0:1;
} catch { console.log(JSON.stringify({readOnly:true,inventoryComplete:false,code:'inventory_export_failed'}));process.exitCode=1; }`)

stdin 中的 createRequire('file:///app/package.json') 使用容器已有的 @prisma/client/Sharp;不是读取本机另一套数据库配置。若依赖不存在、数据库事务失败或输出中途失败,退出非零,不能把残留文件当完整清单。导出器在所有批次成功写入后才写 audit.json。导出完成后再由调用方按需取回只读审计文件,本命令不清理文件。

发布接入函数

当前正式策略仍为 direct replacement,没有恢复蓝绿发布入口。接入点按函数语义定位:

位置 应保留的验收证据
direct_replace_active_container 中 docker stop 前 prepare_material_preview_release 验证已审核指针;run_material_preview_release_gate ... capture 清点全库存并固定探测清单
verify_candidate_brand_and_storage_dependencies 后、切流前 run_material_preview_release_gate ... candidate 验证同清单与库存;失败走现有 immutable image 恢复
verify_proxy_target 成功后、write_current_container 前 run_material_preview_release_gate ... post 通过公开存储 URL 实际 GET/解码同清单;失败走现有恢复
当前版本记录成功后 accept_material_preview_release 核对三个阶段同 commit/清单/库存,原子推进指针

macOS wrapper 从同一 PowerShell 源码抽取发布逻辑,不另复制一套条件。已有 shell 回归验证 capture 失败时没有 stop/switch、candidate 失败时没有 switch、post 失败时调用一次现有回滚,且失败不推进基线。该只读探测验证公开存储交付路径,不能代替登录态页面、私有下载资格或客户端实际 UI 的单独验收。

11 项材质预览恢复属于独立的精确 CAS 数据补丁:先验证新对象,再以原值为条件更新并保留回退记录;不得混入本只读检查器,也不代表高清原件或其余历史依赖已恢复。

相关离线回归:

node --test scripts/check-material-preview-health.test.mjs scripts/export-material-preview-health-manifest.test.mjs scripts/material-preview-release-gate.test.mjs scripts/git-incremental-deploy.test.mjs