Files
DinQuant/backend_api_python/app/routes/user.py
T
TIANHE abbad97bdd v2.2.1: frontend closed-source + Docker one-click deploy
- Remove frontend source code (now in private repo)
- Add pre-built frontend/dist/ with Nginx serving
- Simplify docker-compose.yml (no Node.js build needed)
- Update README with docs index and Docker deploy guide
- Add admin order list and AI analysis stats tabs
- Add quick trade API routes
- Clean up redundant files (package-lock.json, yarn.lock, .iml)
- Add GitHub Actions workflow for frontend update automation
2026-02-27 19:57:23 +08:00

1520 lines
58 KiB
Python

"""
User Management API Routes
Provides endpoints for user CRUD operations, role management, etc.
Only accessible by admin users.
"""
import json
from flask import Blueprint, request, jsonify, g
from app.services.user_service import get_user_service
from app.utils.auth import login_required, admin_required
from app.utils.db import get_db_connection
from app.utils.logger import get_logger
logger = get_logger(__name__)
user_bp = Blueprint('user_manage', __name__)
@user_bp.route('/list', methods=['GET'])
@login_required
@admin_required
def list_users():
"""
List all users (admin only).
Query params:
page: int (default 1)
page_size: int (default 20, max 100)
search: str (optional, search by username/email/nickname)
"""
try:
page = request.args.get('page', 1, type=int)
page_size = request.args.get('page_size', 20, type=int)
search = request.args.get('search', '', type=str)
page_size = min(100, max(1, page_size))
result = get_user_service().list_users(page=page, page_size=page_size, search=search)
return jsonify({
'code': 1,
'msg': 'success',
'data': result
})
except Exception as e:
logger.error(f"list_users failed: {e}")
return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500
@user_bp.route('/detail', methods=['GET'])
@login_required
@admin_required
def get_user_detail():
"""Get user detail by ID (admin only)"""
try:
user_id = request.args.get('id', type=int)
if not user_id:
return jsonify({'code': 0, 'msg': 'Missing user id', 'data': None}), 400
user = get_user_service().get_user_by_id(user_id)
if not user:
return jsonify({'code': 0, 'msg': 'User not found', 'data': None}), 404
return jsonify({
'code': 1,
'msg': 'success',
'data': user
})
except Exception as e:
logger.error(f"get_user_detail failed: {e}")
return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500
@user_bp.route('/create', methods=['POST'])
@login_required
@admin_required
def create_user():
"""
Create a new user (admin only).
Request body:
username: str (required)
password: str (required)
email: str (optional)
nickname: str (optional)
role: str (optional, default 'user')
"""
try:
data = request.get_json() or {}
user_id = get_user_service().create_user(data)
return jsonify({
'code': 1,
'msg': 'User created successfully',
'data': {'id': user_id}
})
except ValueError as e:
return jsonify({'code': 0, 'msg': str(e), 'data': None}), 400
except Exception as e:
logger.error(f"create_user failed: {e}")
return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500
@user_bp.route('/update', methods=['PUT'])
@login_required
@admin_required
def update_user():
"""
Update user information (admin only).
Query params:
id: int (required)
Request body:
email: str (optional)
nickname: str (optional)
role: str (optional)
status: str (optional)
"""
try:
user_id = request.args.get('id', type=int)
if not user_id:
return jsonify({'code': 0, 'msg': 'Missing user id', 'data': None}), 400
data = request.get_json() or {}
success = get_user_service().update_user(user_id, data)
if success:
return jsonify({'code': 1, 'msg': 'User updated successfully', 'data': None})
else:
return jsonify({'code': 0, 'msg': 'Update failed', 'data': None}), 400
except Exception as e:
logger.error(f"update_user failed: {e}")
return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500
@user_bp.route('/delete', methods=['DELETE'])
@login_required
@admin_required
def delete_user():
"""Delete a user (admin only)"""
try:
user_id = request.args.get('id', type=int)
if not user_id:
return jsonify({'code': 0, 'msg': 'Missing user id', 'data': None}), 400
# Prevent deleting self
if hasattr(g, 'user_id') and g.user_id == user_id:
return jsonify({'code': 0, 'msg': 'Cannot delete yourself', 'data': None}), 400
success = get_user_service().delete_user(user_id)
if success:
return jsonify({'code': 1, 'msg': 'User deleted successfully', 'data': None})
else:
return jsonify({'code': 0, 'msg': 'Delete failed', 'data': None}), 400
except Exception as e:
logger.error(f"delete_user failed: {e}")
return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500
@user_bp.route('/reset-password', methods=['POST'])
@login_required
@admin_required
def reset_user_password():
"""
Reset a user's password (admin only).
Request body:
user_id: int (required)
new_password: str (required)
"""
try:
data = request.get_json() or {}
user_id = data.get('user_id')
new_password = data.get('new_password', '')
if not user_id:
return jsonify({'code': 0, 'msg': 'Missing user_id', 'data': None}), 400
if len(new_password) < 6:
return jsonify({'code': 0, 'msg': 'Password must be at least 6 characters', 'data': None}), 400
success = get_user_service().reset_password(user_id, new_password)
if success:
return jsonify({'code': 1, 'msg': 'Password reset successfully', 'data': None})
else:
return jsonify({'code': 0, 'msg': 'Reset failed', 'data': None}), 400
except ValueError as e:
return jsonify({'code': 0, 'msg': str(e), 'data': None}), 400
except Exception as e:
logger.error(f"reset_user_password failed: {e}")
return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500
@user_bp.route('/roles', methods=['GET'])
@login_required
@admin_required
def get_roles():
"""Get available roles and their permissions"""
service = get_user_service()
roles = []
for role in service.ROLES:
roles.append({
'id': role,
'name': role.capitalize(),
'permissions': service.get_user_permissions(role)
})
return jsonify({
'code': 1,
'msg': 'success',
'data': {'roles': roles}
})
# ==================== Billing Management (Admin) ====================
@user_bp.route('/set-credits', methods=['POST'])
@login_required
@admin_required
def set_user_credits():
"""
Set user credits (admin only).
Request body:
user_id: int (required)
credits: int (required)
remark: str (optional)
"""
try:
from app.services.billing_service import get_billing_service
data = request.get_json() or {}
user_id = data.get('user_id')
credits = data.get('credits')
remark = data.get('remark', '')
if not user_id:
return jsonify({'code': 0, 'msg': 'Missing user_id', 'data': None}), 400
if credits is None or credits < 0:
return jsonify({'code': 0, 'msg': 'Credits must be a non-negative number', 'data': None}), 400
operator_id = getattr(g, 'user_id', None)
success, result = get_billing_service().set_credits(user_id, int(credits), remark, operator_id)
if success:
return jsonify({'code': 1, 'msg': 'Credits updated successfully', 'data': {'credits': result}})
else:
return jsonify({'code': 0, 'msg': result, 'data': None}), 400
except Exception as e:
logger.error(f"set_user_credits failed: {e}")
return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500
@user_bp.route('/set-vip', methods=['POST'])
@login_required
@admin_required
def set_user_vip():
"""
Set user VIP status (admin only).
Request body:
user_id: int (required)
vip_days: int (optional, 0 to cancel VIP, positive number to grant VIP for days)
vip_expires_at: str (optional, ISO format datetime, overrides vip_days if provided)
remark: str (optional)
"""
try:
from datetime import datetime, timedelta, timezone
from app.services.billing_service import get_billing_service
data = request.get_json() or {}
user_id = data.get('user_id')
vip_days = data.get('vip_days')
vip_expires_at_str = data.get('vip_expires_at')
remark = data.get('remark', '')
if not user_id:
return jsonify({'code': 0, 'msg': 'Missing user_id', 'data': None}), 400
# Calculate expires_at
expires_at = None
if vip_expires_at_str:
try:
expires_at = datetime.fromisoformat(vip_expires_at_str.replace('Z', '+00:00'))
except ValueError:
return jsonify({'code': 0, 'msg': 'Invalid vip_expires_at format', 'data': None}), 400
elif vip_days is not None:
if vip_days > 0:
expires_at = datetime.now(timezone.utc) + timedelta(days=vip_days)
else:
expires_at = None # Cancel VIP
else:
return jsonify({'code': 0, 'msg': 'Provide vip_days or vip_expires_at', 'data': None}), 400
operator_id = getattr(g, 'user_id', None)
success, result = get_billing_service().set_vip(user_id, expires_at, remark, operator_id)
if success:
return jsonify({
'code': 1,
'msg': 'VIP status updated successfully',
'data': {'vip_expires_at': expires_at.isoformat() if expires_at else None}
})
else:
return jsonify({'code': 0, 'msg': result, 'data': None}), 400
except Exception as e:
logger.error(f"set_user_vip failed: {e}")
return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500
@user_bp.route('/credits-log', methods=['GET'])
@login_required
@admin_required
def get_user_credits_log():
"""
Get user credits log (admin only).
Query params:
user_id: int (required)
page: int (default 1)
page_size: int (default 20)
"""
try:
from app.services.billing_service import get_billing_service
user_id = request.args.get('user_id', type=int)
if not user_id:
return jsonify({'code': 0, 'msg': 'Missing user_id', 'data': None}), 400
page = request.args.get('page', 1, type=int)
page_size = request.args.get('page_size', 20, type=int)
page_size = min(100, max(1, page_size))
result = get_billing_service().get_credits_log(user_id, page, page_size)
return jsonify({'code': 1, 'msg': 'success', 'data': result})
except Exception as e:
logger.error(f"get_user_credits_log failed: {e}")
return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500
# Self-service endpoints (accessible by any logged-in user)
@user_bp.route('/profile', methods=['GET'])
@login_required
def get_profile():
"""Get current user's profile with billing info and notification settings"""
try:
import json
from app.services.billing_service import get_billing_service
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
user = get_user_service().get_user_by_id(user_id)
if not user:
return jsonify({'code': 0, 'msg': 'User not found', 'data': None}), 404
# Add permissions
user['permissions'] = get_user_service().get_user_permissions(user.get('role', 'user'))
# Add billing info
billing_info = get_billing_service().get_user_billing_info(user_id)
user['billing'] = billing_info
# Add notification settings
with get_db_connection() as db:
cur = db.cursor()
cur.execute("SELECT notification_settings FROM qd_users WHERE id = ?", (user_id,))
row = cur.fetchone()
cur.close()
settings_str = (row.get('notification_settings') if row else '') or ''
notification_settings = {}
if settings_str:
try:
notification_settings = json.loads(settings_str)
except Exception:
notification_settings = {}
# Default values
if 'default_channels' not in notification_settings:
notification_settings['default_channels'] = ['browser']
user['notification_settings'] = notification_settings
return jsonify({
'code': 1,
'msg': 'success',
'data': user
})
except Exception as e:
logger.error(f"get_profile failed: {e}")
return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500
@user_bp.route('/profile/update', methods=['PUT'])
@login_required
def update_profile():
"""
Update current user's profile (limited fields).
Request body:
nickname: str (optional)
avatar: str (optional)
Note: Email cannot be changed after registration (for security).
Only admin can change user email via User Management.
"""
try:
user_id = getattr(g, 'user_id', None)
if not user_id:
return jsonify({'code': 0, 'msg': 'Not authenticated', 'data': None}), 401
data = request.get_json() or {}
# Only allow updating certain fields for self-service
# Email is NOT allowed to be changed (security: bound to account)
allowed = {}
for field in ['nickname', 'avatar']:
if field in data:
allowed[field] = data[field]
if not allowed:
return jsonify({'code': 0, 'msg': 'No valid fields to update', 'data': None}), 400
success = get_user_service().update_user(user_id, allowed)
if success:
return jsonify({'code': 1, 'msg': 'Profile updated successfully', 'data': None})
else:
return jsonify({'code': 0, 'msg': 'Update failed', 'data': None}), 400
except Exception as e:
logger.error(f"update_profile failed: {e}")
return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500
@user_bp.route('/my-credits-log', methods=['GET'])
@login_required
def get_my_credits_log():
"""
Get current user's credits log.
Query params:
page: int (default 1)
page_size: int (default 20)
"""
try:
from app.services.billing_service import get_billing_service
user_id = getattr(g, 'user_id', None)
if not user_id:
return jsonify({'code': 0, 'msg': 'Not authenticated', 'data': None}), 401
page = request.args.get('page', 1, type=int)
page_size = request.args.get('page_size', 20, type=int)
page_size = min(100, max(1, page_size))
result = get_billing_service().get_credits_log(user_id, page, page_size)
return jsonify({'code': 1, 'msg': 'success', 'data': result})
except Exception as e:
logger.error(f"get_my_credits_log failed: {e}")
return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500
@user_bp.route('/my-referrals', methods=['GET'])
@login_required
def get_my_referrals():
"""
Get list of users referred by current user.
Query params:
page: int (default 1)
page_size: int (default 20)
Returns:
list: Users referred by current user (id, username, nickname, avatar, created_at)
total: Total count of referrals
referral_code: Current user's referral code (user ID)
referral_bonus: Credits earned per referral
register_bonus: Credits new users get on registration
"""
try:
import os
from app.utils.db import get_db_connection
user_id = getattr(g, 'user_id', None)
if not user_id:
return jsonify({'code': 0, 'msg': 'Not authenticated', 'data': None}), 401
page = request.args.get('page', 1, type=int)
page_size = request.args.get('page_size', 20, type=int)
page_size = min(100, max(1, page_size))
offset = (page - 1) * page_size
with get_db_connection() as db:
cur = db.cursor()
# Get total count
cur.execute(
"SELECT COUNT(*) as cnt FROM qd_users WHERE referred_by = ?",
(user_id,)
)
total = cur.fetchone()['cnt']
# Get referral list
cur.execute(
"""
SELECT id, username, nickname, avatar, created_at
FROM qd_users
WHERE referred_by = ?
ORDER BY created_at DESC
LIMIT ? OFFSET ?
""",
(user_id, page_size, offset)
)
rows = cur.fetchall()
cur.close()
referrals = []
for row in rows:
referrals.append({
'id': row['id'],
'username': row['username'],
'nickname': row['nickname'],
'avatar': row['avatar'],
'created_at': row['created_at'].isoformat() if row['created_at'] else None
})
return jsonify({
'code': 1,
'msg': 'success',
'data': {
'list': referrals,
'total': total,
'page': page,
'page_size': page_size,
'referral_code': str(user_id),
'referral_bonus': int(os.getenv('CREDITS_REFERRAL_BONUS', '0')),
'register_bonus': int(os.getenv('CREDITS_REGISTER_BONUS', '0'))
}
})
except Exception as e:
logger.error(f"get_my_referrals failed: {e}")
return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500
@user_bp.route('/notification-settings', methods=['GET'])
@login_required
def get_notification_settings():
"""
Get current user's notification settings.
Returns:
notification_settings: {
default_channels: ['browser', 'telegram', ...],
telegram_chat_id: str,
email: str (optional, override for notifications),
discord_webhook: str (optional)
}
"""
try:
import json
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
with get_db_connection() as db:
cur = db.cursor()
cur.execute("SELECT notification_settings, email FROM qd_users WHERE id = ?", (user_id,))
row = cur.fetchone()
cur.close()
if not row:
return jsonify({'code': 0, 'msg': 'User not found', 'data': None}), 404
# Parse notification_settings JSON
settings_str = row.get('notification_settings') or ''
settings = {}
if settings_str:
try:
settings = json.loads(settings_str)
except Exception:
settings = {}
# Default values
if 'default_channels' not in settings:
settings['default_channels'] = ['browser']
if 'email' not in settings:
settings['email'] = row.get('email') or ''
return jsonify({
'code': 1,
'msg': 'success',
'data': settings
})
except Exception as e:
logger.error(f"get_notification_settings failed: {e}")
return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500
@user_bp.route('/notification-settings', methods=['PUT'])
@login_required
def update_notification_settings():
"""
Update current user's notification settings.
Request body:
default_channels: list of str (optional, e.g. ['browser', 'telegram'])
telegram_bot_token: str (optional, user's own Telegram bot token)
telegram_chat_id: str (optional)
email: str (optional, for notification override)
discord_webhook: str (optional)
"""
try:
import json
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
data = request.get_json() or {}
# Validate channels
valid_channels = ['browser', 'email', 'telegram', 'discord', 'webhook', 'phone']
default_channels = data.get('default_channels', [])
if not isinstance(default_channels, list):
default_channels = ['browser']
default_channels = [c for c in default_channels if c in valid_channels]
if not default_channels:
default_channels = ['browser']
# Build settings object
settings = {
'default_channels': default_channels,
'telegram_bot_token': str(data.get('telegram_bot_token') or '').strip(),
'telegram_chat_id': str(data.get('telegram_chat_id') or '').strip(),
'email': str(data.get('email') or '').strip(),
'discord_webhook': str(data.get('discord_webhook') or '').strip(),
'webhook_url': str(data.get('webhook_url') or '').strip(),
'phone': str(data.get('phone') or '').strip(),
}
# Remove empty values (but keep default_channels and telegram_bot_token even if partially filled)
settings = {k: v for k, v in settings.items() if v or k == 'default_channels'}
settings_json = json.dumps(settings, ensure_ascii=False)
with get_db_connection() as db:
cur = db.cursor()
cur.execute(
"UPDATE qd_users SET notification_settings = ?, updated_at = NOW() WHERE id = ?",
(settings_json, user_id)
)
db.commit()
cur.close()
return jsonify({
'code': 1,
'msg': 'Notification settings updated',
'data': settings
})
except Exception as e:
logger.error(f"update_notification_settings failed: {e}")
return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500
@user_bp.route('/change-password', methods=['POST'])
@login_required
def change_password():
"""
Change current user's password.
Request body:
old_password: str (required)
new_password: str (required)
"""
try:
user_id = getattr(g, 'user_id', None)
if not user_id:
return jsonify({'code': 0, 'msg': 'Not authenticated', 'data': None}), 401
data = request.get_json() or {}
old_password = data.get('old_password', '')
new_password = data.get('new_password', '')
if not new_password:
return jsonify({'code': 0, 'msg': 'New password required', 'data': None}), 400
if len(new_password) < 6:
return jsonify({'code': 0, 'msg': 'New password must be at least 6 characters', 'data': None}), 400
# Check if user has a password set
user_service = get_user_service()
user = user_service.get_user_by_id(user_id)
if not user:
return jsonify({'code': 0, 'msg': 'User not found', 'data': None}), 404
# Get password_hash to check if user has no password
from app.utils.db import get_db_connection
with get_db_connection() as db:
cur = db.cursor()
cur.execute("SELECT password_hash FROM qd_users WHERE id = ?", (user_id,))
row = cur.fetchone()
cur.close()
password_hash = row.get('password_hash', '') if row else ''
has_password = password_hash and password_hash.strip() != ''
# If user has no password, allow setting password without old password
if not has_password:
if not old_password:
# No old password required for users without password
success = user_service.reset_password(user_id, new_password)
if success:
return jsonify({'code': 1, 'msg': 'Password set successfully', 'data': None})
else:
return jsonify({'code': 0, 'msg': 'Failed to set password', 'data': None}), 500
else:
# If old_password is provided but user has no password, ignore it
success = user_service.reset_password(user_id, new_password)
if success:
return jsonify({'code': 1, 'msg': 'Password set successfully', 'data': None})
else:
return jsonify({'code': 0, 'msg': 'Failed to set password', 'data': None}), 500
else:
# User has existing password, require old password verification
if not old_password:
return jsonify({'code': 0, 'msg': 'Old password required', 'data': None}), 400
success = user_service.change_password(user_id, old_password, new_password)
if success:
return jsonify({'code': 1, 'msg': 'Password changed successfully', 'data': None})
else:
return jsonify({'code': 0, 'msg': 'Old password incorrect', 'data': None}), 400
except ValueError as e:
return jsonify({'code': 0, 'msg': str(e), 'data': None}), 400
except Exception as e:
logger.error(f"change_password failed: {e}")
return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500
# ==================== System Overview (Admin) ====================
def _safe_json_loads(s, default=None):
"""Safely parse JSON string."""
if not s:
return default
if isinstance(s, dict):
return s
try:
return json.loads(s)
except Exception:
return default
@user_bp.route('/system-strategies', methods=['GET'])
@login_required
@admin_required
def get_system_strategies():
"""
Get all strategies across the entire system (admin only).
Returns strategy details with user info, positions, PnL, indicators, etc.
Query params:
page: int (default 1)
page_size: int (default 20, max 100)
status: str (optional, filter by status: running/stopped/all)
search: str (optional, search by strategy name/symbol/username)
"""
try:
page = request.args.get('page', 1, type=int)
page_size = request.args.get('page_size', 20, type=int)
status_filter = request.args.get('status', '', type=str).strip().lower()
search = request.args.get('search', '', type=str).strip()
page_size = min(100, max(1, page_size))
offset = (page - 1) * page_size
with get_db_connection() as db:
cur = db.cursor()
# Build WHERE clause
conditions = []
params = []
if status_filter and status_filter != 'all':
conditions.append("s.status = ?")
params.append(status_filter)
if search:
conditions.append(
"(s.strategy_name ILIKE ? OR s.symbol ILIKE ? OR u.username ILIKE ? OR u.nickname ILIKE ?)"
)
like_val = f"%{search}%"
params.extend([like_val, like_val, like_val, like_val])
where_clause = ""
if conditions:
where_clause = "WHERE " + " AND ".join(conditions)
# Get total count
count_sql = f"""
SELECT COUNT(*) as cnt
FROM qd_strategies_trading s
LEFT JOIN qd_users u ON u.id = s.user_id
{where_clause}
"""
cur.execute(count_sql, tuple(params))
total = cur.fetchone()['cnt']
# Get strategies with user info
query_sql = f"""
SELECT
s.id,
s.user_id,
s.strategy_name,
s.strategy_type,
s.market_category,
s.execution_mode,
s.status,
s.symbol,
s.timeframe,
s.initial_capital,
s.leverage,
s.market_type,
s.indicator_config,
s.trading_config,
s.exchange_config,
s.decide_interval,
s.created_at,
s.updated_at,
u.username,
u.nickname
FROM qd_strategies_trading s
LEFT JOIN qd_users u ON u.id = s.user_id
{where_clause}
ORDER BY s.status DESC, s.updated_at DESC
LIMIT ? OFFSET ?
"""
cur.execute(query_sql, tuple(params) + (page_size, offset))
strategies = cur.fetchall() or []
# Collect strategy IDs
strategy_ids = [s['id'] for s in strategies]
# Batch load positions for these strategies
positions_map = {}
if strategy_ids:
placeholders = ','.join(['?'] * len(strategy_ids))
cur.execute(
f"""
SELECT strategy_id, symbol, side, size, entry_price, current_price,
unrealized_pnl, pnl_percent, equity, updated_at
FROM qd_strategy_positions
WHERE strategy_id IN ({placeholders})
ORDER BY strategy_id, updated_at DESC
""",
tuple(strategy_ids)
)
for pos in (cur.fetchall() or []):
sid = pos['strategy_id']
if sid not in positions_map:
positions_map[sid] = []
positions_map[sid].append(dict(pos))
# Batch load recent trade stats (realized PnL per strategy)
trade_stats_map = {}
if strategy_ids:
placeholders = ','.join(['?'] * len(strategy_ids))
cur.execute(
f"""
SELECT strategy_id,
COUNT(*) as trade_count,
COALESCE(SUM(profit), 0) as total_realized_pnl
FROM qd_strategy_trades
WHERE strategy_id IN ({placeholders})
GROUP BY strategy_id
""",
tuple(strategy_ids)
)
for row in (cur.fetchall() or []):
trade_stats_map[row['strategy_id']] = {
'trade_count': row['trade_count'],
'total_realized_pnl': float(row['total_realized_pnl'] or 0)
}
cur.close()
# Build response
items = []
for s in strategies:
sid = s['id']
indicator_config = _safe_json_loads(s.get('indicator_config'), {})
trading_config = _safe_json_loads(s.get('trading_config'), {})
exchange_config = _safe_json_loads(s.get('exchange_config'), {})
# Extract indicator name
indicator_name = ''
if isinstance(indicator_config, dict):
indicator_name = indicator_config.get('indicator_name') or indicator_config.get('name') or ''
# Extract exchange name
exchange_name = ''
if isinstance(exchange_config, dict):
exchange_name = exchange_config.get('exchange_id') or exchange_config.get('exchange') or ''
# Positions data
positions = positions_map.get(sid, [])
total_unrealized_pnl = sum(float(p.get('unrealized_pnl') or 0) for p in positions)
total_equity = sum(float(p.get('equity') or 0) for p in positions)
position_count = len(positions)
# Trade stats
trade_stats = trade_stats_map.get(sid, {'trade_count': 0, 'total_realized_pnl': 0})
total_realized_pnl = trade_stats['total_realized_pnl']
trade_count = trade_stats['trade_count']
# Calculate total PnL and ROI
initial_capital = float(s.get('initial_capital') or 0)
total_pnl = total_unrealized_pnl + total_realized_pnl
roi = (total_pnl / initial_capital * 100) if initial_capital > 0 else 0
# Cross-sectional info
cs_type = ''
symbol_list = []
if isinstance(trading_config, dict):
cs_type = trading_config.get('cs_strategy_type') or 'single'
symbol_list = trading_config.get('symbol_list') or []
# Format timestamps
created_at = s.get('created_at')
updated_at = s.get('updated_at')
if hasattr(created_at, 'isoformat'):
created_at = created_at.isoformat()
if hasattr(updated_at, 'isoformat'):
updated_at = updated_at.isoformat()
# Format position timestamps
for p in positions:
if hasattr(p.get('updated_at'), 'isoformat'):
p['updated_at'] = p['updated_at'].isoformat()
items.append({
'id': sid,
'user_id': s['user_id'],
'username': s.get('username') or '',
'nickname': s.get('nickname') or '',
'strategy_name': s.get('strategy_name') or '',
'strategy_type': s.get('strategy_type') or '',
'cs_strategy_type': cs_type,
'market_category': s.get('market_category') or '',
'execution_mode': s.get('execution_mode') or '',
'status': s.get('status') or 'stopped',
'symbol': s.get('symbol') or '',
'symbol_list': symbol_list,
'timeframe': s.get('timeframe') or '',
'initial_capital': initial_capital,
'leverage': int(s.get('leverage') or 1),
'market_type': s.get('market_type') or '',
'indicator_name': indicator_name,
'exchange_name': exchange_name,
'decide_interval': s.get('decide_interval') or 300,
'position_count': position_count,
'total_unrealized_pnl': round(total_unrealized_pnl, 4),
'total_realized_pnl': round(total_realized_pnl, 4),
'total_pnl': round(total_pnl, 4),
'total_equity': round(total_equity, 4),
'roi': round(roi, 2),
'trade_count': trade_count,
'positions': positions,
'created_at': created_at,
'updated_at': updated_at
})
# Compute summary stats from all matched strategies (not just current page items).
with get_db_connection() as db:
cur = db.cursor()
# Aggregate strategy counts/capital by execution mode and running status.
agg_sql = f"""
SELECT
COUNT(*) AS total_strategies,
COALESCE(SUM(s.initial_capital), 0) AS total_capital,
COALESCE(SUM(CASE WHEN s.status = 'running' THEN 1 ELSE 0 END), 0) AS running_strategies,
COALESCE(SUM(CASE WHEN s.execution_mode = 'live' THEN 1 ELSE 0 END), 0) AS live_strategies,
COALESCE(SUM(CASE WHEN s.execution_mode = 'signal' THEN 1 ELSE 0 END), 0) AS signal_strategies,
COALESCE(SUM(CASE WHEN s.status = 'running' AND s.execution_mode = 'live' THEN 1 ELSE 0 END), 0) AS running_live_strategies,
COALESCE(SUM(CASE WHEN s.status = 'running' AND s.execution_mode = 'signal' THEN 1 ELSE 0 END), 0) AS running_signal_strategies,
COALESCE(SUM(CASE WHEN s.execution_mode = 'live' THEN s.initial_capital ELSE 0 END), 0) AS live_capital,
COALESCE(SUM(CASE WHEN s.execution_mode = 'signal' THEN s.initial_capital ELSE 0 END), 0) AS signal_capital
FROM qd_strategies_trading s
LEFT JOIN qd_users u ON u.id = s.user_id
{where_clause}
"""
cur.execute(agg_sql, tuple(params))
agg_row = cur.fetchone() or {}
# Aggregate unrealized pnl from current positions.
unreal_sql = f"""
SELECT COALESCE(SUM(p.unrealized_pnl), 0) AS total_unrealized,
COALESCE(SUM(CASE WHEN s.execution_mode = 'live' THEN p.unrealized_pnl ELSE 0 END), 0) AS live_unrealized,
COALESCE(SUM(CASE WHEN s.execution_mode = 'signal' THEN p.unrealized_pnl ELSE 0 END), 0) AS signal_unrealized
FROM qd_strategy_positions p
JOIN qd_strategies_trading s ON s.id = p.strategy_id
LEFT JOIN qd_users u ON u.id = s.user_id
{where_clause}
"""
cur.execute(unreal_sql, tuple(params))
unreal_row = cur.fetchone() or {}
# Aggregate realized pnl from trade history.
realized_sql = f"""
SELECT COALESCE(SUM(t.profit), 0) AS total_realized,
COALESCE(SUM(CASE WHEN s.execution_mode = 'live' THEN t.profit ELSE 0 END), 0) AS live_realized,
COALESCE(SUM(CASE WHEN s.execution_mode = 'signal' THEN t.profit ELSE 0 END), 0) AS signal_realized
FROM qd_strategy_trades t
JOIN qd_strategies_trading s ON s.id = t.strategy_id
LEFT JOIN qd_users u ON u.id = s.user_id
{where_clause}
"""
cur.execute(realized_sql, tuple(params))
realized_row = cur.fetchone() or {}
cur.close()
total_capital = float(agg_row.get('total_capital') or 0)
total_running = int(agg_row.get('running_strategies') or 0)
total_system_pnl = float(unreal_row.get('total_unrealized') or 0) + float(realized_row.get('total_realized') or 0)
live_pnl = float(unreal_row.get('live_unrealized') or 0) + float(realized_row.get('live_realized') or 0)
signal_pnl = float(unreal_row.get('signal_unrealized') or 0) + float(realized_row.get('signal_realized') or 0)
return jsonify({
'code': 1,
'msg': 'success',
'data': {
'items': items,
'total': total,
'page': page,
'page_size': page_size,
'summary': {
'total_strategies': int(agg_row.get('total_strategies') or total),
'running_strategies': total_running,
'total_capital': round(total_capital, 2),
'total_pnl': round(total_system_pnl, 4),
'total_roi': round((total_system_pnl / total_capital * 100) if total_capital > 0 else 0, 2),
'live_strategies': int(agg_row.get('live_strategies') or 0),
'signal_strategies': int(agg_row.get('signal_strategies') or 0),
'running_live_strategies': int(agg_row.get('running_live_strategies') or 0),
'running_signal_strategies': int(agg_row.get('running_signal_strategies') or 0),
'live_capital': round(float(agg_row.get('live_capital') or 0), 2),
'signal_capital': round(float(agg_row.get('signal_capital') or 0), 2),
'live_pnl': round(live_pnl, 4),
'signal_pnl': round(signal_pnl, 4)
}
}
})
except Exception as e:
logger.error(f"get_system_strategies failed: {e}")
import traceback
logger.error(traceback.format_exc())
return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500
# ==================== Admin Orders ====================
@user_bp.route('/admin-orders', methods=['GET'])
@login_required
@admin_required
def get_admin_orders():
"""
Get all orders across the system (admin only).
Merges qd_membership_orders and qd_usdt_orders into a unified list.
Query params:
page: int (default 1)
page_size: int (default 20, max 100)
status: str (optional, filter by status: paid/pending/confirmed/expired/all)
search: str (optional, search by username/email)
"""
try:
page = request.args.get('page', 1, type=int)
page_size = request.args.get('page_size', 20, type=int)
status_filter = request.args.get('status', '', type=str).strip().lower()
search = request.args.get('search', '', type=str).strip()
page_size = min(100, max(1, page_size))
offset = (page - 1) * page_size
with get_db_connection() as db:
cur = db.cursor()
# --- USDT Orders (primary) ---
usdt_conditions = []
usdt_params = []
if status_filter and status_filter != 'all':
usdt_conditions.append("o.status = ?")
usdt_params.append(status_filter)
if search:
usdt_conditions.append("(u.username ILIKE ? OR u.email ILIKE ? OR u.nickname ILIKE ?)")
like_val = f"%{search}%"
usdt_params.extend([like_val, like_val, like_val])
usdt_where = ""
if usdt_conditions:
usdt_where = "WHERE " + " AND ".join(usdt_conditions)
# Count
cur.execute(
f"SELECT COUNT(*) as cnt FROM qd_usdt_orders o LEFT JOIN qd_users u ON u.id = o.user_id {usdt_where}",
tuple(usdt_params)
)
usdt_total = cur.fetchone()['cnt']
# --- Membership Orders (mock) ---
mock_conditions = []
mock_params = []
if status_filter and status_filter != 'all':
mock_conditions.append("m.status = ?")
mock_params.append(status_filter)
if search:
mock_conditions.append("(u.username ILIKE ? OR u.email ILIKE ? OR u.nickname ILIKE ?)")
like_val = f"%{search}%"
mock_params.extend([like_val, like_val, like_val])
mock_where = ""
if mock_conditions:
mock_where = "WHERE " + " AND ".join(mock_conditions)
cur.execute(
f"SELECT COUNT(*) as cnt FROM qd_membership_orders m LEFT JOIN qd_users u ON u.id = m.user_id {mock_where}",
tuple(mock_params)
)
mock_total = cur.fetchone()['cnt']
total = usdt_total + mock_total
# Use UNION ALL to merge both tables into one sorted list
# We select a unified schema
union_sql = f"""
SELECT * FROM (
SELECT
o.id,
'usdt' AS order_type,
o.user_id,
u.username,
u.nickname,
u.email AS user_email,
o.plan,
o.amount_usdt AS amount,
'USDT' AS currency,
o.chain,
o.address,
o.tx_hash,
o.status,
o.created_at,
o.paid_at,
o.confirmed_at,
o.expires_at
FROM qd_usdt_orders o
LEFT JOIN qd_users u ON u.id = o.user_id
{usdt_where}
UNION ALL
SELECT
m.id,
'mock' AS order_type,
m.user_id,
u.username,
u.nickname,
u.email AS user_email,
m.plan,
m.price_usd AS amount,
'USD' AS currency,
'' AS chain,
'' AS address,
'' AS tx_hash,
m.status,
m.created_at,
m.paid_at,
NULL AS confirmed_at,
NULL AS expires_at
FROM qd_membership_orders m
LEFT JOIN qd_users u ON u.id = m.user_id
{mock_where}
) AS combined
ORDER BY combined.created_at DESC
LIMIT ? OFFSET ?
"""
all_params = list(usdt_params) + list(mock_params) + [page_size, offset]
cur.execute(union_sql, tuple(all_params))
rows = cur.fetchall() or []
# Summary stats
cur.execute(
f"""SELECT
COUNT(*) AS total_orders,
COALESCE(SUM(CASE WHEN status IN ('paid','confirmed') THEN 1 ELSE 0 END), 0) AS paid_orders,
COALESCE(SUM(CASE WHEN status = 'pending' THEN 1 ELSE 0 END), 0) AS pending_orders,
COALESCE(SUM(CASE WHEN status IN ('expired','cancelled','failed') THEN 1 ELSE 0 END), 0) AS failed_orders,
COALESCE(SUM(CASE WHEN status IN ('paid','confirmed') THEN amount_usdt ELSE 0 END), 0) AS total_revenue
FROM qd_usdt_orders"""
)
summary_row = cur.fetchone() or {}
cur.close()
items = []
for row in rows:
created_at = row.get('created_at')
paid_at = row.get('paid_at')
confirmed_at = row.get('confirmed_at')
expires_at = row.get('expires_at')
if hasattr(created_at, 'isoformat'):
created_at = created_at.isoformat()
if hasattr(paid_at, 'isoformat'):
paid_at = paid_at.isoformat()
if hasattr(confirmed_at, 'isoformat'):
confirmed_at = confirmed_at.isoformat()
if hasattr(expires_at, 'isoformat'):
expires_at = expires_at.isoformat()
items.append({
'id': row['id'],
'order_type': row.get('order_type') or '',
'user_id': row.get('user_id'),
'username': row.get('username') or '',
'nickname': row.get('nickname') or '',
'user_email': row.get('user_email') or '',
'plan': row.get('plan') or '',
'amount': float(row.get('amount') or 0),
'currency': row.get('currency') or '',
'chain': row.get('chain') or '',
'address': row.get('address') or '',
'tx_hash': row.get('tx_hash') or '',
'status': row.get('status') or '',
'created_at': created_at,
'paid_at': paid_at,
'confirmed_at': confirmed_at,
'expires_at': expires_at
})
return jsonify({
'code': 1,
'msg': 'success',
'data': {
'items': items,
'total': total,
'page': page,
'page_size': page_size,
'summary': {
'total_orders': int(summary_row.get('total_orders') or 0),
'paid_orders': int(summary_row.get('paid_orders') or 0),
'pending_orders': int(summary_row.get('pending_orders') or 0),
'failed_orders': int(summary_row.get('failed_orders') or 0),
'total_revenue': round(float(summary_row.get('total_revenue') or 0), 2)
}
}
})
except Exception as e:
logger.error(f"get_admin_orders failed: {e}")
import traceback
logger.error(traceback.format_exc())
return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500
# ==================== Admin AI Analysis Stats ====================
@user_bp.route('/admin-ai-stats', methods=['GET'])
@login_required
@admin_required
def get_admin_ai_stats():
"""
Get AI analysis usage statistics across the system (admin only).
Does NOT expose analysis results, only aggregated counts/stats.
Query params:
page: int (default 1)
page_size: int (default 20, max 100)
search: str (optional, search by username)
"""
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).strip()
page_size = min(100, max(1, page_size))
offset = (page - 1) * page_size
with get_db_connection() as db:
cur = db.cursor()
# --- Overall summary (from qd_analysis_tasks + qd_analysis_memory) ---
cur.execute("""
SELECT
COUNT(*) AS total_tasks,
COUNT(DISTINCT user_id) AS unique_users,
COUNT(DISTINCT symbol) AS unique_symbols,
COUNT(DISTINCT market) AS unique_markets
FROM qd_analysis_tasks
""")
task_summary = cur.fetchone() or {}
memory_summary = {}
try:
cur.execute("""
SELECT
COUNT(*) AS total_memory,
COALESCE(SUM(CASE WHEN was_correct = true THEN 1 ELSE 0 END), 0) AS correct_count,
COALESCE(SUM(CASE WHEN was_correct = false THEN 1 ELSE 0 END), 0) AS incorrect_count,
COALESCE(SUM(CASE WHEN user_feedback = 'helpful' THEN 1 ELSE 0 END), 0) AS helpful_count,
COALESCE(SUM(CASE WHEN user_feedback = 'not_helpful' THEN 1 ELSE 0 END), 0) AS not_helpful_count
FROM qd_analysis_memory
""")
memory_summary = cur.fetchone() or {}
except Exception as mem_err:
logger.warning(f"qd_analysis_memory query failed (table/column may not exist): {mem_err}")
db.rollback()
cur = db.cursor() # re-create cursor after rollback
memory_summary = {}
# --- Per-user stats ---
user_conditions = []
user_params = []
if search:
user_conditions.append("(u.username ILIKE ? OR u.nickname ILIKE ? OR u.email ILIKE ?)")
like_val = f"%{search}%"
user_params.extend([like_val, like_val, like_val])
user_where = ""
if user_conditions:
user_where = "WHERE " + " AND ".join(user_conditions)
# Count distinct users who have analysis records
cur.execute(
f"""
SELECT COUNT(DISTINCT t.user_id) AS cnt
FROM qd_analysis_tasks t
LEFT JOIN qd_users u ON u.id = t.user_id
{user_where}
""",
tuple(user_params)
)
user_total = cur.fetchone()['cnt']
# Get per-user aggregated stats
cur.execute(
f"""
SELECT
t.user_id,
u.username,
u.nickname,
u.email,
COUNT(*) AS analysis_count,
COUNT(DISTINCT t.symbol) AS symbol_count,
COUNT(DISTINCT t.market) AS market_count,
MAX(t.created_at) AS last_analysis_at,
MIN(t.created_at) AS first_analysis_at
FROM qd_analysis_tasks t
LEFT JOIN qd_users u ON u.id = t.user_id
{user_where}
GROUP BY t.user_id, u.username, u.nickname, u.email
ORDER BY analysis_count DESC
LIMIT ? OFFSET ?
""",
tuple(user_params) + (page_size, offset)
)
user_rows = cur.fetchall() or []
# Get per-user analysis_memory stats (correct/helpful counts)
user_ids = [r['user_id'] for r in user_rows if r.get('user_id')]
memory_stats_map = {}
if user_ids:
try:
placeholders = ','.join(['?'] * len(user_ids))
cur.execute(
f"""
SELECT
user_id,
COUNT(*) AS memory_count,
COALESCE(SUM(CASE WHEN was_correct = true THEN 1 ELSE 0 END), 0) AS correct,
COALESCE(SUM(CASE WHEN was_correct = false THEN 1 ELSE 0 END), 0) AS incorrect,
COALESCE(SUM(CASE WHEN user_feedback = 'helpful' THEN 1 ELSE 0 END), 0) AS helpful,
COALESCE(SUM(CASE WHEN user_feedback = 'not_helpful' THEN 1 ELSE 0 END), 0) AS not_helpful
FROM qd_analysis_memory
WHERE user_id IN ({placeholders})
GROUP BY user_id
""",
tuple(user_ids)
)
for row in (cur.fetchall() or []):
memory_stats_map[row['user_id']] = {
'memory_count': row['memory_count'],
'correct': row['correct'],
'incorrect': row['incorrect'],
'helpful': row['helpful'],
'not_helpful': row['not_helpful']
}
except Exception as mem_err:
logger.warning(f"qd_analysis_memory per-user query failed: {mem_err}")
db.rollback()
cur = db.cursor() # re-create cursor after rollback
memory_stats_map = {}
# Get recent analysis records (last 50)
cur.execute(
"""
SELECT
t.id,
t.user_id,
u.username,
u.nickname,
t.market,
t.symbol,
t.model,
t.status,
t.created_at,
t.completed_at
FROM qd_analysis_tasks t
LEFT JOIN qd_users u ON u.id = t.user_id
ORDER BY t.created_at DESC
LIMIT 50
"""
)
recent_rows = cur.fetchall() or []
cur.close()
# Build per-user items
user_items = []
for row in user_rows:
uid = row.get('user_id')
ms = memory_stats_map.get(uid, {})
last_at = row.get('last_analysis_at')
first_at = row.get('first_analysis_at')
if hasattr(last_at, 'isoformat'):
last_at = last_at.isoformat()
if hasattr(first_at, 'isoformat'):
first_at = first_at.isoformat()
user_items.append({
'user_id': uid,
'username': row.get('username') or '',
'nickname': row.get('nickname') or '',
'email': row.get('email') or '',
'analysis_count': row.get('analysis_count') or 0,
'symbol_count': row.get('symbol_count') or 0,
'market_count': row.get('market_count') or 0,
'correct': ms.get('correct', 0),
'incorrect': ms.get('incorrect', 0),
'helpful': ms.get('helpful', 0),
'not_helpful': ms.get('not_helpful', 0),
'last_analysis_at': last_at,
'first_analysis_at': first_at
})
# Build recent records
recent_items = []
for row in recent_rows:
created_at = row.get('created_at')
completed_at = row.get('completed_at')
if hasattr(created_at, 'isoformat'):
created_at = created_at.isoformat()
if hasattr(completed_at, 'isoformat'):
completed_at = completed_at.isoformat()
recent_items.append({
'id': row['id'],
'user_id': row.get('user_id'),
'username': row.get('username') or '',
'nickname': row.get('nickname') or '',
'market': row.get('market') or '',
'symbol': row.get('symbol') or '',
'model': row.get('model') or '',
'status': row.get('status') or '',
'created_at': created_at,
'completed_at': completed_at
})
return jsonify({
'code': 1,
'msg': 'success',
'data': {
'user_stats': user_items,
'user_total': user_total,
'page': page,
'page_size': page_size,
'recent': recent_items,
'summary': {
'total_analyses': int(task_summary.get('total_tasks') or 0),
'unique_users': int(task_summary.get('unique_users') or 0),
'unique_symbols': int(task_summary.get('unique_symbols') or 0),
'unique_markets': int(task_summary.get('unique_markets') or 0),
'total_memory': int(memory_summary.get('total_memory') or 0),
'correct_count': int(memory_summary.get('correct_count') or 0),
'incorrect_count': int(memory_summary.get('incorrect_count') or 0),
'helpful_count': int(memory_summary.get('helpful_count') or 0),
'not_helpful_count': int(memory_summary.get('not_helpful_count') or 0)
}
}
})
except Exception as e:
logger.error(f"get_admin_ai_stats failed: {e}")
import traceback
logger.error(traceback.format_exc())
return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500