3e7fcce0b1
build / build (push) Successful in 1m11s
新增迁移50与每5分钟/手动持久同步,API只读本地;同步失败保留、权限阻断、租约与质量失效原子激活。 保留全量稳定分页/固定列,已存质量批量读取,元数据不反复解压长答案;详情安全Markdown与原文。 已运行整仓 make verify、源与副本隔离PG -race、真实只读源及隔离浏览器。 实际同步2525条;固定窗口640条本地中位1.46秒,对照直接源33.10秒。 停worker、缺源凭据、重启API后仍可查询;未更新8088,未修改生产原问答。
87 lines
3.5 KiB
TypeScript
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))
|