From c40696145b43a47ea0cef6d83ae2fbe00bb3677f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A7=9A=E7=AB=A0=E6=B5=A9?= Date: Thu, 29 Oct 2026 13:01:46 +0800 Subject: [PATCH] =?UTF-8?q?feat(library-web):=20=E6=81=A2=E5=A4=8D?= =?UTF-8?q?=E5=85=A5=E5=8F=A3=E6=8C=89=E7=9C=9F=E5=AE=9E=E6=9D=A1=E4=BB=B6?= =?UTF-8?q?=E5=90=AF=E7=94=A8=E5=B9=B6=E6=94=AF=E6=8C=81=E7=AD=BE=E5=8F=91?= =?UTF-8?q?=E6=81=A2=E5=A4=8D=E6=8E=88=E6=9D=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 原来"恢复至该版本"写死 disabled,标题还写"生产恢复未接线"——而 POST /library/restores 与授权接口早已可用,等于把整库还原的唯一入口藏起来。 现在页面按 GET /library/restore-availability 启用:缺授权时管理员可签发 30 分钟 有效期的 restore 授权(固定期限,没有"永久授权"),条件满足才可点恢复;工程师只看到 条件与"仅管理员可发起"。封存不可发起时也不再写死"通道未接线",而是显示接口原因、 执行器身份与观测时间。 --- web/src/ReleaseTargets.test.tsx | 81 +++++++++++++-- web/src/ReleaseTargets.tsx | 170 ++++++++++++++++++++++++++++++-- web/src/libraryRestore.test.ts | 87 ++++++++++++++++ web/src/librarySnapshots.ts | 109 ++++++++++++++++++++ 4 files changed, 433 insertions(+), 14 deletions(-) create mode 100644 web/src/libraryRestore.test.ts diff --git a/web/src/ReleaseTargets.test.tsx b/web/src/ReleaseTargets.test.tsx index 2a967cf..6e18f6d 100644 --- a/web/src/ReleaseTargets.test.tsx +++ b/web/src/ReleaseTargets.test.tsx @@ -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('无封存时给出“尚无封存”而不是空表', () => { diff --git a/web/src/ReleaseTargets.tsx b/web/src/ReleaseTargets.tsx index 1c4f952..32a2a0e 100644 --- a/web/src/ReleaseTargets.tsx +++ b/web/src/ReleaseTargets.tsx @@ -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 ? '投递中…' : '封存测试版本'} ) : ( - {canWrite ? '封存通道未接线' : '封存需要知识工程师及以上权限'} + {captureHint(view, canWrite)} )} @@ -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 ? '收起快照' : '查看快照'} - @@ -991,13 +1036,48 @@ function Drawer({ 以上是同一恢复点的配套内容,不支持只选择 Wiki 或某张数据表恢复。恢复速度需实测;“有备份文件”不等于可恢复。

- - + {canRestore && restore && restore.authorization.state !== 'active' ? ( + + ) : null} + {canRestore ? ( + + ) : ( + 按版本恢复仅管理员可发起 + )}
+ {restore && !restore.allowed ? ( + + ) : null} + {restore?.allowed ? ( +

+ 条件已满足:生产写入闸口打开、这份快照有恢复授权 + {restore.authorization.expires_at ? `(有效至 ${stamp(restore.authorization.expires_at)})` : ''} + 、目标实例未在维护。 +

+ ) : null} + {restore === null ?

正在读取恢复条件…

: null} + {restoreNotice ?

{restoreNotice}

: null} ) : 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} /> ) @@ -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(null) + const [restoreNotice, setRestoreNotice] = useState(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()} diff --git a/web/src/libraryRestore.test.ts b/web/src/libraryRestore.test.ts new file mode 100644 index 0000000..5707df3 --- /dev/null +++ b/web/src/libraryRestore.test.ts @@ -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 + 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) + }) +}) diff --git a/web/src/librarySnapshots.ts b/web/src/librarySnapshots.ts index 679c3ca..50441c6 100644 --- a/web/src/librarySnapshots.ts +++ b/web/src/librarySnapshots.ts @@ -185,6 +185,101 @@ export async function enqueueLibraryCapture(scope: string): Promise { + 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 { + 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 { + 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 { 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 }