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
+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)}`)
}