Files
pipeline-lifetime/app/routes.py
T

496 lines
24 KiB
Python

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.orm import joinedload
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
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 = ""):
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=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 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", ""),
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())
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", PASSWORD_RULE_MESSAGE)
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/<purpose>", 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":
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 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"))
grant_fresh_authorization(user, "password_change")
return redirect(url_for("main.account_security"))
@bp.route("/verify/<purpose>/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():
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 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 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")
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/<int:user_id>/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=password_reset_notice_email(
username=user.username,
reset_url=url_for("main.forgot_password", _external=True),
),
)
except (EmailConfigurationError, EmailDeliveryError):
return jsonify({"error": "邮件发送失败"}), 503
return jsonify({"message": "密码重置通知已发送至用户邮箱"})
@bp.route("/download/<int:record_id>/<file_type>")
@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"]})