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:
浅念
2026-10-21 15:30:42 +08:00
parent e52a2eb3e4
commit 0e8b2b5356
10 changed files with 1052 additions and 28 deletions
+22
View File
@@ -0,0 +1,22 @@
// canonical JSON 序列化(Plan 3 Task 1 从 register.service.ts 提取,前置事实 6):
// 对象键递归字典序排序、无空白——media_bundle 清单哈希(register)与 normalized
// checksum(阶段 3 导入,spec §7.5)共用的序列化基形,单一实现避免两处定义漂移。
// 定义钉死(测试独立重算对拍,见 tests/unit/canonical.test.ts):
// - 数组:元素保序(顺序是语义的一部分),逐元素递归;
// - 对象:键按 Array.prototype.sort 默认序(UTF-16 码元)排序后 `"key":value` 逗号连接;
// - 原始值:JSON.stringify 语义(string/number/boolean/null)。
// register 侧改 import 本函数——零行为变更(既有 media_bundle 清单哈希测试守护)。
/** canonical JSON(对象键递归排序、无空白)——哈希输入的序列化基形 */
export function canonicalJson(value: unknown): string {
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]`
if (value !== null && typeof value === 'object') {
const record = value as Record<string, unknown>
const body = Object.keys(record)
.sort()
.map((k) => `${JSON.stringify(k)}:${canonicalJson(record[k])}`)
.join(',')
return `{${body}}`
}
return JSON.stringify(value)
}
@@ -0,0 +1,27 @@
// import 域 Nest 模块(Plan 3 Task 1):注册 ImportJobService 供 standalone context
// 使用(NestFactory.createApplicationContext(ImportModule)——本切片 API 不监听端口,ADR-002)。
// 与 source.module.ts 同构:逻辑全在 import.service.ts 的零装饰器独立函数里,本类只提供
// DI 语境下的调用面;'reflect-metadata' 置顶(CLI/测试直接 import import.service.ts 纯函数
// 文件,不经本模块,故无需提前加载 reflect-metadata)。
import 'reflect-metadata'
import { Injectable, Module } from '@nestjs/common'
import {
createImportJob,
type CreateImportJobInput,
type CreateImportJobResult,
type ImportDb,
} from './import.service.js'
/** createImportJob 的 Nest 注入壳(standalone context / 后续 REST 通道复用同一实现) */
@Injectable()
export class ImportJobService {
create(db: ImportDb, input: CreateImportJobInput): Promise<CreateImportJobResult> {
return createImportJob(db, input)
}
}
@Module({
providers: [ImportJobService],
exports: [ImportJobService],
})
export class ImportModule {}
@@ -0,0 +1,436 @@
// import_jobs 生命周期服务(Plan 3 Task 1,spec §7.2/§7.5、前置事实 6/13):
// 解析站点与快照 → 幂等建 job → 状态推进(queued → running → succeeded/failed)→
// 报告落 <reportRoot>/<jobId>/reports/import.json。
//
// 本文件与 register.service.ts 同纪律保持 **零装饰器**:CLI(scripts/sf.ts 经 tsx)与
// 集成测试直接 import 本文件,不拉起 Nest 装饰器执行;Nest 侧由 import.module.ts 薄包装。
//
// 三快照绑定(前置事实 13):job.source_snapshot_id 指 **wxr 快照**(主输入);
// media_bundle / manual_url_list 不入 job 行(不扩列),按 (wxr 快照的 connection, kind)
// 解析最新记入 job 报告 snapshots 段——导入管道(Task 4/6)从报告/上下文取用。
//
// 幂等:import_jobs.idempotency_key UNIQUE——重复提交同 key **复用既有 job 不重跑**
// (含失败 job:重试须换 key 或等 Task 6 的重试语义,本任务钉死“不静默重跑”);并发
// 同 key 由唯一约束收敛(插入撞 23505 → 重查复用)。
//
// 版本块:四常量单源来自 @sf/adapter-wp 的 versions(ADAPTER/MAPPING/SANITIZER/PARSER);
// job 行只落 mapping_version(MAPPING_VERSION),全四项进报告。默认幂等键 =
// site + wxr 快照 + 三语义版本(adapter/mapping/sanitizer——任一 bump 即新 job,对齐
// spec §7.5 “版本变化可显式触发重新标准化”;parser 是诊断信息不进键)。
//
// 管道注入:Task 6 才接真实管道(terms/content/media/routes 写入器);本任务默认**空
// 管道**(直接成功),pipeline 钩子供集成测试观察 running 中间态与 failed 路径。
import { mkdir, writeFile } from 'node:fs/promises'
import { dirname, join, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { and, desc, eq } from 'drizzle-orm'
import type { NodePgDatabase } from 'drizzle-orm/node-postgres'
import {
ADAPTER_VERSION,
MAPPING_VERSION,
PARSER_VERSION,
SANITIZER_VERSION,
} from '@sf/adapter-wp'
import { importJobs, sites, sourceConnections, sourceSnapshots } from '../../db/schema.js'
import type * as schema from '../../db/schema.js'
import { isUniqueViolation } from '../source/register.service.js'
/** 本文件位于 <repo>/apps/api/src/modules/import/ → 上五级是仓库根 */
const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../../../../..')
export type ImportDb = NodePgDatabase<typeof schema>
export type ImportJobStatus = (typeof schema.importJobStatus.enumValues)[number]
export type ImportErrorCode =
| 'SITE_NOT_FOUND'
| 'SNAPSHOT_NOT_FOUND'
| 'NO_WXR_SNAPSHOT'
| 'PIPELINE_FAILED'
/** 导入失败(可预期错误):CLI 以 code 区分呈现;不裹程序 bug(那些原样上抛) */
export class ImportError extends Error {
readonly code: ImportErrorCode
constructor(code: ImportErrorCode, message: string) {
super(message)
this.name = 'ImportError'
this.code = code
}
}
/** 版本块(报告/上下文携带;job 行只落 mapping_version) */
export interface ImportVersionBlock {
adapter: string
mapping: string
sanitizer: string
parser: string
}
/** 已解析快照引用(报告与管道上下文的载荷;metadata 含 storageRoot 等登记期事实) */
export interface SnapshotRef {
id: string
kind: 'wxr' | 'media_bundle' | 'manual_url_list'
sha256: string
byteSize: number
storagePath: string
capturedAt: Date
metadata: Record<string, unknown>
}
/** 管道执行上下文:Task 6 真实管道的全部输入(站点 locale 接缝见前置事实 8) */
export interface ImportJobContext {
db: ImportDb
jobId: string
siteId: string
siteKey: string
defaultLocale: string
connectionId: string
/** 主输入:wxr 快照(job.source_snapshot_id 指向它) */
wxrSnapshot: SnapshotRef
/** 侧输入(前置事实 13):按 (connection, kind) 解析的最新快照,可能缺席(null) */
mediaBundleSnapshot: SnapshotRef | null
manualUrlListSnapshot: SnapshotRef | null
versions: ImportVersionBlock
}
/** 管道钩子(Task 6 接真实写入器;缺省空管道=直接成功)。抛错 → job failed */
export type ImportPipeline = (ctx: ImportJobContext) => Promise<void>
export interface CreateImportJobInput {
siteKey: string
/** 显式 wxr 快照 id;缺省按 (connection, kind='wxr') 选最新 */
snapshotId?: string
/** 幂等键;缺省 = site + wxr 快照 + 三语义版本 自动派生 */
idempotencyKey?: string
/** 连接名(最新快照解析的 connection 侧);缺省 'default'(与 register 缺省一致) */
connectionName?: string
/** 报告根(落 <reportRoot>/<jobId>/reports/import.json);缺省 repoRoot/artifacts/import */
reportRoot?: string
/** 管道钩子(测试/Task 6 注入);缺省空管道 */
pipeline?: ImportPipeline
}
export interface CreateImportJobResult {
jobId: string
/** job 终态(或复用时既有状态——并发窗口下可能读到中间态) */
status: ImportJobStatus
/** true = 命中既有 job(幂等键复用,未重跑管道、未改行) */
reused: boolean
siteId: string
siteKey: string
connectionId: string
wxrSnapshot: SnapshotRef
mediaBundleSnapshot: SnapshotRef | null
manualUrlListSnapshot: SnapshotRef | null
versions: ImportVersionBlock
reportPath: string | null
durationMs: number
}
const VERSIONS: ImportVersionBlock = {
adapter: ADAPTER_VERSION,
mapping: MAPPING_VERSION,
sanitizer: SANITIZER_VERSION,
parser: PARSER_VERSION,
}
// ---- 快照解析 ----
const SNAPSHOT_COLUMNS = {
id: sourceSnapshots.id,
siteId: sourceSnapshots.siteId,
kind: sourceSnapshots.kind,
sha256: sourceSnapshots.sha256,
byteSize: sourceSnapshots.byteSize,
storagePath: sourceSnapshots.storagePath,
sourceConnectionId: sourceSnapshots.sourceConnectionId,
capturedAt: sourceSnapshots.capturedAt,
metadata: sourceSnapshots.metadata,
}
type SnapshotRow = {
id: string
siteId: string
kind: 'wxr' | 'sitemap' | 'media_bundle' | 'manual_url_list'
sha256: string
byteSize: number
storagePath: string
sourceConnectionId: string
capturedAt: Date
metadata: Record<string, unknown>
}
function toRef(row: SnapshotRow): SnapshotRef {
return {
id: row.id,
// 声音窄化:调用方按 kind 过滤查询(wxr/media_bundle/manual_url_list 三值)或已过
// kind==='wxr' 校验——drizzle 列类型不携带过滤谓词,四值联合在此收窄是安全的
kind: row.kind as SnapshotRef['kind'],
sha256: row.sha256,
byteSize: row.byteSize,
storagePath: row.storagePath,
capturedAt: row.capturedAt,
metadata: row.metadata,
}
}
/** 按 (connection, kind) 解析最新快照(capturedAt 降序、createdAt 降序兜底平局);无则 null */
async function latestSnapshot(
db: ImportDb,
connectionId: string,
kind: 'wxr' | 'media_bundle' | 'manual_url_list',
): Promise<SnapshotRow | null> {
const rows = await db
.select(SNAPSHOT_COLUMNS)
.from(sourceSnapshots)
.where(and(eq(sourceSnapshots.sourceConnectionId, connectionId), eq(sourceSnapshots.kind, kind)))
.orderBy(desc(sourceSnapshots.capturedAt), desc(sourceSnapshots.createdAt))
.limit(1)
return rows.length > 0 ? (rows[0] as SnapshotRow) : null
}
// ---- 幂等键 ----
/** 默认幂等键:site + wxr 快照 + 三语义版本(定义钉死——报告可复算、CLI 可显式覆盖) */
export function defaultIdempotencyKey(siteKey: string, wxrSnapshotId: string): string {
return `import:${siteKey}:${wxrSnapshotId}:${ADAPTER_VERSION}/${MAPPING_VERSION}/${SANITIZER_VERSION}`
}
// ---- 主入口 ----
/**
* 创建(并就地推进到底)一个导入 job:
* 解析(site → wxr 快照 → 侧快照)→ 幂等建行(queued)→ running(attempt=1、started_at)
* → 空管道/注入管道 → succeeded|failed(error_code/error_summary/finished_at)→ 报告落盘。
* 失败口径:解析阶段失败零 job 行;管道阶段失败 job 行保留(failed 终态 + error 列)。
*/
export async function createImportJob(
db: ImportDb,
input: CreateImportJobInput,
): Promise<CreateImportJobResult> {
const startedAt = performance.now()
// 阶段 0:站点解析(不自动建站——建站是 register 的职责)
const siteRows = await db
.select({ id: sites.id, key: sites.key, defaultLocale: sites.defaultLocale })
.from(sites)
.where(eq(sites.key, input.siteKey))
.limit(1)
if (siteRows.length === 0) {
throw new ImportError('SITE_NOT_FOUND', `站点不存在:${input.siteKey}(先运行 sf source:register)`)
}
const site = siteRows[0]!
// 阶段 1:wxr 主输入快照解析(显式 id 优先;缺省按 (connection, kind='wxr') 最新)
let wxrRow: SnapshotRow | null = null
if (input.snapshotId !== undefined) {
const rows = await db
.select(SNAPSHOT_COLUMNS)
.from(sourceSnapshots)
.where(eq(sourceSnapshots.id, input.snapshotId))
.limit(1)
if (rows.length === 0) {
throw new ImportError('SNAPSHOT_NOT_FOUND', `快照不存在:${input.snapshotId}`)
}
const row = rows[0] as SnapshotRow
if (row.siteId !== site.id) {
throw new ImportError(
'SNAPSHOT_NOT_FOUND',
`快照 ${input.snapshotId} 不属于站点 ${input.siteKey}`,
)
}
if (row.kind !== 'wxr') {
throw new ImportError(
'NO_WXR_SNAPSHOT',
`快照 ${input.snapshotId} 的 kind 是 ${row.kind},不是 wxr——主输入必须是 wxr 快照`,
)
}
wxrRow = row
} else {
const connectionName = input.connectionName ?? 'default'
// connection 判重键 (site_id, name) 无库级唯一约束(Plan 1 决定,register 是唯一写入方)
const connRows = await db
.select({ id: sourceConnections.id })
.from(sourceConnections)
.where(and(eq(sourceConnections.siteId, site.id), eq(sourceConnections.name, connectionName)))
.limit(1)
if (connRows.length > 0) {
wxrRow = await latestSnapshot(db, connRows[0]!.id, 'wxr')
}
if (wxrRow === null) {
throw new ImportError(
'NO_WXR_SNAPSHOT',
`站点 ${input.siteKey}(connection=${connectionName})没有任何 wxr 快照——先运行 sf source:register --wxr`,
)
}
}
const connectionId = wxrRow.sourceConnectionId
// 阶段 2:侧快照(前置事实 13)——挂在 wxr 快照的 connection 下按 kind 解析最新
const mediaBundleRow = await latestSnapshot(db, connectionId, 'media_bundle')
const manualUrlListRow = await latestSnapshot(db, connectionId, 'manual_url_list')
const wxrSnapshot = toRef(wxrRow)
const mediaBundleSnapshot = mediaBundleRow === null ? null : toRef(mediaBundleRow)
const manualUrlListSnapshot = manualUrlListRow === null ? null : toRef(manualUrlListRow)
// 阶段 3:幂等建 job(queued)——命中既有 key 直接复用返回(不重跑)
const idempotencyKey = input.idempotencyKey ?? defaultIdempotencyKey(input.siteKey, wxrSnapshot.id)
const resolved = {
siteKey: input.siteKey,
connectionId,
wxrSnapshot,
mediaBundleSnapshot,
manualUrlListSnapshot,
}
const existing = await reuseExisting(db, idempotencyKey)
if (existing !== null) {
return { ...existing, ...resolved, durationMs: performance.now() - startedAt }
}
let jobId: string
try {
const inserted = await db
.insert(importJobs)
.values({
siteId: site.id,
sourceSnapshotId: wxrSnapshot.id,
status: 'queued',
mappingVersion: MAPPING_VERSION,
idempotencyKey,
})
.returning({ id: importJobs.id })
jobId = inserted[0]!.id
} catch (err) {
// 并发同 key:唯一约束收敛——重查复用(插入撞 23505 说明行已存在,重查必命中)
if (isUniqueViolation(err)) {
const raced = await reuseExisting(db, idempotencyKey)
if (raced !== null) {
return { ...raced, ...resolved, durationMs: performance.now() - startedAt }
}
}
throw err
}
// 阶段 4:状态推进 queued → running(attempt 记首次执行)
await db
.update(importJobs)
.set({ status: 'running', startedAt: new Date(), attempt: 1 })
.where(eq(importJobs.id, jobId))
// 阶段 5:管道(本任务缺省空管道;Task 6 接真实写入器)——抛错 → failed 终态
let failed: { code: string; summary: string } | null = null
const ctx: ImportJobContext = {
db,
jobId,
siteId: site.id,
siteKey: site.key,
defaultLocale: site.defaultLocale,
connectionId,
wxrSnapshot,
mediaBundleSnapshot,
manualUrlListSnapshot,
versions: VERSIONS,
}
try {
await (input.pipeline ?? emptyPipeline)(ctx)
} catch (err) {
failed = {
code: err instanceof ImportError ? err.code : 'PIPELINE_FAILED',
summary: err instanceof Error ? `${err.name}: ${err.message}` : String(err),
}
}
// 阶段 6:终态落行(report_path 同步入行)→ 报告写盘
const reportRoot = resolve(input.reportRoot ?? join(REPO_ROOT, 'artifacts', 'import'))
const reportDir = join(reportRoot, jobId, 'reports')
const reportPath = join(reportDir, 'import.json')
const finishedAt = new Date()
const finalStatus: ImportJobStatus = failed === null ? 'succeeded' : 'failed'
await db
.update(importJobs)
.set(
failed === null
? { status: finalStatus, finishedAt, reportPath }
: {
status: finalStatus,
finishedAt,
reportPath,
errorCode: failed.code,
errorSummary: failed.summary,
},
)
.where(eq(importJobs.id, jobId))
const report = {
jobId,
generatedAt: finishedAt.toISOString(),
status: finalStatus,
siteKey: site.key,
siteId: site.id,
connectionId,
snapshots: {
wxr: slimRef(wxrSnapshot),
media_bundle: mediaBundleSnapshot === null ? null : slimRef(mediaBundleSnapshot),
manual_url_list: manualUrlListSnapshot === null ? null : slimRef(manualUrlListSnapshot),
},
versions: VERSIONS,
error: failed,
durationMs: performance.now() - startedAt,
}
await mkdir(reportDir, { recursive: true })
await writeFile(reportPath, `${JSON.stringify(report, null, 2)}\n`, 'utf8')
return {
jobId,
status: finalStatus,
reused: false,
siteId: site.id,
siteKey: site.key,
connectionId,
wxrSnapshot,
mediaBundleSnapshot,
manualUrlListSnapshot,
versions: VERSIONS,
reportPath,
durationMs: performance.now() - startedAt,
}
}
/** 既有 job 的复用返回(不动行、不跑管道);无则 null */
async function reuseExisting(
db: ImportDb,
idempotencyKey: string,
): Promise<Pick<CreateImportJobResult, 'jobId' | 'status' | 'reused' | 'siteId' | 'reportPath' | 'versions'> | null> {
const found = await db
.select({
id: importJobs.id,
status: importJobs.status,
siteId: importJobs.siteId,
reportPath: importJobs.reportPath,
})
.from(importJobs)
.where(eq(importJobs.idempotencyKey, idempotencyKey))
.limit(1)
if (found.length === 0) return null
const row = found[0]!
return {
jobId: row.id,
status: row.status,
reused: true,
siteId: row.siteId,
reportPath: row.reportPath,
versions: VERSIONS,
}
}
/** 报告里的快照引用瘦身版:不含 metadata(media_bundle 清单可达数千条,报告只留身份锚点) */
function slimRef(ref: SnapshotRef): {
id: string
kind: string
sha256: string
byteSize: number
storagePath: string
} {
return { id: ref.id, kind: ref.kind, sha256: ref.sha256, byteSize: ref.byteSize, storagePath: ref.storagePath }
}
/** Task 1 空管道:直接成功(Task 6 以真实写入器替换缺省值) */
async function emptyPipeline(_ctx: ImportJobContext): Promise<void> {}
@@ -33,6 +33,7 @@ import { pipeline } from 'node:stream/promises'
import { and, eq, sql } from 'drizzle-orm'
import type { NodePgDatabase } from 'drizzle-orm/node-postgres'
import { ADAPTER_VERSION } from '@sf/adapter-wp'
import { canonicalJson } from '../../common/canonical.js'
import { sites, sourceConnections, sourceSnapshots } from '../../db/schema.js'
import type * as schema from '../../db/schema.js'
@@ -149,20 +150,9 @@ export interface MediaManifestEntry {
}
// ---- 哈希与副本 ----
/** canonical JSON(对象键递归排序、无空白)——media_bundle 清单哈希的序列化基形 */
function canonicalJson(value: unknown): string {
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]`
if (value !== null && typeof value === 'object') {
const record = value as Record<string, unknown>
const body = Object.keys(record)
.sort()
.map((k) => `${JSON.stringify(k)}:${canonicalJson(record[k])}`)
.join(',')
return `{${body}}`
}
return JSON.stringify(value)
}
// canonicalJson 自 Plan 3 Task 1 起提取至 apps/api/src/common/canonical.ts 共享导出
// (前置事实 6:normalized checksum 与清单哈希共用同一序列化基形)——本文件改 import,
// 定义零变更(tests/unit/canonical.test.ts + 既有清单哈希测试双守护)。
/** 流式单遍 copy+sha256 双算:读 64KB 块 → 哈希更新 → 写目标(pipeline 管背压与错误传播) */
async function copyAndHash(src: string, dest: string): Promise<{ sha256: string; bytes: number }> {
@@ -53,15 +53,15 @@ tests/integration/import-*.test.ts
- [ ] 证据文档:行数、与冒烟 855 attachment 的覆盖率预估(URL 前缀归一后 join 对照)
- [ ] commit `feat(source): 媒体映射侧车导出与登记`
### Task 1: import 命令骨架 + import_jobs 生命周期
### Task 1: import 命令骨架 + import_jobs 生命周期 ✅ 完成
**Files:** `apps/api/src/modules/import/{import.service.ts,import.module.ts}`;sf.ts + `import`;Test `tests/integration/import-job.test.ts`
(本任务含前置事实 12 的 adapter 事件加法扩展:unsupported/error 事件补 `postId?: string`)
- [ ] 失败测试:`sf import --site <key> --snapshot <id>`(或按 connection+kind 选最新快照)→ import_jobs 行 created→running→succeeded(或 succeeded_with_issues/failed);idempotency_key 唯一(重复提交同 key 复用既有 job 不重跑);job 记录 mapping/adapter/sanitizer 版本块(来自 versions.ts)
- [ ] 实现:job 状态机 + 报告落 artifacts/import/<jobId>/;空流(无 snapshot)→ 明确报错
- [ ] 绿 → commit `feat(import): job 骨架与生命周期`
- [x] 失败测试:`sf import --site <key> --snapshot <id>`(或按 connection+kind 选最新快照)→ import_jobs 行 created→running→succeeded(或 succeeded_with_issues/failed);idempotency_key 唯一(重复提交同 key 复用既有 job 不重跑);job 记录 mapping/adapter/sanitizer 版本块(来自 versions.ts)
- [x] 实现:job 状态机 + 报告落 artifacts/import/<jobId>/;空流(无 snapshot)→ 明确报错
- [x] 绿 → commit `feat(import): job 骨架与生命周期`
### Task 2: term 物化(含自建缺失)
+15 -4
View File
@@ -37,11 +37,16 @@ export interface RowResult {
* 全链事件判别联合。unsupported/error 分支与 mapItem 的 MappedResult 同形,仅 error
* 分支补 `sourceType`(`wp:${postType}` 报告锚点:post_id 缺失时 mapItem 拒猜身份,
* 但 post_type 本身仍可作计数归桶依据——命名约定同 unsupported 分支,仅为报告可读性)。
*
* Plan 3 Task 1 加法扩展(前置事实 12):unsupported/error 事件可选携带 `postId`
* (WxrItem.postId 的 String 化;缺失时**键缺席**——不硬造 source_id)。用途:阶段 3
* 导入把 unsupported/error 行也写进 source_records(source_id=post_id)的可审计性。
* 纯加法可选字段:row 事件(UniversalRow 契约)与报告聚合均不受影响,不触发版本 bump。
*/
export type AdapterEvent =
| RowResult
| { kind: 'unsupported'; sourceType: string; reason: string; rawLocation: string }
| { kind: 'error'; sourceType: string; issue: string; rawLocation: string }
| { kind: 'unsupported'; sourceType: string; reason: string; rawLocation: string; postId?: string }
| { kind: 'error'; sourceType: string; issue: string; rawLocation: string; postId?: string }
| { kind: 'term'; term: WxrTerm }
/** 分段计时累计(可注入对象,运行中被本模块累加;消费完成后读取终值) */
@@ -103,9 +108,15 @@ function assembleItem(
timings: AdapterTimings,
): AdapterEvent {
const mapped = mapItem(item, mapConfig)
if (mapped.kind === 'unsupported') return mapped
// postId 附加规则(前置事实 12):post_id 存在 → String 化挂上(与 row.sourceId 同口径,
// 负值原样);缺失 → 键缺席。error 分支今天只在 post_id 缺失时触发,条件式保持
// “存在即挂”的统一不变量,未来 error 触发缘由扩展时无需回改此处。
if (mapped.kind === 'unsupported') {
return item.postId === null ? mapped : { ...mapped, postId: String(item.postId) }
}
if (mapped.kind === 'error') {
return { ...mapped, sourceType: `wp:${item.postType}` }
const base = { ...mapped, sourceType: `wp:${item.postType}` }
return item.postId === null ? base : { ...base, postId: String(item.postId) }
}
const { row } = mapped
@@ -385,6 +385,42 @@ describe('unsupported / error 汇聚', () => {
})
})
describe('unsupported / error 事件加法式补 postId(Plan 3 Task 1 前置事实 12)', () => {
it('unsupported(nav_menu_item):post_id 存在 → 事件携带 postId(String 化,含负值)', async () => {
const xml = wxrDoc(
itemXml({ postId: 401, postType: 'nav_menu_item' }) + itemXml({ postId: -7, postType: 'custom_block' }),
)
const events = await collect(runWxrAdapter(fromXml(xml), { mapConfig: { locale: 'zh-CN' } }))
expect(events[0]).toMatchObject({
kind: 'unsupported',
sourceType: 'wp:nav_menu_item',
postId: '401',
})
// 负值 postId 与 row.sourceId 同口径(String 原样,'-7' 不归一为 '7')
expect(events[1]).toMatchObject({ kind: 'unsupported', sourceType: 'wp:custom_block', postId: '-7' })
})
it('error(post_id 缺失):postId 键缺席——不硬造 source_id(报告照常按计数归桶)', async () => {
const xml = wxrDoc(itemXml({ postId: null, postType: 'post' }))
const events = await collect(runWxrAdapter(fromXml(xml), { mapConfig: { locale: 'zh-CN' } }))
const ev = events[0]
if (ev === undefined || ev.kind !== 'error') throw new Error('期望 error 事件')
expect('postId' in ev).toBe(false)
expect((ev as { postId?: string }).postId).toBeUndefined()
// 报告聚合不受加法字段影响:failed 计数照常
const report = await runWxrAdapterReport(fromXml(xml), { mapConfig: { locale: 'zh-CN' } })
expect(report.totals).toStrictEqual({ rows: 0, terms: 0, unsupported: 0, failed: 1 })
})
it('row 事件不受影响:RowResult 五键结构不变(postId 只加在 unsupported/error 变体上)', async () => {
const events = await collect(
runWxrAdapter(fromXml(wxrDoc(itemXml({ postId: 402 }))), { mapConfig: { locale: 'zh-CN' } }),
)
const ev = soleRow(events)
expect(Object.keys(ev).sort()).toStrictEqual(['changed', 'issues', 'kind', 'row', 'sanitizeDiffs'])
})
})
describe('wppop 解析失败路径全链贯通(Task 4 已实现,Task 6 验证贯通)', () => {
const RAW = 'not-php-at-all'
+51 -6
View File
@@ -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)}`)
}
+411
View File
@@ -0,0 +1,411 @@
// Plan 3 Task 1 失败测试(先于实现):import 命令骨架 + import_jobs 生命周期。
// 覆盖契约(对照计划 Task 1 与前置事实 6/12/13):
// 1. register(wxr + media + media-map 三快照)→ createImportJob:job 行 created(queued)→
// running→succeeded 全程可观察(管道钩子内读 running 中间态)、mapping_version 落
// MAPPING_VERSION、报告记录全四版本块 + 三快照绑定(wxr 主输入 / media_bundle /
// manual_url_list 按 (connection,kind) 解析最新记入报告,job 行不扩列——前置事实 13);
// 2. 无 --snapshot:按 (connection, kind='wxr') 选最新快照;
// 3. idempotency_key 唯一:重复提交同默认 key(site+snapshot+版本)→ 复用既有 job 不重跑
// (reused 标记 + 管道零调用 + 恰一行);显式 key 覆盖默认;并发同 key 收敛同 job;
// 4. 无 wxr 快照(站点仅有非 wxr 输入 / 显式 id 指向非 wxr 快照)→ NO_WXR_SNAPSHOT;
// 显式 id 不存在或属他站 → SNAPSHOT_NOT_FOUND;站点不存在 → SITE_NOT_FOUND;均零 job 行;
// 5. failed 路径:管道抛错 → running→failed(error_code/error_summary/finished_at),
// 重提交同 key 复用失败 job 不重跑。
// 运行于 globalSetup 临时库(ADR-015);storage/报告根用 os.tmpdir 临时目录,finally 清理。
import { existsSync } from 'node:fs'
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { describe, expect, it } from 'vitest'
import { ADAPTER_VERSION, MAPPING_VERSION, PARSER_VERSION, SANITIZER_VERSION } from '@sf/adapter-wp'
import {
ImportError,
createImportJob,
type CreateImportJobInput,
} from '../../apps/api/src/modules/import/import.service.js'
import { registerSource } from '../../apps/api/src/modules/source/register.service.js'
import { rand, withImmutableCleanup, withTestDb, type Db } from './helpers.js'
const FIXTURES_ROOT = fileURLToPath(new URL('../fixtures', import.meta.url))
const WXR_FIXTURE = resolve(FIXTURES_ROOT, 'wxr', 'mini.xml')
interface Ctx {
work: string
storageRoot: string
registerReportRoot: string
importReportRoot: string
mediaDir: string
mediaMapFile: string
allowedRoots: string[]
}
/** 独立媒体树 + 侧车 JSON(形态同 source-register.test.ts) */
async function makeCtx(): Promise<Ctx> {
const work = await mkdtemp(join(tmpdir(), 'sf-import-'))
const mediaDir = join(work, 'media')
await mkdir(join(mediaDir, 'sub'), { recursive: true })
await writeFile(join(mediaDir, 'a.png'), 'sf-import-media-a')
await writeFile(join(mediaDir, 'sub', 'b.png'), 'sf-import-media-b')
const mediaMapFile = join(work, 'media-map.json')
await writeFile(
mediaMapFile,
`${JSON.stringify([
{ sourceUrl: 'https://fixture.example/wp-content/uploads/2026/10/fixture-a.png', filePath: 'a.png' },
])}\n`,
'utf8',
)
return {
work,
storageRoot: join(work, 'storage'),
registerReportRoot: join(work, 'artifacts', 'register'),
importReportRoot: join(work, 'artifacts', 'import'),
mediaDir,
mediaMapFile,
allowedRoots: [FIXTURES_ROOT, work],
}
}
/** 服务写入的 job 行清场:import_jobs 可变直删;快照不可变走触发器开关;FK 反序 */
async function cleanupSiteRows(db: Db, siteId: string): Promise<void> {
await db.execute(`delete from import_jobs where site_id = '${siteId}'`)
await withImmutableCleanup(db, 'source_snapshots', 'source_snapshots_immutable', `site_id = '${siteId}'`)
await db.execute(`delete from source_connections where site_id = '${siteId}'`)
await db.execute(`delete from sites where id = '${siteId}'`)
}
/** 登记三快照(wxr + media_bundle + manual_url_list)的便捷封装 */
async function registerAll(
db: Db,
ctx: Ctx,
siteKey: string,
): Promise<Awaited<ReturnType<typeof registerSource>>> {
return registerSource(db, {
siteKey,
wxrPath: WXR_FIXTURE,
mediaDir: ctx.mediaDir,
mediaMapPath: ctx.mediaMapFile,
allowedRoots: ctx.allowedRoots,
storageRoot: ctx.storageRoot,
reportRoot: ctx.registerReportRoot,
})
}
describe('import_jobs 生命周期(Task 1 骨架)', () => {
it('三快照 register → createImportJob:queued→running→succeeded、mapping_version、报告含版本块与三快照绑定', async () => {
await withTestDb(async (db) => {
const ctx = await makeCtx()
try {
const key = `sfimp-${rand()}`
const reg = await registerAll(db, ctx, key)
try {
const wxr = reg.snapshots.find((s) => s.kind === 'wxr')!
const media = reg.snapshots.find((s) => s.kind === 'media_bundle')!
const map = reg.snapshots.find((s) => s.kind === 'manual_url_list')!
// 管道钩子内观察中间态:此刻 job 已 created(queued)→running(attempt 记首次执行)
let midStatus: string | undefined
let midAttempt: string | number | undefined
let midStartedAt: Date | null | undefined
let midFinishedAt: Date | null | undefined
const result = await createImportJob(db, {
siteKey: key,
snapshotId: wxr.id,
reportRoot: ctx.importReportRoot,
pipeline: async (jobCtx) => {
expect(jobCtx.jobId).toBeTruthy()
const rows = (await db.execute(
`select status, attempt, started_at, finished_at from import_jobs where id = '${jobCtx.jobId}'`,
)) as any
midStatus = rows[0].status
midAttempt = rows[0].attempt
midStartedAt = rows[0].started_at
midFinishedAt = rows[0].finished_at
},
})
expect(result.reused).toBe(false)
expect(result.status).toBe('succeeded')
expect(midStatus).toBe('running')
expect(Number(midAttempt)).toBe(1)
expect(midStartedAt).not.toBeNull()
expect(midFinishedAt).toBeNull()
// 终态行:身份/版本/时间戳/report_path
const job = (await db.execute(
`select status, mapping_version, source_snapshot_id, started_at, finished_at, report_path, attempt, error_code
from import_jobs where id = '${result.jobId}'`,
)) as any
expect(job[0].status).toBe('succeeded')
expect(job[0].mapping_version).toBe(MAPPING_VERSION)
expect(job[0].source_snapshot_id).toBe(wxr.id)
expect(job[0].started_at).not.toBeNull()
expect(job[0].finished_at).not.toBeNull()
expect(job[0].error_code).toBeNull()
expect(Number(job[0].attempt)).toBe(1)
// 报告:全四版本块 + 三快照绑定(前置事实 13:侧快照只入报告,job 行不扩列)
const reportPath = join(ctx.importReportRoot, result.jobId, 'reports', 'import.json')
expect(job[0].report_path).toBe(reportPath)
expect(existsSync(reportPath)).toBe(true)
const report = JSON.parse(await readFile(reportPath, 'utf8')) as {
jobId: string
status: string
versions: Record<string, string>
snapshots: {
wxr: { id: string }
media_bundle: { id: string } | null
manual_url_list: { id: string } | null
}
}
expect(report.jobId).toBe(result.jobId)
expect(report.status).toBe('succeeded')
expect(report.versions).toStrictEqual({
adapter: ADAPTER_VERSION,
mapping: MAPPING_VERSION,
sanitizer: SANITIZER_VERSION,
parser: PARSER_VERSION,
})
expect(report.snapshots.wxr.id).toBe(wxr.id)
expect(report.snapshots.media_bundle?.id).toBe(media.id)
expect(report.snapshots.manual_url_list?.id).toBe(map.id)
} finally {
await cleanupSiteRows(db, reg.siteId)
}
} finally {
await rm(ctx.work, { recursive: true, force: true })
}
})
})
it('无 snapshotId:按 (connection, kind=wxr) 选最新;同默认 key 重提交复用不重跑', async () => {
await withTestDb(async (db) => {
const ctx = await makeCtx()
try {
const key = `sfimp-${rand()}`
const r1 = await registerSource(db, {
siteKey: key,
wxrPath: WXR_FIXTURE,
allowedRoots: ctx.allowedRoots,
storageRoot: ctx.storageRoot,
reportRoot: ctx.registerReportRoot,
})
try {
// 第二个 wxr 快照:内容加尾随换行(sha 不同、仍合法 XML);稍歇保证 capturedAt 严格更晚
const v2 = join(ctx.work, 'mini-v2.xml')
await writeFile(v2, `${await readFile(WXR_FIXTURE, 'utf8')}\n`, 'utf8')
await new Promise((r) => setTimeout(r, 25))
const r2 = await registerSource(db, {
siteKey: key,
wxrPath: v2,
allowedRoots: ctx.allowedRoots,
storageRoot: ctx.storageRoot,
reportRoot: ctx.registerReportRoot,
})
const wxr1 = r1.snapshots.find((s) => s.kind === 'wxr')!
const wxr2 = r2.snapshots.find((s) => s.kind === 'wxr')!
expect(wxr2.id).not.toBe(wxr1.id)
let pipelineCalls = 0
const input: CreateImportJobInput = {
siteKey: key,
reportRoot: ctx.importReportRoot,
pipeline: async () => {
pipelineCalls++
},
}
const a = await createImportJob(db, input)
expect(a.reused).toBe(false)
expect(a.status).toBe('succeeded')
// 最新快照 = 第二个(v2)
const job = (await db.execute(
`select source_snapshot_id from import_jobs where id = '${a.jobId}'`,
)) as any
expect(job[0].source_snapshot_id).toBe(wxr2.id)
// 同默认 key 重提交:复用既有 job、管道零追加调用
const b = await createImportJob(db, input)
expect(b.reused).toBe(true)
expect(b.jobId).toBe(a.jobId)
expect(b.status).toBe('succeeded')
expect(pipelineCalls).toBe(1)
const count = (await db.execute(
`select count(*)::int as n from import_jobs where site_id = '${r1.siteId}'`,
)) as any
expect(count[0].n).toBe(1)
// 显式 idempotencyKey 覆盖默认 key:同 key 复用、异 key(回默认)不新建重复
const c = await createImportJob(db, { ...input, idempotencyKey: 'manual-key-1' })
expect(c.reused).toBe(false)
const d = await createImportJob(db, { ...input, idempotencyKey: 'manual-key-1' })
expect(d.reused).toBe(true)
expect(d.jobId).toBe(c.jobId)
expect(pipelineCalls).toBe(2)
} finally {
await cleanupSiteRows(db, r1.siteId)
}
} finally {
await rm(ctx.work, { recursive: true, force: true })
}
})
})
it('并发同默认 key:恰一 job 行、双方同 jobId、管道恰执行一次(唯一约束收敛)', async () => {
await withTestDb(async (db) => {
const ctx = await makeCtx()
try {
const key = `sfimp-${rand()}`
const reg = await registerAll(db, ctx, key)
try {
const wxr = reg.snapshots.find((s) => s.kind === 'wxr')!
let pipelineCalls = 0
const input: CreateImportJobInput = {
siteKey: key,
snapshotId: wxr.id,
reportRoot: ctx.importReportRoot,
pipeline: async () => {
pipelineCalls++
},
}
const [a, b] = await Promise.all([createImportJob(db, input), createImportJob(db, input)])
expect(a.jobId).toBe(b.jobId)
expect(Number(a.reused) + Number(b.reused)).toBe(1)
expect(pipelineCalls).toBe(1)
const count = (await db.execute(
`select count(*)::int as n from import_jobs where site_id = '${reg.siteId}'`,
)) as any
expect(count[0].n).toBe(1)
} finally {
await cleanupSiteRows(db, reg.siteId)
}
} finally {
await rm(ctx.work, { recursive: true, force: true })
}
})
})
it('可预期失败均零 job 行:SITE_NOT_FOUND / SNAPSHOT_NOT_FOUND(不存在或属他站)/ NO_WXR_SNAPSHOT(无非 wxr 主输入或显式指向非 wxr)', async () => {
await withTestDb(async (db) => {
const ctx = await makeCtx()
try {
// 站点 A:仅 media-map 输入(有 connection、无非 wxr 主输入)
const keyA = `sfimp-${rand()}`
const regA = await registerSource(db, {
siteKey: keyA,
mediaMapPath: ctx.mediaMapFile,
allowedRoots: ctx.allowedRoots,
storageRoot: ctx.storageRoot,
reportRoot: ctx.registerReportRoot,
})
// 站点 B:三快照齐全
const keyB = `sfimp-${rand()}`
const regB = await registerAll(db, ctx, keyB)
try {
const mapSnapA = regA.snapshots[0]!
const mediaSnapB = regB.snapshots.find((s) => s.kind === 'media_bundle')!
const cases: { label: string; input: CreateImportJobInput; code: string }[] = [
{
label: '站点不存在',
input: { siteKey: `sfimp-no-such-${rand()}` },
code: 'SITE_NOT_FOUND',
},
{
label: '仅有 manual_url_list 输入的站点(无任何 wxr 快照)',
input: { siteKey: keyA, reportRoot: ctx.importReportRoot },
code: 'NO_WXR_SNAPSHOT',
},
{
label: '显式 snapshotId 指向非 wxr 快照(media_bundle)',
input: { siteKey: keyB, snapshotId: mediaSnapB.id, reportRoot: ctx.importReportRoot },
code: 'NO_WXR_SNAPSHOT',
},
{
label: '显式 snapshotId 不存在',
input: {
siteKey: keyB,
snapshotId: '00000000-0000-4000-8000-000000000000',
reportRoot: ctx.importReportRoot,
},
code: 'SNAPSHOT_NOT_FOUND',
},
{
label: '显式 snapshotId 属于他站',
input: { siteKey: keyB, snapshotId: mapSnapA.id, reportRoot: ctx.importReportRoot },
code: 'SNAPSHOT_NOT_FOUND',
},
]
for (const c of cases) {
const err: ImportError = await createImportJob(db, c.input).then(
() => {
throw new Error(`用例「${c.label}」不应成功`)
},
(e: ImportError) => e,
)
expect(err, c.label).toBeInstanceOf(ImportError)
expect(err.code, c.label).toBe(c.code)
expect(err.message, c.label).not.toBe('')
}
// 零 job 行(两站点均无)
const count = (await db.execute(
`select count(*)::int as n from import_jobs where site_id in ('${regA.siteId}', '${regB.siteId}')`,
)) as any
expect(count[0].n).toBe(0)
} finally {
await cleanupSiteRows(db, regB.siteId)
await cleanupSiteRows(db, regA.siteId)
}
} finally {
await rm(ctx.work, { recursive: true, force: true })
}
})
})
it('failed 路径:管道抛错 → running→failed(error_code/error_summary/finished_at);重提交同 key 复用失败 job 不重跑', async () => {
await withTestDb(async (db) => {
const ctx = await makeCtx()
try {
const key = `sfimp-${rand()}`
const reg = await registerAll(db, ctx, key)
try {
const wxr = reg.snapshots.find((s) => s.kind === 'wxr')!
let pipelineCalls = 0
const input: CreateImportJobInput = {
siteKey: key,
snapshotId: wxr.id,
reportRoot: ctx.importReportRoot,
pipeline: async () => {
pipelineCalls++
throw new Error('模拟管道失败:断链 attachment')
},
}
const a = await createImportJob(db, input)
expect(a.reused).toBe(false)
expect(a.status).toBe('failed')
const job = (await db.execute(
`select status, error_code, error_summary, finished_at, started_at from import_jobs where id = '${a.jobId}'`,
)) as any
expect(job[0].status).toBe('failed')
expect(job[0].error_code).toBe('PIPELINE_FAILED')
expect(String(job[0].error_summary)).toContain('模拟管道失败')
expect(job[0].started_at).not.toBeNull()
expect(job[0].finished_at).not.toBeNull()
// 同 key 重提交:复用失败 job(不重跑、不改行)
const b = await createImportJob(db, input)
expect(b.reused).toBe(true)
expect(b.jobId).toBe(a.jobId)
expect(b.status).toBe('failed')
expect(pipelineCalls).toBe(1)
const count = (await db.execute(
`select count(*)::int as n from import_jobs where site_id = '${reg.siteId}'`,
)) as any
expect(count[0].n).toBe(1)
} finally {
await cleanupSiteRows(db, reg.siteId)
}
} finally {
await rm(ctx.work, { recursive: true, force: true })
}
})
})
})
+46
View File
@@ -0,0 +1,46 @@
// canonicalJson 共享导出测试(Plan 3 Task 1,前置事实 6):从 register.service.ts 提取到
// apps/api/src/common/canonical.ts——register 侧改 import 零行为变更(既有 media_bundle 清单
// 哈希测试继续绿的回归责任在 tests/integration/source-register.test.ts,此处钉导出本身的
// 序列化定义:对象键递归排序、无空白、数组保序)。
// 独立重算对照:期望串手写钉死,不 import 实现——定义漂移即爆红。
import { describe, expect, it } from 'vitest'
import { canonicalJson } from '../../apps/api/src/common/canonical.js'
describe('canonicalJson(共享导出,前置事实 6)', () => {
it('对象键递归排序:任意嵌套层级都按键名字典序', () => {
expect(canonicalJson({ b: 1, a: 2 })).toBe('{"a":2,"b":1}')
expect(canonicalJson({ b: 1, a: { d: 2, c: 3 } })).toBe('{"a":{"c":3,"d":2},"b":1}')
expect(canonicalJson({ z: { y: { x: { w: 1, v: 2 } } } })).toBe('{"z":{"y":{"x":{"v":2,"w":1}}}}')
})
it('无空白:键与值、数组元素间均无空格/换行', () => {
expect(canonicalJson({ a: [1, 2, 3], b: { c: 'x' } })).toBe('{"a":[1,2,3],"b":{"c":"x"}}')
expect(canonicalJson({ a: 1, b: 2 })).not.toContain(' ')
expect(canonicalJson({ a: { b: { c: [1, { d: 2 }] } } })).not.toMatch(/\s/)
})
it('数组保序:元素顺序是语义的一部分,不排序', () => {
expect(canonicalJson([3, 1, 2])).toBe('[3,1,2]')
expect(canonicalJson([{ z: 1, a: 2 }, { y: 3, b: 4 }])).toBe('[{"a":2,"z":1},{"b":4,"y":3}]')
})
it('原始值走 JSON.stringify 语义:字符串/数字/布尔/null', () => {
expect(canonicalJson('x')).toBe('"x"')
expect(canonicalJson('a"b')).toBe('"a\\"b"')
expect(canonicalJson(3)).toBe('3')
expect(canonicalJson(true)).toBe('true')
expect(canonicalJson(null)).toBe('null')
expect(canonicalJson({ a: null, b: true, c: 0, d: '' })).toBe('{"a":null,"b":true,"c":0,"d":""}')
})
it('键排序按 UTF-16 码元(与 Array.prototype.sort 默认一致)——大写在前、非 ASCII 原样', () => {
expect(canonicalJson({ b: 1, A: 2, a: 3 })).toBe('{"A":2,"a":3,"b":1}')
expect(canonicalJson({ 图: 1, 中: 2 })).toBe(JSON.stringify({ 中: 2, 图: 1 }))
})
it('空对象/空数组序列化', () => {
expect(canonicalJson({})).toBe('{}')
expect(canonicalJson([])).toBe('[]')
expect(canonicalJson({ a: {}, b: [] })).toBe('{"a":{},"b":[]}')
})
})