feat: 核心模块增强 — 加密 + 运行时配置 + 日志缓冲
- crypto.py: API Key 加密/解密工具 - runtime_config.py: 运行时动态配置管理 - log_buffer.py: 内存日志缓冲区 - config.py: 新增加密配置项 - http_client.py: 增强重试和错误处理
This commit is contained in:
@@ -2,58 +2,113 @@
|
||||
* Admin 后台 - 数据源管理页面(报刊风)
|
||||
*
|
||||
* 功能:
|
||||
* - 显示当前数据源状态 (bzzoiro / understat / injuries)
|
||||
* - 显示 API Key 配置状态(脱敏显示)
|
||||
* - 测试连接按钮(调用采集 API 验证)
|
||||
* - 数据源说明
|
||||
* - 显示各数据源配置状态(脱敏),标明值来源:DB 覆盖 / .env 默认 / 未配置
|
||||
* - 在线修改数据源 API Key(写入 app_settings,覆盖 .env;清除则回落)
|
||||
* - 测试连接按钮(后端真实请求上游一次,不触发入库)
|
||||
*/
|
||||
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import { fetchDataSourceStatuses, testDataSource } from '../dal'
|
||||
import type { DataSourceStatus } from '../types'
|
||||
import {
|
||||
fetchDataSourceStatuses,
|
||||
updateSetting,
|
||||
clearSetting,
|
||||
testDataSourceConnection,
|
||||
} from '../dal'
|
||||
import type { DataSourceStatus, DataSourceTestResult } from '../types'
|
||||
import SettingRow from '../SettingRow'
|
||||
import { Card, CardBody, CardHeader, Badge, SectionHeader, Alert, Spinner, SkeletonBlock } from '../components'
|
||||
|
||||
function formatTime(iso: string | null): string {
|
||||
if (!iso) return '暂无记录'
|
||||
try {
|
||||
return new Date(iso).toLocaleString('zh-CN', { hour12: false })
|
||||
} catch {
|
||||
return iso
|
||||
}
|
||||
}
|
||||
|
||||
export default function DataSourcesPage() {
|
||||
const [sources, setSources] = useState<DataSourceStatus[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [loadError, setLoadError] = useState('')
|
||||
|
||||
const [testingSource, setTestingSource] = useState<string | null>(null)
|
||||
const [testResults, setTestResults] = useState<Record<string, { success: boolean; message: string }>>({})
|
||||
const [testResults, setTestResults] = useState<Record<string, DataSourceTestResult>>({})
|
||||
|
||||
const [editingKey, setEditingKey] = useState<string | null>(null)
|
||||
const [busyKey, setBusyKey] = useState<string | null>(null)
|
||||
const [rowNotice, setRowNotice] = useState<{ key: string; ok: boolean; text: string } | null>(null)
|
||||
|
||||
const loadSources = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setLoadError('')
|
||||
try {
|
||||
const data = await fetchDataSourceStatuses()
|
||||
setSources(data)
|
||||
} catch {
|
||||
setSources(await fetchDataSourceStatuses())
|
||||
} catch (err) {
|
||||
setLoadError(err instanceof Error ? err.message.split('\n')[0] : '加载失败')
|
||||
setSources([])
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => { loadSources() }, [loadSources])
|
||||
useEffect(() => {
|
||||
loadSources()
|
||||
}, [loadSources])
|
||||
|
||||
async function handleTest(sourceName: string) {
|
||||
setTestingSource(sourceName)
|
||||
setTestResults(prev => ({ ...prev, [sourceName]: { success: false, message: '测试中...' } }))
|
||||
try {
|
||||
await testDataSource(sourceName as 'bzzoiro' | 'understat' | 'injuries')
|
||||
setTestResults(prev => ({ ...prev, [sourceName]: { success: true, message: '连接成功' } }))
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : '连接失败'
|
||||
setTestResults(prev => ({ ...prev, [sourceName]: { success: false, message: msg } }))
|
||||
const result = await testDataSourceConnection(sourceName)
|
||||
setTestResults(prev => ({ ...prev, [sourceName]: result }))
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message.split('\n')[0] : '连接失败'
|
||||
setTestResults(prev => ({ ...prev, [sourceName]: { ok: false, status: null, latency_ms: 0, detail: msg } }))
|
||||
} finally {
|
||||
setTestingSource(null)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSave(key: string, value: string) {
|
||||
setBusyKey(key)
|
||||
setRowNotice(null)
|
||||
try {
|
||||
await updateSetting(key, value)
|
||||
setRowNotice({ key, ok: true, text: '已保存,立即生效' })
|
||||
setEditingKey(null)
|
||||
await loadSources()
|
||||
} catch (err) {
|
||||
setRowNotice({ key, ok: false, text: err instanceof Error ? err.message.split('\n')[0] : '保存失败' })
|
||||
} finally {
|
||||
setBusyKey(null)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleClear(key: string) {
|
||||
setBusyKey(key)
|
||||
setRowNotice(null)
|
||||
try {
|
||||
await clearSetting(key)
|
||||
setRowNotice({ key, ok: true, text: '已清除数据库覆盖,回落 .env 默认值' })
|
||||
await loadSources()
|
||||
} catch (err) {
|
||||
setRowNotice({ key, ok: false, text: err instanceof Error ? err.message.split('\n')[0] : '清除失败' })
|
||||
} finally {
|
||||
setBusyKey(null)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<SectionHeader
|
||||
title="数据源管理"
|
||||
description="数据采集源的配置状态与连通性测试。"
|
||||
description="数据采集源的 API 配置与连通性测试。修改保存到数据库并立即生效,无需重启;「回落 .env」删除覆盖值。"
|
||||
/>
|
||||
|
||||
{loadError && (
|
||||
<Alert kind="error" title="无法加载数据源配置" message={loadError} />
|
||||
)}
|
||||
|
||||
{/* 数据源卡片 */}
|
||||
{loading ? (
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
@@ -73,39 +128,73 @@ export default function DataSourcesPage() {
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{sources.map(source => {
|
||||
const result = testResults[source.name]
|
||||
const cardKeys = source.settings.map(s => s.key)
|
||||
return (
|
||||
<Card key={source.name}>
|
||||
<CardBody className="space-y-4">
|
||||
{/* 头部 */}
|
||||
<div className="flex items-center justify-between border-b border-ink-200 pb-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-x-2 gap-y-1 border-b border-ink-200 pb-3">
|
||||
<h3 className="font-serif text-sm font-bold text-ink-900">{source.label}</h3>
|
||||
<Badge status={source.keyConfigured ? 'success' : 'error'}>
|
||||
{source.keyConfigured ? '已配置' : '未配置'}
|
||||
<Badge status={source.key_configured ? 'success' : 'error'}>
|
||||
{source.key_configured ? '已就绪' : '缺配置'}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* API Key 状态 */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="text-ink-400">API Key</span>
|
||||
<span className="font-mono text-ink-600">{source.maskedKey}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="text-ink-400">最近采集</span>
|
||||
<span className="text-ink-600">{source.lastIngestion || '暂无记录'}</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-2xs leading-relaxed text-ink-500">{source.description}</p>
|
||||
|
||||
{/* 测试结果 */}
|
||||
{result && (
|
||||
{/* 配置项 */}
|
||||
{source.settings.length > 0 ? (
|
||||
<div>
|
||||
{source.settings.map(setting => (
|
||||
<SettingRow
|
||||
key={setting.key}
|
||||
setting={setting}
|
||||
editing={editingKey === setting.key}
|
||||
busy={busyKey === setting.key}
|
||||
onEdit={() => {
|
||||
setEditingKey(setting.key)
|
||||
setRowNotice(null)
|
||||
}}
|
||||
onCancel={() => setEditingKey(null)}
|
||||
onSave={v => handleSave(setting.key, v)}
|
||||
onClear={() => handleClear(setting.key)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-2xs text-ink-400">无需 API Key</p>
|
||||
)}
|
||||
|
||||
{/* 行级操作提示 */}
|
||||
{rowNotice && cardKeys.includes(rowNotice.key) && (
|
||||
<Alert
|
||||
kind={result.success ? 'ok' : 'error'}
|
||||
title={result.success ? '连接成功' : '连接失败'}
|
||||
message={result.success ? undefined : result.message}
|
||||
kind={rowNotice.ok ? 'ok' : 'error'}
|
||||
title={rowNotice.text}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 操作按钮 */}
|
||||
{/* 最近采集 */}
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="text-ink-400">最近采集</span>
|
||||
<span className="text-ink-600">{formatTime(source.last_ingestion)}</span>
|
||||
</div>
|
||||
|
||||
{/* 测试结果(进行中不渲染,避免占位被误读为失败) */}
|
||||
{result && testingSource !== source.name && (
|
||||
<Alert
|
||||
kind={result.ok ? 'ok' : 'error'}
|
||||
title={
|
||||
result.ok
|
||||
? `连接成功(${result.latency_ms}ms)`
|
||||
: result.status
|
||||
? `HTTP ${result.status}`
|
||||
: '连接失败'
|
||||
}
|
||||
message={result.ok ? undefined : result.detail}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 测试按钮 */}
|
||||
<button
|
||||
onClick={() => handleTest(source.name)}
|
||||
disabled={testingSource === source.name}
|
||||
@@ -120,29 +209,24 @@ export default function DataSourcesPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 数据源说明 */}
|
||||
{/* 配置说明 */}
|
||||
<Card>
|
||||
<CardHeader title="数据源说明" />
|
||||
<CardHeader title="配置说明" />
|
||||
<CardBody>
|
||||
<div className="grid gap-x-6 gap-y-3 sm:grid-cols-3">
|
||||
<div className="border-t border-ink-200 pt-3">
|
||||
<Badge status="info">Bzzoiro</Badge>
|
||||
<p className="mt-2 text-xs leading-relaxed text-ink-600">
|
||||
历史赛程与比分数据,覆盖全球主要联赛。需要 API Key 配置。
|
||||
</p>
|
||||
</div>
|
||||
<div className="border-t border-ink-200 pt-3">
|
||||
<Badge status="info">Understat</Badge>
|
||||
<p className="mt-2 text-xs leading-relaxed text-ink-600">
|
||||
xG(预期进球)进阶数据,无需 API Key,通过网页抓取获取。
|
||||
</p>
|
||||
</div>
|
||||
<div className="border-t border-ink-200 pt-3">
|
||||
<Badge status="info">Injuries</Badge>
|
||||
<p className="mt-2 text-xs leading-relaxed text-ink-600">
|
||||
球员伤停信息,用于预测时考虑阵容完整性。需要 API Key 配置。
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-3 text-xs leading-relaxed text-ink-600">
|
||||
<p className="border-l-2 border-ink-300 pl-3">
|
||||
在此保存的配置存于数据库 <code className="font-mono">app_settings</code> 表并<b>立即生效</b>,
|
||||
优先于服务器 <code className="font-mono">.env</code> 中的同名变量;点「回落 .env」删除覆盖值。
|
||||
若两者都未配置,相应采集功能会报「Key 未设置」。
|
||||
</p>
|
||||
<p className="border-l-2 border-ink-300 pl-3">
|
||||
敏感值只显示末 4 位(不足 8 位全遮),完整值不回传浏览器。
|
||||
</p>
|
||||
<p className="border-l-2 border-ink-300 pl-3">
|
||||
「测试连接」会真实请求上游接口一次:连通且密钥有效 → 成功并显示耗时;
|
||||
HTTP 401/403 → 密钥无效;其他状态码或超时 → 按详情提示排查。
|
||||
测试不写入任何数据。
|
||||
</p>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
Reference in New Issue
Block a user