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:
@@ -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 }
|
||||
}
|
||||
@@ -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 异新修订) */
|
||||
|
||||
@@ -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') */
|
||||
|
||||
@@ -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
@@ -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)}`)
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user