refactor: optimize structure, stability and runtime performance
This commit is contained in:
@@ -8,6 +8,15 @@ admin_api_bp = Blueprint("admin_api", __name__, url_prefix="/yuyx/api")
|
||||
|
||||
# Import side effects: register routes on blueprint
|
||||
from routes.admin_api import core as _core # noqa: F401
|
||||
from routes.admin_api import system_config_api as _system_config_api # noqa: F401
|
||||
from routes.admin_api import operations_api as _operations_api # noqa: F401
|
||||
from routes.admin_api import announcements_api as _announcements_api # noqa: F401
|
||||
from routes.admin_api import users_api as _users_api # noqa: F401
|
||||
from routes.admin_api import account_api as _account_api # noqa: F401
|
||||
from routes.admin_api import feedback_api as _feedback_api # noqa: F401
|
||||
from routes.admin_api import infra_api as _infra_api # noqa: F401
|
||||
from routes.admin_api import tasks_api as _tasks_api # noqa: F401
|
||||
from routes.admin_api import email_api as _email_api # noqa: F401
|
||||
|
||||
# Export security blueprint for app registration
|
||||
from routes.admin_api.security import security_bp # noqa: F401
|
||||
|
||||
63
routes/admin_api/account_api.py
Normal file
63
routes/admin_api/account_api.py
Normal file
@@ -0,0 +1,63 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import annotations
|
||||
|
||||
import database
|
||||
from app_security import validate_password
|
||||
from flask import jsonify, request, session
|
||||
from routes.admin_api import admin_api_bp
|
||||
from routes.decorators import admin_required
|
||||
|
||||
# ==================== 密码重置 / 反馈(管理员) ====================
|
||||
|
||||
|
||||
@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/<int:user_id>/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
|
||||
144
routes/admin_api/announcements_api.py
Normal file
144
routes/admin_api/announcements_api.py
Normal file
@@ -0,0 +1,144 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import posixpath
|
||||
import secrets
|
||||
import time
|
||||
|
||||
import database
|
||||
from app_config import get_config
|
||||
from app_logger import get_logger
|
||||
from app_security import is_safe_path, sanitize_filename
|
||||
from flask import current_app, jsonify, request, url_for
|
||||
from routes.admin_api import admin_api_bp
|
||||
from routes.decorators import admin_required
|
||||
|
||||
logger = get_logger("app")
|
||||
config = get_config()
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
# ==================== 公告管理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/<int:announcement_id>/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/<int:announcement_id>/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/<int:announcement_id>", 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})
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
214
routes/admin_api/email_api.py
Normal file
214
routes/admin_api/email_api.py
Normal file
@@ -0,0 +1,214 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import annotations
|
||||
|
||||
import email_service
|
||||
from app_logger import get_logger
|
||||
from app_security import validate_email
|
||||
from flask import jsonify, request
|
||||
from routes.admin_api import admin_api_bp
|
||||
from routes.decorators import admin_required
|
||||
|
||||
logger = get_logger("app")
|
||||
|
||||
|
||||
@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/<int:config_id>", 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/<int:config_id>", 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/<int:config_id>", 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/<int:config_id>/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/<int:config_id>/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
|
||||
58
routes/admin_api/feedback_api.py
Normal file
58
routes/admin_api/feedback_api.py
Normal file
@@ -0,0 +1,58 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import annotations
|
||||
|
||||
import database
|
||||
from flask import jsonify, request
|
||||
from routes.admin_api import admin_api_bp
|
||||
from routes.decorators import admin_required
|
||||
|
||||
@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/<int:feedback_id>/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/<int:feedback_id>/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/<int:feedback_id>", methods=["DELETE"])
|
||||
@admin_required
|
||||
def delete_feedback_api(feedback_id):
|
||||
"""管理员删除反馈"""
|
||||
if database.delete_feedback(feedback_id):
|
||||
return jsonify({"message": "已删除"})
|
||||
return jsonify({"error": "反馈不存在"}), 404
|
||||
226
routes/admin_api/infra_api.py
Normal file
226
routes/admin_api/infra_api.py
Normal file
@@ -0,0 +1,226 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import time
|
||||
from datetime import datetime
|
||||
|
||||
import database
|
||||
from app_logger import get_logger
|
||||
from flask import jsonify, session
|
||||
from routes.admin_api import admin_api_bp
|
||||
from routes.decorators import admin_required
|
||||
from services.time_utils import BEIJING_TZ, get_beijing_now
|
||||
|
||||
logger = get_logger("app")
|
||||
|
||||
|
||||
@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:
|
||||
# 读取系统运行时间
|
||||
with open('/proc/uptime', 'r') as f:
|
||||
system_uptime = float(f.read().split()[0])
|
||||
|
||||
# 读取 PID 1 的启动时间 (jiffies)
|
||||
with open('/proc/1/stat', 'r') as f:
|
||||
stat = f.read().split()
|
||||
starttime_jiffies = int(stat[21])
|
||||
|
||||
# 获取 CLK_TCK (通常是 100)
|
||||
clk_tck = os.sysconf(os.sysconf_names['SC_CLK_TCK'])
|
||||
|
||||
# 计算容器运行时长(秒)
|
||||
container_uptime_seconds = system_uptime - (starttime_jiffies / clk_tck)
|
||||
|
||||
# 格式化为可读字符串
|
||||
days = int(container_uptime_seconds // 86400)
|
||||
hours = int((container_uptime_seconds % 86400) // 3600)
|
||||
minutes = int((container_uptime_seconds % 3600) // 60)
|
||||
|
||||
if days > 0:
|
||||
docker_status["uptime"] = f"{days}天{hours}小时{minutes}分钟"
|
||||
elif hours > 0:
|
||||
docker_status["uptime"] = f"{hours}小时{minutes}分钟"
|
||||
else:
|
||||
docker_status["uptime"] = f"{minutes}分钟"
|
||||
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)
|
||||
|
||||
228
routes/admin_api/operations_api.py
Normal file
228
routes/admin_api/operations_api.py
Normal file
@@ -0,0 +1,228 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime
|
||||
|
||||
import database
|
||||
import requests
|
||||
from app_logger import get_logger
|
||||
from app_security import is_safe_outbound_url
|
||||
from flask import jsonify, request
|
||||
from routes.admin_api import admin_api_bp
|
||||
from routes.decorators import admin_required
|
||||
from services.scheduler import run_scheduled_task
|
||||
from services.time_utils import BEIJING_TZ, get_beijing_now
|
||||
|
||||
logger = get_logger("app")
|
||||
|
||||
_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
|
||||
|
||||
|
||||
@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()
|
||||
live = str(request.args.get("live", "")).lower() in ("1", "true", "yes")
|
||||
if live:
|
||||
live_status = uploader.refresh_login_status()
|
||||
if live_status.get("success"):
|
||||
logged_in = bool(live_status.get("logged_in"))
|
||||
status["logged_in"] = logged_in
|
||||
status["last_login_ok"] = logged_in
|
||||
status["login_required"] = not logged_in
|
||||
if live_status.get("error"):
|
||||
status["last_error"] = live_status.get("error")
|
||||
else:
|
||||
status["logged_in"] = True if status.get("last_login_ok") else False if status.get("last_login_ok") is False else None
|
||||
if status.get("last_login_ok") is True and status.get("last_error") == "操作超时":
|
||||
status["last_error"] = None
|
||||
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()
|
||||
data = request.get_json(silent=True) or {}
|
||||
force = bool(data.get("force"))
|
||||
if not force:
|
||||
force = str(request.args.get("force", "")).lower() in ("1", "true", "yes")
|
||||
result = uploader.request_qr(force=force)
|
||||
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,
|
||||
}
|
||||
)
|
||||
@@ -62,6 +62,19 @@ def _parse_bool(value: Any) -> bool:
|
||||
return text in {"1", "true", "yes", "y", "on"}
|
||||
|
||||
|
||||
def _parse_int(value: Any, *, default: int | None = None, min_value: int | None = None) -> int | None:
|
||||
try:
|
||||
parsed = int(value)
|
||||
except Exception:
|
||||
parsed = default
|
||||
|
||||
if parsed is None:
|
||||
return None
|
||||
if min_value is not None:
|
||||
parsed = max(int(min_value), parsed)
|
||||
return parsed
|
||||
|
||||
|
||||
def _sanitize_threat_event(event: dict) -> dict:
|
||||
return {
|
||||
"id": event.get("id"),
|
||||
@@ -199,10 +212,7 @@ def ban_ip():
|
||||
if not reason:
|
||||
return jsonify({"error": "reason不能为空"}), 400
|
||||
|
||||
try:
|
||||
duration_hours = max(1, int(duration_hours_raw))
|
||||
except Exception:
|
||||
duration_hours = 24
|
||||
duration_hours = _parse_int(duration_hours_raw, default=24, min_value=1) or 24
|
||||
|
||||
ok = blacklist.ban_ip(ip, reason, duration_hours=duration_hours, permanent=permanent)
|
||||
if not ok:
|
||||
@@ -235,20 +245,14 @@ def ban_user():
|
||||
duration_hours_raw = data.get("duration_hours", 24)
|
||||
permanent = _parse_bool(data.get("permanent", False))
|
||||
|
||||
try:
|
||||
user_id = int(user_id_raw)
|
||||
except Exception:
|
||||
user_id = None
|
||||
user_id = _parse_int(user_id_raw)
|
||||
|
||||
if user_id is None:
|
||||
return jsonify({"error": "user_id不能为空"}), 400
|
||||
if not reason:
|
||||
return jsonify({"error": "reason不能为空"}), 400
|
||||
|
||||
try:
|
||||
duration_hours = max(1, int(duration_hours_raw))
|
||||
except Exception:
|
||||
duration_hours = 24
|
||||
duration_hours = _parse_int(duration_hours_raw, default=24, min_value=1) or 24
|
||||
|
||||
ok = blacklist._ban_user_internal(user_id, reason=reason, duration_hours=duration_hours, permanent=permanent)
|
||||
if not ok:
|
||||
@@ -262,10 +266,7 @@ def unban_user():
|
||||
"""解除用户封禁"""
|
||||
data = _parse_json()
|
||||
user_id_raw = data.get("user_id")
|
||||
try:
|
||||
user_id = int(user_id_raw)
|
||||
except Exception:
|
||||
user_id = None
|
||||
user_id = _parse_int(user_id_raw)
|
||||
|
||||
if user_id is None:
|
||||
return jsonify({"error": "user_id不能为空"}), 400
|
||||
|
||||
228
routes/admin_api/system_config_api.py
Normal file
228
routes/admin_api/system_config_api.py
Normal file
@@ -0,0 +1,228 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import annotations
|
||||
|
||||
import database
|
||||
from app_logger import get_logger
|
||||
from app_security import is_safe_outbound_url, validate_email
|
||||
from flask import jsonify, request
|
||||
from routes.admin_api import admin_api_bp
|
||||
from routes.decorators import admin_required
|
||||
from services.browse_types import BROWSE_TYPE_SHOULD_READ, validate_browse_type
|
||||
from services.tasks import get_task_scheduler
|
||||
|
||||
logger = get_logger("app")
|
||||
|
||||
|
||||
@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")
|
||||
kdocs_row_start = data.get("kdocs_row_start")
|
||||
kdocs_row_end = data.get("kdocs_row_end")
|
||||
|
||||
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
|
||||
|
||||
if kdocs_row_start is not None:
|
||||
try:
|
||||
kdocs_row_start = int(kdocs_row_start)
|
||||
except (ValueError, TypeError):
|
||||
return jsonify({"error": "起始行必须是数字"}), 400
|
||||
if kdocs_row_start < 0:
|
||||
return jsonify({"error": "起始行不能为负数"}), 400
|
||||
|
||||
if kdocs_row_end is not None:
|
||||
try:
|
||||
kdocs_row_end = int(kdocs_row_end)
|
||||
except (ValueError, TypeError):
|
||||
return jsonify({"error": "结束行必须是数字"}), 400
|
||||
if kdocs_row_end < 0:
|
||||
return jsonify({"error": "结束行不能为负数"}), 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,
|
||||
kdocs_row_start=kdocs_row_start,
|
||||
kdocs_row_end=kdocs_row_end,
|
||||
):
|
||||
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": "系统配置已更新"})
|
||||
138
routes/admin_api/tasks_api.py
Normal file
138
routes/admin_api/tasks_api.py
Normal file
@@ -0,0 +1,138 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import annotations
|
||||
|
||||
import database
|
||||
from app_logger import get_logger
|
||||
from flask import jsonify, request
|
||||
from routes.admin_api import admin_api_bp
|
||||
from routes.decorators import admin_required
|
||||
from services.state import safe_iter_task_status_items
|
||||
from services.tasks import get_task_scheduler
|
||||
|
||||
logger = get_logger("app")
|
||||
|
||||
|
||||
def _parse_page_int(name: str, default: int, *, minimum: int, maximum: int) -> int:
|
||||
try:
|
||||
value = int(request.args.get(name, default))
|
||||
return max(minimum, min(value, maximum))
|
||||
except (ValueError, TypeError):
|
||||
return default
|
||||
|
||||
|
||||
@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 = []
|
||||
user_cache = {}
|
||||
|
||||
for account_id, info in safe_iter_task_status_items():
|
||||
elapsed = int(current_time - info.get("start_time", current_time))
|
||||
|
||||
info_user_id = info.get("user_id")
|
||||
if info_user_id not in user_cache:
|
||||
user_cache[info_user_id] = database.get_user_by_id(info_user_id)
|
||||
user = user_cache.get(info_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 = _parse_page_int("limit", 20, minimum=1, maximum=200)
|
||||
offset = _parse_page_int("offset", 0, minimum=0, maximum=10**9)
|
||||
|
||||
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") or "").strip()
|
||||
|
||||
if user_id_filter:
|
||||
try:
|
||||
user_id_filter = int(user_id_filter)
|
||||
except (ValueError, TypeError):
|
||||
user_id_filter = None
|
||||
|
||||
try:
|
||||
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 if account_filter else None,
|
||||
)
|
||||
return jsonify(result)
|
||||
except Exception as e:
|
||||
logger.error(f"获取任务日志失败: {e}")
|
||||
return jsonify({"logs": [], "total": 0, "error": "查询失败"})
|
||||
|
||||
|
||||
@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}条日志"})
|
||||
117
routes/admin_api/users_api.py
Normal file
117
routes/admin_api/users_api.py
Normal file
@@ -0,0 +1,117 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
import database
|
||||
from flask import jsonify, request
|
||||
from routes.admin_api import admin_api_bp
|
||||
from routes.decorators import admin_required
|
||||
from services.state import safe_clear_user_logs, safe_remove_user_accounts
|
||||
|
||||
|
||||
# ==================== 用户管理/统计(管理员) ====================
|
||||
|
||||
|
||||
@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/<int:user_id>/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/<int:user_id>/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/<int:user_id>", 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
|
||||
|
||||
|
||||
# ==================== 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/<int:user_id>/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/<int:user_id>/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/<int:user_id>/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)
|
||||
@@ -40,6 +40,48 @@ def _emit(event: str, data: object, *, room: str | None = None) -> None:
|
||||
pass
|
||||
|
||||
|
||||
def _emit_account_update(user_id: int, account) -> None:
|
||||
_emit("account_update", account.to_dict(), room=f"user_{user_id}")
|
||||
|
||||
|
||||
def _request_json(default=None):
|
||||
if default is None:
|
||||
default = {}
|
||||
data = request.get_json(silent=True)
|
||||
return data if isinstance(data, dict) else default
|
||||
|
||||
|
||||
def _ensure_accounts_loaded(user_id: int) -> dict:
|
||||
accounts = safe_get_user_accounts_snapshot(user_id)
|
||||
if accounts:
|
||||
return accounts
|
||||
load_user_accounts(user_id)
|
||||
return safe_get_user_accounts_snapshot(user_id)
|
||||
|
||||
|
||||
def _get_user_account(user_id: int, account_id: str, *, refresh_if_missing: bool = False):
|
||||
account = safe_get_account(user_id, account_id)
|
||||
if account or (not refresh_if_missing):
|
||||
return account
|
||||
load_user_accounts(user_id)
|
||||
return safe_get_account(user_id, account_id)
|
||||
|
||||
|
||||
def _validate_browse_type_input(raw_browse_type, *, default=BROWSE_TYPE_SHOULD_READ):
|
||||
browse_type = validate_browse_type(raw_browse_type, default=default)
|
||||
if not browse_type:
|
||||
return None, (jsonify({"error": "浏览类型无效"}), 400)
|
||||
return browse_type, None
|
||||
|
||||
|
||||
def _cancel_pending_account_task(user_id: int, account_id: str) -> bool:
|
||||
try:
|
||||
scheduler = get_task_scheduler()
|
||||
return bool(scheduler.cancel_pending_task(user_id=user_id, account_id=account_id))
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
@api_accounts_bp.route("/api/accounts", methods=["GET"])
|
||||
@login_required
|
||||
def get_accounts():
|
||||
@@ -49,8 +91,7 @@ def get_accounts():
|
||||
|
||||
accounts = safe_get_user_accounts_snapshot(user_id)
|
||||
if refresh or not accounts:
|
||||
load_user_accounts(user_id)
|
||||
accounts = safe_get_user_accounts_snapshot(user_id)
|
||||
accounts = _ensure_accounts_loaded(user_id)
|
||||
|
||||
return jsonify([acc.to_dict() for acc in accounts.values()])
|
||||
|
||||
@@ -63,20 +104,18 @@ def add_account():
|
||||
|
||||
current_count = len(database.get_user_accounts(user_id))
|
||||
is_vip = database.is_user_vip(user_id)
|
||||
if not is_vip and current_count >= 3:
|
||||
if (not is_vip) and current_count >= 3:
|
||||
return jsonify({"error": "普通用户最多添加3个账号,升级VIP可无限添加"}), 403
|
||||
data = request.json
|
||||
username = data.get("username", "").strip()
|
||||
password = data.get("password", "").strip()
|
||||
remark = data.get("remark", "").strip()[:200]
|
||||
|
||||
data = _request_json()
|
||||
username = str(data.get("username", "")).strip()
|
||||
password = str(data.get("password", "")).strip()
|
||||
remark = str(data.get("remark", "")).strip()[:200]
|
||||
|
||||
if not username or not password:
|
||||
return jsonify({"error": "用户名和密码不能为空"}), 400
|
||||
|
||||
accounts = safe_get_user_accounts_snapshot(user_id)
|
||||
if not accounts:
|
||||
load_user_accounts(user_id)
|
||||
accounts = safe_get_user_accounts_snapshot(user_id)
|
||||
accounts = _ensure_accounts_loaded(user_id)
|
||||
for acc in accounts.values():
|
||||
if acc.username == username:
|
||||
return jsonify({"error": f"账号 '{username}' 已存在"}), 400
|
||||
@@ -92,7 +131,7 @@ def add_account():
|
||||
safe_set_account(user_id, account_id, account)
|
||||
|
||||
log_to_client(f"添加账号: {username}", user_id)
|
||||
_emit("account_update", account.to_dict(), room=f"user_{user_id}")
|
||||
_emit_account_update(user_id, account)
|
||||
|
||||
return jsonify(account.to_dict())
|
||||
|
||||
@@ -103,15 +142,15 @@ def update_account(account_id):
|
||||
"""更新账号信息(密码等)"""
|
||||
user_id = current_user.id
|
||||
|
||||
account = safe_get_account(user_id, account_id)
|
||||
account = _get_user_account(user_id, account_id)
|
||||
if not account:
|
||||
return jsonify({"error": "账号不存在"}), 404
|
||||
|
||||
if account.is_running:
|
||||
return jsonify({"error": "账号正在运行中,请先停止"}), 400
|
||||
|
||||
data = request.json
|
||||
new_password = data.get("password", "").strip()
|
||||
data = _request_json()
|
||||
new_password = str(data.get("password", "")).strip()
|
||||
new_remember = data.get("remember", account.remember)
|
||||
|
||||
if not new_password:
|
||||
@@ -147,7 +186,7 @@ def delete_account(account_id):
|
||||
"""删除账号"""
|
||||
user_id = current_user.id
|
||||
|
||||
account = safe_get_account(user_id, account_id)
|
||||
account = _get_user_account(user_id, account_id)
|
||||
if not account:
|
||||
return jsonify({"error": "账号不存在"}), 404
|
||||
|
||||
@@ -159,7 +198,6 @@ def delete_account(account_id):
|
||||
username = account.username
|
||||
|
||||
database.delete_account(account_id)
|
||||
|
||||
safe_remove_account(user_id, account_id)
|
||||
|
||||
log_to_client(f"删除账号: {username}", user_id)
|
||||
@@ -196,12 +234,12 @@ def update_remark(account_id):
|
||||
"""更新备注"""
|
||||
user_id = current_user.id
|
||||
|
||||
account = safe_get_account(user_id, account_id)
|
||||
account = _get_user_account(user_id, account_id)
|
||||
if not account:
|
||||
return jsonify({"error": "账号不存在"}), 404
|
||||
|
||||
data = request.json
|
||||
remark = data.get("remark", "").strip()[:200]
|
||||
data = _request_json()
|
||||
remark = str(data.get("remark", "")).strip()[:200]
|
||||
|
||||
database.update_account_remark(account_id, remark)
|
||||
|
||||
@@ -217,17 +255,18 @@ def start_account(account_id):
|
||||
"""启动账号任务"""
|
||||
user_id = current_user.id
|
||||
|
||||
account = safe_get_account(user_id, account_id)
|
||||
account = _get_user_account(user_id, account_id)
|
||||
if not account:
|
||||
return jsonify({"error": "账号不存在"}), 404
|
||||
|
||||
if account.is_running:
|
||||
return jsonify({"error": "任务已在运行中"}), 400
|
||||
|
||||
data = request.json or {}
|
||||
browse_type = validate_browse_type(data.get("browse_type"), default=BROWSE_TYPE_SHOULD_READ)
|
||||
if not browse_type:
|
||||
return jsonify({"error": "浏览类型无效"}), 400
|
||||
data = _request_json()
|
||||
browse_type, browse_error = _validate_browse_type_input(data.get("browse_type"), default=BROWSE_TYPE_SHOULD_READ)
|
||||
if browse_error:
|
||||
return browse_error
|
||||
|
||||
enable_screenshot = data.get("enable_screenshot", True)
|
||||
ok, message = submit_account_task(
|
||||
user_id=user_id,
|
||||
@@ -249,7 +288,7 @@ def stop_account(account_id):
|
||||
"""停止账号任务"""
|
||||
user_id = current_user.id
|
||||
|
||||
account = safe_get_account(user_id, account_id)
|
||||
account = _get_user_account(user_id, account_id)
|
||||
if not account:
|
||||
return jsonify({"error": "账号不存在"}), 404
|
||||
|
||||
@@ -259,20 +298,16 @@ def stop_account(account_id):
|
||||
account.should_stop = True
|
||||
account.status = "正在停止"
|
||||
|
||||
try:
|
||||
scheduler = get_task_scheduler()
|
||||
if scheduler.cancel_pending_task(user_id=user_id, account_id=account_id):
|
||||
account.status = "已停止"
|
||||
account.is_running = False
|
||||
safe_remove_task_status(account_id)
|
||||
_emit("account_update", account.to_dict(), room=f"user_{user_id}")
|
||||
log_to_client(f"任务已取消: {account.username}", user_id)
|
||||
return jsonify({"success": True, "canceled": True})
|
||||
except Exception:
|
||||
pass
|
||||
if _cancel_pending_account_task(user_id, account_id):
|
||||
account.status = "已停止"
|
||||
account.is_running = False
|
||||
safe_remove_task_status(account_id)
|
||||
_emit_account_update(user_id, account)
|
||||
log_to_client(f"任务已取消: {account.username}", user_id)
|
||||
return jsonify({"success": True, "canceled": True})
|
||||
|
||||
log_to_client(f"停止任务: {account.username}", user_id)
|
||||
_emit("account_update", account.to_dict(), room=f"user_{user_id}")
|
||||
_emit_account_update(user_id, account)
|
||||
|
||||
return jsonify({"success": True})
|
||||
|
||||
@@ -283,23 +318,20 @@ def manual_screenshot(account_id):
|
||||
"""手动为指定账号截图"""
|
||||
user_id = current_user.id
|
||||
|
||||
account = safe_get_account(user_id, account_id)
|
||||
if not account:
|
||||
load_user_accounts(user_id)
|
||||
account = safe_get_account(user_id, account_id)
|
||||
account = _get_user_account(user_id, account_id, refresh_if_missing=True)
|
||||
if not account:
|
||||
return jsonify({"error": "账号不存在"}), 404
|
||||
if account.is_running:
|
||||
return jsonify({"error": "任务运行中,无法截图"}), 400
|
||||
|
||||
data = request.json or {}
|
||||
data = _request_json()
|
||||
requested_browse_type = data.get("browse_type", None)
|
||||
if requested_browse_type is None:
|
||||
browse_type = normalize_browse_type(account.last_browse_type)
|
||||
else:
|
||||
browse_type = validate_browse_type(requested_browse_type, default=BROWSE_TYPE_SHOULD_READ)
|
||||
if not browse_type:
|
||||
return jsonify({"error": "浏览类型无效"}), 400
|
||||
browse_type, browse_error = _validate_browse_type_input(requested_browse_type, default=BROWSE_TYPE_SHOULD_READ)
|
||||
if browse_error:
|
||||
return browse_error
|
||||
|
||||
account.last_browse_type = browse_type
|
||||
|
||||
@@ -317,12 +349,16 @@ def manual_screenshot(account_id):
|
||||
def batch_start_accounts():
|
||||
"""批量启动账号"""
|
||||
user_id = current_user.id
|
||||
data = request.json or {}
|
||||
data = _request_json()
|
||||
|
||||
account_ids = data.get("account_ids", [])
|
||||
browse_type = validate_browse_type(data.get("browse_type", BROWSE_TYPE_SHOULD_READ), default=BROWSE_TYPE_SHOULD_READ)
|
||||
if not browse_type:
|
||||
return jsonify({"error": "浏览类型无效"}), 400
|
||||
browse_type, browse_error = _validate_browse_type_input(
|
||||
data.get("browse_type", BROWSE_TYPE_SHOULD_READ),
|
||||
default=BROWSE_TYPE_SHOULD_READ,
|
||||
)
|
||||
if browse_error:
|
||||
return browse_error
|
||||
|
||||
enable_screenshot = data.get("enable_screenshot", True)
|
||||
|
||||
if not account_ids:
|
||||
@@ -331,11 +367,10 @@ def batch_start_accounts():
|
||||
started = []
|
||||
failed = []
|
||||
|
||||
if not safe_get_user_accounts_snapshot(user_id):
|
||||
load_user_accounts(user_id)
|
||||
_ensure_accounts_loaded(user_id)
|
||||
|
||||
for account_id in account_ids:
|
||||
account = safe_get_account(user_id, account_id)
|
||||
account = _get_user_account(user_id, account_id)
|
||||
if not account:
|
||||
failed.append({"id": account_id, "reason": "账号不存在"})
|
||||
continue
|
||||
@@ -357,7 +392,13 @@ def batch_start_accounts():
|
||||
failed.append({"id": account_id, "reason": msg})
|
||||
|
||||
return jsonify(
|
||||
{"success": True, "started_count": len(started), "failed_count": len(failed), "started": started, "failed": failed}
|
||||
{
|
||||
"success": True,
|
||||
"started_count": len(started),
|
||||
"failed_count": len(failed),
|
||||
"started": started,
|
||||
"failed": failed,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -366,39 +407,29 @@ def batch_start_accounts():
|
||||
def batch_stop_accounts():
|
||||
"""批量停止账号"""
|
||||
user_id = current_user.id
|
||||
data = request.json
|
||||
data = _request_json()
|
||||
|
||||
account_ids = data.get("account_ids", [])
|
||||
|
||||
if not account_ids:
|
||||
return jsonify({"error": "请选择要停止的账号"}), 400
|
||||
|
||||
stopped = []
|
||||
|
||||
if not safe_get_user_accounts_snapshot(user_id):
|
||||
load_user_accounts(user_id)
|
||||
_ensure_accounts_loaded(user_id)
|
||||
|
||||
for account_id in account_ids:
|
||||
account = safe_get_account(user_id, account_id)
|
||||
if not account:
|
||||
continue
|
||||
|
||||
if not account.is_running:
|
||||
account = _get_user_account(user_id, account_id)
|
||||
if (not account) or (not account.is_running):
|
||||
continue
|
||||
|
||||
account.should_stop = True
|
||||
account.status = "正在停止"
|
||||
stopped.append(account_id)
|
||||
|
||||
try:
|
||||
scheduler = get_task_scheduler()
|
||||
if scheduler.cancel_pending_task(user_id=user_id, account_id=account_id):
|
||||
account.status = "已停止"
|
||||
account.is_running = False
|
||||
safe_remove_task_status(account_id)
|
||||
except Exception:
|
||||
pass
|
||||
if _cancel_pending_account_task(user_id, account_id):
|
||||
account.status = "已停止"
|
||||
account.is_running = False
|
||||
safe_remove_task_status(account_id)
|
||||
|
||||
_emit("account_update", account.to_dict(), room=f"user_{user_id}")
|
||||
_emit_account_update(user_id, account)
|
||||
|
||||
return jsonify({"success": True, "stopped_count": len(stopped), "stopped": stopped})
|
||||
|
||||
@@ -2,16 +2,20 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import random
|
||||
import secrets
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
from io import BytesIO
|
||||
|
||||
import database
|
||||
import email_service
|
||||
from app_config import get_config
|
||||
from app_logger import get_logger
|
||||
from app_security import get_rate_limit_ip, require_ip_not_locked, validate_email, validate_password, validate_username
|
||||
from flask import Blueprint, jsonify, redirect, render_template, request, url_for
|
||||
from flask import Blueprint, jsonify, request
|
||||
from flask_login import login_required, login_user, logout_user
|
||||
from routes.pages import render_app_spa_or_legacy
|
||||
from services.accounts_service import load_user_accounts
|
||||
@@ -39,12 +43,162 @@ config = get_config()
|
||||
|
||||
api_auth_bp = Blueprint("api_auth", __name__)
|
||||
|
||||
_CAPTCHA_FONT_PATHS = [
|
||||
"/usr/share/fonts/truetype/liberation/LiberationSans-Bold.ttf",
|
||||
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
|
||||
"/usr/share/fonts/truetype/freefont/FreeSansBold.ttf",
|
||||
]
|
||||
_CAPTCHA_FONT = None
|
||||
_CAPTCHA_FONT_LOCK = threading.Lock()
|
||||
|
||||
|
||||
def _get_json_payload() -> dict:
|
||||
data = request.get_json(silent=True)
|
||||
return data if isinstance(data, dict) else {}
|
||||
|
||||
|
||||
def _load_captcha_font(image_font_module):
|
||||
global _CAPTCHA_FONT
|
||||
|
||||
if _CAPTCHA_FONT is not None:
|
||||
return _CAPTCHA_FONT
|
||||
|
||||
with _CAPTCHA_FONT_LOCK:
|
||||
if _CAPTCHA_FONT is not None:
|
||||
return _CAPTCHA_FONT
|
||||
|
||||
for font_path in _CAPTCHA_FONT_PATHS:
|
||||
try:
|
||||
_CAPTCHA_FONT = image_font_module.truetype(font_path, 42)
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
if _CAPTCHA_FONT is None:
|
||||
_CAPTCHA_FONT = image_font_module.load_default()
|
||||
|
||||
return _CAPTCHA_FONT
|
||||
|
||||
|
||||
def _generate_captcha_image_data_uri(code: str) -> str:
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
width, height = 160, 60
|
||||
image = Image.new("RGB", (width, height), color=(255, 255, 255))
|
||||
draw = ImageDraw.Draw(image)
|
||||
|
||||
for _ in range(6):
|
||||
x1 = random.randint(0, width)
|
||||
y1 = random.randint(0, height)
|
||||
x2 = random.randint(0, width)
|
||||
y2 = random.randint(0, height)
|
||||
draw.line(
|
||||
[(x1, y1), (x2, y2)],
|
||||
fill=(random.randint(0, 200), random.randint(0, 200), random.randint(0, 200)),
|
||||
width=1,
|
||||
)
|
||||
|
||||
for _ in range(80):
|
||||
x = random.randint(0, width)
|
||||
y = random.randint(0, height)
|
||||
draw.point((x, y), fill=(random.randint(0, 200), random.randint(0, 200), random.randint(0, 200)))
|
||||
|
||||
font = _load_captcha_font(ImageFont)
|
||||
for i, char in enumerate(code):
|
||||
x = 12 + i * 35 + random.randint(-3, 3)
|
||||
y = random.randint(5, 12)
|
||||
color = (random.randint(0, 150), random.randint(0, 150), random.randint(0, 150))
|
||||
draw.text((x, y), char, font=font, fill=color)
|
||||
|
||||
buffer = BytesIO()
|
||||
image.save(buffer, format="PNG")
|
||||
img_base64 = base64.b64encode(buffer.getvalue()).decode("utf-8")
|
||||
return f"data:image/png;base64,{img_base64}"
|
||||
|
||||
|
||||
def _with_vip_suffix(message: str, auto_approve_enabled: bool, auto_approve_vip_days: int) -> str:
|
||||
if auto_approve_enabled and auto_approve_vip_days > 0:
|
||||
return f"{message},赠送{auto_approve_vip_days}天VIP"
|
||||
return message
|
||||
|
||||
|
||||
def _verify_common_captcha(client_ip: str, captcha_session: str, captcha_code: str):
|
||||
success, message = safe_verify_and_consume_captcha(captcha_session, captcha_code)
|
||||
if success:
|
||||
return True, None
|
||||
|
||||
is_locked = record_failed_captcha(client_ip)
|
||||
if is_locked:
|
||||
return False, (jsonify({"error": "验证码错误次数过多,IP已被锁定1小时"}), 429)
|
||||
return False, (jsonify({"error": message}), 400)
|
||||
|
||||
|
||||
def _verify_login_captcha_if_needed(
|
||||
*,
|
||||
captcha_required: bool,
|
||||
captcha_session: str,
|
||||
captcha_code: str,
|
||||
client_ip: str,
|
||||
username_key: str,
|
||||
):
|
||||
if not captcha_required:
|
||||
return True, None
|
||||
|
||||
if not captcha_session or not captcha_code:
|
||||
return False, (jsonify({"error": "请填写验证码", "need_captcha": True}), 400)
|
||||
|
||||
success, message = safe_verify_and_consume_captcha(captcha_session, captcha_code)
|
||||
if success:
|
||||
return True, None
|
||||
|
||||
record_login_failure(client_ip, username_key)
|
||||
return False, (jsonify({"error": message, "need_captcha": True}), 400)
|
||||
|
||||
|
||||
def _send_password_reset_email_if_possible(email: str, username: str, user_id: int) -> None:
|
||||
result = email_service.send_password_reset_email(email=email, username=username, user_id=user_id)
|
||||
if not result["success"]:
|
||||
logger.error(f"密码重置邮件发送失败: {result['error']}")
|
||||
|
||||
|
||||
def _send_login_security_alert_if_needed(user: dict, username: str, client_ip: str) -> None:
|
||||
try:
|
||||
user_agent = request.headers.get("User-Agent", "")
|
||||
context = database.record_login_context(user["id"], client_ip, user_agent)
|
||||
if not context or (not context.get("new_ip") and not context.get("new_device")):
|
||||
return
|
||||
|
||||
if not config.LOGIN_ALERT_ENABLED:
|
||||
return
|
||||
if not should_send_login_alert(user["id"], client_ip):
|
||||
return
|
||||
if not email_service.get_email_settings().get("login_alert_enabled", True):
|
||||
return
|
||||
|
||||
user_info = database.get_user_by_id(user["id"]) or {}
|
||||
if (not user_info.get("email")) or (not user_info.get("email_verified")):
|
||||
return
|
||||
if not database.get_user_email_notify(user["id"]):
|
||||
return
|
||||
|
||||
email_service.send_security_alert_email(
|
||||
email=user_info.get("email"),
|
||||
username=user_info.get("username") or username,
|
||||
ip_address=client_ip,
|
||||
user_agent=user_agent,
|
||||
new_ip=context.get("new_ip", False),
|
||||
new_device=context.get("new_device", False),
|
||||
user_id=user["id"],
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
@api_auth_bp.route("/api/register", methods=["POST"])
|
||||
@require_ip_not_locked
|
||||
def register():
|
||||
"""用户注册"""
|
||||
data = request.json or {}
|
||||
data = _get_json_payload()
|
||||
username = data.get("username", "").strip()
|
||||
password = data.get("password", "").strip()
|
||||
email = data.get("email", "").strip().lower()
|
||||
@@ -67,12 +221,9 @@ def register():
|
||||
if not allowed:
|
||||
return jsonify({"error": error_msg}), 429
|
||||
|
||||
success, message = safe_verify_and_consume_captcha(captcha_session, captcha_code)
|
||||
if not success:
|
||||
is_locked = record_failed_captcha(client_ip)
|
||||
if is_locked:
|
||||
return jsonify({"error": "验证码错误次数过多,IP已被锁定1小时"}), 429
|
||||
return jsonify({"error": message}), 400
|
||||
captcha_ok, captcha_error_response = _verify_common_captcha(client_ip, captcha_session, captcha_code)
|
||||
if not captcha_ok:
|
||||
return captcha_error_response
|
||||
|
||||
email_settings = email_service.get_email_settings()
|
||||
email_verify_enabled = email_settings.get("register_verify_enabled", False) and email_settings.get("enabled", False)
|
||||
@@ -105,20 +256,22 @@ def register():
|
||||
if email_verify_enabled and email:
|
||||
result = email_service.send_register_verification_email(email=email, username=username, user_id=user_id)
|
||||
if result["success"]:
|
||||
message = "注册成功!验证邮件已发送(可直接登录,建议完成邮箱验证)"
|
||||
if auto_approve_enabled and auto_approve_vip_days > 0:
|
||||
message += f",赠送{auto_approve_vip_days}天VIP"
|
||||
message = _with_vip_suffix(
|
||||
"注册成功!验证邮件已发送(可直接登录,建议完成邮箱验证)",
|
||||
auto_approve_enabled,
|
||||
auto_approve_vip_days,
|
||||
)
|
||||
return jsonify({"success": True, "message": message, "need_verify": True})
|
||||
|
||||
logger.error(f"注册验证邮件发送失败: {result['error']}")
|
||||
message = f"注册成功,但验证邮件发送失败({result['error']})。你仍可直接登录"
|
||||
if auto_approve_enabled and auto_approve_vip_days > 0:
|
||||
message += f",赠送{auto_approve_vip_days}天VIP"
|
||||
message = _with_vip_suffix(
|
||||
f"注册成功,但验证邮件发送失败({result['error']})。你仍可直接登录",
|
||||
auto_approve_enabled,
|
||||
auto_approve_vip_days,
|
||||
)
|
||||
return jsonify({"success": True, "message": message, "need_verify": True})
|
||||
|
||||
message = "注册成功!可直接登录"
|
||||
if auto_approve_enabled and auto_approve_vip_days > 0:
|
||||
message += f",赠送{auto_approve_vip_days}天VIP"
|
||||
message = _with_vip_suffix("注册成功!可直接登录", auto_approve_enabled, auto_approve_vip_days)
|
||||
return jsonify({"success": True, "message": message})
|
||||
return jsonify({"error": "用户名已存在"}), 400
|
||||
|
||||
@@ -175,7 +328,7 @@ def verify_email(token):
|
||||
@require_ip_not_locked
|
||||
def resend_verify_email():
|
||||
"""重发验证邮件"""
|
||||
data = request.json or {}
|
||||
data = _get_json_payload()
|
||||
email = data.get("email", "").strip().lower()
|
||||
captcha_session = data.get("captcha_session", "")
|
||||
captcha_code = data.get("captcha", "").strip()
|
||||
@@ -195,12 +348,9 @@ def resend_verify_email():
|
||||
if not allowed:
|
||||
return jsonify({"error": error_msg}), 429
|
||||
|
||||
success, message = safe_verify_and_consume_captcha(captcha_session, captcha_code)
|
||||
if not success:
|
||||
is_locked = record_failed_captcha(client_ip)
|
||||
if is_locked:
|
||||
return jsonify({"error": "验证码错误次数过多,IP已被锁定1小时"}), 429
|
||||
return jsonify({"error": message}), 400
|
||||
captcha_ok, captcha_error_response = _verify_common_captcha(client_ip, captcha_session, captcha_code)
|
||||
if not captcha_ok:
|
||||
return captcha_error_response
|
||||
|
||||
user = database.get_user_by_email(email)
|
||||
if not user:
|
||||
@@ -235,7 +385,7 @@ def get_email_verify_status():
|
||||
@require_ip_not_locked
|
||||
def forgot_password():
|
||||
"""发送密码重置邮件"""
|
||||
data = request.json or {}
|
||||
data = _get_json_payload()
|
||||
email = data.get("email", "").strip().lower()
|
||||
username = data.get("username", "").strip()
|
||||
captcha_session = data.get("captcha_session", "")
|
||||
@@ -263,12 +413,9 @@ def forgot_password():
|
||||
if not allowed:
|
||||
return jsonify({"error": error_msg}), 429
|
||||
|
||||
success, message = safe_verify_and_consume_captcha(captcha_session, captcha_code)
|
||||
if not success:
|
||||
is_locked = record_failed_captcha(client_ip)
|
||||
if is_locked:
|
||||
return jsonify({"error": "验证码错误次数过多,IP已被锁定1小时"}), 429
|
||||
return jsonify({"error": message}), 400
|
||||
captcha_ok, captcha_error_response = _verify_common_captcha(client_ip, captcha_session, captcha_code)
|
||||
if not captcha_ok:
|
||||
return captcha_error_response
|
||||
|
||||
email_settings = email_service.get_email_settings()
|
||||
if not email_settings.get("enabled", False):
|
||||
@@ -293,20 +440,16 @@ def forgot_password():
|
||||
if not allowed:
|
||||
return jsonify({"error": error_msg}), 429
|
||||
|
||||
result = email_service.send_password_reset_email(
|
||||
_send_password_reset_email_if_possible(
|
||||
email=bound_email,
|
||||
username=user["username"],
|
||||
user_id=user["id"],
|
||||
)
|
||||
if not result["success"]:
|
||||
logger.error(f"密码重置邮件发送失败: {result['error']}")
|
||||
return jsonify({"success": True, "message": "如果该账号已绑定邮箱,您将收到密码重置邮件"})
|
||||
|
||||
user = database.get_user_by_email(email)
|
||||
if user and user.get("status") == "approved":
|
||||
result = email_service.send_password_reset_email(email=email, username=user["username"], user_id=user["id"])
|
||||
if not result["success"]:
|
||||
logger.error(f"密码重置邮件发送失败: {result['error']}")
|
||||
_send_password_reset_email_if_possible(email=email, username=user["username"], user_id=user["id"])
|
||||
|
||||
return jsonify({"success": True, "message": "如果该邮箱已注册,您将收到密码重置邮件"})
|
||||
|
||||
@@ -331,7 +474,7 @@ def reset_password_page(token):
|
||||
@api_auth_bp.route("/api/reset-password-confirm", methods=["POST"])
|
||||
def reset_password_confirm():
|
||||
"""确认密码重置"""
|
||||
data = request.json or {}
|
||||
data = _get_json_payload()
|
||||
token = data.get("token", "").strip()
|
||||
new_password = data.get("new_password", "").strip()
|
||||
|
||||
@@ -356,67 +499,15 @@ def reset_password_confirm():
|
||||
@api_auth_bp.route("/api/generate_captcha", methods=["POST"])
|
||||
def generate_captcha():
|
||||
"""生成4位数字验证码图片"""
|
||||
import base64
|
||||
import uuid
|
||||
from io import BytesIO
|
||||
|
||||
session_id = str(uuid.uuid4())
|
||||
|
||||
code = "".join([str(secrets.randbelow(10)) for _ in range(4)])
|
||||
code = "".join(str(secrets.randbelow(10)) for _ in range(4))
|
||||
|
||||
safe_set_captcha(session_id, {"code": code, "expire_time": time.time() + 300, "failed_attempts": 0})
|
||||
safe_cleanup_expired_captcha()
|
||||
|
||||
try:
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
import io
|
||||
|
||||
width, height = 160, 60
|
||||
image = Image.new("RGB", (width, height), color=(255, 255, 255))
|
||||
draw = ImageDraw.Draw(image)
|
||||
|
||||
for _ in range(6):
|
||||
x1 = random.randint(0, width)
|
||||
y1 = random.randint(0, height)
|
||||
x2 = random.randint(0, width)
|
||||
y2 = random.randint(0, height)
|
||||
draw.line(
|
||||
[(x1, y1), (x2, y2)],
|
||||
fill=(random.randint(0, 200), random.randint(0, 200), random.randint(0, 200)),
|
||||
width=1,
|
||||
)
|
||||
|
||||
for _ in range(80):
|
||||
x = random.randint(0, width)
|
||||
y = random.randint(0, height)
|
||||
draw.point((x, y), fill=(random.randint(0, 200), random.randint(0, 200), random.randint(0, 200)))
|
||||
|
||||
font = None
|
||||
font_paths = [
|
||||
"/usr/share/fonts/truetype/liberation/LiberationSans-Bold.ttf",
|
||||
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
|
||||
"/usr/share/fonts/truetype/freefont/FreeSansBold.ttf",
|
||||
]
|
||||
for font_path in font_paths:
|
||||
try:
|
||||
font = ImageFont.truetype(font_path, 42)
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
if font is None:
|
||||
font = ImageFont.load_default()
|
||||
|
||||
for i, char in enumerate(code):
|
||||
x = 12 + i * 35 + random.randint(-3, 3)
|
||||
y = random.randint(5, 12)
|
||||
color = (random.randint(0, 150), random.randint(0, 150), random.randint(0, 150))
|
||||
draw.text((x, y), char, font=font, fill=color)
|
||||
|
||||
buffer = io.BytesIO()
|
||||
image.save(buffer, format="PNG")
|
||||
img_base64 = base64.b64encode(buffer.getvalue()).decode("utf-8")
|
||||
|
||||
return jsonify({"session_id": session_id, "captcha_image": f"data:image/png;base64,{img_base64}"})
|
||||
captcha_image = _generate_captcha_image_data_uri(code)
|
||||
return jsonify({"session_id": session_id, "captcha_image": captcha_image})
|
||||
except ImportError as e:
|
||||
logger.error(f"PIL库未安装,验证码功能不可用: {e}")
|
||||
safe_delete_captcha(session_id)
|
||||
@@ -427,7 +518,7 @@ def generate_captcha():
|
||||
@require_ip_not_locked
|
||||
def login():
|
||||
"""用户登录"""
|
||||
data = request.json or {}
|
||||
data = _get_json_payload()
|
||||
username = data.get("username", "").strip()
|
||||
password = data.get("password", "").strip()
|
||||
captcha_session = data.get("captcha_session", "")
|
||||
@@ -452,13 +543,15 @@ def login():
|
||||
return jsonify({"error": error_msg, "need_captcha": True}), 429
|
||||
|
||||
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:
|
||||
return jsonify({"error": "请填写验证码", "need_captcha": True}), 400
|
||||
success, message = safe_verify_and_consume_captcha(captcha_session, captcha_code)
|
||||
if not success:
|
||||
record_login_failure(client_ip, username_key)
|
||||
return jsonify({"error": message, "need_captcha": True}), 400
|
||||
captcha_ok, captcha_error_response = _verify_login_captcha_if_needed(
|
||||
captcha_required=captcha_required,
|
||||
captcha_session=captcha_session,
|
||||
captcha_code=captcha_code,
|
||||
client_ip=client_ip,
|
||||
username_key=username_key,
|
||||
)
|
||||
if not captcha_ok:
|
||||
return captcha_error_response
|
||||
|
||||
user = database.verify_user(username, password)
|
||||
if not user:
|
||||
@@ -476,29 +569,7 @@ def login():
|
||||
login_user(user_obj)
|
||||
load_user_accounts(user["id"])
|
||||
|
||||
try:
|
||||
user_agent = request.headers.get("User-Agent", "")
|
||||
context = database.record_login_context(user["id"], client_ip, user_agent)
|
||||
if context and (context.get("new_ip") or context.get("new_device")):
|
||||
if (
|
||||
config.LOGIN_ALERT_ENABLED
|
||||
and should_send_login_alert(user["id"], client_ip)
|
||||
and email_service.get_email_settings().get("login_alert_enabled", True)
|
||||
):
|
||||
user_info = database.get_user_by_id(user["id"]) or {}
|
||||
if user_info.get("email") and user_info.get("email_verified"):
|
||||
if database.get_user_email_notify(user["id"]):
|
||||
email_service.send_security_alert_email(
|
||||
email=user_info.get("email"),
|
||||
username=user_info.get("username") or username,
|
||||
ip_address=client_ip,
|
||||
user_agent=user_agent,
|
||||
new_ip=context.get("new_ip", False),
|
||||
new_device=context.get("new_device", False),
|
||||
user_id=user["id"],
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
_send_login_security_alert_if_needed(user=user, username=username, client_ip=client_ip)
|
||||
return jsonify({"success": True})
|
||||
|
||||
|
||||
|
||||
@@ -2,7 +2,11 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import threading
|
||||
import time as time_mod
|
||||
import uuid
|
||||
|
||||
import database
|
||||
from flask import Blueprint, jsonify, request
|
||||
@@ -17,6 +21,13 @@ api_schedules_bp = Blueprint("api_schedules", __name__)
|
||||
_HHMM_RE = re.compile(r"^(\d{1,2}):(\d{2})$")
|
||||
|
||||
|
||||
def _request_json(default=None):
|
||||
if default is None:
|
||||
default = {}
|
||||
data = request.get_json(silent=True)
|
||||
return data if isinstance(data, dict) else default
|
||||
|
||||
|
||||
def _normalize_hhmm(value: object) -> str | None:
|
||||
match = _HHMM_RE.match(str(value or "").strip())
|
||||
if not match:
|
||||
@@ -28,18 +39,53 @@ def _normalize_hhmm(value: object) -> str | None:
|
||||
return f"{hour:02d}:{minute:02d}"
|
||||
|
||||
|
||||
def _normalize_random_delay(value) -> tuple[int | None, str | None]:
|
||||
try:
|
||||
normalized = int(value or 0)
|
||||
except Exception:
|
||||
return None, "random_delay必须是0或1"
|
||||
if normalized not in (0, 1):
|
||||
return None, "random_delay必须是0或1"
|
||||
return normalized, None
|
||||
|
||||
|
||||
def _parse_schedule_account_ids(raw_value) -> list:
|
||||
try:
|
||||
parsed = json.loads(raw_value or "[]")
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return []
|
||||
return parsed if isinstance(parsed, list) else []
|
||||
|
||||
|
||||
def _get_owned_schedule_or_error(schedule_id: int):
|
||||
schedule = database.get_schedule_by_id(schedule_id)
|
||||
if not schedule:
|
||||
return None, (jsonify({"error": "定时任务不存在"}), 404)
|
||||
if schedule.get("user_id") != current_user.id:
|
||||
return None, (jsonify({"error": "无权访问"}), 403)
|
||||
return schedule, None
|
||||
|
||||
|
||||
def _ensure_user_accounts_loaded(user_id: int) -> None:
|
||||
if safe_get_user_accounts_snapshot(user_id):
|
||||
return
|
||||
load_user_accounts(user_id)
|
||||
|
||||
|
||||
def _parse_browse_type_or_error(raw_value, *, default=BROWSE_TYPE_SHOULD_READ):
|
||||
browse_type = validate_browse_type(raw_value, default=default)
|
||||
if not browse_type:
|
||||
return None, (jsonify({"error": "浏览类型无效"}), 400)
|
||||
return browse_type, None
|
||||
|
||||
|
||||
@api_schedules_bp.route("/api/schedules", methods=["GET"])
|
||||
@login_required
|
||||
def get_user_schedules_api():
|
||||
"""获取当前用户的所有定时任务"""
|
||||
schedules = database.get_user_schedules(current_user.id)
|
||||
import json
|
||||
|
||||
for s in schedules:
|
||||
try:
|
||||
s["account_ids"] = json.loads(s.get("account_ids", "[]") or "[]")
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
s["account_ids"] = []
|
||||
for schedule in schedules:
|
||||
schedule["account_ids"] = _parse_schedule_account_ids(schedule.get("account_ids"))
|
||||
return jsonify(schedules)
|
||||
|
||||
|
||||
@@ -47,23 +93,26 @@ def get_user_schedules_api():
|
||||
@login_required
|
||||
def create_user_schedule_api():
|
||||
"""创建用户定时任务"""
|
||||
data = request.json or {}
|
||||
data = _request_json()
|
||||
|
||||
name = data.get("name", "我的定时任务")
|
||||
schedule_time = data.get("schedule_time", "08:00")
|
||||
weekdays = data.get("weekdays", "1,2,3,4,5")
|
||||
browse_type = validate_browse_type(data.get("browse_type", BROWSE_TYPE_SHOULD_READ), default=BROWSE_TYPE_SHOULD_READ)
|
||||
if not browse_type:
|
||||
return jsonify({"error": "浏览类型无效"}), 400
|
||||
|
||||
browse_type, browse_error = _parse_browse_type_or_error(data.get("browse_type", BROWSE_TYPE_SHOULD_READ))
|
||||
if browse_error:
|
||||
return browse_error
|
||||
|
||||
enable_screenshot = data.get("enable_screenshot", 1)
|
||||
random_delay = int(data.get("random_delay", 0) or 0)
|
||||
random_delay, delay_error = _normalize_random_delay(data.get("random_delay", 0))
|
||||
if delay_error:
|
||||
return jsonify({"error": delay_error}), 400
|
||||
|
||||
account_ids = data.get("account_ids", [])
|
||||
|
||||
normalized_time = _normalize_hhmm(schedule_time)
|
||||
if not normalized_time:
|
||||
return jsonify({"error": "时间格式不正确,应为 HH:MM"}), 400
|
||||
if random_delay not in (0, 1):
|
||||
return jsonify({"error": "random_delay必须是0或1"}), 400
|
||||
|
||||
schedule_id = database.create_user_schedule(
|
||||
user_id=current_user.id,
|
||||
@@ -85,18 +134,11 @@ def create_user_schedule_api():
|
||||
@login_required
|
||||
def get_schedule_detail_api(schedule_id):
|
||||
"""获取定时任务详情"""
|
||||
schedule = database.get_schedule_by_id(schedule_id)
|
||||
if not schedule:
|
||||
return jsonify({"error": "定时任务不存在"}), 404
|
||||
if schedule["user_id"] != current_user.id:
|
||||
return jsonify({"error": "无权访问"}), 403
|
||||
schedule, error_response = _get_owned_schedule_or_error(schedule_id)
|
||||
if error_response:
|
||||
return error_response
|
||||
|
||||
import json
|
||||
|
||||
try:
|
||||
schedule["account_ids"] = json.loads(schedule.get("account_ids", "[]") or "[]")
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
schedule["account_ids"] = []
|
||||
schedule["account_ids"] = _parse_schedule_account_ids(schedule.get("account_ids"))
|
||||
return jsonify(schedule)
|
||||
|
||||
|
||||
@@ -104,14 +146,12 @@ def get_schedule_detail_api(schedule_id):
|
||||
@login_required
|
||||
def update_schedule_api(schedule_id):
|
||||
"""更新定时任务"""
|
||||
schedule = database.get_schedule_by_id(schedule_id)
|
||||
if not schedule:
|
||||
return jsonify({"error": "定时任务不存在"}), 404
|
||||
if schedule["user_id"] != current_user.id:
|
||||
return jsonify({"error": "无权访问"}), 403
|
||||
_, error_response = _get_owned_schedule_or_error(schedule_id)
|
||||
if error_response:
|
||||
return error_response
|
||||
|
||||
data = request.json or {}
|
||||
allowed_fields = [
|
||||
data = _request_json()
|
||||
allowed_fields = {
|
||||
"name",
|
||||
"schedule_time",
|
||||
"weekdays",
|
||||
@@ -120,27 +160,26 @@ def update_schedule_api(schedule_id):
|
||||
"random_delay",
|
||||
"account_ids",
|
||||
"enabled",
|
||||
]
|
||||
|
||||
update_data = {k: v for k, v in data.items() if k in allowed_fields}
|
||||
}
|
||||
update_data = {key: value for key, value in data.items() if key in allowed_fields}
|
||||
|
||||
if "schedule_time" in update_data:
|
||||
normalized_time = _normalize_hhmm(update_data["schedule_time"])
|
||||
if not normalized_time:
|
||||
return jsonify({"error": "时间格式不正确,应为 HH:MM"}), 400
|
||||
update_data["schedule_time"] = normalized_time
|
||||
|
||||
if "random_delay" in update_data:
|
||||
try:
|
||||
update_data["random_delay"] = int(update_data.get("random_delay") or 0)
|
||||
except Exception:
|
||||
return jsonify({"error": "random_delay必须是0或1"}), 400
|
||||
if update_data["random_delay"] not in (0, 1):
|
||||
return jsonify({"error": "random_delay必须是0或1"}), 400
|
||||
random_delay, delay_error = _normalize_random_delay(update_data.get("random_delay"))
|
||||
if delay_error:
|
||||
return jsonify({"error": delay_error}), 400
|
||||
update_data["random_delay"] = random_delay
|
||||
|
||||
if "browse_type" in update_data:
|
||||
normalized = validate_browse_type(update_data.get("browse_type"), default=BROWSE_TYPE_SHOULD_READ)
|
||||
if not normalized:
|
||||
return jsonify({"error": "浏览类型无效"}), 400
|
||||
update_data["browse_type"] = normalized
|
||||
normalized_browse_type, browse_error = _parse_browse_type_or_error(update_data.get("browse_type"))
|
||||
if browse_error:
|
||||
return browse_error
|
||||
update_data["browse_type"] = normalized_browse_type
|
||||
|
||||
success = database.update_user_schedule(schedule_id, **update_data)
|
||||
if success:
|
||||
@@ -152,11 +191,9 @@ def update_schedule_api(schedule_id):
|
||||
@login_required
|
||||
def delete_schedule_api(schedule_id):
|
||||
"""删除定时任务"""
|
||||
schedule = database.get_schedule_by_id(schedule_id)
|
||||
if not schedule:
|
||||
return jsonify({"error": "定时任务不存在"}), 404
|
||||
if schedule["user_id"] != current_user.id:
|
||||
return jsonify({"error": "无权访问"}), 403
|
||||
_, error_response = _get_owned_schedule_or_error(schedule_id)
|
||||
if error_response:
|
||||
return error_response
|
||||
|
||||
success = database.delete_user_schedule(schedule_id)
|
||||
if success:
|
||||
@@ -168,13 +205,11 @@ def delete_schedule_api(schedule_id):
|
||||
@login_required
|
||||
def toggle_schedule_api(schedule_id):
|
||||
"""启用/禁用定时任务"""
|
||||
schedule = database.get_schedule_by_id(schedule_id)
|
||||
if not schedule:
|
||||
return jsonify({"error": "定时任务不存在"}), 404
|
||||
if schedule["user_id"] != current_user.id:
|
||||
return jsonify({"error": "无权访问"}), 403
|
||||
schedule, error_response = _get_owned_schedule_or_error(schedule_id)
|
||||
if error_response:
|
||||
return error_response
|
||||
|
||||
data = request.json
|
||||
data = _request_json()
|
||||
enabled = data.get("enabled", not schedule["enabled"])
|
||||
|
||||
success = database.toggle_user_schedule(schedule_id, enabled)
|
||||
@@ -187,22 +222,11 @@ def toggle_schedule_api(schedule_id):
|
||||
@login_required
|
||||
def run_schedule_now_api(schedule_id):
|
||||
"""立即执行定时任务"""
|
||||
import json
|
||||
import threading
|
||||
import time as time_mod
|
||||
import uuid
|
||||
|
||||
schedule = database.get_schedule_by_id(schedule_id)
|
||||
if not schedule:
|
||||
return jsonify({"error": "定时任务不存在"}), 404
|
||||
if schedule["user_id"] != current_user.id:
|
||||
return jsonify({"error": "无权访问"}), 403
|
||||
|
||||
try:
|
||||
account_ids = json.loads(schedule.get("account_ids", "[]") or "[]")
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
account_ids = []
|
||||
schedule, error_response = _get_owned_schedule_or_error(schedule_id)
|
||||
if error_response:
|
||||
return error_response
|
||||
|
||||
account_ids = _parse_schedule_account_ids(schedule.get("account_ids"))
|
||||
if not account_ids:
|
||||
return jsonify({"error": "没有配置账号"}), 400
|
||||
|
||||
@@ -210,8 +234,7 @@ def run_schedule_now_api(schedule_id):
|
||||
browse_type = normalize_browse_type(schedule.get("browse_type", BROWSE_TYPE_SHOULD_READ))
|
||||
enable_screenshot = schedule["enable_screenshot"]
|
||||
|
||||
if not safe_get_user_accounts_snapshot(user_id):
|
||||
load_user_accounts(user_id)
|
||||
_ensure_user_accounts_loaded(user_id)
|
||||
|
||||
from services.state import safe_create_batch, safe_finalize_batch_after_dispatch
|
||||
from services.task_batches import _send_batch_task_email_if_configured
|
||||
@@ -250,6 +273,7 @@ def run_schedule_now_api(schedule_id):
|
||||
if remaining["done"] or remaining["count"] > 0:
|
||||
return
|
||||
remaining["done"] = True
|
||||
|
||||
execution_duration = int(time_mod.time() - execution_start_time)
|
||||
database.update_schedule_execution_log(
|
||||
log_id,
|
||||
@@ -260,19 +284,17 @@ def run_schedule_now_api(schedule_id):
|
||||
status="completed",
|
||||
)
|
||||
|
||||
task_source = f"user_scheduled:{batch_id}"
|
||||
for account_id in account_ids:
|
||||
account = safe_get_account(user_id, account_id)
|
||||
if not account:
|
||||
skipped_count += 1
|
||||
continue
|
||||
if account.is_running:
|
||||
if (not account) or account.is_running:
|
||||
skipped_count += 1
|
||||
continue
|
||||
|
||||
task_source = f"user_scheduled:{batch_id}"
|
||||
with completion_lock:
|
||||
remaining["count"] += 1
|
||||
ok, msg = submit_account_task(
|
||||
|
||||
ok, _ = submit_account_task(
|
||||
user_id=user_id,
|
||||
account_id=account_id,
|
||||
browse_type=browse_type,
|
||||
|
||||
@@ -4,6 +4,7 @@ from __future__ import annotations
|
||||
|
||||
import os
|
||||
from datetime import datetime
|
||||
from typing import Iterator
|
||||
|
||||
import database
|
||||
from app_config import get_config
|
||||
@@ -15,41 +16,67 @@ from services.time_utils import BEIJING_TZ
|
||||
|
||||
config = get_config()
|
||||
SCREENSHOTS_DIR = config.SCREENSHOTS_DIR
|
||||
_IMAGE_EXTENSIONS = (".png", ".jpg", ".jpeg")
|
||||
|
||||
api_screenshots_bp = Blueprint("api_screenshots", __name__)
|
||||
|
||||
|
||||
def _get_user_prefix(user_id: int) -> str:
|
||||
user_info = database.get_user_by_id(user_id)
|
||||
return user_info["username"] if user_info else f"user{user_id}"
|
||||
|
||||
|
||||
def _is_user_screenshot(filename: str, username_prefix: str) -> bool:
|
||||
return filename.startswith(username_prefix + "_") and filename.lower().endswith(_IMAGE_EXTENSIONS)
|
||||
|
||||
|
||||
def _iter_user_screenshot_entries(username_prefix: str) -> Iterator[os.DirEntry]:
|
||||
if not os.path.exists(SCREENSHOTS_DIR):
|
||||
return
|
||||
|
||||
with os.scandir(SCREENSHOTS_DIR) as entries:
|
||||
for entry in entries:
|
||||
if (not entry.is_file()) or (not _is_user_screenshot(entry.name, username_prefix)):
|
||||
continue
|
||||
yield entry
|
||||
|
||||
|
||||
def _build_display_name(filename: str) -> str:
|
||||
base_name, ext = filename.rsplit(".", 1)
|
||||
parts = base_name.split("_", 1)
|
||||
if len(parts) > 1:
|
||||
return f"{parts[1]}.{ext}"
|
||||
return filename
|
||||
|
||||
|
||||
@api_screenshots_bp.route("/api/screenshots", methods=["GET"])
|
||||
@login_required
|
||||
def get_screenshots():
|
||||
"""获取当前用户的截图列表"""
|
||||
user_id = current_user.id
|
||||
user_info = database.get_user_by_id(user_id)
|
||||
username_prefix = user_info["username"] if user_info else f"user{user_id}"
|
||||
username_prefix = _get_user_prefix(user_id)
|
||||
|
||||
try:
|
||||
screenshots = []
|
||||
if os.path.exists(SCREENSHOTS_DIR):
|
||||
for filename in os.listdir(SCREENSHOTS_DIR):
|
||||
if filename.lower().endswith((".png", ".jpg", ".jpeg")) and filename.startswith(username_prefix + "_"):
|
||||
filepath = os.path.join(SCREENSHOTS_DIR, filename)
|
||||
stat = os.stat(filepath)
|
||||
created_time = datetime.fromtimestamp(stat.st_mtime, tz=BEIJING_TZ)
|
||||
parts = filename.rsplit(".", 1)[0].split("_", 1)
|
||||
if len(parts) > 1:
|
||||
display_name = parts[1] + "." + filename.rsplit(".", 1)[1]
|
||||
else:
|
||||
display_name = filename
|
||||
for entry in _iter_user_screenshot_entries(username_prefix):
|
||||
filename = entry.name
|
||||
stat = entry.stat()
|
||||
created_time = datetime.fromtimestamp(stat.st_mtime, tz=BEIJING_TZ)
|
||||
|
||||
screenshots.append(
|
||||
{
|
||||
"filename": filename,
|
||||
"display_name": _build_display_name(filename),
|
||||
"size": stat.st_size,
|
||||
"created": created_time.strftime("%Y-%m-%d %H:%M:%S"),
|
||||
"_created_ts": stat.st_mtime,
|
||||
}
|
||||
)
|
||||
|
||||
screenshots.sort(key=lambda item: item.get("_created_ts", 0), reverse=True)
|
||||
for item in screenshots:
|
||||
item.pop("_created_ts", None)
|
||||
|
||||
screenshots.append(
|
||||
{
|
||||
"filename": filename,
|
||||
"display_name": display_name,
|
||||
"size": stat.st_size,
|
||||
"created": created_time.strftime("%Y-%m-%d %H:%M:%S"),
|
||||
}
|
||||
)
|
||||
screenshots.sort(key=lambda x: x["created"], reverse=True)
|
||||
return jsonify(screenshots)
|
||||
except Exception as e:
|
||||
return jsonify({"error": str(e)}), 500
|
||||
@@ -60,10 +87,9 @@ def get_screenshots():
|
||||
def serve_screenshot(filename):
|
||||
"""提供截图文件访问"""
|
||||
user_id = current_user.id
|
||||
user_info = database.get_user_by_id(user_id)
|
||||
username_prefix = user_info["username"] if user_info else f"user{user_id}"
|
||||
username_prefix = _get_user_prefix(user_id)
|
||||
|
||||
if not filename.startswith(username_prefix + "_"):
|
||||
if not _is_user_screenshot(filename, username_prefix):
|
||||
return jsonify({"error": "无权访问"}), 403
|
||||
|
||||
if not is_safe_path(SCREENSHOTS_DIR, filename):
|
||||
@@ -77,12 +103,14 @@ def serve_screenshot(filename):
|
||||
def delete_screenshot(filename):
|
||||
"""删除指定截图"""
|
||||
user_id = current_user.id
|
||||
user_info = database.get_user_by_id(user_id)
|
||||
username_prefix = user_info["username"] if user_info else f"user{user_id}"
|
||||
username_prefix = _get_user_prefix(user_id)
|
||||
|
||||
if not filename.startswith(username_prefix + "_"):
|
||||
if not _is_user_screenshot(filename, username_prefix):
|
||||
return jsonify({"error": "无权删除"}), 403
|
||||
|
||||
if not is_safe_path(SCREENSHOTS_DIR, filename):
|
||||
return jsonify({"error": "非法路径"}), 403
|
||||
|
||||
try:
|
||||
filepath = os.path.join(SCREENSHOTS_DIR, filename)
|
||||
if os.path.exists(filepath):
|
||||
@@ -99,19 +127,15 @@ def delete_screenshot(filename):
|
||||
def clear_all_screenshots():
|
||||
"""清空当前用户的所有截图"""
|
||||
user_id = current_user.id
|
||||
user_info = database.get_user_by_id(user_id)
|
||||
username_prefix = user_info["username"] if user_info else f"user{user_id}"
|
||||
username_prefix = _get_user_prefix(user_id)
|
||||
|
||||
try:
|
||||
deleted_count = 0
|
||||
if os.path.exists(SCREENSHOTS_DIR):
|
||||
for filename in os.listdir(SCREENSHOTS_DIR):
|
||||
if filename.lower().endswith((".png", ".jpg", ".jpeg")) and filename.startswith(username_prefix + "_"):
|
||||
filepath = os.path.join(SCREENSHOTS_DIR, filename)
|
||||
os.remove(filepath)
|
||||
deleted_count += 1
|
||||
for entry in _iter_user_screenshot_entries(username_prefix):
|
||||
os.remove(entry.path)
|
||||
deleted_count += 1
|
||||
|
||||
log_to_client(f"清理了 {deleted_count} 个截图文件", user_id)
|
||||
return jsonify({"success": True, "deleted": deleted_count})
|
||||
except Exception as e:
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
|
||||
@@ -10,12 +10,96 @@ from flask import Blueprint, jsonify, request
|
||||
from flask_login import current_user, login_required
|
||||
from routes.pages import render_app_spa_or_legacy
|
||||
from services.state import check_email_rate_limit, check_ip_request_rate, safe_iter_task_status_items
|
||||
from services.tasks import get_task_scheduler
|
||||
|
||||
logger = get_logger("app")
|
||||
|
||||
api_user_bp = Blueprint("api_user", __name__)
|
||||
|
||||
|
||||
def _get_current_user_record():
|
||||
return database.get_user_by_id(current_user.id)
|
||||
|
||||
|
||||
def _get_current_user_or_404():
|
||||
user = _get_current_user_record()
|
||||
if user:
|
||||
return user, None
|
||||
return None, (jsonify({"error": "用户不存在"}), 404)
|
||||
|
||||
|
||||
def _get_current_username(*, fallback: str) -> str:
|
||||
user = _get_current_user_record()
|
||||
username = (user or {}).get("username", "")
|
||||
return username if username else fallback
|
||||
|
||||
|
||||
def _coerce_binary_flag(value, *, field_label: str):
|
||||
if isinstance(value, bool):
|
||||
value = 1 if value else 0
|
||||
try:
|
||||
value = int(value)
|
||||
except Exception:
|
||||
return None, f"{field_label}必须是0或1"
|
||||
if value not in (0, 1):
|
||||
return None, f"{field_label}必须是0或1"
|
||||
return value, None
|
||||
|
||||
|
||||
def _check_bind_email_rate_limits(email: str):
|
||||
client_ip = get_rate_limit_ip()
|
||||
allowed, error_msg = check_ip_request_rate(client_ip, "email")
|
||||
if not allowed:
|
||||
return False, error_msg, 429
|
||||
allowed, error_msg = check_email_rate_limit(email, "bind_email")
|
||||
if not allowed:
|
||||
return False, error_msg, 429
|
||||
return True, "", 200
|
||||
|
||||
|
||||
def _render_verify_bind_failed(*, title: str, error_message: str):
|
||||
spa_initial_state = {
|
||||
"page": "verify_result",
|
||||
"success": False,
|
||||
"title": title,
|
||||
"error_message": error_message,
|
||||
"primary_label": "返回登录",
|
||||
"primary_url": "/login",
|
||||
}
|
||||
return render_app_spa_or_legacy(
|
||||
"verify_failed.html",
|
||||
legacy_context={"error_message": error_message},
|
||||
spa_initial_state=spa_initial_state,
|
||||
)
|
||||
|
||||
|
||||
def _render_verify_bind_success(email: str):
|
||||
spa_initial_state = {
|
||||
"page": "verify_result",
|
||||
"success": True,
|
||||
"title": "邮箱绑定成功",
|
||||
"message": f"邮箱 {email} 已成功绑定到您的账号!",
|
||||
"primary_label": "返回登录",
|
||||
"primary_url": "/login",
|
||||
"redirect_url": "/login",
|
||||
"redirect_seconds": 5,
|
||||
}
|
||||
return render_app_spa_or_legacy("verify_success.html", spa_initial_state=spa_initial_state)
|
||||
|
||||
|
||||
def _get_current_running_count(user_id: int) -> int:
|
||||
try:
|
||||
queue_snapshot = get_task_scheduler().get_queue_state_snapshot() or {}
|
||||
running_by_user = queue_snapshot.get("running_by_user") or {}
|
||||
return int(running_by_user.get(int(user_id), running_by_user.get(str(user_id), 0)) or 0)
|
||||
except Exception:
|
||||
current_running = 0
|
||||
for _, info in safe_iter_task_status_items():
|
||||
if info.get("user_id") == user_id and info.get("status") == "运行中":
|
||||
current_running += 1
|
||||
return current_running
|
||||
|
||||
|
||||
@api_user_bp.route("/api/announcements/active", methods=["GET"])
|
||||
@login_required
|
||||
def get_active_announcement():
|
||||
@@ -77,8 +161,7 @@ def submit_feedback():
|
||||
if len(description) > 2000:
|
||||
return jsonify({"error": "描述不能超过2000个字符"}), 400
|
||||
|
||||
user_info = database.get_user_by_id(current_user.id)
|
||||
username = user_info["username"] if user_info else f"用户{current_user.id}"
|
||||
username = _get_current_username(fallback=f"用户{current_user.id}")
|
||||
|
||||
feedback_id = database.create_bug_feedback(
|
||||
user_id=current_user.id,
|
||||
@@ -104,8 +187,7 @@ def get_my_feedbacks():
|
||||
def get_current_user_vip():
|
||||
"""获取当前用户VIP信息"""
|
||||
vip_info = database.get_user_vip_info(current_user.id)
|
||||
user_info = database.get_user_by_id(current_user.id)
|
||||
vip_info["username"] = user_info["username"] if user_info else "Unknown"
|
||||
vip_info["username"] = _get_current_username(fallback="Unknown")
|
||||
return jsonify(vip_info)
|
||||
|
||||
|
||||
@@ -124,9 +206,9 @@ def change_user_password():
|
||||
if not is_valid:
|
||||
return jsonify({"error": error_msg}), 400
|
||||
|
||||
user = database.get_user_by_id(current_user.id)
|
||||
if not user:
|
||||
return jsonify({"error": "用户不存在"}), 404
|
||||
user, error_response = _get_current_user_or_404()
|
||||
if error_response:
|
||||
return error_response
|
||||
|
||||
username = user.get("username", "")
|
||||
if not username or not database.verify_user(username, current_password):
|
||||
@@ -141,9 +223,9 @@ def change_user_password():
|
||||
@login_required
|
||||
def get_user_email():
|
||||
"""获取当前用户的邮箱信息"""
|
||||
user = database.get_user_by_id(current_user.id)
|
||||
if not user:
|
||||
return jsonify({"error": "用户不存在"}), 404
|
||||
user, error_response = _get_current_user_or_404()
|
||||
if error_response:
|
||||
return error_response
|
||||
|
||||
return jsonify({"email": user.get("email", ""), "email_verified": user.get("email_verified", False)})
|
||||
|
||||
@@ -172,14 +254,9 @@ def update_user_kdocs_settings():
|
||||
return jsonify({"error": "县区长度不能超过50"}), 400
|
||||
|
||||
if kdocs_auto_upload is not None:
|
||||
if isinstance(kdocs_auto_upload, bool):
|
||||
kdocs_auto_upload = 1 if kdocs_auto_upload else 0
|
||||
try:
|
||||
kdocs_auto_upload = int(kdocs_auto_upload)
|
||||
except Exception:
|
||||
return jsonify({"error": "自动上传开关必须是0或1"}), 400
|
||||
if kdocs_auto_upload not in (0, 1):
|
||||
return jsonify({"error": "自动上传开关必须是0或1"}), 400
|
||||
kdocs_auto_upload, parse_error = _coerce_binary_flag(kdocs_auto_upload, field_label="自动上传开关")
|
||||
if parse_error:
|
||||
return jsonify({"error": parse_error}), 400
|
||||
|
||||
if not database.update_user_kdocs_settings(
|
||||
current_user.id,
|
||||
@@ -207,13 +284,9 @@ def bind_user_email():
|
||||
if not is_valid:
|
||||
return jsonify({"error": error_msg}), 400
|
||||
|
||||
client_ip = get_rate_limit_ip()
|
||||
allowed, error_msg = check_ip_request_rate(client_ip, "email")
|
||||
allowed, error_msg, status_code = _check_bind_email_rate_limits(email)
|
||||
if not allowed:
|
||||
return jsonify({"error": error_msg}), 429
|
||||
allowed, error_msg = check_email_rate_limit(email, "bind_email")
|
||||
if not allowed:
|
||||
return jsonify({"error": error_msg}), 429
|
||||
return jsonify({"error": error_msg}), status_code
|
||||
|
||||
settings = email_service.get_email_settings()
|
||||
if not settings.get("enabled", False):
|
||||
@@ -223,9 +296,9 @@ def bind_user_email():
|
||||
if existing_user and existing_user["id"] != current_user.id:
|
||||
return jsonify({"error": "该邮箱已被其他用户绑定"}), 400
|
||||
|
||||
user = database.get_user_by_id(current_user.id)
|
||||
if not user:
|
||||
return jsonify({"error": "用户不存在"}), 404
|
||||
user, error_response = _get_current_user_or_404()
|
||||
if error_response:
|
||||
return error_response
|
||||
|
||||
if user.get("email") == email and user.get("email_verified"):
|
||||
return jsonify({"error": "该邮箱已绑定并验证"}), 400
|
||||
@@ -247,56 +320,20 @@ def verify_bind_email(token):
|
||||
email = result["email"]
|
||||
|
||||
if database.update_user_email(user_id, email, verified=True):
|
||||
spa_initial_state = {
|
||||
"page": "verify_result",
|
||||
"success": True,
|
||||
"title": "邮箱绑定成功",
|
||||
"message": f"邮箱 {email} 已成功绑定到您的账号!",
|
||||
"primary_label": "返回登录",
|
||||
"primary_url": "/login",
|
||||
"redirect_url": "/login",
|
||||
"redirect_seconds": 5,
|
||||
}
|
||||
return render_app_spa_or_legacy("verify_success.html", spa_initial_state=spa_initial_state)
|
||||
return _render_verify_bind_success(email)
|
||||
|
||||
error_message = "邮箱绑定失败,请重试"
|
||||
spa_initial_state = {
|
||||
"page": "verify_result",
|
||||
"success": False,
|
||||
"title": "绑定失败",
|
||||
"error_message": error_message,
|
||||
"primary_label": "返回登录",
|
||||
"primary_url": "/login",
|
||||
}
|
||||
return render_app_spa_or_legacy(
|
||||
"verify_failed.html",
|
||||
legacy_context={"error_message": error_message},
|
||||
spa_initial_state=spa_initial_state,
|
||||
)
|
||||
return _render_verify_bind_failed(title="绑定失败", error_message="邮箱绑定失败,请重试")
|
||||
|
||||
error_message = "验证链接已过期或无效,请重新发送验证邮件"
|
||||
spa_initial_state = {
|
||||
"page": "verify_result",
|
||||
"success": False,
|
||||
"title": "链接无效",
|
||||
"error_message": error_message,
|
||||
"primary_label": "返回登录",
|
||||
"primary_url": "/login",
|
||||
}
|
||||
return render_app_spa_or_legacy(
|
||||
"verify_failed.html",
|
||||
legacy_context={"error_message": error_message},
|
||||
spa_initial_state=spa_initial_state,
|
||||
)
|
||||
return _render_verify_bind_failed(title="链接无效", error_message="验证链接已过期或无效,请重新发送验证邮件")
|
||||
|
||||
|
||||
@api_user_bp.route("/api/user/unbind-email", methods=["POST"])
|
||||
@login_required
|
||||
def unbind_user_email():
|
||||
"""解绑用户邮箱"""
|
||||
user = database.get_user_by_id(current_user.id)
|
||||
if not user:
|
||||
return jsonify({"error": "用户不存在"}), 404
|
||||
user, error_response = _get_current_user_or_404()
|
||||
if error_response:
|
||||
return error_response
|
||||
|
||||
if not user.get("email"):
|
||||
return jsonify({"error": "当前未绑定邮箱"}), 400
|
||||
@@ -334,10 +371,7 @@ def get_run_stats():
|
||||
|
||||
stats = database.get_user_run_stats(user_id)
|
||||
|
||||
current_running = 0
|
||||
for _, info in safe_iter_task_status_items():
|
||||
if info.get("user_id") == user_id and info.get("status") == "运行中":
|
||||
current_running += 1
|
||||
current_running = _get_current_running_count(user_id)
|
||||
|
||||
return jsonify(
|
||||
{
|
||||
|
||||
@@ -31,7 +31,7 @@ def admin_required(f):
|
||||
if is_api:
|
||||
return jsonify({"error": "需要管理员权限"}), 403
|
||||
return redirect(url_for("pages.admin_login_page"))
|
||||
logger.info(f"[admin_required] 管理员 {session.get('admin_username')} 访问 {request.path}")
|
||||
logger.debug(f"[admin_required] 管理员 {session.get('admin_username')} 访问 {request.path}")
|
||||
return f(*args, **kwargs)
|
||||
|
||||
return decorated_function
|
||||
|
||||
@@ -2,12 +2,62 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import time
|
||||
|
||||
from flask import Blueprint, jsonify
|
||||
|
||||
import database
|
||||
import db_pool
|
||||
from services.time_utils import get_beijing_now
|
||||
|
||||
health_bp = Blueprint("health", __name__)
|
||||
_PROCESS_START_TS = time.time()
|
||||
|
||||
|
||||
def _build_runtime_metrics() -> dict:
|
||||
metrics = {
|
||||
"uptime_seconds": max(0, int(time.time() - _PROCESS_START_TS)),
|
||||
}
|
||||
|
||||
try:
|
||||
pool_stats = db_pool.get_pool_stats() or {}
|
||||
metrics["db_pool"] = {
|
||||
"pool_size": int(pool_stats.get("pool_size", 0) or 0),
|
||||
"available": int(pool_stats.get("available", 0) or 0),
|
||||
"in_use": int(pool_stats.get("in_use", 0) or 0),
|
||||
}
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
import psutil
|
||||
|
||||
proc = psutil.Process(os.getpid())
|
||||
with proc.oneshot():
|
||||
mem_info = proc.memory_info()
|
||||
metrics["process"] = {
|
||||
"rss_mb": round(float(mem_info.rss) / 1024 / 1024, 2),
|
||||
"cpu_percent": round(float(proc.cpu_percent(interval=None)), 2),
|
||||
"threads": int(proc.num_threads()),
|
||||
}
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
from services import tasks as tasks_module
|
||||
|
||||
scheduler = getattr(tasks_module, "_task_scheduler", None)
|
||||
if scheduler is not None:
|
||||
queue_snapshot = scheduler.get_queue_state_snapshot() or {}
|
||||
metrics["task_queue"] = {
|
||||
"pending_total": int(queue_snapshot.get("pending_total", 0) or 0),
|
||||
"running_total": int(queue_snapshot.get("running_total", 0) or 0),
|
||||
}
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return metrics
|
||||
|
||||
|
||||
@health_bp.route("/health", methods=["GET"])
|
||||
@@ -26,6 +76,6 @@ def health_check():
|
||||
"time": get_beijing_now().strftime("%Y-%m-%d %H:%M:%S"),
|
||||
"db_ok": db_ok,
|
||||
"db_error": db_error,
|
||||
"metrics": _build_runtime_metrics(),
|
||||
}
|
||||
return jsonify(payload), (200 if db_ok else 500)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user