Compare commits

...

3 Commits

Author SHA1 Message Date
Lv, Qi
a1e4b265ba feat(config): Implement database-centric LLM provider architecture
本次提交完成了一次全面的架构重构,实现了以数据库为中心的、支持多供应商的LLM配置体系。

**核心变更**:
1.  **数据库驱动配置**: 废弃了所有基于本地文件的配置方案 (`analysis-config.json`),将LLM Provider和分析模块的配置作为结构化数据存入数据库的`system_config`表中,由`data-persistence-service`统一管理。
2.  **Schema-in-Code**: 在`common-contracts`中定义了所有配置的Rust Structs,作为整个系统的“单一事实源”,确保了端到端的类型安全。
3.  **服务职责重构**:
    *   `data-persistence-service`吸收了配置管理功能,成为配置的“守门人”。
    *   `config-service-rs`服务已被彻底移除。
    *   `report-generator-service`重构为可以为每个任务动态创建使用不同Provider配置的LLM客户端。
4.  **前端功能增强**:
    *   新增了独立的`/llm-config`页面,用于对LLM Providers及其可用模型进行完整的CRUD管理,并支持模型自动发现。
    *   重构了旧的`/config`页面,为分析模块提供了级联选择器来精确指定所需的Provider和Model。

此次重构极大地提升了系统的灵活性和可扩展性,完全对齐了“配置即数据”的现代化设计原则。
2025-11-17 04:41:36 +08:00
Lv, Qi
53d69a00e5 fix(services): make providers observable and robust
- Fix Dockerfile stub builds; compile full sources (no empty binaries)
- Add ca-certificates and curl in runtime images for TLS/healthchecks
- Enable RUST_LOG and RUST_BACKTRACE for all providers
- Add HTTP /health healthchecks in docker-compose for ports 8000-8004
- Standardize Tushare /health to structured HealthStatus JSON
- Enforce strict config validation (FINNHUB_API_KEY, TUSHARE_API_TOKEN)
- Map provider API keys via .env in docker-compose
- Log provider_services at API Gateway startup for diagnostics

Outcome: provider containers no longer exit silently; missing keys fail fast with explicit errors; health and logs are consistent across modules.
2025-11-17 04:40:51 +08:00
Lv, Qi
9d62a53b73 refactor(architecture): Align frontend & docs with DB gateway pattern
本次提交旨在完成一次架构一致性重构,核心目标是使前端代码和相关文档完全符合“`data-persistence-service`是唯一数据库守门人”的设计原则。

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

此次重构后,系统前端的数据交互已完全收敛至`api-gateway`,确保了架构的统一性、健壮性和高内聚。
2025-11-17 01:29:56 +08:00
71 changed files with 2283 additions and 2548 deletions

View File

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

View File

@ -1,5 +1,3 @@
version: "3.9"
services: services:
postgres-db: postgres-db:
image: timescale/timescaledb:2.15.2-pg16 image: timescale/timescaledb:2.15.2-pg16
@ -35,6 +33,8 @@ 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,8 +55,9 @@ services:
environment: environment:
# 让 Next 的 API 路由代理到新的 api-gateway # 让 Next 的 API 路由代理到新的 api-gateway
NEXT_PUBLIC_BACKEND_URL: http://api-gateway:4000/v1 NEXT_PUBLIC_BACKEND_URL: http://api-gateway:4000/v1
# Prisma 直连数据库(与后端共用同一库) # SSR 内部访问自身 API 的内部地址,避免使用 x-forwarded-host 导致访问宿主机端口
DATABASE_URL: postgresql://postgres:postgres@postgres-db:5432/fundamental?schema=public FRONTEND_INTERNAL_URL: http://fundamental-frontend:3001
BACKEND_INTERNAL_URL: http://api-gateway:4000/v1
NODE_ENV: development NODE_ENV: development
NEXT_TELEMETRY_DISABLED: "1" NEXT_TELEMETRY_DISABLED: "1"
volumes: volumes:
@ -66,7 +67,6 @@ services:
ports: ports:
- "13001:3001" - "13001:3001"
depends_on: depends_on:
- postgres-db
- api-gateway - api-gateway
networks: networks:
- app-network - app-network
@ -77,12 +77,15 @@ 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
# Note: provider_services needs to contain all provider's internal addresses # provider_services via explicit JSON for deterministic parsing
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
@ -102,11 +105,19 @@ 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:
@ -118,13 +129,20 @@ 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 here # Please provide your Tushare token via .env
TUSHARE_API_TOKEN: "YOUR_TUSHARE_API_TOKEN" TUSHARE_API_TOKEN: ${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:
@ -138,11 +156,18 @@ 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:
@ -153,11 +178,18 @@ 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:
@ -168,28 +200,18 @@ 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
# Please provide your LLM provider details in .env file RUST_LOG: info,axum=info
LLM_API_URL: ${LLM_API_URL} RUST_BACKTRACE: "1"
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:
config-service-rs: test: ["CMD-SHELL", "curl -fsS http://localhost:8004/health >/dev/null || exit 1"]
build: interval: 5s
context: . timeout: 5s
dockerfile: services/config-service-rs/Dockerfile retries: 12
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)

View File

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

View File

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

View File

@ -0,0 +1,154 @@
---
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配置。

View File

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

View File

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

View File

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

View File

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

View File

@ -1,11 +1,11 @@
const BACKEND_BASE = process.env.NEXT_PUBLIC_BACKEND_URL; const BACKEND_BASE = process.env.BACKEND_INTERNAL_URL || 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('NEXT_PUBLIC_BACKEND_URL 未配置', { status: 500 }); return new Response('BACKEND_INTERNAL_URL/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`;

View File

@ -1,26 +1,75 @@
import { NextRequest } from 'next/server'; import { NextRequest } from 'next/server';
const BACKEND_BASE = process.env.NEXT_PUBLIC_BACKEND_URL; const BACKEND_BASE = process.env.BACKEND_INTERNAL_URL || process.env.NEXT_PUBLIC_BACKEND_URL;
// 聚合新后端的配置,提供给旧前端调用点一个稳定入口
export async function GET() { export async function GET() {
if (!BACKEND_BASE) { if (!BACKEND_BASE) {
return new Response('NEXT_PUBLIC_BACKEND_URL 未配置', { status: 500 }); return new Response('BACKEND_INTERNAL_URL/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('NEXT_PUBLIC_BACKEND_URL 未配置', { status: 500 }); return new Response('BACKEND_INTERNAL_URL/NEXT_PUBLIC_BACKEND_URL 未配置', { status: 500 });
}
try {
const incoming = await req.json().catch(() => ({}));
const tasks: Promise<Response>[] = [];
if (incoming.llm_providers) {
tasks.push(fetch(`${BACKEND_BASE}/configs/llm_providers`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(incoming.llm_providers),
}));
}
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 [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 });
} }
const body = await req.text();
const resp = await fetch(`${BACKEND_BASE}/config`, {
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' } });
} }

View File

@ -1,17 +1,12 @@
import { NextRequest } from 'next/server'; import { NextRequest } from 'next/server';
const BACKEND_BASE = process.env.NEXT_PUBLIC_BACKEND_URL; const BACKEND_BASE = process.env.BACKEND_INTERNAL_URL || 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('NEXT_PUBLIC_BACKEND_URL 未配置', { status: 500 }); return new Response('BACKEND_INTERNAL_URL/NEXT_PUBLIC_BACKEND_URL 未配置', { status: 500 });
} }
const body = await req.text(); // 新后端暂无统一 /config/test先返回未实现
const resp = await fetch(`${BACKEND_BASE}/config/test`, { const body = await req.text().catch(() => '');
method: 'POST', return Response.json({ success: false, message: 'config/test 未实现', echo: body }, { status: 501 });
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' } });
} }

View File

@ -0,0 +1,34 @@
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' },
});
}

View File

@ -0,0 +1,34 @@
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' },
});
}

View File

@ -1,10 +1,10 @@
import { NextRequest } from 'next/server'; import { NextRequest } from 'next/server';
const BACKEND_BASE = process.env.NEXT_PUBLIC_BACKEND_URL; const BACKEND_BASE = process.env.BACKEND_INTERNAL_URL || 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('NEXT_PUBLIC_BACKEND_URL 未配置', { status: 500 }); return new Response('BACKEND_INTERNAL_URL/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`, {

View File

@ -0,0 +1,22 @@
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' },
});
}

View File

@ -1,27 +1,58 @@
import { NextRequest } from 'next/server'; import { NextRequest } from 'next/server';
const BACKEND_BASE = process.env.NEXT_PUBLIC_BACKEND_URL; const BACKEND_BASE = process.env.BACKEND_INTERNAL_URL || 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('NEXT_PUBLIC_BACKEND_URL 未配置', { status: 500 }); return new Response('BACKEND_INTERNAL_URL/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 path = slug.join('/'); const first = slug?.[0];
const target = `${BACKEND_BASE}/financials/${path}${url.search}`; // 适配旧接口analysis-config → 新分析模块配置
const resp = await fetch(target, { headers: { 'Content-Type': 'application/json' } }); if (first === 'analysis-config') {
// 透传后端响应(支持流式 body const resp = await fetch(`${BACKEND_BASE}/configs/analysis_modules`, { cache: 'no-store' });
const headers = new Headers(); const text = await resp.text();
// 复制关键头,减少代理层缓冲 return new Response(text, {
const contentType = resp.headers.get('content-type') || 'application/json; charset=utf-8'; status: resp.status,
headers.set('content-type', contentType); headers: { 'Content-Type': resp.headers.get('Content-Type') || 'application/json' },
const cacheControl = resp.headers.get('cache-control'); });
if (cacheControl) headers.set('cache-control', cacheControl); }
const xAccelBuffering = resp.headers.get('x-accel-buffering'); // 适配旧接口config → 聚合配置
if (xAccelBuffering) headers.set('x-accel-buffering', xAccelBuffering); if (first === 'config') {
return new Response(resp.body, { status: resp.status, headers }); const resp = await fetch(`${FRONTEND_BASE}/api/config`, { cache: 'no-store' });
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 });
} }

View File

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

View File

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

View File

@ -1,11 +1,11 @@
const BACKEND_BASE = process.env.NEXT_PUBLIC_BACKEND_URL; const BACKEND_BASE = process.env.BACKEND_INTERNAL_URL || 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('NEXT_PUBLIC_BACKEND_URL 未配置', { status: 500 }); return new Response('BACKEND_INTERNAL_URL/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)}`;

View File

@ -1,8 +1,12 @@
'use client'; 'use client';
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { useConfig, updateConfig, testConfig, useAnalysisConfig, updateAnalysisConfig } from '@/hooks/useApi'; import {
import { useConfigStore, SystemConfig } from '@/stores/useConfigStore'; useConfig, updateConfig, testConfig,
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";
@ -12,7 +16,9 @@ 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 type { AnalysisConfigResponse } from '@/types'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
// Types are imported from '@/types'
import type { AnalysisModulesConfig } from '@/types';
export default function ConfigPage() { export default function ConfigPage() {
// 从 Zustand store 获取全局状态 // 从 Zustand store 获取全局状态
@ -20,27 +26,19 @@ export default function ConfigPage() {
// 使用 SWR hook 加载初始配置 // 使用 SWR hook 加载初始配置
useConfig(); useConfig();
// 加载分析配置 // 加载分析配置(统一使用 initialAnalysisModules
const { data: analysisConfig, mutate: mutateAnalysisConfig } = useAnalysisConfig(); // const { data: analysisConfig, mutate: mutateAnalysisConfig } = useAnalysisModules();
// 本地表单状态 // 本地表单状态
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 [localAnalysisConfig, setLocalAnalysisConfig] = useState<Record<string, { const [localAnalysisModules, setLocalAnalysisModules] = useState<AnalysisModulesConfig>({});
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>>({});
@ -48,17 +46,46 @@ 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 (analysisConfig?.analysis_modules) { if (initialAnalysisModules) {
setLocalAnalysisConfig(analysisConfig.analysis_modules); setLocalAnalysisModules(initialAnalysisModules);
} }
}, [analysisConfig]); }, [initialAnalysisModules]);
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) => {
setLocalAnalysisConfig(prev => ({ setLocalAnalysisModules(prev => ({
...prev, ...prev,
[type]: { [type]: {
...prev[type], ...prev[type],
@ -69,7 +96,7 @@ export default function ConfigPage() {
// 更新分析模块的依赖 // 更新分析模块的依赖
const updateAnalysisDependencies = (type: string, dependency: string, checked: boolean) => { const updateAnalysisDependencies = (type: string, dependency: string, checked: boolean) => {
setLocalAnalysisConfig(prev => { setLocalAnalysisModules(prev => {
const currentConfig = prev[type]; const currentConfig = prev[type];
const currentDeps = currentConfig.dependencies || []; const currentDeps = currentConfig.dependencies || [];
@ -88,33 +115,11 @@ 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:// 开头');
@ -149,11 +154,6 @@ export default function ConfigPage() {
const newConfig: Partial<SystemConfig> = {}; const newConfig: Partial<SystemConfig> = {};
// 只更新有值的字段
if (dbUrl) {
newConfig.database = { url: dbUrl };
}
if (newApiApiKey || newApiBaseUrl) { if (newApiApiKey || newApiBaseUrl) {
newConfig.new_api = { newConfig.new_api = {
api_key: newApiApiKey || config?.new_api?.api_key || '', api_key: newApiApiKey || config?.new_api?.api_key || '',
@ -197,10 +197,6 @@ export default function ConfigPage() {
} }
}; };
const handleTestDb = () => {
handleTest('database', { url: dbUrl });
};
const handleTestNewApi = () => { const handleTestNewApi = () => {
handleTest('new_api', { handleTest('new_api', {
api_key: newApiApiKey || config?.new_api?.api_key, api_key: newApiApiKey || config?.new_api?.api_key,
@ -217,7 +213,6 @@ export default function ConfigPage() {
}; };
const handleReset = () => { const handleReset = () => {
setDbUrl('');
setNewApiApiKey(''); setNewApiApiKey('');
setNewApiBaseUrl(''); setNewApiBaseUrl('');
setTushareApiKey(''); setTushareApiKey('');
@ -230,7 +225,6 @@ export default function ConfigPage() {
if (!config) return; if (!config) return;
const configToExport = { const configToExport = {
database: config.database,
new_api: config.new_api, new_api: config.new_api,
data_sources: config.data_sources, data_sources: config.data_sources,
export_time: new Date().toISOString(), export_time: new Date().toISOString(),
@ -258,9 +252,6 @@ 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);
} }
@ -296,51 +287,18 @@ export default function ConfigPage() {
<header className="space-y-2"> <header className="space-y-2">
<h1 className="text-3xl font-bold"></h1> <h1 className="text-3xl font-bold"></h1>
<p className="text-muted-foreground"> <p className="text-muted-foreground">
API密钥 AI
</p> </p>
</header> </header>
<Tabs defaultValue="database" className="space-y-6"> <Tabs defaultValue="ai" className="space-y-6">
<TabsList className="grid w-full grid-cols-5"> <TabsList className="grid w-full grid-cols-4">
<TabsTrigger value="database"></TabsTrigger>
<TabsTrigger value="ai">AI服务</TabsTrigger> <TabsTrigger value="ai">AI服务</TabsTrigger>
<TabsTrigger value="data-sources"></TabsTrigger> <TabsTrigger value="data-sources"></TabsTrigger>
<TabsTrigger value="analysis"></TabsTrigger> <TabsTrigger value="analysis"></TabsTrigger>
<TabsTrigger value="system"></TabsTrigger> <TabsTrigger value="system"></TabsTrigger>
</TabsList> </TabsList>
<TabsContent value="database" className="space-y-4">
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription>PostgreSQL </CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="db-url">URL</Label>
<div className="flex gap-2">
<Input
id="db-url"
type="text"
value={dbUrl}
onChange={(e) => setDbUrl(e.target.value)}
placeholder="postgresql+asyncpg://user:password@host:port/database"
className="flex-1"
/>
<Button onClick={handleTestDb} variant="outline">
</Button>
</div>
{testResults.database && (
<Badge variant={testResults.database.success ? 'default' : 'destructive'}>
{testResults.database.message}
</Badge>
)}
</div>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="ai" className="space-y-4"> <TabsContent value="ai" className="space-y-4">
<Card> <Card>
<CardHeader> <CardHeader>
@ -450,102 +408,63 @@ export default function ConfigPage() {
<CardDescription></CardDescription> <CardDescription></CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-6"> <CardContent className="space-y-6">
{Object.entries(localAnalysisConfig).map(([type, config]) => { {Object.entries(localAnalysisModules).map(([moduleId, config]) => {
const otherModuleKeys = Object.keys(localAnalysisConfig).filter(key => key !== type); const availableModels = llmProviders?.[config.provider_id]?.models.filter(m => m.is_active) || [];
return ( return (
<div key={type} className="space-y-4 p-4 border rounded-lg"> <div key={moduleId} className="space-y-4 p-4 border rounded-lg">
<div className="flex items-center justify-between"> <h3 className="text-lg font-semibold">{config.name || moduleId}</h3>
<h3 className="text-lg font-semibold">{config.name || type}</h3>
<Badge variant="secondary">{type}</Badge>
</div>
<div className="space-y-2">
<Label htmlFor={`${type}-name`}></Label>
<Input
id={`${type}-name`}
value={config.name || ''}
onChange={(e) => updateAnalysisField(type, 'name', e.target.value)}
placeholder="分析模块显示名称"
/>
</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 className="grid grid-cols-1 md:grid-cols-2 gap-4">
<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 className="space-y-2">
<Label htmlFor={`${moduleId}-prompt`}></Label>
<Textarea
id={`${moduleId}-prompt`}
value={config.prompt_template || ''}
onChange={(e) => handleAnalysisChange(moduleId, 'prompt_template', e.target.value)}
rows={10}
/>
</div>
</div> </div>
); );
})} })}
<div className="flex items-center gap-4 pt-4"> <div className="flex items-center gap-4 pt-4">
<Button <Button onClick={handleSaveAnalysis} disabled={isSavingAnalysis}>
onClick={handleSaveAnalysisConfig} {isSavingAnalysis ? '保存中...' : '保存分析配置'}
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'}`}>
@ -565,12 +484,6 @@ export default function ConfigPage() {
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label></Label>
<Badge variant={config?.database?.url ? 'default' : 'secondary'}>
{config?.database?.url ? '已配置' : '未配置'}
</Badge>
</div>
<div className="space-y-2"> <div className="space-y-2">
<Label>New API</Label> <Label>New API</Label>
<Badge variant={config?.new_api?.api_key ? 'default' : 'secondary'}> <Badge variant={config?.new_api?.api_key ? 'default' : 'secondary'}>

View File

@ -2,18 +2,32 @@ 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, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent } 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 mdPath = path.join(process.cwd(), '..', 'docs', 'user-guide.md'); const root = path.join(process.cwd(), '..', 'docs');
try { const candidates = [
const content = await fs.readFile(mdPath, 'utf8'); path.join(root, '1_requirements', '20251109_[Active]_user-guide.md'),
return content; path.join(root, '1_requirements', '20251108_[Active]_requirements.md'),
} catch (error) { path.join(root, '2_architecture', '20251116_[Active]_system_architecture.md'),
console.error("Failed to read user-guide.md:", error); ];
return "# 文档加载失败\n\n无法读取 `docs/user-guide.md` 文件。请检查文件是否存在以及服务器权限。"; for (const p of candidates) {
try {
const content = await fs.readFile(p, 'utf8');
return content;
} catch {
// try next
}
} }
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() {

View File

@ -0,0 +1,404 @@
'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>
);
}

View File

@ -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, generateFullAnalysis, useSnapshot, useRealtimeQuote } from '@/hooks/useApi'; import { useChinaFinancials, useFinancials, useFinancialConfig, useAnalysisConfig, useSnapshot, useRealtimeQuote, useDataRequest, useTaskProgress } 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 } from 'react'; import { useMemo, useCallback } from 'react';
import { formatReportPeriod } from '@/lib/financial-utils'; import { formatReportPeriod } from '@/lib/financial-utils';
export default function ReportPage() { export default function ReportPage() {
@ -107,52 +107,14 @@ export default function ReportPage() {
}; };
error?: string; error?: string;
}>>([]); }>>([]);
// 新架构:触发分析与查看任务进度
const [saving, setSaving] = useState(false) const { trigger: triggerAnalysis, isMutating: triggering } = useDataRequest();
const [saveMsg, setSaveMsg] = useState<string | null>(null) const [requestId, setRequestId] = useState<string | null>(null);
const { progress: taskProgress } = useTaskProgress(requestId);
const saveReport = async () => {
try {
setSaving(true)
setSaveMsg(null)
const content = {
market,
normalizedSymbol: normalizedTsCode,
financialsMeta: financials?.meta || null,
// 同步保存财务数据(用于报告详情页展示)
financials: financials
? {
ts_code: financials.ts_code,
name: (financials as any).name,
series: financials.series,
meta: financials.meta,
}
: null,
analyses: Object.fromEntries(
Object.entries(analysisStates).map(([k, v]) => [k, { content: v.content, error: v.error, elapsed_ms: v.elapsed_ms, tokens: v.tokens }])
)
}
const resp = await fetch('/api/reports', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ symbol: normalizedTsCode, content })
})
if (!resp.ok) {
const t = await resp.json().catch(() => ({}))
throw new Error(t?.error || `HTTP ${resp.status}`)
}
const data = await resp.json()
setSaveMsg('保存成功')
return data
} catch (e) {
setSaveMsg(e instanceof Error ? e.message : '保存失败')
} finally {
setSaving(false)
}
}
const runFullAnalysis = async () => { const runFullAnalysis = useCallback(async () => {
if (!financials || !analysisConfig?.analysis_modules || isAnalysisRunningRef.current) { if (!analysisConfig?.analysis_modules || isAnalysisRunningRef.current) {
return; return;
} }
@ -181,14 +143,14 @@ export default function ReportPage() {
// 触发顺序执行 // 触发顺序执行
setManualRunKey((k) => k + 1); setManualRunKey((k) => k + 1);
}; }, [analysisConfig?.analysis_modules]);
useEffect(() => { useEffect(() => {
if (financials && !fullAnalysisTriggeredRef.current) { if (analysisConfig?.analysis_modules && !fullAnalysisTriggeredRef.current) {
fullAnalysisTriggeredRef.current = true; fullAnalysisTriggeredRef.current = true;
runFullAnalysis(); runFullAnalysis();
} }
}, [financials]); }, [analysisConfig?.analysis_modules, runFullAnalysis]);
// 计算完成比例 // 计算完成比例
const completionProgress = useMemo(() => { const completionProgress = useMemo(() => {
@ -230,8 +192,9 @@ export default function ReportPage() {
if (!financialConfig?.api_groups) return {}; if (!financialConfig?.api_groups) return {};
const map: Record<string, string> = {}; const map: Record<string, string> = {};
Object.values(financialConfig.api_groups).forEach(metrics => { const groups = Object.values((financialConfig as Partial<import('@/types').FinancialConfigResponse>).api_groups || {}) as import('@/types').FinancialMetricConfig[][];
metrics.forEach(metric => { groups.forEach((metrics) => {
(metrics || []).forEach((metric) => {
if (metric.tushareParam && metric.displayText) { if (metric.tushareParam && metric.displayText) {
map[metric.tushareParam] = metric.displayText; map[metric.tushareParam] = metric.displayText;
} }
@ -243,8 +206,9 @@ 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> = {};
Object.entries(financialConfig.api_groups).forEach(([groupName, metrics]) => { const entries = Object.entries((financialConfig as Partial<import('@/types').FinancialConfigResponse>).api_groups || {}) as [string, import('@/types').FinancialMetricConfig[]][];
metrics.forEach((metric) => { entries.forEach(([groupName, metrics]) => {
(metrics || []).forEach((metric) => {
if (metric.tushareParam) { if (metric.tushareParam) {
map[metric.tushareParam] = groupName; map[metric.tushareParam] = groupName;
} }
@ -585,7 +549,7 @@ export default function ReportPage() {
} }
}; };
runAnalysesSequentially(); runAnalysesSequentially();
}, [isLoading, error, financials, analysisConfig, analysisTypes, normalizedTsCode, manualRunKey]); }, [isLoading, error, financials, analysisConfig, analysisTypes, normalizedTsCode, normalizedMarket, unifiedSymbol, startTime, manualRunKey]);
const stopAll = () => { const stopAll = () => {
stopRequestedRef.current = true; stopRequestedRef.current = true;
@ -728,8 +692,14 @@ 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 onClick={runFullAnalysis} disabled={isAnalysisRunningRef.current}> <Button
{isAnalysisRunningRef.current ? '正在分析…' : '开始分析'} onClick={async () => {
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}>
@ -739,6 +709,18 @@ 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">
@ -755,14 +737,6 @@ export default function ReportPage() {
style={{ width: `${completionProgress}%` }} style={{ width: `${completionProgress}%` }}
/> />
</div> </div>
{allTasksCompleted && (
<div className="pt-2">
<Button onClick={saveReport} disabled={saving} variant="outline">
{saving ? '保存中...' : '保存报告'}
</Button>
{saveMsg && <span className="ml-2 text-xs text-muted-foreground">{saveMsg}</span>}
</div>
)}
</CardHeader> </CardHeader>
<CardContent className="space-y-2"> <CardContent className="space-y-2">
{currentAnalysisTask && analysisConfig && ( {currentAnalysisTask && analysisConfig && (
@ -810,13 +784,17 @@ 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 ? ( ) : (() => {
<span> const priceRaw = (realtime as any)?.price;
{realtime.price.toLocaleString()}{new Date(realtime.ts).toLocaleString()} const priceNum = typeof priceRaw === 'number' ? priceRaw : Number(priceRaw);
</span> const tsRaw = (realtime as any)?.ts;
) : ( const tsDate = tsRaw == null ? null : new Date(typeof tsRaw === 'number' ? tsRaw : String(tsRaw));
<span></span> const tsText = tsDate && !isNaN(tsDate.getTime()) ? `${tsDate.toLocaleString()}` : '';
)} if (Number.isFinite(priceNum)) {
return <span> {priceNum.toLocaleString()} {tsText}</span>;
}
return <span></span>;
})()}
</div> </div>
</div> </div>
@ -1529,7 +1507,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.filter(s => s.status === 'done').length}/{financials.meta.steps.length}</div> <div>: {(financials.meta.steps as any[]).filter((s: any) => s?.status === 'done').length}/{(financials.meta.steps as any[]).length}</div>
)} )}
{analysisRecords.length > 0 && ( {analysisRecords.length > 0 && (
<> <>

View File

@ -1,4 +1,4 @@
import { prisma } from '../../../lib/prisma' import { headers } from 'next/headers'
import ReactMarkdown from 'react-markdown' import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm' import remarkGfm from 'remark-gfm'
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs' import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'
@ -15,7 +15,30 @@ type Report = {
export default async function ReportDetailPage({ params }: { params: Promise<{ id: string }> }) { export default async function ReportDetailPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params const { id } = await params
const data = await prisma.report.findUnique({ where: { id } }) const h = await headers()
const host = h.get('x-forwarded-host') || h.get('host') || 'localhost:3000'
const proto = h.get('x-forwarded-proto') || 'http'
const base = process.env.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>

View File

@ -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.NEXT_PUBLIC_BASE_URL || `${proto}://${host}` const base = process.env.FRONTEND_INTERNAL_URL || process.env.NEXT_PUBLIC_BASE_URL || `${proto}://${host}`
const { items, total } = await fetchReports(base) const { items, total } = await fetchReports(base)
return ( return (

View File

@ -0,0 +1,33 @@
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>;
};

View File

@ -0,0 +1,17 @@
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>
);
};

View File

@ -0,0 +1,24 @@
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)}
/>
);
};

View File

@ -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]); }, [companyCode, stableRowIds, saveConfigSafely]);
// 获取配置统计信息 // 获取配置统计信息
const getConfigStats = useCallback(() => { const getConfigStats = useCallback(() => {

View File

@ -1,7 +1,17 @@
import useSWR, { SWRConfiguration } from "swr"; import useSWR, { SWRConfiguration } from "swr";
import { Financials, FinancialsIdentifier } from "@/types"; import {
BatchFinancialDataResponse,
TodaySnapshotResponse,
RealTimeQuoteResponse,
AnalysisConfigResponse,
LlmProvidersConfig,
AnalysisModulesConfig,
FinancialConfigResponse,
} from "@/types";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { AnalysisStep, AnalysisTask } from "@/lib/execution-step-manager"; // Execution-step types not used currently; keep API minimal and explicit
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());
@ -128,11 +138,11 @@ export function useFinancials(market?: string, stockCode?: string, years: number
} }
export function useAnalysisConfig() { export function useAnalysisConfig() {
return useSWR<AnalysisConfigResponse>('/api/financials/analysis-config', fetcher); return useSWR<AnalysisConfigResponse>('/api/configs/analysis_modules', fetcher);
} }
export async function updateAnalysisConfig(config: AnalysisConfigResponse) { export async function updateAnalysisConfig(config: AnalysisConfigResponse) {
const res = await fetch('/api/financials/analysis-config', { const res = await fetch('/api/configs/analysis_modules', {
method: 'PUT', method: 'PUT',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(config), body: JSON.stringify(config),
@ -232,3 +242,114 @@ 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>;
}

View File

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

View File

@ -0,0 +1,71 @@
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);
});
},
}))
);

View File

@ -454,4 +454,47 @@ export const DEFAULT_CONFIG = {
SUCCESS_DISPLAY_DURATION: 2000, SUCCESS_DISPLAY_DURATION: 2000,
/** 错误状态显示时长 (毫秒) */ /** 错误状态显示时长 (毫秒) */
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 Normal file
View File

@ -0,0 +1,52 @@
{
"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
}
}
}
}
}

6
package.json Normal file
View File

@ -0,0 +1,6 @@
{
"dependencies": {
"immer": "^10.2.0",
"zustand": "^5.0.8"
}
}

View File

@ -2,20 +2,9 @@
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
@ -25,6 +14,8 @@ 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/

View File

@ -91,7 +91,7 @@ dependencies = [
"once_cell", "once_cell",
"pin-project", "pin-project",
"portable-atomic", "portable-atomic",
"rand", "rand 0.8.5",
"regex", "regex",
"ring", "ring",
"rustls-native-certs", "rustls-native-certs",
@ -644,16 +644,6 @@ 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"
@ -676,12 +666,6 @@ 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"
@ -717,21 +701,6 @@ 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"
@ -848,8 +817,10 @@ 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]]
@ -859,28 +830,11 @@ 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]]
@ -1019,7 +973,6 @@ dependencies = [
"bytes", "bytes",
"futures-channel", "futures-channel",
"futures-core", "futures-core",
"h2",
"http", "http",
"http-body", "http-body",
"httparse", "httparse",
@ -1046,22 +999,7 @@ 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]]
@ -1083,11 +1021,9 @@ 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]]
@ -1322,12 +1258,6 @@ 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"
@ -1349,6 +1279,12 @@ 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"
@ -1397,23 +1333,6 @@ 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"
@ -1425,7 +1344,7 @@ dependencies = [
"ed25519-dalek", "ed25519-dalek",
"getrandom 0.2.16", "getrandom 0.2.16",
"log", "log",
"rand", "rand 0.8.5",
"signatory", "signatory",
] ]
@ -1444,7 +1363,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", "rand 0.8.5",
] ]
[[package]] [[package]]
@ -1458,7 +1377,7 @@ dependencies = [
"num-integer", "num-integer",
"num-iter", "num-iter",
"num-traits", "num-traits",
"rand", "rand 0.8.5",
"smallvec", "smallvec",
"zeroize", "zeroize",
] ]
@ -1505,50 +1424,12 @@ 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"
@ -1779,6 +1660,61 @@ 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"
@ -1807,8 +1743,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404"
dependencies = [ dependencies = [
"libc", "libc",
"rand_chacha", "rand_chacha 0.3.1",
"rand_core", "rand_core 0.6.4",
]
[[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]]
@ -1818,7 +1764,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
dependencies = [ dependencies = [
"ppv-lite86", "ppv-lite86",
"rand_core", "rand_core 0.6.4",
]
[[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]]
@ -1830,6 +1786,15 @@ 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"
@ -1905,29 +1870,26 @@ 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-native-tls", "tokio-rustls",
"tower", "tower",
"tower-http", "tower-http",
"tower-service", "tower-service",
@ -1935,6 +1897,7 @@ dependencies = [
"wasm-bindgen", "wasm-bindgen",
"wasm-bindgen-futures", "wasm-bindgen-futures",
"web-sys", "web-sys",
"webpki-roots 1.0.4",
] ]
[[package]] [[package]]
@ -2007,7 +1970,7 @@ dependencies = [
"num-traits", "num-traits",
"pkcs1", "pkcs1",
"pkcs8", "pkcs8",
"rand_core", "rand_core 0.6.4",
"signature", "signature",
"spki", "spki",
"subtle", "subtle",
@ -2034,12 +1997,18 @@ dependencies = [
"borsh", "borsh",
"bytes", "bytes",
"num-traits", "num-traits",
"rand", "rand 0.8.5",
"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"
@ -2049,19 +2018,6 @@ 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"
@ -2104,6 +2060,7 @@ 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",
] ]
@ -2423,7 +2380,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c1e303f8205714074f6068773f0e29527e0453937fe837c9717d066635b65f31" checksum = "c1e303f8205714074f6068773f0e29527e0453937fe837c9717d066635b65f31"
dependencies = [ dependencies = [
"pkcs8", "pkcs8",
"rand_core", "rand_core 0.6.4",
"signature", "signature",
"zeroize", "zeroize",
] ]
@ -2435,7 +2392,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
dependencies = [ dependencies = [
"digest", "digest",
"rand_core", "rand_core 0.6.4",
] ]
[[package]] [[package]]
@ -2608,7 +2565,7 @@ dependencies = [
"memchr", "memchr",
"once_cell", "once_cell",
"percent-encoding", "percent-encoding",
"rand", "rand 0.8.5",
"rsa", "rsa",
"rust_decimal", "rust_decimal",
"serde", "serde",
@ -2649,7 +2606,7 @@ dependencies = [
"md-5", "md-5",
"memchr", "memchr",
"once_cell", "once_cell",
"rand", "rand 0.8.5",
"rust_decimal", "rust_decimal",
"serde", "serde",
"serde_json", "serde_json",
@ -2754,46 +2711,12 @@ 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"
@ -2936,16 +2859,6 @@ 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"
@ -2992,7 +2905,7 @@ dependencies = [
"futures-sink", "futures-sink",
"http", "http",
"httparse", "httparse",
"rand", "rand 0.8.5",
"ring", "ring",
"rustls-pki-types", "rustls-pki-types",
"tokio", "tokio",
@ -3404,6 +3317,16 @@ 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"
@ -3473,17 +3396,6 @@ 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"

View File

@ -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", features = ["json"] } reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
# Concurrency & Async # Concurrency & Async
uuid = { version = "1.8", features = ["v4"] } uuid = { version = "1.8", features = ["v4"] }

View File

@ -25,6 +25,7 @@ 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/

View File

@ -34,12 +34,22 @@ 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
.route("/v1/data-requests", post(trigger_data_fetch)) .nest("/v1", create_v1_router())
.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();
@ -59,7 +69,7 @@ async fn get_current_tasks() -> Json<Vec<TaskProgress>> {
Json(vec![]) Json(vec![])
} }
// --- API Handlers --- // --- V1 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.
@ -145,3 +155,86 @@ 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())
}
}

View File

@ -1,4 +1,5 @@
use serde::Deserialize; use serde::Deserialize;
use config::Config;
#[derive(Deserialize, Debug)] #[derive(Deserialize, Debug)]
pub struct AppConfig { pub struct AppConfig {
@ -10,10 +11,41 @@ pub struct AppConfig {
impl AppConfig { impl AppConfig {
pub fn load() -> Result<Self, config::ConfigError> { pub fn load() -> Result<Self, config::ConfigError> {
let config = config::Config::builder() let cfg: Config = config::Config::builder()
.add_source(config::Environment::default().separator("__")) .add_source(config::Environment::default().separator("__"))
.build()?; .build()?;
config.try_deserialize() let server_port: u16 = cfg.get::<u16>("server_port")?;
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,
})
} }
} }

View File

@ -8,9 +8,17 @@ 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() -> Result<()> { async fn main() {
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())
@ -21,6 +29,7 @@ async fn main() -> 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?;

View File

@ -4,6 +4,7 @@
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 {
@ -31,4 +32,60 @@ 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)
}
} }

View File

@ -3,6 +3,8 @@ 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 {
@ -13,7 +15,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 = async_nats::connect(&config.nats_addr).await?; let nats_client = connect_nats_with_retry(&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());
@ -25,3 +27,34 @@ 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())
}

View File

@ -0,0 +1,36 @@
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"

View File

@ -2,5 +2,6 @@ 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;

File diff suppressed because it is too large Load Diff

View File

@ -1,18 +0,0 @@
[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"

View File

@ -1,35 +0,0 @@
# 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"]

View File

@ -1,54 +0,0 @@
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())
}

View File

@ -1,19 +0,0 @@
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()
}
}

View File

@ -1,18 +0,0 @@
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),
}

View File

@ -1,26 +0,0 @@
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(())
}

View File

@ -74,6 +74,7 @@ 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

View File

@ -0,0 +1,43 @@
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))
}

View File

@ -1,6 +1,10 @@
// 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;

View File

@ -1,341 +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.
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)
}

View File

@ -0,0 +1,7 @@
// 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;

View File

@ -0,0 +1,63 @@
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)
}

View File

@ -2,20 +2,9 @@
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
@ -25,6 +14,8 @@ 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/

View File

@ -1,4 +1,4 @@
use secrecy::SecretString; use secrecy::{ExposeSecret, SecretString};
use serde::Deserialize; use serde::Deserialize;
#[derive(Debug, Deserialize, Clone)] #[derive(Debug, Deserialize, Clone)]
@ -12,10 +12,39 @@ pub struct AppConfig {
impl AppConfig { impl AppConfig {
pub fn load() -> Result<Self, config::ConfigError> { pub fn load() -> Result<Self, config::ConfigError> {
let config = config::Config::builder() let cfg = config::Config::builder()
.add_source(config::Environment::default().separator("__")) .add_source(config::Environment::default().separator("__"))
.build()?; .build()?;
config.try_deserialize() let cfg: Self = cfg.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)
} }
} }

View File

@ -2,20 +2,9 @@
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
@ -25,6 +14,8 @@ 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/

View File

@ -1,4 +1,3 @@
use secrecy::SecretString;
use serde::Deserialize; use serde::Deserialize;
#[derive(Debug, Deserialize, Clone)] #[derive(Debug, Deserialize, Clone)]
@ -6,15 +5,16 @@ 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(config::Environment::default().separator("__")) .add_source(
config::Environment::default()
.separator("__")
.ignore_empty(true),
)
.build()?; .build()?;
config.try_deserialize() config.try_deserialize()

View File

@ -6,6 +6,7 @@
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;
@ -55,6 +56,36 @@ 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);

View File

@ -6,28 +6,20 @@ use uuid::Uuid;
use common_contracts::observability::TaskProgress; use common_contracts::observability::TaskProgress;
use crate::{config::AppConfig, llm_client::LlmClient, templates::load_tera}; use crate::{config::AppConfig, 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()),
} }
} }

View File

@ -1,70 +1,128 @@
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; use tera::{Context, Tera};
use tracing::{info, warn}; use tracing::{info, warn, instrument};
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!( info!("Starting report generation workflow.");
"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 from the persistence service // 1. Fetch all necessary data AND configurations in parallel
let (profile, financials) = fetch_data(&persistence_client, &event.symbol).await?; let (profile, financials, llm_providers, analysis_modules) =
fetch_data_and_configs(&persistence_client, &event.symbol).await?;
if financials.is_empty() { if financials.is_empty() {
warn!( warn!("No financial data found. Aborting report generation.");
"No financial data found for symbol: {}. Aborting report generation.",
event.symbol
);
return Ok(()); return Ok(());
} }
// 2. Create context and render the prompt // --- New: Dynamic, Multi-Module Workflow ---
let mut context = Context::new(); let mut generated_results: HashMap<String, String> = HashMap::new();
context.insert("name", &profile.name);
context.insert("industry", &profile.industry);
context.insert("list_date", &profile.list_date.map(|d| d.to_string()));
context.insert("records_count", &financials.len());
let prompt = render_prompt(&state.tera, "company_profile_summary", &context) // Naive sequential execution based on dependencies. A proper topological sort would be better.
.map_err(|e| ProviderError::Internal(anyhow::anyhow!("Prompt rendering failed: {}", e)))?; // 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
}
// 3. Call the LLM to generate the summary info!(module_id = %module_id, "All dependencies met. Generating report for module.");
info!("Generating summary for symbol: {}", event.symbol);
let summary = state.llm_client.generate_text(prompt).await?;
// 4. Persist the generated report (future work) // 2. Dynamically create LLM client for this module
info!( let llm_client = create_llm_client_for_module(&state, &llm_providers, module_config)?;
"Successfully generated report for symbol: {} ({} records)",
event.symbol,
financials.len()
);
info!("Generated Summary: {}", summary);
// 3. Create context and render the prompt
let mut context = Context::new();
context.insert("company_name", &profile.name);
context.insert("ts_code", &event.symbol);
// Inject dependencies into context
for dep in &module_config.dependencies {
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)
.map_err(|e| ProviderError::Internal(anyhow::anyhow!("Prompt rendering failed for module '{}': {}", module_id, e)))?;
// 4. Call the LLM to generate the content for this module
let content = llm_client.generate_text(prompt).await?;
info!(module_id = %module_id, "Successfully generated content.");
// TODO: Persist the generated result via persistence_client
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(())
} }
async fn fetch_data( fn create_llm_client_for_module(
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<(CompanyProfileDto, Vec<TimeSeriesFinancialDto>)> { ) -> Result<(
let (profile, financials) = tokio::try_join!( CompanyProfileDto,
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)) Ok((profile, financials, llm_providers, analysis_modules))
} }

View File

@ -2,20 +2,9 @@
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
@ -25,6 +14,8 @@ 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/

View File

@ -1,6 +1,8 @@
use axum::{routing::get, Router, extract::State}; use std::collections::HashMap;
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()
@ -9,8 +11,16 @@ pub fn create_router(app_state: AppState) -> Router {
.with_state(app_state) .with_state(app_state)
} }
async fn health_check(State(_state): State<AppState>) -> &'static str { async fn health_check(State(_state): State<AppState>) -> Json<HealthStatus> {
"OK" let mut details = HashMap::new();
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>> {

View File

@ -11,9 +11,37 @@ pub struct AppConfig {
impl AppConfig { impl AppConfig {
pub fn load() -> Result<Self, config::ConfigError> { pub fn load() -> Result<Self, config::ConfigError> {
let config = config::Config::builder() let cfg = config::Config::builder()
.add_source(config::Environment::default().separator("__")) .add_source(config::Environment::default().separator("__"))
.build()?; .build()?;
config.try_deserialize() let cfg: Self = cfg.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)
} }
} }

View File

@ -2,20 +2,9 @@
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
@ -25,6 +14,8 @@ 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/