发布页原来只有一个永远 disabled 的"启动同步",title 还写着"生产同步执行链 尚未接线"——执行链其实已经存在。现在: - 管理员看到"签发同步授权(30 分钟)"(action=sync,绑定范围+目标快照), 签发与执行仍是两步,不会自动签发; - 闸口全满足时"启动同步"真的按 POST /library/syncs 投递,被拒时逐条显示 接口返回的缺项; - 目标非空又无生效版本时,管理员看到"建立目标权威基线(维护隔离)"; - 测试封存候选改用接口给的 test_sealed(来自版本登记),不再拿归档里最新 一份顶替; - 维护卡片区分"未运行维护 / 维护能力未接线 / 能力未知",逐项列出缺的变量。 验证:vitest 99 个文件 469 个用例全绿,tsc --noEmit 无错;反向自检(把按钮 改回恒 disabled、把维护文案改回旧句)两处按预期变红后复原。
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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(/<button[^>]*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('也没有维护能力配置')
|
||||
})
|
||||
})
|
||||
|
||||
+166
-10
@@ -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<string, string> = {
|
||||
|
||||
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 (
|
||||
<div className="rel-panel rel-maintenance">
|
||||
<header className="rel-panel-head">
|
||||
@@ -278,13 +313,26 @@ export function MaintenanceCard({
|
||||
<h2>目标维护</h2>
|
||||
<p>同步/恢复期间目标写入者被停住;级别与入口状态来自维护记录本身</p>
|
||||
</div>
|
||||
<span className={`rel-pill ${summary.held ? 'blue' : summary.level === '未接入' ? 'gray' : 'green'}`}>
|
||||
{summary.held ? `${summary.level} · 占用中` : summary.level === '未接入' ? '未接入' : '未占用'}
|
||||
<span
|
||||
className={`rel-pill ${summary.held ? 'blue' : record ? 'green' : capability?.configured ? 'gray' : 'red'}`}
|
||||
>
|
||||
{summary.held
|
||||
? `${summary.level} · 占用中`
|
||||
: record
|
||||
? '未占用'
|
||||
: capability?.configured
|
||||
? '未运行维护'
|
||||
: capability
|
||||
? '能力未接线'
|
||||
: '能力未知'}
|
||||
</span>
|
||||
</header>
|
||||
{summary.level === '未接入' ? (
|
||||
{!record ? (
|
||||
<p className="rel-scope-note">
|
||||
暂无维护窗口记录,也没有维护能力配置;维护类操作保持 fail closed,不会在此状态下写目标。
|
||||
暂无维护窗口记录。{capabilityText}
|
||||
{capability?.configured
|
||||
? ';窗口只在同步或建立基线时开启,页面不会单独开窗口。'
|
||||
: ';维护类操作保持 fail closed,不会在此状态下写目标。'}
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
@@ -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 (
|
||||
<aside className="rel-panel rel-protection">
|
||||
<header className="rel-panel-head">
|
||||
@@ -580,6 +636,21 @@ function ProtectionCard({
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
<div className="rel-drawer-actions">
|
||||
{sync.canSign && authorization.state !== 'active' && targetSealed ? (
|
||||
<button type="button" className="rel-secondary" onClick={sync.onGrant} disabled={sync.pendingGrant}>
|
||||
{sync.pendingGrant ? '签发中…' : '签发同步授权(30 分钟)'}
|
||||
</button>
|
||||
) : null}
|
||||
{baseline.canStart && baselineNeeded ? (
|
||||
<button type="button" className="rel-secondary" onClick={baseline.onStart} disabled={baseline.pending}>
|
||||
{baseline.pending ? '投递中…' : '建立目标权威基线(维护隔离)'}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
{authorization.state !== 'active' && !sync.canSign ? (
|
||||
<p className="rel-hint">同步授权只能由管理员按目标+快照签发;工程师看不到签发入口。</p>
|
||||
) : null}
|
||||
<p className="rel-hint">同步启动后,这里显示本次生产恢复点及备份进度,可直接查看对应快照。</p>
|
||||
<button type="button" className="rel-secondary" onClick={onOpen}>
|
||||
查看已有版本与快照 ›
|
||||
@@ -601,6 +672,8 @@ interface ReadyProps {
|
||||
pendingCapture: boolean
|
||||
onCapture: () => void
|
||||
onOpen: () => void
|
||||
sync: ReleaseSyncActions
|
||||
baseline: ReleaseBaselineActions
|
||||
}
|
||||
|
||||
function ReadyBody({
|
||||
@@ -610,6 +683,8 @@ function ReadyBody({
|
||||
prebackup,
|
||||
gate,
|
||||
authorization,
|
||||
sync,
|
||||
baseline,
|
||||
canCapture,
|
||||
canWrite,
|
||||
pendingCapture,
|
||||
@@ -626,6 +701,9 @@ function ReadyBody({
|
||||
prebackup={prebackup}
|
||||
gate={gate}
|
||||
authorization={authorization}
|
||||
targetSealed={Boolean(snapshot)}
|
||||
sync={sync}
|
||||
baseline={baseline}
|
||||
onOpen={onOpen}
|
||||
/>
|
||||
</div>
|
||||
@@ -1127,6 +1205,8 @@ export function ReleaseTargetsView(props: ViewProps) {
|
||||
pendingCapture,
|
||||
canCapture,
|
||||
canWrite,
|
||||
sync,
|
||||
baseline,
|
||||
canRestore,
|
||||
restore,
|
||||
restoreNotice,
|
||||
@@ -1212,7 +1292,7 @@ export function ReleaseTargetsView(props: ViewProps) {
|
||||
<div className="rel-panel rel-jobhead">
|
||||
<div className="rel-between">
|
||||
<h2>
|
||||
{scopeName(scope)} · {active ? '封存进行中' : view.snapshot ? '已封存,生产同步未接入' : '尚无测试封存'}
|
||||
{scopeName(scope)} · {active ? '封存进行中' : view.snapshot ? '已封存,同步按条件放行' : '尚无测试封存'}
|
||||
</h2>
|
||||
<div className="rel-head-actions">
|
||||
<button type="button" className="rel-secondary" onClick={() => onDrawer(true)}>
|
||||
@@ -1221,14 +1301,15 @@ export function ReleaseTargetsView(props: ViewProps) {
|
||||
<button
|
||||
type="button"
|
||||
className="rel-primary"
|
||||
disabled={!gate.sync_allowed}
|
||||
onClick={sync.onStart}
|
||||
disabled={!sync.canStart || sync.pending}
|
||||
title={
|
||||
gate.sync_allowed
|
||||
? '生产同步执行链尚未接线,按钮不会启用'
|
||||
sync.canStart
|
||||
? '按已签发的同步授权启动:先固定测试版本,再备份目标并写入'
|
||||
: `暂不能启动同步:${gate.reasons.join(';')}`
|
||||
}
|
||||
>
|
||||
▶ 启动同步
|
||||
{sync.pending ? '投递中…' : '▶ 启动同步'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1311,6 +1392,8 @@ export function ReleaseTargetsView(props: ViewProps) {
|
||||
prebackup={prebackup}
|
||||
gate={gate}
|
||||
authorization={authorization}
|
||||
sync={sync}
|
||||
baseline={baseline}
|
||||
canCapture={canCapture}
|
||||
canWrite={canWrite}
|
||||
pendingCapture={pendingCapture}
|
||||
@@ -1409,7 +1492,67 @@ export function ReleaseTargets() {
|
||||
const canWrite = role === 'engineer' || role === 'admin'
|
||||
// 恢复写的是整套生产知识状态,接口只认管理员;页面必须与它一致。
|
||||
const canRestore = role === 'admin'
|
||||
// 签发同步授权同样是管理员动作:授权=钉死目标+快照+参数,不等于执行。
|
||||
const canSignSync = role === 'admin'
|
||||
const canCapture = canWrite && page?.capture_available === true
|
||||
const [pendingSyncGrant, setPendingSyncGrant] = useState(false)
|
||||
const [pendingSync, setPendingSync] = useState(false)
|
||||
const [pendingBaseline, setPendingBaseline] = useState(false)
|
||||
const current = overviews[scope]
|
||||
const targetSnapshot = current?.test_sealed?.snapshot_id ?? ''
|
||||
|
||||
const grantSync = async () => {
|
||||
if (!targetSnapshot) {
|
||||
setNotice('还没有可发布的测试封存:先封存测试版本,再签发同步授权')
|
||||
return
|
||||
}
|
||||
setPendingSyncGrant(true)
|
||||
setNotice(null)
|
||||
try {
|
||||
const grant = await createLibrarySyncAuthorization(scope, targetSnapshot)
|
||||
setNotice(
|
||||
`已签发同步授权${grant.authorized_by ? `(${grant.authorized_by})` : ''},有效至 ${stamp(grant.expires_at)};` +
|
||||
'执行仍需在下一次操作里点"启动同步"'
|
||||
)
|
||||
await load()
|
||||
} catch (grantError) {
|
||||
setNotice(grantError instanceof Error ? grantError.message : '签发同步授权失败')
|
||||
} finally {
|
||||
setPendingSyncGrant(false)
|
||||
}
|
||||
}
|
||||
|
||||
const startSync = async () => {
|
||||
if (!targetSnapshot) {
|
||||
setNotice('还没有可发布的测试封存')
|
||||
return
|
||||
}
|
||||
setPendingSync(true)
|
||||
setNotice(null)
|
||||
try {
|
||||
const accepted = await enqueueLibrarySync(scope, targetSnapshot)
|
||||
setNotice(`已投递同步:${accepted.job_id}(先固定测试版本并生成预备份,再写目标)`)
|
||||
await load()
|
||||
} catch (syncError) {
|
||||
setNotice(syncError instanceof Error ? syncError.message : '启动同步失败')
|
||||
} finally {
|
||||
setPendingSync(false)
|
||||
}
|
||||
}
|
||||
|
||||
const startBaseline = async () => {
|
||||
setPendingBaseline(true)
|
||||
setNotice(null)
|
||||
try {
|
||||
const accepted = await enqueueLibraryBaseline(scope)
|
||||
setNotice(`已投递建立基线:${accepted.job_id}(维护隔离下捕获目标整库,逐类校验后登记为权威基线)`)
|
||||
await load()
|
||||
} catch (baselineError) {
|
||||
setNotice(baselineError instanceof Error ? baselineError.message : '建立目标权威基线失败')
|
||||
} finally {
|
||||
setPendingBaseline(false)
|
||||
}
|
||||
}
|
||||
|
||||
// 恢复条件随"展开的那份快照"变化:闸口、该快照的授权、实例是否在维护都由接口判定。
|
||||
const [restore, setRestore] = useState<LibraryRestoreAvailability | null>(null)
|
||||
@@ -1513,6 +1656,19 @@ export function ReleaseTargets() {
|
||||
pendingCapture={pendingCapture}
|
||||
canCapture={canCapture}
|
||||
canWrite={canWrite}
|
||||
sync={{
|
||||
canSign: canSignSync,
|
||||
pendingGrant: pendingSyncGrant,
|
||||
onGrant: () => void grantSync(),
|
||||
canStart: current?.gate.sync_allowed === true,
|
||||
pending: pendingSync,
|
||||
onStart: () => void startSync(),
|
||||
}}
|
||||
baseline={{
|
||||
canStart: canSignSync,
|
||||
pending: pendingBaseline,
|
||||
onStart: () => void startBaseline(),
|
||||
}}
|
||||
canRestore={canRestore}
|
||||
restore={restore}
|
||||
restoreNotice={restoreNotice}
|
||||
|
||||
@@ -255,6 +255,75 @@ export interface LibraryRestoreGrant {
|
||||
expires_at: string
|
||||
}
|
||||
|
||||
// 同步授权与执行是两步:授权把"范围 + 目标快照 + 参数摘要"钉死并给很短的有效期。
|
||||
// 它不能顶替恢复授权(摘要不同),也不自动签发 —— 必须由管理员显式点击。
|
||||
export async function createLibrarySyncAuthorization(
|
||||
scope: string,
|
||||
snapshotID: string,
|
||||
minutes = 30
|
||||
): Promise<LibraryRestoreGrant> {
|
||||
const response = await fetch('/api/v1/library/authorizations', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||
body: JSON.stringify({ scope, snapshot_id: snapshotID, action: 'sync', expires_in_minutes: minutes }),
|
||||
})
|
||||
const value = (await response.json().catch(() => null)) as unknown
|
||||
if (!response.ok) {
|
||||
if (response.status === 403) throw new Error('生产发布授权仅管理员可签发。')
|
||||
if (response.status === 400 && record(value)) throw new Error(text(value.error) || '授权参数不合法')
|
||||
throw new Error(`签发同步授权失败(HTTP ${response.status})`)
|
||||
}
|
||||
if (!record(value) || !record(value.authorization)) throw new Error('授权响应无效')
|
||||
return {
|
||||
id: text(value.authorization.id),
|
||||
authorized_by: text(value.authorization.authorized_by),
|
||||
expires_at: text(value.authorization.expires_at),
|
||||
}
|
||||
}
|
||||
|
||||
export interface LibrarySyncAccepted {
|
||||
job_id: string
|
||||
scope: string
|
||||
reused: boolean
|
||||
}
|
||||
|
||||
// 启动同步:闸口未齐时接口返回 503 与逐条缺项,页面照实显示,不假装已启动。
|
||||
export async function enqueueLibrarySync(scope: string, targetSnapshotID: string): Promise<LibrarySyncAccepted> {
|
||||
const response = await fetch('/api/v1/library/syncs', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||
body: JSON.stringify({ scope, target_snapshot_id: targetSnapshotID }),
|
||||
})
|
||||
const value = (await response.json().catch(() => null)) as unknown
|
||||
if (!response.ok) {
|
||||
if (record(value) && Array.isArray(value.reasons)) {
|
||||
const reasons = value.reasons.map(text).filter(Boolean)
|
||||
if (reasons.length > 0) throw new Error(`启动同步被拒绝:${reasons.join(';')}`)
|
||||
}
|
||||
if (record(value) && text(value.error)) throw new Error(text(value.error))
|
||||
throw new Error(`启动同步失败(HTTP ${response.status})`)
|
||||
}
|
||||
if (!record(value) || !record(value.job)) throw new Error('同步作业响应无效')
|
||||
return { job_id: text(value.job.id), scope: text(value.job.scope), reused: bool(value.reused) }
|
||||
}
|
||||
|
||||
// 建立目标权威基线:目标非空又没有生效版本时的唯一入口(维护隔离 + 真实捕获 + 校验)。
|
||||
export async function enqueueLibraryBaseline(scope: string): Promise<LibrarySyncAccepted> {
|
||||
const response = await fetch('/api/v1/library/baselines', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||
body: JSON.stringify({ scope }),
|
||||
})
|
||||
const value = (await response.json().catch(() => null)) as unknown
|
||||
if (!response.ok) {
|
||||
if (response.status === 403) throw new Error('建立目标权威基线仅管理员可发起。')
|
||||
if (record(value) && text(value.error)) throw new Error(text(value.error))
|
||||
throw new Error(`建立基线失败(HTTP ${response.status})`)
|
||||
}
|
||||
if (!record(value) || !record(value.job)) throw new Error('基线作业响应无效')
|
||||
return { job_id: text(value.job.id), scope: text(value.job.scope), reused: bool(value.reused) }
|
||||
}
|
||||
|
||||
// 恢复授权与执行是两件事:授权把"目标 + 快照 + 参数"钉死并给很短的有效期,
|
||||
// 同步授权不能顶替恢复授权(摘要不同)。
|
||||
export async function createLibraryRestoreAuthorization(
|
||||
@@ -406,6 +475,18 @@ export interface LibraryMaintenanceView {
|
||||
maintenance?: LibraryMaintenanceRecord | null
|
||||
instance?: string
|
||||
instance_maintenance?: LibraryInstanceMaintenanceRecord | null
|
||||
// 能力是配置事实,和"此刻有没有窗口"分开:未运行维护 ≠ 没有维护能力。
|
||||
capability?: LibraryMaintenanceCapability | null
|
||||
}
|
||||
|
||||
export interface LibraryMaintenanceCapability {
|
||||
configured: boolean
|
||||
missing: string[]
|
||||
instance?: string
|
||||
project?: string
|
||||
writers?: string[]
|
||||
ingress_service?: string
|
||||
note?: string
|
||||
}
|
||||
|
||||
export async function fetchLibraryMaintenance(
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createLibrarySyncAuthorization, enqueueLibraryBaseline, enqueueLibrarySync } from './librarySnapshots'
|
||||
|
||||
// 这些用例钉住"两步走"的接线:授权与执行是两个请求,参数必须与接口契约一致。
|
||||
// 任何一步把 action 写错、或把 snapshot 换掉,都会在真实调用里悄悄失效(授权永远匹配不上)。
|
||||
const jsonResponse = (status: number, body: unknown) =>
|
||||
new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } })
|
||||
|
||||
afterEach(() => vi.restoreAllMocks())
|
||||
|
||||
describe('发布页的同步授权与执行接线', () => {
|
||||
it('签同步授权必须声明 action=sync 与有限有效期', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
jsonResponse(201, {
|
||||
authorization: { id: 'libauth-sync-1', authorized_by: 'admin', expires_at: '2026-10-29T10:00:00Z' },
|
||||
})
|
||||
)
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
const grant = await createLibrarySyncAuthorization('internal', 'snap-1', 30)
|
||||
expect(grant.id).toBe('libauth-sync-1')
|
||||
const [url, init] = fetchMock.mock.calls[0]
|
||||
expect(url).toBe('/api/v1/library/authorizations')
|
||||
expect(JSON.parse(init.body)).toEqual({
|
||||
scope: 'internal',
|
||||
snapshot_id: 'snap-1',
|
||||
action: 'sync',
|
||||
expires_in_minutes: 30,
|
||||
})
|
||||
})
|
||||
|
||||
it('启动同步把准确的 range 与目标快照传给 /library/syncs', async () => {
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValue(jsonResponse(202, { job: { id: 'libsync-internal-1', scope: 'internal' }, reused: false }))
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
const accepted = await enqueueLibrarySync('internal', 'snap-1')
|
||||
expect(accepted.job_id).toBe('libsync-internal-1')
|
||||
const [url, init] = fetchMock.mock.calls[0]
|
||||
expect(url).toBe('/api/v1/library/syncs')
|
||||
expect(JSON.parse(init.body)).toEqual({ scope: 'internal', target_snapshot_id: 'snap-1' })
|
||||
})
|
||||
|
||||
it('闸口未齐时把接口给的缺项原样抛出,而不是假装已启动', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue(
|
||||
jsonResponse(503, {
|
||||
code: 'library_sync_blocked',
|
||||
reasons: ['目标非空且没有生效版本记录', '没有与目标快照匹配的有效发布授权'],
|
||||
})
|
||||
)
|
||||
)
|
||||
await expect(enqueueLibrarySync('internal', 'snap-1')).rejects.toThrow(
|
||||
/目标非空且没有生效版本记录;没有与目标快照匹配的有效发布授权/
|
||||
)
|
||||
})
|
||||
|
||||
it('建立基线只传 range,并返回作业号', async () => {
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValue(jsonResponse(202, { job: { id: 'libbase-internal-1', scope: 'internal' }, reused: false }))
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
const accepted = await enqueueLibraryBaseline('internal')
|
||||
expect(accepted.job_id).toBe('libbase-internal-1')
|
||||
const [url, init] = fetchMock.mock.calls[0]
|
||||
expect(url).toBe('/api/v1/library/baselines')
|
||||
expect(JSON.parse(init.body)).toEqual({ scope: 'internal' })
|
||||
})
|
||||
|
||||
it('建立基线被拒时照实说明(例如目标为空或已有生效版本)', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue(
|
||||
jsonResponse(409, {
|
||||
code: 'library_baseline_exists',
|
||||
error: '该目标已有生效版本,不需要再建基线;换版本请走同步或恢复',
|
||||
})
|
||||
)
|
||||
)
|
||||
await expect(enqueueLibraryBaseline('internal')).rejects.toThrow(/该目标已有生效版本/)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user