Compare commits
No commits in common. "a1e4b265bafca242c3c3317813d6a5739f899c3b" and "45ec5bb16db2ab33c955bad46d481f0ca902fe99" have entirely different histories.
a1e4b265ba
...
45ec5bb16d
@ -1,51 +0,0 @@
|
|||||||
# 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"
|
|
||||||
@ -1,3 +1,5 @@
|
|||||||
|
version: "3.9"
|
||||||
|
|
||||||
services:
|
services:
|
||||||
postgres-db:
|
postgres-db:
|
||||||
image: timescale/timescaledb:2.15.2-pg16
|
image: timescale/timescaledb:2.15.2-pg16
|
||||||
@ -33,8 +35,6 @@ services:
|
|||||||
PORT: 3000
|
PORT: 3000
|
||||||
# Rust service connects to the internal DB service name
|
# Rust service connects to the internal DB service name
|
||||||
DATABASE_URL: postgresql://postgres:postgres@postgres-db:5432/fundamental
|
DATABASE_URL: postgresql://postgres:postgres@postgres-db:5432/fundamental
|
||||||
RUST_LOG: info
|
|
||||||
RUST_BACKTRACE: "1"
|
|
||||||
depends_on:
|
depends_on:
|
||||||
postgres-db:
|
postgres-db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
@ -55,9 +55,8 @@ 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
|
||||||
# SSR 内部访问自身 API 的内部地址,避免使用 x-forwarded-host 导致访问宿主机端口
|
# Prisma 直连数据库(与后端共用同一库)
|
||||||
FRONTEND_INTERNAL_URL: http://fundamental-frontend:3001
|
DATABASE_URL: postgresql://postgres:postgres@postgres-db:5432/fundamental?schema=public
|
||||||
BACKEND_INTERNAL_URL: http://api-gateway:4000/v1
|
|
||||||
NODE_ENV: development
|
NODE_ENV: development
|
||||||
NEXT_TELEMETRY_DISABLED: "1"
|
NEXT_TELEMETRY_DISABLED: "1"
|
||||||
volumes:
|
volumes:
|
||||||
@ -67,6 +66,7 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- "13001:3001"
|
- "13001:3001"
|
||||||
depends_on:
|
depends_on:
|
||||||
|
- postgres-db
|
||||||
- api-gateway
|
- api-gateway
|
||||||
networks:
|
networks:
|
||||||
- app-network
|
- app-network
|
||||||
@ -77,15 +77,12 @@ services:
|
|||||||
context: .
|
context: .
|
||||||
dockerfile: services/api-gateway/Dockerfile
|
dockerfile: services/api-gateway/Dockerfile
|
||||||
container_name: api-gateway
|
container_name: api-gateway
|
||||||
restart: unless-stopped
|
|
||||||
environment:
|
environment:
|
||||||
SERVER_PORT: 4000
|
SERVER_PORT: 4000
|
||||||
NATS_ADDR: nats://nats:4222
|
NATS_ADDR: nats://nats:4222
|
||||||
DATA_PERSISTENCE_SERVICE_URL: http://data-persistence-service:3000/api/v1
|
DATA_PERSISTENCE_SERVICE_URL: http://data-persistence-service:3000/api/v1
|
||||||
# provider_services via explicit JSON for deterministic parsing
|
# Note: provider_services needs to contain all provider's internal addresses
|
||||||
PROVIDER_SERVICES: '["http://alphavantage-provider-service:8000", "http://tushare-provider-service:8001", "http://finnhub-provider-service:8002", "http://yfinance-provider-service:8003"]'
|
PROVIDER_SERVICES: '["http://alphavantage-provider-service:8000", "http://tushare-provider-service:8001", "http://finnhub-provider-service:8002", "http://yfinance-provider-service:8003"]'
|
||||||
RUST_LOG: info,axum=info
|
|
||||||
RUST_BACKTRACE: "1"
|
|
||||||
depends_on:
|
depends_on:
|
||||||
- nats
|
- nats
|
||||||
- data-persistence-service
|
- data-persistence-service
|
||||||
@ -105,19 +102,11 @@ services:
|
|||||||
SERVER_PORT: 8000
|
SERVER_PORT: 8000
|
||||||
NATS_ADDR: nats://nats:4222
|
NATS_ADDR: nats://nats:4222
|
||||||
DATA_PERSISTENCE_SERVICE_URL: http://data-persistence-service:3000/api/v1
|
DATA_PERSISTENCE_SERVICE_URL: http://data-persistence-service:3000/api/v1
|
||||||
ALPHAVANTAGE_API_KEY: ${ALPHAVANTAGE_API_KEY}
|
|
||||||
RUST_LOG: info,axum=info
|
|
||||||
RUST_BACKTRACE: "1"
|
|
||||||
depends_on:
|
depends_on:
|
||||||
- nats
|
- nats
|
||||||
- data-persistence-service
|
- data-persistence-service
|
||||||
networks:
|
networks:
|
||||||
- app-network
|
- app-network
|
||||||
healthcheck:
|
|
||||||
test: ["CMD-SHELL", "curl -fsS http://localhost:8000/health >/dev/null || exit 1"]
|
|
||||||
interval: 5s
|
|
||||||
timeout: 5s
|
|
||||||
retries: 12
|
|
||||||
|
|
||||||
tushare-provider-service:
|
tushare-provider-service:
|
||||||
build:
|
build:
|
||||||
@ -129,20 +118,13 @@ services:
|
|||||||
NATS_ADDR: nats://nats:4222
|
NATS_ADDR: nats://nats:4222
|
||||||
DATA_PERSISTENCE_SERVICE_URL: http://data-persistence-service:3000/api/v1
|
DATA_PERSISTENCE_SERVICE_URL: http://data-persistence-service:3000/api/v1
|
||||||
TUSHARE_API_URL: http://api.waditu.com
|
TUSHARE_API_URL: http://api.waditu.com
|
||||||
# Please provide your Tushare token via .env
|
# Please provide your Tushare token here
|
||||||
TUSHARE_API_TOKEN: ${TUSHARE_API_TOKEN}
|
TUSHARE_API_TOKEN: "YOUR_TUSHARE_API_TOKEN"
|
||||||
RUST_LOG: info,axum=info
|
|
||||||
RUST_BACKTRACE: "1"
|
|
||||||
depends_on:
|
depends_on:
|
||||||
- nats
|
- nats
|
||||||
- data-persistence-service
|
- data-persistence-service
|
||||||
networks:
|
networks:
|
||||||
- app-network
|
- app-network
|
||||||
healthcheck:
|
|
||||||
test: ["CMD-SHELL", "curl -fsS http://localhost:8001/health >/dev/null || exit 1"]
|
|
||||||
interval: 5s
|
|
||||||
timeout: 5s
|
|
||||||
retries: 12
|
|
||||||
|
|
||||||
finnhub-provider-service:
|
finnhub-provider-service:
|
||||||
build:
|
build:
|
||||||
@ -156,18 +138,11 @@ services:
|
|||||||
FINNHUB_API_URL: https://finnhub.io/api/v1
|
FINNHUB_API_URL: https://finnhub.io/api/v1
|
||||||
# Please provide your Finnhub token in .env file
|
# Please provide your Finnhub token in .env file
|
||||||
FINNHUB_API_KEY: ${FINNHUB_API_KEY}
|
FINNHUB_API_KEY: ${FINNHUB_API_KEY}
|
||||||
RUST_LOG: info,axum=info
|
|
||||||
RUST_BACKTRACE: "1"
|
|
||||||
depends_on:
|
depends_on:
|
||||||
- nats
|
- nats
|
||||||
- data-persistence-service
|
- data-persistence-service
|
||||||
networks:
|
networks:
|
||||||
- app-network
|
- app-network
|
||||||
healthcheck:
|
|
||||||
test: ["CMD-SHELL", "curl -fsS http://localhost:8002/health >/dev/null || exit 1"]
|
|
||||||
interval: 5s
|
|
||||||
timeout: 5s
|
|
||||||
retries: 12
|
|
||||||
|
|
||||||
yfinance-provider-service:
|
yfinance-provider-service:
|
||||||
build:
|
build:
|
||||||
@ -178,18 +153,11 @@ services:
|
|||||||
SERVER_PORT: 8003
|
SERVER_PORT: 8003
|
||||||
NATS_ADDR: nats://nats:4222
|
NATS_ADDR: nats://nats:4222
|
||||||
DATA_PERSISTENCE_SERVICE_URL: http://data-persistence-service:3000/api/v1
|
DATA_PERSISTENCE_SERVICE_URL: http://data-persistence-service:3000/api/v1
|
||||||
RUST_LOG: info,axum=info
|
|
||||||
RUST_BACKTRACE: "1"
|
|
||||||
depends_on:
|
depends_on:
|
||||||
- nats
|
- nats
|
||||||
- data-persistence-service
|
- data-persistence-service
|
||||||
networks:
|
networks:
|
||||||
- app-network
|
- app-network
|
||||||
healthcheck:
|
|
||||||
test: ["CMD-SHELL", "curl -fsS http://localhost:8003/health >/dev/null || exit 1"]
|
|
||||||
interval: 5s
|
|
||||||
timeout: 5s
|
|
||||||
retries: 12
|
|
||||||
|
|
||||||
report-generator-service:
|
report-generator-service:
|
||||||
build:
|
build:
|
||||||
@ -200,18 +168,28 @@ services:
|
|||||||
SERVER_PORT: 8004
|
SERVER_PORT: 8004
|
||||||
NATS_ADDR: nats://nats:4222
|
NATS_ADDR: nats://nats:4222
|
||||||
DATA_PERSISTENCE_SERVICE_URL: http://data-persistence-service:3000/api/v1
|
DATA_PERSISTENCE_SERVICE_URL: http://data-persistence-service:3000/api/v1
|
||||||
RUST_LOG: info,axum=info
|
# Please provide your LLM provider details in .env file
|
||||||
RUST_BACKTRACE: "1"
|
LLM_API_URL: ${LLM_API_URL}
|
||||||
|
LLM_API_KEY: ${LLM_API_KEY}
|
||||||
|
LLM_MODEL: ${LLM_MODEL:-"default-model"}
|
||||||
depends_on:
|
depends_on:
|
||||||
- nats
|
- nats
|
||||||
- data-persistence-service
|
- data-persistence-service
|
||||||
networks:
|
networks:
|
||||||
- app-network
|
- app-network
|
||||||
healthcheck:
|
|
||||||
test: ["CMD-SHELL", "curl -fsS http://localhost:8004/health >/dev/null || exit 1"]
|
config-service-rs:
|
||||||
interval: 5s
|
build:
|
||||||
timeout: 5s
|
context: .
|
||||||
retries: 12
|
dockerfile: services/config-service-rs/Dockerfile
|
||||||
|
container_name: config-service-rs
|
||||||
|
environment:
|
||||||
|
SERVER_PORT: 5001
|
||||||
|
# PROJECT_ROOT is set to /workspace in the Dockerfile
|
||||||
|
networks:
|
||||||
|
- app-network
|
||||||
|
volumes:
|
||||||
|
- ./config:/workspace/config:ro
|
||||||
|
|
||||||
# =================================================================
|
# =================================================================
|
||||||
# Python Services (Legacy - to be replaced)
|
# Python Services (Legacy - to be replaced)
|
||||||
|
|||||||
@ -104,7 +104,8 @@
|
|||||||
|
|
||||||
#### 验收标准
|
#### 验收标准
|
||||||
|
|
||||||
1. 选股系统应当提供配置页面用于设置Gemini_API密钥
|
1. 选股系统应当提供配置页面用于设置数据库连接参数
|
||||||
2. 选股系统应当提供配置页面用于设置各市场的数据源配置
|
2. 选股系统应当提供配置页面用于设置Gemini_API密钥
|
||||||
3. 当配置更新时,选股系统应当验证配置的有效性
|
3. 选股系统应当提供配置页面用于设置各市场的数据源配置
|
||||||
4. 当配置保存时,选股系统应当将配置持久化存储
|
4. 当配置更新时,选股系统应当验证配置的有效性
|
||||||
|
5. 当配置保存时,选股系统应当将配置持久化存储
|
||||||
@ -73,6 +73,7 @@
|
|||||||
|
|
||||||
系统提供完善的配置管理功能:
|
系统提供完善的配置管理功能:
|
||||||
|
|
||||||
|
- **数据库配置**:配置 PostgreSQL 数据库连接
|
||||||
- **AI 服务配置**:配置 AI 模型的 API 密钥和端点
|
- **AI 服务配置**:配置 AI 模型的 API 密钥和端点
|
||||||
- **数据源配置**:配置 Tushare、Finnhub 等数据源的 API 密钥
|
- **数据源配置**:配置 Tushare、Finnhub 等数据源的 API 密钥
|
||||||
- **分析模块配置**:自定义分析模块的名称、模型和提示词模板
|
- **分析模块配置**:自定义分析模块的名称、模型和提示词模板
|
||||||
@ -220,11 +221,15 @@ A:
|
|||||||
|
|
||||||
首次使用系统时,需要配置以下内容:
|
首次使用系统时,需要配置以下内容:
|
||||||
|
|
||||||
1. **AI 服务配置**
|
1. **数据库配置**(如使用)
|
||||||
|
- 数据库连接 URL:`postgresql+asyncpg://user:password@host:port/database`
|
||||||
|
- 使用"测试连接"按钮验证连接
|
||||||
|
|
||||||
|
2. **AI 服务配置**
|
||||||
- API Key:输入您的 AI 服务 API 密钥
|
- API Key:输入您的 AI 服务 API 密钥
|
||||||
- Base URL:输入 API 端点地址(如使用自建服务)
|
- Base URL:输入 API 端点地址(如使用自建服务)
|
||||||
|
|
||||||
2. **数据源配置**
|
3. **数据源配置**
|
||||||
- **Tushare**:输入 Tushare API Key(中国市场必需)
|
- **Tushare**:输入 Tushare API Key(中国市场必需)
|
||||||
- **Finnhub**:输入 Finnhub API Key(全球市场可选)
|
- **Finnhub**:输入 Finnhub API Key(全球市场可选)
|
||||||
|
|
||||||
|
|||||||
@ -1,154 +0,0 @@
|
|||||||
---
|
|
||||||
status: 'Pending'
|
|
||||||
created: '2025-11-16'
|
|
||||||
owner: '@lv'
|
|
||||||
---
|
|
||||||
|
|
||||||
# 任务:重构LLM Provider架构 (V2 - 数据库中心化)
|
|
||||||
|
|
||||||
## 1. 任务目标
|
|
||||||
|
|
||||||
为解决当前系统大语言模型(LLM)配置的僵化问题,本次任务旨在重构LLM的配置和调用工作流。我们将实现一个以数据库为中心的、支持多供应商的、结构化的配置体系。该体系将允许每个分析模块都能按需选择其所需的LLM供应商和具体模型,同时保证整个系统的类型安全和数据一致性。
|
|
||||||
|
|
||||||
## 2. 新架构设计:配置即数据
|
|
||||||
|
|
||||||
我们将废弃所有基于本地文件的配置方案 (`analysis-config.json`, `llm-providers.json`),并将所有配置信息作为结构化数据存入数据库。
|
|
||||||
|
|
||||||
### 2.1. 核心原则:Schema-in-Code
|
|
||||||
|
|
||||||
- **不新增数据表**: 我们将利用现有的 `system_config` 表及其 `JSONB` 字段来存储所有配置,无需修改数据库Schema。
|
|
||||||
- **强类型约束**: 所有配置的JSON结构,其“单一事实源”都将是在 **`common-contracts`** crate中定义的Rust Structs。所有服务都必须依赖这些共享的Structs来序列化和反序列化配置数据,从而在应用层面实现强类型约束。
|
|
||||||
|
|
||||||
### 2.2. `common-contracts`中的数据结构定义
|
|
||||||
|
|
||||||
将在`common-contracts`中创建一个新模块(例如 `config_models.rs`),定义如下结构:
|
|
||||||
|
|
||||||
```rust
|
|
||||||
// In: common-contracts/src/config_models.rs
|
|
||||||
|
|
||||||
use serde::{Serialize, Deserialize};
|
|
||||||
use std::collections::HashMap;
|
|
||||||
|
|
||||||
// 单个启用的模型
|
|
||||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
|
||||||
pub struct LlmModel {
|
|
||||||
pub model_id: String, // e.g., "gpt-4o"
|
|
||||||
pub name: Option<String>, // 别名,用于UI显示
|
|
||||||
pub is_active: bool, // 是否在UI中可选
|
|
||||||
}
|
|
||||||
|
|
||||||
// 单个LLM供应商的完整配置
|
|
||||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
|
||||||
pub struct LlmProvider {
|
|
||||||
pub name: String, // "OpenAI 官方"
|
|
||||||
pub api_base_url: String,
|
|
||||||
pub api_key: String, // 直接明文存储
|
|
||||||
pub models: Vec<LlmModel>, // 该供应商下我们启用的模型列表
|
|
||||||
}
|
|
||||||
|
|
||||||
// 整个LLM Provider注册中心的数据结构
|
|
||||||
pub type LlmProvidersConfig = HashMap<String, LlmProvider>; // Key: provider_id, e.g., "openai_official"
|
|
||||||
|
|
||||||
// 单个分析模块的配置
|
|
||||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
|
||||||
pub struct AnalysisModuleConfig {
|
|
||||||
pub name: String, // "看涨分析"
|
|
||||||
pub provider_id: String, // 引用 LlmProvidersConfig 的 Key
|
|
||||||
pub model_id: String, // 引用 LlmModel 中的 model_id
|
|
||||||
pub prompt_template: String,
|
|
||||||
pub dependencies: Vec<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
// 整个分析模块配置集合的数据结构
|
|
||||||
pub type AnalysisModulesConfig = HashMap<String, AnalysisModuleConfig>; // Key: module_id, e.g., "bull_case"
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2.3. `system_config` 表中的数据存储
|
|
||||||
|
|
||||||
我们将使用两个`config_key`来存储这些结构序列化后的JSON:
|
|
||||||
1. **Key: `"llm_providers"`**: 其`config_value`是一个序列化后的`LlmProvidersConfig`。
|
|
||||||
2. **Key: `"analysis_modules"`**: 其`config_value`是一个序列化后的`AnalysisModulesConfig`。
|
|
||||||
|
|
||||||
## 3. 实施步骤
|
|
||||||
|
|
||||||
### 步骤 1: 更新 `common-contracts` (地基)
|
|
||||||
|
|
||||||
1. 在`common-contracts/src/`下创建`config_models.rs`文件。
|
|
||||||
2. 将上述所有Rust Structs定义添加到该文件中,并确保它们在`lib.rs`中被正确导出。
|
|
||||||
|
|
||||||
### 步骤 2: 重构 `data-persistence-service` (配置守门人)
|
|
||||||
|
|
||||||
1. **移除 `config-service-rs`**: 该服务的功能将被`data-persistence-service`完全吸收和取代,可以准备将其从`docker-compose.yml`中移除。
|
|
||||||
2. **实现新的CRUD API**:
|
|
||||||
- `GET /api/v1/configs/llm_providers`: 读取并返回`system_config`中key为`llm_providers`的JSON文档。
|
|
||||||
- `PUT /api/v1/configs/llm_providers`: 接收一个`LlmProvidersConfig`的JSON payload,**使用`common-contracts`中的Structs进行反序列化验证**,验证通过后,将其存入数据库。
|
|
||||||
- `GET /api/v1/configs/analysis_modules`: 读取并返回key为`analysis_modules`的JSON文档。
|
|
||||||
- `PUT /api/v1/configs/analysis_modules`: 接收一个`AnalysisModulesConfig`的JSON payload,进行验证后存入数据库。
|
|
||||||
|
|
||||||
### 步骤 3: 重构 `frontend` (管理UI)
|
|
||||||
|
|
||||||
1. **创建LLM Provider管理页面**:
|
|
||||||
- 提供一个表单,用于新增/编辑`LlmProvider`(对应`llm_providers`JSON中的一个顶级条目)。
|
|
||||||
- 在每个Provider下,提供一个子表单来管理其`models`列表(增、删、改、切换`is_active`状态)。
|
|
||||||
- 实现“自动发现模型”功能,调用`api-gateway`的模型发现端点,让用户可以从中选择模型加入列表。
|
|
||||||
2. **更新分析模块配置页面**:
|
|
||||||
- 为每个分析模块提供两个级联下拉框:
|
|
||||||
1. 第一个下拉框选择`Provider` (数据来自`GET /api/v1/configs/llm_providers`)。
|
|
||||||
2. 第二个下拉框根据第一个的选择,动态加载该Provider下所有`is_active: true`的`Model`。
|
|
||||||
- 更新保存逻辑,以调用`PUT /api/v1/configs/analysis_modules`。
|
|
||||||
|
|
||||||
### 步骤 4: 更新 `api-gateway`
|
|
||||||
|
|
||||||
1. **移除对`config-service-rs`的代理**。
|
|
||||||
2. **代理新的配置API**: 将所有`/api/v1/configs/*`的请求正确地代理到`data-persistence-service`。
|
|
||||||
3. **实现模型发现端点**:
|
|
||||||
- 创建`GET /api/v1/discover-models/{provider_id}`。
|
|
||||||
- 该端点会先调用`data-persistence-service`获取指定provider的`api_base_url`和`api_key`。
|
|
||||||
- 然后使用这些信息向LLM供应商的官方`/models`接口发起请求,并将结果返回给前端。
|
|
||||||
|
|
||||||
### 步骤 5: 重构 `report-generator-service` (最终消费者)
|
|
||||||
|
|
||||||
1. **移除旧配置**:
|
|
||||||
- 修改`docker-compose.yml`,移除所有旧的`LLM_*`环境变量。
|
|
||||||
2. **重构工作流**:
|
|
||||||
- 当收到任务时(例如`bull_case`),它将:
|
|
||||||
a. 并行调用`data-persistence-service`的`GET /api/v1/configs/llm_providers`和`GET /api/v1/configs/analysis_modules`接口,获取完整的配置。
|
|
||||||
b. **使用`common-contracts`中的Structs反序列化**这两个JSON响应,得到类型安全的`LlmProvidersConfig`和`AnalysisModulesConfig`对象。
|
|
||||||
c. 通过`analysis_config["bull_case"]`找到`provider_id`和`model_id`。
|
|
||||||
d. 通过`providers_config[provider_id]`找到对应的`api_base_url`和`api_key`。
|
|
||||||
e. 动态创建`LlmClient`实例,并执行任务。
|
|
||||||
|
|
||||||
## 4. 验收标准
|
|
||||||
|
|
||||||
- ✅ `common-contracts` crate中包含了所有新定义的配置Structs。
|
|
||||||
- ✅ `data-persistence-service`提供了稳定、类型安全的API来管理存储在`system_config`表中的配置。
|
|
||||||
- ✅ `config-service-rs`服务已安全移除。
|
|
||||||
- ✅ 前端提供了一个功能完善的UI,用于管理LLM Providers、Models,并能将它们正确地指派给各个分析模块。
|
|
||||||
- ✅ `report-generator-service`能够正确地、动态地使用数据库中的配置,为不同的分析模块调用不同的LLM Provider和模型。
|
|
||||||
|
|
||||||
## 6. 任务实施清单 (TODO List)
|
|
||||||
|
|
||||||
### 阶段一:定义数据契约 (`common-contracts`)
|
|
||||||
- [x] 在 `src` 目录下创建 `config_models.rs` 文件。
|
|
||||||
- [x] 在 `config_models.rs` 中定义 `LlmModel`, `LlmProvider`, `LlmProvidersConfig`, `AnalysisModuleConfig`, `AnalysisModulesConfig` 等所有Structs。
|
|
||||||
- [x] 在 `lib.rs` 中正确导出 `config_models` 模块,使其对其他服务可见。
|
|
||||||
|
|
||||||
### 阶段二:实现配置的持久化与服务 (`data-persistence-service`)
|
|
||||||
- [x] **[API]** 实现 `GET /api/v1/configs/llm_providers` 端点。
|
|
||||||
- [x] **[API]** 实现 `PUT /api/v1/configs/llm_providers` 端点,并确保使用 `common-contracts` 中的Structs进行反序列化验证。
|
|
||||||
- [x] **[API]** 实现 `GET /api/v1/configs/analysis_modules` 端点。
|
|
||||||
- [x] **[API]** 实现 `PUT /api/v1/configs/analysis_modules` 端点,并进行相应的验证。
|
|
||||||
- [x] **[系统]** 从 `docker-compose.yml` 中安全移除 `config-service-rs` 服务,因其功能已被本服务吸收。
|
|
||||||
|
|
||||||
### 阶段三:更新API网关与前端 (`api-gateway` & `frontend`)
|
|
||||||
- [x] **[api-gateway]** 更新路由配置,将所有 `/api/v1/configs/*` 的请求代理到 `data-persistence-service`。
|
|
||||||
- [x] **[api-gateway]** 实现 `GET /api/v1/discover-models/{provider_id}` 模型发现代理端点。
|
|
||||||
- [x] **[frontend]** 创建全新的“LLM Provider管理”页面UI骨架。
|
|
||||||
- [x] **[frontend]** 实现调用新配置API对LLM Providers和Models进行增、删、改、查的完整逻辑。
|
|
||||||
- [x] **[frontend]** 在Provider管理页面上,实现“自动发现模型”的功能按钮及其后续的UI交互。
|
|
||||||
- [x] **[frontend]** 重构“分析模块配置”页面,使用级联下拉框来选择Provider和Model。
|
|
||||||
|
|
||||||
### 阶段四:重构报告生成服务 (`report-generator-service`)
|
|
||||||
- [x] **[配置]** 从 `docker-compose.yml` 中移除所有旧的、全局的 `LLM_*` 环境变量。
|
|
||||||
- [x] **[核心逻辑]** 重构服务的工作流,实现从 `data-persistence-service` 动态获取`LlmProvidersConfig`和`AnalysisModulesConfig`。
|
|
||||||
- [x] **[核心逻辑]** 实现动态创建 `LlmClient` 实例的逻辑,使其能够根据任务需求使用不同的Provider配置。
|
|
||||||
@ -1,64 +0,0 @@
|
|||||||
# 项目文档中心
|
|
||||||
|
|
||||||
欢迎来到基本面选股系统的文档中心。本文档旨在作为项目所有相关文档的入口和导航,帮助团队成员快速找到所需信息。
|
|
||||||
|
|
||||||
## 概览
|
|
||||||
|
|
||||||
本文档库遵循特定的结构化命名和分类约定,旨在清晰地分离不同领域的关注点。主要目录结构如下:
|
|
||||||
|
|
||||||
- **/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/)
|
|
||||||
@ -9,6 +9,7 @@
|
|||||||
"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",
|
||||||
@ -37,6 +38,7 @@
|
|||||||
"@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"
|
||||||
|
|||||||
3
frontend/prisma/migrations/migration_lock.toml
Normal file
3
frontend/prisma/migrations/migration_lock.toml
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
# Please do not edit this file manually
|
||||||
|
# It should be added in your version-control system (e.g., Git)
|
||||||
|
provider = "postgresql"
|
||||||
19
frontend/prisma/schema.prisma
Normal file
19
frontend/prisma/schema.prisma
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
// 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())
|
||||||
|
}
|
||||||
@ -1,11 +1,11 @@
|
|||||||
const BACKEND_BASE = process.env.BACKEND_INTERNAL_URL || process.env.NEXT_PUBLIC_BACKEND_URL;
|
const BACKEND_BASE = process.env.NEXT_PUBLIC_BACKEND_URL;
|
||||||
|
|
||||||
export async function GET(
|
export async function GET(
|
||||||
_req: Request,
|
_req: Request,
|
||||||
context: { params: Promise<{ symbol: string }> }
|
context: { params: Promise<{ symbol: string }> }
|
||||||
) {
|
) {
|
||||||
if (!BACKEND_BASE) {
|
if (!BACKEND_BASE) {
|
||||||
return new Response('BACKEND_INTERNAL_URL/NEXT_PUBLIC_BACKEND_URL 未配置', { status: 500 });
|
return new Response('NEXT_PUBLIC_BACKEND_URL 未配置', { status: 500 });
|
||||||
}
|
}
|
||||||
const { symbol } = await context.params;
|
const { symbol } = await context.params;
|
||||||
const target = `${BACKEND_BASE}/companies/${encodeURIComponent(symbol)}/profile`;
|
const target = `${BACKEND_BASE}/companies/${encodeURIComponent(symbol)}/profile`;
|
||||||
|
|||||||
@ -1,75 +1,26 @@
|
|||||||
import { NextRequest } from 'next/server';
|
import { NextRequest } from 'next/server';
|
||||||
|
|
||||||
const BACKEND_BASE = process.env.BACKEND_INTERNAL_URL || process.env.NEXT_PUBLIC_BACKEND_URL;
|
const BACKEND_BASE = process.env.NEXT_PUBLIC_BACKEND_URL;
|
||||||
|
|
||||||
// 聚合新后端的配置,提供给旧前端调用点一个稳定入口
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
if (!BACKEND_BASE) {
|
if (!BACKEND_BASE) {
|
||||||
return new Response('BACKEND_INTERNAL_URL/NEXT_PUBLIC_BACKEND_URL 未配置', { status: 500 });
|
return new Response('NEXT_PUBLIC_BACKEND_URL 未配置', { status: 500 });
|
||||||
}
|
|
||||||
try {
|
|
||||||
const [providersResp, modulesResp] = await Promise.all([
|
|
||||||
fetch(`${BACKEND_BASE}/configs/llm_providers`, { cache: 'no-store' }),
|
|
||||||
fetch(`${BACKEND_BASE}/configs/analysis_modules`, { cache: 'no-store' }),
|
|
||||||
]);
|
|
||||||
const providersText = await providersResp.text();
|
|
||||||
const modulesText = await modulesResp.text();
|
|
||||||
let providers: unknown = {};
|
|
||||||
let modules: unknown = {};
|
|
||||||
try { providers = providersText ? JSON.parse(providersText) : {}; } catch { providers = {}; }
|
|
||||||
try { modules = modulesText ? JSON.parse(modulesText) : {}; } catch { modules = {}; }
|
|
||||||
return Response.json({
|
|
||||||
llm_providers: providers,
|
|
||||||
analysis_modules: modules,
|
|
||||||
});
|
|
||||||
} catch (e: any) {
|
|
||||||
return new Response(e?.message || 'Failed to load config', { status: 502 });
|
|
||||||
}
|
}
|
||||||
|
const resp = await fetch(`${BACKEND_BASE}/config`);
|
||||||
|
const text = await resp.text();
|
||||||
|
return new Response(text, { status: resp.status, headers: { 'Content-Type': resp.headers.get('Content-Type') || 'application/json' } });
|
||||||
}
|
}
|
||||||
|
|
||||||
// 允许前端一次性提交部分配置;根据键路由到新后端
|
|
||||||
export async function PUT(req: NextRequest) {
|
export async function PUT(req: NextRequest) {
|
||||||
if (!BACKEND_BASE) {
|
if (!BACKEND_BASE) {
|
||||||
return new Response('BACKEND_INTERNAL_URL/NEXT_PUBLIC_BACKEND_URL 未配置', { status: 500 });
|
return new Response('NEXT_PUBLIC_BACKEND_URL 未配置', { status: 500 });
|
||||||
}
|
}
|
||||||
try {
|
const body = await req.text();
|
||||||
const incoming = await req.json().catch(() => ({}));
|
const resp = await fetch(`${BACKEND_BASE}/config`, {
|
||||||
const tasks: Promise<Response>[] = [];
|
|
||||||
if (incoming.llm_providers) {
|
|
||||||
tasks.push(fetch(`${BACKEND_BASE}/configs/llm_providers`, {
|
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(incoming.llm_providers),
|
body,
|
||||||
}));
|
|
||||||
}
|
|
||||||
if (incoming.analysis_modules) {
|
|
||||||
tasks.push(fetch(`${BACKEND_BASE}/configs/analysis_modules`, {
|
|
||||||
method: 'PUT',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(incoming.analysis_modules),
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
const results = await Promise.all(tasks);
|
|
||||||
const ok = results.every(r => r.ok);
|
|
||||||
if (!ok) {
|
|
||||||
const texts = await Promise.all(results.map(r => r.text().catch(() => '')));
|
|
||||||
return new Response(JSON.stringify({ error: 'Partial update failed', details: texts }), {
|
|
||||||
status: 502,
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
});
|
});
|
||||||
}
|
const text = await resp.text();
|
||||||
// 返回最新聚合
|
return new Response(text, { status: resp.status, headers: { 'Content-Type': resp.headers.get('Content-Type') || 'application/json' } });
|
||||||
const [providersResp, modulesResp] = await Promise.all([
|
|
||||||
fetch(`${BACKEND_BASE}/configs/llm_providers`, { cache: 'no-store' }),
|
|
||||||
fetch(`${BACKEND_BASE}/configs/analysis_modules`, { cache: 'no-store' }),
|
|
||||||
]);
|
|
||||||
const providers = await providersResp.json().catch(() => ({}));
|
|
||||||
const modules = await modulesResp.json().catch(() => ({}));
|
|
||||||
return Response.json({
|
|
||||||
llm_providers: providers,
|
|
||||||
analysis_modules: modules,
|
|
||||||
});
|
|
||||||
} catch (e: any) {
|
|
||||||
return new Response(e?.message || 'Failed to update config', { status: 502 });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,12 +1,17 @@
|
|||||||
import { NextRequest } from 'next/server';
|
import { NextRequest } from 'next/server';
|
||||||
|
|
||||||
const BACKEND_BASE = process.env.BACKEND_INTERNAL_URL || process.env.NEXT_PUBLIC_BACKEND_URL;
|
const BACKEND_BASE = process.env.NEXT_PUBLIC_BACKEND_URL;
|
||||||
|
|
||||||
export async function POST(req: NextRequest) {
|
export async function POST(req: NextRequest) {
|
||||||
if (!BACKEND_BASE) {
|
if (!BACKEND_BASE) {
|
||||||
return new Response('BACKEND_INTERNAL_URL/NEXT_PUBLIC_BACKEND_URL 未配置', { status: 500 });
|
return new Response('NEXT_PUBLIC_BACKEND_URL 未配置', { status: 500 });
|
||||||
}
|
}
|
||||||
// 新后端暂无统一 /config/test;先返回未实现
|
const body = await req.text();
|
||||||
const body = await req.text().catch(() => '');
|
const resp = await fetch(`${BACKEND_BASE}/config/test`, {
|
||||||
return Response.json({ success: false, message: 'config/test 未实现', echo: body }, { status: 501 });
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
const text = await resp.text();
|
||||||
|
return new Response(text, { status: resp.status, headers: { 'Content-Type': resp.headers.get('Content-Type') || 'application/json' } });
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,34 +0,0 @@
|
|||||||
const BACKEND_BASE = process.env.BACKEND_INTERNAL_URL || process.env.NEXT_PUBLIC_BACKEND_URL;
|
|
||||||
|
|
||||||
export async function GET() {
|
|
||||||
if (!BACKEND_BASE) {
|
|
||||||
return new Response('NEXT_PUBLIC_BACKEND_URL 未配置', { status: 500 });
|
|
||||||
}
|
|
||||||
const resp = await fetch(`${BACKEND_BASE}/configs/analysis_modules`, {
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
cache: 'no-store',
|
|
||||||
});
|
|
||||||
const text = await resp.text();
|
|
||||||
return new Response(text, {
|
|
||||||
status: resp.status,
|
|
||||||
headers: { 'Content-Type': resp.headers.get('Content-Type') || 'application/json' },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function PUT(req: Request) {
|
|
||||||
if (!BACKEND_BASE) {
|
|
||||||
return new Response('NEXT_PUBLIC_BACKEND_URL 未配置', { status: 500 });
|
|
||||||
}
|
|
||||||
const body = await req.text();
|
|
||||||
const resp = await fetch(`${BACKEND_BASE}/configs/analysis_modules`, {
|
|
||||||
method: 'PUT',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body,
|
|
||||||
});
|
|
||||||
const text = await resp.text();
|
|
||||||
return new Response(text, {
|
|
||||||
status: resp.status,
|
|
||||||
headers: { 'Content-Type': resp.headers.get('Content-Type') || 'application/json' },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
@ -1,34 +0,0 @@
|
|||||||
const BACKEND_BASE = process.env.BACKEND_INTERNAL_URL || process.env.NEXT_PUBLIC_BACKEND_URL;
|
|
||||||
|
|
||||||
export async function GET() {
|
|
||||||
if (!BACKEND_BASE) {
|
|
||||||
return new Response('NEXT_PUBLIC_BACKEND_URL 未配置', { status: 500 });
|
|
||||||
}
|
|
||||||
const resp = await fetch(`${BACKEND_BASE}/configs/llm_providers`, {
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
cache: 'no-store',
|
|
||||||
});
|
|
||||||
const text = await resp.text();
|
|
||||||
return new Response(text, {
|
|
||||||
status: resp.status,
|
|
||||||
headers: { 'Content-Type': resp.headers.get('Content-Type') || 'application/json' },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function PUT(req: Request) {
|
|
||||||
if (!BACKEND_BASE) {
|
|
||||||
return new Response('NEXT_PUBLIC_BACKEND_URL 未配置', { status: 500 });
|
|
||||||
}
|
|
||||||
const body = await req.text();
|
|
||||||
const resp = await fetch(`${BACKEND_BASE}/configs/llm_providers`, {
|
|
||||||
method: 'PUT',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body,
|
|
||||||
});
|
|
||||||
const text = await resp.text();
|
|
||||||
return new Response(text, {
|
|
||||||
status: resp.status,
|
|
||||||
headers: { 'Content-Type': resp.headers.get('Content-Type') || 'application/json' },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
@ -1,10 +1,10 @@
|
|||||||
import { NextRequest } from 'next/server';
|
import { NextRequest } from 'next/server';
|
||||||
|
|
||||||
const BACKEND_BASE = process.env.BACKEND_INTERNAL_URL || process.env.NEXT_PUBLIC_BACKEND_URL;
|
const BACKEND_BASE = process.env.NEXT_PUBLIC_BACKEND_URL;
|
||||||
|
|
||||||
export async function POST(req: NextRequest) {
|
export async function POST(req: NextRequest) {
|
||||||
if (!BACKEND_BASE) {
|
if (!BACKEND_BASE) {
|
||||||
return new Response('BACKEND_INTERNAL_URL/NEXT_PUBLIC_BACKEND_URL 未配置', { status: 500 });
|
return new Response('NEXT_PUBLIC_BACKEND_URL 未配置', { status: 500 });
|
||||||
}
|
}
|
||||||
const body = await req.text();
|
const body = await req.text();
|
||||||
const resp = await fetch(`${BACKEND_BASE}/data-requests`, {
|
const resp = await fetch(`${BACKEND_BASE}/data-requests`, {
|
||||||
|
|||||||
@ -1,22 +0,0 @@
|
|||||||
const BACKEND_BASE = process.env.NEXT_PUBLIC_BACKEND_URL;
|
|
||||||
|
|
||||||
export async function GET(
|
|
||||||
_req: Request,
|
|
||||||
context: { params: Promise<{ provider_id: string }> }
|
|
||||||
) {
|
|
||||||
if (!BACKEND_BASE) {
|
|
||||||
return new Response('NEXT_PUBLIC_BACKEND_URL 未配置', { status: 500 });
|
|
||||||
}
|
|
||||||
const { provider_id } = await context.params;
|
|
||||||
const target = `${BACKEND_BASE}/discover-models/${encodeURIComponent(provider_id)}`;
|
|
||||||
const resp = await fetch(target, {
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
cache: 'no-store',
|
|
||||||
});
|
|
||||||
const text = await resp.text();
|
|
||||||
return new Response(text, {
|
|
||||||
status: resp.status,
|
|
||||||
headers: { 'Content-Type': resp.headers.get('Content-Type') || 'application/json' },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
@ -1,58 +1,27 @@
|
|||||||
import { NextRequest } from 'next/server';
|
import { NextRequest } from 'next/server';
|
||||||
|
|
||||||
const BACKEND_BASE = process.env.BACKEND_INTERNAL_URL || process.env.NEXT_PUBLIC_BACKEND_URL;
|
const BACKEND_BASE = process.env.NEXT_PUBLIC_BACKEND_URL;
|
||||||
const FRONTEND_BASE = process.env.FRONTEND_INTERNAL_URL || 'http://localhost:3001';
|
|
||||||
|
|
||||||
export async function GET(
|
export async function GET(
|
||||||
req: NextRequest,
|
req: NextRequest,
|
||||||
context: { params: Promise<{ slug: string[] }> }
|
context: { params: Promise<{ slug: string[] }> }
|
||||||
) {
|
) {
|
||||||
if (!BACKEND_BASE) {
|
if (!BACKEND_BASE) {
|
||||||
return new Response('BACKEND_INTERNAL_URL/NEXT_PUBLIC_BACKEND_URL 未配置', { status: 500 });
|
return new Response('NEXT_PUBLIC_BACKEND_URL 未配置', { status: 500 });
|
||||||
}
|
}
|
||||||
const url = new URL(req.url);
|
const url = new URL(req.url);
|
||||||
const { slug } = await context.params;
|
const { slug } = await context.params;
|
||||||
const first = slug?.[0];
|
const path = slug.join('/');
|
||||||
// 适配旧接口:analysis-config → 新分析模块配置
|
const target = `${BACKEND_BASE}/financials/${path}${url.search}`;
|
||||||
if (first === 'analysis-config') {
|
const resp = await fetch(target, { headers: { 'Content-Type': 'application/json' } });
|
||||||
const resp = await fetch(`${BACKEND_BASE}/configs/analysis_modules`, { cache: 'no-store' });
|
// 透传后端响应(支持流式 body)
|
||||||
const text = await resp.text();
|
const headers = new Headers();
|
||||||
return new Response(text, {
|
// 复制关键头,减少代理层缓冲
|
||||||
status: resp.status,
|
const contentType = resp.headers.get('content-type') || 'application/json; charset=utf-8';
|
||||||
headers: { 'Content-Type': resp.headers.get('Content-Type') || 'application/json' },
|
headers.set('content-type', contentType);
|
||||||
});
|
const cacheControl = resp.headers.get('cache-control');
|
||||||
}
|
if (cacheControl) headers.set('cache-control', cacheControl);
|
||||||
// 适配旧接口:config → 聚合配置
|
const xAccelBuffering = resp.headers.get('x-accel-buffering');
|
||||||
if (first === 'config') {
|
if (xAccelBuffering) headers.set('x-accel-buffering', xAccelBuffering);
|
||||||
const resp = await fetch(`${FRONTEND_BASE}/api/config`, { cache: 'no-store' });
|
return new Response(resp.body, { status: resp.status, headers });
|
||||||
const text = await resp.text();
|
|
||||||
return new Response(text, { status: resp.status, headers: { 'Content-Type': 'application/json' } });
|
|
||||||
}
|
|
||||||
// 其他旧 financials 端点在新架构中未实现:返回空对象以避免前端 JSON 解析错误
|
|
||||||
return Response.json({}, { status: 200 });
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function PUT(
|
|
||||||
req: NextRequest,
|
|
||||||
context: { params: Promise<{ slug: string[] }> }
|
|
||||||
) {
|
|
||||||
if (!BACKEND_BASE) {
|
|
||||||
return new Response('BACKEND_INTERNAL_URL/NEXT_PUBLIC_BACKEND_URL 未配置', { status: 500 });
|
|
||||||
}
|
|
||||||
const { slug } = await context.params;
|
|
||||||
const first = slug?.[0];
|
|
||||||
if (first === 'analysis-config') {
|
|
||||||
const body = await req.text();
|
|
||||||
const resp = await fetch(`${BACKEND_BASE}/configs/analysis_modules`, {
|
|
||||||
method: 'PUT',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body,
|
|
||||||
});
|
|
||||||
const text = await resp.text();
|
|
||||||
return new Response(text, {
|
|
||||||
status: resp.status,
|
|
||||||
headers: { 'Content-Type': resp.headers.get('Content-Type') || 'application/json' },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return new Response('Not Found', { status: 404 });
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,5 @@
|
|||||||
import { NextRequest } from 'next/server'
|
import { NextRequest } from 'next/server'
|
||||||
|
import { prisma } from '../../../../lib/prisma'
|
||||||
const BACKEND_BASE = process.env.BACKEND_INTERNAL_URL || process.env.NEXT_PUBLIC_BACKEND_URL;
|
|
||||||
|
|
||||||
export async function GET(
|
export async function GET(
|
||||||
req: NextRequest,
|
req: NextRequest,
|
||||||
@ -22,28 +21,9 @@ export async function GET(
|
|||||||
return Response.json({ error: 'missing id' }, { status: 400 })
|
return Response.json({ error: 'missing id' }, { status: 400 })
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!BACKEND_BASE) {
|
const report = await prisma.report.findUnique({ where: { id } })
|
||||||
return new Response('NEXT_PUBLIC_BACKEND_URL 未配置', { status: 500 });
|
if (!report) {
|
||||||
}
|
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 });
|
|
||||||
}
|
|
||||||
// 将后端 DTO(generated_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)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,13 +1,43 @@
|
|||||||
export const runtime = 'nodejs'
|
export const runtime = 'nodejs'
|
||||||
import { NextRequest } from 'next/server'
|
import { NextRequest } from 'next/server'
|
||||||
|
import { prisma } from '../../../lib/prisma'
|
||||||
|
|
||||||
export async function GET(req: NextRequest) {
|
export async function GET(req: NextRequest) {
|
||||||
// 历史报告列表功能在新架构中由后端持久化服务统一提供。
|
const url = new URL(req.url)
|
||||||
// 当前网关未提供“全量列表”接口(需要 symbol 条件),因此此路由返回空集合。
|
const limit = Number(url.searchParams.get('limit') || 50)
|
||||||
return Response.json({ items: [], total: 0 }, { status: 200 });
|
const offset = Number(url.searchParams.get('offset') || 0)
|
||||||
|
|
||||||
|
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 {
|
||||||
return Response.json({ error: 'Not implemented: creation is handled by backend pipeline' }, { status: 501 });
|
const body = await req.json()
|
||||||
|
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 })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,11 +1,11 @@
|
|||||||
const BACKEND_BASE = process.env.BACKEND_INTERNAL_URL || process.env.NEXT_PUBLIC_BACKEND_URL;
|
const BACKEND_BASE = process.env.NEXT_PUBLIC_BACKEND_URL;
|
||||||
|
|
||||||
export async function GET(
|
export async function GET(
|
||||||
_req: Request,
|
_req: Request,
|
||||||
context: { params: Promise<{ request_id: string }> }
|
context: { params: Promise<{ request_id: string }> }
|
||||||
) {
|
) {
|
||||||
if (!BACKEND_BASE) {
|
if (!BACKEND_BASE) {
|
||||||
return new Response('BACKEND_INTERNAL_URL/NEXT_PUBLIC_BACKEND_URL 未配置', { status: 500 });
|
return new Response('NEXT_PUBLIC_BACKEND_URL 未配置', { status: 500 });
|
||||||
}
|
}
|
||||||
const { request_id } = await context.params;
|
const { request_id } = await context.params;
|
||||||
const target = `${BACKEND_BASE}/tasks/${encodeURIComponent(request_id)}`;
|
const target = `${BACKEND_BASE}/tasks/${encodeURIComponent(request_id)}`;
|
||||||
|
|||||||
@ -1,12 +1,8 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import {
|
import { useConfig, updateConfig, testConfig, useAnalysisConfig, updateAnalysisConfig } from '@/hooks/useApi';
|
||||||
useConfig, updateConfig, testConfig,
|
import { useConfigStore, SystemConfig } from '@/stores/useConfigStore';
|
||||||
useAnalysisModules, updateAnalysisModules, useLlmProviders
|
|
||||||
} from '@/hooks/useApi';
|
|
||||||
import { useConfigStore } from '@/stores/useConfigStore';
|
|
||||||
import type { SystemConfig } from '@/stores/useConfigStore';
|
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
@ -16,9 +12,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
|||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
import { Checkbox } from "@/components/ui/checkbox";
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import type { AnalysisConfigResponse } from '@/types';
|
||||||
// Types are imported from '@/types'
|
|
||||||
import type { AnalysisModulesConfig } from '@/types';
|
|
||||||
|
|
||||||
export default function ConfigPage() {
|
export default function ConfigPage() {
|
||||||
// 从 Zustand store 获取全局状态
|
// 从 Zustand store 获取全局状态
|
||||||
@ -26,19 +20,27 @@ export default function ConfigPage() {
|
|||||||
// 使用 SWR hook 加载初始配置
|
// 使用 SWR hook 加载初始配置
|
||||||
useConfig();
|
useConfig();
|
||||||
|
|
||||||
// 加载分析配置(统一使用 initialAnalysisModules)
|
// 加载分析配置
|
||||||
// const { data: analysisConfig, mutate: mutateAnalysisConfig } = useAnalysisModules();
|
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('');
|
||||||
const [finnhubApiKey, setFinnhubApiKey] = useState('');
|
const [finnhubApiKey, setFinnhubApiKey] = useState('');
|
||||||
|
|
||||||
// 分析配置的本地状态
|
// 分析配置的本地状态
|
||||||
const [localAnalysisModules, setLocalAnalysisModules] = useState<AnalysisModulesConfig>({});
|
const [localAnalysisConfig, setLocalAnalysisConfig] = useState<Record<string, {
|
||||||
|
name: string;
|
||||||
|
model: string;
|
||||||
|
prompt_template: string;
|
||||||
|
dependencies?: string[];
|
||||||
|
}>>({});
|
||||||
|
|
||||||
// 分析配置保存状态(状态定义在下方统一维护)
|
// 分析配置保存状态
|
||||||
|
const [savingAnalysis, setSavingAnalysis] = useState(false);
|
||||||
|
const [analysisSaveMessage, setAnalysisSaveMessage] = useState('');
|
||||||
|
|
||||||
// 测试结果状态
|
// 测试结果状态
|
||||||
const [testResults, setTestResults] = useState<Record<string, { success: boolean; message: string } | null>>({});
|
const [testResults, setTestResults] = useState<Record<string, { success: boolean; message: string } | null>>({});
|
||||||
@ -47,45 +49,16 @@ export default function ConfigPage() {
|
|||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [saveMessage, setSaveMessage] = useState('');
|
const [saveMessage, setSaveMessage] = useState('');
|
||||||
|
|
||||||
// --- New State for Analysis Modules ---
|
// 初始化分析配置的本地状态
|
||||||
const { data: llmProviders } = useLlmProviders();
|
|
||||||
const { data: initialAnalysisModules, mutate } = useAnalysisModules();
|
|
||||||
const [isSavingAnalysis, setIsSavingAnalysis] = useState(false);
|
|
||||||
const [analysisSaveMessage, setAnalysisSaveMessage] = useState('');
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (initialAnalysisModules) {
|
if (analysisConfig?.analysis_modules) {
|
||||||
setLocalAnalysisModules(initialAnalysisModules);
|
setLocalAnalysisConfig(analysisConfig.analysis_modules);
|
||||||
}
|
}
|
||||||
}, [initialAnalysisModules]);
|
}, [analysisConfig]);
|
||||||
|
|
||||||
const handleAnalysisChange = (moduleId: string, field: string, value: string) => {
|
|
||||||
setLocalAnalysisModules(prev => ({
|
|
||||||
...prev,
|
|
||||||
[moduleId]: { ...prev[moduleId], [field]: value }
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSaveAnalysis = async () => {
|
|
||||||
setIsSavingAnalysis(true);
|
|
||||||
setAnalysisSaveMessage('保存中...');
|
|
||||||
try {
|
|
||||||
const updated = await updateAnalysisModules(localAnalysisModules);
|
|
||||||
await mutate(updated, false);
|
|
||||||
setAnalysisSaveMessage('分析配置保存成功!');
|
|
||||||
} catch (e: any) {
|
|
||||||
setAnalysisSaveMessage(`保存失败: ${e.message}`);
|
|
||||||
} finally {
|
|
||||||
setIsSavingAnalysis(false);
|
|
||||||
setTimeout(() => setAnalysisSaveMessage(''), 5000);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 初始化分析配置的本地状态(已在 initialAnalysisModules 的 Effect 中处理)
|
|
||||||
|
|
||||||
// 更新分析配置中的某个字段
|
// 更新分析配置中的某个字段
|
||||||
const updateAnalysisField = (type: string, field: 'name' | 'model' | 'prompt_template', value: string) => {
|
const updateAnalysisField = (type: string, field: 'name' | 'model' | 'prompt_template', value: string) => {
|
||||||
setLocalAnalysisModules(prev => ({
|
setLocalAnalysisConfig(prev => ({
|
||||||
...prev,
|
...prev,
|
||||||
[type]: {
|
[type]: {
|
||||||
...prev[type],
|
...prev[type],
|
||||||
@ -96,7 +69,7 @@ export default function ConfigPage() {
|
|||||||
|
|
||||||
// 更新分析模块的依赖
|
// 更新分析模块的依赖
|
||||||
const updateAnalysisDependencies = (type: string, dependency: string, checked: boolean) => {
|
const updateAnalysisDependencies = (type: string, dependency: string, checked: boolean) => {
|
||||||
setLocalAnalysisModules(prev => {
|
setLocalAnalysisConfig(prev => {
|
||||||
const currentConfig = prev[type];
|
const currentConfig = prev[type];
|
||||||
const currentDeps = currentConfig.dependencies || [];
|
const currentDeps = currentConfig.dependencies || [];
|
||||||
|
|
||||||
@ -115,11 +88,33 @@ export default function ConfigPage() {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// 旧版保存逻辑已移除,统一使用 handleSaveAnalysis
|
// 保存分析配置
|
||||||
|
const handleSaveAnalysisConfig = async () => {
|
||||||
|
setSavingAnalysis(true);
|
||||||
|
setAnalysisSaveMessage('保存中...');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const updated = await updateAnalysisConfig({
|
||||||
|
analysis_modules: localAnalysisConfig
|
||||||
|
});
|
||||||
|
await mutateAnalysisConfig(updated);
|
||||||
|
setAnalysisSaveMessage('保存成功!');
|
||||||
|
} catch (e: any) {
|
||||||
|
setAnalysisSaveMessage(`保存失败: ${e.message}`);
|
||||||
|
} finally {
|
||||||
|
setSavingAnalysis(false);
|
||||||
|
setTimeout(() => setAnalysisSaveMessage(''), 5000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
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:// 开头');
|
||||||
@ -154,6 +149,11 @@ 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,6 +197,10 @@ 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,
|
||||||
@ -213,6 +217,7 @@ export default function ConfigPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleReset = () => {
|
const handleReset = () => {
|
||||||
|
setDbUrl('');
|
||||||
setNewApiApiKey('');
|
setNewApiApiKey('');
|
||||||
setNewApiBaseUrl('');
|
setNewApiBaseUrl('');
|
||||||
setTushareApiKey('');
|
setTushareApiKey('');
|
||||||
@ -225,6 +230,7 @@ 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(),
|
||||||
@ -252,6 +258,9 @@ export default function ConfigPage() {
|
|||||||
const importedConfig = JSON.parse(e.target?.result as string);
|
const importedConfig = JSON.parse(e.target?.result as string);
|
||||||
|
|
||||||
// 验证导入的配置格式
|
// 验证导入的配置格式
|
||||||
|
if (importedConfig.database?.url) {
|
||||||
|
setDbUrl(importedConfig.database.url);
|
||||||
|
}
|
||||||
if (importedConfig.new_api?.base_url) {
|
if (importedConfig.new_api?.base_url) {
|
||||||
setNewApiBaseUrl(importedConfig.new_api.base_url);
|
setNewApiBaseUrl(importedConfig.new_api.base_url);
|
||||||
}
|
}
|
||||||
@ -287,18 +296,51 @@ 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">
|
||||||
管理系统配置,包括 AI 服务与数据源等。敏感信息不回显,留空表示保持当前值。
|
管理系统配置,包括数据库连接、API密钥等。敏感信息不回显,留空表示保持当前值。
|
||||||
</p>
|
</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<Tabs defaultValue="ai" className="space-y-6">
|
<Tabs defaultValue="database" className="space-y-6">
|
||||||
<TabsList className="grid w-full grid-cols-4">
|
<TabsList className="grid w-full grid-cols-5">
|
||||||
|
<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>
|
||||||
@ -408,63 +450,102 @@ export default function ConfigPage() {
|
|||||||
<CardDescription>配置各个分析模块的模型和提示词</CardDescription>
|
<CardDescription>配置各个分析模块的模型和提示词</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-6">
|
<CardContent className="space-y-6">
|
||||||
{Object.entries(localAnalysisModules).map(([moduleId, config]) => {
|
{Object.entries(localAnalysisConfig).map(([type, config]) => {
|
||||||
const availableModels = llmProviders?.[config.provider_id]?.models.filter(m => m.is_active) || [];
|
const otherModuleKeys = Object.keys(localAnalysisConfig).filter(key => key !== type);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={moduleId} className="space-y-4 p-4 border rounded-lg">
|
<div key={type} className="space-y-4 p-4 border rounded-lg">
|
||||||
<h3 className="text-lg font-semibold">{config.name || moduleId}</h3>
|
<div className="flex items-center justify-between">
|
||||||
|
<h3 className="text-lg font-semibold">{config.name || type}</h3>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<Badge variant="secondary">{type}</Badge>
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>LLM Provider</Label>
|
|
||||||
<Select
|
|
||||||
value={config.provider_id}
|
|
||||||
onValueChange={(value) => handleAnalysisChange(moduleId, 'provider_id', value)}
|
|
||||||
>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue placeholder="选择一个 Provider" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{llmProviders && Object.entries(llmProviders).map(([pId, p]) => (
|
|
||||||
<SelectItem key={pId} value={pId}>{p.name}</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Model</Label>
|
|
||||||
<Select
|
|
||||||
value={config.model_id}
|
|
||||||
onValueChange={(value) => handleAnalysisChange(moduleId, 'model_id', value)}
|
|
||||||
disabled={!config.provider_id || availableModels.length === 0}
|
|
||||||
>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue placeholder="选择一个 Model" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{availableModels.map(m => (
|
|
||||||
<SelectItem key={m.model_id} value={m.model_id}>{m.name || m.model_id}</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor={`${moduleId}-prompt`}>提示词模板</Label>
|
<Label htmlFor={`${type}-name`}>显示名称</Label>
|
||||||
<Textarea
|
<Input
|
||||||
id={`${moduleId}-prompt`}
|
id={`${type}-name`}
|
||||||
value={config.prompt_template || ''}
|
value={config.name || ''}
|
||||||
onChange={(e) => handleAnalysisChange(moduleId, 'prompt_template', e.target.value)}
|
onChange={(e) => updateAnalysisField(type, 'name', e.target.value)}
|
||||||
rows={10}
|
placeholder="分析模块显示名称"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor={`${type}-model`}>模型名称</Label>
|
||||||
|
<Input
|
||||||
|
id={`${type}-model`}
|
||||||
|
value={config.model || ''}
|
||||||
|
onChange={(e) => updateAnalysisField(type, 'model', e.target.value)}
|
||||||
|
placeholder="例如: gemini-1.5-pro"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
在 AI 服务中配置的模型名称
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>模块依赖</Label>
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-2 rounded-lg border p-4">
|
||||||
|
{otherModuleKeys.map(depKey => (
|
||||||
|
<div key={depKey} className="flex items-center space-x-2">
|
||||||
|
<Checkbox
|
||||||
|
id={`${type}-dep-${depKey}`}
|
||||||
|
checked={config.dependencies?.includes(depKey)}
|
||||||
|
onCheckedChange={(checked) => {
|
||||||
|
updateAnalysisDependencies(type, depKey, !!checked);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<label
|
||||||
|
htmlFor={`${type}-dep-${depKey}`}
|
||||||
|
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||||
|
>
|
||||||
|
{localAnalysisConfig[depKey]?.name || depKey}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
选择此模块在生成时需要依赖的其他模块。选中的模块结果将通过占位符注入提示词模板。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor={`${type}-prompt`}>提示词模板</Label>
|
||||||
|
<Textarea
|
||||||
|
id={`${type}-prompt`}
|
||||||
|
value={config.prompt_template || ''}
|
||||||
|
onChange={(e) => updateAnalysisField(type, 'prompt_template', e.target.value)}
|
||||||
|
placeholder="提示词模板,支持 {company_name}, {ts_code}, {financial_data} 占位符"
|
||||||
|
rows={10}
|
||||||
|
className="font-mono text-sm"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
基础占位符: <code>{`{company_name}`}</code>, <code>{`{ts_code}`}</code>, <code>{`{financial_data}`}</code>.
|
||||||
|
<br />
|
||||||
|
其他模块:{' '}
|
||||||
|
{otherModuleKeys.length > 0
|
||||||
|
? otherModuleKeys.map((key, index) => (
|
||||||
|
<span key={key}>
|
||||||
|
<code>{`{${key}}`}</code>
|
||||||
|
{index < otherModuleKeys.length - 1 ? ', ' : ''}
|
||||||
|
</span>
|
||||||
|
))
|
||||||
|
: '无'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
<div className="flex items-center gap-4 pt-4">
|
<div className="flex items-center gap-4 pt-4">
|
||||||
<Button onClick={handleSaveAnalysis} disabled={isSavingAnalysis}>
|
<Button
|
||||||
{isSavingAnalysis ? '保存中...' : '保存分析配置'}
|
onClick={handleSaveAnalysisConfig}
|
||||||
|
disabled={savingAnalysis}
|
||||||
|
size="lg"
|
||||||
|
>
|
||||||
|
{savingAnalysis ? '保存中...' : '保存分析配置'}
|
||||||
</Button>
|
</Button>
|
||||||
{analysisSaveMessage && (
|
{analysisSaveMessage && (
|
||||||
<span className={`text-sm ${analysisSaveMessage.includes('成功') ? 'text-green-600' : 'text-red-600'}`}>
|
<span className={`text-sm ${analysisSaveMessage.includes('成功') ? 'text-green-600' : 'text-red-600'}`}>
|
||||||
@ -484,6 +565,12 @@ 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'}>
|
||||||
|
|||||||
@ -2,32 +2,18 @@ import { promises as fs } from 'fs';
|
|||||||
import path from 'path';
|
import path from 'path';
|
||||||
import ReactMarkdown from 'react-markdown';
|
import ReactMarkdown from 'react-markdown';
|
||||||
import remarkGfm from 'remark-gfm';
|
import remarkGfm from 'remark-gfm';
|
||||||
import { Card, CardContent } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
|
||||||
async function getMarkdownContent() {
|
async function getMarkdownContent() {
|
||||||
// process.cwd() is the root of the Next.js project (the 'frontend' directory)
|
// process.cwd() is the root of the Next.js project (the 'frontend' directory)
|
||||||
const root = path.join(process.cwd(), '..', 'docs');
|
const mdPath = path.join(process.cwd(), '..', 'docs', 'user-guide.md');
|
||||||
const candidates = [
|
|
||||||
path.join(root, '1_requirements', '20251109_[Active]_user-guide.md'),
|
|
||||||
path.join(root, '1_requirements', '20251108_[Active]_requirements.md'),
|
|
||||||
path.join(root, '2_architecture', '20251116_[Active]_system_architecture.md'),
|
|
||||||
];
|
|
||||||
for (const p of candidates) {
|
|
||||||
try {
|
try {
|
||||||
const content = await fs.readFile(p, 'utf8');
|
const content = await fs.readFile(mdPath, 'utf8');
|
||||||
return content;
|
return content;
|
||||||
} catch {
|
} catch (error) {
|
||||||
// try next
|
console.error("Failed to read user-guide.md:", error);
|
||||||
|
return "# 文档加载失败\n\n无法读取 `docs/user-guide.md` 文件。请检查文件是否存在以及服务器权限。";
|
||||||
}
|
}
|
||||||
}
|
|
||||||
return [
|
|
||||||
'# 文档加载失败',
|
|
||||||
'',
|
|
||||||
'未找到以下任意文档:',
|
|
||||||
'- docs/1_requirements/20251109_[Active]_user-guide.md',
|
|
||||||
'- docs/1_requirements/20251108_[Active]_requirements.md',
|
|
||||||
'- docs/2_architecture/20251116_[Active]_system_architecture.md',
|
|
||||||
].join('\n');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function DocsPage() {
|
export default async function DocsPage() {
|
||||||
|
|||||||
@ -1,404 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useState, useEffect, useMemo } from 'react';
|
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { useLlmProviders, updateLlmProviders, discoverProviderModels } from '@/hooks/useApi';
|
|
||||||
import type { LlmProvider, LlmModel } from '@/types';
|
|
||||||
import { Badge } from '@/components/ui/badge';
|
|
||||||
import { useLlmConfigStore } from '@/stores/useLlmConfigStore';
|
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
|
|
||||||
import { Input } from '@/components/ui/input';
|
|
||||||
import { Label } from '@/components/ui/label';
|
|
||||||
import { Switch } from '@/components/ui/switch';
|
|
||||||
import { Checkbox } from '@/components/ui/checkbox';
|
|
||||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
|
||||||
|
|
||||||
// Main Page Component
|
|
||||||
export default function LlmConfigPage() {
|
|
||||||
const { data: initialProviders, error, isLoading, mutate } = useLlmProviders();
|
|
||||||
const {
|
|
||||||
providers,
|
|
||||||
setInitialProviders,
|
|
||||||
openModal,
|
|
||||||
modal,
|
|
||||||
deleteProvider,
|
|
||||||
} = useLlmConfigStore();
|
|
||||||
|
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
|
||||||
const [saveMessage, setSaveMessage] = useState('');
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (initialProviders) {
|
|
||||||
setInitialProviders(initialProviders);
|
|
||||||
}
|
|
||||||
}, [initialProviders, setInitialProviders]);
|
|
||||||
|
|
||||||
const handleSaveAll = async () => {
|
|
||||||
setIsSaving(true);
|
|
||||||
setSaveMessage('保存中...');
|
|
||||||
try {
|
|
||||||
const updated = await updateLlmProviders(providers);
|
|
||||||
setInitialProviders(updated);
|
|
||||||
await mutate(updated, false); // revalidate SWR
|
|
||||||
setSaveMessage('保存成功!');
|
|
||||||
} catch (e: any) {
|
|
||||||
setSaveMessage(`保存失败: ${e.message}`);
|
|
||||||
} finally {
|
|
||||||
setIsSaving(false);
|
|
||||||
setTimeout(() => setSaveMessage(''), 5000);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (isLoading) return <div className="text-center p-8">加载中...</div>;
|
|
||||||
if (error) return <div className="text-center p-8 text-red-500">加载配置失败: {error.message}</div>;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="container mx-auto py-6 space-y-6">
|
|
||||||
<header className="space-y-2">
|
|
||||||
<h1 className="text-3xl font-bold">LLM Provider 配置</h1>
|
|
||||||
<p className="text-muted-foreground">
|
|
||||||
在这里管理所有可用的大语言模型(LLM)供应商及其模型。
|
|
||||||
</p>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<div className="flex justify-end">
|
|
||||||
<Button onClick={() => openModal({ type: 'addProvider' })}>新增 Provider</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-8">
|
|
||||||
{Object.entries(providers).map(([providerId, provider]) => (
|
|
||||||
<Card key={providerId}>
|
|
||||||
<CardHeader>
|
|
||||||
<div className="flex justify-between items-start">
|
|
||||||
<div>
|
|
||||||
<CardTitle>{provider.name}</CardTitle>
|
|
||||||
<CardDescription>ID: {providerId}</CardDescription>
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button variant="outline" size="sm" onClick={() => openModal({ type: 'editProvider', providerId })}>编辑</Button>
|
|
||||||
<Button variant="destructive" size="sm" onClick={() => deleteProvider(providerId)}>删除</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<p className="text-sm text-muted-foreground mb-4">
|
|
||||||
API Base URL: <code className="font-mono">{provider.api_base_url}</code>
|
|
||||||
</p>
|
|
||||||
<div className="border rounded-lg p-4">
|
|
||||||
<div className="flex justify-between items-center mb-4">
|
|
||||||
<h3 className="text-lg font-semibold">模型管理</h3>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button variant="outline" size="sm" onClick={() => openModal({ type: 'addModel', providerId })}>新增模型</Button>
|
|
||||||
<Button variant="outline" onClick={() => openModal({ type: 'discoverModels', providerId })}>发现模型</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
{provider.models.map((model, index) => (
|
|
||||||
<div key={index} className="flex items-center justify-between p-2 border-b last:border-b-0">
|
|
||||||
<div>
|
|
||||||
<p className="font-medium">{model.model_id}</p>
|
|
||||||
{model.name && <p className="text-sm text-muted-foreground">{model.name}</p>}
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<Badge variant={model.is_active ? 'default' : 'secondary'}>
|
|
||||||
{model.is_active ? '已激活' : '未激活'}
|
|
||||||
</Badge>
|
|
||||||
<Button variant="ghost" size="sm" onClick={() => openModal({ type: 'editModel', providerId, modelIndex: index })}>编辑</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center justify-between pt-6 border-t">
|
|
||||||
<div className="text-sm text-muted-foreground">
|
|
||||||
{saveMessage}
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<Button size="lg" onClick={handleSaveAll} disabled={isSaving}>
|
|
||||||
{isSaving ? '保存中...' : '保存所有更改'}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{modal.type !== 'closed' && <ConfigModal />}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Modal Components ---
|
|
||||||
|
|
||||||
function ConfigModal() {
|
|
||||||
const { modal } = useLlmConfigStore();
|
|
||||||
|
|
||||||
if (modal.type === 'addProvider' || modal.type === 'editProvider') {
|
|
||||||
return <ProviderEditModal />;
|
|
||||||
}
|
|
||||||
if (modal.type === 'addModel' || modal.type === 'editModel') {
|
|
||||||
return <ModelEditModal />;
|
|
||||||
}
|
|
||||||
if (modal.type === 'discoverModels') {
|
|
||||||
return <DiscoverModelsModal />;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
type ProviderFormData = {
|
|
||||||
id?: string;
|
|
||||||
name?: string;
|
|
||||||
api_base_url?: string;
|
|
||||||
api_key?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
function ProviderEditModal() {
|
|
||||||
const { modal, closeModal, providers, addProvider, updateProvider } = useLlmConfigStore();
|
|
||||||
const [formData, setFormData] = useState<ProviderFormData>({});
|
|
||||||
|
|
||||||
const isAdd = modal.type === 'addProvider';
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (modal.type === 'editProvider') {
|
|
||||||
const provider = providers[modal.providerId];
|
|
||||||
setFormData({ ...provider, id: modal.providerId });
|
|
||||||
} else {
|
|
||||||
setFormData({});
|
|
||||||
}
|
|
||||||
}, [modal, providers]);
|
|
||||||
|
|
||||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
const { name, value } = e.target;
|
|
||||||
setFormData((prev) => ({ ...prev, [name]: value }));
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSubmit = () => {
|
|
||||||
if (isAdd) {
|
|
||||||
const { id, ...providerData } = formData;
|
|
||||||
const providerId = (id || '').trim();
|
|
||||||
if (!providerId) return;
|
|
||||||
const providerObj: LlmProvider = {
|
|
||||||
name: providerData.name || '',
|
|
||||||
api_base_url: providerData.api_base_url || '',
|
|
||||||
api_key: providerData.api_key || '',
|
|
||||||
models: [],
|
|
||||||
};
|
|
||||||
addProvider(providerId, providerObj);
|
|
||||||
} else if (modal.type === 'editProvider') {
|
|
||||||
const { id, ...providerData } = formData;
|
|
||||||
const providerId = (id || '').trim();
|
|
||||||
if (!providerId) return;
|
|
||||||
const existing = providers[providerId] as LlmProvider | undefined;
|
|
||||||
const providerObj: LlmProvider = {
|
|
||||||
name: providerData.name ?? existing?.name ?? '',
|
|
||||||
api_base_url: providerData.api_base_url ?? existing?.api_base_url ?? '',
|
|
||||||
api_key: providerData.api_key ?? existing?.api_key ?? '',
|
|
||||||
models: existing?.models ?? [],
|
|
||||||
};
|
|
||||||
updateProvider(providerId, providerObj);
|
|
||||||
}
|
|
||||||
closeModal();
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open onOpenChange={closeModal}>
|
|
||||||
<DialogContent>
|
|
||||||
<DialogHeader><DialogTitle>{isAdd ? '新增 Provider' : '编辑 Provider'}</DialogTitle></DialogHeader>
|
|
||||||
<div className="space-y-4 py-4">
|
|
||||||
{isAdd && <div className="space-y-2">
|
|
||||||
<Label htmlFor="id">Provider ID</Label>
|
|
||||||
<Input id="id" name="id" value={formData.id || ''} onChange={handleChange} placeholder="e.g., openai_official"/>
|
|
||||||
</div>}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="name">名称</Label>
|
|
||||||
<Input id="name" name="name" value={formData.name || ''} onChange={handleChange} placeholder="e.g., OpenAI 官方"/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="api_base_url">API Base URL</Label>
|
|
||||||
<Input id="api_base_url" name="api_base_url" value={formData.api_base_url || ''} onChange={handleChange} placeholder="https://api.openai.com/v1"/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="api_key">API Key</Label>
|
|
||||||
<Input id="api_key" name="api_key" type="password" value={formData.api_key || ''} onChange={handleChange} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<DialogFooter>
|
|
||||||
<Button variant="outline" onClick={closeModal}>取消</Button>
|
|
||||||
<Button onClick={handleSubmit}>保存</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
type ModelFormData = {
|
|
||||||
model_id?: string;
|
|
||||||
name?: string | null;
|
|
||||||
is_active: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
function ModelEditModal() {
|
|
||||||
const { modal, closeModal, providers, addModel, updateModel } = useLlmConfigStore();
|
|
||||||
const [formData, setFormData] = useState<ModelFormData>({ is_active: true });
|
|
||||||
|
|
||||||
const isAdd = modal.type === 'addModel';
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (modal.type === 'editModel') {
|
|
||||||
const model = providers[modal.providerId].models[modal.modelIndex];
|
|
||||||
setFormData(model);
|
|
||||||
} else {
|
|
||||||
setFormData({ is_active: true });
|
|
||||||
}
|
|
||||||
}, [modal, providers]);
|
|
||||||
|
|
||||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
const { name, value } = e.target;
|
|
||||||
setFormData((prev) => ({ ...prev, [name]: value }));
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSubmit = () => {
|
|
||||||
if (isAdd && modal.type === 'addModel') {
|
|
||||||
if (!formData.model_id) return;
|
|
||||||
const modelObj: LlmModel = {
|
|
||||||
model_id: formData.model_id,
|
|
||||||
name: formData.name ?? null,
|
|
||||||
is_active: !!formData.is_active,
|
|
||||||
};
|
|
||||||
addModel(modal.providerId, modelObj);
|
|
||||||
} else if (modal.type === 'editModel') {
|
|
||||||
if (!formData.model_id) return;
|
|
||||||
const modelObj: LlmModel = {
|
|
||||||
model_id: formData.model_id,
|
|
||||||
name: formData.name ?? null,
|
|
||||||
is_active: !!formData.is_active,
|
|
||||||
};
|
|
||||||
updateModel(modal.providerId, modal.modelIndex, modelObj);
|
|
||||||
}
|
|
||||||
closeModal();
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open onOpenChange={closeModal}>
|
|
||||||
<DialogContent>
|
|
||||||
<DialogHeader><DialogTitle>{isAdd ? '新增 Model' : '编辑 Model'}</DialogTitle></DialogHeader>
|
|
||||||
<div className="space-y-4 py-4">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="model_id">Model ID</Label>
|
|
||||||
<Input id="model_id" name="model_id" value={formData.model_id || ''} onChange={handleChange} placeholder="e.g., gpt-4o"/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="name">别名 (可选)</Label>
|
|
||||||
<Input id="name" name="name" value={formData.name || ''} onChange={handleChange} placeholder="e.g., GPT-4o"/>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<Switch id="is_active" name="is_active" checked={!!formData.is_active} onCheckedChange={(checked) => setFormData((prev) => ({ ...prev, is_active: checked }))}/>
|
|
||||||
<Label htmlFor="is_active">在UI中激活</Label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<DialogFooter>
|
|
||||||
<Button variant="outline" onClick={closeModal}>取消</Button>
|
|
||||||
<Button onClick={handleSubmit}>保存</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
type DiscoveredModel = { id: string; name?: string };
|
|
||||||
|
|
||||||
function DiscoverModelsModal() {
|
|
||||||
const { modal, closeModal, providers, addModel } = useLlmConfigStore();
|
|
||||||
const [discoveredModels, setDiscoveredModels] = useState<DiscoveredModel[]>([]);
|
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [selectedModels, setSelectedModels] = useState<Record<string, boolean>>({});
|
|
||||||
|
|
||||||
const providerId = modal.type === 'discoverModels' ? modal.providerId : '';
|
|
||||||
const existingModelIds = useMemo(() => {
|
|
||||||
if (!providerId) return new Set();
|
|
||||||
return new Set(providers[providerId].models.map(m => m.model_id));
|
|
||||||
}, [providerId, providers]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!providerId) return;
|
|
||||||
|
|
||||||
const fetchModels = async () => {
|
|
||||||
setIsLoading(true);
|
|
||||||
setError(null);
|
|
||||||
try {
|
|
||||||
const result = await discoverProviderModels(providerId);
|
|
||||||
if (result.error) {
|
|
||||||
throw new Error(result.provider_error || result.error);
|
|
||||||
}
|
|
||||||
// OpenAI format is { data: [...] }, others might be [...]
|
|
||||||
const models = (result.data || result) as DiscoveredModel[] | unknown;
|
|
||||||
if (Array.isArray(models)) {
|
|
||||||
setDiscoveredModels(models as DiscoveredModel[]);
|
|
||||||
} else {
|
|
||||||
throw new Error("Invalid response format from provider");
|
|
||||||
}
|
|
||||||
} catch (e: any) {
|
|
||||||
setError(e.message);
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
fetchModels();
|
|
||||||
}, [providerId]);
|
|
||||||
|
|
||||||
const handleToggleModel = (modelId: string, checked: boolean) => {
|
|
||||||
setSelectedModels(prev => ({ ...prev, [modelId]: checked }));
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleImport = () => {
|
|
||||||
if (!providerId) return;
|
|
||||||
Object.entries(selectedModels).forEach(([modelId, isSelected]) => {
|
|
||||||
if (isSelected && !existingModelIds.has(modelId)) {
|
|
||||||
addModel(providerId, { model_id: modelId, name: null, is_active: true });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
closeModal();
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open onOpenChange={closeModal}>
|
|
||||||
<DialogContent className="max-w-2xl">
|
|
||||||
<DialogHeader><DialogTitle>从 {providerId} 发现并导入模型</DialogTitle></DialogHeader>
|
|
||||||
<div className="py-4">
|
|
||||||
{isLoading && <p>正在从供应商API加载模型列表...</p>}
|
|
||||||
{error && <p className="text-red-500">加载失败: {error}</p>}
|
|
||||||
{!isLoading && !error && (
|
|
||||||
<ScrollArea className="h-72">
|
|
||||||
<div className="space-y-2 pr-4">
|
|
||||||
{discoveredModels.map(model => (
|
|
||||||
<div key={model.id} className="flex items-center space-x-2">
|
|
||||||
<Checkbox
|
|
||||||
id={model.id}
|
|
||||||
onCheckedChange={(checked) => handleToggleModel(model.id, !!checked)}
|
|
||||||
disabled={existingModelIds.has(model.id)}
|
|
||||||
/>
|
|
||||||
<label
|
|
||||||
htmlFor={model.id}
|
|
||||||
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
|
||||||
>
|
|
||||||
{model.id} {existingModelIds.has(model.id) && "(已存在)"}
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</ScrollArea>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<DialogFooter>
|
|
||||||
<Button variant="outline" onClick={closeModal}>取消</Button>
|
|
||||||
<Button onClick={handleImport}>导入选中模型</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,7 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useParams, useSearchParams } from 'next/navigation';
|
import { useParams, useSearchParams } from 'next/navigation';
|
||||||
import { useChinaFinancials, useFinancials, useFinancialConfig, useAnalysisConfig, useSnapshot, useRealtimeQuote, useDataRequest, useTaskProgress } from '@/hooks/useApi';
|
import { useChinaFinancials, useFinancials, useFinancialConfig, useAnalysisConfig, generateFullAnalysis, useSnapshot, useRealtimeQuote } from '@/hooks/useApi';
|
||||||
import { Spinner } from '@/components/ui/spinner';
|
import { Spinner } from '@/components/ui/spinner';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { CheckCircle, XCircle, RotateCw } from 'lucide-react';
|
import { CheckCircle, XCircle, RotateCw } from 'lucide-react';
|
||||||
@ -13,7 +13,7 @@ import ReactMarkdown from 'react-markdown';
|
|||||||
import remarkGfm from 'remark-gfm';
|
import remarkGfm from 'remark-gfm';
|
||||||
import { TradingViewWidget } from '@/components/TradingViewWidget';
|
import { TradingViewWidget } from '@/components/TradingViewWidget';
|
||||||
import type { CompanyProfileResponse, AnalysisResponse } from '@/types';
|
import type { CompanyProfileResponse, AnalysisResponse } from '@/types';
|
||||||
import { useMemo, useCallback } from 'react';
|
import { useMemo } from 'react';
|
||||||
import { formatReportPeriod } from '@/lib/financial-utils';
|
import { formatReportPeriod } from '@/lib/financial-utils';
|
||||||
|
|
||||||
export default function ReportPage() {
|
export default function ReportPage() {
|
||||||
@ -107,14 +107,52 @@ export default function ReportPage() {
|
|||||||
};
|
};
|
||||||
error?: string;
|
error?: string;
|
||||||
}>>([]);
|
}>>([]);
|
||||||
// 新架构:触发分析与查看任务进度
|
|
||||||
const { trigger: triggerAnalysis, isMutating: triggering } = useDataRequest();
|
|
||||||
const [requestId, setRequestId] = useState<string | null>(null);
|
|
||||||
const { progress: taskProgress } = useTaskProgress(requestId);
|
|
||||||
|
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [saveMsg, setSaveMsg] = useState<string | null>(null)
|
||||||
|
|
||||||
const runFullAnalysis = useCallback(async () => {
|
const saveReport = async () => {
|
||||||
if (!analysisConfig?.analysis_modules || isAnalysisRunningRef.current) {
|
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 () => {
|
||||||
|
if (!financials || !analysisConfig?.analysis_modules || isAnalysisRunningRef.current) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -143,14 +181,14 @@ export default function ReportPage() {
|
|||||||
|
|
||||||
// 触发顺序执行
|
// 触发顺序执行
|
||||||
setManualRunKey((k) => k + 1);
|
setManualRunKey((k) => k + 1);
|
||||||
}, [analysisConfig?.analysis_modules]);
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (analysisConfig?.analysis_modules && !fullAnalysisTriggeredRef.current) {
|
if (financials && !fullAnalysisTriggeredRef.current) {
|
||||||
fullAnalysisTriggeredRef.current = true;
|
fullAnalysisTriggeredRef.current = true;
|
||||||
runFullAnalysis();
|
runFullAnalysis();
|
||||||
}
|
}
|
||||||
}, [analysisConfig?.analysis_modules, runFullAnalysis]);
|
}, [financials]);
|
||||||
|
|
||||||
// 计算完成比例
|
// 计算完成比例
|
||||||
const completionProgress = useMemo(() => {
|
const completionProgress = useMemo(() => {
|
||||||
@ -192,9 +230,8 @@ export default function ReportPage() {
|
|||||||
if (!financialConfig?.api_groups) return {};
|
if (!financialConfig?.api_groups) return {};
|
||||||
|
|
||||||
const map: Record<string, string> = {};
|
const map: Record<string, string> = {};
|
||||||
const groups = Object.values((financialConfig as Partial<import('@/types').FinancialConfigResponse>).api_groups || {}) as import('@/types').FinancialMetricConfig[][];
|
Object.values(financialConfig.api_groups).forEach(metrics => {
|
||||||
groups.forEach((metrics) => {
|
metrics.forEach(metric => {
|
||||||
(metrics || []).forEach((metric) => {
|
|
||||||
if (metric.tushareParam && metric.displayText) {
|
if (metric.tushareParam && metric.displayText) {
|
||||||
map[metric.tushareParam] = metric.displayText;
|
map[metric.tushareParam] = metric.displayText;
|
||||||
}
|
}
|
||||||
@ -206,9 +243,8 @@ export default function ReportPage() {
|
|||||||
const metricGroupMap = useMemo(() => {
|
const metricGroupMap = useMemo(() => {
|
||||||
if (!financialConfig?.api_groups) return {} as Record<string, string>;
|
if (!financialConfig?.api_groups) return {} as Record<string, string>;
|
||||||
const map: Record<string, string> = {};
|
const map: Record<string, string> = {};
|
||||||
const entries = Object.entries((financialConfig as Partial<import('@/types').FinancialConfigResponse>).api_groups || {}) as [string, import('@/types').FinancialMetricConfig[]][];
|
Object.entries(financialConfig.api_groups).forEach(([groupName, metrics]) => {
|
||||||
entries.forEach(([groupName, metrics]) => {
|
metrics.forEach((metric) => {
|
||||||
(metrics || []).forEach((metric) => {
|
|
||||||
if (metric.tushareParam) {
|
if (metric.tushareParam) {
|
||||||
map[metric.tushareParam] = groupName;
|
map[metric.tushareParam] = groupName;
|
||||||
}
|
}
|
||||||
@ -549,7 +585,7 @@ export default function ReportPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
runAnalysesSequentially();
|
runAnalysesSequentially();
|
||||||
}, [isLoading, error, financials, analysisConfig, analysisTypes, normalizedTsCode, normalizedMarket, unifiedSymbol, startTime, manualRunKey]);
|
}, [isLoading, error, financials, analysisConfig, analysisTypes, normalizedTsCode, manualRunKey]);
|
||||||
|
|
||||||
const stopAll = () => {
|
const stopAll = () => {
|
||||||
stopRequestedRef.current = true;
|
stopRequestedRef.current = true;
|
||||||
@ -692,14 +728,8 @@ export default function ReportPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
<Card className="w-40 flex-shrink-0">
|
<Card className="w-40 flex-shrink-0">
|
||||||
<CardContent className="flex flex-col gap-2">
|
<CardContent className="flex flex-col gap-2">
|
||||||
<Button
|
<Button onClick={runFullAnalysis} disabled={isAnalysisRunningRef.current}>
|
||||||
onClick={async () => {
|
{isAnalysisRunningRef.current ? '正在分析…' : '开始分析'}
|
||||||
const reqId = await triggerAnalysis(unifiedSymbol, normalizedMarket || '');
|
|
||||||
if (reqId) setRequestId(reqId);
|
|
||||||
}}
|
|
||||||
disabled={triggering}
|
|
||||||
>
|
|
||||||
{triggering ? '触发中…' : '触发分析(新架构)'}
|
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="destructive" onClick={stopAll} disabled={!hasRunningTask}>
|
<Button variant="destructive" onClick={stopAll} disabled={!hasRunningTask}>
|
||||||
停止
|
停止
|
||||||
@ -709,18 +739,6 @@ export default function ReportPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
<Card className="w-80">
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="text-lg">任务进度(新架构)</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="text-xs text-muted-foreground">
|
|
||||||
{requestId ? (
|
|
||||||
<pre>{JSON.stringify(taskProgress || {}, null, 2)}</pre>
|
|
||||||
) : (
|
|
||||||
<div>未触发任务</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
<Card className="w-80">
|
<Card className="w-80">
|
||||||
<CardHeader className="flex flex-col space-y-2 pb-2">
|
<CardHeader className="flex flex-col space-y-2 pb-2">
|
||||||
<div className="flex flex-row items-center justify-between w-full">
|
<div className="flex flex-row items-center justify-between w-full">
|
||||||
@ -737,6 +755,14 @@ 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 && (
|
||||||
@ -784,17 +810,13 @@ export default function ReportPage() {
|
|||||||
<span className="inline-flex items-center gap-2"><Spinner className="size-3" /> 正在获取实时报价…</span>
|
<span className="inline-flex items-center gap-2"><Spinner className="size-3" /> 正在获取实时报价…</span>
|
||||||
) : realtimeError ? (
|
) : realtimeError ? (
|
||||||
<span className="text-red-500">实时报价不可用</span>
|
<span className="text-red-500">实时报价不可用</span>
|
||||||
) : (() => {
|
) : realtime ? (
|
||||||
const priceRaw = (realtime as any)?.price;
|
<span>
|
||||||
const priceNum = typeof priceRaw === 'number' ? priceRaw : Number(priceRaw);
|
价格 {realtime.price.toLocaleString()}({new Date(realtime.ts).toLocaleString()})
|
||||||
const tsRaw = (realtime as any)?.ts;
|
</span>
|
||||||
const tsDate = tsRaw == null ? null : new Date(typeof tsRaw === 'number' ? tsRaw : String(tsRaw));
|
) : (
|
||||||
const tsText = tsDate && !isNaN(tsDate.getTime()) ? `(${tsDate.toLocaleString()})` : '';
|
<span>暂无最新报价</span>
|
||||||
if (Number.isFinite(priceNum)) {
|
)}
|
||||||
return <span>价格 {priceNum.toLocaleString()} {tsText}</span>;
|
|
||||||
}
|
|
||||||
return <span>暂无最新报价</span>;
|
|
||||||
})()}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -1507,7 +1529,7 @@ export default function ReportPage() {
|
|||||||
<div className="ml-6 text-sm text-muted-foreground space-y-1">
|
<div className="ml-6 text-sm text-muted-foreground space-y-1">
|
||||||
<div>总耗时: {formatMs(totalElapsedMs)}</div>
|
<div>总耗时: {formatMs(totalElapsedMs)}</div>
|
||||||
{financials?.meta?.steps && (
|
{financials?.meta?.steps && (
|
||||||
<div>财务数据完成步骤: {(financials.meta.steps as any[]).filter((s: any) => s?.status === 'done').length}/{(financials.meta.steps as any[]).length}</div>
|
<div>财务数据完成步骤: {financials.meta.steps.filter(s => s.status === 'done').length}/{financials.meta.steps.length}</div>
|
||||||
)}
|
)}
|
||||||
{analysisRecords.length > 0 && (
|
{analysisRecords.length > 0 && (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { headers } from 'next/headers'
|
import { prisma } from '../../../lib/prisma'
|
||||||
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,30 +15,7 @@ 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 h = await headers()
|
const data = await prisma.report.findUnique({ where: { id } })
|
||||||
const host = h.get('x-forwarded-host') || h.get('host') || 'localhost:3000'
|
|
||||||
const proto = h.get('x-forwarded-proto') || 'http'
|
|
||||||
const base = process.env.FRONTEND_INTERNAL_URL || 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>
|
||||||
|
|||||||
@ -14,7 +14,7 @@ export default async function ReportsPage() {
|
|||||||
const h = await headers()
|
const h = await headers()
|
||||||
const host = h.get('x-forwarded-host') || h.get('host') || 'localhost:3000'
|
const host = h.get('x-forwarded-host') || h.get('host') || 'localhost:3000'
|
||||||
const proto = h.get('x-forwarded-proto') || 'http'
|
const proto = h.get('x-forwarded-proto') || 'http'
|
||||||
const base = process.env.FRONTEND_INTERNAL_URL || process.env.NEXT_PUBLIC_BASE_URL || `${proto}://${host}`
|
const base = process.env.NEXT_PUBLIC_BASE_URL || `${proto}://${host}`
|
||||||
const { items, total } = await fetchReports(base)
|
const { items, total } = await fetchReports(base)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@ -1,33 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
|
|
||||||
type BaseProps = {
|
|
||||||
children?: React.ReactNode;
|
|
||||||
className?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type DialogProps = BaseProps & {
|
|
||||||
open?: boolean;
|
|
||||||
onOpenChange?: (open: boolean) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const Dialog: React.FC<DialogProps> = ({ children }) => {
|
|
||||||
return <div>{children}</div>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const DialogContent: React.FC<BaseProps> = ({ children, className }) => {
|
|
||||||
return <div className={className}>{children}</div>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const DialogHeader: React.FC<BaseProps> = ({ children }) => {
|
|
||||||
return <div>{children}</div>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const DialogTitle: React.FC<BaseProps> = ({ children }) => {
|
|
||||||
return <h3>{children}</h3>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const DialogFooter: React.FC<BaseProps> = ({ children }) => {
|
|
||||||
return <div>{children}</div>;
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
@ -1,17 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
|
|
||||||
type ScrollAreaProps = {
|
|
||||||
children?: React.ReactNode;
|
|
||||||
className?: string;
|
|
||||||
style?: React.CSSProperties;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const ScrollArea: React.FC<ScrollAreaProps> = ({ children, className, style }) => {
|
|
||||||
return (
|
|
||||||
<div className={className} style={{ overflow: 'auto', ...style }}>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
@ -1,24 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
|
|
||||||
type SwitchProps = {
|
|
||||||
id?: string;
|
|
||||||
name?: string;
|
|
||||||
checked?: boolean;
|
|
||||||
onCheckedChange?: (checked: boolean) => void;
|
|
||||||
className?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const Switch: React.FC<SwitchProps> = ({ id, name, checked, onCheckedChange, className }) => {
|
|
||||||
return (
|
|
||||||
<input
|
|
||||||
id={id}
|
|
||||||
name={name}
|
|
||||||
type="checkbox"
|
|
||||||
className={className}
|
|
||||||
checked={!!checked}
|
|
||||||
onChange={(e) => onCheckedChange?.(e.target.checked)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
@ -405,7 +405,7 @@ export function useRowConfig(companyCode: string | null, rowIds: string[]) {
|
|||||||
console.warn('Failed to import config:', error);
|
console.warn('Failed to import config:', error);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}, [companyCode, stableRowIds, saveConfigSafely]);
|
}, [companyCode, stableRowIds]);
|
||||||
|
|
||||||
// 获取配置统计信息
|
// 获取配置统计信息
|
||||||
const getConfigStats = useCallback(() => {
|
const getConfigStats = useCallback(() => {
|
||||||
|
|||||||
@ -1,17 +1,7 @@
|
|||||||
import useSWR, { SWRConfiguration } from "swr";
|
import useSWR, { SWRConfiguration } from "swr";
|
||||||
import {
|
import { Financials, FinancialsIdentifier } from "@/types";
|
||||||
BatchFinancialDataResponse,
|
|
||||||
TodaySnapshotResponse,
|
|
||||||
RealTimeQuoteResponse,
|
|
||||||
AnalysisConfigResponse,
|
|
||||||
LlmProvidersConfig,
|
|
||||||
AnalysisModulesConfig,
|
|
||||||
FinancialConfigResponse,
|
|
||||||
} from "@/types";
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
// Execution-step types not used currently; keep API minimal and explicit
|
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());
|
||||||
|
|
||||||
@ -138,11 +128,11 @@ export function useFinancials(market?: string, stockCode?: string, years: number
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function useAnalysisConfig() {
|
export function useAnalysisConfig() {
|
||||||
return useSWR<AnalysisConfigResponse>('/api/configs/analysis_modules', fetcher);
|
return useSWR<AnalysisConfigResponse>('/api/financials/analysis-config', fetcher);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateAnalysisConfig(config: AnalysisConfigResponse) {
|
export async function updateAnalysisConfig(config: AnalysisConfigResponse) {
|
||||||
const res = await fetch('/api/configs/analysis_modules', {
|
const res = await fetch('/api/financials/analysis-config', {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(config),
|
body: JSON.stringify(config),
|
||||||
@ -242,114 +232,3 @@ 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() {
|
|
||||||
// 透传后端的财务配置(如指标分组、显示名映射等)
|
|
||||||
// 新后端暂未提供该配置,使用 Partial 以避免严格要求字段存在
|
|
||||||
return useSWR<Partial<FinancialConfigResponse>>('/api/config', fetcher);
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- LLM Provider Config Hooks ---
|
|
||||||
|
|
||||||
export function useLlmProviders() {
|
|
||||||
return useSWR<LlmProvidersConfig>('/api/configs/llm_providers', fetcher);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function updateLlmProviders(payload: LlmProvidersConfig) {
|
|
||||||
const res = await fetch('/api/configs/llm_providers', {
|
|
||||||
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}`);
|
|
||||||
}
|
|
||||||
return res.json() as Promise<LlmProvidersConfig>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function discoverProviderModels(providerId: string) {
|
|
||||||
const res = await fetch(`/api/discover-models/${providerId}`);
|
|
||||||
// No error handling for status, as the gateway will return a structured error
|
|
||||||
return res.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Analysis Modules Config Hooks ---
|
|
||||||
|
|
||||||
export function useAnalysisModules() {
|
|
||||||
return useSWR<AnalysisModulesConfig>('/api/configs/analysis_modules', fetcher);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function updateAnalysisModules(payload: AnalysisModulesConfig) {
|
|
||||||
const res = await fetch('/api/configs/analysis_modules', {
|
|
||||||
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}`);
|
|
||||||
}
|
|
||||||
return res.json() as Promise<AnalysisModulesConfig>;
|
|
||||||
}
|
|
||||||
|
|||||||
44
frontend/src/lib/prisma.ts
Normal file
44
frontend/src/lib/prisma.ts
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@ -1,71 +0,0 @@
|
|||||||
import { create } from 'zustand';
|
|
||||||
import { immer } from 'zustand/middleware/immer';
|
|
||||||
import type { LlmProvidersConfig, LlmProvider, LlmModel } from '@/types';
|
|
||||||
|
|
||||||
export type ModalState =
|
|
||||||
| { type: 'closed' }
|
|
||||||
| { type: 'editProvider'; providerId: string }
|
|
||||||
| { type: 'addProvider' }
|
|
||||||
| { type: 'editModel'; providerId: string; modelIndex: number }
|
|
||||||
| { type: 'addModel'; providerId: string }
|
|
||||||
| { type: 'discoverModels'; providerId: string };
|
|
||||||
|
|
||||||
type LlmConfigState = {
|
|
||||||
providers: LlmProvidersConfig;
|
|
||||||
modal: ModalState;
|
|
||||||
setInitialProviders: (providers: LlmProvidersConfig) => void;
|
|
||||||
openModal: (modalState: ModalState) => void;
|
|
||||||
closeModal: () => void;
|
|
||||||
|
|
||||||
// Provider actions
|
|
||||||
updateProvider: (providerId: string, provider: LlmProvider) => void;
|
|
||||||
addProvider: (providerId: string, provider: LlmProvider) => void;
|
|
||||||
deleteProvider: (providerId: string) => void;
|
|
||||||
|
|
||||||
// Model actions
|
|
||||||
updateModel: (providerId: string, modelIndex: number, model: LlmModel) => void;
|
|
||||||
addModel: (providerId: string, model: LlmModel) => void;
|
|
||||||
deleteModel: (providerId: string, modelIndex: number) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const useLlmConfigStore = create<LlmConfigState>()(
|
|
||||||
immer((set) => ({
|
|
||||||
providers: {},
|
|
||||||
modal: { type: 'closed' },
|
|
||||||
|
|
||||||
setInitialProviders: (providers) => set({ providers }),
|
|
||||||
openModal: (modalState) => set({ modal: modalState }),
|
|
||||||
closeModal: () => set({ modal: { type: 'closed' } }),
|
|
||||||
|
|
||||||
updateProvider: (providerId, provider) => {
|
|
||||||
set((state) => {
|
|
||||||
state.providers[providerId] = provider;
|
|
||||||
});
|
|
||||||
},
|
|
||||||
addProvider: (providerId, provider) => {
|
|
||||||
set((state) => {
|
|
||||||
state.providers[providerId] = provider;
|
|
||||||
});
|
|
||||||
},
|
|
||||||
deleteProvider: (providerId) => {
|
|
||||||
set((state) => {
|
|
||||||
delete state.providers[providerId];
|
|
||||||
});
|
|
||||||
},
|
|
||||||
updateModel: (providerId, modelIndex, model) => {
|
|
||||||
set((state) => {
|
|
||||||
state.providers[providerId].models[modelIndex] = model;
|
|
||||||
});
|
|
||||||
},
|
|
||||||
addModel: (providerId, model) => {
|
|
||||||
set((state) => {
|
|
||||||
state.providers[providerId].models.push(model);
|
|
||||||
});
|
|
||||||
},
|
|
||||||
deleteModel: (providerId, modelIndex) => {
|
|
||||||
set((state) => {
|
|
||||||
state.providers[providerId].models.splice(modelIndex, 1);
|
|
||||||
});
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
);
|
|
||||||
@ -455,46 +455,3 @@ export const DEFAULT_CONFIG = {
|
|||||||
/** 错误状态显示时长 (毫秒) */
|
/** 错误状态显示时长 (毫秒) */
|
||||||
ERROR_DISPLAY_DURATION: 10000,
|
ERROR_DISPLAY_DURATION: 10000,
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// LLM 配置相关类型(与后端 common-contracts 配置保持结构一致)
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
export interface LlmModel {
|
|
||||||
/** 模型ID(如 gpt-4o) */
|
|
||||||
model_id: string;
|
|
||||||
/** 可选别名(用于 UI 展示) */
|
|
||||||
name?: string | null;
|
|
||||||
/** 是否在 UI 中可用 */
|
|
||||||
is_active: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface LlmProvider {
|
|
||||||
/** 供应商中文名/展示名(如 OpenAI 官方) */
|
|
||||||
name: string;
|
|
||||||
/** API 基础地址 */
|
|
||||||
api_base_url: string;
|
|
||||||
/** API 密钥(明文存储,由后端负责保护) */
|
|
||||||
api_key: string;
|
|
||||||
/** 启用的模型列表 */
|
|
||||||
models: LlmModel[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/** LLM Provider 注册中心:键为 provider_id(如 openai_official) */
|
|
||||||
export type LlmProvidersConfig = Record<string, LlmProvider>;
|
|
||||||
|
|
||||||
export interface AnalysisModuleConfig {
|
|
||||||
/** 模块中文名/展示名(如 看涨分析) */
|
|
||||||
name: string;
|
|
||||||
/** 关联的 Provider ID(引用 LlmProvidersConfig 的键) */
|
|
||||||
provider_id: string;
|
|
||||||
/** 使用的模型 ID(引用 LlmModel.model_id) */
|
|
||||||
model_id: string;
|
|
||||||
/** 提示词模板(可使用 Tera 变量) */
|
|
||||||
prompt_template: string;
|
|
||||||
/** 依赖的其他模块 ID 列表 */
|
|
||||||
dependencies: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 分析模块配置集合:键为 module_id(如 bull_case) */
|
|
||||||
export type AnalysisModulesConfig = Record<string, AnalysisModuleConfig>;
|
|
||||||
52
package-lock.json
generated
52
package-lock.json
generated
@ -1,52 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "Fundamental_Analysis",
|
|
||||||
"lockfileVersion": 3,
|
|
||||||
"requires": true,
|
|
||||||
"packages": {
|
|
||||||
"": {
|
|
||||||
"dependencies": {
|
|
||||||
"immer": "^10.2.0",
|
|
||||||
"zustand": "^5.0.8"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/immer": {
|
|
||||||
"version": "10.2.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz",
|
|
||||||
"integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"funding": {
|
|
||||||
"type": "opencollective",
|
|
||||||
"url": "https://opencollective.com/immer"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/zustand": {
|
|
||||||
"version": "5.0.8",
|
|
||||||
"resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.8.tgz",
|
|
||||||
"integrity": "sha512-gyPKpIaxY9XcO2vSMrLbiER7QMAMGOQZVRdJ6Zi782jkbzZygq5GI9nG8g+sMgitRtndwaBSl7uiqC49o1SSiw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12.20.0"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"@types/react": ">=18.0.0",
|
|
||||||
"immer": ">=9.0.6",
|
|
||||||
"react": ">=18.0.0",
|
|
||||||
"use-sync-external-store": ">=1.2.0"
|
|
||||||
},
|
|
||||||
"peerDependenciesMeta": {
|
|
||||||
"@types/react": {
|
|
||||||
"optional": true
|
|
||||||
},
|
|
||||||
"immer": {
|
|
||||||
"optional": true
|
|
||||||
},
|
|
||||||
"react": {
|
|
||||||
"optional": true
|
|
||||||
},
|
|
||||||
"use-sync-external-store": {
|
|
||||||
"optional": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,6 +0,0 @@
|
|||||||
{
|
|
||||||
"dependencies": {
|
|
||||||
"immer": "^10.2.0",
|
|
||||||
"zustand": "^5.0.8"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -2,9 +2,20 @@
|
|||||||
FROM rust:1.90 as builder
|
FROM rust:1.90 as builder
|
||||||
|
|
||||||
WORKDIR /usr/src/app
|
WORKDIR /usr/src/app
|
||||||
# Copy full sources (simple and correct; avoids shipping stub binaries)
|
|
||||||
|
# Pre-build dependencies to leverage Docker layer caching
|
||||||
COPY ./services/common-contracts /usr/src/app/services/common-contracts
|
COPY ./services/common-contracts /usr/src/app/services/common-contracts
|
||||||
|
COPY ./services/alphavantage-provider-service/Cargo.toml ./services/alphavantage-provider-service/Cargo.lock* ./services/alphavantage-provider-service/
|
||||||
|
|
||||||
|
WORKDIR /usr/src/app/services/alphavantage-provider-service
|
||||||
|
RUN mkdir -p src && \
|
||||||
|
echo "fn main() {}" > src/main.rs && \
|
||||||
|
cargo build --release --bin alphavantage-provider-service
|
||||||
|
|
||||||
|
# Copy the full source code
|
||||||
COPY ./services/alphavantage-provider-service /usr/src/app/services/alphavantage-provider-service
|
COPY ./services/alphavantage-provider-service /usr/src/app/services/alphavantage-provider-service
|
||||||
|
|
||||||
|
# Build the application
|
||||||
WORKDIR /usr/src/app/services/alphavantage-provider-service
|
WORKDIR /usr/src/app/services/alphavantage-provider-service
|
||||||
RUN cargo build --release --bin alphavantage-provider-service
|
RUN cargo build --release --bin alphavantage-provider-service
|
||||||
|
|
||||||
@ -14,8 +25,6 @@ FROM debian:bookworm-slim
|
|||||||
# Set timezone
|
# Set timezone
|
||||||
ENV TZ=Asia/Shanghai
|
ENV TZ=Asia/Shanghai
|
||||||
RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone
|
RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone
|
||||||
# Minimal runtime deps for health checks (curl) and TLS roots if needed
|
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates curl && rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
# Copy the built binary from the builder stage
|
# Copy the built binary from the builder stage
|
||||||
COPY --from=builder /usr/src/app/services/alphavantage-provider-service/target/release/alphavantage-provider-service /usr/local/bin/
|
COPY --from=builder /usr/src/app/services/alphavantage-provider-service/target/release/alphavantage-provider-service /usr/local/bin/
|
||||||
|
|||||||
348
services/api-gateway/Cargo.lock
generated
348
services/api-gateway/Cargo.lock
generated
@ -91,7 +91,7 @@ dependencies = [
|
|||||||
"once_cell",
|
"once_cell",
|
||||||
"pin-project",
|
"pin-project",
|
||||||
"portable-atomic",
|
"portable-atomic",
|
||||||
"rand 0.8.5",
|
"rand",
|
||||||
"regex",
|
"regex",
|
||||||
"ring",
|
"ring",
|
||||||
"rustls-native-certs",
|
"rustls-native-certs",
|
||||||
@ -644,6 +644,16 @@ dependencies = [
|
|||||||
"typeid",
|
"typeid",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "errno"
|
||||||
|
version = "0.3.14"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"windows-sys 0.61.2",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "etcetera"
|
name = "etcetera"
|
||||||
version = "0.8.0"
|
version = "0.8.0"
|
||||||
@ -666,6 +676,12 @@ dependencies = [
|
|||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "fastrand"
|
||||||
|
version = "2.3.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "fiat-crypto"
|
name = "fiat-crypto"
|
||||||
version = "0.2.9"
|
version = "0.2.9"
|
||||||
@ -701,6 +717,21 @@ version = "0.1.5"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
|
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "foreign-types"
|
||||||
|
version = "0.3.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1"
|
||||||
|
dependencies = [
|
||||||
|
"foreign-types-shared",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "foreign-types-shared"
|
||||||
|
version = "0.1.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "form_urlencoded"
|
name = "form_urlencoded"
|
||||||
version = "1.2.2"
|
version = "1.2.2"
|
||||||
@ -817,10 +848,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592"
|
checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"js-sys",
|
|
||||||
"libc",
|
"libc",
|
||||||
"wasi",
|
"wasi",
|
||||||
"wasm-bindgen",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@ -830,11 +859,28 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
|
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"js-sys",
|
|
||||||
"libc",
|
"libc",
|
||||||
"r-efi",
|
"r-efi",
|
||||||
"wasip2",
|
"wasip2",
|
||||||
"wasm-bindgen",
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "h2"
|
||||||
|
version = "0.4.12"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f3c0b69cfcb4e1b9f1bf2f53f95f766e4661169728ec61cd3fe5a0166f2d1386"
|
||||||
|
dependencies = [
|
||||||
|
"atomic-waker",
|
||||||
|
"bytes",
|
||||||
|
"fnv",
|
||||||
|
"futures-core",
|
||||||
|
"futures-sink",
|
||||||
|
"http",
|
||||||
|
"indexmap",
|
||||||
|
"slab",
|
||||||
|
"tokio",
|
||||||
|
"tokio-util",
|
||||||
|
"tracing",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@ -973,6 +1019,7 @@ dependencies = [
|
|||||||
"bytes",
|
"bytes",
|
||||||
"futures-channel",
|
"futures-channel",
|
||||||
"futures-core",
|
"futures-core",
|
||||||
|
"h2",
|
||||||
"http",
|
"http",
|
||||||
"http-body",
|
"http-body",
|
||||||
"httparse",
|
"httparse",
|
||||||
@ -999,7 +1046,22 @@ dependencies = [
|
|||||||
"tokio",
|
"tokio",
|
||||||
"tokio-rustls",
|
"tokio-rustls",
|
||||||
"tower-service",
|
"tower-service",
|
||||||
"webpki-roots 1.0.4",
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hyper-tls"
|
||||||
|
version = "0.6.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0"
|
||||||
|
dependencies = [
|
||||||
|
"bytes",
|
||||||
|
"http-body-util",
|
||||||
|
"hyper",
|
||||||
|
"hyper-util",
|
||||||
|
"native-tls",
|
||||||
|
"tokio",
|
||||||
|
"tokio-native-tls",
|
||||||
|
"tower-service",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@ -1021,9 +1083,11 @@ dependencies = [
|
|||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"socket2",
|
"socket2",
|
||||||
|
"system-configuration",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tower-service",
|
"tower-service",
|
||||||
"tracing",
|
"tracing",
|
||||||
|
"windows-registry",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@ -1258,6 +1322,12 @@ dependencies = [
|
|||||||
"vcpkg",
|
"vcpkg",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "linux-raw-sys"
|
||||||
|
version = "0.11.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "litemap"
|
name = "litemap"
|
||||||
version = "0.8.1"
|
version = "0.8.1"
|
||||||
@ -1279,12 +1349,6 @@ version = "0.4.28"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432"
|
checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "lru-slab"
|
|
||||||
version = "0.1.2"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "matchers"
|
name = "matchers"
|
||||||
version = "0.2.0"
|
version = "0.2.0"
|
||||||
@ -1333,6 +1397,23 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "native-tls"
|
||||||
|
version = "0.2.14"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "87de3442987e9dbec73158d5c715e7ad9072fda936bb03d19d7fa10e00520f0e"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"log",
|
||||||
|
"openssl",
|
||||||
|
"openssl-probe",
|
||||||
|
"openssl-sys",
|
||||||
|
"schannel",
|
||||||
|
"security-framework",
|
||||||
|
"security-framework-sys",
|
||||||
|
"tempfile",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "nkeys"
|
name = "nkeys"
|
||||||
version = "0.4.5"
|
version = "0.4.5"
|
||||||
@ -1344,7 +1425,7 @@ dependencies = [
|
|||||||
"ed25519-dalek",
|
"ed25519-dalek",
|
||||||
"getrandom 0.2.16",
|
"getrandom 0.2.16",
|
||||||
"log",
|
"log",
|
||||||
"rand 0.8.5",
|
"rand",
|
||||||
"signatory",
|
"signatory",
|
||||||
]
|
]
|
||||||
|
|
||||||
@ -1363,7 +1444,7 @@ version = "0.5.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "fc895af95856f929163a0aa20c26a78d26bfdc839f51b9d5aa7a5b79e52b7e83"
|
checksum = "fc895af95856f929163a0aa20c26a78d26bfdc839f51b9d5aa7a5b79e52b7e83"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"rand 0.8.5",
|
"rand",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@ -1377,7 +1458,7 @@ dependencies = [
|
|||||||
"num-integer",
|
"num-integer",
|
||||||
"num-iter",
|
"num-iter",
|
||||||
"num-traits",
|
"num-traits",
|
||||||
"rand 0.8.5",
|
"rand",
|
||||||
"smallvec",
|
"smallvec",
|
||||||
"zeroize",
|
"zeroize",
|
||||||
]
|
]
|
||||||
@ -1424,12 +1505,50 @@ version = "1.21.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
|
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "openssl"
|
||||||
|
version = "0.10.75"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags",
|
||||||
|
"cfg-if",
|
||||||
|
"foreign-types",
|
||||||
|
"libc",
|
||||||
|
"once_cell",
|
||||||
|
"openssl-macros",
|
||||||
|
"openssl-sys",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "openssl-macros"
|
||||||
|
version = "0.1.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 2.0.110",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "openssl-probe"
|
name = "openssl-probe"
|
||||||
version = "0.1.6"
|
version = "0.1.6"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e"
|
checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "openssl-sys"
|
||||||
|
version = "0.9.111"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "82cab2d520aa75e3c58898289429321eb788c3106963d0dc886ec7a5f4adc321"
|
||||||
|
dependencies = [
|
||||||
|
"cc",
|
||||||
|
"libc",
|
||||||
|
"pkg-config",
|
||||||
|
"vcpkg",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ordered-multimap"
|
name = "ordered-multimap"
|
||||||
version = "0.7.3"
|
version = "0.7.3"
|
||||||
@ -1660,61 +1779,6 @@ dependencies = [
|
|||||||
"syn 1.0.109",
|
"syn 1.0.109",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "quinn"
|
|
||||||
version = "0.11.9"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20"
|
|
||||||
dependencies = [
|
|
||||||
"bytes",
|
|
||||||
"cfg_aliases",
|
|
||||||
"pin-project-lite",
|
|
||||||
"quinn-proto",
|
|
||||||
"quinn-udp",
|
|
||||||
"rustc-hash",
|
|
||||||
"rustls",
|
|
||||||
"socket2",
|
|
||||||
"thiserror 2.0.17",
|
|
||||||
"tokio",
|
|
||||||
"tracing",
|
|
||||||
"web-time",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "quinn-proto"
|
|
||||||
version = "0.11.13"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31"
|
|
||||||
dependencies = [
|
|
||||||
"bytes",
|
|
||||||
"getrandom 0.3.4",
|
|
||||||
"lru-slab",
|
|
||||||
"rand 0.9.2",
|
|
||||||
"ring",
|
|
||||||
"rustc-hash",
|
|
||||||
"rustls",
|
|
||||||
"rustls-pki-types",
|
|
||||||
"slab",
|
|
||||||
"thiserror 2.0.17",
|
|
||||||
"tinyvec",
|
|
||||||
"tracing",
|
|
||||||
"web-time",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "quinn-udp"
|
|
||||||
version = "0.5.14"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd"
|
|
||||||
dependencies = [
|
|
||||||
"cfg_aliases",
|
|
||||||
"libc",
|
|
||||||
"once_cell",
|
|
||||||
"socket2",
|
|
||||||
"tracing",
|
|
||||||
"windows-sys 0.52.0",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "quote"
|
name = "quote"
|
||||||
version = "1.0.42"
|
version = "1.0.42"
|
||||||
@ -1743,18 +1807,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404"
|
checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
"rand_chacha 0.3.1",
|
"rand_chacha",
|
||||||
"rand_core 0.6.4",
|
"rand_core",
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "rand"
|
|
||||||
version = "0.9.2"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1"
|
|
||||||
dependencies = [
|
|
||||||
"rand_chacha 0.9.0",
|
|
||||||
"rand_core 0.9.3",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@ -1764,17 +1818,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
|
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"ppv-lite86",
|
"ppv-lite86",
|
||||||
"rand_core 0.6.4",
|
"rand_core",
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "rand_chacha"
|
|
||||||
version = "0.9.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
|
|
||||||
dependencies = [
|
|
||||||
"ppv-lite86",
|
|
||||||
"rand_core 0.9.3",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@ -1786,15 +1830,6 @@ dependencies = [
|
|||||||
"getrandom 0.2.16",
|
"getrandom 0.2.16",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "rand_core"
|
|
||||||
version = "0.9.3"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38"
|
|
||||||
dependencies = [
|
|
||||||
"getrandom 0.3.4",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "redox_syscall"
|
name = "redox_syscall"
|
||||||
version = "0.5.18"
|
version = "0.5.18"
|
||||||
@ -1870,26 +1905,29 @@ checksum = "9d0946410b9f7b082a427e4ef5c8ff541a88b357bc6c637c40db3a68ac70a36f"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"base64",
|
"base64",
|
||||||
"bytes",
|
"bytes",
|
||||||
|
"encoding_rs",
|
||||||
"futures-core",
|
"futures-core",
|
||||||
|
"h2",
|
||||||
"http",
|
"http",
|
||||||
"http-body",
|
"http-body",
|
||||||
"http-body-util",
|
"http-body-util",
|
||||||
"hyper",
|
"hyper",
|
||||||
"hyper-rustls",
|
"hyper-rustls",
|
||||||
|
"hyper-tls",
|
||||||
"hyper-util",
|
"hyper-util",
|
||||||
"js-sys",
|
"js-sys",
|
||||||
"log",
|
"log",
|
||||||
|
"mime",
|
||||||
|
"native-tls",
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"quinn",
|
|
||||||
"rustls",
|
|
||||||
"rustls-pki-types",
|
"rustls-pki-types",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"serde_urlencoded",
|
"serde_urlencoded",
|
||||||
"sync_wrapper",
|
"sync_wrapper",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-rustls",
|
"tokio-native-tls",
|
||||||
"tower",
|
"tower",
|
||||||
"tower-http",
|
"tower-http",
|
||||||
"tower-service",
|
"tower-service",
|
||||||
@ -1897,7 +1935,6 @@ dependencies = [
|
|||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
"wasm-bindgen-futures",
|
"wasm-bindgen-futures",
|
||||||
"web-sys",
|
"web-sys",
|
||||||
"webpki-roots 1.0.4",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@ -1970,7 +2007,7 @@ dependencies = [
|
|||||||
"num-traits",
|
"num-traits",
|
||||||
"pkcs1",
|
"pkcs1",
|
||||||
"pkcs8",
|
"pkcs8",
|
||||||
"rand_core 0.6.4",
|
"rand_core",
|
||||||
"signature",
|
"signature",
|
||||||
"spki",
|
"spki",
|
||||||
"subtle",
|
"subtle",
|
||||||
@ -1997,18 +2034,12 @@ dependencies = [
|
|||||||
"borsh",
|
"borsh",
|
||||||
"bytes",
|
"bytes",
|
||||||
"num-traits",
|
"num-traits",
|
||||||
"rand 0.8.5",
|
"rand",
|
||||||
"rkyv",
|
"rkyv",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "rustc-hash"
|
|
||||||
version = "2.1.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustc_version"
|
name = "rustc_version"
|
||||||
version = "0.4.1"
|
version = "0.4.1"
|
||||||
@ -2018,6 +2049,19 @@ dependencies = [
|
|||||||
"semver",
|
"semver",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustix"
|
||||||
|
version = "1.1.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags",
|
||||||
|
"errno",
|
||||||
|
"libc",
|
||||||
|
"linux-raw-sys",
|
||||||
|
"windows-sys 0.61.2",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustls"
|
name = "rustls"
|
||||||
version = "0.23.35"
|
version = "0.23.35"
|
||||||
@ -2060,7 +2104,6 @@ version = "1.13.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "94182ad936a0c91c324cd46c6511b9510ed16af436d7b5bab34beab0afd55f7a"
|
checksum = "94182ad936a0c91c324cd46c6511b9510ed16af436d7b5bab34beab0afd55f7a"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"web-time",
|
|
||||||
"zeroize",
|
"zeroize",
|
||||||
]
|
]
|
||||||
|
|
||||||
@ -2380,7 +2423,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "c1e303f8205714074f6068773f0e29527e0453937fe837c9717d066635b65f31"
|
checksum = "c1e303f8205714074f6068773f0e29527e0453937fe837c9717d066635b65f31"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"pkcs8",
|
"pkcs8",
|
||||||
"rand_core 0.6.4",
|
"rand_core",
|
||||||
"signature",
|
"signature",
|
||||||
"zeroize",
|
"zeroize",
|
||||||
]
|
]
|
||||||
@ -2392,7 +2435,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
|
checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"digest",
|
"digest",
|
||||||
"rand_core 0.6.4",
|
"rand_core",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@ -2565,7 +2608,7 @@ dependencies = [
|
|||||||
"memchr",
|
"memchr",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"rand 0.8.5",
|
"rand",
|
||||||
"rsa",
|
"rsa",
|
||||||
"rust_decimal",
|
"rust_decimal",
|
||||||
"serde",
|
"serde",
|
||||||
@ -2606,7 +2649,7 @@ dependencies = [
|
|||||||
"md-5",
|
"md-5",
|
||||||
"memchr",
|
"memchr",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"rand 0.8.5",
|
"rand",
|
||||||
"rust_decimal",
|
"rust_decimal",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
@ -2711,12 +2754,46 @@ dependencies = [
|
|||||||
"syn 2.0.110",
|
"syn 2.0.110",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "system-configuration"
|
||||||
|
version = "0.6.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags",
|
||||||
|
"core-foundation",
|
||||||
|
"system-configuration-sys",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "system-configuration-sys"
|
||||||
|
version = "0.6.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4"
|
||||||
|
dependencies = [
|
||||||
|
"core-foundation-sys",
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tap"
|
name = "tap"
|
||||||
version = "1.0.1"
|
version = "1.0.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369"
|
checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tempfile"
|
||||||
|
version = "3.23.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2d31c77bdf42a745371d260a26ca7163f1e0924b64afa0b688e61b5a9fa02f16"
|
||||||
|
dependencies = [
|
||||||
|
"fastrand",
|
||||||
|
"getrandom 0.3.4",
|
||||||
|
"once_cell",
|
||||||
|
"rustix",
|
||||||
|
"windows-sys 0.61.2",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "thiserror"
|
name = "thiserror"
|
||||||
version = "1.0.69"
|
version = "1.0.69"
|
||||||
@ -2859,6 +2936,16 @@ dependencies = [
|
|||||||
"syn 2.0.110",
|
"syn 2.0.110",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tokio-native-tls"
|
||||||
|
version = "0.3.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2"
|
||||||
|
dependencies = [
|
||||||
|
"native-tls",
|
||||||
|
"tokio",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tokio-rustls"
|
name = "tokio-rustls"
|
||||||
version = "0.26.4"
|
version = "0.26.4"
|
||||||
@ -2905,7 +2992,7 @@ dependencies = [
|
|||||||
"futures-sink",
|
"futures-sink",
|
||||||
"http",
|
"http",
|
||||||
"httparse",
|
"httparse",
|
||||||
"rand 0.8.5",
|
"rand",
|
||||||
"ring",
|
"ring",
|
||||||
"rustls-pki-types",
|
"rustls-pki-types",
|
||||||
"tokio",
|
"tokio",
|
||||||
@ -3317,16 +3404,6 @@ dependencies = [
|
|||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "web-time"
|
|
||||||
version = "1.1.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
|
|
||||||
dependencies = [
|
|
||||||
"js-sys",
|
|
||||||
"wasm-bindgen",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "webpki-roots"
|
name = "webpki-roots"
|
||||||
version = "0.26.11"
|
version = "0.26.11"
|
||||||
@ -3396,6 +3473,17 @@ version = "0.2.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-registry"
|
||||||
|
version = "0.6.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720"
|
||||||
|
dependencies = [
|
||||||
|
"windows-link",
|
||||||
|
"windows-result",
|
||||||
|
"windows-strings",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows-result"
|
name = "windows-result"
|
||||||
version = "0.4.1"
|
version = "0.4.1"
|
||||||
|
|||||||
@ -17,7 +17,7 @@ async-nats = "0.45.0"
|
|||||||
futures-util = "0.3"
|
futures-util = "0.3"
|
||||||
|
|
||||||
# HTTP Client
|
# HTTP Client
|
||||||
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
|
reqwest = { version = "0.12", features = ["json"] }
|
||||||
|
|
||||||
# Concurrency & Async
|
# Concurrency & Async
|
||||||
uuid = { version = "1.8", features = ["v4"] }
|
uuid = { version = "1.8", features = ["v4"] }
|
||||||
|
|||||||
@ -25,7 +25,6 @@ FROM debian:bookworm-slim
|
|||||||
# Set timezone
|
# Set timezone
|
||||||
ENV TZ=Asia/Shanghai
|
ENV TZ=Asia/Shanghai
|
||||||
RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone
|
RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates libssl3 && rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
# Copy the built binary from the builder stage
|
# Copy the built binary from the builder stage
|
||||||
COPY --from=builder /usr/src/app/services/api-gateway/target/release/api-gateway /usr/local/bin/
|
COPY --from=builder /usr/src/app/services/api-gateway/target/release/api-gateway /usr/local/bin/
|
||||||
|
|||||||
@ -34,22 +34,12 @@ pub fn create_router(app_state: AppState) -> Router {
|
|||||||
Router::new()
|
Router::new()
|
||||||
.route("/health", get(health_check))
|
.route("/health", get(health_check))
|
||||||
.route("/tasks", get(get_current_tasks)) // This is the old, stateless one
|
.route("/tasks", get(get_current_tasks)) // This is the old, stateless one
|
||||||
.nest("/v1", create_v1_router())
|
.route("/v1/data-requests", post(trigger_data_fetch))
|
||||||
|
.route("/v1/companies/:symbol/profile", get(get_company_profile))
|
||||||
|
.route("/v1/tasks/:request_id", get(get_task_progress))
|
||||||
.with_state(app_state)
|
.with_state(app_state)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn create_v1_router() -> Router<AppState> {
|
|
||||||
Router::new()
|
|
||||||
.route("/data-requests", post(trigger_data_fetch))
|
|
||||||
.route("/companies/{symbol}/profile", get(get_company_profile))
|
|
||||||
.route("/tasks/{request_id}", get(get_task_progress))
|
|
||||||
// --- New Config Routes ---
|
|
||||||
.route("/configs/llm_providers", get(get_llm_providers_config).put(update_llm_providers_config))
|
|
||||||
.route("/configs/analysis_modules", get(get_analysis_modules_config).put(update_analysis_modules_config))
|
|
||||||
// --- New Discover Route ---
|
|
||||||
.route("/discover-models/{provider_id}", get(discover_models))
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Health & Stateless Tasks ---
|
// --- Health & Stateless Tasks ---
|
||||||
async fn health_check(State(state): State<AppState>) -> Json<HealthStatus> {
|
async fn health_check(State(state): State<AppState>) -> Json<HealthStatus> {
|
||||||
let mut details = HashMap::new();
|
let mut details = HashMap::new();
|
||||||
@ -69,7 +59,7 @@ async fn get_current_tasks() -> Json<Vec<TaskProgress>> {
|
|||||||
Json(vec![])
|
Json(vec![])
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- V1 API Handlers ---
|
// --- API Handlers ---
|
||||||
|
|
||||||
/// [POST /v1/data-requests]
|
/// [POST /v1/data-requests]
|
||||||
/// Triggers the data fetching process by publishing a command to the message bus.
|
/// Triggers the data fetching process by publishing a command to the message bus.
|
||||||
@ -155,86 +145,3 @@ async fn get_task_progress(
|
|||||||
Ok((StatusCode::NOT_FOUND, Json(serde_json::json!({"error": "Task not found"}))).into_response())
|
Ok((StatusCode::NOT_FOUND, Json(serde_json::json!({"error": "Task not found"}))).into_response())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// --- Config API Handlers (Proxy to data-persistence-service) ---
|
|
||||||
|
|
||||||
use common_contracts::config_models::{LlmProvidersConfig, AnalysisModulesConfig};
|
|
||||||
|
|
||||||
/// [GET /v1/configs/llm_providers]
|
|
||||||
async fn get_llm_providers_config(
|
|
||||||
State(state): State<AppState>,
|
|
||||||
) -> Result<impl IntoResponse> {
|
|
||||||
let config = state.persistence_client.get_llm_providers_config().await?;
|
|
||||||
Ok(Json(config))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// [PUT /v1/configs/llm_providers]
|
|
||||||
async fn update_llm_providers_config(
|
|
||||||
State(state): State<AppState>,
|
|
||||||
Json(payload): Json<LlmProvidersConfig>,
|
|
||||||
) -> Result<impl IntoResponse> {
|
|
||||||
let updated_config = state.persistence_client.update_llm_providers_config(&payload).await?;
|
|
||||||
Ok(Json(updated_config))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// [GET /v1/configs/analysis_modules]
|
|
||||||
async fn get_analysis_modules_config(
|
|
||||||
State(state): State<AppState>,
|
|
||||||
) -> Result<impl IntoResponse> {
|
|
||||||
let config = state.persistence_client.get_analysis_modules_config().await?;
|
|
||||||
Ok(Json(config))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// [PUT /v1/configs/analysis_modules]
|
|
||||||
async fn update_analysis_modules_config(
|
|
||||||
State(state): State<AppState>,
|
|
||||||
Json(payload): Json<AnalysisModulesConfig>,
|
|
||||||
) -> Result<impl IntoResponse> {
|
|
||||||
let updated_config = state.persistence_client.update_analysis_modules_config(&payload).await?;
|
|
||||||
Ok(Json(updated_config))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// [GET /v1/discover-models/:provider_id]
|
|
||||||
async fn discover_models(
|
|
||||||
State(state): State<AppState>,
|
|
||||||
Path(provider_id): Path<String>,
|
|
||||||
) -> Result<impl IntoResponse> {
|
|
||||||
let providers = state.persistence_client.get_llm_providers_config().await?;
|
|
||||||
|
|
||||||
if let Some(provider) = providers.get(&provider_id) {
|
|
||||||
let client = reqwest::Client::new();
|
|
||||||
let url = format!("{}/models", provider.api_base_url.trim_end_matches('/'));
|
|
||||||
|
|
||||||
let response = client
|
|
||||||
.get(&url)
|
|
||||||
.bearer_auth(&provider.api_key)
|
|
||||||
.send()
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
if !response.status().is_success() {
|
|
||||||
let status = response.status();
|
|
||||||
let error_text = response.text().await?;
|
|
||||||
warn!(
|
|
||||||
"Failed to discover models for provider '{}'. Status: {}, Body: {}",
|
|
||||||
provider_id, status, error_text
|
|
||||||
);
|
|
||||||
// Return a structured error to the frontend
|
|
||||||
return Ok((
|
|
||||||
StatusCode::BAD_GATEWAY,
|
|
||||||
Json(serde_json::json!({
|
|
||||||
"error": "Failed to fetch models from provider",
|
|
||||||
"provider_error": error_text,
|
|
||||||
})),
|
|
||||||
).into_response());
|
|
||||||
}
|
|
||||||
|
|
||||||
let models_json: serde_json::Value = response.json().await?;
|
|
||||||
Ok((StatusCode::OK, Json(models_json)).into_response())
|
|
||||||
} else {
|
|
||||||
Ok((
|
|
||||||
StatusCode::NOT_FOUND,
|
|
||||||
Json(serde_json::json!({ "error": "Provider not found" })),
|
|
||||||
).into_response())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,5 +1,4 @@
|
|||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use config::Config;
|
|
||||||
|
|
||||||
#[derive(Deserialize, Debug)]
|
#[derive(Deserialize, Debug)]
|
||||||
pub struct AppConfig {
|
pub struct AppConfig {
|
||||||
@ -11,41 +10,10 @@ pub struct AppConfig {
|
|||||||
|
|
||||||
impl AppConfig {
|
impl AppConfig {
|
||||||
pub fn load() -> Result<Self, config::ConfigError> {
|
pub fn load() -> Result<Self, config::ConfigError> {
|
||||||
let cfg: Config = config::Config::builder()
|
let config = config::Config::builder()
|
||||||
.add_source(config::Environment::default().separator("__"))
|
.add_source(config::Environment::default().separator("__"))
|
||||||
.build()?;
|
.build()?;
|
||||||
|
|
||||||
let server_port: u16 = cfg.get::<u16>("server_port")?;
|
config.try_deserialize()
|
||||||
let nats_addr: String = cfg.get::<String>("nats_addr")?;
|
|
||||||
let data_persistence_service_url: String = cfg.get::<String>("data_persistence_service_url")?;
|
|
||||||
|
|
||||||
// Parse provider_services deterministically:
|
|
||||||
// 1) prefer array from env (e.g., PROVIDER_SERVICES__0, PROVIDER_SERVICES__1, ...)
|
|
||||||
// 2) fallback to explicit JSON in PROVIDER_SERVICES
|
|
||||||
let provider_services: Vec<String> = if let Ok(arr) = cfg.get_array("provider_services") {
|
|
||||||
let mut out: Vec<String> = Vec::with_capacity(arr.len());
|
|
||||||
for v in arr {
|
|
||||||
let s = v.into_string().map_err(|e| {
|
|
||||||
config::ConfigError::Message(format!("provider_services must be strings: {}", e))
|
|
||||||
})?;
|
|
||||||
out.push(s);
|
|
||||||
}
|
|
||||||
out
|
|
||||||
} else {
|
|
||||||
let json = cfg.get_string("provider_services")?;
|
|
||||||
serde_json::from_str::<Vec<String>>(&json).map_err(|e| {
|
|
||||||
config::ConfigError::Message(format!(
|
|
||||||
"Invalid JSON for provider_services: {}",
|
|
||||||
e
|
|
||||||
))
|
|
||||||
})?
|
|
||||||
};
|
|
||||||
|
|
||||||
Ok(Self {
|
|
||||||
server_port,
|
|
||||||
nats_addr,
|
|
||||||
data_persistence_service_url,
|
|
||||||
provider_services,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -8,17 +8,9 @@ use crate::config::AppConfig;
|
|||||||
use crate::error::Result;
|
use crate::error::Result;
|
||||||
use crate::state::AppState;
|
use crate::state::AppState;
|
||||||
use tracing::info;
|
use tracing::info;
|
||||||
use std::process;
|
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() {
|
async fn main() -> Result<()> {
|
||||||
if let Err(e) = run().await {
|
|
||||||
eprintln!("api-gateway failed to start: {}", e);
|
|
||||||
process::exit(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn run() -> Result<()> {
|
|
||||||
// Initialize logging
|
// Initialize logging
|
||||||
tracing_subscriber::fmt()
|
tracing_subscriber::fmt()
|
||||||
.with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
|
.with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
|
||||||
@ -29,7 +21,6 @@ async fn run() -> Result<()> {
|
|||||||
// Load configuration
|
// Load configuration
|
||||||
let config = AppConfig::load().map_err(|e| error::AppError::Configuration(e.to_string()))?;
|
let config = AppConfig::load().map_err(|e| error::AppError::Configuration(e.to_string()))?;
|
||||||
let port = config.server_port;
|
let port = config.server_port;
|
||||||
info!("Configured provider services: {:?}", config.provider_services);
|
|
||||||
|
|
||||||
// Initialize application state
|
// Initialize application state
|
||||||
let app_state = AppState::new(config).await?;
|
let app_state = AppState::new(config).await?;
|
||||||
|
|||||||
@ -4,7 +4,6 @@
|
|||||||
|
|
||||||
use crate::error::Result;
|
use crate::error::Result;
|
||||||
use common_contracts::dtos::CompanyProfileDto;
|
use common_contracts::dtos::CompanyProfileDto;
|
||||||
use common_contracts::config_models::{LlmProvidersConfig, AnalysisModulesConfig};
|
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct PersistenceClient {
|
pub struct PersistenceClient {
|
||||||
@ -32,60 +31,4 @@ impl PersistenceClient {
|
|||||||
.await?;
|
.await?;
|
||||||
Ok(profile)
|
Ok(profile)
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Config Methods ---
|
|
||||||
|
|
||||||
pub async fn get_llm_providers_config(&self) -> Result<LlmProvidersConfig> {
|
|
||||||
let url = format!("{}/configs/llm_providers", self.base_url);
|
|
||||||
let config = self
|
|
||||||
.client
|
|
||||||
.get(&url)
|
|
||||||
.send()
|
|
||||||
.await?
|
|
||||||
.error_for_status()?
|
|
||||||
.json::<LlmProvidersConfig>()
|
|
||||||
.await?;
|
|
||||||
Ok(config)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn update_llm_providers_config(&self, payload: &LlmProvidersConfig) -> Result<LlmProvidersConfig> {
|
|
||||||
let url = format!("{}/configs/llm_providers", self.base_url);
|
|
||||||
let updated_config = self
|
|
||||||
.client
|
|
||||||
.put(&url)
|
|
||||||
.json(payload)
|
|
||||||
.send()
|
|
||||||
.await?
|
|
||||||
.error_for_status()?
|
|
||||||
.json::<LlmProvidersConfig>()
|
|
||||||
.await?;
|
|
||||||
Ok(updated_config)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn get_analysis_modules_config(&self) -> Result<AnalysisModulesConfig> {
|
|
||||||
let url = format!("{}/configs/analysis_modules", self.base_url);
|
|
||||||
let config = self
|
|
||||||
.client
|
|
||||||
.get(&url)
|
|
||||||
.send()
|
|
||||||
.await?
|
|
||||||
.error_for_status()?
|
|
||||||
.json::<AnalysisModulesConfig>()
|
|
||||||
.await?;
|
|
||||||
Ok(config)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn update_analysis_modules_config(&self, payload: &AnalysisModulesConfig) -> Result<AnalysisModulesConfig> {
|
|
||||||
let url = format!("{}/configs/analysis_modules", self.base_url);
|
|
||||||
let updated_config = self
|
|
||||||
.client
|
|
||||||
.put(&url)
|
|
||||||
.json(payload)
|
|
||||||
.send()
|
|
||||||
.await?
|
|
||||||
.error_for_status()?
|
|
||||||
.json::<AnalysisModulesConfig>()
|
|
||||||
.await?;
|
|
||||||
Ok(updated_config)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,8 +3,6 @@ use crate::error::Result;
|
|||||||
use crate::persistence::PersistenceClient;
|
use crate::persistence::PersistenceClient;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use async_nats::Client as NatsClient;
|
use async_nats::Client as NatsClient;
|
||||||
use tokio::time::{sleep, Duration};
|
|
||||||
use tracing::{info, warn};
|
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct AppState {
|
pub struct AppState {
|
||||||
@ -15,7 +13,7 @@ pub struct AppState {
|
|||||||
|
|
||||||
impl AppState {
|
impl AppState {
|
||||||
pub async fn new(config: AppConfig) -> Result<Self> {
|
pub async fn new(config: AppConfig) -> Result<Self> {
|
||||||
let nats_client = connect_nats_with_retry(&config.nats_addr).await?;
|
let nats_client = async_nats::connect(&config.nats_addr).await?;
|
||||||
|
|
||||||
let persistence_client =
|
let persistence_client =
|
||||||
PersistenceClient::new(config.data_persistence_service_url.clone());
|
PersistenceClient::new(config.data_persistence_service_url.clone());
|
||||||
@ -27,34 +25,3 @@ impl AppState {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const MAX_NATS_CONNECT_ATTEMPTS: usize = 30;
|
|
||||||
const INITIAL_BACKOFF_MS: u64 = 200;
|
|
||||||
const MAX_BACKOFF_MS: u64 = 2_000;
|
|
||||||
|
|
||||||
async fn connect_nats_with_retry(nats_addr: &str) -> Result<NatsClient> {
|
|
||||||
let mut backoff_ms = INITIAL_BACKOFF_MS;
|
|
||||||
let mut last_err: Option<async_nats::error::Error<async_nats::ConnectErrorKind>> = None;
|
|
||||||
|
|
||||||
for attempt in 1..=MAX_NATS_CONNECT_ATTEMPTS {
|
|
||||||
match async_nats::connect(nats_addr).await {
|
|
||||||
Ok(client) => {
|
|
||||||
info!("Connected to NATS at {} (attempt {})", nats_addr, attempt);
|
|
||||||
return Ok(client);
|
|
||||||
}
|
|
||||||
Err(err) => {
|
|
||||||
warn!(
|
|
||||||
"Failed to connect to NATS at {} (attempt {}): {}",
|
|
||||||
nats_addr, attempt, err
|
|
||||||
);
|
|
||||||
last_err = Some(err);
|
|
||||||
let delay = backoff_ms.min(MAX_BACKOFF_MS);
|
|
||||||
sleep(Duration::from_millis(delay)).await;
|
|
||||||
backoff_ms = backoff_ms.saturating_mul(2);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Exhausted all attempts; return the last error
|
|
||||||
Err(last_err.expect("last_err must be set after failed attempts").into())
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,36 +0,0 @@
|
|||||||
use serde::{Serialize, Deserialize};
|
|
||||||
use std::collections::HashMap;
|
|
||||||
use utoipa::ToSchema;
|
|
||||||
|
|
||||||
// 单个启用的模型
|
|
||||||
#[derive(Serialize, Deserialize, Debug, Clone, ToSchema)]
|
|
||||||
pub struct LlmModel {
|
|
||||||
pub model_id: String, // e.g., "gpt-4o"
|
|
||||||
pub name: Option<String>, // 别名,用于UI显示
|
|
||||||
pub is_active: bool, // 是否在UI中可选
|
|
||||||
}
|
|
||||||
|
|
||||||
// 单个LLM供应商的完整配置
|
|
||||||
#[derive(Serialize, Deserialize, Debug, Clone, ToSchema)]
|
|
||||||
pub struct LlmProvider {
|
|
||||||
pub name: String, // "OpenAI 官方"
|
|
||||||
pub api_base_url: String,
|
|
||||||
pub api_key: String, // 直接明文存储
|
|
||||||
pub models: Vec<LlmModel>, // 该供应商下我们启用的模型列表
|
|
||||||
}
|
|
||||||
|
|
||||||
// 整个LLM Provider注册中心的数据结构
|
|
||||||
pub type LlmProvidersConfig = HashMap<String, LlmProvider>; // Key: provider_id, e.g., "openai_official"
|
|
||||||
|
|
||||||
// 单个分析模块的配置
|
|
||||||
#[derive(Serialize, Deserialize, Debug, Clone, ToSchema)]
|
|
||||||
pub struct AnalysisModuleConfig {
|
|
||||||
pub name: String, // "看涨分析"
|
|
||||||
pub provider_id: String, // 引用 LlmProvidersConfig 的 Key
|
|
||||||
pub model_id: String, // 引用 LlmModel 中的 model_id
|
|
||||||
pub prompt_template: String,
|
|
||||||
pub dependencies: Vec<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
// 整个分析模块配置集合的数据结构
|
|
||||||
pub type AnalysisModulesConfig = HashMap<String, AnalysisModuleConfig>; // Key: module_id, e.g., "bull_case"
|
|
||||||
@ -2,6 +2,5 @@ pub mod dtos;
|
|||||||
pub mod models;
|
pub mod models;
|
||||||
pub mod observability;
|
pub mod observability;
|
||||||
pub mod messages;
|
pub mod messages;
|
||||||
pub mod config_models;
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
1220
services/config-service-rs/Cargo.lock
generated
Normal file
1220
services/config-service-rs/Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
18
services/config-service-rs/Cargo.toml
Normal file
18
services/config-service-rs/Cargo.toml
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
[package]
|
||||||
|
name = "config-service-rs"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
axum = "0.8.7"
|
||||||
|
tokio = { version = "1.0", features = ["full"] }
|
||||||
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
|
serde_json = "1.0"
|
||||||
|
tracing = "0.1"
|
||||||
|
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||||
|
config = "0.15.19"
|
||||||
|
anyhow = "1.0"
|
||||||
|
tower-http = { version = "0.6.6", features = ["cors"] }
|
||||||
|
once_cell = "1.19"
|
||||||
|
thiserror = "2.0.17"
|
||||||
|
hyper = "1"
|
||||||
35
services/config-service-rs/Dockerfile
Normal file
35
services/config-service-rs/Dockerfile
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
# 1. Build Stage
|
||||||
|
FROM rust:1.90 as builder
|
||||||
|
|
||||||
|
WORKDIR /usr/src/app
|
||||||
|
|
||||||
|
# Pre-build dependencies to leverage Docker layer caching
|
||||||
|
COPY ./services/config-service-rs/Cargo.toml ./services/config-service-rs/Cargo.lock* ./services/config-service-rs/
|
||||||
|
WORKDIR /usr/src/app/services/config-service-rs
|
||||||
|
RUN mkdir -p src && \
|
||||||
|
echo "fn main() {}" > src/main.rs && \
|
||||||
|
cargo build --release --bin config-service-rs
|
||||||
|
|
||||||
|
# Copy the full source code
|
||||||
|
COPY ./services/config-service-rs /usr/src/app/services/config-service-rs
|
||||||
|
COPY ./config /usr/src/app/config
|
||||||
|
|
||||||
|
# Build the application
|
||||||
|
WORKDIR /usr/src/app/services/config-service-rs
|
||||||
|
RUN cargo build --release --bin config-service-rs
|
||||||
|
|
||||||
|
# 2. Runtime Stage
|
||||||
|
FROM debian:bookworm-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Set timezone
|
||||||
|
ENV TZ=Asia/Shanghai
|
||||||
|
RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone
|
||||||
|
|
||||||
|
# Copy the built binary and the config directory from the builder stage
|
||||||
|
COPY --from=builder /usr/src/app/services/config-service-rs/target/release/config-service-rs /usr/local/bin/
|
||||||
|
COPY --from=builder /usr/src/app/config ./config
|
||||||
|
|
||||||
|
# Set the binary as the entrypoint
|
||||||
|
ENTRYPOINT ["/usr/local/bin/config-service-rs"]
|
||||||
54
services/config-service-rs/src/api.rs
Normal file
54
services/config-service-rs/src/api.rs
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
use axum::{response::Json, routing::get, Router};
|
||||||
|
use once_cell::sync::Lazy;
|
||||||
|
use serde_json::Value;
|
||||||
|
use std::{path::PathBuf, sync::Arc};
|
||||||
|
|
||||||
|
use crate::config::AppConfig;
|
||||||
|
|
||||||
|
static CONFIGS: Lazy<Arc<CachedConfig>> = Lazy::new(|| Arc::new(CachedConfig::load_from_disk()));
|
||||||
|
|
||||||
|
struct CachedConfig {
|
||||||
|
system: Value,
|
||||||
|
analysis: Value,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CachedConfig {
|
||||||
|
fn load_from_disk() -> Self {
|
||||||
|
let config = AppConfig::load().expect("Failed to load app config for caching");
|
||||||
|
let config_dir = PathBuf::from(&config.project_root).join("config");
|
||||||
|
|
||||||
|
let system_path = config_dir.join("config.json");
|
||||||
|
let analysis_path = config_dir.join("analysis-config.json");
|
||||||
|
|
||||||
|
let system_content = std::fs::read_to_string(system_path)
|
||||||
|
.expect("Failed to read system config.json");
|
||||||
|
let system: Value = serde_json::from_str(&system_content)
|
||||||
|
.expect("Failed to parse system config.json");
|
||||||
|
|
||||||
|
let analysis_content = std::fs::read_to_string(analysis_path)
|
||||||
|
.expect("Failed to read analysis-config.json");
|
||||||
|
let analysis: Value = serde_json::from_str(&analysis_content)
|
||||||
|
.expect("Failed to parse analysis-config.json");
|
||||||
|
|
||||||
|
Self { system, analysis }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn create_router() -> Router {
|
||||||
|
Router::new()
|
||||||
|
.route("/", get(root))
|
||||||
|
.route("/api/v1/system", get(get_system_config))
|
||||||
|
.route("/api/v1/analysis-modules", get(get_analysis_modules))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn root() -> &'static str {
|
||||||
|
"OK"
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_system_config() -> Json<Value> {
|
||||||
|
Json(CONFIGS.system.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_analysis_modules() -> Json<Value> {
|
||||||
|
Json(CONFIGS.analysis.clone())
|
||||||
|
}
|
||||||
19
services/config-service-rs/src/config.rs
Normal file
19
services/config-service-rs/src/config.rs
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
use config::{Config, ConfigError, Environment};
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct AppConfig {
|
||||||
|
pub server_port: u16,
|
||||||
|
pub project_root: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AppConfig {
|
||||||
|
pub fn load() -> Result<Self, ConfigError> {
|
||||||
|
let config = Config::builder()
|
||||||
|
.set_default("server_port", 8080)?
|
||||||
|
.set_default("project_root", "/workspace")?
|
||||||
|
.add_source(Environment::default().separator("__"))
|
||||||
|
.build()?;
|
||||||
|
config.try_deserialize()
|
||||||
|
}
|
||||||
|
}
|
||||||
18
services/config-service-rs/src/error.rs
Normal file
18
services/config-service-rs/src/error.rs
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
pub type Result<T> = std::result::Result<T, AppError>;
|
||||||
|
|
||||||
|
#[derive(Error, Debug)]
|
||||||
|
pub enum AppError {
|
||||||
|
#[error("Configuration error: {0}")]
|
||||||
|
Config(#[from] config::ConfigError),
|
||||||
|
|
||||||
|
#[error("IO error: {0}")]
|
||||||
|
Io(#[from] std::io::Error),
|
||||||
|
|
||||||
|
#[error("JSON parsing error: {0}")]
|
||||||
|
Json(#[from] serde_json::Error),
|
||||||
|
|
||||||
|
#[error("Server startup error: {0}")]
|
||||||
|
Server(#[from] hyper::Error),
|
||||||
|
}
|
||||||
26
services/config-service-rs/src/main.rs
Normal file
26
services/config-service-rs/src/main.rs
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
mod api;
|
||||||
|
mod config;
|
||||||
|
mod error;
|
||||||
|
|
||||||
|
use crate::{config::AppConfig, error::Result};
|
||||||
|
use std::net::SocketAddr;
|
||||||
|
use tracing::info;
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> Result<()> {
|
||||||
|
tracing_subscriber::fmt()
|
||||||
|
.with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
|
||||||
|
.init();
|
||||||
|
|
||||||
|
let config = AppConfig::load()?;
|
||||||
|
let port = config.server_port;
|
||||||
|
|
||||||
|
let app = api::create_router();
|
||||||
|
|
||||||
|
let addr = SocketAddr::from(([0, 0, 0, 0], port));
|
||||||
|
info!("Server listening on {}", addr);
|
||||||
|
let listener = tokio::net::TcpListener::bind(&addr).await?;
|
||||||
|
axum::serve(listener, app).await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@ -74,7 +74,6 @@ wasm-cli = []
|
|||||||
mcp = ["service_kit/mcp"]
|
mcp = ["service_kit/mcp"]
|
||||||
# 可选:透传 api-cli 给 service_kit
|
# 可选:透传 api-cli 给 service_kit
|
||||||
# api-cli = ["service_kit/api-cli"]
|
# api-cli = ["service_kit/api-cli"]
|
||||||
full-data = []
|
|
||||||
|
|
||||||
# --- For Local Development ---
|
# --- For Local Development ---
|
||||||
# If you are developing `service_kit` locally, uncomment the following lines
|
# If you are developing `service_kit` locally, uncomment the following lines
|
||||||
|
|||||||
@ -1,43 +0,0 @@
|
|||||||
use axum::{extract::State, Json};
|
|
||||||
use common_contracts::config_models::{LlmProvidersConfig, AnalysisModulesConfig};
|
|
||||||
use service_kit::api;
|
|
||||||
|
|
||||||
use crate::{db::system_config, AppState, ServerError};
|
|
||||||
|
|
||||||
#[api(GET, "/api/v1/configs/llm_providers", output(detail = "LlmProvidersConfig"))]
|
|
||||||
pub async fn get_llm_providers_config(
|
|
||||||
State(state): State<AppState>,
|
|
||||||
) -> Result<Json<LlmProvidersConfig>, ServerError> {
|
|
||||||
let pool = state.pool();
|
|
||||||
let config = system_config::get_config::<LlmProvidersConfig>(pool, "llm_providers").await?;
|
|
||||||
Ok(Json(config))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[api(PUT, "/api/v1/configs/llm_providers", output(detail = "LlmProvidersConfig"))]
|
|
||||||
pub async fn update_llm_providers_config(
|
|
||||||
State(state): State<AppState>,
|
|
||||||
Json(payload): Json<LlmProvidersConfig>,
|
|
||||||
) -> Result<Json<LlmProvidersConfig>, ServerError> {
|
|
||||||
let pool = state.pool();
|
|
||||||
let updated_config = system_config::update_config(pool, "llm_providers", &payload).await?;
|
|
||||||
Ok(Json(updated_config))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[api(GET, "/api/v1/configs/analysis_modules", output(detail = "AnalysisModulesConfig"))]
|
|
||||||
pub async fn get_analysis_modules_config(
|
|
||||||
State(state): State<AppState>,
|
|
||||||
) -> Result<Json<AnalysisModulesConfig>, ServerError> {
|
|
||||||
let pool = state.pool();
|
|
||||||
let config = system_config::get_config::<AnalysisModulesConfig>(pool, "analysis_modules").await?;
|
|
||||||
Ok(Json(config))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[api(PUT, "/api/v1/configs/analysis_modules", output(detail = "AnalysisModulesConfig"))]
|
|
||||||
pub async fn update_analysis_modules_config(
|
|
||||||
State(state): State<AppState>,
|
|
||||||
Json(payload): Json<AnalysisModulesConfig>,
|
|
||||||
) -> Result<Json<AnalysisModulesConfig>, ServerError> {
|
|
||||||
let pool = state.pool();
|
|
||||||
let updated_config = system_config::update_config(pool, "analysis_modules", &payload).await?;
|
|
||||||
Ok(Json(updated_config))
|
|
||||||
}
|
|
||||||
@ -1,10 +1,6 @@
|
|||||||
// This module will contain all the API handler definitions
|
// This module will contain all the API handler definitions
|
||||||
// which are then collected by the `inventory` crate.
|
// which are then collected by the `inventory` crate.
|
||||||
#[cfg(feature = "full-data")]
|
|
||||||
pub mod companies;
|
pub mod companies;
|
||||||
#[cfg(feature = "full-data")]
|
|
||||||
pub mod market_data;
|
pub mod market_data;
|
||||||
#[cfg(feature = "full-data")]
|
|
||||||
pub mod analysis;
|
pub mod analysis;
|
||||||
pub mod system;
|
pub mod system;
|
||||||
pub mod configs;
|
|
||||||
|
|||||||
341
services/data-persistence-service/src/db.rs
Normal file
341
services/data-persistence-service/src/db.rs
Normal file
@ -0,0 +1,341 @@
|
|||||||
|
// This module contains all the database interaction logic,
|
||||||
|
// using `sqlx` to query the PostgreSQL database.
|
||||||
|
//
|
||||||
|
// Functions in this module will be called by the API handlers
|
||||||
|
// to fetch or store data.
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
dtos::{CompanyProfileDto, DailyMarketDataDto, NewAnalysisResultDto, TimeSeriesFinancialDto, RealtimeQuoteDto},
|
||||||
|
models::{AnalysisResult, CompanyProfile, DailyMarketData, TimeSeriesFinancial, RealtimeQuote},
|
||||||
|
};
|
||||||
|
use anyhow::Result;
|
||||||
|
use sqlx::PgPool;
|
||||||
|
use rust_decimal::Decimal;
|
||||||
|
use chrono::NaiveDate;
|
||||||
|
use tracing::info;
|
||||||
|
|
||||||
|
/// Upserts a company profile into the database.
|
||||||
|
/// If a company with the same symbol already exists, it will be updated.
|
||||||
|
/// Otherwise, a new record will be inserted.
|
||||||
|
pub async fn upsert_company(pool: &PgPool, company: &CompanyProfileDto) -> Result<()> {
|
||||||
|
info!(target: "db", symbol = %company.symbol, "DB upsert_company started");
|
||||||
|
sqlx::query!(
|
||||||
|
r#"
|
||||||
|
INSERT INTO company_profiles (symbol, name, industry, list_date, additional_info, updated_at)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, NOW())
|
||||||
|
ON CONFLICT (symbol) DO UPDATE SET
|
||||||
|
name = EXCLUDED.name,
|
||||||
|
industry = EXCLUDED.industry,
|
||||||
|
list_date = EXCLUDED.list_date,
|
||||||
|
additional_info = EXCLUDED.additional_info,
|
||||||
|
updated_at = NOW()
|
||||||
|
"#,
|
||||||
|
company.symbol,
|
||||||
|
company.name,
|
||||||
|
company.industry,
|
||||||
|
company.list_date,
|
||||||
|
company.additional_info,
|
||||||
|
)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
info!(target: "db", symbol = %company.symbol, "DB upsert_company finished");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetches a single company profile by its symbol.
|
||||||
|
pub async fn get_company_by_symbol(pool: &PgPool, symbol: &str) -> Result<Option<CompanyProfile>> {
|
||||||
|
info!(target: "db", symbol = %symbol, "DB get_company_by_symbol started");
|
||||||
|
let company = sqlx::query_as!(
|
||||||
|
CompanyProfile,
|
||||||
|
r#"
|
||||||
|
SELECT symbol, name, industry, list_date, additional_info, updated_at
|
||||||
|
FROM company_profiles
|
||||||
|
WHERE symbol = $1
|
||||||
|
"#,
|
||||||
|
symbol
|
||||||
|
)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
info!(target: "db", symbol = %symbol, found = company.is_some(), "DB get_company_by_symbol finished");
|
||||||
|
Ok(company)
|
||||||
|
}
|
||||||
|
|
||||||
|
// =================================================================================
|
||||||
|
// Market Data Functions (Task T3.2)
|
||||||
|
// =================================================================================
|
||||||
|
|
||||||
|
pub async fn batch_insert_financials(pool: &PgPool, financials: &[TimeSeriesFinancialDto]) -> Result<()> {
|
||||||
|
info!(target: "db", count = financials.len(), "DB batch_insert_financials started");
|
||||||
|
// Note: This is a simple iterative approach. For very high throughput,
|
||||||
|
// a single COPY statement or sqlx's `copy` module would be more performant.
|
||||||
|
for financial in financials {
|
||||||
|
sqlx::query!(
|
||||||
|
r#"
|
||||||
|
INSERT INTO time_series_financials (symbol, metric_name, period_date, value, source)
|
||||||
|
VALUES ($1, $2, $3, $4, $5)
|
||||||
|
ON CONFLICT (symbol, metric_name, period_date) DO UPDATE SET
|
||||||
|
value = EXCLUDED.value,
|
||||||
|
source = EXCLUDED.source
|
||||||
|
"#,
|
||||||
|
financial.symbol,
|
||||||
|
financial.metric_name,
|
||||||
|
financial.period_date,
|
||||||
|
Decimal::from_f64_retain(financial.value).expect("invalid decimal conversion from f64"), // Convert f64 to Decimal
|
||||||
|
financial.source,
|
||||||
|
)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
info!(target: "db", count = financials.len(), "DB batch_insert_financials finished");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_financials_by_symbol(
|
||||||
|
pool: &PgPool,
|
||||||
|
symbol: &str,
|
||||||
|
metrics: Option<Vec<String>>,
|
||||||
|
) -> Result<Vec<TimeSeriesFinancial>> {
|
||||||
|
info!(target: "db", symbol = %symbol, has_metrics = metrics.as_ref().map(|m| !m.is_empty()).unwrap_or(false), "DB get_financials_by_symbol started");
|
||||||
|
let results = if let Some(metrics) = metrics {
|
||||||
|
sqlx::query_as!(
|
||||||
|
TimeSeriesFinancial,
|
||||||
|
r#"
|
||||||
|
SELECT symbol, metric_name, period_date, value, source
|
||||||
|
FROM time_series_financials
|
||||||
|
WHERE symbol = $1 AND metric_name = ANY($2)
|
||||||
|
ORDER BY period_date DESC
|
||||||
|
"#,
|
||||||
|
symbol,
|
||||||
|
&metrics
|
||||||
|
)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await?
|
||||||
|
} else {
|
||||||
|
sqlx::query_as!(
|
||||||
|
TimeSeriesFinancial,
|
||||||
|
r#"
|
||||||
|
SELECT symbol, metric_name, period_date, value, source
|
||||||
|
FROM time_series_financials
|
||||||
|
WHERE symbol = $1
|
||||||
|
ORDER BY period_date DESC
|
||||||
|
"#,
|
||||||
|
symbol
|
||||||
|
)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await?
|
||||||
|
};
|
||||||
|
info!(target: "db", symbol = %symbol, items = results.len(), "DB get_financials_by_symbol finished");
|
||||||
|
Ok(results)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn batch_insert_daily_data(pool: &PgPool, daily_data: &[DailyMarketDataDto]) -> Result<()> {
|
||||||
|
info!(target: "db", count = daily_data.len(), "DB batch_insert_daily_data started");
|
||||||
|
for data in daily_data {
|
||||||
|
sqlx::query!(
|
||||||
|
r#"
|
||||||
|
INSERT INTO daily_market_data (symbol, trade_date, open_price, high_price, low_price, close_price, volume, pe, pb, total_mv)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||||
|
ON CONFLICT (symbol, trade_date) DO UPDATE SET
|
||||||
|
open_price = EXCLUDED.open_price,
|
||||||
|
high_price = EXCLUDED.high_price,
|
||||||
|
low_price = EXCLUDED.low_price,
|
||||||
|
close_price = EXCLUDED.close_price,
|
||||||
|
volume = EXCLUDED.volume,
|
||||||
|
pe = EXCLUDED.pe,
|
||||||
|
pb = EXCLUDED.pb,
|
||||||
|
total_mv = EXCLUDED.total_mv
|
||||||
|
"#,
|
||||||
|
data.symbol,
|
||||||
|
data.trade_date,
|
||||||
|
data.open_price.and_then(Decimal::from_f64_retain),
|
||||||
|
data.high_price.and_then(Decimal::from_f64_retain),
|
||||||
|
data.low_price.and_then(Decimal::from_f64_retain),
|
||||||
|
data.close_price.and_then(Decimal::from_f64_retain),
|
||||||
|
data.volume,
|
||||||
|
data.pe.and_then(Decimal::from_f64_retain),
|
||||||
|
data.pb.and_then(Decimal::from_f64_retain),
|
||||||
|
data.total_mv.and_then(Decimal::from_f64_retain),
|
||||||
|
)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
info!(target: "db", count = daily_data.len(), "DB batch_insert_daily_data finished");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_daily_data_by_symbol(
|
||||||
|
pool: &PgPool,
|
||||||
|
symbol: &str,
|
||||||
|
start_date: Option<NaiveDate>,
|
||||||
|
end_date: Option<NaiveDate>,
|
||||||
|
) -> Result<Vec<DailyMarketData>> {
|
||||||
|
// This query is simplified. A real-world scenario might need more complex date filtering.
|
||||||
|
info!(target: "db", symbol = %symbol, start = ?start_date, end = ?end_date, "DB get_daily_data_by_symbol started");
|
||||||
|
let daily_data = sqlx::query_as!(
|
||||||
|
DailyMarketData,
|
||||||
|
r#"
|
||||||
|
SELECT symbol, trade_date, open_price, high_price, low_price, close_price, volume, pe, pb, total_mv
|
||||||
|
FROM daily_market_data
|
||||||
|
WHERE symbol = $1
|
||||||
|
AND ($2::DATE IS NULL OR trade_date >= $2)
|
||||||
|
AND ($3::DATE IS NULL OR trade_date <= $3)
|
||||||
|
ORDER BY trade_date DESC
|
||||||
|
"#,
|
||||||
|
symbol,
|
||||||
|
start_date,
|
||||||
|
end_date
|
||||||
|
)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
info!(target: "db", symbol = %symbol, items = daily_data.len(), "DB get_daily_data_by_symbol finished");
|
||||||
|
Ok(daily_data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// =================================================================================
|
||||||
|
// Realtime Quotes Functions
|
||||||
|
// =================================================================================
|
||||||
|
|
||||||
|
pub async fn insert_realtime_quote(pool: &PgPool, quote: &RealtimeQuoteDto) -> Result<()> {
|
||||||
|
info!(target: "db", symbol = %quote.symbol, market = %quote.market, ts = %quote.ts, "DB insert_realtime_quote started");
|
||||||
|
sqlx::query!(
|
||||||
|
r#"
|
||||||
|
INSERT INTO realtime_quotes (
|
||||||
|
symbol, market, ts, price, open_price, high_price, low_price, prev_close, change, change_percent, volume, source, updated_at
|
||||||
|
) VALUES (
|
||||||
|
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, NOW()
|
||||||
|
)
|
||||||
|
ON CONFLICT (symbol, market, ts) DO UPDATE SET
|
||||||
|
price = EXCLUDED.price,
|
||||||
|
open_price = EXCLUDED.open_price,
|
||||||
|
high_price = EXCLUDED.high_price,
|
||||||
|
low_price = EXCLUDED.low_price,
|
||||||
|
prev_close = EXCLUDED.prev_close,
|
||||||
|
change = EXCLUDED.change,
|
||||||
|
change_percent = EXCLUDED.change_percent,
|
||||||
|
volume = EXCLUDED.volume,
|
||||||
|
source = EXCLUDED.source,
|
||||||
|
updated_at = NOW()
|
||||||
|
"#,
|
||||||
|
quote.symbol,
|
||||||
|
quote.market,
|
||||||
|
quote.ts,
|
||||||
|
Decimal::from_f64_retain(quote.price).expect("invalid price"),
|
||||||
|
quote.open_price.and_then(Decimal::from_f64_retain),
|
||||||
|
quote.high_price.and_then(Decimal::from_f64_retain),
|
||||||
|
quote.low_price.and_then(Decimal::from_f64_retain),
|
||||||
|
quote.prev_close.and_then(Decimal::from_f64_retain),
|
||||||
|
quote.change.and_then(Decimal::from_f64_retain),
|
||||||
|
quote.change_percent.and_then(Decimal::from_f64_retain),
|
||||||
|
quote.volume,
|
||||||
|
quote.source.as_deref(),
|
||||||
|
)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
info!(target: "db", symbol = %quote.symbol, market = %quote.market, "DB insert_realtime_quote finished");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_latest_realtime_quote(pool: &PgPool, market: &str, symbol: &str) -> Result<Option<RealtimeQuote>> {
|
||||||
|
info!(target: "db", symbol = %symbol, market = %market, "DB get_latest_realtime_quote started");
|
||||||
|
let rec = sqlx::query_as!(
|
||||||
|
RealtimeQuote,
|
||||||
|
r#"
|
||||||
|
SELECT symbol, market, ts, price, open_price, high_price, low_price, prev_close, change, change_percent, volume, source, updated_at
|
||||||
|
FROM realtime_quotes
|
||||||
|
WHERE symbol = $1 AND market = $2
|
||||||
|
ORDER BY ts DESC
|
||||||
|
LIMIT 1
|
||||||
|
"#,
|
||||||
|
symbol,
|
||||||
|
market
|
||||||
|
)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await?;
|
||||||
|
info!(target: "db", symbol = %symbol, market = %market, found = rec.is_some(), "DB get_latest_realtime_quote finished");
|
||||||
|
Ok(rec)
|
||||||
|
}
|
||||||
|
|
||||||
|
// =================================================================================
|
||||||
|
// Analysis Results Functions (Task T3.3)
|
||||||
|
// =================================================================================
|
||||||
|
|
||||||
|
pub async fn create_analysis_result(pool: &PgPool, result: &NewAnalysisResultDto) -> Result<AnalysisResult> {
|
||||||
|
info!(target: "db", symbol = %result.symbol, module_id = %result.module_id, "DB create_analysis_result started");
|
||||||
|
let new_result = sqlx::query_as!(
|
||||||
|
AnalysisResult,
|
||||||
|
r#"
|
||||||
|
INSERT INTO analysis_results (symbol, module_id, model_name, content, meta_data)
|
||||||
|
VALUES ($1, $2, $3, $4, $5)
|
||||||
|
RETURNING id, symbol, module_id, generated_at, model_name, content, meta_data
|
||||||
|
"#,
|
||||||
|
result.symbol,
|
||||||
|
result.module_id,
|
||||||
|
result.model_name,
|
||||||
|
result.content,
|
||||||
|
result.meta_data,
|
||||||
|
)
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
info!(target: "db", id = %new_result.id, symbol = %new_result.symbol, module_id = %new_result.module_id, "DB create_analysis_result finished");
|
||||||
|
Ok(new_result)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_analysis_results(
|
||||||
|
pool: &PgPool,
|
||||||
|
symbol: &str,
|
||||||
|
module_id: Option<&str>,
|
||||||
|
) -> Result<Vec<AnalysisResult>> {
|
||||||
|
info!(target: "db", symbol = %symbol, module = ?module_id, "DB get_analysis_results started");
|
||||||
|
let results = if let Some(module) = module_id {
|
||||||
|
sqlx::query_as!(
|
||||||
|
AnalysisResult,
|
||||||
|
r#"
|
||||||
|
SELECT id, symbol, module_id, generated_at, model_name, content, meta_data
|
||||||
|
FROM analysis_results
|
||||||
|
WHERE symbol = $1 AND module_id = $2
|
||||||
|
ORDER BY generated_at DESC
|
||||||
|
"#,
|
||||||
|
symbol,
|
||||||
|
module
|
||||||
|
)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await?
|
||||||
|
} else {
|
||||||
|
sqlx::query_as!(
|
||||||
|
AnalysisResult,
|
||||||
|
r#"
|
||||||
|
SELECT id, symbol, module_id, generated_at, model_name, content, meta_data
|
||||||
|
FROM analysis_results
|
||||||
|
WHERE symbol = $1
|
||||||
|
ORDER BY generated_at DESC
|
||||||
|
"#,
|
||||||
|
symbol
|
||||||
|
)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await?
|
||||||
|
};
|
||||||
|
info!(target: "db", symbol = %symbol, items = results.len(), "DB get_analysis_results finished");
|
||||||
|
Ok(results)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_analysis_result_by_id(pool: &PgPool, id: uuid::Uuid) -> Result<Option<AnalysisResult>> {
|
||||||
|
info!(target: "db", id = %id, "DB get_analysis_result_by_id started");
|
||||||
|
let result = sqlx::query_as!(
|
||||||
|
AnalysisResult,
|
||||||
|
r#"
|
||||||
|
SELECT id, symbol, module_id, generated_at, model_name, content, meta_data
|
||||||
|
FROM analysis_results
|
||||||
|
WHERE id = $1
|
||||||
|
"#,
|
||||||
|
id
|
||||||
|
)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
info!(target: "db", id = %id, found = result.is_some(), "DB get_analysis_result_by_id finished");
|
||||||
|
Ok(result)
|
||||||
|
}
|
||||||
@ -1,7 +0,0 @@
|
|||||||
// This module contains all the database interaction logic,
|
|
||||||
// using `sqlx` to query the PostgreSQL database.
|
|
||||||
//
|
|
||||||
// Functions in this module will be called by the API handlers
|
|
||||||
// to fetch or store data.
|
|
||||||
|
|
||||||
pub mod system_config;
|
|
||||||
@ -1,63 +0,0 @@
|
|||||||
use anyhow::{Context, Result};
|
|
||||||
use serde::{de::DeserializeOwned, Serialize};
|
|
||||||
use sqlx::{Row, PgPool};
|
|
||||||
|
|
||||||
/// Fetches a configuration JSON from the `system_config` table
|
|
||||||
/// and deserializes it into the specified generic type `T`.
|
|
||||||
/// If the key does not exist, it returns a default `T`.
|
|
||||||
pub async fn get_config<T>(pool: &PgPool, key: &str) -> Result<T>
|
|
||||||
where
|
|
||||||
T: DeserializeOwned + Default,
|
|
||||||
{
|
|
||||||
let row_opt = sqlx::query("SELECT config_value FROM system_config WHERE config_key = $1")
|
|
||||||
.bind(key)
|
|
||||||
.fetch_optional(pool)
|
|
||||||
.await
|
|
||||||
.context("Failed to fetch from system_config")?;
|
|
||||||
|
|
||||||
if let Some(row) = row_opt {
|
|
||||||
// Postgres JSON/JSONB maps to serde_json::Value
|
|
||||||
let value: serde_json::Value = row
|
|
||||||
.try_get("config_value")
|
|
||||||
.context("Missing column 'config_value'")?;
|
|
||||||
let config: T = serde_json::from_value(value)
|
|
||||||
.context("Failed to deserialize config_value from database")?;
|
|
||||||
Ok(config)
|
|
||||||
} else {
|
|
||||||
Ok(T::default())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Serializes the provided configuration struct `T` into JSON
|
|
||||||
/// and upserts it into the `system_config` table for the given key.
|
|
||||||
pub async fn update_config<T>(pool: &PgPool, key: &str, config: &T) -> Result<T>
|
|
||||||
where
|
|
||||||
T: Serialize + DeserializeOwned,
|
|
||||||
{
|
|
||||||
let json_value = serde_json::to_value(config)
|
|
||||||
.context("Failed to serialize config to JSON value")?;
|
|
||||||
|
|
||||||
let row = sqlx::query(
|
|
||||||
r#"
|
|
||||||
INSERT INTO system_config (config_key, config_value, updated_at)
|
|
||||||
VALUES ($1, $2, NOW())
|
|
||||||
ON CONFLICT (config_key) DO UPDATE SET
|
|
||||||
config_value = EXCLUDED.config_value,
|
|
||||||
updated_at = NOW()
|
|
||||||
RETURNING config_value
|
|
||||||
"#,
|
|
||||||
)
|
|
||||||
.bind(key)
|
|
||||||
.bind(json_value)
|
|
||||||
.fetch_one(pool)
|
|
||||||
.await
|
|
||||||
.context("Failed to upsert into system_config")?;
|
|
||||||
|
|
||||||
let value: serde_json::Value = row
|
|
||||||
.try_get("config_value")
|
|
||||||
.context("Missing returned column 'config_value'")?;
|
|
||||||
let result: T = serde_json::from_value(value)
|
|
||||||
.context("Failed to deserialize updated config_value from database")?;
|
|
||||||
|
|
||||||
Ok(result)
|
|
||||||
}
|
|
||||||
@ -2,9 +2,20 @@
|
|||||||
FROM rust:1.90 as builder
|
FROM rust:1.90 as builder
|
||||||
|
|
||||||
WORKDIR /usr/src/app
|
WORKDIR /usr/src/app
|
||||||
# Copy full sources (simple and correct; avoids shipping stub binaries)
|
|
||||||
|
# Pre-build dependencies to leverage Docker layer caching
|
||||||
COPY ./services/common-contracts /usr/src/app/services/common-contracts
|
COPY ./services/common-contracts /usr/src/app/services/common-contracts
|
||||||
|
COPY ./services/finnhub-provider-service/Cargo.toml ./services/finnhub-provider-service/Cargo.lock* ./services/finnhub-provider-service/
|
||||||
|
|
||||||
|
WORKDIR /usr/src/app/services/finnhub-provider-service
|
||||||
|
RUN mkdir -p src && \
|
||||||
|
echo "fn main() {}" > src/main.rs && \
|
||||||
|
cargo build --release --bin finnhub-provider-service
|
||||||
|
|
||||||
|
# Copy the full source code
|
||||||
COPY ./services/finnhub-provider-service /usr/src/app/services/finnhub-provider-service
|
COPY ./services/finnhub-provider-service /usr/src/app/services/finnhub-provider-service
|
||||||
|
|
||||||
|
# Build the application
|
||||||
WORKDIR /usr/src/app/services/finnhub-provider-service
|
WORKDIR /usr/src/app/services/finnhub-provider-service
|
||||||
RUN cargo build --release --bin finnhub-provider-service
|
RUN cargo build --release --bin finnhub-provider-service
|
||||||
|
|
||||||
@ -14,8 +25,6 @@ FROM debian:bookworm-slim
|
|||||||
# Set timezone
|
# Set timezone
|
||||||
ENV TZ=Asia/Shanghai
|
ENV TZ=Asia/Shanghai
|
||||||
RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone
|
RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone
|
||||||
# Minimal runtime deps for health checks (curl) and TLS roots if needed
|
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates curl && rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
# Copy the built binary from the builder stage
|
# Copy the built binary from the builder stage
|
||||||
COPY --from=builder /usr/src/app/services/finnhub-provider-service/target/release/finnhub-provider-service /usr/local/bin/
|
COPY --from=builder /usr/src/app/services/finnhub-provider-service/target/release/finnhub-provider-service /usr/local/bin/
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
use secrecy::{ExposeSecret, SecretString};
|
use secrecy::SecretString;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, Clone)]
|
#[derive(Debug, Deserialize, Clone)]
|
||||||
@ -12,39 +12,10 @@ pub struct AppConfig {
|
|||||||
|
|
||||||
impl AppConfig {
|
impl AppConfig {
|
||||||
pub fn load() -> Result<Self, config::ConfigError> {
|
pub fn load() -> Result<Self, config::ConfigError> {
|
||||||
let cfg = config::Config::builder()
|
let config = config::Config::builder()
|
||||||
.add_source(config::Environment::default().separator("__"))
|
.add_source(config::Environment::default().separator("__"))
|
||||||
.build()?;
|
.build()?;
|
||||||
|
|
||||||
let cfg: Self = cfg.try_deserialize()?;
|
config.try_deserialize()
|
||||||
|
|
||||||
// Deterministic validation without fallback
|
|
||||||
if cfg.server_port == 0 {
|
|
||||||
return Err(config::ConfigError::Message(
|
|
||||||
"SERVER_PORT must be > 0".to_string(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
if cfg.nats_addr.trim().is_empty() {
|
|
||||||
return Err(config::ConfigError::Message(
|
|
||||||
"NATS_ADDR must not be empty".to_string(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
if cfg.data_persistence_service_url.trim().is_empty() {
|
|
||||||
return Err(config::ConfigError::Message(
|
|
||||||
"DATA_PERSISTENCE_SERVICE_URL must not be empty".to_string(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
if cfg.finnhub_api_url.trim().is_empty() {
|
|
||||||
return Err(config::ConfigError::Message(
|
|
||||||
"FINNHUB_API_URL must not be empty".to_string(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
if cfg.finnhub_api_key.expose_secret().trim().is_empty() {
|
|
||||||
return Err(config::ConfigError::Message(
|
|
||||||
"FINNHUB_API_KEY must not be empty".to_string(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(cfg)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,9 +2,20 @@
|
|||||||
FROM rust:1.90 as builder
|
FROM rust:1.90 as builder
|
||||||
|
|
||||||
WORKDIR /usr/src/app
|
WORKDIR /usr/src/app
|
||||||
# Copy full sources (simple and correct; avoids shipping stub binaries)
|
|
||||||
|
# Pre-build dependencies to leverage Docker layer caching
|
||||||
COPY ./services/common-contracts /usr/src/app/services/common-contracts
|
COPY ./services/common-contracts /usr/src/app/services/common-contracts
|
||||||
|
COPY ./services/report-generator-service/Cargo.toml ./services/report-generator-service/Cargo.lock* ./services/report-generator-service/
|
||||||
|
|
||||||
|
WORKDIR /usr/src/app/services/report-generator-service
|
||||||
|
RUN mkdir -p src && \
|
||||||
|
echo "fn main() {}" > src/main.rs && \
|
||||||
|
cargo build --release --bin report-generator-service
|
||||||
|
|
||||||
|
# Copy the full source code
|
||||||
COPY ./services/report-generator-service /usr/src/app/services/report-generator-service
|
COPY ./services/report-generator-service /usr/src/app/services/report-generator-service
|
||||||
|
|
||||||
|
# Build the application
|
||||||
WORKDIR /usr/src/app/services/report-generator-service
|
WORKDIR /usr/src/app/services/report-generator-service
|
||||||
RUN cargo build --release --bin report-generator-service
|
RUN cargo build --release --bin report-generator-service
|
||||||
|
|
||||||
@ -14,8 +25,6 @@ FROM debian:bookworm-slim
|
|||||||
# Set timezone
|
# Set timezone
|
||||||
ENV TZ=Asia/Shanghai
|
ENV TZ=Asia/Shanghai
|
||||||
RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone
|
RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone
|
||||||
# Minimal runtime deps for health checks (curl) and TLS roots if needed
|
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates curl && rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
# Copy the built binary from the builder stage
|
# Copy the built binary from the builder stage
|
||||||
COPY --from=builder /usr/src/app/services/report-generator-service/target/release/report-generator-service /usr/local/bin/
|
COPY --from=builder /usr/src/app/services/report-generator-service/target/release/report-generator-service /usr/local/bin/
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
use secrecy::SecretString;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, Clone)]
|
#[derive(Debug, Deserialize, Clone)]
|
||||||
@ -5,16 +6,15 @@ pub struct AppConfig {
|
|||||||
pub server_port: u16,
|
pub server_port: u16,
|
||||||
pub nats_addr: String,
|
pub nats_addr: String,
|
||||||
pub data_persistence_service_url: String,
|
pub data_persistence_service_url: String,
|
||||||
|
pub llm_api_url: String,
|
||||||
|
pub llm_api_key: SecretString,
|
||||||
|
pub llm_model: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AppConfig {
|
impl AppConfig {
|
||||||
pub fn load() -> Result<Self, config::ConfigError> {
|
pub fn load() -> Result<Self, config::ConfigError> {
|
||||||
let config = config::Config::builder()
|
let config = config::Config::builder()
|
||||||
.add_source(
|
.add_source(config::Environment::default().separator("__"))
|
||||||
config::Environment::default()
|
|
||||||
.separator("__")
|
|
||||||
.ignore_empty(true),
|
|
||||||
)
|
|
||||||
.build()?;
|
.build()?;
|
||||||
|
|
||||||
config.try_deserialize()
|
config.try_deserialize()
|
||||||
|
|||||||
@ -6,7 +6,6 @@
|
|||||||
|
|
||||||
use crate::error::Result;
|
use crate::error::Result;
|
||||||
use common_contracts::{
|
use common_contracts::{
|
||||||
config_models::{AnalysisModulesConfig, LlmProvidersConfig},
|
|
||||||
dtos::{CompanyProfileDto, RealtimeQuoteDto, TimeSeriesFinancialBatchDto, TimeSeriesFinancialDto},
|
dtos::{CompanyProfileDto, RealtimeQuoteDto, TimeSeriesFinancialBatchDto, TimeSeriesFinancialDto},
|
||||||
};
|
};
|
||||||
use tracing::info;
|
use tracing::info;
|
||||||
@ -56,36 +55,6 @@ impl PersistenceClient {
|
|||||||
Ok(dtos)
|
Ok(dtos)
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Config Fetching Methods ---
|
|
||||||
|
|
||||||
pub async fn get_llm_providers_config(&self) -> Result<LlmProvidersConfig> {
|
|
||||||
let url = format!("{}/configs/llm_providers", self.base_url);
|
|
||||||
info!("Fetching LLM providers config from {}", url);
|
|
||||||
let config = self
|
|
||||||
.client
|
|
||||||
.get(&url)
|
|
||||||
.send()
|
|
||||||
.await?
|
|
||||||
.error_for_status()?
|
|
||||||
.json::<LlmProvidersConfig>()
|
|
||||||
.await?;
|
|
||||||
Ok(config)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn get_analysis_modules_config(&self) -> Result<AnalysisModulesConfig> {
|
|
||||||
let url = format!("{}/configs/analysis_modules", self.base_url);
|
|
||||||
info!("Fetching analysis modules config from {}", url);
|
|
||||||
let config = self
|
|
||||||
.client
|
|
||||||
.get(&url)
|
|
||||||
.send()
|
|
||||||
.await?
|
|
||||||
.error_for_status()?
|
|
||||||
.json::<AnalysisModulesConfig>()
|
|
||||||
.await?;
|
|
||||||
Ok(config)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn upsert_company_profile(&self, profile: CompanyProfileDto) -> Result<()> {
|
pub async fn upsert_company_profile(&self, profile: CompanyProfileDto) -> Result<()> {
|
||||||
let url = format!("{}/companies", self.base_url);
|
let url = format!("{}/companies", self.base_url);
|
||||||
info!("Upserting company profile for {} to {}", profile.symbol, url);
|
info!("Upserting company profile for {} to {}", profile.symbol, url);
|
||||||
|
|||||||
@ -6,20 +6,28 @@ use uuid::Uuid;
|
|||||||
|
|
||||||
use common_contracts::observability::TaskProgress;
|
use common_contracts::observability::TaskProgress;
|
||||||
|
|
||||||
use crate::{config::AppConfig, templates::load_tera};
|
use crate::{config::AppConfig, llm_client::LlmClient, templates::load_tera};
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct AppState {
|
pub struct AppState {
|
||||||
pub tasks: Arc<DashMap<Uuid, TaskProgress>>,
|
pub tasks: Arc<DashMap<Uuid, TaskProgress>>,
|
||||||
pub config: Arc<AppConfig>,
|
pub config: Arc<AppConfig>,
|
||||||
|
pub llm_client: Arc<LlmClient>,
|
||||||
pub tera: Arc<Tera>,
|
pub tera: Arc<Tera>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AppState {
|
impl AppState {
|
||||||
pub fn new(config: AppConfig) -> Self {
|
pub fn new(config: AppConfig) -> Self {
|
||||||
|
let llm_client = Arc::new(LlmClient::new(
|
||||||
|
config.llm_api_url.clone(),
|
||||||
|
config.llm_api_key.clone(),
|
||||||
|
config.llm_model.clone(),
|
||||||
|
));
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
tasks: Arc::new(DashMap::new()),
|
tasks: Arc::new(DashMap::new()),
|
||||||
config: Arc::new(config),
|
config: Arc::new(config),
|
||||||
|
llm_client,
|
||||||
tera: Arc::new(load_tera()),
|
tera: Arc::new(load_tera()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,128 +1,70 @@
|
|||||||
use std::collections::HashMap;
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use common_contracts::config_models::{AnalysisModuleConfig, AnalysisModulesConfig, LlmProvider, LlmProvidersConfig};
|
|
||||||
use common_contracts::dtos::{CompanyProfileDto, TimeSeriesFinancialDto};
|
use common_contracts::dtos::{CompanyProfileDto, TimeSeriesFinancialDto};
|
||||||
use common_contracts::messages::FinancialsPersistedEvent;
|
use common_contracts::messages::FinancialsPersistedEvent;
|
||||||
use tera::{Context, Tera};
|
use tera::Context;
|
||||||
use tracing::{info, warn, instrument};
|
use tracing::{info, warn};
|
||||||
|
|
||||||
use crate::error::{ProviderError, Result};
|
use crate::error::{ProviderError, Result};
|
||||||
use crate::llm_client::LlmClient;
|
|
||||||
use crate::persistence::PersistenceClient;
|
use crate::persistence::PersistenceClient;
|
||||||
use crate::state::AppState;
|
use crate::state::AppState;
|
||||||
use crate::templates::render_prompt;
|
use crate::templates::render_prompt;
|
||||||
|
|
||||||
#[instrument(skip_all, fields(symbol = %event.symbol))]
|
|
||||||
pub async fn run_report_generation_workflow(
|
pub async fn run_report_generation_workflow(
|
||||||
state: Arc<AppState>,
|
state: Arc<AppState>,
|
||||||
event: FinancialsPersistedEvent,
|
event: FinancialsPersistedEvent,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
info!("Starting report generation workflow.");
|
info!(
|
||||||
|
"Starting report generation workflow for symbol: {}",
|
||||||
|
event.symbol
|
||||||
|
);
|
||||||
|
|
||||||
let persistence_client =
|
let persistence_client =
|
||||||
PersistenceClient::new(state.config.data_persistence_service_url.clone());
|
PersistenceClient::new(state.config.data_persistence_service_url.clone());
|
||||||
|
|
||||||
// 1. Fetch all necessary data AND configurations in parallel
|
// 1. Fetch all necessary data from the persistence service
|
||||||
let (profile, financials, llm_providers, analysis_modules) =
|
let (profile, financials) = fetch_data(&persistence_client, &event.symbol).await?;
|
||||||
fetch_data_and_configs(&persistence_client, &event.symbol).await?;
|
|
||||||
|
|
||||||
if financials.is_empty() {
|
if financials.is_empty() {
|
||||||
warn!("No financial data found. Aborting report generation.");
|
warn!(
|
||||||
|
"No financial data found for symbol: {}. Aborting report generation.",
|
||||||
|
event.symbol
|
||||||
|
);
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- New: Dynamic, Multi-Module Workflow ---
|
// 2. Create context and render the prompt
|
||||||
let mut generated_results: HashMap<String, String> = HashMap::new();
|
|
||||||
|
|
||||||
// Naive sequential execution based on dependencies. A proper topological sort would be better.
|
|
||||||
// For now, we just iterate multiple times to resolve dependencies.
|
|
||||||
for _ in 0..analysis_modules.len() {
|
|
||||||
for (module_id, module_config) in &analysis_modules {
|
|
||||||
if generated_results.contains_key(module_id.as_str()) {
|
|
||||||
continue; // Already generated
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if all dependencies are met
|
|
||||||
let deps_met = module_config.dependencies.iter().all(|dep| generated_results.contains_key(dep));
|
|
||||||
if !deps_met {
|
|
||||||
continue; // Will try again in the next iteration
|
|
||||||
}
|
|
||||||
|
|
||||||
info!(module_id = %module_id, "All dependencies met. Generating report for module.");
|
|
||||||
|
|
||||||
// 2. Dynamically create LLM client for this module
|
|
||||||
let llm_client = create_llm_client_for_module(&state, &llm_providers, module_config)?;
|
|
||||||
|
|
||||||
// 3. Create context and render the prompt
|
|
||||||
let mut context = Context::new();
|
let mut context = Context::new();
|
||||||
context.insert("company_name", &profile.name);
|
context.insert("name", &profile.name);
|
||||||
context.insert("ts_code", &event.symbol);
|
context.insert("industry", &profile.industry);
|
||||||
// Inject dependencies into context
|
context.insert("list_date", &profile.list_date.map(|d| d.to_string()));
|
||||||
for dep in &module_config.dependencies {
|
context.insert("records_count", &financials.len());
|
||||||
if let Some(content) = generated_results.get(dep) {
|
|
||||||
context.insert(dep, content);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// A placeholder for financial data, can be expanded
|
|
||||||
context.insert("financial_data", "...");
|
|
||||||
|
|
||||||
let prompt = Tera::one_off(&module_config.prompt_template, &context, true)
|
let prompt = render_prompt(&state.tera, "company_profile_summary", &context)
|
||||||
.map_err(|e| ProviderError::Internal(anyhow::anyhow!("Prompt rendering failed for module '{}': {}", module_id, e)))?;
|
.map_err(|e| ProviderError::Internal(anyhow::anyhow!("Prompt rendering failed: {}", e)))?;
|
||||||
|
|
||||||
// 4. Call the LLM to generate the content for this module
|
// 3. Call the LLM to generate the summary
|
||||||
let content = llm_client.generate_text(prompt).await?;
|
info!("Generating summary for symbol: {}", event.symbol);
|
||||||
info!(module_id = %module_id, "Successfully generated content.");
|
let summary = state.llm_client.generate_text(prompt).await?;
|
||||||
|
|
||||||
// TODO: Persist the generated result via persistence_client
|
// 4. Persist the generated report (future work)
|
||||||
|
info!(
|
||||||
|
"Successfully generated report for symbol: {} ({} records)",
|
||||||
|
event.symbol,
|
||||||
|
financials.len()
|
||||||
|
);
|
||||||
|
info!("Generated Summary: {}", summary);
|
||||||
|
|
||||||
generated_results.insert(module_id.clone(), content);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if generated_results.len() != analysis_modules.len() {
|
|
||||||
warn!("Could not generate all modules due to missing dependencies or circular dependency.");
|
|
||||||
}
|
|
||||||
|
|
||||||
info!("Report generation workflow finished.");
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn create_llm_client_for_module(
|
async fn fetch_data(
|
||||||
state: &Arc<AppState>,
|
|
||||||
llm_providers: &LlmProvidersConfig,
|
|
||||||
module_config: &AnalysisModuleConfig,
|
|
||||||
) -> Result<LlmClient> {
|
|
||||||
let provider = llm_providers.get(&module_config.provider_id).ok_or_else(|| {
|
|
||||||
ProviderError::Configuration(format!(
|
|
||||||
"Provider '{}' not found in llm_providers config",
|
|
||||||
module_config.provider_id
|
|
||||||
))
|
|
||||||
})?;
|
|
||||||
|
|
||||||
// In the old design, the api key name was stored. In the new design, it's stored directly.
|
|
||||||
let api_key = provider.api_key.clone();
|
|
||||||
|
|
||||||
Ok(LlmClient::new(
|
|
||||||
provider.api_base_url.clone(),
|
|
||||||
api_key.into(), // Convert String to SecretString
|
|
||||||
module_config.model_id.clone(),
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn fetch_data_and_configs(
|
|
||||||
client: &PersistenceClient,
|
client: &PersistenceClient,
|
||||||
symbol: &str,
|
symbol: &str,
|
||||||
) -> Result<(
|
) -> Result<(CompanyProfileDto, Vec<TimeSeriesFinancialDto>)> {
|
||||||
CompanyProfileDto,
|
let (profile, financials) = tokio::try_join!(
|
||||||
Vec<TimeSeriesFinancialDto>,
|
|
||||||
LlmProvidersConfig,
|
|
||||||
AnalysisModulesConfig,
|
|
||||||
)> {
|
|
||||||
let (profile, financials, llm_providers, analysis_modules) = tokio::try_join!(
|
|
||||||
client.get_company_profile(symbol),
|
client.get_company_profile(symbol),
|
||||||
client.get_financial_statements(symbol),
|
client.get_financial_statements(symbol)
|
||||||
client.get_llm_providers_config(),
|
|
||||||
client.get_analysis_modules_config(),
|
|
||||||
)?;
|
)?;
|
||||||
Ok((profile, financials, llm_providers, analysis_modules))
|
Ok((profile, financials))
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,9 +2,20 @@
|
|||||||
FROM rust:1.90 as builder
|
FROM rust:1.90 as builder
|
||||||
|
|
||||||
WORKDIR /usr/src/app
|
WORKDIR /usr/src/app
|
||||||
# Copy full sources (simple and correct; avoids shipping stub binaries)
|
|
||||||
|
# Pre-build dependencies to leverage Docker layer caching
|
||||||
COPY ./services/common-contracts /usr/src/app/services/common-contracts
|
COPY ./services/common-contracts /usr/src/app/services/common-contracts
|
||||||
|
COPY ./services/tushare-provider-service/Cargo.toml ./services/tushare-provider-service/Cargo.lock* ./services/tushare-provider-service/
|
||||||
|
|
||||||
|
WORKDIR /usr/src/app/services/tushare-provider-service
|
||||||
|
RUN mkdir -p src && \
|
||||||
|
echo "fn main() {}" > src/main.rs && \
|
||||||
|
cargo build --release --bin tushare-provider-service
|
||||||
|
|
||||||
|
# Copy the full source code
|
||||||
COPY ./services/tushare-provider-service /usr/src/app/services/tushare-provider-service
|
COPY ./services/tushare-provider-service /usr/src/app/services/tushare-provider-service
|
||||||
|
|
||||||
|
# Build the application
|
||||||
WORKDIR /usr/src/app/services/tushare-provider-service
|
WORKDIR /usr/src/app/services/tushare-provider-service
|
||||||
RUN cargo build --release --bin tushare-provider-service
|
RUN cargo build --release --bin tushare-provider-service
|
||||||
|
|
||||||
@ -14,8 +25,6 @@ FROM debian:bookworm-slim
|
|||||||
# Set timezone
|
# Set timezone
|
||||||
ENV TZ=Asia/Shanghai
|
ENV TZ=Asia/Shanghai
|
||||||
RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone
|
RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone
|
||||||
# Minimal runtime deps for health checks (curl) and TLS roots if needed
|
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates curl && rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
# Copy the built binary from the builder stage
|
# Copy the built binary from the builder stage
|
||||||
COPY --from=builder /usr/src/app/services/tushare-provider-service/target/release/tushare-provider-service /usr/local/bin/
|
COPY --from=builder /usr/src/app/services/tushare-provider-service/target/release/tushare-provider-service /usr/local/bin/
|
||||||
|
|||||||
@ -1,8 +1,6 @@
|
|||||||
use std::collections::HashMap;
|
use axum::{routing::get, Router, extract::State};
|
||||||
use axum::{routing::get, Router, extract::State, response::Json};
|
|
||||||
|
|
||||||
use crate::state::AppState;
|
use crate::state::AppState;
|
||||||
use common_contracts::observability::{HealthStatus, ServiceStatus};
|
|
||||||
|
|
||||||
pub fn create_router(app_state: AppState) -> Router {
|
pub fn create_router(app_state: AppState) -> Router {
|
||||||
Router::new()
|
Router::new()
|
||||||
@ -11,16 +9,8 @@ pub fn create_router(app_state: AppState) -> Router {
|
|||||||
.with_state(app_state)
|
.with_state(app_state)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn health_check(State(_state): State<AppState>) -> Json<HealthStatus> {
|
async fn health_check(State(_state): State<AppState>) -> &'static str {
|
||||||
let mut details = HashMap::new();
|
"OK"
|
||||||
details.insert("message_bus_connection".to_string(), "ok".to_string());
|
|
||||||
let status = HealthStatus {
|
|
||||||
module_id: "tushare-provider-service".to_string(),
|
|
||||||
status: ServiceStatus::Ok,
|
|
||||||
version: env!("CARGO_PKG_VERSION").to_string(),
|
|
||||||
details,
|
|
||||||
};
|
|
||||||
Json(status)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn get_tasks(State(state): State<AppState>) -> axum::Json<Vec<common_contracts::observability::TaskProgress>> {
|
async fn get_tasks(State(state): State<AppState>) -> axum::Json<Vec<common_contracts::observability::TaskProgress>> {
|
||||||
|
|||||||
@ -11,37 +11,9 @@ pub struct AppConfig {
|
|||||||
|
|
||||||
impl AppConfig {
|
impl AppConfig {
|
||||||
pub fn load() -> Result<Self, config::ConfigError> {
|
pub fn load() -> Result<Self, config::ConfigError> {
|
||||||
let cfg = config::Config::builder()
|
let config = config::Config::builder()
|
||||||
.add_source(config::Environment::default().separator("__"))
|
.add_source(config::Environment::default().separator("__"))
|
||||||
.build()?;
|
.build()?;
|
||||||
let cfg: Self = cfg.try_deserialize()?;
|
config.try_deserialize()
|
||||||
|
|
||||||
if cfg.server_port == 0 {
|
|
||||||
return Err(config::ConfigError::Message(
|
|
||||||
"SERVER_PORT must be > 0".to_string(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
if cfg.nats_addr.trim().is_empty() {
|
|
||||||
return Err(config::ConfigError::Message(
|
|
||||||
"NATS_ADDR must not be empty".to_string(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
if cfg.data_persistence_service_url.trim().is_empty() {
|
|
||||||
return Err(config::ConfigError::Message(
|
|
||||||
"DATA_PERSISTENCE_SERVICE_URL must not be empty".to_string(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
if cfg.tushare_api_url.trim().is_empty() {
|
|
||||||
return Err(config::ConfigError::Message(
|
|
||||||
"TUSHARE_API_URL must not be empty".to_string(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
if cfg.tushare_api_token.trim().is_empty() || cfg.tushare_api_token.trim() == "YOUR_TUSHARE_API_TOKEN" {
|
|
||||||
return Err(config::ConfigError::Message(
|
|
||||||
"TUSHARE_API_TOKEN must be provided (non-empty, non-placeholder)".to_string(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(cfg)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,9 +2,20 @@
|
|||||||
FROM rust:1.90 as builder
|
FROM rust:1.90 as builder
|
||||||
|
|
||||||
WORKDIR /usr/src/app
|
WORKDIR /usr/src/app
|
||||||
# Copy full sources (simple and correct; avoids shipping stub binaries)
|
|
||||||
|
# Pre-build dependencies to leverage Docker layer caching
|
||||||
COPY ./services/common-contracts /usr/src/app/services/common-contracts
|
COPY ./services/common-contracts /usr/src/app/services/common-contracts
|
||||||
|
COPY ./services/yfinance-provider-service/Cargo.toml ./services/yfinance-provider-service/Cargo.lock* ./services/yfinance-provider-service/
|
||||||
|
|
||||||
|
WORKDIR /usr/src/app/services/yfinance-provider-service
|
||||||
|
RUN mkdir -p src && \
|
||||||
|
echo "fn main() {}" > src/main.rs && \
|
||||||
|
cargo build --release --bin yfinance-provider-service
|
||||||
|
|
||||||
|
# Copy the full source code
|
||||||
COPY ./services/yfinance-provider-service /usr/src/app/services/yfinance-provider-service
|
COPY ./services/yfinance-provider-service /usr/src/app/services/yfinance-provider-service
|
||||||
|
|
||||||
|
# Build the application
|
||||||
WORKDIR /usr/src/app/services/yfinance-provider-service
|
WORKDIR /usr/src/app/services/yfinance-provider-service
|
||||||
RUN cargo build --release --bin yfinance-provider-service
|
RUN cargo build --release --bin yfinance-provider-service
|
||||||
|
|
||||||
@ -14,8 +25,6 @@ FROM debian:bookworm-slim
|
|||||||
# Set timezone
|
# Set timezone
|
||||||
ENV TZ=Asia/Shanghai
|
ENV TZ=Asia/Shanghai
|
||||||
RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone
|
RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone
|
||||||
# Minimal runtime deps for health checks (curl) and TLS roots if needed
|
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates curl && rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
# Copy the built binary from the builder stage
|
# Copy the built binary from the builder stage
|
||||||
COPY --from=builder /usr/src/app/services/yfinance-provider-service/target/release/yfinance-provider-service /usr/local/bin/
|
COPY --from=builder /usr/src/app/services/yfinance-provider-service/target/release/yfinance-provider-service /usr/local/bin/
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user