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 }
}