feat(library-web): 恢复入口按真实条件启用并支持签发恢复授权

原来"恢复至该版本"写死 disabled,标题还写"生产恢复未接线"——而 POST /library/restores
与授权接口早已可用,等于把整库还原的唯一入口藏起来。

现在页面按 GET /library/restore-availability 启用:缺授权时管理员可签发 30 分钟
有效期的 restore 授权(固定期限,没有"永久授权"),条件满足才可点恢复;工程师只看到
条件与"仅管理员可发起"。封存不可发起时也不再写死"通道未接线",而是显示接口原因、
执行器身份与观测时间。
This commit is contained in:
姚章浩
2026-10-29 13:01:46 +08:00
parent 47eb23cf68
commit c40696145b
4 changed files with 433 additions and 14 deletions
+74 -7
View File
@@ -149,6 +149,13 @@ const base: ViewProps = {
pendingCapture: false,
canCapture: true,
canWrite: true,
canRestore: false,
restore: null,
restoreNotice: null,
pendingRestore: false,
pendingGrant: false,
onGrantRestore: vi.fn(),
onRestore: vi.fn(),
maintenance: null,
now: new Date('2026-10-28T03:35:00Z').getTime(),
onRefresh: vi.fn(),
@@ -239,18 +246,62 @@ describe('ReleaseTargetsView(确认稿布局)', () => {
expect(html).toContain('列表只校验清单自身,字节级复核在同步/恢复执行前进行')
})
it('版本与快照抽屉按 scope 列出全部历史并区分测试封存/不可直接恢复', () => {
const html = render({ drawerOpen: true, expandedSnapshot: internalSnapshot.snapshot_id })
it('版本与快照抽屉按 scope 列出全部历史,并把恢复条件交给接口判定', () => {
const html = render({ drawerOpen: true, expandedSnapshot: internalSnapshot.snapshot_id, canRestore: true })
expect(html).toContain('版本与快照')
expect(html).toContain('封存 f992d64ea45b')
expect(html).toContain('测试封存')
expect(html).toContain('不可直接恢复')
expect(html).toContain('恢复条件见下方')
expect(html).toContain('恢复至该版本')
expect(html).toMatch(/恢复至该版本[^<]*<\/button>/)
expect(html).toContain('分项数量 / 字节 / 摘要(2 项)')
expect(html).toContain('不支持只选择 Wiki 或某张数据表恢复')
})
it('管理员在缺授权时看到签发入口,恢复按钮按接口给出的原因禁用', () => {
const html = render({
drawerOpen: true,
expandedSnapshot: internalSnapshot.snapshot_id,
canRestore: true,
restore: {
allowed: false,
reasons: ['没有针对该快照的恢复授权:先签发 restore 授权(绑定同一快照)'],
scope: 'internal',
snapshot_id: internalSnapshot.snapshot_id,
authorization: { state: 'none' },
},
})
expect(html).toContain('签发恢复授权(30 分钟)')
expect(html).toContain('没有针对该快照的恢复授权')
expect(html).toMatch(/rel-danger[^>]*disabled/)
// 不能再说"未接线":条件不满足要给出具体原因
expect(html).not.toContain('生产恢复未接线')
})
it('条件全满足时恢复按钮可用,并写明授权有效期', () => {
const html = render({
drawerOpen: true,
expandedSnapshot: internalSnapshot.snapshot_id,
canRestore: true,
restore: {
allowed: true,
reasons: [],
scope: 'internal',
snapshot_id: internalSnapshot.snapshot_id,
authorization: { state: 'active', authorized_by: 'yaozhanghao', expires_at: '2026-10-28T04:10:00Z' },
},
})
expect(html).toContain('条件已满足')
expect(html).toContain('有效至')
expect(html).not.toMatch(/rel-danger[^>]*disabled/)
})
it('工程师看不到恢复入口,只看到权限说明', () => {
const html = render({ drawerOpen: true, expandedSnapshot: internalSnapshot.snapshot_id, canRestore: false })
expect(html).toContain('按版本恢复仅管理员可发起')
expect(html).not.toContain('签发恢复授权')
})
it('抽屉只展示当前选中库的历史', () => {
const html = render({ drawerOpen: true, scope: 'external' })
expect(html).toContain('封存 01343b0b310b')
@@ -277,14 +328,30 @@ describe('ReleaseTargetsView(确认稿布局)', () => {
expect(html).toContain('主机侧正在暂停写入者、导出并恢复')
})
it('无权限时不给封存入口,未接线时说明通道状态', () => {
it('无权限时不给封存入口;不可发起时给出接口返回的真实原因与执行器身份', () => {
expect(render({ canCapture: false, canWrite: false })).toContain('封存需要知识工程师及以上权限')
const unwired = render({
const unavailable = render({
canCapture: false,
canWrite: true,
page: { ...page, capture_available: false, capture_reason: '主机侧封存 agent 未接线' },
page: { ...page, capture_available: false },
overview: {
...overview,
capture_available: false,
capture_reason: '执行器指向的实例不匹配:agent="weknora071full",期望="kgmaint-weknora"',
executor: {
available: false,
busy: false,
project: 'weknora071full',
source_app: 'WKNFull-app',
observed_at: '2026-10-28T03:34:00Z',
},
},
})
expect(unwired).toContain('封存通道未接线')
expect(unavailable).toContain('执行器指向的实例不匹配')
expect(unavailable).toContain('执行器 weknora071full / WKNFull-app')
expect(unavailable).toContain('观测于')
// 通道可用/不可用都不许用写死的"未接线"糊过去
expect(unavailable).not.toContain('封存通道未接线')
})
it('无封存时给出“尚无封存”而不是空表', () => {
+163 -7
View File
@@ -1,10 +1,13 @@
import { useCallback, useEffect, useState } from 'react'
import {
createLibraryRestoreAuthorization,
enqueueLibraryCapture,
enqueueLibraryRecovery,
enqueueLibraryRestore,
fetchLibraryCaptures,
fetchLibraryMaintenance,
fetchLibraryOverview,
fetchLibraryRestoreAvailability,
fetchLibrarySnapshots,
formatBytes,
scopeLabel,
@@ -12,6 +15,7 @@ import {
type LibraryJobRecord,
type LibraryMaintenanceView,
type LibraryOverview,
type LibraryRestoreAvailability,
type LibrarySnapshot,
type LibrarySnapshotList,
type LibraryStageRow,
@@ -85,6 +89,14 @@ interface ViewProps {
pendingCapture: boolean
canCapture: boolean
canWrite: boolean
// 按版本恢复是管理员动作:工程师看得到条件,但拿不到恢复入口。
canRestore: boolean
restore: LibraryRestoreAvailability | null
restoreNotice: string | null
pendingRestore: boolean
pendingGrant: boolean
onGrantRestore: () => void
onRestore: () => void
overview: LibraryOverview | null
maintenance: LibraryMaintenanceView | null
now: number
@@ -118,6 +130,20 @@ function scopeView(
const scopeName = (scope: Scope) => (scope === 'internal' ? '对内知识库' : '对外知识库')
// 不可发起时必须说清是哪一条不满足:读的是接口给出的真实原因(执行器不可达、
// 指向别的实例、降级、观测过期),不是一句写死的"通道未接线"。
function captureHint(view: ScopeView, canWrite: boolean): string {
if (!canWrite) return '封存需要知识工程师及以上权限'
const executor = view.overview?.executor
const reason = view.overview?.capture_reason || executor?.reason
const observed = executor?.observed_at ? `(观测于 ${stamp(executor.observed_at)})` : ''
const identity = executor?.project
? `执行器 ${executor.project}${executor.source_app ? ` / ${executor.source_app}` : ''} · `
: ''
if (reason) return `${identity}封存通道不可用:${reason}${observed}`
return '封存通道状态未知:还没有读到执行器观测'
}
function statusOf(view: ScopeView) {
const job = view.job
if (job && (job.state === 'running' || job.state === 'pending')) {
@@ -629,7 +655,7 @@ function ReadyBody({
{pendingCapture ? '投递中…' : '封存测试版本'}
</button>
) : (
<span className="rel-hint">{canWrite ? '封存通道未接线' : '封存需要知识工程师及以上权限'}</span>
<span className="rel-hint">{captureHint(view, canWrite)}</span>
)}
</div>
</>
@@ -822,8 +848,16 @@ interface DrawerProps {
onExpand: (id: string | null) => void
onClose: () => void
canCapture: boolean
canRestore: boolean
pendingCapture: boolean
onCapture: () => void
// 恢复条件来自容器(真实接口判定):双闸 / 该快照的授权 / 目标是否在维护。
restore: LibraryRestoreAvailability | null
restoreNotice: string | null
pendingRestore: boolean
pendingGrant: boolean
onGrantRestore: () => void
onRestore: () => void
}
function Drawer({
@@ -835,8 +869,15 @@ function Drawer({
onExpand,
onClose,
canCapture,
canRestore,
pendingCapture,
onCapture,
restore,
restoreNotice,
pendingRestore,
pendingGrant,
onGrantRestore,
onRestore,
}: DrawerProps) {
if (!open) return null
const listing = view.history
@@ -919,8 +960,12 @@ function Drawer({
>
{isSelected ? '收起快照' : '查看快照'}
</button>
<button type="button" className="rel-link" disabled title="生产恢复未接线,也没有恢复演练记录">
不可直接恢复
<button
type="button"
className="rel-link"
onClick={() => onExpand(isSelected ? null : snapshot.snapshot_id)}
>
{isSelected ? '恢复条件见下方' : '查看恢复条件'}
</button>
</td>
</tr>
@@ -991,13 +1036,48 @@ function Drawer({
以上是同一恢复点的配套内容,不支持只选择 Wiki 或某张数据表恢复。恢复速度需实测;“有备份文件”不等于可恢复。
</p>
<div className="rel-drawer-actions">
<button type="button" disabled title="生产恢复验证记录未接入">
<button type="button" disabled title="逐项校验证据在同步/恢复执行时产生,作业详情里可见">
查看校验记录
</button>
<button type="button" className="rel-danger" disabled title="生产恢复未接线,也没有经过恢复演练">
恢复至该版本
</button>
{canRestore && restore && restore.authorization.state !== 'active' ? (
<button type="button" className="rel-secondary" onClick={onGrantRestore} disabled={pendingGrant}>
{pendingGrant ? '签发中…' : '签发恢复授权(30 分钟)'}
</button>
) : null}
{canRestore ? (
<button
type="button"
className="rel-danger"
onClick={onRestore}
disabled={pendingRestore || restore?.allowed !== true}
title={
restore?.allowed
? '把整套生产知识状态恢复到这个快照'
: (restore?.reasons ?? []).join(';') || '恢复条件尚未读取到'
}
>
{pendingRestore ? '投递中…' : '恢复至该版本'}
</button>
) : (
<span className="rel-hint">按版本恢复仅管理员可发起</span>
)}
</div>
{restore && !restore.allowed ? (
<ul className="rel-gate" aria-label="恢复条件">
{restore.reasons.map((reason) => (
<li key={reason}>{reason}</li>
))}
</ul>
) : null}
{restore?.allowed ? (
<p className="rel-hint">
条件已满足:生产写入闸口打开、这份快照有恢复授权
{restore.authorization.expires_at ? `(有效至 ${stamp(restore.authorization.expires_at)})` : ''}
、目标实例未在维护。
</p>
) : null}
{restore === null ? <p className="rel-hint">正在读取恢复条件…</p> : null}
{restoreNotice ? <p className="rel-notice">{restoreNotice}</p> : null}
</section>
) : null}
@@ -1047,6 +1127,13 @@ export function ReleaseTargetsView(props: ViewProps) {
pendingCapture,
canCapture,
canWrite,
canRestore,
restore,
restoreNotice,
pendingRestore,
pendingGrant,
onGrantRestore,
onRestore,
now,
maintenance,
onRefresh,
@@ -1241,8 +1328,15 @@ export function ReleaseTargetsView(props: ViewProps) {
onExpand={onExpandSnapshot}
onClose={() => onDrawer(false)}
canCapture={canCapture}
canRestore={canRestore}
pendingCapture={pendingCapture}
onCapture={onCapture}
restore={restore}
restoreNotice={restoreNotice}
pendingRestore={pendingRestore}
pendingGrant={pendingGrant}
onGrantRestore={onGrantRestore}
onRestore={onRestore}
/>
</section>
)
@@ -1313,8 +1407,63 @@ export function ReleaseTargets() {
const role = auth?.identity?.role
const canWrite = role === 'engineer' || role === 'admin'
// 恢复写的是整套生产知识状态,接口只认管理员;页面必须与它一致。
const canRestore = role === 'admin'
const canCapture = canWrite && page?.capture_available === true
// 恢复条件随"展开的那份快照"变化:闸口、该快照的授权、实例是否在维护都由接口判定。
const [restore, setRestore] = useState<LibraryRestoreAvailability | null>(null)
const [restoreNotice, setRestoreNotice] = useState<string | null>(null)
const [pendingRestore, setPendingRestore] = useState(false)
const [pendingGrant, setPendingGrant] = useState(false)
const [restoreRound, setRestoreRound] = useState(0)
const restoreSnapshot = expandedSnapshot ?? ''
useEffect(() => {
if (!drawerOpen || !restoreSnapshot) {
setRestore(null)
return
}
const controller = new AbortController()
void fetchLibraryRestoreAvailability(scope, restoreSnapshot, controller.signal)
.then((value) => setRestore(value))
.catch(() => setRestore(null))
return () => controller.abort()
}, [drawerOpen, restoreSnapshot, scope, restoreRound])
const grantRestore = async () => {
if (!restoreSnapshot) return
setPendingGrant(true)
setRestoreNotice(null)
try {
const grant = await createLibraryRestoreAuthorization(scope, restoreSnapshot)
setRestoreNotice(
`已签发恢复授权${grant.authorized_by ? `(${grant.authorized_by})` : ''},有效至 ${stamp(grant.expires_at)}`
)
setRestoreRound((round) => round + 1)
await load()
} catch (grantError) {
setRestoreNotice(grantError instanceof Error ? grantError.message : '签发恢复授权失败')
} finally {
setPendingGrant(false)
}
}
const startRestore = async () => {
if (!restoreSnapshot) return
setPendingRestore(true)
setRestoreNotice(null)
try {
const accepted = await enqueueLibraryRestore(scope, restoreSnapshot)
setRestoreNotice(`已投递恢复:${accepted.job_id}(整套生产知识状态回到该快照)`)
setRestoreRound((round) => round + 1)
await load()
} catch (restoreError) {
setRestoreNotice(restoreError instanceof Error ? restoreError.message : '发起恢复失败')
} finally {
setPendingRestore(false)
}
}
const startCapture = async () => {
setPendingCapture(true)
setNotice(null)
@@ -1364,6 +1513,13 @@ export function ReleaseTargets() {
pendingCapture={pendingCapture}
canCapture={canCapture}
canWrite={canWrite}
canRestore={canRestore}
restore={restore}
restoreNotice={restoreNotice}
pendingRestore={pendingRestore}
pendingGrant={pendingGrant}
onGrantRestore={() => void grantRestore()}
onRestore={() => void startRestore()}
maintenance={maintenances[scope]}
now={now}
onRefresh={() => void load()}
+87
View File
@@ -0,0 +1,87 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
createLibraryRestoreAuthorization,
enqueueLibraryRestore,
fetchLibraryRestoreAvailability,
} from './librarySnapshots'
const jsonResponse = (body: unknown, status = 200) =>
new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } })
afterEach(() => {
vi.unstubAllGlobals()
})
describe('整库恢复的前端契约', () => {
it('读取恢复条件时把原因与授权状态原样带回来', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue(
jsonResponse({
allowed: false,
reasons: ['没有针对该快照的恢复授权:先签发 restore 授权(绑定同一快照)'],
scope: 'internal',
snapshot_id: 'snap-1',
authorization: { state: 'none', reason: '没有与目标快照匹配的有效发布授权' },
})
)
)
const availability = await fetchLibraryRestoreAvailability('internal', 'snap-1')
expect(availability?.allowed).toBe(false)
expect(availability?.reasons).toHaveLength(1)
expect(availability?.authorization.state).toBe('none')
const requested = (vi.mocked(fetch).mock.calls[0]?.[0] as string) ?? ''
expect(requested).toContain('scope=internal')
expect(requested).toContain('snapshot_id=snap-1')
})
it('签发恢复授权必须声明 action=restore 与有限有效期', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue(
jsonResponse(
{
authorization: {
id: 'libauth-restore-1',
authorized_by: 'yaozhanghao',
expires_at: '2026-10-29T09:00:00Z',
},
},
201
)
)
)
const grant = await createLibraryRestoreAuthorization('internal', 'snap-1')
expect(grant.id).toBe('libauth-restore-1')
const init = vi.mocked(fetch).mock.calls[0]?.[1] as RequestInit
const body = JSON.parse(String(init.body)) as Record<string, unknown>
expect(body.action).toBe('restore')
expect(body.expires_in_minutes).toBe(30)
})
it('恢复被拒时把逐条原因说给用户,而不是只说 HTTP 状态', async () => {
vi.stubGlobal(
'fetch',
vi
.fn()
.mockResolvedValue(
jsonResponse({ code: 'library_restore_blocked', reasons: ['生产写入闸口关闭', '没有该快照的恢复授权'] }, 503)
)
)
await expect(enqueueLibraryRestore('internal', 'snap-1')).rejects.toThrow('生产写入闸口关闭;没有该快照的恢复授权')
})
it('投递成功时返回作业号,供页面显示真实进度', async () => {
vi.stubGlobal(
'fetch',
vi
.fn()
.mockResolvedValue(
jsonResponse({ job: { id: 'librestore-internal-1', scope: 'internal' }, reused: false }, 202)
)
)
const accepted = await enqueueLibraryRestore('internal', 'snap-1')
expect(accepted.job_id).toBe('librestore-internal-1')
expect(accepted.reused).toBe(false)
})
})
+109
View File
@@ -185,6 +185,101 @@ export async function enqueueLibraryCapture(scope: string): Promise<LibraryCaptu
}
}
// 恢复能不能点,由真实条件决定:双闸、这份快照的恢复授权、目标实例是否在维护。
export interface LibraryRestoreAvailability {
allowed: boolean
reasons: string[]
scope: string
snapshot_id: string
authorization: LibraryAuthorizationView
}
export async function fetchLibraryRestoreAvailability(
scope: string,
snapshotID: string,
signal?: AbortSignal
): Promise<LibraryRestoreAvailability | null> {
const query = `scope=${encodeURIComponent(scope)}&snapshot_id=${encodeURIComponent(snapshotID)}`
const response = await fetch(`/api/v1/library/restore-availability?${query}`, {
headers: { Accept: 'application/json' },
signal,
})
const value = (await response.json().catch(() => null)) as unknown
if (!record(value)) return null
return {
allowed: bool(value.allowed),
reasons: Array.isArray(value.reasons) ? value.reasons.map(text).filter(Boolean) : [],
scope: text(value.scope),
snapshot_id: text(value.snapshot_id),
authorization: record(value.authorization)
? {
state: text(value.authorization.state) === 'active' ? 'active' : 'none',
authorized_by: text(value.authorization.authorized_by) || undefined,
expires_at: text(value.authorization.expires_at) || undefined,
reason: text(value.authorization.reason) || undefined,
}
: { state: 'none' },
}
}
// 恢复整套生产知识状态:同一份快照的文档、分块、索引、资源与 Wiki 一起回放。
export interface LibraryRestoreAccepted {
job_id: string
scope: string
reused: boolean
}
export async function enqueueLibraryRestore(scope: string, snapshotID: string): Promise<LibraryRestoreAccepted> {
const response = await fetch('/api/v1/library/restores', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify({ scope, snapshot_id: snapshotID }),
})
const value = (await response.json().catch(() => null)) as unknown
if (!response.ok) {
if (response.status === 403) throw new Error('按版本恢复仅管理员可发起。')
if (response.status === 503 && record(value)) {
const reasons = Array.isArray(value.reasons) ? value.reasons.map(text).filter(Boolean) : []
throw new Error(reasons.length > 0 ? reasons.join(';') : text(value.error) || '恢复条件不满足')
}
if (response.status === 409 && record(value)) 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 interface LibraryRestoreGrant {
id: string
authorized_by: string
expires_at: string
}
// 恢复授权与执行是两件事:授权把"目标 + 快照 + 参数"钉死并给很短的有效期,
// 同步授权不能顶替恢复授权(摘要不同)。
export async function createLibraryRestoreAuthorization(
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: 'restore', expires_in_minutes: minutes }),
})
const value = (await response.json().catch(() => null)) as unknown
if (!response.ok) {
if (response.status === 403) throw new 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 async function fetchLibraryCaptures(signal?: AbortSignal): Promise<LibraryCaptureJob[]> {
const response = await fetch('/api/v1/library/captures', {
headers: { Accept: 'application/json' },
@@ -391,6 +486,18 @@ export interface LibraryGate {
reasons: string[]
}
export interface LibraryExecutorView {
available: boolean
reason?: string
instance?: string
project?: string
source_app?: string
archive_root?: string
busy: boolean
degraded?: string
observed_at?: string
}
export interface LibraryOverview {
scope: string
test_sealed?: LibrarySnapshot
@@ -404,6 +511,8 @@ export interface LibraryOverview {
authorization: LibraryAuthorizationView
capture_available: boolean
capture_reason?: string
// 执行器是 worker 的真实观测:指向哪个实例、何时观测、是否降级/在跑。
executor?: LibraryExecutorView
now: string
}