from __future__ import annotations import hashlib import os import re import secrets from datetime import datetime, timedelta from flask import Blueprint, abort, current_app, flash, jsonify, redirect, render_template, request, send_file, session, url_for from flask_login import current_user, login_required, login_user, logout_user from sqlalchemy import or_ from sqlalchemy.orm import joinedload, selectinload from .config import BASE_DIR from .email import ( EmailConfigurationError, EmailDeliveryError, password_reset_notice_email, send_transactional_email, verification_code_email, ) from .extensions import db from .models import AppSetting, EmailVerificationCode, TrustedDevice, UploadRecord, User from .prediction import PredictionError, run_prediction from .security import new_captcha from .time_utils import format_datetime_for_timezone, utc_now bp = Blueprint("main", __name__) REFERENCE_PDF_NAME = "20260630标准文本——供水管道健康状态与剩余寿命评估技术导则.pdf" TEMPLATE_EXCEL_NAME = "管道预测数据模板.xlsx" REGISTRATION_SETTING_KEY = "allow_registration" RECORDS_PER_PAGE = 10 USERS_PER_PAGE = 20 EMAIL_RE = re.compile(r"^[^\s@]+@[^\s@]+\.[^\s@]+$") EMAIL_CODE_PURPOSES = { "register", "login", "reset", "change_password", "change_email_old", "change_email_new", } EMAIL_CODE_LABELS = { "register": "完成注册", "login": "登录确认", "reset": "重置密码", "change_password": "修改密码", "change_email_old": "确认原邮箱", "change_email_new": "确认新邮箱", } TRUSTED_DEVICE_COOKIE = "trusted_device" def normal_email(value: str) -> str: return value.strip().lower() def valid_email(value: str) -> bool: return len(value) <= 254 and bool(EMAIL_RE.fullmatch(value)) def valid_password(password: str) -> bool: return ( 12 <= len(password) <= 128 and not any(character.isspace() for character in password) and any(character.islower() for character in password) and any(character.isupper() for character in password) and any(character.isdigit() for character in password) and any(not character.isalnum() for character in password) ) PASSWORD_RULE_MESSAGE = "密码须为12至128位,包含大写字母、小写字母、数字和特殊字符,且不能含空格。" def grant_fresh_authorization(user: User, purpose: str) -> None: """Grant a short, session-bound authorization after successful email MFA.""" session[f"fresh_auth_{purpose}"] = { "user_id": user.id, "auth_version": user.auth_version, "expires_at": ( utc_now() + timedelta(minutes=current_app.config["FRESH_AUTH_MINUTES"]) ).isoformat(), } def has_fresh_authorization(user: User, purpose: str) -> bool: authorization = session.get(f"fresh_auth_{purpose}") if not isinstance(authorization, dict): return False try: expires_at = datetime.fromisoformat(authorization["expires_at"]) except (KeyError, TypeError, ValueError): return False return ( authorization.get("user_id") == user.id and authorization.get("auth_version") == user.auth_version and expires_at > utc_now() ) def consume_fresh_authorization(purpose: str) -> None: session.pop(f"fresh_auth_{purpose}", None) def render_auth_template( mode: str, status_code: int = 200, captcha: str = "", auth_error_field: str | None = None, ): return render_template( "login.html", mode=mode, captcha=captcha, auth_error_field=auth_error_field, ), status_code def refresh_captcha() -> str: session["captcha"] = new_captcha() return session["captcha"] def render_auth_error( mode: str, message: str, status_code: int = 400, auth_error_field: str | None = None, ): flash(message, "error") return render_auth_template(mode, status_code, refresh_captcha(), auth_error_field) def captcha_is_valid() -> bool: value = request.form.get("captcha", "").strip().upper() return bool(value and value == session.get("captcha", "")) def require_admin() -> None: if not current_user.is_admin: abort(403) def registration_allowed() -> bool: return AppSetting.get_bool(REGISTRATION_SETTING_KEY, current_app.config["ALLOW_REGISTRATION"]) def requested_page(parameter: str = "page") -> int: try: return max(int(request.args.get(parameter, 1)), 1) except (TypeError, ValueError): return 1 def paginated_uploads(query, endpoint: str): page = requested_page() pagination = query.order_by(UploadRecord.upload_time.desc()).paginate( page=page, per_page=RECORDS_PER_PAGE, error_out=False, ) if pagination.pages and page > pagination.pages: return pagination, redirect(url_for(endpoint, page=pagination.pages)) return pagination, None def paginated_users(): search = request.args.get("q", "").strip() page = requested_page("user_page") query = User.query.filter(User.is_admin.is_(False)).options( selectinload(User.trusted_devices) ) if search: pattern = f"%{search}%" query = query.filter( or_(User.username.ilike(pattern), User.email.ilike(pattern)) ) pagination = query.order_by(User.created_at.desc(), User.id.desc()).paginate( page=page, per_page=USERS_PER_PAGE, error_out=False, ) if pagination.pages and page > pagination.pages: return pagination, search, redirect( url_for( "main.admin_dashboard", page=requested_page(), user_page=pagination.pages, q=search, ) ) return pagination, search, None def admin_dashboard_redirect(): return redirect( url_for( "main.admin_dashboard", user_page=request.form.get("user_page", 1), q=request.form.get("q", ""), ) ) def code_hash(email: str, purpose: str, code: str) -> str: return hashlib.sha256(f"{email}:{purpose}:{code}".encode()).hexdigest() def issue_email_code(email: str, purpose: str) -> str | None: """Issue an email code, returning a failure reason when it cannot be sent.""" now = utc_now() resend_at = now - timedelta(seconds=current_app.config["EMAIL_CODE_RESEND_SECONDS"]) recent = ( EmailVerificationCode.query.filter_by(email=email, purpose=purpose) .filter( EmailVerificationCode.created_at >= resend_at, EmailVerificationCode.expires_at > now, EmailVerificationCode.used_at.is_(None), ) .first() ) ip = request.remote_addr or "" hourly = EmailVerificationCode.query.filter( EmailVerificationCode.created_at >= now - timedelta(hours=1), EmailVerificationCode.requested_ip == ip, ).count() if recent: return "resend_wait" if hourly >= 20: return "rate_limited" EmailVerificationCode.query.filter_by(email=email, purpose=purpose, used_at=None).update({"used_at": now}) code = f"{secrets.randbelow(1_000_000):06d}" record = EmailVerificationCode( email=email, purpose=purpose, code_hash=code_hash(email, purpose, code), expires_at=now + timedelta(minutes=current_app.config["EMAIL_CODE_MINUTES"]), requested_ip=ip, ) db.session.add(record) db.session.commit() try: send_transactional_email( to=email, subject=f"【管道健康】{EMAIL_CODE_LABELS.get(purpose, '身份验证')}验证码", html=verification_code_email( code=code, minutes=current_app.config["EMAIL_CODE_MINUTES"], purpose=EMAIL_CODE_LABELS.get(purpose, "身份验证"), ), ) except (EmailConfigurationError, EmailDeliveryError): db.session.delete(record) db.session.commit() return "delivery_failed" return None def email_code_issue_message(reason: str) -> str: if reason == "resend_wait": return f"验证码已发送,请 {current_app.config['EMAIL_CODE_RESEND_SECONDS']} 秒后再试。" if reason == "rate_limited": return "请求过于频繁,请稍后再试。" return "验证码发送失败,请稍后重试。" def consume_email_code(email: str, purpose: str, code: str) -> bool: record = ( EmailVerificationCode.query.filter_by(email=email, purpose=purpose, used_at=None) .order_by(EmailVerificationCode.created_at.desc()) .first() ) if ( record is None or record.expires_at <= utc_now() or record.attempts >= current_app.config["EMAIL_CODE_MAX_ATTEMPTS"] ): return False record.attempts += 1 if not secrets.compare_digest(record.code_hash, code_hash(email, purpose, code.strip())): db.session.commit() return False record.used_at = utc_now() db.session.commit() return True def trusted_device_for(user: User): token = request.cookies.get(TRUSTED_DEVICE_COOKIE, "") if not token: return None record = TrustedDevice.query.filter_by( token_hash=hashlib.sha256(token.encode()).hexdigest(), user_id=user.id, ).first() if record and record.expires_at > utc_now() and record.auth_version == user.auth_version: record.last_used_at = utc_now() db.session.commit() return record return None def login_response(user: User, remember: bool, trust_device: bool = False): login_user(user, remember=remember) session["auth_version"] = user.auth_version response = redirect(url_for("main.home")) if trust_device: token = secrets.token_urlsafe(32) device = TrustedDevice( user_id=user.id, token_hash=hashlib.sha256(token.encode()).hexdigest(), expires_at=utc_now() + timedelta(days=current_app.config["TRUSTED_DEVICE_DAYS"]), auth_version=user.auth_version, ) db.session.add(device) db.session.commit() response.set_cookie( TRUSTED_DEVICE_COOKIE, token, max_age=current_app.config["TRUSTED_DEVICE_DAYS"] * 86400, secure=current_app.config["SESSION_COOKIE_SECURE"], httponly=True, samesite="Lax", ) return response def verification_page(title: str, purpose: str): return render_template( "verification.html", title=title, purpose=purpose, email=session.get("pending_email", ""), resend_seconds=current_app.config["EMAIL_CODE_RESEND_SECONDS"], ) @bp.route("/") def index(): return redirect(url_for("main.home" if current_user.is_authenticated else "main.login")) @bp.route("/login", methods=["GET", "POST"]) def login(): if current_user.is_authenticated: return redirect(url_for("main.home")) if request.method == "GET": return render_auth_template("login", captcha=refresh_captcha()) identifier, password = request.form.get("identifier", "").strip(), request.form.get("password", "") if not captcha_is_valid(): return render_auth_error("login", "图形验证码错误", auth_error_field="captcha") user = User.query.filter( or_(User.username == identifier, User.email == normal_email(identifier)) ).first() if not user or not user.is_active_account or not user.check_password(password): return render_auth_error("login", "用户名、邮箱或密码错误") remember = bool(request.form.get("remember")) if trusted_device_for(user): return login_response(user, remember) session.update(pending_email=user.email, pending_user_id=user.id, pending_remember=remember, pending_purpose="login") if reason := issue_email_code(user.email, "login"): if reason == "resend_wait": flash("邮箱验证码已发送,请输入验证码完成二次认证。", "info") return redirect(url_for("main.verify_email", purpose="login")) return render_auth_error( "login", email_code_issue_message(reason), 503 if reason == "delivery_failed" else 429, ) return redirect(url_for("main.verify_email", purpose="login")) @bp.route("/register", methods=["GET", "POST"]) def register(): if request.method == "GET": return render_auth_template("register", captcha=refresh_captcha()) username, email, password = request.form.get("username", "").strip(), normal_email(request.form.get("email", "")), request.form.get("password", "") if not captcha_is_valid(): return render_auth_error("register", "图形验证码错误", auth_error_field="captcha") if not registration_allowed(): return render_auth_error("register", "当前未开放自助注册,请联系管理员。", 403) if not username or len(username) > 100: return render_auth_error("register", "显示名不能为空且不能超过100个字符", auth_error_field="username") if not valid_email(email): return render_auth_error("register", "请输入有效的邮箱地址", auth_error_field="email") if not valid_password(password): return render_auth_error("register", PASSWORD_RULE_MESSAGE, auth_error_field="password") if User.query.filter((User.username == username) | (User.email == email)).first(): return render_auth_error("register", "显示名或邮箱已被使用") user = User(username=username, email=email, is_admin=False, is_active_account=False) user.set_password(password); db.session.add(user); db.session.commit() session.update(pending_email=email, pending_user_id=user.id, pending_purpose="register") if reason := issue_email_code(email, "register"): return render_auth_error( "register", email_code_issue_message(reason), 503 if reason == "delivery_failed" else 429, ) return redirect(url_for("main.verify_email", purpose="register")) @bp.route("/verify/", methods=["GET", "POST"]) def verify_email(purpose: str): if purpose not in EMAIL_CODE_PURPOSES or session.get("pending_purpose") != purpose: abort(400) verification_title = "邮箱二次认证" if purpose == "login" else "邮箱验证" if request.method == "GET": return verification_page(verification_title, purpose) email = session.get("pending_email", "") if not consume_email_code(email, purpose, request.form.get("code", "")): flash("邮箱验证码无效、过期或尝试次数已用尽。", "error"); return verification_page(verification_title, purpose), 400 user = db.session.get(User, session.get("pending_user_id")) if not user or (purpose != "change_email_new" and user.email != email): abort(400) if purpose == "register": user.email_verified_at = utc_now() user.is_active_account = True db.session.commit() session.clear() flash("邮箱验证成功,欢迎使用系统。", "info") return login_response(user, remember=False) if purpose == "login": remember = bool(session.get("pending_remember")) trust_device = bool(request.form.get("trust_device")) session.clear() return login_response(user, remember, trust_device=trust_device) if purpose == "reset": grant_fresh_authorization(user, "password_reset") return redirect(url_for("main.set_password")) if purpose == "change_email_old": new_email = session.get("new_email", "") session.update(pending_email=new_email, pending_purpose="change_email_new") if reason := issue_email_code(new_email, "change_email_new"): flash(email_code_issue_message(reason), "error"); return redirect(url_for("main.account_security")) return redirect(url_for("main.verify_email", purpose="change_email_new")) if purpose == "change_email_new": if User.query.filter(User.email == email, User.id != user.id).first(): flash("该邮箱已被使用。", "error"); return redirect(url_for("main.account_security")) user.email = email; user.email_verified_at = utc_now(); user.revoke_authentication(); TrustedDevice.query.filter_by(user_id=user.id).delete(); db.session.commit(); logout_user(); session.clear(); flash("邮箱已更新,请重新登录。", "info"); return redirect(url_for("main.login")) grant_fresh_authorization(user, "password_change") return redirect(url_for("main.account_security")) @bp.route("/verify//resend", methods=["POST"]) def resend_code(purpose: str): if purpose not in EMAIL_CODE_PURPOSES or session.get("pending_purpose") != purpose: abort(400) if reason := issue_email_code(session.get("pending_email", ""), purpose): flash(email_code_issue_message(reason), "error") else: flash("验证码已发送,请查收邮箱。", "info") return redirect(url_for("main.verify_email", purpose=purpose)) @bp.route("/forgot-password", methods=["GET", "POST"]) def forgot_password(): if request.method == "GET": return render_template("forgot_password.html") email = normal_email(request.form.get("email", "")); user = User.query.filter_by(email=email, is_active_account=True).first() if user: session.update(pending_email=email, pending_user_id=user.id, pending_purpose="reset") reason = issue_email_code(email, "reset") if reason in {None, "resend_wait"}: if reason == "resend_wait": flash("邮箱验证码已发送,请直接输入验证码继续。", "info") return redirect(url_for("main.verify_email", purpose="reset")) flash(email_code_issue_message(reason), "error") return redirect(url_for("main.forgot_password")) flash("若该邮箱已注册,验证码将发送至邮箱。", "info") return redirect(url_for("main.login")) @bp.route("/set-password", methods=["GET", "POST"]) def set_password(): authorization = session.get("fresh_auth_password_reset") user = db.session.get(User, authorization.get("user_id")) if isinstance(authorization, dict) else None if not user or not has_fresh_authorization(user, "password_reset"): consume_fresh_authorization("password_reset") return redirect(url_for("main.forgot_password")) if request.method == "GET": return render_template("set_password.html", title="设置新密码", action=url_for("main.set_password")) password, confirm = request.form.get("password", ""), request.form.get("password_confirm", "") if not valid_password(password) or password != confirm: flash(f"{PASSWORD_RULE_MESSAGE} 两次输入必须一致。", "error"); return render_template("set_password.html", title="设置新密码", action=url_for("main.set_password")), 400 user.set_password(password); user.revoke_authentication(); TrustedDevice.query.filter_by(user_id=user.id).delete(); db.session.commit(); consume_fresh_authorization("password_reset"); session.clear(); flash("密码已重置,请重新登录。", "info"); return redirect(url_for("main.login")) @bp.route("/account/security", methods=["GET", "POST"]) @login_required def account_security(): if request.method == "GET": return render_template( "account_security.html", password_change_authorized=has_fresh_authorization( current_user, "password_change", ), ) action = request.form.get("action") if action == "revoke_devices": if not current_user.check_password(request.form.get("current_password", "")): flash("当前密码不正确,未撤销受信设备。", "error") return redirect(url_for("main.account_security")) current_user.revoke_authentication(); TrustedDevice.query.filter_by(user_id=current_user.id).delete(); db.session.commit(); logout_user(); flash("所有受信设备已撤销,请重新登录。", "info"); return redirect(url_for("main.login")) if not current_user.check_password(request.form.get("current_password", "")): flash("当前密码不正确。", "error"); return redirect(url_for("main.account_security")) if action == "change_email": new_email = normal_email(request.form.get("new_email", "")) if not valid_email(new_email) or User.query.filter_by(email=new_email).first(): flash("请输入未被使用的有效邮箱地址。", "error"); return redirect(url_for("main.account_security")) session.update(pending_email=current_user.email, pending_user_id=current_user.id, pending_purpose="change_email_old", new_email=new_email) if reason := issue_email_code(current_user.email, "change_email_old"): flash(email_code_issue_message(reason), "error"); return redirect(url_for("main.account_security")) return redirect(url_for("main.verify_email", purpose="change_email_old")) session.update(pending_email=current_user.email, pending_user_id=current_user.id, pending_purpose="change_password") if reason := issue_email_code(current_user.email, "change_password"): flash(email_code_issue_message(reason), "error"); return redirect(url_for("main.account_security")) return redirect(url_for("main.verify_email", purpose="change_password")) @bp.route("/account/change-password", methods=["POST"]) @login_required def change_password(): if not has_fresh_authorization(current_user, "password_change"): consume_fresh_authorization("password_change") abort(403) password, confirm = request.form.get("password", ""), request.form.get("password_confirm", "") if not valid_password(password) or password != confirm or current_user.check_password(password): flash(f"{PASSWORD_RULE_MESSAGE} 两次输入必须一致,且不能与当前密码相同。", "error"); return redirect(url_for("main.account_security")) current_user.set_password(password); current_user.revoke_authentication(); TrustedDevice.query.filter_by(user_id=current_user.id).delete(); db.session.commit(); consume_fresh_authorization("password_change"); logout_user(); flash("密码已更新,请重新登录。", "info"); return redirect(url_for("main.login")) @bp.route("/logout", methods=["POST"]) @login_required def logout(): logout_user(); return redirect(url_for("main.login")) @bp.route("/home") @login_required def home(): return render_template("home.html") @bp.route("/history") @login_required def history_page(): pagination, page_redirect = paginated_uploads(UploadRecord.query.filter_by(user_id=current_user.id), "main.history_page") return page_redirect or render_template("history.html", pagination=pagination, records=pagination.items) @bp.route("/admin") @login_required def admin_dashboard(): require_admin() pagination, page_redirect = paginated_uploads( UploadRecord.query.options(joinedload(UploadRecord.user)), "main.admin_dashboard", ) user_pagination, user_search, user_page_redirect = paginated_users() active_device_counts = { user.id: sum( device.expires_at > utc_now() and device.auth_version == user.auth_version for device in user.trusted_devices ) for user in user_pagination.items } if page_redirect or user_page_redirect: return page_redirect or user_page_redirect return render_template( "admin.html", pagination=pagination, records=pagination.items, user_pagination=user_pagination, user_search=user_search, active_device_counts=active_device_counts, registration_allowed=registration_allowed(), ) @bp.route("/admin/registration", methods=["POST"]) @login_required def update_registration_setting(): require_admin(); enabled = request.form.get("allow_registration") == "on"; AppSetting.set_bool(REGISTRATION_SETTING_KEY, enabled); db.session.commit(); flash("已开放用户自助注册" if enabled else "已关闭用户自助注册", "info"); return redirect(url_for("main.admin_dashboard")) @bp.route("/admin/users//password-reset", methods=["POST"]) @login_required def admin_password_reset(user_id: int): require_admin() user = db.session.get(User, user_id) if not user or user.is_admin: abort(404) try: send_transactional_email( to=user.email, subject="【管道健康】密码重置通知", html=password_reset_notice_email( username=user.username, reset_url=url_for("main.forgot_password", _external=True), ), ) except (EmailConfigurationError, EmailDeliveryError): flash("邮件发送失败,请稍后重试。", "error") else: flash("密码重置通知已发送至用户邮箱。", "info") return admin_dashboard_redirect() @bp.route("/admin/users//trusted-devices/revoke", methods=["POST"]) @login_required def admin_revoke_trusted_devices(user_id: int): require_admin() user = db.session.get(User, user_id) if user is None or user.is_admin: abort(404) user.revoke_authentication() TrustedDevice.query.filter_by(user_id=user.id).delete() db.session.commit() flash(f"已撤销 {user.username} 的所有受信设备。", "info") return admin_dashboard_redirect() @bp.route("/admin/users//account-status", methods=["POST"]) @login_required def admin_update_account_status(user_id: int): require_admin() user = db.session.get(User, user_id) if user is None or user.is_admin: abort(404) user.is_active_account = request.form.get("is_active") == "true" user.revoke_authentication() TrustedDevice.query.filter_by(user_id=user.id).delete() db.session.commit() message = "已启用账号" if user.is_active_account else "已停用账号并撤销所有会话" flash(f"{user.username}:{message}。", "info") return admin_dashboard_redirect() @bp.route("/download//") @login_required def download_file(record_id: int, file_type: str): record = db.session.get(UploadRecord, record_id) if record is None or not (current_user.is_admin or current_user.id == record.user_id): abort(404 if record is None else 403) path = {"original": record.saved_path, "prediction": record.prediction_path}.get(file_type) if path is None or not os.path.exists(path): abort(404) return send_file(path, as_attachment=True) @bp.route("/download_template") def download_template(): return send_file(BASE_DIR / "example.xlsx", as_attachment=True, download_name=TEMPLATE_EXCEL_NAME) @bp.route("/reference_pdf") @login_required def reference_pdf(): return send_file(BASE_DIR / REFERENCE_PDF_NAME, as_attachment=False, download_name=REFERENCE_PDF_NAME, mimetype="application/pdf") @bp.route("/reference") @login_required def reference_page(): return render_template("reference.html") @bp.route("/result") @login_required def result_page(): return render_template("result.html", result=session.get("last_result")) @bp.route("/predict", methods=["POST"]) @login_required def predict(): model = current_app.config.get("RSF_MODEL"); uploaded = request.files.get("file") if model is None: return jsonify({"error": "模型未成功加载,请检查模型文件。"}), 500 if uploaded is None or uploaded.filename == "": return jsonify({"error": "未选择文件"}), 400 try: artifacts = run_prediction(uploaded, int(current_user.id), model) except PredictionError as exc: return jsonify({"error": exc.message}), exc.status_code record = UploadRecord(user_id=current_user.id, original_filename=artifacts.original_filename, saved_path=str(artifacts.saved_path), prediction_path=str(artifacts.excel_path), image_path=str(artifacts.image_path)); db.session.add(record); db.session.commit() result = {"original_filename": artifacts.original_filename, "generated_at": format_datetime_for_timezone(record.upload_time, current_app.config["APP_TIMEZONE"]), "image_url": url_for("static", filename=f"images/{artifacts.image_filename}"), "excel_url": url_for("main.download_file", record_id=record.id, file_type="prediction"), "result_url": url_for("main.result_page"), "sample_count": int(artifacts.sample_count), "summary_rows": artifacts.summary_rows[:6], "analysis_text": artifacts.analysis_text, "model_version": artifacts.model_version}; session["last_result"] = result return jsonify({"message": "预测成功", "image_url": result["image_url"], "excel_url": result["excel_url"], "result_url": result["result_url"], "sample_count": result["sample_count"], "original_filename": artifacts.original_filename, "model_version": result["model_version"]})