From c35f10835acd8b08ce8cd111648f8e4a7b584667 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B5=85=E5=BF=B5?= Date: Thu, 22 Oct 2026 07:06:23 +0800 Subject: [PATCH] =?UTF-8?q?feat(publish):=20=E6=98=BE=E5=BC=8F=E5=8F=91?= =?UTF-8?q?=E5=B8=83=E5=91=BD=E4=BB=A4=EF=BC=88dry-run/confirm/=E5=AE=A1?= =?UTF-8?q?=E8=AE=A1/=E7=AB=99=E7=82=B9=E9=85=8D=E7=BD=AE=E9=A6=96?= =?UTF-8?q?=E7=89=88/=E9=94=9A=E7=82=B9helper=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - publishSiteContent 零装饰器服务:dry-run 只读清单(current 非空且 published≠current) + 随机 confirm token;confirm 单事务批量 UPDATE published←current + audit_logs (action=content.publish,全仓第一个审计写入方)+ site_config_revisions v1 首版 ({name,origin,locale,brand:{}},UNIQUE(site_id,version) 幂等) - 空清单 no-op 零写入;SITE_NOT_FOUND/CONFIRM_TOKEN_REQUIRED 均先于任何写入 - P0 门禁不做(ADR-018 记偏离,服务 JSDoc 注明);导入永不发布(ADR-010) - sf CLI + content:publish(--dry-run/--confirm/--actor,缺省 dry-run 安全优先, actor 缺省 super_admin@cli) - anchors.ts:五列内容身份查询从 content/term/route 三写入器抽单源共享(零行为 变更,既有 333 测试守护) - 集成测试 7 例(临时库 mini 全链):dry-run 零写入/confirm/幂等/config 首版与 既有跳过/actor/空清单/SITE_NOT_FOUND/缺 token Co-Authored-By: Claude Code --- apps/api/src/modules/import/anchors.ts | 71 ++++ apps/api/src/modules/import/content-writer.ts | 27 +- apps/api/src/modules/import/route-writer.ts | 24 +- apps/api/src/modules/import/term-writer.ts | 24 +- .../api/src/modules/publish/publish.module.ts | 28 ++ .../src/modules/publish/publish.service.ts | 345 +++++++++++++++++ scripts/sf.ts | 74 +++- tests/integration/publish.test.ts | 365 ++++++++++++++++++ 8 files changed, 892 insertions(+), 66 deletions(-) create mode 100644 apps/api/src/modules/import/anchors.ts create mode 100644 apps/api/src/modules/publish/publish.module.ts create mode 100644 apps/api/src/modules/publish/publish.service.ts create mode 100644 tests/integration/publish.test.ts diff --git a/apps/api/src/modules/import/anchors.ts b/apps/api/src/modules/import/anchors.ts new file mode 100644 index 0000000..92d9e95 --- /dev/null +++ b/apps/api/src/modules/import/anchors.ts @@ -0,0 +1,71 @@ +// 五列内容身份锚点共享 helper(Plan 4 Task 1,ADR-006、Plan 3 前置事实 ④): +// source_records 的内容身份(site + connection + source_type + source_id + locale, +// 五列——快照列不在键上,跨快照复用同一 content_entry)→ 挂接 entry 的最新快照行 +// (latest first)。此前同一查询在 content/term/route 三写入器各持一份拷贝—— +// publish 域(第 4 个消费方触发 Plan 3 审查既定条件)起抽此单源共享,零行为变更 +// (WHERE/ORDER BY/LIMIT 逐字保真,由既有集成测试守护)。 +// +// 消费方契约: +// - content-writer(findPriorLinkedRecord):id/checksum/entryId 全消费(幂等三态); +// - term-writer(resolveContentEntryId)/ route-writer(resolveEntryAnchor): +// 只消费 contentEntryId(entry 锚点解析)。 +// 六列(快照内)查询**不在**此处——那是 content/media 写入器各自的快照行查找 +// (snapshot_id 进键),与五列身份语义不同,勿合并。 +import { and, desc, eq, isNotNull } from 'drizzle-orm' +import { sourceRecords } from '../../db/schema.js' +import type { ImportDb } from './import.service.js' + +/** 五列身份的固定侧(site + connection)——各写入器的 WriterContext 均结构性满足 */ +export interface AnchorIdentity { + siteId: string + connectionId: string +} + +/** 五列身份的行侧(UniversalRow 的身份三列投影形状) */ +export interface AnchorRowKey { + sourceType: string + sourceId: string + locale: string +} + +/** 命中行(contentEntryId 非 null——谓词保证;normalizedChecksum 可空:record-only 足迹无) */ +export interface LinkedSourceRecord { + id: string + contentEntryId: string + normalizedChecksum: string | null +} + +/** + * 五列内容身份 → 挂接 entry 的最新 source_records 行(latest first——createdAt 降序, + * 与既有三写入器同口径);无挂接行 → null。谓词含 content_entry_id IS NOT NULL + * (record-only / 占位足迹不参与锚点解析)。 + */ +export async function findLatestLinkedSourceRecord( + db: ImportDb, + identity: AnchorIdentity, + row: AnchorRowKey, +): Promise { + const rows = await db + .select({ + id: sourceRecords.id, + contentEntryId: sourceRecords.contentEntryId, + normalizedChecksum: sourceRecords.normalizedChecksum, + }) + .from(sourceRecords) + .where( + and( + eq(sourceRecords.siteId, identity.siteId), + eq(sourceRecords.sourceConnectionId, identity.connectionId), + eq(sourceRecords.sourceType, row.sourceType), + eq(sourceRecords.sourceId, row.sourceId), + eq(sourceRecords.locale, row.locale), + isNotNull(sourceRecords.contentEntryId), + ), + ) + .orderBy(desc(sourceRecords.createdAt)) + .limit(1) + const r = rows[0] + if (r === undefined) return null + // contentEntryId 非 null:查询谓词保证(drizzle 谓词不细化 select 类型) + return { id: r.id, contentEntryId: r.contentEntryId!, normalizedChecksum: r.normalizedChecksum } +} diff --git a/apps/api/src/modules/import/content-writer.ts b/apps/api/src/modules/import/content-writer.ts index 8d838ff..5b9d68e 100644 --- a/apps/api/src/modules/import/content-writer.ts +++ b/apps/api/src/modules/import/content-writer.ts @@ -46,11 +46,12 @@ // Task 6 编排负责把 site.default_locale 接进 mapConfig(否则适配器产 locale 空串行, // 1047 行全被此处拒收——接缝两侧双保险)。 import { createHash } from 'node:crypto' -import { and, desc, eq, isNotNull } from 'drizzle-orm' +import { and, eq } from 'drizzle-orm' import { UNIVERSAL_ROW_VERSION, type UniversalRow } from '@sf/contracts' import type { AdapterEvent } from '@sf/adapter-wp' import { canonicalJson } from '../../common/canonical.js' import { contentEntries, contentRevisions, entryLocales, sourceRecords } from '../../db/schema.js' +import { findLatestLinkedSourceRecord } from './anchors.js' import type { ImportDb, ImportPipeline, ImportPipelineOutcome, ImportVersionBlock, WriterContext } from './import.service.js' /** 事务句柄类型(db.transaction 回调参数——从 ImportDb 的事务签名提取,避免重复泛型展开) */ @@ -252,29 +253,9 @@ export function createContentEventHandler(identity: WriterContext & { return rows[0] ?? null } - /** 五列内容身份 → 既有 entry 的最新快照行(跨快照;latest first——与 term-writer 同口径) */ + /** 五列内容身份 → 既有 entry 的最新快照行(anchors.ts 共享 helper——latest first,跨快照) */ async function findPriorLinkedRecord(row: UniversalRow) { - const rows = await db - .select({ - id: sourceRecords.id, - contentEntryId: sourceRecords.contentEntryId, - normalizedChecksum: sourceRecords.normalizedChecksum, - }) - .from(sourceRecords) - .where( - and( - eq(sourceRecords.siteId, siteId), - eq(sourceRecords.sourceConnectionId, identity.connectionId), - eq(sourceRecords.sourceType, row.sourceType), - eq(sourceRecords.sourceId, row.sourceId), - eq(sourceRecords.locale, row.locale), - isNotNull(sourceRecords.contentEntryId), - ), - ) - .orderBy(desc(sourceRecords.createdAt)) - .limit(1) - // contentEntryId 非 null:查询谓词 isNotNull 保证(drizzle 谓词不细化 select 类型) - return rows[0] === undefined ? null : { ...rows[0], contentEntryId: rows[0].contentEntryId! } + return findLatestLinkedSourceRecord(db, { siteId, connectionId: identity.connectionId }, row) } /** 内容行主路径:幂等三态(miss / checksum 同 skip / checksum 异新修订) */ diff --git a/apps/api/src/modules/import/route-writer.ts b/apps/api/src/modules/import/route-writer.ts index e1426a3..f3228d4 100644 --- a/apps/api/src/modules/import/route-writer.ts +++ b/apps/api/src/modules/import/route-writer.ts @@ -67,10 +67,11 @@ // 事务边界:全部单语句原子操作(SELECT → INSERT/UPDATE 序列无跨语句不变量——并发同 key // 由 route_exact_uq 唯一约束兜底,冲突上抛按 PIPELINE_FAILED 处置),与 term/media 同纪律 // (ctx.db 直连约定)。 -import { and, desc, eq, inArray, isNotNull, isNull, ne } from 'drizzle-orm' +import { and, eq, inArray, isNull, ne } from 'drizzle-orm' import type { AdapterEvent } from '@sf/adapter-wp' import type { UniversalRow } from '@sf/contracts' -import { mediaAssets, routeRegistry, sites, sourceRecords, taxonomies, terms } from '../../db/schema.js' +import { mediaAssets, routeRegistry, sites, taxonomies, terms } from '../../db/schema.js' +import { findLatestLinkedSourceRecord } from './anchors.js' import { CONTENT_TYPE_KEY_MAP } from './content-writer.js' import type { ImportPipeline, ImportPipelineOutcome, WriterContext } from './import.service.js' @@ -260,24 +261,9 @@ export function createRouteEventHandler(identity: WriterContext & { return pairs.length === 0 ? null : pairs.join('&') } - /** source_records 五列身份 → content_entry_id(content-writer 接缝,latest first——与 term-writer 同口径) */ + /** source_records 五列身份 → content_entry_id(anchors.ts 共享 helper——content-writer 接缝,latest first) */ async function resolveEntryAnchor(row: UniversalRow): Promise { - const rows = await db - .select({ contentEntryId: sourceRecords.contentEntryId }) - .from(sourceRecords) - .where( - and( - eq(sourceRecords.siteId, siteId), - eq(sourceRecords.sourceConnectionId, identity.connectionId), - eq(sourceRecords.sourceType, row.sourceType), - eq(sourceRecords.sourceId, row.sourceId), - eq(sourceRecords.locale, row.locale), - isNotNull(sourceRecords.contentEntryId), - ), - ) - .orderBy(desc(sourceRecords.createdAt)) - .limit(1) - return rows[0]?.contentEntryId ?? null + return (await findLatestLinkedSourceRecord(db, { siteId, connectionId: identity.connectionId }, row))?.contentEntryId ?? null } /** writeRoute 的四态产出(降级计数只认实际写入——'created'/'updated') */ diff --git a/apps/api/src/modules/import/term-writer.ts b/apps/api/src/modules/import/term-writer.ts index cbb1303..a50f746 100644 --- a/apps/api/src/modules/import/term-writer.ts +++ b/apps/api/src/modules/import/term-writer.ts @@ -31,10 +31,11 @@ // // 事务边界:所有 upsert 均为单语句原子操作(insert ... on conflict do nothing + // returning / 单行 update),无跨语句不变量需要显式事务(约定见 ImportJobContext.db)。 -import { and, desc, eq, isNotNull } from 'drizzle-orm' +import { and, eq } from 'drizzle-orm' import type { AdapterEvent, WxrTerm } from '@sf/adapter-wp' import type { UniversalRow } from '@sf/contracts' -import { contentTerms, sourceRecords, taxonomies, terms } from '../../db/schema.js' +import { contentTerms, taxonomies, terms } from '../../db/schema.js' +import { findLatestLinkedSourceRecord } from './anchors.js' import { isUniqueViolation } from '../source/register.service.js' import type { ImportPipeline, ImportPipelineOutcome, WriterContext } from './import.service.js' @@ -284,24 +285,9 @@ export function createTermEventHandler(identity: WriterContext): TermEventHandle slugIndex.set(key, { id, sourceId, countedAuto: false }) } - /** row 的稳定身份 → content entry(source_records.content_entry_id 是内容写入器的接缝列) */ + /** row 的稳定身份 → content entry(anchors.ts 共享 helper;source_records.content_entry_id 是内容写入器的接缝列) */ async function resolveContentEntryId(row: UniversalRow): Promise { - const rows = await db - .select({ contentEntryId: sourceRecords.contentEntryId }) - .from(sourceRecords) - .where( - and( - eq(sourceRecords.siteId, siteId), - eq(sourceRecords.sourceConnectionId, identity.connectionId), - eq(sourceRecords.sourceType, row.sourceType), - eq(sourceRecords.sourceId, row.sourceId), - eq(sourceRecords.locale, row.locale), - isNotNull(sourceRecords.contentEntryId), - ), - ) - .orderBy(desc(sourceRecords.createdAt)) - .limit(1) - return rows.length > 0 ? rows[0]!.contentEntryId : null + return (await findLatestLinkedSourceRecord(db, { siteId, connectionId: identity.connectionId }, row))?.contentEntryId ?? null } /** row 事件:先收集 refs 建齐 term(含自建缺失),再关联 content_terms(顺序无关性) */ diff --git a/apps/api/src/modules/publish/publish.module.ts b/apps/api/src/modules/publish/publish.module.ts new file mode 100644 index 0000000..b0948ed --- /dev/null +++ b/apps/api/src/modules/publish/publish.module.ts @@ -0,0 +1,28 @@ +// publish 域 Nest 模块(Plan 4 Task 1):注册 PublishService 供 standalone context +// 使用(NestFactory.createApplicationContext(PublishModule)——本切片 API 不监听端口,ADR-002)。 +// 与 source.module.ts / import.module.ts 同构薄壳:逻辑全在 publish.service.ts 的 +// 零装饰器独立函数里,本类只提供 DI 语境下的调用面;'reflect-metadata' 置顶 +// (CLI/测试直接 import publish.service.ts 纯函数文件,不经本模块,故无需提前加载 +// reflect-metadata)。 +import 'reflect-metadata' +import { Injectable, Module } from '@nestjs/common' +import { + publishSiteContent, + type PublishDb, + type PublishSiteContentInput, + type PublishSiteContentResult, +} from './publish.service.js' + +/** publishSiteContent 的 Nest 注入壳(standalone context / 后续 REST 通道复用同一实现) */ +@Injectable() +export class PublishService { + publish(db: PublishDb, input: PublishSiteContentInput): Promise { + return publishSiteContent(db, input) + } +} + +@Module({ + providers: [PublishService], + exports: [PublishService], +}) +export class PublishModule {} diff --git a/apps/api/src/modules/publish/publish.service.ts b/apps/api/src/modules/publish/publish.service.ts new file mode 100644 index 0000000..dc1a8af --- /dev/null +++ b/apps/api/src/modules/publish/publish.service.ts @@ -0,0 +1,345 @@ +// 显式发布服务(Plan 4 Task 1,V2 spec §8、计划前置事实 3/8): +// publish = entry_locales.published_revision_id ← current_revision_id(首次批量发布 = +// 导入产出的全部 current 条目)——修订行永不 UPDATE(ADR-010 冻结语义:构建只读 +// published,发布后导入再产新修订不影响已发布内容)。导入永不发布(Plan 3 保证, +// 本服务是发布指针的唯一写入方)。 +// +// 两段式纪律(显式发布):dry-run 只读(候选清单 + 汇总计数 + **随机 confirm token** +// 输出给操作者);confirm 携带 token 执行(无 token → CONFIRM_TOKEN_REQUIRED)。 +// token 不落库(无会话状态)——它的纪律价值是"操作者必须看过 dry-run 输出并复制 +// 其产物",不是密码学凭证;候选集在 dry-run 与 confirm 之间的变化由 confirm 期 +// 重查谓词自然体现(publish 数以实际 UPDATE 行数为准)。 +// +// 审计(前置事实 8①——本文件是全仓第一个 audit_logs 写入方):每次**实际发布** +// (publishedCount>0)落一行——action='content.publish'、actor_type='cli_user'、 +// actor_label=传入值(缺省 super_admin@cli,audit_logs.actor_label NOT NULL)、 +// before/after 摘要含条数。空清单 no-op 不落审计(无事实不记录)。 +// +// P0 门禁不做(前置事实 8②,**ADR-018 记偏离**):V1 publish 不读导入报告文件做 +// P0 阻断——import issue 无 DB 查询源,206 条真实现状下严格门禁会反堵发布; +// missing 媒体等已在 build/gate 层有真实防线。Plan 5 SEO diff 时重审。 +// +// site_config_revisions 首版:首次实际发布时站点无 config 修订 → 从 site 行生成 v1 +// (data={name,origin,locale,brand:{}}——主题消费的最小配置面,Task 2 快照 site.json +// 的输入);已有任何 config 修订则跳过(版本序列归后续配置编辑域);UNIQUE(site_id, +// version) + onConflictDoNothing 幂等(并发窗口撞约束即行已在)。 +// +// 本文件与 register/import 服务同纪律保持 **零装饰器**:CLI(scripts/sf.ts 经 tsx) +// 与集成测试直接 import 本文件;Nest 侧由 publish.module.ts 薄包装。 +// +// 事务边界:候选查询/计数在事务外(只读快照面);实际发布 = 单事务 +// (批量 UPDATE + config v1 + audit INSERT 全成或全回滚——审计与指针前移原子)。 +import { createHash, randomBytes } from 'node:crypto' +import { and, count, desc, eq, isNotNull, isNull, ne, or, sql } from 'drizzle-orm' +import type { NodePgDatabase } from 'drizzle-orm/node-postgres' +import { canonicalJson } from '../../common/canonical.js' +import { + auditLogs, + contentEntries, + entryLocales, + siteConfigRevisions, + sites, +} from '../../db/schema.js' +import type * as schema from '../../db/schema.js' + +export type PublishDb = NodePgDatabase + +export type PublishErrorCode = 'SITE_NOT_FOUND' | 'CONFIRM_TOKEN_REQUIRED' + +/** 发布失败(可预期错误):CLI 以 code 区分呈现;不裹程序 bug(那些原样上抛) */ +export class PublishError extends Error { + readonly code: PublishErrorCode + constructor(code: PublishErrorCode, message: string) { + super(message) + this.name = 'PublishError' + this.code = code + } +} + +/** actor 缺省标签(前置事实 8①:CLI 必传或缺省 super_admin@cli——不用模糊 system 顶替) */ +export const DEFAULT_ACTOR_LABEL = 'super_admin@cli' + +/** audit_logs 落值常量(command_id = CLI 命令名;action 遵域.动词命名) */ +const AUDIT_COMMAND_ID = 'content:publish' +const AUDIT_ACTION = 'content.publish' +const AUDIT_OBJECT_TYPE = 'site' + +/** 站点解析的投影面(config v1 生成的 site 行输入) */ +interface SiteRow { + id: string + key: string + name: string + defaultLocale: string + productionOrigin: string +} + +/** 发布候选(dry-run 清单与 confirm 载荷共用查询):current 非空且 published≠current 的 entry_locale */ +export interface PublishCandidate { + contentEntryId: string + locale: string + /** content_entries.type_key(清单的人类可读归属) */ + typeKey: string + slug: string + currentRevisionId: string + publishedRevisionId: string | null +} + +/** config 修订处置结果(dry-run 恒 null——零写入) */ +export interface SiteConfigRevisionOutcome { + /** true = 本次生成 v1;false = 已有修订跳过(version = 既有最新版本号) */ + created: boolean + version: number +} + +export interface PublishSiteContentInput { + siteKey: string + /** true = 只读清单 + token(零写入);false/缺省 = confirm 执行(须携带 confirmToken) */ + dryRun?: boolean + /** dry-run 产出的 token(confirm 必传——显式发布纪律) */ + confirmToken?: string + /** 审计 actor 标签;缺省 {@link DEFAULT_ACTOR_LABEL} */ + actor?: string +} + +export interface PublishSiteContentResult { + siteKey: string + siteId: string + dryRun: boolean + /** dry-run = 随机 token(输出给操作者);confirm = 实际传入值 */ + confirmToken: string | null + /** 候选清单(查询起点:current 非空且 published≠current) */ + candidates: PublishCandidate[] + /** 实际发布条数(dry-run 恒 0;confirm = 事务内 UPDATE 实际行数) */ + publishedCount: number + /** 已等条数(current 非空且 published=current——skip 语义的计数面) */ + upToDateCount: number + /** config 修订处置(dry-run / 空清单 no-op 恒 null) */ + siteConfigRevision: SiteConfigRevisionOutcome | null +} + +/** 随机 confirm token:pub- 前缀 + 24 位 hex(96 bit——同毫秒多次 dry-run 可区分) */ +function makeConfirmToken(): string { + return `pub-${randomBytes(12).toString('hex')}` +} + +/** 候选谓词(查询起点钉死):current 非空且(published 缺席或 ≠ current) */ +function pendingPublishPredicate(siteId: string) { + return and( + eq(entryLocales.siteId, siteId), + isNotNull(entryLocales.currentRevisionId), + or( + isNull(entryLocales.publishedRevisionId), + ne(entryLocales.publishedRevisionId, entryLocales.currentRevisionId), + ), + ) +} + +/** 已等谓词(skip 语义计数):current 非空且 published=current */ +function upToDatePredicate(siteId: string) { + return and( + eq(entryLocales.siteId, siteId), + isNotNull(entryLocales.currentRevisionId), + eq(entryLocales.publishedRevisionId, entryLocales.currentRevisionId), + ) +} + +/** 候选清单查询(entry_locales ⋈ content_entries;typeKey/slug 排序钉死清单呈现稳定) */ +async function queryCandidates(db: PublishDb, siteId: string): Promise { + const rows = await db + .select({ + contentEntryId: entryLocales.contentEntryId, + locale: entryLocales.locale, + typeKey: contentEntries.typeKey, + slug: entryLocales.slug, + currentRevisionId: entryLocales.currentRevisionId, + publishedRevisionId: entryLocales.publishedRevisionId, + }) + .from(entryLocales) + .innerJoin(contentEntries, eq(entryLocales.contentEntryId, contentEntries.id)) + .where(pendingPublishPredicate(siteId)) + .orderBy(contentEntries.typeKey, entryLocales.slug) + // currentRevisionId 非 null:谓词保证(drizzle 谓词不细化 select 类型) + return rows.map((r) => ({ + contentEntryId: r.contentEntryId, + locale: r.locale, + typeKey: r.typeKey, + slug: r.slug, + currentRevisionId: r.currentRevisionId!, + publishedRevisionId: r.publishedRevisionId, + })) +} + +/** 已等条数计数 */ +async function countUpToDate(db: PublishDb, siteId: string): Promise { + const rows = await db + .select({ n: count() }) + .from(entryLocales) + .where(upToDatePredicate(siteId)) + return Number(rows[0]!.n) +} + +/** + * config 修订首版 ensure(事务内):无任何修订 → INSERT v1(data 从 site 行派生、 + * checksum = canonicalJson sha256——与 register 快照哈希同法);已有 → 跳过。 + * onConflictDoNothing 收口并发撞 UNIQUE(site_id,version)(撞即行已在,重查回报既有版本)。 + */ +async function ensureSiteConfigRevision( + tx: Parameters[0]>[0], + site: SiteRow, +): Promise { + const existing = await tx + .select({ version: siteConfigRevisions.version }) + .from(siteConfigRevisions) + .where(eq(siteConfigRevisions.siteId, site.id)) + .orderBy(desc(siteConfigRevisions.version)) + .limit(1) + if (existing.length > 0) return { created: false, version: existing[0]!.version } + const data = { + name: site.name, + origin: site.productionOrigin, + locale: site.defaultLocale, + brand: {}, + } + const checksum = createHash('sha256').update(canonicalJson(data)).digest('hex') + const inserted = await tx + .insert(siteConfigRevisions) + .values({ siteId: site.id, version: 1, data, checksum }) + .onConflictDoNothing({ target: [siteConfigRevisions.siteId, siteConfigRevisions.version] }) + .returning({ version: siteConfigRevisions.version }) + if (inserted.length > 0) return { created: true, version: inserted[0]!.version } + // 并发撞唯一约束:行已在——重查回报既有版本 + const raced = await tx + .select({ version: siteConfigRevisions.version }) + .from(siteConfigRevisions) + .where(eq(siteConfigRevisions.siteId, site.id)) + .orderBy(desc(siteConfigRevisions.version)) + .limit(1) + return { created: false, version: raced[0]!.version } +} + +/** + * 显式发布站点内容(两段式:dry-run 清单 / confirm 执行)。 + * + * dry-run:候选清单 + 已等计数 + 随机 confirm token——**零写入**(不落 audit、不生成 + * config 修订)。confirm:单事务内批量 UPDATE(published ← current,实际行数为权威) + * + config v1 ensure + audit INSERT;空候选 = no-op(零写入、不落审计、不生成 config)。 + * 失败口径:站点不存在(SITE_NOT_FOUND)与缺 token(CONFIRM_TOKEN_REQUIRED)均发生在 + * 任何写入之前。 + */ +export async function publishSiteContent( + db: PublishDb, + input: PublishSiteContentInput, +): Promise { + const dryRun = input.dryRun ?? false + + // 站点解析(不自动建站——建站是 register 的职责) + const siteRows = await db + .select({ + id: sites.id, + key: sites.key, + name: sites.name, + defaultLocale: sites.defaultLocale, + productionOrigin: sites.productionOrigin, + }) + .from(sites) + .where(eq(sites.key, input.siteKey)) + .limit(1) + if (siteRows.length === 0) { + throw new PublishError('SITE_NOT_FOUND', `站点不存在:${input.siteKey}(先运行 sf source:register)`) + } + const site = siteRows[0] as SiteRow + + // 只读快照面(事务外):候选清单 + 已等计数——dry-run 与 confirm 共用 + const candidates = await queryCandidates(db, site.id) + const upToDateBefore = await countUpToDate(db, site.id) + + if (dryRun) { + return { + siteKey: site.key, + siteId: site.id, + dryRun: true, + confirmToken: makeConfirmToken(), + candidates, + publishedCount: 0, + upToDateCount: upToDateBefore, + siteConfigRevision: null, + } + } + + // confirm 纪律:无 token 不执行(显式发布的操作面门槛——先 dry-run 看清单) + if (input.confirmToken === undefined || input.confirmToken === '') { + throw new PublishError( + 'CONFIRM_TOKEN_REQUIRED', + `确认发布须携带 dry-run 输出的 confirm token(先运行 sf content:publish --site ${site.key} --dry-run)`, + ) + } + + // 空清单 no-op:零写入、不落审计、不生成 config("无事实不记录") + if (candidates.length === 0) { + return { + siteKey: site.key, + siteId: site.id, + dryRun: false, + confirmToken: input.confirmToken, + candidates, + publishedCount: 0, + upToDateCount: upToDateBefore, + siteConfigRevision: null, + } + } + + const actor = input.actor ?? DEFAULT_ACTOR_LABEL + let publishedCount = 0 + let upToDateAfter = upToDateBefore + let configOutcome: SiteConfigRevisionOutcome | null = null + await db.transaction(async (tx) => { + // 批量 UPDATE:published ← current(谓词重查——confirm 时刻的库内状态为权威, + // 实际行数可能少于 dry-run 清单【并发窗口】;单操作者 CLI 下两者一致) + const updated = await tx + .update(entryLocales) + .set({ publishedRevisionId: sql`${entryLocales.currentRevisionId}`, updatedAt: new Date() }) + .where(pendingPublishPredicate(site.id)) + .returning({ id: entryLocales.id }) + publishedCount = updated.length + + if (publishedCount > 0) { + // config v1 ensure(首次实际发布时) + configOutcome = await ensureSiteConfigRevision(tx, site) + upToDateAfter = upToDateBefore + publishedCount + // 审计:批量摘要(before/after 含条数)——追加式单行 + await tx.insert(auditLogs).values({ + siteId: site.id, + actorType: 'cli_user', + actorId: null, + actorLabel: actor, + commandId: AUDIT_COMMAND_ID, + action: AUDIT_ACTION, + objectType: AUDIT_OBJECT_TYPE, + objectId: site.id, + beforeValue: { + siteKey: site.key, + pendingPublish: candidates.length, + upToDate: upToDateBefore, + }, + afterValue: { + siteKey: site.key, + published: publishedCount, + upToDate: upToDateAfter, + siteConfigRevision: configOutcome, + }, + occurredAt: new Date(), + }) + } + }) + + return { + siteKey: site.key, + siteId: site.id, + dryRun: false, + confirmToken: input.confirmToken, + candidates, + publishedCount, + upToDateCount: upToDateAfter, + siteConfigRevision: configOutcome, + } +} diff --git a/scripts/sf.ts b/scripts/sf.ts index d8e288b..e860e64 100644 --- a/scripts/sf.ts +++ b/scripts/sf.ts @@ -1,12 +1,13 @@ #!/usr/bin/env node -// sf CLI(Plan 2 Task 7 + Plan 3 Task 1):commander 三命令—— +// sf CLI(Plan 2 Task 7 + Plan 3 Task 1 + Plan 4 Task 1):commander 四命令—— // - wxr:probe:只读诊断(任意可读路径;Task 8 直读 cms 用此通道),出 JSON/HTML 报告不入库; // - source:register:调 registerSource 登记 content-addressed 快照(路径白名单校验在服务内); -// - import:调 createImportJob 建 import_jobs 并推进生命周期(Task 6:默认编排管道——四家写入器汇流)。 +// - import:调 createImportJob 建 import_jobs 并推进生命周期(Task 6:默认编排管道——四家写入器汇流); +// - content:publish:调 publishSiteContent 显式发布(dry-run 清单+confirm token → --confirm 执行)。 // 执行机制(Plan 2 评审钉死):根 devDep tsx 直吃 TS 源码(含 apps/api 的 Nest 源码与 // packages 的装饰器/TS 源文件),@sf/* 经根/包 tsconfig paths 直连 src——无需预构建 dist。 -// 注意:CLI 只 import register.service.ts / import.service.ts(零装饰器纯函数文件), -// 不拉起 Nest 模块——反射元数据依赖由此避免;module 文件留给 standalone context。 +// 注意:CLI 只 import register.service.ts / import.service.ts / publish.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' @@ -19,6 +20,11 @@ import { createImportJob, type CreateImportJobInput, } from '../apps/api/src/modules/import/import.service.js' +import { + DEFAULT_ACTOR_LABEL, + PublishError, + publishSiteContent, +} from '../apps/api/src/modules/publish/publish.service.js' import { makeRunId, registerSource, @@ -222,8 +228,64 @@ program } }) +program + .command('content:publish') + .description('显式发布:entry_locales.published ← current(V2 spec §8;dry-run 出差异清单+confirm token,--confirm 执行;导入永不发布)') + .requiredOption('--site ', '站点 key') + .option('--dry-run', '只读展示将发布的条目清单(不带 --confirm 时的缺省行为——安全优先)') + .option('--confirm ', '确认执行(携带 dry-run 输出的 confirm token)') + .option('--actor