#!/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//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/") 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消费失败: 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/", 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": "获取失败"})