refactor(architecture): Align frontend & docs with DB gateway pattern

本次提交旨在完成一次架构一致性重构,核心目标是使前端代码和相关文档完全符合“`data-persistence-service`是唯一数据库守门人”的设计原则。

主要变更包括:
1.  **移除前端数据库直连**:
    *   从`docker-compose.yml`中删除了`frontend`服务的`DATABASE_URL`。
    *   彻底移除了`frontend`项目中的`Prisma`依赖、配置文件和客户端实例。
2.  **清理前端UI**:
    *   从配置页面中删除了所有与数据库设置相关的UI组件和业务逻辑。
3.  **同步更新文档**:
    *   更新了《用户使用文档》和《需求文档》,移除了所有提及或要求前端进行数据库配置的过时内容。

此次重构后,系统前端的数据交互已完全收敛至`api-gateway`,确保了架构的统一性、健壮性和高内聚。
This commit is contained in:
Lv, Qi 2025-11-17 01:29:56 +08:00
parent 45ec5bb16d
commit 9d62a53b73
15 changed files with 250 additions and 235 deletions

View File

@ -0,0 +1,51 @@
# THIS FILE IS AUTOMATICALLY GENERATED BY CARGO
#
# When uploading crates to the registry Cargo will automatically
# "normalize" Cargo.toml files for maximal compatibility
# with all versions of Cargo and also rewrite `path` dependencies
# to registry (e.g., crates.io) dependencies.
#
# If you are reading this file be aware that the original Cargo.toml
# will likely look very different (and much more reasonable).
# See Cargo.toml.orig for the original contents.
[package]
edition = "2024"
rust-version = "1.63"
name = "thread_local"
version = "1.1.9"
authors = ["Amanieu d'Antras <amanieu@gmail.com>"]
build = false
autolib = false
autobins = false
autoexamples = false
autotests = false
autobenches = false
description = "Per-object thread-local storage"
documentation = "https://docs.rs/thread_local/"
readme = "README.md"
keywords = [
"thread_local",
"concurrent",
"thread",
]
license = "MIT OR Apache-2.0"
repository = "https://github.com/Amanieu/thread_local-rs"
[features]
nightly = []
[lib]
name = "thread_local"
path = "src/lib.rs"
[[bench]]
name = "thread_local"
path = "benches/thread_local.rs"
harness = false
[dependencies.cfg-if]
version = "1.0.0"
[dev-dependencies.criterion]
version = "0.5.1"

View File

@ -55,8 +55,6 @@ services:
environment: environment:
# 让 Next 的 API 路由代理到新的 api-gateway # 让 Next 的 API 路由代理到新的 api-gateway
NEXT_PUBLIC_BACKEND_URL: http://api-gateway:4000/v1 NEXT_PUBLIC_BACKEND_URL: http://api-gateway:4000/v1
# Prisma 直连数据库(与后端共用同一库)
DATABASE_URL: postgresql://postgres:postgres@postgres-db:5432/fundamental?schema=public
NODE_ENV: development NODE_ENV: development
NEXT_TELEMETRY_DISABLED: "1" NEXT_TELEMETRY_DISABLED: "1"
volumes: volumes:
@ -66,7 +64,6 @@ services:
ports: ports:
- "13001:3001" - "13001:3001"
depends_on: depends_on:
- postgres-db
- api-gateway - api-gateway
networks: networks:
- app-network - app-network

View File

@ -104,8 +104,7 @@
#### 验收标准 #### 验收标准
1. 选股系统应当提供配置页面用于设置数据库连接参数 1. 选股系统应当提供配置页面用于设置Gemini_API密钥
2. 选股系统应当提供配置页面用于设置Gemini_API密钥 2. 选股系统应当提供配置页面用于设置各市场的数据源配置
3. 选股系统应当提供配置页面用于设置各市场的数据源配置 3. 当配置更新时,选股系统应当验证配置的有效性
4. 当配置更新时,选股系统应当验证配置的有效性 4. 当配置保存时,选股系统应当将配置持久化存储
5. 当配置保存时,选股系统应当将配置持久化存储

View File

@ -73,7 +73,6 @@
系统提供完善的配置管理功能: 系统提供完善的配置管理功能:
- **数据库配置**:配置 PostgreSQL 数据库连接
- **AI 服务配置**:配置 AI 模型的 API 密钥和端点 - **AI 服务配置**:配置 AI 模型的 API 密钥和端点
- **数据源配置**:配置 Tushare、Finnhub 等数据源的 API 密钥 - **数据源配置**:配置 Tushare、Finnhub 等数据源的 API 密钥
- **分析模块配置**:自定义分析模块的名称、模型和提示词模板 - **分析模块配置**:自定义分析模块的名称、模型和提示词模板
@ -221,15 +220,11 @@ A:
首次使用系统时,需要配置以下内容: 首次使用系统时,需要配置以下内容:
1. **数据库配置**(如使用) 1. **AI 服务配置**
- 数据库连接 URL`postgresql+asyncpg://user:password@host:port/database`
- 使用"测试连接"按钮验证连接
2. **AI 服务配置**
- API Key输入您的 AI 服务 API 密钥 - API Key输入您的 AI 服务 API 密钥
- Base URL输入 API 端点地址(如使用自建服务) - Base URL输入 API 端点地址(如使用自建服务)
3. **数据源配置** 2. **数据源配置**
- **Tushare**:输入 Tushare API Key中国市场必需 - **Tushare**:输入 Tushare API Key中国市场必需
- **Finnhub**:输入 Finnhub API Key全球市场可选 - **Finnhub**:输入 Finnhub API Key全球市场可选

View File

@ -0,0 +1,64 @@
# 项目文档中心
欢迎来到基本面选股系统的文档中心。本文档旨在作为项目所有相关文档的入口和导航,帮助团队成员快速找到所需信息。
## 概览
本文档库遵循特定的结构化命名和分类约定,旨在清晰地分离不同领域的关注点。主要目录结构如下:
- **/1_requirements**: 存放所有与产品需求和用户功能相关的文档。
- **/2_architecture**: 包含系统高级架构、设计原则和核心规范。
- **/3_project_management**: 用于项目跟踪、开发日志和任务管理。
- **/4_archive**: 存放已合并或过时的历史文档。
- **/5_data_dictionary**: 定义系统中使用的数据模型和字段。
---
## 快速导航
以下是项目中几个最核心文档的快速访问链接,直接指向其关键章节。
### 1. 需求与功能
- **[需求文档 (`requirements.md`)]** - 定义了产品的核心功能和MVP版本的验收标准。
- [查看系统核心功能](1_requirements/20251108_[Active]_requirements.md#需求-1)
- [了解九大分析模块](1_requirements/20251108_[Active]_requirements.md#需求-5)
- [查看报告生成进度追踪需求](1_requirements/20251108_[Active]_requirements.md#需求-7)
- **[用户使用文档 (`user-guide.md`)]** - 为系统的最终用户提供详细的操作指南。
- [快速入门指引](1_requirements/20251109_[Active]_user-guide.md#快速开始)
- [财务数据指标解读](1_requirements/20251109_[Active]_user-guide.md#财务数据解读)
- [首次使用的系统配置](1_requirements/20251109_[Active]_user-guide.md#首次使用配置)
### 2. 架构与设计
- **[系统架构总览 (`system_architecture.md`)]** - 项目的“单一事实源”,描述了事件驱动微服务架构的核心理念。
- [核心架构理念与原则](2_architecture/20251116_[Active]_system_architecture.md#12-核心架构理念)
- [目标架构图](2_architecture/20251116_[Active]_system_architecture.md#21-目标架构图)
- [数据库 Schema 设计概览](2_architecture/20251116_[Active]_system_architecture.md#5-数据库-schema-设计)
- **[系统模块设计准则 (`architecture_module_specification.md`)]** - 对微服务必须遵守的 `SystemModule` 行为契约进行了形式化定义。
- [核心思想:`SystemModule` Trait](4_archive/merged_sources/20251115_[Active]_architecture_module_specification.md#3-systemmodule-trait模块的行为契约)
- [强制实现的可观测性接口 (`/health`, `/tasks`)](4_archive/merged_sources/20251115_[Active]_architecture_module_specification.md#41-可观测性接口的数据结构)
### 3. 数据与模型
- **[财务数据字典 (`financial_data_dictionary.md`)]** - 定义了所有前端展示的财务指标及其在不同数据源Tushare, Finnhub的映射关系。
- [查看主要财务指标定义](5_data_dictionary/20251109_[Living]_financial_data_dictionary.md#1-主要指标-key-indicators)
- [查看资产负债结构定义](5_data_dictionary/20251109_[Living]_financial_data_dictionary.md#3-资产负债结构-asset--liability-structure)
- **[数据库 Schema 详细设计 (`database_schema_design.md`)]** - 提供了所有核心数据表的详细 `CREATE TABLE` 语句和设计哲学。
- [为何选择 TimescaleDB](4_archive/merged_sources/20251109_[Active]_database_schema_design.md#11-时间序列数据-postgresql--timescaledb)
- [查看 `time_series_financials` 表结构](4_archive/merged_sources/20251109_[Active]_database_schema_design.md#211-time_series_financials-财务指标表)
- [查看 `analysis_results` 表结构](4_archive/merged_sources/20251109_[Active]_database_schema_design.md#22-analysis_results-ai分析结果表)
### 4. 项目管理
- **[项目当前状态 (`project-status.md`)]** - 一个动态更新的文档,记录了项目的当前进展、已知问题和下一步计划。
- [查看当前功能与数据状态](3_project_management/20251109_[Living]_project-status.md#当前功能与数据状态)
- [查看已知问题与限制](3_project_management/20251109_[Living]_project-status.md#已知问题限制)
- [查看后续开发计划](3_project_management/20251109_[Living]_project-status.md#后续计划优先级由高到低)
- **开发日志与任务**:
- [查看所有开发日志](./3_project_management/logs/)
- [查看已完成的任务](./3_project_management/tasks/completed/)

View File

@ -9,7 +9,6 @@
"lint": "eslint" "lint": "eslint"
}, },
"dependencies": { "dependencies": {
"@prisma/client": "^6.18.0",
"@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-navigation-menu": "^1.2.14", "@radix-ui/react-navigation-menu": "^1.2.14",
"@radix-ui/react-select": "^2.2.6", "@radix-ui/react-select": "^2.2.6",
@ -38,7 +37,6 @@
"@types/react-dom": "^19", "@types/react-dom": "^19",
"eslint": "^9", "eslint": "^9",
"eslint-config-next": "15.5.5", "eslint-config-next": "15.5.5",
"prisma": "^6.18.0",
"tailwindcss": "^4", "tailwindcss": "^4",
"tw-animate-css": "^1.4.0", "tw-animate-css": "^1.4.0",
"typescript": "^5" "typescript": "^5"

View File

@ -1,3 +0,0 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "postgresql"

View File

@ -1,19 +0,0 @@
// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
shadowDatabaseUrl = env("PRISMA_MIGRATE_SHADOW_DATABASE_URL")
}
model Report {
id String @id @default(uuid())
symbol String
content Json
createdAt DateTime @default(now())
}

View File

@ -1,5 +1,6 @@
import { NextRequest } from 'next/server' import { NextRequest } from 'next/server'
import { prisma } from '../../../../lib/prisma'
const BACKEND_BASE = process.env.NEXT_PUBLIC_BACKEND_URL;
export async function GET( export async function GET(
req: NextRequest, req: NextRequest,
@ -21,9 +22,28 @@ export async function GET(
return Response.json({ error: 'missing id' }, { status: 400 }) return Response.json({ error: 'missing id' }, { status: 400 })
} }
const report = await prisma.report.findUnique({ where: { id } }) if (!BACKEND_BASE) {
if (!report) { return new Response('NEXT_PUBLIC_BACKEND_URL 未配置', { status: 500 });
return Response.json({ error: 'not found' }, { status: 404 }) }
const resp = await fetch(`${BACKEND_BASE}/analysis-results/${encodeURIComponent(id)}`);
const text = await resp.text();
if (!resp.ok) {
return new Response(text || 'not found', { status: resp.status });
}
// 将后端 DTOgenerated_at 等适配为前端旧结构字段createdAt
try {
const dto = JSON.parse(text);
const adapted = {
id: dto.id,
symbol: dto.symbol,
createdAt: dto.generated_at || dto.generatedAt || null,
content: dto.content,
module_id: dto.module_id,
model_name: dto.model_name,
meta_data: dto.meta_data,
};
return Response.json(adapted);
} catch {
return Response.json({ error: 'invalid response from backend' }, { status: 502 });
} }
return Response.json(report)
} }

View File

@ -1,43 +1,15 @@
export const runtime = 'nodejs' export const runtime = 'nodejs'
import { NextRequest } from 'next/server' import { NextRequest } from 'next/server'
import { prisma } from '../../../lib/prisma'
const BACKEND_BASE = process.env.NEXT_PUBLIC_BACKEND_URL;
export async function GET(req: NextRequest) { export async function GET(req: NextRequest) {
const url = new URL(req.url) // 历史报告列表功能在新架构中由后端持久化服务统一提供。
const limit = Number(url.searchParams.get('limit') || 50) // 当前网关未提供“全量列表”接口(需要 symbol 条件),因此此路由返回空集合。
const offset = Number(url.searchParams.get('offset') || 0) return Response.json({ items: [], total: 0 }, { status: 200 });
const [items, total] = await Promise.all([
prisma.report.findMany({
orderBy: { createdAt: 'desc' },
skip: offset,
take: Math.min(Math.max(limit, 1), 200)
}),
prisma.report.count()
])
return Response.json({ items, total })
} }
export async function POST(req: NextRequest) { export async function POST(req: NextRequest) {
try { // 新架构下,报告持久化由后端流水线/服务完成,此处不再直接创建。
const body = await req.json() return Response.json({ error: 'Not implemented: creation is handled by backend pipeline' }, { status: 501 });
const symbol = String(body.symbol || '').trim()
const content = body.content
if (!symbol) {
return Response.json({ error: 'symbol is required' }, { status: 400 })
}
if (typeof content === 'undefined') {
return Response.json({ error: 'content is required' }, { status: 400 })
}
const created = await prisma.report.create({
data: { symbol, content }
})
return Response.json(created, { status: 201 })
} catch (e) {
return Response.json({ error: 'invalid json body' }, { status: 400 })
}
} }

View File

@ -24,7 +24,6 @@ export default function ConfigPage() {
const { data: analysisConfig, mutate: mutateAnalysisConfig } = useAnalysisConfig(); const { data: analysisConfig, mutate: mutateAnalysisConfig } = useAnalysisConfig();
// 本地表单状态 // 本地表单状态
const [dbUrl, setDbUrl] = useState('');
const [newApiApiKey, setNewApiApiKey] = useState(''); const [newApiApiKey, setNewApiApiKey] = useState('');
const [newApiBaseUrl, setNewApiBaseUrl] = useState(''); const [newApiBaseUrl, setNewApiBaseUrl] = useState('');
const [tushareApiKey, setTushareApiKey] = useState(''); const [tushareApiKey, setTushareApiKey] = useState('');
@ -110,11 +109,6 @@ export default function ConfigPage() {
const validateConfig = () => { const validateConfig = () => {
const errors: string[] = []; const errors: string[] = [];
// 验证数据库URL格式
if (dbUrl && !dbUrl.match(/^postgresql(\+asyncpg)?:\/\/.+/)) {
errors.push('数据库URL格式不正确应为 postgresql://user:pass@host:port/dbname');
}
// 验证New API Base URL格式 // 验证New API Base URL格式
if (newApiBaseUrl && !newApiBaseUrl.match(/^https?:\/\/.+/)) { if (newApiBaseUrl && !newApiBaseUrl.match(/^https?:\/\/.+/)) {
errors.push('New API Base URL格式不正确应为 http:// 或 https:// 开头'); errors.push('New API Base URL格式不正确应为 http:// 或 https:// 开头');
@ -149,11 +143,6 @@ export default function ConfigPage() {
const newConfig: Partial<SystemConfig> = {}; const newConfig: Partial<SystemConfig> = {};
// 只更新有值的字段
if (dbUrl) {
newConfig.database = { url: dbUrl };
}
if (newApiApiKey || newApiBaseUrl) { if (newApiApiKey || newApiBaseUrl) {
newConfig.new_api = { newConfig.new_api = {
api_key: newApiApiKey || config?.new_api?.api_key || '', api_key: newApiApiKey || config?.new_api?.api_key || '',
@ -197,10 +186,6 @@ export default function ConfigPage() {
} }
}; };
const handleTestDb = () => {
handleTest('database', { url: dbUrl });
};
const handleTestNewApi = () => { const handleTestNewApi = () => {
handleTest('new_api', { handleTest('new_api', {
api_key: newApiApiKey || config?.new_api?.api_key, api_key: newApiApiKey || config?.new_api?.api_key,
@ -217,7 +202,6 @@ export default function ConfigPage() {
}; };
const handleReset = () => { const handleReset = () => {
setDbUrl('');
setNewApiApiKey(''); setNewApiApiKey('');
setNewApiBaseUrl(''); setNewApiBaseUrl('');
setTushareApiKey(''); setTushareApiKey('');
@ -230,7 +214,6 @@ export default function ConfigPage() {
if (!config) return; if (!config) return;
const configToExport = { const configToExport = {
database: config.database,
new_api: config.new_api, new_api: config.new_api,
data_sources: config.data_sources, data_sources: config.data_sources,
export_time: new Date().toISOString(), export_time: new Date().toISOString(),
@ -296,51 +279,18 @@ export default function ConfigPage() {
<header className="space-y-2"> <header className="space-y-2">
<h1 className="text-3xl font-bold"></h1> <h1 className="text-3xl font-bold"></h1>
<p className="text-muted-foreground"> <p className="text-muted-foreground">
API密钥 AI
</p> </p>
</header> </header>
<Tabs defaultValue="database" className="space-y-6"> <Tabs defaultValue="ai" className="space-y-6">
<TabsList className="grid w-full grid-cols-5"> <TabsList className="grid w-full grid-cols-4">
<TabsTrigger value="database"></TabsTrigger>
<TabsTrigger value="ai">AI服务</TabsTrigger> <TabsTrigger value="ai">AI服务</TabsTrigger>
<TabsTrigger value="data-sources"></TabsTrigger> <TabsTrigger value="data-sources"></TabsTrigger>
<TabsTrigger value="analysis"></TabsTrigger> <TabsTrigger value="analysis"></TabsTrigger>
<TabsTrigger value="system"></TabsTrigger> <TabsTrigger value="system"></TabsTrigger>
</TabsList> </TabsList>
<TabsContent value="database" className="space-y-4">
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription>PostgreSQL </CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="db-url">URL</Label>
<div className="flex gap-2">
<Input
id="db-url"
type="text"
value={dbUrl}
onChange={(e) => setDbUrl(e.target.value)}
placeholder="postgresql+asyncpg://user:password@host:port/database"
className="flex-1"
/>
<Button onClick={handleTestDb} variant="outline">
</Button>
</div>
{testResults.database && (
<Badge variant={testResults.database.success ? 'default' : 'destructive'}>
{testResults.database.message}
</Badge>
)}
</div>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="ai" className="space-y-4"> <TabsContent value="ai" className="space-y-4">
<Card> <Card>
<CardHeader> <CardHeader>
@ -565,12 +515,6 @@ export default function ConfigPage() {
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label></Label>
<Badge variant={config?.database?.url ? 'default' : 'secondary'}>
{config?.database?.url ? '已配置' : '未配置'}
</Badge>
</div>
<div className="space-y-2"> <div className="space-y-2">
<Label>New API</Label> <Label>New API</Label>
<Badge variant={config?.new_api?.api_key ? 'default' : 'secondary'}> <Badge variant={config?.new_api?.api_key ? 'default' : 'secondary'}>

View File

@ -108,48 +108,6 @@ export default function ReportPage() {
error?: string; error?: string;
}>>([]); }>>([]);
const [saving, setSaving] = useState(false)
const [saveMsg, setSaveMsg] = useState<string | null>(null)
const saveReport = async () => {
try {
setSaving(true)
setSaveMsg(null)
const content = {
market,
normalizedSymbol: normalizedTsCode,
financialsMeta: financials?.meta || null,
// 同步保存财务数据(用于报告详情页展示)
financials: financials
? {
ts_code: financials.ts_code,
name: (financials as any).name,
series: financials.series,
meta: financials.meta,
}
: null,
analyses: Object.fromEntries(
Object.entries(analysisStates).map(([k, v]) => [k, { content: v.content, error: v.error, elapsed_ms: v.elapsed_ms, tokens: v.tokens }])
)
}
const resp = await fetch('/api/reports', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ symbol: normalizedTsCode, content })
})
if (!resp.ok) {
const t = await resp.json().catch(() => ({}))
throw new Error(t?.error || `HTTP ${resp.status}`)
}
const data = await resp.json()
setSaveMsg('保存成功')
return data
} catch (e) {
setSaveMsg(e instanceof Error ? e.message : '保存失败')
} finally {
setSaving(false)
}
}
const runFullAnalysis = async () => { const runFullAnalysis = async () => {
if (!financials || !analysisConfig?.analysis_modules || isAnalysisRunningRef.current) { if (!financials || !analysisConfig?.analysis_modules || isAnalysisRunningRef.current) {
@ -755,14 +713,6 @@ export default function ReportPage() {
style={{ width: `${completionProgress}%` }} style={{ width: `${completionProgress}%` }}
/> />
</div> </div>
{allTasksCompleted && (
<div className="pt-2">
<Button onClick={saveReport} disabled={saving} variant="outline">
{saving ? '保存中...' : '保存报告'}
</Button>
{saveMsg && <span className="ml-2 text-xs text-muted-foreground">{saveMsg}</span>}
</div>
)}
</CardHeader> </CardHeader>
<CardContent className="space-y-2"> <CardContent className="space-y-2">
{currentAnalysisTask && analysisConfig && ( {currentAnalysisTask && analysisConfig && (

View File

@ -1,4 +1,4 @@
import { prisma } from '../../../lib/prisma' import { headers } from 'next/headers'
import ReactMarkdown from 'react-markdown' import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm' import remarkGfm from 'remark-gfm'
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs' import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'
@ -15,7 +15,30 @@ type Report = {
export default async function ReportDetailPage({ params }: { params: Promise<{ id: string }> }) { export default async function ReportDetailPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params const { id } = await params
const data = await prisma.report.findUnique({ where: { id } }) const h = await headers()
const host = h.get('x-forwarded-host') || h.get('host') || 'localhost:3000'
const proto = h.get('x-forwarded-proto') || 'http'
const base = process.env.NEXT_PUBLIC_BASE_URL || `${proto}://${host}`
const resp = await fetch(`${base}/api/reports/${encodeURIComponent(id)}`, { cache: 'no-store' })
if (!resp.ok) {
return <div className="text-sm text-red-600"></div>
}
const raw = await resp.json()
let parsedContent: any = {}
try {
// 后端 content 可能为 JSON 字符串,也可能为已解析对象
parsedContent = typeof raw?.content === 'string' ? JSON.parse(raw.content) : (raw?.content ?? {})
} catch {
parsedContent = {}
}
const data: Report | null = raw
? {
id: String(raw.id),
symbol: String(raw.symbol ?? ''),
content: parsedContent,
createdAt: String(raw.createdAt ?? raw.generated_at ?? new Date().toISOString()),
}
: null
if (!data) { if (!data) {
return <div className="text-sm text-red-600"></div> return <div className="text-sm text-red-600"></div>

View File

@ -2,6 +2,8 @@ import useSWR, { SWRConfiguration } from "swr";
import { Financials, FinancialsIdentifier } from "@/types"; import { Financials, FinancialsIdentifier } from "@/types";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { AnalysisStep, AnalysisTask } from "@/lib/execution-step-manager"; import { AnalysisStep, AnalysisTask } from "@/lib/execution-step-manager";
import { useConfigStore } from "@/stores/useConfigStore";
import type { SystemConfig } from "@/stores/useConfigStore";
const fetcher = (url: string) => fetch(url).then((res) => res.json()); const fetcher = (url: string) => fetch(url).then((res) => res.json());
@ -232,3 +234,69 @@ export function useRealtimeQuote(
} }
); );
} }
// ===============================
// 配置相关 Hooks 与函数
// ===============================
export function useConfig() {
const { setConfig, setError, setLoading } = useConfigStore();
const { data, error, isLoading } = useSWR<SystemConfig>('/api/config', fetcher);
useEffect(() => {
setLoading(Boolean(isLoading));
if (error) {
setError(error.message || '加载配置失败');
} else if (data) {
setConfig(data);
}
}, [data, error, isLoading, setConfig, setError, setLoading]);
return { data, error, isLoading };
}
export async function updateConfig(payload: Partial<SystemConfig>) {
const res = await fetch('/api/config', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(text || `HTTP ${res.status}`);
}
const updated: SystemConfig = await res.json();
// 同步到 store
try {
const { setConfig } = useConfigStore.getState();
setConfig(updated);
} catch (_) {
// ignore
}
return updated;
}
export async function testConfig(type: string, data: unknown) {
const res = await fetch('/api/config/test', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ type, data }),
});
const text = await res.text();
if (!res.ok) {
try {
const err = JSON.parse(text);
throw new Error(err?.message || text);
} catch {
throw new Error(text || `HTTP ${res.status}`);
}
}
try {
return JSON.parse(text);
} catch {
return { success: true, message: text || 'OK' };
}
}
export function useFinancialConfig() {
// 透传后端的财务配置(如指标分组、显示名映射等)
return useSWR<any>('/api/financials/config', fetcher);
}

View File

@ -1,44 +0,0 @@
import { PrismaClient } from '@prisma/client'
import fs from 'node:fs'
import path from 'node:path'
const globalForPrisma = global as unknown as { prisma?: PrismaClient }
function loadDatabaseUrlFromConfig(): string | undefined {
try {
const configPath = path.resolve(process.cwd(), '..', 'config', 'config.json')
const raw = fs.readFileSync(configPath, 'utf-8')
const json = JSON.parse(raw)
const dbUrl: unknown = json?.database?.url
if (typeof dbUrl !== 'string' || !dbUrl) return undefined
// 将后端风格的 "postgresql+asyncpg://" 转换为 Prisma 需要的 "postgresql://"
let url = dbUrl.replace(/^postgresql\+[^:]+:\/\//, 'postgresql://')
// 若未指定 schema默认 public
if (!/[?&]schema=/.test(url)) {
url += (url.includes('?') ? '&' : '?') + 'schema=public'
}
return url
} catch {
return undefined
}
}
const databaseUrl = loadDatabaseUrlFromConfig() || process.env.DATABASE_URL
export const prisma =
globalForPrisma.prisma ||
new PrismaClient({
datasources: databaseUrl ? { db: { url: databaseUrl } } : undefined,
log: ['error', 'warn']
})
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma