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:
+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)}`)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user