feat(import): job 骨架与生命周期
Plan 3 Task 1:sf import 命令骨架 + import_jobs 生命周期(空管道,Task 6 接真实管道)。 - import.service.ts(零装饰器):createImportJob——站点/快照解析(缺省按 (connection, kind='wxr') 选最新;三快照绑定按前置事实 13 记入报告,job 行不扩列)、幂等建行 (默认 key=site+wxr快照+三语义版本;重复提交复用既有 job 不重跑,并发撞唯一约束 重查收敛)、状态推进 queued→running→succeeded/failed(attempt/started_at/finished_at/ error_code/error_summary)、报告落 artifacts/import/<jobId>/reports/import.json (全四版本块:adapter/mapping/sanitizer/parser;job 行落 mapping_version) - 可预期错误带 code:SITE_NOT_FOUND / SNAPSHOT_NOT_FOUND / NO_WXR_SNAPSHOT / PIPELINE_FAILED——解析期失败零 job 行 - adapter 事件加法式补 postId(前置事实 12):unsupported/error 事件可选携带 String(postId),缺失时键缺席不硬造 source_id;row 事件与报告聚合零影响、不触发版本 bump - canonicalJson 提取至 apps/api/src/common/canonical.ts 共享导出(前置事实 6), register 侧改 import 零行为变更(既有清单哈希测试守护) - sf CLI + import 命令(--site/--snapshot/--key/--connection),输出 job 摘要与版本块 - 测试:tests/integration/import-job.test.ts(5 例:生命周期观察/最新快照解析/幂等复用 与并发收敛/可预期失败零落行/failed 路径)+ adapter postId 3 例 + canonical 单测 6 例 (265→279 全绿);typecheck 四段链/lint 绿;临时库 CLI 冒烟(register→import→复用)通过 Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
+51
-6
@@ -1,11 +1,12 @@
|
||||
#!/usr/bin/env node
|
||||
// sf CLI(Plan 2 Task 7):commander 两命令——
|
||||
// sf CLI(Plan 2 Task 7 + Plan 3 Task 1):commander 三命令——
|
||||
// - wxr:probe:只读诊断(任意可读路径;Task 8 直读 cms 用此通道),出 JSON/HTML 报告不入库;
|
||||
// - source:register:调 registerSource 登记 content-addressed 快照(路径白名单校验在服务内)。
|
||||
// - source:register:调 registerSource 登记 content-addressed 快照(路径白名单校验在服务内);
|
||||
// - import:调 createImportJob 建 import_jobs 并推进生命周期(Task 1 空管道骨架;Task 6 接真实管道)。
|
||||
// 执行机制(Plan 2 评审钉死):根 devDep tsx 直吃 TS 源码(含 apps/api 的 Nest 源码与
|
||||
// packages 的装饰器/TS 源文件),@sf/* 经根/包 tsconfig paths 直连 src——无需预构建 dist。
|
||||
// 注意:CLI 只 import register.service.ts(零装饰器纯函数文件),不拉起 Nest 模块——
|
||||
// 反射元数据依赖由此避免;source.module.ts 留给 standalone context。
|
||||
// 注意:CLI 只 import register.service.ts / import.service.ts(零装饰器纯函数文件),
|
||||
// 不拉起 Nest 模块——反射元数据依赖由此避免;module 文件留给 standalone context。
|
||||
import { createReadStream } from 'node:fs'
|
||||
import { mkdir, writeFile } from 'node:fs/promises'
|
||||
import { dirname, join, resolve } from 'node:path'
|
||||
@@ -13,6 +14,11 @@ 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 {
|
||||
ImportError,
|
||||
createImportJob,
|
||||
type CreateImportJobInput,
|
||||
} from '../apps/api/src/modules/import/import.service.js'
|
||||
import {
|
||||
makeRunId,
|
||||
registerSource,
|
||||
@@ -39,7 +45,7 @@ function printProbeSummary(report: WxrAdapterReport): void {
|
||||
const program = new Command()
|
||||
program
|
||||
.name('sf')
|
||||
.description('SiteForge CLI(阶段 2:来源输入登记与 WXR 探测)')
|
||||
.description('SiteForge CLI(阶段 3:来源输入登记、WXR 探测与导入 job 生命周期)')
|
||||
.version('0.1.0')
|
||||
|
||||
program
|
||||
@@ -155,14 +161,53 @@ program
|
||||
}
|
||||
})
|
||||
|
||||
program
|
||||
.command('import')
|
||||
.description('导入已登记的 wxr 快照(Task 1 空管道骨架:job 生命周期 + 报告;真实管道 Task 6 接入)')
|
||||
.requiredOption('--site <key>', '站点 key')
|
||||
.option('--snapshot <id>', 'wxr 快照 id(缺省按 connection+kind=wxr 选最新)')
|
||||
.option('--key <key>', '幂等键(缺省 = site+snapshot+版本块 自动派生;重复提交同 key 复用既有 job 不重跑)')
|
||||
.option('--connection <name>', '连接名(快照解析的 connection 侧,缺省 default)', 'default')
|
||||
.action(async (opts: { site: string; snapshot?: string; key?: string; connection: string }) => {
|
||||
// getDb 走 DATABASE_URL_TEST ?? DATABASE_URL 回退链——临时库冒烟只需 env 覆盖
|
||||
const db = getDb()
|
||||
// exactOptionalPropertyTypes:缺席键不传 undefined 值(与 source:register 同款)
|
||||
const input: CreateImportJobInput = { siteKey: opts.site, connectionName: opts.connection }
|
||||
if (opts.snapshot !== undefined) input.snapshotId = opts.snapshot
|
||||
if (opts.key !== undefined) input.idempotencyKey = opts.key
|
||||
try {
|
||||
const result = await createImportJob(db, input)
|
||||
console.log(
|
||||
`[import] job=${result.jobId} status=${result.status}${result.reused ? '(复用既有 job,未重跑)' : ''}`,
|
||||
)
|
||||
console.log(` site=${result.siteKey} connection=${result.connectionId}`)
|
||||
console.log(
|
||||
` wxr 快照: ${result.wxrSnapshot.sha256.slice(0, 12)}… (${result.wxrSnapshot.id})`,
|
||||
)
|
||||
console.log(
|
||||
` media_bundle: ${result.mediaBundleSnapshot === null ? '(无)' : result.mediaBundleSnapshot.id} / manual_url_list: ${result.manualUrlListSnapshot === null ? '(无)' : result.manualUrlListSnapshot.id}`,
|
||||
)
|
||||
console.log(
|
||||
` 版本: adapter=${result.versions.adapter} mapping=${result.versions.mapping} sanitizer=${result.versions.sanitizer} parser=${result.versions.parser}`,
|
||||
)
|
||||
if (result.reportPath !== null) console.log(` report: ${result.reportPath}`)
|
||||
console.log(` total ${result.durationMs.toFixed(1)}ms`)
|
||||
if (result.status === 'failed') process.exitCode = 1
|
||||
} finally {
|
||||
await (db as { $client: { end: () => Promise<void> } }).$client.end()
|
||||
}
|
||||
})
|
||||
|
||||
// parseAsync:action 内抛错走此 catch(commander 默认会 process.exit(1) 并打印堆栈,
|
||||
// 这里收敛为单行错误信息 + exitCode——SourceRegisterError 打 code,其余原样呈现)
|
||||
// 这里收敛为单行错误信息 + exitCode——SourceRegisterError/ImportError 打 code,其余原样呈现)
|
||||
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 if (err instanceof ImportError) {
|
||||
console.error(`[sf] ${err.code}: ${err.message}`)
|
||||
} else {
|
||||
console.error(`[sf] ${err instanceof Error ? err.message : String(err)}`)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user