fix(source): DB 事务化;storageRoot 入 metadata;siteCreated 与并发测试;CLI 体验与校验小修
评审修复(4 项 Important + 8 项 Minor): - DB 事务化:site→connection→snapshots 全部落库包进 db.transaction 单事务 ("任何失败零落库"字面成立);助手改收 Pick<select|insert|execute> 结构类型 (tx 与直连 db 通用)。并发撞唯一约束不在事务内补查(语句失败后续查 25P02), 改为 registerSource 层事务级重试(≤3 次,第二次 select 命中复用分支) - connection 并发:select-then-insert 无约束可兜底,事务内 pg_advisory_xact_lock (siteId:name) 串行化同键登记(kind 枚举扩展推迟 REST 通道,注释注明) - storageRoot 绝对路径固化进每条 snapshot metadata 与报告 inputs(快照不可变, 事后无法补写——永久缺口封堵) - ensureSite 返回 created;RegisterResult.siteCreated;CLI 站点已存在时提示 --name/--locale/--origin 未生效 - 媒体复制期逐文件 sha 复核(copyAndHash 对拍清单,源中途变更 → COPY_FAILED) - 空媒体目录拒绝(EMPTY_MEDIA_DIR) - CLI:PATH_NOT_ALLOWED 追加 --allow 提示;--origin 经 new URL 校验(http/s)、 --locale 语言标签形状校验,fail fast;开跑打印生效白名单(审计) - makeRunId 服务导出、CLI 复用;.tmp 残留手工清理策略写入文件头注释 - 测试 +2:并发同输入恰好一组行/双方同 id/新建标志恰一(3 次重跑稳定); isUniqueViolation 形状单测(drizzle cause.code / 裸 code / 其他) 全套 263 绿;typecheck / lint 绿;CLI 冒烟对临时库验证事务路径、siteCreated 输出、defaults 落库(en-US / https://qa.example)、白名单打印与校验报错 Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
+34
-13
@@ -13,18 +13,15 @@ import { fileURLToPath } from 'node:url'
|
||||
import { Command } from 'commander'
|
||||
import { renderReportHtml, runWxrAdapterReport, type WxrAdapterReport } from '@sf/adapter-wp'
|
||||
import { getDb } from '../apps/api/src/db/client.js'
|
||||
import { registerSource, SourceRegisterError, type RegisterInput } from '../apps/api/src/modules/source/register.service.js'
|
||||
import {
|
||||
makeRunId,
|
||||
registerSource,
|
||||
SourceRegisterError,
|
||||
type RegisterInput,
|
||||
} from '../apps/api/src/modules/source/register.service.js'
|
||||
|
||||
const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
|
||||
|
||||
/** runId = 紧凑时间戳 + 6 位随机(与 register.service 同形;报告目录按 runId 归档) */
|
||||
function makeRunId(): string {
|
||||
const ts = new Date()
|
||||
const p = (n: number) => String(n).padStart(2, '0')
|
||||
const compact = `${ts.getFullYear()}${p(ts.getMonth() + 1)}${p(ts.getDate())}T${p(ts.getHours())}${p(ts.getMinutes())}${p(ts.getSeconds())}`
|
||||
return `${compact}-${Math.random().toString(36).slice(2, 8)}`
|
||||
}
|
||||
|
||||
/** 探测报告摘要(stdout):版本块之外的计数/清洗/耗时 + 致命错误 */
|
||||
function printProbeSummary(report: WxrAdapterReport): void {
|
||||
console.log(` rows=${report.totals.rows} terms=${report.totals.terms} unsupported=${report.totals.unsupported} failed=${report.totals.failed}`)
|
||||
@@ -91,30 +88,48 @@ program
|
||||
if (opts.wxr === undefined && opts.sitemap === undefined && opts.media === undefined) {
|
||||
throw new SourceRegisterError('NO_INPUTS', '至少提供 --wxr / --sitemap / --media 之一')
|
||||
}
|
||||
// fail fast:建站默认列在服务端只在"站点新建"时生效——先做形状校验,避免跑完摄取才报错
|
||||
if (opts.origin !== undefined) {
|
||||
let parsed: URL
|
||||
try {
|
||||
parsed = new URL(opts.origin)
|
||||
} catch {
|
||||
throw new Error(`--origin 不是合法 URL:${opts.origin}`)
|
||||
}
|
||||
if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') {
|
||||
throw new Error(`--origin 必须是 http(s) URL:${opts.origin}`)
|
||||
}
|
||||
}
|
||||
if (opts.locale !== undefined && !/^[A-Za-z]{2,8}(-[A-Za-z0-9]{1,8})*$/.test(opts.locale)) {
|
||||
throw new Error(`--locale 不符合语言标签形状(如 en、zh-CN):${opts.locale}`)
|
||||
}
|
||||
// CLI 是可信操作者通道:白名单 = storage/incoming(常规通道)+ 操作者所在 cwd(显式
|
||||
// 于此运行的动作本身即放行依据)+ --allow 追加根。服务内的白名单校验(PATH_NOT_ALLOWED)
|
||||
// 防的是未来程序化/REST 通道的越界读取。
|
||||
// 防的是未来程序化/REST 通道的越界读取。生效白名单先行打印(审计:路径放行决策可回溯)。
|
||||
const allowedRoots = [join(REPO_ROOT, 'storage', 'incoming'), process.cwd(), ...opts.allow.map((d) => resolve(d))]
|
||||
const input: RegisterInput = {
|
||||
siteKey: opts.site,
|
||||
allowedRoots: [join(REPO_ROOT, 'storage', 'incoming'), process.cwd(), ...opts.allow.map((d) => resolve(d))],
|
||||
allowedRoots,
|
||||
storageRoot: join(REPO_ROOT, 'storage'),
|
||||
reportRoot: join(REPO_ROOT, 'artifacts', 'register'),
|
||||
}
|
||||
if (opts.wxr !== undefined) input.wxrPath = opts.wxr
|
||||
if (opts.sitemap !== undefined) input.sitemapPath = opts.sitemap
|
||||
if (opts.media !== undefined) input.mediaDir = opts.media
|
||||
if (opts.name !== undefined || opts.locale !== undefined || opts.origin !== undefined) {
|
||||
const defaultsPassed = opts.name !== undefined || opts.locale !== undefined || opts.origin !== undefined
|
||||
if (defaultsPassed) {
|
||||
const defaults: NonNullable<RegisterInput['siteDefaults']> = {}
|
||||
if (opts.name !== undefined) defaults.name = opts.name
|
||||
if (opts.locale !== undefined) defaults.defaultLocale = opts.locale
|
||||
if (opts.origin !== undefined) defaults.productionOrigin = opts.origin
|
||||
input.siteDefaults = defaults
|
||||
}
|
||||
console.log(`[source:register] 允许目录:${allowedRoots.join(' ; ')}`)
|
||||
// getDb 走 DATABASE_URL_TEST ?? DATABASE_URL 回退链——对临时库冒烟只需 env 覆盖,无需改代码
|
||||
const db = getDb()
|
||||
try {
|
||||
const result = await registerSource(db, input)
|
||||
console.log(`[source:register] runId=${result.runId} site=${result.siteKey} (${result.siteId})`)
|
||||
console.log(`[source:register] runId=${result.runId} site=${result.siteKey} (${result.siteId}${result.siteCreated ? ' 新建' : ' 已存在'})`)
|
||||
console.log(
|
||||
` connection=${result.connectionId}${result.connectionCreated ? ' (created)' : ' (existing)'}`,
|
||||
)
|
||||
@@ -123,6 +138,9 @@ program
|
||||
` ${s.kind}: sha256=${s.sha256.slice(0, 12)}… bytes=${s.byteSize} ${s.reused ? 'snapshot复用' : 'snapshot新建'}${s.copied ? '' : ' 副本已存在'} (${s.durationMs.toFixed(1)}ms)`,
|
||||
)
|
||||
}
|
||||
if (!result.siteCreated && defaultsPassed) {
|
||||
console.error(' 注意:站点已存在,--name/--locale/--origin 未生效')
|
||||
}
|
||||
console.log(` report: ${result.reportPath} (total ${result.durationMs.toFixed(1)}ms)`)
|
||||
} finally {
|
||||
await (db as { $client: { end: () => Promise<void> } }).$client.end()
|
||||
@@ -134,6 +152,9 @@ program
|
||||
program.parseAsync(process.argv).catch((err: unknown) => {
|
||||
if (err instanceof SourceRegisterError) {
|
||||
console.error(`[sf] ${err.code}: ${err.message}`)
|
||||
if (err.code === 'PATH_NOT_ALLOWED') {
|
||||
console.error(' 提示:可用 --allow <dir> 放行额外目录,或将文件移入上述目录')
|
||||
}
|
||||
} else {
|
||||
console.error(`[sf] ${err instanceof Error ? err.message : String(err)}`)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user