diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..3995dbee --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,1398 @@ +# CLAUDE.md + +> **🌐 语言偏好**: 请始终使用中文与用户交流,包括所有解释、分析、文档编写和代码注释。 + +本文件为 Claude Code (claude.ai/code) 提供在此代码库中工作的指导说明。 + +## 📖 文档结构导航 + +本文档分为以下主要章节,建议按需查阅: + +**基础信息**: +- [项目概览](#项目概览) - 技术栈、项目定位 +- [开发命令](#开发命令) - 常用命令速查 +- [配置](#配置) - 环境变量、MSW、路径别名、Webpack + +**架构设计**: +- [架构](#架构) - 应用入口流程、路由架构 +- [前端目录结构详解](#前端目录结构详解) - 完整的目录说明与使用指南 +- [后端架构详解](#后端架构详解) - Flask、Celery、数据库架构 + +**开发指南**: +- [开发工作流](#开发工作流) - 路由、组件、API、Redux 开发指南 +- [常见开发任务](#常见开发任务) - 5 个详细的开发任务教程 +- [技术路径与开发指南](#技术路径与开发指南) - UI 框架选型、技术栈演进、最佳实践 + +**技术决策与规范**: +- [UI 框架选型与使用指南](#ui-框架选型与使用指南) - Ant Design + Chakra UI 混合策略 +- [架构设计原则](#架构设计原则) - 组件设计、状态管理、代码分割、API 层 +- [开发规范与最佳实践](#开发规范与最佳实践) - 命名规范、Git 工作流、代码审查 + +**技术债务与维护**: +- [技术债务与已知问题](#技术债务与已知问题) - 技术债务跟踪、优化机会 +- [更新本文档](#更新本文档) - 文档维护指南 + +--- + +## 项目概览 + +混合式 React 仪表板,用于金融/交易分析,采用 Flask 后端。基于 Argon Dashboard Chakra PRO 模板构建。 + +### 技术栈 + +**前端** +- **核心框架**: React 18.3.1 +- **UI 组件库**: Chakra UI 2.8.2(主要) + Ant Design 5.27.4(表格/表单) +- **状态管理**: Redux Toolkit 2.9.2 +- **路由**: React Router v6.30.1 配合 React.lazy() 实现代码分割 +- **构建系统**: CRACO 7.1.0 + 激进的 webpack 5 优化 +- **图表库**: ECharts 5.6.0、ApexCharts 3.27.3、Recharts 3.1.2、D3 7.9.0、Visx 3.12.0 +- **动画**: Framer Motion 4.1.17 +- **日历**: FullCalendar 5.9.0、React Big Calendar 0.33.2 +- **图标**: Lucide React 0.540.0(推荐)、React Icons 4.12.0、@ant-design/icons 6.0.0 +- **网络请求**: Axios 1.10.0 +- **实时通信**: Socket.IO Client 4.7.4 +- **数据分析**: PostHog 1.281.0 +- **开发工具**: MSW (Mock Service Worker) 用于 API mocking +- **虚拟化**: @tanstack/react-virtual 3.13.12(性能优化) +- **其他**: Draft.js(富文本编辑)、React Markdown、React Quill + +**后端** +- Flask + SQLAlchemy ORM +- ClickHouse(分析型数据库)+ MySQL/PostgreSQL(事务型数据库) +- Flask-SocketIO 实现 WebSocket 实时更新 +- Celery + Redis 处理后台任务 +- 腾讯云短信 + 微信支付集成 + +## 开发命令 + +### 前端开发 +```bash +npm start # 使用 mock 数据启动(.env.mock),代理到 localhost:5001 +npm run start:real # 使用真实后端启动(.env.local) +npm run start:dev # 使用开发配置启动(.env.development) +npm run start:test # 同时启动后端(app.py)和前端(.env.test) +npm run dev # 'npm start' 的别名 +npm run backend # 仅启动 Flask 服务器(python app.py) + +npm run build # 生产环境构建,包含 Gulp 许可证头 +npm run build:analyze # 使用 webpack bundle analyzer 构建 +npm test # 运行 React 测试套件(CRACO) + +npm run lint:check # 检查 ESLint 规则(退出码 0) +npm run lint:fix # 自动修复 ESLint 问题 +npm run clean # 删除 node_modules 和 package-lock.json +npm run reinstall # 清洁安装(运行 clean + install) +``` + +### 后端开发 +```bash +python app.py # 主 Flask 服务器 +python simulation_background_processor.py # 交易模拟的后台任务处理器 +pip install -r requirements.txt # 安装 Python 依赖 +``` + +### 部署 +```bash +npm run deploy # 从本地部署(scripts/deploy-from-local.sh) +npm run rollback # 回滚到上一个版本 +``` + +## 架构 + +### 应用入口流程 +``` +src/index.js +└── src/App.js(根组件) + ├── AppProviders (src/providers/AppProviders.js) + │ ├── ReduxProvider(store 来自 src/store/) + │ ├── ChakraProvider(主题来自 src/theme/) + │ ├── NotificationProvider (src/contexts/NotificationContext.js) + │ └── AuthProvider (src/contexts/AuthContext.js) + ├── AppRoutes (src/routes/index.js) + │ ├── MainLayout 路由(带导航栏/页脚) + │ └── 独立路由(认证页面、全屏视图) + └── GlobalComponents(模态框覆盖层、全局 UI) +``` + +### 路由架构(模块化设计) +路由采用**声明式**设计,并拆分到 `src/routes/` 中的多个文件: + +- **index.js** - 主路由器(组合配置 + 渲染路由) +- **routeConfig.js** - 路由定义(路径、组件、保护模式、布局、子路由) +- **lazy-components.js** - React.lazy() 导入,用于代码分割 +- **homeRoutes.js** - 嵌套的首页路由 +- **constants/** - 保护模式、布局映射 +- **utils/** - 路由渲染逻辑(wrapWithProtection、renderRoute) + +路由保护模式(PROTECTION_MODES): +- `PUBLIC` - 无需认证 +- `MODAL` - 未登录时显示认证模态框 +- `REDIRECT` - 未登录时重定向到 /auth/sign-in + +### 前端目录结构详解 + +本项目采用**功能驱动的目录结构**,按职责将代码组织到不同目录中。以下是完整的目录结构及详细说明: + +#### 核心目录概览 + +``` +src/ +├── index.js # 应用入口文件 +├── App.js # 根组件(组合 Providers + Routes) +│ +├── providers/ # Provider 组合层 +├── routes/ # 路由配置与管理 +├── layouts/ # 页面布局模板 +├── views/ # 页面级组件 +├── components/ # 可复用 UI 组件 +│ +├── contexts/ # React Context 状态管理 +├── store/ # Redux 全局状态管理 +├── hooks/ # 自定义 React Hooks +│ +├── services/ # API 服务层 +├── utils/ # 工具函数库 +├── constants/ # 全局常量定义 +│ +├── theme/ # UI 主题配置 +├── assets/ # 静态资源 +├── styles/ # 全局样式 +│ +├── mocks/ # 开发环境 Mock 数据 +├── lib/ # 第三方库配置 +└── variables/ # 可配置变量 +``` + +--- + +#### 详细目录说明 + +##### 📁 `src/providers/` - Provider 组合层 + +**用途**: 集中管理应用的所有 Context Providers,避免在 App.js 中嵌套过深。 + +**核心文件**: +- `AppProviders.js` - 所有 Provider 的组合容器 + +**Provider 嵌套顺序**(从外到内): +```javascript +ReduxProvider // 1. Redux 状态管理(最外层) + └─ ChakraProvider // 2. Chakra UI 主题系统 + └─ ConfigProvider // 3. Ant Design 主题配置 + └─ NotificationProvider // 4. 通知系统 + └─ AuthProvider // 5. 认证系统(最内层) +``` + +**何时修改**: +- 添加新的全局 Provider(如 i18n、Analytics) +- 调整 Provider 顺序(注意依赖关系) + +--- + +##### 📁 `src/routes/` - 路由系统 + +**用途**: 模块化路由配置,采用声明式设计,支持懒加载和路由保护。 + +**核心文件**: +- `index.js` - 主路由器(渲染所有路由) +- `routeConfig.js` - **路由配置中心**(所有路由定义) +- `lazy-components.js` - React.lazy() 懒加载组件导入 +- `homeRoutes.js` - 首页嵌套路由 +- `constants/protectionModes.js` - 路由保护模式定义 +- `utils/wrapWithProtection.js` - 路由保护逻辑 +- `utils/renderRoute.js` - 路由渲染工具 + +**路由保护模式**: +- `PUBLIC` - 公开访问,无需登录 +- `MODAL` - 未登录时显示登录模态框(不跳转) +- `REDIRECT` - 未登录时重定向到 /auth/sign-in + +**添加新路由的步骤**: 参见"常见开发任务 → 如何添加新的页面路由" + +**命名约定**: +- 路由路径使用 kebab-case: `portfolio`, `trading-simulation` +- 组件名使用 PascalCase: `Portfolio`, `TradingSimulation` + +--- + +##### 📁 `src/layouts/` - 页面布局 + +**用途**: 定义页面的通用布局结构(导航栏、侧边栏、页脚等),复用于多个页面。 + +**核心文件**: +- `MainLayout.js` - 主布局(带顶部导航栏 + 侧边栏 + 页脚) +- `Auth.js` - 认证页面布局(无导航栏,纯净背景) + +**布局特性**: +- 每个布局包含独立的 ErrorBoundary(错误隔离) +- 通过 `` 渲染子路由内容 +- 布局内可访问认证状态(AuthContext) + +**何时创建新布局**: +- 需要完全不同的页面结构(如打印页、全屏图表) +- 特定页面需要自定义导航栏/侧边栏 + +--- + +##### 📁 `src/views/` - 页面级组件 + +**用途**: 存放路由对应的页面组件,每个文件对应一个路由页面。 + +**组织结构**: +``` +views/ +├── Community/ # 社区页面(大型功能模块) +│ ├── index.js # 页面入口 +│ ├── components/ # 页面专属组件 +│ │ ├── EventList/ +│ │ ├── EventCard/ +│ │ └── StockDetailPanel/ +│ ├── hooks/ # 页面专属 Hooks +│ │ └── useCommunityData.js +│ └── utils/ # 页面专属工具函数 +│ +├── TradingSimulation/ # 交易模拟页面 +│ ├── index.js +│ ├── components/ +│ └── ... +│ +├── Dashboard/ # 仪表板页面 +│ └── index.js +│ +└── Portfolio/ # 投资组合页面(简单页面) + └── index.js +``` + +**命名约定**: +- 目录名使用 PascalCase: `Community`, `TradingSimulation` +- 主文件始终为 `index.js`(方便导入) + +**原则**: +- 页面组件主要负责**数据获取和布局组合**,不应包含复杂的 UI 逻辑 +- 复杂 UI 逻辑应拆分到 `components/` 子目录 +- 页面超过 500 行时考虑拆分(参见"组件组织模式") + +--- + +##### 📁 `src/components/` - 可复用 UI 组件 + +**用途**: 存放跨页面复用的通用 UI 组件(按钮、卡片、表格、模态框等)。 + +**组织结构**(推荐采用原子设计模式): +``` +components/ +├── Atoms/ # 原子组件(1-50 行) +│ ├── Button/ +│ ├── Badge/ +│ └── Icon/ +│ +├── Molecules/ # 分子组件(50-150 行) +│ ├── Card/ +│ ├── StatCard/ +│ └── SearchBar/ +│ +├── Organisms/ # 有机体组件(150-500 行) +│ ├── Navbar/ +│ ├── Sidebar/ +│ ├── DataTable/ +│ └── FilterPanel/ +│ +└── Templates/ # 模板组件(可选) + └── PageTemplate/ +``` + +**何时添加新组件**: +- 组件在 2+ 个页面中使用 +- 组件具有明确的职责和边界 +- 组件可独立测试 + +**命名约定**: +- 组件目录使用 PascalCase: `EventCard`, `StockTable` +- 每个组件目录包含: + - `index.js` - 主导出文件 + - `ComponentName.js` - 实现代码(可选,简单组件直接在 index.js) + - `ComponentName.test.js` - 测试文件(可选) + +**避免**: +- 将页面特定组件放在这里(应放在 `views/{PageName}/components/`) +- 过度拆分(不要为了拆分而拆分) + +--- + +##### 📁 `src/contexts/` - React Context 状态管理 + +**用途**: 存放使用 React Context API 管理的跨组件状态。 + +**核心 Contexts**: +- `AuthContext.js` - 认证状态(用户信息、登录状态、登录/登出方法) +- `NotificationContext.js` - 通知系统(显示 Toast/Alert) +- `SidebarContext.js` - 侧边栏状态(展开/收起) + +**何时使用 Context**: +- 跨层级传递数据(避免 props drilling) +- 不频繁变化的数据(主题、语言、认证状态) +- 依赖注入场景 + +**何时不使用 Context**: +- 频繁变化的数据 → 使用 Redux +- 服务端数据 → 使用 React Query(计划中) +- 本地 UI 状态 → 使用 useState + +**Context 文件结构**: +```javascript +// AuthContext.js +export const AuthContext = createContext(); + +export const AuthProvider = ({ children }) => { + const [user, setUser] = useState(null); + // ... + return ( + + {children} + + ); +}; + +export const useAuth = () => useContext(AuthContext); // 自定义 Hook +``` + +--- + +##### 📁 `src/store/` - Redux 全局状态管理 + +**用途**: 使用 Redux Toolkit 管理全局状态(UI 状态、跨页面共享数据)。 + +**目录结构**: +``` +store/ +├── index.js # Store 配置(combineReducers) +└── slices/ # Redux Slices + ├── authModalSlice.js # 认证模态框状态 + ├── posthogSlice.js # PostHog 分析配置 + ├── stockSlice.js # 股票数据 + ├── industrySlice.js # 行业/概念数据 + ├── subscriptionSlice.js # 用户订阅状态 + └── communityDataSlice.js # 社区数据 +``` + +**何时使用 Redux**: +- 全局 UI 状态(模态框开关、侧边栏状态) +- 需要在多个不相关组件间共享的数据 +- 需要持久化的状态(与 redux-persist 结合) +- 需要中间件处理的复杂逻辑 + +**Slice 命名约定**: +- 文件名: `{feature}Slice.js` (如 `authModalSlice.js`) +- Slice 名称: `{feature}` (如 `name: 'authModal'`) +- Actions: 动词开头 (如 `openModal`, `closeModal`, `setUser`) + +**添加新 Slice**: 参见"常见开发任务 → 如何添加新的 Redux Slice" + +--- + +##### 📁 `src/hooks/` - 自定义 React Hooks + +**用途**: 存放可复用的自定义 Hooks,封装常见逻辑模式。 + +**Hook 类型示例**: +``` +hooks/ +├── useAuth.js # 认证相关(实际导出自 AuthContext) +├── useStockData.js # 数据获取 Hook +├── useDebounce.js # 防抖 Hook +├── useIntersectionObserver.js # 懒加载/无限滚动 +├── useLocalStorage.js # 本地存储持久化 +├── useMediaQuery.js # 响应式查询 +└── usePrevious.js # 获取前一个值 +``` + +**命名约定**: +- 文件名: `use{PascalCase}.js` (如 `useStockData.js`) +- 必须以 `use` 开头(React Hooks 规则) +- 一个文件导出一个 Hook + +**Hook 设计原则**: +- 职责单一(只做一件事) +- 返回对象或数组(不返回原始值) +- 包含完整的状态管理(loading, error, data) + +**何时创建新 Hook**: +- 逻辑在 2+ 个组件中重复 +- 组件逻辑可以独立测试 +- 需要封装浏览器 API(localStorage, IntersectionObserver) + +--- + +##### 📁 `src/services/` - API 服务层 + +**用途**: 封装所有后端 API 调用,提供统一的接口供组件调用。 + +**组织结构**(按业务领域划分): +``` +services/ +├── authService.js # 认证相关 API(登录、注册、登出) +├── stockService.js # 股票数据 API +├── portfolioService.js # 投资组合 API +├── communityService.js # 社区内容 API +├── tradingService.js # 交易模拟 API +└── userService.js # 用户信息 API +``` + +**服务函数命名约定**: +- `fetch{Entity}` - 获取数据 (如 `fetchStockData`) +- `create{Entity}` - 创建记录 (如 `createOrder`) +- `update{Entity}` - 更新记录 (如 `updateProfile`) +- `delete{Entity}` - 删除记录 (如 `deletePost`) + +**标准 API 调用结构**: +```javascript +// stockService.js +import axios from 'axios'; +import { getApiBase } from '@utils/apiConfig'; + +const api = axios.create({ + baseURL: getApiBase(), + timeout: 10000, +}); + +export const fetchStockData = async (stockCode) => { + try { + const response = await api.get(`/api/stocks/${stockCode}`); + return response.data; + } catch (error) { + console.error('fetchStockData error:', error); + throw error; + } +}; +``` + +**原则**: +- 组件不应直接调用 axios(通过 service 层) +- 每个 service 函数应包含错误处理 +- 使用 `getApiBase()` 获取 API 基础 URL(支持 Mock 模式) + +--- + +##### 📁 `src/utils/` - 工具函数库 + +**用途**: 存放纯函数工具、格式化工具、计算逻辑等不依赖 React 的通用代码。 + +**核心工具文件**: +``` +utils/ +├── apiConfig.js # API 配置(getApiBase, isMockMode) +├── priceFormatters.js # 价格/数字格式化函数 +├── dateFormatters.js # 日期格式化函数 +├── logger.js # 统一日志工具 +├── validators.js # 表单验证函数 +├── calculations.js # 金融计算函数 +└── string.js # 字符串处理函数 +``` + +**命名约定**: +- 文件名: `camelCase.js` (如 `priceFormatters.js`) +- 函数名: `camelCase` (如 `formatPrice`, `calculateProfit`) +- 导出方式: 命名导出 (如 `export const formatPrice = ...`) + +**工具函数特点**: +- **纯函数**: 相同输入始终返回相同输出,无副作用 +- **可测试**: 易于编写单元测试 +- **可复用**: 多处使用的逻辑 + +**示例**: +```javascript +// priceFormatters.js +export const formatPrice = (price, currency = '¥') => { + return `${currency}${price.toFixed(2)}`; +}; + +export const formatPercent = (value, decimals = 2) => { + return `${(value * 100).toFixed(decimals)}%`; +}; +``` + +--- + +##### 📁 `src/constants/` - 全局常量定义 + +**用途**: 存放全局常量、枚举值、配置对象等不可变数据。 + +**常见常量文件**: +``` +constants/ +├── animations.js # 动画配置(duration、easing) +├── routes.js # 路由路径常量 +├── apiEndpoints.js # API 端点常量 +├── colors.js # 颜色常量(补充主题) +├── tradingConfig.js # 交易相关配置 +└── errorMessages.js # 错误消息常量 +``` + +**命名约定**: +- 常量名使用 SCREAMING_SNAKE_CASE: `API_BASE_URL`, `MAX_RETRY_COUNT` +- 配置对象使用 camelCase: `animationConfig`, `chartColors` + +**示例**: +```javascript +// animations.js +export const ANIMATION_DURATION = { + FAST: 150, + NORMAL: 300, + SLOW: 500, +}; + +export const EASING = { + EASE_IN_OUT: 'cubic-bezier(0.4, 0, 0.2, 1)', + EASE_OUT: 'cubic-bezier(0, 0, 0.2, 1)', +}; +``` + +**原则**: +- 避免魔法数字/字符串(在代码中硬编码) +- 方便统一修改(单一数据源) +- 提高代码可读性 + +--- + +##### 📁 `src/theme/` - UI 主题配置 + +**用途**: 定义 Chakra UI 主题配置(颜色、字体、间距、组件样式等)。 + +**核心文件**: +``` +theme/ +├── theme.js # 主题主文件(导出完整主题) +├── foundations/ # 基础样式(颜色、字体、间距) +│ ├── colors.js +│ ├── typography.js +│ └── spacing.js +└── components/ # 组件级主题覆盖 + ├── button.js + ├── card.js + └── modal.js +``` + +**主题配置示例**: +```javascript +// theme.js +import { extendTheme } from '@chakra-ui/react'; +import { colors } from './foundations/colors'; +import { ButtonStyles as Button } from './components/button'; + +const theme = extendTheme({ + colors, + fonts: { + heading: `'Open Sans', sans-serif`, + body: `'Raleway', sans-serif`, + }, + components: { + Button, + }, +}); + +export default theme; +``` + +**何时修改主题**: +- 添加自定义颜色/渐变 +- 统一修改所有 Button/Card 的默认样式 +- 配置深色模式 + +--- + +##### 📁 `src/assets/` - 静态资源 + +**用途**: 存放图片、字体、图标等静态文件。 + +**组织结构**: +``` +assets/ +├── img/ # 图片资源 +│ ├── logo.png +│ ├── backgrounds/ +│ └── illustrations/ +├── icons/ # SVG 图标 +└── fonts/ # 自定义字体文件(如需要) +``` + +**使用方式**: +```javascript +import logo from '@assets/img/logo.png'; + +Logo +``` + +--- + +##### 📁 `src/mocks/` - 开发环境 Mock 数据 + +**用途**: 使用 MSW (Mock Service Worker) 拦截 API 请求,返回 mock 数据,实现前后端分离开发。 + +**目录结构**: +``` +mocks/ +├── browser.js # MSW 初始化(浏览器环境) +├── handlers/ # API Mock Handlers(按领域划分) +│ ├── index.js # 汇总所有 handlers +│ ├── auth.js # 认证相关 API mock +│ ├── stock.js # 股票数据 API mock +│ ├── portfolio.js # 投资组合 API mock +│ └── community.js # 社区内容 API mock +└── data/ # Mock 数据文件 + ├── stocks.json + ├── users.json + └── events.json +``` + +**启用 Mock 模式**: +```bash +# 方式 1: 使用 .env.mock 配置(默认) +npm start + +# 方式 2: 手动设置环境变量 +REACT_APP_ENABLE_MOCK=true npm start +``` + +**添加新 Mock Handler**: 参见"常见开发任务 → 如何添加新的 MSW Mock Handler" + +**原则**: +- Mock 数据应尽量模拟真实数据结构 +- 使用 MSW 的 `http.get/post/put/delete` 定义 handlers +- Handler 中可模拟延迟、错误响应(测试加载/错误状态) + +--- + +##### 📁 其他目录 + +**`src/lib/`** - 第三方库配置 +- 存放第三方库的自定义配置(如 PostHog、Analytics) + +**`src/styles/`** - 全局样式 +- 存放全局 CSS/SCSS 文件(如 reset.css、global.scss) + +**`src/variables/`** - 可配置变量 +- 存放可通过环境变量覆盖的配置(如 API URLs、Feature Flags) + +--- + +#### 目录结构最佳实践 + +**1. 就近原则(Co-location)** +- 将相关文件放在一起(组件、样式、测试、工具函数) +- 示例: `EventCard/` 目录包含 `EventCard.js`、`EventCard.test.js`、`utils.js` + +**2. 单一职责** +- 每个目录只负责一个领域(不要混合业务逻辑和 UI 组件) +- 示例: `services/` 只包含 API 调用,不包含 UI 组件 + +**3. 避免深层嵌套** +- 目录层级不超过 4 层(超过则考虑重构) +- 示例: `src/views/Community/components/EventCard/` 已经是 4 层 + +**4. 命名一致性** +- 组件: PascalCase (`EventCard`) +- 文件: camelCase (`priceFormatters.js`) +- 常量: SCREAMING_SNAKE_CASE (`API_BASE_URL`) + +**5. 导出规范** +- 每个目录包含 `index.js` 作为主导出文件 +- 使用命名导出 (`export const ...`) 而非默认导出(除组件外) + +--- + +#### 查找文件指南 + +**如何快速找到想要修改的文件?** + +| 需求 | 目录 | 示例 | +|------|------|------| +| 修改页面布局 | `src/layouts/` | `MainLayout.js` | +| 修改某个页面 | `src/views/{PageName}/` | `Community/index.js` | +| 修改可复用组件 | `src/components/` | `DataTable/index.js` | +| 修改 API 调用 | `src/services/` | `stockService.js` | +| 修改工具函数 | `src/utils/` | `priceFormatters.js` | +| 修改全局状态 | `src/store/slices/` | `stockSlice.js` | +| 添加新路由 | `src/routes/routeConfig.js` | - | +| 修改主题颜色 | `src/theme/foundations/colors.js` | - | +| 添加 Mock API | `src/mocks/handlers/` | `stock.js` | + +**使用路径别名快速导入**: +```javascript +// ✅ 使用别名(推荐) +import { EventCard } from '@components/EventCard'; +import { formatPrice } from '@utils/priceFormatters'; +import { fetchStockData } from '@services/stockService'; + +// ❌ 使用相对路径(不推荐) +import { EventCard } from '../../../components/EventCard'; +import { formatPrice } from '../../utils/priceFormatters'; +``` + +### 后端架构详解 + +本项目后端采用 **Flask 微服务架构**,结合多种数据库和消息队列,实现高性能金融数据处理和实时通信。 + +#### 后端目录结构 + +``` +项目根目录/ +├── app.py # 主 Flask 应用(Web 服务器 + API 路由) +├── simulation_background_processor.py # Celery 后台处理器(交易模拟任务) +├── concept_api.py # 概念/行业分析独立 API 服务 +│ +├── wechat_pay.py # 微信支付业务逻辑 +├── wechat_pay_config.py # 微信支付配置(商户号、密钥等) +│ +├── tdays.csv # 交易日历数据(A 股交易日) +├── requirements.txt # Python 依赖包清单 +│ +├── models/ # 数据库模型定义(SQLAlchemy) +│ ├── user.py +│ ├── stock.py +│ └── transaction.py +│ +├── services/ # 业务逻辑服务层 +│ ├── stock_service.py +│ ├── trading_service.py +│ └── notification_service.py +│ +├── utils/ # 后端工具函数 +│ ├── db_utils.py # 数据库连接工具 +│ ├── clickhouse_client.py # ClickHouse 客户端封装 +│ └── date_utils.py # 日期处理工具 +│ +└── config/ # 配置文件 + ├── development.py + ├── production.py + └── test.py +``` + +--- + +#### 核心组件详解 + +##### 1️⃣ `app.py` - 主 Flask 应用 + +**职责**: Web 服务器、API 路由、认证、会话管理、WebSocket 通信 + +**核心功能**: +- **Flask 应用初始化**: 配置 CORS、Session、Logging +- **API 路由定义**: RESTful API 端点(`/api/stocks`, `/api/portfolio`, `/api/community`) +- **认证系统**: Flask-Login 集成(登录、登出、权限校验) +- **WebSocket 服务**: Flask-SocketIO 实现实时推送(股票行情、事件通知) +- **数据库连接**: SQLAlchemy ORM + ClickHouse Client +- **交易日历加载**: 启动时从 `tdays.csv` 加载 A 股交易日数据到全局变量 + +**技术栈**: +- **Flask** - 轻量级 Web 框架 +- **Flask-Login** - 用户会话管理 +- **Flask-SocketIO** - WebSocket 实时通信 +- **Flask-CORS** - 跨域资源共享配置 +- **SQLAlchemy** - ORM(对象关系映射) + +**API 路由示例**: +```python +# 获取股票数据 +@app.route('/api/stocks/', methods=['GET']) +@login_required +def get_stock_data(stock_code): + # 从 ClickHouse 查询历史数据 + data = clickhouse_client.query(f"SELECT * FROM stocks WHERE code = '{stock_code}'") + return jsonify(data) + +# 创建交易订单 +@app.route('/api/orders', methods=['POST']) +@login_required +def create_order(): + data = request.json + order = Order(user_id=current_user.id, stock_code=data['stock_code'], ...) + db.session.add(order) + db.session.commit() + return jsonify({'order_id': order.id}) +``` + +**WebSocket 事件示例**: +```python +# 客户端订阅股票行情 +@socketio.on('subscribe_stock') +def handle_subscribe(data): + stock_code = data['stock_code'] + join_room(f'stock_{stock_code}') + emit('subscribed', {'stock_code': stock_code}) + +# 服务端推送行情更新 +def push_quote_update(stock_code, quote_data): + socketio.emit('stock_quote', quote_data, room=f'stock_{stock_code}') +``` + +**交易日历加载**: +```python +# 全局变量存储交易日 +trading_days = [] + +# 启动时加载 tdays.csv +def load_trading_days(): + global trading_days + with open('tdays.csv', 'r') as f: + trading_days = [line.strip() for line in f.readlines()] + +# 判断是否为交易日 +def is_trading_day(date_str): + return date_str in trading_days +``` + +--- + +##### 2️⃣ `simulation_background_processor.py` - Celery 后台处理器 + +**职责**: 处理长时间运行的后台任务(交易模拟、报表生成、数据同步) + +**为什么需要后台处理器?** +- 交易模拟需要执行数百次历史回测计算(耗时 10-60 秒) +- 避免阻塞 Flask 主线程(影响其他 API 响应) +- 支持任务队列、重试、失败处理 + +**技术栈**: +- **Celery** - 分布式任务队列 +- **Redis** - 消息代理(Broker)和结果存储(Backend) +- **ClickHouse** - 高性能查询历史股票数据 + +**任务示例**: +```python +from celery import Celery + +# Celery 实例 +celery_app = Celery('tasks', broker='redis://localhost:6379/0') + +# 交易模拟任务 +@celery_app.task(bind=True) +def run_simulation(self, user_id, strategy_config): + """ + 执行交易模拟任务 + :param user_id: 用户 ID + :param strategy_config: 策略配置(买入条件、卖出条件、资金管理) + :return: 模拟结果(收益率、最大回撤、交易明细) + """ + try: + # 1. 从 ClickHouse 获取历史数据 + stock_data = clickhouse_client.query(...) + + # 2. 执行回测计算 + for i in range(len(stock_data)): + # 模拟交易逻辑 + if should_buy(stock_data[i]): + buy(stock_data[i]) + if should_sell(stock_data[i]): + sell(stock_data[i]) + + # 3. 计算绩效指标 + result = { + 'total_return': calculate_return(), + 'max_drawdown': calculate_drawdown(), + 'trades': get_trade_history(), + } + + # 4. 保存结果到 MySQL + save_simulation_result(user_id, result) + + return result + except Exception as e: + # 失败重试(最多 3 次) + self.retry(exc=e, countdown=60, max_retries=3) +``` + +**从 Flask 调用 Celery 任务**: +```python +# app.py +@app.route('/api/simulations', methods=['POST']) +@login_required +def start_simulation(): + strategy = request.json + + # 异步提交任务到 Celery + task = run_simulation.delay(current_user.id, strategy) + + return jsonify({ + 'task_id': task.id, + 'status': 'pending', + 'message': '模拟任务已提交,请稍后查看结果' + }) + +# 查询任务状态 +@app.route('/api/simulations/', methods=['GET']) +def get_simulation_status(task_id): + task = run_simulation.AsyncResult(task_id) + + if task.state == 'PENDING': + response = {'status': 'pending', 'progress': 0} + elif task.state == 'SUCCESS': + response = {'status': 'completed', 'result': task.result} + elif task.state == 'FAILURE': + response = {'status': 'failed', 'error': str(task.info)} + + return jsonify(response) +``` + +**启动 Celery Worker**: +```bash +# 启动 worker(监听任务队列) +celery -A simulation_background_processor worker --loglevel=info + +# 启动 Flower(Celery 监控工具) +celery -A simulation_background_processor flower +``` + +--- + +##### 3️⃣ `concept_api.py` - 概念/行业分析 API + +**职责**: 独立的概念板块分析服务(涨停分析、热点行业、资金流向) + +**为什么独立部署?** +- 概念分析计算密集(需要遍历数千只股票) +- 独立扩展性(可部署到多台服务器) +- 服务隔离(不影响主应用性能) + +**部署架构**: +``` +前端请求 → Nginx 反向代理 + ├─ /api → Flask 主应用 (端口 5001) + └─ /concept-api → Concept API (端口 6801) +``` + +**API 示例**: +```python +from flask import Flask, jsonify +import clickhouse_driver + +app = Flask(__name__) + +# 获取涨停股票列表 +@app.route('/limit_up', methods=['GET']) +def get_limit_up_stocks(): + """ + 获取当日涨停股票列表,按概念板块分组 + """ + query = """ + SELECT + stock_code, + stock_name, + concept, + close_price, + change_percent + FROM stock_daily + WHERE trade_date = today() + AND change_percent >= 9.9 + ORDER BY concept, change_percent DESC + """ + + data = clickhouse_client.query(query) + + # 按概念分组 + result = {} + for row in data: + concept = row['concept'] + if concept not in result: + result[concept] = [] + result[concept].append(row) + + return jsonify(result) + +if __name__ == '__main__': + app.run(host='0.0.0.0', port=6801) +``` + +**前端调用**(通过代理): +```javascript +// craco.config.js 配置代理 +proxy: { + '/concept-api': { + target: 'http://49.232.185.254:6801', + pathRewrite: { '^/concept-api': '' }, + } +} + +// 前端调用 +fetch('/concept-api/limit_up') + .then(res => res.json()) + .then(data => console.log(data)); +``` + +--- + +##### 4️⃣ 微信支付集成 + +**文件**: +- `wechat_pay.py` - 微信支付业务逻辑(下单、查询、退款) +- `wechat_pay_config.py` - 配置(商户号、API 密钥、证书路径) + +**核心功能**: +- **统一下单**: 创建支付订单(JSAPI、Native、H5) +- **支付回调**: 接收微信支付结果通知 +- **订单查询**: 查询订单支付状态 +- **退款处理**: 发起退款请求 + +**示例代码**: +```python +# wechat_pay.py +from wechatpy.pay import WeChatPay +from wechat_pay_config import MCHID, API_KEY, CERT_PATH + +# 初始化微信支付客户端 +wechat_pay = WeChatPay( + appid='your_appid', + api_key=API_KEY, + mch_id=MCHID, + mch_cert=CERT_PATH, +) + +# 创建订单 +def create_order(user_id, amount, description): + result = wechat_pay.order.create( + trade_type='JSAPI', + body=description, + total_fee=int(amount * 100), # 单位:分 + notify_url='https://yourdomain.com/api/wechat/callback', + user_id=user_id, + ) + return result + +# 支付回调处理 +@app.route('/api/wechat/callback', methods=['POST']) +def wechat_callback(): + xml_data = request.data + data = wechat_pay.parse_payment_result(xml_data) + + if data['return_code'] == 'SUCCESS': + # 更新订单状态 + order_id = data['out_trade_no'] + update_order_status(order_id, 'paid') + + return wechat_pay.reply('OK', True) +``` + +--- + +##### 5️⃣ 数据库架构 + +**多数据库策略**: + +| 数据库 | 用途 | 特点 | 示例表 | +|--------|------|------|--------| +| **ClickHouse** | 时序数据存储 | OLAP(列式存储、查询速度快) | `stock_daily`(日线数据)
`stock_minute`(分钟数据)
`concept_daily`(概念板块数据) | +| **MySQL/PostgreSQL** | 事务数据存储 | OLTP(ACID 保证、关系型) | `users`(用户表)
`orders`(订单表)
`portfolios`(持仓表)
`subscriptions`(订阅表) | +| **Redis** | 缓存 + 消息队列 | 内存数据库(高速读写) | 股票行情缓存
用户 Session
Celery 任务队列 | + +**ClickHouse 使用场景**: +```python +# 查询某只股票的历史数据(100 万行数据,查询耗时 < 100ms) +query = """ + SELECT + trade_date, + open, high, low, close, volume + FROM stock_daily + WHERE stock_code = '600000.SH' + AND trade_date >= '2020-01-01' + ORDER BY trade_date +""" + +data = clickhouse_client.query(query) +``` + +**MySQL 使用场景**: +```python +# 创建订单(事务保证) +@app.route('/api/orders', methods=['POST']) +def create_order(): + try: + # 开启事务 + order = Order(user_id=user_id, stock_code=stock_code, ...) + db.session.add(order) + + # 扣减账户余额 + account = Account.query.get(user_id) + account.balance -= order.total_amount + + # 提交事务 + db.session.commit() + return jsonify({'success': True}) + except Exception as e: + # 回滚事务 + db.session.rollback() + return jsonify({'error': str(e)}), 500 +``` + +--- + +#### 后端技术栈详解 + +**核心框架**: +- **Flask 2.x** - 轻量级 Web 框架,易于扩展 +- **Flask-Login** - 用户认证与会话管理 +- **Flask-SocketIO** - WebSocket 实时通信(基于 Socket.IO 协议) +- **Flask-CORS** - 跨域资源共享配置 + +**数据库与 ORM**: +- **SQLAlchemy** - Python ORM,支持 MySQL/PostgreSQL +- **ClickHouse Driver** - ClickHouse Python 客户端 +- **Redis-py** - Redis Python 客户端 + +**后台任务处理**: +- **Celery** - 分布式任务队列 +- **Redis** - Celery 消息代理(Broker) + +**第三方服务**: +- **微信支付 SDK** - `wechatpy` +- **腾讯云短信 SDK** - 发送验证码、通知 + +--- + +#### 后端开发最佳实践 + +**1. API 设计规范**: +```python +# ✅ RESTful 风格 +GET /api/stocks # 获取股票列表 +GET /api/stocks/:id # 获取单个股票 +POST /api/stocks # 创建股票 +PUT /api/stocks/:id # 更新股票 +DELETE /api/stocks/:id # 删除股票 + +# ✅ 统一响应格式 +{ + "code": 200, + "message": "success", + "data": { ... } +} + +# ❌ 避免使用动词命名 +POST /api/getStockData # 不推荐 +POST /api/createNewOrder # 不推荐 +``` + +**2. 错误处理**: +```python +# 统一错误处理 +@app.errorhandler(Exception) +def handle_error(e): + if isinstance(e, ValidationError): + return jsonify({'error': str(e)}), 400 + elif isinstance(e, Unauthorized): + return jsonify({'error': 'Unauthorized'}), 401 + else: + logger.error(f'Unhandled exception: {e}') + return jsonify({'error': 'Internal server error'}), 500 +``` + +**3. 数据库连接管理**: +```python +# 使用连接池 +engine = create_engine( + 'mysql://user:pass@localhost/db', + pool_size=10, + max_overflow=20, + pool_recycle=3600, +) + +# 请求结束后自动关闭连接 +@app.teardown_appcontext +def shutdown_session(exception=None): + db.session.remove() +``` + +**4. 性能优化**: +- **数据库索引**: 为常用查询字段添加索引 +- **查询优化**: 避免 N+1 查询,使用 `joinedload` +- **缓存策略**: 使用 Redis 缓存热点数据(股票行情、用户信息) +- **异步任务**: 耗时操作使用 Celery 异步处理 + +--- + +#### 后端部署架构 + +**生产环境部署**: +``` +Nginx (反向代理 + 负载均衡) + ├─ Gunicorn (WSGI 服务器) × 4 进程 + │ └─ Flask 应用 (app.py) + │ + ├─ Celery Worker × 2 进程 + │ └─ 后台任务处理 + │ + ├─ Redis (缓存 + 消息队列) + ├─ MySQL (事务数据) + └─ ClickHouse (时序数据) +``` + +**启动命令**: +```bash +# 启动 Flask 应用(Gunicorn) +gunicorn -w 4 -b 0.0.0.0:5001 app:app + +# 启动 Celery Worker +celery -A simulation_background_processor worker --loglevel=info + +# 启动 Concept API +python concept_api.py +``` + +**监控与日志**: +- **日志**: 使用 Python `logging` 模块记录日志 +- **监控**: Celery Flower 监控任务状态 +- **性能**: 使用 APM 工具(如 New Relic、Datadog) + +--- + +## 配置 + +### 环境文件 +``` +.env.mock - Mock 模式(默认):MSW 拦截所有 API 调用,无需后端 +.env.development - 开发模式:连接到开发后端 +.env.test - 测试模式:用于 'npm run start:test'(后端 + 前端一起) +.env.production - 生产环境构建配置 +``` + +**关键环境变量:** +- `REACT_APP_ENABLE_MOCK=true` - 启用 MSW mocking +- `REACT_APP_API_URL` - 后端 URL(空字符串 = 使用相对路径或 MSW) + +### MSW (Mock Service Worker) 设置 +MSW 用于开发期间的 API mocking: + +1. **激活方式**:在 env 文件中设置 `REACT_APP_ENABLE_MOCK=true` +2. **Worker 文件**:`public/mockServiceWorker.js`(自动生成) +3. **Handlers**:`src/mocks/handlers/`(按领域组织:auth、stock、company 等) +4. **模式**:`onUnhandledRequest: 'warn'` - 未处理的请求会传递到后端 + +当 MSW 激活时,开发服务器代理被禁用(MSW 优先拦截)。 + +### 路径别名 (craco.config.js) +所有别名解析到 `src/` 子目录: +``` +@/ → src/ +@assets/ → src/assets/ +@components/ → src/components/ +@constants/ → src/constants/ +@contexts/ → src/contexts/ +@data/ → src/data/ +@hooks/ → src/hooks/ +@layouts/ → src/layouts/ +@lib/ → src/lib/ +@mocks/ → src/mocks/ +@providers/ → src/providers/ +@routes/ → src/routes/ +@services/ → src/services/ +@store/ → src/store/ +@styles/ → src/styles/ +@theme/ → src/theme/ +@utils/ → src/utils/ +@variables/ → src/variables/ +@views/ → src/views/ +``` + +### Webpack 优化 (craco.config.js) +**性能特性:** +- 文件系统缓存(重新构建速度提升 50-80%) +- 按库激进的代码分割: + - `react-vendor` - React 核心(优先级 30) + - `charts-lib` - echarts、d3、apexcharts、recharts(优先级 25) + - `chakra-ui` - Chakra UI + Emotion(优先级 23) + - `antd-lib` - Ant Design(优先级 22) + - `three-lib` - Three.js(优先级 20) + - `calendar-lib` - moment、date-fns、FullCalendar(优先级 18) +- 从构建中移除 ESLint 插件(速度提升 20-30%) +- 启用 Babel 缓存 +- moment locale 剥离(IgnorePlugin) +- Source maps:生产环境禁用,开发环境使用 `eval-cheap-module-source-map` + +**开发服务器:** +- 端口 3000(prestart 时杀死现有进程) +- 代理(当 MSW 禁用时):`/api` → `http://49.232.185.254:5001` +- Bundle 分析器:`ANALYZE=true npm run build:analyze` + +### 构建流程 +1. `npm run build` 使用 CRACO + webpack 优化编译 +2. Gulp 任务(`gulp licenses`)为 JS/HTML 添加 Creative Tim 许可证头 +3. 输出:`build/` 目录 + +**Node 兼容性:** +```bash +NODE_OPTIONS='--openssl-legacy-provider --max_old_space_size=4096' +``` + +## 开发工作流 + +### 路由开发 +添加新路由的步骤: + +1. 在 `src/routes/lazy-components.js` 中添加 lazy import +2. 在 `src/routes/routeConfig.js` 中添加路由配置: + - `path` - URL 路径 + - `component` - 来自 lazyComponents + - `protection` - MODAL/REDIRECT/PUBLIC + - `layout` - 'main'(带导航栏)或 'none'(全屏) +3. 路由自动使用 Suspense + ErrorBoundary 渲染(由 PageTransitionWrapper 处理) + +### 组件组织模式 +基于最近的重构(详见 README.md): + +**原子设计模式:** +- **Atoms(原子)** - 基础 UI 元素(按钮、徽章、输入框) +- **Molecules(分子)** - 原子的组合(卡片、表单) +- **Organisms(有机体)** - 复杂组件(列表、面板) + +**大型组件示例结构(1000+ 行):** +``` +src/views/Community/components/ +├── EventCard/ +│ ├── index.js - 智能包装器(路由紧凑 vs 详细视图) +│ ├── CompactEventCard.js - 紧凑视图 +│ ├── DetailedEventCard.js - 详细视图 +│ ├── EventTimeline.js - 原子组件 +│ ├── EventImportanceBadge.js - 原子组件 +│ └── ... +``` + +**工具提取:** +- 将可复用逻辑提取到 `src/utils/`(如 `priceFormatters.js`) +- 将共享常量提取到 `src/constants/`(如 `animations.js`) + +### API 集成 +**服务层**(`src/services/`): +- 使用 `src/utils/apiConfig.js` 中的 `getApiBase()` 获取基础 URL +- 示例:`${getApiBase()}/api/endpoint` +- 在 mock 模式下,MSW 拦截;在开发/生产环境下,请求后端 + +**添加新的 API 端点:** +1. 在 `src/services/` 中添加服务函数(或在组件中内联) +2. 如果使用 MSW:在 `src/mocks/handlers/{domain}.js` 中添加 handler +3. 在 `src/mocks/handlers/index.js` 中导入 handler + +### Redux 状态管理 +**现有 slices**(`src/store/slices/`): +- `authModalSlice` - 认证模态框状态 +- `posthogSlice` - PostHog 分析 +- `stockSlice` - 股票数据 +- `industrySlice` - 行业/概念数据 +- `subscriptionSlice` - 用户订阅 +- `communityDataSlice` - 社区内容 + +**添加新 slice:** +1. 创建 `src/store/slices/yourSlice.js` +2. 在 `src/store/index.js` 中导入并添加 +3. 通过 `useSelector` 访问,通过 `useDispatch` 派发 + +--- + +## 更新本文档 + +本 CLAUDE.md 文件是一个持续更新的文档。在以下情况下应更新它: +- 添加新的架构模式或指南 +- 发现新的最佳实践 +- 解决或添加技术债务项 +- 做出重要的技术决策 +- 进行重要的代码清理或重构 + +所有开发人员应在入职时审查本文档,并在做出架构决策时参考它。 diff --git a/mcp_database.py b/mcp_database.py index ec2df704..00024b9a 100644 --- a/mcp_database.py +++ b/mcp_database.py @@ -544,3 +544,240 @@ async def get_stock_comparison( "comparison_type": metric, "stocks": [convert_row(row) for row in results] } + + +async def get_user_favorite_stocks(user_id: str, limit: int = 100) -> List[Dict[str, Any]]: + """ + 获取用户自选股列表 + + Args: + user_id: 用户ID + limit: 返回条数 + + Returns: + 自选股列表(包含最新行情数据) + """ + pool = await get_pool() + + async with pool.acquire() as conn: + async with conn.cursor(aiomysql.DictCursor) as cursor: + # 查询用户自选股(假设有 user_favorites 表) + # 如果没有此表,可以根据实际情况调整 + query = """ + SELECT + f.user_id, + f.stock_code, + b.SECNAME as stock_name, + b.F030V as industry, + t.F007N as current_price, + t.F010N as change_pct, + t.F012N as turnover_rate, + t.F026N as pe_ratio, + t.TRADEDATE as latest_trade_date, + f.created_at as favorite_time + FROM user_favorites f + INNER JOIN ea_baseinfo b ON f.stock_code = b.SECCODE + LEFT JOIN ( + SELECT SECCODE, MAX(TRADEDATE) as max_date + FROM ea_trade + GROUP BY SECCODE + ) latest ON b.SECCODE = latest.SECCODE + LEFT JOIN ea_trade t ON b.SECCODE = t.SECCODE + AND t.TRADEDATE = latest.max_date + WHERE f.user_id = %s AND f.is_deleted = 0 + ORDER BY f.created_at DESC + LIMIT %s + """ + + await cursor.execute(query, [user_id, limit]) + results = await cursor.fetchall() + + return [convert_row(row) for row in results] + + +async def get_user_favorite_events(user_id: str, limit: int = 100) -> List[Dict[str, Any]]: + """ + 获取用户自选事件列表 + + Args: + user_id: 用户ID + limit: 返回条数 + + Returns: + 自选事件列表 + """ + pool = await get_pool() + + async with pool.acquire() as conn: + async with conn.cursor(aiomysql.DictCursor) as cursor: + # 查询用户自选事件(假设有 user_event_favorites 表) + query = """ + SELECT + f.user_id, + f.event_id, + e.title, + e.description, + e.event_date, + e.importance, + e.related_stocks, + e.category, + f.created_at as favorite_time + FROM user_event_favorites f + INNER JOIN events e ON f.event_id = e.id + WHERE f.user_id = %s AND f.is_deleted = 0 + ORDER BY e.event_date DESC + LIMIT %s + """ + + await cursor.execute(query, [user_id, limit]) + results = await cursor.fetchall() + + return [convert_row(row) for row in results] + + +async def add_favorite_stock(user_id: str, stock_code: str) -> Dict[str, Any]: + """ + 添加自选股 + + Args: + user_id: 用户ID + stock_code: 股票代码 + + Returns: + 操作结果 + """ + pool = await get_pool() + + async with pool.acquire() as conn: + async with conn.cursor(aiomysql.DictCursor) as cursor: + # 检查是否已存在 + check_query = """ + SELECT id, is_deleted + FROM user_favorites + WHERE user_id = %s AND stock_code = %s + """ + await cursor.execute(check_query, [user_id, stock_code]) + existing = await cursor.fetchone() + + if existing: + if existing['is_deleted'] == 1: + # 恢复已删除的记录 + update_query = """ + UPDATE user_favorites + SET is_deleted = 0, updated_at = NOW() + WHERE id = %s + """ + await cursor.execute(update_query, [existing['id']]) + return {"success": True, "message": "已恢复自选股"} + else: + return {"success": False, "message": "该股票已在自选中"} + + # 插入新记录 + insert_query = """ + INSERT INTO user_favorites (user_id, stock_code, created_at, updated_at, is_deleted) + VALUES (%s, %s, NOW(), NOW(), 0) + """ + await cursor.execute(insert_query, [user_id, stock_code]) + return {"success": True, "message": "添加自选股成功"} + + +async def remove_favorite_stock(user_id: str, stock_code: str) -> Dict[str, Any]: + """ + 删除自选股 + + Args: + user_id: 用户ID + stock_code: 股票代码 + + Returns: + 操作结果 + """ + pool = await get_pool() + + async with pool.acquire() as conn: + async with conn.cursor(aiomysql.DictCursor) as cursor: + query = """ + UPDATE user_favorites + SET is_deleted = 1, updated_at = NOW() + WHERE user_id = %s AND stock_code = %s AND is_deleted = 0 + """ + result = await cursor.execute(query, [user_id, stock_code]) + + if result > 0: + return {"success": True, "message": "删除自选股成功"} + else: + return {"success": False, "message": "未找到该自选股"} + + +async def add_favorite_event(user_id: str, event_id: int) -> Dict[str, Any]: + """ + 添加自选事件 + + Args: + user_id: 用户ID + event_id: 事件ID + + Returns: + 操作结果 + """ + pool = await get_pool() + + async with pool.acquire() as conn: + async with conn.cursor(aiomysql.DictCursor) as cursor: + # 检查是否已存在 + check_query = """ + SELECT id, is_deleted + FROM user_event_favorites + WHERE user_id = %s AND event_id = %s + """ + await cursor.execute(check_query, [user_id, event_id]) + existing = await cursor.fetchone() + + if existing: + if existing['is_deleted'] == 1: + # 恢复已删除的记录 + update_query = """ + UPDATE user_event_favorites + SET is_deleted = 0, updated_at = NOW() + WHERE id = %s + """ + await cursor.execute(update_query, [existing['id']]) + return {"success": True, "message": "已恢复自选事件"} + else: + return {"success": False, "message": "该事件已在自选中"} + + # 插入新记录 + insert_query = """ + INSERT INTO user_event_favorites (user_id, event_id, created_at, updated_at, is_deleted) + VALUES (%s, %s, NOW(), NOW(), 0) + """ + await cursor.execute(insert_query, [user_id, event_id]) + return {"success": True, "message": "添加自选事件成功"} + + +async def remove_favorite_event(user_id: str, event_id: int) -> Dict[str, Any]: + """ + 删除自选事件 + + Args: + user_id: 用户ID + event_id: 事件ID + + Returns: + 操作结果 + """ + pool = await get_pool() + + async with pool.acquire() as conn: + async with conn.cursor(aiomysql.DictCursor) as cursor: + query = """ + UPDATE user_event_favorites + SET is_deleted = 1, updated_at = NOW() + WHERE user_id = %s AND event_id = %s AND is_deleted = 0 + """ + result = await cursor.execute(query, [user_id, event_id]) + + if result > 0: + return {"success": True, "message": "删除自选事件成功"} + else: + return {"success": False, "message": "未找到该自选事件"} diff --git a/mcp_elasticsearch.py b/mcp_elasticsearch.py new file mode 100644 index 00000000..53407f5f --- /dev/null +++ b/mcp_elasticsearch.py @@ -0,0 +1,320 @@ +""" +Elasticsearch 连接和工具模块 +用于聊天记录存储和向量搜索 +""" + +from elasticsearch import Elasticsearch, helpers +from datetime import datetime +from typing import List, Dict, Any, Optional +import logging +import json +import openai + +logger = logging.getLogger(__name__) + +# ==================== 配置 ==================== + +# ES 配置 +ES_CONFIG = { + "host": "http://222.128.1.157:19200", + "index_chat_history": "agent_chat_history", # 聊天记录索引 +} + +# Embedding 配置 +EMBEDDING_CONFIG = { + "api_key": "dummy", + "base_url": "http://222.128.1.157:18008/v1", + "model": "qwen3-embedding-8b", + "dims": 4096, # 向量维度 +} + +# ==================== ES 客户端 ==================== + +class ESClient: + """Elasticsearch 客户端封装""" + + def __init__(self): + self.es = Elasticsearch([ES_CONFIG["host"]], request_timeout=60) + self.chat_index = ES_CONFIG["index_chat_history"] + + # 初始化 OpenAI 客户端用于 embedding + self.embedding_client = openai.OpenAI( + api_key=EMBEDDING_CONFIG["api_key"], + base_url=EMBEDDING_CONFIG["base_url"], + ) + self.embedding_model = EMBEDDING_CONFIG["model"] + + # 初始化索引 + self.create_chat_history_index() + + def create_chat_history_index(self): + """创建聊天记录索引""" + if self.es.indices.exists(index=self.chat_index): + logger.info(f"索引 {self.chat_index} 已存在") + return + + mappings = { + "properties": { + "session_id": {"type": "keyword"}, # 会话ID + "user_id": {"type": "keyword"}, # 用户ID + "user_nickname": {"type": "text"}, # 用户昵称 + "user_avatar": {"type": "keyword"}, # 用户头像URL + "message_type": {"type": "keyword"}, # user / assistant + "message": {"type": "text"}, # 消息内容 + "message_embedding": { # 消息向量 + "type": "dense_vector", + "dims": EMBEDDING_CONFIG["dims"], + "index": True, + "similarity": "cosine" + }, + "plan": {"type": "text"}, # 执行计划(仅 assistant) + "steps": {"type": "text"}, # 执行步骤(仅 assistant) + "timestamp": {"type": "date"}, # 时间戳 + "created_at": {"type": "date"}, # 创建时间 + } + } + + self.es.indices.create(index=self.chat_index, body={"mappings": mappings}) + logger.info(f"创建索引: {self.chat_index}") + + def generate_embedding(self, text: str) -> List[float]: + """生成文本向量""" + try: + if not text or len(text.strip()) == 0: + return [] + + # 截断过长文本 + text = text[:16000] if len(text) > 16000 else text + + response = self.embedding_client.embeddings.create( + model=self.embedding_model, + input=[text] + ) + return response.data[0].embedding + except Exception as e: + logger.error(f"Embedding 生成失败: {e}") + return [] + + def save_chat_message( + self, + session_id: str, + user_id: str, + user_nickname: str, + user_avatar: str, + message_type: str, # "user" or "assistant" + message: str, + plan: Optional[str] = None, + steps: Optional[str] = None, + ) -> str: + """ + 保存聊天消息 + + Args: + session_id: 会话ID + user_id: 用户ID + user_nickname: 用户昵称 + user_avatar: 用户头像URL + message_type: 消息类型 (user/assistant) + message: 消息内容 + plan: 执行计划(可选) + steps: 执行步骤(可选) + + Returns: + 文档ID + """ + try: + # 生成向量 + embedding = self.generate_embedding(message) + + doc = { + "session_id": session_id, + "user_id": user_id, + "user_nickname": user_nickname, + "user_avatar": user_avatar, + "message_type": message_type, + "message": message, + "message_embedding": embedding if embedding else None, + "plan": plan, + "steps": steps, + "timestamp": datetime.now(), + "created_at": datetime.now(), + } + + result = self.es.index(index=self.chat_index, body=doc) + logger.info(f"保存聊天记录: {result['_id']}") + return result["_id"] + + except Exception as e: + logger.error(f"保存聊天记录失败: {e}") + raise + + def get_chat_sessions(self, user_id: str, limit: int = 50) -> List[Dict[str, Any]]: + """ + 获取用户的聊天会话列表 + + Args: + user_id: 用户ID + limit: 返回数量 + + Returns: + 会话列表,每个会话包含:session_id, last_message, last_timestamp + """ + try: + # 聚合查询:按 session_id 分组,获取每个会话的最后一条消息 + query = { + "query": { + "term": {"user_id": user_id} + }, + "aggs": { + "sessions": { + "terms": { + "field": "session_id", + "size": limit, + "order": {"last_message": "desc"} + }, + "aggs": { + "last_message": { + "max": {"field": "timestamp"} + }, + "last_message_content": { + "top_hits": { + "size": 1, + "sort": [{"timestamp": {"order": "desc"}}], + "_source": ["message", "timestamp", "message_type"] + } + } + } + } + }, + "size": 0 + } + + result = self.es.search(index=self.chat_index, body=query) + + sessions = [] + for bucket in result["aggregations"]["sessions"]["buckets"]: + session_data = bucket["last_message_content"]["hits"]["hits"][0]["_source"] + sessions.append({ + "session_id": bucket["key"], + "last_message": session_data["message"], + "last_timestamp": session_data["timestamp"], + "message_count": bucket["doc_count"], + }) + + return sessions + + except Exception as e: + logger.error(f"获取会话列表失败: {e}") + return [] + + def get_chat_history( + self, + session_id: str, + limit: int = 100 + ) -> List[Dict[str, Any]]: + """ + 获取指定会话的聊天历史 + + Args: + session_id: 会话ID + limit: 返回数量 + + Returns: + 聊天记录列表 + """ + try: + query = { + "query": { + "term": {"session_id": session_id} + }, + "sort": [{"timestamp": {"order": "asc"}}], + "size": limit + } + + result = self.es.search(index=self.chat_index, body=query) + + messages = [] + for hit in result["hits"]["hits"]: + doc = hit["_source"] + messages.append({ + "message_type": doc["message_type"], + "message": doc["message"], + "plan": doc.get("plan"), + "steps": doc.get("steps"), + "timestamp": doc["timestamp"], + }) + + return messages + + except Exception as e: + logger.error(f"获取聊天历史失败: {e}") + return [] + + def search_chat_history( + self, + user_id: str, + query_text: str, + top_k: int = 10 + ) -> List[Dict[str, Any]]: + """ + 向量搜索聊天历史 + + Args: + user_id: 用户ID + query_text: 查询文本 + top_k: 返回数量 + + Returns: + 相关聊天记录列表 + """ + try: + # 生成查询向量 + query_embedding = self.generate_embedding(query_text) + if not query_embedding: + return [] + + # 向量搜索 + query = { + "query": { + "bool": { + "must": [ + {"term": {"user_id": user_id}}, + { + "script_score": { + "query": {"match_all": {}}, + "script": { + "source": "cosineSimilarity(params.query_vector, 'message_embedding') + 1.0", + "params": {"query_vector": query_embedding} + } + } + } + ] + } + }, + "size": top_k + } + + result = self.es.search(index=self.chat_index, body=query) + + messages = [] + for hit in result["hits"]["hits"]: + doc = hit["_source"] + messages.append({ + "session_id": doc["session_id"], + "message_type": doc["message_type"], + "message": doc["message"], + "timestamp": doc["timestamp"], + "score": hit["_score"], + }) + + return messages + + except Exception as e: + logger.error(f"向量搜索失败: {e}") + return [] + + +# ==================== 全局实例 ==================== + +# 创建全局 ES 客户端 +es_client = ESClient() diff --git a/mcp_server.py b/mcp_server.py index 412971e0..35156b5b 100644 --- a/mcp_server.py +++ b/mcp_server.py @@ -17,6 +17,8 @@ import mcp_database as db from openai import OpenAI import json import asyncio +import uuid +from mcp_elasticsearch import es_client # 配置日志 logging.basicConfig(level=logging.INFO) @@ -135,6 +137,10 @@ class AgentChatRequest(BaseModel): """聊天请求""" message: str conversation_history: List[ConversationMessage] = [] + user_id: Optional[str] = None # 用户ID + user_nickname: Optional[str] = None # 用户昵称 + user_avatar: Optional[str] = None # 用户头像URL + session_id: Optional[str] = None # 会话ID(如果为空则创建新会话) # ==================== MCP工具定义 ==================== @@ -1023,7 +1029,14 @@ class MCPAgentIntegrated: for tool in tools ]) - return f"""你是一个专业的金融研究助手。根据用户问题,制定详细的执行计划。 + return f"""你是"价小前",北京价值前沿科技公司的AI投研聊天助手。 + +## 你的人格特征 +- **名字**: 价小前 +- **身份**: 北京价值前沿科技公司的专业AI投研助手 +- **专业领域**: 股票投资研究、市场分析、新闻解读、财务分析 +- **性格**: 专业、严谨、友好,擅长用简洁的语言解释复杂的金融概念 +- **服务宗旨**: 帮助投资者做出更明智的投资决策,提供数据驱动的研究支持 ## 可用工具 @@ -1040,7 +1053,7 @@ class MCPAgentIntegrated: - 概念板块: 相同题材股票分类 ## 任务 -分析用户问题,制定执行计划。返回 JSON: +分析用户问题,制定详细的执行计划。返回 JSON: ```json {{ @@ -1533,7 +1546,32 @@ async def chat(request: ChatRequest): @app.post("/agent/chat", response_model=AgentResponse) async def agent_chat(request: AgentChatRequest): """智能代理对话端点(非流式)""" - logger.info(f"Agent chat: {request.message}") + logger.info(f"Agent chat: {request.message} (user: {request.user_id})") + + # ==================== 权限检查 ==================== + # 仅允许 max 用户使用 + if request.user_id != "max": + raise HTTPException( + status_code=403, + detail="很抱歉,「价小前投研」功能目前仅对特定用户开放。如需使用,请联系管理员。" + ) + + # ==================== 会话管理 ==================== + # 如果没有提供 session_id,创建新会话 + session_id = request.session_id or str(uuid.uuid4()) + + # 保存用户消息到 ES + try: + es_client.save_chat_message( + session_id=session_id, + user_id=request.user_id or "anonymous", + user_nickname=request.user_nickname or "匿名用户", + user_avatar=request.user_avatar or "", + message_type="user", + message=request.message, + ) + except Exception as e: + logger.error(f"保存用户消息失败: {e}") # 获取工具列表 tools = [tool.dict() for tool in TOOLS] @@ -1565,7 +1603,31 @@ async def agent_chat(request: AgentChatRequest): tool_handlers=TOOL_HANDLERS, ) - return response + # 保存 Agent 回复到 ES + try: + # 将执行步骤转换为JSON字符串 + steps_json = json.dumps( + [{"tool": step.tool, "result": step.result} for step in response.steps], + ensure_ascii=False + ) + + es_client.save_chat_message( + session_id=session_id, + user_id=request.user_id or "anonymous", + user_nickname=request.user_nickname or "匿名用户", + user_avatar=request.user_avatar or "", + message_type="assistant", + message=response.final_answer, + plan=response.plan, + steps=steps_json, + ) + except Exception as e: + logger.error(f"保存 Agent 回复失败: {e}") + + # 在响应中返回 session_id + response_dict = response.dict() + response_dict["session_id"] = session_id + return response_dict @app.post("/agent/chat/stream") async def agent_chat_stream(request: AgentChatRequest): @@ -1610,6 +1672,81 @@ async def agent_chat_stream(request: AgentChatRequest): }, ) +# ==================== 聊天记录管理 API ==================== + +@app.get("/agent/sessions") +async def get_chat_sessions(user_id: str, limit: int = 50): + """ + 获取用户的聊天会话列表 + + Args: + user_id: 用户ID + limit: 返回数量(默认50) + + Returns: + 会话列表 + """ + try: + sessions = es_client.get_chat_sessions(user_id, limit) + return { + "success": True, + "data": sessions, + "count": len(sessions) + } + except Exception as e: + logger.error(f"获取会话列表失败: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +@app.get("/agent/history/{session_id}") +async def get_chat_history(session_id: str, limit: int = 100): + """ + 获取指定会话的聊天历史 + + Args: + session_id: 会话ID + limit: 返回数量(默认100) + + Returns: + 聊天记录列表 + """ + try: + messages = es_client.get_chat_history(session_id, limit) + return { + "success": True, + "data": messages, + "count": len(messages) + } + except Exception as e: + logger.error(f"获取聊天历史失败: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +@app.post("/agent/search") +async def search_chat_history(user_id: str, query: str, top_k: int = 10): + """ + 向量搜索聊天历史 + + Args: + user_id: 用户ID + query: 查询文本 + top_k: 返回数量(默认10) + + Returns: + 相关聊天记录列表 + """ + try: + results = es_client.search_chat_history(user_id, query, top_k) + return { + "success": True, + "data": results, + "count": len(results) + } + except Exception as e: + logger.error(f"向量搜索失败: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + # ==================== 健康检查 ==================== @app.get("/health") diff --git a/src/routes/routeConfig.js b/src/routes/routeConfig.js index 6d632416..9ae9911d 100644 --- a/src/routes/routeConfig.js +++ b/src/routes/routeConfig.js @@ -157,8 +157,8 @@ export const routeConfig = [ protection: PROTECTION_MODES.MODAL, layout: 'main', meta: { - title: 'AI投资助手', - description: '基于MCP的智能投资顾问' + title: '价小前投研', + description: '北京价值前沿科技公司的AI投研聊天助手' } }, ];