feat(publish): 显式发布命令(dry-run/confirm/审计/站点配置首版/锚点helper)

- 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 <noreply@anthropic.com>
This commit is contained in:
浅念
2026-10-22 07:06:23 +08:00
parent a009f49961
commit c35f10835a
8 changed files with 892 additions and 66 deletions
+71
View File
@@ -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<LinkedSourceRecord | null> {
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 }
}
+4 -23
View File
@@ -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 异新修订) */
+5 -19
View File
@@ -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<string | null> {
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') */
+5 -19
View File
@@ -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<string | null> {
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(顺序无关性) */
@@ -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<PublishSiteContentResult> {
return publishSiteContent(db, input)
}
}
@Module({
providers: [PublishService],
exports: [PublishService],
})
export class PublishModule {}
@@ -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<typeof schema>
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<PublishCandidate[]> {
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<number> {
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<Parameters<PublishDb['transaction']>[0]>[0],
site: SiteRow,
): Promise<SiteConfigRevisionOutcome> {
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<PublishSiteContentResult> {
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,
}
}
+69 -5
View File
@@ -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 <token> 执行;导入永不发布)')
.requiredOption('--site <key>', '站点 key')
.option('--dry-run', '只读展示将发布的条目清单(不带 --confirm 时的缺省行为——安全优先)')
.option('--confirm <token>', '确认执行(携带 dry-run 输出的 confirm token)')
.option('--actor <label>', `审计 actor 标签(audit_logs.actor_label,缺省 ${DEFAULT_ACTOR_LABEL})`, DEFAULT_ACTOR_LABEL)
.action(async (opts: { site: string; dryRun?: boolean; confirm?: string; actor: string }) => {
// 缺省 dry-run(安全优先):不带 --confirm 永不落发布写入;--dry-run 与 --confirm
// 同时给出时仍按 dry-run 执行(提示操作者矛盾输入)
const dryRun = opts.confirm === undefined
if (!dryRun && opts.dryRun === true) {
console.error(' 注意:--dry-run 与 --confirm 同时给出——按 dry-run 执行(安全优先)')
}
// getDb 走 DATABASE_URL_TEST ?? DATABASE_URL 回退链——临时库冒烟只需 env 覆盖
const db = getDb()
// exactOptionalPropertyTypes:缺席键不传 undefined 值(与 source:register 同款)
const input = {
siteKey: opts.site,
dryRun,
actor: opts.actor,
...(opts.confirm !== undefined ? { confirmToken: opts.confirm } : {}),
}
try {
const result = await publishSiteContent(db, input)
if (result.dryRun) {
console.log(
`[content:publish] site=${opts.site} dry-run:待发布 ${result.candidates.length} 条(已等 ${result.upToDateCount} 条,零写入)`,
)
for (const c of result.candidates) {
console.log(
` ${c.typeKey}/${c.slug} [${c.locale}] ${c.currentRevisionId.slice(0, 8)}… → ${c.publishedRevisionId === null ? 'NULL' : `${c.publishedRevisionId.slice(0, 8)}…`}`,
)
}
console.log(` confirm token: ${result.confirmToken}`)
console.log(` 执行发布:pnpm sf content:publish --site ${opts.site} --confirm ${result.confirmToken}`)
} else {
console.log(
`[content:publish] site=${opts.site} 发布完成:published=${result.publishedCount}(已等 ${result.upToDateCount} 条)`,
)
if (result.publishedCount === 0) {
console.log(' 无待发布条目(no-op)')
} else {
console.log(` audit: action=content.publish actor=${opts.actor}`)
if (result.siteConfigRevision !== null) {
console.log(
` site_config_revisions: v${result.siteConfigRevision.version}${result.siteConfigRevision.created ? '(首版生成)' : '(已有,跳过)'}`,
)
}
}
}
} finally {
await (db as { $client: { end: () => Promise<void> } }).$client.end()
}
})
// parseAsync:action 内抛错走此 catch(commander 默认会 process.exit(1) 并打印堆栈,
// 这里收敛为单行错误信息 + exitCode——SourceRegisterError/ImportError 打 code,其余原样呈现)
// 这里收敛为单行错误信息 + exitCode——SourceRegisterError/ImportError/PublishError 打 code,其余原样呈现)
program.parseAsync(process.argv).catch((err: unknown) => {
if (err instanceof SourceRegisterError) {
console.error(`[sf] ${err.code}: ${err.message}`)
@@ -232,6 +294,8 @@ program.parseAsync(process.argv).catch((err: unknown) => {
}
} else if (err instanceof ImportError) {
console.error(`[sf] ${err.code}: ${err.message}`)
} else if (err instanceof PublishError) {
console.error(`[sf] ${err.code}: ${err.message}`)
} else {
console.error(`[sf] ${err instanceof Error ? err.message : String(err)}`)
}
+365
View File
@@ -0,0 +1,365 @@
// Plan 4 Task 1 失败测试(先于实现):content:publish 显式发布(V2 spec §8、前置事实 3/8)。
// 覆盖契约(对照计划 Task 1):
// 1. dry-run:mini 全链(register→import)后 3 条候选清单(typeKey/slug/修订对 current→NULL)
// + 汇总计数(待发布/已等)+ confirm token(随机、两次 dry-run 不同)+ **零写入**
// (published 全 NULL、无 audit_logs 行、无 site_config_revisions 行);
// 2. confirm(token 取自 dry-run 输出):3 条 published_revision_id=current_revision_id +
// audit_logs 行(action='content.publish'、actor_type='cli_user'、actor_label=传入值、
// command_id='content:publish'、before/after 摘要含条数)+ 返回发布计数;
// 3. 幂等:重复 confirm(已全等)→ 0 条变更 + skip 语义(candidates 空、upToDate=3)
// + 无新增 audit 行(no-op 不落审计);
// 4. site_config_revisions 首版:首次 publish 从 site 行生成 v1
// (data={name,origin,locale,brand:{}}、checksum=canonicalJson 哈希同法)+
// UNIQUE(site_id,version) 幂等(二次发布不建 v2);已有 config 修订则跳过(created=false);
// 5. actor:传入生效落 audit_logs.actor_label;缺省 super_admin@cli(前置事实 8①);
// 6. 无候选条目:dry-run 空清单不报错;confirm 空清单 no-op(零写入零审计);
// 未导入站点 → SITE_NOT_FOUND(dry-run 与 confirm 双通道)。
// 运行于 globalSetup 临时库(ADR-015);storage/报告根 os.tmpdir,finally 清理。
// P0 门禁不做(前置事实 8②,ADR-018 记偏离)——本测试面不含报告文件阻断语义。
import { createHash } from 'node:crypto'
import { mkdtemp, rm } 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 { canonicalJson } from '../../apps/api/src/common/canonical.js'
import { createImportJob } 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 { 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')
/** 本文件写入的行清场:audit_logs(不可变触发器开关)+ config 修订 + import 全链反序 */
async function cleanupSiteRows(db: Db, siteId: string): Promise<void> {
await withImmutableCleanup(db, 'audit_logs', 'audit_logs_immutable', `site_id = '${siteId}'`)
await db.execute(`delete from site_config_revisions where site_id = '${siteId}'`)
await db.execute(`delete from route_registry where site_id = '${siteId}'`)
await db.execute(`delete from media_usages where site_id = '${siteId}'`)
await db.execute(`delete from media_assets where site_id = '${siteId}'`)
await db.execute(`delete from content_terms where site_id = '${siteId}'`)
await db.execute(`delete from terms where site_id = '${siteId}'`)
await db.execute(`delete from taxonomies where site_id = '${siteId}'`)
await db.execute(`delete from source_records where site_id = '${siteId}'`)
await db.execute(`delete from entry_locales where site_id = '${siteId}'`)
await withImmutableCleanup(db, 'content_revisions', 'content_revisions_immutable', `site_id = '${siteId}'`)
await db.execute(`delete from content_entries where site_id = '${siteId}'`)
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}'`)
}
interface Work {
work: string
storageRoot: string
reportRoot: string
allowedRoots: string[]
}
async function makeWork(prefix: string): Promise<Work> {
const work = await mkdtemp(join(tmpdir(), `${prefix}-`))
return {
work,
storageRoot: join(work, 'storage'),
reportRoot: join(work, 'artifacts', 'import'),
allowedRoots: [FIXTURES_ROOT, work],
}
}
/** mini 全链(register wxr → import):3 内容条目全 current 未发布(无媒体快照——attachment 301 目标缺席 issues 属预期) */
async function registerAndImport(db: Db, w: Work): Promise<{ key: string; siteId: string }> {
const key = `sfpub-${rand()}`
const reg = await registerSource(db, {
siteKey: key,
wxrPath: WXR_FIXTURE,
allowedRoots: w.allowedRoots,
storageRoot: w.storageRoot,
reportRoot: join(w.work, 'artifacts', 'register'),
})
const result = await createImportJob(db, { siteKey: key, reportRoot: w.reportRoot })
expect(result.status).toBe('succeeded_with_issues')
return { key, siteId: reg.siteId }
}
/** 单值计数便捷 */
async function countOf(db: Db, sql: string): Promise<number> {
const rows = (await db.execute(sql)) as any
return Number(rows[0].n)
}
describe('content:publish(显式发布)', () => {
it('dry-run:3 条候选清单(typeKey/slug/修订对)+ 计数 + confirm token;零写入', async () => {
await withTestDb(async (db) => {
const w = await makeWork('sf-pub-dry')
try {
const { key, siteId } = await registerAndImport(db, w)
try {
const dry = await publishSiteContent(db, { siteKey: key, dryRun: true })
expect(dry.dryRun).toBe(true)
expect(dry.publishedCount).toBe(0)
expect(dry.upToDateCount).toBe(0)
// confirm token:形状(pub- 前缀随机 hex)+ 两次 dry-run 不同(随机性)
expect(dry.confirmToken).toMatch(/^pub-[0-9a-f]{24}$/)
const dry2 = await publishSiteContent(db, { siteKey: key, dryRun: true })
expect(dry2.confirmToken).toMatch(/^pub-[0-9a-f]{24}$/)
expect(dry2.confirmToken).not.toBe(dry.confirmToken)
// 候选清单:mini 3 条(typeKey/slug 排序钉死)——修订对 current→NULL
expect(dry.candidates).toHaveLength(3)
expect(
dry.candidates.map((c) => [c.typeKey, c.slug, c.locale, c.publishedRevisionId]),
).toStrictEqual([
['basic_page', 'about-us', 'en', null],
['news_article', 'fixture-news-a', 'en', null],
['product', 'fixture-product', 'en', null],
])
for (const c of dry.candidates) {
expect(c.currentRevisionId).toMatch(/^[0-9a-f-]{36}$/)
// current 修订真实存在于该 entry(清单不是凭空构造)
expect(
await countOf(
db,
`select count(*)::int as n from content_revisions where site_id = '${siteId}' and id = '${c.currentRevisionId}' and content_entry_id = '${c.contentEntryId}'`,
),
).toBe(1)
}
// 零写入:published 全 NULL + 无 audit 行 + 无 config 修订
expect(
await countOf(
db,
`select count(*)::int as n from entry_locales where site_id = '${siteId}' and published_revision_id is not null`,
),
).toBe(0)
expect(await countOf(db, `select count(*)::int as n from audit_logs where site_id = '${siteId}'`)).toBe(0)
expect(await countOf(db, `select count(*)::int as n from site_config_revisions where site_id = '${siteId}'`)).toBe(0)
} finally {
await cleanupSiteRows(db, siteId)
}
} finally {
await rm(w.work, { recursive: true, force: true })
}
})
})
it('confirm:3 条 published=current + audit_logs 行(action/actor/before-after 摘要含条数)+ 发布计数;config v1 首版生成', async () => {
await withTestDb(async (db) => {
const w = await makeWork('sf-pub-confirm')
try {
const { key, siteId } = await registerAndImport(db, w)
try {
const dry = await publishSiteContent(db, { siteKey: key, dryRun: true })
const run = await publishSiteContent(db, {
siteKey: key,
confirmToken: dry.confirmToken!,
actor: 'boss@corp',
})
expect(run.dryRun).toBe(false)
expect(run.publishedCount).toBe(3)
expect(run.candidates).toHaveLength(3)
expect(run.upToDateCount).toBe(3)
expect(run.confirmToken).toBe(dry.confirmToken)
// DB:全站 3 条 published_revision_id = current_revision_id(修订指针冻结语义)
const eq = await countOf(
db,
`select count(*)::int as n from entry_locales where site_id = '${siteId}' and published_revision_id = current_revision_id`,
)
expect(eq).toBe(3)
// audit:恰一行,字段面(前置事实 8①——actor_label NOT NULL,本任务是全仓第一个审计写入方)
const audit = (await db.execute(`select actor_type, actor_label, command_id, action,
object_type, object_id, before_value, after_value from audit_logs where site_id = '${siteId}'`)) as any
expect(audit).toHaveLength(1)
expect(audit[0].action).toBe('content.publish')
expect(audit[0].actor_type).toBe('cli_user')
expect(audit[0].actor_label).toBe('boss@corp')
expect(audit[0].command_id).toBe('content:publish')
expect(audit[0].object_id).toBe(siteId)
// before/after 摘要含条数
expect(Number(audit[0].before_value.pendingPublish)).toBe(3)
expect(Number(audit[0].before_value.upToDate)).toBe(0)
expect(Number(audit[0].after_value.published)).toBe(3)
expect(Number(audit[0].after_value.upToDate)).toBe(3)
// config v1:data={name,origin,locale,brand:{}}(站点行派生)+ checksum=canonicalJson 哈希同法
const config = (await db.execute(`select version, data, checksum from site_config_revisions
where site_id = '${siteId}'`)) as any
expect(config).toHaveLength(1)
expect(config[0].version).toBe(1)
expect(config[0].data).toStrictEqual({
name: key,
origin: `https://${key}.example`,
locale: 'en',
brand: {},
})
expect(config[0].checksum).toBe(createHash('sha256').update(canonicalJson(config[0].data)).digest('hex'))
expect(run.siteConfigRevision).toStrictEqual({ created: true, version: 1 })
} finally {
await cleanupSiteRows(db, siteId)
}
} finally {
await rm(w.work, { recursive: true, force: true })
}
})
})
it('幂等:重复 confirm(已全等)→ 0 条变更 + skip 语义(空候选、upToDate=3)+ 无新增 audit/config 行', async () => {
await withTestDb(async (db) => {
const w = await makeWork('sf-pub-idem')
try {
const { key, siteId } = await registerAndImport(db, w)
try {
const dry = await publishSiteContent(db, { siteKey: key, dryRun: true })
const first = await publishSiteContent(db, { siteKey: key, confirmToken: dry.confirmToken! })
expect(first.publishedCount).toBe(3)
// 重复发布:候选为空(published=current 已等)→ no-op
const again = await publishSiteContent(db, { siteKey: key, confirmToken: dry.confirmToken! })
expect(again.publishedCount).toBe(0)
expect(again.candidates).toHaveLength(0)
expect(again.upToDateCount).toBe(3)
expect(again.siteConfigRevision).toBeNull()
// 零新增写入:audit 仍 1 行、config 仍 1 行(UNIQUE(site_id,version) 幂等)
expect(await countOf(db, `select count(*)::int as n from audit_logs where site_id = '${siteId}'`)).toBe(1)
expect(await countOf(db, `select count(*)::int as n from site_config_revisions where site_id = '${siteId}'`)).toBe(1)
} finally {
await cleanupSiteRows(db, siteId)
}
} finally {
await rm(w.work, { recursive: true, force: true })
}
})
})
it('config 既有跳过:publish 前已有 config 修订 → 不新建(created=false,行数不变)', async () => {
await withTestDb(async (db) => {
const w = await makeWork('sf-pub-skip')
try {
const { key, siteId } = await registerAndImport(db, w)
try {
await db.execute(`insert into site_config_revisions (id, site_id, version, data, checksum)
values (gen_random_uuid(), '${siteId}', 4, '{"seed":true}', 'seed')`)
const run = await publishSiteContent(db, { siteKey: key, confirmToken: 'pub-000000000000000000000000' })
expect(run.publishedCount).toBe(3)
expect(run.siteConfigRevision).toStrictEqual({ created: false, version: 4 })
// 行数不变:seed 行原样(不建 v1/v5、不改 seed)——投影转原生
//(postgres.js 原型差异会让 toStrictEqual 报"无视觉差异"失败,import-full 同款)
const rows = (await db.execute(`select version, data from site_config_revisions
where site_id = '${siteId}' order by version`)) as any
expect(
rows.map((r: any) => ({ version: Number(r.version), data: JSON.parse(JSON.stringify(r.data)) })),
).toStrictEqual([{ version: 4, data: { seed: true } }])
} finally {
await cleanupSiteRows(db, siteId)
}
} finally {
await rm(w.work, { recursive: true, force: true })
}
})
})
it('actor:传入生效;缺省 super_admin@cli(前置事实 8①)', async () => {
await withTestDb(async (db) => {
const w = await makeWork('sf-pub-actor')
try {
const { key, siteId } = await registerAndImport(db, w)
try {
const run = await publishSiteContent(db, { siteKey: key, confirmToken: 'pub-111111111111111111111111' })
expect(run.publishedCount).toBe(3)
const audit = (await db.execute(`select actor_label from audit_logs where site_id = '${siteId}'`)) as any
expect(audit).toHaveLength(1)
expect(audit[0].actor_label).toBe(DEFAULT_ACTOR_LABEL)
expect(audit[0].actor_label).toBe('super_admin@cli')
} finally {
await cleanupSiteRows(db, siteId)
}
} finally {
await rm(w.work, { recursive: true, force: true })
}
})
})
it('无候选/未导入站点:dry-run 空清单不报错;confirm 空清单 no-op;SITE_NOT_FOUND 双通道', async () => {
await withTestDb(async (db) => {
const w = await makeWork('sf-pub-empty')
try {
const { key, siteId } = await registerAndImport(db, w)
try {
// 发布到全等
const dry = await publishSiteContent(db, { siteKey: key, dryRun: true })
await publishSiteContent(db, { siteKey: key, confirmToken: dry.confirmToken! })
// 二次 dry-run:空清单不报错
const dryEmpty = await publishSiteContent(db, { siteKey: key, dryRun: true })
expect(dryEmpty.candidates).toHaveLength(0)
expect(dryEmpty.publishedCount).toBe(0)
expect(dryEmpty.upToDateCount).toBe(3)
expect(dryEmpty.confirmToken).toMatch(/^pub-[0-9a-f]{24}$/)
// confirm 空清单:no-op(零新增 audit、零写入)
const noop = await publishSiteContent(db, { siteKey: key, confirmToken: dry.confirmToken! })
expect(noop.publishedCount).toBe(0)
expect(await countOf(db, `select count(*)::int as n from audit_logs where site_id = '${siteId}'`)).toBe(1)
} finally {
await cleanupSiteRows(db, siteId)
}
// 未导入站点(不存在):dry-run 与 confirm 双通道 SITE_NOT_FOUND
for (const input of [
{ siteKey: 'no-such-site', dryRun: true },
{ siteKey: 'no-such-site', confirmToken: 'pub-222222222222222222222222' },
]) {
const err = await publishSiteContent(db, input).then(
() => {
throw new Error('不应成功')
},
(e: unknown) => e,
)
expect(err).toBeInstanceOf(PublishError)
expect((err as PublishError).code).toBe('SITE_NOT_FOUND')
}
} finally {
await rm(w.work, { recursive: true, force: true })
}
})
})
it('confirm 缺 token → CONFIRM_TOKEN_REQUIRED(显式发布纪律:无 token 不执行)', async () => {
await withTestDb(async (db) => {
const w = await makeWork('sf-pub-notoken')
try {
const { key, siteId } = await registerAndImport(db, w)
try {
const err = await publishSiteContent(db, { siteKey: key }).then(
() => {
throw new Error('不应成功')
},
(e: unknown) => e,
)
expect(err).toBeInstanceOf(PublishError)
expect((err as PublishError).code).toBe('CONFIRM_TOKEN_REQUIRED')
// 零写入(拒绝发生在任何 UPDATE 之前)
expect(
await countOf(
db,
`select count(*)::int as n from entry_locales where site_id = '${siteId}' and published_revision_id is not null`,
),
).toBe(0)
expect(await countOf(db, `select count(*)::int as n from audit_logs where site_id = '${siteId}'`)).toBe(0)
} finally {
await cleanupSiteRows(db, siteId)
}
} finally {
await rm(w.work, { recursive: true, force: true })
}
})
})
})