#!/usr/bin/env python3 # -*- coding: utf-8 -*- from __future__ import annotations import os import posixpath import secrets import threading import time from datetime import datetime import database import email_service import requests from app_config import get_config from app_logger import get_logger from app_security import ( get_rate_limit_ip, is_safe_outbound_url, is_safe_path, require_ip_not_locked, sanitize_filename, validate_email, validate_password, ) from flask import current_app, jsonify, redirect, request, session, url_for from routes.admin_api import admin_api_bp from routes.decorators import admin_required from services.accounts_service import load_user_accounts from services.browse_types import BROWSE_TYPE_SHOULD_READ, validate_browse_type from services.checkpoints import get_checkpoint_mgr from services.scheduler import run_scheduled_task from services.state import ( safe_clear_user_logs, safe_get_account, safe_get_user_accounts_snapshot, safe_iter_task_status_items, safe_remove_user_accounts, safe_verify_and_consume_captcha, check_login_ip_user_locked, check_login_rate_limits, get_login_failure_delay_seconds, record_login_username_attempt, check_ip_request_rate, check_login_captcha_required, clear_login_failures, record_login_failure, ) from services.tasks import get_task_scheduler, submit_account_task from services.time_utils import BEIJING_TZ, get_beijing_now logger = get_logger("app") config = get_config() _server_cpu_percent_lock = threading.Lock() _server_cpu_percent_last: float | None = None _server_cpu_percent_last_ts = 0.0 def _get_server_cpu_percent() -> float: import psutil global _server_cpu_percent_last, _server_cpu_percent_last_ts now = time.time() with _server_cpu_percent_lock: if _server_cpu_percent_last is not None and (now - _server_cpu_percent_last_ts) < 0.5: return _server_cpu_percent_last try: if _server_cpu_percent_last is None: cpu_percent = float(psutil.cpu_percent(interval=0.1)) else: cpu_percent = float(psutil.cpu_percent(interval=None)) except Exception: cpu_percent = float(_server_cpu_percent_last or 0.0) if cpu_percent < 0: cpu_percent = 0.0 _server_cpu_percent_last = cpu_percent _server_cpu_percent_last_ts = now return cpu_percent def _admin_reauth_required() -> bool: try: return time.time() > float(session.get("admin_reauth_until", 0) or 0) except Exception: return True def _require_admin_reauth(): if _admin_reauth_required(): return jsonify({"error": "需要二次确认", "code": "reauth_required"}), 401 return None def _get_upload_dir(): rel_dir = getattr(config, "ANNOUNCEMENT_IMAGE_DIR", "static/announcements") if not is_safe_path(current_app.root_path, rel_dir): rel_dir = "static/announcements" abs_dir = os.path.join(current_app.root_path, rel_dir) os.makedirs(abs_dir, exist_ok=True) return abs_dir, rel_dir def _get_file_size(file_storage): try: file_storage.stream.seek(0, os.SEEK_END) size = file_storage.stream.tell() file_storage.stream.seek(0) return size except Exception: return None @admin_api_bp.route("/debug-config", methods=["GET"]) @admin_required def debug_config(): """调试配置信息(仅管理员可访问,生产环境应禁用)""" if not current_app.debug: return jsonify({"error": "调试端点已在生产环境禁用"}), 403 return jsonify( { "secret_key_set": bool(current_app.secret_key), "secret_key_length": len(current_app.secret_key) if current_app.secret_key else 0, "session_config": { "SESSION_COOKIE_NAME": current_app.config.get("SESSION_COOKIE_NAME"), "SESSION_COOKIE_SECURE": current_app.config.get("SESSION_COOKIE_SECURE"), "SESSION_COOKIE_HTTPONLY": current_app.config.get("SESSION_COOKIE_HTTPONLY"), "SESSION_COOKIE_SAMESITE": current_app.config.get("SESSION_COOKIE_SAMESITE"), "PERMANENT_SESSION_LIFETIME": str(current_app.config.get("PERMANENT_SESSION_LIFETIME")), }, "has_session": bool(session), "cookies_received": list(request.cookies.keys()), } ) @admin_api_bp.route("/login", methods=["POST"]) @require_ip_not_locked def admin_login(): """管理员登录(支持JSON和form-data两种格式)""" if request.is_json: data = request.json or {} else: data = request.form username = data.get("username", "").strip() password = data.get("password", "").strip() captcha_session = data.get("captcha_session", "") captcha_code = data.get("captcha", "").strip() need_captcha = data.get("need_captcha", False) client_ip = get_rate_limit_ip() username_key = username scan_locked = record_login_username_attempt(client_ip, username_key) is_locked, remaining = check_login_ip_user_locked(client_ip, username_key) if is_locked: wait_hint = f"{remaining // 60 + 1}分钟" if remaining >= 60 else f"{remaining}秒" if request.is_json: return jsonify({"error": f"账号短时锁定,请{wait_hint}后再试", "need_captcha": True}), 429 return redirect(url_for("pages.admin_login_page")) allowed, error_msg = check_ip_request_rate(client_ip, "login") if not allowed: if request.is_json: return jsonify({"error": error_msg, "need_captcha": True}), 429 return redirect(url_for("pages.admin_login_page")) allowed, error_msg = check_login_rate_limits(client_ip, username_key) if not allowed: if request.is_json: return jsonify({"error": error_msg, "need_captcha": True}), 429 return redirect(url_for("pages.admin_login_page")) captcha_required = check_login_captcha_required(client_ip, username_key) or scan_locked or bool(need_captcha) if captcha_required: if not captcha_session or not captcha_code: if request.is_json: return jsonify({"error": "请填写验证码", "need_captcha": True}), 400 return redirect(url_for("pages.admin_login_page")) success, message = safe_verify_and_consume_captcha(captcha_session, captcha_code) if not success: record_login_failure(client_ip, username_key) if request.is_json: return jsonify({"error": message, "need_captcha": True}), 400 return redirect(url_for("pages.admin_login_page")) admin = database.verify_admin(username, password) if admin: clear_login_failures(client_ip, username_key) session.pop("admin_id", None) session.pop("admin_username", None) session["admin_id"] = admin["id"] session["admin_username"] = admin["username"] session["admin_reauth_until"] = time.time() + int(config.ADMIN_REAUTH_WINDOW_SECONDS) session.permanent = True session.modified = True logger.info(f"[admin_login] 管理员 {username} 登录成功") if request.is_json: return jsonify({"success": True, "redirect": "/yuyx/admin"}) return redirect(url_for("pages.admin_page")) record_login_failure(client_ip, username_key) delay = get_login_failure_delay_seconds(client_ip, username_key) if delay > 0: time.sleep(delay) logger.warning(f"[admin_login] 管理员 {username} 登录失败 - 用户名或密码错误") if request.is_json: return jsonify({"error": "管理员用户名或密码错误", "need_captcha": check_login_captcha_required(client_ip)}), 401 return redirect(url_for("pages.admin_login_page")) @admin_api_bp.route("/logout", methods=["POST"]) @admin_required def admin_logout(): """管理员登出""" session.pop("admin_id", None) session.pop("admin_username", None) session.pop("admin_reauth_until", None) return jsonify({"success": True}) @admin_api_bp.route("/admin/reauth", methods=["POST"]) @admin_required def admin_reauth(): """管理员敏感操作二次确认""" data = request.json or {} password = (data.get("password") or "").strip() if not password: return jsonify({"error": "密码不能为空"}), 400 username = session.get("admin_username") if not username: return jsonify({"error": "未登录"}), 401 admin = database.verify_admin(username, password) if not admin: return jsonify({"error": "密码错误"}), 401 session["admin_reauth_until"] = time.time() + int(config.ADMIN_REAUTH_WINDOW_SECONDS) session.modified = True return jsonify({"success": True, "expires_in": int(config.ADMIN_REAUTH_WINDOW_SECONDS)}) # ==================== 公告管理API(管理员) ==================== @admin_api_bp.route("/announcements/upload_image", methods=["POST"]) @admin_required def admin_upload_announcement_image(): """上传公告图片(返回可访问URL)""" file = request.files.get("file") if not file or not file.filename: return jsonify({"error": "请选择图片"}), 400 filename = sanitize_filename(file.filename) ext = os.path.splitext(filename)[1].lower() allowed_exts = getattr(config, "ALLOWED_ANNOUNCEMENT_IMAGE_EXTENSIONS", {".png", ".jpg", ".jpeg"}) if not ext or ext not in allowed_exts: return jsonify({"error": "不支持的图片格式"}), 400 if file.mimetype and not str(file.mimetype).startswith("image/"): return jsonify({"error": "文件类型无效"}), 400 size = _get_file_size(file) max_size = int(getattr(config, "MAX_ANNOUNCEMENT_IMAGE_SIZE", 5 * 1024 * 1024)) if size is not None and size > max_size: max_mb = max_size // 1024 // 1024 return jsonify({"error": f"图片大小不能超过{max_mb}MB"}), 400 abs_dir, rel_dir = _get_upload_dir() token = secrets.token_hex(6) name = f"announcement_{int(time.time())}_{token}{ext}" save_path = os.path.join(abs_dir, name) file.save(save_path) static_root = os.path.join(current_app.root_path, "static") rel_to_static = os.path.relpath(abs_dir, static_root) if rel_to_static.startswith(".."): rel_to_static = "announcements" url_path = posixpath.join(rel_to_static.replace(os.sep, "/"), name) return jsonify({"success": True, "url": url_for("serve_static", filename=url_path)}) @admin_api_bp.route("/announcements", methods=["GET"]) @admin_required def admin_get_announcements(): """获取公告列表""" try: limit = int(request.args.get("limit", 50)) offset = int(request.args.get("offset", 0)) except (TypeError, ValueError): limit, offset = 50, 0 limit = max(1, min(200, limit)) offset = max(0, offset) return jsonify(database.get_announcements(limit=limit, offset=offset)) @admin_api_bp.route("/announcements", methods=["POST"]) @admin_required def admin_create_announcement(): """创建公告(默认启用并替换旧公告)""" data = request.json or {} title = (data.get("title") or "").strip() content = (data.get("content") or "").strip() image_url = (data.get("image_url") or "").strip() is_active = bool(data.get("is_active", True)) if image_url and len(image_url) > 1000: return jsonify({"error": "图片地址过长"}), 400 announcement_id = database.create_announcement(title, content, image_url=image_url, is_active=is_active) if not announcement_id: return jsonify({"error": "标题和内容不能为空"}), 400 return jsonify({"success": True, "id": announcement_id}) @admin_api_bp.route("/announcements//activate", methods=["POST"]) @admin_required def admin_activate_announcement(announcement_id): """启用公告(会自动停用其他公告)""" if not database.get_announcement_by_id(announcement_id): return jsonify({"error": "公告不存在"}), 404 ok = database.set_announcement_active(announcement_id, True) return jsonify({"success": ok}) @admin_api_bp.route("/announcements//deactivate", methods=["POST"]) @admin_required def admin_deactivate_announcement(announcement_id): """停用公告""" if not database.get_announcement_by_id(announcement_id): return jsonify({"error": "公告不存在"}), 404 ok = database.set_announcement_active(announcement_id, False) return jsonify({"success": ok}) @admin_api_bp.route("/announcements/", methods=["DELETE"]) @admin_required def admin_delete_announcement(announcement_id): """删除公告""" if not database.get_announcement_by_id(announcement_id): return jsonify({"error": "公告不存在"}), 404 ok = database.delete_announcement(announcement_id) return jsonify({"success": ok}) # ==================== 用户管理/统计(管理员) ==================== @admin_api_bp.route("/users", methods=["GET"]) @admin_required def get_all_users(): """获取所有用户""" users = database.get_all_users() return jsonify(users) @admin_api_bp.route("/users/pending", methods=["GET"]) @admin_required def get_pending_users(): """获取待审核用户""" users = database.get_pending_users() return jsonify(users) @admin_api_bp.route("/users//approve", methods=["POST"]) @admin_required def approve_user_route(user_id): """审核通过用户""" if database.approve_user(user_id): return jsonify({"success": True}) return jsonify({"error": "审核失败"}), 400 @admin_api_bp.route("/users//reject", methods=["POST"]) @admin_required def reject_user_route(user_id): """拒绝用户""" if database.reject_user(user_id): return jsonify({"success": True}) return jsonify({"error": "拒绝失败"}), 400 @admin_api_bp.route("/users/", methods=["DELETE"]) @admin_required def delete_user_route(user_id): """删除用户""" if database.delete_user(user_id): safe_remove_user_accounts(user_id) safe_clear_user_logs(user_id) return jsonify({"success": True}) return jsonify({"error": "删除失败"}), 400 @admin_api_bp.route("/stats", methods=["GET"]) @admin_required def get_system_stats(): """获取系统统计""" stats = database.get_system_stats() stats["admin_username"] = session.get("admin_username", "admin") return jsonify(stats) @admin_api_bp.route("/browser_pool/stats", methods=["GET"]) @admin_required def get_browser_pool_stats(): """获取截图线程池状态""" try: from browser_pool_worker import get_browser_worker_pool pool = get_browser_worker_pool() stats = pool.get_stats() or {} worker_details = [] for w in stats.get("workers") or []: last_ts = float(w.get("last_active_ts") or 0) last_active_at = None if last_ts > 0: try: last_active_at = datetime.fromtimestamp(last_ts, tz=BEIJING_TZ).strftime("%Y-%m-%d %H:%M:%S") except Exception: last_active_at = None created_ts = w.get("browser_created_at") created_at = None if created_ts: try: created_at = datetime.fromtimestamp(float(created_ts), tz=BEIJING_TZ).strftime("%Y-%m-%d %H:%M:%S") except Exception: created_at = None worker_details.append( { "worker_id": w.get("worker_id"), "idle": bool(w.get("idle")), "has_browser": bool(w.get("has_browser")), "total_tasks": int(w.get("total_tasks") or 0), "failed_tasks": int(w.get("failed_tasks") or 0), "browser_use_count": int(w.get("browser_use_count") or 0), "browser_created_at": created_at, "browser_created_ts": created_ts, "last_active_at": last_active_at, "last_active_ts": last_ts, "thread_alive": bool(w.get("thread_alive")), } ) total_workers = len(worker_details) if worker_details else int(stats.get("pool_size") or 0) return jsonify( { "total_workers": total_workers, "active_workers": int(stats.get("busy_workers") or 0), "idle_workers": int(stats.get("idle_workers") or 0), "queue_size": int(stats.get("queue_size") or 0), "workers": worker_details, "summary": { "total_tasks": int(stats.get("total_tasks") or 0), "failed_tasks": int(stats.get("failed_tasks") or 0), "success_rate": stats.get("success_rate"), }, "server_time_cst": get_beijing_now().strftime("%Y-%m-%d %H:%M:%S"), } ) except Exception as e: logger.exception(f"[AdminAPI] 获取截图线程池状态失败: {e}") return jsonify({"error": "获取截图线程池状态失败"}), 500 @admin_api_bp.route("/docker_stats", methods=["GET"]) @admin_required def get_docker_stats(): """获取Docker容器运行状态""" import subprocess docker_status = { "running": False, "container_name": "N/A", "uptime": "N/A", "memory_usage": "N/A", "memory_limit": "N/A", "memory_percent": "N/A", "cpu_percent": "N/A", "status": "Unknown", } try: if os.path.exists("/.dockerenv"): docker_status["running"] = True try: with open("/etc/hostname", "r") as f: docker_status["container_name"] = f.read().strip() except Exception as e: logger.debug(f"读取容器名称失败: {e}") try: if os.path.exists("/sys/fs/cgroup/memory.current"): with open("/sys/fs/cgroup/memory.current", "r") as f: mem_total = int(f.read().strip()) cache = 0 if os.path.exists("/sys/fs/cgroup/memory.stat"): with open("/sys/fs/cgroup/memory.stat", "r") as f: for line in f: if line.startswith("inactive_file "): cache = int(line.split()[1]) break mem_bytes = mem_total - cache docker_status["memory_usage"] = "{:.2f} MB".format(mem_bytes / 1024 / 1024) if os.path.exists("/sys/fs/cgroup/memory.max"): with open("/sys/fs/cgroup/memory.max", "r") as f: limit_str = f.read().strip() if limit_str != "max": limit_bytes = int(limit_str) docker_status["memory_limit"] = "{:.2f} GB".format(limit_bytes / 1024 / 1024 / 1024) docker_status["memory_percent"] = "{:.2f}%".format(mem_bytes / limit_bytes * 100) elif os.path.exists("/sys/fs/cgroup/memory/memory.usage_in_bytes"): with open("/sys/fs/cgroup/memory/memory.usage_in_bytes", "r") as f: mem_bytes = int(f.read().strip()) docker_status["memory_usage"] = "{:.2f} MB".format(mem_bytes / 1024 / 1024) with open("/sys/fs/cgroup/memory/memory.limit_in_bytes", "r") as f: limit_bytes = int(f.read().strip()) if limit_bytes < 1e18: docker_status["memory_limit"] = "{:.2f} GB".format(limit_bytes / 1024 / 1024 / 1024) docker_status["memory_percent"] = "{:.2f}%".format(mem_bytes / limit_bytes * 100) except Exception as e: logger.debug(f"读取内存信息失败: {e}") try: if os.path.exists("/sys/fs/cgroup/cpu.stat"): cpu_usage = 0 with open("/sys/fs/cgroup/cpu.stat", "r") as f: for line in f: if line.startswith("usage_usec"): cpu_usage = int(line.split()[1]) break time.sleep(0.1) cpu_usage2 = 0 with open("/sys/fs/cgroup/cpu.stat", "r") as f: for line in f: if line.startswith("usage_usec"): cpu_usage2 = int(line.split()[1]) break cpu_percent = (cpu_usage2 - cpu_usage) / 0.1 / 1e6 * 100 docker_status["cpu_percent"] = "{:.2f}%".format(cpu_percent) elif os.path.exists("/sys/fs/cgroup/cpu/cpuacct.usage"): with open("/sys/fs/cgroup/cpu/cpuacct.usage", "r") as f: cpu_usage = int(f.read().strip()) time.sleep(0.1) with open("/sys/fs/cgroup/cpu/cpuacct.usage", "r") as f: cpu_usage2 = int(f.read().strip()) cpu_percent = (cpu_usage2 - cpu_usage) / 0.1 / 1e9 * 100 docker_status["cpu_percent"] = "{:.2f}%".format(cpu_percent) except Exception as e: logger.debug(f"读取CPU信息失败: {e}") try: result = subprocess.check_output(["uptime", "-p"]).decode("utf-8").strip() docker_status["uptime"] = result.replace("up ", "") except Exception as e: logger.debug(f"获取运行时间失败: {e}") docker_status["status"] = "Running" else: docker_status["status"] = "Not in Docker" except Exception as e: docker_status["status"] = f"Error: {str(e)}" return jsonify(docker_status) # ==================== VIP 管理(管理员) ==================== @admin_api_bp.route("/vip/config", methods=["GET"]) @admin_required def get_vip_config_api(): """获取VIP配置""" config = database.get_vip_config() return jsonify(config) @admin_api_bp.route("/vip/config", methods=["POST"]) @admin_required def set_vip_config_api(): """设置默认VIP天数""" data = request.json or {} days = data.get("default_vip_days", 0) if not isinstance(days, int) or days < 0: return jsonify({"error": "VIP天数必须是非负整数"}), 400 database.set_default_vip_days(days) return jsonify({"message": "VIP配置已更新", "default_vip_days": days}) @admin_api_bp.route("/users//vip", methods=["POST"]) @admin_required def set_user_vip_api(user_id): """设置用户VIP""" data = request.json or {} days = data.get("days", 30) valid_days = [7, 30, 365, 999999] if days not in valid_days: return jsonify({"error": "VIP天数必须是 7/30/365/999999 之一"}), 400 if database.set_user_vip(user_id, days): vip_type = {7: "一周", 30: "一个月", 365: "一年", 999999: "永久"}[days] return jsonify({"message": f"VIP设置成功: {vip_type}"}) return jsonify({"error": "设置失败,用户不存在"}), 400 @admin_api_bp.route("/users//vip", methods=["DELETE"]) @admin_required def remove_user_vip_api(user_id): """移除用户VIP""" if database.remove_user_vip(user_id): return jsonify({"message": "VIP已移除"}) return jsonify({"error": "移除失败"}), 400 @admin_api_bp.route("/users//vip", methods=["GET"]) @admin_required def get_user_vip_info_api(user_id): """获取用户VIP信息(管理员)""" vip_info = database.get_user_vip_info(user_id) return jsonify(vip_info) # ==================== 系统配置 / 定时 / 代理(管理员) ==================== @admin_api_bp.route("/system/config", methods=["GET"]) @admin_required def get_system_config_api(): """获取系统配置""" return jsonify(database.get_system_config()) @admin_api_bp.route("/system/config", methods=["POST"]) @admin_required def update_system_config_api(): """更新系统配置""" data = request.json or {} max_concurrent = data.get("max_concurrent_global") schedule_enabled = data.get("schedule_enabled") schedule_time = data.get("schedule_time") schedule_browse_type = data.get("schedule_browse_type") schedule_weekdays = data.get("schedule_weekdays") new_max_concurrent_per_account = data.get("max_concurrent_per_account") new_max_screenshot_concurrent = data.get("max_screenshot_concurrent") enable_screenshot = data.get("enable_screenshot") auto_approve_enabled = data.get("auto_approve_enabled") auto_approve_hourly_limit = data.get("auto_approve_hourly_limit") auto_approve_vip_days = data.get("auto_approve_vip_days") kdocs_enabled = data.get("kdocs_enabled") kdocs_doc_url = data.get("kdocs_doc_url") kdocs_default_unit = data.get("kdocs_default_unit") kdocs_sheet_name = data.get("kdocs_sheet_name") kdocs_sheet_index = data.get("kdocs_sheet_index") kdocs_unit_column = data.get("kdocs_unit_column") kdocs_image_column = data.get("kdocs_image_column") kdocs_admin_notify_enabled = data.get("kdocs_admin_notify_enabled") kdocs_admin_notify_email = data.get("kdocs_admin_notify_email") if max_concurrent is not None: if not isinstance(max_concurrent, int) or max_concurrent < 1: return jsonify({"error": "全局并发数必须大于0(建议:小型服务器2-5,中型5-10,大型10-20)"}), 400 if new_max_concurrent_per_account is not None: if not isinstance(new_max_concurrent_per_account, int) or new_max_concurrent_per_account < 1: return jsonify({"error": "单账号并发数必须大于0(建议设为1,避免同一用户任务相互影响)"}), 400 if new_max_screenshot_concurrent is not None: if not isinstance(new_max_screenshot_concurrent, int) or new_max_screenshot_concurrent < 1: return jsonify({"error": "截图并发数必须大于0(建议根据服务器配置设置,wkhtmltoimage 资源占用较低)"}), 400 if enable_screenshot is not None: if isinstance(enable_screenshot, bool): enable_screenshot = 1 if enable_screenshot else 0 if enable_screenshot not in (0, 1): return jsonify({"error": "截图开关必须是0或1"}), 400 if schedule_time is not None: import re if not re.match(r"^([01]\\d|2[0-3]):([0-5]\\d)$", schedule_time): return jsonify({"error": "时间格式错误,应为 HH:MM"}), 400 if schedule_browse_type is not None: normalized = validate_browse_type(schedule_browse_type, default=BROWSE_TYPE_SHOULD_READ) if not normalized: return jsonify({"error": "浏览类型无效"}), 400 schedule_browse_type = normalized if schedule_weekdays is not None: try: days = [int(d.strip()) for d in schedule_weekdays.split(",") if d.strip()] if not all(1 <= d <= 7 for d in days): return jsonify({"error": "星期数字必须在1-7之间"}), 400 except (ValueError, AttributeError): return jsonify({"error": "星期格式错误"}), 400 if auto_approve_hourly_limit is not None: if not isinstance(auto_approve_hourly_limit, int) or auto_approve_hourly_limit < 1: return jsonify({"error": "每小时注册限制必须大于0"}), 400 if auto_approve_vip_days is not None: if not isinstance(auto_approve_vip_days, int) or auto_approve_vip_days < 0: return jsonify({"error": "注册赠送VIP天数不能为负数"}), 400 if kdocs_enabled is not None: if isinstance(kdocs_enabled, bool): kdocs_enabled = 1 if kdocs_enabled else 0 if kdocs_enabled not in (0, 1): return jsonify({"error": "表格上传开关必须是0或1"}), 400 if kdocs_doc_url is not None: kdocs_doc_url = str(kdocs_doc_url or "").strip() if kdocs_doc_url and not is_safe_outbound_url(kdocs_doc_url): return jsonify({"error": "文档链接格式不正确"}), 400 if kdocs_default_unit is not None: kdocs_default_unit = str(kdocs_default_unit or "").strip() if len(kdocs_default_unit) > 50: return jsonify({"error": "默认县区长度不能超过50"}), 400 if kdocs_sheet_name is not None: kdocs_sheet_name = str(kdocs_sheet_name or "").strip() if len(kdocs_sheet_name) > 50: return jsonify({"error": "Sheet名称长度不能超过50"}), 400 if kdocs_sheet_index is not None: try: kdocs_sheet_index = int(kdocs_sheet_index) except Exception: return jsonify({"error": "Sheet序号必须是数字"}), 400 if kdocs_sheet_index < 0: return jsonify({"error": "Sheet序号不能为负数"}), 400 if kdocs_unit_column is not None: kdocs_unit_column = str(kdocs_unit_column or "").strip().upper() if not kdocs_unit_column: return jsonify({"error": "县区列不能为空"}), 400 import re if not re.match(r"^[A-Z]{1,3}$", kdocs_unit_column): return jsonify({"error": "县区列格式错误"}), 400 if kdocs_image_column is not None: kdocs_image_column = str(kdocs_image_column or "").strip().upper() if not kdocs_image_column: return jsonify({"error": "图片列不能为空"}), 400 import re if not re.match(r"^[A-Z]{1,3}$", kdocs_image_column): return jsonify({"error": "图片列格式错误"}), 400 if kdocs_admin_notify_enabled is not None: if isinstance(kdocs_admin_notify_enabled, bool): kdocs_admin_notify_enabled = 1 if kdocs_admin_notify_enabled else 0 if kdocs_admin_notify_enabled not in (0, 1): return jsonify({"error": "管理员通知开关必须是0或1"}), 400 if kdocs_admin_notify_email is not None: kdocs_admin_notify_email = str(kdocs_admin_notify_email or "").strip() if kdocs_admin_notify_email: is_valid, error_msg = validate_email(kdocs_admin_notify_email) if not is_valid: return jsonify({"error": error_msg}), 400 old_config = database.get_system_config() or {} if not database.update_system_config( max_concurrent=max_concurrent, schedule_enabled=schedule_enabled, schedule_time=schedule_time, schedule_browse_type=schedule_browse_type, schedule_weekdays=schedule_weekdays, max_concurrent_per_account=new_max_concurrent_per_account, max_screenshot_concurrent=new_max_screenshot_concurrent, enable_screenshot=enable_screenshot, auto_approve_enabled=auto_approve_enabled, auto_approve_hourly_limit=auto_approve_hourly_limit, auto_approve_vip_days=auto_approve_vip_days, kdocs_enabled=kdocs_enabled, kdocs_doc_url=kdocs_doc_url, kdocs_default_unit=kdocs_default_unit, kdocs_sheet_name=kdocs_sheet_name, kdocs_sheet_index=kdocs_sheet_index, kdocs_unit_column=kdocs_unit_column, kdocs_image_column=kdocs_image_column, kdocs_admin_notify_enabled=kdocs_admin_notify_enabled, kdocs_admin_notify_email=kdocs_admin_notify_email, ): return jsonify({"error": "更新失败"}), 400 try: new_config = database.get_system_config() or {} scheduler = get_task_scheduler() scheduler.update_limits( max_global=int(new_config.get("max_concurrent_global", old_config.get("max_concurrent_global", 2))), max_per_user=int(new_config.get("max_concurrent_per_account", old_config.get("max_concurrent_per_account", 1))), ) if new_max_screenshot_concurrent is not None: try: from browser_pool_worker import resize_browser_worker_pool if resize_browser_worker_pool(int(new_config.get("max_screenshot_concurrent", new_max_screenshot_concurrent))): logger.info(f"截图线程池并发已更新为: {new_config.get('max_screenshot_concurrent')}") except Exception as pool_error: logger.warning(f"截图线程池并发更新失败: {pool_error}") except Exception: pass if max_concurrent is not None and max_concurrent != old_config.get("max_concurrent_global"): logger.info(f"全局并发数已更新为: {max_concurrent}") if new_max_concurrent_per_account is not None and new_max_concurrent_per_account != old_config.get("max_concurrent_per_account"): logger.info(f"单用户并发数已更新为: {new_max_concurrent_per_account}") if new_max_screenshot_concurrent is not None: logger.info(f"截图并发数已更新为: {new_max_screenshot_concurrent}") return jsonify({"message": "系统配置已更新"}) @admin_api_bp.route("/kdocs/status", methods=["GET"]) @admin_required def get_kdocs_status_api(): """获取金山文档上传状态""" try: from services.kdocs_uploader import get_kdocs_uploader uploader = get_kdocs_uploader() status = uploader.get_status() return jsonify(status) except Exception as e: return jsonify({"error": f"获取状态失败: {e}"}), 500 @admin_api_bp.route("/kdocs/qr", methods=["POST"]) @admin_required def get_kdocs_qr_api(): """获取金山文档登录二维码""" try: from services.kdocs_uploader import get_kdocs_uploader uploader = get_kdocs_uploader() result = uploader.request_qr() if not result.get("success"): return jsonify({"error": result.get("error", "获取二维码失败")}), 400 return jsonify(result) except Exception as e: return jsonify({"error": f"获取二维码失败: {e}"}), 500 @admin_api_bp.route("/kdocs/clear-login", methods=["POST"]) @admin_required def clear_kdocs_login_api(): """清除金山文档登录态""" try: from services.kdocs_uploader import get_kdocs_uploader uploader = get_kdocs_uploader() result = uploader.clear_login() if not result.get("success"): return jsonify({"error": result.get("error", "清除失败")}), 400 return jsonify({"success": True}) except Exception as e: return jsonify({"error": f"清除失败: {e}"}), 500 @admin_api_bp.route("/schedule/execute", methods=["POST"]) @admin_required def execute_schedule_now(): """立即执行定时任务(无视定时时间和星期限制)""" try: threading.Thread(target=run_scheduled_task, args=(True,), daemon=True).start() logger.info("[立即执行定时任务] 管理员手动触发定时任务执行(跳过星期检查)") return jsonify({"message": "定时任务已开始执行,请查看任务列表获取进度"}) except Exception as e: logger.error(f"[立即执行定时任务] 启动失败: {str(e)}") return jsonify({"error": f"启动失败: {str(e)}"}), 500 @admin_api_bp.route("/proxy/config", methods=["GET"]) @admin_required def get_proxy_config_api(): """获取代理配置""" config_data = database.get_system_config() return jsonify( { "proxy_enabled": config_data.get("proxy_enabled", 0), "proxy_api_url": config_data.get("proxy_api_url", ""), "proxy_expire_minutes": config_data.get("proxy_expire_minutes", 3), } ) @admin_api_bp.route("/proxy/config", methods=["POST"]) @admin_required def update_proxy_config_api(): """更新代理配置""" data = request.json or {} proxy_enabled = data.get("proxy_enabled") proxy_api_url = (data.get("proxy_api_url", "") or "").strip() proxy_expire_minutes = data.get("proxy_expire_minutes") if proxy_enabled is not None and proxy_enabled not in [0, 1]: return jsonify({"error": "proxy_enabled必须是0或1"}), 400 if proxy_expire_minutes is not None: if not isinstance(proxy_expire_minutes, int) or proxy_expire_minutes < 1: return jsonify({"error": "代理有效期必须是大于0的整数"}), 400 if database.update_system_config( proxy_enabled=proxy_enabled, proxy_api_url=proxy_api_url, proxy_expire_minutes=proxy_expire_minutes, ): return jsonify({"message": "代理配置已更新"}) return jsonify({"error": "更新失败"}), 400 @admin_api_bp.route("/proxy/test", methods=["POST"]) @admin_required def test_proxy_api(): """测试代理连接""" data = request.json or {} api_url = (data.get("api_url") or "").strip() if not api_url: return jsonify({"error": "请提供API地址"}), 400 if not is_safe_outbound_url(api_url): return jsonify({"error": "API地址不可用或不安全"}), 400 try: response = requests.get(api_url, timeout=10) if response.status_code == 200: ip_port = response.text.strip() if ip_port and ":" in ip_port: return jsonify({"success": True, "proxy": ip_port, "message": f"代理获取成功: {ip_port}"}) return jsonify({"success": False, "message": f"代理格式错误: {ip_port}"}), 400 return jsonify({"success": False, "message": f"HTTP错误: {response.status_code}"}), 400 except Exception as e: return jsonify({"success": False, "message": f"连接失败: {str(e)}"}), 500 @admin_api_bp.route("/server/info", methods=["GET"]) @admin_required def get_server_info_api(): """获取服务器信息""" import psutil cpu_percent = _get_server_cpu_percent() memory = psutil.virtual_memory() memory_total = f"{memory.total / (1024**3):.1f}GB" memory_used = f"{memory.used / (1024**3):.1f}GB" memory_percent = memory.percent disk = psutil.disk_usage("/") disk_total = f"{disk.total / (1024**3):.1f}GB" disk_used = f"{disk.used / (1024**3):.1f}GB" disk_percent = disk.percent boot_time = datetime.fromtimestamp(psutil.boot_time(), tz=BEIJING_TZ) uptime_delta = get_beijing_now() - boot_time days = uptime_delta.days hours = uptime_delta.seconds // 3600 uptime = f"{days}天{hours}小时" return jsonify( { "cpu_percent": cpu_percent, "memory_total": memory_total, "memory_used": memory_used, "memory_percent": memory_percent, "disk_total": disk_total, "disk_used": disk_used, "disk_percent": disk_percent, "uptime": uptime, } ) # ==================== 任务统计与日志(管理员) ==================== @admin_api_bp.route("/task/stats", methods=["GET"]) @admin_required def get_task_stats_api(): """获取任务统计数据""" date_filter = request.args.get("date") stats = database.get_task_stats(date_filter) return jsonify(stats) @admin_api_bp.route("/task/running", methods=["GET"]) @admin_required def get_running_tasks_api(): """获取当前运行中和排队中的任务""" import time as time_mod current_time = time_mod.time() running = [] queuing = [] for account_id, info in safe_iter_task_status_items(): elapsed = int(current_time - info.get("start_time", current_time)) user = database.get_user_by_id(info.get("user_id")) user_username = user["username"] if user else "N/A" progress = info.get("progress", {"items": 0, "attachments": 0}) task_info = { "account_id": account_id, "user_id": info.get("user_id"), "user_username": user_username, "username": info.get("username"), "browse_type": info.get("browse_type"), "source": info.get("source", "manual"), "detail_status": info.get("detail_status", "未知"), "progress_items": progress.get("items", 0), "progress_attachments": progress.get("attachments", 0), "elapsed_seconds": elapsed, "elapsed_display": f"{elapsed // 60}分{elapsed % 60}秒" if elapsed >= 60 else f"{elapsed}秒", } if info.get("status") == "运行中": running.append(task_info) else: queuing.append(task_info) running.sort(key=lambda x: x["elapsed_seconds"], reverse=True) queuing.sort(key=lambda x: x["elapsed_seconds"], reverse=True) try: max_concurrent = int(get_task_scheduler().max_global) except Exception: max_concurrent = int((database.get_system_config() or {}).get("max_concurrent_global", 2)) return jsonify( { "running": running, "queuing": queuing, "running_count": len(running), "queuing_count": len(queuing), "max_concurrent": max_concurrent, } ) @admin_api_bp.route("/task/logs", methods=["GET"]) @admin_required def get_task_logs_api(): """获取任务日志列表(支持分页和多种筛选)""" limit = int(request.args.get("limit", 20)) offset = int(request.args.get("offset", 0)) date_filter = request.args.get("date") status_filter = request.args.get("status") source_filter = request.args.get("source") user_id_filter = request.args.get("user_id") account_filter = request.args.get("account") if user_id_filter: try: user_id_filter = int(user_id_filter) except ValueError: user_id_filter = None result = database.get_task_logs( limit=limit, offset=offset, date_filter=date_filter, status_filter=status_filter, source_filter=source_filter, user_id_filter=user_id_filter, account_filter=account_filter, ) return jsonify(result) @admin_api_bp.route("/task/logs/clear", methods=["POST"]) @admin_required def clear_old_task_logs_api(): """清理旧的任务日志""" data = request.json or {} days = data.get("days", 30) if not isinstance(days, int) or days < 1: return jsonify({"error": "天数必须是大于0的整数"}), 400 deleted_count = database.delete_old_task_logs(days) return jsonify({"message": f"已删除{days}天前的{deleted_count}条日志"}) @admin_api_bp.route("/docker/restart", methods=["POST"]) @admin_required def restart_docker_container(): """重启Docker容器""" import subprocess try: reauth_response = _require_admin_reauth() if reauth_response: return reauth_response if not os.path.exists("/.dockerenv"): return jsonify({"error": "当前不在Docker容器中运行"}), 400 logger.info("[系统] 管理员触发Docker容器重启") restart_script = """ import os import time time.sleep(3) os._exit(0) """ with open("/tmp/restart_container.py", "w") as f: f.write(restart_script) subprocess.Popen( ["python", "/tmp/restart_container.py"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, start_new_session=True, ) return jsonify({"success": True, "message": "容器将在3秒后重启,请稍后刷新页面"}) except Exception as e: logger.error(f"[系统] Docker容器重启失败: {str(e)}") return jsonify({"error": f"重启失败: {str(e)}"}), 500 # ==================== 密码重置 / 反馈(管理员) ==================== @admin_api_bp.route("/admin/password", methods=["PUT"]) @admin_required def update_admin_password(): """修改管理员密码""" data = request.json or {} new_password = (data.get("new_password") or "").strip() if not new_password: return jsonify({"error": "密码不能为空"}), 400 username = session.get("admin_username") if database.update_admin_password(username, new_password): return jsonify({"success": True}) return jsonify({"error": "修改失败"}), 400 @admin_api_bp.route("/admin/username", methods=["PUT"]) @admin_required def update_admin_username(): """修改管理员用户名""" data = request.json or {} new_username = (data.get("new_username") or "").strip() if not new_username: return jsonify({"error": "用户名不能为空"}), 400 old_username = session.get("admin_username") if database.update_admin_username(old_username, new_username): session["admin_username"] = new_username return jsonify({"success": True}) return jsonify({"error": "修改失败,用户名可能已存在"}), 400 @admin_api_bp.route("/users//reset_password", methods=["POST"]) @admin_required def admin_reset_password_route(user_id): """管理员直接重置用户密码(无需审核)""" data = request.json or {} new_password = (data.get("new_password") or "").strip() if not new_password: return jsonify({"error": "新密码不能为空"}), 400 is_valid, error_msg = validate_password(new_password) if not is_valid: return jsonify({"error": error_msg}), 400 if database.admin_reset_user_password(user_id, new_password): return jsonify({"message": "密码重置成功"}) return jsonify({"error": "重置失败,用户不存在"}), 400 @admin_api_bp.route("/feedbacks", methods=["GET"]) @admin_required def get_all_feedbacks(): """管理员获取所有反馈""" status = request.args.get("status") try: limit = int(request.args.get("limit", 100)) offset = int(request.args.get("offset", 0)) limit = min(max(1, limit), 1000) offset = max(0, offset) except (ValueError, TypeError): return jsonify({"error": "无效的分页参数"}), 400 feedbacks = database.get_bug_feedbacks(limit=limit, offset=offset, status_filter=status) stats = database.get_feedback_stats() return jsonify({"feedbacks": feedbacks, "stats": stats}) @admin_api_bp.route("/feedbacks//reply", methods=["POST"]) @admin_required def reply_to_feedback(feedback_id): """管理员回复反馈""" data = request.get_json() or {} reply = (data.get("reply") or "").strip() if not reply: return jsonify({"error": "回复内容不能为空"}), 400 if database.reply_feedback(feedback_id, reply): return jsonify({"message": "回复成功"}) return jsonify({"error": "反馈不存在"}), 404 @admin_api_bp.route("/feedbacks//close", methods=["POST"]) @admin_required def close_feedback_api(feedback_id): """管理员关闭反馈""" if database.close_feedback(feedback_id): return jsonify({"message": "已关闭"}) return jsonify({"error": "反馈不存在"}), 404 @admin_api_bp.route("/feedbacks/", methods=["DELETE"]) @admin_required def delete_feedback_api(feedback_id): """管理员删除反馈""" if database.delete_feedback(feedback_id): return jsonify({"message": "已删除"}) return jsonify({"error": "反馈不存在"}), 404 # ==================== 断点续传(管理员) ==================== @admin_api_bp.route("/checkpoint/paused") @admin_required def checkpoint_get_paused(): try: user_id = request.args.get("user_id", type=int) tasks = get_checkpoint_mgr().get_paused_tasks(user_id=user_id) return jsonify({"success": True, "tasks": tasks}) except Exception as e: logger.error(f"获取暂停任务失败: {e}") return jsonify({"success": False, "message": str(e)}), 500 @admin_api_bp.route("/checkpoint//resume", methods=["POST"]) @admin_required def checkpoint_resume(task_id): try: checkpoint_mgr = get_checkpoint_mgr() checkpoint = checkpoint_mgr.get_checkpoint(task_id) if not checkpoint: return jsonify({"success": False, "message": "任务不存在"}), 404 if checkpoint["status"] != "paused": return jsonify({"success": False, "message": "任务未暂停"}), 400 if checkpoint_mgr.resume_task(task_id): user_id = checkpoint["user_id"] if not safe_get_user_accounts_snapshot(user_id): load_user_accounts(user_id) ok, msg = submit_account_task( user_id=user_id, account_id=checkpoint["account_id"], browse_type=checkpoint["browse_type"], enable_screenshot=True, source="resumed", ) if not ok: return jsonify({"success": False, "message": msg}), 400 return jsonify({"success": True}) return jsonify({"success": False}), 500 except Exception as e: logger.error(f"恢复任务失败: {e}") return jsonify({"success": False, "message": str(e)}), 500 @admin_api_bp.route("/checkpoint//abandon", methods=["POST"]) @admin_required def checkpoint_abandon(task_id): try: if get_checkpoint_mgr().abandon_task(task_id): return jsonify({"success": True}) return jsonify({"success": False}), 404 except Exception as e: return jsonify({"success": False, "message": str(e)}), 500 # ==================== 邮件服务(管理员) ==================== @admin_api_bp.route("/email/settings", methods=["GET"]) @admin_required def get_email_settings_api(): """获取全局邮件设置""" try: settings = email_service.get_email_settings() return jsonify(settings) except Exception as e: logger.error(f"获取邮件设置失败: {e}") return jsonify({"error": str(e)}), 500 @admin_api_bp.route("/email/settings", methods=["POST"]) @admin_required def update_email_settings_api(): """更新全局邮件设置""" try: data = request.json or {} enabled = data.get("enabled", False) failover_enabled = data.get("failover_enabled", True) register_verify_enabled = data.get("register_verify_enabled") login_alert_enabled = data.get("login_alert_enabled") base_url = data.get("base_url") task_notify_enabled = data.get("task_notify_enabled") email_service.update_email_settings( enabled=enabled, failover_enabled=failover_enabled, register_verify_enabled=register_verify_enabled, login_alert_enabled=login_alert_enabled, base_url=base_url, task_notify_enabled=task_notify_enabled, ) return jsonify({"success": True}) except Exception as e: logger.error(f"更新邮件设置失败: {e}") return jsonify({"error": str(e)}), 500 @admin_api_bp.route("/smtp/configs", methods=["GET"]) @admin_required def get_smtp_configs_api(): """获取所有SMTP配置列表""" try: configs = email_service.get_smtp_configs(include_password=False) return jsonify(configs) except Exception as e: logger.error(f"获取SMTP配置失败: {e}") return jsonify({"error": str(e)}), 500 @admin_api_bp.route("/smtp/configs", methods=["POST"]) @admin_required def create_smtp_config_api(): """创建SMTP配置""" try: data = request.json or {} if not data.get("host"): return jsonify({"error": "SMTP服务器地址不能为空"}), 400 if not data.get("username"): return jsonify({"error": "SMTP用户名不能为空"}), 400 config_id = email_service.create_smtp_config(data) return jsonify({"success": True, "id": config_id}) except Exception as e: logger.error(f"创建SMTP配置失败: {e}") return jsonify({"error": str(e)}), 500 @admin_api_bp.route("/smtp/configs/", methods=["GET"]) @admin_required def get_smtp_config_api(config_id): """获取单个SMTP配置详情""" try: config_data = email_service.get_smtp_config(config_id, include_password=False) if not config_data: return jsonify({"error": "配置不存在"}), 404 return jsonify(config_data) except Exception as e: logger.error(f"获取SMTP配置失败: {e}") return jsonify({"error": str(e)}), 500 @admin_api_bp.route("/smtp/configs/", methods=["PUT"]) @admin_required def update_smtp_config_api(config_id): """更新SMTP配置""" try: data = request.json or {} if email_service.update_smtp_config(config_id, data): return jsonify({"success": True}) return jsonify({"error": "更新失败"}), 400 except Exception as e: logger.error(f"更新SMTP配置失败: {e}") return jsonify({"error": str(e)}), 500 @admin_api_bp.route("/smtp/configs/", methods=["DELETE"]) @admin_required def delete_smtp_config_api(config_id): """删除SMTP配置""" try: if email_service.delete_smtp_config(config_id): return jsonify({"success": True}) return jsonify({"error": "删除失败"}), 400 except Exception as e: logger.error(f"删除SMTP配置失败: {e}") return jsonify({"error": str(e)}), 500 @admin_api_bp.route("/smtp/configs//test", methods=["POST"]) @admin_required def test_smtp_config_api(config_id): """测试SMTP配置""" try: data = request.json or {} test_email = str(data.get("email", "") or "").strip() if not test_email: return jsonify({"error": "请提供测试邮箱"}), 400 is_valid, error_msg = validate_email(test_email) if not is_valid: return jsonify({"error": error_msg}), 400 result = email_service.test_smtp_config(config_id, test_email) return jsonify(result) except Exception as e: logger.error(f"测试SMTP配置失败: {e}") return jsonify({"success": False, "error": str(e)}), 500 @admin_api_bp.route("/smtp/configs//primary", methods=["POST"]) @admin_required def set_primary_smtp_config_api(config_id): """设置主SMTP配置""" try: if email_service.set_primary_smtp_config(config_id): return jsonify({"success": True}) return jsonify({"error": "设置失败"}), 400 except Exception as e: logger.error(f"设置主SMTP配置失败: {e}") return jsonify({"error": str(e)}), 500 @admin_api_bp.route("/smtp/configs/primary/clear", methods=["POST"]) @admin_required def clear_primary_smtp_config_api(): """取消主SMTP配置""" try: email_service.clear_primary_smtp_config() return jsonify({"success": True}) except Exception as e: logger.error(f"取消主SMTP配置失败: {e}") return jsonify({"error": str(e)}), 500 @admin_api_bp.route("/email/stats", methods=["GET"]) @admin_required def get_email_stats_api(): """获取邮件发送统计""" try: stats = email_service.get_email_stats() return jsonify(stats) except Exception as e: logger.error(f"获取邮件统计失败: {e}") return jsonify({"error": str(e)}), 500 @admin_api_bp.route("/email/logs", methods=["GET"]) @admin_required def get_email_logs_api(): """获取邮件发送日志""" try: page = request.args.get("page", 1, type=int) page_size = request.args.get("page_size", 20, type=int) email_type = request.args.get("type", None) status = request.args.get("status", None) page_size = min(max(page_size, 10), 100) result = email_service.get_email_logs(page, page_size, email_type, status) return jsonify(result) except Exception as e: logger.error(f"获取邮件日志失败: {e}") return jsonify({"error": str(e)}), 500 @admin_api_bp.route("/email/logs/cleanup", methods=["POST"]) @admin_required def cleanup_email_logs_api(): """清理过期邮件日志""" try: data = request.json or {} days = data.get("days", 30) days = min(max(days, 7), 365) deleted = email_service.cleanup_email_logs(days) return jsonify({"success": True, "deleted": deleted}) except Exception as e: logger.error(f"清理邮件日志失败: {e}") return jsonify({"error": str(e)}), 500