diff --git a/README.md b/README.md index 7aac1924c..c17100d39 100644 --- a/README.md +++ b/README.md @@ -53,7 +53,7 @@ ### What is QuantDinger? -QuantDinger is a **local-first, privacy-first quantitative trading infrastructure**. It runs entirely on your machine, giving you full control over your strategies, trading data, and API keys. +QuantDinger is a **local-first, privacy-first, self-hosted quantitative trading infrastructure**. It runs on your own machine/server, providing **multi-user accounts backed by PostgreSQL** while keeping full control of your strategies, trading data, and API keys. ### Why Local-First? @@ -73,7 +73,7 @@ QuantDinger includes a built-in **LLM-based multi-agent research system** that g ### Core Value -- **🔓 Apache 2.0 Open Source**: Fully permissive and commercial-friendly. Unlike viral licenses (GPL/AGPL), you truly own your code and modifications. +- **🔓 Apache 2.0 Open Source (Code)**: Permissive and commercial-friendly. You can fork and modify the codebase under Apache 2.0, while preserving required notices. - **🐍 Python-Native & Visual**: Write indicators in standard Python (easier than PineScript) with AI assistance. Visualize signals directly on charts—a "Local TradingView" experience. - **🤖 AI-Loop Optimization**: It doesn't just run strategies; AI analyzes backtest results to suggest parameter tuning (Stop-Loss/TP/MACD settings), forming a closed optimization loop. - **🌍 Universal Market Access**: One unified system for Crypto (Live), US/CN Stocks, Forex, and Futures (Data/Notify). @@ -81,72 +81,6 @@ QuantDinger includes a built-in **LLM-based multi-agent research system** that g --- -## 🏆 Our Partners & Sponsors - -
- -### 💼 Trusted Exchange Partners - -We're proud to partner with leading cryptocurrency exchanges that provide reliable infrastructure for quantitative trading. These partnerships help support the ongoing development of QuantDinger. - - - - - - - -
- - Binance - -

- World's Largest Crypto Exchange
- Spot • Futures • Margin Trading -
- - OKX - -

- Leading Derivatives Platform
- Spot • Perpetual • Options -
- - Bitget - -

- Innovative Copy Trading
- Spot • Futures • Social Trading -
- -

- By using our partner links, you support QuantDinger's development while enjoying the same trading experience. -

- ---- - -### 💝 Direct Support - -Your contributions help us maintain and improve QuantDinger. Every donation makes a difference! - -**Crypto Donations (ERC-20 / BEP-20 / Polygon / Arbitrum)** - -``` -0x96fa4962181bea077f8c7240efe46afbe73641a7 -``` - -

- USDT - ETH -

- -

- Thank you for supporting open-source development! 🙏 -

- -
- ---- - ## 📺 Video Demo
@@ -261,7 +195,7 @@ QuantDinger provides a unified data interface across multiple markets: QuantDinger’s agents don’t start from scratch every time. The backend includes a **local memory store** and an optional **reflection/verification loop**: - **What it is**: RAG-style experience retrieval injected into agent prompts (NOT model fine-tuning). -- **Where it lives**: Local SQLite files under `backend_api_python/data/memory/` (privacy-first). +- **Where it lives**: PostgreSQL database (shared with main data) or local files under `backend_api_python/data/memory/` (privacy-first). ```mermaid flowchart TB @@ -301,7 +235,7 @@ flowchart TB end %% ===== 🧠 Memory Layer ===== - subgraph Memory["🧠 Local SQLite Memory (data/memory/)"] + subgraph Memory["🧠 PostgreSQL Memory Store"] M1[("market_analyst")] M2[("fundamental")] M3[("news_analyst")] @@ -356,9 +290,9 @@ Config lives in `.env` (see `backend_api_python/env.example`): `ENABLE_AGENT_MEM ### 7. Tech Stack -- **Backend**: Python (Flask) + SQLite + Redis (optional) +- **Backend**: Python (Flask) + PostgreSQL + Redis (optional) - **Frontend**: Vue 2 + Ant Design Vue + KlineCharts/ECharts -- **Deployment**: Docker Compose +- **Deployment**: Docker Compose (with PostgreSQL) --- @@ -441,7 +375,7 @@ All UI elements, error messages, and documentation are fully translated. Languag │ (Flask + strategy runtime) │ └──────────────┬──────────────┘ │ - ├─ SQLite (quantdinger.db) + ├─ PostgreSQL (multi-user support) ├─ Redis (optional cache) └─ Data providers / LLMs / Exchanges ``` @@ -466,9 +400,27 @@ All UI elements, error messages, and documentation are fully translated. Languag ### Option 1: Docker Deployment (Recommended) -The fastest way to get QuantDinger running. +The fastest way to get QuantDinger running with PostgreSQL database and multi-user support. + +#### 1. Configure Environment -#### 1. Start Services +Create a `.env` file in project root: + +```bash +# Database Configuration +POSTGRES_USER=quantdinger +POSTGRES_PASSWORD=your_secure_password +POSTGRES_DB=quantdinger + +# Admin Account (created on first startup) +ADMIN_USER=quantdinger +ADMIN_PASSWORD=123456 + +# Optional: AI Features +OPENROUTER_API_KEY=your_api_key +``` + +#### 2. Start Services **Linux / macOS** ```bash @@ -486,17 +438,20 @@ Copy-Item backend_api_python\env.example -Destination backend_api_python\.env docker-compose up -d --build ``` -#### 2. Configuration & Access - -- **Frontend UI**: http://localhost:8888 -- **Default Account**: `quantdinger` / `123456` - -> **Note**: For production or AI features, edit `backend_api_python/.env` (add `OPENROUTER_API_KEY`, change passwords) and restart with `docker-compose restart backend`. +This will automatically: +- Start PostgreSQL database (port 5432) +- Initialize database schema +- Start backend API (port 5000) +- Start frontend (port 8888) +- Create admin user from `ADMIN_USER`/`ADMIN_PASSWORD` in `.env` #### 3. Access the Application -- **Frontend UI**: http://localhost +- **Frontend UI**: http://localhost:8888 - **Backend API**: http://localhost:5000 +- **Default Account**: Uses `ADMIN_USER` / `ADMIN_PASSWORD` from `.env` (default: `quantdinger` / `123456`, please change for production) + +> **Note**: For production, edit `backend_api_python/.env` to set strong passwords, add `OPENROUTER_API_KEY` for AI features, then restart with `docker-compose restart backend`. #### Docker Commands Reference @@ -535,29 +490,30 @@ docker exec -it quantdinger-frontend /bin/sh #### Docker Architecture ``` -┌─────────────────┐ ┌─────────────────┐ -│ Frontend │ │ Backend │ -│ (Nginx) │────▶│ (Python) │ -│ Port: 80 │ │ Port: 5000 │ -└─────────────────┘ └─────────────────┘ - │ │ - └───────────────────────┘ - Docker Network +┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ +│ Frontend │ │ Backend │ │ PostgreSQL │ +│ (Nginx) │────▶│ (Python) │────▶│ Database │ +│ Port: 8888 │ │ Port: 5000 │ │ Port: 5432 │ +└─────────────────┘ └─────────────────┘ └─────────────────┘ + │ │ │ + └───────────────────────┴───────────────────────┘ + Docker Network ``` - **Frontend**: Vue.js app served by Nginx, proxies API requests to backend -- **Backend**: Python Flask API service +- **Backend**: Python Flask API service with multi-user authentication +- **PostgreSQL**: Database for user data, strategies, and trading records #### Data Persistence -The following data is mounted to the host and persists across container restarts: +The following data is persisted across container restarts: ```yaml volumes: - - ./backend_api_python/quantdinger.db:/app/quantdinger.db # Database - - ./backend_api_python/logs:/app/logs # Logs - - ./backend_api_python/data:/app/data # Data directory - - ./backend_api_python/.env:/app/.env # Configuration + postgres_data: # PostgreSQL database + - ./backend_api_python/logs:/app/logs # Logs + - ./backend_api_python/data:/app/data # Data directory + - ./backend_api_python/.env:/app/.env # Configuration ``` #### Customization @@ -630,10 +586,17 @@ docker-compose logs backend curl http://localhost:5000/api/health ``` -**Database permission issues:** +**Database connection issues:** ```bash -chmod 666 backend_api_python/quantdinger.db +# Check PostgreSQL container status +docker-compose logs postgres + +# Verify PostgreSQL is ready +docker exec quantdinger-db pg_isready -U quantdinger + +# Connect to database manually +docker exec -it quantdinger-db psql -U quantdinger -d quantdinger ``` **Build failures:** @@ -669,11 +632,14 @@ docker-compose up -d --build #### Backup ```bash -# Backup database -cp backend_api_python/quantdinger.db backup/quantdinger_$(date +%Y%m%d).db +# Backup PostgreSQL database +docker exec quantdinger-db pg_dump -U quantdinger quantdinger > backup/quantdinger_$(date +%Y%m%d).sql # Backup configuration cp backend_api_python/.env backup/.env_$(date +%Y%m%d) + +# Restore database (if needed) +cat backup/quantdinger_YYYYMMDD.sql | docker exec -i quantdinger-db psql -U quantdinger quantdinger ``` --- @@ -684,13 +650,40 @@ cp backend_api_python/.env backup/.env_$(date +%Y%m%d) - Python 3.10+ recommended - Node.js 16+ recommended +- PostgreSQL 14+ installed and running -#### 1. Start the backend (Flask API) +#### 1. Setup PostgreSQL + +```bash +# Create database and user +sudo -u postgres psql +CREATE DATABASE quantdinger; +CREATE USER quantdinger WITH ENCRYPTED PASSWORD 'your_password'; +GRANT ALL PRIVILEGES ON DATABASE quantdinger TO quantdinger; +\q + +# Initialize schema +psql -U quantdinger -d quantdinger -f backend_api_python/migrations/init.sql +``` + +#### 2. Start the backend (Flask API) ```bash cd backend_api_python pip install -r requirements.txt cp env.example .env # Windows: copy env.example .env +``` + +Edit `.env` and set: +```bash +DATABASE_URL=postgresql://quantdinger:your_password@localhost:5432/quantdinger +SECRET_KEY=your-secret-key +ADMIN_USER=quantdinger +ADMIN_PASSWORD=123456 +``` + +Then start: +```bash python run.py ``` @@ -714,7 +707,7 @@ Use `backend_api_python/env.example` as a template. Common settings include: - **Auth**: `SECRET_KEY`, `ADMIN_USER`, `ADMIN_PASSWORD` - **Server**: `PYTHON_API_HOST`, `PYTHON_API_PORT`, `PYTHON_API_DEBUG` -- **Database**: `SQLITE_DATABASE_FILE` (optional; default is `backend_api_python/data/quantdinger.db`) +- **Database**: `DATABASE_URL` (PostgreSQL connection string) - **AI / LLM**: `OPENROUTER_API_KEY`, `OPENROUTER_MODEL`, timeouts - **Web search**: `SEARCH_PROVIDER`, `SEARCH_GOOGLE_*`, `SEARCH_BING_API_KEY` - **Proxy (optional)**: `PROXY_PORT` or `PROXY_URL` @@ -751,24 +744,82 @@ Licensed under the **Apache License 2.0**. See `LICENSE`. --- -## 💰 Project Sustainability +## 💼 Commercial License & Sponsorship + +QuantDinger is licensed under **Apache License 2.0** (code). However, **Apache 2.0 does NOT grant trademark rights**. Our branding assets (name/logo) are protected as trademarks and are governed separately from the code license: + +- **Copyright/Attribution**: You must keep required copyright and license notices (including any NOTICE/attribution in the repo and in the UI where applicable). +- **Trademarks (Name/Logo/Branding)**: Without permission, you may not modify QuantDinger branding (name/logo/UI brand), or use it to imply endorsement or misrepresent origin. If you redistribute a modified version, you should remove QuantDinger branding and rebrand unless you have a commercial license. + +If you need to keep/modify QuantDinger branding in a redistribution (including UI branding and logo usage), please contact us for a **commercial license**. + +See: `TRADEMARKS.md` + +### What you get with a Commercial License + +- **Commercial authorization** to modify branding/copyright display as agreed +- **Operations support**: deployment, upgrades, incident support, and maintenance guidance +- **Consulting services**: architecture review, performance tuning, strategy workflow consulting +- **Sponsorship options**: become a project sponsor and we can **display your logo/ad** (README/website/in-app placement as agreed) + +### Contact + +- **Telegram**: `https://t.me/worldinbroker` +- **Email**: [brokermr810@gmail.com](mailto:brokermr810@gmail.com) + +--- + +### 💼 Trusted Exchange Partners (Affiliate Links) + +By using our partner links, you support QuantDinger's development while enjoying the same trading experience. + +
+ + + + + + +
+ + Binance + +

+ World's Largest Crypto Exchange
+ Spot • Futures • Margin Trading +
+ + OKX + +

+ Leading Derivatives Platform
+ Spot • Perpetual • Options +
+ + Bitget + +

+ Innovative Copy Trading
+ Spot • Futures • Social Trading +
+
+ +--- -QuantDinger is open-source and free to use. If you find it useful, here are ways to support ongoing development: +### 💝 Direct Support (Donations) -### Professional Services +Your contributions help us maintain and improve QuantDinger. -Professional services are available: +**Crypto Donations (ERC-20 / BEP-20 / Polygon / Arbitrum)** -| Service | Description | -|---------|-------------| -| **Deployment & Setup** | One-on-one assistance with server deployment, configuration, and optimization | -| **Custom Strategy Development** | Tailored trading strategies designed for your specific needs and markets | -| **Enterprise Upgrade** | Commercial license, priority support, and advanced features for businesses | -| **Training & Consulting** | Hands-on training sessions and strategic consulting for your trading team | +``` +0x96fa4962181bea077f8c7240efe46afbe73641a7 +``` -**Interested?** Contact us via: -- 📧 Email: [brokermr810@gmail.com](mailto:brokermr810@gmail.com) -- 💬 Telegram: [QuantDinger Group](https://t.me/quantdinger) +

+ USDT + ETH +

--- diff --git a/README_CN.md b/README_CN.md index 0c0cd9701..f9be31d26 100644 --- a/README_CN.md +++ b/README_CN.md @@ -53,7 +53,7 @@ ### QuantDinger 是什么? -QuantDinger 是一个**本地优先、隐私优先的量化交易基础设施**。它完全运行在你的机器上,让你完全控制自己的策略、交易数据和 API 密钥。 +QuantDinger 是一个**本地优先、隐私优先、自托管的量化交易基础设施**。它运行在你的机器/服务器上,提供 **PostgreSQL 支持的多用户账号体系**,同时让你完全控制自己的策略、交易数据和 API 密钥。 ### 为什么选择本地优先? @@ -73,7 +73,7 @@ QuantDinger 包含一个内置的**基于 LLM 的多智能体研究系统**, ### 核心价值 -- **🔓 Apache 2.0 开源**:完全宽松且商业友好。不像病毒式的 GPL/AGPL 协议,你真正拥有你的代码和修改权。 +- **🔓 Apache 2.0 开源(代码)**:宽松且商业友好。你可以在 Apache 2.0 下 fork/修改代码,但需保留许可与署名等必要声明。 - **🐍 Python 原生 & 可视化**:使用标准 Python 编写指标(比 PineScript 更简单),并由 AI 辅助。直接在图表上可视化信号——打造“本地版 TradingView”体验。 - **🤖 AI 闭环优化**:不仅运行策略,AI 还会分析回测结果并建议参数调整(止损/止盈/MACD 设置),形成闭环优化。 - **🌍 全球市场接入**:统一系统支持加密货币(实盘)、美股/A股、外汇和期货(数据/通知)。 @@ -81,72 +81,6 @@ QuantDinger 包含一个内置的**基于 LLM 的多智能体研究系统**, --- -## 🏆 我们的合作伙伴与赞助商 - -
- -### 💼 值得信赖的交易所合作伙伴 - -我们很自豪能与领先的加密货币交易所合作,为量化交易提供可靠的基础设施。这些合作伙伴关系有助于支持 QuantDinger 的持续发展。 - - - - - - - -
- - Binance - -

- 全球最大的加密货币交易所
- 现货 • 期货 • 杠杆交易 -
- - OKX - -

- 领先的衍生品平台
- 现货 • 永续合约 • 期权 -
- - Bitget - -

- 创新的跟单交易
- 现货 • 期货 • 社交交易 -
- -

- 使用我们的合作伙伴链接,在享受相同交易体验的同时支持 QuantDinger 的发展。 -

- ---- - -### 💝 直接支持 - -您的贡献帮助我们维护和改进 QuantDinger。每一份捐赠都意义重大! - -**加密货币捐赠 (ERC-20 / BEP-20 / Polygon / Arbitrum)** - -``` -0x96fa4962181bea077f8c7240efe46afbe73641a7 -``` - -

- USDT - ETH -

- -

- 感谢您支持开源开发!🙏 -

- -
- ---- - ## 📺 视频演示
@@ -394,7 +328,7 @@ score = w_{sim}\cdot sim + w_{recency}\cdot recency + w_{returns}\cdot returns\_ ### 7. 技术栈 -- **后端**:Python (Flask) + SQLite + Redis(可选) +- **后端**:Python (Flask) + PostgreSQL + Redis(可选) - **前端**:Vue 2 + Ant Design Vue + KlineCharts/ECharts - **部署**:Docker Compose @@ -480,7 +414,7 @@ QuantDinger 为全球用户构建,提供全面的国际化支持: │ (Flask + 策略运行时) │ └──────────────┬──────────────┘ │ - ├─ SQLite (quantdinger.db) + ├─ PostgreSQL(多用户支持) ├─ Redis (可选缓存) └─ 数据提供商 / LLMs / 交易所 ``` @@ -619,26 +553,82 @@ npm run serve --- -## 💰 项目可持续性 +## 💼 商业授权与赞助(Commercial License & Sponsorship) + +QuantDinger 的代码使用 **Apache License 2.0** 授权。但需要注意:**Apache 2.0 不授予商标权**。QuantDinger 的名称/Logo/品牌标识受商标与品牌政策约束(与代码许可分离): + +- **版权/署名**:你必须保留必要的版权与许可声明(例如仓库中的 LICENSE/NOTICE 等,以及代码中的署名信息)。 +- **商标(名称/Logo/品牌)**:你不得使用 QuantDinger 的名称/Logo/品牌来暗示背书或误导来源;若再发布修改版,一般需要移除/替换 QuantDinger 品牌标识,除非获得书面许可。 + +如果你希望在再发布版本中**保留/修改 QuantDinger 品牌展示**(包括 UI 品牌、Logo 使用等),请联系我们获取 **商业授权**。 + +另见:`TRADEMARKS.md` + +### 商业授权可获得 + +- **品牌/版权展示的商用授权**(以双方约定为准) +- **运维支持**:部署、升级、故障处理与维护建议 +- **咨询服务**:架构评审、性能调优、策略工作流咨询 +- **赞助商权益**:成为项目赞助商,可按约定展示你的 Logo/广告(README/官网/应用内等) + +### 联系方式 + +- **Telegram**: [QuantDinger Group](https://t.me/worldinbroker) +- **Email**: [brokermr810@gmail.com](mailto:brokermr810@gmail.com) + +--- + +### 💼 值得信赖的交易所合作伙伴(联盟链接) -QuantDinger 是开源且免费使用的。如果你觉得它有用,以下是一些支持项目持续发展的方式: +使用我们的合作伙伴链接,在享受相同交易体验的同时支持 QuantDinger 的发展。 + +
+ + + + + + +
+ + Binance + +

+ 全球最大的加密货币交易所
+ 现货 • 期货 • 杠杆交易 +
+ + OKX + +

+ 领先的衍生品平台
+ 现货 • 永续合约 • 期权 +
+ + Bitget + +

+ 创新的跟单交易
+ 现货 • 期货 • 社交交易 +
+
--- -### 专业服务 +### 💝 直接支持(捐赠) -提供以下专业服务: +你的贡献帮助我们维护和改进 QuantDinger。 -| 服务 | 描述 | -|---------|-------------| -| **部署与设置** | 一对一协助服务器部署、配置和优化 | -| **定制策略开发** | 针对特定需求和市场定制交易策略 | -| **企业版升级** | 商业授权、优先支持和企业级高级功能 | -| **培训与咨询** | 为你的交易团队提供实战培训和战略咨询 | +**加密货币捐赠 (ERC-20 / BEP-20 / Polygon / Arbitrum)** -**感兴趣?** 联系我们: -- 📧 Email: [brokermr810@gmail.com](mailto:brokermr810@gmail.com) -- 💬 Telegram: [QuantDinger Group](https://t.me/quantdinger) +``` +0x96fa4962181bea077f8c7240efe46afbe73641a7 +``` + +

+ USDT + ETH +

--- diff --git a/README_JA.md b/README_JA.md index 7aa7dcde4..205ce930f 100644 --- a/README_JA.md +++ b/README_JA.md @@ -53,7 +53,7 @@ ### QuantDingerとは? -QuantDingerは **ローカルファースト、プライバシー重視の定量的取引インフラストラクチャ** です。完全にあなたのマシン上で実行され、戦略、取引データ、APIキーを完全にコントロールできます。 +QuantDingerは **ローカルファースト、プライバシー重視のセルフホスト型定量取引インフラ** です。あなたのマシン/サーバー上で実行され、**PostgreSQL によるマルチユーザーアカウント** を提供しつつ、戦略・取引データ・APIキーを完全にコントロールできます。 ### なぜローカルファーストなのか? @@ -73,7 +73,7 @@ QuantDingerには、ウェブから金融情報を収集し、ローカル市場 ### コアバリュー -- **🔓 Apache 2.0 オープンソース**: 完全に寛容で商用利用に適しています。ウイルス性のGPL/AGPLライセンスとは異なり、コードと変更を真に所有できます。 +- **🔓 Apache 2.0 オープンソース(コード)**: 寛容で商用利用に適しています。Apache 2.0 の範囲で fork/改変が可能ですが、ライセンス/著作権表示など必要な告知は保持してください。 - **🐍 Pythonネイティブ & ビジュアル**: 標準のPythonでインジケーターを作成(PineScriptより簡単)、AIが支援します。チャート上でシグナルを直接可視化 ——「ローカル版TradingView」体験を構築。 - **🤖 AIループ最適化**: 戦略を実行するだけでなく、AIがバックテスト結果を分析してパラメータ調整(ストップロス/利益確定/MACD設定)を提案し、閉ループ最適化を形成します。 - **🌍 グローバルマーケットアクセス**: 暗号資産(実取引)、米国/中国株、FX、先物(データ/通知)をサポートする統一システム。 @@ -81,72 +81,6 @@ QuantDingerには、ウェブから金融情報を収集し、ローカル市場 --- -## 🏆 パートナーとスポンサー - -
- -### 💼 信頼できる取引所パートナー - -私たちは、定量取引に信頼性の高いインフラストラクチャを提供する主要な暗号通貨取引所と提携できることを誇りに思います。これらのパートナーシップは、QuantDingerの継続的な開発を支援するのに役立ちます。 - - - - - - - -
- - Binance - -

- 世界最大の暗号通貨取引所
- 現物 • 先物 • マージン取引 -
- - OKX - -

- 主要なデリバティブプラットフォーム
- 現物 • パーペチュアル • オプション -
- - Bitget - -

- 革新的なコピートレード
- 現物 • 先物 • ソーシャルトレード -
- -

- パートナーリンクを使用することで、同じ取引体験を楽しみながらQuantDingerの開発を支援できます。 -

- ---- - -### 💝 直接サポート - -あなたの貢献は、QuantDingerの維持と改善に役立ちます。すべての寄付が重要です! - -**暗号通貨寄付 (ERC-20 / BEP-20 / Polygon / Arbitrum)** - -``` -0x96fa4962181bea077f8c7240efe46afbe73641a7 -``` - -

- USDT - ETH -

- -

- オープンソース開発へのご支援ありがとうございます!🙏 -

- -
- ---- - ## 📺 動画デモ
@@ -575,26 +509,82 @@ npm run serve --- -## 💰 プロジェクトの持続可能性 +## 💼 商用ライセンス & スポンサー(Commercial License & Sponsorship) + +QuantDinger のコードは **Apache License 2.0** で提供されています。ただし **Apache 2.0 は商標権を付与しません**。QuantDinger の名称/ロゴ/ブランドは商標およびブランドポリシーの対象です(コードライセンスとは別扱い): + +- **著作権/帰属表示**:LICENSE/NOTICE など、必要な著作権・ライセンス告知は保持してください。 +- **商標(名称/ロゴ/ブランド)**:QuantDinger の名称/ロゴ/ブランドを用いて、出所の誤認や背書きを示唆してはいけません。改変版を再配布する場合、書面許可がない限り QuantDinger ブランド表示の削除/置換が必要になることがあります。 + +再配布物で QuantDinger のブランド表示を**保持/変更**したい場合(UI 上のロゴ使用などを含む)、**商用ライセンス**についてお問い合わせください。 + +参照:`TRADEMARKS.md` + +### 商用ライセンスで得られるもの + +- 合意に基づく **ブランド/表示の商用許諾** +- **運用サポート**:デプロイ、アップグレード、障害対応、保守ガイダンス +- **コンサルティング**:アーキテクチャレビュー、性能チューニング、戦略ワークフロー相談 +- **スポンサー枠**:スポンサーとしてロゴ/広告掲載(README/サイト/アプリ内など合意に基づく) + +### 連絡先 + +- **Telegram**: [QuantDinger Group](https://t.me/worldinbroker) +- **Email**: [brokermr810@gmail.com](mailto:brokermr810@gmail.com) + +--- + +### 💼 取引所パートナー(アフィリエイトリンク) -QuantDingerはオープンソースで無料で使用できます。有用だと思われる場合は、以下が継続的な開発を支援する方法です: +パートナーリンクを使用することで、同じ取引体験を楽しみながら QuantDinger の開発を支援できます。 + +
+ + + + + + +
+ + Binance + +

+ 世界最大の暗号通貨取引所
+ 現物 • 先物 • マージン取引 +
+ + OKX + +

+ 主要なデリバティブプラットフォーム
+ 現物 • パーペチュアル • オプション +
+ + Bitget + +

+ 革新的なコピートレード
+ 現物 • 先物 • ソーシャルトレード +
+
--- -### 商用サービス +### 💝 直接サポート(寄付) -以下のプロフェッショナルサービスを提供しています: +あなたのご支援は QuantDinger の維持・改善に役立ちます。 -| サービス | 説明 | -|---------|-------------| -| **デプロイ & セットアップ** | サーバーのデプロイ、設定、最適化に関するマンツーマンのサポート | -| **カスタム戦略開発** | 特定のニーズや市場に合わせて設計された取引戦略の開発 | -| **エンタープライズアップグレード** | 商用ライセンス、優先サポート、ビジネス向けの高度な機能 | -| **トレーニング & コンサルティング** | 取引チーム向けの実践的なトレーニングセッションと戦略コンサルティング | +**暗号通貨寄付 (ERC-20 / BEP-20 / Polygon / Arbitrum)** -**ご興味がありますか?** 以下よりお問い合わせください: -- 📧 Email: [brokermr810@gmail.com](mailto:brokermr810@gmail.com) -- 💬 Telegram: [QuantDinger Group](https://t.me/quantdinger) +``` +0x96fa4962181bea077f8c7240efe46afbe73641a7 +``` + +

+ USDT + ETH +

--- diff --git a/README_KO.md b/README_KO.md index 3501fc27e..ea6a11c59 100644 --- a/README_KO.md +++ b/README_KO.md @@ -53,7 +53,7 @@ ### QuantDinger란 무엇인가요? -QuantDinger는 **로컬 우선, 프라이버시 우선의 정량 거래 인프라**입니다. 완전히 귀하의 머신에서 실행되며, 전략, 거래 데이터 및 API 키를 완전히 제어할 수 있습니다. +QuantDinger는 **로컬 우선, 프라이버시 우선의 셀프 호스팅 정량 거래 인프라**입니다. 귀하의 머신/서버에서 실행되며, **PostgreSQL 기반 멀티 유저 계정**을 제공하면서도 전략, 거래 데이터 및 API 키를 완전히 제어할 수 있습니다. ### 왜 로컬 우선인가요? @@ -73,7 +73,7 @@ QuantDinger는 웹에서 금융 정보를 수집하고, 로컬 시장 데이터 ### 핵심 가치 -- **🔓 Apache 2.0 오픈소스**: 완전히 허용적이며 상업적으로 친화적입니다. 바이러스성 GPL/AGPL 라이선스와 달리, 코드와 수정 사항을 진정으로 소유할 수 있습니다. +- **🔓 Apache 2.0 오픈소스(코드)**: 허용적이며 상업적으로 친화적입니다. Apache 2.0 범위에서 fork/수정이 가능하지만, 라이선스/저작권 고지 등 필요한 표기는 유지해야 합니다. - **🐍 파이썬 네이티브 & 비주얼**: 표준 파이썬으로 지표를 작성(PineScript보다 쉬움)하고 AI의 지원을 받으세요. 차트에서 신호를 직접 시각화하여 "로컬 버전의 TradingView" 경험을 구축하세요. - **🤖 AI 루프 최적화**: 전략을 실행할 뿐만 아니라, AI가 백테스트 결과를 분석하여 매개변수 조정(손절매/이익실현/MACD 설정)을 제안하고 폐루프 최적화를 형성합니다. - **🌍 글로벌 마켓 액세스**: 암호화폐(실거래), 미국/중국 주식, 외환 및 선물(데이터/알림)을 지원하는 통합 시스템. @@ -81,72 +81,6 @@ QuantDinger는 웹에서 금융 정보를 수집하고, 로컬 시장 데이터 --- -## 🏆 파트너 및 스폰서 - -
- -### 💼 신뢰할 수 있는 거래소 파트너 - -우리는 정량 거래를 위한 신뢰할 수 있는 인프라를 제공하는 주요 암호화폐 거래소와 파트너십을 맺고 있음을 자랑스럽게 생각합니다. 이러한 파트너십은 QuantDinger의 지속적인 개발을 지원하는 데 도움이 됩니다. - - - - - - - -
- - Binance - -

- 세계 최대 암호화폐 거래소
- 현물 • 선물 • 마진 거래 -
- - OKX - -

- 주요 파생상품 플랫폼
- 현물 • 영구 선물 • 옵션 -
- - Bitget - -

- 혁신적인 복사 거래
- 현물 • 선물 • 소셜 거래 -
- -

- 파트너 링크를 사용하면 동일한 거래 경험을 즐기면서 QuantDinger의 개발을 지원할 수 있습니다. -

- ---- - -### 💝 직접 지원 - -귀하의 기여는 QuantDinger의 유지 및 개선에 도움이 됩니다. 모든 기부가 중요합니다! - -**암호화폐 기부 (ERC-20 / BEP-20 / Polygon / Arbitrum)** - -``` -0x96fa4962181bea077f8c7240efe46afbe73641a7 -``` - -

- USDT - ETH -

- -

- 오픈소스 개발에 대한 지원에 감사드립니다! 🙏 -

- -
- ---- - ## 📺 동영상 데모
@@ -588,26 +522,82 @@ npm run serve --- -## 💰 프로젝트 지속 가능성 +## 💼 상용 라이선스 & 스폰서(Commercial License & Sponsorship) + +QuantDinger의 코드는 **Apache License 2.0**으로 제공됩니다. 다만 **Apache 2.0은 상표권을 부여하지 않습니다**. QuantDinger의 이름/로고/브랜딩은 상표 및 브랜드 정책의 적용을 받으며(코드 라이선스와 별개): + +- **저작권/표기**: LICENSE/NOTICE 등 필요한 저작권·라이선스 고지는 유지해야 합니다. +- **상표(이름/로고/브랜딩)**: QuantDinger의 이름/로고/브랜딩을 사용해 출처를 오인시키거나 보증(endorsement)을 암시해서는 안 됩니다. 수정 버전을 재배포할 때는 서면 허가가 없는 한 QuantDinger 브랜딩을 제거/대체해야 할 수 있습니다. + +재배포물에서 QuantDinger 브랜딩을 **유지/수정**하고 싶다면(UI 로고 사용 포함) **상용 라이선스**를 문의해 주세요. + +참고: `TRADEMARKS.md` + +### 상용 라이선스로 제공되는 것 + +- 합의된 범위 내 **브랜딩/표시의 상용 허가** +- **운영 지원**: 배포, 업그레이드, 장애 대응, 유지보수 가이드 +- **컨설팅**: 아키텍처 리뷰, 성능 튜닝, 전략 워크플로우 컨설팅 +- **스폰서 옵션**: 스폰서로서 로고/광고 노출(README/웹사이트/앱 내 등 합의에 따름) + +### 연락처 + +- **Telegram**: [QuantDinger Group](https://t.me/worldinbroker) +- **Email**: [brokermr810@gmail.com](mailto:brokermr810@gmail.com) + +--- + +### 💼 거래소 파트너(어필리에이트 링크) -QuantDinger는 오픈소스이며 무료로 사용할 수 있습니다. 유용하다고 생각되면 다음은 지속적인 개발을 지원하는 방법입니다: +파트너 링크를 사용하면 동일한 거래 경험을 즐기면서 QuantDinger의 개발을 지원할 수 있습니다. + +
+ + + + + + +
+ + Binance + +

+ 세계 최대 암호화폐 거래소
+ 현물 • 선물 • 마진 거래 +
+ + OKX + +

+ 주요 파생상품 플랫폼
+ 현물 • 영구 선물 • 옵션 +
+ + Bitget + +

+ 혁신적인 복사 거래
+ 현물 • 선물 • 소셜 거래 +
+
--- -### 상용 서비스 +### 💝 직접 지원(기부) -다음 전문 서비스를 제공합니다: +귀하의 기여는 QuantDinger의 유지 및 개선에 도움이 됩니다. -| 서비스 | 설명 | -|---------|-------------| -| **배포 및 설정** | 서버 배포, 구성 및 최적화에 대한 일대일 지원 | -| **맞춤형 전략 개발** | 귀하의 특정 요구와 시장에 맞춘 거래 전략 설계 | -| **엔터프라이즈 업그레이드** | 상용 라이선스, 우선 지원 및 비즈니스를 위한 고급 기능 | -| **교육 및 컨설팅** | 거래 팀을 위한 실전 교육 세션 및 전략 컨설팅 | +**암호화폐 기부 (ERC-20 / BEP-20 / Polygon / Arbitrum)** -**관심이 있으십니까?** 다음을 통해 문의하십시오: -- 📧 Email: [brokermr810@gmail.com](mailto:brokermr810@gmail.com) -- 💬 Telegram: [QuantDinger Group](https://t.me/quantdinger) +``` +0x96fa4962181bea077f8c7240efe46afbe73641a7 +``` + +

+ USDT + ETH +

--- diff --git a/README_TW.md b/README_TW.md index f006f09d1..a85b79074 100644 --- a/README_TW.md +++ b/README_TW.md @@ -53,7 +53,7 @@ ### QuantDinger 是什麼? -QuantDinger 是一個**本地優先、隱私優先的量化交易基礎設施**。它完全運行在你的機器上,讓你完全控制自己的策略、交易數據和 API 密鑰。 +QuantDinger 是一個**本地優先、隱私優先、自託管的量化交易基礎設施**。它運行在你的機器/伺服器上,提供 **PostgreSQL 支持的多用戶帳號體系**,同時讓你完全控制自己的策略、交易數據和 API 密鑰。 ### 為什麼選擇本地優先? @@ -73,7 +73,7 @@ QuantDinger 包含一個內置的**基於 LLM 的多智能體研究系統**, ### 核心價值 -- **🔓 Apache 2.0 開源**:完全寬鬆且商業友好。不像病毒式的 GPL/AGPL 協議,你真正擁有你的代碼和修改權。 +- **🔓 Apache 2.0 開源(代碼)**:寬鬆且商業友好。你可以在 Apache 2.0 下 fork/修改代碼,但需保留許可與署名等必要聲明。 - **🐍 Python 原生 & 可視化**:使用標準 Python 編寫指標(比 PineScript 更簡單),並由 AI 輔助。直接在圖表上可視化信號——打造「本地版 TradingView」體驗。 - **🤖 AI 閉環優化**:不僅運行策略,AI 還會分析回測結果並建議參數調整(止損/止盈/MACD 設置),形成閉環優化。 - **🌍 全球市場接入**:統一系統支持加密貨幣(實盤)、美股/A股、外匯和期貨(數據/通知)。 @@ -81,72 +81,6 @@ QuantDinger 包含一個內置的**基於 LLM 的多智能體研究系統**, --- -## 🏆 我們的合作夥伴與贊助商 - -
- -### 💼 值得信賴的交易所合作夥伴 - -我們很自豪能與領先的加密貨幣交易所合作,為量化交易提供可靠的基礎設施。這些合作夥伴關係有助於支持 QuantDinger 的持續發展。 - - - - - - - -
- - Binance - -

- 全球最大的加密貨幣交易所
- 現貨 • 期貨 • 槓桿交易 -
- - OKX - -

- 領先的衍生品平台
- 現貨 • 永續合約 • 期權 -
- - Bitget - -

- 創新的跟單交易
- 現貨 • 期貨 • 社交交易 -
- -

- 使用我們的合作夥伴鏈接,在享受相同交易體驗的同時支持 QuantDinger 的發展。 -

- ---- - -### 💝 直接支持 - -您的貢獻幫助我們維護和改進 QuantDinger。每一份捐贈都意義重大! - -**加密貨幣捐贈 (ERC-20 / BEP-20 / Polygon / Arbitrum)** - -``` -0x96fa4962181bea077f8c7240efe46afbe73641a7 -``` - -

- USDT - ETH -

- -

- 感謝您支持開源開發!🙏 -

- -
- ---- - ## 📺 視頻演示
@@ -589,26 +523,82 @@ npm run serve --- -## 💰 專案可持續性 +## 💼 商業授權與贊助(Commercial License & Sponsorship) + +QuantDinger 的代碼使用 **Apache License 2.0** 授權。但請注意:**Apache 2.0 不授予商標權**。QuantDinger 的名稱/Logo/品牌標識受商標與品牌政策約束(與代碼許可分離): + +- **版權/署名**:你必須保留必要的版權與許可聲明(例如倉庫中的 LICENSE/NOTICE 等,以及代碼中的署名資訊)。 +- **商標(名稱/Logo/品牌)**:你不得使用 QuantDinger 的名稱/Logo/品牌來暗示背書或誤導來源;若再發佈修改版,一般需要移除/替換 QuantDinger 品牌標識,除非獲得書面許可。 + +若你希望在再發佈版本中**保留/修改 QuantDinger 品牌展示**(包括 UI 品牌、Logo 使用等),請聯繫我們獲取 **商業授權**。 + +另見:`TRADEMARKS.md` + +### 商業授權可獲得 + +- **品牌/版權展示的商用授權**(以雙方約定為準) +- **運維支持**:部署、升級、故障處理與維護建議 +- **諮詢服務**:架構評審、性能調優、策略工作流諮詢 +- **贊助商權益**:成為專案贊助商,可按約定展示你的 Logo/廣告(README/官網/應用內等) + +### 聯繫方式 + +- **Telegram**: [QuantDinger Group](https://t.me/worldinbroker) +- **Email**: [brokermr810@gmail.com](mailto:brokermr810@gmail.com) + +--- + +### 💼 值得信賴的交易所合作夥伴(聯盟鏈接) -QuantDinger 是開源且免費使用的。如果你覺得它有用,以下是一些支持專案持續發展的方式: +使用我們的合作夥伴鏈接,在享受相同交易體驗的同時支持 QuantDinger 的發展。 + +
+ + + + + + +
+ + Binance + +

+ 全球最大的加密貨幣交易所
+ 現貨 • 期貨 • 槓桿交易 +
+ + OKX + +

+ 領先的衍生品平台
+ 現貨 • 永續合約 • 期權 +
+ + Bitget + +

+ 創新的跟單交易
+ 現貨 • 期貨 • 社交交易 +
+
--- -### 商業服務 +### 💝 直接支持(捐贈) -我們提供專業服務,助你充分利用 QuantDinger: +你的貢獻幫助我們維護和改進 QuantDinger。 -| 服務 | 描述 | -|---------|-------------| -| **部署與設置** | 一對一協助服務器部署、配置和優化 | -| **定制策略開發** | 針對特定需求和市場定制交易策略 | -| **企業版升級** | 商業授權、優先支持和企業級高級功能 | -| **培訓與咨詢** | 為你的交易團隊提供實戰培訓和戰略咨詢 | +**加密貨幣捐贈 (ERC-20 / BEP-20 / Polygon / Arbitrum)** -**感興趣?** 聯繫我們: -- 📧 Email: [brokermr810@gmail.com](mailto:brokermr810@gmail.com) -- 💬 Telegram: [QuantDinger Group](https://t.me/quantdinger) +``` +0x96fa4962181bea077f8c7240efe46afbe73641a7 +``` + +

+ USDT + ETH +

--- diff --git a/TRADEMARKS.md b/TRADEMARKS.md new file mode 100644 index 000000000..cad8e0d4c --- /dev/null +++ b/TRADEMARKS.md @@ -0,0 +1,86 @@ +# QuantDinger Trademarks & Branding Policy + +This document governs the use of **QuantDinger** trademarks and brand assets (including the name, logo, and visual identity). +It is **separate** from the code license. The code in this repository is licensed under the **Apache License 2.0** (see `LICENSE`). + +> Note: This policy is provided for clarity and is not legal advice. + +--- + +## 1) What is covered + +The following are considered **QuantDinger brand assets**: + +- The **QuantDinger** name and word marks +- The QuantDinger **logo**, icon, and related images +- Project branding used in the UI/website/marketing materials +- Any confusingly similar marks or visual identity that may imply association + +--- + +## 2) Apache 2.0 vs. trademarks (important) + +Apache License 2.0 governs the **code** and allows you to fork, modify, and redistribute the code under its terms. +However, **Apache 2.0 does not grant trademark rights**. Trademarks are governed by this policy. + +You must still comply with Apache 2.0 requirements (e.g., preserving license/copyright notices, and `NOTICE` if present). + +--- + +## 3) Permitted uses (generally allowed) + +You may: + +- **Accurately refer** to the project as “QuantDinger” when discussing the unmodified project (e.g., tutorials, reviews, bug reports). +- Use the name “QuantDinger” to **link to this repository** or the official community channels. +- Use the logo/name for **non-commercial editorial references** (e.g., blog posts) as long as it does not imply endorsement, and the logo is used **unmodified**. + +--- + +## 4) Prohibited uses (not allowed without permission) + +You may **not**, without written permission: + +- Use QuantDinger trademarks/branding in a way that **suggests endorsement**, sponsorship, affiliation, or official status when it is not true. +- Redistribute a **modified** version of the software while keeping QuantDinger branding in a way that could confuse users about the origin. +- **Modify, adapt, recolor, distort, crop, or create derivative works** of the QuantDinger logo or other brand assets. +- **Replace/alter/remove** QuantDinger branding in a redistribution **while still using the QuantDinger name/logo/brand** (including in-app UI branding), unless you have a commercial license. +- Use QuantDinger branding to market a competing product/service as “official”, “certified”, or “approved” by QuantDinger. +- Register or use confusingly similar names/domains/social accounts that may mislead users. + +--- + +## 5) Forks & redistributions (recommended rules) + +If you fork and distribute a modified version: + +- **Do not** use “QuantDinger” as the primary product name. +- **Do not modify** QuantDinger logos/brand assets. If you do not have a commercial license, you should **remove QuantDinger branding and rebrand** your distribution to avoid confusion. +- Keep required Apache 2.0 notices (LICENSE/NOTICE) and do not misrepresent authorship. + +If you distribute an **unmodified** build, you may keep QuantDinger branding, but you must not imply that your organization is the official owner unless authorized. + +--- + +## 6) Commercial license, permission & sponsorship + +If you need any of the following, please contact us for a **commercial license / written permission**: + +- Keeping QuantDinger branding in a redistributed modified version +- **Changing/replacing/removing** QuantDinger branding in the UI/distribution (as agreed) +- Modifying/removing copyright display as agreed +- White-labeling / OEM / enterprise deployments + +Commercial licensing can include: + +- **Operations support**: deployment, upgrades, incident support, maintenance guidance +- **Consulting services**: architecture review, performance tuning, strategy workflow consulting +- **Sponsorship**: become a sponsor and we can display your logo/ad (README/website/in-app placement as agreed) + +--- + +## 7) Contact + +- Telegram: `https://t.me/worldinbroker` +- Email: `mailto:brokermr810@gmail.com` + diff --git a/backend_api_python/.gitignore b/backend_api_python/.gitignore index 6b6c2f2c3..806709812 100644 --- a/backend_api_python/.gitignore +++ b/backend_api_python/.gitignore @@ -40,4 +40,4 @@ wheels/ # Local runtime data (do not commit) quantdinger.db /data/ - +/logs/ diff --git a/backend_api_python/README.md b/backend_api_python/README.md index 9ea306895..0495184a6 100644 --- a/backend_api_python/README.md +++ b/backend_api_python/README.md @@ -1,17 +1,16 @@ # QuantDinger Python API (backend) -Flask-based local-first backend for QuantDinger: market data, indicators, AI analysis, backtesting, and a strategy runtime (with an optional pending-order worker). - -This repository is intentionally simple: **no external database is required by default**. Data is stored in a local SQLite file (`quantdinger.db`) created/updated automatically on startup. +Flask-based backend for QuantDinger: market data, indicators, AI analysis, backtesting, and a strategy runtime with multi-user support. ## What you get - **Multi-market data layer**: factory-based providers (crypto / US stocks / CN&HK stocks / futures, etc.) -- **Indicators + backtesting**: persisted runs/history in SQLite +- **Indicators + backtesting**: persisted runs/history in PostgreSQL - **AI multi-agent analysis**: optional web search + OpenRouter LLM integration - **Strategy runtime**: thread-based executor, with optional auto-restore on startup - **Pending orders worker (optional)**: polls queued orders and dispatches signals (webhook/notifications) -- **Local auth (single-user)**: `/login` with env-configured admin credentials (JWT) +- **Multi-user authentication**: role-based access control (admin/manager/user/viewer) +- **User management**: admin can create/edit/delete users and reset passwords ## Project layout @@ -22,8 +21,10 @@ backend_api_python/ │ ├─ config/ # Settings (env-driven) │ ├─ data_sources/ # Data sources + factory │ ├─ routes/ # REST endpoints -│ ├─ services/ # Analysis, agents, strategies, search, ... -│ └─ utils/ # SQLite helpers, config loader, logging, HTTP utils +│ ├─ services/ # Analysis, agents, strategies, search, user_service +│ └─ utils/ # PostgreSQL helpers, config loader, logging, HTTP utils +├─ migrations/ +│ └─ init.sql # PostgreSQL schema initialization ├─ env.example # Copy to .env for local config ├─ requirements.txt ├─ run.py # Entrypoint (loads .env, applies proxy env, starts Flask) @@ -31,46 +32,99 @@ backend_api_python/ └─ README.md ``` -## Quick start (local development) +## Quick start (Docker - Recommended) + +### 1) Configure environment + +Create `.env` file in project root: + +```bash +# Database +POSTGRES_USER=quantdinger +POSTGRES_PASSWORD=your_secure_password +POSTGRES_DB=quantdinger + +# Admin account (created on first startup) +ADMIN_USER=admin +ADMIN_PASSWORD=your_admin_password + +# Optional +OPENROUTER_API_KEY=your_api_key +``` + +### 2) Start services + +```bash +docker-compose up -d +``` + +This will: +- Start PostgreSQL database (port 5432) +- Initialize database schema automatically +- Start backend API (port 5000) +- Start frontend (port 8888) +- Create admin user from `ADMIN_USER`/`ADMIN_PASSWORD` + +### 3) Access the system + +- Frontend: `http://localhost:8888` +- Backend API: `http://localhost:5000` +- Login with your configured admin credentials + +## Quick start (Local Development) ### Prerequisites - Python 3.10+ recommended +- PostgreSQL 14+ installed and running -### 1) Install dependencies +### 1) Setup PostgreSQL + +```bash +# Create database and user +sudo -u postgres psql +CREATE DATABASE quantdinger; +CREATE USER quantdinger WITH ENCRYPTED PASSWORD 'your_password'; +GRANT ALL PRIVILEGES ON DATABASE quantdinger TO quantdinger; +\q + +# Initialize schema +psql -U quantdinger -d quantdinger -f migrations/init.sql +``` + +### 2) Install dependencies ```bash cd backend_api_python pip install -r requirements.txt ``` -### 2) Create your local `.env` +### 3) Create your local `.env` Windows (CMD): - ```bash copy env.example .env ``` Windows (PowerShell): - ```bash Copy-Item env.example .env ``` -Then edit `.env` and set at least: - -- `SECRET_KEY` -- `ADMIN_USER` -- `ADMIN_PASSWORD` - -Optional but common: +Then edit `.env` and set: -- `OPENROUTER_API_KEY` (for AI analysis) -- `FINNHUB_API_KEY` / `SEARCH_GOOGLE_*` / `SEARCH_BING_API_KEY` (for richer data/search) -- `PROXY_PORT` or `PROXY_URL` (if your network blocks some providers) +```bash +# Required +DATABASE_URL=postgresql://quantdinger:your_password@localhost:5432/quantdinger +SECRET_KEY=your-secret-key-change-me +ADMIN_USER=admin +ADMIN_PASSWORD=your_admin_password + +# Optional but recommended +OPENROUTER_API_KEY=your_api_key +``` -### 3) Start the API server +### 4) Start the API server ```bash python run.py @@ -78,47 +132,74 @@ python run.py Default address: `http://localhost:5000` -## Database (SQLite) +## Database (PostgreSQL) + +- Connection: configured via `DATABASE_URL` environment variable +- Schema: initialized via `migrations/init.sql` +- Tables are managed with foreign key constraints and indexes for performance +- User data isolation via `user_id` column in relevant tables -- Default file: `backend_api_python/data/quantdinger.db` (override via `SQLITE_DATABASE_FILE`) -- Tables are created/updated automatically on startup (see `app/utils/db.py`) -- `qd_addon_config` exists for backward compatibility, but **this backend reads secrets from `.env` / OS env**, not from the database (see `app/utils/config_loader.py`) +## User Roles & Permissions -## AI memory augmentation (local-only) +| Role | Permissions | +|------|-------------| +| admin | Full access + user management | +| manager | Strategy, backtest, portfolio, settings | +| user | Strategy, backtest, portfolio (own data) | +| viewer | Dashboard view only | + +## API Endpoints + +### Authentication +```text +POST /api/user/login - User login +POST /api/user/logout - User logout +GET /api/user/info - Get current user info +``` + +### User Management (Admin only) +```text +GET /api/users/list - List all users +POST /api/users/create - Create user +PUT /api/users/update?id= - Update user +DELETE /api/users/delete?id= - Delete user +POST /api/users/reset-password - Reset password +``` + +### Self-Service +```text +GET /api/users/profile - Get own profile +PUT /api/users/profile/update - Update own profile +POST /api/users/change-password - Change own password +``` + +### Other Endpoints +```text +GET /api/health +GET /api/indicator/kline +POST /api/analysis/multi +``` + +## AI memory augmentation This backend includes a lightweight, privacy-first **memory-augmented multi-agent** system: -- Memory DBs (per role): `backend_api_python/data/memory/*_memory.db` -- Reflection DB (optional auto-verify loop): `backend_api_python/data/memory/reflection_records.db` +- Memory DBs stored in PostgreSQL - API hooks: - `POST /api/analysis/multi` (main entry) - `POST /api/analysis/reflect` (manual learn from post-trade outcomes) -- Controls: see `.env` / `env.example`: +- Controls in `.env`: - `ENABLE_AGENT_MEMORY`, `AGENT_MEMORY_*` - `ENABLE_REFLECTION_WORKER`, `REFLECTION_WORKER_INTERVAL_SEC` -## Frontend integration (Vue dev server) - -The Vue dev server proxies `/api/*` to this backend by default: +## Frontend integration +For Vue dev server: - Frontend: `http://localhost:8000` - Backend: `http://localhost:5000` +- Proxy config: `quantdinger_vue/vue.config.js` -Proxy config: `quantdinger_vue/vue.config.js` - -## Useful endpoints - -```text -GET /health -POST /login -GET /info -GET /api/indicator/kline -POST /api/analysis/multi -``` - -## Production (optional) - -Gunicorn example: +## Production (Gunicorn) ```bash gunicorn -c gunicorn_config.py "run:app" @@ -126,11 +207,11 @@ gunicorn -c gunicorn_config.py "run:app" ## Troubleshooting -- If outbound data/search requests fail, configure `PROXY_PORT` (or `PROXY_URL`) in `.env`. -- If you don’t want strategies to auto-restore on startup, set `DISABLE_RESTORE_RUNNING_STRATEGIES=true`. -- If you don’t want the pending-order worker, set `ENABLE_PENDING_ORDER_WORKER=false`. +- **Database connection failed**: Check `DATABASE_URL` format and PostgreSQL service status +- **Outbound requests fail**: Configure `PROXY_PORT` or `PROXY_URL` in `.env` +- **Disable auto-restore**: Set `DISABLE_RESTORE_RUNNING_STRATEGIES=true` +- **Disable pending-order worker**: Set `ENABLE_PENDING_ORDER_WORKER=false` ## License Apache License 2.0. See repository root `LICENSE`. - diff --git a/backend_api_python/app/__init__.py b/backend_api_python/app/__init__.py index e97cb1e7b..edbb67090 100644 --- a/backend_api_python/app/__init__.py +++ b/backend_api_python/app/__init__.py @@ -180,6 +180,85 @@ def create_app(config_name='default'): setup_logger() + # Initialize database and ensure admin user exists + try: + from app.utils.db import init_database, get_db_type + logger.info(f"Database type: {get_db_type()}") + init_database() + + # Ensure admin user exists (multi-user mode) + from app.services.user_service import get_user_service + get_user_service().ensure_admin_exists() + except Exception as e: + logger.warning(f"Database initialization note: {e}") + + # ===================================================== + # Demo Mode Middleware (Read-Only Mode) + # ===================================================== + import os + from flask import request, jsonify + + # Check environment variable IS_DEMO_MODE + is_demo_mode = os.getenv('IS_DEMO_MODE', 'false').lower() == 'true' + + if is_demo_mode: + logger.info("!!! SYSTEM STARTING IN DEMO MODE (READ-ONLY) !!!") + + @app.before_request + def global_demo_mode_check(): + """ + Global interceptor for demo mode. + Blocks all state-changing methods AND access to sensitive GET endpoints. + """ + path = request.path + + # 1. Block access to sensitive settings/config APIs (even if GET) + # These endpoints reveal internal config or allow settings changes + sensitive_endpoints = [ + '/api/settings', # All settings routes + '/api/credentials', # Credentials management + '/api/market/watchlist/add', # Modifying watchlist (POST, already blocked but good to be explicit) + '/api/market/watchlist/remove' + ] + + # Check if path starts with any sensitive prefix + if any(path.startswith(endpoint) for endpoint in sensitive_endpoints): + return jsonify({ + 'code': 403, + 'msg': 'Demo mode: Access to settings and credentials is forbidden.', + 'data': None + }), 403 + + # 2. Allow safe methods (GET, HEAD, OPTIONS) + if request.method in ['GET', 'HEAD', 'OPTIONS']: + return None + + # 2. Allow Authentication (Login/Logout) + # The auth routes are mounted at /api/user (see app/routes/__init__.py) + if request.path.endswith('/login') or request.path.endswith('/logout'): + return None + + # 3. Allow specific read-only POST endpoints (Whitelist) + # Some search/query endpoints use POST for complex payloads but don't modify state. + whitelist_post_endpoints = [ + '/api/indicator/getIndicators', # Search indicators + '/api/market/klines', # Fetch K-lines (sometimes POST) + '/api/ai/chat', # AI Chat (generates response, doesn't mutate system state) + '/api/analysis/indicator', # Analysis request + '/api/analysis/ai_analysis' # AI Analysis request + ] + + # Check if current path ends with any whitelist item + if any(request.path.endswith(endpoint) for endpoint in whitelist_post_endpoints): + return None + + # 4. Block everything else + return jsonify({ + 'code': 403, + 'msg': 'Demo mode: Read-only access. Forbidden to modify data.', + 'data': None + }), 403 + from app.routes import register_routes register_routes(app) diff --git a/backend_api_python/app/data/market_symbols_seed.py b/backend_api_python/app/data/market_symbols_seed.py index 5ec7ad7a7..a883c11db 100644 --- a/backend_api_python/app/data/market_symbols_seed.py +++ b/backend_api_python/app/data/market_symbols_seed.py @@ -1,120 +1,106 @@ """ -Seed symbol list used by Market APIs in local-only mode. +Market symbols seed data and lookup functions. -This file is derived from the legacy MySQL `qd_market_symbols` table (non-secret data). -It provides a deterministic offline list for: -- hot symbols per market -- lightweight symbol search +Data is stored in PostgreSQL table `qd_market_symbols` (initialized via migrations/init.sql). +This module provides helper functions to query hot symbols, search, and get symbol names. """ from __future__ import annotations from typing import Dict, List, Optional +from app.utils.logger import get_logger -SEED_MARKET_SYMBOLS: List[Dict] = [ - # AShare - {'market': 'AShare', 'symbol': '000001', 'name': '平安银行', 'exchange': 'SZSE', 'currency': 'CNY', 'is_active': 1, 'is_hot': 1, 'sort_order': 100}, - {'market': 'AShare', 'symbol': '000002', 'name': '万科A', 'exchange': 'SZSE', 'currency': 'CNY', 'is_active': 1, 'is_hot': 1, 'sort_order': 99}, - {'market': 'AShare', 'symbol': '600000', 'name': '浦发银行', 'exchange': 'SSE', 'currency': 'CNY', 'is_active': 1, 'is_hot': 1, 'sort_order': 98}, - {'market': 'AShare', 'symbol': '600036', 'name': '招商银行', 'exchange': 'SSE', 'currency': 'CNY', 'is_active': 1, 'is_hot': 1, 'sort_order': 97}, - {'market': 'AShare', 'symbol': '600519', 'name': '贵州茅台', 'exchange': 'SSE', 'currency': 'CNY', 'is_active': 1, 'is_hot': 1, 'sort_order': 96}, - {'market': 'AShare', 'symbol': '000858', 'name': '五粮液', 'exchange': 'SZSE', 'currency': 'CNY', 'is_active': 1, 'is_hot': 1, 'sort_order': 95}, - {'market': 'AShare', 'symbol': '002415', 'name': '海康威视', 'exchange': 'SZSE', 'currency': 'CNY', 'is_active': 1, 'is_hot': 1, 'sort_order': 94}, - {'market': 'AShare', 'symbol': '300059', 'name': '东方财富', 'exchange': 'SZSE', 'currency': 'CNY', 'is_active': 1, 'is_hot': 1, 'sort_order': 93}, - {'market': 'AShare', 'symbol': '000725', 'name': '京东方A', 'exchange': 'SZSE', 'currency': 'CNY', 'is_active': 1, 'is_hot': 1, 'sort_order': 92}, - {'market': 'AShare', 'symbol': '002594', 'name': '比亚迪', 'exchange': 'SZSE', 'currency': 'CNY', 'is_active': 1, 'is_hot': 1, 'sort_order': 91}, - - # USStock - {'market': 'USStock', 'symbol': 'AAPL', 'name': 'Apple Inc.', 'exchange': 'NASDAQ', 'currency': 'USD', 'is_active': 1, 'is_hot': 1, 'sort_order': 100}, - {'market': 'USStock', 'symbol': 'MSFT', 'name': 'Microsoft Corporation', 'exchange': 'NASDAQ', 'currency': 'USD', 'is_active': 1, 'is_hot': 1, 'sort_order': 99}, - {'market': 'USStock', 'symbol': 'GOOGL', 'name': 'Alphabet Inc.', 'exchange': 'NASDAQ', 'currency': 'USD', 'is_active': 1, 'is_hot': 1, 'sort_order': 98}, - {'market': 'USStock', 'symbol': 'AMZN', 'name': 'Amazon.com Inc.', 'exchange': 'NASDAQ', 'currency': 'USD', 'is_active': 1, 'is_hot': 1, 'sort_order': 97}, - {'market': 'USStock', 'symbol': 'TSLA', 'name': 'Tesla, Inc.', 'exchange': 'NASDAQ', 'currency': 'USD', 'is_active': 1, 'is_hot': 1, 'sort_order': 96}, - {'market': 'USStock', 'symbol': 'META', 'name': 'Meta Platforms Inc.', 'exchange': 'NASDAQ', 'currency': 'USD', 'is_active': 1, 'is_hot': 1, 'sort_order': 95}, - {'market': 'USStock', 'symbol': 'NVDA', 'name': 'NVIDIA Corporation', 'exchange': 'NASDAQ', 'currency': 'USD', 'is_active': 1, 'is_hot': 1, 'sort_order': 94}, - {'market': 'USStock', 'symbol': 'JPM', 'name': 'JPMorgan Chase & Co.', 'exchange': 'NYSE', 'currency': 'USD', 'is_active': 1, 'is_hot': 1, 'sort_order': 93}, - {'market': 'USStock', 'symbol': 'V', 'name': 'Visa Inc.', 'exchange': 'NYSE', 'currency': 'USD', 'is_active': 1, 'is_hot': 1, 'sort_order': 92}, - {'market': 'USStock', 'symbol': 'JNJ', 'name': 'Johnson & Johnson', 'exchange': 'NYSE', 'currency': 'USD', 'is_active': 1, 'is_hot': 1, 'sort_order': 91}, - - # HShare - {'market': 'HShare', 'symbol': '00700', 'name': 'Tencent Holdings', 'exchange': 'HKEX', 'currency': 'HKD', 'is_active': 1, 'is_hot': 1, 'sort_order': 100}, - {'market': 'HShare', 'symbol': '09988', 'name': 'Alibaba Group', 'exchange': 'HKEX', 'currency': 'HKD', 'is_active': 1, 'is_hot': 1, 'sort_order': 99}, - {'market': 'HShare', 'symbol': '03690', 'name': 'Meituan', 'exchange': 'HKEX', 'currency': 'HKD', 'is_active': 1, 'is_hot': 1, 'sort_order': 98}, - {'market': 'HShare', 'symbol': '01810', 'name': 'Xiaomi Corporation', 'exchange': 'HKEX', 'currency': 'HKD', 'is_active': 1, 'is_hot': 1, 'sort_order': 97}, - {'market': 'HShare', 'symbol': '02318', 'name': 'Ping An Insurance', 'exchange': 'HKEX', 'currency': 'HKD', 'is_active': 1, 'is_hot': 1, 'sort_order': 96}, - {'market': 'HShare', 'symbol': '01398', 'name': 'ICBC', 'exchange': 'HKEX', 'currency': 'HKD', 'is_active': 1, 'is_hot': 1, 'sort_order': 95}, - {'market': 'HShare', 'symbol': '00939', 'name': 'CCB', 'exchange': 'HKEX', 'currency': 'HKD', 'is_active': 1, 'is_hot': 1, 'sort_order': 94}, - {'market': 'HShare', 'symbol': '01299', 'name': 'AIA Group', 'exchange': 'HKEX', 'currency': 'HKD', 'is_active': 1, 'is_hot': 1, 'sort_order': 93}, - {'market': 'HShare', 'symbol': '02020', 'name': 'Anta Sports', 'exchange': 'HKEX', 'currency': 'HKD', 'is_active': 1, 'is_hot': 1, 'sort_order': 92}, - {'market': 'HShare', 'symbol': '01024', 'name': 'Kuaishou Technology', 'exchange': 'HKEX', 'currency': 'HKD', 'is_active': 1, 'is_hot': 1, 'sort_order': 91}, - - # Crypto - {'market': 'Crypto', 'symbol': 'BTC/USDT', 'name': 'Bitcoin', 'exchange': 'Binance', 'currency': 'USDT', 'is_active': 1, 'is_hot': 1, 'sort_order': 100}, - {'market': 'Crypto', 'symbol': 'ETH/USDT', 'name': 'Ethereum', 'exchange': 'Binance', 'currency': 'USDT', 'is_active': 1, 'is_hot': 1, 'sort_order': 99}, - {'market': 'Crypto', 'symbol': 'BNB/USDT', 'name': 'BNB', 'exchange': 'Binance', 'currency': 'USDT', 'is_active': 1, 'is_hot': 1, 'sort_order': 98}, - {'market': 'Crypto', 'symbol': 'SOL/USDT', 'name': 'Solana', 'exchange': 'Binance', 'currency': 'USDT', 'is_active': 1, 'is_hot': 1, 'sort_order': 97}, - {'market': 'Crypto', 'symbol': 'XRP/USDT', 'name': 'Ripple', 'exchange': 'Binance', 'currency': 'USDT', 'is_active': 1, 'is_hot': 1, 'sort_order': 96}, - {'market': 'Crypto', 'symbol': 'ADA/USDT', 'name': 'Cardano', 'exchange': 'Binance', 'currency': 'USDT', 'is_active': 1, 'is_hot': 1, 'sort_order': 95}, - {'market': 'Crypto', 'symbol': 'DOGE/USDT', 'name': 'Dogecoin', 'exchange': 'Binance', 'currency': 'USDT', 'is_active': 1, 'is_hot': 1, 'sort_order': 94}, - {'market': 'Crypto', 'symbol': 'DOT/USDT', 'name': 'Polkadot', 'exchange': 'Binance', 'currency': 'USDT', 'is_active': 1, 'is_hot': 1, 'sort_order': 93}, - {'market': 'Crypto', 'symbol': 'MATIC/USDT', 'name': 'Polygon', 'exchange': 'Binance', 'currency': 'USDT', 'is_active': 1, 'is_hot': 1, 'sort_order': 92}, - {'market': 'Crypto', 'symbol': 'AVAX/USDT', 'name': 'Avalanche', 'exchange': 'Binance', 'currency': 'USDT', 'is_active': 1, 'is_hot': 1, 'sort_order': 91}, - - # Forex (names are kept as-is from legacy dump) - {'market': 'Forex', 'symbol': 'XAUUSD', 'name': 'Euro/US Dollar', 'exchange': 'Forex', 'currency': 'USD', 'is_active': 1, 'is_hot': 1, 'sort_order': 100}, - {'market': 'Forex', 'symbol': 'XAGUSD', 'name': 'British Pound/US Dollar', 'exchange': 'Forex', 'currency': 'USD', 'is_active': 1, 'is_hot': 1, 'sort_order': 99}, - {'market': 'Forex', 'symbol': 'EURUSD', 'name': 'US Dollar/Japanese Yen', 'exchange': 'Forex', 'currency': 'USD', 'is_active': 1, 'is_hot': 1, 'sort_order': 98}, - {'market': 'Forex', 'symbol': 'GBPUSD', 'name': 'Australian Dollar/US Dollar', 'exchange': 'Forex', 'currency': 'USD', 'is_active': 1, 'is_hot': 1, 'sort_order': 97}, - {'market': 'Forex', 'symbol': 'USDJPY', 'name': 'US Dollar/Canadian Dollar', 'exchange': 'Forex', 'currency': 'USD', 'is_active': 1, 'is_hot': 1, 'sort_order': 96}, - {'market': 'Forex', 'symbol': 'AUDUSD', 'name': 'US Dollar/Swiss Franc', 'exchange': 'Forex', 'currency': 'USD', 'is_active': 1, 'is_hot': 1, 'sort_order': 95}, - {'market': 'Forex', 'symbol': 'USDCAD', 'name': 'New Zealand Dollar/US Dollar', 'exchange': 'Forex', 'currency': 'USD', 'is_active': 1, 'is_hot': 1, 'sort_order': 94}, - {'market': 'Forex', 'symbol': 'NZDUSD', 'name': 'US Dollar/Offshore Chinese Yuan', 'exchange': 'Forex', 'currency': 'USD', 'is_active': 1, 'is_hot': 1, 'sort_order': 93}, - {'market': 'Forex', 'symbol': 'USDCHF', 'name': 'Euro/British Pound', 'exchange': 'Forex', 'currency': 'EUR', 'is_active': 1, 'is_hot': 1, 'sort_order': 92}, - {'market': 'Forex', 'symbol': 'EURJPY', 'name': 'Euro/Japanese Yen', 'exchange': 'Forex', 'currency': 'EUR', 'is_active': 1, 'is_hot': 1, 'sort_order': 91}, - - # Futures - {'market': 'Futures', 'symbol': 'CL', 'name': 'WTI Crude Oil', 'exchange': 'NYMEX', 'currency': 'USD', 'is_active': 1, 'is_hot': 1, 'sort_order': 100}, - {'market': 'Futures', 'symbol': 'GC', 'name': 'Gold', 'exchange': 'COMEX', 'currency': 'USD', 'is_active': 1, 'is_hot': 1, 'sort_order': 99}, - {'market': 'Futures', 'symbol': 'SI', 'name': 'Silver', 'exchange': 'COMEX', 'currency': 'USD', 'is_active': 1, 'is_hot': 1, 'sort_order': 98}, - {'market': 'Futures', 'symbol': 'NG', 'name': 'Natural Gas', 'exchange': 'NYMEX', 'currency': 'USD', 'is_active': 1, 'is_hot': 1, 'sort_order': 97}, - {'market': 'Futures', 'symbol': 'HG', 'name': 'Copper', 'exchange': 'COMEX', 'currency': 'USD', 'is_active': 1, 'is_hot': 1, 'sort_order': 96}, - {'market': 'Futures', 'symbol': 'ZC', 'name': 'Corn', 'exchange': 'CBOT', 'currency': 'USD', 'is_active': 1, 'is_hot': 1, 'sort_order': 95}, - {'market': 'Futures', 'symbol': 'ZS', 'name': 'Soybeans', 'exchange': 'CBOT', 'currency': 'USD', 'is_active': 1, 'is_hot': 1, 'sort_order': 94}, - {'market': 'Futures', 'symbol': 'ZW', 'name': 'Wheat', 'exchange': 'CBOT', 'currency': 'USD', 'is_active': 1, 'is_hot': 1, 'sort_order': 93}, - {'market': 'Futures', 'symbol': 'ES', 'name': 'S&P 500 E-mini', 'exchange': 'CME', 'currency': 'USD', 'is_active': 1, 'is_hot': 1, 'sort_order': 92}, - {'market': 'Futures', 'symbol': 'NQ', 'name': 'NASDAQ 100 E-mini', 'exchange': 'CME', 'currency': 'USD', 'is_active': 1, 'is_hot': 1, 'sort_order': 91}, -] +logger = get_logger(__name__) + + +def _get_db_connection(): + """Get database connection, returns None if not available.""" + try: + from app.utils.db import get_db_connection + return get_db_connection() + except Exception: + return None def get_hot_symbols(market: str, limit: int = 10) -> List[Dict]: + """ + Get hot symbols for a market. + + Args: + market: Market name (e.g., 'Crypto', 'USStock', 'AShare') + limit: Maximum number of results + + Returns: + List of {market, symbol, name} dicts + """ market = (market or '').strip() - items = [x for x in SEED_MARKET_SYMBOLS if x.get('market') == market and int(x.get('is_active', 1)) == 1 and int(x.get('is_hot', 0)) == 1] - items.sort(key=lambda x: int(x.get('sort_order', 0)), reverse=True) - return [{'market': x['market'], 'symbol': x['symbol'], 'name': x.get('name') or ''} for x in items[: max(limit, 0)]] + if not market: + return [] + + try: + with _get_db_connection() as db: + cur = db.cursor() + cur.execute( + """ + SELECT market, symbol, name FROM qd_market_symbols + WHERE market = ? AND is_active = 1 AND is_hot = 1 + ORDER BY sort_order DESC + LIMIT ? + """, + (market, max(limit, 0)) + ) + rows = cur.fetchall() or [] + cur.close() + return [{'market': r['market'], 'symbol': r['symbol'], 'name': r.get('name') or ''} for r in rows] + except Exception as e: + logger.debug(f"get_hot_symbols from DB failed: {e}") + return [] def search_symbols(market: str, keyword: str, limit: int = 20) -> List[Dict]: + """ + Search symbols by keyword. + + Args: + market: Market name + keyword: Search keyword (matches symbol or name) + limit: Maximum number of results + + Returns: + List of {market, symbol, name} dicts + """ market = (market or '').strip() - kw = (keyword or '').strip().upper() + kw = (keyword or '').strip() if not market or not kw: return [] - - items = [x for x in SEED_MARKET_SYMBOLS if x.get('market') == market and int(x.get('is_active', 1)) == 1] - out: List[Dict] = [] - - for x in sorted(items, key=lambda i: int(i.get('sort_order', 0)), reverse=True): - sym = str(x.get('symbol') or '').upper() - name = str(x.get('name') or '').upper() - if kw in sym or kw in name: - out.append({'market': x['market'], 'symbol': x['symbol'], 'name': x.get('name') or ''}) - if len(out) >= max(limit, 0): - break - - return out + + # Use ILIKE for case-insensitive search in PostgreSQL + pattern = f'%{kw}%' + + try: + with _get_db_connection() as db: + cur = db.cursor() + cur.execute( + """ + SELECT market, symbol, name FROM qd_market_symbols + WHERE market = ? AND is_active = 1 + AND (UPPER(symbol) LIKE UPPER(?) OR UPPER(name) LIKE UPPER(?)) + ORDER BY sort_order DESC + LIMIT ? + """, + (market, pattern, pattern, max(limit, 0)) + ) + rows = cur.fetchall() or [] + cur.close() + return [{'market': r['market'], 'symbol': r['symbol'], 'name': r.get('name') or ''} for r in rows] + except Exception as e: + logger.debug(f"search_symbols from DB failed: {e}") + return [] def _normalize_for_match(market: str, symbol: str) -> str: + """Normalize symbol for matching (padding digits for A-Share/H-Share).""" m = (market or '').strip() s = (symbol or '').strip().upper() if not m or not s: @@ -123,7 +109,7 @@ def _normalize_for_match(market: str, symbol: str) -> str: # A-Share codes are usually 6 digits if m == 'AShare' and s.isdigit() and len(s) < 6: s = s.zfill(6) - # H-Share codes are often 5 digits in the dump + # H-Share codes are often 5 digits if m == 'HShare' and s.isdigit() and len(s) < 5: s = s.zfill(5) return s @@ -131,8 +117,14 @@ def _normalize_for_match(market: str, symbol: str) -> str: def get_symbol_name(market: str, symbol: str) -> Optional[str]: """ - Best-effort exact lookup for display name. - Returns None if not found. + Get display name for a symbol. + + Args: + market: Market name + symbol: Symbol (e.g., 'AAPL', 'BTC/USDT', '600519') + + Returns: + Symbol name or None if not found """ m = (market or '').strip() if not m: @@ -142,20 +134,65 @@ def get_symbol_name(market: str, symbol: str) -> Optional[str]: if not s: return None - # Crypto: allow user to pass BTC (try BTC/USDT) or full pair. + # Crypto: allow user to pass BTC (try BTC/USDT) or full pair candidate_symbols = [s] if m == 'Crypto' and '/' not in s: candidate_symbols.append(f"{s}/USDT") - for item in SEED_MARKET_SYMBOLS: - if item.get('market') != m: - continue - item_sym = _normalize_for_match(m, str(item.get('symbol') or '')) - for cand in candidate_symbols: - if item_sym == cand: - name = item.get('name') - return str(name) if name else None - + try: + with _get_db_connection() as db: + cur = db.cursor() + for cand in candidate_symbols: + cur.execute( + "SELECT name FROM qd_market_symbols WHERE market = ? AND UPPER(symbol) = ?", + (m, cand.upper()) + ) + row = cur.fetchone() + if row and row.get('name'): + cur.close() + return str(row['name']) + cur.close() + except Exception as e: + logger.debug(f"get_symbol_name from DB failed: {e}") + return None +def get_all_symbols(market: str = None) -> List[Dict]: + """ + Get all active symbols, optionally filtered by market. + + Args: + market: Optional market filter + + Returns: + List of symbol records + """ + try: + with _get_db_connection() as db: + cur = db.cursor() + if market: + cur.execute( + """ + SELECT market, symbol, name, exchange, currency, is_hot, sort_order + FROM qd_market_symbols + WHERE market = ? AND is_active = 1 + ORDER BY sort_order DESC + """, + (market.strip(),) + ) + else: + cur.execute( + """ + SELECT market, symbol, name, exchange, currency, is_hot, sort_order + FROM qd_market_symbols + WHERE is_active = 1 + ORDER BY market, sort_order DESC + """ + ) + rows = cur.fetchall() or [] + cur.close() + return [dict(r) for r in rows] + except Exception as e: + logger.debug(f"get_all_symbols from DB failed: {e}") + return [] diff --git a/backend_api_python/app/routes/__init__.py b/backend_api_python/app/routes/__init__.py index 8c52f2f2b..2e2edb8dd 100644 --- a/backend_api_python/app/routes/__init__.py +++ b/backend_api_python/app/routes/__init__.py @@ -21,9 +21,12 @@ def register_routes(app: Flask): from app.routes.portfolio import portfolio_bp from app.routes.ibkr import ibkr_bp from app.routes.mt5 import mt5_bp + from app.routes.user import user_bp + from app.routes.strategy_code import strategy_code_bp app.register_blueprint(health_bp) - app.register_blueprint(auth_bp, url_prefix='/api/user') + app.register_blueprint(auth_bp, url_prefix='/api/auth') # Auth routes + app.register_blueprint(user_bp, url_prefix='/api/users') # User management app.register_blueprint(kline_bp, url_prefix='/api/indicator') app.register_blueprint(analysis_bp, url_prefix='/api/analysis') app.register_blueprint(backtest_bp, url_prefix='/api/indicator') @@ -37,4 +40,4 @@ def register_routes(app: Flask): app.register_blueprint(portfolio_bp, url_prefix='/api/portfolio') app.register_blueprint(ibkr_bp, url_prefix='/api/ibkr') app.register_blueprint(mt5_bp, url_prefix='/api/mt5') - + app.register_blueprint(strategy_code_bp, url_prefix='/api/strategy-code') diff --git a/backend_api_python/app/routes/ai_chat.py b/backend_api_python/app/routes/ai_chat.py index 81e37025f..14831ebd3 100644 --- a/backend_api_python/app/routes/ai_chat.py +++ b/backend_api_python/app/routes/ai_chat.py @@ -32,7 +32,7 @@ def chat_message(): }) -@ai_chat_bp.route('/chat/history', methods=['POST']) +@ai_chat_bp.route('/chat/history', methods=['GET']) def get_chat_history(): """Return empty history (compatibility stub).""" return jsonify({'code': 1, 'msg': 'success', 'data': []}) diff --git a/backend_api_python/app/routes/analysis.py b/backend_api_python/app/routes/analysis.py index 8ec144b1c..10748441c 100644 --- a/backend_api_python/app/routes/analysis.py +++ b/backend_api_python/app/routes/analysis.py @@ -2,7 +2,7 @@ Analysis API routes (local-only). Implements multi-dimensional analysis plus lightweight task/history APIs for the frontend. """ -from flask import Blueprint, request, jsonify, Response +from flask import Blueprint, request, jsonify, Response, g import json import traceback import time @@ -11,11 +11,11 @@ from app.utils.logger import get_logger from app.utils.db import get_db_connection from app.utils.language import detect_request_language +from app.utils.auth import login_required logger = get_logger(__name__) analysis_bp = Blueprint('analysis', __name__) -DEFAULT_USER_ID = 1 def _now_ts() -> int: return int(time.time()) @@ -23,27 +23,58 @@ def _now_ts() -> int: def _normalize_symbol(symbol: str) -> str: return (symbol or '').strip().upper() -def _store_task(market: str, symbol: str, model: str, language: str, status: str, result: dict = None, error_message: str = "") -> int: - now = _now_ts() +def _store_task(user_id: int, market: str, symbol: str, model: str, language: str, status: str, result: dict = None, error_message: str = "") -> int: + """Create a new task record. For pending tasks, completed_at is NULL.""" result_json = json.dumps(result or {}, ensure_ascii=False) with get_db_connection() as db: cur = db.cursor() - cur.execute( - """ - INSERT INTO qd_analysis_tasks (user_id, market, symbol, model, language, status, result_json, error_message, created_at, completed_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - """, - (DEFAULT_USER_ID, market, symbol, model or '', language or 'en-US', status, result_json, error_message or '', now, now if status in ['completed', 'failed'] else None) - ) + if status in ['completed', 'failed']: + cur.execute( + """ + INSERT INTO qd_analysis_tasks (user_id, market, symbol, model, language, status, result_json, error_message, created_at, completed_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, NOW(), NOW()) + """, + (user_id, market, symbol, model or '', language or 'en-US', status, result_json, error_message or '') + ) + else: + cur.execute( + """ + INSERT INTO qd_analysis_tasks (user_id, market, symbol, model, language, status, result_json, error_message, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, NOW()) + """, + (user_id, market, symbol, model or '', language or 'en-US', status, result_json, error_message or '') + ) task_id = cur.lastrowid db.commit() cur.close() return int(task_id) -def _get_task(task_id: int) -> dict: + +def _update_task(task_id: int, status: str, result: dict = None, error_message: str = "") -> bool: + """Update an existing task with result and status.""" + try: + result_json = json.dumps(result or {}, ensure_ascii=False) + with get_db_connection() as db: + cur = db.cursor() + cur.execute( + """ + UPDATE qd_analysis_tasks + SET status = ?, result_json = ?, error_message = ?, completed_at = NOW() + WHERE id = ? + """, + (status, result_json, error_message or '', task_id) + ) + db.commit() + cur.close() + return True + except Exception as e: + logger.error(f"_update_task failed: {e}") + return False + +def _get_task(task_id: int, user_id: int) -> dict: with get_db_connection() as db: cur = db.cursor() - cur.execute("SELECT * FROM qd_analysis_tasks WHERE id = ? AND user_id = ?", (task_id, DEFAULT_USER_ID)) + cur.execute("SELECT * FROM qd_analysis_tasks WHERE id = ? AND user_id = ?", (task_id, user_id)) row = cur.fetchone() cur.close() return row or None @@ -60,16 +91,25 @@ def _parse_result_json(row: dict) -> dict: @analysis_bp.route('/multi', methods=['POST']) @analysis_bp.route('/multiAnalysis', methods=['POST']) # compatibility with legacy naming +@login_required def multi_analysis(): """ - Multi-dimensional analysis. + Multi-dimensional analysis for the current user. Request body: market: Market (AShare, USStock, HShare, Crypto, Forex, Futures) symbol: Symbol language: Optional; if omitted we will detect from request headers (X-App-Lang / Accept-Language) """ + task_id = None + user_id = None + market = '' + symbol = '' + model = None + language = 'en-US' + try: + user_id = g.user_id data = request.get_json() if not data: return jsonify({ @@ -100,49 +140,83 @@ def multi_analysis(): logger.info(f"Analyze request: {market}:{symbol}, use_multi_agent={use_multi_agent}, model={model}") - # Create analysis service instance (local-only; no paid credits) - service = AnalysisService(use_multi_agent=use_multi_agent) - result = service.analyze(market, symbol, language, model=model, timeframe=timeframe) - - # Persist as "completed" history (no paid credits in local mode). - task_id = _store_task(market, symbol, model or '', language, 'completed', result=result, error_message='') - - # Keep frontend compatible: if it expects task polling, it can still use the id. - result_payload = dict(result or {}) - result_payload['task_id'] = task_id + # Step 0: Check billing (计费检查) + from app.services.billing_service import get_billing_service + billing_success, billing_msg = get_billing_service().check_and_consume( + user_id=user_id, + feature='ai_analysis', + reference_id=f'{market}:{symbol}' + ) + if not billing_success: + if 'insufficient_credits' in billing_msg: + parts = billing_msg.split(':') + current = parts[1] if len(parts) > 1 else '0' + required = parts[2] if len(parts) > 2 else '?' + return jsonify({ + 'code': 0, + 'msg': f'Insufficient credits. Current: {current}, Required: {required}', + 'data': {'error_type': 'insufficient_credits', 'current': current, 'required': required} + }), 402 + return jsonify({'code': 0, 'msg': billing_msg, 'data': None}), 400 + + # Step 1: Create a "pending" task record first (so user can see progress in history) + task_id = _store_task(user_id, market, symbol, model or '', language, 'pending', result={}, error_message='') + + # Step 2: Run analysis in background thread (so user can navigate away) + import threading + + def run_analysis_background(task_id_inner, market_inner, symbol_inner, language_inner, model_inner, timeframe_inner, use_multi_agent_inner): + """Execute analysis in background and update task when done.""" + try: + service = AnalysisService(use_multi_agent=use_multi_agent_inner) + result = service.analyze(market_inner, symbol_inner, language_inner, model=model_inner, timeframe=timeframe_inner) + _update_task(task_id_inner, 'completed', result=result, error_message='') + logger.info(f"Background analysis completed for task {task_id_inner}") + except Exception as e: + logger.error(f"Background analysis failed for task {task_id_inner}: {e}") + _update_task(task_id_inner, 'failed', result={}, error_message=str(e)) + + analysis_thread = threading.Thread( + target=run_analysis_background, + args=(task_id, market, symbol, language, model, timeframe, use_multi_agent), + daemon=False # Keep running even if main request thread ends + ) + analysis_thread.start() - return jsonify({'code': 1, 'msg': 'success', 'data': result_payload}) + # Step 3: Return immediately with task_id (frontend will poll for results) + return jsonify({'code': 1, 'msg': 'success', 'data': {'task_id': task_id, 'status': 'pending'}}) except Exception as e: logger.error(f"Analysis failed: {str(e)}") logger.error(traceback.format_exc()) + + # Update existing task as "failed", or create a new failed record if task_id doesn't exist try: - market = (data or {}).get('market', '') if 'data' in locals() else '' - symbol = (data or {}).get('symbol', '') if 'data' in locals() else '' - language = detect_request_language(request, body=(data or {}), default='en-US') - model = (data or {}).get('model', '') if 'data' in locals() else '' - market = str(market).strip() - symbol = _normalize_symbol(symbol) - _store_task(market, symbol, model, language, 'failed', result={}, error_message=str(e)) + if task_id: + _update_task(task_id, 'failed', result={}, error_message=str(e)) + elif user_id: + _store_task(user_id, market, symbol, model or '', language, 'failed', result={}, error_message=str(e)) except Exception: pass + return jsonify({ 'code': 0, 'msg': f'Analysis failed: {str(e)}', - 'data': None + 'data': {'task_id': task_id} if task_id else None }), 500 -@analysis_bp.route('/getTaskStatus', methods=['POST']) +@analysis_bp.route('/getTaskStatus', methods=['GET']) +@login_required def get_task_status(): - """Frontend compatibility: return task status + result by task_id.""" + """Frontend compatibility: return task status + result by task_id for the current user.""" try: - data = request.get_json() or {} - task_id = int(data.get('task_id') or 0) + user_id = g.user_id + task_id = int(request.args.get('task_id') or 0) if not task_id: return jsonify({'code': 0, 'msg': 'Missing task_id', 'data': None}), 400 - row = _get_task(task_id) + row = _get_task(task_id, user_id) if not row: return jsonify({'code': 0, 'msg': 'Task not found', 'data': None}), 404 @@ -161,20 +235,21 @@ def get_task_status(): return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500 -@analysis_bp.route('/getHistoryList', methods=['POST']) +@analysis_bp.route('/getHistoryList', methods=['GET']) +@login_required def get_history_list(): - """Frontend compatibility: paginated analysis history for the single user.""" + """Frontend compatibility: paginated analysis history for the current user.""" try: - data = request.get_json() or {} - page = int(data.get('page') or 1) - pagesize = int(data.get('pagesize') or 20) + user_id = g.user_id + page = int(request.args.get('page') or 1) + pagesize = int(request.args.get('pagesize') or 20) page = max(page, 1) pagesize = min(max(pagesize, 1), 100) offset = (page - 1) * pagesize with get_db_connection() as db: cur = db.cursor() - cur.execute("SELECT COUNT(1) as cnt FROM qd_analysis_tasks WHERE user_id = ?", (DEFAULT_USER_ID,)) + cur.execute("SELECT COUNT(1) as cnt FROM qd_analysis_tasks WHERE user_id = ?", (user_id,)) total = int((cur.fetchone() or {}).get('cnt') or 0) cur.execute( """ @@ -184,7 +259,7 @@ def get_history_list(): ORDER BY id DESC LIMIT ? OFFSET ? """, - (DEFAULT_USER_ID, pagesize, offset) + (user_id, pagesize, offset) ) rows = cur.fetchall() or [] cur.close() @@ -192,6 +267,11 @@ def get_history_list(): out = [] for r in rows: has_result = bool((r.get('result_json') or '').strip()) + # Convert datetime to Unix timestamp for frontend compatibility + created_at = r.get('created_at') + completed_at = r.get('completed_at') + createtime = int(created_at.timestamp()) if created_at else 0 + completetime = int(completed_at.timestamp()) if completed_at else None out.append({ 'id': r.get('id'), 'market': r.get('market'), @@ -200,8 +280,8 @@ def get_history_list(): 'status': r.get('status'), 'has_result': has_result, 'error_message': r.get('error_message') or '', - 'createtime': int(r.get('created_at') or 0), - 'completetime': int(r.get('completed_at') or 0) if r.get('completed_at') else None + 'createtime': createtime, + 'completetime': completetime }) return jsonify({'code': 1, 'msg': 'success', 'data': {'list': out, 'total': total}}) @@ -211,13 +291,46 @@ def get_history_list(): return jsonify({'code': 0, 'msg': str(e), 'data': {'list': [], 'total': 0}}), 500 +@analysis_bp.route('/deleteTask', methods=['POST']) +@login_required +def delete_task(): + """Delete an analysis task by task_id for the current user.""" + try: + user_id = g.user_id + data = request.get_json() or {} + task_id = int(data.get('task_id') or 0) + + if not task_id: + return jsonify({'code': 0, 'msg': 'Missing task_id', 'data': None}), 400 + + # Verify task belongs to user + row = _get_task(task_id, user_id) + if not row: + return jsonify({'code': 0, 'msg': 'Task not found', 'data': None}), 404 + + # Delete the task + with get_db_connection() as db: + cur = db.cursor() + cur.execute("DELETE FROM qd_analysis_tasks WHERE id = ? AND user_id = ?", (task_id, user_id)) + db.commit() + cur.close() + + return jsonify({'code': 1, 'msg': 'success', 'data': {'deleted_id': task_id}}) + except Exception as e: + logger.error(f"delete_task failed: {str(e)}") + logger.error(traceback.format_exc()) + return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500 + + @analysis_bp.route('/createTask', methods=['POST']) +@login_required def create_task(): """ Compatibility endpoint for legacy frontend. In local-only mode we do not run a separate async worker; we create a completed task record immediately. """ try: + user_id = g.user_id data = request.get_json() or {} market = str((data.get('market') or '')).strip() symbol = _normalize_symbol(data.get('symbol')) @@ -228,7 +341,7 @@ def create_task(): return jsonify({'code': 0, 'msg': 'Missing market or symbol', 'data': None}), 400 # Create a placeholder "pending" task so frontend can show task_id if it needs it. - task_id = _store_task(market, symbol, str(model), language, 'pending', result={}, error_message='') + task_id = _store_task(user_id, market, symbol, str(model), language, 'pending', result={}, error_message='') return jsonify({'code': 1, 'msg': 'success', 'data': {'task_id': task_id, 'status': 'pending'}}) except Exception as e: logger.error(f"create_task failed: {str(e)}") @@ -236,47 +349,8 @@ def create_task(): return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500 -@analysis_bp.route('/stream', methods=['POST']) -def stream_analysis(): - """Streaming analysis (SSE).""" - try: - data = request.get_json() - if not data: - return jsonify({'code': 0, 'msg': 'Request body is required'}), 400 - - market = data.get('market', '') - symbol = data.get('symbol', '') - language = detect_request_language(request, body=data, default='en-US') - use_multi_agent = data.get('use_multi_agent', None) - timeframe = data.get('timeframe', '1D') - - def generate(): - try: - yield f"data: {json.dumps({'status': 'started', 'message': 'Analysis started'})}\n\n" - - service = AnalysisService(use_multi_agent=use_multi_agent) - result = service.analyze(market, symbol, language, timeframe=timeframe) - - yield f"data: {json.dumps({'status': 'completed', 'data': result})}\n\n" - except Exception as e: - yield f"data: {json.dumps({'status': 'error', 'message': str(e)})}\n\n" - - return Response( - generate(), - mimetype='text/event-stream', - headers={ - 'Cache-Control': 'no-cache', - 'Connection': 'keep-alive', - 'X-Accel-Buffering': 'no' - } - ) - - except Exception as e: - logger.error(f"Streaming analysis failed: {str(e)}") - return jsonify({'code': 0, 'msg': str(e)}), 500 - - @analysis_bp.route('/reflect', methods=['POST']) +@login_required def reflect(): """ Reflection API. diff --git a/backend_api_python/app/routes/auth.py b/backend_api_python/app/routes/auth.py index 36993232f..f25c1953f 100644 --- a/backend_api_python/app/routes/auth.py +++ b/backend_api_python/app/routes/auth.py @@ -1,68 +1,1032 @@ +""" +Authentication API Routes -from flask import Blueprint, request, jsonify +Handles login, logout, registration, password reset, and OAuth authentication. +Supports both multi-user (database) and single-user (legacy) modes. +""" +import os +from flask import Blueprint, request, jsonify, g, redirect +from urllib.parse import urlencode from app.config.settings import Config -from app.utils.auth import generate_token +from app.utils.auth import generate_token, login_required, authenticate_legacy from app.utils.logger import get_logger -auth_bp = Blueprint('auth', __name__) logger = get_logger(__name__) +auth_bp = Blueprint('auth', __name__) + +def _build_frontend_login_redirect(frontend_url: str, **params) -> str: + """ + Build a redirect URL to frontend login page for OAuth flows. + + Frontend uses Vue Router hash mode (`/#/user/login`), so redirecting to `/user/login` + will 404 on static hosting. Always normalize to `{origin}/#/user/login`. + """ + base = (frontend_url or '').strip().rstrip('/') + if not base: + base = 'http://localhost:8080' + + if '/#/' in base: + origin = base.split('/#/', 1)[0].rstrip('/') + elif '#' in base: + origin = base.split('#', 1)[0].rstrip('/') + else: + origin = base + + login_url = f"{origin}/#/user/login" + qs = urlencode({k: v for k, v in params.items() if v is not None and v != ''}) + return f"{login_url}?{qs}" if qs else login_url + + +def _is_single_user_mode() -> bool: + """Check if system is in single-user (legacy) mode""" + return os.getenv('SINGLE_USER_MODE', 'false').lower() == 'true' + + +def _get_client_ip() -> str: + """Get client IP address from request""" + # Check for proxy headers + if request.headers.get('X-Forwarded-For'): + return request.headers.get('X-Forwarded-For').split(',')[0].strip() + if request.headers.get('X-Real-IP'): + return request.headers.get('X-Real-IP') + return request.remote_addr or '0.0.0.0' + + +def _get_user_agent() -> str: + """Get user agent from request""" + return request.headers.get('User-Agent', '')[:500] + + +# ============================================================================= +# Security Config Endpoint +# ============================================================================= + +@auth_bp.route('/security-config', methods=['GET']) +def get_security_config(): + """ + Get public security configuration for frontend. + + Returns: + turnstile_enabled: bool + turnstile_site_key: str + registration_enabled: bool + oauth_google_enabled: bool + oauth_github_enabled: bool + """ + try: + from app.services.security_service import get_security_service + config = get_security_service().get_security_config() + return jsonify({'code': 1, 'msg': 'success', 'data': config}) + except Exception as e: + logger.error(f"get_security_config error: {e}") + return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500 + + +# ============================================================================= +# Login Endpoint (Enhanced with security) +# ============================================================================= + @auth_bp.route('/login', methods=['POST']) def login(): - """Login (single-user, env-configured credentials).""" + """ + User login endpoint. + + Request body: + username: str + password: str + turnstile_token: str (optional, required if Turnstile is enabled) + + Returns: + token: JWT token + userinfo: User information + """ + ip_address = _get_client_ip() + user_agent = _get_user_agent() + try: + from app.services.security_service import get_security_service + security = get_security_service() + data = request.get_json() if not data: return jsonify({'code': 400, 'msg': 'No data provided', 'data': None}), 400 - + username = data.get('username') or data.get('account') password = data.get('password') + turnstile_token = data.get('turnstile_token') if not username or not password: - return jsonify({'code': 400, 'msg': 'Missing username or password', 'data': None}), 400 - - # Validate credentials from environment / settings - if username == Config.ADMIN_USER and password == Config.ADMIN_PASSWORD: - token = generate_token(username) - if token: - return jsonify({ - 'code': 1, - 'msg': 'Login successful', - 'data': { - 'token': token, - 'userinfo': { - 'username': username, - 'nickname': 'Admin', - 'avatar': '' - } - } - }) - else: - return jsonify({'code': 500, 'msg': 'Token generation error', 'data': None}), 500 - else: + return jsonify({'code': 400, 'msg': 'Missing username/email or password', 'data': None}), 400 + + # Step 1: Verify Turnstile (if enabled) + turnstile_ok, turnstile_msg = security.verify_turnstile(turnstile_token, ip_address) + if not turnstile_ok: + return jsonify({'code': 0, 'msg': turnstile_msg, 'data': None}), 400 + + # Step 2: Check rate limiting + allowed, block_msg = security.check_login_allowed(username, ip_address) + if not allowed: + return jsonify({'code': 0, 'msg': block_msg, 'data': {'blocked': True}}), 429 + + is_demo = os.getenv('IS_DEMO_MODE', 'false').lower() == 'true' + user = None + + # Step 3: Authenticate + if not _is_single_user_mode(): + try: + from app.services.user_service import get_user_service + user = get_user_service().authenticate(username, password) + + # Check if user has no password set (code-login user) + if user and user.get('_no_password'): + user.pop('_no_password', None) + # Record failed attempt + security.record_login_attempt(ip_address, 'ip', False, ip_address, user_agent) + security.record_login_attempt(username, 'account', False, ip_address, user_agent) + security.log_security_event('login_failed', user.get('id'), ip_address, user_agent, + {'username': username, 'reason': 'no_password_set'}) + return jsonify({ + 'code': 0, + 'msg': 'This account was created with email verification code and has no password set. Please use email code login or set a password first in your profile settings.', + 'data': None + }), 401 + except Exception as e: + logger.warning(f"Multi-user auth failed, trying legacy: {e}") + + # Fallback to legacy single-user mode + if not user: + user = authenticate_legacy(username, password) + + if not user: + # Record failed attempt + security.record_login_attempt(ip_address, 'ip', False, ip_address, user_agent) + security.record_login_attempt(username, 'account', False, ip_address, user_agent) + security.log_security_event('login_failed', None, ip_address, user_agent, + {'username': username, 'reason': 'invalid_credentials'}) return jsonify({'code': 0, 'msg': 'Invalid credentials', 'data': None}), 401 + + # Check user status + if user.get('status') == 'disabled': + security.log_security_event('login_blocked', user.get('id'), ip_address, user_agent, + {'reason': 'account_disabled'}) + return jsonify({'code': 0, 'msg': 'Account is disabled', 'data': None}), 403 + + if user.get('status') == 'pending': + return jsonify({'code': 0, 'msg': 'Account is pending activation', 'data': None}), 403 + + # Step 4: Generate token + token = generate_token( + user_id=user.get('id') or user.get('user_id', 1), + username=user.get('username', username), + role=user.get('role', 'admin') + ) + + if not token: + return jsonify({'code': 500, 'msg': 'Token generation error', 'data': None}), 500 + + # Step 5: Record successful login + security.record_login_attempt(ip_address, 'ip', True, ip_address, user_agent) + security.record_login_attempt(username, 'account', True, ip_address, user_agent) + security.clear_login_attempts(ip_address, 'ip') + security.clear_login_attempts(username, 'account') + security.log_security_event('login_success', user.get('id'), ip_address, user_agent) + + # Build user info for frontend + userinfo = { + 'id': user.get('id') or user.get('user_id', 1), + 'username': user.get('username', username), + 'nickname': user.get('nickname', 'User') + (' (Demo)' if is_demo else ''), + 'avatar': user.get('avatar', '/avatar2.jpg'), + 'is_demo': is_demo, + 'role': { + 'id': user.get('role', 'admin'), + 'permissions': _get_permissions(user.get('role', 'admin')) + } + } + + return jsonify({ + 'code': 1, + 'msg': 'Login successful', + 'data': { + 'token': token, + 'userinfo': userinfo + } + }) except Exception as e: logger.error(f"Login error: {e}") return jsonify({'code': 500, 'msg': str(e), 'data': None}), 500 + +# ============================================================================= +# Email Code Login +# ============================================================================= + +@auth_bp.route('/login-code', methods=['POST']) +def login_with_code(): + """ + Login with email verification code (quick login / register). + If user doesn't exist, create a new account automatically. + + Request body: + email: str + code: str (verification code) + turnstile_token: str (optional) + referral_code: str (optional, referrer's user ID - only for new users) + """ + ip_address = _get_client_ip() + user_agent = _get_user_agent() + + try: + from app.services.security_service import get_security_service + from app.services.email_service import get_email_service + from app.services.user_service import get_user_service + from app.services.billing_service import get_billing_service + + security = get_security_service() + email_service = get_email_service() + user_service = get_user_service() + billing_service = get_billing_service() + + data = request.get_json() + if not data: + return jsonify({'code': 0, 'msg': 'No data provided', 'data': None}), 400 + + email = (data.get('email') or '').strip().lower() + code = data.get('code', '').strip() + turnstile_token = data.get('turnstile_token') + referral_code = data.get('referral_code', '').strip() + + # Validate inputs + if not email or not email_service.is_valid_email(email): + return jsonify({'code': 0, 'msg': 'Invalid email address', 'data': None}), 400 + + if not code: + return jsonify({'code': 0, 'msg': 'Verification code is required', 'data': None}), 400 + + # Verify Turnstile + turnstile_ok, turnstile_msg = security.verify_turnstile(turnstile_token, ip_address) + if not turnstile_ok: + return jsonify({'code': 0, 'msg': turnstile_msg, 'data': None}), 400 + + # Verify email code + code_valid, code_msg = email_service.verify_code(email, code, 'login') + if not code_valid: + return jsonify({'code': 0, 'msg': code_msg, 'data': None}), 400 + + # Check if user exists + user = user_service.get_user_by_email(email) + is_new_user = False + + if not user: + # Check if registration is enabled + if os.getenv('ENABLE_REGISTRATION', 'true').lower() != 'true': + return jsonify({'code': 0, 'msg': 'User not found and registration is disabled', 'data': None}), 403 + + # Auto-create user with email as username + import re + # Generate username from email (before @) + base_username = re.sub(r'[^a-zA-Z0-9_]', '', email.split('@')[0]) + if not base_username or not base_username[0].isalpha(): + base_username = 'user_' + base_username + + # Make sure username is unique + username = base_username + counter = 1 + while user_service.get_user_by_username(username): + username = f"{base_username}_{counter}" + counter += 1 + + # Validate referral code (user ID) + referred_by = None + if referral_code: + try: + referrer_id = int(referral_code) + referrer = user_service.get_user_by_id(referrer_id) + if referrer and referrer.get('status') == 'active': + referred_by = referrer_id + except (ValueError, TypeError): + pass # Invalid referral code, ignore + + # Create user without password (can set later) + user_id = user_service.create_user( + username=username, + password=None, # No password for code-login users + email=email, + nickname=username, + role='user', + status='active', + email_verified=True, + referred_by=referred_by + ) + + if not user_id: + return jsonify({'code': 0, 'msg': 'Failed to create account', 'data': None}), 500 + + # Grant registration bonus credits + register_bonus = int(os.getenv('CREDITS_REGISTER_BONUS', '0')) + if register_bonus > 0: + billing_service.add_credits( + user_id=user_id, + amount=register_bonus, + action='register_bonus', + remark='Registration bonus' + ) + + # Grant referral bonus to referrer + if referred_by: + referral_bonus = int(os.getenv('CREDITS_REFERRAL_BONUS', '0')) + if referral_bonus > 0: + billing_service.add_credits( + user_id=referred_by, + amount=referral_bonus, + action='referral_bonus', + remark=f'Referral bonus for inviting user {username}', + reference_id=str(user_id) + ) + + user = user_service.get_user_by_id(user_id) + is_new_user = True + + # Log registration + security.log_security_event('register_via_code', user_id, ip_address, user_agent, + {'email': email, 'referred_by': referred_by}) + + # Check user status + if user.get('status') == 'disabled': + security.log_security_event('login_blocked', user.get('id'), ip_address, user_agent, + {'reason': 'account_disabled'}) + return jsonify({'code': 0, 'msg': 'Account is disabled', 'data': None}), 403 + + # Generate token + token = generate_token( + user_id=user['id'], + username=user['username'], + role=user.get('role', 'user') + ) + + if not token: + return jsonify({'code': 500, 'msg': 'Token generation error', 'data': None}), 500 + + # Update last login time + try: + from app.utils.db import get_db_connection + with get_db_connection() as db: + cur = db.cursor() + cur.execute( + "UPDATE qd_users SET last_login_at = NOW() WHERE id = ?", + (user['id'],) + ) + db.commit() + cur.close() + except Exception as e: + logger.warning(f"Failed to update last_login_at: {e}") + + # Log login + security.log_security_event('login_via_code', user['id'], ip_address, user_agent) + + is_demo = os.getenv('IS_DEMO_MODE', 'false').lower() == 'true' + + return jsonify({ + 'code': 1, + 'msg': 'Login successful' + (' (new account created)' if is_new_user else ''), + 'data': { + 'token': token, + 'is_new_user': is_new_user, + 'userinfo': { + 'id': user['id'], + 'username': user['username'], + 'nickname': user.get('nickname', user['username']) + (' (Demo)' if is_demo else ''), + 'email': user.get('email'), + 'avatar': user.get('avatar', '/avatar2.jpg'), + 'is_demo': is_demo, + 'role': { + 'id': user.get('role', 'user'), + 'permissions': _get_permissions(user.get('role', 'user')) + } + } + } + }) + + except Exception as e: + logger.error(f"login_with_code error: {e}") + return jsonify({'code': 0, 'msg': 'Login failed', 'data': None}), 500 + + +# ============================================================================= +# Registration Endpoints +# ============================================================================= + +@auth_bp.route('/send-code', methods=['POST']) +def send_verification_code(): + """ + Send verification code to email. + + Request body: + email: str + type: str (register, reset_password, change_password, change_email) + turnstile_token: str (optional) + """ + ip_address = _get_client_ip() + + try: + from app.services.security_service import get_security_service + from app.services.email_service import get_email_service + + security = get_security_service() + email_service = get_email_service() + + data = request.get_json() + if not data: + return jsonify({'code': 0, 'msg': 'No data provided', 'data': None}), 400 + + email = (data.get('email') or '').strip().lower() + code_type = data.get('type', 'register') + turnstile_token = data.get('turnstile_token') + + # Validate email + if not email or not email_service.is_valid_email(email): + return jsonify({'code': 0, 'msg': 'Invalid email address', 'data': None}), 400 + + # Verify Turnstile + turnstile_ok, turnstile_msg = security.verify_turnstile(turnstile_token, ip_address) + if not turnstile_ok: + return jsonify({'code': 0, 'msg': turnstile_msg, 'data': None}), 400 + + # Check rate limit + can_send, rate_msg = security.can_send_verification_code(email, ip_address) + if not can_send: + return jsonify({'code': 0, 'msg': rate_msg, 'data': None}), 429 + + # For registration, check if email already exists + if code_type == 'register': + from app.services.user_service import get_user_service + existing = get_user_service().get_user_by_email(email) + if existing: + return jsonify({'code': 0, 'msg': 'Email already registered', 'data': None}), 400 + + # For login type - always allow (will auto-create if not exists) + # No special check needed + + # For reset_password, check if email exists + if code_type == 'reset_password': + from app.services.user_service import get_user_service + existing = get_user_service().get_user_by_email(email) + if not existing: + # Don't reveal if email exists or not (security best practice) + # But still return success to prevent email enumeration + return jsonify({'code': 1, 'msg': 'If the email exists, a verification code has been sent', 'data': None}) + + # Send verification code + success, msg = email_service.send_verification_code(email, code_type, ip_address) + + if success: + security.log_security_event('verification_code_sent', None, ip_address, + _get_user_agent(), {'email': email, 'type': code_type}) + return jsonify({'code': 1, 'msg': 'Verification code sent', 'data': None}) + else: + return jsonify({'code': 0, 'msg': msg, 'data': None}), 500 + + except Exception as e: + logger.error(f"send_verification_code error: {e}") + return jsonify({'code': 0, 'msg': 'Failed to send verification code', 'data': None}), 500 + + +@auth_bp.route('/register', methods=['POST']) +def register(): + """ + Register new user with email verification. + + Request body: + email: str + code: str (verification code) + username: str + password: str + turnstile_token: str (optional) + referral_code: str (optional, referrer's user ID) + """ + ip_address = _get_client_ip() + user_agent = _get_user_agent() + + try: + # Check if registration is enabled + if os.getenv('ENABLE_REGISTRATION', 'true').lower() != 'true': + return jsonify({'code': 0, 'msg': 'Registration is disabled', 'data': None}), 403 + + from app.services.security_service import get_security_service + from app.services.email_service import get_email_service + from app.services.user_service import get_user_service + from app.services.billing_service import get_billing_service + + security = get_security_service() + email_service = get_email_service() + user_service = get_user_service() + billing_service = get_billing_service() + + data = request.get_json() + if not data: + return jsonify({'code': 0, 'msg': 'No data provided', 'data': None}), 400 + + email = (data.get('email') or '').strip().lower() + code = data.get('code', '').strip() + username = (data.get('username') or '').strip() + password = data.get('password', '') + turnstile_token = data.get('turnstile_token') + referral_code = data.get('referral_code', '').strip() + + # Validate inputs + if not email or not email_service.is_valid_email(email): + return jsonify({'code': 0, 'msg': 'Invalid email address', 'data': None}), 400 + + if not code: + return jsonify({'code': 0, 'msg': 'Verification code is required', 'data': None}), 400 + + if not username or len(username) < 3 or len(username) > 30: + return jsonify({'code': 0, 'msg': 'Username must be 3-30 characters', 'data': None}), 400 + + # Validate username format (alphanumeric and underscore only) + import re + if not re.match(r'^[a-zA-Z][a-zA-Z0-9_]*$', username): + return jsonify({'code': 0, 'msg': 'Username must start with letter and contain only letters, numbers, and underscores', 'data': None}), 400 + + # Validate password strength + pwd_valid, pwd_msg = security.validate_password_strength(password) + if not pwd_valid: + return jsonify({'code': 0, 'msg': pwd_msg, 'data': None}), 400 + + # Verify Turnstile + turnstile_ok, turnstile_msg = security.verify_turnstile(turnstile_token, ip_address) + if not turnstile_ok: + return jsonify({'code': 0, 'msg': turnstile_msg, 'data': None}), 400 + + # Verify email code + code_valid, code_msg = email_service.verify_code(email, code, 'register') + if not code_valid: + return jsonify({'code': 0, 'msg': code_msg, 'data': None}), 400 + + # Check if username already exists + existing_user = user_service.get_user_by_username(username) + if existing_user: + return jsonify({'code': 0, 'msg': 'Username already taken', 'data': None}), 400 + + # Check if email already exists + existing_email = user_service.get_user_by_email(email) + if existing_email: + return jsonify({'code': 0, 'msg': 'Email already registered', 'data': None}), 400 + + # Validate referral code (user ID) + referred_by = None + if referral_code: + try: + referrer_id = int(referral_code) + referrer = user_service.get_user_by_id(referrer_id) + if referrer and referrer.get('status') == 'active': + referred_by = referrer_id + except (ValueError, TypeError): + pass # Invalid referral code, ignore + + # Create user + user_id = user_service.create_user( + username=username, + password=password, + email=email, + nickname=username, + role='user', + status='active', + email_verified=True, + referred_by=referred_by + ) + + if not user_id: + return jsonify({'code': 0, 'msg': 'Failed to create account', 'data': None}), 500 + + # Grant registration bonus credits + register_bonus = int(os.getenv('CREDITS_REGISTER_BONUS', '0')) + if register_bonus > 0: + billing_service.add_credits( + user_id=user_id, + amount=register_bonus, + action='register_bonus', + remark='Registration bonus' + ) + + # Grant referral bonus to referrer + if referred_by: + referral_bonus = int(os.getenv('CREDITS_REFERRAL_BONUS', '0')) + if referral_bonus > 0: + billing_service.add_credits( + user_id=referred_by, + amount=referral_bonus, + action='referral_bonus', + remark=f'Referral bonus for inviting user {username}', + reference_id=str(user_id) + ) + + # Log registration + security.log_security_event('register', user_id, ip_address, user_agent, + {'email': email, 'referred_by': referred_by}) + + # Auto login after registration + token = generate_token(user_id=user_id, username=username, role='user') + + is_demo = os.getenv('IS_DEMO_MODE', 'false').lower() == 'true' + + return jsonify({ + 'code': 1, + 'msg': 'Registration successful', + 'data': { + 'token': token, + 'userinfo': { + 'id': user_id, + 'username': username, + 'nickname': username, + 'email': email, + 'avatar': '/avatar2.jpg', + 'is_demo': is_demo, + 'role': { + 'id': 'user', + 'permissions': _get_permissions('user') + } + } + } + }) + + except Exception as e: + logger.error(f"register error: {e}") + return jsonify({'code': 0, 'msg': 'Registration failed', 'data': None}), 500 + + +@auth_bp.route('/reset-password', methods=['POST']) +def reset_password(): + """ + Reset password with email verification. + + Request body: + email: str + code: str (verification code) + new_password: str + turnstile_token: str (optional) + """ + ip_address = _get_client_ip() + user_agent = _get_user_agent() + + try: + from app.services.security_service import get_security_service + from app.services.email_service import get_email_service + from app.services.user_service import get_user_service + + security = get_security_service() + email_service = get_email_service() + user_service = get_user_service() + + data = request.get_json() + if not data: + return jsonify({'code': 0, 'msg': 'No data provided', 'data': None}), 400 + + email = (data.get('email') or '').strip().lower() + code = data.get('code', '').strip() + new_password = data.get('new_password', '') + turnstile_token = data.get('turnstile_token') + + # Validate inputs + if not email or not code or not new_password: + return jsonify({'code': 0, 'msg': 'Missing required fields', 'data': None}), 400 + + # Validate password strength + pwd_valid, pwd_msg = security.validate_password_strength(new_password) + if not pwd_valid: + return jsonify({'code': 0, 'msg': pwd_msg, 'data': None}), 400 + + # Verify Turnstile + turnstile_ok, turnstile_msg = security.verify_turnstile(turnstile_token, ip_address) + if not turnstile_ok: + return jsonify({'code': 0, 'msg': turnstile_msg, 'data': None}), 400 + + # Verify email code + code_valid, code_msg = email_service.verify_code(email, code, 'reset_password') + if not code_valid: + return jsonify({'code': 0, 'msg': code_msg, 'data': None}), 400 + + # Get user by email + user = user_service.get_user_by_email(email) + if not user: + return jsonify({'code': 0, 'msg': 'User not found', 'data': None}), 404 + + # Update password + success = user_service.update_password(user['id'], new_password) + if not success: + return jsonify({'code': 0, 'msg': 'Failed to reset password', 'data': None}), 500 + + # Clear any existing login blocks for this account + security.clear_login_attempts(user['username'], 'account') + + # Log password reset + security.log_security_event('password_reset', user['id'], ip_address, user_agent) + + return jsonify({'code': 1, 'msg': 'Password reset successful', 'data': None}) + + except Exception as e: + logger.error(f"reset_password error: {e}") + return jsonify({'code': 0, 'msg': 'Password reset failed', 'data': None}), 500 + + +@auth_bp.route('/change-password', methods=['POST']) +@login_required +def change_password(): + """ + Change password with email verification (for logged-in users). + + Request body: + code: str (verification code sent to user's email) + new_password: str + """ + ip_address = _get_client_ip() + user_agent = _get_user_agent() + user_id = g.user_id + + try: + from app.services.security_service import get_security_service + from app.services.email_service import get_email_service + from app.services.user_service import get_user_service + + security = get_security_service() + email_service = get_email_service() + user_service = get_user_service() + + data = request.get_json() + if not data: + return jsonify({'code': 0, 'msg': 'No data provided', 'data': None}), 400 + + code = data.get('code', '').strip() + new_password = data.get('new_password', '') + + if not code or not new_password: + return jsonify({'code': 0, 'msg': 'Missing required fields', 'data': None}), 400 + + # Validate password strength + pwd_valid, pwd_msg = security.validate_password_strength(new_password) + if not pwd_valid: + return jsonify({'code': 0, 'msg': pwd_msg, 'data': None}), 400 + + # Get user + user = user_service.get_user_by_id(user_id) + if not user or not user.get('email'): + return jsonify({'code': 0, 'msg': 'User email not found', 'data': None}), 400 + + # Verify email code + code_valid, code_msg = email_service.verify_code(user['email'], code, 'change_password') + if not code_valid: + return jsonify({'code': 0, 'msg': code_msg, 'data': None}), 400 + + # Update password + success = user_service.update_password(user_id, new_password) + if not success: + return jsonify({'code': 0, 'msg': 'Failed to change password', 'data': None}), 500 + + # Log password change + security.log_security_event('password_changed', user_id, ip_address, user_agent) + + return jsonify({'code': 1, 'msg': 'Password changed successfully', 'data': None}) + + except Exception as e: + logger.error(f"change_password error: {e}") + return jsonify({'code': 0, 'msg': 'Password change failed', 'data': None}), 500 + + +# ============================================================================= +# OAuth Endpoints +# ============================================================================= + +@auth_bp.route('/oauth/google', methods=['GET']) +def oauth_google(): + """Redirect to Google OAuth authorization page""" + try: + from app.services.oauth_service import get_oauth_service + oauth = get_oauth_service() + + if not oauth.google_enabled: + return jsonify({'code': 0, 'msg': 'Google OAuth is not configured', 'data': None}), 400 + + auth_url, state = oauth.get_google_auth_url() + return redirect(auth_url) + + except Exception as e: + logger.error(f"oauth_google error: {e}") + return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500 + + +@auth_bp.route('/oauth/google/callback', methods=['GET']) +def oauth_google_callback(): + """Handle Google OAuth callback""" + ip_address = _get_client_ip() + user_agent = _get_user_agent() + + try: + from app.services.oauth_service import get_oauth_service + from app.services.security_service import get_security_service + + oauth = get_oauth_service() + security = get_security_service() + + code = request.args.get('code') + state = request.args.get('state') + error = request.args.get('error') + + frontend_url = oauth.frontend_url + + if error: + return redirect(_build_frontend_login_redirect(frontend_url, oauth_error=error)) + + if not code or not state: + return redirect(_build_frontend_login_redirect(frontend_url, oauth_error='missing_params')) + + # Handle callback + success, result = oauth.handle_google_callback(code, state) + if not success: + error_msg = result.get('error', 'unknown_error') + return redirect(_build_frontend_login_redirect(frontend_url, oauth_error=error_msg)) + + # Get or create user + user_success, user_result = oauth.get_or_create_user_from_oauth(result) + if not user_success: + error_msg = user_result.get('error', 'user_creation_failed') + return redirect(_build_frontend_login_redirect(frontend_url, oauth_error=error_msg)) + + # Generate token + token = generate_token( + user_id=user_result['id'], + username=user_result['username'], + role=user_result.get('role', 'user') + ) + + # Log OAuth login + security.log_security_event('oauth_login', user_result['id'], ip_address, user_agent, + {'provider': 'google'}) + + # Redirect to frontend with token + return redirect(_build_frontend_login_redirect(frontend_url, oauth_token=token)) + + except Exception as e: + logger.error(f"oauth_google_callback error: {e}") + from app.services.oauth_service import get_oauth_service + frontend_url = get_oauth_service().frontend_url + return redirect(_build_frontend_login_redirect(frontend_url, oauth_error='server_error')) + + +@auth_bp.route('/oauth/github', methods=['GET']) +def oauth_github(): + """Redirect to GitHub OAuth authorization page""" + try: + from app.services.oauth_service import get_oauth_service + oauth = get_oauth_service() + + if not oauth.github_enabled: + return jsonify({'code': 0, 'msg': 'GitHub OAuth is not configured', 'data': None}), 400 + + auth_url, state = oauth.get_github_auth_url() + return redirect(auth_url) + + except Exception as e: + logger.error(f"oauth_github error: {e}") + return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500 + + +@auth_bp.route('/oauth/github/callback', methods=['GET']) +def oauth_github_callback(): + """Handle GitHub OAuth callback""" + ip_address = _get_client_ip() + user_agent = _get_user_agent() + + try: + from app.services.oauth_service import get_oauth_service + from app.services.security_service import get_security_service + + oauth = get_oauth_service() + security = get_security_service() + + code = request.args.get('code') + state = request.args.get('state') + error = request.args.get('error') + + frontend_url = oauth.frontend_url + + if error: + return redirect(_build_frontend_login_redirect(frontend_url, oauth_error=error)) + + if not code or not state: + return redirect(_build_frontend_login_redirect(frontend_url, oauth_error='missing_params')) + + # Handle callback + success, result = oauth.handle_github_callback(code, state) + if not success: + error_msg = result.get('error', 'unknown_error') + return redirect(_build_frontend_login_redirect(frontend_url, oauth_error=error_msg)) + + # Get or create user + user_success, user_result = oauth.get_or_create_user_from_oauth(result) + if not user_success: + error_msg = user_result.get('error', 'user_creation_failed') + return redirect(_build_frontend_login_redirect(frontend_url, oauth_error=error_msg)) + + # Generate token + token = generate_token( + user_id=user_result['id'], + username=user_result['username'], + role=user_result.get('role', 'user') + ) + + # Log OAuth login + security.log_security_event('oauth_login', user_result['id'], ip_address, user_agent, + {'provider': 'github'}) + + # Redirect to frontend with token + return redirect(_build_frontend_login_redirect(frontend_url, oauth_token=token)) + + except Exception as e: + logger.error(f"oauth_github_callback error: {e}") + from app.services.oauth_service import get_oauth_service + frontend_url = get_oauth_service().frontend_url + return redirect(_build_frontend_login_redirect(frontend_url, oauth_error='server_error')) + + +# ============================================================================= +# Other Endpoints +# ============================================================================= + @auth_bp.route('/logout', methods=['POST']) def logout(): """Logout (client removes token; server is stateless).""" return jsonify({'code': 1, 'msg': 'Logout successful', 'data': None}) + @auth_bp.route('/info', methods=['GET']) +@login_required def get_user_info(): - """Get user info (single-user mock).""" - return jsonify({ - 'code': 1, - 'msg': 'Success', - 'data': { - 'id': 1, - 'username': Config.ADMIN_USER, - 'nickname': 'Admin', - 'avatar': '/avatar2.jpg', - 'role': {'id': 'admin', 'permissions': ['dashboard', 'exception', 'account']} - } - }) + """Get current user info.""" + try: + is_demo = os.getenv('IS_DEMO_MODE', 'false').lower() == 'true' + + user_id = getattr(g, 'user_id', 1) + username = getattr(g, 'user', Config.ADMIN_USER) + role = getattr(g, 'user_role', 'admin') + + # Try to get full user info from database + user_data = None + if not _is_single_user_mode(): + try: + from app.services.user_service import get_user_service + user_data = get_user_service().get_user_by_id(user_id) + except Exception as e: + logger.warning(f"Failed to get user from database: {e}") + + if user_data: + return jsonify({ + 'code': 1, + 'msg': 'Success', + 'data': { + 'id': user_data.get('id'), + 'username': user_data.get('username'), + 'nickname': user_data.get('nickname', 'User') + (' (Demo)' if is_demo else ''), + 'email': user_data.get('email'), + 'avatar': user_data.get('avatar', '/avatar2.jpg'), + 'is_demo': is_demo, + 'role': { + 'id': user_data.get('role', 'user'), + 'permissions': _get_permissions(user_data.get('role', 'user')) + } + } + }) + + # Fallback for legacy mode + return jsonify({ + 'code': 1, + 'msg': 'Success', + 'data': { + 'id': user_id, + 'username': username, + 'nickname': 'Admin' + (' (Demo)' if is_demo else ''), + 'avatar': '/avatar2.jpg', + 'is_demo': is_demo, + 'role': { + 'id': role, + 'permissions': _get_permissions(role) + } + } + }) + except Exception as e: + logger.error(f"get_user_info error: {e}") + return jsonify({'code': 500, 'msg': str(e), 'data': None}), 500 + +def _get_permissions(role: str) -> list: + """Get permissions list for a role""" + try: + from app.services.user_service import get_user_service + return get_user_service().get_user_permissions(role) + except Exception: + # Default permissions for admin + if role == 'admin': + return ['dashboard', 'view', 'indicator', 'backtest', 'strategy', + 'portfolio', 'settings', 'user_manage', 'credentials'] + return ['dashboard', 'view', 'indicator', 'backtest', 'strategy', 'portfolio'] diff --git a/backend_api_python/app/routes/backtest.py b/backend_api_python/app/routes/backtest.py index bd0522a6e..0dcd15fbe 100644 --- a/backend_api_python/app/routes/backtest.py +++ b/backend_api_python/app/routes/backtest.py @@ -1,7 +1,7 @@ """ Backtest API routes """ -from flask import Blueprint, request, jsonify +from flask import Blueprint, request, jsonify, g from datetime import datetime import traceback import json @@ -11,6 +11,7 @@ from app.services.backtest import BacktestService from app.utils.logger import get_logger from app.utils.db import get_db_connection +from app.utils.auth import login_required import requests logger = get_logger(__name__) @@ -82,12 +83,12 @@ def _normalize_lang(lang: str | None) -> str: return l2 if l2 in supported else "zh-CN" -@backtest_bp.route('/backtest/precision-info', methods=['POST']) +@backtest_bp.route('/backtest/precision-info', methods=['GET']) def get_precision_info(): """ 获取回测精度信息(用于前端提示) - Params: + Params (Query String): market: 市场类型 startDate: 开始日期 (YYYY-MM-DD) endDate: 结束日期 (YYYY-MM-DD) @@ -96,13 +97,10 @@ def get_precision_info(): 精度信息,包含推荐的执行时间框架和预估K线数量 """ try: - data = request.get_json() - if not data: - return jsonify({'code': 0, 'msg': 'Request body is required'}), 400 - - market = data.get('market', 'crypto') - start_date_str = data.get('startDate', '') - end_date_str = data.get('endDate', '') + # Use request.args for GET params + market = request.args.get('market', 'crypto') + start_date_str = request.args.get('startDate', '') + end_date_str = request.args.get('endDate', '') if not start_date_str or not end_date_str: return jsonify({'code': 0, 'msg': 'startDate and endDate are required'}), 400 @@ -123,9 +121,10 @@ def get_precision_info(): @backtest_bp.route('/backtest', methods=['POST']) +@login_required def run_backtest(): """ - Run indicator backtest + Run indicator backtest for the current user. Params: indicatorId: Indicator ID (optional) @@ -148,8 +147,8 @@ def run_backtest(): 'data': None }), 400 - # Extract params - user_id = int(data.get('userid') or data.get('userId') or 1) + # Extract params - use current user's ID + user_id = g.user_id indicator_code = data.get('indicatorCode', '') indicator_id = data.get('indicatorId') symbol = data.get('symbol', '') @@ -267,7 +266,6 @@ def run_backtest(): # Persist backtest run for AI optimization / history run_id = None try: - now_ts = int(time.time()) with get_db_connection() as db: cur = db.cursor() cur.execute( @@ -276,7 +274,7 @@ def run_backtest(): (user_id, indicator_id, market, symbol, timeframe, start_date, end_date, initial_capital, commission, slippage, leverage, trade_direction, strategy_config, status, error_message, result_json, created_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NOW()) """, ( user_id, @@ -294,8 +292,7 @@ def run_backtest(): json.dumps(strategy_config or {}, ensure_ascii=False), 'success', '', - json.dumps(result or {}, ensure_ascii=False), - now_ts + json.dumps(result or {}, ensure_ascii=False) ) ) run_id = cur.lastrowid @@ -327,9 +324,8 @@ def run_backtest(): # Best-effort persist failed run (if we have enough context) try: data = data if isinstance(data, dict) else {} - user_id = int(data.get('userid') or data.get('userId') or 1) + user_id = g.user_id indicator_id = data.get('indicatorId') - now_ts = int(time.time()) with get_db_connection() as db: cur = db.cursor() cur.execute( @@ -338,7 +334,7 @@ def run_backtest(): (user_id, indicator_id, market, symbol, timeframe, start_date, end_date, initial_capital, commission, slippage, leverage, trade_direction, strategy_config, status, error_message, result_json, created_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NOW()) """, ( user_id, @@ -356,8 +352,7 @@ def run_backtest(): json.dumps(data.get('strategyConfig') or {}, ensure_ascii=False), 'failed', str(e), - '', - now_ts + '' ) ) db.commit() @@ -371,13 +366,13 @@ def run_backtest(): }), 500 -@backtest_bp.route('/backtest/history', methods=['POST']) +@backtest_bp.route('/backtest/history', methods=['GET']) +@login_required def get_backtest_history(): """ - Get backtest run history (saved in SQLite). + Get backtest run history for the current user. - Params: - userid: User ID (default 1) + Params (Query String): limit: Page size (default 50, max 200) offset: Offset (default 0) indicatorId: Optional indicator id filter @@ -386,17 +381,17 @@ def get_backtest_history(): timeframe: Optional timeframe filter """ try: - data = request.get_json() or {} - user_id = int(data.get('userid') or data.get('userId') or 1) - limit = int(data.get('limit') or 50) - offset = int(data.get('offset') or 0) + # Use current user's ID + user_id = g.user_id + limit = int(request.args.get('limit') or 50) + offset = int(request.args.get('offset') or 0) limit = max(1, min(limit, 200)) offset = max(0, offset) - indicator_id = data.get('indicatorId') - symbol = (data.get('symbol') or '').strip() - market = (data.get('market') or '').strip() - timeframe = (data.get('timeframe') or '').strip() + indicator_id = request.args.get('indicatorId') + symbol = (request.args.get('symbol') or '').strip() + market = (request.args.get('market') or '').strip() + timeframe = (request.args.get('timeframe') or '').strip() where = ["user_id = ?"] params = [user_id] @@ -449,19 +444,18 @@ def get_backtest_history(): return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500 -@backtest_bp.route('/backtest/get', methods=['POST']) +@backtest_bp.route('/backtest/get', methods=['GET']) +@login_required def get_backtest_run(): """ - Get a backtest run detail by run id (includes result_json). + Get a backtest run detail by run id for the current user. - Params: - userid: User ID (default 1) + Params (Query String): runId: Backtest run id (required) """ try: - data = request.get_json() or {} - user_id = int(data.get('userid') or data.get('userId') or 1) - run_id = int(data.get('runId') or 0) + user_id = g.user_id + run_id = int(request.args.get('runId') or 0) if not run_id: return jsonify({'code': 0, 'msg': 'runId is required', 'data': None}), 400 @@ -716,17 +710,18 @@ def _heuristic_ai_advice(runs: list[dict], lang: str) -> str: @backtest_bp.route('/backtest/aiAnalyze', methods=['POST']) +@login_required def ai_analyze_backtest_runs(): """ - AI analyze selected backtest runs and provide strategy_config tuning suggestions. + AI analyze selected backtest runs and provide strategy_config tuning suggestions + for the current user. Params: - userid: User ID (default 1) runIds: list[int] (required) """ try: data = request.get_json() or {} - user_id = int(data.get('userid') or data.get('userId') or 1) + user_id = g.user_id lang = _normalize_lang(data.get('lang')) run_ids = data.get('runIds') or [] if not isinstance(run_ids, list) or not run_ids: diff --git a/backend_api_python/app/routes/credentials.py b/backend_api_python/app/routes/credentials.py index 3dd5f2c21..2e04ddbb2 100644 --- a/backend_api_python/app/routes/credentials.py +++ b/backend_api_python/app/routes/credentials.py @@ -9,17 +9,16 @@ import time import traceback import json -from flask import Blueprint, request, jsonify +from flask import Blueprint, request, jsonify, g from app.utils.db import get_db_connection from app.utils.logger import get_logger +from app.utils.auth import login_required logger = get_logger(__name__) credentials_bp = Blueprint('credentials', __name__) -DEFAULT_USER_ID = 1 - def _api_key_hint(api_key: str) -> str: if not api_key: @@ -31,9 +30,11 @@ def _api_key_hint(api_key: str) -> str: @credentials_bp.route('/list', methods=['GET']) +@login_required def list_credentials(): + """List all credentials for the current user.""" try: - user_id = request.args.get('user_id', type=int) or DEFAULT_USER_ID + user_id = g.user_id with get_db_connection() as db: cur = db.cursor() @@ -57,10 +58,12 @@ def list_credentials(): @credentials_bp.route('/create', methods=['POST']) +@login_required def create_credential(): + """Create a new credential for the current user.""" try: + user_id = g.user_id data = request.get_json() or {} - user_id = int(data.get('user_id') or DEFAULT_USER_ID) name = (data.get('name') or '').strip() exchange_id = (data.get('exchange_id') or '').strip() api_key = (data.get('api_key') or '').strip() @@ -78,16 +81,15 @@ def create_credential(): 'secret_key': secret_key, 'passphrase': passphrase }, ensure_ascii=False) - now = int(time.time()) with get_db_connection() as db: cur = db.cursor() cur.execute( """ INSERT INTO qd_exchange_credentials (user_id, name, exchange_id, api_key_hint, encrypted_config, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?) + VALUES (?, ?, ?, ?, ?, NOW(), NOW()) """, - (user_id, name, exchange_id, _api_key_hint(api_key), plaintext_config, now, now) + (user_id, name, exchange_id, _api_key_hint(api_key), plaintext_config) ) new_id = cur.lastrowid db.commit() @@ -101,10 +103,12 @@ def create_credential(): @credentials_bp.route('/delete', methods=['DELETE']) +@login_required def delete_credential(): + """Delete a credential for the current user.""" try: + user_id = g.user_id cred_id = request.args.get('id', type=int) - user_id = request.args.get('user_id', type=int) or DEFAULT_USER_ID if not cred_id: return jsonify({'code': 0, 'msg': 'Missing id', 'data': None}), 400 @@ -125,14 +129,14 @@ def delete_credential(): @credentials_bp.route('/get', methods=['GET']) +@login_required def get_credential(): """ Return decrypted credential for form auto-fill. - NOTE: In a production system, this must be protected by strong authentication/authorization. """ try: + user_id = g.user_id cred_id = request.args.get('id', type=int) - user_id = request.args.get('user_id', type=int) or DEFAULT_USER_ID if not cred_id: return jsonify({'code': 0, 'msg': 'Missing id', 'data': None}), 400 diff --git a/backend_api_python/app/routes/dashboard.py b/backend_api_python/app/routes/dashboard.py index a8725872c..3150df784 100644 --- a/backend_api_python/app/routes/dashboard.py +++ b/backend_api_python/app/routes/dashboard.py @@ -15,10 +15,11 @@ import time from typing import Any, Dict, List, Tuple -from flask import Blueprint, jsonify, request +from flask import Blueprint, jsonify, request, g from app.utils.db import get_db_connection from app.utils.logger import get_logger +from app.utils.auth import login_required logger = get_logger(__name__) @@ -255,19 +256,24 @@ def _compute_strategy_stats(trades: List[Dict[str, Any]], strategies: List[Dict[ @dashboard_bp.route("/summary", methods=["GET"]) +@login_required def summary(): """ Return dashboard summary used by `quantdinger_vue/src/views/dashboard/index.vue`. """ try: - # Strategy counts + user_id = g.user_id + + # Strategy counts (filtered by user_id) with get_db_connection() as db: cur = db.cursor() cur.execute( """ SELECT id, strategy_name, strategy_type, status, initial_capital, trading_config FROM qd_strategies_trading - """ + WHERE user_id = ? + """, + (user_id,) ) strategies = cur.fetchall() or [] cur.close() @@ -292,7 +298,7 @@ def _truthy(v: Any) -> bool: if isinstance(tc, dict) and _truthy(tc.get("enable_ai_filter")): ai_enabled_strategy_count += 1 - # Positions (best-effort) + # Positions (best-effort, filtered by user_id) with get_db_connection() as db: cur = db.cursor() cur.execute( @@ -300,8 +306,10 @@ def _truthy(v: Any) -> bool: SELECT p.*, s.strategy_name, s.initial_capital, s.leverage, s.market_type FROM qd_strategy_positions p LEFT JOIN qd_strategies_trading s ON s.id = p.strategy_id + WHERE p.user_id = ? ORDER BY p.updated_at DESC - """ + """, + (user_id,) ) rows = cur.fetchall() or [] cur.close() @@ -332,7 +340,7 @@ def _truthy(v: Any) -> bool: } ) - # Recent trades (best-effort) + # Recent trades (best-effort, filtered by user_id) with get_db_connection() as db: cur = db.cursor() cur.execute( @@ -340,9 +348,11 @@ def _truthy(v: Any) -> bool: SELECT t.*, s.strategy_name FROM qd_strategy_trades t LEFT JOIN qd_strategies_trading s ON s.id = t.strategy_id + WHERE t.user_id = ? ORDER BY t.created_at DESC LIMIT 500 - """ + """, + (user_id,) ) recent_trades = cur.fetchall() or [] cur.close() @@ -497,18 +507,20 @@ def _truthy(v: Any) -> bool: @dashboard_bp.route("/pendingOrders", methods=["GET"]) +@login_required def pending_orders(): """ Return pending orders list for dashboard page. """ try: + user_id = g.user_id page = max(1, _safe_int(request.args.get("page"), 1)) page_size = max(1, min(200, _safe_int(request.args.get("pageSize"), 20))) offset = (page - 1) * page_size with get_db_connection() as db: cur = db.cursor() - cur.execute("SELECT COUNT(1) AS cnt FROM pending_orders") + cur.execute("SELECT COUNT(1) AS cnt FROM pending_orders WHERE user_id = ?", (user_id,)) total = int((cur.fetchone() or {}).get("cnt") or 0) cur.close() @@ -525,10 +537,11 @@ def pending_orders(): s.execution_mode AS strategy_execution_mode FROM pending_orders o LEFT JOIN qd_strategies_trading s ON s.id = o.strategy_id + WHERE o.user_id = ? ORDER BY o.id DESC - LIMIT %s OFFSET %s + LIMIT ? OFFSET ? """, - (int(page_size), int(offset)), + (user_id, int(page_size), int(offset)), ) rows = cur.fetchall() or [] cur.close() @@ -607,18 +620,21 @@ def pending_orders(): @dashboard_bp.route("/pendingOrders/", methods=["DELETE"]) +@login_required def delete_pending_order(order_id: int): """ Delete a pending order record (dashboard operation). """ try: + user_id = g.user_id oid = int(order_id or 0) if oid <= 0: return jsonify({"code": 0, "msg": "invalid_id", "data": None}), 400 with get_db_connection() as db: cur = db.cursor() - cur.execute("SELECT id, status FROM pending_orders WHERE id = %s", (oid,)) + # Verify the order belongs to current user + cur.execute("SELECT id, status FROM pending_orders WHERE id = ? AND user_id = ?", (oid, user_id)) row = cur.fetchone() or {} if not row: cur.close() @@ -627,7 +643,7 @@ def delete_pending_order(order_id: int): if st == "processing": cur.close() return jsonify({"code": 0, "msg": "cannot_delete_processing", "data": None}), 400 - cur.execute("DELETE FROM pending_orders WHERE id = %s", (oid,)) + cur.execute("DELETE FROM pending_orders WHERE id = ? AND user_id = ?", (oid, user_id)) db.commit() cur.close() diff --git a/backend_api_python/app/routes/indicator.py b/backend_api_python/app/routes/indicator.py index 433c9270a..cd2ab6468 100644 --- a/backend_api_python/app/routes/indicator.py +++ b/backend_api_python/app/routes/indicator.py @@ -17,12 +17,13 @@ import traceback from typing import Any, Dict, List -from flask import Blueprint, Response, jsonify, request +from flask import Blueprint, Response, jsonify, request, g import pandas as pd import numpy as np from app.utils.db import get_db_connection from app.utils.logger import get_logger +from app.utils.auth import login_required import requests logger = get_logger(__name__) @@ -115,24 +116,21 @@ def _generate_mock_df(length=200): return df -@indicator_bp.route("/getIndicators", methods=["POST"]) +@indicator_bp.route("/getIndicators", methods=["GET"]) +@login_required def get_indicators(): """ - Get indicator list for a user. - - Request: - { userid: number } + Get indicator list for the current user. Response: { code: 1, data: [ ... ] } """ try: - data = request.get_json() or {} - user_id = int(data.get("userid") or 1) + user_id = g.user_id with get_db_connection() as db: cur = db.cursor() - # Local mode: "我的指标" should include both purchased and custom indicators. + # Get user's own indicators (both purchased and custom). cur.execute( """ SELECT @@ -156,13 +154,13 @@ def get_indicators(): @indicator_bp.route("/saveIndicator", methods=["POST"]) +@login_required def save_indicator(): """ - Create or update an indicator. + Create or update an indicator for the current user. Request (frontend sends many extra fields; we store only the essentials): { - userid: number, id: number (0 for create), name: string, code: string, @@ -172,7 +170,7 @@ def save_indicator(): """ try: data = request.get_json() or {} - user_id = int(data.get("userid") or 1) + user_id = g.user_id indicator_id = int(data.get("id") or 0) code = data.get("code") or "" name = (data.get("name") or "").strip() @@ -199,7 +197,7 @@ def save_indicator(): if not name: name = "Custom Indicator" - now = _now_ts() + now = _now_ts() # For BIGINT fields (createtime, updatetime) with get_db_connection() as db: cur = db.cursor() @@ -209,10 +207,10 @@ def save_indicator(): UPDATE qd_indicator_codes SET name = ?, code = ?, description = ?, publish_to_community = ?, pricing_type = ?, price = ?, preview_image = ?, - updatetime = ?, updated_at = ? + updatetime = ?, updated_at = NOW() WHERE id = ? AND user_id = ? AND (is_buy IS NULL OR is_buy = 0) """, - (name, code, description, publish_to_community, pricing_type, price, preview_image, now, now, indicator_id, user_id), + (name, code, description, publish_to_community, pricing_type, price, preview_image, now, indicator_id, user_id), ) else: cur.execute( @@ -221,9 +219,9 @@ def save_indicator(): (user_id, is_buy, end_time, name, code, description, publish_to_community, pricing_type, price, preview_image, createtime, updatetime, created_at, updated_at) - VALUES (?, 0, 1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + VALUES (?, 0, 1, ?, ?, ?, ?, ?, ?, ?, ?, ?, NOW(), NOW()) """, - (user_id, name, code, description, publish_to_community, pricing_type, price, preview_image, now, now, now, now), + (user_id, name, code, description, publish_to_community, pricing_type, price, preview_image, now, now), ) indicator_id = int(cur.lastrowid or 0) db.commit() @@ -236,11 +234,12 @@ def save_indicator(): @indicator_bp.route("/deleteIndicator", methods=["POST"]) +@login_required def delete_indicator(): - """Delete an indicator by id.""" + """Delete an indicator by id for the current user.""" try: data = request.get_json() or {} - user_id = int(data.get("userid") or 1) + user_id = g.user_id indicator_id = int(data.get("id") or 0) if not indicator_id: return jsonify({"code": 0, "msg": "id is required", "data": None}), 400 @@ -261,6 +260,7 @@ def delete_indicator(): @indicator_bp.route("/verifyCode", methods=["POST"]) +@login_required def verify_code(): """ Verify/Dry-run indicator code with mock data. @@ -372,6 +372,7 @@ def verify_code(): @indicator_bp.route("/aiGenerate", methods=["POST"]) +@login_required def ai_generate(): """ SSE endpoint to generate indicator code. diff --git a/backend_api_python/app/routes/kline.py b/backend_api_python/app/routes/kline.py index 88382051f..a009dd7ab 100644 --- a/backend_api_python/app/routes/kline.py +++ b/backend_api_python/app/routes/kline.py @@ -14,7 +14,7 @@ kline_service = KlineService() -@kline_bp.route('/kline', methods=['GET', 'POST']) +@kline_bp.route('/kline', methods=['GET']) def get_kline(): """ 获取K线数据 @@ -27,17 +27,12 @@ def get_kline(): before_time: 获取此时间之前的数据 (可选,Unix时间戳) """ try: - # 支持 GET 和 POST - if request.method == 'POST': - data = request.get_json() or {} - else: - data = request.args - - market = data.get('market', 'USStock') - symbol = data.get('symbol', '') - timeframe = data.get('timeframe', '1D') - limit = int(data.get('limit', 300)) - before_time = data.get('before_time') or data.get('beforeTime') + # 强制 GET, 使用 request.args + market = request.args.get('market', 'USStock') + symbol = request.args.get('symbol', '') + timeframe = request.args.get('timeframe', '1D') + limit = int(request.args.get('limit', 300)) + before_time = request.args.get('before_time') or request.args.get('beforeTime') if before_time: before_time = int(before_time) diff --git a/backend_api_python/app/routes/market.py b/backend_api_python/app/routes/market.py index 5a669fdc4..4b694f323 100644 --- a/backend_api_python/app/routes/market.py +++ b/backend_api_python/app/routes/market.py @@ -2,7 +2,7 @@ Market API routes (local-only). Provides watchlist, market metadata, symbol search, and pricing helpers for the frontend. """ -from flask import Blueprint, request, jsonify +from flask import Blueprint, request, jsonify, g import traceback import json import time @@ -13,6 +13,7 @@ from app.utils.cache import CacheManager from app.utils.db import get_db_connection from app.utils.config_loader import load_addon_config +from app.utils.auth import login_required from app.data.market_symbols_seed import ( get_hot_symbols as seed_get_hot_symbols, search_symbols as seed_search_symbols, @@ -26,11 +27,9 @@ kline_service = KlineService() cache = CacheManager() -# 线程池用于并行获取价格 +# Thread pool for parallel price fetching executor = ThreadPoolExecutor(max_workers=10) -DEFAULT_USER_ID = 1 - def _now_ts() -> int: return int(time.time()) @@ -125,7 +124,7 @@ def _sort_items(items): return jsonify({'code': 1, 'msg': 'success', 'data': data}) -@market_bp.route('/menuFooterConfig', methods=['POST']) +@market_bp.route('/menuFooterConfig', methods=['GET']) def get_menu_footer_config(): """ Compatibility stub for old PHP `getMenuFooterConfig`. @@ -150,17 +149,16 @@ def get_menu_footer_config(): } return jsonify({'code': 1, 'msg': 'success', 'data': data}) -@market_bp.route('/symbols/search', methods=['POST']) +@market_bp.route('/symbols/search', methods=['GET']) def search_symbols(): """ Lightweight symbol search. In local-only mode we keep this simple; frontend allows manual input when no results. """ try: - data = request.get_json() or {} - market = (data.get('market') or '').strip() - keyword = (data.get('keyword') or '').strip().upper() - limit = int(data.get('limit') or 20) + market = (request.args.get('market') or '').strip() + keyword = (request.args.get('keyword') or '').strip().upper() + limit = int(request.args.get('limit') or 20) if not market or not keyword: return jsonify({'code': 1, 'msg': 'success', 'data': []}) @@ -172,29 +170,30 @@ def search_symbols(): logger.error(traceback.format_exc()) return jsonify({'code': 0, 'msg': str(e), 'data': []}), 500 -@market_bp.route('/symbols/hot', methods=['POST']) +@market_bp.route('/symbols/hot', methods=['GET']) def get_hot_symbols(): """Return a small curated hot list per market (local-only).""" try: - data = request.get_json() or {} - market = (data.get('market') or '').strip() - limit = int(data.get('limit') or 10) + market = (request.args.get('market') or '').strip() + limit = int(request.args.get('limit') or 10) hot = seed_get_hot_symbols(market=market, limit=limit) return jsonify({'code': 1, 'msg': 'success', 'data': hot}) except Exception as e: logger.error(f"get_hot_symbols failed: {str(e)}") return jsonify({'code': 0, 'msg': str(e), 'data': []}), 500 -@market_bp.route('/watchlist/get', methods=['POST']) +@market_bp.route('/watchlist/get', methods=['GET']) +@login_required def get_watchlist(): - """Get local watchlist for the single user.""" + """Get watchlist for the current user.""" try: + user_id = g.user_id _ensure_watchlist_table() with get_db_connection() as db: cur = db.cursor() cur.execute( "SELECT id, market, symbol, name FROM qd_watchlist WHERE user_id = ? ORDER BY id DESC", - (DEFAULT_USER_ID,) + (user_id,) ) rows = cur.fetchall() or [] @@ -213,8 +212,8 @@ def get_watchlist(): if resolved and resolved != current_name: row['name'] = resolved cur.execute( - "UPDATE qd_watchlist SET name = ?, updated_at = ? WHERE user_id = ? AND market = ? AND symbol = ?", - (resolved, _now_ts(), DEFAULT_USER_ID, market, symbol) + "UPDATE qd_watchlist SET name = ?, updated_at = NOW() WHERE user_id = ? AND market = ? AND symbol = ?", + (resolved, user_id, market, symbol) ) except Exception: continue @@ -227,9 +226,11 @@ def get_watchlist(): return jsonify({'code': 0, 'msg': str(e), 'data': []}), 500 @market_bp.route('/watchlist/add', methods=['POST']) +@login_required def add_watchlist(): - """Add a symbol to local watchlist (no credits/fees in local mode).""" + """Add a symbol to watchlist for the current user.""" try: + user_id = g.user_id data = request.get_json() or {} market = (data.get('market') or '').strip() symbol = _normalize_symbol(data.get('symbol')) @@ -241,18 +242,18 @@ def add_watchlist(): resolved = resolve_symbol_name(market, symbol) or seed_get_symbol_name(market, symbol) name = name_in or resolved or symbol - now = _now_ts() with get_db_connection() as db: cur = db.cursor() - # Insert or ignore duplicates. + # Insert or update (PostgreSQL UPSERT) cur.execute( - "INSERT OR IGNORE INTO qd_watchlist (user_id, market, symbol, name, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)", - (DEFAULT_USER_ID, market, symbol, name, now, now) - ) - # If already exists, keep it fresh and sync name. - cur.execute( - "UPDATE qd_watchlist SET name = ?, updated_at = ? WHERE user_id = ? AND market = ? AND symbol = ?", - (name, now, DEFAULT_USER_ID, market, symbol) + """ + INSERT INTO qd_watchlist (user_id, market, symbol, name, created_at, updated_at) + VALUES (?, ?, ?, ?, NOW(), NOW()) + ON CONFLICT(user_id, market, symbol) DO UPDATE SET + name = excluded.name, + updated_at = NOW() + """, + (user_id, market, symbol, name) ) db.commit() cur.close() @@ -264,9 +265,11 @@ def add_watchlist(): return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500 @market_bp.route('/watchlist/remove', methods=['POST']) +@login_required def remove_watchlist(): - """Remove a symbol from watchlist. Frontend only passes symbol, so we delete across markets.""" + """Remove a symbol from watchlist for the current user.""" try: + user_id = g.user_id data = request.get_json() or {} symbol = _normalize_symbol(data.get('symbol')) if not symbol: @@ -276,7 +279,7 @@ def remove_watchlist(): cur = db.cursor() cur.execute( "DELETE FROM qd_watchlist WHERE user_id = ? AND symbol = ?", - (DEFAULT_USER_ID, symbol) + (user_id, symbol) ) db.commit() cur.close() @@ -290,51 +293,17 @@ def remove_watchlist(): def get_single_price(market: str, symbol: str) -> dict: """获取单个标的的价格数据""" try: - # 先尝试从缓存获取(60秒缓存) - cache_key = f"watchlist_price:{market}:{symbol}" - cached_data = cache.get(cache_key) - - if cached_data: - logger.debug(f"Cache hit: {market}:{symbol}") - return { - 'market': market, - 'symbol': symbol, - 'price': cached_data.get('price', 0), - 'change': cached_data.get('change', 0), - 'changePercent': cached_data.get('changePercent', 0) - } + # 使用 get_realtime_price 获取实时价格(内部已有30秒缓存) + # 相比原先的 '1D' K线逻辑,这能更及时地反映 Crypto 等 24h 市场的变化 + price_data = kline_service.get_realtime_price(market, symbol) - # 获取最新的一根K线 - klines = kline_service.get_kline(market, symbol, '1D', 2) - - if klines and len(klines) > 0: - latest = klines[-1] - prev_close = klines[-2]['close'] if len(klines) > 1 else latest.get('open', 0) - current_price = latest.get('close', 0) - - change = round(current_price - prev_close, 4) if prev_close else 0 - change_percent = round(change / prev_close * 100, 2) if prev_close and prev_close > 0 else 0 - - result = { - 'market': market, - 'symbol': symbol, - 'price': current_price, - 'change': change, - 'changePercent': change_percent - } - - # 缓存60秒 - cache.set(cache_key, result, 60) - - return result - else: - return { - 'market': market, - 'symbol': symbol, - 'price': 0, - 'change': 0, - 'changePercent': 0 - } + return { + 'market': market, + 'symbol': symbol, + 'price': price_data.get('price', 0), + 'change': price_data.get('change', 0), + 'changePercent': price_data.get('changePercent', 0) + } except Exception as e: logger.error(f"Failed to fetch price {market}:{symbol} - {str(e)}") return { @@ -346,45 +315,26 @@ def get_single_price(market: str, symbol: str) -> dict: } -@market_bp.route('/watchlist/prices', methods=['POST']) +@market_bp.route('/watchlist/prices', methods=['GET']) def get_watchlist_prices(): """ 批量获取自选股价格 - 请求体: - { - "watchlist": [ - {"market": "USStock", "symbol": "AAPL"}, - {"market": "Crypto", "symbol": "BTC"}, - ... - ] - } - - 响应: - { - "code": 1, - "msg": "success", - "data": [ - {"market": "USStock", "symbol": "AAPL", "price": 150.0, "change": 1.5, "changePercent": 1.0}, - {"market": "Crypto", "symbol": "BTC", "price": 95000.0, "change": 1000, "changePercent": 1.05} - ] - } + Params (Query String): + watchlist: JSON string of list of {market, symbol} objects + e.g. ?watchlist=[{"market":"USStock","symbol":"AAPL"}] """ try: - data = request.get_json() - if not data: - return jsonify({ - 'code': 0, - 'msg': 'Request body is required', - 'data': [] - }), 400 - - watchlist = data.get('watchlist', []) + watchlist_str = request.args.get('watchlist', '[]') + try: + watchlist = json.loads(watchlist_str) + except Exception: + watchlist = [] if not watchlist or not isinstance(watchlist, list): return jsonify({ 'code': 0, - 'msg': 'Invalid watchlist format', + 'msg': 'Invalid watchlist format (expected JSON list in query param)', 'data': [] }), 400 diff --git a/backend_api_python/app/routes/portfolio.py b/backend_api_python/app/routes/portfolio.py index 8209fd7c9..d21e3bf25 100644 --- a/backend_api_python/app/routes/portfolio.py +++ b/backend_api_python/app/routes/portfolio.py @@ -2,7 +2,7 @@ Portfolio API routes (local-only). Manages manual positions (user's existing holdings) and AI monitoring tasks. """ -from flask import Blueprint, request, jsonify +from flask import Blueprint, request, jsonify, g import json import traceback import time @@ -13,6 +13,7 @@ from app.utils.logger import get_logger from app.utils.cache import CacheManager from app.utils.db import get_db_connection +from app.utils.auth import login_required from app.services.symbol_name import resolve_symbol_name from app.data.market_symbols_seed import get_symbol_name as seed_get_symbol_name @@ -23,18 +24,16 @@ cache = CacheManager() # Thread pool for parallel price fetching -# 降低并发数避免触发API限制(尤其是外汇/美股等有速率限制的API) +# Lower concurrency to avoid triggering API limits (especially for forex/US stocks) executor = ThreadPoolExecutor(max_workers=3) -# 请求间隔(秒),避免请求过快 +# Request interval (seconds) to avoid too frequent requests REQUEST_INTERVAL = 0.3 -# 速率限制相关 +# Rate limiting related _request_lock = threading.Lock() _last_request_time = {} # {market: timestamp} -DEFAULT_USER_ID = 1 - def _now_ts() -> int: return int(time.time()) @@ -109,10 +108,12 @@ def _get_single_price(market: str, symbol: str, force_refresh: bool = False) -> # ==================== Position CRUD ==================== @portfolio_bp.route('/positions', methods=['GET']) +@login_required def get_positions(): - """Get all manual positions with current prices.""" + """Get all manual positions with current prices for the current user.""" try: - # 检查是否强制刷新(跳过缓存) + user_id = g.user_id + # Check if force refresh (skip cache) force_refresh = request.args.get('refresh', '').lower() in ('1', 'true', 'yes') with get_db_connection() as db: @@ -124,7 +125,7 @@ def get_positions(): WHERE user_id = ? ORDER BY id DESC """, - (DEFAULT_USER_ID,) + (user_id,) ) rows = cur.fetchall() or [] cur.close() @@ -212,9 +213,11 @@ def get_positions(): @portfolio_bp.route('/positions', methods=['POST']) +@login_required def add_position(): - """Add a new manual position.""" + """Add a new manual position for the current user.""" try: + user_id = g.user_id data = request.get_json() or {} market = (data.get('market') or '').strip() symbol = _normalize_symbol(data.get('symbol')) @@ -244,7 +247,6 @@ def add_position(): name = name_in or resolved or symbol tags_json = json.dumps(tags if isinstance(tags, list) else [], ensure_ascii=False) - now = _now_ts() with get_db_connection() as db: cur = db.cursor() @@ -252,7 +254,7 @@ def add_position(): """ INSERT INTO qd_manual_positions (user_id, market, symbol, name, side, quantity, entry_price, entry_time, notes, tags, group_name, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NOW(), NOW()) ON CONFLICT(user_id, market, symbol, side) DO UPDATE SET name = excluded.name, quantity = excluded.quantity, @@ -261,9 +263,9 @@ def add_position(): notes = excluded.notes, tags = excluded.tags, group_name = excluded.group_name, - updated_at = excluded.updated_at + updated_at = NOW() """, - (DEFAULT_USER_ID, market, symbol, name, side, quantity, entry_price, entry_time, notes, tags_json, group_name, now, now) + (user_id, market, symbol, name, side, quantity, entry_price, entry_time, notes, tags_json, group_name) ) position_id = cur.lastrowid db.commit() @@ -277,9 +279,11 @@ def add_position(): @portfolio_bp.route('/positions/', methods=['PUT']) +@login_required def update_position(position_id): - """Update an existing position.""" + """Update an existing position for the current user.""" try: + user_id = g.user_id data = request.get_json() or {} updates = [] @@ -323,10 +327,9 @@ def update_position(position_id): if not updates: return jsonify({'code': 0, 'msg': 'No fields to update', 'data': None}), 400 - updates.append('updated_at = ?') - params.append(_now_ts()) + updates.append('updated_at = NOW()') params.append(position_id) - params.append(DEFAULT_USER_ID) + params.append(user_id) with get_db_connection() as db: cur = db.cursor() @@ -345,14 +348,16 @@ def update_position(position_id): @portfolio_bp.route('/positions/', methods=['DELETE']) +@login_required def delete_position(position_id): - """Delete a position.""" + """Delete a position for the current user.""" try: + user_id = g.user_id with get_db_connection() as db: cur = db.cursor() cur.execute( "DELETE FROM qd_manual_positions WHERE id = ? AND user_id = ?", - (position_id, DEFAULT_USER_ID) + (position_id, user_id) ) db.commit() cur.close() @@ -365,10 +370,12 @@ def delete_position(position_id): @portfolio_bp.route('/summary', methods=['GET']) +@login_required def get_portfolio_summary(): - """Get portfolio summary with total value, PnL, and market distribution.""" + """Get portfolio summary with total value, PnL, and market distribution for the current user.""" try: - # 检查是否强制刷新 + user_id = g.user_id + # Check if force refresh force_refresh = request.args.get('refresh', '').lower() in ('1', 'true', 'yes') with get_db_connection() as db: @@ -379,7 +386,7 @@ def get_portfolio_summary(): FROM qd_manual_positions WHERE user_id = ? """, - (DEFAULT_USER_ID,) + (user_id,) ) rows = cur.fetchall() or [] cur.close() @@ -485,9 +492,11 @@ def get_portfolio_summary(): # ==================== Monitor CRUD ==================== @portfolio_bp.route('/monitors', methods=['GET']) +@login_required def get_monitors(): - """Get all position monitors.""" + """Get all position monitors for the current user.""" try: + user_id = g.user_id with get_db_connection() as db: cur = db.cursor() cur.execute( @@ -498,7 +507,7 @@ def get_monitors(): WHERE user_id = ? ORDER BY id DESC """, - (DEFAULT_USER_ID,) + (user_id,) ) rows = cur.fetchall() or [] cur.close() @@ -529,9 +538,11 @@ def get_monitors(): @portfolio_bp.route('/monitors', methods=['POST']) +@login_required def add_monitor(): - """Add a new position monitor.""" + """Add a new position monitor for the current user.""" try: + user_id = g.user_id data = request.get_json() or {} name = (data.get('name') or '').strip() position_ids = data.get('position_ids') or [] @@ -547,9 +558,7 @@ def add_monitor(): monitor_type = 'ai' # Calculate next_run_at based on interval - now = _now_ts() interval_minutes = int(config.get('interval_minutes') or 60) - next_run_at = now + (interval_minutes * 60) position_ids_json = json.dumps(position_ids if isinstance(position_ids, list) else [], ensure_ascii=False) config_json = json.dumps(config if isinstance(config, dict) else {}, ensure_ascii=False) @@ -561,10 +570,10 @@ def add_monitor(): """ INSERT INTO qd_position_monitors (user_id, name, position_ids, monitor_type, config, notification_config, is_active, next_run_at, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + VALUES (?, ?, ?, ?, ?, ?, ?, NOW() + INTERVAL '%s minutes', NOW(), NOW()) """, - (DEFAULT_USER_ID, name, position_ids_json, monitor_type, config_json, notification_config_json, - 1 if is_active else 0, next_run_at, now, now) + (user_id, name, position_ids_json, monitor_type, config_json, notification_config_json, + 1 if is_active else 0, interval_minutes) ) monitor_id = cur.lastrowid db.commit() @@ -578,9 +587,11 @@ def add_monitor(): @portfolio_bp.route('/monitors/', methods=['PUT']) +@login_required def update_monitor(monitor_id): - """Update an existing monitor.""" + """Update an existing monitor for the current user.""" try: + user_id = g.user_id data = request.get_json() or {} updates = [] @@ -599,15 +610,14 @@ def update_monitor(monitor_id): updates.append('monitor_type = ?') params.append((data.get('monitor_type') or 'ai').strip()) + next_run_interval = None # Will store interval for special handling if 'config' in data: config = data.get('config') or {} updates.append('config = ?') params.append(json.dumps(config if isinstance(config, dict) else {}, ensure_ascii=False)) - # Recalculate next_run_at if interval changed - interval_minutes = int(config.get('interval_minutes') or 60) - updates.append('next_run_at = ?') - params.append(_now_ts() + (interval_minutes * 60)) + # Recalculate next_run_at if interval changed (handled separately for PostgreSQL) + next_run_interval = int(config.get('interval_minutes') or 60) if 'notification_config' in data: notification_config = data.get('notification_config') or {} @@ -621,10 +631,13 @@ def update_monitor(monitor_id): if not updates: return jsonify({'code': 0, 'msg': 'No fields to update', 'data': None}), 400 - updates.append('updated_at = ?') - params.append(_now_ts()) + # Add next_run_at update if interval was changed + if next_run_interval is not None: + updates.append(f"next_run_at = NOW() + INTERVAL '{next_run_interval} minutes'") + + updates.append('updated_at = NOW()') params.append(monitor_id) - params.append(DEFAULT_USER_ID) + params.append(user_id) with get_db_connection() as db: cur = db.cursor() @@ -643,14 +656,16 @@ def update_monitor(monitor_id): @portfolio_bp.route('/monitors/', methods=['DELETE']) +@login_required def delete_monitor(monitor_id): - """Delete a monitor.""" + """Delete a monitor for the current user.""" try: + user_id = g.user_id with get_db_connection() as db: cur = db.cursor() cur.execute( "DELETE FROM qd_position_monitors WHERE id = ? AND user_id = ?", - (monitor_id, DEFAULT_USER_ID) + (monitor_id, user_id) ) db.commit() cur.close() @@ -663,26 +678,62 @@ def delete_monitor(monitor_id): @portfolio_bp.route('/monitors//run', methods=['POST']) +@login_required def run_monitor_now(monitor_id): - """Manually trigger a monitor to run immediately.""" + """Manually trigger a monitor to run immediately. + + Supports two modes: + - async=true (default): Returns immediately, runs in background, notifies via notification system + - async=false: Waits for completion and returns result (may timeout for large portfolios) + """ try: from app.services.portfolio_monitor import run_single_monitor - # Get language from request body or Accept-Language header + user_id = g.user_id + + # Get parameters from request body data = request.get_json(force=True, silent=True) or {} language = data.get('language') + async_mode = data.get('async', True) # Default to async mode - # Fallback to Accept-Language header + # Fallback to Accept-Language header for language if not language: accept_lang = request.headers.get('Accept-Language', '') if 'zh' in accept_lang.lower(): - language = 'en-US' + language = 'zh-CN' else: language = 'en-US' - result = run_single_monitor(monitor_id, override_language=language) - - return jsonify({'code': 1, 'msg': 'success', 'data': result}) + if async_mode: + # Async mode: Start background thread and return immediately + import threading + + def run_in_background(mid, lang, uid): + try: + run_single_monitor(mid, override_language=lang, user_id=uid) + except Exception as e: + logger.error(f"Background monitor run failed: {e}") + + thread = threading.Thread( + target=run_in_background, + args=(monitor_id, language, user_id), + daemon=True + ) + thread.start() + + return jsonify({ + 'code': 1, + 'msg': 'success', + 'data': { + 'status': 'running', + 'message': 'Monitor is running in background. Results will be sent via notification.' + } + }) + else: + # Sync mode: Wait for completion (may timeout) + result = run_single_monitor(monitor_id, override_language=language, user_id=user_id) + return jsonify({'code': 1, 'msg': 'success', 'data': result}) + except Exception as e: logger.error(f"run_monitor_now failed: {str(e)}") logger.error(traceback.format_exc()) @@ -692,9 +743,11 @@ def run_monitor_now(monitor_id): # ==================== Alerts CRUD ==================== @portfolio_bp.route('/alerts', methods=['GET']) +@login_required def get_alerts(): - """Get all position alerts.""" + """Get all position alerts for the current user.""" try: + user_id = g.user_id with get_db_connection() as db: cur = db.cursor() cur.execute( @@ -708,7 +761,7 @@ def get_alerts(): WHERE a.user_id = ? ORDER BY a.id DESC """, - (DEFAULT_USER_ID,) + (user_id,) ) rows = cur.fetchall() or [] cur.close() @@ -743,9 +796,11 @@ def get_alerts(): @portfolio_bp.route('/alerts', methods=['POST']) +@login_required def add_alert(): - """Add a new position alert.""" + """Add a new position alert for the current user.""" try: + user_id = g.user_id data = request.get_json() or {} position_id = data.get('position_id') # Can be None for symbol-level alerts market = (data.get('market') or '').strip() @@ -768,7 +823,7 @@ def add_alert(): cur = db.cursor() cur.execute( "SELECT market, symbol FROM qd_manual_positions WHERE id = ? AND user_id = ?", - (position_id, DEFAULT_USER_ID) + (position_id, user_id) ) pos = cur.fetchone() cur.close() @@ -782,7 +837,6 @@ def add_alert(): if threshold <= 0 and alert_type.startswith('price_'): return jsonify({'code': 0, 'msg': 'Threshold must be positive for price alerts', 'data': None}), 400 - now = _now_ts() notification_config_json = json.dumps(notification_config if isinstance(notification_config, dict) else {}, ensure_ascii=False) with get_db_connection() as db: @@ -793,7 +847,7 @@ def add_alert(): if position_id: cur.execute( "SELECT id FROM qd_position_alerts WHERE position_id = ? AND user_id = ?", - (position_id, DEFAULT_USER_ID) + (position_id, user_id) ) existing = cur.fetchone() if existing: @@ -805,11 +859,11 @@ def add_alert(): """ UPDATE qd_position_alerts SET alert_type = ?, threshold = ?, notification_config = ?, - is_active = ?, is_triggered = 0, repeat_interval = ?, notes = ?, updated_at = ? + is_active = ?, is_triggered = 0, repeat_interval = ?, notes = ?, updated_at = NOW() WHERE id = ? """, (alert_type, threshold, notification_config_json, - 1 if is_active else 0, repeat_interval, notes, now, existing_alert_id) + 1 if is_active else 0, repeat_interval, notes, existing_alert_id) ) alert_id = existing_alert_id else: @@ -819,10 +873,10 @@ def add_alert(): INSERT INTO qd_position_alerts (user_id, position_id, market, symbol, alert_type, threshold, notification_config, is_active, repeat_interval, notes, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NOW(), NOW()) """, - (DEFAULT_USER_ID, position_id, market, symbol, alert_type, threshold, notification_config_json, - 1 if is_active else 0, repeat_interval, notes, now, now) + (user_id, position_id, market, symbol, alert_type, threshold, notification_config_json, + 1 if is_active else 0, repeat_interval, notes) ) alert_id = cur.lastrowid @@ -837,9 +891,11 @@ def add_alert(): @portfolio_bp.route('/alerts/', methods=['PUT']) +@login_required def update_alert(alert_id): - """Update an existing alert.""" + """Update an existing alert for the current user.""" try: + user_id = g.user_id data = request.get_json() or {} updates = [] @@ -877,10 +933,9 @@ def update_alert(alert_id): if not updates: return jsonify({'code': 0, 'msg': 'No fields to update', 'data': None}), 400 - updates.append('updated_at = ?') - params.append(_now_ts()) + updates.append('updated_at = NOW()') params.append(alert_id) - params.append(DEFAULT_USER_ID) + params.append(user_id) with get_db_connection() as db: cur = db.cursor() @@ -899,14 +954,16 @@ def update_alert(alert_id): @portfolio_bp.route('/alerts/', methods=['DELETE']) +@login_required def delete_alert(alert_id): - """Delete an alert.""" + """Delete an alert for the current user.""" try: + user_id = g.user_id with get_db_connection() as db: cur = db.cursor() cur.execute( "DELETE FROM qd_position_alerts WHERE id = ? AND user_id = ?", - (alert_id, DEFAULT_USER_ID) + (alert_id, user_id) ) db.commit() cur.close() @@ -921,9 +978,11 @@ def delete_alert(alert_id): # ==================== Groups ==================== @portfolio_bp.route('/groups', methods=['GET']) +@login_required def get_groups(): - """Get list of all groups with position counts.""" + """Get list of all groups with position counts for the current user.""" try: + user_id = g.user_id with get_db_connection() as db: cur = db.cursor() cur.execute( @@ -934,14 +993,14 @@ def get_groups(): GROUP BY group_name ORDER BY group_name """, - (DEFAULT_USER_ID,) + (user_id,) ) rows = cur.fetchall() or [] # Also get count of ungrouped cur.execute( "SELECT COUNT(*) as count FROM qd_manual_positions WHERE user_id = ? AND (group_name IS NULL OR group_name = '')", - (DEFAULT_USER_ID,) + (user_id,) ) ungrouped = cur.fetchone() cur.close() @@ -971,9 +1030,11 @@ def get_groups(): @portfolio_bp.route('/groups/rename', methods=['POST']) +@login_required def rename_group(): - """Rename a group.""" + """Rename a group for the current user.""" try: + user_id = g.user_id data = request.get_json() or {} old_name = (data.get('old_name') or '').strip() new_name = (data.get('new_name') or '').strip() @@ -981,12 +1042,11 @@ def rename_group(): if not old_name: return jsonify({'code': 0, 'msg': 'old_name is required', 'data': None}), 400 - now = _now_ts() with get_db_connection() as db: cur = db.cursor() cur.execute( - "UPDATE qd_manual_positions SET group_name = ?, updated_at = ? WHERE user_id = ? AND group_name = ?", - (new_name, now, DEFAULT_USER_ID, old_name) + "UPDATE qd_manual_positions SET group_name = ?, updated_at = NOW() WHERE user_id = ? AND group_name = ?", + (new_name, user_id, old_name) ) db.commit() cur.close() diff --git a/backend_api_python/app/routes/settings.py b/backend_api_python/app/routes/settings.py index 84748bae9..4cb9cc6b9 100644 --- a/backend_api_python/app/routes/settings.py +++ b/backend_api_python/app/routes/settings.py @@ -1,11 +1,14 @@ """ Settings API - 读取和保存 .env 配置 + +Admin-only endpoints for system configuration management. """ import os import re from flask import Blueprint, request, jsonify from app.utils.logger import get_logger from app.utils.config_loader import clear_config_cache +from app.utils.auth import login_required, admin_required logger = get_logger(__name__) @@ -73,6 +76,13 @@ 'default': '123456', 'description': 'Administrator login password. MUST change in production' }, + { + 'key': 'ADMIN_EMAIL', + 'label': 'Admin Email', + 'type': 'text', + 'default': 'admin@example.com', + 'description': 'Administrator email for password reset and notifications' + }, ] }, @@ -654,11 +664,245 @@ ] }, - # ==================== 13. 应用配置 ==================== + # ==================== 13. 注册与安全 ==================== + 'security': { + 'title': 'Registration & Security', + 'icon': 'safety', + 'order': 13, + 'items': [ + { + 'key': 'ENABLE_REGISTRATION', + 'label': 'Enable Registration', + 'type': 'boolean', + 'default': 'True', + 'description': 'Allow new users to register accounts' + }, + { + 'key': 'TURNSTILE_SITE_KEY', + 'label': 'Turnstile Site Key', + 'type': 'text', + 'required': False, + 'link': 'https://dash.cloudflare.com/?to=/:account/turnstile', + 'link_text': 'settings.link.getTurnstileKey', + 'description': 'Cloudflare Turnstile site key for CAPTCHA verification' + }, + { + 'key': 'TURNSTILE_SECRET_KEY', + 'label': 'Turnstile Secret Key', + 'type': 'password', + 'required': False, + 'description': 'Cloudflare Turnstile secret key' + }, + { + 'key': 'FRONTEND_URL', + 'label': 'Frontend URL', + 'type': 'text', + 'default': 'http://localhost:8080', + 'description': 'Frontend URL for OAuth redirects' + }, + { + 'key': 'GOOGLE_CLIENT_ID', + 'label': 'Google Client ID', + 'type': 'text', + 'required': False, + 'link': 'https://console.cloud.google.com/apis/credentials', + 'link_text': 'settings.link.getGoogleCredentials', + 'description': 'Google OAuth Client ID' + }, + { + 'key': 'GOOGLE_CLIENT_SECRET', + 'label': 'Google Client Secret', + 'type': 'password', + 'required': False, + 'description': 'Google OAuth Client Secret' + }, + { + 'key': 'GOOGLE_REDIRECT_URI', + 'label': 'Google Redirect URI', + 'type': 'text', + 'default': 'http://localhost:5000/api/auth/oauth/google/callback', + 'description': 'Google OAuth callback URL' + }, + { + 'key': 'GITHUB_CLIENT_ID', + 'label': 'GitHub Client ID', + 'type': 'text', + 'required': False, + 'link': 'https://github.com/settings/developers', + 'link_text': 'settings.link.getGithubCredentials', + 'description': 'GitHub OAuth Client ID' + }, + { + 'key': 'GITHUB_CLIENT_SECRET', + 'label': 'GitHub Client Secret', + 'type': 'password', + 'required': False, + 'description': 'GitHub OAuth Client Secret' + }, + { + 'key': 'GITHUB_REDIRECT_URI', + 'label': 'GitHub Redirect URI', + 'type': 'text', + 'default': 'http://localhost:5000/api/auth/oauth/github/callback', + 'description': 'GitHub OAuth callback URL' + }, + { + 'key': 'SECURITY_IP_MAX_ATTEMPTS', + 'label': 'IP Max Failed Attempts', + 'type': 'number', + 'default': '10', + 'description': 'Block IP after this many failed login attempts' + }, + { + 'key': 'SECURITY_IP_WINDOW_MINUTES', + 'label': 'IP Window (minutes)', + 'type': 'number', + 'default': '5', + 'description': 'Time window for counting IP failed attempts' + }, + { + 'key': 'SECURITY_IP_BLOCK_MINUTES', + 'label': 'IP Block Duration (minutes)', + 'type': 'number', + 'default': '15', + 'description': 'How long to block IP after exceeding limit' + }, + { + 'key': 'SECURITY_ACCOUNT_MAX_ATTEMPTS', + 'label': 'Account Max Failed Attempts', + 'type': 'number', + 'default': '5', + 'description': 'Lock account after this many failed login attempts' + }, + { + 'key': 'SECURITY_ACCOUNT_WINDOW_MINUTES', + 'label': 'Account Window (minutes)', + 'type': 'number', + 'default': '60', + 'description': 'Time window for counting account failed attempts' + }, + { + 'key': 'SECURITY_ACCOUNT_BLOCK_MINUTES', + 'label': 'Account Block Duration (minutes)', + 'type': 'number', + 'default': '30', + 'description': 'How long to lock account after exceeding limit' + }, + { + 'key': 'VERIFICATION_CODE_EXPIRE_MINUTES', + 'label': 'Verification Code Expiry (minutes)', + 'type': 'number', + 'default': '10', + 'description': 'Email verification code validity period' + }, + { + 'key': 'VERIFICATION_CODE_RATE_LIMIT', + 'label': 'Code Rate Limit (seconds)', + 'type': 'number', + 'default': '60', + 'description': 'Minimum time between verification code requests per email' + }, + { + 'key': 'VERIFICATION_CODE_IP_HOURLY_LIMIT', + 'label': 'Code Hourly Limit per IP', + 'type': 'number', + 'default': '10', + 'description': 'Maximum verification codes per IP per hour' + }, + { + 'key': 'VERIFICATION_CODE_MAX_ATTEMPTS', + 'label': 'Code Max Attempts', + 'type': 'number', + 'default': '5', + 'description': 'Maximum attempts to verify a code before lockout' + }, + { + 'key': 'VERIFICATION_CODE_LOCK_MINUTES', + 'label': 'Code Lock Minutes', + 'type': 'number', + 'default': '30', + 'description': 'Lockout duration after exceeding max attempts' + }, + ] + }, + + # ==================== 14. 计费配置 ==================== + 'billing': { + 'title': 'Billing & Credits', + 'icon': 'dollar', + 'order': 14, + 'items': [ + { + 'key': 'BILLING_ENABLED', + 'label': 'Enable Billing', + 'type': 'boolean', + 'default': 'False', + 'description': 'Enable billing system. When enabled, users need credits to use certain features' + }, + { + 'key': 'BILLING_VIP_BYPASS', + 'label': 'VIP Free', + 'type': 'boolean', + 'default': 'True', + 'description': 'VIP users can use all paid features for free during VIP period' + }, + { + 'key': 'BILLING_COST_AI_ANALYSIS', + 'label': 'AI Analysis Cost', + 'type': 'number', + 'default': '10', + 'description': 'Credits consumed per AI analysis request' + }, + { + 'key': 'BILLING_COST_STRATEGY_RUN', + 'label': 'Strategy Run Cost', + 'type': 'number', + 'default': '5', + 'description': 'Credits consumed when starting a strategy' + }, + { + 'key': 'BILLING_COST_BACKTEST', + 'label': 'Backtest Cost', + 'type': 'number', + 'default': '3', + 'description': 'Credits consumed per backtest run' + }, + { + 'key': 'BILLING_COST_PORTFOLIO_MONITOR', + 'label': 'Portfolio Monitor Cost', + 'type': 'number', + 'default': '8', + 'description': 'Credits consumed per portfolio AI monitoring run' + }, + { + 'key': 'RECHARGE_TELEGRAM_URL', + 'label': 'Recharge Telegram URL', + 'type': 'text', + 'default': 'https://t.me/your_support_bot', + 'description': 'Telegram customer service URL for recharge inquiries' + }, + { + 'key': 'CREDITS_REGISTER_BONUS', + 'label': 'Register Bonus', + 'type': 'number', + 'default': '100', + 'description': 'Credits awarded to new users on registration' + }, + { + 'key': 'CREDITS_REFERRAL_BONUS', + 'label': 'Referral Bonus', + 'type': 'number', + 'default': '50', + 'description': 'Credits awarded to referrer when someone signs up with their code' + }, + ] + }, + + # ==================== 15. 应用配置 ==================== 'app': { 'title': 'Application', 'icon': 'appstore', - 'order': 13, + 'order': 15, 'items': [ { 'key': 'CORS_ORIGINS', @@ -791,8 +1035,10 @@ def write_env_file(env_values): @settings_bp.route('/schema', methods=['GET']) +@login_required +@admin_required def get_settings_schema(): - """获取配置项定义""" + """获取配置项定义 (admin only)""" return jsonify({ 'code': 1, 'msg': 'success', @@ -801,8 +1047,10 @@ def get_settings_schema(): @settings_bp.route('/values', methods=['GET']) +@login_required +@admin_required def get_settings_values(): - """获取当前配置值 - 包括敏感信息(真实值)""" + """获取当前配置值 - 包括敏感信息(真实值)(admin only)""" env_values = read_env_file() # 构建返回数据,返回真实值 @@ -825,8 +1073,10 @@ def get_settings_values(): @settings_bp.route('/save', methods=['POST']) +@login_required +@admin_required def save_settings(): - """保存配置""" + """保存配置 (admin only)""" try: data = request.get_json() if not data: @@ -878,8 +1128,10 @@ def save_settings(): @settings_bp.route('/openrouter-balance', methods=['GET']) +@login_required +@admin_required def get_openrouter_balance(): - """查询 OpenRouter 账户余额""" + """查询 OpenRouter 账户余额 (admin only)""" try: import requests from app.config.api_keys import APIKeys @@ -954,8 +1206,10 @@ def get_openrouter_balance(): @settings_bp.route('/test-connection', methods=['POST']) +@login_required +@admin_required def test_connection(): - """测试API连接""" + """测试API连接 (admin only)""" try: data = request.get_json() service = data.get('service') diff --git a/backend_api_python/app/routes/strategy.py b/backend_api_python/app/routes/strategy.py index 7d16f9d45..cb614e3fe 100644 --- a/backend_api_python/app/routes/strategy.py +++ b/backend_api_python/app/routes/strategy.py @@ -1,7 +1,7 @@ """ -交易策略 API 路由 +Trading Strategy API Routes """ -from flask import Blueprint, request, jsonify +from flask import Blueprint, request, jsonify, g import traceback import time @@ -11,6 +11,7 @@ from app import get_trading_executor from app.utils.logger import get_logger from app.utils.db import get_db_connection +from app.utils.auth import login_required from app.data_sources import DataSourceFactory logger = get_logger(__name__) @@ -29,12 +30,13 @@ def get_strategy_service() -> StrategyService: @strategy_bp.route('/strategies', methods=['GET']) +@login_required def list_strategies(): """ - 策略列表(本地版:单用户) + List strategies for the current user. """ try: - user_id = request.args.get('user_id', type=int) or 1 + user_id = g.user_id items = get_strategy_service().list_strategies(user_id=user_id) return jsonify({'code': 1, 'msg': 'success', 'data': {'strategies': items}}) except Exception as e: @@ -44,12 +46,14 @@ def list_strategies(): @strategy_bp.route('/strategies/detail', methods=['GET']) +@login_required def get_strategy_detail(): try: + user_id = g.user_id strategy_id = request.args.get('id', type=int) if not strategy_id: return jsonify({'code': 0, 'msg': 'Missing strategy id parameter', 'data': None}), 400 - st = get_strategy_service().get_strategy(strategy_id) + st = get_strategy_service().get_strategy(strategy_id, user_id=user_id) if not st: return jsonify({'code': 0, 'msg': 'Strategy not found', 'data': None}), 404 return jsonify({'code': 1, 'msg': 'success', 'data': st}) @@ -60,11 +64,13 @@ def get_strategy_detail(): @strategy_bp.route('/strategies/create', methods=['POST']) +@login_required def create_strategy(): try: + user_id = g.user_id payload = request.get_json() or {} - # Local mode default user - payload['user_id'] = int(payload.get('user_id') or 1) + # Use current user's ID + payload['user_id'] = user_id payload['strategy_type'] = payload.get('strategy_type') or 'IndicatorStrategy' new_id = get_strategy_service().create_strategy(payload) return jsonify({'code': 1, 'msg': 'success', 'data': {'id': new_id}}) @@ -75,18 +81,20 @@ def create_strategy(): @strategy_bp.route('/strategies/batch-create', methods=['POST']) +@login_required def batch_create_strategies(): """ - 批量创建策略(多币种) + Batch create strategies (multiple symbols) - 请求体: - strategy_name: 策略基础名称 - symbols: 币种数组,如 ["Crypto:BTC/USDT", "Crypto:ETH/USDT"] - ... 其他策略配置 + Request body: + strategy_name: Base strategy name + symbols: Array of symbols, e.g. ["Crypto:BTC/USDT", "Crypto:ETH/USDT"] + ... other strategy config """ try: + user_id = g.user_id payload = request.get_json() or {} - payload['user_id'] = int(payload.get('user_id') or 1) + payload['user_id'] = user_id payload['strategy_type'] = payload.get('strategy_type') or 'IndicatorStrategy' result = get_strategy_service().batch_create_strategies(payload) @@ -94,13 +102,13 @@ def batch_create_strategies(): if result['success']: return jsonify({ 'code': 1, - 'msg': f"成功创建 {result['total_created']} 个策略", + 'msg': f"Successfully created {result['total_created']} strategies", 'data': result }) else: return jsonify({ 'code': 0, - 'msg': '批量创建失败', + 'msg': 'Batch creation failed', 'data': result }) except Exception as e: @@ -110,31 +118,33 @@ def batch_create_strategies(): @strategy_bp.route('/strategies/batch-start', methods=['POST']) +@login_required def batch_start_strategies(): """ - 批量启动策略 + Batch start strategies - 请求体: - strategy_ids: 策略ID数组 - 或 - strategy_group_id: 策略组ID + Request body: + strategy_ids: Array of strategy IDs + or + strategy_group_id: Strategy group ID """ try: + user_id = g.user_id payload = request.get_json() or {} strategy_ids = payload.get('strategy_ids') or [] strategy_group_id = payload.get('strategy_group_id') - # 如果提供了策略组ID,获取组内所有策略 + # If strategy_group_id provided, get all strategies in the group if strategy_group_id and not strategy_ids: - strategy_ids = get_strategy_service().get_strategies_by_group(strategy_group_id) + strategy_ids = get_strategy_service().get_strategies_by_group(strategy_group_id, user_id=user_id) if not strategy_ids: - return jsonify({'code': 0, 'msg': '请提供策略ID', 'data': None}), 400 + return jsonify({'code': 0, 'msg': 'Please provide strategy IDs', 'data': None}), 400 - # 先更新数据库状态 - result = get_strategy_service().batch_start_strategies(strategy_ids) + # Update database status first + result = get_strategy_service().batch_start_strategies(strategy_ids, user_id=user_id) - # 然后启动执行器 + # Then start executor executor = get_trading_executor() for sid in result.get('success_ids', []): try: @@ -144,7 +154,7 @@ def batch_start_strategies(): return jsonify({ 'code': 1 if result['success'] else 0, - 'msg': f"成功启动 {len(result.get('success_ids', []))} 个策略", + 'msg': f"Successfully started {len(result.get('success_ids', []))} strategies", 'data': result }) except Exception as e: @@ -154,27 +164,29 @@ def batch_start_strategies(): @strategy_bp.route('/strategies/batch-stop', methods=['POST']) +@login_required def batch_stop_strategies(): """ - 批量停止策略 + Batch stop strategies - 请求体: - strategy_ids: 策略ID数组 - 或 - strategy_group_id: 策略组ID + Request body: + strategy_ids: Array of strategy IDs + or + strategy_group_id: Strategy group ID """ try: + user_id = g.user_id payload = request.get_json() or {} strategy_ids = payload.get('strategy_ids') or [] strategy_group_id = payload.get('strategy_group_id') if strategy_group_id and not strategy_ids: - strategy_ids = get_strategy_service().get_strategies_by_group(strategy_group_id) + strategy_ids = get_strategy_service().get_strategies_by_group(strategy_group_id, user_id=user_id) if not strategy_ids: - return jsonify({'code': 0, 'msg': '请提供策略ID', 'data': None}), 400 + return jsonify({'code': 0, 'msg': 'Please provide strategy IDs', 'data': None}), 400 - # 先停止执行器 + # Stop executor first executor = get_trading_executor() for sid in strategy_ids: try: @@ -182,12 +194,12 @@ def batch_stop_strategies(): except Exception as e: logger.error(f"Failed to stop executor for strategy {sid}: {e}") - # 然后更新数据库状态 - result = get_strategy_service().batch_stop_strategies(strategy_ids) + # Then update database status + result = get_strategy_service().batch_stop_strategies(strategy_ids, user_id=user_id) return jsonify({ 'code': 1 if result['success'] else 0, - 'msg': f"成功停止 {len(result.get('success_ids', []))} 个策略", + 'msg': f"Successfully stopped {len(result.get('success_ids', []))} strategies", 'data': result }) except Exception as e: @@ -197,40 +209,42 @@ def batch_stop_strategies(): @strategy_bp.route('/strategies/batch-delete', methods=['DELETE']) +@login_required def batch_delete_strategies(): """ - 批量删除策略 + Batch delete strategies - 请求体: - strategy_ids: 策略ID数组 - 或 - strategy_group_id: 策略组ID + Request body: + strategy_ids: Array of strategy IDs + or + strategy_group_id: Strategy group ID """ try: + user_id = g.user_id payload = request.get_json() or {} strategy_ids = payload.get('strategy_ids') or [] strategy_group_id = payload.get('strategy_group_id') if strategy_group_id and not strategy_ids: - strategy_ids = get_strategy_service().get_strategies_by_group(strategy_group_id) + strategy_ids = get_strategy_service().get_strategies_by_group(strategy_group_id, user_id=user_id) if not strategy_ids: - return jsonify({'code': 0, 'msg': '请提供策略ID', 'data': None}), 400 + return jsonify({'code': 0, 'msg': 'Please provide strategy IDs', 'data': None}), 400 - # 先停止执行器 + # Stop executor first executor = get_trading_executor() for sid in strategy_ids: try: executor.stop_strategy(sid) except Exception as e: - pass # 忽略停止错误 + pass # Ignore stop errors - # 然后删除 - result = get_strategy_service().batch_delete_strategies(strategy_ids) + # Then delete + result = get_strategy_service().batch_delete_strategies(strategy_ids, user_id=user_id) return jsonify({ 'code': 1 if result['success'] else 0, - 'msg': f"成功删除 {len(result.get('success_ids', []))} 个策略", + 'msg': f"Successfully deleted {len(result.get('success_ids', []))} strategies", 'data': result }) except Exception as e: @@ -240,13 +254,15 @@ def batch_delete_strategies(): @strategy_bp.route('/strategies/update', methods=['PUT']) +@login_required def update_strategy(): try: + user_id = g.user_id strategy_id = request.args.get('id', type=int) if not strategy_id: return jsonify({'code': 0, 'msg': 'Missing strategy id parameter', 'data': None}), 400 payload = request.get_json() or {} - ok = get_strategy_service().update_strategy(strategy_id, payload) + ok = get_strategy_service().update_strategy(strategy_id, payload, user_id=user_id) if not ok: return jsonify({'code': 0, 'msg': 'Strategy not found', 'data': None}), 404 return jsonify({'code': 1, 'msg': 'success', 'data': None}) @@ -257,12 +273,14 @@ def update_strategy(): @strategy_bp.route('/strategies/delete', methods=['DELETE']) +@login_required def delete_strategy(): try: + user_id = g.user_id strategy_id = request.args.get('id', type=int) if not strategy_id: return jsonify({'code': 0, 'msg': 'Missing strategy id parameter', 'data': None}), 400 - ok = get_strategy_service().delete_strategy(strategy_id) + ok = get_strategy_service().delete_strategy(strategy_id, user_id=user_id) return jsonify({'code': 1 if ok else 0, 'msg': 'success' if ok else 'failed', 'data': None}) except Exception as e: logger.error(f"delete_strategy failed: {str(e)}") @@ -271,12 +289,20 @@ def delete_strategy(): @strategy_bp.route('/strategies/trades', methods=['GET']) +@login_required def get_trades(): - """交易记录(从本地 SQLite 读取)""" + """Get trade records for the current user's strategy.""" try: + user_id = g.user_id strategy_id = request.args.get('id', type=int) if not strategy_id: return jsonify({'code': 0, 'msg': 'Missing strategy id parameter', 'data': {'trades': [], 'items': []}}), 400 + + # Verify strategy belongs to user + st = get_strategy_service().get_strategy(strategy_id, user_id=user_id) + if not st: + return jsonify({'code': 0, 'msg': 'Strategy not found', 'data': {'trades': [], 'items': []}}), 404 + with get_db_connection() as db: cur = db.cursor() cur.execute( @@ -299,12 +325,20 @@ def get_trades(): @strategy_bp.route('/strategies/positions', methods=['GET']) +@login_required def get_positions(): - """持仓记录(从本地 SQLite 读取)""" + """Get position records for the current user's strategy.""" try: + user_id = g.user_id strategy_id = request.args.get('id', type=int) if not strategy_id: return jsonify({'code': 0, 'msg': 'Missing strategy id parameter', 'data': {'positions': [], 'items': []}}), 400 + + # Verify strategy belongs to user + st = get_strategy_service().get_strategy(strategy_id, user_id=user_id) + if not st: + return jsonify({'code': 0, 'msg': 'Strategy not found', 'data': {'positions': [], 'items': []}}), 404 + with get_db_connection() as db: cur = db.cursor() cur.execute( @@ -382,10 +416,10 @@ def _calc_pnl_percent(entry_price: float, size: float, pnl: float) -> float: cur.execute( """ UPDATE qd_strategy_positions - SET current_price = ?, unrealized_pnl = ?, pnl_percent = ?, updated_at = ? + SET current_price = ?, unrealized_pnl = ?, pnl_percent = ?, updated_at = NOW() WHERE id = ? """, - (float(cp or 0.0), float(pnl), float(pct), int(now), int(rr.get("id"))), + (float(cp or 0.0), float(pnl), float(pct), int(rr.get("id"))), ) except Exception: pass @@ -400,14 +434,18 @@ def _calc_pnl_percent(entry_price: float, size: float, pnl: float) -> float: @strategy_bp.route('/strategies/equityCurve', methods=['GET']) +@login_required def get_equity_curve(): - """净值曲线(本地简单计算:initial_capital + 累计 profit)""" + """Get equity curve for the current user's strategy.""" try: + user_id = g.user_id strategy_id = request.args.get('id', type=int) if not strategy_id: return jsonify({'code': 0, 'msg': 'Missing strategy id parameter', 'data': []}), 400 - st = get_strategy_service().get_strategy(strategy_id) or {} + st = get_strategy_service().get_strategy(strategy_id, user_id=user_id) or {} + if not st: + return jsonify({'code': 0, 'msg': 'Strategy not found', 'data': []}), 404 initial = float(st.get('initial_capital') or (st.get('trading_config') or {}).get('initial_capital') or 0) if initial <= 0: initial = 1000.0 @@ -447,14 +485,16 @@ def get_equity_curve(): @strategy_bp.route('/strategies/stop', methods=['POST']) +@login_required def stop_strategy(): """ - 停止策略 + Stop a strategy for the current user. - 参数: - id: 策略ID + Params: + id: Strategy ID """ try: + user_id = g.user_id strategy_id = request.args.get('id', type=int) if not strategy_id: @@ -464,18 +504,23 @@ def stop_strategy(): 'data': None }), 400 - # 获取策略类型 + # Verify strategy belongs to user + st = get_strategy_service().get_strategy(strategy_id, user_id=user_id) + if not st: + return jsonify({'code': 0, 'msg': 'Strategy not found', 'data': None}), 404 + + # Get strategy type strategy_type = get_strategy_service().get_strategy_type(strategy_id) # Local backend: AI strategy executor was removed. Only indicator strategies are supported. if strategy_type == 'PromptBasedStrategy': return jsonify({'code': 0, 'msg': 'AI strategy has been removed; local edition does not support starting/stopping AI strategies', 'data': None}), 400 - # 指标策略 + # Indicator strategy get_trading_executor().stop_strategy(strategy_id) - # 更新策略状态 - get_strategy_service().update_strategy_status(strategy_id, 'stopped') + # Update strategy status + get_strategy_service().update_strategy_status(strategy_id, 'stopped', user_id=user_id) return jsonify({ 'code': 1, @@ -494,14 +539,16 @@ def stop_strategy(): @strategy_bp.route('/strategies/start', methods=['POST']) +@login_required def start_strategy(): """ - 启动策略 + Start a strategy for the current user. - 参数: - id: 策略ID + Params: + id: Strategy ID """ try: + user_id = g.user_id strategy_id = request.args.get('id', type=int) if not strategy_id: @@ -511,22 +558,27 @@ def start_strategy(): 'data': None }), 400 - # 获取策略类型 + # Verify strategy belongs to user + st = get_strategy_service().get_strategy(strategy_id, user_id=user_id) + if not st: + return jsonify({'code': 0, 'msg': 'Strategy not found', 'data': None}), 404 + + # Get strategy type strategy_type = get_strategy_service().get_strategy_type(strategy_id) - # 更新策略状态 - get_strategy_service().update_strategy_status(strategy_id, 'running') + # Update strategy status + get_strategy_service().update_strategy_status(strategy_id, 'running', user_id=user_id) # Local backend: AI strategy executor was removed. Only indicator strategies are supported. if strategy_type == 'PromptBasedStrategy': return jsonify({'code': 0, 'msg': 'AI strategy has been removed; local edition does not support starting AI strategies', 'data': None}), 400 - # 指标策略 + # Indicator strategy success = get_trading_executor().start_strategy(strategy_id) if not success: - # 如果启动失败,恢复状态 - get_strategy_service().update_strategy_status(strategy_id, 'stopped') + # If start failed, restore status + get_strategy_service().update_strategy_status(strategy_id, 'stopped', user_id=user_id) return jsonify({ 'code': 0, 'msg': 'Failed to start strategy executor', @@ -550,12 +602,13 @@ def start_strategy(): @strategy_bp.route('/strategies/test-connection', methods=['POST']) +@login_required def test_connection(): """ - 测试交易所连接 + Test exchange connection. - 请求体: - exchange_config: 交易所配置 + Request body: + exchange_config: Exchange configuration """ try: data = request.get_json() or {} @@ -619,12 +672,13 @@ def test_connection(): @strategy_bp.route('/strategies/get-symbols', methods=['POST']) +@login_required def get_symbols(): """ - 获取交易所交易对列表 + Get exchange trading pairs list. - 请求体: - exchange_config: 交易所配置 + Request body: + exchange_config: Exchange configuration """ try: data = request.get_json() or {} @@ -662,9 +716,10 @@ def get_symbols(): @strategy_bp.route('/strategies/preview-compile', methods=['POST']) +@login_required def preview_compile(): """ - 预览编译后的策略结果 + Preview compiled strategy result. """ try: data = request.get_json() or {} @@ -708,9 +763,10 @@ def preview_compile(): @strategy_bp.route('/strategies/notifications', methods=['GET']) +@login_required def get_strategy_notifications(): """ - Strategy signal notifications (browser channel persistence). + Strategy signal notifications for the current user. Query: - id: strategy id (optional) @@ -718,16 +774,39 @@ def get_strategy_notifications(): - since_id: return rows with id > since_id (optional) """ try: + user_id = g.user_id strategy_id = request.args.get('id', type=int) limit = request.args.get('limit', type=int) or 50 limit = max(1, min(200, int(limit))) since_id = request.args.get('since_id', type=int) or 0 + # Get user's strategy IDs for filtering notifications + user_strategy_ids = [] + with get_db_connection() as db: + cur = db.cursor() + cur.execute("SELECT id FROM qd_strategies_trading WHERE user_id = ?", (user_id,)) + rows = cur.fetchall() or [] + user_strategy_ids = [r.get('id') for r in rows if r.get('id')] + cur.close() + + if not user_strategy_ids: + return jsonify({'code': 1, 'msg': 'success', 'data': {'items': []}}) + where = [] args = [] + + # Filter by user's strategies if strategy_id: - where.append("strategy_id = ?") - args.append(int(strategy_id)) + if strategy_id in user_strategy_ids: + where.append("strategy_id = ?") + args.append(int(strategy_id)) + else: + return jsonify({'code': 1, 'msg': 'success', 'data': {'items': []}}) + else: + placeholders = ",".join(["?"] * len(user_strategy_ids)) + where.append(f"strategy_id IN ({placeholders})") + args.extend(user_strategy_ids) + if since_id: where.append("id > ?") args.append(int(since_id)) @@ -756,19 +835,25 @@ def get_strategy_notifications(): @strategy_bp.route('/strategies/notifications/read', methods=['POST']) +@login_required def mark_notification_read(): - """Mark a single notification as read.""" + """Mark a single notification as read for the current user.""" try: + user_id = g.user_id data = request.get_json(force=True, silent=True) or {} notification_id = data.get('id') if not notification_id: return jsonify({'code': 0, 'msg': 'Missing id'}), 400 + # Only update notifications for user's strategies with get_db_connection() as db: cur = db.cursor() cur.execute( - "UPDATE qd_strategy_notifications SET is_read = 1 WHERE id = ?", - (int(notification_id),) + """ + UPDATE qd_strategy_notifications SET is_read = 1 + WHERE id = ? AND strategy_id IN (SELECT id FROM qd_strategies_trading WHERE user_id = ?) + """, + (int(notification_id), user_id) ) db.commit() cur.close() @@ -780,12 +865,20 @@ def mark_notification_read(): @strategy_bp.route('/strategies/notifications/read-all', methods=['POST']) +@login_required def mark_all_notifications_read(): - """Mark all notifications as read.""" + """Mark all notifications as read for the current user.""" try: + user_id = g.user_id with get_db_connection() as db: cur = db.cursor() - cur.execute("UPDATE qd_strategy_notifications SET is_read = 1") + cur.execute( + """ + UPDATE qd_strategy_notifications SET is_read = 1 + WHERE strategy_id IN (SELECT id FROM qd_strategies_trading WHERE user_id = ?) + """, + (user_id,) + ) db.commit() cur.close() @@ -796,12 +889,20 @@ def mark_all_notifications_read(): @strategy_bp.route('/strategies/notifications/clear', methods=['DELETE']) +@login_required def clear_notifications(): - """Clear all notifications (delete from database).""" + """Clear all notifications for the current user.""" try: + user_id = g.user_id with get_db_connection() as db: cur = db.cursor() - cur.execute("DELETE FROM qd_strategy_notifications") + cur.execute( + """ + DELETE FROM qd_strategy_notifications + WHERE strategy_id IN (SELECT id FROM qd_strategies_trading WHERE user_id = ?) + """, + (user_id,) + ) db.commit() cur.close() diff --git a/backend_api_python/app/routes/strategy_code.py b/backend_api_python/app/routes/strategy_code.py index 84879a012..a134f5562 100644 --- a/backend_api_python/app/routes/strategy_code.py +++ b/backend_api_python/app/routes/strategy_code.py @@ -41,11 +41,10 @@ def _extract_meta_from_code(code: str) -> Dict[str, str]: return {"name": name, "description": description} -@strategy_code_bp.route("/strategy/getStrategies", methods=["POST"]) +@strategy_code_bp.route("/strategy/getStrategies", methods=["GET"]) def get_strategies(): try: - data = request.get_json() or {} - user_id = int(data.get("userid") or 1) + user_id = int(request.args.get("userid") or 1) with get_db_connection() as db: cur = db.cursor() cur.execute( diff --git a/backend_api_python/app/routes/user.py b/backend_api_python/app/routes/user.py new file mode 100644 index 000000000..b3d53aba1 --- /dev/null +++ b/backend_api_python/app/routes/user.py @@ -0,0 +1,605 @@ +""" +User Management API Routes + +Provides endpoints for user CRUD operations, role management, etc. +Only accessible by admin users. +""" +from flask import Blueprint, request, jsonify, g +from app.services.user_service import get_user_service +from app.utils.auth import login_required, admin_required +from app.utils.logger import get_logger + +logger = get_logger(__name__) + +user_bp = Blueprint('user_manage', __name__) + + +@user_bp.route('/list', methods=['GET']) +@login_required +@admin_required +def list_users(): + """ + List all users (admin only). + + Query params: + page: int (default 1) + page_size: int (default 20, max 100) + search: str (optional, search by username/email/nickname) + """ + try: + page = request.args.get('page', 1, type=int) + page_size = request.args.get('page_size', 20, type=int) + search = request.args.get('search', '', type=str) + page_size = min(100, max(1, page_size)) + + result = get_user_service().list_users(page=page, page_size=page_size, search=search) + + return jsonify({ + 'code': 1, + 'msg': 'success', + 'data': result + }) + except Exception as e: + logger.error(f"list_users failed: {e}") + return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500 + + +@user_bp.route('/detail', methods=['GET']) +@login_required +@admin_required +def get_user_detail(): + """Get user detail by ID (admin only)""" + try: + user_id = request.args.get('id', type=int) + if not user_id: + return jsonify({'code': 0, 'msg': 'Missing user id', 'data': None}), 400 + + user = get_user_service().get_user_by_id(user_id) + if not user: + return jsonify({'code': 0, 'msg': 'User not found', 'data': None}), 404 + + return jsonify({ + 'code': 1, + 'msg': 'success', + 'data': user + }) + except Exception as e: + logger.error(f"get_user_detail failed: {e}") + return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500 + + +@user_bp.route('/create', methods=['POST']) +@login_required +@admin_required +def create_user(): + """ + Create a new user (admin only). + + Request body: + username: str (required) + password: str (required) + email: str (optional) + nickname: str (optional) + role: str (optional, default 'user') + """ + try: + data = request.get_json() or {} + + user_id = get_user_service().create_user(data) + + return jsonify({ + 'code': 1, + 'msg': 'User created successfully', + 'data': {'id': user_id} + }) + except ValueError as e: + return jsonify({'code': 0, 'msg': str(e), 'data': None}), 400 + except Exception as e: + logger.error(f"create_user failed: {e}") + return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500 + + +@user_bp.route('/update', methods=['PUT']) +@login_required +@admin_required +def update_user(): + """ + Update user information (admin only). + + Query params: + id: int (required) + + Request body: + email: str (optional) + nickname: str (optional) + role: str (optional) + status: str (optional) + """ + try: + user_id = request.args.get('id', type=int) + if not user_id: + return jsonify({'code': 0, 'msg': 'Missing user id', 'data': None}), 400 + + data = request.get_json() or {} + + success = get_user_service().update_user(user_id, data) + + if success: + return jsonify({'code': 1, 'msg': 'User updated successfully', 'data': None}) + else: + return jsonify({'code': 0, 'msg': 'Update failed', 'data': None}), 400 + except Exception as e: + logger.error(f"update_user failed: {e}") + return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500 + + +@user_bp.route('/delete', methods=['DELETE']) +@login_required +@admin_required +def delete_user(): + """Delete a user (admin only)""" + try: + user_id = request.args.get('id', type=int) + if not user_id: + return jsonify({'code': 0, 'msg': 'Missing user id', 'data': None}), 400 + + # Prevent deleting self + if hasattr(g, 'user_id') and g.user_id == user_id: + return jsonify({'code': 0, 'msg': 'Cannot delete yourself', 'data': None}), 400 + + success = get_user_service().delete_user(user_id) + + if success: + return jsonify({'code': 1, 'msg': 'User deleted successfully', 'data': None}) + else: + return jsonify({'code': 0, 'msg': 'Delete failed', 'data': None}), 400 + except Exception as e: + logger.error(f"delete_user failed: {e}") + return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500 + + +@user_bp.route('/reset-password', methods=['POST']) +@login_required +@admin_required +def reset_user_password(): + """ + Reset a user's password (admin only). + + Request body: + user_id: int (required) + new_password: str (required) + """ + try: + data = request.get_json() or {} + user_id = data.get('user_id') + new_password = data.get('new_password', '') + + if not user_id: + return jsonify({'code': 0, 'msg': 'Missing user_id', 'data': None}), 400 + + if len(new_password) < 6: + return jsonify({'code': 0, 'msg': 'Password must be at least 6 characters', 'data': None}), 400 + + success = get_user_service().reset_password(user_id, new_password) + + if success: + return jsonify({'code': 1, 'msg': 'Password reset successfully', 'data': None}) + else: + return jsonify({'code': 0, 'msg': 'Reset failed', 'data': None}), 400 + except ValueError as e: + return jsonify({'code': 0, 'msg': str(e), 'data': None}), 400 + except Exception as e: + logger.error(f"reset_user_password failed: {e}") + return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500 + + +@user_bp.route('/roles', methods=['GET']) +@login_required +@admin_required +def get_roles(): + """Get available roles and their permissions""" + service = get_user_service() + + roles = [] + for role in service.ROLES: + roles.append({ + 'id': role, + 'name': role.capitalize(), + 'permissions': service.get_user_permissions(role) + }) + + return jsonify({ + 'code': 1, + 'msg': 'success', + 'data': {'roles': roles} + }) + + +# ==================== Billing Management (Admin) ==================== + +@user_bp.route('/set-credits', methods=['POST']) +@login_required +@admin_required +def set_user_credits(): + """ + Set user credits (admin only). + + Request body: + user_id: int (required) + credits: int (required) + remark: str (optional) + """ + try: + from app.services.billing_service import get_billing_service + + data = request.get_json() or {} + user_id = data.get('user_id') + credits = data.get('credits') + remark = data.get('remark', '') + + if not user_id: + return jsonify({'code': 0, 'msg': 'Missing user_id', 'data': None}), 400 + + if credits is None or credits < 0: + return jsonify({'code': 0, 'msg': 'Credits must be a non-negative number', 'data': None}), 400 + + operator_id = getattr(g, 'user_id', None) + success, result = get_billing_service().set_credits(user_id, int(credits), remark, operator_id) + + if success: + return jsonify({'code': 1, 'msg': 'Credits updated successfully', 'data': {'credits': result}}) + else: + return jsonify({'code': 0, 'msg': result, 'data': None}), 400 + except Exception as e: + logger.error(f"set_user_credits failed: {e}") + return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500 + + +@user_bp.route('/set-vip', methods=['POST']) +@login_required +@admin_required +def set_user_vip(): + """ + Set user VIP status (admin only). + + Request body: + user_id: int (required) + vip_days: int (optional, 0 to cancel VIP, positive number to grant VIP for days) + vip_expires_at: str (optional, ISO format datetime, overrides vip_days if provided) + remark: str (optional) + """ + try: + from datetime import datetime, timedelta, timezone + from app.services.billing_service import get_billing_service + + data = request.get_json() or {} + user_id = data.get('user_id') + vip_days = data.get('vip_days') + vip_expires_at_str = data.get('vip_expires_at') + remark = data.get('remark', '') + + if not user_id: + return jsonify({'code': 0, 'msg': 'Missing user_id', 'data': None}), 400 + + # Calculate expires_at + expires_at = None + if vip_expires_at_str: + try: + expires_at = datetime.fromisoformat(vip_expires_at_str.replace('Z', '+00:00')) + except ValueError: + return jsonify({'code': 0, 'msg': 'Invalid vip_expires_at format', 'data': None}), 400 + elif vip_days is not None: + if vip_days > 0: + expires_at = datetime.now(timezone.utc) + timedelta(days=vip_days) + else: + expires_at = None # Cancel VIP + else: + return jsonify({'code': 0, 'msg': 'Provide vip_days or vip_expires_at', 'data': None}), 400 + + operator_id = getattr(g, 'user_id', None) + success, result = get_billing_service().set_vip(user_id, expires_at, remark, operator_id) + + if success: + return jsonify({ + 'code': 1, + 'msg': 'VIP status updated successfully', + 'data': {'vip_expires_at': expires_at.isoformat() if expires_at else None} + }) + else: + return jsonify({'code': 0, 'msg': result, 'data': None}), 400 + except Exception as e: + logger.error(f"set_user_vip failed: {e}") + return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500 + + +@user_bp.route('/credits-log', methods=['GET']) +@login_required +@admin_required +def get_user_credits_log(): + """ + Get user credits log (admin only). + + Query params: + user_id: int (required) + page: int (default 1) + page_size: int (default 20) + """ + try: + from app.services.billing_service import get_billing_service + + user_id = request.args.get('user_id', type=int) + if not user_id: + return jsonify({'code': 0, 'msg': 'Missing user_id', 'data': None}), 400 + + page = request.args.get('page', 1, type=int) + page_size = request.args.get('page_size', 20, type=int) + page_size = min(100, max(1, page_size)) + + result = get_billing_service().get_credits_log(user_id, page, page_size) + + return jsonify({'code': 1, 'msg': 'success', 'data': result}) + except Exception as e: + logger.error(f"get_user_credits_log failed: {e}") + return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500 + + +# Self-service endpoints (accessible by any logged-in user) + +@user_bp.route('/profile', methods=['GET']) +@login_required +def get_profile(): + """Get current user's profile with billing info""" + try: + from app.services.billing_service import get_billing_service + + user_id = getattr(g, 'user_id', None) + if not user_id: + return jsonify({'code': 0, 'msg': 'Not authenticated', 'data': None}), 401 + + user = get_user_service().get_user_by_id(user_id) + if not user: + return jsonify({'code': 0, 'msg': 'User not found', 'data': None}), 404 + + # Add permissions + user['permissions'] = get_user_service().get_user_permissions(user.get('role', 'user')) + + # Add billing info + billing_info = get_billing_service().get_user_billing_info(user_id) + user['billing'] = billing_info + + return jsonify({ + 'code': 1, + 'msg': 'success', + 'data': user + }) + except Exception as e: + logger.error(f"get_profile failed: {e}") + return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500 + + +@user_bp.route('/profile/update', methods=['PUT']) +@login_required +def update_profile(): + """ + Update current user's profile (limited fields). + + Request body: + nickname: str (optional) + avatar: str (optional) + + Note: Email cannot be changed after registration (for security). + Only admin can change user email via User Management. + """ + try: + user_id = getattr(g, 'user_id', None) + if not user_id: + return jsonify({'code': 0, 'msg': 'Not authenticated', 'data': None}), 401 + + data = request.get_json() or {} + + # Only allow updating certain fields for self-service + # Email is NOT allowed to be changed (security: bound to account) + allowed = {} + for field in ['nickname', 'avatar']: + if field in data: + allowed[field] = data[field] + + if not allowed: + return jsonify({'code': 0, 'msg': 'No valid fields to update', 'data': None}), 400 + + success = get_user_service().update_user(user_id, allowed) + + if success: + return jsonify({'code': 1, 'msg': 'Profile updated successfully', 'data': None}) + else: + return jsonify({'code': 0, 'msg': 'Update failed', 'data': None}), 400 + except Exception as e: + logger.error(f"update_profile failed: {e}") + return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500 + + +@user_bp.route('/my-credits-log', methods=['GET']) +@login_required +def get_my_credits_log(): + """ + Get current user's credits log. + + Query params: + page: int (default 1) + page_size: int (default 20) + """ + try: + from app.services.billing_service import get_billing_service + + user_id = getattr(g, 'user_id', None) + if not user_id: + return jsonify({'code': 0, 'msg': 'Not authenticated', 'data': None}), 401 + + page = request.args.get('page', 1, type=int) + page_size = request.args.get('page_size', 20, type=int) + page_size = min(100, max(1, page_size)) + + result = get_billing_service().get_credits_log(user_id, page, page_size) + + return jsonify({'code': 1, 'msg': 'success', 'data': result}) + except Exception as e: + logger.error(f"get_my_credits_log failed: {e}") + return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500 + + +@user_bp.route('/my-referrals', methods=['GET']) +@login_required +def get_my_referrals(): + """ + Get list of users referred by current user. + + Query params: + page: int (default 1) + page_size: int (default 20) + + Returns: + list: Users referred by current user (id, username, nickname, avatar, created_at) + total: Total count of referrals + referral_code: Current user's referral code (user ID) + referral_bonus: Credits earned per referral + register_bonus: Credits new users get on registration + """ + try: + import os + from app.utils.db import get_db_connection + + user_id = getattr(g, 'user_id', None) + if not user_id: + return jsonify({'code': 0, 'msg': 'Not authenticated', 'data': None}), 401 + + page = request.args.get('page', 1, type=int) + page_size = request.args.get('page_size', 20, type=int) + page_size = min(100, max(1, page_size)) + offset = (page - 1) * page_size + + with get_db_connection() as db: + cur = db.cursor() + + # Get total count + cur.execute( + "SELECT COUNT(*) as cnt FROM qd_users WHERE referred_by = ?", + (user_id,) + ) + total = cur.fetchone()['cnt'] + + # Get referral list + cur.execute( + """ + SELECT id, username, nickname, avatar, created_at + FROM qd_users + WHERE referred_by = ? + ORDER BY created_at DESC + LIMIT ? OFFSET ? + """, + (user_id, page_size, offset) + ) + rows = cur.fetchall() + cur.close() + + referrals = [] + for row in rows: + referrals.append({ + 'id': row['id'], + 'username': row['username'], + 'nickname': row['nickname'], + 'avatar': row['avatar'], + 'created_at': row['created_at'].isoformat() if row['created_at'] else None + }) + + return jsonify({ + 'code': 1, + 'msg': 'success', + 'data': { + 'list': referrals, + 'total': total, + 'page': page, + 'page_size': page_size, + 'referral_code': str(user_id), + 'referral_bonus': int(os.getenv('CREDITS_REFERRAL_BONUS', '0')), + 'register_bonus': int(os.getenv('CREDITS_REGISTER_BONUS', '0')) + } + }) + except Exception as e: + logger.error(f"get_my_referrals failed: {e}") + return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500 + + +@user_bp.route('/change-password', methods=['POST']) +@login_required +def change_password(): + """ + Change current user's password. + + Request body: + old_password: str (required) + new_password: str (required) + """ + try: + user_id = getattr(g, 'user_id', None) + if not user_id: + return jsonify({'code': 0, 'msg': 'Not authenticated', 'data': None}), 401 + + data = request.get_json() or {} + old_password = data.get('old_password', '') + new_password = data.get('new_password', '') + + if not new_password: + return jsonify({'code': 0, 'msg': 'New password required', 'data': None}), 400 + + if len(new_password) < 6: + return jsonify({'code': 0, 'msg': 'New password must be at least 6 characters', 'data': None}), 400 + + # Check if user has a password set + user_service = get_user_service() + user = user_service.get_user_by_id(user_id) + if not user: + return jsonify({'code': 0, 'msg': 'User not found', 'data': None}), 404 + + # Get password_hash to check if user has no password + from app.utils.db import get_db_connection + with get_db_connection() as db: + cur = db.cursor() + cur.execute("SELECT password_hash FROM qd_users WHERE id = ?", (user_id,)) + row = cur.fetchone() + cur.close() + + password_hash = row.get('password_hash', '') if row else '' + has_password = password_hash and password_hash.strip() != '' + + # If user has no password, allow setting password without old password + if not has_password: + if not old_password: + # No old password required for users without password + success = user_service.reset_password(user_id, new_password) + if success: + return jsonify({'code': 1, 'msg': 'Password set successfully', 'data': None}) + else: + return jsonify({'code': 0, 'msg': 'Failed to set password', 'data': None}), 500 + else: + # If old_password is provided but user has no password, ignore it + success = user_service.reset_password(user_id, new_password) + if success: + return jsonify({'code': 1, 'msg': 'Password set successfully', 'data': None}) + else: + return jsonify({'code': 0, 'msg': 'Failed to set password', 'data': None}), 500 + else: + # User has existing password, require old password verification + if not old_password: + return jsonify({'code': 0, 'msg': 'Old password required', 'data': None}), 400 + + success = user_service.change_password(user_id, old_password, new_password) + + if success: + return jsonify({'code': 1, 'msg': 'Password changed successfully', 'data': None}) + else: + return jsonify({'code': 0, 'msg': 'Old password incorrect', 'data': None}), 400 + except ValueError as e: + return jsonify({'code': 0, 'msg': str(e), 'data': None}), 400 + except Exception as e: + logger.error(f"change_password failed: {e}") + return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500 diff --git a/backend_api_python/app/services/agents/coordinator.py b/backend_api_python/app/services/agents/coordinator.py index 513776739..2d29a7177 100644 --- a/backend_api_python/app/services/agents/coordinator.py +++ b/backend_api_python/app/services/agents/coordinator.py @@ -97,6 +97,227 @@ def _init_agents(self): self.neutral_analyst = NeutralAnalyst() self.safe_analyst = SafeAnalyst() + def run_analysis_stream(self, market: str, symbol: str, language: str = 'zh-CN', model: str = None, timeframe: str = "1D", on_progress=None): + """ + Run the full multi-agent analysis workflow with progress callbacks. + + Args: + on_progress: Callback function that receives (agent_name: str, status: str, result: Optional[dict]) + status can be: 'started', 'completed', 'error' + + Yields: + Progress events as dicts: { 'agent': str, 'status': str, 'result': dict or None } + """ + logger.info(f"Multi-agent stream analysis start: {market}:{symbol}, model={model}, language={language}") + + def emit_progress(agent: str, status: str, result: dict = None): + """Emit progress event.""" + if on_progress: + on_progress(agent, status, result) + + # Build base context + from .tools import AgentTools + tools = AgentTools() + + emit_progress('data_collection', 'started', None) + + # 1) Base data + current_price = tools.get_current_price(market, symbol) + company_data = tools.get_company_data(market, symbol, language=language) + + # Normalize timeframe + tf = (timeframe or "1D").strip() + + # 2) Kline + fundamentals + kline_data = tools.get_stock_data(market, symbol, days=30, timeframe=tf) + fundamental_data = tools.get_fundamental_data(market, symbol) + indicators = tools.calculate_technical_indicators(kline_data or []) + + # 3) News (Finnhub + web search) + company_name = company_data.get('name', symbol) if company_data else symbol + news_data = tools.get_news(market, symbol, days=7, company_name=company_name) + + base_data = { + "market": market, + "symbol": symbol, + "current_price": current_price, + "kline_data": kline_data, + "fundamental_data": fundamental_data, + "company_data": company_data, + "news_data": news_data, + "indicators": indicators, + } + + context = { + "market": market, + "symbol": symbol, + "language": language, + "model": model, + "timeframe": tf, + "memory_features": { + "timeframe": tf, + "price": (current_price or {}).get("price"), + "changePercent": (current_price or {}).get("changePercent"), + "indicators": indicators, + }, + "base_data": base_data + } + + emit_progress('data_collection', 'completed', None) + + # Phase 1: Analysts (parallel but report individually) + logger.info("Phase 1: Analyst team") + + # Fundamental Analyst + emit_progress('fundamental', 'started', None) + fundamental_report = self.fundamental_analyst.analyze(context) + emit_progress('fundamental', 'completed', fundamental_report.get('data', {})) + + # Technical Analyst (market_analyst) + emit_progress('technical', 'started', None) + market_report = self.market_analyst.analyze(context) + emit_progress('technical', 'completed', market_report.get('data', {})) + + # News Analyst + emit_progress('news', 'started', None) + news_report = self.news_analyst.analyze(context) + emit_progress('news', 'completed', news_report.get('data', {})) + + # Sentiment Analyst + emit_progress('sentiment', 'started', None) + sentiment_report = self.sentiment_analyst.analyze(context) + emit_progress('sentiment', 'completed', sentiment_report.get('data', {})) + + # Risk Analyst + emit_progress('risk', 'started', None) + risk_report = self.risk_analyst.analyze(context) + emit_progress('risk', 'completed', risk_report.get('data', {})) + + # Update context with analyst outputs + context.update({ + "market_report": market_report, + "fundamental_report": fundamental_report, + "news_report": news_report, + "sentiment_report": sentiment_report, + "risk_report": risk_report, + }) + + # Phase 2: Bull/Bear debate + logger.info("Phase 2: Research debate") + + emit_progress('debate_bull', 'started', None) + bull_argument = self.bull_researcher.analyze(context) + emit_progress('debate_bull', 'completed', bull_argument.get('data', {})) + + emit_progress('debate_bear', 'started', None) + bear_argument = self.bear_researcher.analyze(context) + emit_progress('debate_bear', 'completed', bear_argument.get('data', {})) + + context["bull_argument"] = bull_argument + context["bear_argument"] = bear_argument + + # Research manager decision + emit_progress('debate_research', 'started', None) + research_decision = self._make_research_decision(bull_argument, bear_argument, context) + context["research_decision"] = research_decision + emit_progress('debate_research', 'completed', {'research_decision': research_decision}) + + # Phase 3: Trader decision + logger.info("Phase 3: Trader decision") + emit_progress('trader_decision', 'started', None) + trader_result = self.trader_agent.analyze(context) + trader_plan = trader_result.get('data', {}).get('trading_plan', {}) + context["trader_plan"] = trader_plan + emit_progress('trader_decision', 'completed', trader_result.get('data', {})) + + # Phase 4: Risk debate + logger.info("Phase 4: Risk debate") + + emit_progress('risk_debate_risky', 'started', None) + risky_result = self.risky_analyst.analyze(context) + emit_progress('risk_debate_risky', 'completed', risky_result.get('data', {})) + + emit_progress('risk_debate_neutral', 'started', None) + neutral_result = self.neutral_analyst.analyze(context) + emit_progress('risk_debate_neutral', 'completed', neutral_result.get('data', {})) + + emit_progress('risk_debate_safe', 'started', None) + safe_result = self.safe_analyst.analyze(context) + emit_progress('risk_debate_safe', 'completed', safe_result.get('data', {})) + + # Final decision + logger.info("Phase 5: Final decision") + emit_progress('final_decision', 'started', None) + final_decision = self._make_risk_decision(risky_result, neutral_result, safe_result, trader_result, context) + emit_progress('final_decision', 'completed', final_decision) + + # Generate overview + emit_progress('overview', 'started', None) + overview = self._generate_overview(context, final_decision) + emit_progress('overview', 'completed', overview) + + # Record for reflection + if self.reflection_service and final_decision.get('decision') in ['BUY', 'SELL', 'HOLD']: + try: + self.reflection_service.record_analysis( + market=market, + symbol=symbol, + price=base_data.get('current_price', {}).get('price'), + decision=final_decision.get('decision'), + confidence=final_decision.get('confidence', 50), + reasoning=final_decision.get('reasoning', ''), + check_days=7 + ) + except Exception as e: + logger.warning(f"Record reflection failed: {e}") + + # Build final result + debate_data = { + "bull": bull_argument.get('data', {}) if bull_argument.get('data') else {}, + "bear": bear_argument.get('data', {}) if bear_argument.get('data') else {}, + "research_decision": research_decision if research_decision else "Analyzing..." + } + + trader_decision_data = trader_result.get('data', {}) if trader_result.get('data') else { + "decision": "HOLD", + "confidence": 50, + "reasoning": "Analyzing...", + "trading_plan": {}, + "report": "Analyzing..." + } + + risk_debate_data = { + "risky": risky_result.get('data', {}) if risky_result.get('data') else {}, + "neutral": neutral_result.get('data', {}) if neutral_result.get('data') else {}, + "safe": safe_result.get('data', {}) if safe_result.get('data') else {} + } + + if not final_decision or (isinstance(final_decision, dict) and len(final_decision) == 0): + final_decision = { + "decision": "HOLD", + "confidence": 50, + "reasoning": "Analyzing...", + "risk_summary": {}, + "recommendation": "Analyzing..." + } + + result = { + "overview": overview, + "fundamental": fundamental_report.get('data', {}), + "technical": market_report.get('data', {}), + "news": news_report.get('data', {}), + "sentiment": sentiment_report.get('data', {}), + "risk": risk_report.get('data', {}), + "debate": debate_data, + "trader_decision": trader_decision_data, + "risk_debate": risk_debate_data, + "final_decision": final_decision, + "error": None + } + + logger.info(f"Multi-agent stream analysis completed: {market}:{symbol}") + return result + def run_analysis(self, market: str, symbol: str, language: str = 'zh-CN', model: str = None, timeframe: str = "1D") -> Dict[str, Any]: """ Run the full multi-agent analysis workflow. diff --git a/backend_api_python/app/services/agents/memory.py b/backend_api_python/app/services/agents/memory.py index 66b3e568b..0a5ab9a27 100644 --- a/backend_api_python/app/services/agents/memory.py +++ b/backend_api_python/app/services/agents/memory.py @@ -1,7 +1,7 @@ """ -Agent memory system (local-only). +Agent memory system (PostgreSQL). -This module stores agent experiences in SQLite and retrieves relevant past cases +This module stores agent experiences in PostgreSQL and retrieves relevant past cases to inject into prompts (RAG-style). It does NOT finetune model weights. Retrieval (configurable): @@ -14,7 +14,6 @@ - optional returns weight """ -import sqlite3 import json import os import math @@ -23,31 +22,24 @@ import difflib from app.utils.logger import get_logger +from app.utils.db import get_db_connection from .embedding import EmbeddingService, cosine_sim logger = get_logger(__name__) class AgentMemory: - """智能体记忆系统""" + """Agent memory system using PostgreSQL""" def __init__(self, agent_name: str, db_path: Optional[str] = None): """ - 初始化记忆系统 + Initialize memory system. Args: - agent_name: 智能体名称 - db_path: 数据库路径(可选) + agent_name: Agent identifier (e.g., 'trader_agent', 'risk_analyst') + db_path: Deprecated parameter, kept for backward compatibility """ self.agent_name = agent_name - - if db_path is None: - # 默认数据库路径 - db_dir = os.path.join(os.path.dirname(__file__), '..', '..', '..', 'data', 'memory') - os.makedirs(db_dir, exist_ok=True) - db_path = os.path.join(db_dir, f'{agent_name}_memory.db') - - self.db_path = db_path self.embedder = EmbeddingService() self.enable_vector = os.getenv("AGENT_MEMORY_ENABLE_VECTOR", "true").lower() == "true" self.candidate_limit = int(os.getenv("AGENT_MEMORY_CANDIDATE_LIMIT", "500") or 500) @@ -55,58 +47,6 @@ def __init__(self, agent_name: str, db_path: Optional[str] = None): self.w_sim = float(os.getenv("AGENT_MEMORY_W_SIM", "0.75") or 0.75) self.w_recency = float(os.getenv("AGENT_MEMORY_W_RECENCY", "0.20") or 0.20) self.w_returns = float(os.getenv("AGENT_MEMORY_W_RETURNS", "0.05") or 0.05) - self._init_database() - - def _init_database(self): - """初始化数据库表""" - try: - conn = sqlite3.connect(self.db_path) - cursor = conn.cursor() - - cursor.execute(''' - CREATE TABLE IF NOT EXISTS memories ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - situation TEXT NOT NULL, - recommendation TEXT NOT NULL, - result TEXT, - returns REAL, - market TEXT, - symbol TEXT, - timeframe TEXT, - features_json TEXT, - embedding BLOB, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP - ) - ''') - - # Best-effort migration for older DBs - # NOTE: For existing tables, we must add missing columns BEFORE creating indexes - # that reference them (otherwise we'll hit: "no such column: market"). - cursor.execute("PRAGMA table_info(memories)") - existing_cols = {row[1] for row in cursor.fetchall() or []} - for col, ddl in { - "market": "TEXT", - "symbol": "TEXT", - "timeframe": "TEXT", - "features_json": "TEXT", - "embedding": "BLOB", - }.items(): - if col not in existing_cols: - cursor.execute(f"ALTER TABLE memories ADD COLUMN {col} {ddl}") - - # 创建索引(放在迁移之后,兼容旧库) - cursor.execute(''' - CREATE INDEX IF NOT EXISTS idx_created_at ON memories(created_at) - ''') - cursor.execute(''' - CREATE INDEX IF NOT EXISTS idx_market_symbol ON memories(market, symbol) - ''') - - conn.commit() - conn.close() - except Exception as e: - logger.error(f"初始化记忆数据库失败: {e}") def _now_utc(self) -> datetime: return datetime.now(timezone.utc) @@ -156,13 +96,13 @@ def add_memory( metadata: Optional[Dict[str, Any]] = None, ): """ - 添加记忆 + Add a memory entry. Args: - situation: 情况描述 - recommendation: 建议/决策 - result: 结果描述(可选) - returns: 收益(可选) + situation: Situation description + recommendation: Decision/recommendation made + result: Outcome description (optional) + returns: Return percentage (optional) metadata: Optional structured metadata (market/symbol/timeframe/features...) """ try: @@ -182,45 +122,50 @@ def add_memory( vec = self.embedder.embed(text) embedding_blob = self.embedder.to_bytes(vec) - conn = sqlite3.connect(self.db_path) - cursor = conn.cursor() - - cursor.execute(''' - INSERT INTO memories (situation, recommendation, result, returns, market, symbol, timeframe, features_json, embedding) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) - ''', (situation, recommendation, result, returns, market, symbol, timeframe, features_json, embedding_blob)) - - conn.commit() - conn.close() - logger.info(f"{self.agent_name} 添加新记忆") + with get_db_connection() as conn: + cur = conn.cursor() + cur.execute( + """ + INSERT INTO qd_agent_memories + (agent_name, situation, recommendation, result, returns, market, symbol, timeframe, features_json, embedding) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + (self.agent_name, situation, recommendation, result, returns, market, symbol, timeframe, features_json, embedding_blob) + ) + conn.commit() + cur.close() + logger.info(f"{self.agent_name} added new memory") except Exception as e: - logger.error(f"添加记忆失败: {e}") + logger.error(f"Failed to add memory: {e}") def get_memories(self, current_situation: str, n_matches: int = 5, metadata: Optional[Dict[str, Any]] = None) -> List[Dict[str, Any]]: """ - 检索相似记忆 + Retrieve similar memories. Args: - current_situation: 当前情况描述 - n_matches: 返回的匹配数量 + current_situation: Current situation description + n_matches: Number of matches to return + metadata: Optional metadata for filtering/weighting Returns: - 匹配的记忆列表 + List of matching memory entries """ try: - conn = sqlite3.connect(self.db_path) - cursor = conn.cursor() - - # 获取所有记忆 - cursor.execute(''' - SELECT id, situation, recommendation, result, returns, created_at, market, symbol, timeframe, features_json, embedding - FROM memories - ORDER BY created_at DESC - LIMIT ? - ''', (int(self.candidate_limit),)) - - all_memories = cursor.fetchall() - conn.close() + with get_db_connection() as conn: + cur = conn.cursor() + cur.execute( + """ + SELECT id, situation, recommendation, result, returns, created_at, + market, symbol, timeframe, features_json, embedding + FROM qd_agent_memories + WHERE agent_name = ? + ORDER BY created_at DESC + LIMIT ? + """, + (self.agent_name, int(self.candidate_limit)) + ) + all_memories = cur.fetchall() or [] + cur.close() if not all_memories: return [] @@ -240,23 +185,24 @@ def get_memories(self, current_situation: str, n_matches: int = 5, metadata: Opt ranked = [] for row in all_memories: - ( - mem_id, - situation, - recommendation, - result, - returns, - created_at, - market, - symbol, - timeframe, - features_json, - embedding_blob, - ) = row + mem_id = row['id'] + situation = row['situation'] + recommendation = row['recommendation'] + result = row['result'] + returns = row['returns'] + created_at = row['created_at'] + market = row['market'] + symbol = row['symbol'] + timeframe = row['timeframe'] + features_json = row['features_json'] + embedding_blob = row['embedding'] sim = 0.0 if self.enable_vector and embedding_blob: try: + # Handle memoryview/bytes from PostgreSQL + if isinstance(embedding_blob, memoryview): + embedding_blob = bytes(embedding_blob) mem_vec = self.embedder.from_bytes(embedding_blob) sim = cosine_sim(query_vec, mem_vec) except Exception: @@ -292,50 +238,60 @@ def get_memories(self, current_situation: str, n_matches: int = 5, metadata: Opt return ranked[: max(0, int(n_matches or 0))] except Exception as e: - logger.error(f"检索记忆失败: {e}") + logger.error(f"Failed to retrieve memories: {e}") return [] def update_memory_result(self, memory_id: int, result: str, returns: Optional[float] = None): """ - 更新记忆的结果 + Update memory result. Args: - memory_id: 记忆ID - result: 结果描述 - returns: 收益 + memory_id: Memory ID + result: Outcome description + returns: Return percentage """ try: - conn = sqlite3.connect(self.db_path) - cursor = conn.cursor() - - cursor.execute(''' - UPDATE memories - SET result = ?, returns = ?, updated_at = CURRENT_TIMESTAMP - WHERE id = ? - ''', (result, returns, memory_id)) - - conn.commit() - conn.close() - logger.info(f"{self.agent_name} 更新记忆 {memory_id}") + with get_db_connection() as conn: + cur = conn.cursor() + cur.execute( + """ + UPDATE qd_agent_memories + SET result = ?, returns = ?, updated_at = NOW() + WHERE id = ? AND agent_name = ? + """, + (result, returns, memory_id, self.agent_name) + ) + conn.commit() + cur.close() + logger.info(f"{self.agent_name} updated memory {memory_id}") except Exception as e: - logger.error(f"更新记忆失败: {e}") + logger.error(f"Failed to update memory: {e}") def get_statistics(self) -> Dict[str, Any]: - """获取记忆统计信息""" + """Get memory statistics for this agent.""" try: - conn = sqlite3.connect(self.db_path) - cursor = conn.cursor() - - cursor.execute('SELECT COUNT(*) FROM memories') - total = cursor.fetchone()[0] - - cursor.execute('SELECT AVG(returns) FROM memories WHERE returns IS NOT NULL') - avg_returns = cursor.fetchone()[0] or 0 - - cursor.execute('SELECT COUNT(*) FROM memories WHERE returns > 0') - positive = cursor.fetchone()[0] - - conn.close() + with get_db_connection() as conn: + cur = conn.cursor() + + cur.execute( + 'SELECT COUNT(*) as cnt FROM qd_agent_memories WHERE agent_name = ?', + (self.agent_name,) + ) + total = cur.fetchone()['cnt'] + + cur.execute( + 'SELECT AVG(returns) as avg_ret FROM qd_agent_memories WHERE agent_name = ? AND returns IS NOT NULL', + (self.agent_name,) + ) + avg_returns = cur.fetchone()['avg_ret'] or 0 + + cur.execute( + 'SELECT COUNT(*) as cnt FROM qd_agent_memories WHERE agent_name = ? AND returns > 0', + (self.agent_name,) + ) + positive = cur.fetchone()['cnt'] + + cur.close() return { 'total_memories': total, @@ -344,5 +300,20 @@ def get_statistics(self) -> Dict[str, Any]: 'success_rate': round(positive / total * 100, 2) if total > 0 else 0 } except Exception as e: - logger.error(f"获取统计信息失败: {e}") + logger.error(f"Failed to get statistics: {e}") return {} + + def clear_memories(self): + """Clear all memories for this agent (use with caution).""" + try: + with get_db_connection() as conn: + cur = conn.cursor() + cur.execute( + 'DELETE FROM qd_agent_memories WHERE agent_name = ?', + (self.agent_name,) + ) + conn.commit() + cur.close() + logger.warning(f"{self.agent_name} cleared all memories") + except Exception as e: + logger.error(f"Failed to clear memories: {e}") diff --git a/backend_api_python/app/services/agents/reflection.py b/backend_api_python/app/services/agents/reflection.py index 8d8de7c93..7d3dc3d6b 100644 --- a/backend_api_python/app/services/agents/reflection.py +++ b/backend_api_python/app/services/agents/reflection.py @@ -1,220 +1,249 @@ """ -自动反思与验证服务 -用于记录分析预测,并在未来自动验证结果,实现闭环学习 +Auto-reflection and verification service (PostgreSQL). + +Records analysis predictions and auto-verifies results in the future +to achieve closed-loop learning for AI agents. """ -import sqlite3 + import os -import json from datetime import datetime, timedelta from typing import List, Dict, Any, Optional + from app.utils.logger import get_logger +from app.utils.db import get_db_connection from .memory import AgentMemory from .tools import AgentTools logger = get_logger(__name__) + class ReflectionService: - """反思服务:管理分析记录的存储和验证""" + """Reflection service: manages storage and verification of analysis records.""" def __init__(self, db_path: Optional[str] = None): - if db_path is None: - # 默认数据库路径 - db_dir = os.path.join(os.path.dirname(__file__), '..', '..', '..', 'data', 'memory') - os.makedirs(db_dir, exist_ok=True) - db_path = os.path.join(db_dir, 'reflection_records.db') + """ + Initialize reflection service. - self.db_path = db_path + Args: + db_path: Deprecated parameter, kept for backward compatibility + """ self.tools = AgentTools() - self._init_database() - - def _init_database(self): - """初始化数据库表""" - try: - conn = sqlite3.connect(self.db_path) - cursor = conn.cursor() - - # 创建分析记录表 - cursor.execute(''' - CREATE TABLE IF NOT EXISTS analysis_records ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - market TEXT NOT NULL, - symbol TEXT NOT NULL, - initial_price REAL, - decision TEXT, - confidence INTEGER, - reasoning TEXT, - analysis_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - target_check_date TIMESTAMP, - status TEXT DEFAULT 'PENDING', -- PENDING, COMPLETED, FAILED - final_price REAL, - actual_return REAL, - check_result TEXT - ) - ''') - - # 创建索引 - cursor.execute(''' - CREATE INDEX IF NOT EXISTS idx_status_date ON analysis_records(status, target_check_date) - ''') - - conn.commit() - conn.close() - except Exception as e: - logger.error(f"初始化反思数据库失败: {e}") - def record_analysis(self, market: str, symbol: str, price: float, - decision: str, confidence: int, reasoning: str, - check_days: int = 7): + def record_analysis( + self, + market: str, + symbol: str, + price: float, + decision: str, + confidence: int, + reasoning: str, + check_days: int = 7 + ): """ - 记录一次分析,以便未来验证 + Record an analysis for future verification. Args: - market: 市场 - symbol: 代码 - price: 当前价格 - decision: 决策 (BUY/SELL/HOLD) - confidence: 置信度 - reasoning: 理由 - check_days: 几天后验证 (默认7天) + market: Market type + symbol: Symbol code + price: Current price + decision: Decision (BUY/SELL/HOLD) + confidence: Confidence level (0-100) + reasoning: Reasoning text + check_days: Days until verification (default 7) """ try: - conn = sqlite3.connect(self.db_path) - cursor = conn.cursor() - target_date = datetime.now() + timedelta(days=check_days) - cursor.execute(''' - INSERT INTO analysis_records - (market, symbol, initial_price, decision, confidence, reasoning, target_check_date) - VALUES (?, ?, ?, ?, ?, ?, ?) - ''', (market, symbol, price, decision, confidence, reasoning, target_date)) - - conn.commit() - conn.close() + with get_db_connection() as conn: + cur = conn.cursor() + cur.execute( + """ + INSERT INTO qd_reflection_records + (market, symbol, initial_price, decision, confidence, reasoning, target_check_date) + VALUES (?, ?, ?, ?, ?, ?, ?) + """, + (market, symbol, price, decision, confidence, reasoning, target_date) + ) + conn.commit() + cur.close() logger.info(f"Recorded analysis for reflection: {market}:{symbol}, will verify after {check_days} day(s)") except Exception as e: - logger.error(f"记录分析失败: {e}") + logger.error(f"Failed to record analysis: {e}") def run_verification_cycle(self): """ - 执行验证周期:检查到期的记录,验证结果,并写入记忆 + Execute verification cycle: check due records, verify results, and write to memory. """ - logger.info("开始执行自动反思验证周期...") + logger.info("Starting auto-reflection verification cycle...") try: - conn = sqlite3.connect(self.db_path) - cursor = conn.cursor() - - # 1. 查找所有已到期且未处理的记录 - cursor.execute(''' - SELECT id, market, symbol, initial_price, decision, confidence, reasoning, analysis_date - FROM analysis_records - WHERE status = 'PENDING' AND target_check_date <= CURRENT_TIMESTAMP - ''') - - records = cursor.fetchall() - - if not records: - logger.info("没有需要验证的记录") - conn.close() - return - - logger.info(f"发现 {len(records)} 条待验证记录") - - # 初始化记忆系统(用于写入验证结果) - trader_memory = AgentMemory('trader_agent') - - for record in records: - record_id, market, symbol, initial_price, decision, confidence, reasoning, analysis_date = record + with get_db_connection() as conn: + cur = conn.cursor() + + # 1. Find all due and pending records + cur.execute( + """ + SELECT id, market, symbol, initial_price, decision, confidence, reasoning, analysis_date + FROM qd_reflection_records + WHERE status = 'PENDING' AND target_check_date <= NOW() + """ + ) + records = cur.fetchall() or [] + + if not records: + logger.info("No records to verify") + cur.close() + return + + logger.info(f"Found {len(records)} records to verify") + + # Initialize memory system for writing verification results + trader_memory = AgentMemory('trader_agent') - try: - # 2. 获取当前最新价格 - current_price_data = self.tools.get_current_price(market, symbol) - current_price = current_price_data.get('price') + for record in records: + record_id = record['id'] + market = record['market'] + symbol = record['symbol'] + initial_price = record['initial_price'] + decision = record['decision'] + confidence = record['confidence'] + reasoning = record['reasoning'] + analysis_date = record['analysis_date'] - if not current_price: - logger.warning(f"无法获取 {market}:{symbol} 的当前价格,跳过") - continue + try: + # 2. Get current price + current_price_data = self.tools.get_current_price(market, symbol) + current_price = current_price_data.get('price') - # 3. 计算收益和结果 - if not initial_price or initial_price == 0: - actual_return = 0.0 - else: - actual_return = (current_price - initial_price) / initial_price * 100 - - # 评估结果 - result_desc = "" - is_good_prediction = False - - if decision == "BUY": - if actual_return > 2.0: - result_desc = "Correct: price rose after BUY" - is_good_prediction = True - elif actual_return < -2.0: - result_desc = "Wrong: price fell after BUY" - else: - result_desc = "Neutral: limited price movement" - elif decision == "SELL": - if actual_return < -2.0: - result_desc = "Correct: price fell after SELL" - is_good_prediction = True - elif actual_return > 2.0: - result_desc = "Wrong: price rose after SELL" - else: - result_desc = "Neutral: limited price movement" - else: # HOLD - if -2.0 <= actual_return <= 2.0: - result_desc = "Correct: limited movement during HOLD" - is_good_prediction = True + if not current_price: + logger.warning(f"Cannot get current price for {market}:{symbol}, skipping") + continue + + # 3. Calculate return and result + if not initial_price or initial_price == 0: + actual_return = 0.0 else: - result_desc = f"Deviated: large movement during HOLD ({actual_return:.2f}%)" + actual_return = (current_price - initial_price) / initial_price * 100 + + # Evaluate result + result_desc = "" + is_good_prediction = False + + if decision == "BUY": + if actual_return > 2.0: + result_desc = "Correct: price rose after BUY" + is_good_prediction = True + elif actual_return < -2.0: + result_desc = "Wrong: price fell after BUY" + else: + result_desc = "Neutral: limited price movement" + elif decision == "SELL": + if actual_return < -2.0: + result_desc = "Correct: price fell after SELL" + is_good_prediction = True + elif actual_return > 2.0: + result_desc = "Wrong: price rose after SELL" + else: + result_desc = "Neutral: limited price movement" + else: # HOLD + if -2.0 <= actual_return <= 2.0: + result_desc = "Correct: limited movement during HOLD" + is_good_prediction = True + else: + result_desc = f"Deviated: large movement during HOLD ({actual_return:.2f}%)" - # 4. 写入记忆系统 (Let the agent learn) - memory_situation = f"{market}:{symbol} auto-verified (analysis_date: {analysis_date})" - memory_recommendation = f"Decision: {decision} (confidence {confidence}), reasoning: {(reasoning or '')[:120]}" - memory_result = f"Verification: {result_desc}; return={actual_return:.2f}% (initial {initial_price} -> final {current_price})" - - trader_memory.add_memory( - memory_situation, - memory_recommendation, - memory_result, - actual_return, - metadata={ - "market": market, - "symbol": symbol, - "timeframe": "1D", - "features": { - "source": "auto_verify", - "decision": decision, - "confidence": confidence, - "initial_price": initial_price, - "final_price": current_price, - "analysis_date": str(analysis_date), - "result_desc": result_desc, - "is_good_prediction": bool(is_good_prediction), - }, - } - ) - - # 5. 更新记录状态 - cursor.execute(''' - UPDATE analysis_records - SET status = 'COMPLETED', final_price = ?, actual_return = ?, check_result = ? - WHERE id = ? - ''', (current_price, actual_return, result_desc, record_id)) - - conn.commit() - logger.info(f"验证完成 {market}:{symbol}: {result_desc}") - - except Exception as inner_e: - logger.error(f"处理记录 {record_id} 失败: {inner_e}") - # 标记为失败,避免重复处理 - # cursor.execute("UPDATE analysis_records SET status = 'FAILED' WHERE id = ?", (record_id,)) - # conn.commit() - - conn.close() - logger.info("反思验证周期结束") + # 4. Write to memory system (agent learning) + memory_situation = f"{market}:{symbol} auto-verified (analysis_date: {analysis_date})" + memory_recommendation = f"Decision: {decision} (confidence {confidence}), reasoning: {(reasoning or '')[:120]}" + memory_result = f"Verification: {result_desc}; return={actual_return:.2f}% (initial {initial_price} -> final {current_price})" + + trader_memory.add_memory( + memory_situation, + memory_recommendation, + memory_result, + actual_return, + metadata={ + "market": market, + "symbol": symbol, + "timeframe": "1D", + "features": { + "source": "auto_verify", + "decision": decision, + "confidence": confidence, + "initial_price": initial_price, + "final_price": current_price, + "analysis_date": str(analysis_date), + "result_desc": result_desc, + "is_good_prediction": bool(is_good_prediction), + }, + } + ) + + # 5. Update record status + cur.execute( + """ + UPDATE qd_reflection_records + SET status = 'COMPLETED', final_price = ?, actual_return = ?, check_result = ? + WHERE id = ? + """, + (current_price, actual_return, result_desc, record_id) + ) + conn.commit() + logger.info(f"Verification completed {market}:{symbol}: {result_desc}") + + except Exception as inner_e: + logger.error(f"Failed to process record {record_id}: {inner_e}") + # Optionally mark as failed to avoid repeated processing + # cur.execute("UPDATE qd_reflection_records SET status = 'FAILED' WHERE id = ?", (record_id,)) + # conn.commit() + + cur.close() + logger.info("Reflection verification cycle completed") except Exception as e: - logger.error(f"执行验证周期失败: {e}") + logger.error(f"Failed to execute verification cycle: {e}") + def get_pending_count(self) -> int: + """Get count of pending verification records.""" + try: + with get_db_connection() as conn: + cur = conn.cursor() + cur.execute("SELECT COUNT(*) as cnt FROM qd_reflection_records WHERE status = 'PENDING'") + count = cur.fetchone()['cnt'] + cur.close() + return count + except Exception as e: + logger.error(f"Failed to get pending count: {e}") + return 0 + + def get_statistics(self) -> Dict[str, Any]: + """Get reflection statistics.""" + try: + with get_db_connection() as conn: + cur = conn.cursor() + + cur.execute("SELECT COUNT(*) as cnt FROM qd_reflection_records") + total = cur.fetchone()['cnt'] + + cur.execute("SELECT COUNT(*) as cnt FROM qd_reflection_records WHERE status = 'PENDING'") + pending = cur.fetchone()['cnt'] + + cur.execute("SELECT COUNT(*) as cnt FROM qd_reflection_records WHERE status = 'COMPLETED'") + completed = cur.fetchone()['cnt'] + + cur.execute( + "SELECT AVG(actual_return) as avg_ret FROM qd_reflection_records WHERE status = 'COMPLETED' AND actual_return IS NOT NULL" + ) + avg_return = cur.fetchone()['avg_ret'] or 0 + + cur.close() + + return { + 'total_records': total, + 'pending_records': pending, + 'completed_records': completed, + 'average_return': round(avg_return, 2) + } + except Exception as e: + logger.error(f"Failed to get statistics: {e}") + return {} diff --git a/backend_api_python/app/services/billing_service.py b/backend_api_python/app/services/billing_service.py new file mode 100644 index 000000000..d90f124ff --- /dev/null +++ b/backend_api_python/app/services/billing_service.py @@ -0,0 +1,445 @@ +""" +Billing Service - 统一计费服务 + +管理用户积分消费、VIP状态检查、计费配置等功能。 +支持两种计费模式: +1. 积分消耗模式:每次使用功能扣除相应积分 +2. VIP免费模式:VIP用户在有效期内免费使用 + +计费配置存储在 .env 文件中,可通过系统设置界面配置。 +""" +import os +import time +from datetime import datetime, timezone +from decimal import Decimal +from typing import Dict, Any, Optional, Tuple + +from app.utils.db import get_db_connection +from app.utils.logger import get_logger + +logger = get_logger(__name__) + + +# 功能计费配置键名 +BILLING_CONFIG_PREFIX = 'BILLING_' + +# 默认计费配置 +DEFAULT_BILLING_CONFIG = { + # 全局开关 + 'enabled': False, # 是否启用计费 + 'vip_bypass': True, # VIP用户是否免费 + + # 各功能积分消耗(0表示免费) + 'cost_ai_analysis': 10, # AI分析 每次消耗积分 + 'cost_strategy_run': 5, # 策略运行 每次消耗积分(启动时) + 'cost_backtest': 3, # 回测 每次消耗积分 + 'cost_portfolio_monitor': 8, # Portfolio AI监控 每次消耗积分 + 'cost_indicator_create': 0, # 创建指标 免费 +} + +# Feature name mapping (for log recording) +FEATURE_NAMES = { + 'ai_analysis': 'AI Analysis', + 'strategy_run': 'Strategy Run', + 'backtest': 'Backtest', + 'portfolio_monitor': 'Portfolio Monitor', + 'indicator_create': 'Indicator Create', +} + + +class BillingService: + """计费服务类""" + + def __init__(self): + self._config_cache = None + self._config_cache_time = 0 + self._cache_ttl = 60 # 配置缓存60秒 + + def get_billing_config(self) -> Dict[str, Any]: + """获取计费配置""" + now = time.time() + if self._config_cache and (now - self._config_cache_time) < self._cache_ttl: + return self._config_cache + + config = {} + for key, default_value in DEFAULT_BILLING_CONFIG.items(): + env_key = f'{BILLING_CONFIG_PREFIX}{key.upper()}' + value = os.getenv(env_key) + + if value is None or value == '': + config[key] = default_value + elif isinstance(default_value, bool): + config[key] = str(value).lower() in ('true', '1', 'yes') + elif isinstance(default_value, int): + try: + config[key] = int(value) + except (ValueError, TypeError): + config[key] = default_value + else: + config[key] = value + + self._config_cache = config + self._config_cache_time = now + return config + + def clear_config_cache(self): + """清除配置缓存""" + self._config_cache = None + self._config_cache_time = 0 + + def is_billing_enabled(self) -> bool: + """检查是否启用计费""" + config = self.get_billing_config() + return config.get('enabled', False) + + def get_feature_cost(self, feature: str) -> int: + """获取功能消耗积分""" + config = self.get_billing_config() + cost_key = f'cost_{feature}' + return config.get(cost_key, 0) + + def get_user_credits(self, user_id: int) -> Decimal: + """获取用户积分余额""" + try: + with get_db_connection() as db: + cur = db.cursor() + cur.execute( + "SELECT credits FROM qd_users WHERE id = ?", + (user_id,) + ) + row = cur.fetchone() + cur.close() + + if row: + return Decimal(str(row.get('credits', 0) or 0)) + return Decimal('0') + except Exception as e: + logger.error(f"get_user_credits failed: {e}") + return Decimal('0') + + def get_user_vip_status(self, user_id: int) -> Tuple[bool, Optional[datetime]]: + """ + 获取用户VIP状态 + + Returns: + (is_vip, expires_at): VIP是否有效, VIP过期时间 + """ + try: + with get_db_connection() as db: + cur = db.cursor() + cur.execute( + "SELECT vip_expires_at FROM qd_users WHERE id = ?", + (user_id,) + ) + row = cur.fetchone() + cur.close() + + if row and row.get('vip_expires_at'): + expires_at = row['vip_expires_at'] + # 确保是 datetime 对象 + if isinstance(expires_at, str): + expires_at = datetime.fromisoformat(expires_at.replace('Z', '+00:00')) + + # 检查是否过期 + now = datetime.now(timezone.utc) + if expires_at.tzinfo is None: + expires_at = expires_at.replace(tzinfo=timezone.utc) + + is_vip = expires_at > now + return is_vip, expires_at + + return False, None + except Exception as e: + logger.error(f"get_user_vip_status failed: {e}") + return False, None + + def check_and_consume(self, user_id: int, feature: str, reference_id: str = '') -> Tuple[bool, str]: + """ + 检查并消耗积分 + + Args: + user_id: 用户ID + feature: 功能名称(ai_analysis/strategy_run/backtest/portfolio_monitor等) + reference_id: 关联ID(可选) + + Returns: + (success, message): 是否成功, 提示消息 + """ + # 检查是否启用计费 + if not self.is_billing_enabled(): + return True, 'billing_disabled' + + config = self.get_billing_config() + cost = self.get_feature_cost(feature) + + # 免费功能 + if cost <= 0: + return True, 'free_feature' + + # 检查VIP状态 + if config.get('vip_bypass', True): + is_vip, _ = self.get_user_vip_status(user_id) + if is_vip: + return True, 'vip_free' + + # 检查积分余额 + credits = self.get_user_credits(user_id) + if credits < cost: + return False, f'insufficient_credits:{credits}:{cost}' + + # 扣除积分 + try: + new_balance = credits - Decimal(str(cost)) + + with get_db_connection() as db: + cur = db.cursor() + + # 更新用户积分 + cur.execute( + "UPDATE qd_users SET credits = ?, updated_at = NOW() WHERE id = ?", + (float(new_balance), user_id) + ) + + # 记录日志 + feature_name = FEATURE_NAMES.get(feature, feature) + cur.execute( + """ + INSERT INTO qd_credits_log + (user_id, action, amount, balance_after, feature, reference_id, remark, created_at) + VALUES (?, 'consume', ?, ?, ?, ?, ?, NOW()) + """, + (user_id, -cost, float(new_balance), feature, reference_id, f'Consume: {feature_name}') + ) + + db.commit() + cur.close() + + logger.info(f"User {user_id} consumed {cost} credits for {feature}, balance: {new_balance}") + return True, 'consumed' + + except Exception as e: + logger.error(f"check_and_consume failed: {e}") + return False, f'error:{str(e)}' + + def add_credits(self, user_id: int, amount: int, action: str = 'recharge', + remark: str = '', operator_id: int = None, reference_id: str = '') -> Tuple[bool, str]: + """ + 增加用户积分 + + Args: + user_id: 用户ID + amount: 增加金额(正数) + action: 操作类型(recharge/admin_adjust/refund/referral_bonus/register_bonus) + remark: 备注 + operator_id: 操作人ID(管理员操作时) + reference_id: 关联ID(如被邀请用户ID、订单号等) + + Returns: + (success, message) + """ + if amount <= 0: + return False, 'amount_must_be_positive' + + try: + credits = self.get_user_credits(user_id) + new_balance = credits + Decimal(str(amount)) + + with get_db_connection() as db: + cur = db.cursor() + + # 更新用户积分 + cur.execute( + "UPDATE qd_users SET credits = ?, updated_at = NOW() WHERE id = ?", + (float(new_balance), user_id) + ) + + # 记录日志(包含 reference_id) + cur.execute( + """ + INSERT INTO qd_credits_log + (user_id, action, amount, balance_after, remark, operator_id, reference_id, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, NOW()) + """, + (user_id, action, amount, float(new_balance), remark, operator_id, reference_id) + ) + + db.commit() + cur.close() + + logger.info(f"User {user_id} added {amount} credits ({action}), balance: {new_balance}") + return True, str(new_balance) + + except Exception as e: + logger.error(f"add_credits failed: {e}") + return False, str(e) + + def set_credits(self, user_id: int, amount: int, remark: str = '', + operator_id: int = None) -> Tuple[bool, str]: + """ + 设置用户积分(管理员直接设置) + + Args: + user_id: 用户ID + amount: 设置的金额 + remark: 备注 + operator_id: 操作人ID + + Returns: + (success, message) + """ + if amount < 0: + return False, 'amount_cannot_be_negative' + + try: + old_credits = self.get_user_credits(user_id) + diff = Decimal(str(amount)) - old_credits + + with get_db_connection() as db: + cur = db.cursor() + + # 更新用户积分 + cur.execute( + "UPDATE qd_users SET credits = ?, updated_at = NOW() WHERE id = ?", + (amount, user_id) + ) + + # 记录日志 + cur.execute( + """ + INSERT INTO qd_credits_log + (user_id, action, amount, balance_after, remark, operator_id, created_at) + VALUES (?, 'admin_adjust', ?, ?, ?, ?, NOW()) + """, + (user_id, float(diff), amount, remark or f'Admin adjust: {old_credits} -> {amount}', operator_id) + ) + + db.commit() + cur.close() + + logger.info(f"User {user_id} credits set to {amount} by admin {operator_id}") + return True, str(amount) + + except Exception as e: + logger.error(f"set_credits failed: {e}") + return False, str(e) + + def set_vip(self, user_id: int, expires_at: Optional[datetime], + remark: str = '', operator_id: int = None) -> Tuple[bool, str]: + """ + 设置用户VIP状态 + + Args: + user_id: 用户ID + expires_at: VIP过期时间(None表示取消VIP) + remark: 备注 + operator_id: 操作人ID + + Returns: + (success, message) + """ + try: + with get_db_connection() as db: + cur = db.cursor() + + # 更新VIP过期时间 + cur.execute( + "UPDATE qd_users SET vip_expires_at = ?, updated_at = NOW() WHERE id = ?", + (expires_at, user_id) + ) + + # 记录日志 + action = 'vip_grant' if expires_at else 'vip_revoke' + log_remark = remark or (f'VIP granted until {expires_at}' if expires_at else 'VIP revoked') + cur.execute( + """ + INSERT INTO qd_credits_log + (user_id, action, amount, balance_after, remark, operator_id, created_at) + VALUES (?, ?, 0, (SELECT credits FROM qd_users WHERE id = ?), ?, ?, NOW()) + """, + (user_id, action, user_id, log_remark, operator_id) + ) + + db.commit() + cur.close() + + logger.info(f"User {user_id} VIP set to {expires_at} by admin {operator_id}") + return True, 'success' + + except Exception as e: + logger.error(f"set_vip failed: {e}") + return False, str(e) + + def get_credits_log(self, user_id: int, page: int = 1, page_size: int = 20) -> Dict[str, Any]: + """获取用户积分变动日志""" + offset = (page - 1) * page_size + + try: + with get_db_connection() as db: + cur = db.cursor() + + # 获取总数 + cur.execute( + "SELECT COUNT(*) as count FROM qd_credits_log WHERE user_id = ?", + (user_id,) + ) + total = cur.fetchone()['count'] + + # 获取日志 + cur.execute( + """ + SELECT id, action, amount, balance_after, feature, reference_id, remark, created_at + FROM qd_credits_log + WHERE user_id = ? + ORDER BY created_at DESC + LIMIT ? OFFSET ? + """, + (user_id, page_size, offset) + ) + logs = cur.fetchall() or [] + cur.close() + + return { + 'items': logs, + 'total': total, + 'page': page, + 'page_size': page_size, + 'total_pages': (total + page_size - 1) // page_size + } + except Exception as e: + logger.error(f"get_credits_log failed: {e}") + return {'items': [], 'total': 0, 'page': 1, 'page_size': page_size, 'total_pages': 0} + + def get_user_billing_info(self, user_id: int) -> Dict[str, Any]: + """获取用户计费信息(供前端显示)""" + credits = self.get_user_credits(user_id) + is_vip, vip_expires_at = self.get_user_vip_status(user_id) + config = self.get_billing_config() + + return { + 'credits': float(credits), + 'is_vip': is_vip, + 'vip_expires_at': vip_expires_at.isoformat() if vip_expires_at else None, + 'billing_enabled': config.get('enabled', False), + 'vip_bypass': config.get('vip_bypass', True), + # Public support link for credits recharge / VIP purchase + 'recharge_telegram_url': os.getenv('RECHARGE_TELEGRAM_URL', '').strip() or 'https://t.me/your_support_bot', + # 功能费用(供前端显示) + 'feature_costs': { + 'ai_analysis': config.get('cost_ai_analysis', 0), + 'strategy_run': config.get('cost_strategy_run', 0), + 'backtest': config.get('cost_backtest', 0), + 'portfolio_monitor': config.get('cost_portfolio_monitor', 0), + } + } + + +# 全局单例 +_billing_service = None + + +def get_billing_service() -> BillingService: + """获取计费服务单例""" + global _billing_service + if _billing_service is None: + _billing_service = BillingService() + return _billing_service diff --git a/backend_api_python/app/services/email_service.py b/backend_api_python/app/services/email_service.py new file mode 100644 index 000000000..d8dd8903a --- /dev/null +++ b/backend_api_python/app/services/email_service.py @@ -0,0 +1,361 @@ +""" +Email Service - Handles email verification codes and notifications. +""" +import os +import random +import string +import smtplib +from email.mime.text import MIMEText +from email.mime.multipart import MIMEMultipart +from datetime import datetime, timedelta +from typing import Tuple, Optional +from app.utils.db import get_db_connection +from app.utils.logger import get_logger + +logger = get_logger(__name__) + +# Singleton instance +_email_service = None + + +def get_email_service(): + """Get singleton EmailService instance""" + global _email_service + if _email_service is None: + _email_service = EmailService() + return _email_service + + +class EmailService: + """Email service for verification codes and notifications""" + + def __init__(self): + self._load_config() + + def _load_config(self): + """Load email configuration from environment variables""" + self.smtp_host = os.getenv('SMTP_HOST', '') + self.smtp_port = int(os.getenv('SMTP_PORT', '587')) + self.smtp_user = os.getenv('SMTP_USER', '') + self.smtp_password = os.getenv('SMTP_PASSWORD', '') + self.smtp_from = os.getenv('SMTP_FROM', '') or self.smtp_user + self.smtp_use_tls = os.getenv('SMTP_USE_TLS', 'true').lower() == 'true' + self.smtp_use_ssl = os.getenv('SMTP_USE_SSL', 'false').lower() == 'true' + + # Verification code settings + self.code_expire_minutes = int(os.getenv('VERIFICATION_CODE_EXPIRE_MINUTES', '10')) + self.code_length = 6 + + # Verification code attempt limits (anti-brute-force) + self.code_max_attempts = int(os.getenv('VERIFICATION_CODE_MAX_ATTEMPTS', '5')) + self.code_lock_minutes = int(os.getenv('VERIFICATION_CODE_LOCK_MINUTES', '30')) + + # Check if email is properly configured + self.email_enabled = bool(self.smtp_host and self.smtp_user and self.smtp_password) + + if not self.email_enabled: + logger.warning("Email service is not configured. SMTP settings are missing.") + + def is_configured(self) -> bool: + """Check if email service is properly configured""" + return self.email_enabled + + # ========================================================================= + # Verification Code Generation & Storage + # ========================================================================= + + def generate_code(self) -> str: + """Generate a random numeric verification code""" + return ''.join(random.choices(string.digits, k=self.code_length)) + + def create_verification_code(self, email: str, code_type: str, + ip_address: str = None) -> Tuple[bool, str]: + """ + Create and store a new verification code. + + Args: + email: Email address + code_type: Type of verification (register, reset_password, change_password, change_email) + ip_address: Requester's IP address + + Returns: + (success, code_or_message) + """ + try: + code = self.generate_code() + expires_at = datetime.now() + timedelta(minutes=self.code_expire_minutes) + + with get_db_connection() as db: + cur = db.cursor() + + # Invalidate any existing unused codes of the same type for this email + cur.execute( + """ + UPDATE qd_verification_codes + SET used_at = NOW() + WHERE email = ? AND type = ? AND used_at IS NULL + """, + (email, code_type) + ) + + # Insert new code + cur.execute( + """ + INSERT INTO qd_verification_codes + (email, code, type, expires_at, ip_address) + VALUES (?, ?, ?, ?, ?) + """, + (email, code, code_type, expires_at, ip_address) + ) + db.commit() + cur.close() + + return True, code + + except Exception as e: + logger.error(f"Failed to create verification code: {e}") + return False, 'Failed to generate verification code' + + def verify_code(self, email: str, code: str, code_type: str) -> Tuple[bool, str]: + """ + Verify a submitted code with brute-force protection. + + Args: + email: Email address + code: The code to verify + code_type: Type of verification + + Returns: + (valid, message) + """ + try: + with get_db_connection() as db: + cur = db.cursor() + + # Check if locked due to too many failed attempts + lock_window = datetime.now() - timedelta(minutes=self.code_lock_minutes) + cur.execute( + """ + SELECT COUNT(*) as cnt FROM qd_verification_codes + WHERE email = ? AND type = ? + AND attempts >= ? AND last_attempt_at > ? + AND used_at IS NULL + """, + (email, code_type, self.code_max_attempts, lock_window.isoformat()) + ) + lock_row = cur.fetchone() + if lock_row and lock_row['cnt'] > 0: + cur.close() + return False, f'Too many failed attempts. Please try again in {self.code_lock_minutes} minutes' + + # Find latest unused code for this email/type + cur.execute( + """ + SELECT id, code as stored_code, expires_at, attempts FROM qd_verification_codes + WHERE email = ? AND type = ? AND used_at IS NULL + ORDER BY created_at DESC + LIMIT 1 + """, + (email, code_type) + ) + row = cur.fetchone() + + if not row: + cur.close() + return False, 'Invalid verification code' + + code_id = row['id'] + stored_code = row['stored_code'] + attempts = row['attempts'] or 0 + + # Check if code matches + if stored_code != code: + # Increment attempt counter + new_attempts = attempts + 1 + cur.execute( + """ + UPDATE qd_verification_codes + SET attempts = ?, last_attempt_at = NOW() + WHERE id = ? + """, + (new_attempts, code_id) + ) + db.commit() + cur.close() + + remaining = self.code_max_attempts - new_attempts + if remaining <= 0: + return False, f'Too many failed attempts. Please try again in {self.code_lock_minutes} minutes' + return False, f'Invalid verification code. {remaining} attempts remaining' + + # Check expiration + expires_at = row['expires_at'] + if isinstance(expires_at, str): + expires_at = datetime.fromisoformat(expires_at) + + if datetime.now() > expires_at: + cur.close() + return False, 'Verification code has expired' + + # Mark as used + cur.execute( + "UPDATE qd_verification_codes SET used_at = NOW() WHERE id = ?", + (code_id,) + ) + db.commit() + cur.close() + + return True, 'verified' + + except Exception as e: + logger.error(f"Failed to verify code: {e}") + return False, 'Verification failed' + + # ========================================================================= + # Email Sending + # ========================================================================= + + def send_email(self, to_email: str, subject: str, html_body: str) -> Tuple[bool, str]: + """ + Send an email. + + Args: + to_email: Recipient email address + subject: Email subject + html_body: HTML body content + + Returns: + (success, message) + """ + if not self.email_enabled: + logger.warning(f"Email not sent (service disabled): {subject} to {to_email}") + return False, 'Email service is not configured' + + try: + msg = MIMEMultipart('alternative') + msg['Subject'] = subject + msg['From'] = self.smtp_from + msg['To'] = to_email + + # Plain text version (fallback) + text_body = html_body.replace('
', '\n').replace('
', '\n') + # Simple HTML tag removal for plain text + import re + text_body = re.sub('<[^<]+?>', '', text_body) + + part1 = MIMEText(text_body, 'plain', 'utf-8') + part2 = MIMEText(html_body, 'html', 'utf-8') + + msg.attach(part1) + msg.attach(part2) + + # Connect and send + if self.smtp_use_ssl: + server = smtplib.SMTP_SSL(self.smtp_host, self.smtp_port) + else: + server = smtplib.SMTP(self.smtp_host, self.smtp_port) + if self.smtp_use_tls: + server.starttls() + + server.login(self.smtp_user, self.smtp_password) + server.sendmail(self.smtp_from, to_email, msg.as_string()) + server.quit() + + logger.info(f"Email sent successfully: {subject} to {to_email}") + return True, 'sent' + + except smtplib.SMTPAuthenticationError as e: + logger.error(f"SMTP authentication failed: {e}") + return False, 'Email authentication failed' + except smtplib.SMTPException as e: + logger.error(f"SMTP error: {e}") + return False, 'Failed to send email' + except Exception as e: + logger.error(f"Email error: {e}") + return False, 'Failed to send email' + + def send_verification_code(self, email: str, code_type: str, + ip_address: str = None) -> Tuple[bool, str]: + """ + Generate and send a verification code email. + + Args: + email: Recipient email address + code_type: Type of verification (register, reset_password, change_password) + ip_address: Requester's IP address + + Returns: + (success, message) + """ + # Generate code + success, code_or_msg = self.create_verification_code(email, code_type, ip_address) + if not success: + return False, code_or_msg + + code = code_or_msg + + # Prepare email content based on type + if code_type == 'register': + subject = 'QuantDinger - Verification Code for Registration' + action_text = 'complete your registration' + elif code_type == 'login': + subject = 'QuantDinger - Quick Login Verification Code' + action_text = 'log in to your account' + elif code_type == 'reset_password': + subject = 'QuantDinger - Password Reset Verification Code' + action_text = 'reset your password' + elif code_type == 'change_password': + subject = 'QuantDinger - Verification Code for Password Change' + action_text = 'change your password' + elif code_type == 'change_email': + subject = 'QuantDinger - Verification Code for Email Change' + action_text = 'change your email address' + else: + subject = 'QuantDinger - Verification Code' + action_text = 'complete the verification' + + html_body = f""" +
+
+

QuantDinger

+

AI-Driven Quantitative Insights

+
+ +
+

+ Your verification code to {action_text} is: +

+
+ {code} +
+

+ This code will expire in {self.code_expire_minutes} minutes. +

+
+ +
+

+ Security Notice: If you did not request this code, + please ignore this email. Do not share this code with anyone. +

+
+ +
+

© QuantDinger. All rights reserved.

+
+
+ """ + + # Send email + return self.send_email(email, subject, html_body) + + # ========================================================================= + # Email Validation + # ========================================================================= + + @staticmethod + def is_valid_email(email: str) -> bool: + """Basic email format validation""" + import re + pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$' + return bool(re.match(pattern, email)) diff --git a/backend_api_python/app/services/live_trading/records.py b/backend_api_python/app/services/live_trading/records.py index d5b773696..3adb76d7f 100644 --- a/backend_api_python/app/services/live_trading/records.py +++ b/backend_api_python/app/services/live_trading/records.py @@ -14,6 +14,19 @@ from app.utils.db import get_db_connection +def _get_user_id_from_strategy(strategy_id: int) -> int: + """Get user_id from strategy table. Defaults to 1 if not found.""" + try: + with get_db_connection() as db: + cur = db.cursor() + cur.execute("SELECT user_id FROM qd_strategies_trading WHERE id = %s", (strategy_id,)) + row = cur.fetchone() + cur.close() + return int((row or {}).get('user_id') or 1) + except Exception: + return 1 + + def record_trade( *, strategy_id: int, @@ -24,19 +37,22 @@ def record_trade( commission: float = 0.0, commission_ccy: str = "", profit: Optional[float] = None, + user_id: int = None, ) -> None: - now = int(time.time()) value = float(amount or 0.0) * float(price or 0.0) + if user_id is None: + user_id = _get_user_id_from_strategy(strategy_id) with get_db_connection() as db: cur = db.cursor() cur.execute( """ INSERT INTO qd_strategy_trades - (strategy_id, symbol, type, price, amount, value, commission, commission_ccy, profit, created_at) + (user_id, strategy_id, symbol, type, price, amount, value, commission, commission_ccy, profit, created_at) VALUES - (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s) + (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, NOW()) """, ( + int(user_id), int(strategy_id), str(symbol), str(trade_type), @@ -46,7 +62,6 @@ def record_trade( float(commission or 0.0), str(commission_ccy or ""), profit, - now, ), ) db.commit() @@ -86,25 +101,27 @@ def upsert_position( current_price: float, highest_price: float = 0.0, lowest_price: float = 0.0, + user_id: int = None, ) -> None: - now = int(time.time()) + if user_id is None: + user_id = _get_user_id_from_strategy(strategy_id) with get_db_connection() as db: cur = db.cursor() cur.execute( """ INSERT INTO qd_strategy_positions - (strategy_id, symbol, side, size, entry_price, current_price, highest_price, lowest_price, updated_at) + (user_id, strategy_id, symbol, side, size, entry_price, current_price, highest_price, lowest_price, updated_at) VALUES - (%s, %s, %s, %s, %s, %s, %s, %s, %s) + (%s, %s, %s, %s, %s, %s, %s, %s, %s, NOW()) ON CONFLICT(strategy_id, symbol, side) DO UPDATE SET size = excluded.size, entry_price = excluded.entry_price, current_price = excluded.current_price, - highest_price = CASE WHEN excluded.highest_price > 0 THEN excluded.highest_price ELSE highest_price END, - lowest_price = CASE WHEN excluded.lowest_price > 0 THEN excluded.lowest_price ELSE lowest_price END, - updated_at = excluded.updated_at + highest_price = CASE WHEN excluded.highest_price > 0 THEN excluded.highest_price ELSE qd_strategy_positions.highest_price END, + lowest_price = CASE WHEN excluded.lowest_price > 0 THEN excluded.lowest_price ELSE qd_strategy_positions.lowest_price END, + updated_at = NOW() """, - (int(strategy_id), str(symbol), str(side), float(size or 0.0), float(entry_price or 0.0), float(current_price or 0.0), float(highest_price or 0.0), float(lowest_price or 0.0), now), + (int(user_id), int(strategy_id), str(symbol), str(side), float(size or 0.0), float(entry_price or 0.0), float(current_price or 0.0), float(highest_price or 0.0), float(lowest_price or 0.0)), ) db.commit() cur.close() diff --git a/backend_api_python/app/services/oauth_service.py b/backend_api_python/app/services/oauth_service.py new file mode 100644 index 000000000..931c4eee8 --- /dev/null +++ b/backend_api_python/app/services/oauth_service.py @@ -0,0 +1,535 @@ +""" +OAuth Service - Handles Google and GitHub OAuth authentication. +""" +import os +import secrets +import requests +from urllib.parse import urlencode +from datetime import datetime +from typing import Tuple, Optional, Dict, Any +from app.utils.db import get_db_connection +from app.utils.logger import get_logger + +logger = get_logger(__name__) + +# Singleton instance +_oauth_service = None + + +def get_oauth_service(): + """Get singleton OAuthService instance""" + global _oauth_service + if _oauth_service is None: + _oauth_service = OAuthService() + return _oauth_service + + +class OAuthService: + """OAuth service for Google and GitHub authentication""" + + def __init__(self): + self._load_config() + + def _load_config(self): + """Load OAuth configuration from environment variables""" + # Google OAuth + self.google_client_id = os.getenv('GOOGLE_CLIENT_ID', '') + self.google_client_secret = os.getenv('GOOGLE_CLIENT_SECRET', '') + self.google_redirect_uri = os.getenv('GOOGLE_REDIRECT_URI', '') + self.google_enabled = bool(self.google_client_id and self.google_client_secret) + + # GitHub OAuth + self.github_client_id = os.getenv('GITHUB_CLIENT_ID', '') + self.github_client_secret = os.getenv('GITHUB_CLIENT_SECRET', '') + self.github_redirect_uri = os.getenv('GITHUB_REDIRECT_URI', '') + self.github_enabled = bool(self.github_client_id and self.github_client_secret) + + # Frontend URL for redirect after OAuth + self.frontend_url = os.getenv('FRONTEND_URL', 'http://localhost:8080') + + # State storage (in-memory for simplicity, could use Redis in production) + self._states = {} + + # ========================================================================= + # Google OAuth + # ========================================================================= + + def get_google_auth_url(self, state: str = None) -> Tuple[str, str]: + """ + Generate Google OAuth authorization URL. + + Returns: + (auth_url, state) + """ + if not self.google_enabled: + return '', '' + + state = state or secrets.token_urlsafe(32) + self._states[state] = {'provider': 'google', 'created_at': datetime.now()} + + params = { + 'client_id': self.google_client_id, + 'redirect_uri': self.google_redirect_uri, + 'response_type': 'code', + 'scope': 'openid email profile', + 'state': state, + 'access_type': 'offline', + 'prompt': 'select_account' + } + + auth_url = f"https://accounts.google.com/o/oauth2/v2/auth?{urlencode(params)}" + return auth_url, state + + def handle_google_callback(self, code: str, state: str) -> Tuple[bool, Dict[str, Any]]: + """ + Handle Google OAuth callback. + + Args: + code: Authorization code from Google + state: State parameter for CSRF protection + + Returns: + (success, user_info_or_error) + """ + # Validate state + if state not in self._states or self._states[state].get('provider') != 'google': + return False, {'error': 'Invalid state parameter'} + + del self._states[state] + + try: + # Exchange code for tokens + token_response = requests.post( + 'https://oauth2.googleapis.com/token', + data={ + 'code': code, + 'client_id': self.google_client_id, + 'client_secret': self.google_client_secret, + 'redirect_uri': self.google_redirect_uri, + 'grant_type': 'authorization_code' + }, + timeout=10 + ) + + if token_response.status_code != 200: + logger.error(f"Google token exchange failed: {token_response.text}") + return False, {'error': 'Failed to exchange authorization code'} + + tokens = token_response.json() + access_token = tokens.get('access_token') + + # Get user info + user_response = requests.get( + 'https://www.googleapis.com/oauth2/v2/userinfo', + headers={'Authorization': f'Bearer {access_token}'}, + timeout=10 + ) + + if user_response.status_code != 200: + logger.error(f"Google user info failed: {user_response.text}") + return False, {'error': 'Failed to get user information'} + + user_info = user_response.json() + + return True, { + 'provider': 'google', + 'provider_user_id': user_info.get('id'), + 'email': user_info.get('email'), + 'name': user_info.get('name'), + 'avatar': user_info.get('picture'), + 'access_token': access_token, + 'refresh_token': tokens.get('refresh_token') + } + + except requests.RequestException as e: + logger.error(f"Google OAuth error: {e}") + return False, {'error': 'OAuth service unavailable'} + + # ========================================================================= + # GitHub OAuth + # ========================================================================= + + def get_github_auth_url(self, state: str = None) -> Tuple[str, str]: + """ + Generate GitHub OAuth authorization URL. + + Returns: + (auth_url, state) + """ + if not self.github_enabled: + return '', '' + + state = state or secrets.token_urlsafe(32) + self._states[state] = {'provider': 'github', 'created_at': datetime.now()} + + params = { + 'client_id': self.github_client_id, + 'redirect_uri': self.github_redirect_uri, + 'scope': 'user:email read:user', + 'state': state + } + + auth_url = f"https://github.com/login/oauth/authorize?{urlencode(params)}" + return auth_url, state + + def handle_github_callback(self, code: str, state: str) -> Tuple[bool, Dict[str, Any]]: + """ + Handle GitHub OAuth callback. + + Args: + code: Authorization code from GitHub + state: State parameter for CSRF protection + + Returns: + (success, user_info_or_error) + """ + # Validate state + if state not in self._states or self._states[state].get('provider') != 'github': + return False, {'error': 'Invalid state parameter'} + + del self._states[state] + + try: + # Exchange code for token + token_response = requests.post( + 'https://github.com/login/oauth/access_token', + data={ + 'client_id': self.github_client_id, + 'client_secret': self.github_client_secret, + 'code': code, + 'redirect_uri': self.github_redirect_uri + }, + headers={'Accept': 'application/json'}, + timeout=10 + ) + + if token_response.status_code != 200: + logger.error(f"GitHub token exchange failed: {token_response.text}") + return False, {'error': 'Failed to exchange authorization code'} + + tokens = token_response.json() + access_token = tokens.get('access_token') + + if not access_token: + error = tokens.get('error_description', 'Unknown error') + logger.error(f"GitHub token error: {error}") + return False, {'error': error} + + # Get user info + user_response = requests.get( + 'https://api.github.com/user', + headers={ + 'Authorization': f'Bearer {access_token}', + 'Accept': 'application/vnd.github.v3+json' + }, + timeout=10 + ) + + if user_response.status_code != 200: + logger.error(f"GitHub user info failed: {user_response.text}") + return False, {'error': 'Failed to get user information'} + + user_info = user_response.json() + + # Get user email (might be private) + email = user_info.get('email') + if not email: + email_response = requests.get( + 'https://api.github.com/user/emails', + headers={ + 'Authorization': f'Bearer {access_token}', + 'Accept': 'application/vnd.github.v3+json' + }, + timeout=10 + ) + if email_response.status_code == 200: + emails = email_response.json() + # Find primary email + for e in emails: + if e.get('primary') and e.get('verified'): + email = e.get('email') + break + # Fallback to any verified email + if not email: + for e in emails: + if e.get('verified'): + email = e.get('email') + break + + return True, { + 'provider': 'github', + 'provider_user_id': str(user_info.get('id')), + 'email': email, + 'name': user_info.get('name') or user_info.get('login'), + 'avatar': user_info.get('avatar_url'), + 'access_token': access_token, + 'refresh_token': None # GitHub doesn't use refresh tokens + } + + except requests.RequestException as e: + logger.error(f"GitHub OAuth error: {e}") + return False, {'error': 'OAuth service unavailable'} + + # ========================================================================= + # OAuth Link Management + # ========================================================================= + + def get_or_create_user_from_oauth(self, oauth_info: Dict[str, Any]) -> Tuple[bool, Dict[str, Any]]: + """ + Get existing user or create new user from OAuth info. + + Args: + oauth_info: Dict with provider, provider_user_id, email, name, avatar, tokens + + Returns: + (success, user_or_error) + """ + provider = oauth_info['provider'] + provider_user_id = oauth_info['provider_user_id'] + email = oauth_info.get('email') + name = oauth_info.get('name', '') + avatar = oauth_info.get('avatar', '/avatar2.jpg') + + try: + with get_db_connection() as db: + cur = db.cursor() + + # Check if OAuth link exists + cur.execute( + """ + SELECT user_id FROM qd_oauth_links + WHERE provider = ? AND provider_user_id = ? + """, + (provider, provider_user_id) + ) + link = cur.fetchone() + + if link: + # Existing OAuth link - get user + user_id = link['user_id'] + cur.execute( + """ + SELECT id, username, email, nickname, avatar, status, role + FROM qd_users WHERE id = ? + """, + (user_id,) + ) + user = cur.fetchone() + + if user: + # Update OAuth tokens + cur.execute( + """ + UPDATE qd_oauth_links + SET access_token = ?, refresh_token = ?, updated_at = NOW() + WHERE provider = ? AND provider_user_id = ? + """, + (oauth_info.get('access_token'), oauth_info.get('refresh_token'), + provider, provider_user_id) + ) + + # Update last login + cur.execute( + "UPDATE qd_users SET last_login_at = NOW() WHERE id = ?", + (user_id,) + ) + db.commit() + cur.close() + return True, dict(user) + else: + # Orphaned OAuth link - remove it + cur.execute( + "DELETE FROM qd_oauth_links WHERE provider = ? AND provider_user_id = ?", + (provider, provider_user_id) + ) + db.commit() + + # Check if user exists with same email + if email: + cur.execute( + """ + SELECT id, username, email, nickname, avatar, status, role + FROM qd_users WHERE email = ? + """, + (email,) + ) + existing_user = cur.fetchone() + + if existing_user: + # Link OAuth to existing user + cur.execute( + """ + INSERT INTO qd_oauth_links + (user_id, provider, provider_user_id, provider_email, + provider_name, provider_avatar, access_token, refresh_token) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + """, + (existing_user['id'], provider, provider_user_id, email, + name, avatar, oauth_info.get('access_token'), + oauth_info.get('refresh_token')) + ) + cur.execute( + "UPDATE qd_users SET last_login_at = NOW() WHERE id = ?", + (existing_user['id'],) + ) + db.commit() + cur.close() + return True, dict(existing_user) + + # Create new user + # Generate unique username from OAuth name or email + base_username = (name or email.split('@')[0] if email else provider_user_id) + base_username = ''.join(c for c in base_username if c.isalnum() or c in '_-')[:30] + username = base_username + + # Ensure username is unique + counter = 1 + while True: + cur.execute("SELECT id FROM qd_users WHERE username = ?", (username,)) + if not cur.fetchone(): + break + username = f"{base_username}_{counter}" + counter += 1 + + # Generate a random password (user won't need it for OAuth login) + import secrets + random_password = secrets.token_urlsafe(32) + from app.services.user_service import get_user_service + password_hash = get_user_service().hash_password(random_password) + + # Ensure email is unique or generate placeholder + if email: + cur.execute("SELECT id FROM qd_users WHERE email = ?", (email,)) + if cur.fetchone(): + email = f"{provider}_{provider_user_id}@oauth.local" + else: + email = f"{provider}_{provider_user_id}@oauth.local" + + # Insert new user + cur.execute( + """ + INSERT INTO qd_users + (username, password_hash, email, nickname, avatar, status, role, email_verified) + VALUES (?, ?, ?, ?, ?, 'active', 'user', TRUE) + """, + (username, password_hash, email, name or username, avatar or '/avatar2.jpg') + ) + user_id = cur.lastrowid + + # Create OAuth link + cur.execute( + """ + INSERT INTO qd_oauth_links + (user_id, provider, provider_user_id, provider_email, + provider_name, provider_avatar, access_token, refresh_token) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + """, + (user_id, provider, provider_user_id, oauth_info.get('email'), + name, avatar, oauth_info.get('access_token'), + oauth_info.get('refresh_token')) + ) + + db.commit() + cur.close() + + # Grant registration bonus credits for OAuth-created users + # Keep consistent with email/password registration flows (auth.py). + try: + register_bonus = int(os.getenv('CREDITS_REGISTER_BONUS', '0')) + except (ValueError, TypeError): + register_bonus = 0 + if register_bonus > 0: + try: + from app.services.billing_service import get_billing_service + get_billing_service().add_credits( + user_id=user_id, + amount=register_bonus, + action='register_bonus', + remark=f'Registration bonus (OAuth:{provider})' + ) + except Exception as e: + logger.warning(f"Failed to grant OAuth registration bonus: {e}") + + return True, { + 'id': user_id, + 'username': username, + 'email': email, + 'nickname': name or username, + 'avatar': avatar or '/avatar2.jpg', + 'status': 'active', + 'role': 'user' + } + + except Exception as e: + logger.error(f"OAuth user creation failed: {e}") + return False, {'error': 'Failed to create user account'} + + def get_user_oauth_links(self, user_id: int) -> list: + """Get all OAuth links for a user""" + try: + with get_db_connection() as db: + cur = db.cursor() + cur.execute( + """ + SELECT provider, provider_email, provider_name, created_at + FROM qd_oauth_links WHERE user_id = ? + """, + (user_id,) + ) + links = cur.fetchall() + cur.close() + return [dict(link) for link in links] if links else [] + except Exception as e: + logger.error(f"Failed to get OAuth links: {e}") + return [] + + def unlink_oauth(self, user_id: int, provider: str) -> Tuple[bool, str]: + """Unlink an OAuth provider from user account""" + try: + with get_db_connection() as db: + cur = db.cursor() + + # Check if user has password (can't unlink last auth method) + cur.execute( + "SELECT password_hash FROM qd_users WHERE id = ?", + (user_id,) + ) + user = cur.fetchone() + + if not user or not user['password_hash']: + # Check if this is the only OAuth link + cur.execute( + "SELECT COUNT(*) as count FROM qd_oauth_links WHERE user_id = ?", + (user_id,) + ) + count = cur.fetchone()['count'] + if count <= 1: + cur.close() + return False, 'Cannot unlink the only authentication method' + + cur.execute( + "DELETE FROM qd_oauth_links WHERE user_id = ? AND provider = ?", + (user_id, provider) + ) + db.commit() + cur.close() + return True, 'unlinked' + + except Exception as e: + logger.error(f"Failed to unlink OAuth: {e}") + return False, 'Failed to unlink account' + + # ========================================================================= + # Cleanup + # ========================================================================= + + def cleanup_expired_states(self, max_age_minutes: int = 10): + """Clean up expired OAuth states""" + cutoff = datetime.now() + from datetime import timedelta + cutoff = cutoff - timedelta(minutes=max_age_minutes) + + expired = [k for k, v in self._states.items() + if v.get('created_at', datetime.now()) < cutoff] + for k in expired: + del self._states[k] diff --git a/backend_api_python/app/services/pending_order_worker.py b/backend_api_python/app/services/pending_order_worker.py index c31d8de45..5584d1975 100644 --- a/backend_api_python/app/services/pending_order_worker.py +++ b/backend_api_python/app/services/pending_order_worker.py @@ -417,9 +417,8 @@ def _sync_positions_best_effort(self) -> None: cur = db.cursor() for rid in to_delete_ids: cur.execute("DELETE FROM qd_strategy_positions WHERE id = %s", (int(rid),)) - now_ts = int(time.time()) for u in to_update: - cur.execute("UPDATE qd_strategy_positions SET size = %s, updated_at = %s WHERE id = %s", (float(u["size"]), now_ts, int(u["id"]))) + cur.execute("UPDATE qd_strategy_positions SET size = %s, updated_at = NOW() WHERE id = %s", (float(u["size"]), int(u["id"]))) db.commit() cur.close() @@ -436,24 +435,22 @@ def _fetch_pending_orders(self, limit: int = 50) -> List[Dict[str, Any]]: except Exception: stale_sec = 0 if stale_sec > 0: - now = int(time.time()) - cutoff = now - stale_sec with get_db_connection() as db: cur = db.cursor() cur.execute( """ UPDATE pending_orders SET status = 'pending', - updated_at = %s, + updated_at = NOW(), dispatch_note = CASE WHEN dispatch_note IS NULL OR dispatch_note = '' THEN 'requeued_stale_processing' ELSE dispatch_note END WHERE status = 'processing' - AND (updated_at IS NULL OR updated_at < %s) + AND (updated_at IS NULL OR updated_at < NOW() - INTERVAL '%s seconds') AND (attempts < max_attempts) """, - (now, cutoff), + (stale_sec,), ) db.commit() cur.close() @@ -480,7 +477,6 @@ def _fetch_pending_orders(self, limit: int = 50) -> List[Dict[str, Any]]: def _mark_processing(self, order_id: int) -> bool: try: - now = int(time.time()) with get_db_connection() as db: cur = db.cursor() # Only claim if still pending to avoid double-processing. @@ -489,11 +485,11 @@ def _mark_processing(self, order_id: int) -> bool: UPDATE pending_orders SET status = 'processing', attempts = COALESCE(attempts, 0) + 1, - processed_at = %s, - updated_at = %s + processed_at = NOW(), + updated_at = NOW() WHERE id = %s AND status = 'pending' """, - (now, now, int(order_id)), + (int(order_id),), ) claimed = getattr(cur, "rowcount", None) db.commit() @@ -1925,36 +1921,34 @@ def _mark_sent( avg_price: float = 0.0, executed_at: Optional[int] = None, ) -> None: - now = int(time.time()) with get_db_connection() as db: cur = db.cursor() + # Use NOW() for timestamp fields; executed_at is set to NOW() if provided, else NULL cur.execute( """ UPDATE pending_orders SET status = 'sent', last_error = %s, dispatch_note = %s, - sent_at = %s, - executed_at = %s, + sent_at = NOW(), + executed_at = CASE WHEN %s THEN NOW() ELSE NULL END, exchange_id = %s, exchange_order_id = %s, exchange_response_json = %s, filled = %s, avg_price = %s, - updated_at = %s + updated_at = NOW() WHERE id = %s """, ( "", str(note or ""), - now, - int(executed_at) if executed_at is not None else None, + executed_at is not None, # Boolean flag for CASE WHEN str(exchange_id or ""), str(exchange_order_id or ""), str(exchange_response_json or ""), float(filled or 0.0), float(avg_price or 0.0), - now, int(order_id), ), ) @@ -1962,7 +1956,6 @@ def _mark_sent( cur.close() def _mark_failed(self, order_id: int, error: str) -> None: - now = int(time.time()) with get_db_connection() as db: cur = db.cursor() cur.execute( @@ -1970,16 +1963,15 @@ def _mark_failed(self, order_id: int, error: str) -> None: UPDATE pending_orders SET status = 'failed', last_error = %s, - updated_at = %s + updated_at = NOW() WHERE id = %s """, - (str(error or "failed"), now, int(order_id)), + (str(error or "failed"), int(order_id)), ) db.commit() cur.close() def _mark_deferred(self, order_id: int, reason: str) -> None: - now = int(time.time()) with get_db_connection() as db: cur = db.cursor() cur.execute( @@ -1987,10 +1979,10 @@ def _mark_deferred(self, order_id: int, reason: str) -> None: UPDATE pending_orders SET status = 'deferred', last_error = %s, - updated_at = %s + updated_at = NOW() WHERE id = %s """, - (str(reason or "deferred"), now, int(order_id)), + (str(reason or "deferred"), int(order_id)), ) db.commit() cur.close() diff --git a/backend_api_python/app/services/portfolio_monitor.py b/backend_api_python/app/services/portfolio_monitor.py index 05b798948..0b63334a6 100644 --- a/backend_api_python/app/services/portfolio_monitor.py +++ b/backend_api_python/app/services/portfolio_monitor.py @@ -79,10 +79,11 @@ def _safe_json_loads(value, default=None): return default -def _get_positions_for_monitor(position_ids: List[int] = None) -> List[Dict[str, Any]]: - """Get positions, optionally filtered by IDs.""" +def _get_positions_for_monitor(position_ids: List[int] = None, user_id: int = None) -> List[Dict[str, Any]]: + """Get positions, optionally filtered by IDs and user_id.""" try: kline_service = KlineService() + effective_user_id = user_id if user_id is not None else DEFAULT_USER_ID with get_db_connection() as db: cur = db.cursor() @@ -94,7 +95,7 @@ def _get_positions_for_monitor(position_ids: List[int] = None) -> List[Dict[str, FROM qd_manual_positions WHERE user_id = ? AND id IN ({placeholders}) """, - [DEFAULT_USER_ID] + list(position_ids) + [effective_user_id] + list(position_ids) ) else: cur.execute( @@ -103,7 +104,7 @@ def _get_positions_for_monitor(position_ids: List[int] = None) -> List[Dict[str, FROM qd_manual_positions WHERE user_id = ? """, - (DEFAULT_USER_ID,) + (effective_user_id,) ) rows = cur.fetchall() or [] cur.close() @@ -726,15 +727,17 @@ def _send_monitor_notification( positions: List[Dict[str, Any]] = None, position_analyses: List[Dict[str, Any]] = None, language: str = 'en-US', - custom_prompt: str = '' + custom_prompt: str = '', + user_id: int = None ) -> None: """Send notification with analysis result using appropriate format for each channel.""" try: notifier = SignalNotifier() - + effective_user_id = user_id if user_id is not None else DEFAULT_USER_ID + channels = notification_config.get('channels', ['browser']) targets = notification_config.get('targets', {}) - + title = f"📊 资产监测: {monitor_name}" if language.startswith('zh') else f"📊 Portfolio Monitor: {monitor_name}" if not result.get('success'): @@ -745,17 +748,16 @@ def _send_monitor_notification( try: ch = str(channel).strip().lower() if ch == 'browser': - now = _now_ts() with get_db_connection() as db: cur = db.cursor() cur.execute( """ INSERT INTO qd_strategy_notifications - (strategy_id, symbol, signal_type, channels, title, message, payload_json, created_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?) + (user_id, strategy_id, symbol, signal_type, channels, title, message, payload_json, created_at) + VALUES (?, NULL, ?, ?, ?, ?, ?, ?, NOW()) """, - (0, 'PORTFOLIO', 'ai_monitor', 'browser', error_title, error_msg, - json.dumps(result, ensure_ascii=False), now) + (effective_user_id, 'PORTFOLIO', 'ai_monitor', 'browser', error_title, error_msg, + json.dumps(result, ensure_ascii=False)) ) db.commit() cur.close() @@ -792,17 +794,16 @@ def _send_monitor_notification( if ch == 'browser': # Browser notification uses HTML report - now = _now_ts() with get_db_connection() as db: cur = db.cursor() cur.execute( """ INSERT INTO qd_strategy_notifications - (strategy_id, symbol, signal_type, channels, title, message, payload_json, created_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?) + (user_id, strategy_id, symbol, signal_type, channels, title, message, payload_json, created_at) + VALUES (?, NULL, ?, ?, ?, ?, ?, ?, NOW()) """, - (0, 'PORTFOLIO', 'ai_monitor', 'browser', title, html_report, - json.dumps(result, ensure_ascii=False), now) + (effective_user_id, 'PORTFOLIO', 'ai_monitor', 'browser', title, html_report, + json.dumps(result, ensure_ascii=False)) ) db.commit() cur.close() @@ -848,24 +849,28 @@ def _send_monitor_notification( logger.error(f"_send_monitor_notification failed: {e}") -def run_single_monitor(monitor_id: int, override_language: str = None) -> Dict[str, Any]: +def run_single_monitor(monitor_id: int, override_language: str = None, user_id: int = None) -> Dict[str, Any]: """Run a single monitor and return the result. Args: monitor_id: The monitor ID to run override_language: Optional language override (e.g., 'zh-CN', 'en-US') If provided, will override the language in monitor config + user_id: Optional user ID for user isolation """ try: + # Use provided user_id or default + effective_user_id = user_id if user_id is not None else DEFAULT_USER_ID + with get_db_connection() as db: cur = db.cursor() cur.execute( """ - SELECT id, name, position_ids, monitor_type, config, notification_config + SELECT id, user_id, name, position_ids, monitor_type, config, notification_config FROM qd_position_monitors WHERE id = ? AND user_id = ? """, - (monitor_id, DEFAULT_USER_ID) + (monitor_id, effective_user_id) ) row = cur.fetchone() cur.close() @@ -873,6 +878,7 @@ def run_single_monitor(monitor_id: int, override_language: str = None) -> Dict[s if not row: return {'success': False, 'error': 'Monitor not found'} + monitor_user_id = int(row.get('user_id') or effective_user_id) name = row.get('name') or f'Monitor #{monitor_id}' position_ids = _safe_json_loads(row.get('position_ids'), []) monitor_type = row.get('monitor_type') or 'ai' @@ -883,8 +889,8 @@ def run_single_monitor(monitor_id: int, override_language: str = None) -> Dict[s if override_language: config['language'] = override_language - # Get positions - positions = _get_positions_for_monitor(position_ids if position_ids else None) + # Get positions for this user + positions = _get_positions_for_monitor(position_ids if position_ids else None, user_id=monitor_user_id) if not positions: return {'success': False, 'error': 'No positions to analyze'} @@ -897,19 +903,21 @@ def run_single_monitor(monitor_id: int, override_language: str = None) -> Dict[s result = {'success': False, 'error': f'Unsupported monitor type: {monitor_type}'} # Update monitor record - now = _now_ts() interval_minutes = int(config.get('interval_minutes') or 60) - next_run_at = now + (interval_minutes * 60) with get_db_connection() as db: cur = db.cursor() cur.execute( """ UPDATE qd_position_monitors - SET last_run_at = ?, next_run_at = ?, last_result = ?, run_count = run_count + 1, updated_at = ? + SET last_run_at = NOW(), + next_run_at = NOW() + INTERVAL '%s minutes', + last_result = ?, + run_count = run_count + 1, + updated_at = NOW() WHERE id = ? """, - (now, next_run_at, json.dumps(result, ensure_ascii=False), now, monitor_id) + (interval_minutes, json.dumps(result, ensure_ascii=False), monitor_id) ) db.commit() cur.close() @@ -926,7 +934,8 @@ def run_single_monitor(monitor_id: int, override_language: str = None) -> Dict[s positions=positions, position_analyses=position_analyses, language=language, - custom_prompt=custom_prompt + custom_prompt=custom_prompt, + user_id=monitor_user_id ) return result @@ -938,24 +947,24 @@ def run_single_monitor(monitor_id: int, override_language: str = None) -> Dict[s def _check_position_alerts(): """Check all active alerts and trigger notifications if conditions are met.""" + from datetime import datetime, timezone try: kline_service = KlineService() notifier = SignalNotifier() - now = _now_ts() + now = datetime.now(timezone.utc) with get_db_connection() as db: cur = db.cursor() - # Get active alerts that haven't been triggered (or can repeat) + # Get active alerts for all users that haven't been triggered (or can repeat) cur.execute( """ - SELECT a.id, a.position_id, a.market, a.symbol, a.alert_type, a.threshold, + SELECT a.id, a.user_id, a.position_id, a.market, a.symbol, a.alert_type, a.threshold, a.notification_config, a.is_triggered, a.last_triggered_at, a.repeat_interval, p.entry_price, p.quantity, p.side, p.name as position_name FROM qd_position_alerts a LEFT JOIN qd_manual_positions p ON a.position_id = p.id - WHERE a.user_id = ? AND a.is_active = 1 - """, - (DEFAULT_USER_ID,) + WHERE a.is_active = 1 + """ ) alerts = cur.fetchall() or [] cur.close() @@ -963,19 +972,24 @@ def _check_position_alerts(): for alert in alerts: try: alert_id = alert.get('id') + alert_user_id = int(alert.get('user_id') or 1) alert_type = alert.get('alert_type') threshold = float(alert.get('threshold') or 0) market = alert.get('market') symbol = alert.get('symbol') is_triggered = bool(alert.get('is_triggered')) - last_triggered_at = alert.get('last_triggered_at') or 0 + last_triggered_at = alert.get('last_triggered_at') # datetime or None repeat_interval = int(alert.get('repeat_interval') or 0) notification_config = _safe_json_loads(alert.get('notification_config'), {}) # Check if we can trigger (not triggered yet, or repeat interval passed) can_trigger = not is_triggered - if is_triggered and repeat_interval > 0: - if now - last_triggered_at >= repeat_interval: + if is_triggered and repeat_interval > 0 and last_triggered_at: + # Convert last_triggered_at to timezone-aware if needed + if last_triggered_at.tzinfo is None: + last_triggered_at = last_triggered_at.replace(tzinfo=timezone.utc) + elapsed_seconds = (now - last_triggered_at).total_seconds() + if elapsed_seconds >= repeat_interval: can_trigger = True if not can_trigger: @@ -1048,10 +1062,10 @@ def _check_position_alerts(): cur.execute( """ UPDATE qd_position_alerts - SET is_triggered = 1, last_triggered_at = ?, trigger_count = trigger_count + 1, updated_at = ? + SET is_triggered = 1, last_triggered_at = NOW(), trigger_count = trigger_count + 1, updated_at = NOW() WHERE id = ? """, - (now, now, alert_id) + (alert_id,) ) db.commit() cur.close() @@ -1070,11 +1084,11 @@ def _check_position_alerts(): cur.execute( """ INSERT INTO qd_strategy_notifications - (strategy_id, symbol, signal_type, channels, title, message, payload_json, created_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?) + (user_id, strategy_id, symbol, signal_type, channels, title, message, payload_json, created_at) + VALUES (?, NULL, ?, ?, ?, ?, ?, ?, NOW()) """, - (0, symbol, 'price_alert', 'browser', alert_title, alert_message, - json.dumps({'alert_id': alert_id, 'alert_type': alert_type}, ensure_ascii=False), now) + (alert_user_id, symbol, 'price_alert', 'browser', alert_title, alert_message, + json.dumps({'alert_id': alert_id, 'alert_type': alert_type}, ensure_ascii=False)) ) db.commit() cur.close() @@ -1096,7 +1110,7 @@ def _check_position_alerts(): logger.error(f"_check_position_alerts failed: {e}") -def notify_strategy_signal_for_positions(market: str, symbol: str, signal_type: str, signal_detail: str): +def notify_strategy_signal_for_positions(market: str, symbol: str, signal_type: str, signal_detail: str, user_id: int = None): """ Called when a strategy signal is triggered. Check if user has manual positions in this symbol and send notification. @@ -1108,14 +1122,25 @@ def notify_strategy_signal_for_positions(market: str, symbol: str, signal_type: with get_db_connection() as db: cur = db.cursor() - cur.execute( - """ - SELECT id, market, symbol, name, side, quantity, entry_price, group_name - FROM qd_manual_positions - WHERE user_id = ? AND symbol = ? - """, - (DEFAULT_USER_ID, symbol) - ) + # Query positions for all users or specific user + if user_id is not None: + cur.execute( + """ + SELECT id, user_id, market, symbol, name, side, quantity, entry_price, group_name + FROM qd_manual_positions + WHERE user_id = ? AND symbol = ? + """, + (user_id, symbol) + ) + else: + cur.execute( + """ + SELECT id, user_id, market, symbol, name, side, quantity, entry_price, group_name + FROM qd_manual_positions + WHERE symbol = ? + """, + (symbol,) + ) positions = cur.fetchall() or [] cur.close() @@ -1127,6 +1152,7 @@ def notify_strategy_signal_for_positions(market: str, symbol: str, signal_type: now = _now_ts() for pos in positions: + pos_user_id = int(pos.get('user_id') or 1) pos_name = pos.get('name') or symbol pos_side = pos.get('side') or 'long' quantity = float(pos.get('quantity') or 0) @@ -1149,11 +1175,11 @@ def notify_strategy_signal_for_positions(market: str, symbol: str, signal_type: cur.execute( """ INSERT INTO qd_strategy_notifications - (strategy_id, symbol, signal_type, channels, title, message, payload_json, created_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?) + (user_id, strategy_id, symbol, signal_type, channels, title, message, payload_json, created_at) + VALUES (?, NULL, ?, ?, ?, ?, ?, ?, NOW()) """, - (0, symbol, 'strategy_linkage', 'browser', title, message, - json.dumps({'signal_type': signal_type}, ensure_ascii=False), now) + (pos_user_id, symbol, 'strategy_linkage', 'browser', title, message, + json.dumps({'signal_type': signal_type}, ensure_ascii=False)) ) db.commit() cur.close() @@ -1170,22 +1196,19 @@ def _monitor_loop(): while not _stop_event.is_set(): try: - now = _now_ts() - - # 1. Check position alerts (price/pnl alerts) + # 1. Check position alerts (price/pnl alerts) for all users _check_position_alerts() - # 2. Find AI monitors that are due + # 2. Find AI monitors that are due for all users with get_db_connection() as db: cur = db.cursor() cur.execute( """ - SELECT id FROM qd_position_monitors - WHERE user_id = ? AND is_active = 1 AND next_run_at <= ? + SELECT id, user_id FROM qd_position_monitors + WHERE is_active = 1 AND next_run_at <= NOW() ORDER BY next_run_at ASC LIMIT 10 - """, - (DEFAULT_USER_ID, now) + """ ) rows = cur.fetchall() or [] cur.close() @@ -1194,10 +1217,11 @@ def _monitor_loop(): if _stop_event.is_set(): break monitor_id = row.get('id') + monitor_user_id = int(row.get('user_id') or 1) if monitor_id: - logger.info(f"Running due monitor #{monitor_id}") + logger.info(f"Running due monitor #{monitor_id} for user #{monitor_user_id}") try: - run_single_monitor(monitor_id) + run_single_monitor(monitor_id, user_id=monitor_user_id) except Exception as e: logger.error(f"Monitor #{monitor_id} execution failed: {e}") except Exception as e: diff --git a/backend_api_python/app/services/security_service.py b/backend_api_python/app/services/security_service.py new file mode 100644 index 000000000..8ac7e9fd8 --- /dev/null +++ b/backend_api_python/app/services/security_service.py @@ -0,0 +1,393 @@ +""" +Security Service - Handles Turnstile verification, rate limiting, and brute-force protection. +""" +import os +import json +import requests +from datetime import datetime, timedelta +from typing import Tuple, Optional, Dict, Any +from app.utils.db import get_db_connection +from app.utils.logger import get_logger + +logger = get_logger(__name__) + +# Singleton instance +_security_service = None + + +def get_security_service(): + """Get singleton SecurityService instance""" + global _security_service + if _security_service is None: + _security_service = SecurityService() + return _security_service + + +class SecurityService: + """Security service for authentication protection""" + + def __init__(self): + self._load_config() + + def _load_config(self): + """Load security configuration from environment variables""" + # Turnstile config + self.turnstile_site_key = os.getenv('TURNSTILE_SITE_KEY', '') + self.turnstile_secret_key = os.getenv('TURNSTILE_SECRET_KEY', '') + self.turnstile_enabled = bool(self.turnstile_site_key and self.turnstile_secret_key) + + # IP rate limit config + self.ip_max_attempts = int(os.getenv('SECURITY_IP_MAX_ATTEMPTS', '10')) + self.ip_window_minutes = int(os.getenv('SECURITY_IP_WINDOW_MINUTES', '5')) + self.ip_block_minutes = int(os.getenv('SECURITY_IP_BLOCK_MINUTES', '15')) + + # Account rate limit config + self.account_max_attempts = int(os.getenv('SECURITY_ACCOUNT_MAX_ATTEMPTS', '5')) + self.account_window_minutes = int(os.getenv('SECURITY_ACCOUNT_WINDOW_MINUTES', '60')) + self.account_block_minutes = int(os.getenv('SECURITY_ACCOUNT_BLOCK_MINUTES', '30')) + + # Verification code rate limit + self.code_rate_limit_seconds = int(os.getenv('VERIFICATION_CODE_RATE_LIMIT', '60')) + self.code_ip_hourly_limit = int(os.getenv('VERIFICATION_CODE_IP_HOURLY_LIMIT', '10')) + + def get_security_config(self) -> Dict[str, Any]: + """Get public security config for frontend""" + return { + 'turnstile_enabled': self.turnstile_enabled, + 'turnstile_site_key': self.turnstile_site_key, + 'registration_enabled': os.getenv('ENABLE_REGISTRATION', 'true').lower() == 'true', + 'oauth_google_enabled': bool(os.getenv('GOOGLE_CLIENT_ID', '')), + 'oauth_github_enabled': bool(os.getenv('GITHUB_CLIENT_ID', '')), + } + + # ========================================================================= + # Turnstile Verification + # ========================================================================= + + def verify_turnstile(self, token: str, ip_address: str = None) -> Tuple[bool, str]: + """ + Verify Cloudflare Turnstile token. + + Returns: + (success, message) + """ + if not self.turnstile_enabled: + # If Turnstile is not configured, skip verification + return True, 'turnstile_disabled' + + if not token: + return False, 'Missing Turnstile token' + + try: + response = requests.post( + 'https://challenges.cloudflare.com/turnstile/v0/siteverify', + data={ + 'secret': self.turnstile_secret_key, + 'response': token, + 'remoteip': ip_address + }, + timeout=10 + ) + result = response.json() + + if result.get('success'): + return True, 'verified' + else: + error_codes = result.get('error-codes', []) + logger.warning(f"Turnstile verification failed: {error_codes}") + return False, 'Turnstile verification failed' + + except requests.RequestException as e: + logger.error(f"Turnstile API error: {e}") + # On API error, we might want to allow (fail-open) or deny (fail-closed) + # For security, we'll deny + return False, 'Turnstile service unavailable' + + # ========================================================================= + # Rate Limiting & Brute-Force Protection + # ========================================================================= + + def record_login_attempt(self, identifier: str, identifier_type: str, + success: bool, ip_address: str = None, + user_agent: str = None) -> bool: + """ + Record a login attempt for rate limiting. + + Args: + identifier: IP address or username + identifier_type: 'ip' or 'account' + success: Whether the attempt was successful + ip_address: Client IP address + user_agent: Client user agent string + """ + try: + with get_db_connection() as db: + cur = db.cursor() + cur.execute( + """ + INSERT INTO qd_login_attempts + (identifier, identifier_type, success, ip_address, user_agent) + VALUES (?, ?, ?, ?, ?) + """, + (identifier, identifier_type, success, ip_address, user_agent) + ) + db.commit() + cur.close() + return True + except Exception as e: + logger.error(f"Failed to record login attempt: {e}") + return False + + def is_blocked(self, identifier: str, identifier_type: str) -> Tuple[bool, int]: + """ + Check if an identifier (IP or account) is blocked due to too many failed attempts. + + Returns: + (is_blocked, remaining_seconds) + """ + try: + if identifier_type == 'ip': + max_attempts = self.ip_max_attempts + window_minutes = self.ip_window_minutes + block_minutes = self.ip_block_minutes + else: # account + max_attempts = self.account_max_attempts + window_minutes = self.account_window_minutes + block_minutes = self.account_block_minutes + + with get_db_connection() as db: + cur = db.cursor() + + # Count failed attempts in the time window + window_start = datetime.now() - timedelta(minutes=window_minutes) + cur.execute( + """ + SELECT COUNT(*) as count, MAX(attempt_time) as last_attempt + FROM qd_login_attempts + WHERE identifier = ? AND identifier_type = ? + AND success = FALSE AND attempt_time > ? + """, + (identifier, identifier_type, window_start) + ) + row = cur.fetchone() + cur.close() + + if not row: + return False, 0 + + failed_count = row['count'] or 0 + last_attempt = row['last_attempt'] + + if failed_count >= max_attempts: + # Check if still in block period + if last_attempt: + block_until = last_attempt + timedelta(minutes=block_minutes) + if datetime.now() < block_until: + remaining = int((block_until - datetime.now()).total_seconds()) + return True, remaining + + return False, 0 + + except Exception as e: + logger.error(f"Failed to check block status: {e}") + return False, 0 + + def check_login_allowed(self, username: str, ip_address: str) -> Tuple[bool, str]: + """ + Check if login is allowed for the given username and IP. + + Returns: + (allowed, message) + """ + # Check IP block + ip_blocked, ip_remaining = self.is_blocked(ip_address, 'ip') + if ip_blocked: + minutes = ip_remaining // 60 + return False, f'Too many failed attempts from this IP. Try again in {minutes + 1} minutes.' + + # Check account block + account_blocked, account_remaining = self.is_blocked(username, 'account') + if account_blocked: + minutes = account_remaining // 60 + return False, f'Account temporarily locked due to too many failed attempts. Try again in {minutes + 1} minutes.' + + return True, 'allowed' + + def clear_login_attempts(self, identifier: str, identifier_type: str) -> bool: + """ + Clear login attempts for an identifier (called after successful login). + """ + try: + with get_db_connection() as db: + cur = db.cursor() + cur.execute( + """ + DELETE FROM qd_login_attempts + WHERE identifier = ? AND identifier_type = ? + """, + (identifier, identifier_type) + ) + db.commit() + cur.close() + return True + except Exception as e: + logger.error(f"Failed to clear login attempts: {e}") + return False + + # ========================================================================= + # Security Audit Logging + # ========================================================================= + + def log_security_event(self, action: str, user_id: int = None, + ip_address: str = None, user_agent: str = None, + details: dict = None) -> bool: + """ + Log a security-related event. + + Args: + action: Event type (login, logout, register, reset_password, etc.) + user_id: User ID if applicable + ip_address: Client IP + user_agent: Client user agent + details: Additional details as dict + """ + try: + details_json = json.dumps(details) if details else None + + with get_db_connection() as db: + cur = db.cursor() + cur.execute( + """ + INSERT INTO qd_security_logs + (user_id, action, ip_address, user_agent, details) + VALUES (?, ?, ?, ?, ?) + """, + (user_id, action, ip_address, user_agent, details_json) + ) + db.commit() + cur.close() + return True + except Exception as e: + logger.error(f"Failed to log security event: {e}") + return False + + # ========================================================================= + # Verification Code Rate Limiting + # ========================================================================= + + def can_send_verification_code(self, email: str, ip_address: str) -> Tuple[bool, str]: + """ + Check if we can send a verification code to this email from this IP. + + Returns: + (allowed, message) + """ + try: + with get_db_connection() as db: + cur = db.cursor() + + # Check email rate limit (one code per minute per email) + rate_limit_time = datetime.now() - timedelta(seconds=self.code_rate_limit_seconds) + cur.execute( + """ + SELECT COUNT(*) as count FROM qd_verification_codes + WHERE email = ? AND created_at > ? + """, + (email, rate_limit_time) + ) + row = cur.fetchone() + if row and row['count'] > 0: + return False, f'Please wait {self.code_rate_limit_seconds} seconds before requesting another code' + + # Check IP hourly limit + hour_ago = datetime.now() - timedelta(hours=1) + cur.execute( + """ + SELECT COUNT(*) as count FROM qd_verification_codes + WHERE ip_address = ? AND created_at > ? + """, + (ip_address, hour_ago) + ) + row = cur.fetchone() + if row and row['count'] >= self.code_ip_hourly_limit: + return False, 'Too many verification code requests from this IP. Try again later.' + + cur.close() + return True, 'allowed' + + except Exception as e: + logger.error(f"Failed to check verification code rate limit: {e}") + return True, 'allowed' # Fail open on DB errors + + # ========================================================================= + # Password Strength Validation + # ========================================================================= + + def validate_password_strength(self, password: str) -> Tuple[bool, str]: + """ + Validate password meets minimum security requirements. + + Requirements: + - At least 8 characters + - Contains at least one uppercase letter + - Contains at least one lowercase letter + - Contains at least one digit + + Returns: + (valid, message) + """ + if len(password) < 8: + return False, 'Password must be at least 8 characters long' + + if not any(c.isupper() for c in password): + return False, 'Password must contain at least one uppercase letter' + + if not any(c.islower() for c in password): + return False, 'Password must contain at least one lowercase letter' + + if not any(c.isdigit() for c in password): + return False, 'Password must contain at least one digit' + + return True, 'valid' + + # ========================================================================= + # Cleanup + # ========================================================================= + + def cleanup_old_records(self, days: int = 7) -> int: + """ + Clean up old login attempts and expired verification codes. + + Returns: + Number of records deleted + """ + deleted = 0 + cutoff = datetime.now() - timedelta(days=days) + + try: + with get_db_connection() as db: + cur = db.cursor() + + # Clean old login attempts + cur.execute( + "DELETE FROM qd_login_attempts WHERE attempt_time < ?", + (cutoff,) + ) + deleted += cur.rowcount or 0 + + # Clean expired verification codes + cur.execute( + "DELETE FROM qd_verification_codes WHERE expires_at < ?", + (cutoff,) + ) + deleted += cur.rowcount or 0 + + db.commit() + cur.close() + + logger.info(f"Security cleanup: deleted {deleted} old records") + return deleted + + except Exception as e: + logger.error(f"Security cleanup failed: {e}") + return 0 diff --git a/backend_api_python/app/services/signal_notifier.py b/backend_api_python/app/services/signal_notifier.py index 23b234ef0..15a3144e0 100644 --- a/backend_api_python/app/services/signal_notifier.py +++ b/backend_api_python/app/services/signal_notifier.py @@ -447,18 +447,31 @@ def _notify_browser( title: str, message: str, payload: Dict[str, Any], + user_id: int = None, ) -> Tuple[bool, str]: try: now = int(time.time()) + # Get user_id from strategy if not provided + if user_id is None: + try: + with get_db_connection() as db: + cur = db.cursor() + cur.execute("SELECT user_id FROM qd_strategies_trading WHERE id = ?", (strategy_id,)) + row = cur.fetchone() + cur.close() + user_id = int((row or {}).get('user_id') or 1) + except Exception: + user_id = 1 with get_db_connection() as db: cur = db.cursor() cur.execute( """ INSERT INTO qd_strategy_notifications - (strategy_id, symbol, signal_type, channels, title, message, payload_json, created_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?) + (user_id, strategy_id, symbol, signal_type, channels, title, message, payload_json, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, NOW()) """, ( + int(user_id), int(strategy_id), str(symbol or ""), str(signal_type or ""), @@ -466,7 +479,6 @@ def _notify_browser( str(title or ""), str(message or ""), json.dumps(payload or {}, ensure_ascii=False), - int(now), ), ) db.commit() diff --git a/backend_api_python/app/services/strategy.py b/backend_api_python/app/services/strategy.py index 153b9d97e..cb60f98ae 100644 --- a/backend_api_python/app/services/strategy.py +++ b/backend_api_python/app/services/strategy.py @@ -427,16 +427,21 @@ def get_strategy_type(self, strategy_id: int) -> str: except Exception: return 'IndicatorStrategy' - def update_strategy_status(self, strategy_id: int, status: str) -> bool: - """Update strategy status.""" + def update_strategy_status(self, strategy_id: int, status: str, user_id: int = None) -> bool: + """Update strategy status. If user_id is provided, verify ownership.""" try: - now = int(time.time()) with get_db_connection() as db: cur = db.cursor() - cur.execute( - "UPDATE qd_strategies_trading SET status = ?, updated_at = ? WHERE id = ?", - (status, now, strategy_id) - ) + if user_id is not None: + cur.execute( + "UPDATE qd_strategies_trading SET status = ?, updated_at = NOW() WHERE id = ? AND user_id = ?", + (status, strategy_id, user_id) + ) + else: + cur.execute( + "UPDATE qd_strategies_trading SET status = ?, updated_at = NOW() WHERE id = ?", + (status, strategy_id) + ) db.commit() cur.close() return True @@ -467,7 +472,7 @@ def _dump_json_or_encrypt(self, obj: Any, encrypt: bool = False) -> str: return json.dumps(obj, ensure_ascii=False) def list_strategies(self, user_id: int = 1) -> List[Dict[str, Any]]: - """List strategies for local single-user.""" + """List strategies for the specified user.""" try: with get_db_connection() as db: cur = db.cursor() @@ -475,8 +480,10 @@ def list_strategies(self, user_id: int = 1) -> List[Dict[str, Any]]: """ SELECT * FROM qd_strategies_trading + WHERE user_id = ? ORDER BY id DESC - """ + """, + (user_id,) ) rows = cur.fetchall() or [] cur.close() @@ -501,11 +508,15 @@ def list_strategies(self, user_id: int = 1) -> List[Dict[str, Any]]: logger.error(f"list_strategies failed: {e}") return [] - def get_strategy(self, strategy_id: int) -> Optional[Dict[str, Any]]: + def get_strategy(self, strategy_id: int, user_id: int = None) -> Optional[Dict[str, Any]]: + """Get strategy by ID. If user_id is provided, verify ownership.""" try: with get_db_connection() as db: cur = db.cursor() - cur.execute("SELECT * FROM qd_strategies_trading WHERE id = ?", (strategy_id,)) + if user_id is not None: + cur.execute("SELECT * FROM qd_strategies_trading WHERE id = ? AND user_id = ?", (strategy_id, user_id)) + else: + cur.execute("SELECT * FROM qd_strategies_trading WHERE id = ?", (strategy_id,)) r = cur.fetchone() cur.close() if not r: @@ -521,11 +532,11 @@ def get_strategy(self, strategy_id: int) -> Optional[Dict[str, Any]]: return None def create_strategy(self, payload: Dict[str, Any]) -> int: - now = int(time.time()) name = (payload.get('strategy_name') or '').strip() if not name: raise ValueError("strategy_name is required") + user_id = payload.get('user_id') or 1 strategy_type = payload.get('strategy_type') or 'IndicatorStrategy' market_category = payload.get('market_category') or 'Crypto' execution_mode = payload.get('execution_mode') or 'signal' @@ -551,14 +562,15 @@ def create_strategy(self, payload: Dict[str, Any]) -> int: cur.execute( """ INSERT INTO qd_strategies_trading - (strategy_name, strategy_type, market_category, execution_mode, notification_config, + (user_id, strategy_name, strategy_type, market_category, execution_mode, notification_config, status, symbol, timeframe, initial_capital, leverage, market_type, exchange_config, indicator_config, trading_config, ai_model_config, decide_interval, strategy_group_id, group_base_name, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NOW(), NOW()) """, ( + user_id, name, strategy_type, market_category, @@ -576,9 +588,7 @@ def create_strategy(self, payload: Dict[str, Any]) -> int: self._dump_json_or_encrypt(payload.get('ai_model_config') or {}, encrypt=False), int(payload.get('decide_interval') or 300), strategy_group_id, - group_base_name, - now, - now + group_base_name ) ) new_id = cur.lastrowid @@ -657,14 +667,14 @@ def batch_create_strategies(self, payload: Dict[str, Any]) -> Dict[str, Any]: 'total_failed': len(failed_symbols) } - def batch_start_strategies(self, strategy_ids: List[int]) -> Dict[str, Any]: - """Batch start strategies""" + def batch_start_strategies(self, strategy_ids: List[int], user_id: int = None) -> Dict[str, Any]: + """Batch start strategies. If user_id is provided, verify ownership.""" success_ids = [] failed_ids = [] for sid in strategy_ids: try: - self.update_strategy_status(sid, 'running') + self.update_strategy_status(sid, 'running', user_id=user_id) success_ids.append(sid) except Exception as e: logger.error(f"Failed to start strategy {sid}: {e}") @@ -676,14 +686,14 @@ def batch_start_strategies(self, strategy_ids: List[int]) -> Dict[str, Any]: 'failed_ids': failed_ids } - def batch_stop_strategies(self, strategy_ids: List[int]) -> Dict[str, Any]: - """Batch stop strategies""" + def batch_stop_strategies(self, strategy_ids: List[int], user_id: int = None) -> Dict[str, Any]: + """Batch stop strategies. If user_id is provided, verify ownership.""" success_ids = [] failed_ids = [] for sid in strategy_ids: try: - self.update_strategy_status(sid, 'stopped') + self.update_strategy_status(sid, 'stopped', user_id=user_id) success_ids.append(sid) except Exception as e: logger.error(f"Failed to stop strategy {sid}: {e}") @@ -695,14 +705,14 @@ def batch_stop_strategies(self, strategy_ids: List[int]) -> Dict[str, Any]: 'failed_ids': failed_ids } - def batch_delete_strategies(self, strategy_ids: List[int]) -> Dict[str, Any]: - """Batch delete strategies""" + def batch_delete_strategies(self, strategy_ids: List[int], user_id: int = None) -> Dict[str, Any]: + """Batch delete strategies. If user_id is provided, verify ownership.""" success_ids = [] failed_ids = [] for sid in strategy_ids: try: - self.delete_strategy(sid) + self.delete_strategy(sid, user_id=user_id) success_ids.append(sid) except Exception as e: logger.error(f"Failed to delete strategy {sid}: {e}") @@ -714,15 +724,21 @@ def batch_delete_strategies(self, strategy_ids: List[int]) -> Dict[str, Any]: 'failed_ids': failed_ids } - def get_strategies_by_group(self, strategy_group_id: str) -> List[Dict[str, Any]]: - """Get all strategies in a group""" + def get_strategies_by_group(self, strategy_group_id: str, user_id: int = None) -> List[Dict[str, Any]]: + """Get all strategies in a group. If user_id is provided, filter by user.""" try: with get_db_connection() as db: cur = db.cursor() - cur.execute( - "SELECT id FROM qd_strategies_trading WHERE strategy_group_id = ?", - (strategy_group_id,) - ) + if user_id is not None: + cur.execute( + "SELECT id FROM qd_strategies_trading WHERE strategy_group_id = ? AND user_id = ?", + (strategy_group_id, user_id) + ) + else: + cur.execute( + "SELECT id FROM qd_strategies_trading WHERE strategy_group_id = ?", + (strategy_group_id,) + ) rows = cur.fetchall() or [] cur.close() return [row['id'] for row in rows] @@ -730,9 +746,8 @@ def get_strategies_by_group(self, strategy_group_id: str) -> List[Dict[str, Any] logger.error(f"get_strategies_by_group failed: {e}") return [] - def update_strategy(self, strategy_id: int, payload: Dict[str, Any]) -> bool: - now = int(time.time()) - existing = self.get_strategy(strategy_id) + def update_strategy(self, strategy_id: int, payload: Dict[str, Any], user_id: int = None) -> bool: + existing = self.get_strategy(strategy_id, user_id=user_id) if not existing: return False @@ -771,7 +786,7 @@ def update_strategy(self, strategy_id: int, payload: Dict[str, Any]) -> bool: indicator_config = ?, trading_config = ?, ai_model_config = ?, - updated_at = ? + updated_at = NOW() WHERE id = ? """, ( @@ -788,7 +803,6 @@ def update_strategy(self, strategy_id: int, payload: Dict[str, Any]) -> bool: self._dump_json_or_encrypt(indicator_config, encrypt=False), self._dump_json_or_encrypt(trading_config, encrypt=False), self._dump_json_or_encrypt(ai_model_config, encrypt=False), - now, strategy_id ) ) @@ -796,11 +810,15 @@ def update_strategy(self, strategy_id: int, payload: Dict[str, Any]) -> bool: cur.close() return True - def delete_strategy(self, strategy_id: int) -> bool: + def delete_strategy(self, strategy_id: int, user_id: int = None) -> bool: + """Delete strategy. If user_id is provided, verify ownership.""" try: with get_db_connection() as db: cur = db.cursor() - cur.execute("DELETE FROM qd_strategies_trading WHERE id = ?", (strategy_id,)) + if user_id is not None: + cur.execute("DELETE FROM qd_strategies_trading WHERE id = ? AND user_id = ?", (strategy_id, user_id)) + else: + cur.execute("DELETE FROM qd_strategies_trading WHERE id = ?", (strategy_id,)) db.commit() cur.close() return True diff --git a/backend_api_python/app/services/trading_executor.py b/backend_api_python/app/services/trading_executor.py index 5aa90291a..db9a042f0 100644 --- a/backend_api_python/app/services/trading_executor.py +++ b/backend_api_python/app/services/trading_executor.py @@ -2191,19 +2191,32 @@ def _persist_browser_notification( title: str, message: str, payload: Optional[Dict[str, Any]] = None, + user_id: int = None, ) -> None: """Best-effort persist notification row for the frontend '通知' panel (browser channel).""" try: now = int(time.time()) + # Get user_id from strategy if not provided + if user_id is None: + try: + with get_db_connection() as db: + cur = db.cursor() + cur.execute("SELECT user_id FROM qd_strategies_trading WHERE id = ?", (strategy_id,)) + row = cur.fetchone() + cur.close() + user_id = int((row or {}).get('user_id') or 1) + except Exception: + user_id = 1 with get_db_connection() as db: cur = db.cursor() cur.execute( """ INSERT INTO qd_strategy_notifications - (strategy_id, symbol, signal_type, channels, title, message, payload_json, created_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?) + (user_id, strategy_id, symbol, signal_type, channels, title, message, payload_json, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, NOW()) """, ( + int(user_id), int(strategy_id), str(symbol or ""), str(signal_type or ""), @@ -2211,7 +2224,6 @@ def _persist_browser_notification( str(title or ""), str(message or ""), json.dumps(payload or {}, ensure_ascii=False), - int(now), ), ) db.commit() @@ -2419,18 +2431,28 @@ def _enqueue_pending_order( # Best-effort only; do not block enqueue on dedup query errors. pass + # Get user_id from strategy + user_id = 1 + try: + cur.execute("SELECT user_id FROM qd_strategies_trading WHERE id = %s", (strategy_id,)) + row = cur.fetchone() + user_id = int((row or {}).get('user_id') or 1) + except Exception: + pass + cur.execute( """ INSERT INTO pending_orders - (strategy_id, symbol, signal_type, signal_ts, market_type, order_type, amount, price, + (user_id, strategy_id, symbol, signal_type, signal_ts, market_type, order_type, amount, price, execution_mode, status, priority, attempts, max_attempts, last_error, payload_json, created_at, updated_at, processed_at, sent_at) VALUES - (%s, %s, %s, %s, %s, %s, %s, %s, + (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, - %s, %s, %s, %s) + NOW(), NOW(), NULL, NULL) """, ( + int(user_id), int(strategy_id), symbol, signal_type, @@ -2446,10 +2468,6 @@ def _enqueue_pending_order( 10, '', json.dumps(payload, ensure_ascii=False), - now, - now, - None, - None, ), ) pending_id = cur.lastrowid @@ -2473,16 +2491,24 @@ def _calculate_current_equity(self, strategy_id: int, initial_capital: float) -> def _record_trade(self, strategy_id: int, symbol: str, type: str, price: float, amount: float, value: float, profit: float = None, commission: float = None): """记录交易到数据库""" try: + # Get user_id from strategy + user_id = 1 with get_db_connection() as db: cursor = db.cursor() + try: + cursor.execute("SELECT user_id FROM qd_strategies_trading WHERE id = %s", (strategy_id,)) + row = cursor.fetchone() + user_id = int((row or {}).get('user_id') or 1) + except Exception: + pass query = """ INSERT INTO qd_strategy_trades ( - strategy_id, symbol, type, price, amount, value, commission, profit, created_at + user_id, strategy_id, symbol, type, price, amount, value, commission, profit, created_at ) VALUES ( - %s, %s, %s, %s, %s, %s, %s, %s, %s + %s, %s, %s, %s, %s, %s, %s, %s, %s, NOW() ) """ - cursor.execute(query, (strategy_id, symbol, type, price, amount, value, commission or 0, profit, int(time.time()))) + cursor.execute(query, (user_id, strategy_id, symbol, type, price, amount, value, commission or 0, profit)) db.commit() cursor.close() except Exception as e: @@ -2501,24 +2527,32 @@ def _update_position( ): """更新持仓状态""" try: + # Get user_id from strategy + user_id = 1 with get_db_connection() as db: cursor = db.cursor() + try: + cursor.execute("SELECT user_id FROM qd_strategies_trading WHERE id = %s", (strategy_id,)) + row = cursor.fetchone() + user_id = int((row or {}).get('user_id') or 1) + except Exception: + pass # 简化:直接 Update 或 Insert upsert_query = """ INSERT INTO qd_strategy_positions ( - strategy_id, symbol, side, size, entry_price, current_price, highest_price, lowest_price, updated_at + user_id, strategy_id, symbol, side, size, entry_price, current_price, highest_price, lowest_price, updated_at ) VALUES ( - %s, %s, %s, %s, %s, %s, %s, %s, %s + %s, %s, %s, %s, %s, %s, %s, %s, %s, NOW() ) ON CONFLICT(strategy_id, symbol, side) DO UPDATE SET size = excluded.size, entry_price = excluded.entry_price, current_price = excluded.current_price, - highest_price = CASE WHEN excluded.highest_price > 0 THEN excluded.highest_price ELSE highest_price END, - lowest_price = CASE WHEN excluded.lowest_price > 0 THEN excluded.lowest_price ELSE lowest_price END, - updated_at = excluded.updated_at + highest_price = CASE WHEN excluded.highest_price > 0 THEN excluded.highest_price ELSE qd_strategy_positions.highest_price END, + lowest_price = CASE WHEN excluded.lowest_price > 0 THEN excluded.lowest_price ELSE qd_strategy_positions.lowest_price END, + updated_at = NOW() """ cursor.execute(upsert_query, ( - strategy_id, symbol, side, size, entry_price, current_price, highest_price, lowest_price, int(time.time()) + user_id, strategy_id, symbol, side, size, entry_price, current_price, highest_price, lowest_price )) db.commit() cursor.close() diff --git a/backend_api_python/app/services/user_service.py b/backend_api_python/app/services/user_service.py new file mode 100644 index 000000000..ecd000409 --- /dev/null +++ b/backend_api_python/app/services/user_service.py @@ -0,0 +1,458 @@ +""" +User Service - Multi-user management + +Handles user CRUD operations, password hashing, and role management. +""" +import hashlib +import time +import os +from typing import Optional, Dict, Any, List +from app.utils.db import get_db_connection +from app.utils.logger import get_logger + +logger = get_logger(__name__) + +# Try to import bcrypt for secure password hashing +try: + import bcrypt + HAS_BCRYPT = True +except ImportError: + HAS_BCRYPT = False + logger.warning("bcrypt not installed. Using SHA256 for password hashing (less secure).") + + +class UserService: + """User management service""" + + # Available roles (ordered by privilege level) + ROLES = ['viewer', 'user', 'manager', 'admin'] + + # Role permissions mapping + ROLE_PERMISSIONS = { + 'viewer': ['dashboard', 'view'], + 'user': ['dashboard', 'view', 'indicator', 'backtest', 'strategy', 'portfolio'], + 'manager': ['dashboard', 'view', 'indicator', 'backtest', 'strategy', 'portfolio', 'settings'], + 'admin': ['dashboard', 'view', 'indicator', 'backtest', 'strategy', 'portfolio', 'settings', 'user_manage', 'credentials'], + } + + def hash_password(self, password: str) -> str: + """Hash password using bcrypt (preferred) or SHA256 (fallback)""" + if HAS_BCRYPT: + salt = bcrypt.gensalt(rounds=12) + return bcrypt.hashpw(password.encode('utf-8'), salt).decode('utf-8') + else: + # Fallback to SHA256 with salt + salt = os.urandom(16).hex() + hashed = hashlib.sha256((password + salt).encode('utf-8')).hexdigest() + return f"sha256${salt}${hashed}" + + def verify_password(self, password: str, password_hash: str) -> bool: + """Verify password against hash""" + if password_hash.startswith('$2b$') or password_hash.startswith('$2a$'): + # bcrypt hash + if HAS_BCRYPT: + try: + return bcrypt.checkpw(password.encode('utf-8'), password_hash.encode('utf-8')) + except Exception: + return False + return False + elif password_hash.startswith('sha256$'): + # SHA256 fallback hash + parts = password_hash.split('$') + if len(parts) != 3: + return False + salt = parts[1] + stored_hash = parts[2] + computed = hashlib.sha256((password + salt).encode('utf-8')).hexdigest() + return computed == stored_hash + return False + + def get_user_by_id(self, user_id: int) -> Optional[Dict[str, Any]]: + """Get user by ID""" + try: + with get_db_connection() as db: + cur = db.cursor() + cur.execute( + """ + SELECT id, username, email, nickname, avatar, status, role, + credits, vip_expires_at, last_login_at, created_at, updated_at + FROM qd_users WHERE id = ? + """, + (user_id,) + ) + row = cur.fetchone() + cur.close() + return row + except Exception as e: + logger.error(f"get_user_by_id failed: {e}") + return None + + def get_user_by_username(self, username: str) -> Optional[Dict[str, Any]]: + """Get user by username (includes password_hash for auth)""" + try: + with get_db_connection() as db: + cur = db.cursor() + cur.execute( + """ + SELECT id, username, password_hash, email, nickname, avatar, + status, role, last_login_at, created_at, updated_at + FROM qd_users WHERE username = ? + """, + (username,) + ) + row = cur.fetchone() + cur.close() + return row + except Exception as e: + logger.error(f"get_user_by_username failed: {e}") + return None + + def get_user_by_email(self, email: str) -> Optional[Dict[str, Any]]: + """Get user by email (includes password_hash for auth)""" + if not email: + return None + try: + with get_db_connection() as db: + cur = db.cursor() + cur.execute( + """ + SELECT id, username, password_hash, email, nickname, avatar, + status, role, last_login_at, created_at, updated_at + FROM qd_users WHERE LOWER(email) = LOWER(?) + """, + (email,) + ) + row = cur.fetchone() + cur.close() + return row + except Exception as e: + logger.error(f"get_user_by_email failed: {e}") + return None + + def authenticate(self, username: str, password: str) -> Optional[Dict[str, Any]]: + """ + Authenticate user with username/email and password. + Supports both username and email login. + Returns user info (without password_hash) if successful, None otherwise. + """ + # Try username first + user = self.get_user_by_username(username) + + # If not found, try email (supports both username and email login) + if not user: + user = self.get_user_by_email(username) + + if not user: + return None + + if user.get('status') != 'active': + logger.warning(f"Login attempt for disabled user: {username}") + return None + + password_hash = user.get('password_hash', '') + + # Check if user has no password (code-login user) + if not password_hash or password_hash.strip() == '': + logger.info(f"Password login attempted for code-login user: {username}") + # Return a special marker to indicate no password set + # This allows the caller to provide a more specific error message + return {'_no_password': True, **user} + + if not self.verify_password(password, password_hash): + return None + + # Update last login time + try: + with get_db_connection() as db: + cur = db.cursor() + cur.execute( + "UPDATE qd_users SET last_login_at = NOW() WHERE id = ?", + (user['id'],) + ) + db.commit() + cur.close() + except Exception as e: + logger.warning(f"Failed to update last_login_at: {e}") + + # Remove password_hash from return value + user.pop('password_hash', None) + return user + + def create_user(self, data: Dict[str, Any] = None, **kwargs) -> Optional[int]: + """ + Create a new user. + + Args: + data: dict with user fields, OR use keyword arguments: + username: str (required), + password: str (optional, can be None for code-login users), + email: str (optional), + nickname: str (optional), + role: str (optional, default 'user'), + status: str (optional, default 'active'), + email_verified: bool (optional, default False), + referred_by: int (optional, referrer user ID) + + Returns: + New user ID or None if failed + """ + # Support both dict and kwargs style + if data is None: + data = kwargs + else: + data = {**data, **kwargs} + + username = (data.get('username') or '').strip() + password = data.get('password') # Can be None for code-login users + + if not username: + raise ValueError("Username is required") + + if len(username) < 3 or len(username) > 50: + raise ValueError("Username must be 3-50 characters") + + # Password validation only if provided + if password and len(password) < 6: + raise ValueError("Password must be at least 6 characters") + + # Check if username already exists + existing = self.get_user_by_username(username) + if existing: + raise ValueError("Username already exists") + + # Hash password or use empty string for code-login users + password_hash = self.hash_password(password) if password else '' + email = (data.get('email') or '').strip() or None + nickname = (data.get('nickname') or '').strip() or username + role = data.get('role', 'user') + status = data.get('status', 'active') + email_verified = data.get('email_verified', False) + referred_by = data.get('referred_by') # Referrer user ID + + if role not in self.ROLES: + role = 'user' + + try: + with get_db_connection() as db: + cur = db.cursor() + cur.execute( + """ + INSERT INTO qd_users + (username, password_hash, email, nickname, role, status, email_verified, referred_by, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, NOW(), NOW()) + """, + (username, password_hash, email, nickname, role, status, email_verified, referred_by) + ) + db.commit() + user_id = cur.lastrowid + cur.close() + + # For PostgreSQL, get the ID differently + if user_id is None: + cur = db.cursor() + cur.execute("SELECT id FROM qd_users WHERE username = ?", (username,)) + row = cur.fetchone() + user_id = row['id'] if row else None + cur.close() + + logger.info(f"Created user: {username} (id={user_id}, referred_by={referred_by})") + return user_id + except Exception as e: + logger.error(f"create_user failed: {e}") + raise + + def update_user(self, user_id: int, data: Dict[str, Any]) -> bool: + """ + Update user information. + + Args: + user_id: User ID + data: Fields to update (email, nickname, avatar, role, status) + """ + allowed_fields = ['email', 'nickname', 'avatar', 'role', 'status'] + updates = [] + values = [] + + for field in allowed_fields: + if field in data: + value = data[field] + if field == 'role' and value not in self.ROLES: + continue + updates.append(f"{field} = ?") + values.append(value) + + if not updates: + return False + + updates.append("updated_at = NOW()") + values.append(user_id) + + try: + with get_db_connection() as db: + cur = db.cursor() + sql = f"UPDATE qd_users SET {', '.join(updates)} WHERE id = ?" + cur.execute(sql, tuple(values)) + db.commit() + cur.close() + return True + except Exception as e: + logger.error(f"update_user failed: {e}") + return False + + def change_password(self, user_id: int, old_password: str, new_password: str) -> bool: + """Change user password (requires old password verification, except for users with no password)""" + user = self.get_user_by_id(user_id) + if not user: + return False + + # Get full user with password_hash + with get_db_connection() as db: + cur = db.cursor() + cur.execute("SELECT password_hash FROM qd_users WHERE id = ?", (user_id,)) + row = cur.fetchone() + cur.close() + + if not row: + return False + + password_hash = row.get('password_hash', '') + + # If user has no password (code-login user), allow setting password without old password + if not password_hash or password_hash.strip() == '': + logger.info(f"Setting initial password for code-login user: {user_id}") + return self.reset_password(user_id, new_password) + + # For users with existing password, verify old password + if not self.verify_password(old_password, password_hash): + return False + + return self.reset_password(user_id, new_password) + + def reset_password(self, user_id: int, new_password: str) -> bool: + """Reset user password (admin operation, no old password required)""" + if len(new_password) < 6: + raise ValueError("Password must be at least 6 characters") + + password_hash = self.hash_password(new_password) + + try: + with get_db_connection() as db: + cur = db.cursor() + cur.execute( + "UPDATE qd_users SET password_hash = ?, updated_at = NOW() WHERE id = ?", + (password_hash, user_id) + ) + db.commit() + cur.close() + return True + except Exception as e: + logger.error(f"reset_password failed: {e}") + return False + + def update_password(self, user_id: int, new_password: str) -> bool: + """Alias for reset_password - update user password without old password verification""" + return self.reset_password(user_id, new_password) + + def delete_user(self, user_id: int) -> bool: + """Delete a user""" + try: + with get_db_connection() as db: + cur = db.cursor() + cur.execute("DELETE FROM qd_users WHERE id = ?", (user_id,)) + db.commit() + cur.close() + return True + except Exception as e: + logger.error(f"delete_user failed: {e}") + return False + + def list_users(self, page: int = 1, page_size: int = 20, search: str = None) -> Dict[str, Any]: + """List all users with pagination and optional search""" + offset = (page - 1) * page_size + + try: + with get_db_connection() as db: + cur = db.cursor() + + # Build WHERE clause for search + where_clause = "" + params = [] + if search and search.strip(): + search_term = f"%{search.strip()}%" + where_clause = "WHERE username LIKE ? OR email LIKE ? OR nickname LIKE ?" + params = [search_term, search_term, search_term] + + # Get total count + count_sql = f"SELECT COUNT(*) as count FROM qd_users {where_clause}" + cur.execute(count_sql, tuple(params)) + total = cur.fetchone()['count'] + + # Get users + query_sql = f""" + SELECT id, username, email, nickname, avatar, status, role, + credits, vip_expires_at, last_login_at, created_at, updated_at + FROM qd_users + {where_clause} + ORDER BY id DESC + LIMIT ? OFFSET ? + """ + cur.execute(query_sql, tuple(params + [page_size, offset])) + users = cur.fetchall() + cur.close() + + return { + 'items': users, + 'total': total, + 'page': page, + 'page_size': page_size, + 'total_pages': (total + page_size - 1) // page_size + } + except Exception as e: + logger.error(f"list_users failed: {e}") + return {'items': [], 'total': 0, 'page': 1, 'page_size': page_size, 'total_pages': 0} + + def get_user_permissions(self, role: str) -> List[str]: + """Get permissions for a role""" + return self.ROLE_PERMISSIONS.get(role, self.ROLE_PERMISSIONS['viewer']) + + def ensure_admin_exists(self): + """ + Ensure at least one admin user exists. + Creates admin using ADMIN_USER/ADMIN_PASSWORD from env if no users exist. + """ + try: + with get_db_connection() as db: + cur = db.cursor() + cur.execute("SELECT COUNT(*) as count FROM qd_users") + count = cur.fetchone()['count'] + cur.close() + + if count == 0: + # Create admin using env credentials + admin_user = os.getenv('ADMIN_USER', 'admin') + admin_password = os.getenv('ADMIN_PASSWORD', 'admin123') + admin_email = os.getenv('ADMIN_EMAIL', 'admin@example.com') + + self.create_user({ + 'username': admin_user, + 'password': admin_password, + 'email': admin_email, + 'nickname': 'Administrator', + 'role': 'admin', + 'status': 'active', + 'email_verified': True # Admin email is pre-verified + }) + logger.info(f"Created admin user: {admin_user} ({admin_email})") + except Exception as e: + logger.error(f"ensure_admin_exists failed: {e}") + + +# Global singleton +_user_service = None + +def get_user_service() -> UserService: + """Get UserService singleton""" + global _user_service + if _user_service is None: + _user_service = UserService() + return _user_service diff --git a/backend_api_python/app/utils/auth.py b/backend_api_python/app/utils/auth.py index b5fa689a9..c5f243fce 100644 --- a/backend_api_python/app/utils/auth.py +++ b/backend_api_python/app/utils/auth.py @@ -1,6 +1,12 @@ +""" +Authentication Utilities +JWT token generation, verification, and middleware decorators. +Supports multi-user authentication with role-based access control. +""" import jwt import datetime +import os from functools import wraps from flask import request, jsonify, g from app.config.settings import Config @@ -8,13 +14,26 @@ logger = get_logger(__name__) -def generate_token(username): - """Generate JWT token.""" + +def generate_token(user_id: int, username: str, role: str = 'user') -> str: + """ + Generate JWT token with user information. + + Args: + user_id: User ID + username: Username + role: User role (admin/manager/user/viewer) + + Returns: + JWT token string + """ try: payload = { 'exp': datetime.datetime.utcnow() + datetime.timedelta(days=7), 'iat': datetime.datetime.utcnow(), - 'sub': username + 'sub': username, + 'user_id': user_id, + 'role': role, } return jwt.encode( payload, @@ -25,18 +44,44 @@ def generate_token(username): logger.error(f"Token generation failed: {e}") return None -def verify_token(token): - """Verify JWT token.""" + +def verify_token(token: str) -> dict: + """ + Verify JWT token and return payload. + + Args: + token: JWT token string + + Returns: + Token payload dict or None if invalid + """ try: payload = jwt.decode(token, Config.SECRET_KEY, algorithms=['HS256']) - return payload['sub'] + return payload except jwt.ExpiredSignatureError: + logger.debug("Token expired") return None - except jwt.InvalidTokenError: + except jwt.InvalidTokenError as e: + logger.debug(f"Invalid token: {e}") return None + +def get_current_user_id() -> int: + """Get current user ID from flask.g context""" + return getattr(g, 'user_id', None) + + +def get_current_user_role() -> str: + """Get current user role from flask.g context""" + return getattr(g, 'user_role', 'user') + + def login_required(f): - """Decorator that enforces Bearer token auth.""" + """ + Decorator that enforces Bearer token auth. + + Sets g.user, g.user_id, g.user_role on successful auth. + """ @wraps(f) def decorated(*args, **kwargs): token = None @@ -51,13 +96,96 @@ def decorated(*args, **kwargs): if not token: return jsonify({'code': 401, 'msg': 'Token missing', 'data': None}), 401 - username = verify_token(token) - if not username: + payload = verify_token(token) + if not payload: return jsonify({'code': 401, 'msg': 'Token invalid or expired', 'data': None}), 401 - - # Store user in flask.g - g.user = username + + # Store user info in flask.g + g.user = payload.get('sub') + g.user_id = payload.get('user_id') + g.user_role = payload.get('role', 'user') + return f(*args, **kwargs) return decorated + +def admin_required(f): + """ + Decorator that requires admin role. + Must be used after @login_required. + """ + @wraps(f) + def decorated(*args, **kwargs): + role = getattr(g, 'user_role', None) + if role != 'admin': + return jsonify({'code': 403, 'msg': 'Admin access required', 'data': None}), 403 + return f(*args, **kwargs) + return decorated + + +def manager_required(f): + """ + Decorator that requires manager or admin role. + Must be used after @login_required. + """ + @wraps(f) + def decorated(*args, **kwargs): + role = getattr(g, 'user_role', None) + if role not in ('admin', 'manager'): + return jsonify({'code': 403, 'msg': 'Manager access required', 'data': None}), 403 + return f(*args, **kwargs) + return decorated + + +def permission_required(permission: str): + """ + Decorator factory that checks for a specific permission. + Must be used after @login_required. + + Usage: + @login_required + @permission_required('strategy') + def my_endpoint(): + ... + """ + def decorator(f): + @wraps(f) + def decorated(*args, **kwargs): + role = getattr(g, 'user_role', 'user') + + # Import here to avoid circular import + from app.services.user_service import get_user_service + permissions = get_user_service().get_user_permissions(role) + + if permission not in permissions: + return jsonify({ + 'code': 403, + 'msg': f'Permission denied: {permission}', + 'data': None + }), 403 + + return f(*args, **kwargs) + return decorated + return decorator + + +# Legacy compatibility: single-user mode fallback +def _is_single_user_mode() -> bool: + """Check if system is in single-user (legacy) mode""" + return os.getenv('SINGLE_USER_MODE', 'false').lower() == 'true' + + +def authenticate_legacy(username: str, password: str) -> dict: + """ + Legacy single-user authentication (for backward compatibility). + Uses ADMIN_USER and ADMIN_PASSWORD from environment. + """ + if username == Config.ADMIN_USER and password == Config.ADMIN_PASSWORD: + return { + 'user_id': 1, + 'username': username, + 'role': 'admin', + 'nickname': 'Admin', + } + return None diff --git a/backend_api_python/app/utils/db.py b/backend_api_python/app/utils/db.py index f9ebfe322..98a873381 100644 --- a/backend_api_python/app/utils/db.py +++ b/backend_api_python/app/utils/db.py @@ -1,662 +1,65 @@ - -""" -SQLite 数据库连接工具 (本地化适配版) """ -import sqlite3 -import os -import threading -import shutil -from typing import Optional, Any, List, Dict -from contextlib import contextmanager -from app.utils.logger import get_logger - -logger = get_logger(__name__) - -# SQLite 主库路径解析 -# -# 目标默认行为:把主库放到 `backend_api_python/data/quantdinger.db` -# 兼容旧行为:老版本会在 `backend_api_python/quantdinger.db` 建库 -_BASE_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))) -_DEFAULT_DB_FILE = os.path.join(_BASE_DIR, 'data', 'quantdinger.db') -_LEGACY_DB_FILE = os.path.join(_BASE_DIR, 'quantdinger.db') - - -def _get_db_file() -> str: - """ - Resolve SQLite DB file path. - - Priority: - - SQLITE_DATABASE_FILE env (Docker 推荐:/app/data/quantdinger.db) - - default: backend_api_python/data/quantdinger.db (local recommended) - - Also performs a best-effort one-time migration: - If the configured/default path doesn't exist but legacy db exists, copy legacy to configured/default path. - """ - env_path = os.getenv('SQLITE_DATABASE_FILE') - db_path = (env_path or '').strip() or _DEFAULT_DB_FILE - - # Ensure parent dir exists - parent = os.path.dirname(db_path) - if parent and not os.path.exists(parent): - try: - os.makedirs(parent, exist_ok=True) - except Exception: - pass - - # Best-effort migration from legacy path - try: - if os.path.abspath(db_path) != os.path.abspath(_LEGACY_DB_FILE): - if (not os.path.exists(db_path)) and os.path.exists(_LEGACY_DB_FILE): - shutil.copy2(_LEGACY_DB_FILE, db_path) - logger.info(f"Migrated SQLite DB from legacy path to {db_path}") - except Exception as e: - logger.warning(f"SQLite DB migration skipped/failed: {e}") - - return db_path - -# 线程锁,用于简单的并发控制(SQLite 对写操作有限制) -_db_lock = threading.Lock() - -def _init_db_schema(conn): - """初始化数据库表结构""" - cursor = conn.cursor() - - def ensure_columns(table: str, columns: Dict[str, str]) -> None: - """ - Ensure columns exist for an existing SQLite table (simple migration). - columns: {column_name: "TYPE DEFAULT ..."} - """ - try: - cursor.execute(f"PRAGMA table_info({table})") - existing = {row[1] for row in cursor.fetchall() or []} # row[1] is column name - for col, ddl in columns.items(): - if col in existing: - continue - cursor.execute(f"ALTER TABLE {table} ADD COLUMN {col} {ddl}") - except Exception as e: - logger.warning(f"ensure_columns failed for table={table}: {e}") - - # 1. 策略表 - cursor.execute(""" - CREATE TABLE IF NOT EXISTS qd_strategies_trading ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - strategy_name TEXT NOT NULL, - strategy_type TEXT DEFAULT 'IndicatorStrategy', - market_category TEXT DEFAULT 'Crypto', - execution_mode TEXT DEFAULT 'signal', - notification_config TEXT DEFAULT '', -- JSON string - status TEXT DEFAULT 'stopped', - symbol TEXT, - timeframe TEXT, - initial_capital REAL DEFAULT 1000, - leverage INTEGER DEFAULT 1, - market_type TEXT DEFAULT 'swap', - exchange_config TEXT, -- JSON string - indicator_config TEXT, -- JSON string - trading_config TEXT, -- JSON string - ai_model_config TEXT, -- JSON string - decide_interval INTEGER DEFAULT 300, - created_at INTEGER, - updated_at INTEGER - ) - """) - - ensure_columns("qd_strategies_trading", { - "market_category": "TEXT DEFAULT 'Crypto'", - "execution_mode": "TEXT DEFAULT 'signal'", - "notification_config": "TEXT DEFAULT ''", - "strategy_group_id": "TEXT DEFAULT ''", # 策略组ID,批量创建的策略共享同一个组ID - "group_base_name": "TEXT DEFAULT ''" # 策略组基础名称(用于显示) - }) - - # 2. 持仓表 - cursor.execute(""" - CREATE TABLE IF NOT EXISTS qd_strategy_positions ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - strategy_id INTEGER, - symbol TEXT, - side TEXT, -- long/short - size REAL, - entry_price REAL, - current_price REAL, - highest_price REAL DEFAULT 0, - lowest_price REAL DEFAULT 0, - unrealized_pnl REAL DEFAULT 0, - pnl_percent REAL DEFAULT 0, - equity REAL DEFAULT 0, - updated_at INTEGER, - UNIQUE(strategy_id, symbol, side) - ) - """) - - ensure_columns("qd_strategy_positions", { - "highest_price": "REAL DEFAULT 0", - "lowest_price": "REAL DEFAULT 0", - }) - - # 3. 交易记录表 - cursor.execute(""" - CREATE TABLE IF NOT EXISTS qd_strategy_trades ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - strategy_id INTEGER, - symbol TEXT, - type TEXT, -- open_long, close_short, etc. - price REAL, - amount REAL, - value REAL, - commission REAL DEFAULT 0, - commission_ccy TEXT DEFAULT '', - profit REAL DEFAULT 0, - created_at INTEGER - ) - """) - - ensure_columns("qd_strategy_trades", { - "commission_ccy": "TEXT DEFAULT ''", - }) - - # NOTE: - # We intentionally do not persist runtime logs in DB for local deployments. - # Use console logs / stdout prints instead. - - # 3.1 Pending orders queue (signal dispatch / live execution) - cursor.execute(""" - CREATE TABLE IF NOT EXISTS pending_orders ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - strategy_id INTEGER, - symbol TEXT NOT NULL, - signal_type TEXT NOT NULL, -- open_long/close_long/open_short/close_short/add_long/add_short - signal_ts INTEGER, -- candle timestamp (seconds). used for strict de-dup per candle - market_type TEXT DEFAULT 'swap', - order_type TEXT DEFAULT 'market', - amount REAL DEFAULT 0, -- base amount (or stake amount depending on execution) - price REAL DEFAULT 0, -- reference price at enqueue time - execution_mode TEXT DEFAULT 'signal', -- signal/live - status TEXT DEFAULT 'pending', -- pending/processing/sent/failed/deferred - priority INTEGER DEFAULT 0, - attempts INTEGER DEFAULT 0, - max_attempts INTEGER DEFAULT 10, - last_error TEXT DEFAULT '', - payload_json TEXT DEFAULT '', -- JSON string for dispatcher - -- Live execution result fields (best-effort) - dispatch_note TEXT DEFAULT '', - exchange_id TEXT DEFAULT '', - exchange_order_id TEXT DEFAULT '', - exchange_response_json TEXT DEFAULT '', - filled REAL DEFAULT 0, - avg_price REAL DEFAULT 0, - executed_at INTEGER, - created_at INTEGER, - updated_at INTEGER, - processed_at INTEGER, - sent_at INTEGER - ) - """) - - ensure_columns("pending_orders", { - "signal_ts": "INTEGER", - "market_type": "TEXT DEFAULT 'swap'", - "order_type": "TEXT DEFAULT 'market'", - "price": "REAL DEFAULT 0", - "execution_mode": "TEXT DEFAULT 'signal'", - "status": "TEXT DEFAULT 'pending'", - "priority": "INTEGER DEFAULT 0", - "attempts": "INTEGER DEFAULT 0", - "max_attempts": "INTEGER DEFAULT 10", - "last_error": "TEXT DEFAULT ''", - "payload_json": "TEXT DEFAULT ''", - "dispatch_note": "TEXT DEFAULT ''", - "exchange_id": "TEXT DEFAULT ''", - "exchange_order_id": "TEXT DEFAULT ''", - "exchange_response_json": "TEXT DEFAULT ''", - "filled": "REAL DEFAULT 0", - "avg_price": "REAL DEFAULT 0", - "executed_at": "INTEGER", - "created_at": "INTEGER", - "updated_at": "INTEGER", - "processed_at": "INTEGER", - "sent_at": "INTEGER", - }) - - # 3.2 Strategy notifications (browser polling / audit trail) - cursor.execute(""" - CREATE TABLE IF NOT EXISTS qd_strategy_notifications ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - strategy_id INTEGER, - symbol TEXT DEFAULT '', - signal_type TEXT DEFAULT '', - channels TEXT DEFAULT '', - title TEXT DEFAULT '', - message TEXT DEFAULT '', - payload_json TEXT DEFAULT '', - created_at INTEGER - ) - """) - - ensure_columns("qd_strategy_notifications", { - "strategy_id": "INTEGER", - "symbol": "TEXT DEFAULT ''", - "signal_type": "TEXT DEFAULT ''", - "channels": "TEXT DEFAULT ''", - "title": "TEXT DEFAULT ''", - "message": "TEXT DEFAULT ''", - "payload_json": "TEXT DEFAULT ''", - "created_at": "INTEGER", - "is_read": "INTEGER DEFAULT 0", - }) - - # 4. 指标代码表(参考 MySQL: qd_indicator_codes) - # 说明: - # - 本地化后统一使用 SQLite,但字段保持与 MySQL 结构接近,便于前端/业务复用。 - cursor.execute(""" - CREATE TABLE IF NOT EXISTS qd_indicator_codes ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id INTEGER NOT NULL DEFAULT 1, - is_buy INTEGER NOT NULL DEFAULT 0, - end_time INTEGER NOT NULL DEFAULT 1, - name TEXT NOT NULL DEFAULT '', - code TEXT, - description TEXT DEFAULT '', - publish_to_community INTEGER NOT NULL DEFAULT 0, - pricing_type TEXT NOT NULL DEFAULT 'free', - price REAL NOT NULL DEFAULT 0, - is_encrypted INTEGER NOT NULL DEFAULT 0, - preview_image TEXT DEFAULT '', - createtime INTEGER, - updatetime INTEGER, - -- legacy local columns (kept for backward compatibility) - created_at INTEGER, - updated_at INTEGER - ) - """) +Database Connection Utility - PostgreSQL Only - # Migrate older local DBs (missing columns) to the new schema shape. - ensure_columns("qd_indicator_codes", { - "user_id": "INTEGER NOT NULL DEFAULT 1", - "is_buy": "INTEGER NOT NULL DEFAULT 0", - "end_time": "INTEGER NOT NULL DEFAULT 1", - "publish_to_community": "INTEGER NOT NULL DEFAULT 0", - "pricing_type": "TEXT NOT NULL DEFAULT 'free'", - "price": "REAL NOT NULL DEFAULT 0", - "is_encrypted": "INTEGER NOT NULL DEFAULT 0", - "preview_image": "TEXT DEFAULT ''", - "createtime": "INTEGER", - "updatetime": "INTEGER" - }) +Provides unified interface for PostgreSQL database operations. - # 4.1 策略代码表(indicator-analysis 本地策略;与交易执行器的 qd_strategies_trading 区分) - cursor.execute(""" - CREATE TABLE IF NOT EXISTS qd_strategy_codes ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id INTEGER NOT NULL DEFAULT 1, - name TEXT NOT NULL DEFAULT '', - code TEXT, - description TEXT DEFAULT '', - createtime INTEGER, - updatetime INTEGER - ) - """) - - ensure_columns("qd_strategy_codes", { - "user_id": "INTEGER NOT NULL DEFAULT 1", - "name": "TEXT NOT NULL DEFAULT ''", - "code": "TEXT", - "description": "TEXT DEFAULT ''", - "createtime": "INTEGER", - "updatetime": "INTEGER" - }) - - # 5. AI决策记录表 - cursor.execute(""" - CREATE TABLE IF NOT EXISTS qd_ai_decisions ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - strategy_id INTEGER, - decision_data TEXT, -- JSON - context_data TEXT, -- JSON - created_at INTEGER - ) - """) - - # 6. 插件/系统配置表(原来由 MySQL 提供,这里用 SQLite 本地化) - cursor.execute(""" - CREATE TABLE IF NOT EXISTS qd_addon_config ( - config_key TEXT PRIMARY KEY, - config_value TEXT, - type TEXT DEFAULT 'string' - ) - """) - - # 7. Watchlist (local-only, single-user by default) - cursor.execute(""" - CREATE TABLE IF NOT EXISTS qd_watchlist ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id INTEGER DEFAULT 1, - market TEXT NOT NULL, - symbol TEXT NOT NULL, - name TEXT DEFAULT '', - created_at INTEGER, - updated_at INTEGER, - UNIQUE(user_id, market, symbol) - ) - """) - - # 8. Analysis tasks / history (local-only) - cursor.execute(""" - CREATE TABLE IF NOT EXISTS qd_analysis_tasks ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id INTEGER DEFAULT 1, - market TEXT NOT NULL, - symbol TEXT NOT NULL, - model TEXT DEFAULT '', - language TEXT DEFAULT 'en-US', - status TEXT DEFAULT 'completed', -- completed/failed/processing/pending - result_json TEXT DEFAULT '', - error_message TEXT DEFAULT '', - created_at INTEGER, - completed_at INTEGER - ) - """) - - # 9. Backtest runs (for AI optimization / history) - cursor.execute(""" - CREATE TABLE IF NOT EXISTS qd_backtest_runs ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id INTEGER NOT NULL DEFAULT 1, - indicator_id INTEGER, - market TEXT NOT NULL, - symbol TEXT NOT NULL, - timeframe TEXT NOT NULL, - start_date TEXT NOT NULL, -- YYYY-MM-DD - end_date TEXT NOT NULL, -- YYYY-MM-DD - initial_capital REAL DEFAULT 10000, - commission REAL DEFAULT 0.001, - slippage REAL DEFAULT 0, - leverage INTEGER DEFAULT 1, - trade_direction TEXT DEFAULT 'long', - strategy_config TEXT DEFAULT '', -- JSON string - status TEXT DEFAULT 'success', -- success/failed - error_message TEXT DEFAULT '', - result_json TEXT DEFAULT '', -- JSON string - created_at INTEGER - ) - """) - - ensure_columns("qd_backtest_runs", { - "user_id": "INTEGER NOT NULL DEFAULT 1", - "indicator_id": "INTEGER", - "market": "TEXT NOT NULL DEFAULT ''", - "symbol": "TEXT NOT NULL DEFAULT ''", - "timeframe": "TEXT NOT NULL DEFAULT ''", - "start_date": "TEXT NOT NULL DEFAULT ''", - "end_date": "TEXT NOT NULL DEFAULT ''", - "initial_capital": "REAL DEFAULT 10000", - "commission": "REAL DEFAULT 0.001", - "slippage": "REAL DEFAULT 0", - "leverage": "INTEGER DEFAULT 1", - "trade_direction": "TEXT DEFAULT 'long'", - "strategy_config": "TEXT DEFAULT ''", - "status": "TEXT DEFAULT 'success'", - "error_message": "TEXT DEFAULT ''", - "result_json": "TEXT DEFAULT ''", - "created_at": "INTEGER" - }) - - # 10. Exchange credentials vault (local-only) - cursor.execute(""" - CREATE TABLE IF NOT EXISTS qd_exchange_credentials ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id INTEGER NOT NULL DEFAULT 1, - name TEXT DEFAULT '', - exchange_id TEXT NOT NULL, - api_key_hint TEXT DEFAULT '', - encrypted_config TEXT NOT NULL, -- encrypted JSON string - created_at INTEGER, - updated_at INTEGER - ) - """) - - ensure_columns("qd_exchange_credentials", { - "user_id": "INTEGER NOT NULL DEFAULT 1", - "name": "TEXT DEFAULT ''", - "exchange_id": "TEXT NOT NULL DEFAULT ''", - "api_key_hint": "TEXT DEFAULT ''", - "encrypted_config": "TEXT NOT NULL DEFAULT ''", - "created_at": "INTEGER", - "updated_at": "INTEGER" - }) - - # 11. Manual positions (user's existing holdings outside the system) - cursor.execute(""" - CREATE TABLE IF NOT EXISTS qd_manual_positions ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id INTEGER NOT NULL DEFAULT 1, - market TEXT NOT NULL, -- Crypto/USStock/AShare/HShare/Forex/Futures - symbol TEXT NOT NULL, - name TEXT DEFAULT '', -- Display name - side TEXT DEFAULT 'long', -- long/short - quantity REAL NOT NULL DEFAULT 0, - entry_price REAL NOT NULL DEFAULT 0, - entry_time INTEGER, -- Position open timestamp - notes TEXT DEFAULT '', -- User notes - tags TEXT DEFAULT '', -- JSON array of tags - group_name TEXT DEFAULT '', -- Group name for categorization - created_at INTEGER, - updated_at INTEGER, - UNIQUE(user_id, market, symbol, side) - ) - """) - - ensure_columns("qd_manual_positions", { - "user_id": "INTEGER NOT NULL DEFAULT 1", - "market": "TEXT NOT NULL DEFAULT ''", - "symbol": "TEXT NOT NULL DEFAULT ''", - "name": "TEXT DEFAULT ''", - "side": "TEXT DEFAULT 'long'", - "quantity": "REAL NOT NULL DEFAULT 0", - "entry_price": "REAL NOT NULL DEFAULT 0", - "entry_time": "INTEGER", - "notes": "TEXT DEFAULT ''", - "tags": "TEXT DEFAULT ''", - "group_name": "TEXT DEFAULT ''", - "created_at": "INTEGER", - "updated_at": "INTEGER" - }) +Usage: + from app.utils.db import get_db_connection - # 11.1 Position alerts (price alerts and PnL alerts for manual positions) - cursor.execute(""" - CREATE TABLE IF NOT EXISTS qd_position_alerts ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id INTEGER NOT NULL DEFAULT 1, - position_id INTEGER, -- FK to qd_manual_positions (NULL = symbol-level alert) - market TEXT DEFAULT '', - symbol TEXT DEFAULT '', - alert_type TEXT NOT NULL, -- price_above / price_below / pnl_above / pnl_below - threshold REAL NOT NULL DEFAULT 0, - notification_config TEXT DEFAULT '', -- JSON: channels, targets - is_active INTEGER DEFAULT 1, - is_triggered INTEGER DEFAULT 0, - last_triggered_at INTEGER, - trigger_count INTEGER DEFAULT 0, - repeat_interval INTEGER DEFAULT 0, -- 0=once, >0=repeat every N seconds - notes TEXT DEFAULT '', - created_at INTEGER, - updated_at INTEGER - ) - """) - - ensure_columns("qd_position_alerts", { - "user_id": "INTEGER NOT NULL DEFAULT 1", - "position_id": "INTEGER", - "market": "TEXT DEFAULT ''", - "symbol": "TEXT DEFAULT ''", - "alert_type": "TEXT NOT NULL DEFAULT 'price_above'", - "threshold": "REAL NOT NULL DEFAULT 0", - "notification_config": "TEXT DEFAULT ''", - "is_active": "INTEGER DEFAULT 1", - "is_triggered": "INTEGER DEFAULT 0", - "last_triggered_at": "INTEGER", - "trigger_count": "INTEGER DEFAULT 0", - "repeat_interval": "INTEGER DEFAULT 0", - "notes": "TEXT DEFAULT ''", - "created_at": "INTEGER", - "updated_at": "INTEGER" - }) - - # 12. Position monitors (AI analysis tasks for manual positions) - cursor.execute(""" - CREATE TABLE IF NOT EXISTS qd_position_monitors ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id INTEGER NOT NULL DEFAULT 1, - name TEXT DEFAULT '', -- Monitor name - position_ids TEXT DEFAULT '', -- JSON array of position IDs (empty = all positions) - monitor_type TEXT DEFAULT 'ai', -- ai / price_alert / pnl_alert - config TEXT DEFAULT '', -- JSON config (interval_minutes, prompt, thresholds, etc.) - notification_config TEXT DEFAULT '', -- JSON notification config (channels, targets) - is_active INTEGER DEFAULT 1, - last_run_at INTEGER, - next_run_at INTEGER, - last_result TEXT DEFAULT '', -- Last analysis result (JSON) - run_count INTEGER DEFAULT 0, - created_at INTEGER, - updated_at INTEGER - ) - """) - - ensure_columns("qd_position_monitors", { - "user_id": "INTEGER NOT NULL DEFAULT 1", - "name": "TEXT DEFAULT ''", - "position_ids": "TEXT DEFAULT ''", - "monitor_type": "TEXT DEFAULT 'ai'", - "config": "TEXT DEFAULT ''", - "notification_config": "TEXT DEFAULT ''", - "is_active": "INTEGER DEFAULT 1", - "last_run_at": "INTEGER", - "next_run_at": "INTEGER", - "last_result": "TEXT DEFAULT ''", - "run_count": "INTEGER DEFAULT 0", - "created_at": "INTEGER", - "updated_at": "INTEGER" - }) - - conn.commit() - logger.info("Database schema initialized (SQLite)") - -# 初始化一次(按 db_file 维度) -_has_initialized = False -_initialized_db_file = None + with get_db_connection() as conn: + cursor = conn.cursor() + cursor.execute("SELECT * FROM users WHERE id = %s", (user_id,)) + row = cursor.fetchone() + conn.commit() + +Configuration: + DATABASE_URL=postgresql://user:password@host:port/dbname +""" -class SQLiteCursor: - """模拟 pymysql DictCursor""" - def __init__(self, cursor): - self._cursor = cursor +# Re-export from PostgreSQL module +from app.utils.db_postgres import ( + get_pg_connection as get_db_connection, + get_pg_connection_sync as get_db_connection_sync, + is_postgres_available, + close_pool as close_db, +) - def execute(self, query: str, args: Any = None): - # 适配 MySQL -> SQLite 语法 - # 1. 替换占位符: %s -> ? - query = query.replace('%s', '?') - # 2. 替换 INSERT IGNORE -> INSERT OR IGNORE - query = query.replace('INSERT IGNORE', 'INSERT OR IGNORE') - # 3. 替换 ON DUPLICATE KEY UPDATE -> 简化为 UPSERT (SQLite 3.24+) - # 注意:复杂的 ON DUPLICATE KEY UPDATE 很难自动转换,建议业务代码改写 - # 这里做一个简单的替换尝试,如果失败则需要人工介入代码 - if 'ON DUPLICATE KEY UPDATE' in query: - # 简单的正则替换很难完美,这里记录日志提醒 - logger.warning(f"Complex SQL may require manual SQLite adaptation: {query}") - # 尝试转换为 SQLite 的 ON CONFLICT (id) DO UPDATE SET ... - # 但由于不知道主键冲突列,很难自动转换。 - # 临时方案:如果遇到这种 SQL,可能报错。我们假设主要业务逻辑已经重构。 - pass - if args: - return self._cursor.execute(query, args) - return self._cursor.execute(query) +def get_db_type() -> str: + """Get database type (always postgresql)""" + return 'postgresql' - def fetchone(self): - row = self._cursor.fetchone() - if row is None: - return None - # Convert sqlite3.Row to dict - return dict(row) - def fetchall(self): - rows = self._cursor.fetchall() - return [dict(row) for row in rows] +def is_postgres() -> bool: + """Check if using PostgreSQL (always True)""" + return True - def close(self): - self._cursor.close() - - @property - def lastrowid(self): - return self._cursor.lastrowid - -class SQLiteConnection: - """数据库连接包装类""" - def __init__(self, db_path): - self._conn = sqlite3.connect(db_path, check_same_thread=False, timeout=30.0) - # 设置 Row factory 以支持字段名访问 - self._conn.row_factory = sqlite3.Row - - def cursor(self): - return SQLiteCursor(self._conn.cursor()) - - def commit(self): - self._conn.commit() - - def rollback(self): - self._conn.rollback() - - def close(self): - self._conn.close() -@contextmanager -def get_db_connection(): +def init_database(): """ - 获取数据库连接 (Context Manager) + Initialize database connection. + Schema is created via migrations/init.sql on PostgreSQL container start. """ - global _has_initialized, _initialized_db_file - - # 简单的连接创建,不使用连接池(SQLite 文件锁机制决定了连接池意义不大) - # 使用线程锁防止写冲突(虽然 SQLite 有 WAL 模式,但稳妥起见) - # 注意:这里加锁粒度较大,如果是高并发场景可能会慢,但对于个人量化系统足够。 - - # 初始化表结构(确保每个 db_file 都被初始化过) - db_file = _get_db_file() - if (not _has_initialized) or (_initialized_db_file != db_file): - try: - conn_init = sqlite3.connect(db_file) - _init_db_schema(conn_init) - conn_init.close() - _has_initialized = True - _initialized_db_file = db_file - except Exception as e: - logger.error(f"Failed to initialize database: {e}") + if is_postgres_available(): + from app.utils.logger import get_logger + logger = get_logger(__name__) + logger.info("PostgreSQL connection verified") + else: + raise RuntimeError("Cannot connect to PostgreSQL. Check DATABASE_URL.") - conn = SQLiteConnection(db_file) - try: - # with _db_lock: # SQLite 内部有锁,这里如果不跨线程共享连接其实不用强加锁 - yield conn - except Exception as e: - logger.error(f"Database operation error: {e}") - conn.rollback() - raise e - finally: - conn.close() - -def get_db_connection_sync(): - """兼容旧接口""" - global _has_initialized, _initialized_db_file - db_file = _get_db_file() - if (not _has_initialized) or (_initialized_db_file != db_file): - try: - conn_init = sqlite3.connect(db_file) - _init_db_schema(conn_init) - conn_init.close() - _has_initialized = True - _initialized_db_file = db_file - except Exception as e: - logger.error(f"Failed to initialize database: {e}") - - return SQLiteConnection(db_file) +# Legacy alias def close_db_connection(): + """Legacy alias for close_db""" pass + + +__all__ = [ + 'get_db_connection', + 'get_db_connection_sync', + 'close_db_connection', + 'init_database', + 'close_db', + 'get_db_type', + 'is_postgres', +] diff --git a/backend_api_python/app/utils/db_postgres.py b/backend_api_python/app/utils/db_postgres.py new file mode 100644 index 000000000..cc15fd93c --- /dev/null +++ b/backend_api_python/app/utils/db_postgres.py @@ -0,0 +1,297 @@ +""" +PostgreSQL Database Connection Utility + +Supports multi-user mode with connection pooling and SQLite compatibility layer. +""" +import os +import threading +from typing import Optional, Any, List, Dict +from contextlib import contextmanager +from app.utils.logger import get_logger + +logger = get_logger(__name__) + +# Try to import psycopg2 +try: + import psycopg2 + from psycopg2 import pool + from psycopg2.extras import RealDictCursor + HAS_PSYCOPG2 = True +except ImportError: + HAS_PSYCOPG2 = False + logger.warning("psycopg2 not installed. PostgreSQL support disabled.") + +# Connection pool (global singleton) +_connection_pool: Optional[Any] = None +_pool_lock = threading.Lock() + + +def _get_database_url() -> str: + """Get database connection URL from environment""" + return os.getenv('DATABASE_URL', '').strip() + + +def _parse_database_url(url: str) -> Dict[str, Any]: + """ + Parse DATABASE_URL format: postgresql://user:password@host:port/dbname + """ + if not url: + return {} + + # Remove protocol prefix + if url.startswith('postgresql://'): + url = url[13:] + elif url.startswith('postgres://'): + url = url[11:] + else: + return {} + + result = {} + + # Split user:password@host:port/dbname + if '@' in url: + auth, hostpart = url.rsplit('@', 1) + if ':' in auth: + result['user'], result['password'] = auth.split(':', 1) + else: + result['user'] = auth + else: + hostpart = url + + # Split host:port/dbname + if '/' in hostpart: + hostport, result['dbname'] = hostpart.split('/', 1) + else: + hostport = hostpart + + if ':' in hostport: + result['host'], port_str = hostport.split(':', 1) + result['port'] = int(port_str) + else: + result['host'] = hostport + result['port'] = 5432 + + return result + + +def _get_connection_pool(): + """Get or create connection pool""" + global _connection_pool + + if _connection_pool is not None: + return _connection_pool + + with _pool_lock: + if _connection_pool is not None: + return _connection_pool + + if not HAS_PSYCOPG2: + raise RuntimeError("psycopg2 is not installed. Cannot use PostgreSQL.") + + db_url = _get_database_url() + if not db_url: + raise RuntimeError("DATABASE_URL environment variable is not set.") + + params = _parse_database_url(db_url) + if not params: + raise RuntimeError(f"Invalid DATABASE_URL format: {db_url}") + + try: + _connection_pool = pool.ThreadedConnectionPool( + minconn=2, + maxconn=20, + host=params.get('host', 'localhost'), + port=params.get('port', 5432), + user=params.get('user', 'quantdinger'), + password=params.get('password', ''), + dbname=params.get('dbname', 'quantdinger'), + connect_timeout=10, + ) + logger.info(f"PostgreSQL connection pool created: {params.get('host')}:{params.get('port')}/{params.get('dbname')}") + except Exception as e: + logger.error(f"Failed to create PostgreSQL connection pool: {e}") + raise + + return _connection_pool + + +class PostgresCursor: + """PostgreSQL cursor wrapper with SQLite placeholder compatibility""" + + def __init__(self, cursor): + self._cursor = cursor + self._last_insert_id = None + + def _convert_placeholders(self, query: str) -> str: + """ + Convert SQLite-style ? placeholders to PostgreSQL %s + Also handle some SQL syntax differences + """ + # Replace ? -> %s + query = query.replace('?', '%s') + + # SQLite: INSERT OR IGNORE -> PostgreSQL: INSERT ... ON CONFLICT DO NOTHING + query = query.replace('INSERT OR IGNORE', 'INSERT') + + return query + + def execute(self, query: str, args: Any = None): + """Execute SQL statement""" + query = self._convert_placeholders(query) + + # Check if this is an INSERT and add RETURNING id if not present + is_insert = query.strip().upper().startswith('INSERT') + if is_insert and 'RETURNING' not in query.upper(): + query = query.rstrip(';').rstrip() + ' RETURNING id' + + if args: + if not isinstance(args, (tuple, list)): + args = (args,) + result = self._cursor.execute(query, args) + else: + result = self._cursor.execute(query) + + # Capture last insert id for INSERT statements + if is_insert: + try: + row = self._cursor.fetchone() + if row and 'id' in row: + self._last_insert_id = row['id'] + except Exception: + pass + + return result + + def fetchone(self) -> Optional[Dict[str, Any]]: + """Fetch single row""" + row = self._cursor.fetchone() + if row is None: + return None + return dict(row) if row else None + + def fetchall(self) -> List[Dict[str, Any]]: + """Fetch all rows""" + rows = self._cursor.fetchall() + return [dict(row) for row in rows] if rows else [] + + def close(self): + """Close cursor""" + self._cursor.close() + + @property + def lastrowid(self) -> Optional[int]: + """Get last inserted row ID""" + return self._last_insert_id + + @property + def rowcount(self) -> int: + """Get affected row count""" + return self._cursor.rowcount + + +class PostgresConnection: + """PostgreSQL connection wrapper""" + + def __init__(self, conn): + self._conn = conn + self._pool = _get_connection_pool() + + def cursor(self) -> PostgresCursor: + """Create cursor""" + return PostgresCursor(self._conn.cursor(cursor_factory=RealDictCursor)) + + def commit(self): + """Commit transaction""" + self._conn.commit() + + def rollback(self): + """Rollback transaction""" + self._conn.rollback() + + def close(self): + """Return connection to pool""" + if self._pool and self._conn: + try: + self._pool.putconn(self._conn) + except Exception as e: + logger.warning(f"Failed to return connection to pool: {e}") + + +@contextmanager +def get_pg_connection(): + """ + Get PostgreSQL database connection (Context Manager) + """ + pool = _get_connection_pool() + conn = None + try: + conn = pool.getconn() + pg_conn = PostgresConnection(conn) + yield pg_conn + except Exception as e: + if conn: + try: + conn.rollback() + except Exception: + pass + logger.error(f"PostgreSQL operation error: {e}") + raise + finally: + if conn: + try: + pool.putconn(conn) + except Exception: + pass + + +def get_pg_connection_sync() -> PostgresConnection: + """ + Get connection synchronously (caller must close) + """ + pool = _get_connection_pool() + conn = pool.getconn() + return PostgresConnection(conn) + + +def execute_sql(sql: str, params: tuple = None) -> List[Dict[str, Any]]: + """ + Execute SQL and return results (convenience function) + """ + with get_pg_connection() as conn: + cursor = conn.cursor() + cursor.execute(sql, params) + if sql.strip().upper().startswith('SELECT'): + return cursor.fetchall() + conn.commit() + return [] + + +def is_postgres_available() -> bool: + """Check if PostgreSQL is available""" + if not HAS_PSYCOPG2: + return False + + db_url = _get_database_url() + if not db_url: + return False + + try: + with get_pg_connection() as conn: + cursor = conn.cursor() + cursor.execute("SELECT 1") + return True + except Exception as e: + logger.debug(f"PostgreSQL not available: {e}") + return False + + +def close_pool(): + """Close connection pool (call on app shutdown)""" + global _connection_pool + if _connection_pool: + try: + _connection_pool.closeall() + _connection_pool = None + logger.info("PostgreSQL connection pool closed") + except Exception as e: + logger.warning(f"Error closing connection pool: {e}") diff --git a/backend_api_python/env.example b/backend_api_python/env.example index ad36874f3..b4eaf0fbb 100644 --- a/backend_api_python/env.example +++ b/backend_api_python/env.example @@ -7,6 +7,14 @@ SECRET_KEY=quantdinger-secret-key-change-me ADMIN_USER=quantdinger ADMIN_PASSWORD=123456 +ADMIN_EMAIL= + +# ========================= +# Demo Mode +# ========================= +# Set to true to enable read-only mode for public demo. +# Blocks all POST/PUT/DELETE requests except login. +IS_DEMO_MODE=false # ========================= # Network / App @@ -16,12 +24,13 @@ PYTHON_API_PORT=5000 PYTHON_API_DEBUG=False # ========================= -# Database (SQLite) +# Database Configuration (PostgreSQL) # ========================= -# 主库文件路径(可选) -# - 不设置时,默认使用:backend_api_python/data/quantdinger.db -# - Docker 推荐:/app/data/quantdinger.db -SQLITE_DATABASE_FILE= +# PostgreSQL connection URL (required for multi-user mode) +# Format: postgresql://user:password@host:port/dbname +# Docker: uses this default, no changes needed +# Local: change 'postgres' to 'localhost' and update password +DATABASE_URL=postgresql://quantdinger:quantdinger123@postgres:5432/quantdinger # ========================= # Pending orders worker (optional) @@ -209,3 +218,71 @@ SEARCH_BING_API_KEY= # Internal API key (optional, if you add internal-service auth later) INTERNAL_API_KEY= +# ========================= +# Registration & Security (注册与安全) +# ========================= +# Enable user registration (允许用户注册) +ENABLE_REGISTRATION=true + +# Cloudflare Turnstile (人机验证) +# Get your keys at: https://dash.cloudflare.com/?to=/:account/turnstile +TURNSTILE_SITE_KEY= +TURNSTILE_SECRET_KEY= + +# Frontend URL (for OAuth redirects) +FRONTEND_URL=http://localhost:8080 + +# Google OAuth +# Get your credentials at: https://console.cloud.google.com/apis/credentials +GOOGLE_CLIENT_ID= +GOOGLE_CLIENT_SECRET= +GOOGLE_REDIRECT_URI=http://localhost:5000/api/auth/oauth/google/callback + +# GitHub OAuth +# Get your credentials at: https://github.com/settings/developers +GITHUB_CLIENT_ID= +GITHUB_CLIENT_SECRET= +GITHUB_REDIRECT_URI=http://localhost:5000/api/auth/oauth/github/callback + +# Security: IP rate limit (防爆破 - IP维度) +# Block IP after N failed attempts within M minutes for X minutes +SECURITY_IP_MAX_ATTEMPTS=10 +SECURITY_IP_WINDOW_MINUTES=5 +SECURITY_IP_BLOCK_MINUTES=15 + +# Security: Account rate limit (防爆破 - 账户维度) +SECURITY_ACCOUNT_MAX_ATTEMPTS=5 +SECURITY_ACCOUNT_WINDOW_MINUTES=60 +SECURITY_ACCOUNT_BLOCK_MINUTES=30 + +# Verification code settings (验证码设置) +VERIFICATION_CODE_EXPIRE_MINUTES=10 +VERIFICATION_CODE_RATE_LIMIT=60 +VERIFICATION_CODE_IP_HOURLY_LIMIT=10 +VERIFICATION_CODE_MAX_ATTEMPTS=5 +VERIFICATION_CODE_LOCK_MINUTES=30 + +# ========================= +# Billing & Credits (积分计费系统) +# ========================= +# Enable billing system (启用计费系统) +BILLING_ENABLED=False + +# VIP users can use all paid features for free (VIP用户免费) +BILLING_VIP_BYPASS=True + +# Credits consumed per feature (各功能消耗积分数) +BILLING_COST_AI_ANALYSIS=10 +BILLING_COST_STRATEGY_RUN=5 +BILLING_COST_BACKTEST=3 +BILLING_COST_PORTFOLIO_MONITOR=8 + +# Telegram customer service URL for recharge (充值跳转的Telegram链接) +RECHARGE_TELEGRAM_URL=https://t.me/quantdinger + +# New user registration bonus credits (新用户注册赠送积分) +CREDITS_REGISTER_BONUS=100 + +# Referral bonus credits (邀请用户赠送积分,邀请人获得) +CREDITS_REFERRAL_BONUS=50 + diff --git a/backend_api_python/migrations/init.sql b/backend_api_python/migrations/init.sql new file mode 100644 index 000000000..10bd86dee --- /dev/null +++ b/backend_api_python/migrations/init.sql @@ -0,0 +1,642 @@ +-- QuantDinger PostgreSQL Schema Initialization +-- This script runs automatically when PostgreSQL container starts for the first time. + +-- ============================================================================= +-- 1. Users & Authentication +-- ============================================================================= + +CREATE TABLE IF NOT EXISTS qd_users ( + id SERIAL PRIMARY KEY, + username VARCHAR(50) UNIQUE NOT NULL, + password_hash VARCHAR(255) NOT NULL, + email VARCHAR(100) UNIQUE, + nickname VARCHAR(50), + avatar VARCHAR(255) DEFAULT '/avatar2.jpg', + status VARCHAR(20) DEFAULT 'active', -- active/disabled/pending + role VARCHAR(20) DEFAULT 'user', -- admin/manager/user/viewer + credits DECIMAL(20,2) DEFAULT 0, -- 积分余额 + vip_expires_at TIMESTAMP, -- VIP过期时间 + email_verified BOOLEAN DEFAULT FALSE, -- 邮箱是否已验证 + referred_by INTEGER, -- 邀请人ID + last_login_at TIMESTAMP, + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_users_referred_by ON qd_users(referred_by); + +-- Note: Admin user is created automatically by the application on startup +-- using ADMIN_USER and ADMIN_PASSWORD from environment variables + +-- ============================================================================= +-- 1.5. Credits Log (积分变动日志) +-- ============================================================================= + +CREATE TABLE IF NOT EXISTS qd_credits_log ( + id SERIAL PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES qd_users(id) ON DELETE CASCADE, + action VARCHAR(50) NOT NULL, -- recharge/consume/refund/admin_adjust/vip_grant + amount DECIMAL(20,2) NOT NULL, -- 变动金额(正数增加,负数减少) + balance_after DECIMAL(20,2) NOT NULL, -- 变动后余额 + feature VARCHAR(50) DEFAULT '', -- 消费的功能:ai_analysis/strategy_run/backtest 等 + reference_id VARCHAR(100) DEFAULT '', -- 关联ID(如订单号、分析任务ID等) + remark TEXT DEFAULT '', -- 备注 + operator_id INTEGER, -- 操作人ID(管理员调整时记录) + created_at TIMESTAMP DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_credits_log_user_id ON qd_credits_log(user_id); +CREATE INDEX IF NOT EXISTS idx_credits_log_action ON qd_credits_log(action); +CREATE INDEX IF NOT EXISTS idx_credits_log_created_at ON qd_credits_log(created_at); + +-- ============================================================================= +-- 1.6. Verification Codes (邮箱验证码) +-- ============================================================================= + +CREATE TABLE IF NOT EXISTS qd_verification_codes ( + id SERIAL PRIMARY KEY, + email VARCHAR(100) NOT NULL, + code VARCHAR(10) NOT NULL, + type VARCHAR(20) NOT NULL, -- register/login/reset_password/change_email/change_password + expires_at TIMESTAMP NOT NULL, + used_at TIMESTAMP, + ip_address VARCHAR(45), + attempts INTEGER DEFAULT 0, -- Failed verification attempts (anti-brute-force) + last_attempt_at TIMESTAMP, -- Last attempt time + created_at TIMESTAMP DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_verification_codes_email ON qd_verification_codes(email); +CREATE INDEX IF NOT EXISTS idx_verification_codes_type ON qd_verification_codes(type); +CREATE INDEX IF NOT EXISTS idx_verification_codes_expires ON qd_verification_codes(expires_at); + +-- ============================================================================= +-- 1.7. Login Attempts (登录尝试记录 - 防爆破) +-- ============================================================================= + +CREATE TABLE IF NOT EXISTS qd_login_attempts ( + id SERIAL PRIMARY KEY, + identifier VARCHAR(100) NOT NULL, -- IP address or username + identifier_type VARCHAR(10) NOT NULL, -- 'ip' or 'account' + attempt_time TIMESTAMP DEFAULT NOW(), + success BOOLEAN DEFAULT FALSE, + ip_address VARCHAR(45), + user_agent TEXT +); + +CREATE INDEX IF NOT EXISTS idx_login_attempts_identifier ON qd_login_attempts(identifier, identifier_type); +CREATE INDEX IF NOT EXISTS idx_login_attempts_time ON qd_login_attempts(attempt_time); + +-- ============================================================================= +-- 1.8. OAuth Links (第三方账号关联) +-- ============================================================================= + +CREATE TABLE IF NOT EXISTS qd_oauth_links ( + id SERIAL PRIMARY KEY, + user_id INTEGER REFERENCES qd_users(id) ON DELETE CASCADE, + provider VARCHAR(20) NOT NULL, -- 'google' or 'github' + provider_user_id VARCHAR(100) NOT NULL, + provider_email VARCHAR(100), + provider_name VARCHAR(100), + provider_avatar VARCHAR(255), + access_token TEXT, + refresh_token TEXT, + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW(), + UNIQUE(provider, provider_user_id) +); + +CREATE INDEX IF NOT EXISTS idx_oauth_links_user_id ON qd_oauth_links(user_id); +CREATE INDEX IF NOT EXISTS idx_oauth_links_provider ON qd_oauth_links(provider); + +-- ============================================================================= +-- 1.9. Security Audit Log (安全审计日志) +-- ============================================================================= + +CREATE TABLE IF NOT EXISTS qd_security_logs ( + id SERIAL PRIMARY KEY, + user_id INTEGER, + action VARCHAR(50) NOT NULL, -- login/logout/register/reset_password/oauth_login/etc + ip_address VARCHAR(45), + user_agent TEXT, + details TEXT, -- JSON with additional info + created_at TIMESTAMP DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_security_logs_user_id ON qd_security_logs(user_id); +CREATE INDEX IF NOT EXISTS idx_security_logs_action ON qd_security_logs(action); +CREATE INDEX IF NOT EXISTS idx_security_logs_created_at ON qd_security_logs(created_at); + +-- ============================================================================= +-- 2. Trading Strategies +-- ============================================================================= + +CREATE TABLE IF NOT EXISTS qd_strategies_trading ( + id SERIAL PRIMARY KEY, + user_id INTEGER NOT NULL DEFAULT 1 REFERENCES qd_users(id) ON DELETE CASCADE, + strategy_name VARCHAR(255) NOT NULL, + strategy_type VARCHAR(50) DEFAULT 'IndicatorStrategy', + market_category VARCHAR(50) DEFAULT 'Crypto', + execution_mode VARCHAR(20) DEFAULT 'signal', + notification_config TEXT DEFAULT '', + status VARCHAR(20) DEFAULT 'stopped', + symbol VARCHAR(50), + timeframe VARCHAR(10), + initial_capital DECIMAL(20,8) DEFAULT 1000, + leverage INTEGER DEFAULT 1, + market_type VARCHAR(20) DEFAULT 'swap', + exchange_config TEXT, + indicator_config TEXT, + trading_config TEXT, + ai_model_config TEXT, + decide_interval INTEGER DEFAULT 300, + strategy_group_id VARCHAR(100) DEFAULT '', + group_base_name VARCHAR(255) DEFAULT '', + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_strategies_user_id ON qd_strategies_trading(user_id); +CREATE INDEX IF NOT EXISTS idx_strategies_status ON qd_strategies_trading(status); +CREATE INDEX IF NOT EXISTS idx_strategies_group_id ON qd_strategies_trading(strategy_group_id); + +-- ============================================================================= +-- 3. Strategy Positions +-- ============================================================================= + +CREATE TABLE IF NOT EXISTS qd_strategy_positions ( + id SERIAL PRIMARY KEY, + user_id INTEGER NOT NULL DEFAULT 1 REFERENCES qd_users(id) ON DELETE CASCADE, + strategy_id INTEGER REFERENCES qd_strategies_trading(id) ON DELETE CASCADE, + symbol VARCHAR(50), + side VARCHAR(10), -- long/short + size DECIMAL(20,8), + entry_price DECIMAL(20,8), + current_price DECIMAL(20,8), + highest_price DECIMAL(20,8) DEFAULT 0, + lowest_price DECIMAL(20,8) DEFAULT 0, + unrealized_pnl DECIMAL(20,8) DEFAULT 0, + pnl_percent DECIMAL(10,4) DEFAULT 0, + equity DECIMAL(20,8) DEFAULT 0, + updated_at TIMESTAMP DEFAULT NOW(), + UNIQUE(strategy_id, symbol, side) +); + +CREATE INDEX IF NOT EXISTS idx_positions_user_id ON qd_strategy_positions(user_id); +CREATE INDEX IF NOT EXISTS idx_positions_strategy_id ON qd_strategy_positions(strategy_id); + +-- ============================================================================= +-- 4. Strategy Trades +-- ============================================================================= + +CREATE TABLE IF NOT EXISTS qd_strategy_trades ( + id SERIAL PRIMARY KEY, + user_id INTEGER NOT NULL DEFAULT 1 REFERENCES qd_users(id) ON DELETE CASCADE, + strategy_id INTEGER REFERENCES qd_strategies_trading(id) ON DELETE CASCADE, + symbol VARCHAR(50), + type VARCHAR(30), -- open_long, close_short, etc. + price DECIMAL(20,8), + amount DECIMAL(20,8), + value DECIMAL(20,8), + commission DECIMAL(20,8) DEFAULT 0, + commission_ccy VARCHAR(20) DEFAULT '', + profit DECIMAL(20,8) DEFAULT 0, + created_at TIMESTAMP DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_trades_user_id ON qd_strategy_trades(user_id); +CREATE INDEX IF NOT EXISTS idx_trades_strategy_id ON qd_strategy_trades(strategy_id); +CREATE INDEX IF NOT EXISTS idx_trades_created_at ON qd_strategy_trades(created_at); + +-- ============================================================================= +-- 5. Pending Orders Queue +-- ============================================================================= + +CREATE TABLE IF NOT EXISTS pending_orders ( + id SERIAL PRIMARY KEY, + user_id INTEGER NOT NULL DEFAULT 1 REFERENCES qd_users(id) ON DELETE CASCADE, + strategy_id INTEGER REFERENCES qd_strategies_trading(id) ON DELETE SET NULL, + symbol VARCHAR(50) NOT NULL, + signal_type VARCHAR(30) NOT NULL, + signal_ts BIGINT, + market_type VARCHAR(20) DEFAULT 'swap', + order_type VARCHAR(20) DEFAULT 'market', + amount DECIMAL(20,8) DEFAULT 0, + price DECIMAL(20,8) DEFAULT 0, + execution_mode VARCHAR(20) DEFAULT 'signal', + status VARCHAR(20) DEFAULT 'pending', + priority INTEGER DEFAULT 0, + attempts INTEGER DEFAULT 0, + max_attempts INTEGER DEFAULT 10, + last_error TEXT DEFAULT '', + payload_json TEXT DEFAULT '', + dispatch_note TEXT DEFAULT '', + exchange_id VARCHAR(50) DEFAULT '', + exchange_order_id VARCHAR(100) DEFAULT '', + exchange_response_json TEXT DEFAULT '', + filled DECIMAL(20,8) DEFAULT 0, + avg_price DECIMAL(20,8) DEFAULT 0, + executed_at TIMESTAMP, + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW(), + processed_at TIMESTAMP, + sent_at TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS idx_pending_orders_user_id ON pending_orders(user_id); +CREATE INDEX IF NOT EXISTS idx_pending_orders_status ON pending_orders(status); +CREATE INDEX IF NOT EXISTS idx_pending_orders_strategy_id ON pending_orders(strategy_id); + +-- ============================================================================= +-- 6. Strategy Notifications +-- ============================================================================= + +CREATE TABLE IF NOT EXISTS qd_strategy_notifications ( + id SERIAL PRIMARY KEY, + user_id INTEGER NOT NULL DEFAULT 1 REFERENCES qd_users(id) ON DELETE CASCADE, + strategy_id INTEGER REFERENCES qd_strategies_trading(id) ON DELETE CASCADE, + symbol VARCHAR(50) DEFAULT '', + signal_type VARCHAR(30) DEFAULT '', + channels VARCHAR(255) DEFAULT '', + title VARCHAR(255) DEFAULT '', + message TEXT DEFAULT '', + payload_json TEXT DEFAULT '', + is_read INTEGER DEFAULT 0, + created_at TIMESTAMP DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_notifications_user_id ON qd_strategy_notifications(user_id); +CREATE INDEX IF NOT EXISTS idx_notifications_strategy_id ON qd_strategy_notifications(strategy_id); +CREATE INDEX IF NOT EXISTS idx_notifications_is_read ON qd_strategy_notifications(is_read); + +-- ============================================================================= +-- 7. Indicator Codes +-- ============================================================================= + +CREATE TABLE IF NOT EXISTS qd_indicator_codes ( + id SERIAL PRIMARY KEY, + user_id INTEGER NOT NULL DEFAULT 1 REFERENCES qd_users(id) ON DELETE CASCADE, + is_buy INTEGER NOT NULL DEFAULT 0, + end_time BIGINT NOT NULL DEFAULT 1, + name VARCHAR(255) NOT NULL DEFAULT '', + code TEXT, + description TEXT DEFAULT '', + publish_to_community INTEGER NOT NULL DEFAULT 0, + pricing_type VARCHAR(20) NOT NULL DEFAULT 'free', + price DECIMAL(10,2) NOT NULL DEFAULT 0, + is_encrypted INTEGER NOT NULL DEFAULT 0, + preview_image VARCHAR(500) DEFAULT '', + createtime BIGINT, + updatetime BIGINT, + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_indicator_codes_user_id ON qd_indicator_codes(user_id); + +-- ============================================================================= +-- 8. Strategy Codes +-- ============================================================================= + +CREATE TABLE IF NOT EXISTS qd_strategy_codes ( + id SERIAL PRIMARY KEY, + user_id INTEGER NOT NULL DEFAULT 1 REFERENCES qd_users(id) ON DELETE CASCADE, + name VARCHAR(255) NOT NULL DEFAULT '', + code TEXT, + description TEXT DEFAULT '', + createtime BIGINT, + updatetime BIGINT, + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_strategy_codes_user_id ON qd_strategy_codes(user_id); + +-- ============================================================================= +-- 9. AI Decisions +-- ============================================================================= + +CREATE TABLE IF NOT EXISTS qd_ai_decisions ( + id SERIAL PRIMARY KEY, + user_id INTEGER NOT NULL DEFAULT 1 REFERENCES qd_users(id) ON DELETE CASCADE, + strategy_id INTEGER REFERENCES qd_strategies_trading(id) ON DELETE CASCADE, + decision_data TEXT, + context_data TEXT, + created_at TIMESTAMP DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_ai_decisions_user_id ON qd_ai_decisions(user_id); + +-- ============================================================================= +-- 10. Addon Config +-- ============================================================================= + +CREATE TABLE IF NOT EXISTS qd_addon_config ( + config_key VARCHAR(100) PRIMARY KEY, + config_value TEXT, + type VARCHAR(20) DEFAULT 'string' +); + +-- ============================================================================= +-- 11. Watchlist +-- ============================================================================= + +CREATE TABLE IF NOT EXISTS qd_watchlist ( + id SERIAL PRIMARY KEY, + user_id INTEGER DEFAULT 1 REFERENCES qd_users(id) ON DELETE CASCADE, + market VARCHAR(50) NOT NULL, + symbol VARCHAR(50) NOT NULL, + name VARCHAR(100) DEFAULT '', + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW(), + UNIQUE(user_id, market, symbol) +); + +CREATE INDEX IF NOT EXISTS idx_watchlist_user_id ON qd_watchlist(user_id); + +-- ============================================================================= +-- 12. Analysis Tasks +-- ============================================================================= + +CREATE TABLE IF NOT EXISTS qd_analysis_tasks ( + id SERIAL PRIMARY KEY, + user_id INTEGER DEFAULT 1 REFERENCES qd_users(id) ON DELETE CASCADE, + market VARCHAR(50) NOT NULL, + symbol VARCHAR(50) NOT NULL, + model VARCHAR(100) DEFAULT '', + language VARCHAR(20) DEFAULT 'en-US', + status VARCHAR(20) DEFAULT 'completed', + result_json TEXT DEFAULT '', + error_message TEXT DEFAULT '', + created_at TIMESTAMP DEFAULT NOW(), + completed_at TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS idx_analysis_tasks_user_id ON qd_analysis_tasks(user_id); + +-- ============================================================================= +-- 13. Backtest Runs +-- ============================================================================= + +CREATE TABLE IF NOT EXISTS qd_backtest_runs ( + id SERIAL PRIMARY KEY, + user_id INTEGER NOT NULL DEFAULT 1 REFERENCES qd_users(id) ON DELETE CASCADE, + indicator_id INTEGER, + market VARCHAR(50) NOT NULL DEFAULT '', + symbol VARCHAR(50) NOT NULL DEFAULT '', + timeframe VARCHAR(10) NOT NULL DEFAULT '', + start_date VARCHAR(20) NOT NULL DEFAULT '', + end_date VARCHAR(20) NOT NULL DEFAULT '', + initial_capital DECIMAL(20,8) DEFAULT 10000, + commission DECIMAL(10,6) DEFAULT 0.001, + slippage DECIMAL(10,6) DEFAULT 0, + leverage INTEGER DEFAULT 1, + trade_direction VARCHAR(20) DEFAULT 'long', + strategy_config TEXT DEFAULT '', + status VARCHAR(20) DEFAULT 'success', + error_message TEXT DEFAULT '', + result_json TEXT DEFAULT '', + created_at TIMESTAMP DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_backtest_runs_user_id ON qd_backtest_runs(user_id); +CREATE INDEX IF NOT EXISTS idx_backtest_runs_indicator_id ON qd_backtest_runs(indicator_id); + +-- ============================================================================= +-- 14. Exchange Credentials +-- ============================================================================= + +CREATE TABLE IF NOT EXISTS qd_exchange_credentials ( + id SERIAL PRIMARY KEY, + user_id INTEGER NOT NULL DEFAULT 1 REFERENCES qd_users(id) ON DELETE CASCADE, + name VARCHAR(100) DEFAULT '', + exchange_id VARCHAR(50) NOT NULL, + api_key_hint VARCHAR(50) DEFAULT '', + encrypted_config TEXT NOT NULL, + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_exchange_credentials_user_id ON qd_exchange_credentials(user_id); + +-- ============================================================================= +-- 15. Manual Positions +-- ============================================================================= + +CREATE TABLE IF NOT EXISTS qd_manual_positions ( + id SERIAL PRIMARY KEY, + user_id INTEGER NOT NULL DEFAULT 1 REFERENCES qd_users(id) ON DELETE CASCADE, + market VARCHAR(50) NOT NULL, + symbol VARCHAR(50) NOT NULL, + name VARCHAR(100) DEFAULT '', + side VARCHAR(10) DEFAULT 'long', + quantity DECIMAL(20,8) NOT NULL DEFAULT 0, + entry_price DECIMAL(20,8) NOT NULL DEFAULT 0, + entry_time BIGINT, + notes TEXT DEFAULT '', + tags TEXT DEFAULT '', + group_name VARCHAR(100) DEFAULT '', + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW(), + UNIQUE(user_id, market, symbol, side) +); + +CREATE INDEX IF NOT EXISTS idx_manual_positions_user_id ON qd_manual_positions(user_id); + +-- ============================================================================= +-- 16. Position Alerts +-- ============================================================================= + +CREATE TABLE IF NOT EXISTS qd_position_alerts ( + id SERIAL PRIMARY KEY, + user_id INTEGER NOT NULL DEFAULT 1 REFERENCES qd_users(id) ON DELETE CASCADE, + position_id INTEGER, + market VARCHAR(50) DEFAULT '', + symbol VARCHAR(50) DEFAULT '', + alert_type VARCHAR(30) NOT NULL, + threshold DECIMAL(20,8) NOT NULL DEFAULT 0, + notification_config TEXT DEFAULT '', + is_active INTEGER DEFAULT 1, + is_triggered INTEGER DEFAULT 0, + last_triggered_at TIMESTAMP, + trigger_count INTEGER DEFAULT 0, + repeat_interval INTEGER DEFAULT 0, + notes TEXT DEFAULT '', + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_position_alerts_user_id ON qd_position_alerts(user_id); +CREATE INDEX IF NOT EXISTS idx_position_alerts_position_id ON qd_position_alerts(position_id); + +-- ============================================================================= +-- 17. Position Monitors +-- ============================================================================= + +CREATE TABLE IF NOT EXISTS qd_position_monitors ( + id SERIAL PRIMARY KEY, + user_id INTEGER NOT NULL DEFAULT 1 REFERENCES qd_users(id) ON DELETE CASCADE, + name VARCHAR(100) DEFAULT '', + position_ids TEXT DEFAULT '', + monitor_type VARCHAR(20) DEFAULT 'ai', + config TEXT DEFAULT '', + notification_config TEXT DEFAULT '', + is_active INTEGER DEFAULT 1, + last_run_at TIMESTAMP, + next_run_at TIMESTAMP, + last_result TEXT DEFAULT '', + run_count INTEGER DEFAULT 0, + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_position_monitors_user_id ON qd_position_monitors(user_id); + +-- ============================================================================= +-- 18. Market Symbols (Seed Data) +-- ============================================================================= + +CREATE TABLE IF NOT EXISTS qd_market_symbols ( + id SERIAL PRIMARY KEY, + market VARCHAR(50) NOT NULL, + symbol VARCHAR(50) NOT NULL, + name VARCHAR(255) DEFAULT '', + exchange VARCHAR(50) DEFAULT '', + currency VARCHAR(10) DEFAULT '', + is_active INTEGER DEFAULT 1, + is_hot INTEGER DEFAULT 0, + sort_order INTEGER DEFAULT 0, + created_at TIMESTAMP DEFAULT NOW(), + UNIQUE(market, symbol) +); + +CREATE INDEX IF NOT EXISTS idx_market_symbols_market ON qd_market_symbols(market); +CREATE INDEX IF NOT EXISTS idx_market_symbols_is_hot ON qd_market_symbols(market, is_hot); + +-- Seed data: Hot symbols for each market +INSERT INTO qd_market_symbols (market, symbol, name, exchange, currency, is_active, is_hot, sort_order) VALUES +-- AShare (China A-Shares) +('AShare', '000001', '平安银行', 'SZSE', 'CNY', 1, 1, 100), +('AShare', '000002', '万科A', 'SZSE', 'CNY', 1, 1, 99), +('AShare', '600000', '浦发银行', 'SSE', 'CNY', 1, 1, 98), +('AShare', '600036', '招商银行', 'SSE', 'CNY', 1, 1, 97), +('AShare', '600519', '贵州茅台', 'SSE', 'CNY', 1, 1, 96), +('AShare', '000858', '五粮液', 'SZSE', 'CNY', 1, 1, 95), +('AShare', '002415', '海康威视', 'SZSE', 'CNY', 1, 1, 94), +('AShare', '300059', '东方财富', 'SZSE', 'CNY', 1, 1, 93), +('AShare', '000725', '京东方A', 'SZSE', 'CNY', 1, 1, 92), +('AShare', '002594', '比亚迪', 'SZSE', 'CNY', 1, 1, 91), +-- USStock (US Stocks) +('USStock', 'AAPL', 'Apple Inc.', 'NASDAQ', 'USD', 1, 1, 100), +('USStock', 'MSFT', 'Microsoft Corporation', 'NASDAQ', 'USD', 1, 1, 99), +('USStock', 'GOOGL', 'Alphabet Inc.', 'NASDAQ', 'USD', 1, 1, 98), +('USStock', 'AMZN', 'Amazon.com Inc.', 'NASDAQ', 'USD', 1, 1, 97), +('USStock', 'TSLA', 'Tesla, Inc.', 'NASDAQ', 'USD', 1, 1, 96), +('USStock', 'META', 'Meta Platforms Inc.', 'NASDAQ', 'USD', 1, 1, 95), +('USStock', 'NVDA', 'NVIDIA Corporation', 'NASDAQ', 'USD', 1, 1, 94), +('USStock', 'JPM', 'JPMorgan Chase & Co.', 'NYSE', 'USD', 1, 1, 93), +('USStock', 'V', 'Visa Inc.', 'NYSE', 'USD', 1, 1, 92), +('USStock', 'JNJ', 'Johnson & Johnson', 'NYSE', 'USD', 1, 1, 91), +-- HShare (Hong Kong Stocks) +('HShare', '00700', 'Tencent Holdings', 'HKEX', 'HKD', 1, 1, 100), +('HShare', '09988', 'Alibaba Group', 'HKEX', 'HKD', 1, 1, 99), +('HShare', '03690', 'Meituan', 'HKEX', 'HKD', 1, 1, 98), +('HShare', '01810', 'Xiaomi Corporation', 'HKEX', 'HKD', 1, 1, 97), +('HShare', '02318', 'Ping An Insurance', 'HKEX', 'HKD', 1, 1, 96), +('HShare', '01398', 'ICBC', 'HKEX', 'HKD', 1, 1, 95), +('HShare', '00939', 'CCB', 'HKEX', 'HKD', 1, 1, 94), +('HShare', '01299', 'AIA Group', 'HKEX', 'HKD', 1, 1, 93), +('HShare', '02020', 'Anta Sports', 'HKEX', 'HKD', 1, 1, 92), +('HShare', '01024', 'Kuaishou Technology', 'HKEX', 'HKD', 1, 1, 91), +-- Crypto +('Crypto', 'BTC/USDT', 'Bitcoin', 'Binance', 'USDT', 1, 1, 100), +('Crypto', 'ETH/USDT', 'Ethereum', 'Binance', 'USDT', 1, 1, 99), +('Crypto', 'BNB/USDT', 'BNB', 'Binance', 'USDT', 1, 1, 98), +('Crypto', 'SOL/USDT', 'Solana', 'Binance', 'USDT', 1, 1, 97), +('Crypto', 'XRP/USDT', 'Ripple', 'Binance', 'USDT', 1, 1, 96), +('Crypto', 'ADA/USDT', 'Cardano', 'Binance', 'USDT', 1, 1, 95), +('Crypto', 'DOGE/USDT', 'Dogecoin', 'Binance', 'USDT', 1, 1, 94), +('Crypto', 'DOT/USDT', 'Polkadot', 'Binance', 'USDT', 1, 1, 93), +('Crypto', 'MATIC/USDT', 'Polygon', 'Binance', 'USDT', 1, 1, 92), +('Crypto', 'AVAX/USDT', 'Avalanche', 'Binance', 'USDT', 1, 1, 91), +-- Forex +('Forex', 'XAUUSD', 'Gold/USD', 'Forex', 'USD', 1, 1, 100), +('Forex', 'XAGUSD', 'Silver/USD', 'Forex', 'USD', 1, 1, 99), +('Forex', 'EURUSD', 'Euro/US Dollar', 'Forex', 'USD', 1, 1, 98), +('Forex', 'GBPUSD', 'British Pound/US Dollar', 'Forex', 'USD', 1, 1, 97), +('Forex', 'USDJPY', 'US Dollar/Japanese Yen', 'Forex', 'USD', 1, 1, 96), +('Forex', 'AUDUSD', 'Australian Dollar/US Dollar', 'Forex', 'USD', 1, 1, 95), +('Forex', 'USDCAD', 'US Dollar/Canadian Dollar', 'Forex', 'USD', 1, 1, 94), +('Forex', 'NZDUSD', 'New Zealand Dollar/US Dollar', 'Forex', 'USD', 1, 1, 93), +('Forex', 'USDCHF', 'US Dollar/Swiss Franc', 'Forex', 'EUR', 1, 1, 92), +('Forex', 'EURJPY', 'Euro/Japanese Yen', 'Forex', 'EUR', 1, 1, 91), +-- Futures +('Futures', 'CL', 'WTI Crude Oil', 'NYMEX', 'USD', 1, 1, 100), +('Futures', 'GC', 'Gold', 'COMEX', 'USD', 1, 1, 99), +('Futures', 'SI', 'Silver', 'COMEX', 'USD', 1, 1, 98), +('Futures', 'NG', 'Natural Gas', 'NYMEX', 'USD', 1, 1, 97), +('Futures', 'HG', 'Copper', 'COMEX', 'USD', 1, 1, 96), +('Futures', 'ZC', 'Corn', 'CBOT', 'USD', 1, 1, 95), +('Futures', 'ZS', 'Soybeans', 'CBOT', 'USD', 1, 1, 94), +('Futures', 'ZW', 'Wheat', 'CBOT', 'USD', 1, 1, 93), +('Futures', 'ES', 'S&P 500 E-mini', 'CME', 'USD', 1, 1, 92), +('Futures', 'NQ', 'NASDAQ 100 E-mini', 'CME', 'USD', 1, 1, 91) +ON CONFLICT (market, symbol) DO NOTHING; + +-- ============================================================================= +-- 19. Agent Memories (AI Learning System) +-- ============================================================================= +-- Stores agent decision experiences for RAG-style retrieval during analysis. +-- Each agent (trader, risk_analyst, etc.) shares this table but is identified by agent_name. + +CREATE TABLE IF NOT EXISTS qd_agent_memories ( + id SERIAL PRIMARY KEY, + agent_name VARCHAR(100) NOT NULL, + situation TEXT NOT NULL, + recommendation TEXT NOT NULL, + result TEXT, + returns REAL, + market VARCHAR(50), + symbol VARCHAR(50), + timeframe VARCHAR(20), + features_json TEXT, + embedding BYTEA, + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_agent_memories_agent ON qd_agent_memories(agent_name); +CREATE INDEX IF NOT EXISTS idx_agent_memories_created ON qd_agent_memories(agent_name, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_agent_memories_market ON qd_agent_memories(agent_name, market, symbol); + +-- ============================================================================= +-- 20. Reflection Records (AI Auto-Verification System) +-- ============================================================================= +-- Records analysis predictions for future auto-verification and closed-loop learning. + +CREATE TABLE IF NOT EXISTS qd_reflection_records ( + id SERIAL PRIMARY KEY, + market VARCHAR(50) NOT NULL, + symbol VARCHAR(50) NOT NULL, + initial_price REAL, + decision VARCHAR(20), + confidence INTEGER, + reasoning TEXT, + analysis_date TIMESTAMP DEFAULT NOW(), + target_check_date TIMESTAMP, + status VARCHAR(20) DEFAULT 'PENDING', + final_price REAL, + actual_return REAL, + check_result TEXT +); + +CREATE INDEX IF NOT EXISTS idx_reflection_status ON qd_reflection_records(status, target_check_date); +CREATE INDEX IF NOT EXISTS idx_reflection_market ON qd_reflection_records(market, symbol); + +-- ============================================================================= +-- Completion Notice +-- ============================================================================= +DO $$ +BEGIN + RAISE NOTICE 'QuantDinger PostgreSQL schema initialized successfully!'; +END $$; diff --git a/backend_api_python/requirements.txt b/backend_api_python/requirements.txt index f51e72b17..5ffacad60 100644 --- a/backend_api_python/requirements.txt +++ b/backend_api_python/requirements.txt @@ -11,9 +11,13 @@ pymysql>=1.0.2 SQLAlchemy>=2.0.0 PyJWT==2.8.0 python-dotenv>=1.0.1 +# PostgreSQL support (multi-user mode) +psycopg2-binary>=2.9.9 +# Password hashing +bcrypt>=4.1.0 # Interactive Brokers trading (optional, for US/HK stock trading via TWS/IB Gateway) ib_insync>=0.9.86 # MetaTrader 5 trading (optional, for forex trading via MT5 terminal, Windows only) # Note: MetaTrader5 is Windows-only and not available on Linux/macOS # Install separately on Windows: pip install MetaTrader5>=5.0.45 -# MetaTrader5>=5.0.45 \ No newline at end of file +# MetaTrader5>=5.0.45 diff --git a/backend_api_python/run.py b/backend_api_python/run.py index 284e27223..6f54bab44 100644 --- a/backend_api_python/run.py +++ b/backend_api_python/run.py @@ -83,6 +83,15 @@ def main(): """启动应用""" # Keep startup messages ASCII-only and short. print("QuantDinger Python API v2.0.0") + + # Check demo mode status for debugging + demo_status = os.getenv('IS_DEMO_MODE', 'false').lower() + print(f"Status Check: IS_DEMO_MODE={demo_status}") + if demo_status == 'true': + print("!!! RUNNING IN DEMO MODE (READ-ONLY) !!!") + else: + print("Running in FULL ACCESS mode") + print(f"Service starting at: http://{Config.HOST}:{Config.PORT}") # Flask dev server is for local development only. diff --git a/docker-compose.yml b/docker-compose.yml index 3873ced1c..4d8036d20 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -4,6 +4,30 @@ version: '3.8' services: + # PostgreSQL Database + postgres: + image: postgres:16-alpine + container_name: quantdinger-db + restart: unless-stopped + environment: + POSTGRES_DB: ${POSTGRES_DB:-quantdinger} + POSTGRES_USER: ${POSTGRES_USER:-quantdinger} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-quantdinger123} + TZ: Asia/Shanghai + volumes: + - postgres_data:/var/lib/postgresql/data + - ./backend_api_python/migrations/init.sql:/docker-entrypoint-initdb.d/01-init.sql + - ./backend_api_python/migrations:/migrations + ports: + - "127.0.0.1:5432:5432" + networks: + - quantdinger-network + healthcheck: + test: ["CMD-SHELL", "pg_isready -U quantdinger -d quantdinger"] + interval: 10s + timeout: 5s + retries: 5 + # Backend API Service backend: build: @@ -11,21 +35,24 @@ services: dockerfile: Dockerfile container_name: quantdinger-backend restart: unless-stopped + depends_on: + postgres: + condition: service_healthy ports: - "127.0.0.1:5000:5000" volumes: - # Persistent database and logs - # - ./backend_api_python/quantdinger.db:/app/quantdinger.db + # Persistent logs - ./backend_api_python/logs:/app/logs - ./backend_api_python/data:/app/data # Configuration file (optional, for development) - # NOTE: must be writable for /api/settings/save to persist changes - ./backend_api_python/.env:/app/.env environment: - PYTHON_API_HOST=0.0.0.0 - PYTHON_API_PORT=5000 - TZ=Asia/Shanghai - - SQLITE_DATABASE_FILE=/app/data/quantdinger.db + # Database connection + - DATABASE_URL=postgresql://${POSTGRES_USER:-quantdinger}:${POSTGRES_PASSWORD:-quantdinger123}@postgres:5432/${POSTGRES_DB:-quantdinger} + - DB_TYPE=postgresql networks: - quantdinger-network healthcheck: @@ -53,11 +80,10 @@ services: timeout: 10s retries: 3 +volumes: + postgres_data: + driver: local + networks: quantdinger-network: driver: bridge - -# Optional: Add data volumes (for more complex persistence) -# volumes: -# backend-data: -# backend-logs: diff --git a/docs/OAUTH_CONFIG_CN.md b/docs/OAUTH_CONFIG_CN.md new file mode 100644 index 000000000..ca98782af --- /dev/null +++ b/docs/OAUTH_CONFIG_CN.md @@ -0,0 +1,227 @@ +# OAuth 第三方登录配置指南 + +本文档介绍如何配置 Google 和 GitHub 第三方登录,以及 Cloudflare Turnstile 人机验证。 + +## 目录 + +- [Google OAuth 配置](#google-oauth-配置) +- [GitHub OAuth 配置](#github-oauth-配置) +- [Cloudflare Turnstile 配置](#cloudflare-turnstile-配置) +- [部署配置说明](#部署配置说明) +- [常见问题](#常见问题) + +--- + +## Google OAuth 配置 + +### 步骤 1:创建 Google Cloud 项目 + +1. 访问 [Google Cloud Console](https://console.cloud.google.com/) +2. 点击顶部的项目选择器,然后点击「新建项目」 +3. 输入项目名称(如 `QuantDinger`),点击「创建」 + +### 步骤 2:配置 OAuth 同意屏幕 + +1. 在左侧菜单中,选择「API 和服务」→「OAuth 同意屏幕」 +2. 选择用户类型: + - **外部**:允许任何 Google 账户登录(推荐) + - **内部**:仅限组织内用户(需要 Google Workspace) +3. 填写应用信息: + - 应用名称:`QuantDinger` + - 用户支持电子邮件:您的邮箱 + - 开发者联系信息:您的邮箱 +4. 点击「保存并继续」,跳过「范围」和「测试用户」,完成配置 + +### 步骤 3:创建 OAuth 2.0 客户端 ID + +1. 在左侧菜单中,选择「API 和服务」→「凭据」 +2. 点击「+ 创建凭据」→「OAuth 客户端 ID」 +3. 选择应用类型:**Web 应用** +4. 填写名称:`QuantDinger Web Client` +5. 添加「已授权的重定向 URI」: + ``` + http://localhost:5000/api/auth/oauth/google/callback + ``` + > 部署到服务器后,需要添加生产环境的 URI(见下文) +6. 点击「创建」 +7. 复制生成的 **客户端 ID** 和 **客户端密钥** + +### 步骤 4:配置 .env 文件 + +```bash +# Google OAuth +GOOGLE_CLIENT_ID=你的客户端ID.apps.googleusercontent.com +GOOGLE_CLIENT_SECRET=你的客户端密钥 +GOOGLE_REDIRECT_URI=http://localhost:5000/api/auth/oauth/google/callback +``` + +--- + +## GitHub OAuth 配置 + +### 步骤 1:创建 GitHub OAuth App + +1. 访问 [GitHub Developer Settings](https://github.com/settings/developers) +2. 点击「OAuth Apps」→「New OAuth App」 +3. 填写应用信息: + - **Application name**:`QuantDinger` + - **Homepage URL**:`http://localhost:8080`(或您的域名) + - **Authorization callback URL**: + ``` + http://localhost:5000/api/auth/oauth/github/callback + ``` +4. 点击「Register application」 + +### 步骤 2:获取凭据 + +1. 在应用详情页面,复制 **Client ID** +2. 点击「Generate a new client secret」生成密钥 +3. 立即复制 **Client Secret**(只显示一次) + +### 步骤 3:配置 .env 文件 + +```bash +# GitHub OAuth +GITHUB_CLIENT_ID=你的Client_ID +GITHUB_CLIENT_SECRET=你的Client_Secret +GITHUB_REDIRECT_URI=http://localhost:5000/api/auth/oauth/github/callback +``` + +--- + +## Cloudflare Turnstile 配置 + +Turnstile 是 Cloudflare 提供的免费、隐私友好的人机验证服务,用于防止机器人攻击。 + +### 步骤 1:创建 Turnstile Widget + +1. 访问 [Cloudflare Turnstile](https://dash.cloudflare.com/?to=/:account/turnstile) +2. 点击「Add site」 +3. 填写信息: + - **Site name**:`QuantDinger` + - **Domain**:添加您的域名(本地开发可添加 `localhost`) + - **Widget Mode**:选择 `Managed`(推荐)或 `Invisible` +4. 点击「Create」 + +### 步骤 2:获取密钥 + +创建成功后,您将看到: +- **Site Key**:前端使用,可公开 +- **Secret Key**:后端使用,需保密 + +### 步骤 3:配置 .env 文件 + +```bash +# Cloudflare Turnstile +TURNSTILE_SITE_KEY=你的Site_Key +TURNSTILE_SECRET_KEY=你的Secret_Key +``` + +--- + +## 部署配置说明 + +当您将应用部署到服务器并绑定域名时,需要更新配置。 + +### 场景 1:前后端同域(推荐) + +假设您的域名是 `yourdomain.com`,前后端通过 nginx 反向代理部署在同一域名下: +- 前端:`https://yourdomain.com` +- 后端 API:`https://yourdomain.com/api` + +**.env 配置:** +```bash +# 前端地址(OAuth 成功后跳转) +FRONTEND_URL=https://yourdomain.com + +# Google OAuth +GOOGLE_REDIRECT_URI=https://yourdomain.com/api/auth/oauth/google/callback + +# GitHub OAuth +GITHUB_REDIRECT_URI=https://yourdomain.com/api/auth/oauth/github/callback +``` + +### 场景 2:前后端分离域名 + +假设: +- 前端:`https://yourdomain.com` +- 后端 API:`https://api.yourdomain.com` + +**.env 配置:** +```bash +FRONTEND_URL=https://yourdomain.com + +GOOGLE_REDIRECT_URI=https://api.yourdomain.com/api/auth/oauth/google/callback + +GITHUB_REDIRECT_URI=https://api.yourdomain.com/api/auth/oauth/github/callback +``` + +### 更新 OAuth 提供商配置 + +部署后,您还需要在 OAuth 提供商后台更新回调地址: + +**Google Cloud Console:** +1. 访问「凭据」页面 +2. 编辑您的 OAuth 客户端 +3. 在「已授权的重定向 URI」中添加生产环境地址: + ``` + https://yourdomain.com/api/auth/oauth/google/callback + ``` + +**GitHub Developer Settings:** +1. 编辑您的 OAuth App +2. 更新「Authorization callback URL」为生产环境地址 + +### Turnstile 域名配置 + +在 Cloudflare Turnstile 控制台中,确保添加了您的生产域名: +- `yourdomain.com` +- `www.yourdomain.com`(如果使用) + +--- + +## 常见问题 + +### Q: OAuth 登录跳转后显示错误「redirect_uri_mismatch」 + +**A:** 回调地址不匹配。请检查: +1. `.env` 中的 `GOOGLE_REDIRECT_URI` 或 `GITHUB_REDIRECT_URI` +2. OAuth 提供商后台配置的回调地址 +3. 两者必须完全一致(包括 http/https、端口、路径) + +### Q: Turnstile 验证一直失败 + +**A:** 请检查: +1. `TURNSTILE_SITE_KEY` 和 `TURNSTILE_SECRET_KEY` 是否正确 +2. 当前域名是否已添加到 Turnstile 的域名列表 +3. 本地开发时,确保添加了 `localhost` + +### Q: 如何禁用第三方登录? + +**A:** 在 `.env` 中留空相关配置即可: +```bash +GOOGLE_CLIENT_ID= +GOOGLE_CLIENT_SECRET= +GITHUB_CLIENT_ID= +GITHUB_CLIENT_SECRET= +``` +系统会自动隐藏第三方登录按钮。 + +### Q: 如何禁用用户注册? + +**A:** 在 `.env` 中设置: +```bash +ENABLE_REGISTRATION=false +``` + +### Q: OAuth 登录成功但无法跳转回前端 + +**A:** 请检查 `FRONTEND_URL` 配置是否正确,确保是前端页面的完整地址(包含协议)。 + +--- + +## 相关链接 + +- [Google Cloud Console](https://console.cloud.google.com/apis/credentials) +- [GitHub Developer Settings](https://github.com/settings/developers) +- [Cloudflare Turnstile](https://dash.cloudflare.com/?to=/:account/turnstile) diff --git a/docs/OAUTH_CONFIG_EN.md b/docs/OAUTH_CONFIG_EN.md new file mode 100644 index 000000000..eca3d2571 --- /dev/null +++ b/docs/OAUTH_CONFIG_EN.md @@ -0,0 +1,227 @@ +# OAuth Third-Party Login Configuration Guide + +This document explains how to configure Google and GitHub OAuth login, as well as Cloudflare Turnstile CAPTCHA verification. + +## Table of Contents + +- [Google OAuth Configuration](#google-oauth-configuration) +- [GitHub OAuth Configuration](#github-oauth-configuration) +- [Cloudflare Turnstile Configuration](#cloudflare-turnstile-configuration) +- [Deployment Configuration](#deployment-configuration) +- [FAQ](#faq) + +--- + +## Google OAuth Configuration + +### Step 1: Create a Google Cloud Project + +1. Visit [Google Cloud Console](https://console.cloud.google.com/) +2. Click the project selector at the top, then click "New Project" +3. Enter a project name (e.g., `QuantDinger`), click "Create" + +### Step 2: Configure OAuth Consent Screen + +1. In the left menu, select "APIs & Services" → "OAuth consent screen" +2. Choose user type: + - **External**: Allows any Google account to login (recommended) + - **Internal**: Only for organization users (requires Google Workspace) +3. Fill in application information: + - App name: `QuantDinger` + - User support email: Your email + - Developer contact information: Your email +4. Click "Save and Continue", skip "Scopes" and "Test users", complete setup + +### Step 3: Create OAuth 2.0 Client ID + +1. In the left menu, select "APIs & Services" → "Credentials" +2. Click "+ Create Credentials" → "OAuth client ID" +3. Select application type: **Web application** +4. Enter name: `QuantDinger Web Client` +5. Add "Authorized redirect URIs": + ``` + http://localhost:5000/api/auth/oauth/google/callback + ``` + > After deploying to server, add production URI (see below) +6. Click "Create" +7. Copy the generated **Client ID** and **Client Secret** + +### Step 4: Configure .env File + +```bash +# Google OAuth +GOOGLE_CLIENT_ID=your-client-id.apps.googleusercontent.com +GOOGLE_CLIENT_SECRET=your-client-secret +GOOGLE_REDIRECT_URI=http://localhost:5000/api/auth/oauth/google/callback +``` + +--- + +## GitHub OAuth Configuration + +### Step 1: Create GitHub OAuth App + +1. Visit [GitHub Developer Settings](https://github.com/settings/developers) +2. Click "OAuth Apps" → "New OAuth App" +3. Fill in application information: + - **Application name**: `QuantDinger` + - **Homepage URL**: `http://localhost:8080` (or your domain) + - **Authorization callback URL**: + ``` + http://localhost:5000/api/auth/oauth/github/callback + ``` +4. Click "Register application" + +### Step 2: Get Credentials + +1. On the application details page, copy the **Client ID** +2. Click "Generate a new client secret" +3. Immediately copy the **Client Secret** (shown only once) + +### Step 3: Configure .env File + +```bash +# GitHub OAuth +GITHUB_CLIENT_ID=your-client-id +GITHUB_CLIENT_SECRET=your-client-secret +GITHUB_REDIRECT_URI=http://localhost:5000/api/auth/oauth/github/callback +``` + +--- + +## Cloudflare Turnstile Configuration + +Turnstile is a free, privacy-friendly CAPTCHA service provided by Cloudflare to prevent bot attacks. + +### Step 1: Create Turnstile Widget + +1. Visit [Cloudflare Turnstile](https://dash.cloudflare.com/?to=/:account/turnstile) +2. Click "Add site" +3. Fill in information: + - **Site name**: `QuantDinger` + - **Domain**: Add your domain (for local development, add `localhost`) + - **Widget Mode**: Select `Managed` (recommended) or `Invisible` +4. Click "Create" + +### Step 2: Get Keys + +After creation, you will see: +- **Site Key**: Used by frontend, can be public +- **Secret Key**: Used by backend, keep it secret + +### Step 3: Configure .env File + +```bash +# Cloudflare Turnstile +TURNSTILE_SITE_KEY=your-site-key +TURNSTILE_SECRET_KEY=your-secret-key +``` + +--- + +## Deployment Configuration + +When deploying to a server with a domain name, you need to update the configuration. + +### Scenario 1: Same Domain for Frontend and Backend (Recommended) + +Assuming your domain is `yourdomain.com`, with frontend and backend deployed under the same domain via nginx reverse proxy: +- Frontend: `https://yourdomain.com` +- Backend API: `https://yourdomain.com/api` + +**.env Configuration:** +```bash +# Frontend URL (redirect after OAuth success) +FRONTEND_URL=https://yourdomain.com + +# Google OAuth +GOOGLE_REDIRECT_URI=https://yourdomain.com/api/auth/oauth/google/callback + +# GitHub OAuth +GITHUB_REDIRECT_URI=https://yourdomain.com/api/auth/oauth/github/callback +``` + +### Scenario 2: Separate Domains for Frontend and Backend + +Assuming: +- Frontend: `https://yourdomain.com` +- Backend API: `https://api.yourdomain.com` + +**.env Configuration:** +```bash +FRONTEND_URL=https://yourdomain.com + +GOOGLE_REDIRECT_URI=https://api.yourdomain.com/api/auth/oauth/google/callback + +GITHUB_REDIRECT_URI=https://api.yourdomain.com/api/auth/oauth/github/callback +``` + +### Update OAuth Provider Settings + +After deployment, you also need to update callback URLs in OAuth provider dashboards: + +**Google Cloud Console:** +1. Go to "Credentials" page +2. Edit your OAuth client +3. Add production URL in "Authorized redirect URIs": + ``` + https://yourdomain.com/api/auth/oauth/google/callback + ``` + +**GitHub Developer Settings:** +1. Edit your OAuth App +2. Update "Authorization callback URL" to production URL + +### Turnstile Domain Configuration + +In Cloudflare Turnstile dashboard, make sure to add your production domains: +- `yourdomain.com` +- `www.yourdomain.com` (if used) + +--- + +## FAQ + +### Q: OAuth login shows "redirect_uri_mismatch" error + +**A:** Callback URL mismatch. Please check: +1. `GOOGLE_REDIRECT_URI` or `GITHUB_REDIRECT_URI` in `.env` +2. Callback URL configured in OAuth provider dashboard +3. Both must match exactly (including http/https, port, path) + +### Q: Turnstile verification keeps failing + +**A:** Please check: +1. Are `TURNSTILE_SITE_KEY` and `TURNSTILE_SECRET_KEY` correct? +2. Is your current domain added to Turnstile's domain list? +3. For local development, make sure `localhost` is added + +### Q: How to disable third-party login? + +**A:** Leave the related configuration empty in `.env`: +```bash +GOOGLE_CLIENT_ID= +GOOGLE_CLIENT_SECRET= +GITHUB_CLIENT_ID= +GITHUB_CLIENT_SECRET= +``` +The system will automatically hide third-party login buttons. + +### Q: How to disable user registration? + +**A:** Set in `.env`: +```bash +ENABLE_REGISTRATION=false +``` + +### Q: OAuth login succeeds but cannot redirect back to frontend + +**A:** Please check if `FRONTEND_URL` is configured correctly, make sure it's the complete frontend page URL (including protocol). + +--- + +## Related Links + +- [Google Cloud Console](https://console.cloud.google.com/apis/credentials) +- [GitHub Developer Settings](https://github.com/settings/developers) +- [Cloudflare Turnstile](https://dash.cloudflare.com/?to=/:account/turnstile) diff --git a/docs/multi-user-setup.md b/docs/multi-user-setup.md new file mode 100644 index 000000000..16071fe3d --- /dev/null +++ b/docs/multi-user-setup.md @@ -0,0 +1,213 @@ +# Multi-User System Setup Guide + +This guide explains how to configure QuantDinger for multi-user mode with PostgreSQL database. + +## Architecture Overview + +``` +┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ +│ Frontend │────▶│ Backend API │────▶│ PostgreSQL │ +│ (Vue.js) │ │ (Flask) │ │ Database │ +└─────────────────┘ └─────────────────┘ └─────────────────┘ + │ + ┌──────┴──────┐ + │ User Auth │ + │ JWT Token │ + │ Role-based │ + │ Access │ + └─────────────┘ +``` + +## Quick Start (Docker) + +### 1. Update docker-compose.yml + +The new `docker-compose.yml` already includes PostgreSQL. Just set the password: + +```bash +# Create .env file in project root +cat > .env << EOF +POSTGRES_USER=quantdinger +POSTGRES_PASSWORD=your_secure_password_here +POSTGRES_DB=quantdinger +EOF +``` + +### 2. Start Services + +```bash +docker-compose up -d +``` + +This will: +- Start PostgreSQL database +- Initialize schema automatically (via `init.sql`) +- Start backend API connected to PostgreSQL +- Start frontend + +### 3. Default Credentials + +- **Username**: `admin` +- **Password**: `admin123` + +**Important**: Change the admin password immediately after first login! + +## Manual Setup (Development) + +### 1. Install PostgreSQL + +```bash +# Ubuntu/Debian +sudo apt install postgresql postgresql-contrib + +# macOS +brew install postgresql + +# Windows +# Download from https://www.postgresql.org/download/windows/ +``` + +### 2. Create Database + +```bash +# Connect to PostgreSQL +sudo -u postgres psql + +# Create database and user +CREATE DATABASE quantdinger; +CREATE USER quantdinger WITH ENCRYPTED PASSWORD 'your_password'; +GRANT ALL PRIVILEGES ON DATABASE quantdinger TO quantdinger; +\q +``` + +### 3. Initialize Schema + +```bash +# Run init.sql +psql -U quantdinger -d quantdinger -f backend_api_python/migrations/init.sql +``` + +### 4. Configure Backend + +Create/update `backend_api_python/.env`: + +```bash +# Database Configuration +DB_TYPE=postgresql +DATABASE_URL=postgresql://quantdinger:your_password@localhost:5432/quantdinger + +# Disable single-user legacy mode +SINGLE_USER_MODE=false +``` + +### 5. Install Dependencies + +```bash +cd backend_api_python +pip install -r requirements.txt +``` + +### 6. Start Backend + +```bash +python run.py +``` + +## Migration from SQLite + +If you have existing data in SQLite: + +```bash +# Set environment variables +export DATABASE_URL=postgresql://quantdinger:your_password@localhost:5432/quantdinger + +# Run migration script +python scripts/migrate_sqlite_to_postgres.py +``` + +## User Roles & Permissions + +| Role | Permissions | +|---------|-------------| +| admin | Full access, user management, settings | +| manager | Strategy, backtest, portfolio, settings | +| user | Strategy, backtest, portfolio (own data) | +| viewer | View only (dashboard) | + +## API Endpoints + +### Authentication + +``` +POST /api/user/login - Login +POST /api/user/logout - Logout +GET /api/user/info - Get current user info +``` + +### User Management (Admin only) + +``` +GET /api/users/list - List all users +GET /api/users/detail?id= - Get user detail +POST /api/users/create - Create user +PUT /api/users/update?id= - Update user +DELETE /api/users/delete?id= - Delete user +POST /api/users/reset-password - Reset user password +GET /api/users/roles - Get available roles +``` + +### Self-Service + +``` +GET /api/users/profile - Get own profile +PUT /api/users/profile/update - Update own profile +POST /api/users/change-password - Change own password +``` + +## Security Recommendations + +1. **Change default admin password** immediately +2. **Use strong passwords** (min 12 characters) +3. **Enable HTTPS** in production +4. **Restrict database access** to backend only +5. **Regular backups** of PostgreSQL data + +## Troubleshooting + +### Cannot connect to PostgreSQL + +```bash +# Check PostgreSQL is running +sudo systemctl status postgresql + +# Check connection +psql -U quantdinger -d quantdinger -c "SELECT 1" +``` + +### Migration fails + +```bash +# Check SQLite path +ls -la backend_api_python/data/quantdinger.db + +# Check PostgreSQL tables +psql -U quantdinger -d quantdinger -c "\dt" +``` + +### Token invalid after restart + +JWT tokens are validated using `SECRET_KEY`. Ensure the same key is used: + +```bash +# Generate a secure key +python -c "import secrets; print(secrets.token_hex(32))" + +# Set in .env +SECRET_KEY=your_generated_key +``` + +## Legacy Support + +The system now requires PostgreSQL for multi-user support. SQLite is no longer supported. + +If you need to migrate from an older SQLite-based installation, contact the project maintainers for assistance. diff --git a/quantdinger_vue/public/avatar2.jpg b/quantdinger_vue/public/avatar2.jpg index 9adb2d1b8..59dc7ffb6 100644 Binary files a/quantdinger_vue/public/avatar2.jpg and b/quantdinger_vue/public/avatar2.jpg differ diff --git a/quantdinger_vue/src/api/auth.js b/quantdinger_vue/src/api/auth.js new file mode 100644 index 000000000..5212a6f9f --- /dev/null +++ b/quantdinger_vue/src/api/auth.js @@ -0,0 +1,131 @@ +import request from '@/utils/request' + +function joinApiBase (path) { + const base = (process.env.VUE_APP_API_BASE_URL || '').trim() + const p = path.startsWith('/') ? path : `/${path}` + if (!base) return p + + const b = base.replace(/\/+$/, '') + // Avoid duplicate "/api/api/*" when base is "/api" or ends with "/api" + if (b.endsWith('/api') && p.startsWith('/api/')) { + return b + p.slice('/api'.length) + } + return b + p +} + +/** + * Get security configuration (Turnstile, OAuth settings) + */ +export function getSecurityConfig () { + return request({ + url: '/api/auth/security-config', + method: 'get' + }) +} + +/** + * User login + * @param {Object} data - { username, password, turnstile_token } + */ +export function login (data) { + return request({ + url: '/api/auth/login', + method: 'post', + data + }) +} + +/** + * User logout + */ +export function logout () { + return request({ + url: '/api/auth/logout', + method: 'post' + }) +} + +/** + * Get current user info + */ +export function getUserInfo () { + return request({ + url: '/api/auth/info', + method: 'get' + }) +} + +/** + * Send verification code + * @param {Object} data - { email, type, turnstile_token } + * type: 'register' | 'login' | 'reset_password' | 'change_password' | 'change_email' + */ +export function sendVerificationCode (data) { + return request({ + url: '/api/auth/send-code', + method: 'post', + data + }) +} + +/** + * Login with email verification code (quick login) + * @param {Object} data - { email, code, turnstile_token } + */ +export function loginWithCode (data) { + return request({ + url: '/api/auth/login-code', + method: 'post', + data + }) +} + +/** + * User registration + * @param {Object} data - { email, code, username, password, turnstile_token } + */ +export function register (data) { + return request({ + url: '/api/auth/register', + method: 'post', + data + }) +} + +/** + * Reset password + * @param {Object} data - { email, code, new_password, turnstile_token } + */ +export function resetPassword (data) { + return request({ + url: '/api/auth/reset-password', + method: 'post', + data + }) +} + +/** + * Change password (for logged-in users) + * @param {Object} data - { code, new_password } + */ +export function changePassword (data) { + return request({ + url: '/api/auth/change-password', + method: 'post', + data + }) +} + +/** + * Get Google OAuth URL + */ +export function getGoogleOAuthUrl () { + return joinApiBase('/api/auth/oauth/google') +} + +/** + * Get GitHub OAuth URL + */ +export function getGitHubOAuthUrl () { + return joinApiBase('/api/auth/oauth/github') +} diff --git a/quantdinger_vue/src/api/login.js b/quantdinger_vue/src/api/login.js index d6db492bf..5bd228f39 100644 --- a/quantdinger_vue/src/api/login.js +++ b/quantdinger_vue/src/api/login.js @@ -1,9 +1,9 @@ import request from '@/utils/request' const userApi = { - Login: '/api/user/login', - Logout: '/api/user/logout', - UserInfo: '/api/user/info', + Login: '/api/auth/login', + Logout: '/api/auth/logout', + UserInfo: '/api/auth/info', UserMenu: '/user/nav' } diff --git a/quantdinger_vue/src/api/market.js b/quantdinger_vue/src/api/market.js index bc01a8d13..01ada5170 100644 --- a/quantdinger_vue/src/api/market.js +++ b/quantdinger_vue/src/api/market.js @@ -1,4 +1,4 @@ -import request from '@/utils/request' +import request, { ANALYSIS_TIMEOUT } from '@/utils/request' const marketApi = { // Watchlist @@ -11,6 +11,7 @@ const marketApi = { CreateAnalysisTask: '/api/analysis/createTask', GetAnalysisTaskStatus: '/api/analysis/getTaskStatus', GetAnalysisHistoryList: '/api/analysis/getHistoryList', + DeleteAnalysisTask: '/api/analysis/deleteTask', ReflectAnalysis: '/api/analysis/reflect', // AI chat (optional) ChatMessage: '/api/ai/chat/message', @@ -34,8 +35,8 @@ const marketApi = { export function getWatchlist (parameter) { return request({ url: marketApi.GetWatchlist, - method: 'post', - data: parameter + method: 'get', + params: parameter }) } @@ -73,8 +74,10 @@ export function removeWatchlist (parameter) { export function getWatchlistPrices (parameter) { return request({ url: marketApi.GetWatchlistPrices, - method: 'post', - data: parameter + method: 'get', + params: { + watchlist: JSON.stringify(parameter.watchlist || []) + } }) } @@ -99,8 +102,8 @@ export function chatMessage (parameter) { export function getChatHistory (parameter) { return request({ url: marketApi.GetChatHistory, - method: 'post', - data: parameter + method: 'get', + params: parameter }) } @@ -126,7 +129,8 @@ export function multiAnalysis (parameter) { return request({ url: marketApi.MultiAnalysis, method: 'post', - data: parameter + data: parameter, + timeout: ANALYSIS_TIMEOUT // Extended timeout for AI analysis }) } @@ -151,8 +155,8 @@ export function createAnalysisTask (parameter) { export function getAnalysisTaskStatus (parameter) { return request({ url: marketApi.GetAnalysisTaskStatus, - method: 'post', - data: parameter + method: 'get', + params: parameter }) } @@ -164,6 +168,19 @@ export function getAnalysisTaskStatus (parameter) { export function getAnalysisHistoryList (parameter) { return request({ url: marketApi.GetAnalysisHistoryList, + method: 'get', + params: parameter + }) +} + +/** + * Delete analysis task + * @param parameter { task_id: number } + * @returns {*} + */ +export function deleteAnalysisTask (parameter) { + return request({ + url: marketApi.DeleteAnalysisTask, method: 'post', data: parameter }) @@ -200,8 +217,7 @@ export function getConfig () { export function getMenuFooterConfig () { return request({ url: marketApi.GetMenuFooterConfig, - method: 'post', - data: {} + method: 'get' }) } @@ -224,8 +240,8 @@ export function getMarketTypes () { export function searchSymbols (parameter) { return request({ url: marketApi.SearchSymbols, - method: 'post', - data: parameter + method: 'get', + params: parameter }) } @@ -237,7 +253,7 @@ export function searchSymbols (parameter) { export function getHotSymbols (parameter) { return request({ url: marketApi.GetHotSymbols, - method: 'post', - data: parameter + method: 'get', + params: parameter }) } diff --git a/quantdinger_vue/src/api/user.js b/quantdinger_vue/src/api/user.js new file mode 100644 index 000000000..9a4f08ac9 --- /dev/null +++ b/quantdinger_vue/src/api/user.js @@ -0,0 +1,188 @@ +/** + * User Management API + */ +import request from '@/utils/request' + +// ==================== Admin APIs ==================== + +/** + * Get user list (admin only) + * @param {Object} params - { page, page_size, search } + */ +export function getUserList (params) { + return request({ + url: '/api/users/list', + method: 'get', + params + }) +} + +/** + * Get user detail (admin only) + * @param {Number} id - User ID + */ +export function getUserDetail (id) { + return request({ + url: '/api/users/detail', + method: 'get', + params: { id } + }) +} + +/** + * Create new user (admin only) + * @param {Object} data - { username, password, email, nickname, role } + */ +export function createUser (data) { + return request({ + url: '/api/users/create', + method: 'post', + data + }) +} + +/** + * Update user (admin only) + * @param {Number} id - User ID + * @param {Object} data - { email, nickname, role, status } + */ +export function updateUser (id, data) { + return request({ + url: '/api/users/update', + method: 'put', + params: { id }, + data + }) +} + +/** + * Delete user (admin only) + * @param {Number} id - User ID + */ +export function deleteUser (id) { + return request({ + url: '/api/users/delete', + method: 'delete', + params: { id } + }) +} + +/** + * Reset user password (admin only) + * @param {Object} data - { user_id, new_password } + */ +export function resetUserPassword (data) { + return request({ + url: '/api/users/reset-password', + method: 'post', + data + }) +} + +/** + * Get available roles + */ +export function getRoles () { + return request({ + url: '/api/users/roles', + method: 'get' + }) +} + +// ==================== Self-Service APIs ==================== + +/** + * Get current user profile + */ +export function getProfile () { + return request({ + url: '/api/users/profile', + method: 'get' + }) +} + +/** + * Update current user profile + * @param {Object} data - { nickname, email, avatar } + */ +export function updateProfile (data) { + return request({ + url: '/api/users/profile/update', + method: 'put', + data + }) +} + +/** + * Change current user password + * @param {Object} data - { old_password, new_password } + */ +export function changePassword (data) { + return request({ + url: '/api/users/change-password', + method: 'post', + data + }) +} + +/** + * Get current user's credits log + * @param {Object} params - { page, page_size } + */ +export function getMyCreditsLog (params) { + return request({ + url: '/api/users/my-credits-log', + method: 'get', + params + }) +} + +/** + * Get current user's referral list + * @param {Object} params - { page, page_size } + */ +export function getMyReferrals (params) { + return request({ + url: '/api/users/my-referrals', + method: 'get', + params + }) +} + +// ==================== Billing Management (Admin) ==================== + +/** + * Set user credits (admin only) + * @param {Object} data - { user_id, credits, remark } + */ +export function setUserCredits (data) { + return request({ + url: '/api/users/set-credits', + method: 'post', + data + }) +} + +/** + * Set user VIP status (admin only) + * @param {Object} data - { user_id, vip_days, vip_expires_at, remark } + */ +export function setUserVip (data) { + return request({ + url: '/api/users/set-vip', + method: 'post', + data + }) +} + +/** + * Get user credits log (admin only) + * @param {Object} params - { user_id, page, page_size } + */ +export function getUserCreditsLog (params) { + return request({ + url: '/api/users/credits-log', + method: 'get', + params + }) +} diff --git a/quantdinger_vue/src/components/GlobalHeader/AvatarDropdown.vue b/quantdinger_vue/src/components/GlobalHeader/AvatarDropdown.vue index 3f764485e..825514662 100644 --- a/quantdinger_vue/src/components/GlobalHeader/AvatarDropdown.vue +++ b/quantdinger_vue/src/components/GlobalHeader/AvatarDropdown.vue @@ -6,6 +6,11 @@ @@ -357,7 +369,7 @@ + + diff --git a/quantdinger_vue/src/views/trading-assistant/index.vue b/quantdinger_vue/src/views/trading-assistant/index.vue index 3e6051c7b..f6845f1fb 100644 --- a/quantdinger_vue/src/views/trading-assistant/index.vue +++ b/quantdinger_vue/src/views/trading-assistant/index.vue @@ -2409,8 +2409,8 @@ export default { // 使用和 indicator-analysis 页面相同的接口 const res = await request({ url: '/api/indicator/getIndicators', - method: 'post', - data: { + method: 'get', + params: { userid: userId } }) diff --git a/quantdinger_vue/src/views/user-manage/index.vue b/quantdinger_vue/src/views/user-manage/index.vue new file mode 100644 index 000000000..5172bb600 --- /dev/null +++ b/quantdinger_vue/src/views/user-manage/index.vue @@ -0,0 +1,840 @@ + + + + + diff --git a/quantdinger_vue/src/views/user/Login.vue b/quantdinger_vue/src/views/user/Login.vue index 708b498b8..b12b64e1d 100644 --- a/quantdinger_vue/src/views/user/Login.vue +++ b/quantdinger_vue/src/views/user/Login.vue @@ -5,37 +5,578 @@
+ +
+ +

{{ $t('user.oauth.processing') || 'Processing login...' }}

+
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + {{ $t('user.login.submit') || 'Login' }} + + + + + + + + + + + + + + + + + + + + + + + + + + + {{ codeLoginCountdown > 0 ? `${codeLoginCountdown}s` : ($t('user.login.sendCode') || 'Send') }} + + + + + + + + + {{ $t('user.login.submit') || 'Login' }} + + + + + + +
+ {{ $t('user.login.orLoginWith') || 'Or login with' }} +
+ + + + + + + + Google + + + + GitHub + +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + {{ registerCountdown > 0 ? `${registerCountdown}s` : ($t('user.register.sendCode') || 'Send') }} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {{ $t('user.register.submit') || 'Create Account' }} + + + +
+ + + +
+
+ + + + + + + + + + + + + + + + + + + + @@ -47,104 +588,764 @@ size="large" type="primary" htmlType="submit" - class="login-button" - :loading="state.loginBtn" - :disabled="state.loginBtn" + class="submit-button" + :loading="resetLoading" block - >Login + >{{ $t('user.resetPassword.submit') || 'Reset Password' }} - -