diff --git a/CHANGELOG.md b/CHANGELOG.md index 49dd5a6..ccb29c7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,47 @@ # Changelog +## 开发快照(首次发布入口:同步授权、目标权威基线与维护能力) + +### Added + +- **管理员在发布页签发同步授权(`POST /api/v1/library/authorizations`,`action=sync`)**: + 授权把「范围 + 目标快照 + 参数摘要 + 有效期」钉死,仍与执行分成两步 —— + 页面只提供入口,不会替你签发,也不会自动重试执行。工程师看得到缺项原因, + 但拿不到签发按钮(接口本来就只认管理员)。 +- **目标权威基线(迁移 075,作业类型 `baseline`)**:目标非空、又没有生效版本时, + 首次发布的真实解法是「维护隔离 → 整库捕获 → 逐类校验 → 登记基线」。 + `POST /api/v1/library/baselines` 只建作业;worker 用与同步同一套维护窗口 + (关入口、停写入者、证明排空)把目标整库捕获成归档快照,再用同一套 `Verify` + 证明目标与这份快照一致,最后登记 `production_effective`(证据写明 + `origin=target_baseline`、快照 ID、逐表行数、目标索引映射)。 + 它**不写目标内容、不清空目标、不手写版本**:没有捕获与校验证据就没有基线。 + 基线作业与 sync/restore 共用同一把写互斥(同一 scope 只允许一个在飞写作业), + worker 被杀时同一作业 id 会接管那个窗口继续,而不是把目标留在停机状态。 +- **维护能力(`GET /api/v1/library/maintenance` 的 `capability`)**:页面区分 + 「此刻没有维护窗口」与「维护能力没接线」,逐项列出缺少的 + `LIBRARY_TARGET_*`;接口没给能力信息时显示"未知",不再用一句 + "也没有维护能力配置"糊过去。 + +### Changed + +- **`productionScopeEmpty` 改成整库口径**:原来只数 `knowledges`,"文档删了但 + 分块/Wiki/资源还在"的目标会被判成空库,从而绕过基线初始化。现在用适配层 + 同一份表清单与范围过滤(`v071.ScopeContentEmpty`)逐表计数,并把逐表行数 + 交回接口,"空"只有一份定义。 +- **拟发布的测试封存只认版本登记(`kind=test_sealed`)**:归档里同时存在生产 + 预备份与目标基线快照,按"这个 scope 最新一份"挑会把目标自己的内容显示成 + 测试候选。现在候选来自登记、再用归档确认它还在本机;不在就说明原因。 +- **发布页不再说"生产同步执行链尚未接线"**:同步按钮按真实闸口启用, + 点击走 `POST /api/v1/library/syncs`;被拒时逐条显示接口返回的缺项。 +- 缺项文案从"生产当前权威版本未知,不能启动写入"改为指向真实入口: + 「目标非空且没有生效版本记录:先建立目标权威基线…或确认目标确实是空库」。 + +### Fixed + +- 维护卡片把"没有窗口记录"错误地写成"没有维护能力配置",与实际接线不符。 +- 首次发布时 `POST /library/baselines` 在目标读不到时**不建作业、不登记版本** + (拒绝用建基线掩盖连接/配置故障)。 + ## 开发快照(发布执行器可用性、整库还原入口与诊断) ### Added diff --git a/web/src/ReleaseTargets.test.tsx b/web/src/ReleaseTargets.test.tsx index 6e18f6d..b5394eb 100644 --- a/web/src/ReleaseTargets.test.tsx +++ b/web/src/ReleaseTargets.test.tsx @@ -92,7 +92,7 @@ const overview: LibraryOverview = { reasons: [ '生产写入闸口关闭(PRODUCTION_RELEASE_REQUESTS_ENABLED=false)', '没有已核验的生产恢复演练证据(PRODUCTION_BACKUP_RESTORE_VERIFIED=false)', - '生产当前权威版本未知,不能启动写入', + '目标非空且没有生效版本记录:先建立目标权威基线(维护隔离下捕获整库并逐类校验),或确认目标确实是空库', '本次目标快照还没有生产预备份', ], }, @@ -150,6 +150,8 @@ const base: ViewProps = { canCapture: true, canWrite: true, canRestore: false, + sync: { canSign: false, pendingGrant: false, onGrant: vi.fn(), canStart: false, pending: false, onStart: vi.fn() }, + baseline: { canStart: false, pending: false, onStart: vi.fn() }, restore: null, restoreNotice: null, pendingRestore: false, @@ -173,7 +175,7 @@ describe('ReleaseTargetsView(确认稿布局)', () => { expect(html).toContain('对外知识库') expect(html).toContain('role="tab"') // 只显示一个主作业卡:选中库标题出现一次,另一个库不渲染作业卡 - expect(html.match(/· 已封存,生产同步未接入/g)?.length).toBe(1) + expect(html.match(/· 已封存,同步按条件放行/g)?.length).toBe(1) expect(html).not.toContain('对外知识库 · 尚无') }) @@ -355,8 +357,92 @@ describe('ReleaseTargetsView(确认稿布局)', () => { }) it('无封存时给出“尚无封存”而不是空表', () => { - const html = render({ page: { ...page, snapshots: [] } }) + // 归档没有快照时接口也不会给 test_sealed:两处必须一致,否则页面会把 + // 一个不存在的封存显示成"已封存"。 + const html = render({ + page: { ...page, snapshots: [] }, + overview: { ...overview, test_sealed: undefined, test_sealed_reason: '还没有该目标的测试封存' }, + }) expect(html).toContain('尚无测试封存') expect(html).toContain('还没有测试库封存') }) + + it('管理员在缺同步授权时看到签发入口,工程师只看到“需管理员签发”', () => { + const admin = render({ + sync: { canSign: true, pendingGrant: false, onGrant: vi.fn(), canStart: false, pending: false, onStart: vi.fn() }, + }) + expect(admin).toContain('签发同步授权(30 分钟)') + // 授权入口不能顺手把执行也放行:闸口条件仍然逐条可见 + expect(admin).toContain('生产写入闸口关闭') + const engineer = render() + expect(engineer).not.toContain('签发同步授权') + expect(engineer).toContain('同步授权只能由管理员按目标+快照签发') + }) + + it('目标非空又没有权威版本时,管理员看到建立基线入口;有生效版本时不显示', () => { + const admin = render({ + baseline: { canStart: true, pending: false, onStart: vi.fn() }, + }) + expect(admin).toContain('建立目标权威基线(维护隔离)') + expect(admin).toContain('目标非空且没有生效版本记录') + const known = render({ + baseline: { canStart: true, pending: false, onStart: vi.fn() }, + overview: { + ...overview, + production: { + state: 'known', + version: { + id: 'v1', + kind: 'production_effective', + snapshot_id: 'a'.repeat(64), + version_label: '基线 abc', + source_job_id: 'libbase-1', + evidence: {}, + created_by: 'admin', + created_at: '2026-10-29T09:00:00Z', + }, + }, + }, + }) + // 有生效版本时不再提供基线入口(缺项说明文字里仍会提到它存在,所以只断言按钮) + expect(known).not.toMatch(/建立目标权威基线(维护隔离)<\/button>/) + }) + + it('闸口全满足时“启动同步”真的可点,并给出执行说明', () => { + const html = render({ + sync: { canSign: false, pendingGrant: false, onGrant: vi.fn(), canStart: true, pending: false, onStart: vi.fn() }, + overview: { ...overview, gate: { sync_allowed: true, reasons: [] } }, + }) + expect(html).toMatch(/]*class="rel-primary"[^>]*>▶ 启动同步/) + expect(html).not.toMatch(/class="rel-primary"[^>]*disabled/) + expect(html).toContain('先固定测试版本,再备份目标并写入') + }) + + it('维护卡片区分“未运行维护”与“没有维护能力”', () => { + const wired = render({ + maintenance: { + held: false, + capability: { + configured: true, + missing: [], + instance: 'kgseal-app', + project: 'kgseal-weknora', + writers: ['app'], + ingress_service: 'ingress', + }, + }, + }) + expect(wired).toContain('未运行维护') + expect(wired).toContain('维护能力已接线:实例 kgseal-app · 写入者 app') + expect(wired).not.toContain('也没有维护能力配置') + const broken = render({ + maintenance: { held: false, capability: { configured: false, missing: ['LIBRARY_TARGET_WRITERS'] } }, + }) + expect(broken).toContain('能力未接线') + expect(broken).toContain('维护能力未接线:缺少 LIBRARY_TARGET_WRITERS') + // 接口没给能力信息时不能猜成"没有能力" + const unknown = render({ maintenance: { held: false } }) + expect(unknown).toContain('维护能力状态未知') + expect(unknown).not.toContain('也没有维护能力配置') + }) }) diff --git a/web/src/ReleaseTargets.tsx b/web/src/ReleaseTargets.tsx index d663dcc..0988204 100644 --- a/web/src/ReleaseTargets.tsx +++ b/web/src/ReleaseTargets.tsx @@ -1,9 +1,12 @@ import { useCallback, useEffect, useState } from 'react' import { createLibraryRestoreAuthorization, + createLibrarySyncAuthorization, + enqueueLibraryBaseline, enqueueLibraryCapture, enqueueLibraryRecovery, enqueueLibraryRestore, + enqueueLibrarySync, fetchLibraryCaptures, fetchLibraryMaintenance, fetchLibraryOverview, @@ -62,6 +65,24 @@ const jobStateLabel: Record = { type Scope = 'internal' | 'external' +// 签发授权与执行是两步:页面只提供入口,不会替人签发,也不会自动重试执行。 +export interface ReleaseSyncActions { + // 管理员才能签发;工程师看得到条件但没有按钮。 + canSign: boolean + pendingGrant: boolean + onGrant: () => void + // 闸口全部满足才能启动(接口用同一份判定,按钮只是提前说明)。 + canStart: boolean + pending: boolean + onStart: () => void +} + +export interface ReleaseBaselineActions { + canStart: boolean + pending: boolean + onStart: () => void +} + interface ScopeView { scope: Scope snapshot?: LibrarySnapshot @@ -89,6 +110,10 @@ interface ViewProps { pendingCapture: boolean canCapture: boolean canWrite: boolean + // 发布动作:签发授权(管理员)与启动同步是两个独立步骤,页面绝不自动签发。 + sync: ReleaseSyncActions + // 目标非空又没有生效版本时,唯一入口是"建立目标权威基线"(管理员)。 + baseline: ReleaseBaselineActions // 按版本恢复是管理员动作:工程师看得到条件,但拿不到恢复入口。 canRestore: boolean restore: LibraryRestoreAvailability | null @@ -118,7 +143,9 @@ function scopeView( overview?.job ?? persisted.find((item) => item.state === 'pending' || item.state === 'running') ?? persisted[0] return { scope, - snapshot: history[0], + // 候选只认接口给的 test_sealed(来自版本登记):归档里还有生产预备份与 + // 目标基线快照,按"最新一份"挑会把目标自己的内容显示成测试封存。 + snapshot: overview?.test_sealed, history, activeJob: scoped.find((job) => ACTIVE_STATES.includes(job.state)), lastJob: scoped[0], @@ -271,6 +298,14 @@ export function MaintenanceCard({ }) { const summary = maintenanceSummary(maintenance, job) const record = maintenance?.instance_maintenance ?? maintenance?.maintenance + // "没有窗口记录"不等于"没有维护能力":能力是配置事实,接口单独返回。 + // 接口没给能力信息时也不能猜,明确写"未知"。 + const capability = maintenance?.capability + const capabilityText = !capability + ? '维护能力状态未知:接口没有返回能力信息,无法区分"未运行维护"与"能力未接线"' + : capability.configured + ? `维护能力已接线:实例 ${capability.instance || '—'} · 写入者 ${(capability.writers ?? []).join('、') || '—'}` + : `维护能力未接线:缺少 ${(capability.missing ?? []).join('、') || '必要配置'}` return (
@@ -278,13 +313,26 @@ export function MaintenanceCard({

目标维护

同步/恢复期间目标写入者被停住;级别与入口状态来自维护记录本身

- - {summary.held ? `${summary.level} · 占用中` : summary.level === '未接入' ? '未接入' : '未占用'} + + {summary.held + ? `${summary.level} · 占用中` + : record + ? '未占用' + : capability?.configured + ? '未运行维护' + : capability + ? '能力未接线' + : '能力未知'} - {summary.level === '未接入' ? ( + {!record ? (

- 暂无维护窗口记录,也没有维护能力配置;维护类操作保持 fail closed,不会在此状态下写目标。 + 暂无维护窗口记录。{capabilityText} + {capability?.configured + ? ';窗口只在同步或建立基线时开启,页面不会单独开窗口。' + : ';维护类操作保持 fail closed,不会在此状态下写目标。'}

) : ( <> @@ -534,15 +582,23 @@ function ProtectionCard({ prebackup, gate, authorization, + targetSealed, + sync, + baseline, onOpen, }: { production: LibraryOverview['production'] prebackup: LibraryOverview['prebackup'] gate: LibraryOverview['gate'] authorization: LibraryOverview['authorization'] + targetSealed: boolean + sync: ReleaseSyncActions + baseline: ReleaseBaselineActions onOpen: () => void }) { const backupKnown = prebackup.state === 'known' && prebackup.version + // 基线入口只在"目标非空、却没有生效版本"时才是答案;空库走首次同步(预备份+登记)。 + const baselineNeeded = production.state !== 'known' && targetSealed return (