@@ -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의 개발을 지원할 수 있습니다.
+
+
---
-### 상용 서비스
+### 💝 직접 지원(기부)
-다음 전문 서비스를 제공합니다:
+귀하의 기여는 QuantDinger의 유지 및 개선에 도움이 됩니다.
-| 서비스 | 설명 |
-|---------|-------------|
-| **배포 및 설정** | 서버 배포, 구성 및 최적화에 대한 일대일 지원 |
-| **맞춤형 전략 개발** | 귀하의 특정 요구와 시장에 맞춘 거래 전략 설계 |
-| **엔터프라이즈 업그레이드** | 상용 라이선스, 우선 지원 및 비즈니스를 위한 고급 기능 |
-| **교육 및 컨설팅** | 거래 팀을 위한 실전 교육 세션 및 전략 컨설팅 |
+**암호화폐 기부 (ERC-20 / BEP-20 / Polygon / Arbitrum)**
-**관심이 있으십니까?** 다음을 통해 문의하십시오:
-- 📧 Email: [brokermr810@gmail.com](mailto:brokermr810@gmail.com)
-- 💬 Telegram: [QuantDinger Group](https://t.me/quantdinger)
+```
+0x96fa4962181bea077f8c7240efe46afbe73641a7
+```
+
+
---
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 的多智能體研究系統**,
---
-## 🏆 我們的合作夥伴與贊助商
-
-
@@ -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 的發展。
+
+
+
+
+
+
+
+
+
+ 全球最大的加密貨幣交易所
+ 現貨 • 期貨 • 槓桿交易
+ |
+
+
+
+
+
+ 領先的衍生品平台
+ 現貨 • 永續合約 • 期權
+ |
+
+
+
+
+
+ 創新的跟單交易
+ 現貨 • 期貨 • 社交交易
+ |
+
+
+
---
-### 商業服務
+### 💝 直接支持(捐贈)
-我們提供專業服務,助你充分利用 QuantDinger:
+你的貢獻幫助我們維護和改進 QuantDinger。
-| 服務 | 描述 |
-|---------|-------------|
-| **部署與設置** | 一對一協助服務器部署、配置和優化 |
-| **定制策略開發** | 針對特定需求和市場定制交易策略 |
-| **企業版升級** | 商業授權、優先支持和企業級高級功能 |
-| **培訓與咨詢** | 為你的交易團隊提供實戰培訓和戰略咨詢 |
+**加密貨幣捐贈 (ERC-20 / BEP-20 / Polygon / Arbitrum)**
-**感興趣?** 聯繫我們:
-- 📧 Email: [brokermr810@gmail.com](mailto:brokermr810@gmail.com)
-- 💬 Telegram: [QuantDinger Group](https://t.me/quantdinger)
+```
+0x96fa4962181bea077f8c7240efe46afbe73641a7
+```
+
+
+
+
+
---
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 @@