更新说明:\n1. 新增用户端与管理员端 Passkey 登录/注册/设备管理(最多3台,支持设备备注、删除设备)。\n2. 修复 Passkey 注册与登录流程中的浏览器/证书/CSRF相关问题,增强错误提示。\n3. 前台登录页改为独立入口,首屏仅加载必要资源,其他页面按需加载。\n4. 系统配置页改为静默获取金山文档状态,避免首屏阻塞,并优化状态展示为“检测中/已登录/未登录/异常”。\n5. 补充后端接口与页面渲染适配,修复多入口下样式依赖注入问题。\n6. 同步更新前后台构建产物与相关静态资源。
643 lines
23 KiB
Python
643 lines
23 KiB
Python
#!/usr/bin/env python3
|
||
# -*- coding: utf-8 -*-
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import time
|
||
|
||
import database
|
||
import email_service
|
||
from app_logger import get_logger
|
||
from app_security import get_rate_limit_ip, require_ip_not_locked, validate_email, validate_password
|
||
from flask import Blueprint, jsonify, request, session
|
||
from flask_login import current_user, login_required
|
||
from routes.pages import render_app_spa_or_legacy
|
||
from services.passkeys import (
|
||
MAX_PASSKEYS_PER_OWNER,
|
||
encode_credential_id,
|
||
get_credential_transports,
|
||
get_expected_origins,
|
||
get_rp_id,
|
||
is_challenge_valid,
|
||
make_registration_options,
|
||
normalize_device_name,
|
||
verify_registration,
|
||
)
|
||
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__)
|
||
_USER_PASSKEY_REGISTER_SESSION_KEY = "user_passkey_register_state"
|
||
|
||
|
||
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 _parse_credential_payload(data: dict) -> dict | None:
|
||
credential = data.get("credential")
|
||
if isinstance(credential, dict):
|
||
return credential
|
||
if isinstance(credential, str):
|
||
try:
|
||
parsed = json.loads(credential)
|
||
return parsed if isinstance(parsed, dict) else None
|
||
except Exception:
|
||
return None
|
||
return None
|
||
|
||
|
||
def _truncate_text(value, max_len: int = 300) -> str:
|
||
text = str(value or "").strip()
|
||
if len(text) > max_len:
|
||
return f"{text[:max_len]}..."
|
||
return text
|
||
|
||
|
||
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():
|
||
"""获取当前用户应展示的公告(若无则返回announcement=null)"""
|
||
try:
|
||
user_id = int(current_user.id)
|
||
except Exception:
|
||
return jsonify({"announcement": None})
|
||
|
||
announcement = database.get_active_announcement_for_user(user_id)
|
||
if not announcement:
|
||
return jsonify({"announcement": None})
|
||
|
||
return jsonify(
|
||
{
|
||
"announcement": {
|
||
"id": announcement.get("id"),
|
||
"title": announcement.get("title", ""),
|
||
"content": announcement.get("content", ""),
|
||
"image_url": announcement.get("image_url") or "",
|
||
"created_at": announcement.get("created_at"),
|
||
}
|
||
}
|
||
)
|
||
|
||
|
||
@api_user_bp.route("/api/announcements/<int:announcement_id>/dismiss", methods=["POST"])
|
||
@login_required
|
||
def dismiss_announcement(announcement_id):
|
||
"""用户永久关闭某条公告(本次公告不再弹窗)"""
|
||
try:
|
||
user_id = int(current_user.id)
|
||
except Exception:
|
||
return jsonify({"error": "请先登录"}), 401
|
||
|
||
announcement = database.get_announcement_by_id(announcement_id)
|
||
if not announcement:
|
||
return jsonify({"error": "公告不存在"}), 404
|
||
|
||
database.dismiss_announcement_for_user(user_id, announcement_id)
|
||
return jsonify({"success": True})
|
||
|
||
|
||
@api_user_bp.route("/api/feedback", methods=["POST"])
|
||
@login_required
|
||
def submit_feedback():
|
||
"""用户提交Bug反馈"""
|
||
data = request.get_json()
|
||
title = data.get("title", "").strip()
|
||
description = data.get("description", "").strip()
|
||
contact = data.get("contact", "").strip()
|
||
|
||
if not title or not description:
|
||
return jsonify({"error": "标题和描述不能为空"}), 400
|
||
|
||
if len(title) > 100:
|
||
return jsonify({"error": "标题不能超过100个字符"}), 400
|
||
|
||
if len(description) > 2000:
|
||
return jsonify({"error": "描述不能超过2000个字符"}), 400
|
||
|
||
username = _get_current_username(fallback=f"用户{current_user.id}")
|
||
|
||
feedback_id = database.create_bug_feedback(
|
||
user_id=current_user.id,
|
||
username=username,
|
||
title=title,
|
||
description=description,
|
||
contact=contact,
|
||
)
|
||
|
||
return jsonify({"message": "反馈提交成功", "id": feedback_id})
|
||
|
||
|
||
@api_user_bp.route("/api/feedback", methods=["GET"])
|
||
@login_required
|
||
def get_my_feedbacks():
|
||
"""获取当前用户的反馈列表"""
|
||
feedbacks = database.get_user_feedbacks(current_user.id)
|
||
return jsonify(feedbacks)
|
||
|
||
|
||
@api_user_bp.route("/api/user/vip", methods=["GET"])
|
||
@login_required
|
||
def get_current_user_vip():
|
||
"""获取当前用户VIP信息"""
|
||
vip_info = database.get_user_vip_info(current_user.id)
|
||
vip_info["username"] = _get_current_username(fallback="Unknown")
|
||
return jsonify(vip_info)
|
||
|
||
|
||
@api_user_bp.route("/api/user/password", methods=["POST"])
|
||
@login_required
|
||
def change_user_password():
|
||
"""用户修改自己的密码"""
|
||
data = request.get_json()
|
||
current_password = data.get("current_password")
|
||
new_password = data.get("new_password")
|
||
|
||
if not current_password or 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
|
||
|
||
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):
|
||
return jsonify({"error": "当前密码错误"}), 400
|
||
|
||
if database.admin_reset_user_password(current_user.id, new_password):
|
||
return jsonify({"success": True})
|
||
return jsonify({"error": "密码更新失败"}), 500
|
||
|
||
|
||
@api_user_bp.route("/api/user/email", methods=["GET"])
|
||
@login_required
|
||
def get_user_email():
|
||
"""获取当前用户的邮箱信息"""
|
||
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)})
|
||
|
||
|
||
@api_user_bp.route("/api/user/kdocs", methods=["GET"])
|
||
@login_required
|
||
def get_user_kdocs_settings():
|
||
"""获取当前用户的金山文档设置"""
|
||
settings = database.get_user_kdocs_settings(current_user.id) or {}
|
||
cfg = database.get_system_config() or {}
|
||
default_unit = (cfg.get("kdocs_default_unit") or "").strip() or "道县"
|
||
kdocs_unit = (settings.get("kdocs_unit") or "").strip() or default_unit
|
||
kdocs_auto_upload = 1 if int(settings.get("kdocs_auto_upload", 0) or 0) == 1 else 0
|
||
return jsonify({"kdocs_unit": kdocs_unit, "kdocs_auto_upload": kdocs_auto_upload})
|
||
|
||
|
||
@api_user_bp.route("/api/user/kdocs", methods=["POST"])
|
||
@login_required
|
||
def update_user_kdocs_settings():
|
||
"""更新当前用户的金山文档设置"""
|
||
data = request.get_json() or {}
|
||
kdocs_unit = data.get("kdocs_unit")
|
||
kdocs_auto_upload = data.get("kdocs_auto_upload")
|
||
|
||
if kdocs_unit is not None:
|
||
kdocs_unit = str(kdocs_unit or "").strip()
|
||
if len(kdocs_unit) > 50:
|
||
return jsonify({"error": "县区长度不能超过50"}), 400
|
||
|
||
if kdocs_auto_upload is not None:
|
||
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,
|
||
kdocs_unit=kdocs_unit,
|
||
kdocs_auto_upload=kdocs_auto_upload,
|
||
):
|
||
return jsonify({"error": "更新失败"}), 400
|
||
|
||
settings = database.get_user_kdocs_settings(current_user.id) or {}
|
||
cfg = database.get_system_config() or {}
|
||
default_unit = (cfg.get("kdocs_default_unit") or "").strip() or "道县"
|
||
response_settings = {
|
||
"kdocs_unit": (settings.get("kdocs_unit") or "").strip() or default_unit,
|
||
"kdocs_auto_upload": 1 if int(settings.get("kdocs_auto_upload", 0) or 0) == 1 else 0,
|
||
}
|
||
return jsonify({"success": True, "settings": response_settings})
|
||
|
||
|
||
@api_user_bp.route("/api/user/bind-email", methods=["POST"])
|
||
@login_required
|
||
@require_ip_not_locked
|
||
def bind_user_email():
|
||
"""发送邮箱绑定验证邮件"""
|
||
data = request.get_json() or {}
|
||
email = data.get("email", "").strip().lower()
|
||
|
||
if not email:
|
||
return jsonify({"error": "请输入有效的邮箱地址"}), 400
|
||
|
||
is_valid, error_msg = validate_email(email)
|
||
if not is_valid:
|
||
return jsonify({"error": error_msg}), 400
|
||
|
||
allowed, error_msg, status_code = _check_bind_email_rate_limits(email)
|
||
if not allowed:
|
||
return jsonify({"error": error_msg}), status_code
|
||
|
||
settings = email_service.get_email_settings()
|
||
if not settings.get("enabled", False):
|
||
return jsonify({"error": "邮件功能未启用,请联系管理员"}), 400
|
||
|
||
existing_user = database.get_user_by_email(email)
|
||
if existing_user and existing_user["id"] != current_user.id:
|
||
return jsonify({"error": "该邮箱已被其他用户绑定"}), 400
|
||
|
||
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
|
||
|
||
result = email_service.send_bind_email_verification(user_id=current_user.id, email=email, username=user["username"])
|
||
|
||
if result["success"]:
|
||
return jsonify({"success": True, "message": "验证邮件已发送,请查收"})
|
||
return jsonify({"error": result["error"]}), 500
|
||
|
||
|
||
@api_user_bp.route("/api/verify-bind-email/<token>")
|
||
def verify_bind_email(token):
|
||
"""验证邮箱绑定Token"""
|
||
result = email_service.verify_bind_email_token(token, consume=False)
|
||
|
||
if result:
|
||
token_id = result["token_id"]
|
||
user_id = result["user_id"]
|
||
email = result["email"]
|
||
|
||
if database.update_user_email(user_id, email, verified=True):
|
||
if not email_service.consume_email_token(token_id):
|
||
logger.warning(f"邮箱绑定成功但Token消费失败: token_id={token_id}, user_id={user_id}")
|
||
return _render_verify_bind_success(email)
|
||
|
||
return _render_verify_bind_failed(title="绑定失败", error_message="邮箱绑定失败,请重试")
|
||
|
||
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, error_response = _get_current_user_or_404()
|
||
if error_response:
|
||
return error_response
|
||
|
||
if not user.get("email"):
|
||
return jsonify({"error": "当前未绑定邮箱"}), 400
|
||
|
||
if database.update_user_email(current_user.id, None, verified=False):
|
||
return jsonify({"success": True, "message": "邮箱已解绑"})
|
||
return jsonify({"error": "解绑失败"}), 500
|
||
|
||
|
||
@api_user_bp.route("/api/user/email-notify", methods=["GET"])
|
||
@login_required
|
||
def get_user_email_notify():
|
||
"""获取用户邮件通知偏好"""
|
||
enabled = database.get_user_email_notify(current_user.id)
|
||
return jsonify({"enabled": enabled})
|
||
|
||
|
||
@api_user_bp.route("/api/user/email-notify", methods=["POST"])
|
||
@login_required
|
||
def update_user_email_notify():
|
||
"""更新用户邮件通知偏好"""
|
||
data = request.get_json()
|
||
enabled = data.get("enabled", True)
|
||
|
||
if database.update_user_email_notify(current_user.id, enabled):
|
||
return jsonify({"success": True})
|
||
return jsonify({"error": "更新失败"}), 500
|
||
|
||
|
||
@api_user_bp.route("/api/user/passkeys", methods=["GET"])
|
||
@login_required
|
||
def list_user_passkeys():
|
||
"""获取当前用户绑定的 Passkey 设备列表。"""
|
||
rows = database.list_passkeys("user", int(current_user.id))
|
||
items = []
|
||
for row in rows:
|
||
credential_id = str(row.get("credential_id") or "")
|
||
preview = ""
|
||
if credential_id:
|
||
preview = f"{credential_id[:8]}...{credential_id[-6:]}" if len(credential_id) > 16 else credential_id
|
||
items.append(
|
||
{
|
||
"id": int(row.get("id")),
|
||
"device_name": str(row.get("device_name") or ""),
|
||
"credential_id_preview": preview,
|
||
"created_at": row.get("created_at"),
|
||
"last_used_at": row.get("last_used_at"),
|
||
"transports": str(row.get("transports") or ""),
|
||
}
|
||
)
|
||
return jsonify({"items": items, "limit": MAX_PASSKEYS_PER_OWNER})
|
||
|
||
|
||
@api_user_bp.route("/api/user/passkeys/register/options", methods=["POST"])
|
||
@login_required
|
||
def user_passkey_register_options():
|
||
"""当前登录用户创建 Passkey:下发 registration challenge。"""
|
||
user, error_response = _get_current_user_or_404()
|
||
if error_response:
|
||
return error_response
|
||
|
||
count = database.count_passkeys("user", int(current_user.id))
|
||
if count >= MAX_PASSKEYS_PER_OWNER:
|
||
return jsonify({"error": f"最多可绑定{MAX_PASSKEYS_PER_OWNER}台设备"}), 400
|
||
|
||
data = request.get_json(silent=True) or {}
|
||
device_name = normalize_device_name(data.get("device_name"))
|
||
|
||
existing = database.list_passkeys("user", int(current_user.id))
|
||
exclude_credential_ids = [str(item.get("credential_id") or "").strip() for item in existing if item.get("credential_id")]
|
||
|
||
try:
|
||
rp_id = get_rp_id(request)
|
||
expected_origins = get_expected_origins(request)
|
||
except Exception as e:
|
||
logger.warning(f"[passkey] 用户注册 options 失败(user_id={current_user.id}): {e}")
|
||
return jsonify({"error": "Passkey配置异常,请联系管理员"}), 500
|
||
|
||
try:
|
||
options = make_registration_options(
|
||
rp_id=rp_id,
|
||
rp_name="知识管理平台",
|
||
user_name=str(user.get("username") or f"user-{current_user.id}"),
|
||
user_display_name=str(user.get("username") or f"user-{current_user.id}"),
|
||
user_id_bytes=f"user:{int(current_user.id)}".encode("utf-8"),
|
||
exclude_credential_ids=exclude_credential_ids,
|
||
)
|
||
except Exception as e:
|
||
logger.warning(f"[passkey] 用户注册 options 构建失败(user_id={current_user.id}): {e}")
|
||
return jsonify({"error": "生成Passkey挑战失败"}), 500
|
||
|
||
challenge = str(options.get("challenge") or "").strip()
|
||
if not challenge:
|
||
return jsonify({"error": "生成Passkey挑战失败"}), 500
|
||
|
||
session[_USER_PASSKEY_REGISTER_SESSION_KEY] = {
|
||
"user_id": int(current_user.id),
|
||
"challenge": challenge,
|
||
"rp_id": rp_id,
|
||
"expected_origins": expected_origins,
|
||
"device_name": device_name,
|
||
"created_at": time.time(),
|
||
}
|
||
session.modified = True
|
||
return jsonify({"publicKey": options, "limit": MAX_PASSKEYS_PER_OWNER})
|
||
|
||
|
||
@api_user_bp.route("/api/user/passkeys/register/verify", methods=["POST"])
|
||
@login_required
|
||
def user_passkey_register_verify():
|
||
"""当前登录用户创建 Passkey:校验 attestation 并落库。"""
|
||
state = session.get(_USER_PASSKEY_REGISTER_SESSION_KEY) or {}
|
||
if not state:
|
||
return jsonify({"error": "Passkey挑战不存在或已过期,请重试"}), 400
|
||
if int(state.get("user_id") or 0) != int(current_user.id):
|
||
return jsonify({"error": "Passkey挑战与当前用户不匹配"}), 400
|
||
if not is_challenge_valid(state.get("created_at")):
|
||
session.pop(_USER_PASSKEY_REGISTER_SESSION_KEY, None)
|
||
return jsonify({"error": "Passkey挑战已过期,请重试"}), 400
|
||
|
||
data = request.get_json(silent=True) or {}
|
||
credential = _parse_credential_payload(data)
|
||
if not credential:
|
||
return jsonify({"error": "Passkey参数缺失"}), 400
|
||
|
||
count = database.count_passkeys("user", int(current_user.id))
|
||
if count >= MAX_PASSKEYS_PER_OWNER:
|
||
session.pop(_USER_PASSKEY_REGISTER_SESSION_KEY, None)
|
||
return jsonify({"error": f"最多可绑定{MAX_PASSKEYS_PER_OWNER}台设备"}), 400
|
||
|
||
try:
|
||
verified = verify_registration(
|
||
credential=credential,
|
||
expected_challenge=str(state.get("challenge") or ""),
|
||
expected_rp_id=str(state.get("rp_id") or ""),
|
||
expected_origins=list(state.get("expected_origins") or []),
|
||
)
|
||
except Exception as e:
|
||
logger.warning(f"[passkey] 用户注册验签失败(user_id={current_user.id}): {e}")
|
||
return jsonify({"error": "Passkey验证失败,请重试"}), 400
|
||
|
||
credential_id = encode_credential_id(verified.credential_id)
|
||
public_key = encode_credential_id(verified.credential_public_key)
|
||
transports = get_credential_transports(credential)
|
||
device_name = normalize_device_name(data.get("device_name") if "device_name" in data else state.get("device_name"))
|
||
aaguid = str(verified.aaguid or "")
|
||
|
||
created_id = database.create_passkey(
|
||
"user",
|
||
int(current_user.id),
|
||
credential_id=credential_id,
|
||
public_key=public_key,
|
||
sign_count=int(verified.sign_count or 0),
|
||
device_name=device_name,
|
||
transports=transports,
|
||
aaguid=aaguid,
|
||
)
|
||
if not created_id:
|
||
return jsonify({"error": "该Passkey已绑定,或保存失败"}), 400
|
||
|
||
session.pop(_USER_PASSKEY_REGISTER_SESSION_KEY, None)
|
||
return jsonify({"success": True, "id": int(created_id), "device_name": device_name})
|
||
|
||
|
||
@api_user_bp.route("/api/user/passkeys/<int:passkey_id>", methods=["DELETE"])
|
||
@login_required
|
||
def delete_user_passkey(passkey_id):
|
||
"""删除当前用户绑定的 Passkey 设备。"""
|
||
ok = database.delete_passkey("user", int(current_user.id), int(passkey_id))
|
||
if ok:
|
||
return jsonify({"success": True})
|
||
return jsonify({"error": "设备不存在或已删除"}), 404
|
||
|
||
|
||
@api_user_bp.route("/api/user/passkeys/client-error", methods=["POST"])
|
||
@login_required
|
||
def report_user_passkey_client_error():
|
||
"""上报浏览器端 Passkey 失败详情,便于排查兼容性问题。"""
|
||
data = request.get_json(silent=True) or {}
|
||
error_name = _truncate_text(data.get("name"), 120)
|
||
error_message = _truncate_text(data.get("message"), 400)
|
||
error_code = _truncate_text(data.get("code"), 120)
|
||
ua = _truncate_text(data.get("user_agent") or request.headers.get("User-Agent", ""), 300)
|
||
stage = _truncate_text(data.get("stage"), 80)
|
||
source = _truncate_text(data.get("source"), 80)
|
||
|
||
logger.warning(
|
||
"[passkey][client-error][user] user_id=%s stage=%s source=%s name=%s code=%s message=%s ua=%s",
|
||
current_user.id,
|
||
stage or "-",
|
||
source or "-",
|
||
error_name or "-",
|
||
error_code or "-",
|
||
error_message or "-",
|
||
ua or "-",
|
||
)
|
||
return jsonify({"success": True})
|
||
|
||
|
||
@api_user_bp.route("/api/run_stats", methods=["GET"])
|
||
@login_required
|
||
def get_run_stats():
|
||
"""获取当前用户的运行统计"""
|
||
user_id = current_user.id
|
||
|
||
stats = database.get_user_run_stats(user_id)
|
||
|
||
current_running = _get_current_running_count(user_id)
|
||
|
||
return jsonify(
|
||
{
|
||
"today_completed": stats.get("completed", 0),
|
||
"current_running": current_running,
|
||
"today_failed": stats.get("failed", 0),
|
||
"today_items": stats.get("total_items", 0),
|
||
"today_attachments": stats.get("total_attachments", 0),
|
||
}
|
||
)
|
||
|
||
|
||
@api_user_bp.route("/api/kdocs/status", methods=["GET"])
|
||
@login_required
|
||
def get_kdocs_status_for_user():
|
||
"""获取金山文档在线状态(用户端简化版)"""
|
||
try:
|
||
# 检查系统是否启用了金山文档功能
|
||
cfg = database.get_system_config() or {}
|
||
kdocs_enabled = int(cfg.get("kdocs_enabled") or 0)
|
||
|
||
if not kdocs_enabled:
|
||
return jsonify({"enabled": False, "online": False, "message": "未启用"})
|
||
|
||
# 获取金山文档状态
|
||
from services.kdocs_uploader import get_kdocs_uploader
|
||
|
||
kdocs = get_kdocs_uploader()
|
||
status = kdocs.get_status()
|
||
|
||
login_required_flag = status.get("login_required", False)
|
||
last_login_ok = status.get("last_login_ok")
|
||
|
||
# 重启后首次查询时,状态可能还是 None,这里做一次轻量实时校验
|
||
if last_login_ok is None:
|
||
live_status = kdocs.refresh_login_status()
|
||
if live_status.get("success"):
|
||
logged_in = bool(live_status.get("logged_in"))
|
||
login_required_flag = not logged_in
|
||
last_login_ok = logged_in
|
||
|
||
# 判断是否在线
|
||
is_online = not login_required_flag and last_login_ok is True
|
||
|
||
return jsonify({
|
||
"enabled": True,
|
||
"online": is_online,
|
||
"message": "就绪" if is_online else "离线"
|
||
})
|
||
except Exception as e:
|
||
logger.error(f"获取金山文档状态失败: {e}")
|
||
return jsonify({"enabled": False, "online": False, "message": "获取失败"})
|