""" User Management API Routes Provides endpoints for user CRUD operations, role management, etc. Only accessible by admin users. """ import json import re from flask import Blueprint, g, jsonify, request from app.services.user_service import get_user_service from app.utils.auth import admin_required, login_required from app.utils.db import get_db_connection from app.utils.logger import get_logger logger = get_logger(__name__) _PROFILE_TIMEZONE_RE = re.compile(r"^[A-Za-z0-9_/+\-.]+$") 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) timezone: str (optional, IANA id; empty = follow client) 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 "timezone" in data: tz = (data.get("timezone") or "").strip() if tz and (len(tz) > 64 or not _PROFILE_TIMEZONE_RE.match(tz)): return jsonify({"code": 0, "msg": "Invalid timezone identifier", "data": None}), 400 allowed["timezone"] = tz 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(), "webhook_token": str(data.get("webhook_token") 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("/notification-settings/test", methods=["POST"]) @login_required def test_notification_settings(): """ Send a test notification using the current user's saved notification_settings (save settings first via PUT /notification-settings). """ try: import json from app.services.signal_notifier import SignalNotifier 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 settings_str = row.get("notification_settings") or "" account_email = (row.get("email") or "").strip() settings = {} if settings_str: try: settings = json.loads(settings_str) except Exception: settings = {} channels = settings.get("default_channels") or ["browser"] if not isinstance(channels, list) or not channels: channels = ["browser"] notify_email = (settings.get("email") or "").strip() or account_email targets = { "telegram": (settings.get("telegram_chat_id") or "").strip(), "telegram_bot_token": (settings.get("telegram_bot_token") or "").strip(), "email": notify_email, "phone": (settings.get("phone") or "").strip(), "discord": (settings.get("discord_webhook") or "").strip(), "webhook": (settings.get("webhook_url") or "").strip(), "webhook_token": (settings.get("webhook_token") or "").strip(), } accept = (request.headers.get("Accept-Language") or "") + " " + (request.headers.get("X-Locale") or "") language = "zh-CN" if "zh" in accept.lower() else "en-US" notifier = SignalNotifier() results = notifier.send_profile_test_notifications( user_id=int(user_id), channels=channels, targets=targets, language=language, ) any_ok = any((v or {}).get("ok") for v in results.values()) failed = [k for k, v in results.items() if not (v or {}).get("ok")] if failed: err_detail = {k: (results.get(k) or {}).get("error", "") for k in failed} logger.warning( "notification_settings test: user_id=%s failed_channels=%s errors=%s", user_id, failed, err_detail ) if not any_ok: detail = "; ".join(f"{k}: {(results[k] or {}).get('error', '')}" for k in failed) or "all channels failed" return jsonify({"code": 0, "msg": detail, "data": {"results": results}}) msg = "Test notification sent" if failed: msg = f"Sent OK; failed: {', '.join(failed)}" return jsonify({"code": 1, "msg": msg, "data": {"results": results}}) except Exception as e: logger.error(f"test_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) execution_mode: str (optional, live/signal — omit or all for any) search: str (optional, search by strategy name/symbol/username) sort_by: str (optional, whitelist; default status+updated_at) sort_order: str (optional, asc or desc; default desc when sort_by set) """ 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() execution_filter = request.args.get("execution_mode", "", type=str).strip().lower() search = request.args.get("search", "", type=str).strip() sort_by = request.args.get("sort_by", "", type=str).strip().lower() sort_order = request.args.get("sort_order", "desc", type=str).strip().lower() if sort_order not in ("asc", "desc"): sort_order = "desc" page_size = min(100, max(1, page_size)) offset = (page - 1) * page_size sort_sql_map = { "id": "s.id", "updated_at": "s.updated_at", "created_at": "s.created_at", "initial_capital": "s.initial_capital", "strategy_name": "s.strategy_name", "symbol": "s.symbol", "status": "s.status", "execution_mode": "s.execution_mode", "leverage": "s.leverage", } sort_expr_map = { "total_pnl": ( "(COALESCE((SELECT SUM(unrealized_pnl) FROM qd_strategy_positions p WHERE p.strategy_id = s.id), 0)" " + COALESCE((SELECT SUM(profit) FROM qd_strategy_trades t WHERE t.strategy_id = s.id), 0))" ), "trade_count": "(SELECT COUNT(*) FROM qd_strategy_trades t WHERE t.strategy_id = s.id)", "position_count": "(SELECT COUNT(*) FROM qd_strategy_positions p WHERE p.strategy_id = s.id)", "total_equity": ( "COALESCE((SELECT SUM(equity) FROM qd_strategy_positions p WHERE p.strategy_id = s.id), 0)" ), } direction = "ASC" if sort_order == "asc" else "DESC" 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 execution_filter in ("live", "signal"): conditions.append("s.execution_mode = ?") params.append(execution_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) if sort_by in sort_sql_map: order_clause = f"ORDER BY {sort_sql_map[sort_by]} {direction}, s.id DESC" elif sort_by in sort_expr_map: order_clause = f"ORDER BY {sort_expr_map[sort_by]} {direction}, s.id DESC" else: order_clause = "ORDER BY s.status DESC, s.updated_at DESC, s.id DESC" # 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_clause} 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( """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 --- # Build WHERE clause for user search (applied after JOIN) user_where_clause = "" user_params = [] if search: user_where_clause = "WHERE (u.username ILIKE ? OR u.nickname ILIKE ? OR u.email ILIKE ?)" like_val = f"%{search.strip()}%" user_params = [like_val, like_val, like_val] # Count distinct users who have analysis records (matching search criteria) count_sql = 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_clause} """ cur.execute(count_sql, tuple(user_params)) count_result = cur.fetchone() user_total = count_result["cnt"] if count_result else 0 # Get per-user aggregated stats # Important: Filter by user search criteria AFTER grouping, but we need to apply it in WHERE # Since we're grouping by user fields, we need to filter before GROUP BY stats_sql = 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_clause} GROUP BY t.user_id, u.username, u.nickname, u.email ORDER BY analysis_count DESC LIMIT ? OFFSET ? """ cur.execute(stats_sql, 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) # Ensure we get user info even if user_id is NULL or user doesn't exist cur.execute( """ SELECT t.id, t.user_id, COALESCE(u.username, '') AS username, COALESCE(u.nickname, '') AS nickname, COALESCE(u.email, '') AS email, 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 WHERE t.user_id IS NOT NULL 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") if not uid: # Skip rows with NULL user_id continue ms = memory_stats_map.get(uid, {}) last_at = row.get("last_analysis_at") first_at = row.get("first_analysis_at") # Convert datetime to ISO format string if needed if last_at and hasattr(last_at, "isoformat"): last_at = last_at.isoformat() elif last_at: last_at = str(last_at) else: last_at = None if first_at and hasattr(first_at, "isoformat"): first_at = first_at.isoformat() elif first_at: first_at = str(first_at) else: first_at = None user_items.append( { "user_id": int(uid), "username": str(row.get("username") or ""), "nickname": str(row.get("nickname") or ""), "email": str(row.get("email") or ""), "analysis_count": int(row.get("analysis_count") or 0), "symbol_count": int(row.get("symbol_count") or 0), "market_count": int(row.get("market_count") or 0), "correct": int(ms.get("correct", 0)), "incorrect": int(ms.get("incorrect", 0)), "helpful": int(ms.get("helpful", 0)), "not_helpful": int(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: user_id = row.get("user_id") if not user_id: # Skip rows with NULL user_id continue created_at = row.get("created_at") completed_at = row.get("completed_at") # Convert datetime to ISO format string if needed if created_at and hasattr(created_at, "isoformat"): created_at = created_at.isoformat() elif created_at: created_at = str(created_at) else: created_at = None if completed_at and hasattr(completed_at, "isoformat"): completed_at = completed_at.isoformat() elif completed_at: completed_at = str(completed_at) else: completed_at = None recent_items.append( { "id": int(row.get("id") or 0), "user_id": int(user_id), "username": str(row.get("username") or ""), "nickname": str(row.get("nickname") or ""), "email": str(row.get("email") or ""), "market": str(row.get("market") or ""), "symbol": str(row.get("symbol") or ""), "model": str(row.get("model") or ""), "status": str(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