feat: improve authentication and user management
- Fix token missing issue after email code registration/login - Add last login time update for code-based login - Support email login in addition to username login - Fix password login for code-registered users (allow setting password) - Fix referral code parameter passing from URL hash - Add email column to user management table - Improve profile page layout (align card heights, reorganize layout) - Add i18n support for Register Bonus, Referral Bonus, Code Lock Minutes, Code Max Attempts - Fix billing service add_credits to support reference_id parameter - Update password handling documentation
This commit is contained in:
@@ -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')
|
||||
|
||||
@@ -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='')
|
||||
|
||||
|
||||
@@ -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)."""
|
||||
|
||||
@@ -680,25 +680,60 @@ def delete_monitor(monitor_id):
|
||||
@portfolio_bp.route('/monitors/<int:monitor_id>/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())
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user