feat: add email-based authentication

This commit is contained in:
2026-08-03 18:49:08 +08:00
parent 82a8b4187a
commit f2209a0e00
19 changed files with 641 additions and 1555 deletions
+18 -8
View File
@@ -2,14 +2,17 @@ from __future__ import annotations
import logging
from flask import Flask, abort, jsonify, request
from flask import Flask, abort, jsonify, request, redirect, session
from flask_login import current_user, logout_user
from .config import Config, DATA_DIR, ensure_dirs
from .extensions import db, login_manager
from .models import AppSetting, User
from .migrations import upgrade_schema
from .prediction import FEATURES, load_model
from .security import csrf_token, validate_csrf_token
from .time_utils import current_year_for_timezone, format_datetime_for_timezone
from .time_utils import utc_now
def create_app(config_object: type[Config] = Config, *, load_model_on_start: bool = True) -> Flask:
@@ -34,7 +37,7 @@ def create_app(config_object: type[Config] = Config, *, load_model_on_start: boo
app.register_blueprint(bp)
with app.app_context():
db.create_all()
upgrade_schema()
init_admin_user(app)
if load_model_on_start:
@@ -59,23 +62,26 @@ def configure_logging() -> None:
def init_admin_user(app: Flask) -> None:
admin_username = app.config["ADMIN_USERNAME"]
admin_password = app.config["ADMIN_PASSWORD"]
if admin_password:
admin_email = app.config["ADMIN_EMAIL"]
if admin_password and admin_email:
admin = User.query.filter_by(username=admin_username).first()
if admin is None:
admin = User(username=admin_username, is_admin=True)
admin = User(username=admin_username, email=admin_email, is_admin=True, is_active_account=True)
admin.set_password(admin_password)
db.session.add(admin)
else:
admin.is_admin = True
if not admin.check_password(admin_password):
admin.set_password(admin_password)
if not admin.email:
admin.email = admin_email
admin.email_verified_at = utc_now()
admin.is_active_account = True
db.session.commit()
elif not User.query.filter_by(is_admin=True).first():
logging.warning("未设置 ADMIN_PASSWORD,跳过自动创建管理员账号。")
logging.warning("未设置 ADMIN_PASSWORD 或 ADMIN_EMAIL,跳过自动创建管理员账号。")
default_admin = User.query.filter_by(username="admin", is_admin=True).first()
if default_admin and default_admin.check_password("admin123"):
logging.warning("检测到默认管理员密码 admin123,请立即通过 ADMIN_PASSWORD 更新。")
logging.warning("检测到默认管理员密码 admin123,请通过账户安全页立即更新。")
def register_app_hooks(app: Flask) -> None:
@@ -114,6 +120,10 @@ def register_app_hooks(app: Flask) -> None:
@app.before_request
def protect_csrf():
if current_user.is_authenticated and session.get("auth_version") != current_user.auth_version:
logout_user()
session.clear()
return redirect("/login")
if request.method not in {"POST", "PUT", "PATCH", "DELETE"}:
return None
if validate_csrf_token():
+24
View File
@@ -4,6 +4,8 @@ import os
import secrets
from pathlib import Path
from dotenv import dotenv_values
BASE_DIR = Path(__file__).resolve().parent.parent
DATA_DIR = BASE_DIR / "data"
STATIC_DIR = BASE_DIR / "static"
@@ -11,6 +13,20 @@ UPLOAD_DIR = BASE_DIR / "uploads"
IMAGE_DIR = STATIC_DIR / "images"
def load_local_environment() -> None:
"""加载本地配置,优先级为系统环境变量 > .env.local > .env。"""
values = dotenv_values(BASE_DIR / ".env")
overrides = dotenv_values(BASE_DIR / ".env.local")
# .env.local 中的空值表示“沿用 .env”,避免本机模板清空密钥。
values.update({key: value for key, value in overrides.items() if value})
for key, value in values.items():
if value:
os.environ.setdefault(key, value)
load_local_environment()
def env_int(name: str, default: int) -> int:
try:
return int(os.environ.get(name, str(default)))
@@ -42,6 +58,7 @@ class Config:
MAX_CONTENT_LENGTH = env_int("MAX_UPLOAD_BYTES", 16 * 1024 * 1024)
SESSION_COOKIE_HTTPONLY = True
SESSION_COOKIE_SAMESITE = "Lax"
SESSION_COOKIE_SECURE = env_bool("SESSION_COOKIE_SECURE", APP_ENV == "production")
FUSION_MODEL_CORE_DIR = os.environ.get(
"FUSION_MODEL_CORE_DIR",
str(BASE_DIR / "model_core"),
@@ -49,8 +66,15 @@ class Config:
ALLOW_REGISTRATION = env_bool("ALLOW_REGISTRATION", False)
ADMIN_USERNAME = os.environ.get("ADMIN_USERNAME", "admin").strip() or "admin"
ADMIN_PASSWORD = os.environ.get("ADMIN_PASSWORD")
ADMIN_EMAIL = os.environ.get("ADMIN_EMAIL", "").strip().lower()
APP_TIMEZONE = os.environ.get("APP_TIMEZONE", "Asia/Shanghai").strip() or "Asia/Shanghai"
PASSWORD_RESET_TOKEN_MINUTES = env_int("PASSWORD_RESET_TOKEN_MINUTES", 30)
RESEND_API_KEY = os.environ.get("RESEND_API_KEY", "").strip()
RESEND_FROM_EMAIL = os.environ.get("RESEND_FROM_EMAIL", "").strip()
EMAIL_CODE_MINUTES = env_int("EMAIL_CODE_MINUTES", 10)
EMAIL_CODE_RESEND_SECONDS = env_int("EMAIL_CODE_RESEND_SECONDS", 60)
EMAIL_CODE_MAX_ATTEMPTS = env_int("EMAIL_CODE_MAX_ATTEMPTS", 5)
TRUSTED_DEVICE_DAYS = env_int("TRUSTED_DEVICE_DAYS", 30)
def ensure_dirs() -> None:
+39
View File
@@ -12,6 +12,10 @@ class User(UserMixin, db.Model):
id = db.Column(db.Integer, primary_key=True)
username = db.Column(db.String(100), unique=True, nullable=False)
email = db.Column(db.String(254), unique=True, index=True)
email_verified_at = db.Column(db.DateTime)
is_active_account = db.Column(db.Boolean, default=False, nullable=False)
auth_version = db.Column(db.Integer, default=1, nullable=False)
password_hash = db.Column(db.String(255), nullable=False)
is_admin = db.Column(db.Boolean, default=False, nullable=False)
created_at = db.Column(db.DateTime, default=utc_now)
@@ -22,6 +26,13 @@ class User(UserMixin, db.Model):
def check_password(self, password: str) -> bool:
return check_password_hash(self.password_hash, password)
@property
def is_active(self) -> bool:
return self.is_active_account
def revoke_authentication(self) -> None:
self.auth_version += 1
class PasswordResetToken(db.Model):
__tablename__ = "password_reset_tokens"
@@ -42,6 +53,34 @@ class PasswordResetToken(db.Model):
created_by = db.relationship("User", foreign_keys=[created_by_id])
class EmailVerificationCode(db.Model):
__tablename__ = "email_verification_codes"
id = db.Column(db.Integer, primary_key=True)
email = db.Column(db.String(254), nullable=False, index=True)
purpose = db.Column(db.String(32), nullable=False, index=True)
code_hash = db.Column(db.String(64), nullable=False)
expires_at = db.Column(db.DateTime, nullable=False)
attempts = db.Column(db.Integer, default=0, nullable=False)
used_at = db.Column(db.DateTime)
requested_ip = db.Column(db.String(64))
created_at = db.Column(db.DateTime, default=utc_now, nullable=False, index=True)
class TrustedDevice(db.Model):
__tablename__ = "trusted_devices"
id = db.Column(db.Integer, primary_key=True)
user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False, index=True)
token_hash = db.Column(db.String(64), unique=True, nullable=False, index=True)
expires_at = db.Column(db.DateTime, nullable=False, index=True)
auth_version = db.Column(db.Integer, nullable=False)
created_at = db.Column(db.DateTime, default=utc_now, nullable=False)
last_used_at = db.Column(db.DateTime, default=utc_now, nullable=False)
user = db.relationship("User", backref=db.backref("trusted_devices", lazy=True))
class UploadRecord(db.Model):
__tablename__ = "upload_records"
+295 -316
View File
@@ -1,29 +1,19 @@
from __future__ import annotations
import os
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 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, PasswordResetToken, UploadRecord, User
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
@@ -33,6 +23,36 @@ REFERENCE_PDF_NAME = "20260630标准文本——供水管道健康状态与剩
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 = ""):
@@ -50,44 +70,8 @@ def render_auth_error(mode: str, message: str, status_code: int = 400):
def captcha_is_valid() -> bool:
captcha_input = request.form.get("captcha", "").strip().upper()
return bool(captcha_input and captcha_input == session.get("captcha", ""))
def password_reset_token_hash(token: str) -> str:
return hashlib.sha256(token.encode("utf-8")).hexdigest()
def password_reset_expiry() -> datetime:
minutes = max(int(current_app.config["PASSWORD_RESET_TOKEN_MINUTES"]), 1)
return utc_now() + timedelta(minutes=minutes)
def format_app_datetime(value: datetime | None) -> str:
return format_datetime_for_timezone(value, current_app.config["APP_TIMEZONE"])
def active_password_reset_token(token: str) -> PasswordResetToken | None:
reset_token = PasswordResetToken.query.filter_by(
token_hash=password_reset_token_hash(token)
).first()
if (
reset_token is None
or reset_token.used_at is not None
or reset_token.expires_at <= utc_now()
):
return None
return reset_token
def render_password_reset_unavailable(status_code: int = 400):
flash("重置链接无效或已过期,请联系管理员重新生成。", "error")
return render_template(
"password_reset.html",
reset_token=None,
token="",
token_available=False,
), status_code
value = request.form.get("captcha", "").strip().upper()
return bool(value and value == session.get("captcha", ""))
def require_admin() -> None:
@@ -96,10 +80,7 @@ def require_admin() -> None:
def registration_allowed() -> bool:
return AppSetting.get_bool(
REGISTRATION_SETTING_KEY,
current_app.config["ALLOW_REGISTRATION"],
)
return AppSetting.get_bool(REGISTRATION_SETTING_KEY, current_app.config["ALLOW_REGISTRATION"])
def requested_page() -> int:
@@ -111,333 +92,331 @@ def requested_page() -> int:
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)
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 password_reset_users():
return (
User.query.filter(User.is_admin.is_(False))
.order_by(User.username.asc(), User.id.asc())
.all()
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=(
"<p>你的验证码是:</p>"
f"<p style='font-size:28px;letter-spacing:6px'><strong>{code}</strong></p>"
f"<p>验证码 {current_app.config['EMAIL_CODE_MINUTES']} 分钟内有效,请勿向他人透露。</p>"
),
)
except (EmailConfigurationError, EmailDeliveryError):
db.session.delete(record)
db.session.commit()
return False
return True
def prediction_result_payload(artifacts, record: UploadRecord) -> dict:
image_url = url_for("static", filename=f"images/{artifacts.image_filename}")
return {
"original_filename": artifacts.original_filename,
"generated_at": format_app_datetime(record.upload_time),
"image_url": image_url,
"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,
}
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():
if current_user.is_authenticated:
return redirect(url_for("main.home"))
return redirect(url_for("main.login"))
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())
username = request.form.get("username", "").strip()
password = request.form.get("password", "")
if not captcha_is_valid():
return render_auth_error("login", "验证码错误")
user = User.query.filter_by(username=username).first()
if not user or not user.check_password(password):
return render_auth_error("login", "用户名或密码错误")
login_user(user, remember=bool(request.form.get("remember")))
return redirect(url_for("main.home"))
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 = request.form.get("username", "").strip()
password = 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:
return render_auth_error("register", "用户名不能为空")
if len(password) < 6:
return render_auth_error("register", "密码至少需要 6 位")
if User.query.filter_by(username=username).first():
return render_auth_error("register", "用户名已存在")
user = User(username=username, is_admin=False)
user.set_password(password)
db.session.add(user)
db.session.commit()
flash("注册成功,请登录", "info")
return render_auth_template("login", captcha=refresh_captcha())
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("/password-reset/<token>", methods=["GET", "POST"])
def password_reset(token: str):
reset_token = active_password_reset_token(token)
if reset_token is None:
return render_password_reset_unavailable()
@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": 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"))
if request.method == "GET":
return render_template(
"password_reset.html",
reset_token=reset_token,
token=token,
token_available=True,
)
password = request.form.get("password", "")
password_confirm = request.form.get("password_confirm", "")
if len(password) < 6:
flash("密码至少需要 6 位", "error")
return render_template(
"password_reset.html",
reset_token=reset_token,
token=token,
token_available=True,
), 400
if password != password_confirm:
flash("两次输入的密码不一致", "error")
return render_template(
"password_reset.html",
reset_token=reset_token,
token=token,
token_available=True,
), 400
@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))
reset_token.user.set_password(password)
reset_token.used_at = utc_now()
db.session.commit()
flash("密码已重置,请使用新密码登录", "info")
return render_auth_template("login", captcha=refresh_captcha())
@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"))
def logout(): logout_user(); return redirect(url_for("main.login"))
@bp.route("/home")
@login_required
def home():
return render_template("home.html")
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",
)
if page_redirect:
return page_redirect
return render_template(
"history.html",
pagination=pagination,
records=pagination.items,
)
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",
)
if page_redirect:
return page_redirect
return render_template(
"admin.html",
pagination=pagination,
records=pagination.items,
password_reset_users=password_reset_users(),
registration_allowed=registration_allowed(),
)
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()
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"))
allow_registration = request.form.get("allow_registration") == "on"
AppSetting.set_bool(REGISTRATION_SETTING_KEY, allow_registration)
db.session.commit()
message = "已开放用户自助注册" if allow_registration else "已关闭用户自助注册"
if request.headers.get("X-Requested-With") == "XMLHttpRequest":
return jsonify(
{
"message": message,
"registration_allowed": allow_registration,
"status_label": "已开放" if allow_registration else "已关闭",
}
)
flash(message, "info")
return redirect(url_for("main.admin_dashboard"))
@bp.route("/admin/users/<int:user_id>/password-reset-link", methods=["POST"])
@bp.route("/admin/users/<int:user_id>/password-reset", methods=["POST"])
@login_required
def create_password_reset_link(user_id: int):
require_admin()
user = db.session.get(User, user_id)
if user is None:
return jsonify({"error": "用户不存在"}), 404
if user.is_admin:
return jsonify({"error": "管理员账号不支持通过此入口重置密码"}), 403
now = utc_now()
PasswordResetToken.query.filter_by(user_id=user.id, used_at=None).update(
{"used_at": now}
)
token = secrets.token_urlsafe(32)
reset_token = PasswordResetToken(
user_id=user.id,
created_by_id=current_user.id,
token_hash=password_reset_token_hash(token),
expires_at=password_reset_expiry(),
)
db.session.add(reset_token)
db.session.commit()
return jsonify(
{
"message": f"已生成 {user.username} 的密码重置链接",
"reset_url": url_for("main.password_reset", token=token, _external=True),
"expires_at": format_app_datetime(reset_token.expires_at),
"user_id": user.id,
"username": user.username,
}
)
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"<p>{user.username},管理员已要求你重置密码。</p><p>请访问 <a href='{url_for('main.forgot_password', _external=True)}'>找回密码</a>,系统会将一次性验证码发送到本邮箱。</p>",
)
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:
abort(404)
if not (current_user.is_admin or current_user.id == record.user_id):
abort(403)
file_path = {
"original": record.saved_path,
"prediction": record.prediction_path,
}.get(file_type)
if file_path is None:
abort(404)
if not os.path.exists(file_path):
abort(404)
return send_file(file_path, as_attachment=True)
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():
template_path = BASE_DIR / "example.xlsx"
if not template_path.exists():
abort(404)
return send_file(template_path, as_attachment=True, download_name=TEMPLATE_EXCEL_NAME)
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():
pdf_path = BASE_DIR / REFERENCE_PDF_NAME
if not pdf_path.exists():
abort(404)
return send_file(
pdf_path,
as_attachment=False,
download_name=REFERENCE_PDF_NAME,
mimetype="application/pdf",
)
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")
def reference_page(): return render_template("reference.html")
@bp.route("/result")
@login_required
def result_page():
result = session.get("last_result")
return render_template("result.html", result=result)
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")
if model is None:
return jsonify({"error": "模型未成功加载,请检查模型文件"}), 500
uploaded = request.files.get("file")
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()
last_result = prediction_result_payload(artifacts, record)
session["last_result"] = last_result
return jsonify(
{
"message": "预测成功",
"image_url": last_result["image_url"],
"excel_url": last_result["excel_url"],
"result_url": last_result["result_url"],
"sample_count": last_result["sample_count"],
"original_filename": artifacts.original_filename,
"model_version": last_result["model_version"],
}
)
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"]})