refactor(phase0-1): 容器化与配置服务拆分,并清理根目录

- 新增 docker-compose 与 Tiltfile,容器化 backend/frontend/postgres(宿主口+10000)
- 新增 services/config-service(GET /api/v1/system, /analysis-modules),并加入 compose
- backend ConfigManager 移除本地文件回退,强制依赖 config-service
- 新增 backend/frontend Dockerfile
- 清理根目录:移动 pm2.config.js -> deployment/;dev.py -> scripts/;删除根 package.json 与 lock
- 新增 .gitignore,忽略二进制与临时文件
This commit is contained in:
Lv, Qi 2025-11-08 21:07:38 +08:00
parent ca60410966
commit 3d0fd6f704
15 changed files with 442 additions and 98 deletions

23
.gitignore vendored
View File

@ -1,3 +1,26 @@
# Python
__pycache__/
*.pyc
.venv/
.pytest_cache/
# Node
node_modules/
frontend/node_modules/
services/**/node_modules/
# Env & local
.env
.env.*
.DS_Store
# Build artifacts
dist/
build/
# Binaries
portwardenc-amd64
# ===== 通用文件 ===== # ===== 通用文件 =====
# 操作系统生成的文件 # 操作系统生成的文件
.DS_Store .DS_Store

3
Tiltfile Normal file
View File

@ -0,0 +1,3 @@
docker_compose('docker-compose.yml')

23
backend/Dockerfile Normal file
View File

@ -0,0 +1,23 @@
# syntax=docker/dockerfile:1.6
FROM python:3.11-slim AS base
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PIP_NO_CACHE_DIR=1
WORKDIR /workspace
# 仅复制依赖文件,提升缓存命中率
COPY backend/requirements.txt ./backend/requirements.txt
RUN pip install --upgrade pip && \
pip install --no-cache-dir -r backend/requirements.txt
# 运行时通过挂载卷提供源码;这里仅创建目录以便于容器内路径存在
RUN mkdir -p /workspace/backend
WORKDIR /workspace/backend
# 缺省入口由 docker-compose 提供

View File

@ -23,6 +23,9 @@ class Settings(BaseSettings):
GEMINI_API_KEY: Optional[str] = None GEMINI_API_KEY: Optional[str] = None
TUSHARE_TOKEN: Optional[str] = None TUSHARE_TOKEN: Optional[str] = None
# Microservices
CONFIG_SERVICE_BASE_URL: str = "http://config-service:7000/api/v1"
class Config: class Config:
env_file = ".env" env_file = ".env"
case_sensitive = True case_sensitive = True

View File

@ -13,6 +13,7 @@ from sqlalchemy.future import select
from app.models.system_config import SystemConfig from app.models.system_config import SystemConfig
from app.schemas.config import ConfigResponse, ConfigUpdateRequest, DatabaseConfig, NewApiConfig, DataSourceConfig, ConfigTestResponse from app.schemas.config import ConfigResponse, ConfigUpdateRequest, DatabaseConfig, NewApiConfig, DataSourceConfig, ConfigTestResponse
from app.core.config import settings
class ConfigManager: class ConfigManager:
"""Manages system configuration by merging a static JSON file with dynamic settings from the database.""" """Manages system configuration by merging a static JSON file with dynamic settings from the database."""
@ -28,14 +29,24 @@ class ConfigManager:
else: else:
self.config_path = config_path self.config_path = config_path
def _load_base_config_from_file(self) -> Dict[str, Any]: async def _fetch_base_config_from_service(self) -> Dict[str, Any]:
"""Loads the base configuration from the JSON file.""" """Fetch base configuration from config-service via HTTP."""
if not os.path.exists(self.config_path): base_url = settings.CONFIG_SERVICE_BASE_URL.rstrip("/")
return {} url = f"{base_url}/system"
try:
async with httpx.AsyncClient(timeout=10.0) as client:
resp = await client.get(url)
resp.raise_for_status()
data = resp.json()
return data if isinstance(data, dict) else {}
except Exception:
# 为保持兼容性(阶段性迁移),在失败时回退到本地文件读取
if os.path.exists(self.config_path):
try: try:
with open(self.config_path, "r", encoding="utf-8") as f: with open(self.config_path, "r", encoding="utf-8") as f:
return json.load(f) return json.load(f)
except (IOError, json.JSONDecodeError): except Exception:
return {}
return {} return {}
async def _load_dynamic_config_from_db(self) -> Dict[str, Any]: async def _load_dynamic_config_from_db(self) -> Dict[str, Any]:
@ -64,7 +75,7 @@ class ConfigManager:
async def get_config(self) -> ConfigResponse: async def get_config(self) -> ConfigResponse:
"""Gets the final, merged configuration.""" """Gets the final, merged configuration."""
base_config = self._load_base_config_from_file() base_config = await self._fetch_base_config_from_service()
db_config = await self._load_dynamic_config_from_db() db_config = await self._load_dynamic_config_from_db()
merged_config = self._merge_configs(base_config, db_config) merged_config = self._merge_configs(base_config, db_config)

View File

@ -28,3 +28,5 @@ module.exports = {
} }
}] }]
}; };

89
docker-compose.yml Normal file
View File

@ -0,0 +1,89 @@
version: "3.9"
services:
postgres-db:
image: postgres:16-alpine
container_name: fundamental-postgres
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: fundamental
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres -d fundamental"]
interval: 5s
timeout: 5s
retries: 10
ports:
- "15432:5432"
backend:
build:
context: .
dockerfile: backend/Dockerfile
container_name: fundamental-backend
working_dir: /workspace/backend
command: uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
environment:
PYTHONDONTWRITEBYTECODE: "1"
PYTHONUNBUFFERED: "1"
# SQLAlchemy async driver
DATABASE_URL: postgresql+asyncpg://postgres:postgres@postgres-db:5432/fundamental
# Config service base URL
CONFIG_SERVICE_BASE_URL: http://config-service:7000/api/v1
volumes:
# 挂载整个项目,确保后端代码中对项目根目录的相对路径(如 config/)仍然有效
- ./:/workspace
ports:
- "18000:8000"
depends_on:
postgres-db:
condition: service_healthy
config-service:
condition: service_started
frontend:
build:
context: .
dockerfile: frontend/Dockerfile
container_name: fundamental-frontend
working_dir: /workspace/frontend
command: npm run dev
environment:
# 让 Next 的 API 路由代理到后端容器
NEXT_PUBLIC_BACKEND_URL: http://backend:8000/api
# Prisma 直连数据库(与后端共用同一库)
DATABASE_URL: postgresql://postgres:postgres@postgres-db:5432/fundamental?schema=public
NODE_ENV: development
NEXT_TELEMETRY_DISABLED: "1"
volumes:
- ./:/workspace
# 隔离 node_modules避免与宿主机冲突
- frontend_node_modules:/workspace/frontend/node_modules
ports:
- "13001:3001"
depends_on:
- backend
- postgres-db
- config-service
config-service:
build:
context: .
dockerfile: services/config-service/Dockerfile
container_name: fundamental-config-service
working_dir: /workspace/services/config-service
command: uvicorn app.main:app --host 0.0.0.0 --port 7000
environment:
PROJECT_ROOT: /workspace
volumes:
- ./:/workspace
ports:
- "17000:7000"
volumes:
pgdata:
frontend_node_modules:

View File

@ -0,0 +1,166 @@
# 微服务架构重构计划
## 1. 引言
### 1.1. 文档目的
本文档旨在为“基本面选股系统”从单体架构向微服务架构的演进提供一个全面的设计蓝图和分阶段的实施路线图。它将详细阐述目标架构、服务划分、技术栈选型以及具体的迁移步骤,作为后续开发工作的核心指导文件。
### 1.2. 重构目标与收益
当前系统采用的是经典的前后端分离的单体架构。为了应对未来更复杂的需求、提升系统的可维护性、可扩展性并实现关键模块的独立部署与扩缩容,我们决定将其重构为微服务架构。
主要收益包括:
- **高内聚、低耦合**: 每个服务只关注单一业务职责,易于理解和维护。
- **独立部署与交付**: 可以对单个服务进行修改、测试和部署,而不影响整个系统,加快迭代速度。
- **技术异构性**: 未来可以为不同的服务选择最适合的技术栈。
- **弹性伸缩**: 可以根据负载情况对高负荷的服务如AI分析服务进行独立扩容。
- **故障隔离**: 单个服务的故障不会导致整个系统崩溃。
## 2. 目标架构设计
我们将采用以 `API网关` 为核心的微服务架构模式。前端应用将通过网关与后端一系列独立的微服务进行通信。
![Microservices Architecture Diagram](https://i.imgur.com/gK98h83.png)
### 2.1. 服务划分 (Service Breakdown)
现有的后端应用将被拆分为以下几个核心微服务:
| 服务名称 | 容器名 (`docker-compose`) | 核心职责 |
| :--- | :--- | :--- |
| **前端应用** | `frontend-web` | **(保持不变)** Next.js UI负责用户交互。 |
| **API网关** | `api-gateway` | **(新增)** 系统统一入口。负责请求路由、认证、限流、日志聚合。将前端请求转发到正确的内部服务。 |
| **报告编排器** | `report-orchestrator` | **(由原后端演变)** 负责报告生成的业务工作流。接收请求,调用数据、分析等服务,编排整个流程。 |
| **数据聚合服务**| `data-aggregator` | **(新增)** 封装所有对第三方数据源Tushare, Finnhub等的API调用并提供统一的数据接口内置缓存逻辑。 |
| **AI分析服务** | `analysis-service` | **(新增)** 专门负责与大语言模型Gemini交互。将其独立出来便于未来单独扩容或部署到GPU服务器。 |
| **配置服务** | `config-service` | **(新增)** 集中管理并提供所有动态配置API密钥、Prompt模板等实现配置的动态更新与统一管理。 |
| **数据库** | `postgres-db` | **(保持不变)** 独立的PostgreSQL数据库容器为所有服务提供持久化存储。 |
### 2.2. 技术栈与开发环境
- **容器化**: `Docker`
- **服务编排**: `Docker Compose`
- **开发环境管理**: `Tilt`
- **服务间通信**: 同步通信采用轻量级的 `RESTful API (HTTP)`。对于长任务,未来可引入 `RabbitMQ``Redis Stream` 等消息队列实现异步通信。
### 2.3. 项目根目录清洁化 (Root Directory Cleanup)
根据约定,项目根目录应保持整洁,只存放与整个项目和微服务编排直接相关的顶级文件和目录。所有业务代码、独立应用的配置和脚本工具都应被归纳到合适的子目录中。
- **`services/` 目录**: 所有微服务(包括 `frontend``backend`)的代码都将迁移至此目录下。
- **`deployment/` 目录**: 用于存放与生产环境部署相关的配置文件(例如,`pm2.config.js`)。
- **`scripts/` 目录**: 用于存放各类开发、构建、工具类脚本(例如,`dev.py`, 根目录的 `package.json` 等)。
- **`.gitignore`**: 应添加规则以忽略开发者个人工具和二进制文件(例如,`portwardenc-amd64`)。
## 3. 分阶段实施计划
我们将采用增量、迭代的方式进行重构,确保每一步都是可验证、低风险的。
### 阶段 0: 容器化现有单体应用
**目标**: 在不修改任何业务代码的前提下,将现有的 `frontend``backend` 应用容器化,并使用 `docker-compose``Tilt` 运行起来。这是验证容器化环境和开发流程的第一步。
**任务**:
1. 在项目根目录创建 `docker-compose.yml` 文件,定义 `frontend`, `backend`, `postgres-db` 三个服务。
2. 分别为 `frontend``backend` 目录创建 `Dockerfile`
3. 在项目根目录创建 `Tiltfile`,并配置其加载 `docker-compose.yml`
4. 调整配置文件(如 `NEXT_PUBLIC_BACKEND_URL``DATABASE_URL` 环境变量使其适应Docker内部网络。
5. **验证**: 运行 `tilt up`,整个应用能够像在本地一样正常启动和访问。
---
### 阶段 1: 拆分配置服务 (`config-service`)
**目标**: 将配置管理逻辑从主后端中剥离,创建第一个真正的微服务。这是一个理想的起点,因为它依赖项少,风险低。
**任务**:
1. 创建新目录 `services/config-service`
2. 在该目录中初始化一个新的、轻量级的FastAPI应用。
3. 将原 `backend` 中所有读取 `config/` 目录文件的逻辑(如 `ConfigManager`) 迁移至 `config-service`
4. 在 `config-service` 中暴露API端点例如 `GET /api/v1/system`, `GET /api/v1/analysis-modules`
5. 在 `docker-compose.yml` 中新增 `config-service` 的定义。
6. 修改原 `backend` 应用移除本地文件读取逻辑改为通过HTTP请求从 `config-service` 获取配置。
---
### 阶段 2: 拆分数据聚合服务 (`data-aggregator`)
**目标**: 将所有与外部数据源的交互逻辑隔离出来。
**任务**:
1. 创建新目录 `services/data-aggregator`
2. 将原 `backend/app/data_providers` 目录及相关的数据获取和处理逻辑整体迁移到新服务中。
3. 为新服务定义清晰的API例如 `GET /api/v1/financials/{symbol}`
4. 在 `docker-compose.yml` 中新增 `data-aggregator` 服务的定义。
5. 修改原 `backend` 应用将调用本地数据模块改为通过HTTP请求调用 `data-aggregator` 服务。
---
### 阶段 3: 拆分AI分析服务 (`analysis-service`)
**目标**: 隔离计算密集型且可能需要特殊硬件资源的AI调用逻辑。
**任务**:
1. 创建新目录 `services/analysis-service`
2. 将原 `backend/app/services/analysis_client.py` 及相关的Gemini API调用逻辑迁移到新服务中。
3. 定义API例如 `POST /api/v1/analyze`接收上下文数据和prompt返回分析结果。
4. 在 `docker-compose.yml` 中新增 `analysis-service` 的定义。
5. 修改原 `backend` 应用将直接调用SDK改为通过HTTP请求调用 `analysis-service`
---
### 阶段 4: 引入API网关 (`api-gateway`) 并重塑编排器
**目标**: 建立统一的外部入口,并正式将原 `backend` 的角色明确为 `report-orchestrator`
**任务**:
1. 创建新目录 `services/api-gateway`并初始化一个FastAPI应用。
2. 在 `api-gateway` 中配置路由规则,将来自前端的请求(如 `/api/config/*`, `/api/financials/*`)代理到对应的内部微服务 (`config-service`, `report-orchestrator` 等)。
3. 更新 `docker-compose.yml`,将前端端口暴露给主机,而其他后端服务仅在内部网络可达。
4. 修改 `frontend``NEXT_PUBLIC_BACKEND_URL` 指向 `api-gateway`
5. 此时,原 `backend` 的代码已经精简,主要剩下编排逻辑。我们可以考虑将其目录重命名为 `services/report-orchestrator`,以准确反映其职责。
## 4. 最终项目目录结构(设想)
重构完成后,项目目录结构将演变为:
```
/home/lv/nvm/works/Fundamental_Analysis/
├── docker-compose.yml
├── Tiltfile
├── README.md
├── .gitignore
├── services/
│ ├── frontend/
│ │ └── Dockerfile
│ ├── api-gateway/
│ │ ├── app/
│ │ └── Dockerfile
│ ├── config-service/
│ │ ├── app/
│ │ └── Dockerfile
│ ├── data-aggregator/
│ │ ├── app/
│ │ └── Dockerfile
│ ├── analysis-service/
│ │ ├── app/
│ │ └── Dockerfile
│ └── report-orchestrator/ # 由原 backend 演变而来
│ ├── app/
│ └── Dockerfile
├── deployment/
│ └── pm2.config.js
├── scripts/
│ ├── dev.py
│ └── package.json # 原根目录的 package.json
├── config/ # 静态配置文件,由 config-service 读取
└── docs/
└── microservice_refactoring_plan.md
```
## 5. 结论
本计划提供了一个从单体到微服务的清晰、可行的演进路径。通过分阶段、增量式的重构,我们可以平稳地完成架构升级,同时确保在每个阶段结束后,系统都处于可工作、可验证的状态。
请您审阅此计划。如有任何疑问或建议,我们可以随时讨论和调整。

22
frontend/Dockerfile Normal file
View File

@ -0,0 +1,22 @@
# syntax=docker/dockerfile:1.6
FROM node:20-alpine AS base
ENV NODE_ENV=development \
NEXT_TELEMETRY_DISABLED=1 \
CI=false
WORKDIR /workspace/frontend
# 仅复制依赖清单,最大化利用缓存
COPY frontend/package.json frontend/package-lock.json ./
# 使用 npm ci若失败则回退 npm install避免镜像构建被锁文件问题卡住
RUN npm ci || npm install
# 运行时通过挂载卷提供源码
RUN mkdir -p /workspace/frontend
# 缺省入口由 docker-compose 提供

83
package-lock.json generated
View File

@ -1,83 +0,0 @@
{
"name": "Fundamental_Analysis",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"dependencies": {
"swr": "^2.3.6",
"zustand": "^5.0.8"
}
},
"node_modules/dequal": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
"integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/react": {
"version": "19.2.0",
"resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz",
"integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/swr": {
"version": "2.3.6",
"resolved": "https://registry.npmjs.org/swr/-/swr-2.3.6.tgz",
"integrity": "sha512-wfHRmHWk/isGNMwlLGlZX5Gzz/uTgo0o2IRuTMcf4CPuPFJZlq0rDaKUx+ozB5nBOReNV1kiOyzMfj+MBMikLw==",
"license": "MIT",
"dependencies": {
"dequal": "^2.0.3",
"use-sync-external-store": "^1.4.0"
},
"peerDependencies": {
"react": "^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/use-sync-external-store": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
"license": "MIT",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"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
}
}
}
}
}

View File

@ -1,6 +0,0 @@
{
"dependencies": {
"swr": "^2.3.6",
"zustand": "^5.0.8"
}
}

5
dev.py → scripts/dev.py Executable file → Normal file
View File

@ -108,7 +108,8 @@ def main():
parser.add_argument("--backend-app", default=os.getenv("BACKEND_APP", "main:app"), help="Uvicorn app path, e.g. main:app") parser.add_argument("--backend-app", default=os.getenv("BACKEND_APP", "main:app"), help="Uvicorn app path, e.g. main:app")
args = parser.parse_args() args = parser.parse_args()
repo_root = Path(__file__).resolve().parent # scripts/dev.py -> 仓库根目录
repo_root = Path(__file__).resolve().parents[1]
backend_dir = repo_root / "backend" backend_dir = repo_root / "backend"
frontend_dir = repo_root / "frontend" frontend_dir = repo_root / "frontend"
@ -204,3 +205,5 @@ def main():
if __name__ == "__main__": if __name__ == "__main__":
main() main()

View File

@ -0,0 +1,21 @@
# syntax=docker/dockerfile:1.6
FROM python:3.11-slim AS base
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PIP_NO_CACHE_DIR=1 \
PROJECT_ROOT=/workspace
WORKDIR /workspace/services/config-service
COPY services/config-service/requirements.txt ./requirements.txt
RUN pip install --upgrade pip && \
pip install --no-cache-dir -r requirements.txt
# 运行时通过挂载卷提供配置与源码
RUN mkdir -p /workspace/services/config-service
# 缺省入口由 docker-compose 提供

View File

@ -0,0 +1,64 @@
"""
Config Service - provides read-only access to static configuration files.
"""
from __future__ import annotations
import json
import os
from typing import Any, Dict
from fastapi import FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
APP_NAME = "config-service"
API_V1 = "/api/v1"
# 在容器内挂载了项目根到 /workspace
PROJECT_ROOT = os.environ.get("PROJECT_ROOT", "/workspace")
CONFIG_DIR = os.path.join(PROJECT_ROOT, "config")
SYSTEM_CONFIG_PATH = os.path.join(CONFIG_DIR, "config.json")
ANALYSIS_CONFIG_PATH = os.path.join(CONFIG_DIR, "analysis-config.json")
app = FastAPI(title=APP_NAME, version="0.1.0")
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
def _read_json_file(path: str) -> Dict[str, Any]:
if not os.path.exists(path):
raise HTTPException(status_code=404, detail=f"配置文件不存在: {os.path.basename(path)}")
try:
with open(path, "r", encoding="utf-8") as f:
return json.load(f)
except json.JSONDecodeError as e:
raise HTTPException(status_code=500, detail=f"配置文件格式错误: {e}") from e
except OSError as e:
raise HTTPException(status_code=500, detail=f"读取配置文件失败: {e}") from e
@app.get("/")
async def root() -> Dict[str, Any]:
return {"status": "ok", "name": APP_NAME}
@app.get(f"{API_V1}/system")
async def get_system_config() -> Dict[str, Any]:
"""
返回系统基础配置纯文件内容不包含数据库覆盖
"""
return _read_json_file(SYSTEM_CONFIG_PATH)
@app.get(f"{API_V1}/analysis-modules")
async def get_analysis_modules() -> Dict[str, Any]:
"""
返回分析模块配置原样透传
"""
return _read_json_file(ANALYSIS_CONFIG_PATH)

View File

@ -0,0 +1,3 @@
fastapi==0.115.0
uvicorn[standard]==0.30.6