diff --git a/backend_api_python/app/routes/__init__.py b/backend_api_python/app/routes/__init__.py index 9393b0e..2e2edb8 100644 --- a/backend_api_python/app/routes/__init__.py +++ b/backend_api_python/app/routes/__init__.py @@ -25,7 +25,7 @@ def register_routes(app: Flask): 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') diff --git a/backend_api_python/app/routes/analysis.py b/backend_api_python/app/routes/analysis.py index ab32b25..1074844 100644 --- a/backend_api_python/app/routes/analysis.py +++ b/backend_api_python/app/routes/analysis.py @@ -140,6 +140,25 @@ def multi_analysis(): logger.info(f"Analyze request: {market}:{symbol}, use_multi_agent={use_multi_agent}, model={model}") + # 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='') diff --git a/backend_api_python/app/routes/auth.py b/backend_api_python/app/routes/auth.py index 333eba7..186d7d4 100644 --- a/backend_api_python/app/routes/auth.py +++ b/backend_api_python/app/routes/auth.py @@ -1,11 +1,11 @@ """ Authentication API Routes -Handles login, logout, and user info retrieval. +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 +from flask import Blueprint, request, jsonify, g, redirect from app.config.settings import Config from app.utils.auth import generate_token, login_required, authenticate_legacy from app.utils.logger import get_logger @@ -20,6 +20,50 @@ def _is_single_user_mode() -> bool: 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(): """ @@ -28,30 +72,62 @@ def login(): 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 + 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 - # Try multi-user authentication first + # 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}") @@ -60,9 +136,23 @@ def login(): 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 - # Generate token + # 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), @@ -72,6 +162,13 @@ def login(): 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), @@ -99,6 +196,741 @@ def login(): 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(f"{frontend_url}/user/login?oauth_error={error}") + + if not code or not state: + return redirect(f"{frontend_url}/user/login?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(f"{frontend_url}/user/login?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(f"{frontend_url}/user/login?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(f"{frontend_url}/user/login?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(f"{frontend_url}/user/login?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(f"{frontend_url}/user/login?oauth_error={error}") + + if not code or not state: + return redirect(f"{frontend_url}/user/login?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(f"{frontend_url}/user/login?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(f"{frontend_url}/user/login?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(f"{frontend_url}/user/login?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(f"{frontend_url}/user/login?oauth_error=server_error") + + +# ============================================================================= +# Other Endpoints +# ============================================================================= + @auth_bp.route('/logout', methods=['POST']) def logout(): """Logout (client removes token; server is stateless).""" diff --git a/backend_api_python/app/routes/portfolio.py b/backend_api_python/app/routes/portfolio.py index f2acd78..d21e3bf 100644 --- a/backend_api_python/app/routes/portfolio.py +++ b/backend_api_python/app/routes/portfolio.py @@ -680,25 +680,60 @@ 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()) diff --git a/backend_api_python/app/routes/settings.py b/backend_api_python/app/routes/settings.py index 40ca26f..4cb9cc6 100644 --- a/backend_api_python/app/routes/settings.py +++ b/backend_api_python/app/routes/settings.py @@ -76,6 +76,13 @@ CONFIG_SCHEMA = { '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' + }, ] }, @@ -657,11 +664,245 @@ CONFIG_SCHEMA = { ] }, - # ==================== 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', diff --git a/backend_api_python/app/routes/user.py b/backend_api_python/app/routes/user.py index 9f175ea..b3d53ab 100644 --- a/backend_api_python/app/routes/user.py +++ b/backend_api_python/app/routes/user.py @@ -24,13 +24,15 @@ def list_users(): 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) + result = get_user_service().list_users(page=page, page_size=page_size, search=search) return jsonify({ 'code': 1, @@ -213,13 +215,143 @@ def get_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""" + """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 @@ -231,6 +363,10 @@ def get_profile(): # 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', @@ -249,8 +385,10 @@ def update_profile(): Request body: nickname: str (optional) - email: 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) @@ -260,8 +398,9 @@ def update_profile(): 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', 'email', 'avatar']: + for field in ['nickname', 'avatar']: if field in data: allowed[field] = data[field] @@ -279,6 +418,117 @@ def update_profile(): 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(): @@ -298,18 +548,56 @@ def change_password(): old_password = data.get('old_password', '') new_password = data.get('new_password', '') - if not old_password or not new_password: - return jsonify({'code': 0, 'msg': 'Both old and new password required', 'data': None}), 400 + 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 - success = get_user_service().change_password(user_id, old_password, new_password) + # 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 - if success: - return jsonify({'code': 1, 'msg': 'Password changed successfully', 'data': None}) + # 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: - return jsonify({'code': 0, 'msg': 'Old password incorrect', 'data': None}), 400 + # 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: 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 0000000..8107d77 --- /dev/null +++ b/backend_api_python/app/services/billing_service.py @@ -0,0 +1,443 @@ +""" +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), + # 功能费用(供前端显示) + '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 0000000..d8dd890 --- /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/oauth_service.py b/backend_api_python/app/services/oauth_service.py new file mode 100644 index 0000000..2d134f3 --- /dev/null +++ b/backend_api_python/app/services/oauth_service.py @@ -0,0 +1,517 @@ +""" +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() + + 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/security_service.py b/backend_api_python/app/services/security_service.py new file mode 100644 index 0000000..8ac7e9f --- /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/user_service.py b/backend_api_python/app/services/user_service.py index 0bbb51c..ecd0004 100644 --- a/backend_api_python/app/services/user_service.py +++ b/backend_api_python/app/services/user_service.py @@ -75,7 +75,7 @@ class UserService: cur.execute( """ SELECT id, username, email, nickname, avatar, status, role, - last_login_at, created_at, updated_at + credits, vip_expires_at, last_login_at, created_at, updated_at FROM qd_users WHERE id = ? """, (user_id,) @@ -107,12 +107,41 @@ class UserService: 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 and password. + 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 @@ -120,7 +149,16 @@ class UserService: logger.warning(f"Login attempt for disabled user: {username}") return None - if not self.verify_password(password, user.get('password_hash', '')): + 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 @@ -140,33 +178,41 @@ class UserService: user.pop('password_hash', None) return user - def create_user(self, data: Dict[str, Any]) -> Optional[int]: + def create_user(self, data: Dict[str, Any] = None, **kwargs) -> Optional[int]: """ Create a new user. Args: - data: { + data: dict with user fields, OR use keyword arguments: username: str (required), - password: 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') - } + 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 """ - username = (data.get('username') or '').strip() - password = data.get('password') or '' + # Support both dict and kwargs style + if data is None: + data = kwargs + else: + data = {**data, **kwargs} - if not username or not password: - raise ValueError("Username and password are required") + 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") - if len(password) < 6: + # 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 @@ -174,11 +220,14 @@ class UserService: if existing: raise ValueError("Username already exists") - password_hash = self.hash_password(password) + # 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' @@ -189,10 +238,10 @@ class UserService: cur.execute( """ INSERT INTO qd_users - (username, password_hash, email, nickname, role, status, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, NOW(), NOW()) + (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) + (username, password_hash, email, nickname, role, status, email_verified, referred_by) ) db.commit() user_id = cur.lastrowid @@ -206,7 +255,7 @@ class UserService: user_id = row['id'] if row else None cur.close() - logger.info(f"Created user: {username} (id={user_id})") + 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}") @@ -251,7 +300,7 @@ class UserService: return False def change_password(self, user_id: int, old_password: str, new_password: str) -> bool: - """Change user password (requires old password verification)""" + """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 @@ -266,7 +315,15 @@ class UserService: if not row: return False - if not self.verify_password(old_password, row['password_hash']): + 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) @@ -292,6 +349,10 @@ class UserService: 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: @@ -305,29 +366,37 @@ class UserService: logger.error(f"delete_user failed: {e}") return False - def list_users(self, page: int = 1, page_size: int = 20) -> Dict[str, Any]: - """List all users with pagination""" + 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 - cur.execute("SELECT COUNT(*) as count FROM qd_users") + count_sql = f"SELECT COUNT(*) as count FROM qd_users {where_clause}" + cur.execute(count_sql, tuple(params)) total = cur.fetchone()['count'] # Get users - cur.execute( - """ + query_sql = f""" SELECT id, username, email, nickname, avatar, status, role, - last_login_at, created_at, updated_at + credits, vip_expires_at, last_login_at, created_at, updated_at FROM qd_users + {where_clause} ORDER BY id DESC LIMIT ? OFFSET ? - """, - (page_size, offset) - ) + """ + cur.execute(query_sql, tuple(params + [page_size, offset])) users = cur.fetchall() cur.close() @@ -362,15 +431,18 @@ class UserService: # 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' + 'status': 'active', + 'email_verified': True # Admin email is pre-verified }) - logger.info(f"Created admin user: {admin_user}") + logger.info(f"Created admin user: {admin_user} ({admin_email})") except Exception as e: logger.error(f"ensure_admin_exists failed: {e}") diff --git a/backend_api_python/env.example b/backend_api_python/env.example index 3ec6766..8d99faa 100644 --- a/backend_api_python/env.example +++ b/backend_api_python/env.example @@ -7,6 +7,7 @@ SECRET_KEY=quantdinger-secret-key-change-me ADMIN_USER=quantdinger ADMIN_PASSWORD=123456 +ADMIN_EMAIL=admin@example.com # ========================= # Demo Mode @@ -217,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/your_support_bot + +# 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 index 7fcb046..10bd86d 100644 --- a/backend_api_python/migrations/init.sql +++ b/backend_api_python/migrations/init.sql @@ -9,19 +9,124 @@ 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), + 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 -- ============================================================================= diff --git a/docker-compose.yml b/docker-compose.yml index 06c5a3d..4d8036d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -17,6 +17,7 @@ services: 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: diff --git a/docs/OAUTH_CONFIG_CN.md b/docs/OAUTH_CONFIG_CN.md new file mode 100644 index 0000000..ca98782 --- /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 0000000..eca3d25 --- /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/quantdinger_vue/public/avatar2.jpg b/quantdinger_vue/public/avatar2.jpg index 9adb2d1..59dc7ff 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 0000000..bb388b3 --- /dev/null +++ b/quantdinger_vue/src/api/auth.js @@ -0,0 +1,118 @@ +import request from '@/utils/request' + +/** + * 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 `${process.env.VUE_APP_API_BASE_URL || ''}/api/auth/oauth/google` +} + +/** + * Get GitHub OAuth URL + */ +export function getGitHubOAuthUrl () { + return `${process.env.VUE_APP_API_BASE_URL || ''}/api/auth/oauth/github` +} diff --git a/quantdinger_vue/src/api/login.js b/quantdinger_vue/src/api/login.js index d6db492..5bd228f 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/portfolio.js b/quantdinger_vue/src/api/portfolio.js index 732508e..06d02cc 100644 --- a/quantdinger_vue/src/api/portfolio.js +++ b/quantdinger_vue/src/api/portfolio.js @@ -80,7 +80,8 @@ export function runMonitor (id, params = {}) { return request({ url: `/api/portfolio/monitors/${id}/run`, method: 'post', - data: params + data: params, + timeout: 60000 // 60s timeout for monitor API (async mode should return immediately) }) } diff --git a/quantdinger_vue/src/api/user.js b/quantdinger_vue/src/api/user.js index c605843..9a4f08a 100644 --- a/quantdinger_vue/src/api/user.js +++ b/quantdinger_vue/src/api/user.js @@ -7,7 +7,7 @@ import request from '@/utils/request' /** * Get user list (admin only) - * @param {Object} params - { page, page_size } + * @param {Object} params - { page, page_size, search } */ export function getUserList (params) { return request({ @@ -124,3 +124,65 @@ export function changePassword (data) { 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/Turnstile/index.vue b/quantdinger_vue/src/components/Turnstile/index.vue new file mode 100644 index 0000000..2444cd2 --- /dev/null +++ b/quantdinger_vue/src/components/Turnstile/index.vue @@ -0,0 +1,199 @@ + + + + + diff --git a/quantdinger_vue/src/config/router.config.js b/quantdinger_vue/src/config/router.config.js index 7a0a90a..3ae59a2 100644 --- a/quantdinger_vue/src/config/router.config.js +++ b/quantdinger_vue/src/config/router.config.js @@ -159,11 +159,6 @@ export const constantRouterMap = [ path: 'login', name: 'login', component: () => import(/* webpackChunkName: "user" */ '@/views/user/Login') - }, - { - path: 'recover', - name: 'recover', - component: undefined } ] }, diff --git a/quantdinger_vue/src/core/bootstrap.js b/quantdinger_vue/src/core/bootstrap.js index f9b282c..298851c 100644 --- a/quantdinger_vue/src/core/bootstrap.js +++ b/quantdinger_vue/src/core/bootstrap.js @@ -24,7 +24,13 @@ export default function Initializer () { store.commit(TOGGLE_WEAK, storage.get(TOGGLE_WEAK, defaultSettings.colorWeak)) store.commit(TOGGLE_COLOR, storage.get(TOGGLE_COLOR, defaultSettings.primaryColor)) store.commit(TOGGLE_MULTI_TAB, storage.get(TOGGLE_MULTI_TAB, defaultSettings.multiTab)) - store.commit('SET_TOKEN', storage.get(ACCESS_TOKEN)) + // 处理 token 可能是字符串或对象的情况 + let token = storage.get(ACCESS_TOKEN) + if (token && typeof token !== 'string') { + token = token.token || token.value || (typeof token === 'object' ? null : token) + } + token = typeof token === 'string' ? token : null + store.commit('SET_TOKEN', token) store.dispatch('setLang', storage.get(APP_LANGUAGE, 'en-US')) // last step diff --git a/quantdinger_vue/src/locales/lang/en-US.js b/quantdinger_vue/src/locales/lang/en-US.js index fd75cdb..cd78c3b 100644 --- a/quantdinger_vue/src/locales/lang/en-US.js +++ b/quantdinger_vue/src/locales/lang/en-US.js @@ -215,6 +215,100 @@ const locale = { 'user.login.privacy.view': 'View Privacy Policy', 'user.login.privacy.collapse': 'Hide Privacy Policy', 'user.login.privacy.content': 'We value your privacy and data protection. (1) Scope of collection: We only collect information necessary to provide the service (e.g., email, mobile number, country code, Web3 wallet address) and limited logs/device data. (2) Purpose of use: Account login and security verification, feature provisioning, troubleshooting, and compliance requirements. (3) Storage & security: Data is encrypted and access-controlled to prevent unauthorized access, disclosure, or loss. (4) Sharing & third parties: We do not share personal data with third parties except as required by law or to deliver the service; where third-party services are involved (e.g., wallets, SMS providers), processing is limited to the minimum scope required. (5) Cookies/local storage: Used for session and login state (e.g., tokens, PHPSESSID). You may clear or restrict them in your browser. (6) Your rights: You may exercise rights of access, correction, deletion, and consent withdrawal as permitted by law. (7) Changes & notices: We will provide prominent notice for updates. Continued use indicates that you have read and agreed to the updated terms. If you do not agree, please stop using the service and contact us.', + + // Login page additions + 'user.login.username': 'Username', + 'user.login.usernameRequired': 'Please enter username', + 'user.login.passwordRequired': 'Please enter password', + 'user.login.tab': 'Login', + 'user.login.submit': 'Login', + 'user.login.register': 'Create Account', + 'user.login.forgotPassword': 'Forgot Password?', + 'user.login.orLoginWith': 'Or login with', + 'user.login.methodPassword': 'Password', + 'user.login.methodCode': 'Email Code', + 'user.login.email': 'Email', + 'user.login.emailRequired': 'Please enter email', + 'user.login.emailInvalid': 'Invalid email format', + 'user.login.verificationCode': 'Verification Code', + 'user.login.codeRequired': 'Please enter verification code', + 'user.login.sendCode': 'Send', + 'user.login.codeSent': 'Verification code sent', + 'user.login.codeLoginHint': 'New users will be auto-registered', + 'user.login.welcomeNew': 'Welcome!', + 'user.login.accountCreated': 'Your account has been created', + + // OAuth + 'user.oauth.processing': 'Processing login...', + 'user.oauth.error.missing_params': 'Missing required parameters', + 'user.oauth.error.invalid_state': 'Invalid state parameter', + 'user.oauth.error.user_creation_failed': 'Failed to create user', + 'user.oauth.error.server_error': 'Server error', + + // Register page + 'user.register.tab': 'Register', + 'user.register.title': 'Create Account', + 'user.register.email': 'Email', + 'user.register.emailRequired': 'Please enter email', + 'user.register.emailInvalid': 'Invalid email format', + 'user.register.verificationCode': 'Verification Code', + 'user.register.codeRequired': 'Please enter verification code', + 'user.register.sendCode': 'Send Code', + 'user.register.codeSent': 'Verification code sent', + 'user.register.username': 'Username', + 'user.register.usernameRequired': 'Please enter username', + 'user.register.usernameLength': 'Username must be 3-30 characters', + 'user.register.usernamePattern': 'Start with letter, letters/numbers/underscore only', + 'user.register.password': 'Password', + 'user.register.passwordRequired': 'Please enter password', + 'user.register.confirmPassword': 'Confirm Password', + 'user.register.confirmPasswordRequired': 'Please confirm password', + 'user.register.passwordMismatch': 'Passwords do not match', + 'user.register.submit': 'Create Account', + 'user.register.haveAccount': 'Already have an account?', + 'user.register.login': 'Login', + 'user.register.success': 'Registration successful', + 'user.register.pleaseLogin': 'Please login with your new account', + 'user.register.pwdMinLength': 'At least 8 characters', + 'user.register.pwdUppercase': 'At least one uppercase letter', + 'user.register.pwdLowercase': 'At least one lowercase letter', + 'user.register.pwdNumber': 'At least one number', + + // Reset password page + 'user.resetPassword.title': 'Reset Password', + 'user.resetPassword.email': 'Email', + 'user.resetPassword.emailRequired': 'Please enter email', + 'user.resetPassword.emailInvalid': 'Invalid email format', + 'user.resetPassword.verificationCode': 'Verification Code', + 'user.resetPassword.codeRequired': 'Please enter verification code', + 'user.resetPassword.sendCode': 'Send Code', + 'user.resetPassword.codeSent': 'Verification code sent', + 'user.resetPassword.next': 'Next', + 'user.resetPassword.backToLogin': 'Back to Login', + 'user.resetPassword.resettingFor': 'Resetting password for', + 'user.resetPassword.newPassword': 'New Password', + 'user.resetPassword.passwordRequired': 'Please enter new password', + 'user.resetPassword.confirmPassword': 'Confirm New Password', + 'user.resetPassword.confirmPasswordRequired': 'Please confirm password', + 'user.resetPassword.submit': 'Reset Password', + 'user.resetPassword.back': 'Back', + 'user.resetPassword.successTitle': 'Password Reset Successful', + 'user.resetPassword.successSubtitle': 'You can now login with your new password', + 'user.resetPassword.goToLogin': 'Go to Login', + + // Security + 'user.security.retry': 'Retry', + + // Profile - change password + 'profile.passwordHintNew': 'For security, email verification is required to change password. Password must be at least 8 characters with uppercase, lowercase, and number.', + 'profile.verificationCode': 'Verification Code', + 'profile.codeRequired': 'Please enter verification code', + 'profile.codePlaceholder': 'Enter verification code', + 'profile.sendCode': 'Send Code', + 'profile.codeSent': 'Verification code sent', + 'profile.codeWillSendTo': 'Code will be sent to', + 'profile.noEmailWarning': 'Please set your email first in Basic Info tab', + 'account.basicInfo': 'Basic Information', 'account.id': 'User ID', 'account.username': 'Username', @@ -2019,6 +2113,9 @@ const locale = { 'settings.link.supportedExchanges': 'Supported Exchanges', 'settings.link.applyApi': 'Apply API', 'settings.link.createSearchEngine': 'Create Search Engine', + 'settings.link.getTurnstileKey': 'Get Turnstile Key', + 'settings.link.getGoogleCredentials': 'Get Google Credentials', + 'settings.link.getGithubCredentials': 'Get GitHub Credentials', 'settings.restartRequired': 'Settings saved. Some changes require Python service restart to take effect.', 'settings.copyRestartCmd': 'Copy restart command', 'settings.copySuccess': 'Copied', @@ -2037,11 +2134,35 @@ const locale = { 'settings.group.agent': 'AI Agent', 'settings.group.network': 'Network & Proxy', 'settings.group.search': 'Web Search', + 'settings.group.security': 'Registration & Security', 'settings.group.app': 'Application', // Settings fields - Auth 'settings.field.SECRET_KEY': 'Secret Key', 'settings.field.ADMIN_USER': 'Admin Username', 'settings.field.ADMIN_PASSWORD': 'Admin Password', + 'settings.field.ADMIN_EMAIL': 'Admin Email', + // Settings fields - Security + 'settings.field.ENABLE_REGISTRATION': 'Enable Registration', + 'settings.field.TURNSTILE_SITE_KEY': 'Turnstile Site Key', + 'settings.field.TURNSTILE_SECRET_KEY': 'Turnstile Secret Key', + 'settings.field.FRONTEND_URL': 'Frontend URL', + 'settings.field.GOOGLE_CLIENT_ID': 'Google Client ID', + 'settings.field.GOOGLE_CLIENT_SECRET': 'Google Client Secret', + 'settings.field.GOOGLE_REDIRECT_URI': 'Google Redirect URI', + 'settings.field.GITHUB_CLIENT_ID': 'GitHub Client ID', + 'settings.field.GITHUB_CLIENT_SECRET': 'GitHub Client Secret', + 'settings.field.GITHUB_REDIRECT_URI': 'GitHub Redirect URI', + 'settings.field.SECURITY_IP_MAX_ATTEMPTS': 'IP Max Failed Attempts', + 'settings.field.SECURITY_IP_WINDOW_MINUTES': 'IP Window (minutes)', + 'settings.field.SECURITY_IP_BLOCK_MINUTES': 'IP Block Duration (minutes)', + 'settings.field.SECURITY_ACCOUNT_MAX_ATTEMPTS': 'Account Max Failed Attempts', + 'settings.field.SECURITY_ACCOUNT_WINDOW_MINUTES': 'Account Window (minutes)', + 'settings.field.SECURITY_ACCOUNT_BLOCK_MINUTES': 'Account Block Duration (minutes)', + 'settings.field.VERIFICATION_CODE_EXPIRE_MINUTES': 'Verification Code Expiry (minutes)', + 'settings.field.VERIFICATION_CODE_RATE_LIMIT': 'Code Rate Limit (seconds)', + 'settings.field.VERIFICATION_CODE_IP_HOURLY_LIMIT': 'Code Hourly Limit per IP', + 'settings.field.VERIFICATION_CODE_MAX_ATTEMPTS': 'Code Max Attempts', + 'settings.field.VERIFICATION_CODE_LOCK_MINUTES': 'Code Lock Minutes', // Settings fields - Server 'settings.field.PYTHON_API_HOST': 'Listen Address', 'settings.field.PYTHON_API_PORT': 'Port', @@ -2179,6 +2300,10 @@ const locale = { 'portfolio.monitors.channels': 'Channels', 'portfolio.monitors.runNow': 'Run Now', 'portfolio.monitors.analysisResult': 'AI Analysis Result', + 'portfolio.monitors.runningTitle': 'AI Analysis Started', + 'portfolio.monitors.runningDesc': 'Analysis is running in background. Results will be pushed via notification. Analyzing multiple positions may take a few minutes.', + 'portfolio.monitors.timeoutTitle': 'Request Timeout', + 'portfolio.monitors.timeoutDesc': 'Analysis may still be running in background. Please check notifications for results later. If no notification is received, please try again.', 'portfolio.modal.addPosition': 'Add Position', 'portfolio.modal.editPosition': 'Edit Position', 'portfolio.modal.addMonitor': 'Add Monitor', @@ -2238,6 +2363,7 @@ const locale = { 'portfolio.message.monitorDisabled': 'Monitor paused', 'portfolio.message.monitorRunSuccess': 'Analysis completed', 'portfolio.message.monitorRunFailed': 'Analysis failed', + 'portfolio.message.monitorRunning': 'AI analysis started, please wait for notification', // Portfolio - Groups 'portfolio.groups.all': 'All Positions', 'portfolio.groups.ungrouped': 'Ungrouped', @@ -2280,6 +2406,7 @@ const locale = { 'common.refresh': 'Refresh', 'userManage.title': 'User Management', + 'userManage.searchPlaceholder': 'Search by username/email/nickname', 'userManage.description': 'Manage system users, roles and permissions', 'userManage.createUser': 'Create User', 'userManage.editUser': 'Edit User', @@ -2325,6 +2452,7 @@ const locale = { 'profile.nicknamePlaceholder': 'Enter your nickname', 'profile.emailPlaceholder': 'Enter your email', 'profile.emailInvalid': 'Invalid email format', + 'profile.emailCannotChange': 'Email cannot be changed after registration', 'profile.passwordHint': 'Password must be at least 6 characters', 'profile.oldPassword': 'Current Password', 'profile.newPassword': 'New Password', @@ -2336,7 +2464,89 @@ const locale = { 'profile.confirmPasswordRequired': 'Please confirm password', 'profile.confirmPasswordPlaceholder': 'Confirm new password', 'profile.passwordMin': 'Password must be at least 6 characters', - 'profile.passwordMismatch': 'Passwords do not match' + 'profile.passwordMismatch': 'Passwords do not match', + + // Profile - Credits + 'profile.credits.title': 'My Credits', + 'profile.credits.unit': 'Credits', + 'profile.credits.recharge': 'Top Up', + 'profile.credits.vipExpires': 'VIP expires on', + 'profile.credits.vipExpired': 'VIP expired', + 'profile.credits.noVip': 'Not a VIP', + 'profile.credits.hint': 'AI analysis and other features consume credits. VIP users get free access.', + + // Profile - Credits Log + 'profile.creditsLog': 'Credits History', + 'profile.creditsLog.time': 'Time', + 'profile.creditsLog.action': 'Type', + 'profile.creditsLog.amount': 'Change', + 'profile.creditsLog.balance': 'Balance', + 'profile.creditsLog.remark': 'Remark', + 'profile.creditsLog.actionConsume': 'Consume', + 'profile.creditsLog.actionRecharge': 'Recharge', + 'profile.creditsLog.actionAdjust': 'Adjust', + 'profile.creditsLog.actionRefund': 'Refund', + 'profile.creditsLog.actionVipGrant': 'VIP Grant', + 'profile.creditsLog.actionVipRevoke': 'VIP Revoke', + 'profile.creditsLog.actionRegisterBonus': 'Register Bonus', + 'profile.creditsLog.actionReferralBonus': 'Referral Bonus', + + // Profile - Referral + 'profile.referral.title': 'Invite Friends', + 'profile.referral.listTab': 'Referrals', + 'profile.referral.totalInvited': 'Invited', + 'profile.referral.bonusPerInvite': 'Per Invite', + 'profile.referral.yourLink': 'Your Referral Link', + 'profile.referral.copyLink': 'Copy Link', + 'profile.referral.linkCopied': 'Referral link copied', + 'profile.referral.newUserBonus': 'New users get', + 'profile.referral.user': 'User', + 'profile.referral.registerTime': 'Registered', + 'profile.referral.noReferrals': 'No referrals yet', + 'profile.referral.shareNow': 'Share Now', + + // User Manage - Credits & VIP + 'userManage.credits': 'Credits', + 'userManage.adjustCredits': 'Adjust Credits', + 'userManage.setVip': 'Set VIP', + 'userManage.currentCredits': 'Current Credits', + 'userManage.newCredits': 'New Credits', + 'userManage.enterCredits': 'Enter new credits amount', + 'userManage.creditsNonNegative': 'Credits cannot be negative', + 'userManage.currentVip': 'Current VIP Status', + 'userManage.vipActive': 'Active', + 'userManage.vipExpired': 'Expired', + 'userManage.vipDays': 'VIP Days', + 'userManage.vipExpiresAt': 'VIP Expires At', + 'userManage.cancelVip': 'Cancel VIP', + 'userManage.days': 'days', + 'userManage.customDate': 'Custom Date', + 'userManage.selectDate': 'Please select a date', + 'userManage.remark': 'Remark', + 'userManage.remarkPlaceholder': 'Optional remark', + + // Settings - Billing + 'settings.group.billing': 'Billing & Credits', + 'settings.field.BILLING_ENABLED': 'Enable Billing', + 'settings.field.BILLING_VIP_BYPASS': 'VIP Free Access', + 'settings.field.BILLING_COST_AI_ANALYSIS': 'AI Analysis Cost', + 'settings.field.BILLING_COST_STRATEGY_RUN': 'Strategy Run Cost', + 'settings.field.BILLING_COST_BACKTEST': 'Backtest Cost', + 'settings.field.BILLING_COST_PORTFOLIO_MONITOR': 'Portfolio Monitor Cost', + 'settings.field.CREDITS_REGISTER_BONUS': 'Register Bonus', + 'settings.field.CREDITS_REFERRAL_BONUS': 'Referral Bonus', + 'settings.field.RECHARGE_TELEGRAM_URL': 'Recharge Telegram URL', + 'settings.desc.BILLING_ENABLED': 'Enable billing system. Users need credits to use certain features when enabled', + 'settings.desc.BILLING_VIP_BYPASS': 'VIP users can use all paid features for free during VIP period', + 'settings.desc.BILLING_COST_AI_ANALYSIS': 'Credits consumed per AI analysis request', + 'settings.desc.BILLING_COST_STRATEGY_RUN': 'Credits consumed when starting a strategy', + 'settings.desc.BILLING_COST_BACKTEST': 'Credits consumed per backtest run', + 'settings.desc.BILLING_COST_PORTFOLIO_MONITOR': 'Credits consumed per portfolio AI monitoring run', + 'settings.desc.CREDITS_REGISTER_BONUS': 'Credits awarded to new users on registration', + 'settings.desc.CREDITS_REFERRAL_BONUS': 'Credits awarded to referrer when someone signs up with their referral code', + 'settings.desc.VERIFICATION_CODE_MAX_ATTEMPTS': 'Maximum attempts to verify a code before lockout', + 'settings.desc.VERIFICATION_CODE_LOCK_MINUTES': 'Lockout duration after exceeding max attempts', + 'settings.desc.RECHARGE_TELEGRAM_URL': 'Telegram customer service URL for recharge inquiries' } export default { diff --git a/quantdinger_vue/src/locales/lang/zh-CN.js b/quantdinger_vue/src/locales/lang/zh-CN.js index bb9138e..fa435e3 100644 --- a/quantdinger_vue/src/locales/lang/zh-CN.js +++ b/quantdinger_vue/src/locales/lang/zh-CN.js @@ -215,6 +215,100 @@ const locale = { 'user.login.privacy.view': '查看用户隐私条款', 'user.login.privacy.collapse': '收起用户隐私条款', 'user.login.privacy.content': '我们重视您的隐私与数据保护。1) 收集范围:仅收集实现功能所需的信息(如邮箱、手机号、区号、Web3 钱包地址)以及必要的日志与设备信息。2) 使用目的:用于账户登录与安全校验、服务功能提供、问题排查与合规要求。3) 存储与安全:数据加密存储,并采取必要的权限与访问控制措施,尽力防止未经授权的访问、披露或丢失。4) 共享与第三方:除法律法规要求或履行服务所必需外,不会与第三方共享您的个人信息;若涉及第三方服务(如钱包、短信服务商),仅在实现功能所需的最小范围内处理。5) Cookies/本地存储:用于登录态与必要的会话维持(如令牌、PHPSESSID),您可在浏览器中进行清理或限制。6) 个人权利:您可根据法律法规行使查询、更正、删除、撤回同意等权利。7) 变更与通知:本条款更新后将在页面显著位置提示。继续使用本服务即表示您已阅读并同意更新内容。若您不同意本条款或其中任何更新,请停止使用本服务并联系我们。', + +// Login page additions +'user.login.username': '用户名', +'user.login.usernameRequired': '请输入用户名', +'user.login.passwordRequired': '请输入密码', +'user.login.tab': '登录', +'user.login.submit': '登录', +'user.login.register': '注册账户', +'user.login.forgotPassword': '忘记密码?', +'user.login.orLoginWith': '或使用以下方式登录', +'user.login.methodPassword': '密码登录', +'user.login.methodCode': '验证码登录', +'user.login.email': '邮箱', +'user.login.emailRequired': '请输入邮箱', +'user.login.emailInvalid': '邮箱格式不正确', +'user.login.verificationCode': '验证码', +'user.login.codeRequired': '请输入验证码', +'user.login.sendCode': '发送', +'user.login.codeSent': '验证码已发送', +'user.login.codeLoginHint': '新用户将自动注册', +'user.login.welcomeNew': '欢迎!', +'user.login.accountCreated': '您的账户已创建成功', + +// OAuth +'user.oauth.processing': '正在处理登录...', +'user.oauth.error.missing_params': '缺少必要参数', +'user.oauth.error.invalid_state': '无效的状态参数', +'user.oauth.error.user_creation_failed': '创建用户失败', +'user.oauth.error.server_error': '服务器错误', + +// Register page +'user.register.tab': '注册', +'user.register.title': '创建账户', +'user.register.email': '邮箱', +'user.register.emailRequired': '请输入邮箱', +'user.register.emailInvalid': '邮箱格式不正确', +'user.register.verificationCode': '验证码', +'user.register.codeRequired': '请输入验证码', +'user.register.sendCode': '发送验证码', +'user.register.codeSent': '验证码已发送', +'user.register.username': '用户名', +'user.register.usernameRequired': '请输入用户名', +'user.register.usernameLength': '用户名需要3-30个字符', +'user.register.usernamePattern': '以字母开头,只能包含字母、数字和下划线', +'user.register.password': '密码', +'user.register.passwordRequired': '请输入密码', +'user.register.confirmPassword': '确认密码', +'user.register.confirmPasswordRequired': '请确认密码', +'user.register.passwordMismatch': '两次输入的密码不一致', +'user.register.submit': '创建账户', +'user.register.haveAccount': '已有账户?', +'user.register.login': '登录', +'user.register.success': '注册成功', +'user.register.pleaseLogin': '请使用新账户登录', +'user.register.pwdMinLength': '至少8个字符', +'user.register.pwdUppercase': '至少包含一个大写字母', +'user.register.pwdLowercase': '至少包含一个小写字母', +'user.register.pwdNumber': '至少包含一个数字', + +// Reset password page +'user.resetPassword.title': '重置密码', +'user.resetPassword.email': '邮箱', +'user.resetPassword.emailRequired': '请输入邮箱', +'user.resetPassword.emailInvalid': '邮箱格式不正确', +'user.resetPassword.verificationCode': '验证码', +'user.resetPassword.codeRequired': '请输入验证码', +'user.resetPassword.sendCode': '发送验证码', +'user.resetPassword.codeSent': '验证码已发送', +'user.resetPassword.next': '下一步', +'user.resetPassword.backToLogin': '返回登录', +'user.resetPassword.resettingFor': '正在为以下邮箱重置密码', +'user.resetPassword.newPassword': '新密码', +'user.resetPassword.passwordRequired': '请输入新密码', +'user.resetPassword.confirmPassword': '确认新密码', +'user.resetPassword.confirmPasswordRequired': '请确认新密码', +'user.resetPassword.submit': '重置密码', +'user.resetPassword.back': '返回', +'user.resetPassword.successTitle': '密码重置成功', +'user.resetPassword.successSubtitle': '您现在可以使用新密码登录了', +'user.resetPassword.goToLogin': '前往登录', + +// Security +'user.security.retry': '重试', + +// Profile - change password +'profile.passwordHintNew': '为了安全,修改密码需要邮箱验证。密码至少8个字符,包含大小写字母和数字。', +'profile.verificationCode': '验证码', +'profile.codeRequired': '请输入验证码', +'profile.codePlaceholder': '输入验证码', +'profile.sendCode': '发送验证码', +'profile.codeSent': '验证码已发送', +'profile.codeWillSendTo': '验证码将发送至', +'profile.noEmailWarning': '请先在基本信息中设置邮箱', + 'account.basicInfo': '基础信息', 'account.id': '用户ID', 'account.username': '用户名', @@ -1415,6 +1509,7 @@ const locale = { 'trading-assistant.exchange.connectionFailed': '连接失败', 'trading-assistant.exchange.testFailed': '连接测试失败', 'trading-assistant.exchange.fillComplete': '请填写完整的交易所配置信息', +'trading-assistant.exchange.ipWhitelistTip': '请在交易所API设置中将以下IP添加到白名单:', 'trading-assistant.strategyTypeOptions.ai': 'AI驱动策略', 'trading-assistant.strategyTypeOptions.indicator': '技术指标策略', 'trading-assistant.strategyTypeOptions.aiDeveloping': 'AI驱动策略功能开发中,敬请期待', @@ -1758,6 +1853,9 @@ const locale = { 'settings.link.supportedExchanges': '支持的交易所', 'settings.link.applyApi': '申请API', 'settings.link.createSearchEngine': '创建搜索引擎', +'settings.link.getTurnstileKey': '获取Turnstile密钥', +'settings.link.getGoogleCredentials': '获取Google凭据', +'settings.link.getGithubCredentials': '获取GitHub凭据', 'settings.restartRequired': '配置已保存,部分配置需要重启Python服务才能生效', 'settings.copyRestartCmd': '复制重启命令', 'settings.copySuccess': '复制成功', @@ -1775,11 +1873,35 @@ const locale = { 'settings.group.agent': 'AI Agent', 'settings.group.network': '网络代理', 'settings.group.search': '搜索配置', +'settings.group.security': '注册与安全', 'settings.group.app': '应用配置', // Settings fields - Auth 'settings.field.SECRET_KEY': 'Secret Key', 'settings.field.ADMIN_USER': '管理员用户名', 'settings.field.ADMIN_PASSWORD': '管理员密码', +'settings.field.ADMIN_EMAIL': '管理员邮箱', +// Settings fields - Security +'settings.field.ENABLE_REGISTRATION': '允许注册', +'settings.field.TURNSTILE_SITE_KEY': 'Turnstile Site Key', +'settings.field.TURNSTILE_SECRET_KEY': 'Turnstile Secret Key', +'settings.field.FRONTEND_URL': '前端URL', +'settings.field.GOOGLE_CLIENT_ID': 'Google Client ID', +'settings.field.GOOGLE_CLIENT_SECRET': 'Google Client Secret', +'settings.field.GOOGLE_REDIRECT_URI': 'Google回调URL', +'settings.field.GITHUB_CLIENT_ID': 'GitHub Client ID', +'settings.field.GITHUB_CLIENT_SECRET': 'GitHub Client Secret', +'settings.field.GITHUB_REDIRECT_URI': 'GitHub回调URL', +'settings.field.SECURITY_IP_MAX_ATTEMPTS': 'IP最大失败次数', +'settings.field.SECURITY_IP_WINDOW_MINUTES': 'IP统计窗口(分钟)', +'settings.field.SECURITY_IP_BLOCK_MINUTES': 'IP封禁时长(分钟)', +'settings.field.SECURITY_ACCOUNT_MAX_ATTEMPTS': '账户最大失败次数', +'settings.field.SECURITY_ACCOUNT_WINDOW_MINUTES': '账户统计窗口(分钟)', +'settings.field.SECURITY_ACCOUNT_BLOCK_MINUTES': '账户锁定时长(分钟)', +'settings.field.VERIFICATION_CODE_EXPIRE_MINUTES': '验证码有效期(分钟)', +'settings.field.VERIFICATION_CODE_RATE_LIMIT': '验证码发送间隔(秒)', +'settings.field.VERIFICATION_CODE_IP_HOURLY_LIMIT': 'IP每小时验证码上限', +'settings.field.VERIFICATION_CODE_MAX_ATTEMPTS': '验证码最大尝试次数', +'settings.field.VERIFICATION_CODE_LOCK_MINUTES': '验证码锁定时长(分钟)', // Settings fields - Server 'settings.field.PYTHON_API_HOST': '监听地址', 'settings.field.PYTHON_API_PORT': '端口', @@ -1988,6 +2110,10 @@ const locale = { 'portfolio.monitors.channels': '通知渠道', 'portfolio.monitors.runNow': '立即执行', 'portfolio.monitors.analysisResult': 'AI 分析结果', +'portfolio.monitors.runningTitle': 'AI 分析已启动', +'portfolio.monitors.runningDesc': '分析正在后台运行,完成后会通过通知推送结果。分析多个持仓可能需要几分钟时间。', +'portfolio.monitors.timeoutTitle': '请求超时', +'portfolio.monitors.timeoutDesc': '分析可能正在后台运行中,请稍后查看通知获取结果。如果长时间没有收到通知,请重试。', 'portfolio.modal.addPosition': '添加持仓', 'portfolio.modal.editPosition': '编辑持仓', 'portfolio.modal.addMonitor': '添加监控', @@ -2047,6 +2173,7 @@ const locale = { 'portfolio.message.monitorDisabled': '监控已暂停', 'portfolio.message.monitorRunSuccess': '分析完成', 'portfolio.message.monitorRunFailed': '分析失败', +'portfolio.message.monitorRunning': 'AI 分析已启动,请等待通知', // Portfolio - 分组 'portfolio.groups.all': '全部持仓', 'portfolio.groups.ungrouped': '未分组', @@ -2089,6 +2216,7 @@ const locale = { 'common.refresh': '刷新', 'userManage.title': '用户管理', +'userManage.searchPlaceholder': '搜索用户名/邮箱/昵称', 'userManage.description': '管理系统用户、角色和权限', 'userManage.createUser': '创建用户', 'userManage.editUser': '编辑用户', @@ -2134,6 +2262,7 @@ const locale = { 'profile.nicknamePlaceholder': '输入您的昵称', 'profile.emailPlaceholder': '输入您的邮箱', 'profile.emailInvalid': '邮箱格式不正确', +'profile.emailCannotChange': '注册后邮箱不可修改', 'profile.passwordHint': '密码至少需要6个字符', 'profile.oldPassword': '当前密码', 'profile.newPassword': '新密码', @@ -2145,7 +2274,89 @@ const locale = { 'profile.confirmPasswordRequired': '请确认新密码', 'profile.confirmPasswordPlaceholder': '再次输入新密码', 'profile.passwordMin': '密码至少6个字符', -'profile.passwordMismatch': '两次输入的密码不一致' +'profile.passwordMismatch': '两次输入的密码不一致', + +// Profile - Credits +'profile.credits.title': '我的积分', +'profile.credits.unit': '积分', +'profile.credits.recharge': '开通/充值', +'profile.credits.vipExpires': 'VIP有效期至', +'profile.credits.vipExpired': 'VIP已过期', +'profile.credits.noVip': '非VIP用户', +'profile.credits.hint': '使用AI分析等功能会消耗积分,VIP用户免费', + +// Profile - Credits Log (消费记录) +'profile.creditsLog': '消费记录', +'profile.creditsLog.time': '时间', +'profile.creditsLog.action': '类型', +'profile.creditsLog.amount': '变动', +'profile.creditsLog.balance': '余额', +'profile.creditsLog.remark': '备注', +'profile.creditsLog.actionConsume': '消费', +'profile.creditsLog.actionRecharge': '充值', +'profile.creditsLog.actionAdjust': '调整', +'profile.creditsLog.actionRefund': '退款', +'profile.creditsLog.actionVipGrant': 'VIP授予', +'profile.creditsLog.actionVipRevoke': 'VIP取消', +'profile.creditsLog.actionRegisterBonus': '注册奖励', +'profile.creditsLog.actionReferralBonus': '邀请奖励', + +// Profile - Referral (邀请) +'profile.referral.title': '邀请好友', +'profile.referral.listTab': '邀请列表', +'profile.referral.totalInvited': '已邀请', +'profile.referral.bonusPerInvite': '每邀请获得', +'profile.referral.yourLink': '您的邀请链接', +'profile.referral.copyLink': '复制链接', +'profile.referral.linkCopied': '邀请链接已复制', +'profile.referral.newUserBonus': '新用户注册获得', +'profile.referral.user': '用户', +'profile.referral.registerTime': '注册时间', +'profile.referral.noReferrals': '暂无邀请记录', +'profile.referral.shareNow': '立即分享邀请', + +// User Manage - Credits & VIP +'userManage.credits': '积分', +'userManage.adjustCredits': '调整积分', +'userManage.setVip': '设置VIP', +'userManage.currentCredits': '当前积分', +'userManage.newCredits': '新积分', +'userManage.enterCredits': '输入新的积分数量', +'userManage.creditsNonNegative': '积分不能为负数', +'userManage.currentVip': '当前VIP状态', +'userManage.vipActive': '有效', +'userManage.vipExpired': '已过期', +'userManage.vipDays': 'VIP天数', +'userManage.vipExpiresAt': 'VIP过期时间', +'userManage.cancelVip': '取消VIP', +'userManage.days': '天', +'userManage.customDate': '自定义日期', +'userManage.selectDate': '请选择日期', +'userManage.remark': '备注', +'userManage.remarkPlaceholder': '可选备注', + +// Settings - Billing +'settings.group.billing': '计费配置', +'settings.field.BILLING_ENABLED': '启用计费', +'settings.field.BILLING_VIP_BYPASS': 'VIP免费', +'settings.field.BILLING_COST_AI_ANALYSIS': 'AI分析消耗', +'settings.field.BILLING_COST_STRATEGY_RUN': '策略运行消耗', +'settings.field.BILLING_COST_BACKTEST': '回测消耗', +'settings.field.BILLING_COST_PORTFOLIO_MONITOR': 'Portfolio监控消耗', +'settings.field.CREDITS_REGISTER_BONUS': '注册奖励', +'settings.field.CREDITS_REFERRAL_BONUS': '邀请奖励', +'settings.field.RECHARGE_TELEGRAM_URL': '充值Telegram链接', +'settings.desc.BILLING_ENABLED': '启用计费系统。启用后,用户使用某些功能需要消耗积分', +'settings.desc.BILLING_VIP_BYPASS': 'VIP用户在有效期内可免费使用所有付费功能', +'settings.desc.BILLING_COST_AI_ANALYSIS': '每次AI分析消耗的积分数', +'settings.desc.BILLING_COST_STRATEGY_RUN': '启动策略时消耗的积分数', +'settings.desc.BILLING_COST_BACKTEST': '每次回测消耗的积分数', +'settings.desc.BILLING_COST_PORTFOLIO_MONITOR': '每次Portfolio AI监控消耗的积分数', +'settings.desc.CREDITS_REGISTER_BONUS': '新用户注册时获得的积分奖励', +'settings.desc.CREDITS_REFERRAL_BONUS': '用户通过邀请链接成功邀请新用户时,邀请人获得的积分奖励', +'settings.desc.VERIFICATION_CODE_MAX_ATTEMPTS': '验证码验证失败的最大尝试次数,超过后将锁定', +'settings.desc.VERIFICATION_CODE_LOCK_MINUTES': '验证码验证失败次数超过限制后的锁定时长(分钟)', +'settings.desc.RECHARGE_TELEGRAM_URL': '用户点击充值时跳转的Telegram客服链接' } export default { diff --git a/quantdinger_vue/src/locales/lang/zh-TW.js b/quantdinger_vue/src/locales/lang/zh-TW.js index 6e6d58c..df1d9b4 100644 --- a/quantdinger_vue/src/locales/lang/zh-TW.js +++ b/quantdinger_vue/src/locales/lang/zh-TW.js @@ -8,6 +8,17 @@ const components = { } const locale = { + // 通用 + 'common.confirm': '確定', + 'common.cancel': '取消', + 'common.save': '保存', + 'common.delete': '刪除', + 'common.edit': '編輯', + 'common.add': '添加', + 'common.close': '關閉', + 'common.ok': '確定', + 'common.actions': '操作', + 'common.refresh': '刷新', 'submit': '提交', 'save': '保存', 'submit.ok': '提交成功', @@ -19,7 +30,8 @@ const locale = { 'menu.dashboard.community': '指標社區', 'menu.dashboard.analysis': 'AI 分析', 'menu.dashboard.tradingAssistant': '交易助手', - 'menu.settings': '系统设置', + 'menu.dashboard.portfolio': '資產監測', + 'menu.settings': '系統設置', 'menu.dashboard.aiTradingAssistant': 'AI交易助手', 'menu.dashboard.signalRobot': '信號機器人', 'menu.dashboard.monitor': '監控頁', @@ -97,6 +109,36 @@ const locale = { 'app.setting.themecolor.geekblue': '極客藍', 'app.setting.themecolor.purple': '醬紫', 'app.setting.tooltip': '頁面設置', + + // 通知中心 + 'notice.title': '通知中心', + 'notice.empty': '暫無通知', + 'notice.markAllRead': '全部已讀', + 'notice.clear': '清空通知', + 'notice.close': '關閉', + 'notice.justNow': '剛剛', + 'notice.minutesAgo': '分鐘前', + 'notice.hoursAgo': '小時前', + 'notice.daysAgo': '天前', + 'notice.detailInfo': '詳細信息', + 'notice.aiDecision': 'AI決策', + 'notice.confidence': '置信度', + 'notice.reasoning': '分析理由', + 'notice.symbol': '標的代碼', + 'notice.currentPrice': '當前價格', + 'notice.triggerPrice': '觸發價格', + 'notice.action': '操作', + 'notice.quantity': '數量', + 'notice.viewPortfolio': '查看持倉', + 'notice.type.aiMonitor': 'AI監控', + 'notice.type.priceAlert': '價格提醒', + 'notice.type.signal': '交易信號', + 'notice.type.buy': '買入信號', + 'notice.type.sell': '賣出信號', + 'notice.type.hold': '持有建議', + 'notice.type.trade': '交易執行', + 'notice.type.notification': '系統通知', + 'user.login.userName': '用戶名', 'user.login.password': '密碼', 'user.login.username.placeholder': '賬戶: admin', @@ -1674,6 +1716,9 @@ const locale = { 'settings.link.supportedExchanges': '支持的交易所', 'settings.link.applyApi': '申請API', 'settings.link.createSearchEngine': '創建搜索引擎', + 'settings.link.getTurnstileKey': '獲取Turnstile密鑰', + 'settings.link.getGoogleCredentials': '獲取Google憑據', + 'settings.link.getGithubCredentials': '獲取GitHub憑據', 'settings.restartRequired': '配置已保存,部分配置需要重啟Python服務才能生效', 'settings.copyRestartCmd': '複製重啟命令', 'settings.copySuccess': '複製成功', @@ -1692,11 +1737,35 @@ const locale = { 'settings.group.agent': 'AI Agent', 'settings.group.network': '網絡代理', 'settings.group.search': '搜索配置', + 'settings.group.security': '註冊與安全', 'settings.group.app': '應用配置', // Settings fields - Auth 'settings.field.SECRET_KEY': 'Secret Key', 'settings.field.ADMIN_USER': '管理員用戶名', 'settings.field.ADMIN_PASSWORD': '管理員密碼', + 'settings.field.ADMIN_EMAIL': '管理員郵箱', + // Settings fields - Security + 'settings.field.ENABLE_REGISTRATION': '允許註冊', + 'settings.field.TURNSTILE_SITE_KEY': 'Turnstile Site Key', + 'settings.field.TURNSTILE_SECRET_KEY': 'Turnstile Secret Key', + 'settings.field.FRONTEND_URL': '前端URL', + 'settings.field.GOOGLE_CLIENT_ID': 'Google Client ID', + 'settings.field.GOOGLE_CLIENT_SECRET': 'Google Client Secret', + 'settings.field.GOOGLE_REDIRECT_URI': 'Google回調URL', + 'settings.field.GITHUB_CLIENT_ID': 'GitHub Client ID', + 'settings.field.GITHUB_CLIENT_SECRET': 'GitHub Client Secret', + 'settings.field.GITHUB_REDIRECT_URI': 'GitHub回調URL', + 'settings.field.SECURITY_IP_MAX_ATTEMPTS': 'IP最大失敗次數', + 'settings.field.SECURITY_IP_WINDOW_MINUTES': 'IP統計窗口(分鐘)', + 'settings.field.SECURITY_IP_BLOCK_MINUTES': 'IP封禁時長(分鐘)', + 'settings.field.SECURITY_ACCOUNT_MAX_ATTEMPTS': '帳戶最大失敗次數', + 'settings.field.SECURITY_ACCOUNT_WINDOW_MINUTES': '帳戶統計窗口(分鐘)', + 'settings.field.SECURITY_ACCOUNT_BLOCK_MINUTES': '帳戶鎖定時長(分鐘)', + 'settings.field.VERIFICATION_CODE_EXPIRE_MINUTES': '驗證碼有效期(分鐘)', + 'settings.field.VERIFICATION_CODE_RATE_LIMIT': '驗證碼發送間隔(秒)', + 'settings.field.VERIFICATION_CODE_IP_HOURLY_LIMIT': 'IP每小時驗證碼上限', + 'settings.field.VERIFICATION_CODE_MAX_ATTEMPTS': '驗證碼最大嘗試次數', + 'settings.field.VERIFICATION_CODE_LOCK_MINUTES': '驗證碼鎖定時長(分鐘)', // Settings fields - Server 'settings.field.PYTHON_API_HOST': '監聽地址', 'settings.field.PYTHON_API_PORT': '端口', @@ -1864,7 +1933,283 @@ const locale = { 'settings.desc.RATE_LIMIT': '每IP每分鐘的API請求限制', 'settings.desc.ENABLE_CACHE': '啟用響應緩存以提高性能', 'settings.desc.ENABLE_REQUEST_LOG': '記錄所有API請求日誌,用於調試', - 'settings.desc.ENABLE_AI_ANALYSIS': '啟用AI驅動的市場分析功能' + 'settings.desc.ENABLE_AI_ANALYSIS': '啟用AI驅動的市場分析功能', + + // Portfolio - 資產監測 + 'portfolio.summary.totalValue': '總市值', + 'portfolio.summary.totalCost': '總成本', + 'portfolio.summary.totalPnl': '總盈虧', + 'portfolio.summary.positionCount': '持倉數量', + 'portfolio.summary.profitLossRatio': '盈利/虧損', + 'portfolio.summary.today': '今日', + 'portfolio.summary.todayPnl': '今日盈虧', + 'portfolio.summary.bestPerformer': '最佳表現', + 'portfolio.summary.worstPerformer': '最差表現', + 'portfolio.summary.priceSync': '價格同步', + 'portfolio.summary.syncInterval': '刷新間隔', + 'portfolio.summary.justNow': '剛剛', + 'portfolio.summary.ago': '前', + 'portfolio.positions.title': '我的持倉', + 'portfolio.positions.add': '添加持倉', + 'portfolio.positions.addFirst': '添加第一筆持倉', + 'portfolio.positions.empty': '暫無持倉記錄', + 'portfolio.positions.deleteConfirm': '確定刪除這筆持倉嗎?', + 'portfolio.positions.currentPrice': '現價', + 'portfolio.positions.entryPrice': '買入價', + 'portfolio.positions.quantity': '數量', + 'portfolio.positions.side': '方向', + 'portfolio.positions.long': '做多', + 'portfolio.positions.short': '做空', + 'portfolio.positions.marketValue': '市值', + 'portfolio.positions.pnl': '盈虧', + 'portfolio.positions.items': '個持倉', + 'portfolio.monitors.title': 'AI 監控', + 'portfolio.monitors.add': '添加監控', + 'portfolio.monitors.addFirst': '添加 AI 監控', + 'portfolio.monitors.empty': '暫無監控任務', + 'portfolio.monitors.deleteConfirm': '確定刪除這個監控任務嗎?', + 'portfolio.monitors.interval': '執行間隔', + 'portfolio.monitors.lastRun': '上次執行', + 'portfolio.monitors.nextRun': '下次執行', + 'portfolio.monitors.channels': '通知渠道', + 'portfolio.monitors.runNow': '立即執行', + 'portfolio.monitors.analysisResult': 'AI 分析結果', + 'portfolio.monitors.runningTitle': 'AI 分析已啟動', + 'portfolio.monitors.runningDesc': '分析正在後臺運行,完成後會通過通知推送結果。分析多個持倉可能需要幾分鐘時間。', + 'portfolio.monitors.timeoutTitle': '請求超時', + 'portfolio.monitors.timeoutDesc': '分析可能正在後臺運行中,請稍後查看通知獲取結果。如果長時間沒有收到通知,請重試。', + 'portfolio.modal.addPosition': '添加持倉', + 'portfolio.modal.editPosition': '編輯持倉', + 'portfolio.modal.addMonitor': '添加監控', + 'portfolio.modal.editMonitor': '編輯監控', + 'portfolio.form.market': '市場', + 'portfolio.form.marketRequired': '請選擇市場', + 'portfolio.form.selectMarket': '選擇市場', + 'portfolio.form.symbol': '標的代碼', + 'portfolio.form.symbolRequired': '請輸入標的代碼', + 'portfolio.form.searchSymbol': '搜索或輸入標的代碼', + 'portfolio.form.useAsSymbol': '使用', + 'portfolio.form.asSymbolCode': '作為標的代碼', + 'portfolio.form.symbolHint': '可搜索標的庫,或直接輸入任意代碼', + 'portfolio.form.side': '方向', + 'portfolio.form.quantity': '數量', + 'portfolio.form.quantityRequired': '請輸入數量', + 'portfolio.form.enterQuantity': '輸入持倉數量', + 'portfolio.form.entryPrice': '買入價', + 'portfolio.form.entryPriceRequired': '請輸入買入價', + 'portfolio.form.enterEntryPrice': '輸入買入價格', + 'portfolio.form.notes': '備注', + 'portfolio.form.enterNotes': '可選:添加備注', + 'portfolio.form.monitorName': '監控名稱', + 'portfolio.form.monitorNameRequired': '請輸入監控名稱', + 'portfolio.form.enterMonitorName': '例如:每日組合分析', + 'portfolio.form.interval': '執行間隔', + 'portfolio.form.minutes': '分鐘', + 'portfolio.form.hour': '小時', + 'portfolio.form.hours': '小時', + 'portfolio.form.notifyChannels': '通知渠道', + 'portfolio.form.browser': '瀏覽器通知', + 'portfolio.form.email': '郵件', + 'portfolio.form.telegramChatId': 'Telegram Chat ID', + 'portfolio.form.enterTelegramChatId': '輸入 Telegram Chat ID', + 'portfolio.form.telegramRequired': '請輸入 Telegram Chat ID', + 'portfolio.form.emailAddress': '郵箱地址', + 'portfolio.form.enterEmail': '輸入郵箱地址', + 'portfolio.form.emailRequired': '請輸入郵箱地址', + 'portfolio.form.emailInvalid': '請輸入有效的郵箱地址', + 'portfolio.form.customPrompt': '自定義提示', + 'portfolio.form.customPromptPlaceholder': '可選:添加特別關注點,例如"重點關注科技股風險"', + 'portfolio.form.monitorScope': '監控範圍', + 'portfolio.form.allPositions': '全部持倉', + 'portfolio.form.selectedPositions': '指定持倉', + 'portfolio.form.selectPositions': '選擇持倉', + 'portfolio.form.selectAll': '全選', + 'portfolio.form.deselectAll': '全不選', + 'portfolio.form.selectedCount': '已選 {count}/{total}', + 'portfolio.form.pleaseSelectPositions': '請至少選擇一個持倉進行監控', + 'portfolio.message.loadFailed': '加載數據失敗', + 'portfolio.message.saveSuccess': '保存成功', + 'portfolio.message.saveFailed': '保存失敗', + 'portfolio.message.deleteSuccess': '刪除成功', + 'portfolio.message.deleteFailed': '刪除失敗', + 'portfolio.message.updateFailed': '更新失敗', + 'portfolio.message.monitorEnabled': '監控已啟用', + 'portfolio.message.monitorDisabled': '監控已暫停', + 'portfolio.message.monitorRunSuccess': '分析完成', + 'portfolio.message.monitorRunFailed': '分析失敗', + 'portfolio.message.monitorRunning': 'AI 分析已啟動,請等待通知', + // Portfolio - 分組 + 'portfolio.groups.all': '全部持倉', + 'portfolio.groups.ungrouped': '未分組', + 'portfolio.form.group': '分組', + 'portfolio.form.enterGroup': '輸入或選擇分組', + // Portfolio - 預警 + 'portfolio.alerts.title': '價格/盈虧預警', + 'portfolio.alerts.addAlert': '添加預警', + 'portfolio.alerts.editAlert': '編輯預警', + 'portfolio.alerts.alertType': '預警類型', + 'portfolio.alerts.priceAbove': '價格高於', + 'portfolio.alerts.priceBelow': '價格低於', + 'portfolio.alerts.pnlAbove': '盈利高於 (%)', + 'portfolio.alerts.pnlBelow': '虧損低於 (%)', + 'portfolio.alerts.threshold': '閾值', + 'portfolio.alerts.thresholdRequired': '請輸入閾值', + 'portfolio.alerts.enterPrice': '輸入價格', + 'portfolio.alerts.enterPercent': '輸入百分比', + 'portfolio.alerts.currentPrice': '當前價格', + 'portfolio.alerts.currentPriceHint': '當前價格', + 'portfolio.alerts.repeatInterval': '重複提醒', + 'portfolio.alerts.noRepeat': '不重複 (觸發一次)', + 'portfolio.alerts.every5min': '每 5 分鐘', + 'portfolio.alerts.every15min': '每 15 分鐘', + 'portfolio.alerts.every30min': '每 30 分鐘', + 'portfolio.alerts.every1hour': '每 1 小時', + 'portfolio.alerts.every4hours': '每 4 小時', + 'portfolio.alerts.onceDaily': '每天一次', + 'portfolio.alerts.enabled': '啟用預警', + 'portfolio.alerts.enabledDesc': '開啟後將自動監測並觸發通知', + 'portfolio.alerts.delete': '刪除', + 'portfolio.alerts.deleteConfirm': '確定要刪除此預警嗎?', + 'portfolio.modal.addAlert': '添加預警', + 'portfolio.modal.editAlert': '編輯預警', + + // User Management + 'menu.userManage': '用戶管理', + 'menu.myProfile': '個人中心', + 'userManage.title': '用戶管理', + 'userManage.searchPlaceholder': '搜索用戶名/郵箱/暱稱', + 'userManage.description': '管理系統用戶、角色和權限', + 'userManage.createUser': '創建用戶', + 'userManage.editUser': '編輯用戶', + 'userManage.username': '用戶名', + 'userManage.password': '密碼', + 'userManage.nickname': '暱稱', + 'userManage.email': '郵箱', + 'userManage.role': '角色', + 'userManage.status': '狀態', + 'userManage.lastLogin': '最後登錄', + 'userManage.active': '啟用', + 'userManage.disabled': '禁用', + 'userManage.neverLogin': '從未登錄', + 'userManage.usernameRequired': '請輸入用戶名', + 'userManage.usernamePlaceholder': '輸入用戶名', + 'userManage.passwordRequired': '請輸入密碼', + 'userManage.passwordPlaceholder': '輸入密碼(至少6位)', + 'userManage.passwordMin': '密碼至少6個字符', + 'userManage.nicknamePlaceholder': '輸入暱稱', + 'userManage.emailPlaceholder': '輸入郵箱', + 'userManage.emailInvalid': '郵箱格式不正確', + 'userManage.rolePlaceholder': '選擇角色', + 'userManage.statusPlaceholder': '選擇狀態', + 'userManage.resetPassword': '重置密碼', + 'userManage.resetPasswordWarning': '此操作將重置用戶密碼', + 'userManage.newPassword': '新密碼', + 'userManage.newPasswordPlaceholder': '輸入新密碼', + 'userManage.confirmDelete': '確定要刪除此用戶嗎?', + 'userManage.roleAdmin': '管理員', + 'userManage.roleManager': '經理', + 'userManage.roleUser': '普通用戶', + 'userManage.roleViewer': '訪客', + 'userManage.credits': '積分', + 'userManage.adjustCredits': '調整積分', + 'userManage.setVip': '設置VIP', + 'userManage.currentCredits': '當前積分', + 'userManage.newCredits': '新積分', + 'userManage.enterCredits': '輸入新的積分數量', + 'userManage.creditsNonNegative': '積分不能為負數', + 'userManage.currentVip': '當前VIP狀態', + 'userManage.vipActive': '有效', + 'userManage.vipExpired': '已過期', + 'userManage.vipDays': 'VIP天數', + 'userManage.vipExpiresAt': 'VIP過期時間', + 'userManage.cancelVip': '取消VIP', + 'userManage.days': '天', + 'userManage.customDate': '自定義日期', + 'userManage.selectDate': '請選擇日期', + 'userManage.remark': '備注', + 'userManage.remarkPlaceholder': '可選備注', + + // Profile + 'profile.title': '個人中心', + 'profile.description': '管理您的賬戶設置和偏好', + 'profile.basicInfo': '基本信息', + 'profile.changePassword': '修改密碼', + 'profile.username': '用戶名', + 'profile.nickname': '暱稱', + 'profile.email': '郵箱', + 'profile.lastLogin': '最後登錄', + 'profile.nicknamePlaceholder': '輸入您的暱稱', + 'profile.emailPlaceholder': '輸入您的郵箱', + 'profile.emailInvalid': '郵箱格式不正確', + 'profile.emailCannotChange': '註冊後郵箱不可修改', + 'profile.passwordHint': '密碼至少需要6個字符', + 'profile.oldPassword': '當前密碼', + 'profile.newPassword': '新密碼', + 'profile.confirmPassword': '確認密碼', + 'profile.oldPasswordRequired': '請輸入當前密碼', + 'profile.oldPasswordPlaceholder': '輸入當前密碼', + 'profile.newPasswordRequired': '請輸入新密碼', + 'profile.newPasswordPlaceholder': '輸入新密碼', + 'profile.confirmPasswordRequired': '請確認新密碼', + 'profile.confirmPasswordPlaceholder': '再次輸入新密碼', + 'profile.passwordMin': '密碼至少6個字符', + 'profile.passwordMismatch': '兩次輸入的密碼不一致', + 'profile.credits.title': '我的積分', + 'profile.credits.unit': '積分', + 'profile.credits.recharge': '開通/充值', + 'profile.credits.vipExpires': 'VIP有效期至', + 'profile.credits.vipExpired': 'VIP已過期', + 'profile.credits.noVip': '非VIP用戶', + 'profile.credits.hint': '使用AI分析等功能會消耗積分,VIP用戶免費', + 'profile.creditsLog': '消費記錄', + 'profile.creditsLog.time': '時間', + 'profile.creditsLog.action': '類型', + 'profile.creditsLog.amount': '變動', + 'profile.creditsLog.balance': '餘額', + 'profile.creditsLog.remark': '備注', + 'profile.creditsLog.actionConsume': '消費', + 'profile.creditsLog.actionRecharge': '充值', + 'profile.creditsLog.actionAdjust': '調整', + 'profile.creditsLog.actionRefund': '退款', + 'profile.creditsLog.actionVipGrant': 'VIP授予', + 'profile.creditsLog.actionVipRevoke': 'VIP取消', + 'profile.creditsLog.actionRegisterBonus': '註冊獎勵', + 'profile.creditsLog.actionReferralBonus': '邀請獎勵', + 'profile.referral.title': '邀請好友', + 'profile.referral.listTab': '邀請列表', + 'profile.referral.totalInvited': '已邀請', + 'profile.referral.bonusPerInvite': '每邀請獲得', + 'profile.referral.yourLink': '您的邀請鏈接', + 'profile.referral.copyLink': '複製鏈接', + 'profile.referral.linkCopied': '邀請鏈接已複製', + 'profile.referral.newUserBonus': '新用戶註冊獲得', + 'profile.referral.user': '用戶', + 'profile.referral.registerTime': '註冊時間', + 'profile.referral.noReferrals': '暫無邀請記錄', + 'profile.referral.shareNow': '立即分享邀請', + + // Settings - Billing + 'settings.group.billing': '計費配置', + 'settings.field.BILLING_ENABLED': '啟用計費', + 'settings.field.BILLING_VIP_BYPASS': 'VIP免費', + 'settings.field.BILLING_COST_AI_ANALYSIS': 'AI分析消耗', + 'settings.field.BILLING_COST_STRATEGY_RUN': '策略運行消耗', + 'settings.field.BILLING_COST_BACKTEST': '回測消耗', + 'settings.field.BILLING_COST_PORTFOLIO_MONITOR': 'Portfolio監控消耗', + 'settings.field.CREDITS_REGISTER_BONUS': '註冊獎勵', + 'settings.field.CREDITS_REFERRAL_BONUS': '邀請獎勵', + 'settings.field.RECHARGE_TELEGRAM_URL': '充值Telegram鏈接', + 'settings.desc.BILLING_ENABLED': '啟用計費系統。啟用後,用戶使用某些功能需要消耗積分', + 'settings.desc.BILLING_VIP_BYPASS': 'VIP用戶在有效期內可免費使用所有付費功能', + 'settings.desc.BILLING_COST_AI_ANALYSIS': '每次AI分析消耗的積分數', + 'settings.desc.BILLING_COST_STRATEGY_RUN': '啟動策略時消耗的積分數', + 'settings.desc.BILLING_COST_BACKTEST': '每次回測消耗的積分數', + 'settings.desc.BILLING_COST_PORTFOLIO_MONITOR': '每次Portfolio AI監控消耗的積分數', + 'settings.desc.CREDITS_REGISTER_BONUS': '新用戶註冊時獲得的積分獎勵', + 'settings.desc.CREDITS_REFERRAL_BONUS': '用戶通過邀請鏈接成功邀請新用戶時,邀請人獲得的積分獎勵', + 'settings.desc.VERIFICATION_CODE_MAX_ATTEMPTS': '驗證碼驗證失敗的最大嘗試次數,超過後將鎖定', + 'settings.desc.VERIFICATION_CODE_LOCK_MINUTES': '驗證碼驗證失敗次數超過限制後的鎖定時長(分鐘)', + 'settings.desc.RECHARGE_TELEGRAM_URL': '用戶點擊充值時跳轉的Telegram客服鏈接' } export default { diff --git a/quantdinger_vue/src/permission.js b/quantdinger_vue/src/permission.js index d4c4c2b..9f7bd23 100644 --- a/quantdinger_vue/src/permission.js +++ b/quantdinger_vue/src/permission.js @@ -29,7 +29,12 @@ router.beforeEach((to, from, next) => { to.meta && typeof to.meta.title !== 'undefined' && setDocumentTitle(`${i18nRender(to.meta.title)} - ${domTitle}`) // Check whether we have a token (local-only auth). - const token = storage.get(ACCESS_TOKEN) + // 处理 token 可能是字符串或对象的情况 + let token = storage.get(ACCESS_TOKEN) + if (token && typeof token !== 'string') { + token = token.token || token.value || (typeof token === 'object' ? null : token) + } + token = typeof token === 'string' ? token : null if (token) { // 有 token,允许访问所有页面 diff --git a/quantdinger_vue/src/utils/request.js b/quantdinger_vue/src/utils/request.js index 5535ce9..93ab62e 100644 --- a/quantdinger_vue/src/utils/request.js +++ b/quantdinger_vue/src/utils/request.js @@ -10,6 +10,26 @@ const PHPSESSID_KEY = 'PHPSESSID' // Locale storage key used by vue-i18n (see src/locales/index.js) const LOCALE_KEY = 'lang' +/** + * 获取 token,处理 token 可能是字符串或对象的情况 + */ +function getToken () { + let token = storage.get(ACCESS_TOKEN) + if (!token) { + return null + } + if (typeof token !== 'string') { + // 如果是对象,尝试获取 token 属性 + if (token && typeof token === 'object') { + token = token.token || token.value || null + } else { + token = null + } + } + // 确保 token 是字符串且不为空 + return (typeof token === 'string' && token.length > 0) ? token : null +} + // 创建 axios 实例 const request = axios.create({ // API 请求的默认前缀 @@ -38,11 +58,11 @@ const errorHandler = (error) => { description: 'Authorization verification failed' }) // 不清理本地 token,避免刷新后丢失登录态;仅跳转到登录页 - const loginPath = '/user/login' - const cur = window.location.pathname + window.location.search - if (!cur.includes('/user/login')) { - const redirect = encodeURIComponent(cur) - window.location.assign(`${loginPath}?redirect=${redirect}`) + // 项目使用 hash 模式,需要跳转到 /#/user/login + const curHash = window.location.hash || '' + if (!curHash.includes('/user/login')) { + const redirect = encodeURIComponent(curHash.replace('#', '') || '/') + window.location.assign(`/#/user/login?redirect=${redirect}`) } } } @@ -51,7 +71,8 @@ const errorHandler = (error) => { // request interceptor request.interceptors.request.use(config => { - const token = storage.get(ACCESS_TOKEN) + // 使用统一的 token 获取函数 + const token = getToken() const lang = storage.get(LOCALE_KEY) || 'en-US' // Tell backend which UI language user is using, so AI reports can match it. @@ -67,6 +88,15 @@ request.interceptors.request.use(config => { config.headers[ACCESS_TOKEN] = token // 兼容后端要求的 token 头 config.headers['token'] = token + } else { + // 调试:如果 token 不存在,记录日志 + if (config.url && config.url.includes('/api/auth/info')) { + const rawToken = storage.get(ACCESS_TOKEN) + console.warn('Token missing for /api/auth/info request') + console.warn('Raw token from storage:', rawToken) + console.warn('Token type:', typeof rawToken) + console.warn('Token value:', rawToken) + } } // 防止缓存导致的 304:为请求添加禁止缓存的头 diff --git a/quantdinger_vue/src/views/portfolio/index.vue b/quantdinger_vue/src/views/portfolio/index.vue index 6f697c7..b79fda0 100644 --- a/quantdinger_vue/src/views/portfolio/index.vue +++ b/quantdinger_vue/src/views/portfolio/index.vue @@ -1585,13 +1585,21 @@ export default { async runMonitorNow (id) { this.runningMonitor = id try { - // 传递当前语言给后端 + // 传递当前语言给后端,使用异步模式 const currentLang = this.$store.getters.lang || 'en-US' - const res = await runMonitor(id, { language: currentLang }) + const res = await runMonitor(id, { language: currentLang, async: true }) if (res && res.code === 1) { - if (res.data?.success) { + // 异步模式:后端立即返回,在后台执行 + if (res.data?.status === 'running') { + this.$message.success(this.$t('portfolio.message.monitorRunning')) + this.$notification.info({ + message: this.$t('portfolio.monitors.runningTitle'), + description: this.$t('portfolio.monitors.runningDesc'), + duration: 5 + }) + } else if (res.data?.success) { + // 同步模式返回结果(兼容旧逻辑) this.$message.success(this.$t('portfolio.message.monitorRunSuccess')) - // Show analysis result in a modal or notification if (res.data.analysis) { this.$notification.open({ message: this.$t('portfolio.monitors.analysisResult'), @@ -1599,13 +1607,22 @@ export default { duration: 0 }) } - } else { - this.$message.error(res.data?.error || this.$t('portfolio.message.monitorRunFailed')) + } else if (res.data?.error) { + this.$message.error(res.data.error || this.$t('portfolio.message.monitorRunFailed')) } this.loadMonitors() } } catch (e) { - this.$message.error(this.$t('portfolio.message.monitorRunFailed')) + // Handle timeout gracefully - analysis may still be running in background + if (e.code === 'ECONNABORTED' || e.message?.includes('timeout')) { + this.$notification.warning({ + message: this.$t('portfolio.monitors.timeoutTitle'), + description: this.$t('portfolio.monitors.timeoutDesc'), + duration: 8 + }) + } else { + this.$message.error(this.$t('portfolio.message.monitorRunFailed')) + } } finally { this.runningMonitor = null } diff --git a/quantdinger_vue/src/views/profile/index.vue b/quantdinger_vue/src/views/profile/index.vue index 84609f6..34582d2 100644 --- a/quantdinger_vue/src/views/profile/index.vue +++ b/quantdinger_vue/src/views/profile/index.vue @@ -8,9 +8,9 @@

{{ $t('profile.description') || 'Manage your account settings and preferences' }}

- - - + + +
@@ -19,6 +19,10 @@ {{ getRoleLabel(profile.role) }} + + + VIP +

@@ -42,8 +46,98 @@
- - + + + + + + +
+

+ + {{ $t('profile.credits.title') || '我的积分' }} +

+
+
+
+ {{ formatCredits(billing.credits) }} + {{ $t('profile.credits.unit') || '积分' }} +
+
+ + + {{ $t('profile.credits.vipExpires') || 'VIP有效期至' }}: {{ formatDate(billing.vip_expires_at) }} + + + {{ $t('profile.credits.vipExpired') || 'VIP已过期' }} + +
+
+ {{ $t('profile.credits.noVip') || '非VIP用户' }} +
+
+ +
+ + {{ $t('profile.credits.recharge') || '开通/充值' }} + +
+
+ + {{ $t('profile.credits.hint') || '使用AI分析等功能会消耗积分,VIP用户免费' }} +
+
+
+ + + + +
+

+ + {{ $t('profile.referral.title') || '邀请好友' }} +

+
+
+
+
+ {{ referralData.total || 0 }} + {{ $t('profile.referral.totalInvited') || '已邀请' }} +
+
+ +{{ referralData.referral_bonus }} + {{ $t('profile.referral.bonusPerInvite') || '每邀请获得' }} +
+
+ + +
+ + {{ $t('profile.referral.newUserBonus') || '新用户注册获得' }} {{ referralData.register_bonus }} {{ $t('profile.credits.unit') || '积分' }} +
+
+
+
+
+
+
+ + + + @@ -60,13 +154,13 @@ + + + @@ -83,21 +177,42 @@ - - - - + + + + + + + + + + + {{ pwdCodeCountdown > 0 ? `${pwdCodeCountdown}s` : ($t('profile.sendCode') || 'Send Code') }} + + + + + @@ -105,7 +220,7 @@ v-decorator="['new_password', { rules: [ { required: true, message: $t('profile.newPasswordRequired') || 'Please enter new password' }, - { min: 6, message: $t('profile.passwordMin') || 'Password must be at least 6 characters' } + { validator: validateNewPassword } ] }]" :placeholder="$t('profile.newPasswordPlaceholder') || 'Enter new password'" @@ -129,13 +244,81 @@ - + {{ $t('profile.changePassword') || 'Change Password' }} + + + + + + + + + + + + + + + + + + + + + + + + + + + {{ $t('profile.referral.noReferrals') || '暂无邀请记录' }} + + {{ $t('profile.referral.shareNow') || '立即分享邀请' }} + + + @@ -144,7 +327,8 @@