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,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user