From 61c08112284a3ad596bdf2bc5e542cf69b764f15 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A7=9A=E7=AB=A0=E6=B5=A9?= Date: Wed, 30 Sep 2026 10:53:25 +0800 Subject: [PATCH] =?UTF-8?q?feat(web):=20=E7=B3=BB=E7=BB=9F=E8=AE=BE?= =?UTF-8?q?=E7=BD=AE=E9=A1=B5=E7=BC=96=E8=BE=91=E5=B9=B6=E6=A0=A1=E9=AA=8C?= =?UTF-8?q?=E6=9C=8D=E5=8A=A1=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 系统设置页新增服务配置表单(生产/测试地址、租户 ID、知识库 ID),保存时回显校验结果与失败原因 - 同步前端类型定义与接口调用,补齐设置相关前端用例 --- web/src/App.test.tsx | 6 +- web/src/App.tsx | 2 +- web/src/SystemSettings.test.tsx | 55 ++++++++++--- web/src/SystemSettings.tsx | 108 ++++++++++++++++++++++--- web/src/auth.tsx | 2 +- web/src/uiSettings.test.ts | 80 ++++++++++++++++++- web/src/uiSettings.ts | 134 ++++++++++++++++++++++++++++++-- 7 files changed, 351 insertions(+), 36 deletions(-) diff --git a/web/src/App.test.tsx b/web/src/App.test.tsx index f73445f..10c1d62 100644 --- a/web/src/App.test.tsx +++ b/web/src/App.test.tsx @@ -118,8 +118,10 @@ describe('ModulePage', () => { expect(html).toContain('治理工作台') expect(html).toContain('P2 · 知识治理总览') expect(html).toContain('PP历史来源清单已接管') - expect(html).toContain('本地测试WeKnora已连接') - expect(html).toContain('生产WeKnora仍未连接') + expect(html).toContain('生产写入:关闭') + expect(html).toContain('以「系统设置 → 服务配置」的实时探测为准') + expect(html).not.toContain('本地测试WeKnora已连接') + expect(html).not.toContain('生产WeKnora仍未连接') expect(html).not.toContain('尚未连接测试或生产WeKnora') expect(html).toContain('任务概览') expect(html).toContain('Release Worker 默认关闭') diff --git a/web/src/App.tsx b/web/src/App.tsx index 36337bd..40bf4f8 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -284,7 +284,7 @@ export function ModulePage({ moduleId, jobs, loading, error, onRefresh, connecti
运行安全 - PP历史来源清单已接管;本地测试WeKnora已连接,生产WeKnora仍未连接。 + PP历史来源清单已接管;生产写入:{releaseCapabilities.production_release_requests_enabled ? (releaseCapabilities.production_backup_restore_verified ? '已开启' : '已开启(快照恢复未验证,仍被阻断)') : '关闭'};测试 / 生产 WeKnora 连接状态以「系统设置 → 服务配置」的实时探测为准。
Release Worker 默认关闭
diff --git a/web/src/SystemSettings.test.tsx b/web/src/SystemSettings.test.tsx index 0af791c..211fc8f 100644 --- a/web/src/SystemSettings.test.tsx +++ b/web/src/SystemSettings.test.tsx @@ -1,6 +1,7 @@ import { renderToStaticMarkup } from 'react-dom/server' import { describe,expect,it,vi } from 'vitest' import { SystemSettingsView, visibleSaveMessage } from './SystemSettings' +import { AuthContext } from './auth' import type { MonitorOverview } from './monitor' import type { CatalogSource } from './sourceCatalog' import type { UISettings } from './uiSettings' @@ -8,13 +9,28 @@ import type { SourceConnectionListItem } from './sourceConnections' const monitor:MonitorOverview={mode:'simulation',simulated:true,observed_at:'2026-09-08T12:00:00Z',host:{cpu_percent:42,memory_used_bytes:1,memory_total_bytes:2,disk_used_bytes:1,disk_total_bytes:2,gpu_state:'unknown',gpu_detail:'未接入真实GPU监控',simulated:true},services:[{key:'mineru',name:'MinerU',state:'healthy',detail:'模拟',latency_ms:1,observed_at:'2026-09-08T12:00:00Z',simulated:true}],knowledge_bases:[],health:[{key:'mineru',name:'MinerU',liveness:'up',readiness:'blocked',reason:'服务可达但容量已满(429),暂时不可接单',latency_ms:30,observed_at:'2026-09-08T12:00:00Z',last_success_at:'2026-09-08T11:59:00Z',consecutive_failures:2,configured:true,applicable:true}]} const sources:CatalogSource[]=[{connection_id:'ding-1',source_type:'dingtalk',name:'公司级技术支持知识库',root_node_id:'root-1',folder_count:1079,file_count:3444,observed_at:'2026-09-09T09:30:32Z',snapshot_only:true,snapshot_status:'ready',last_refresh_duration_ms:0}] const connections:SourceConnectionListItem[]=[{id:'conn-ding',source_type:'dingtalk',name:'公司级技术支持知识库',enabled:true,schedule_configured:true,health_status:'healthy',item_count:3444,updated_at:'2026-09-09T09:30:32Z',next_refresh_at:'2026-09-12T10:00:00Z'},{id:'conn-man',source_type:'manual',name:'本地手工验收',enabled:false,schedule_configured:false,health_status:'unknown',item_count:5,updated_at:'2026-09-09T09:30:32Z',next_refresh_at:null}] -const settings:UISettings={id:'settings-default',state:'active',document_refresh_seconds:15,resource_refresh_seconds:30,stalled_after_seconds:1800,finalizing_after_seconds:7200,ingestion_concurrency:2,updated_by:'system:migration',created_at:'2026-09-10T08:00:00Z',activated_at:'2026-09-10T08:00:00Z',retired_at:null,error_code:''} +const settings:UISettings={id:'settings-default',state:'active',document_refresh_seconds:15,resource_refresh_seconds:30,stalled_after_seconds:1800,finalizing_after_seconds:7200,ingestion_concurrency:2,test_weknora_base_url:'',production_weknora_base_url:'',production_tenant_id:'',production_knowledge_base_id:'',updated_by:'system:migration',created_at:'2026-09-10T08:00:00Z',activated_at:'2026-09-10T08:00:00Z',retired_at:null,error_code:''} +const authValue=(role:'admin'|'engineer')=>({identity:{id:`${role}-1`,username:role,display_name:role,role,must_change_password:false},ready:true,login:async()=>null,logout:async()=>{},changePassword:async()=>null,signedOut:0,notifySignedOut:()=>{}}) +describe('SystemSettingsView service configuration',()=>{ + it('lets an administrator edit exactly the four service fields in the web console',()=>{ + const configured:UISettings={...settings,test_weknora_base_url:'https://weknora.test.example',production_weknora_base_url:'https://weknora.prod.example',production_tenant_id:'10000',production_knowledge_base_id:'kb-abc123'} + const view=(role:'admin'|'engineer')=>renderToStaticMarkup() + const adminHtml=view('admin') + for(const text of ['测试地址','生产地址','生产 ID','知识库 ID','保存并启用服务配置','https://weknora.test.example','https://weknora.prod.example','10000','kb-abc123','四项留空即表示未配置'])expect(adminHtml).toContain(text) + expect(adminHtml).not.toContain('仅管理员可修改') + // 界面只剩这四项:依赖探活矩阵与对内/对外绑定下拉不再出现在服务配置页。 + for(const removed of ['MinerU','Embedding','Redis / MQ','PostgreSQL','测试知识库映射'])expect(adminHtml).not.toContain(removed) + const engineerHtml=view('engineer') + expect(engineerHtml).toContain('仅管理员可修改') + expect(engineerHtml).not.toContain('保存并启用服务配置') + }) +}) describe('SystemSettingsView',()=>{ it('separates document-engineer settings from locked administrator and production controls',()=>{ const html=renderToStaticMarkup() for(const text of ['系统设置','数据源配置','服务配置','存储与备份','刷新策略','设置服务已启用','当前活动版本','公司级技术支持知识库','钉钉知识库','认证方式','端点与凭据','文档工程师可调整','管理员设置已锁定'])expect(html).toContain(text) const servicesHtml=renderToStaticMarkup() - for(const text of ['测试 WeKnora','生产 WeKnora','MinerU','Embedding'])expect(servicesHtml).toContain(text) + for(const text of ['测试 WeKnora','生产 WeKnora','测试地址','生产地址','生产 ID','知识库 ID'])expect(servicesHtml).toContain(text) const storageHtml=renderToStaticMarkup() expect(storageHtml).toContain('生产发布请求') expect(storageHtml).toContain('仅保存凭据引用,不可回读') @@ -23,13 +39,10 @@ describe('SystemSettingsView',()=>{ expect(visibleSaveMessage('已保存',true)).toBeNull();expect(visibleSaveMessage('已保存',false)).toBe('已保存') expect(html).not.toContain('本地模拟');expect(html).not.toContain('保存模拟设置') for(const forbidden of ['DATABASE_URL','RELEASE_CONFIG_FILE','API Key明文','secret_ref'])expect(html).not.toContain(forbidden) - // P10 dual-state matrix: reachable-but-not-workable must be explicit (services section). - expect(servicesHtml).toContain('可达 · 不可接单') - expect(servicesHtml).toContain('服务可达但容量已满(429)') - expect(servicesHtml).toContain('连续失败2次') - expect(servicesHtml).toContain('PostgreSQL') - expect(servicesHtml).toContain('对内解析队列:已配置') - expect(servicesHtml).toContain('对外解析队列:已配置') + // 两个环境卡仍由真实探测驱动(依赖矩阵搬到总览页,本页不再渲染)。 + expect(servicesHtml).toContain('解析队列 对内 已配置 · 对外 已配置') + expect(servicesHtml).not.toContain('PostgreSQL') + expect(servicesHtml).toContain('端点探测:暂无真实探测结果') // V1: left-nav section controls, per-source schedule editing, danger modal & history affordances. expect(html).toContain('settings-nav') expect(html).toContain('下次自动刷新') @@ -46,4 +59,28 @@ describe('SystemSettingsView',()=>{ const html=renderToStaticMarkup() expect(html).toContain('加载失败');expect(html).not.toContain('模拟运行中') }) + it('renders the WeKnora cards and production channel from live probe and the real production gate',()=>{ + const probed:MonitorOverview={...monitor,health:[ + {key:'weknora',name:'WeKnora API',liveness:'up',readiness:'ready',reason:'',latency_ms:86,observed_at:'2026-09-08T12:00:00Z',last_success_at:'2026-09-08T11:59:00Z',consecutive_failures:0,configured:true,applicable:true}, + {key:'weknora-production',name:'生产 WeKnora',liveness:'up',readiness:'ready',reason:'',latency_ms:41,observed_at:'2026-09-08T12:00:00Z',last_success_at:'2026-09-08T11:59:00Z',consecutive_failures:0,configured:true,applicable:true}, + {key:'embedding',name:'Embedding',liveness:'up',readiness:'not_applicable',reason:'未部署:本平台不使用该依赖',latency_ms:0,observed_at:'2026-09-08T12:00:00Z',last_success_at:null,consecutive_failures:0,configured:false,applicable:false}]} + const view=(enabled:boolean,verified:boolean)=>renderToStaticMarkup() + const on=view(true,true) + expect(on).toContain('测试 WeKnora') + expect(on).toContain('可达 · 可接单');expect(on).toContain('延迟 86ms') + expect(on).toContain('已启用 · 可提交生产请求');expect(on).toContain('生产写入:开启');expect(on).toContain('发布目标与凭据:管理员受限配置') + const off=view(false,false) + expect(off).toContain('未启用');expect(off).toContain('生产写入:关闭') + // 卡片状态由真实探测/门禁驱动,写死的“已连接 / 未连接 / 生产映射:未配置”必须消失。 + for(const removed of ['本地真实连接','生产映射:未配置'])expect(off).not.toContain(removed) + // 生产端点可达性来自真实探活,且只说真话:探活不等于验证过接单能力。 + expect(on).toContain('端点探测:端点可达 · 未做接单验证') + const blocked=renderToStaticMarkup() + expect(blocked).toContain('已启用 · 被阻断') + // 未配置生产端点时必须显式说「未配置」,不得靠文案含糊过去。 + expect(blocked).toContain('端点探测:暂无真实探测结果') + const unconfigured=renderToStaticMarkup() + expect(unconfigured).toContain('端点探测:未配置') + expect(unconfigured).not.toContain('端点可达') +}) }) diff --git a/web/src/SystemSettings.tsx b/web/src/SystemSettings.tsx index a73c9f3..d2b164f 100644 --- a/web/src/SystemSettings.tsx +++ b/web/src/SystemSettings.tsx @@ -4,7 +4,6 @@ import { MembersSection } from './MembersSection' import { DingtalkSessionControl } from './DingtalkSessionControl' import { DingtalkSpaceRegistrar } from './DingtalkSpaceRegistrar' import { FilenameFilterEditor } from './FilenameFilterEditor' -import { RoutingBindingsSection } from './RoutingBindingsSection' import { useOptionalAuth } from './auth' import { fetchMonitorOverview } from './monitor' import type { MonitorOverview } from './monitor' @@ -13,7 +12,7 @@ import { fetchCatalogRoutingCapabilities, fetchCatalogSources } from './sourceCa import type { CatalogRoutingCapabilities, CatalogSource } from './sourceCatalog' import { activateSourceSettingsRevision, createSourceSettingsRevision, fetchSourceConnectionList, fetchSourceSettingsRevisions } from './sourceConnections' import type { SourceConnectionListItem, SourceSettingsRevision } from './sourceConnections' -import { activateUISettings, createUISettingsCandidate, fetchActiveUISettings } from './uiSettings' +import { activateUISettings, createUISettingsCandidate, fetchActiveUISettings, validKnowledgeBaseID, validProductionTenantID, validServiceEndpoint } from './uiSettings' import type { UISettings, UISettingsCandidateInput } from './uiSettings' import './systemSettings.css' @@ -23,6 +22,7 @@ interface Props { monitor: MonitorOverview | null settings: UISettings | null productionEnabled: boolean + productionBackupRestoreVerified?: boolean routingCapabilities: CatalogRoutingCapabilities loading: boolean saving: boolean @@ -49,6 +49,42 @@ const sourceAuthLabels: Record = { dingtalk: '专用浏览器扫 const secondsLabel = (value: number) => value < 60 ? `${value}秒` : value < 3600 ? `${value / 60}分钟` : `${value / 3600}小时` export const visibleSaveMessage = (message: string | null, dirty: boolean) => dirty ? null : message +type ServiceHealthView = MonitorOverview['health'][number] +type ServiceView = MonitorOverview['services'][number] + +// 服务配置只保留四项可写值(测试地址、生产地址、生产 ID、知识库 ID), +// 依赖与解析队列的状态仍按真实探测呈现,不写死。 +const findHealth = (monitor: MonitorOverview | null, name: string): ServiceHealthView | undefined => (monitor?.health || []).find((item) => item.name === name || item.name.startsWith(name)) +export const healthStateLabel = (health: ServiceHealthView | undefined, service: ServiceView | undefined): string => { + if (!health) { + if (!service) return '待配置' + return service.state === 'healthy' ? '正常' : service.state === 'unknown' ? '未接入' : '需关注' + } + if (health.readiness === 'ready') return '可达 · 可接单' + if (health.readiness === 'not_configured') return '状态未知 · 未配置' + if (health.readiness === 'not_applicable') return '未部署' + return health.liveness === 'up' ? '可达 · 不可接单' : '不可达 · 不可接单' +} +export const healthStateDetail = (health: ServiceHealthView | undefined, service: ServiceView | undefined): string => { + if (!health) return service?.detail || '真实 Provider 未接入' + const success = health.last_success_at ? `最近成功 ${new Date(health.last_success_at).toLocaleTimeString('zh-CN', { hour12: false })}` : '' + if (health.readiness === 'ready') return [`延迟 ${health.latency_ms}ms`, success].filter(Boolean).join(';') + const failures = health.consecutive_failures ? `连续失败${health.consecutive_failures}次` : '' + return [health.reason, success, failures].filter(Boolean).join(';') +} + +// 生产 WeKnora 卡片:端点可达性来自真实探测,写入门禁来自生产开关,两者分开呈现。 +// 生产侧只做探活(/health),不做一次真实读写,所以接单能力永远是「未做接单验证」, +// 不跟着别处的「可达 · 可接单」一起含糊过去。 +export const productionWeKnoraProbe = (monitor: MonitorOverview | null): string => { + const health = findHealth(monitor, '生产 WeKnora') + if (!health) return '端点探测:暂无真实探测结果' + if (!health.configured) return '端点探测:未配置' + const label = health.liveness === 'up' ? (health.readiness === 'ready' ? '端点可达 · 未做接单验证' : '端点可达 · 不可接单') : '端点不可达' + const detail = healthStateDetail(health, undefined) + return `端点探测:${label}${detail ? `(${detail})` : ''}` +} + const intervalChoices: { value: number; label: string }[] = [ { value: 0, label: '仅手动刷新' }, { value: 3600, label: '每小时' }, @@ -138,7 +174,7 @@ function SourceSettingsCard({ connection, onSourcesChanged }: { connection: Sour } -export function SystemSettingsView({ sources, connections, monitor, settings, productionEnabled, routingCapabilities, loading, saving, error, saveMessage, onSave, onSourcesChanged, onRefresh, initialSection = 'sources' }: Props) { +export function SystemSettingsView({ sources, connections, monitor, settings, productionEnabled, productionBackupRestoreVerified = false, routingCapabilities, loading, saving, error, saveMessage, onSave, onSourcesChanged, onRefresh, initialSection = 'sources' }: Props) { const auth = useOptionalAuth() const identity = auth?.identity ?? null const [section, setSection] = useState(initialSection) @@ -147,6 +183,10 @@ export function SystemSettingsView({ sources, connections, monitor, settings, pr const [stalled, setStalled] = useState<1800 | 3600>(settings?.stalled_after_seconds ?? 1800) const [finalizing, setFinalizing] = useState<7200 | 14400>(settings?.finalizing_after_seconds ?? 7200) const [concurrency, setConcurrency] = useState(settings?.ingestion_concurrency ?? 2) + const [endpoint, setEndpoint] = useState(settings?.production_weknora_base_url ?? '') + const [testEndpoint, setTestEndpoint] = useState(settings?.test_weknora_base_url ?? '') + const [productionTenantID, setProductionTenantID] = useState(settings?.production_tenant_id ?? '') + const [productionKnowledgeBaseID, setProductionKnowledgeBaseID] = useState(settings?.production_knowledge_base_id ?? '') const [dirty, setDirty] = useState(false) useEffect(() => { @@ -156,10 +196,32 @@ export function SystemSettingsView({ sources, connections, monitor, settings, pr setStalled(settings.stalled_after_seconds) setFinalizing(settings.finalizing_after_seconds) setConcurrency(settings.ingestion_concurrency) + setEndpoint(settings.production_weknora_base_url) + setTestEndpoint(settings.test_weknora_base_url) + setProductionTenantID(settings.production_tenant_id) + setProductionKnowledgeBaseID(settings.production_knowledge_base_id) setDirty(false) }, [settings]) const canSave = Boolean(settings && !saving) + const isAdmin = identity?.role === 'admin' + const endpointValid = validServiceEndpoint(endpoint) + const testEndpointValid = validServiceEndpoint(testEndpoint) + const productionTenantIDValid = validProductionTenantID(productionTenantID) + const productionKnowledgeBaseIDValid = validKnowledgeBaseID(productionKnowledgeBaseID) + const serviceSettingsValid = endpointValid && testEndpointValid && productionTenantIDValid && productionKnowledgeBaseIDValid + const serviceSettingsDirty = Boolean(settings && ( + testEndpoint.trim() !== settings.test_weknora_base_url || + endpoint.trim() !== settings.production_weknora_base_url || + productionTenantID.trim() !== settings.production_tenant_id || + productionKnowledgeBaseID.trim() !== settings.production_knowledge_base_id)) + const candidateInput = (overrides: Partial = {}): UISettingsCandidateInput => ({ + document_refresh_seconds: documentRefresh, resource_refresh_seconds: resourceRefresh, + stalled_after_seconds: stalled, finalizing_after_seconds: finalizing, ingestion_concurrency: concurrency, + test_weknora_base_url: testEndpoint.trim(), production_weknora_base_url: endpoint.trim(), + production_tenant_id: productionTenantID.trim(), production_knowledge_base_id: productionKnowledgeBaseID.trim(), ...overrides, + }) + const saveEndpoint = () => { setDirty(false); onSave(candidateInput()) } return

系统设置

查看内容源、服务与备份状态;来源调度与刷新参数采用版本化保存。

{error ?
{error}
: null} @@ -176,11 +238,25 @@ export function SystemSettingsView({ sources, connections, monitor, settings, pr :
尚无可显示的来源连接。
} : null} {section === 'services' ?
-

服务配置

测试、生产必须独立配置端点、凭据引用和知识库 ID;凭据只写不读。

-
测试 WeKnora本地真实连接端点 / 凭据 / 知识库 ID:管理员受限配置,页面上不可回读
生产 WeKnora未连接生产写入:关闭;凭据不可回读
-
测试知识库映射对内解析队列:{routingCapabilities.internal_enabled ? '已配置' : '未配置'}对外解析队列:{routingCapabilities.external_enabled ? '已配置' : '未配置'}生产映射:未配置
- -
{['PostgreSQL', 'MinerU', 'Embedding', 'LLM API', 'Redis / MQ', 'OSS'].map((name) => { const health = (monitor.health || []).find((item) => item.name === name || item.name.startsWith(name)); const service = monitor.services.find((item) => item.name === name || item.name.startsWith(name)); const liveLabel = !health ? (service ? (service.state === 'healthy' ? '正常' : service.state === 'unknown' ? '未接入' : '需关注') : '待配置') : health.readiness === 'ready' ? '可达 · 可接单' : health.readiness === 'not_configured' ? '状态未知 · 未配置' : health.readiness === 'not_applicable' ? '未部署' : (health.liveness === 'up' ? '可达 · 不可接单' : '不可达 · 不可接单'); return
{name}{liveLabel}{health ? [health.reason, health.last_success_at ? `最近成功 ${new Date(health.last_success_at).toLocaleTimeString('zh-CN', { hour12: false })}` : '', health.consecutive_failures ? `连续失败${health.consecutive_failures}次` : ''].filter(Boolean).join(';') || (health.readiness === 'ready' ? `延迟 ${health.latency_ms}ms` : '') : service?.detail || '真实 Provider 未接入'}
})}
+

服务配置

只配四项:测试地址、生产地址、生产 ID、知识库 ID。保存会先生成修订、验证后原子启用,旧版本保留用于审计;凭据只写不读。

+
测试 WeKnora{healthStateLabel(findHealth(monitor, 'WeKnora API'), undefined)}{`${healthStateDetail(findHealth(monitor, 'WeKnora API'), undefined) || '端点探测:未配置'};解析队列 对内 ${routingCapabilities.internal_enabled ? '已配置' : '未配置'} · 对外 ${routingCapabilities.external_enabled ? '已配置' : '未配置'}`}
生产 WeKnora{!productionEnabled ? '未启用' : productionBackupRestoreVerified ? '已启用 · 可提交生产请求' : '已启用 · 被阻断'}{`${productionWeKnoraProbe(monitor)};生产写入:${productionEnabled ? '开启' : '关闭'};发布目标与凭据:管理员受限配置,本页不可回读`}
+
+ {isAdmin ? <> + + + + + : <> + + + + + } +
+ {isAdmin ? <> + 四项留空即表示未配置。地址只接受 http(s) 源地址(不能带用户名密码、查询串或 # 片段,凭据一律走文件引用);生产 ID 是发布契约里的租户 ID,十进制数字;知识库 ID 是生产知识库标识,粘贴 URL 不会通过。保存写入服务器活动版本(版本化 + 审计 + 原子切换);后端同时对测试地址/生产地址做真实探活,并用生产地址真查这两个 ID(WeKnora 列库接口):端点回答"没有这个知识库"或租户不符会拒绝保存,端点抖动、鉴权失败或未配置生产凭据只警告不拦,核验原话与知识库名在保存后显示。 + {serviceSettingsValid ? null :

格式不合法:地址需 http:// 或 https:// 开头的纯地址,不能带用户名密码、? 查询串或 # 片段;生产 ID 需为十进制数字;知识库 ID 不能含空格、/、?、# 或 \。

} + : 服务配置仅管理员可修改;当前值:测试地址 {settings.test_weknora_base_url || '未配置'} · 生产地址 {settings.production_weknora_base_url || '未配置'} · 生产 ID {settings.production_tenant_id || '未配置'} · 知识库 ID {settings.production_knowledge_base_id || '未配置'}。}
: null} {section === 'storage' ?

存储与备份

文件资源与生产发布前快照使用独立受限配置。

@@ -196,7 +272,7 @@ export function SystemSettingsView({ sources, connections, monitor, settings, pr - + {visibleSaveMessage(saveMessage, dirty) ?

{saveMessage}

: null} 当前:文档 {secondsLabel(settings.document_refresh_seconds)}、资源 {secondsLabel(settings.resource_refresh_seconds)}、停滞 {secondsLabel(settings.stalled_after_seconds)}、finalizing {secondsLabel(settings.finalizing_after_seconds)}、入库并发 {settings.ingestion_concurrency}。
: null} @@ -211,6 +287,7 @@ export function SystemSettings() { const [monitor, setMonitor] = useState(null) const [settings, setSettings] = useState(null) const [productionEnabled, setProductionEnabled] = useState(false) + const [productionBackupRestoreVerified, setProductionBackupRestoreVerified] = useState(false) const [routingCapabilities, setRoutingCapabilities] = useState({ internal_enabled: false, external_enabled: false }) const [loading, setLoading] = useState(true) const [saving, setSaving] = useState(false) @@ -220,16 +297,23 @@ export function SystemSettings() { useEffect(() => { const controller = new AbortController(); setLoading(true) - void Promise.all([fetchCatalogSources(controller.signal), fetchSourceConnectionList(controller.signal), fetchMonitorOverview(controller.signal), fetchReleaseCapabilities(controller.signal), fetchCatalogRoutingCapabilities(controller.signal), fetchActiveUISettings(controller.signal)]).then(([nextSources, nextConnections, overview, capabilities, routing, active]) => { if (!controller.signal.aborted) { setSources(nextSources); setConnections(nextConnections); setMonitor(overview); setProductionEnabled(capabilities.production_release_requests_enabled); setRoutingCapabilities(routing); setSettings(active); setError(null) } }).catch((cause) => { if (!controller.signal.aborted) setError(cause instanceof Error ? cause.message : '加载系统设置失败') }).finally(() => { if (!controller.signal.aborted) setLoading(false) }) + void Promise.all([fetchCatalogSources(controller.signal), fetchSourceConnectionList(controller.signal), fetchMonitorOverview(controller.signal), fetchReleaseCapabilities(controller.signal), fetchCatalogRoutingCapabilities(controller.signal), fetchActiveUISettings(controller.signal)]).then(([nextSources, nextConnections, overview, capabilities, routing, active]) => { if (!controller.signal.aborted) { setSources(nextSources); setConnections(nextConnections); setMonitor(overview); setProductionEnabled(capabilities.production_release_requests_enabled); setProductionBackupRestoreVerified(capabilities.production_backup_restore_verified); setRoutingCapabilities(routing); setSettings(active); setError(null) } }).catch((cause) => { if (!controller.signal.aborted) setError(cause instanceof Error ? cause.message : '加载系统设置失败') }).finally(() => { if (!controller.signal.aborted) setLoading(false) }) return () => controller.abort() }, [revision]) const save = async (input: UISettingsCandidateInput) => { setSaving(true); setSaveMessage(null); setError(null) - try { const candidate = await createUISettingsCandidate(input); const active = await activateUISettings(candidate.id); setSettings(active); setSaveMessage(`刷新与告警参数已验证并启用:文档${secondsLabel(active.document_refresh_seconds)}、资源${secondsLabel(active.resource_refresh_seconds)}、停滞${secondsLabel(active.stalled_after_seconds)}、finalizing ${secondsLabel(active.finalizing_after_seconds)}、入库并发 ${active.ingestion_concurrency};旧版本已保留用于审计。`) } + try { + const candidate = await createUISettingsCandidate(input) + const activated = await activateUISettings(candidate.settings.id) + const active = activated.settings + const verification = activated.verification ?? candidate.verification + setSettings(active) + setSaveMessage(`设置已验证并启用:文档${secondsLabel(active.document_refresh_seconds)}、资源${secondsLabel(active.resource_refresh_seconds)}、停滞${secondsLabel(active.stalled_after_seconds)}、finalizing ${secondsLabel(active.finalizing_after_seconds)}、入库并发 ${active.ingestion_concurrency}、测试地址 ${active.test_weknora_base_url || '未配置'}、生产地址 ${active.production_weknora_base_url || '未配置'}、生产 ID ${active.production_tenant_id || '未配置'}、知识库 ID ${active.production_knowledge_base_id || '未配置'};旧版本已保留用于审计。${verification ? `核验:${verification.message}` : ''}`) + } catch (cause) { setError(cause instanceof Error ? cause.message : '保存系统设置失败') } finally { setSaving(false) } } const reloadSources = useCallback(() => { setSaveMessage(null); setRevision((value) => value + 1) }, []) - return void save(input)} onSourcesChanged={reloadSources} onRefresh={reloadSources}/> + return void save(input)} onSourcesChanged={reloadSources} onRefresh={reloadSources}/> } diff --git a/web/src/auth.tsx b/web/src/auth.tsx index d52e1f9..85dddc2 100644 --- a/web/src/auth.tsx +++ b/web/src/auth.tsx @@ -24,7 +24,7 @@ interface AuthContextValue { notifySignedOut: () => void } -const AuthContext = createContext(null) +export const AuthContext = createContext(null) export function useAuth(): AuthContextValue { const value = useContext(AuthContext) diff --git a/web/src/uiSettings.test.ts b/web/src/uiSettings.test.ts index df93bd9..8443d58 100644 --- a/web/src/uiSettings.test.ts +++ b/web/src/uiSettings.test.ts @@ -1,11 +1,19 @@ import { describe, expect, it, vi } from 'vitest' -import { activateUISettings, createUISettingsCandidate, fetchActiveUISettings, parseUISettingsResponse, settingsRefreshDelays } from './uiSettings' +import { activateUISettings, createUISettingsCandidate, fetchActiveUISettings, parseServiceTargetVerification, parseUISettingsResponse, settingsRefreshDelays, UISettingsError } from './uiSettings' + +const candidateInput = { + document_refresh_seconds: 15, resource_refresh_seconds: 30, stalled_after_seconds: 1800, + finalizing_after_seconds: 7200, ingestion_concurrency: 2, + test_weknora_base_url: '', production_weknora_base_url: '', production_tenant_id: '', production_knowledge_base_id: '', +} as const const active = { id: 'settings-default', state: 'active', document_refresh_seconds: 15, resource_refresh_seconds: 30, stalled_after_seconds: 1800, - finalizing_after_seconds: 7200, ingestion_concurrency: 2, updated_by: 'system:migration', + finalizing_after_seconds: 7200, ingestion_concurrency: 2, production_weknora_base_url: '', + test_weknora_base_url: '', production_tenant_id: '', production_knowledge_base_id: '', + updated_by: 'system:migration', created_at: '2026-09-10T08:00:00Z', activated_at: '2026-09-10T08:00:00Z', retired_at: null, error_code: '', } @@ -16,6 +24,25 @@ describe('UI settings API', () => { expect(() => parseUISettingsResponse({ data: { ...active, document_refresh_seconds: 31 } })).toThrow('invalid UI settings response') }) + it('accepts only the four service fields with their own rules', () => { + const parsed = parseUISettingsResponse({ data: { ...active, test_weknora_base_url: 'https://weknora.test.example:8443/kb', production_tenant_id: '10000', production_knowledge_base_id: 'kb-abc_123' } }) + expect(parsed.test_weknora_base_url).toBe('https://weknora.test.example:8443/kb') + expect(parsed.production_tenant_id).toBe('10000') + expect(parsed.production_knowledge_base_id).toBe('kb-abc_123') + for (const bad of ['weknora.test.example', 'https://user:pass@weknora.test.example', 'https://weknora.test.example?token=abc']) + expect(() => parseUISettingsResponse({ data: { ...active, test_weknora_base_url: bad } })).toThrow('invalid UI settings response') + for (const bad of ['abc', '10 000', '-1', '0', '10000000000000000000000', '10000.5']) + expect(() => parseUISettingsResponse({ data: { ...active, production_tenant_id: bad } })).toThrow('invalid UI settings response') + for (const bad of ['kb/abc', 'kb abc', 'kb-abc?x', 'https://weknora.prod.example/kb']) + expect(() => parseUISettingsResponse({ data: { ...active, production_knowledge_base_id: bad } })).toThrow('invalid UI settings response') + }) + + it('keeps a web-configured production endpoint only when it is a bare http(s) origin', () => { + expect(parseUISettingsResponse({ data: { ...active, production_weknora_base_url: 'https://weknora.prod.example:8443/kb' } }).production_weknora_base_url).toBe('https://weknora.prod.example:8443/kb') + for (const bad of ['weknora.prod.example', 'https://user:pass@weknora.prod.example', 'https://weknora.prod.example?token=abc', 'https://weknora.prod.example#frag', ' https://weknora.prod.example']) + expect(() => parseUISettingsResponse({ data: { ...active, production_weknora_base_url: bad } })).toThrow('invalid UI settings response') + }) + it('converts the active revision into independent monitor delays', () => { expect(settingsRefreshDelays({ ...active, document_refresh_seconds: 30, resource_refresh_seconds: 60 })).toEqual({ documentMs: 30000, resourceMs: 60000 }) }) @@ -29,14 +56,59 @@ describe('UI settings API', () => { vi.stubGlobal('fetch', fetchMock) await fetchActiveUISettings() - await createUISettingsCandidate({ document_refresh_seconds: 30, resource_refresh_seconds: 60, stalled_after_seconds: 3600, finalizing_after_seconds: 14400, ingestion_concurrency: 3 }) + await createUISettingsCandidate({ document_refresh_seconds: 30, resource_refresh_seconds: 60, stalled_after_seconds: 3600, finalizing_after_seconds: 14400, ingestion_concurrency: 3, test_weknora_base_url: '', production_weknora_base_url: '', production_tenant_id: '', production_knowledge_base_id: '' }) await activateUISettings('uis-candidate') expect(fetchMock.mock.calls[0][0]).toBe('/api/v1/ui-settings') expect(fetchMock.mock.calls[1][0]).toBe('/api/v1/ui-settings/candidates') - expect(fetchMock.mock.calls[1][1]).toMatchObject({ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ document_refresh_seconds: 30, resource_refresh_seconds: 60, stalled_after_seconds: 3600, finalizing_after_seconds: 14400, ingestion_concurrency: 3 }) }) + expect(fetchMock.mock.calls[1][1]).toMatchObject({ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ document_refresh_seconds: 30, resource_refresh_seconds: 60, stalled_after_seconds: 3600, finalizing_after_seconds: 14400, ingestion_concurrency: 3, test_weknora_base_url: '', production_weknora_base_url: '', production_tenant_id: '', production_knowledge_base_id: '' }) }) expect(fetchMock.mock.calls[2][0]).toBe('/api/v1/ui-settings/uis-candidate/activate') expect(fetchMock.mock.calls[2][1]).toMatchObject({ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' }) vi.unstubAllGlobals() }) + + // 保存的返回值带上真实核验结果:界面要能显示"用的是哪个知识库、属于哪个租户"。 + it('returns the real verification block produced while saving', async () => { + const candidate = { ...active, id: 'uis-candidate', state: 'validated', activated_at: null } + const verification = { + status: 'verified', message: '已核验:生产地址上存在知识库「生产知识库」,租户 10000 一致', blocked: false, + knowledge_base_name: '生产知识库', observed_tenant_id: '10000', + test_endpoint: { status: 'reachable', message: '测试地址可达(可列出 7 个知识库)' }, + production_endpoint: { status: 'reachable', message: '生产地址可达(可列出 7 个知识库)' }, + checked_at: '2026-09-18T09:00:00Z', + } + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(JSON.stringify({ data: candidate, operation: 'created', verification }), { status: 201 }))) + const saved = await createUISettingsCandidate(candidateInput) + expect(saved.settings.id).toBe('uis-candidate') + expect(saved.verification).toEqual(verification) + vi.unstubAllGlobals() + }) + + // 被拒绝时保留服务器那句话,并带上核验详情,而不是退化成 "request failed (400)"。 + it('keeps a refusal sentence and its detail instead of a status code', async () => { + const verification = { + status: 'missing', message: '未通过:生产地址上找不到知识库 ID「kb-prod-1」;该地址现有 1 个知识库:生产知识库(kb-prod-1)', blocked: true, + knowledge_base_name: '', observed_tenant_id: '', + test_endpoint: { status: 'reachable', message: '测试地址可达' }, + production_endpoint: { status: 'reachable', message: '生产地址可达' }, + checked_at: '2026-09-18T09:00:00Z', + } + vi.stubGlobal('fetch', vi.fn().mockImplementation(() => Promise.resolve(new Response(JSON.stringify({ error: '未通过:生产地址上找不到知识库 ID「kb-prod-1」', verification }), { status: 400 })))) + await expect(createUISettingsCandidate(candidateInput)).rejects.toThrow('找不到知识库 ID') + const failure = await createUISettingsCandidate(candidateInput).catch((cause: unknown) => cause) + expect(failure).toBeInstanceOf(UISettingsError) + expect((failure as UISettingsError).verification?.status).toBe('missing') + expect((failure as UISettingsError).verification?.blocked).toBe(true) + vi.unstubAllGlobals() + }) + + // 没有核验块(未配置核验器的开发 socket)或格式不对时,一律当作"没有核验",绝不编造"已核验"。 + it('treats a missing or malformed verification block as no verification', () => { + expect(parseServiceTargetVerification(undefined)).toBeNull() + expect(parseServiceTargetVerification({ status: 'verified' })).toBeNull() + expect(parseServiceTargetVerification({ status: 'verified', message: 'x', blocked: false, test_endpoint: 'nope' })).toEqual({ + status: 'verified', message: 'x', blocked: false, knowledge_base_name: '', observed_tenant_id: '', + test_endpoint: { status: '', message: '' }, production_endpoint: { status: '', message: '' }, checked_at: '', + }) + }) }) diff --git a/web/src/uiSettings.ts b/web/src/uiSettings.ts index b890f50..ee726b3 100644 --- a/web/src/uiSettings.ts +++ b/web/src/uiSettings.ts @@ -7,6 +7,10 @@ export interface UISettings { stalled_after_seconds: 1800 | 3600 finalizing_after_seconds: 7200 | 14400 ingestion_concurrency: number + test_weknora_base_url: string + production_weknora_base_url: string + production_tenant_id: string + production_knowledge_base_id: string updated_by: string created_at: string activated_at: string | null @@ -20,6 +24,78 @@ export interface UISettingsCandidateInput { stalled_after_seconds: UISettings['stalled_after_seconds'] finalizing_after_seconds: UISettings['finalizing_after_seconds'] ingestion_concurrency: number + test_weknora_base_url: string + production_weknora_base_url: string + production_tenant_id: string + production_knowledge_base_id: string +} + +// 保存服务配置时后端会用生产地址真查一次:找到了才写入,并把知识库名回显; +// 端点明确回答"不存在"会拒绝保存(HTTP 400 + 这句 message),端点抖动只警告不拦。 +export interface ServiceEndpointStatus { + status: string + message: string +} + +export interface ServiceTargetVerification { + status: string + message: string + blocked: boolean + knowledge_base_name: string + observed_tenant_id: string + test_endpoint: ServiceEndpointStatus + production_endpoint: ServiceEndpointStatus + checked_at: string +} + +export interface ServiceSettingsSave { + settings: UISettings + verification: ServiceTargetVerification | null +} + +export class UISettingsError extends Error { + readonly verification: ServiceTargetVerification | null + + constructor(message: string, verification: ServiceTargetVerification | null) { + super(message) + this.name = 'UISettingsError' + this.verification = verification + } +} + +// Both environments are operator-provided endpoints typed in the console. +// Accepting only a bare http(s) origin keeps credentials, query strings and +// fragments out of stored configuration; the backend enforces the same rule. +export function validServiceEndpoint(value: string): boolean { + const trimmed = value.trim() + if (trimmed === '') return true + if (trimmed !== value || trimmed.length > 300) return false + try { + const parsed = new URL(trimmed) + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return false + if (parsed.username || parsed.password || parsed.search || parsed.hash) return false + return parsed.host.length > 0 + } catch { + return false + } +} + +// The production tenant of the target WeKnora is a decimal id; a sign, space or +// letter is a typing mistake, never a valid tenant. +export function validProductionTenantID(value: string): boolean { + if (value.trim() === '') return true + if (value !== value.trim() || value.length > 20) return false + if (!/^[0-9]+$/.test(value)) return false + return Number(value) > 0 +} + +// The target knowledge base is an opaque id (letters, digits, '.', '_', '-', +// ':'). A pasted URL must not pass as an id, so paths, queries and whitespace +// are rejected here and on the server. +export function validKnowledgeBaseID(value: string): boolean { + if (value.trim() === '') return true + if (value !== value.trim() || value.length > 128) return false + return !/[\x00\r\n\t /?#\\]/.test(value) } export function settingsRefreshDelays(settings: Pick) { @@ -43,6 +119,10 @@ export function parseUISettingsResponse(value: unknown): UISettings { typeof item.stalled_after_seconds !== 'number' || !stalledAfter.has(item.stalled_after_seconds) || typeof item.finalizing_after_seconds !== 'number' || !finalizingAfter.has(item.finalizing_after_seconds) || typeof item.ingestion_concurrency !== 'number' || !Number.isInteger(item.ingestion_concurrency) || item.ingestion_concurrency < 1 || item.ingestion_concurrency > 16 || + typeof item.test_weknora_base_url !== 'string' || !validServiceEndpoint(item.test_weknora_base_url) || + typeof item.production_weknora_base_url !== 'string' || !validServiceEndpoint(item.production_weknora_base_url) || + typeof item.production_tenant_id !== 'string' || !validProductionTenantID(item.production_tenant_id) || + typeof item.production_knowledge_base_id !== 'string' || !validKnowledgeBaseID(item.production_knowledge_base_id) || typeof item.updated_by !== 'string' || typeof item.created_at !== 'string' || (item.activated_at !== null && typeof item.activated_at !== 'string') || (item.retired_at !== null && typeof item.retired_at !== 'string') || typeof item.error_code !== 'string') { @@ -56,6 +136,10 @@ export function parseUISettingsResponse(value: unknown): UISettings { stalled_after_seconds: item.stalled_after_seconds as UISettings['stalled_after_seconds'], finalizing_after_seconds: item.finalizing_after_seconds as UISettings['finalizing_after_seconds'], ingestion_concurrency: item.ingestion_concurrency, + test_weknora_base_url: item.test_weknora_base_url, + production_weknora_base_url: item.production_weknora_base_url, + production_tenant_id: item.production_tenant_id, + production_knowledge_base_id: item.production_knowledge_base_id, updated_by: item.updated_by, created_at: item.created_at, activated_at: item.activated_at as string | null, @@ -64,24 +148,60 @@ export function parseUISettingsResponse(value: unknown): UISettings { } } +function parseEndpointStatus(value: unknown): ServiceEndpointStatus { + if (!isRecord(value) || typeof value.status !== 'string' || typeof value.message !== 'string') return { status: '', message: '' } + return { status: value.status, message: value.message } +} + +// 核验块是诚实声明,解析失败按"没有核验"处理,绝不编造"已核验"。 +export function parseServiceTargetVerification(value: unknown): ServiceTargetVerification | null { + if (!isRecord(value) || typeof value.status !== 'string' || typeof value.message !== 'string' || typeof value.blocked !== 'boolean') return null + return { + status: value.status, + message: value.message, + blocked: value.blocked, + knowledge_base_name: typeof value.knowledge_base_name === 'string' ? value.knowledge_base_name : '', + observed_tenant_id: typeof value.observed_tenant_id === 'string' ? value.observed_tenant_id : '', + test_endpoint: parseEndpointStatus(value.test_endpoint), + production_endpoint: parseEndpointStatus(value.production_endpoint), + checked_at: typeof value.checked_at === 'string' ? value.checked_at : '', + } +} + +async function readPayload(response: Response): Promise> { + if (response.ok) { + const payload: unknown = await response.json() + return isRecord(payload) ? payload : {} + } + // 拒绝保存时服务器给的就是要展示的那句话,别再退化成状态码。 + const failure: unknown = await response.json().catch(() => null) + const record = isRecord(failure) ? failure : {} + const message = typeof record.error === 'string' && record.error.trim() !== '' ? record.error : `UI settings request failed (${response.status})` + throw new UISettingsError(message, parseServiceTargetVerification(record.verification)) +} + async function readSettings(response: Response): Promise { - if (!response.ok) throw new Error(`UI settings request failed (${response.status})`) - return parseUISettingsResponse(await response.json()) + return parseUISettingsResponse(await readPayload(response)) +} + +async function readSettingsSave(response: Response): Promise { + const payload = await readPayload(response) + return { settings: parseUISettingsResponse(payload), verification: parseServiceTargetVerification(payload.verification) } } export async function fetchActiveUISettings(signal?: AbortSignal): Promise { return readSettings(await fetch('/api/v1/ui-settings', { signal })) } -export async function createUISettingsCandidate(input: UISettingsCandidateInput): Promise { - return readSettings(await fetch('/api/v1/ui-settings/candidates', { +export async function createUISettingsCandidate(input: UISettingsCandidateInput): Promise { + return readSettingsSave(await fetch('/api/v1/ui-settings/candidates', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Governance-Action': 'ui-settings', 'Idempotency-Key': newOperationID() }, body: JSON.stringify(input), })) } -export async function activateUISettings(revisionID: string): Promise { +export async function activateUISettings(revisionID: string): Promise { if (!/^[A-Za-z0-9._:-]{2,36}$/.test(revisionID)) throw new Error('invalid UI settings revision') - return readSettings(await fetch(`/api/v1/ui-settings/${encodeURIComponent(revisionID)}/activate`, { + return readSettingsSave(await fetch(`/api/v1/ui-settings/${encodeURIComponent(revisionID)}/activate`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Governance-Action': 'ui-settings', 'Idempotency-Key': newOperationID() }, body: '{}', })) -} +} \ No newline at end of file