from __future__ import annotations import hashlib import os import re import secrets from datetime import 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.orm import joinedload from .config import BASE_DIR from .email import EmailConfigurationError, EmailDeliveryError, send_transactional_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 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 def render_auth_template(mode: str, status_code: int = 200, captcha: str = ""): return render_template("login.html", mode=mode, captcha=captcha), 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): flash(message, "error") return render_auth_template(mode, status_code, refresh_captcha()) 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() -> int: try: return max(int(request.args.get("page", 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 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) -> bool: 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) .first() ) ip = request.remote_addr or "" hourly = EmailVerificationCode.query.filter( EmailVerificationCode.created_at >= now - timedelta(hours=1), EmailVerificationCode.requested_ip == ip, ).count() if recent or hourly >= 20: return False 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=( "

你的验证码是:

" f"

{code}

" f"

验证码 {current_app.config['EMAIL_CODE_MINUTES']} 分钟内有效,请勿向他人透露。

" ), ) except (EmailConfigurationError, EmailDeliveryError): db.session.delete(record) db.session.commit() return False return True 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", "")) @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()) email, password = normal_email(request.form.get("email", "")), request.form.get("password", "") if not captcha_is_valid(): return render_auth_error("login", "验证码错误") user = User.query.filter_by(email=email).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=email, pending_user_id=user.id, pending_remember=remember, pending_purpose="login") if not issue_email_code(email, "login"): return render_auth_error("login", "验证码发送失败,请稍后重试", 503) 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", "验证码错误") if not registration_allowed(): return render_auth_error("register", "当前未开放自助注册,请联系管理员。", 403) if not username or len(username) > 100: return render_auth_error("register", "显示名不能为空且不能超过100个字符") if not valid_email(email): return render_auth_error("register", "请输入有效的邮箱地址") if not valid_password(password): return render_auth_error("register", "密码长度应为12至128位") 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 not issue_email_code(email, "register"): return render_auth_error("register", "验证码发送失败,请稍后重试", 503) 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) if request.method == "GET": return verification_page("邮箱验证", purpose) email = session.get("pending_email", "") if not consume_email_code(email, purpose, request.form.get("code", "")): flash("验证码无效、过期或尝试次数已用尽。", "error"); return verification_page("邮箱验证", 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 redirect(url_for("main.login")) if purpose == "login": remember = bool(session.get("pending_remember")); session.clear(); return login_response(user, remember, trust_device=True) if purpose == "reset": session["reset_verified_user_id"] = user.id; 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 not issue_email_code(new_email, "change_email_new"): flash("新邮箱验证码发送失败,请稍后重试。", "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")) session["password_change_verified"] = True; 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 not issue_email_code(session.get("pending_email", ""), purpose): flash("发送过于频繁或服务暂不可用,请稍后再试。", "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") issue_email_code(email, "reset") flash("若该邮箱已注册,验证码将发送至邮箱。", "info") return redirect(url_for("main.login")) @bp.route("/set-password", methods=["GET", "POST"]) def set_password(): user = db.session.get(User, session.get("reset_verified_user_id")) if not user: 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("密码应为12至128位,且两次输入一致。", "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(); 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") action = request.form.get("action") if action == "revoke_devices": 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 not issue_email_code(current_user.email, "change_email_old"): flash("验证码发送失败,请稍后重试。", "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 not issue_email_code(current_user.email, "change_password"): flash("验证码发送失败,请稍后重试。", "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 session.pop("password_change_verified", False): 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("密码应为12至128位、两次一致且不能与当前密码相同。", "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(); 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") return page_redirect or render_template("admin.html", pagination=pagination, records=pagination.items, password_reset_users=User.query.filter(User.is_admin.is_(False)).order_by(User.username).all(), 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: return jsonify({"error": "用户不存在或不支持此操作"}), 404 try: send_transactional_email( to=user.email, subject="供水管道健康评估系统:请重置密码", html=f"

{user.username},管理员已要求你重置密码。

请访问 找回密码,系统会将一次性验证码发送到本邮箱。

", ) except (EmailConfigurationError, EmailDeliveryError): return jsonify({"error": "邮件发送失败"}), 503 return jsonify({"message": "密码重置通知已发送至用户邮箱"}) @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"]})