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, registration_invitation_email, send_transactional_email, verification_code_email, ) from .extensions import db from .models import ( AppSetting, EmailVerificationCode, PasswordResetToken, RegistrationInvitation, 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", "change_password", "change_email_old", "change_email_new", } EMAIL_CODE_LABELS = { "register": "完成注册", "login": "登录确认", "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 masked_email(value: str) -> str: """Return an email suitable for confirmation screens without exposing it.""" local_part, separator, domain = normal_email(value).partition("@") if not separator or not local_part or not domain: return "已绑定邮箱" if len(local_part) == 1: visible_local = "*" elif len(local_part) == 2: visible_local = f"{local_part[0]}*" elif len(local_part) <= 4: visible_local = f"{local_part[0]}**{local_part[-1]}" else: visible_local = f"{local_part[:2]}****{local_part[-2:]}" return f"{visible_local}@{domain}" def token_hash(token: str) -> str: return hashlib.sha256(token.encode()).hexdigest() def issue_password_reset_token(user: User, created_by: User) -> str: """Invalidate outstanding links for a user and create a fresh one-time reset link.""" now = utc_now() PasswordResetToken.query.filter_by(user_id=user.id, used_at=None).update( {"expires_at": now}, synchronize_session=False ) token = secrets.token_urlsafe(32) db.session.add( PasswordResetToken( user_id=user.id, created_by_id=created_by.id, token_hash=token_hash(token), expires_at=now + timedelta(minutes=current_app.config["PASSWORD_RESET_TOKEN_MINUTES"]), ) ) db.session.commit() return token def active_password_reset_token(token: str) -> PasswordResetToken | None: return PasswordResetToken.query.filter( PasswordResetToken.token_hash == token_hash(token), PasswordResetToken.used_at.is_(None), PasswordResetToken.expires_at > utc_now(), ).first() def issue_registration_invitation(email: str, created_by: User) -> str: """Create a fresh invitation and invalidate any previous unused invitation.""" now = utc_now() RegistrationInvitation.query.filter_by(email=email, used_at=None).update( {"expires_at": now}, synchronize_session=False ) token = secrets.token_urlsafe(32) db.session.add( RegistrationInvitation( email=email, created_by_id=created_by.id, token_hash=token_hash(token), expires_at=now + timedelta(minutes=current_app.config["PASSWORD_RESET_TOKEN_MINUTES"]), ) ) db.session.commit() return token def active_registration_invitation(token: str) -> RegistrationInvitation | None: return RegistrationInvitation.query.filter( RegistrationInvitation.token_hash == token_hash(token), RegistrationInvitation.used_at.is_(None), RegistrationInvitation.expires_at > utc_now(), ).first() def claim_token(model, token_id: int) -> bool: """Atomically consume a still-valid, one-time link token.""" return ( model.query.filter( model.id == token_id, model.used_at.is_(None), model.expires_at > utc_now(), ).update({"used_at": utc_now()}, synchronize_session=False) == 1 ) def revoke_user_authentication(user: User) -> None: """Invalidate browser sessions and trusted-device cookies for a user.""" user.revoke_authentication() TrustedDevice.query.filter_by(user_id=user.id).delete() 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 valid_new_password(user: User, password: str, confirmation: str) -> bool: return ( valid_password(password) and password == confirmation and not user.check_password(password) ) 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=masked_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 == "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(); revoke_user_authentication(user); 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: token = issue_password_reset_token(user, user) try: send_transactional_email( to=user.email, subject="【管道健康】重置密码", html=password_reset_notice_email( username=user.username, reset_url=url_for("main.reset_password_with_token", token=token, _external=True), minutes=current_app.config["PASSWORD_RESET_TOKEN_MINUTES"], ), ) except (EmailConfigurationError, EmailDeliveryError): current_app.logger.exception("密码重置邮件发送失败") flash("若该邮箱已注册,重置链接将发送至邮箱。", "info") return redirect(url_for("main.forgot_password")) @bp.route("/reset-password/", methods=["GET", "POST"]) def reset_password_with_token(token: str): reset_token = active_password_reset_token(token) if reset_token is None or not reset_token.user.is_active_account: flash("该重置链接无效、已过期或已使用。请重新申请。", "error") return redirect(url_for("main.forgot_password")) if request.method == "GET": return render_template("reset_password.html", token=token) password = request.form.get("password", "") confirm = request.form.get("password_confirm", "") if not valid_new_password(reset_token.user, password, confirm): flash(f"{PASSWORD_RULE_MESSAGE} 两次输入必须一致,且不能与当前密码相同。", "error") return render_template("reset_password.html", token=token), 400 if not claim_token(PasswordResetToken, reset_token.id): db.session.rollback() flash("该重置链接已失效,请重新申请。", "error") return redirect(url_for("main.forgot_password")) reset_token.user.set_password(password) revoke_user_authentication(reset_token.user) db.session.commit() flash("密码已重置,请使用新密码登录。", "info") return redirect(url_for("main.login")) @bp.route("/invite/", methods=["GET", "POST"]) def accept_registration_invitation(token: str): invitation = active_registration_invitation(token) if invitation is None: flash("该注册链接无效、已过期或已使用。请联系管理员重新邀请。", "error") return redirect(url_for("main.login")) if User.query.filter_by(email=invitation.email).first(): flash("该邮箱已注册,请直接登录或联系管理员。", "error") return redirect(url_for("main.login")) if request.method == "GET": return render_template("accept_invitation.html", token=token, email=masked_email(invitation.email)) username = request.form.get("username", "").strip() password = request.form.get("password", "") confirm = request.form.get("password_confirm", "") if not username or len(username) > 100: flash("显示名不能为空且不能超过100个字符。", "error") return render_template("accept_invitation.html", token=token, email=masked_email(invitation.email)), 400 if User.query.filter(User.username == username).first(): flash("该显示名已被使用。", "error") return render_template("accept_invitation.html", token=token, email=masked_email(invitation.email)), 400 if not valid_password(password) or password != confirm: flash(f"{PASSWORD_RULE_MESSAGE} 两次输入必须一致。", "error") return render_template("accept_invitation.html", token=token, email=masked_email(invitation.email)), 400 if not claim_token(RegistrationInvitation, invitation.id): db.session.rollback() flash("该注册链接已失效,请联系管理员重新邀请。", "error") return redirect(url_for("main.login")) user = User( username=username, email=invitation.email, email_verified_at=utc_now(), is_active_account=True, ) user.set_password(password) db.session.add(user) db.session.commit() session.clear() flash("注册成功,欢迎使用系统。", "info") return login_response(user, remember=False) @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")) revoke_user_authentication(current_user); 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); revoke_user_authentication(current_user); 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/invitations", methods=["POST"]) @login_required def admin_send_registration_invitation(): require_admin() email = normal_email(request.form.get("email", "")) if not valid_email(email): flash("请输入有效的受邀邮箱。", "error") return admin_dashboard_redirect() if User.query.filter_by(email=email).first(): flash("该邮箱已注册,不能重复发送邀请。", "error") return admin_dashboard_redirect() token = issue_registration_invitation(email, current_user) try: send_transactional_email( to=email, subject="【管道健康】注册邀请", html=registration_invitation_email( invitation_url=url_for("main.accept_registration_invitation", token=token, _external=True), minutes=current_app.config["PASSWORD_RESET_TOKEN_MINUTES"], ), ) except (EmailConfigurationError, EmailDeliveryError): flash("邀请链接已生成,但邮件发送失败,请检查邮件服务配置后重新发送。", "error") else: flash("注册链接已发送至受邀邮箱。", "info") return admin_dashboard_redirect() @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) token = issue_password_reset_token(user, current_user) try: send_transactional_email( to=user.email, subject="【管道健康】重置密码", html=password_reset_notice_email( username=user.username, reset_url=url_for("main.reset_password_with_token", token=token, _external=True), minutes=current_app.config["PASSWORD_RESET_TOKEN_MINUTES"], ), ) 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) revoke_user_authentication(user) 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" revoke_user_authentication(user) 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"]})