Files
knowledge-governance/web/src/qaMirror.ts
T
姚章浩 3e7fcce0b1
build / build (push) Successful in 1m11s
feat(qa-ops): 持久本地问答副本与安全答案预览
新增迁移50与每5分钟/手动持久同步,API只读本地;同步失败保留、权限阻断、租约与质量失效原子激活。
保留全量稳定分页/固定列,已存质量批量读取,元数据不反复解压长答案;详情安全Markdown与原文。

已运行整仓 make verify、源与副本隔离PG -race、真实只读源及隔离浏览器。
实际同步2525条;固定窗口640条本地中位1.46秒,对照直接源33.10秒。
停worker、缺源凭据、重启API后仍可查询;未更新8088,未修改生产原问答。
2026-10-18 19:10:47 +08:00

87 lines
3.5 KiB
TypeScript

import { getPayload } from './qaOps'
export interface QAMirrorStatus {
mode: 'local'
ready: boolean
blocked: boolean
revision: number
records: number
source_observed_at: string | null
last_success_at: string | null
next_sync_at: string | null
error_code: string
interval_seconds: 300
worker_ready: boolean
job: { id: string; state: string; loaded: number; expected: number; changed: number; error_code: string } | null
}
const invalid = (): never => {
throw new Error('本地问答同步响应无效,请检查接口契约')
}
const object = (v: unknown): Record<string, unknown> =>
v && typeof v === 'object' && !Array.isArray(v) ? (v as Record<string, unknown>) : invalid()
const text = (v: unknown): string => (typeof v === 'string' ? v : invalid())
const count = (v: unknown): number => (typeof v === 'number' && Number.isSafeInteger(v) && v >= 0 ? v : invalid())
const bool = (v: unknown): boolean => (typeof v === 'boolean' ? v : invalid())
const stamp = (v: unknown): string | null =>
v === null ? null : typeof v === 'string' && Number.isFinite(Date.parse(v)) ? v : invalid()
export function parseQAMirrorStatus(value: unknown): QAMirrorStatus {
const v = object(value)
if (v.mode !== 'local' || v.interval_seconds !== 300) return invalid()
let job: QAMirrorStatus['job'] = null
if (v.job !== null) {
const j = object(v.job)
const state = text(j.state)
if (!['pending', 'running', 'retry', 'completed', 'failed', 'cancelled'].includes(state)) return invalid()
job = {
id: text(j.id),
state,
loaded: count(j.loaded),
expected: count(j.expected),
changed: count(j.changed),
error_code: text(j.error_code),
}
if (!job.id || job.loaded > job.expected || job.changed > job.loaded) return invalid()
}
const out: QAMirrorStatus = {
mode: 'local',
ready: bool(v.ready),
blocked: bool(v.blocked),
revision: count(v.revision),
records: count(v.records),
source_observed_at: stamp(v.source_observed_at),
last_success_at: stamp(v.last_success_at),
next_sync_at: stamp(v.next_sync_at),
error_code: text(v.error_code),
interval_seconds: 300,
worker_ready: bool(v.worker_ready),
job,
}
if (out.ready && (!out.last_success_at || !out.source_observed_at || out.revision < 1)) return invalid()
if (!out.ready && (out.records !== 0 || out.revision !== 0 || out.last_success_at !== null)) return invalid()
return out
}
export async function fetchQAMirrorStatus(signal?: AbortSignal): Promise<QAMirrorStatus> {
return parseQAMirrorStatus(await getPayload('/api/v1/qa-ops/sync', signal))
}
export async function startQAMirrorSync(key: string, signal?: AbortSignal): Promise<string> {
const data = object(
await getPayload('/api/v1/qa-ops/sync', signal, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-Governance-Action': 'qa-records-sync', 'Idempotency-Key': key },
body: '{}',
})
)
const id = text(data.job_id)
if (!id || typeof data.deduplicated !== 'boolean') return invalid()
return id
}
export function mirrorStateChanged(
previous: { revision: number; blocked: boolean; ready: boolean } | undefined,
current: { revision: number; blocked: boolean; ready: boolean }
): boolean {
return previous
? previous.revision !== current.revision || previous.blocked !== current.blocked || previous.ready !== current.ready
: current.ready
}
export const mirrorSyncActive = (s: QAMirrorStatus | null) =>
Boolean(s?.job && ['pending', 'running', 'retry'].includes(s.job.state))