2026-10-21 20:08:06 +08:00
|
|
|
|
// import_jobs 生命周期服务(Plan 3 Task 1 + Task 6 编排,spec §7.2/§7.5、前置事实 6/8/13):
|
2026-10-21 15:56:00 +08:00
|
|
|
|
// 解析站点与快照 → 幂等建 job → 状态推进(queued → running → succeeded /
|
|
|
|
|
|
// succeeded_with_issues / failed)→ 报告落 <reportRoot>/<jobId>/reports/import.json
|
|
|
|
|
|
// (temp+rename 原子写、**先于终态落行**——行内 report_path 出现即文件存在)。
|
|
|
|
|
|
// 管道产出 Outcome(counts/issues)汇入报告;issues 非空 → succeeded_with_issues。
|
|
|
|
|
|
// 复用(幂等键命中)只返回 job 行锚定字段——快照/版本解析不回读(权威在既有报告)。
|
2026-10-21 15:30:42 +08:00
|
|
|
|
//
|
|
|
|
|
|
// 本文件与 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 是诊断信息不进键)。
|
|
|
|
|
|
//
|
2026-10-21 21:20:12 +08:00
|
|
|
|
// 管道注入:Task 6 起默认 = **编排管道**(四家写入器汇流)——orchestratedPipeline 自
|
|
|
|
|
|
// Task 8 起抽取于 orchestrator.ts(纯函数 over ctx,零行为变更)。本文件负责:真事件流
|
|
|
|
|
|
// 构造(runWxrAdapter 读 wxr 快照副本,mapConfig.locale 接 site.default_locale——前置
|
|
|
|
|
|
// 事实 8)→ Outcome 汇合(mergeOutcomes)→ 报告固化。注入 pipeline 钩子供集成测试观察
|
|
|
|
|
|
// 中间态/失败路径(注入时缺省事件流为空——Task 1 语义保持)。
|
2026-10-21 20:08:06 +08:00
|
|
|
|
//
|
|
|
|
|
|
// stale 回收(Task 6 义务④):每次建 job 前把同 site 超过 24h 仍 queued/running 的 job
|
2026-10-21 21:20:12 +08:00
|
|
|
|
// 置 stale(崩溃/中断残留终态化)。心跳列决策见 ADR-017——现阶段无心跳列,以
|
2026-10-21 20:08:06 +08:00
|
|
|
|
// created_at 近似(job 为单进程就地推进,24h 远超任何真实执行时长)。
|
|
|
|
|
|
import { createReadStream } from 'node:fs'
|
2026-10-21 15:56:00 +08:00
|
|
|
|
import { mkdir, rename, rm, writeFile } from 'node:fs/promises'
|
2026-10-21 15:30:42 +08:00
|
|
|
|
import { dirname, join, resolve } from 'node:path'
|
|
|
|
|
|
import { fileURLToPath } from 'node:url'
|
2026-10-21 20:08:06 +08:00
|
|
|
|
import { and, desc, eq, inArray, lt } from 'drizzle-orm'
|
2026-10-21 15:30:42 +08:00
|
|
|
|
import type { NodePgDatabase } from 'drizzle-orm/node-postgres'
|
|
|
|
|
|
import {
|
|
|
|
|
|
ADAPTER_VERSION,
|
|
|
|
|
|
MAPPING_VERSION,
|
|
|
|
|
|
PARSER_VERSION,
|
|
|
|
|
|
SANITIZER_VERSION,
|
2026-10-21 20:08:06 +08:00
|
|
|
|
runWxrAdapter,
|
2026-10-21 16:19:00 +08:00
|
|
|
|
type AdapterEvent,
|
2026-10-21 20:08:06 +08:00
|
|
|
|
type AdapterTimings,
|
2026-10-21 15:30:42 +08:00
|
|
|
|
} 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'
|
2026-10-21 21:20:12 +08:00
|
|
|
|
import { mergeOutcomes, orchestratedPipeline } from './orchestrator.js'
|
2026-10-21 20:34:43 +08:00
|
|
|
|
import {
|
|
|
|
|
|
DEFAULT_NEWS_PATH,
|
|
|
|
|
|
DEFAULT_PRODUCTS_PATH,
|
|
|
|
|
|
isSafePath,
|
|
|
|
|
|
normalizeListPath,
|
|
|
|
|
|
} from './route-writer.js'
|
2026-10-21 21:20:12 +08:00
|
|
|
|
import { renderImportReportHtml } from './report-html.js'
|
2026-10-21 15:30:42 +08:00
|
|
|
|
|
|
|
|
|
|
/** 本文件位于 <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'
|
2026-10-21 16:19:00 +08:00
|
|
|
|
| 'REPORT_WRITE_FAILED'
|
2026-10-21 20:34:43 +08:00
|
|
|
|
| 'INVALID_LIST_PATH'
|
2026-10-21 15:30:42 +08:00
|
|
|
|
|
|
|
|
|
|
/** 导入失败(可预期错误):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 {
|
2026-10-21 16:19:00 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 写入器自行管理事务:ctx.db 为直连(非事务代理),需要原子性的多语句写入用
|
|
|
|
|
|
* db.transaction 自行包裹;单语句 upsert(on conflict do nothing/update)本身原子,
|
|
|
|
|
|
* 无需显式事务。
|
|
|
|
|
|
*/
|
2026-10-21 15:30:42 +08:00
|
|
|
|
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
|
2026-10-21 15:56:00 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 报告累加器(与 Outcome 同形):写入器可就地累加(ctx.report.counts.k++ /
|
|
|
|
|
|
* ctx.report.issues.push(...))。合并规则见 {@link ImportPipeline}——返回值优先。
|
|
|
|
|
|
*/
|
|
|
|
|
|
report: ImportPipelineOutcome
|
2026-10-21 16:19:00 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 适配器事件流(runWxrAdapter 产物:row/unsupported/error/term 按文档序交错)。
|
2026-10-21 20:08:06 +08:00
|
|
|
|
* Task 6 兑现:缺省 = 从 wxr 快照副本构造的真流(mapConfig.locale=site.default_locale
|
|
|
|
|
|
* ——前置事实 8);测试经 {@link CreateImportJobInput.events} 手构注入。单消费方
|
|
|
|
|
|
* (编排器)一遍 for-await 分派给四家写入器——只承诺"可迭代一次",无 tee 无重迭代。
|
2026-10-21 16:19:00 +08:00
|
|
|
|
*/
|
|
|
|
|
|
events: AsyncIterable<AdapterEvent>
|
2026-10-21 15:30:42 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-21 15:56:00 +08:00
|
|
|
|
/**
|
2026-10-21 20:08:06 +08:00
|
|
|
|
* 管道产出契约(Task 1 钉死、Task 6 修订——counts 聚合改**求和**、加法扩 issueGroups/
|
|
|
|
|
|
* sanitize):
|
2026-10-21 15:56:00 +08:00
|
|
|
|
* - counts:计数块(键名由写入器约定,如 contentEntries/terms/routes)——原样进报告;
|
|
|
|
|
|
* - issues:问题清单(简单字符串——机器可读错误码按 Plan 2 台账推迟);**非空 → job
|
2026-10-21 20:08:06 +08:00
|
|
|
|
* 终态 succeeded_with_issues**(问题全部入报告,不静默);
|
|
|
|
|
|
* - issueGroups / sanitize:可选分组视图与清洗统计(编排管道产出;issues 仍是全量扁平清单)。
|
2026-10-21 15:56:00 +08:00
|
|
|
|
*
|
2026-10-21 20:08:06 +08:00
|
|
|
|
* 汇合规则(Task 6 钉死):最终产出 = ctx.report 累加器 ⊕ 管道返回值——counts 按键
|
|
|
|
|
|
* **求和**、issues 串联(累加器在前、返回值在后)、issueGroups 逐键串联、sanitize
|
2026-10-21 20:34:43 +08:00
|
|
|
|
* 数值相加。管道抛错时无返回值,累加器现值进报告(部分产出可审计)——缺省编排管道
|
|
|
|
|
|
* 在重抛前自行冲刷部分产出(term/content 纯返回 finish + adapter 问题/清洗统计 +
|
|
|
|
|
|
* eventsDispatched 进度计数,见 orchestratedPipeline),注入管道的冲刷行为由注入方自理。
|
2026-10-21 15:56:00 +08:00
|
|
|
|
*/
|
|
|
|
|
|
export interface ImportPipelineOutcome {
|
2026-10-21 18:19:37 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 计数块(原样进报告)。**键契约:counts 键是跨写入器的扁平全局命名空间**——
|
2026-10-21 20:08:06 +08:00
|
|
|
|
* 汇合按"同名键**求和**"聚合(ctx.report.counts ⊕ outcome.counts,Task 6 钉死:
|
|
|
|
|
|
* 四家写入器键前缀互斥,求和是防覆盖的安全网——Task 1 的 CLOBBER 语义废弃)。
|
|
|
|
|
|
* 各写入器仍须各持独占键名并在自身 JSDoc 声明归属(term:termsProcessed/
|
|
|
|
|
|
* autoCreatedTerms/contentTermsCreated;content:contentEntries/revisionsCreated/
|
|
|
|
|
|
* sourceRecords/skipped/unsupportedRecords/failedRecords;media 六键 media 前缀;
|
2026-10-21 20:34:43 +08:00
|
|
|
|
* route 五键 routes 前缀;orchestrator:eventsDispatched——仅失败路径部分冲刷时
|
|
|
|
|
|
* 出现,成功路径不占键——新写入器勿复用他家键名)。
|
2026-10-21 18:19:37 +08:00
|
|
|
|
*/
|
2026-10-21 15:56:00 +08:00
|
|
|
|
counts: Record<string, number>
|
|
|
|
|
|
issues: string[]
|
2026-10-21 20:08:06 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 可选:按来源分组的 issues 视图(报告分组呈现)。**issues 字段仍是无分组全量
|
|
|
|
|
|
* 清单**(既有消费方契约不变);分组键 = 四写入器前缀 + adapter(row 事件自带行级
|
|
|
|
|
|
* 问题)+ orchestrator(编排器自身)。
|
|
|
|
|
|
*/
|
|
|
|
|
|
issueGroups?: Partial<Record<ImportIssueGroup, string[]>>
|
|
|
|
|
|
/** 可选:清洗统计(row 事件 sanitizeDiffs/changed 的聚合——编排层在分派循环收集) */
|
|
|
|
|
|
sanitize?: ImportSanitizeStats
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** issues 分组键:四写入器前缀 + adapter(row 事件 issues 面)+ orchestrator(编排器) */
|
|
|
|
|
|
export type ImportIssueGroup = 'content' | 'term' | 'media' | 'route' | 'adapter' | 'orchestrator'
|
|
|
|
|
|
|
|
|
|
|
|
/** 清洗统计(Plan 2 报告纪律的导入侧镜像:changed 语义 = html !== 输入原文) */
|
|
|
|
|
|
export interface ImportSanitizeStats {
|
|
|
|
|
|
/** changed=true 的 row 数(零 diff 等价变形也计——如 img 自闭合规范化) */
|
|
|
|
|
|
changedRows: number
|
|
|
|
|
|
/** 按 diff 类型计数('removed-tag'/'removed-attr'——sparse:零计类型不占键) */
|
|
|
|
|
|
byDiffType: Record<string, number>
|
2026-10-21 15:56:00 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-21 20:08:06 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 写入器共享身份基类型(Task 6 义务③:编译期防漂移)——四家写入器的 identity 参数均以
|
|
|
|
|
|
* 此为基础(`WriterContext & { 各家专属列 }`),db/siteId/connectionId 三列的形状与命名
|
|
|
|
|
|
* 单源约束;ImportJobContext 结构性满足(Pick 自它),编排注入即传 ctx 的这三列。
|
|
|
|
|
|
*/
|
|
|
|
|
|
export type WriterContext = Pick<ImportJobContext, 'db' | 'siteId' | 'connectionId'>
|
|
|
|
|
|
|
|
|
|
|
|
/** 管道(缺省 = Task 6 编排管道 orchestratedPipeline——四家写入器汇流)。抛错 → job failed */
|
2026-10-21 15:56:00 +08:00
|
|
|
|
export type ImportPipeline = (ctx: ImportJobContext) => Promise<ImportPipelineOutcome>
|
2026-10-21 15:30:42 +08:00
|
|
|
|
|
|
|
|
|
|
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
|
2026-10-21 16:19:00 +08:00
|
|
|
|
/**
|
2026-10-21 20:08:06 +08:00
|
|
|
|
* 适配器事件流(ctx.events 的注入位):测试手构注入。注入而缺省 pipeline 时仍走编排
|
|
|
|
|
|
* 管道(手构流 + 真编排);注入 pipeline 而无 events 时为空流(Task 1 语义保持);
|
|
|
|
|
|
* 两者皆缺席 = 真流(wxr 快照副本,前置事实 8 的 locale 接线在真流构造处)。
|
2026-10-21 16:19:00 +08:00
|
|
|
|
*/
|
|
|
|
|
|
events?: AsyncIterable<AdapterEvent>
|
2026-10-21 20:08:06 +08:00
|
|
|
|
/** 管道钩子(测试注入);缺省 = 编排管道 */
|
2026-10-21 15:30:42 +08:00
|
|
|
|
pipeline?: ImportPipeline
|
2026-10-21 20:34:43 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 主列表页路径(前置事实 9:CLI 参数来源,规范化值记入 job 报告);缺省 /products/。
|
|
|
|
|
|
* 解析期防线(阶段 2.5,建 job 行前):不安全段(穿越/控制字符等)→ INVALID_LIST_PATH
|
|
|
|
|
|
*/
|
2026-10-21 20:08:06 +08:00
|
|
|
|
productsPath?: string
|
2026-10-21 20:34:43 +08:00
|
|
|
|
/** 新闻列表页路径(同上,缺省 /news/;防线同 productsPath) */
|
2026-10-21 20:08:06 +08:00
|
|
|
|
newsPath?: string
|
2026-10-21 15:30:42 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export interface CreateImportJobResult {
|
|
|
|
|
|
jobId: string
|
|
|
|
|
|
/** job 终态(或复用时既有状态——并发窗口下可能读到中间态) */
|
|
|
|
|
|
status: ImportJobStatus
|
|
|
|
|
|
/** true = 命中既有 job(幂等键复用,未重跑管道、未改行) */
|
|
|
|
|
|
reused: boolean
|
|
|
|
|
|
siteId: string
|
2026-10-21 15:56:00 +08:00
|
|
|
|
/** job 行的 mapping_version(新建=当前 MAPPING_VERSION;复用=行值——权威记录在行) */
|
|
|
|
|
|
mappingVersion: string
|
2026-10-21 15:30:42 +08:00
|
|
|
|
reportPath: string | null
|
|
|
|
|
|
durationMs: number
|
2026-10-21 15:56:00 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 以下字段**仅新建路径填充**(复用口径忠实化:reuse 不回读快照/版本解析,快照与
|
|
|
|
|
|
* 版本块的权威记录是既有 job 报告——reportPath 指向它;复用返回只认 job 行锚定字段)
|
|
|
|
|
|
*/
|
|
|
|
|
|
siteKey?: string
|
|
|
|
|
|
connectionId?: string
|
|
|
|
|
|
wxrSnapshot?: SnapshotRef
|
|
|
|
|
|
mediaBundleSnapshot?: SnapshotRef | null
|
|
|
|
|
|
manualUrlListSnapshot?: SnapshotRef | null
|
|
|
|
|
|
versions?: ImportVersionBlock
|
2026-10-21 15:30:42 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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}`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-21 20:34:43 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 主列表路径解析(阶段 2.5 输入防线,Task 6 审查 Fix 3):安全检查(route-writer 的
|
|
|
|
|
|
* isSafePath 同判据)→ normalizeListPath 规范化(补首尾斜杠)。不安全段 →
|
|
|
|
|
|
* INVALID_LIST_PATH(CLI 无需新码——既有 ImportError 通道按 code 呈现)。
|
|
|
|
|
|
*/
|
|
|
|
|
|
function resolveListPath(raw: string, cliFlag: string): string {
|
|
|
|
|
|
if (!isSafePath(raw)) {
|
|
|
|
|
|
throw new ImportError(
|
|
|
|
|
|
'INVALID_LIST_PATH',
|
|
|
|
|
|
`${cliFlag}=${raw} 含不安全段(NUL/反斜杠/控制字符/穿越段 '..')——拒绝(完整校验规则 SP3 验证器)`,
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
try {
|
|
|
|
|
|
return normalizeListPath(raw)
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
// normalizeListPath 自带同判据拒绝(双覆盖的理论重叠分支)——归码为可预期错误
|
|
|
|
|
|
throw new ImportError('INVALID_LIST_PATH', `${cliFlag}=${raw}:${err instanceof Error ? err.message : String(err)}`)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-21 15:30:42 +08:00
|
|
|
|
// ---- 主入口 ----
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 创建(并就地推进到底)一个导入 job:
|
|
|
|
|
|
* 解析(site → wxr 快照 → 侧快照)→ 幂等建行(queued)→ running(attempt=1、started_at)
|
|
|
|
|
|
* → 空管道/注入管道 → succeeded|failed(error_code/error_summary/finished_at)→ 报告落盘。
|
2026-10-21 20:34:43 +08:00
|
|
|
|
* 失败口径:解析阶段失败零 job 行(含主列表路径防线 INVALID_LIST_PATH——建行前抛出,
|
|
|
|
|
|
* 不留悬挂 running 行);管道阶段失败 job 行保留(failed 终态 + error 列)。
|
2026-10-21 15:30:42 +08:00
|
|
|
|
*/
|
|
|
|
|
|
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]!
|
|
|
|
|
|
|
2026-10-21 20:08:06 +08:00
|
|
|
|
// 阶段 0.5:stale 回收(Task 6 义务④)——同 site 超过 24h 仍 queued/running 的 job 置
|
|
|
|
|
|
// stale(崩溃/中断残留的终态化,finished_at 记宣判时刻)。心跳列决策留 ADR-017:现阶
|
|
|
|
|
|
// 段无心跳列,以 created_at 近似(job 单进程就地推进,24h 远超任何真实执行时长)。
|
|
|
|
|
|
await db
|
|
|
|
|
|
.update(importJobs)
|
|
|
|
|
|
.set({ status: 'stale', finishedAt: new Date() })
|
|
|
|
|
|
.where(
|
|
|
|
|
|
and(
|
|
|
|
|
|
eq(importJobs.siteId, site.id),
|
|
|
|
|
|
inArray(importJobs.status, ['queued', 'running']),
|
|
|
|
|
|
lt(importJobs.createdAt, new Date(Date.now() - STALE_AFTER_MS)),
|
|
|
|
|
|
),
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-10-21 15:30:42 +08:00
|
|
|
|
// 阶段 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)
|
|
|
|
|
|
|
2026-10-21 20:34:43 +08:00
|
|
|
|
// 阶段 2.5:主列表路径解析(Task 6 审查 Fix 3——**建 job 行前**的输入防线:穿越段等
|
|
|
|
|
|
// 不安全输入 → INVALID_LIST_PATH 零 job 行残留;建行/置 running 后再抛会留悬挂行)。
|
|
|
|
|
|
// normalizeListPath 自带同判据安全拒绝(route-writer 侧双覆盖),此处前置检查给出
|
|
|
|
|
|
// CLI 参数归属的错误信息。复用命中同样过检:非法输入恒拒(与 SITE_NOT_FOUND 同纪律)。
|
|
|
|
|
|
const productsPath = resolveListPath(input.productsPath ?? DEFAULT_PRODUCTS_PATH, '--products-path')
|
|
|
|
|
|
const newsPath = resolveListPath(input.newsPath ?? DEFAULT_NEWS_PATH, '--news-path')
|
|
|
|
|
|
|
2026-10-21 15:56:00 +08:00
|
|
|
|
// 阶段 3:幂等建 job(queued)——命中既有 key 直接复用返回(不重跑)。
|
|
|
|
|
|
// 复用口径忠实化:只返回 job 行锚定字段(含行内 mapping_version/report_path),
|
|
|
|
|
|
// 快照与版本块不回读(权威记录在既有 job 报告——reportPath 指向它)。
|
2026-10-21 15:30:42 +08:00
|
|
|
|
const idempotencyKey = input.idempotencyKey ?? defaultIdempotencyKey(input.siteKey, wxrSnapshot.id)
|
|
|
|
|
|
const existing = await reuseExisting(db, idempotencyKey)
|
|
|
|
|
|
if (existing !== null) {
|
2026-10-21 15:56:00 +08:00
|
|
|
|
return { ...existing, durationMs: performance.now() - startedAt }
|
2026-10-21 15:30:42 +08:00
|
|
|
|
}
|
|
|
|
|
|
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) {
|
2026-10-21 15:56:00 +08:00
|
|
|
|
return { ...raced, durationMs: performance.now() - startedAt }
|
2026-10-21 15:30:42 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
throw err
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 阶段 4:状态推进 queued → running(attempt 记首次执行)
|
2026-10-21 15:56:00 +08:00
|
|
|
|
const attempt = 1
|
2026-10-21 15:30:42 +08:00
|
|
|
|
await db
|
|
|
|
|
|
.update(importJobs)
|
2026-10-21 15:56:00 +08:00
|
|
|
|
.set({ status: 'running', startedAt: new Date(), attempt })
|
2026-10-21 15:30:42 +08:00
|
|
|
|
.where(eq(importJobs.id, jobId))
|
|
|
|
|
|
|
2026-10-21 20:08:06 +08:00
|
|
|
|
// 阶段 5:管道——缺省 = 编排管道(四家写入器汇流);注入钩子优先。抛错 → failed 终态。
|
|
|
|
|
|
// 事件流三分:注入 events 优先(手构流 + 缺省编排管道 = 测试形态)→ 注入 pipeline 而
|
|
|
|
|
|
// 无 events = 空流(Task 1 语义保持)→ 双缺席 = 真流(wxr 快照副本 + locale 接线,
|
|
|
|
|
|
// 前置事实 8——缺省空串会让全部行被 content-writer 以 locale 不匹配拒收)。
|
2026-10-21 20:34:43 +08:00
|
|
|
|
// productsPath/newsPath 已在阶段 2.5 解析(建行前防线 + 报告/管道共用规范化值)。
|
2026-10-21 20:08:06 +08:00
|
|
|
|
const useRealStream = input.events === undefined && input.pipeline === undefined
|
|
|
|
|
|
const adapterTimings: AdapterTimings | null = useRealStream ? { parseMs: 0, sanitizeMs: 0 } : null
|
|
|
|
|
|
const events: AsyncIterable<AdapterEvent> =
|
|
|
|
|
|
input.events !== undefined
|
|
|
|
|
|
? input.events
|
|
|
|
|
|
: input.pipeline !== undefined
|
|
|
|
|
|
? emptyEvents()
|
|
|
|
|
|
: realEventStream(wxrSnapshot, site.defaultLocale, adapterTimings!)
|
|
|
|
|
|
const pipeline: ImportPipeline =
|
|
|
|
|
|
input.pipeline !== undefined ? input.pipeline : orchestratedPipeline({ productsPath, newsPath })
|
|
|
|
|
|
|
2026-10-21 16:19:00 +08:00
|
|
|
|
let failed: { code: ImportErrorCode; summary: string } | null = null
|
2026-10-21 15:30:42 +08:00
|
|
|
|
const ctx: ImportJobContext = {
|
|
|
|
|
|
db,
|
|
|
|
|
|
jobId,
|
|
|
|
|
|
siteId: site.id,
|
|
|
|
|
|
siteKey: site.key,
|
|
|
|
|
|
defaultLocale: site.defaultLocale,
|
|
|
|
|
|
connectionId,
|
|
|
|
|
|
wxrSnapshot,
|
|
|
|
|
|
mediaBundleSnapshot,
|
|
|
|
|
|
manualUrlListSnapshot,
|
|
|
|
|
|
versions: VERSIONS,
|
2026-10-21 15:56:00 +08:00
|
|
|
|
report: { counts: {}, issues: [] },
|
2026-10-21 20:08:06 +08:00
|
|
|
|
events,
|
2026-10-21 15:30:42 +08:00
|
|
|
|
}
|
2026-10-21 15:56:00 +08:00
|
|
|
|
let outcome: ImportPipelineOutcome | null = null
|
2026-10-21 15:30:42 +08:00
|
|
|
|
try {
|
2026-10-21 20:08:06 +08:00
|
|
|
|
outcome = await pipeline(ctx)
|
2026-10-21 15:30:42 +08:00
|
|
|
|
} catch (err) {
|
|
|
|
|
|
failed = {
|
|
|
|
|
|
code: err instanceof ImportError ? err.code : 'PIPELINE_FAILED',
|
2026-10-21 15:56:00 +08:00
|
|
|
|
// '[pipeline]' 前缀:管道阶段错误与解析阶段错误在 error_summary 上可区分
|
|
|
|
|
|
//(ImportError 保留原 code——管道内可预期失败复用同一套错误码面)
|
|
|
|
|
|
summary: `[pipeline] ${err instanceof Error ? `${err.name}: ${err.message}` : String(err)}`,
|
2026-10-21 15:30:42 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-10-21 15:56:00 +08:00
|
|
|
|
// 抛错时无返回值:累加器现值进报告(部分产出可审计)
|
2026-10-21 20:08:06 +08:00
|
|
|
|
const merged: ImportPipelineOutcome = outcome === null ? ctx.report : mergeOutcomes(ctx.report, outcome)
|
2026-10-21 15:56:00 +08:00
|
|
|
|
let finalStatus: ImportJobStatus
|
|
|
|
|
|
if (failed !== null) {
|
|
|
|
|
|
finalStatus = 'failed'
|
|
|
|
|
|
} else {
|
2026-10-21 21:20:12 +08:00
|
|
|
|
// 终态判定在**全量** issues 长度上做(截断是报告固化层语义,不吞终态事实)
|
2026-10-21 15:56:00 +08:00
|
|
|
|
finalStatus = merged.issues.length > 0 ? 'succeeded_with_issues' : 'succeeded'
|
|
|
|
|
|
}
|
2026-10-21 15:30:42 +08:00
|
|
|
|
|
2026-10-21 21:20:12 +08:00
|
|
|
|
// 报告 issues 封顶(Task 8 兑现 Task 6 推迟项,对齐 Plan 2 ROW_ERRORS_CAP 纪律):
|
|
|
|
|
|
// flat 清单与 issueGroups 各组按同一常量截断——对抗性输入(单条 item 上万行级问题)
|
|
|
|
|
|
// 不使报告体积失去上界;counts/sanitize 统计不受影响始终完整。分组是同一集合的
|
|
|
|
|
|
// 视图,各组溢出 ≤ flat 溢出(分划性质),不单独入账。
|
|
|
|
|
|
const cappedIssues = merged.issues.slice(0, REPORT_ISSUES_CAP)
|
|
|
|
|
|
const issuesTruncated = merged.issues.length - cappedIssues.length
|
|
|
|
|
|
const cappedGroups = capIssueGroups(merged.issueGroups)
|
|
|
|
|
|
|
2026-10-21 15:56:00 +08:00
|
|
|
|
// 阶段 6:报告先行落盘(temp+rename 原子替换),终态落行在后——行内 report_path
|
|
|
|
|
|
// 出现即文件存在;写盘失败 → job failed(REPORT_WRITE_FAILED、report_path 不落,
|
2026-10-21 21:20:12 +08:00
|
|
|
|
// 不留“succeeded 但报告失踪”或悬挂路径)。import.json 为权威产物(机器消费),
|
|
|
|
|
|
// import.html 为同数据的渲染视图(Task 8 兑现 Task 6 推迟项)——同 try 同失败口径。
|
2026-10-21 15:30:42 +08:00
|
|
|
|
const reportRoot = resolve(input.reportRoot ?? join(REPO_ROOT, 'artifacts', 'import'))
|
|
|
|
|
|
const reportDir = join(reportRoot, jobId, 'reports')
|
|
|
|
|
|
const reportPath = join(reportDir, 'import.json')
|
2026-10-21 21:20:12 +08:00
|
|
|
|
const htmlPath = join(reportDir, 'import.html')
|
|
|
|
|
|
const report: ImportReport = {
|
2026-10-21 15:30:42 +08:00
|
|
|
|
jobId,
|
2026-10-21 15:56:00 +08:00
|
|
|
|
generatedAt: new Date().toISOString(),
|
2026-10-21 15:30:42 +08:00
|
|
|
|
status: finalStatus,
|
|
|
|
|
|
siteKey: site.key,
|
|
|
|
|
|
siteId: site.id,
|
|
|
|
|
|
connectionId,
|
2026-10-21 15:56:00 +08:00
|
|
|
|
idempotencyKey,
|
|
|
|
|
|
attempt,
|
2026-10-21 15:30:42 +08:00
|
|
|
|
snapshots: {
|
|
|
|
|
|
wxr: slimRef(wxrSnapshot),
|
|
|
|
|
|
media_bundle: mediaBundleSnapshot === null ? null : slimRef(mediaBundleSnapshot),
|
|
|
|
|
|
manual_url_list: manualUrlListSnapshot === null ? null : slimRef(manualUrlListSnapshot),
|
|
|
|
|
|
},
|
|
|
|
|
|
versions: VERSIONS,
|
2026-10-21 20:08:06 +08:00
|
|
|
|
// 主列表路径(前置事实 9:CLI 参数来源记入报告——route-writer 实际消费的规范化值)
|
|
|
|
|
|
paths: { productsPath, newsPath },
|
2026-10-21 15:56:00 +08:00
|
|
|
|
counts: merged.counts,
|
2026-10-21 21:20:12 +08:00
|
|
|
|
issues: cappedIssues,
|
|
|
|
|
|
issuesTruncated,
|
|
|
|
|
|
...(cappedGroups === undefined ? {} : { issueGroups: cappedGroups }),
|
2026-10-21 20:08:06 +08:00
|
|
|
|
...(merged.sanitize === undefined ? {} : { sanitize: merged.sanitize }),
|
|
|
|
|
|
// 适配器分段计时(仅真流编排——Plan 2 报告纪律的 parse/sanitize 分段在导入侧续录)
|
|
|
|
|
|
...(adapterTimings === null ? {} : { adapterTiming: { parseMs: adapterTimings.parseMs, sanitizeMs: adapterTimings.sanitizeMs } }),
|
2026-10-21 15:30:42 +08:00
|
|
|
|
error: failed,
|
|
|
|
|
|
durationMs: performance.now() - startedAt,
|
|
|
|
|
|
}
|
2026-10-21 15:56:00 +08:00
|
|
|
|
let reportWritten = true
|
|
|
|
|
|
try {
|
|
|
|
|
|
await mkdir(reportDir, { recursive: true })
|
2026-10-21 21:20:12 +08:00
|
|
|
|
await writeAtomically(reportPath, `${JSON.stringify(report, null, 2)}\n`)
|
|
|
|
|
|
await writeAtomically(htmlPath, `${renderImportReportHtml(report)}\n`)
|
2026-10-21 15:56:00 +08:00
|
|
|
|
} catch (err) {
|
|
|
|
|
|
reportWritten = false
|
|
|
|
|
|
failed = {
|
|
|
|
|
|
code: 'REPORT_WRITE_FAILED',
|
|
|
|
|
|
summary: `[report] ${err instanceof Error ? `${err.name}: ${err.message}` : String(err)}`,
|
|
|
|
|
|
}
|
|
|
|
|
|
finalStatus = 'failed'
|
|
|
|
|
|
}
|
|
|
|
|
|
const finishedAt = new Date()
|
|
|
|
|
|
// 终态落行:failed===null ⟹ 写盘成功(report_path 同步入行);failed≠null 时按
|
|
|
|
|
|
// 写盘成败决定 report_path 去留(写失败不落——文件不存在不悬挂路径)
|
|
|
|
|
|
const finalUpdate =
|
|
|
|
|
|
failed === null
|
|
|
|
|
|
? { status: finalStatus, finishedAt, reportPath }
|
|
|
|
|
|
: reportWritten
|
|
|
|
|
|
? { status: finalStatus, finishedAt, reportPath, errorCode: failed.code, errorSummary: failed.summary }
|
|
|
|
|
|
: { status: finalStatus, finishedAt, errorCode: failed.code, errorSummary: failed.summary }
|
|
|
|
|
|
await db.update(importJobs).set(finalUpdate).where(eq(importJobs.id, jobId))
|
2026-10-21 15:30:42 +08:00
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
jobId,
|
|
|
|
|
|
status: finalStatus,
|
|
|
|
|
|
reused: false,
|
|
|
|
|
|
siteId: site.id,
|
2026-10-21 15:56:00 +08:00
|
|
|
|
mappingVersion: MAPPING_VERSION,
|
2026-10-21 15:30:42 +08:00
|
|
|
|
siteKey: site.key,
|
|
|
|
|
|
connectionId,
|
|
|
|
|
|
wxrSnapshot,
|
|
|
|
|
|
mediaBundleSnapshot,
|
|
|
|
|
|
manualUrlListSnapshot,
|
|
|
|
|
|
versions: VERSIONS,
|
2026-10-21 15:56:00 +08:00
|
|
|
|
reportPath: reportWritten ? reportPath : null,
|
2026-10-21 15:30:42 +08:00
|
|
|
|
durationMs: performance.now() - startedAt,
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-21 15:56:00 +08:00
|
|
|
|
/** 既有 job 的复用返回(不动行、不跑管道;仅 job 行锚定字段);无则 null */
|
2026-10-21 15:30:42 +08:00
|
|
|
|
async function reuseExisting(
|
|
|
|
|
|
db: ImportDb,
|
|
|
|
|
|
idempotencyKey: string,
|
2026-10-21 15:56:00 +08:00
|
|
|
|
): Promise<Pick<CreateImportJobResult, 'jobId' | 'status' | 'reused' | 'siteId' | 'mappingVersion' | 'reportPath'> | null> {
|
2026-10-21 15:30:42 +08:00
|
|
|
|
const found = await db
|
|
|
|
|
|
.select({
|
|
|
|
|
|
id: importJobs.id,
|
|
|
|
|
|
status: importJobs.status,
|
|
|
|
|
|
siteId: importJobs.siteId,
|
2026-10-21 15:56:00 +08:00
|
|
|
|
mappingVersion: importJobs.mappingVersion,
|
2026-10-21 15:30:42 +08:00
|
|
|
|
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,
|
2026-10-21 15:56:00 +08:00
|
|
|
|
mappingVersion: row.mappingVersion,
|
2026-10-21 15:30:42 +08:00
|
|
|
|
reportPath: row.reportPath,
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** 报告里的快照引用瘦身版:不含 metadata(media_bundle 清单可达数千条,报告只留身份锚点) */
|
2026-10-21 21:20:12 +08:00
|
|
|
|
export interface SnapshotRefSlim {
|
2026-10-21 15:30:42 +08:00
|
|
|
|
id: string
|
|
|
|
|
|
kind: string
|
|
|
|
|
|
sha256: string
|
|
|
|
|
|
byteSize: number
|
|
|
|
|
|
storagePath: string
|
2026-10-21 21:20:12 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function slimRef(ref: SnapshotRef): SnapshotRefSlim {
|
2026-10-21 15:30:42 +08:00
|
|
|
|
return { id: ref.id, kind: ref.kind, sha256: ref.sha256, byteSize: ref.byteSize, storagePath: ref.storagePath }
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-21 21:20:12 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 报告 issues 固化上限(条):flat 清单与 issueGroups 各组按此截断,超出只计数
|
|
|
|
|
|
* (issuesTruncated——见 {@link ImportReport.issuesTruncated})。Plan 2 Task 6
|
|
|
|
|
|
* ROW_ERRORS_CAP 纪律的导入侧镜像:对抗性输入下单条 item 可产出上万行级问题,
|
|
|
|
|
|
* 固化全部会使报告体积失去上界;统计维度(counts/sanitize)不受影响始终完整。
|
|
|
|
|
|
*/
|
|
|
|
|
|
const REPORT_ISSUES_CAP = 10_000
|
|
|
|
|
|
|
|
|
|
|
|
/** issueGroups 各组按 {@link REPORT_ISSUES_CAP} 截断(组溢出不单独入账——分划性质下 ≤ flat 溢出) */
|
|
|
|
|
|
function capIssueGroups(
|
|
|
|
|
|
groups: Partial<Record<ImportIssueGroup, string[]>> | undefined,
|
|
|
|
|
|
): Partial<Record<ImportIssueGroup, string[]>> | undefined {
|
|
|
|
|
|
if (groups === undefined) return undefined
|
|
|
|
|
|
const capped: Partial<Record<ImportIssueGroup, string[]>> = {}
|
|
|
|
|
|
for (const [key, value] of Object.entries(groups) as Array<[ImportIssueGroup, string[] | undefined]>) {
|
|
|
|
|
|
if (value === undefined) continue
|
|
|
|
|
|
capped[key] = value.slice(0, REPORT_ISSUES_CAP)
|
|
|
|
|
|
}
|
|
|
|
|
|
return capped
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 导入报告对象(import.json 的固化形状——renderImportReportHtml 的输入契约)。
|
|
|
|
|
|
* issues/issueGroups 已按 {@link REPORT_ISSUES_CAP} 截断(issuesTruncated 记 flat
|
|
|
|
|
|
* 溢出条数);对象层保留**原文**(未转义——JSON 归档/机器消费的语义层),HTML
|
|
|
|
|
|
* 渲染层(report-html.ts)负责转义。
|
|
|
|
|
|
*/
|
|
|
|
|
|
export interface ImportReport {
|
|
|
|
|
|
jobId: string
|
|
|
|
|
|
generatedAt: string
|
|
|
|
|
|
status: ImportJobStatus
|
|
|
|
|
|
siteKey: string
|
|
|
|
|
|
siteId: string
|
|
|
|
|
|
connectionId: string
|
|
|
|
|
|
idempotencyKey: string
|
|
|
|
|
|
attempt: number
|
|
|
|
|
|
snapshots: {
|
|
|
|
|
|
wxr: SnapshotRefSlim
|
|
|
|
|
|
media_bundle: SnapshotRefSlim | null
|
|
|
|
|
|
manual_url_list: SnapshotRefSlim | null
|
|
|
|
|
|
}
|
|
|
|
|
|
versions: ImportVersionBlock
|
|
|
|
|
|
/** 主列表路径(前置事实 9:CLI 参数来源——route-writer 实际消费的规范化值) */
|
|
|
|
|
|
paths: { productsPath: string; newsPath: string }
|
|
|
|
|
|
counts: Record<string, number>
|
|
|
|
|
|
/** 行级问题清单(封顶 {@link REPORT_ISSUES_CAP} 条——超出只计数) */
|
|
|
|
|
|
issues: string[]
|
|
|
|
|
|
/** 超出 {@link REPORT_ISSUES_CAP} 被丢弃固化的条数(只计数;issueGroups 各组同限截断不另计) */
|
|
|
|
|
|
issuesTruncated: number
|
|
|
|
|
|
/** 可选:按来源分组的 issues 视图(各组同限截断) */
|
|
|
|
|
|
issueGroups?: Partial<Record<ImportIssueGroup, string[]>>
|
|
|
|
|
|
/** 可选:清洗统计(row 事件 sanitizeDiffs/changed 的聚合——编排管道产出) */
|
|
|
|
|
|
sanitize?: ImportSanitizeStats
|
|
|
|
|
|
/** 可选:适配器分段计时(仅真流编排) */
|
|
|
|
|
|
adapterTiming?: { parseMs: number; sanitizeMs: number }
|
|
|
|
|
|
error: { code: ImportErrorCode; summary: string } | null
|
|
|
|
|
|
durationMs: number
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** temp+rename 原子写(失败清走 tmp 不留半成品——报告落盘纪律的共用形状) */
|
|
|
|
|
|
async function writeAtomically(path: string, content: string): Promise<void> {
|
|
|
|
|
|
const tmpPath = `${path}.tmp`
|
|
|
|
|
|
try {
|
|
|
|
|
|
await writeFile(tmpPath, content, 'utf8')
|
|
|
|
|
|
await rename(tmpPath, path)
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
await rm(tmpPath, { force: true })
|
|
|
|
|
|
throw err
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-21 16:19:00 +08:00
|
|
|
|
/** 缺省空事件流(input.events 缺席时的 ctx.events 占位;每次调用产新迭代器) */
|
|
|
|
|
|
async function* emptyEvents(): AsyncGenerator<AdapterEvent, void, void> {}
|
|
|
|
|
|
|
2026-10-21 21:20:12 +08:00
|
|
|
|
// ---- 事件流构造与 stale 回收(编排本体见 orchestrator.ts) ----
|
2026-10-21 20:08:06 +08:00
|
|
|
|
|
2026-10-21 21:20:12 +08:00
|
|
|
|
/** stale 回收阈值:queued/running 滞留超过此时长即宣判(毫秒)——决策与理由见 ADR-017 */
|
2026-10-21 20:08:06 +08:00
|
|
|
|
const STALE_AFTER_MS = 24 * 60 * 60 * 1000
|
|
|
|
|
|
|
|
|
|
|
|
/** wxr 快照副本绝对路径(metadata.storageRoot + storagePath——登记期固化的存储根事实) */
|
|
|
|
|
|
function wxrSnapshotCopyPath(snapshot: SnapshotRef): string {
|
|
|
|
|
|
const root = snapshot.metadata.storageRoot
|
|
|
|
|
|
if (typeof root !== 'string' || root === '') {
|
|
|
|
|
|
throw new Error(
|
|
|
|
|
|
`wxr 快照 ${snapshot.id} metadata 缺 storageRoot(登记期不变量破裂——无法读取副本构造事件流)`,
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
return join(root, snapshot.storagePath)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 真事件流(惰性生成器:路径解析/读盘错误推迟到首拍 next()——落进管道执行期,由
|
|
|
|
|
|
* createImportJob 的 PIPELINE_FAILED 收口,不留"job 已 running 而流构造抛错"的悬挂行)。
|
|
|
|
|
|
* 前置事实 8:mapConfig.locale 必须接 site.default_locale(缺省空串 = 全行被拒收)。
|
|
|
|
|
|
*/
|
|
|
|
|
|
async function* realEventStream(
|
|
|
|
|
|
snapshot: SnapshotRef,
|
|
|
|
|
|
locale: string,
|
|
|
|
|
|
timings: AdapterTimings,
|
|
|
|
|
|
): AsyncGenerator<AdapterEvent, void, void> {
|
|
|
|
|
|
yield* runWxrAdapter(createReadStream(wxrSnapshotCopyPath(snapshot)), {
|
|
|
|
|
|
mapConfig: { locale },
|
|
|
|
|
|
timings,
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|