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
+29 -17
View File
@@ -1,28 +1,40 @@
# Copy to .env for local deployment. # 环境变量模板:复制为 .env 后填写实际值。不要提交 .env 或 .env.local。
# Generate SECRET_KEY with: python -c "import secrets; print(secrets.token_hex(32))" # 优先级:系统环境变量 > .env.local > .env。
SECRET_KEY=change-me-to-a-long-random-secret
# Runtime mode is not set by docker compose. Local python runs default to development; # 运行环境:production(生产)/ development(开发)
# the Docker image sets APP_ENV=production and DEBUG=false in the image. APP_ENV=production
# 是否开启 Flask 调试与热重载。生产必须为 false。
DEBUG=false
# 会话加密密钥。执行 python -c "import secrets; print(secrets.token_hex(32))" 生成。
SECRET_KEY=replace-with-a-long-random-secret
# Required for first deployment. Used to create or rotate the admin account on startup. # 首次启动时创建管理员;之后不会用这些值覆盖管理员密码。
ADMIN_USERNAME=admin ADMIN_USERNAME=admin
ADMIN_PASSWORD=change-me ADMIN_PASSWORD=replace-with-a-strong-password
ADMIN_EMAIL=admin@waternetwork.cn
# Persist the app database in the mounted ./data directory. # 数据库连接。默认使用项目 data 目录中的 SQLite 文件。
DATABASE_URL=sqlite:////app/data/pipe_survival_0331.db DATABASE_URL=sqlite:////app/data/pipe_survival_0331.db
# 页面展示的时区。
# Timezone used when displaying UTC timestamps.
APP_TIMEZONE=Asia/Shanghai APP_TIMEZONE=Asia/Shanghai
# Default: 16 MiB # 单个上传文件最大字节数,16 MiB = 16777216。
MAX_UPLOAD_BYTES=16777216 MAX_UPLOAD_BYTES=16777216
# 模型核心目录。
# Default RC1 fusion model core directory inside the Docker image.
FUSION_MODEL_CORE_DIR=/app/model_core FUSION_MODEL_CORE_DIR=/app/model_core
# 是否允许公开自助注册。
# Keep public registration closed by default.
ALLOW_REGISTRATION=false ALLOW_REGISTRATION=false
# Minutes before an admin-generated password reset link expires. # Resend 邮件服务。发件地址必须已在 Resend 验证。
PASSWORD_RESET_TOKEN_MINUTES=30 RESEND_API_KEY=re_xxxxxxxxx
RESEND_FROM_EMAIL=no-reply@waternetwork.cn
# 邮件验证码:有效期(分钟)、重发间隔(秒)、最大尝试次数。
EMAIL_CODE_MINUTES=10
EMAIL_CODE_RESEND_SECONDS=60
EMAIL_CODE_MAX_ATTEMPTS=5
# 受信设备的有效期(天)。
TRUSTED_DEVICE_DAYS=30
# HTTPS 下必须为 true;本地 HTTP 开发环境设为 false。
SESSION_COOKIE_SECURE=true
+18 -8
View File
@@ -2,14 +2,17 @@ from __future__ import annotations
import logging 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 .config import Config, DATA_DIR, ensure_dirs
from .extensions import db, login_manager from .extensions import db, login_manager
from .models import AppSetting, User from .models import AppSetting, User
from .migrations import upgrade_schema
from .prediction import FEATURES, load_model from .prediction import FEATURES, load_model
from .security import csrf_token, validate_csrf_token from .security import csrf_token, validate_csrf_token
from .time_utils import current_year_for_timezone, format_datetime_for_timezone 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: 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) app.register_blueprint(bp)
with app.app_context(): with app.app_context():
db.create_all() upgrade_schema()
init_admin_user(app) init_admin_user(app)
if load_model_on_start: if load_model_on_start:
@@ -59,23 +62,26 @@ def configure_logging() -> None:
def init_admin_user(app: Flask) -> None: def init_admin_user(app: Flask) -> None:
admin_username = app.config["ADMIN_USERNAME"] admin_username = app.config["ADMIN_USERNAME"]
admin_password = app.config["ADMIN_PASSWORD"] 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() admin = User.query.filter_by(username=admin_username).first()
if admin is None: 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) admin.set_password(admin_password)
db.session.add(admin) db.session.add(admin)
else: else:
admin.is_admin = True admin.is_admin = True
if not admin.check_password(admin_password): if not admin.email:
admin.set_password(admin_password) admin.email = admin_email
admin.email_verified_at = utc_now()
admin.is_active_account = True
db.session.commit() db.session.commit()
elif not User.query.filter_by(is_admin=True).first(): 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() default_admin = User.query.filter_by(username="admin", is_admin=True).first()
if default_admin and default_admin.check_password("admin123"): if default_admin and default_admin.check_password("admin123"):
logging.warning("检测到默认管理员密码 admin123,请立即通过 ADMIN_PASSWORD 更新。") logging.warning("检测到默认管理员密码 admin123,请通过账户安全页立即更新。")
def register_app_hooks(app: Flask) -> None: def register_app_hooks(app: Flask) -> None:
@@ -114,6 +120,10 @@ def register_app_hooks(app: Flask) -> None:
@app.before_request @app.before_request
def protect_csrf(): 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"}: if request.method not in {"POST", "PUT", "PATCH", "DELETE"}:
return None return None
if validate_csrf_token(): if validate_csrf_token():
+24
View File
@@ -4,6 +4,8 @@ import os
import secrets import secrets
from pathlib import Path from pathlib import Path
from dotenv import dotenv_values
BASE_DIR = Path(__file__).resolve().parent.parent BASE_DIR = Path(__file__).resolve().parent.parent
DATA_DIR = BASE_DIR / "data" DATA_DIR = BASE_DIR / "data"
STATIC_DIR = BASE_DIR / "static" STATIC_DIR = BASE_DIR / "static"
@@ -11,6 +13,20 @@ UPLOAD_DIR = BASE_DIR / "uploads"
IMAGE_DIR = STATIC_DIR / "images" 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: def env_int(name: str, default: int) -> int:
try: try:
return int(os.environ.get(name, str(default))) 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) MAX_CONTENT_LENGTH = env_int("MAX_UPLOAD_BYTES", 16 * 1024 * 1024)
SESSION_COOKIE_HTTPONLY = True SESSION_COOKIE_HTTPONLY = True
SESSION_COOKIE_SAMESITE = "Lax" 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 = os.environ.get(
"FUSION_MODEL_CORE_DIR", "FUSION_MODEL_CORE_DIR",
str(BASE_DIR / "model_core"), str(BASE_DIR / "model_core"),
@@ -49,8 +66,15 @@ class Config:
ALLOW_REGISTRATION = env_bool("ALLOW_REGISTRATION", False) ALLOW_REGISTRATION = env_bool("ALLOW_REGISTRATION", False)
ADMIN_USERNAME = os.environ.get("ADMIN_USERNAME", "admin").strip() or "admin" ADMIN_USERNAME = os.environ.get("ADMIN_USERNAME", "admin").strip() or "admin"
ADMIN_PASSWORD = os.environ.get("ADMIN_PASSWORD") 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" APP_TIMEZONE = os.environ.get("APP_TIMEZONE", "Asia/Shanghai").strip() or "Asia/Shanghai"
PASSWORD_RESET_TOKEN_MINUTES = env_int("PASSWORD_RESET_TOKEN_MINUTES", 30) 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: def ensure_dirs() -> None:
+39
View File
@@ -12,6 +12,10 @@ class User(UserMixin, db.Model):
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
username = db.Column(db.String(100), unique=True, nullable=False) 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) password_hash = db.Column(db.String(255), nullable=False)
is_admin = db.Column(db.Boolean, default=False, nullable=False) is_admin = db.Column(db.Boolean, default=False, nullable=False)
created_at = db.Column(db.DateTime, default=utc_now) 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: def check_password(self, password: str) -> bool:
return check_password_hash(self.password_hash, password) 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): class PasswordResetToken(db.Model):
__tablename__ = "password_reset_tokens" __tablename__ = "password_reset_tokens"
@@ -42,6 +53,34 @@ class PasswordResetToken(db.Model):
created_by = db.relationship("User", foreign_keys=[created_by_id]) 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): class UploadRecord(db.Model):
__tablename__ = "upload_records" __tablename__ = "upload_records"
+294 -315
View File
@@ -1,29 +1,19 @@
from __future__ import annotations from __future__ import annotations
import os
import hashlib import hashlib
import os
import re
import secrets import secrets
from datetime import timedelta from datetime import timedelta
from flask import ( from flask import Blueprint, abort, current_app, flash, jsonify, redirect, render_template, request, send_file, session, url_for
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 flask_login import current_user, login_required, login_user, logout_user
from sqlalchemy.orm import joinedload from sqlalchemy.orm import joinedload
from .config import BASE_DIR from .config import BASE_DIR
from .email import EmailConfigurationError, EmailDeliveryError, send_transactional_email
from .extensions import db 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 .prediction import PredictionError, run_prediction
from .security import new_captcha from .security import new_captcha
from .time_utils import format_datetime_for_timezone, utc_now from .time_utils import format_datetime_for_timezone, utc_now
@@ -33,6 +23,36 @@ REFERENCE_PDF_NAME = "20260630标准文本——供水管道健康状态与剩
TEMPLATE_EXCEL_NAME = "管道预测数据模板.xlsx" TEMPLATE_EXCEL_NAME = "管道预测数据模板.xlsx"
REGISTRATION_SETTING_KEY = "allow_registration" REGISTRATION_SETTING_KEY = "allow_registration"
RECORDS_PER_PAGE = 10 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 = ""): 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: def captcha_is_valid() -> bool:
captcha_input = request.form.get("captcha", "").strip().upper() value = request.form.get("captcha", "").strip().upper()
return bool(captcha_input and captcha_input == session.get("captcha", "")) return bool(value and value == 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
def require_admin() -> None: def require_admin() -> None:
@@ -96,10 +80,7 @@ def require_admin() -> None:
def registration_allowed() -> bool: def registration_allowed() -> bool:
return AppSetting.get_bool( return AppSetting.get_bool(REGISTRATION_SETTING_KEY, current_app.config["ALLOW_REGISTRATION"])
REGISTRATION_SETTING_KEY,
current_app.config["ALLOW_REGISTRATION"],
)
def requested_page() -> int: def requested_page() -> int:
@@ -111,333 +92,331 @@ def requested_page() -> int:
def paginated_uploads(query, endpoint: str): def paginated_uploads(query, endpoint: str):
page = requested_page() page = requested_page()
pagination = ( pagination = query.order_by(UploadRecord.upload_time.desc()).paginate(
query.order_by(UploadRecord.upload_time.desc()) page=page,
.paginate(page=page, per_page=RECORDS_PER_PAGE, error_out=False) per_page=RECORDS_PER_PAGE,
error_out=False,
) )
if pagination.pages and page > pagination.pages: if pagination.pages and page > pagination.pages:
return pagination, redirect(url_for(endpoint, page=pagination.pages)) return pagination, redirect(url_for(endpoint, page=pagination.pages))
return pagination, None return pagination, None
def password_reset_users(): def code_hash(email: str, purpose: str, code: str) -> str:
return ( return hashlib.sha256(f"{email}:{purpose}:{code}".encode()).hexdigest()
User.query.filter(User.is_admin.is_(False))
.order_by(User.username.asc(), User.id.asc())
.all() 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: def consume_email_code(email: str, purpose: str, code: str) -> bool:
image_url = url_for("static", filename=f"images/{artifacts.image_filename}") record = (
return { EmailVerificationCode.query.filter_by(email=email, purpose=purpose, used_at=None)
"original_filename": artifacts.original_filename, .order_by(EmailVerificationCode.created_at.desc())
"generated_at": format_app_datetime(record.upload_time), .first()
"image_url": image_url, )
"excel_url": url_for("main.download_file", record_id=record.id, file_type="prediction"), if (
"result_url": url_for("main.result_page"), record is None
"sample_count": int(artifacts.sample_count), or record.expires_at <= utc_now()
"summary_rows": artifacts.summary_rows[:6], or record.attempts >= current_app.config["EMAIL_CODE_MAX_ATTEMPTS"]
"analysis_text": artifacts.analysis_text, ):
"model_version": artifacts.model_version, 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("/") @bp.route("/")
def index(): def index():
if current_user.is_authenticated: return redirect(url_for("main.home" if current_user.is_authenticated else "main.login"))
return redirect(url_for("main.home"))
return redirect(url_for("main.login"))
@bp.route("/login", methods=["GET", "POST"]) @bp.route("/login", methods=["GET", "POST"])
def login(): def login():
if current_user.is_authenticated: if current_user.is_authenticated:
return redirect(url_for("main.home")) return redirect(url_for("main.home"))
if request.method == "GET": if request.method == "GET":
return render_auth_template("login", captcha=refresh_captcha()) return render_auth_template("login", captcha=refresh_captcha())
email, password = normal_email(request.form.get("email", "")), request.form.get("password", "")
username = request.form.get("username", "").strip() if not captcha_is_valid(): return render_auth_error("login", "验证码错误")
password = request.form.get("password", "") user = User.query.filter_by(email=email).first()
if not captcha_is_valid(): if not user or not user.is_active_account or not user.check_password(password):
return render_auth_error("login", "验证码错误") return render_auth_error("login", "邮箱或密码错误")
remember = bool(request.form.get("remember"))
user = User.query.filter_by(username=username).first() if trusted_device_for(user):
if not user or not user.check_password(password): return login_response(user, remember)
return render_auth_error("login", "用户名或密码错误") session.update(pending_email=email, pending_user_id=user.id, pending_remember=remember, pending_purpose="login")
if not issue_email_code(email, "login"):
login_user(user, remember=bool(request.form.get("remember"))) return render_auth_error("login", "验证码发送失败,请稍后重试", 503)
return redirect(url_for("main.home")) return redirect(url_for("main.verify_email", purpose="login"))
@bp.route("/register", methods=["GET", "POST"]) @bp.route("/register", methods=["GET", "POST"])
def register(): def register():
if request.method == "GET": if request.method == "GET": return render_auth_template("register", captcha=refresh_captcha())
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", "验证码错误")
username = request.form.get("username", "").strip() if not registration_allowed(): return render_auth_error("register", "当前未开放自助注册,请联系管理员。", 403)
password = request.form.get("password", "") 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 captcha_is_valid(): if not valid_password(password): return render_auth_error("register", "密码长度应为12至128位")
return render_auth_error("register", "验证码错误") if User.query.filter((User.username == username) | (User.email == email)).first(): return render_auth_error("register", "显示名或邮箱已被使用")
if not registration_allowed(): user = User(username=username, email=email, is_admin=False, is_active_account=False)
return render_auth_error("register", "当前未开放自助注册,请联系管理员。", 403) 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 username: if not issue_email_code(email, "register"): return render_auth_error("register", "验证码发送失败,请稍后重试", 503)
return render_auth_error("register", "用户名不能为空") return redirect(url_for("main.verify_email", purpose="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())
@bp.route("/password-reset/<token>", methods=["GET", "POST"]) @bp.route("/verify/<purpose>", methods=["GET", "POST"])
def password_reset(token: str): def verify_email(purpose: str):
reset_token = active_password_reset_token(token) if purpose not in EMAIL_CODE_PURPOSES or session.get("pending_purpose") != purpose:
if reset_token is None: abort(400)
return render_password_reset_unavailable() 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", "") @bp.route("/verify/<purpose>/resend", methods=["POST"])
password_confirm = request.form.get("password_confirm", "") def resend_code(purpose: str):
if len(password) < 6: if purpose not in EMAIL_CODE_PURPOSES or session.get("pending_purpose") != purpose:
flash("密码至少需要 6 位", "error") abort(400)
return render_template( if not issue_email_code(session.get("pending_email", ""), purpose): flash("发送过于频繁或服务暂不可用,请稍后再试。", "error")
"password_reset.html", else: flash("验证码已发送,请查收邮箱。", "info")
reset_token=reset_token, return redirect(url_for("main.verify_email", purpose=purpose))
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
reset_token.user.set_password(password)
reset_token.used_at = utc_now() @bp.route("/forgot-password", methods=["GET", "POST"])
db.session.commit() def forgot_password():
flash("密码已重置,请使用新密码登录", "info") if request.method == "GET": return render_template("forgot_password.html")
return render_auth_template("login", captcha=refresh_captcha()) 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"]) @bp.route("/logout", methods=["POST"])
@login_required @login_required
def logout(): def logout(): logout_user(); return redirect(url_for("main.login"))
logout_user()
return redirect(url_for("main.login"))
@bp.route("/home") @bp.route("/home")
@login_required @login_required
def home(): def home(): return render_template("home.html")
return render_template("home.html")
@bp.route("/history") @bp.route("/history")
@login_required @login_required
def history_page(): def history_page():
pagination, page_redirect = paginated_uploads( pagination, page_redirect = paginated_uploads(UploadRecord.query.filter_by(user_id=current_user.id), "main.history_page")
UploadRecord.query.filter_by(user_id=current_user.id), return page_redirect or render_template("history.html", pagination=pagination, records=pagination.items)
"main.history_page",
)
if page_redirect:
return page_redirect
return render_template(
"history.html",
pagination=pagination,
records=pagination.items,
)
@bp.route("/admin") @bp.route("/admin")
@login_required @login_required
def admin_dashboard(): def admin_dashboard():
require_admin() require_admin(); pagination, page_redirect = paginated_uploads(UploadRecord.query.options(joinedload(UploadRecord.user)), "main.admin_dashboard")
pagination, page_redirect = paginated_uploads( 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())
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(),
)
@bp.route("/admin/registration", methods=["POST"]) @bp.route("/admin/registration", methods=["POST"])
@login_required @login_required
def update_registration_setting(): 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" @bp.route("/admin/users/<int:user_id>/password-reset", methods=["POST"])
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"])
@login_required @login_required
def create_password_reset_link(user_id: int): def admin_password_reset(user_id: int):
require_admin() require_admin(); user = db.session.get(User, user_id)
if not user or user.is_admin: return jsonify({"error": "用户不存在或不支持此操作"}), 404
user = db.session.get(User, user_id) try:
if user is None: send_transactional_email(
return jsonify({"error": "用户不存在"}), 404 to=user.email,
if user.is_admin: subject="供水管道健康评估系统:请重置密码",
return jsonify({"error": "管理员账号不支持通过此入口重置密码"}), 403 html=f"<p>{user.username},管理员已要求你重置密码。</p><p>请访问 <a href='{url_for('main.forgot_password', _external=True)}'>找回密码</a>,系统会将一次性验证码发送到本邮箱。</p>",
now = utc_now()
PasswordResetToken.query.filter_by(user_id=user.id, used_at=None).update(
{"used_at": now}
) )
except (EmailConfigurationError, EmailDeliveryError):
token = secrets.token_urlsafe(32) return jsonify({"error": "邮件发送失败"}), 503
reset_token = PasswordResetToken( return jsonify({"message": "密码重置通知已发送至用户邮箱"})
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,
}
)
@bp.route("/download/<int:record_id>/<file_type>") @bp.route("/download/<int:record_id>/<file_type>")
@login_required @login_required
def download_file(record_id: int, file_type: str): def download_file(record_id: int, file_type: str):
record = db.session.get(UploadRecord, record_id) record = db.session.get(UploadRecord, record_id)
if record is None: 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)
abort(404) path = {"original": record.saved_path, "prediction": record.prediction_path}.get(file_type)
if not (current_user.is_admin or current_user.id == record.user_id): if path is None or not os.path.exists(path): abort(404)
abort(403) return send_file(path, as_attachment=True)
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)
@bp.route("/download_template") @bp.route("/download_template")
def download_template(): def download_template(): return send_file(BASE_DIR / "example.xlsx", as_attachment=True, download_name=TEMPLATE_EXCEL_NAME)
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)
@bp.route("/reference_pdf") @bp.route("/reference_pdf")
@login_required @login_required
def reference_pdf(): def reference_pdf(): return send_file(BASE_DIR / REFERENCE_PDF_NAME, as_attachment=False, download_name=REFERENCE_PDF_NAME, mimetype="application/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",
)
@bp.route("/reference") @bp.route("/reference")
@login_required @login_required
def reference_page(): def reference_page(): return render_template("reference.html")
return render_template("reference.html")
@bp.route("/result") @bp.route("/result")
@login_required @login_required
def result_page(): def result_page(): return render_template("result.html", result=session.get("last_result"))
result = session.get("last_result")
return render_template("result.html", result=result)
@bp.route("/predict", methods=["POST"]) @bp.route("/predict", methods=["POST"])
@login_required @login_required
def predict(): def predict():
model = current_app.config.get("RSF_MODEL") model = current_app.config.get("RSF_MODEL"); uploaded = request.files.get("file")
if model is None: if model is None: return jsonify({"error": "模型未成功加载,请检查模型文件。"}), 500
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)
uploaded = request.files.get("file") except PredictionError as exc: return jsonify({"error": exc.message}), exc.status_code
if uploaded is None or uploaded.filename == "": 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()
return jsonify({"error": "未选择文件"}), 400 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"]})
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"],
}
)
+21
View File
@@ -0,0 +1,21 @@
# Development-only overrides. Do not use this file in production.
# Usage: docker compose -f docker-compose.yml -f docker-compose.dev.yml up --build
# Docker Compose 默认只读取 .env.env.local 用于本机直接运行 Python 时覆盖 .env。
services:
pipeline-lifetime:
restart: "no"
environment:
APP_ENV: development
DEBUG: "true"
SESSION_COOKIE_SECURE: "false"
command:
["conda", "run", "--no-capture-output", "-n", "demo", "python", "main.py"]
volumes:
- ./app:/app/app
- ./templates:/app/templates
- ./static:/app/static
- ./main.py:/app/main.py
- ./data:/app/data
- ./data/uploads:/app/uploads
- ./data/images:/app/static/images
- ./example.xlsx:/app/example.xlsx:ro
+10
View File
@@ -6,15 +6,25 @@ services:
container_name: pipeline-lifetime container_name: pipeline-lifetime
restart: unless-stopped restart: unless-stopped
environment: environment:
APP_ENV: ${APP_ENV:-production}
DEBUG: ${DEBUG:-false}
SECRET_KEY: ${SECRET_KEY:?Set SECRET_KEY in .env} SECRET_KEY: ${SECRET_KEY:?Set SECRET_KEY in .env}
ADMIN_USERNAME: ${ADMIN_USERNAME:-admin} ADMIN_USERNAME: ${ADMIN_USERNAME:-admin}
ADMIN_PASSWORD: ${ADMIN_PASSWORD:?Set ADMIN_PASSWORD in .env} ADMIN_PASSWORD: ${ADMIN_PASSWORD:?Set ADMIN_PASSWORD in .env}
ADMIN_EMAIL: ${ADMIN_EMAIL:?Set ADMIN_EMAIL in .env}
DATABASE_URL: ${DATABASE_URL:-sqlite:////app/data/pipe_survival_0331.db} DATABASE_URL: ${DATABASE_URL:-sqlite:////app/data/pipe_survival_0331.db}
APP_TIMEZONE: ${APP_TIMEZONE:-Asia/Shanghai} APP_TIMEZONE: ${APP_TIMEZONE:-Asia/Shanghai}
MAX_UPLOAD_BYTES: ${MAX_UPLOAD_BYTES:-16777216} MAX_UPLOAD_BYTES: ${MAX_UPLOAD_BYTES:-16777216}
FUSION_MODEL_CORE_DIR: ${FUSION_MODEL_CORE_DIR:-/app/model_core} FUSION_MODEL_CORE_DIR: ${FUSION_MODEL_CORE_DIR:-/app/model_core}
ALLOW_REGISTRATION: ${ALLOW_REGISTRATION:-false} ALLOW_REGISTRATION: ${ALLOW_REGISTRATION:-false}
PASSWORD_RESET_TOKEN_MINUTES: ${PASSWORD_RESET_TOKEN_MINUTES:-30} PASSWORD_RESET_TOKEN_MINUTES: ${PASSWORD_RESET_TOKEN_MINUTES:-30}
RESEND_API_KEY: ${RESEND_API_KEY:-}
RESEND_FROM_EMAIL: ${RESEND_FROM_EMAIL:-}
EMAIL_CODE_MINUTES: ${EMAIL_CODE_MINUTES:-10}
EMAIL_CODE_RESEND_SECONDS: ${EMAIL_CODE_RESEND_SECONDS:-60}
EMAIL_CODE_MAX_ATTEMPTS: ${EMAIL_CODE_MAX_ATTEMPTS:-5}
TRUSTED_DEVICE_DAYS: ${TRUSTED_DEVICE_DAYS:-30}
SESSION_COOKIE_SECURE: ${SESSION_COOKIE_SECURE:-true}
ports: ports:
- "5005:5005" - "5005:5005"
volumes: volumes:
+2
View File
@@ -1,9 +1,11 @@
Flask==3.1.3 Flask==3.1.3
Flask-SQLAlchemy==3.1.1 Flask-SQLAlchemy==3.1.1
Flask-Login==0.6.3 Flask-Login==0.6.3
python-dotenv==1.2.2
Werkzeug==3.1.6 Werkzeug==3.1.6
SQLAlchemy==2.0.48 SQLAlchemy==2.0.48
gunicorn==23.0.0 gunicorn==23.0.0
resend==2.35.0
pandas==2.3.3 pandas==2.3.3
numpy==2.0.2 numpy==2.0.2
+1
View File
@@ -0,0 +1 @@
<form method="post" class="space-y-4"><input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><label class="block text-sm font-semibold">邮箱<input name="email" type="email" required class="mt-1 w-full rounded-lg border border-line px-3 py-2" autocomplete="email"></label><label class="block text-sm font-semibold">密码<a class="float-right text-primary" href="{{ url_for('main.forgot_password') }}">找回密码</a><input name="password" type="password" required class="mt-1 w-full rounded-lg border border-line px-3 py-2" autocomplete="current-password"></label><label class="block text-sm font-semibold">图形验证码<div class="mt-1 flex gap-2"><input name="captcha" required class="min-w-0 flex-1 rounded-lg border border-line px-3 py-2"><span class="rounded-lg bg-blueSoft px-3 py-2 font-bold tracking-widest">{{ captcha }}</span><a class="rounded-lg border border-line px-2 py-2" href="{{ url_for('main.login') }}"></a></div></label><label class="flex gap-2 text-sm text-textSub"><input type="checkbox" name="remember">保持登录状态</label><button class="ui-btn ui-btn-lg ui-btn-primary w-full">登录</button></form>
+1
View File
@@ -0,0 +1 @@
<form method="post" class="space-y-4"><input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><label class="block text-sm font-semibold">显示名<input name="username" required {{ 'disabled' if not allow_registration }} class="mt-1 w-full rounded-lg border border-line px-3 py-2"></label><label class="block text-sm font-semibold">邮箱<input name="email" type="email" required {{ 'disabled' if not allow_registration }} class="mt-1 w-full rounded-lg border border-line px-3 py-2" autocomplete="email"></label><label class="block text-sm font-semibold">密码(12 至 128 位)<input name="password" type="password" minlength="12" required {{ 'disabled' if not allow_registration }} class="mt-1 w-full rounded-lg border border-line px-3 py-2" autocomplete="new-password"></label><label class="block text-sm font-semibold">图形验证码<div class="mt-1 flex gap-2"><input name="captcha" required {{ 'disabled' if not allow_registration }} class="min-w-0 flex-1 rounded-lg border border-line px-3 py-2"><span class="rounded-lg bg-blueSoft px-3 py-2 font-bold tracking-widest">{{ captcha }}</span><a class="rounded-lg border border-line px-2 py-2" href="{{ url_for('main.register') }}"></a></div></label>{% if not allow_registration %}<p class="text-sm text-dangerText">当前未开放自助注册,请联系管理员。</p>{% endif %}<button {{ 'disabled' if not allow_registration }} class="ui-btn ui-btn-lg ui-btn-primary w-full">发送邮箱验证码</button></form>
+12
View File
@@ -0,0 +1,12 @@
{% extends "base.html" %}
{% block content %}
<section class="mx-auto max-w-xl rounded-xl border border-line bg-white p-6 shadow-panel">
<h1 class="text-2xl font-extrabold">账户安全</h1>
<p class="mt-2 text-sm text-textSub">登录邮箱:{{ current_user.email }}</p>
{% with messages=get_flashed_messages(with_categories=true) %}{% for c,m in messages %}<p class="mt-3 text-sm text-dangerText">{{ m }}</p>{% endfor %}{% endwith %}
<form method="post" class="mt-6 space-y-3"><input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><input name="current_password" type="password" required class="w-full rounded-lg border border-line px-3 py-2" placeholder="当前密码"><button class="ui-btn ui-btn-primary">发送修改密码验证码</button></form>
<form method="post" class="mt-6 space-y-3 border-t border-line pt-5"><input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><input type="hidden" name="action" value="change_email"><input name="current_password" type="password" required class="w-full rounded-lg border border-line px-3 py-2" placeholder="当前密码"><input name="new_email" type="email" required class="w-full rounded-lg border border-line px-3 py-2" placeholder="新邮箱"><button class="ui-btn ui-btn-secondary">验证并更换邮箱</button></form>
<form method="post" class="mt-6 border-t border-line pt-5"><input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><input type="hidden" name="action" value="revoke_devices"><button class="ui-btn ui-btn-secondary">撤销所有受信设备</button></form>
{% if session.get('password_change_verified') %}<form method="post" action="{{ url_for('main.change_password') }}" class="mt-6 space-y-3 border-t border-line pt-5"><input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><input name="password" type="password" minlength="12" required class="w-full rounded-lg border border-line px-3 py-2" placeholder="新密码"><input name="password_confirm" type="password" minlength="12" required class="w-full rounded-lg border border-line px-3 py-2" placeholder="确认新密码"><button class="ui-btn ui-btn-primary">更新密码</button></form>{% endif %}
</section>
{% endblock %}
+30 -203
View File
@@ -2,237 +2,64 @@
{% from "_pagination.html" import render_pagination %} {% from "_pagination.html" import render_pagination %}
{% set active_page = "admin" %} {% set active_page = "admin" %}
{% block title %}管理台 | 供水管道健康评估系统{% endblock %} {% block title %}管理台{% endblock %}
{% block content %} {% block content %}
<div class="mb-6 flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
<div> <div>
<h1 class="text-3xl font-extrabold tracking-tight sm:text-4xl">管理台</h1> <h1 class="text-3xl font-extrabold">管理台</h1>
<p class="mt-2 text-sm text-textSub">管理系统注册状态、密码重置链接,查看所有用户的上传文件和预测结果</p> <p class="mt-2 text-sm text-textSub">管理注册状态、密码重置和所有预测记录</p>
</div>
<a href="{{ url_for('main.home') }}" class="ui-btn ui-btn-secondary">
<span class="material-symbols-outlined text-lg">arrow_back</span>
返回主页
</a>
</div> </div>
<section class="mb-6 rounded-lg border border-line bg-white p-5 shadow-panel"> <section class="mt-6 rounded-xl border border-line bg-white p-5 shadow-panel">
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between"> <h2 class="font-extrabold">用户注册</h2>
<div> <form method="post" action="{{ url_for('main.update_registration_setting') }}" class="mt-3 flex items-center gap-3">
<h2 class="text-lg font-extrabold tracking-tight">用户注册</h2>
<p class="mt-1 text-sm text-textSub">
当前状态:<span id="registrationStatus" class="font-bold {{ 'text-successText' if registration_allowed else 'text-slate-600' }}">{{ '已开放' if registration_allowed else '已关闭' }}</span>
</p>
</div>
<form id="registrationForm" method="post" action="{{ url_for('main.update_registration_setting') }}" class="flex items-center gap-3">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<label class="inline-flex items-center gap-2 text-sm font-semibold text-slate-600"> <label class="text-sm">
<input id="registrationToggle" type="checkbox" name="allow_registration" class="rounded border-slate-300 text-primary focus:ring-primary" {{ 'checked' if registration_allowed }}> <input type="checkbox" name="allow_registration" {{ 'checked' if registration_allowed }}>
允许自助注册 允许自助注册
</label> </label>
<button id="registrationSubmit" type="submit" class="ui-btn ui-btn-sm ui-btn-primary"> <button class="ui-btn ui-btn-sm ui-btn-primary">保存</button>
<span class="material-symbols-outlined text-lg">save</span>
<span id="registrationSubmitText">保存</span>
</button>
</form> </form>
</div>
</section> </section>
<section class="mb-6 rounded-lg border border-line bg-white p-5 shadow-panel"> <section class="mt-6 rounded-xl border border-line bg-white p-5 shadow-panel">
<div class="mb-4 flex flex-col gap-1 sm:flex-row sm:items-center sm:justify-between"> <h2 class="font-extrabold">用户密码重置</h2>
<div> <p class="mt-1 text-sm text-textSub">向用户已验证邮箱发送重置通知。</p>
<h2 class="text-lg font-extrabold tracking-tight">用户密码重置</h2> <div class="mt-4 overflow-x-auto">
<p class="mt-1 text-sm text-textSub">为普通用户生成一次性重置链接,旧链接会自动失效。</p>
</div>
<span class="text-sm text-textSub">共 {{ password_reset_users|length }} 位普通用户</span>
</div>
<div id="resetLinkPanel" class="mb-4 hidden rounded-lg border border-blue-200 bg-blue-50 p-4">
<div class="mb-2 flex flex-col gap-1 sm:flex-row sm:items-center sm:justify-between">
<div class="text-sm font-extrabold text-textMain">已生成重置链接</div>
<div id="resetLinkExpires" class="text-xs font-semibold text-textSub"></div>
</div>
<div class="flex flex-col gap-3 sm:flex-row">
<input id="resetLinkValue" class="min-w-0 flex-1 rounded-md border border-blue-200 bg-white px-3 py-2 text-sm text-textMain" readonly>
<button id="resetLinkCopy" type="button" class="ui-btn ui-btn-sm ui-btn-secondary">
<span class="material-symbols-outlined text-lg">content_copy</span>
复制
</button>
</div>
</div>
<div class="overflow-x-auto rounded-lg border border-line">
<table class="min-w-full text-sm"> <table class="min-w-full text-sm">
<thead class="bg-slate-50 text-xs font-bold uppercase tracking-[0.12em] text-textSub"> <thead>
<tr> <tr class="border-b border-line text-left">
<th class="px-4 py-3 text-left">用户</th> <th class="p-2">显示名</th><th class="p-2">邮箱</th><th class="p-2">操作</th>
<th class="px-4 py-3 text-left">创建时间</th>
<th class="px-4 py-3 text-left">操作</th>
</tr> </tr>
</thead> </thead>
<tbody class="divide-y divide-line"> <tbody>
{% for user in password_reset_users %} {% for user in password_reset_users %}
<tr class="hover:bg-slate-50"> <tr class="border-b border-line">
<td class="px-4 py-3 font-semibold">{{ user.username }}</td> <td class="p-2">{{ user.username }}</td>
<td class="px-4 py-3 text-textSub">{{ format_datetime(user.created_at) }}</td> <td class="p-2">{{ user.email or '未设置' }}</td>
<td class="px-4 py-3"> <td class="p-2">
<form method="post" action="{{ url_for('main.create_password_reset_link', user_id=user.id) }}" data-reset-link-form class="inline-flex"> {% if user.email %}
<form method="post" action="{{ url_for('main.admin_password_reset', user_id=user.id) }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="ui-btn ui-btn-sm ui-btn-secondary"> <button class="ui-btn ui-btn-sm ui-btn-secondary">发送重置通知</button>
<span class="material-symbols-outlined text-lg">link</span>
生成重置链接
</button>
</form> </form>
{% endif %}
</td> </td>
</tr> </tr>
{% else %}
<tr>
<td colspan="3" class="px-4 py-8 text-center text-textSub">暂无普通用户</td>
</tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
</div> </div>
</section> </section>
<section class="flex h-[710px] flex-col overflow-hidden rounded-lg border border-line bg-white shadow-panel"> <section class="mt-6 rounded-xl border border-line bg-white p-5 shadow-panel">
<div class="border-b border-line px-5 py-4"> <h2 class="font-extrabold">上传记录</h2>
<div class="flex flex-col gap-1 sm:flex-row sm:items-center sm:justify-between"> <div class="mt-4 overflow-x-auto">
<h2 class="text-lg font-extrabold">上传记录</h2>
{% if pagination.total %}
<span class="text-sm text-textSub">共 {{ pagination.total }} 条</span>
{% endif %}
</div>
</div>
<div class="min-h-0 flex-1 overflow-auto">
<table class="min-w-full text-sm"> <table class="min-w-full text-sm">
<thead class="bg-slate-50 text-xs font-bold uppercase tracking-[0.12em] text-textSub"> <thead><tr class="border-b border-line text-left"><th class="p-2">用户</th><th class="p-2">文件</th><th class="p-2">时间</th></tr></thead>
<tr> <tbody>{% for record in records %}<tr class="border-b border-line"><td class="p-2">{{ record.user.username }}</td><td class="p-2">{{ record.original_filename }}</td><td class="p-2">{{ format_datetime(record.upload_time) }}</td></tr>{% endfor %}</tbody>
<th class="px-5 py-4 text-left">用户</th>
<th class="px-5 py-4 text-left">原始文件</th>
<th class="px-5 py-4 text-left">上传时间</th>
<th class="px-5 py-4 text-left">下载</th>
</tr>
</thead>
<tbody class="divide-y divide-line">
{% for record in records %}
<tr class="hover:bg-slate-50">
<td class="px-5 py-4 font-semibold">{{ record.user.username }}</td>
<td class="max-w-[420px] truncate px-5 py-4">{{ record.original_filename }}</td>
<td class="px-5 py-4 text-textSub">{{ format_datetime(record.upload_time) }}</td>
<td class="px-5 py-4">
<div class="flex flex-wrap gap-3">
<a class="font-bold text-primary" href="{{ url_for('main.download_file', record_id=record.id, file_type='original') }}">原始文件</a>
<a class="font-bold text-primary" href="{{ url_for('main.download_file', record_id=record.id, file_type='prediction') }}">预测结果</a>
</div>
</td>
</tr>
{% else %}
<tr>
<td colspan="4" class="px-5 py-12 text-center text-textSub">暂无上传记录</td>
</tr>
{% endfor %}
</tbody>
</table> </table>
</div> </div>
{{ render_pagination(pagination, 'main.admin_dashboard') }} {{ render_pagination(pagination, 'main.admin_dashboard') }}
</section> </section>
{% endblock %} {% endblock %}
{% block scripts %}
<script>
(() => {
const form = document.getElementById('registrationForm');
const toggle = document.getElementById('registrationToggle');
const status = document.getElementById('registrationStatus');
const submit = document.getElementById('registrationSubmit');
const submitText = document.getElementById('registrationSubmitText');
if (!form || !toggle || !status || !submit || !submitText) return;
form.addEventListener('submit', async (event) => {
event.preventDefault();
submit.disabled = true;
submitText.textContent = '保存中';
try {
const response = await fetch(form.action, {
method: 'POST',
body: new FormData(form),
headers: { 'X-Requested-With': 'XMLHttpRequest' },
});
const data = await response.json();
if (!response.ok) {
window.showAppNotification?.(data.error || '保存失败,请刷新页面后重试。', 'error', '保存失败');
return;
}
toggle.checked = Boolean(data.registration_allowed);
status.textContent = data.status_label;
status.classList.toggle('text-successText', data.registration_allowed);
status.classList.toggle('text-slate-600', !data.registration_allowed);
window.showAppNotification?.(data.message, 'info');
} catch (error) {
window.showAppNotification?.('请求失败,请检查后端服务是否正常。', 'error', '保存失败');
} finally {
submit.disabled = false;
submitText.textContent = '保存';
}
});
})();
(() => {
const panel = document.getElementById('resetLinkPanel');
const value = document.getElementById('resetLinkValue');
const expires = document.getElementById('resetLinkExpires');
const copy = document.getElementById('resetLinkCopy');
if (!panel || !value || !expires || !copy) return;
document.querySelectorAll('[data-reset-link-form]').forEach((form) => {
const submit = form.querySelector('button[type="submit"]');
const submitText = submit?.lastChild;
form.addEventListener('submit', async (event) => {
event.preventDefault();
if (submit) submit.disabled = true;
if (submitText) submitText.textContent = '生成中';
try {
const response = await fetch(form.action, {
method: 'POST',
body: new FormData(form),
headers: { 'X-Requested-With': 'XMLHttpRequest' },
});
const data = await response.json();
if (!response.ok) {
window.showAppNotification?.(data.error || '生成失败,请刷新页面后重试。', 'error', '生成失败');
return;
}
value.value = data.reset_url;
expires.textContent = `有效期至 ${data.expires_at}`;
panel.classList.remove('hidden');
window.showAppNotification?.(data.message, 'info');
} catch (error) {
window.showAppNotification?.('请求失败,请检查后端服务是否正常。', 'error', '生成失败');
} finally {
if (submit) submit.disabled = false;
if (submitText) submitText.textContent = '生成重置链接';
}
});
});
copy.addEventListener('click', async () => {
value.select();
try {
await navigator.clipboard.writeText(value.value);
} catch (error) {
document.execCommand('copy');
}
window.showAppNotification?.('重置链接已复制', 'info');
});
})();
</script>
{% endblock %}
+3 -2
View File
@@ -20,11 +20,12 @@
<a href="{{ url_for('main.result_page') }}" class="flex h-full items-center px-3 {{ 'text-primary border-b-2 border-primary' if active_page == 'result' else 'text-slate-500 border-b-2 border-transparent hover:text-primary' }}">结果</a> <a href="{{ url_for('main.result_page') }}" class="flex h-full items-center px-3 {{ 'text-primary border-b-2 border-primary' if active_page == 'result' else 'text-slate-500 border-b-2 border-transparent hover:text-primary' }}">结果</a>
<a href="{{ url_for('main.history_page') }}" class="flex h-full items-center px-3 {{ 'text-primary border-b-2 border-primary' if active_page == 'history' else 'text-slate-500 border-b-2 border-transparent hover:text-primary' }}">历史</a> <a href="{{ url_for('main.history_page') }}" class="flex h-full items-center px-3 {{ 'text-primary border-b-2 border-primary' if active_page == 'history' else 'text-slate-500 border-b-2 border-transparent hover:text-primary' }}">历史</a>
<a href="{{ url_for('main.reference_page') }}" class="flex h-full items-center px-3 {{ 'text-primary border-b-2 border-primary' if active_page == 'reference' else 'text-slate-500 border-b-2 border-transparent hover:text-primary' }}">文档</a> <a href="{{ url_for('main.reference_page') }}" class="flex h-full items-center px-3 {{ 'text-primary border-b-2 border-primary' if active_page == 'reference' else 'text-slate-500 border-b-2 border-transparent hover:text-primary' }}">文档</a>
<a href="{{ url_for('main.account_security') }}" class="flex h-full items-center px-3 text-slate-500 border-b-2 border-transparent hover:text-primary">安全</a>
{% if current_user.is_admin %} {% if current_user.is_admin %}
<a href="{{ url_for('main.admin_dashboard') }}" class="flex h-full items-center px-3 {{ 'text-primary border-b-2 border-primary' if active_page == 'admin' else 'text-slate-500 border-b-2 border-transparent hover:text-primary' }}">管理</a> <a href="{{ url_for('main.admin_dashboard') }}" class="flex h-full items-center px-3 {{ 'text-primary border-b-2 border-primary' if active_page == 'admin' else 'text-slate-500 border-b-2 border-transparent hover:text-primary' }}">管理</a>
{% endif %} {% endif %}
</nav> </nav>
<div class="flex items-center gap-3"> {% if current_user.is_authenticated %}<div class="flex items-center gap-3">
<div class="hidden sm:flex items-center gap-2 text-sm font-semibold text-slate-600"> <div class="hidden sm:flex items-center gap-2 text-sm font-semibold text-slate-600">
<div class="h-8 w-8 rounded-full bg-primary text-white flex items-center justify-center text-xs">{{ current_user.username[:1]|upper }}</div> <div class="h-8 w-8 rounded-full bg-primary text-white flex items-center justify-center text-xs">{{ current_user.username[:1]|upper }}</div>
<span class="max-w-[120px] truncate">{{ current_user.username }}</span> <span class="max-w-[120px] truncate">{{ current_user.username }}</span>
@@ -33,7 +34,7 @@
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button class="ui-btn ui-btn-sm ui-btn-secondary font-semibold text-slate-600" type="submit">退出</button> <button class="ui-btn ui-btn-sm ui-btn-secondary font-semibold text-slate-600" type="submit">退出</button>
</form> </form>
</div> </div>{% endif %}
</div> </div>
</div> </div>
<nav class="md:hidden border-t border-line bg-white"> <nav class="md:hidden border-t border-line bg-white">
+1
View File
@@ -0,0 +1 @@
{% extends "base.html" %}{% block content %}<section class="mx-auto max-w-md rounded-xl border border-line bg-white p-6 shadow-panel"><h1 class="text-2xl font-extrabold">找回密码</h1><p class="mt-2 text-sm text-textSub">输入邮箱后,如账户存在会收到验证码。</p><form method="post" class="mt-5 space-y-4"><input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><input name="email" type="email" required class="w-full rounded-lg border border-line px-3 py-2" placeholder="邮箱"><button class="ui-btn ui-btn-primary w-full">发送验证码</button></form></section>{% endblock %}
+11 -447
View File
@@ -1,454 +1,18 @@
<!DOCTYPE html> <!doctype html>
<html lang="zh-CN"> <html lang="zh-CN">
<head> <head>
<meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{ '注册' if mode == 'register' else '登录' }} | 供水管道健康评估系统</title> <title>{{ '注册' if mode == 'register' else '登录' }} | 供水管道健康评估系统</title>
<meta charset="utf-8" /> <link href="{{ url_for('static', filename='css/app.css') }}" rel="stylesheet">
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link href="{{ url_for('static', filename='css/app.css') }}" rel="stylesheet" />
<style>
.password-toggle {
position: absolute;
right: .75rem;
top: 50%;
display: inline-flex;
width: 32px;
height: 32px;
transform: translateY(-50%);
align-items: center;
justify-content: center;
border: 0;
border-radius: .5rem;
background: transparent;
color: #64748b;
padding: 0;
transition: background-color .15s ease, color .15s ease;
}
.password-toggle:hover {
background: rgba(148, 163, 184, .16);
color: #005EB8;
}
.password-toggle:focus-visible {
outline: 2px solid #005EB8;
outline-offset: 2px;
}
.password-toggle:disabled {
cursor: not-allowed;
opacity: .45;
}
.password-toggle .material-symbols-outlined {
font-size: 20px;
line-height: 1;
}
.auth-input-error {
border-color: #dc2626 !important;
background: #fff7f7 !important;
box-shadow: 0 0 0 3px rgba(220, 38, 38, .12) !important;
}
.captcha-token {
-webkit-user-select: none;
user-select: none;
}
.dot-grid {
background-image: radial-gradient(circle at 1px 1px, rgba(148,163,184,.30) 1.2px, transparent 0);
background-size: 42px 42px;
}
.panel-frame {
box-shadow: none;
border: none;
}
.gradient-board {
background: linear-gradient(180deg, #2455a3 0%, #123e7d 100%);
}
.auth-alert {
pointer-events: none;
opacity: 0;
transform: translateY(.5rem);
}
.auth-alert::before {
content: '';
position: absolute;
left: 24px;
top: -7px;
width: 14px;
height: 14px;
transform: rotate(45deg);
border-left: 1px solid currentColor;
border-top: 1px solid currentColor;
background: #fff;
color: #bfdbfe;
}
.auth-alert.is-error::before {
color: #fecaca;
}
.auth-alert.is-visible {
pointer-events: auto;
opacity: 1;
transform: translateY(0);
}
@media (min-width: 1280px) {
.auth-alert {
transform: translateX(.75rem);
}
.auth-alert::before {
left: -7px;
top: var(--auth-alert-arrow-top, 44px);
border: 0;
border-left: 1px solid currentColor;
border-bottom: 1px solid currentColor;
}
.auth-alert.is-visible {
transform: translateX(0);
}
}
</style>
</head> </head>
<body class="bg-page min-h-screen text-textMain"> <body class="min-h-screen bg-page text-textMain">
{% set page_notice = "当前未开放自助注册。系统仅支持管理员分配账号,请联系管理员完成账号开通后再登录。" if mode == 'register' and not allow_registration else none %} <main class="mx-auto flex min-h-screen max-w-md items-center px-5">
{% with flashed_messages = get_flashed_messages(with_categories=true) %} <section class="w-full rounded-2xl border border-line bg-white p-7 shadow-panel">
<script> <header class="mb-7 text-center"><span class="material-symbols-outlined text-4xl text-primary">water_drop</span><h1 class="mt-2 text-2xl font-extrabold">供水管道健康评估系统</h1><p class="mt-2 text-sm text-textSub">{{ '使用邮箱创建账号' if mode == 'register' else '使用邮箱安全登录' }}</p></header>
window.__flashMessages = {{ flashed_messages|tojson }}; {% with messages = get_flashed_messages(with_categories=true) %}{% for category, message in messages %}<p class="mb-4 rounded-lg p-3 text-sm {{ 'bg-dangerSoft text-dangerText' if category == 'error' else 'bg-blueSoft text-primary' }}">{{ message }}</p>{% endfor %}{% endwith %}
window.__pageNotice = {{ page_notice|tojson }}; <nav class="mb-5 flex gap-5 border-b border-line text-sm font-bold"><a class="pb-3 {{ 'border-b-2 border-primary text-primary' if mode == 'login' else 'text-textSub' }}" href="{{ url_for('main.login') }}">登录</a><a class="pb-3 {{ 'border-b-2 border-primary text-primary' if mode == 'register' else 'text-textSub' }}" href="{{ url_for('main.register') }}">注册</a></nav>
</script> {% if mode == 'login' %}{% include "_login_form.html" %}{% else %}{% include "_register_form.html" %}{% endif %}
<div class="sr-only" aria-hidden="true">
{% for category, message in flashed_messages %}{{ message }}{% endfor %}
{% if page_notice %}{{ page_notice }}{% endif %}
</div>
{% endwith %}
<div class="min-h-screen grid lg:grid-cols-[1.4fr_1fr]">
<section class="gradient-board hidden lg:flex flex-col justify-between px-16 py-14 text-white">
<div></div>
<div>
<div class="mb-8 flex items-center gap-3 text-[26px] font-extrabold tracking-tight text-white">
<span class="material-symbols-outlined text-[32px]">water_drop</span>
<span>供水管道健康评估系统</span>
</div>
<h1 class="text-[52px] leading-[1.5] font-extrabold tracking-tight">
供水管道健康状态与<br>
剩余寿命评估系统<br>
</h1>
<p class="mt-6 text-white/75 text-[15px] max-w-[440px] leading-7">上传管网数据,自动生成健康状态评估、剩余寿命预测与电子表格报告。</p>
</div>
<div class="text-white/50 text-[12px]">© {{ now_year() }} 供水管道健康评估系统</div>
</section> </section>
</main>
<section id="authPanel" class="relative bg-white flex flex-col justify-between min-h-screen">
<div class="flex-1 flex items-center px-7 sm:px-12 md:px-14 py-12">
<div id="authCard" class="relative w-full max-w-[360px] min-h-[620px] mx-auto">
<div id="alertBox" class="auth-alert fixed left-7 right-7 top-6 z-50 hidden rounded-lg border bg-white p-3.5 shadow-[0_18px_45px_rgba(15,23,42,.16)] transition-all duration-200 ease-out sm:left-12 sm:right-12 xl:left-auto xl:right-auto xl:w-[320px]" role="status" aria-live="polite">
<div class="flex items-start gap-3">
<span id="alertIconWrap" class="flex h-8 w-8 shrink-0 items-center justify-center rounded-md">
<span id="alertIcon" class="material-symbols-outlined text-lg">priority_high</span>
</span>
<div class="min-w-0 flex-1 pt-0.5">
<div id="alertTitle" class="text-sm font-extrabold text-textMain"></div>
<div id="alertMessage" class="mt-1 text-sm leading-5 text-textSub"></div>
</div>
<button id="alertClose" class="flex h-8 w-8 shrink-0 items-center justify-center rounded-md text-slate-400 transition hover:bg-slate-100 hover:text-slate-700" type="button" aria-label="关闭通知">
<span class="material-symbols-outlined text-base">close</span>
</button>
</div>
</div>
<div class="mb-8 flex items-center justify-center gap-2 text-center text-[18px] font-extrabold text-primary lg:hidden">
<span class="material-symbols-outlined text-[28px]">water_drop</span>
<span>供水管道健康评估系统</span>
</div>
<h2 class="text-center text-[44px] lg:text-[40px] font-extrabold tracking-tight {{ 'mb-7' if mode == 'register' else 'mb-10' }}">系统门户</h2>
<div class="flex items-center gap-8 text-[13px] font-semibold border-b border-slate-200 mb-7">
<a href="{{ url_for('main.login') }}" class="border-b-2 py-3 {{ 'text-primary border-primary' if mode == 'login' else 'text-slate-500 border-transparent' }}">登录</a>
<a href="{{ url_for('main.register') }}" class="border-b-2 py-3 {{ 'text-primary border-primary' if mode == 'register' else 'text-slate-500 border-transparent' }}">注册</a>
</div>
{% if mode == 'login' %}
<form method="post" action="{{ url_for('main.login') }}" class="space-y-5" novalidate data-auth-form>
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div>
<label class="block text-[11px] tracking-[0.18em] uppercase text-slate-500 mb-2">用户名</label>
<div class="relative">
<span class="material-symbols-outlined absolute left-4 top-1/2 -translate-y-1/2 text-slate-400 text-lg">person</span>
<input name="username" required data-field-label="用户名" class="w-full pl-11 pr-4 py-3.5 rounded-xl bg-[#eceff3] border border-transparent focus:border-primary focus:ring-0" placeholder="请输入用户名" />
</div>
</div>
<div>
<div class="flex items-center justify-between mb-2">
<label class="block text-[11px] tracking-[0.18em] uppercase text-slate-500">密码</label>
<button id="forgotPasswordBtn" type="button" class="text-[12px] font-semibold text-primary transition hover:text-primaryDeep">找回密码</button>
</div>
<div class="relative">
<span class="material-symbols-outlined absolute left-4 top-1/2 -translate-y-1/2 text-slate-400 text-lg">lock</span>
<input id="loginPassword" name="password" type="password" required data-field-label="密码" class="w-full pl-11 pr-12 py-3.5 rounded-xl bg-[#eceff3] border border-transparent focus:border-primary focus:ring-0" placeholder="请输入密码" />
<button class="password-toggle" type="button" data-password-toggle="loginPassword" aria-label="显示密码" aria-pressed="false">
<span class="material-symbols-outlined" aria-hidden="true">visibility</span>
</button>
</div>
</div>
<div>
<label class="block text-[11px] tracking-[0.18em] uppercase text-slate-500 mb-2">验证码</label>
<div class="grid grid-cols-[1fr_92px_44px] gap-3 items-center">
<div class="relative">
<span class="material-symbols-outlined absolute left-4 top-1/2 -translate-y-1/2 text-slate-400 text-lg">verified_user</span>
<input name="captcha" required data-field-label="验证码" class="w-full pl-11 pr-4 py-3.5 rounded-xl bg-[#eceff3] border border-transparent focus:border-primary focus:ring-0" placeholder="请输入验证码" />
</div>
<div class="captcha-token rounded-xl bg-blueSoft text-textMain border border-blue-100 h-[50px] flex items-center justify-center font-black tracking-[0.18em] italic" aria-label="验证码" data-captcha-token>{{ captcha }}</div>
<a href="{{ url_for('main.login') }}" class="ui-btn ui-btn-field ui-btn-secondary px-0" aria-label="刷新验证码">
<span class="material-symbols-outlined">refresh</span>
</a>
</div>
</div>
<label class="inline-flex items-center gap-2 text-sm text-slate-500">
<input type="checkbox" name="remember" class="rounded border-slate-300 text-primary focus:ring-primary" />
保持登录状态 24 小时
</label>
<button class="ui-btn ui-btn-lg ui-btn-primary w-full mt-2">
登录
<span class="material-symbols-outlined text-lg">arrow_forward</span>
</button>
</form>
{% else %}
<form method="post" action="{{ url_for('main.register') }}" class="space-y-4" novalidate data-auth-form>
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div>
<label class="block text-[11px] tracking-[0.18em] uppercase text-slate-500 mb-2">用户名</label>
<div class="relative">
<span class="material-symbols-outlined absolute left-4 top-1/2 -translate-y-1/2 text-slate-400 text-lg">badge</span>
<input name="username" required data-field-label="用户名" {{ 'disabled' if not allow_registration }} class="w-full pl-11 pr-4 py-3.5 rounded-xl bg-[#eceff3] border border-transparent focus:border-primary focus:ring-0 disabled:cursor-not-allowed disabled:bg-slate-100 disabled:text-slate-400" placeholder="请输入用户名" />
</div>
</div>
<div>
<label class="block text-[11px] tracking-[0.18em] uppercase text-slate-500 mb-2">密码</label>
<div class="relative">
<span class="material-symbols-outlined absolute left-4 top-1/2 -translate-y-1/2 text-slate-400 text-lg">lock</span>
<input id="registerPassword" name="password" type="password" minlength="6" required data-field-label="密码" {{ 'disabled' if not allow_registration }} class="w-full pl-11 pr-12 py-3.5 rounded-xl bg-[#eceff3] border border-transparent focus:border-primary focus:ring-0 disabled:cursor-not-allowed disabled:bg-slate-100 disabled:text-slate-400" placeholder="请输入至少 6 位密码" />
<button class="password-toggle" type="button" data-password-toggle="registerPassword" aria-label="显示密码" aria-pressed="false" {{ 'disabled' if not allow_registration }}>
<span class="material-symbols-outlined" aria-hidden="true">visibility</span>
</button>
</div>
</div>
<div>
<label class="block text-[11px] tracking-[0.18em] uppercase text-slate-500 mb-2">验证码</label>
<div class="grid grid-cols-[1fr_92px_44px] gap-3 items-center">
<div class="relative">
<span class="material-symbols-outlined absolute left-4 top-1/2 -translate-y-1/2 text-slate-400 text-lg">verified_user</span>
<input name="captcha" required data-field-label="验证码" {{ 'disabled' if not allow_registration }} class="w-full pl-11 pr-4 py-3.5 rounded-xl bg-[#eceff3] border border-transparent focus:border-primary focus:ring-0 disabled:cursor-not-allowed disabled:bg-slate-100 disabled:text-slate-400" placeholder="请输入验证码" />
</div>
<div class="captcha-token rounded-xl bg-blueSoft text-textMain border border-blue-100 h-[50px] flex items-center justify-center font-black tracking-[0.18em] italic" aria-label="验证码" data-captcha-token>{{ captcha }}</div>
<a href="{{ url_for('main.register') }}" class="ui-btn ui-btn-field ui-btn-secondary px-0 {{ 'pointer-events-none opacity-50' if not allow_registration }}" aria-label="刷新验证码" aria-disabled="{{ 'true' if not allow_registration else 'false' }}">
<span class="material-symbols-outlined">refresh</span>
</a>
</div>
</div>
<button {{ 'disabled' if not allow_registration }} class="ui-btn ui-btn-lg ui-btn-primary w-full mt-2">
注册
<span class="material-symbols-outlined text-lg">person_add</span>
</button>
</form>
{% endif %}
</div>
</div>
</section>
</div>
<script>
(() => {
const alertBox = document.getElementById('alertBox');
const alertIconWrap = document.getElementById('alertIconWrap');
const alertIcon = document.getElementById('alertIcon');
const alertTitle = document.getElementById('alertTitle');
const alertMessage = document.getElementById('alertMessage');
const alertClose = document.getElementById('alertClose');
const authCard = document.getElementById('authCard');
let alertTimer = null;
let alertHideTimer = null;
let alertAnchor = null;
function hideAlert() {
clearTimeout(alertTimer);
clearTimeout(alertHideTimer);
if (!alertBox) return;
alertBox.classList.remove('is-visible');
alertHideTimer = setTimeout(() => {
alertBox.classList.add('hidden');
}, 200);
}
function positionAlert(anchor) {
if (!alertBox || !authCard) return;
const cardRect = authCard.getBoundingClientRect();
const anchorRect = anchor?.getBoundingClientRect();
const useSidePopover = window.matchMedia('(min-width: 1280px)').matches;
if (!useSidePopover) {
alertBox.style.left = '';
alertBox.style.right = '';
alertBox.style.top = '';
alertBox.style.setProperty('--auth-alert-arrow-top', '28px');
return;
}
const width = 320;
const gap = 18;
const viewportPadding = 16;
const desiredLeft = cardRect.right + gap;
const left = Math.min(desiredLeft, window.innerWidth - width - viewportPadding);
const targetCenter = anchorRect ? anchorRect.top + (anchorRect.height / 2) : cardRect.top + 116;
const top = Math.max(viewportPadding, Math.min(targetCenter - 42, window.innerHeight - 140));
const arrowTop = Math.max(22, Math.min(targetCenter - top - 7, 92));
alertBox.style.left = `${left}px`;
alertBox.style.right = 'auto';
alertBox.style.top = `${top}px`;
alertBox.style.setProperty('--auth-alert-arrow-top', `${arrowTop}px`);
}
function showAppNotification(message, type = 'info', title, anchor) {
if (!alertBox || !alertIconWrap || !alertIcon || !alertTitle || !alertMessage) return;
clearTimeout(alertTimer);
clearTimeout(alertHideTimer);
alertAnchor = anchor || null;
positionAlert(anchor);
alertBox.classList.remove('hidden', 'border-red-200', 'border-blue-200', 'is-error');
alertIconWrap.classList.remove('bg-dangerSoft', 'text-dangerText', 'bg-blueSoft', 'text-primary');
if (type === 'error') {
alertBox.classList.add('border-red-200', 'is-error');
alertIconWrap.classList.add('bg-dangerSoft', 'text-dangerText');
alertIcon.textContent = 'priority_high';
alertTitle.textContent = title || '操作未完成';
} else {
alertBox.classList.add('border-blue-200');
alertIconWrap.classList.add('bg-blueSoft', 'text-primary');
alertIcon.textContent = 'info';
alertTitle.textContent = title || '提示';
}
alertMessage.textContent = message;
requestAnimationFrame(() => {
alertBox.classList.add('is-visible');
});
alertTimer = setTimeout(hideAlert, 10000);
}
if (alertClose) {
alertClose.addEventListener('click', hideAlert);
}
window.addEventListener('resize', () => {
if (alertBox && alertBox.classList.contains('is-visible')) {
positionAlert(alertAnchor);
}
});
function markFieldError(field) {
if (!field) return;
field.classList.add('auth-input-error');
field.setAttribute('aria-invalid', 'true');
}
function clearFieldError(field) {
field.classList.remove('auth-input-error');
field.removeAttribute('aria-invalid');
}
function clearFormErrors(form) {
form.querySelectorAll('.auth-input-error').forEach(clearFieldError);
}
function fieldForServerMessage(message) {
const activeForm = document.querySelector('[data-auth-form]');
if (!activeForm) return null;
if (message.includes('验证码')) return activeForm.querySelector('input[name="captcha"]');
if (message.includes('密码')) return activeForm.querySelector('input[name="password"]');
if (message.includes('用户名')) return activeForm.querySelector('input[name="username"]');
return null;
}
const flashedMessages = window.__flashMessages || [];
const pageNotice = window.__pageNotice;
if (flashedMessages.length) {
const [category, message] = flashedMessages[flashedMessages.length - 1];
const serverField = category === 'error' ? fieldForServerMessage(message) : null;
showAppNotification(message, category === 'error' ? 'error' : 'info', undefined, serverField);
if (category === 'error') {
markFieldError(serverField);
}
} else if (pageNotice) {
showAppNotification(pageNotice);
}
document.querySelectorAll('[data-auth-form]').forEach((form) => {
form.querySelectorAll('input').forEach((field) => {
field.addEventListener('input', () => clearFieldError(field));
});
form.addEventListener('submit', (event) => {
clearFormErrors(form);
const fields = Array.from(form.querySelectorAll('input[required]:not(:disabled)'));
const emptyField = fields.find((field) => !field.value.trim());
if (emptyField) {
event.preventDefault();
const label = emptyField.dataset.fieldLabel || '必填项';
showAppNotification(`${label}不能为空`, 'error', undefined, emptyField);
markFieldError(emptyField);
emptyField.focus();
return;
}
const shortPassword = fields.find((field) => {
const minLength = Number(field.getAttribute('minlength'));
return minLength > 0 && field.value.length < minLength;
});
if (shortPassword) {
event.preventDefault();
const minLength = shortPassword.getAttribute('minlength');
showAppNotification(`密码至少需要 ${minLength}`, 'error', undefined, shortPassword);
markFieldError(shortPassword);
shortPassword.focus();
}
});
});
document.querySelectorAll('[data-password-toggle]').forEach((toggle) => {
const input = document.getElementById(toggle.dataset.passwordToggle);
const icon = toggle.querySelector('.material-symbols-outlined');
if (!input || !icon) return;
toggle.addEventListener('click', () => {
const shouldShow = input.type === 'password';
input.type = shouldShow ? 'text' : 'password';
icon.textContent = shouldShow ? 'visibility_off' : 'visibility';
toggle.setAttribute('aria-label', shouldShow ? '隐藏密码' : '显示密码');
toggle.setAttribute('aria-pressed', shouldShow ? 'true' : 'false');
});
});
document.querySelectorAll('[data-captcha-token]').forEach((token) => {
token.addEventListener('contextmenu', (event) => event.preventDefault());
token.addEventListener('selectstart', (event) => event.preventDefault());
});
document.addEventListener('copy', (event) => {
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0) return;
const range = selection.getRangeAt(0);
const includesCaptcha = Array.from(document.querySelectorAll('[data-captcha-token]')).some((token) => {
return range.intersectsNode(token);
});
if (includesCaptcha) {
event.preventDefault();
}
});
document.getElementById('forgotPasswordBtn')?.addEventListener('click', () => {
showAppNotification('请联系管理员获取一次性重置链接后设置新密码。', 'info', '密码重置');
});
})();
</script>
</body> </body>
</html> </html>
+1
View File
@@ -0,0 +1 @@
{% extends "base.html" %}{% block content %}<section class="mx-auto max-w-md rounded-xl border border-line bg-white p-6 shadow-panel"><h1 class="text-2xl font-extrabold">{{ title }}</h1>{% with messages=get_flashed_messages(with_categories=true) %}{% for c,m in messages %}<p class="mt-3 text-sm text-dangerText">{{ m }}</p>{% endfor %}{% endwith %}<form method="post" action="{{ action }}" class="mt-5 space-y-4"><input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><input name="password" type="password" minlength="12" required class="w-full rounded-lg border border-line px-3 py-2" placeholder="新密码(12 至 128 位)"><input name="password_confirm" type="password" minlength="12" required class="w-full rounded-lg border border-line px-3 py-2" placeholder="再次输入新密码"><button class="ui-btn ui-btn-primary w-full">保存密码</button></form></section>{% endblock %}
+1
View File
@@ -0,0 +1 @@
<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>{{ title }}</title><link href="{{ url_for('static', filename='css/app.css') }}" rel="stylesheet"></head><body class="min-h-screen bg-page"><main class="mx-auto flex min-h-screen max-w-md items-center px-5"><section class="w-full rounded-2xl border border-line bg-white p-7 shadow-panel"><h1 class="text-2xl font-extrabold">{{ title }}</h1><p class="mt-2 text-sm text-textSub">验证码已发送至 {{ email }},10 分钟内有效。</p>{% with messages=get_flashed_messages(with_categories=true) %}{% for c,m in messages %}<p class="mt-4 text-sm text-dangerText">{{ m }}</p>{% endfor %}{% endwith %}<form method="post" class="mt-6 space-y-4"><input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><label class="block text-sm font-semibold">6 位验证码<input name="code" inputmode="numeric" pattern="[0-9]{6}" maxlength="6" required autofocus class="mt-1 w-full rounded-lg border border-line px-3 py-2 text-center text-xl tracking-[.5em]"></label><button class="ui-btn ui-btn-primary w-full">验证</button></form><form method="post" action="{{ url_for('main.resend_code', purpose=purpose) }}" class="mt-3"><input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><button class="text-sm font-semibold text-primary">重新发送验证码</button></form></section></main></body></html>
+77 -555
View File
@@ -2,581 +2,103 @@ from __future__ import annotations
import re import re
import unittest import unittest
from datetime import datetime, timedelta
from tempfile import TemporaryDirectory from tempfile import TemporaryDirectory
from urllib.parse import urlparse from unittest.mock import patch
from app import create_app from app import create_app
from app.config import Config from app.config import Config
from app.extensions import db from app.extensions import db
from app.models import AppSetting, PasswordResetToken, UploadRecord, User from app.models import EmailVerificationCode, TrustedDevice, User
from app.time_utils import utc_now
class RegistrationRoutesTest(unittest.TestCase): class EmailAuthenticationTest(unittest.TestCase):
def create_test_app( def create_app(self, directory: str):
self,
temp_dir: str,
*,
allow_registration: bool,
app_timezone: str = "Asia/Shanghai",
):
class TestConfig(Config): class TestConfig(Config):
TESTING = True TESTING = True
SECRET_KEY = "test-secret" SECRET_KEY = "test-secret"
SECRET_KEY_GENERATED = False SECRET_KEY_GENERATED = False
SQLALCHEMY_DATABASE_URI = f"sqlite:///{temp_dir}/test.db" SQLALCHEMY_DATABASE_URI = f"sqlite:///{directory}/test.db"
ALLOW_REGISTRATION = allow_registration ALLOW_REGISTRATION = True
APP_TIMEZONE = app_timezone
ADMIN_PASSWORD = None ADMIN_PASSWORD = None
ADMIN_EMAIL = ""
RESEND_API_KEY = "test"
RESEND_FROM_EMAIL = "no-reply@waternetwork.cn"
SESSION_COOKIE_SECURE = False
return create_app(TestConfig, load_model_on_start=False) return create_app(TestConfig, load_model_on_start=False)
def csrf_token_from(self, html: bytes) -> str: def csrf(self, response) -> str:
match = re.search(rb'name="csrf_token" value="([^"]+)"', html) return re.search(rb'name="csrf_token" value="([^"]+)"', response.data).group(1).decode()
self.assertIsNotNone(match)
return match.group(1).decode()
def create_user(self, app, username: str, password: str, *, is_admin: bool = False) -> int: def form(self, client, path: str, **data):
page = client.get(path)
data["csrf_token"] = self.csrf(page)
return client.post(path, data=data)
@patch("app.routes.send_transactional_email")
@patch("app.routes.secrets.randbelow", return_value=123456)
def test_registration_requires_and_consumes_email_code(self, _random, _send):
with TemporaryDirectory() as directory:
app = self.create_app(directory); client = app.test_client()
page = client.get("/register")
with client.session_transaction() as state: captcha = state["captcha"]
response = client.post("/register", data={"csrf_token": self.csrf(page), "username": "Alice", "email": "Alice@example.com", "password": "password-1234", "captcha": captcha})
self.assertEqual(response.status_code, 302)
with app.app_context(): with app.app_context():
user = User(username=username, is_admin=is_admin) user = User.query.filter_by(email="alice@example.com").one()
user.set_password(password) self.assertFalse(user.is_active_account)
db.session.add(user) self.assertEqual(EmailVerificationCode.query.count(), 1)
db.session.commit() verify = client.get("/verify/register")
return user.id response = client.post("/verify/register", data={"csrf_token": self.csrf(verify), "code": "123456"})
self.assertEqual(response.status_code, 302)
with app.app_context(): self.assertTrue(User.query.filter_by(email="alice@example.com").one().is_active_account)
def add_upload_records(self, app, username: str, count: int) -> None: @patch("app.routes.send_transactional_email")
@patch("app.routes.secrets.randbelow", return_value=123456)
def test_unknown_device_requires_email_mfa_and_creates_trusted_device(self, _random, _send):
with TemporaryDirectory() as directory:
app = self.create_app(directory)
with app.app_context(): with app.app_context():
user = User.query.filter_by(username=username).one() user = User(username="Alice", email="alice@example.com", is_active_account=True); user.set_password("password-1234"); db.session.add(user); db.session.commit()
base_time = datetime(2026, 1, 1, 12, 0, 0) client = app.test_client(); page = client.get("/login")
for index in range(count): with client.session_transaction() as state: captcha = state["captcha"]
db.session.add( response = client.post("/login", data={"csrf_token": self.csrf(page), "email": "alice@example.com", "password": "password-1234", "captcha": captcha})
UploadRecord( self.assertEqual(response.location, "/verify/login")
user_id=user.id, verify = client.get("/verify/login")
original_filename=f"{username}-file-{index:02d}.xlsx", response = client.post("/verify/login", data={"csrf_token": self.csrf(verify), "code": "123456"})
saved_path=f"/tmp/{username}-original-{index:02d}.xlsx", self.assertEqual(response.status_code, 302)
prediction_path=f"/tmp/{username}-prediction-{index:02d}.xlsx", with app.app_context(): self.assertEqual(TrustedDevice.query.count(), 1)
image_path=f"/tmp/{username}-image-{index:02d}.png",
upload_time=base_time + timedelta(minutes=index),
)
)
db.session.commit()
def login(self, client, username: str, password: str) -> None: @patch("app.routes.send_transactional_email")
response = client.get("/login") @patch("app.routes.secrets.randbelow", return_value=123456)
token = self.csrf_token_from(response.data) def test_password_reset_revokes_trusted_devices(self, _random, _send):
with client.session_transaction() as session: with TemporaryDirectory() as directory:
captcha = session["captcha"] app = self.create_app(directory)
login_response = client.post(
"/login",
data={
"csrf_token": token,
"username": username,
"password": password,
"captcha": captcha,
},
)
self.assertEqual(login_response.status_code, 302)
def login_attempt(self, client, username: str, password: str):
response = client.get("/login")
token = self.csrf_token_from(response.data)
with client.session_transaction() as session:
captcha = session["captcha"]
return client.post(
"/login",
data={
"csrf_token": token,
"username": username,
"password": password,
"captcha": captcha,
},
)
def create_reset_link(self, app, client, user_id: int) -> str:
token = self.csrf_token_from(client.get("/admin").data)
response = client.post(
f"/admin/users/{user_id}/password-reset-link",
data={"csrf_token": token},
)
self.assertEqual(response.status_code, 200)
return urlparse(response.get_json()["reset_url"]).path
def test_login_page_always_shows_register_entry_when_registration_is_closed(self) -> None:
with TemporaryDirectory() as temp_dir:
app = self.create_test_app(temp_dir, allow_registration=False)
response = app.test_client().get("/login")
self.assertEqual(response.status_code, 200)
self.assertIn('href="/register"', response.get_data(as_text=True))
def test_register_page_is_visible_but_disabled_when_registration_is_closed(self) -> None:
with TemporaryDirectory() as temp_dir:
app = self.create_test_app(temp_dir, allow_registration=False)
response = app.test_client().get("/register")
html = response.get_data(as_text=True)
self.assertEqual(response.status_code, 200)
self.assertIn("当前未开放自助注册", html)
self.assertIn("disabled", html)
def test_register_page_shows_captcha(self) -> None:
with TemporaryDirectory() as temp_dir:
app = self.create_test_app(temp_dir, allow_registration=True)
client = app.test_client()
response = client.get("/register")
html = response.get_data(as_text=True)
with client.session_transaction() as session:
captcha = session["captcha"]
self.assertEqual(response.status_code, 200)
self.assertIn('name="captcha"', html)
self.assertIn(captcha, html)
def test_template_download_uses_chinese_filename(self) -> None:
with TemporaryDirectory() as temp_dir:
app = self.create_test_app(temp_dir, allow_registration=True)
response = app.test_client().get("/download_template")
self.assertEqual(response.status_code, 200)
self.assertIn("attachment", response.headers["Content-Disposition"])
self.assertIn(
"filename*=UTF-8''%E7%AE%A1%E9%81%93%E9%A2%84%E6%B5%8B%E6%95%B0%E6%8D%AE%E6%A8%A1%E6%9D%BF.xlsx",
response.headers["Content-Disposition"],
)
def test_register_post_does_not_create_user_when_registration_is_closed(self) -> None:
with TemporaryDirectory() as temp_dir:
app = self.create_test_app(temp_dir, allow_registration=False)
client = app.test_client()
token = self.csrf_token_from(client.get("/register").data)
with client.session_transaction() as session:
captcha = session["captcha"]
response = client.post(
"/register",
data={
"csrf_token": token,
"username": "new-user",
"password": "secret123",
"captcha": captcha,
},
)
self.assertEqual(response.status_code, 403)
self.assertIn("当前未开放自助注册", response.get_data(as_text=True))
with app.app_context(): with app.app_context():
self.assertIsNone(User.query.filter_by(username="new-user").first()) user = User(username="Alice", email="alice@example.com", is_active_account=True); user.set_password("password-1234"); db.session.add(user); db.session.commit(); db.session.add(TrustedDevice(user_id=user.id, token_hash="a" * 64, auth_version=1, expires_at=__import__('app.time_utils', fromlist=['utc_now']).utc_now())); db.session.commit()
def test_register_post_checks_captcha_before_registration_setting(self) -> None:
with TemporaryDirectory() as temp_dir:
app = self.create_test_app(temp_dir, allow_registration=False)
client = app.test_client() client = app.test_client()
token = self.csrf_token_from(client.get("/register").data) response = self.form(client, "/forgot-password", email="alice@example.com")
self.assertEqual(response.status_code, 302)
response = client.post( verify = client.get("/verify/reset")
"/register", response = client.post("/verify/reset", data={"csrf_token": self.csrf(verify), "code": "123456"})
data={ self.assertEqual(response.location, "/set-password")
"csrf_token": token, page = client.get("/set-password")
"username": "new-user", response = client.post("/set-password", data={"csrf_token": self.csrf(page), "password": "new-password-1234", "password_confirm": "new-password-1234"})
"password": "secret123", self.assertEqual(response.status_code, 302)
"captcha": "WRONG", with app.app_context():
}, user = User.query.filter_by(email="alice@example.com").one()
) self.assertTrue(user.check_password("new-password-1234")); self.assertEqual(TrustedDevice.query.count(), 0); self.assertEqual(user.auth_version, 2)
@patch("app.routes.send_transactional_email")
@patch("app.routes.secrets.randbelow", return_value=123456)
def test_invalid_code_attempts_are_limited(self, _random, _send):
with TemporaryDirectory() as directory:
app = self.create_app(directory); client = app.test_client()
with app.app_context():
user = User(username="Alice", email="alice@example.com", is_active_account=True); user.set_password("password-1234"); db.session.add(user); db.session.commit()
page = client.get("/login")
with client.session_transaction() as state: captcha = state["captcha"]
client.post("/login", data={"csrf_token": self.csrf(page), "email": "alice@example.com", "password": "password-1234", "captcha": captcha})
for _ in range(5):
page = client.get("/verify/login"); client.post("/verify/login", data={"csrf_token": self.csrf(page), "code": "000000"})
page = client.get("/verify/login"); response = client.post("/verify/login", data={"csrf_token": self.csrf(page), "code": "123456"})
self.assertEqual(response.status_code, 400) self.assertEqual(response.status_code, 400)
self.assertIn("验证码错误", response.get_data(as_text=True))
with app.app_context():
self.assertIsNone(User.query.filter_by(username="new-user").first())
def test_register_post_rejects_wrong_captcha(self) -> None:
with TemporaryDirectory() as temp_dir:
app = self.create_test_app(temp_dir, allow_registration=True)
client = app.test_client()
token = self.csrf_token_from(client.get("/register").data)
response = client.post(
"/register",
data={
"csrf_token": token,
"username": "new-user",
"password": "secret123",
"captcha": "WRONG",
},
)
self.assertEqual(response.status_code, 400)
self.assertIn("验证码错误", response.get_data(as_text=True))
with app.app_context():
self.assertIsNone(User.query.filter_by(username="new-user").first())
def test_register_post_creates_user_when_registration_is_open(self) -> None:
with TemporaryDirectory() as temp_dir:
app = self.create_test_app(temp_dir, allow_registration=True)
client = app.test_client()
token = self.csrf_token_from(client.get("/register").data)
with client.session_transaction() as session:
captcha = session["captcha"]
response = client.post(
"/register",
data={
"csrf_token": token,
"username": "new-user",
"password": "secret123",
"captcha": captcha,
},
)
self.assertEqual(response.status_code, 200)
self.assertIn("注册成功,请登录", response.get_data(as_text=True))
with app.app_context():
user = User.query.filter_by(username="new-user").first()
self.assertIsNotNone(user)
self.assertFalse(user.is_admin)
def test_admin_can_enable_registration_from_admin_panel(self) -> None:
with TemporaryDirectory() as temp_dir:
app = self.create_test_app(temp_dir, allow_registration=False)
self.create_user(app, "admin", "secret123", is_admin=True)
client = app.test_client()
self.login(client, "admin", "secret123")
token = self.csrf_token_from(client.get("/admin").data)
response = client.post(
"/admin/registration",
data={"csrf_token": token, "allow_registration": "on"},
follow_redirects=True,
)
self.assertEqual(response.status_code, 200)
self.assertIn("已开放用户自助注册", response.get_data(as_text=True))
with app.app_context():
self.assertTrue(AppSetting.get_bool("allow_registration"))
register_page = client.get("/register").get_data(as_text=True)
self.assertNotIn("当前未开放自助注册", register_page)
def test_admin_can_disable_registration_from_admin_panel(self) -> None:
with TemporaryDirectory() as temp_dir:
app = self.create_test_app(temp_dir, allow_registration=True)
self.create_user(app, "admin", "secret123", is_admin=True)
client = app.test_client()
self.login(client, "admin", "secret123")
token = self.csrf_token_from(client.get("/admin").data)
response = client.post(
"/admin/registration",
data={"csrf_token": token},
follow_redirects=True,
)
self.assertEqual(response.status_code, 200)
self.assertIn("已关闭用户自助注册", response.get_data(as_text=True))
with app.app_context():
self.assertFalse(AppSetting.get_bool("allow_registration", True))
def test_admin_can_create_password_reset_link_for_regular_user(self) -> None:
with TemporaryDirectory() as temp_dir:
app = self.create_test_app(temp_dir, allow_registration=False)
self.create_user(app, "admin", "secret123", is_admin=True)
user_id = self.create_user(app, "alice", "oldpass")
client = app.test_client()
self.login(client, "admin", "secret123")
token = self.csrf_token_from(client.get("/admin").data)
response = client.post(
f"/admin/users/{user_id}/password-reset-link",
data={"csrf_token": token},
)
data = response.get_json()
self.assertEqual(response.status_code, 200)
self.assertIn("/password-reset/", data["reset_url"])
self.assertEqual(data["username"], "alice")
with app.app_context():
self.assertEqual(PasswordResetToken.query.count(), 1)
def test_admin_password_reset_section_lists_registered_users_without_uploads(self) -> None:
with TemporaryDirectory() as temp_dir:
app = self.create_test_app(temp_dir, allow_registration=True)
self.create_user(app, "admin", "secret123", is_admin=True)
client = app.test_client()
register_page = client.get("/register")
token = self.csrf_token_from(register_page.data)
with client.session_transaction() as session:
captcha = session["captcha"]
register_response = client.post(
"/register",
data={
"csrf_token": token,
"username": "registered-user",
"password": "secret123",
"captcha": captcha,
},
)
self.assertEqual(register_response.status_code, 200)
self.login(client, "admin", "secret123")
admin_page = client.get("/admin").get_data(as_text=True)
self.assertIn("用户密码重置", admin_page)
self.assertIn("registered-user", admin_page)
self.assertIn("生成重置链接", admin_page)
def test_password_reset_link_requires_admin(self) -> None:
with TemporaryDirectory() as temp_dir:
app = self.create_test_app(temp_dir, allow_registration=False)
user_id = self.create_user(app, "alice", "oldpass")
client = app.test_client()
token = self.csrf_token_from(client.get("/login").data)
anonymous_response = client.post(
f"/admin/users/{user_id}/password-reset-link",
data={"csrf_token": token},
)
self.assertEqual(anonymous_response.status_code, 302)
self.login(client, "alice", "oldpass")
token = self.csrf_token_from(client.get("/home").data)
user_response = client.post(
f"/admin/users/{user_id}/password-reset-link",
data={"csrf_token": token},
)
self.assertEqual(user_response.status_code, 403)
def test_admin_cannot_create_password_reset_link_for_admin_user(self) -> None:
with TemporaryDirectory() as temp_dir:
app = self.create_test_app(temp_dir, allow_registration=False)
admin_id = self.create_user(app, "admin", "secret123", is_admin=True)
client = app.test_client()
self.login(client, "admin", "secret123")
token = self.csrf_token_from(client.get("/admin").data)
response = client.post(
f"/admin/users/{admin_id}/password-reset-link",
data={"csrf_token": token},
)
self.assertEqual(response.status_code, 403)
self.assertIn("管理员账号", response.get_json()["error"])
def test_password_reset_changes_password_and_consumes_link(self) -> None:
with TemporaryDirectory() as temp_dir:
app = self.create_test_app(temp_dir, allow_registration=False)
self.create_user(app, "admin", "secret123", is_admin=True)
user_id = self.create_user(app, "alice", "oldpass")
admin_client = app.test_client()
self.login(admin_client, "admin", "secret123")
reset_path = self.create_reset_link(app, admin_client, user_id)
client = app.test_client()
reset_page = client.get(reset_path)
token = self.csrf_token_from(reset_page.data)
response = client.post(
reset_path,
data={
"csrf_token": token,
"password": "newpass123",
"password_confirm": "newpass123",
},
)
self.assertEqual(response.status_code, 200)
self.assertIn("密码已重置", response.get_data(as_text=True))
self.assertEqual(self.login_attempt(client, "alice", "oldpass").status_code, 400)
self.assertEqual(self.login_attempt(client, "alice", "newpass123").status_code, 302)
self.assertEqual(client.get(reset_path).status_code, 400)
with app.app_context():
reset_token = PasswordResetToken.query.one()
self.assertIsNotNone(reset_token.used_at)
def test_password_reset_page_displays_expiry_in_configured_timezone(self) -> None:
with TemporaryDirectory() as temp_dir:
app = self.create_test_app(temp_dir, allow_registration=False)
self.create_user(app, "admin", "secret123", is_admin=True)
user_id = self.create_user(app, "alice", "oldpass")
admin_client = app.test_client()
self.login(admin_client, "admin", "secret123")
reset_path = self.create_reset_link(app, admin_client, user_id)
with app.app_context():
reset_token = PasswordResetToken.query.one()
reset_token.expires_at = datetime(2027, 1, 1, 0, 0, 0)
db.session.commit()
response = app.test_client().get(reset_path)
self.assertEqual(response.status_code, 200)
self.assertIn("2027-01-01 08:00:00", response.get_data(as_text=True))
def test_expired_password_reset_link_cannot_change_password(self) -> None:
with TemporaryDirectory() as temp_dir:
app = self.create_test_app(temp_dir, allow_registration=False)
self.create_user(app, "admin", "secret123", is_admin=True)
user_id = self.create_user(app, "alice", "oldpass")
admin_client = app.test_client()
self.login(admin_client, "admin", "secret123")
reset_path = self.create_reset_link(app, admin_client, user_id)
with app.app_context():
reset_token = PasswordResetToken.query.one()
reset_token.expires_at = utc_now() - timedelta(minutes=1)
db.session.commit()
client = app.test_client()
token = self.csrf_token_from(client.get("/login").data)
response = client.post(
reset_path,
data={
"csrf_token": token,
"password": "newpass123",
"password_confirm": "newpass123",
},
)
self.assertEqual(response.status_code, 400)
self.assertEqual(self.login_attempt(client, "alice", "oldpass").status_code, 302)
def test_new_password_reset_link_invalidates_previous_link(self) -> None:
with TemporaryDirectory() as temp_dir:
app = self.create_test_app(temp_dir, allow_registration=False)
self.create_user(app, "admin", "secret123", is_admin=True)
user_id = self.create_user(app, "alice", "oldpass")
client = app.test_client()
self.login(client, "admin", "secret123")
first_path = self.create_reset_link(app, client, user_id)
second_path = self.create_reset_link(app, client, user_id)
self.assertEqual(client.get(first_path).status_code, 400)
self.assertEqual(client.get(second_path).status_code, 200)
def test_password_reset_validation_does_not_consume_link(self) -> None:
with TemporaryDirectory() as temp_dir:
app = self.create_test_app(temp_dir, allow_registration=False)
self.create_user(app, "admin", "secret123", is_admin=True)
user_id = self.create_user(app, "alice", "oldpass")
admin_client = app.test_client()
self.login(admin_client, "admin", "secret123")
reset_path = self.create_reset_link(app, admin_client, user_id)
client = app.test_client()
reset_page = client.get(reset_path)
token = self.csrf_token_from(reset_page.data)
short_response = client.post(
reset_path,
data={
"csrf_token": token,
"password": "short",
"password_confirm": "short",
},
)
self.assertEqual(short_response.status_code, 400)
reset_page = client.get(reset_path)
token = self.csrf_token_from(reset_page.data)
mismatch_response = client.post(
reset_path,
data={
"csrf_token": token,
"password": "newpass123",
"password_confirm": "different",
},
)
self.assertEqual(mismatch_response.status_code, 400)
self.assertEqual(client.get(reset_path).status_code, 200)
with app.app_context():
reset_token = PasswordResetToken.query.one()
self.assertIsNone(reset_token.used_at)
def test_history_page_paginates_upload_records(self) -> None:
with TemporaryDirectory() as temp_dir:
app = self.create_test_app(temp_dir, allow_registration=False)
self.create_user(app, "alice", "secret123")
self.add_upload_records(app, "alice", 12)
client = app.test_client()
self.login(client, "alice", "secret123")
first_page = client.get("/history").get_data(as_text=True)
second_page = client.get("/history?page=2").get_data(as_text=True)
self.assertIn("共 12 条", first_page)
self.assertIn("alice-file-11.xlsx", first_page)
self.assertIn("alice-file-02.xlsx", first_page)
self.assertNotIn("alice-file-01.xlsx", first_page)
self.assertIn("第 <span class=\"font-bold text-textMain\">2</span> / 2 页", second_page)
self.assertIn("alice-file-01.xlsx", second_page)
self.assertIn("alice-file-00.xlsx", second_page)
def test_history_page_displays_utc_upload_time_in_configured_timezone(self) -> None:
with TemporaryDirectory() as temp_dir:
app = self.create_test_app(
temp_dir,
allow_registration=False,
app_timezone="America/New_York",
)
self.create_user(app, "alice", "secret123")
self.add_upload_records(app, "alice", 1)
client = app.test_client()
self.login(client, "alice", "secret123")
html = client.get("/history").get_data(as_text=True)
self.assertIn("2026-01-01 07:00:00", html)
def test_admin_page_paginates_upload_records(self) -> None:
with TemporaryDirectory() as temp_dir:
app = self.create_test_app(temp_dir, allow_registration=False)
self.create_user(app, "admin", "secret123", is_admin=True)
self.create_user(app, "alice", "secret123")
self.add_upload_records(app, "alice", 12)
client = app.test_client()
self.login(client, "admin", "secret123")
first_page = client.get("/admin").get_data(as_text=True)
second_page = client.get("/admin?page=2").get_data(as_text=True)
self.assertIn("共 12 条", first_page)
self.assertIn("alice-file-11.xlsx", first_page)
self.assertIn("alice-file-02.xlsx", first_page)
self.assertNotIn("alice-file-01.xlsx", first_page)
self.assertIn("alice-file-01.xlsx", second_page)
self.assertIn("alice-file-00.xlsx", second_page)
def test_admin_page_displays_utc_times_in_configured_timezone(self) -> None:
with TemporaryDirectory() as temp_dir:
app = self.create_test_app(
temp_dir,
allow_registration=False,
app_timezone="America/New_York",
)
self.create_user(app, "admin", "secret123", is_admin=True)
self.create_user(app, "alice", "secret123")
with app.app_context():
alice = User.query.filter_by(username="alice").one()
alice.created_at = datetime(2026, 1, 1, 0, 0, 0)
db.session.commit()
self.add_upload_records(app, "alice", 1)
client = app.test_client()
self.login(client, "admin", "secret123")
html = client.get("/admin").get_data(as_text=True)
self.assertIn("2025-12-31 19:00:00", html)
self.assertIn("2026-01-01 07:00:00", html)
if __name__ == "__main__":
unittest.main()
+58
View File
@@ -0,0 +1,58 @@
from __future__ import annotations
import unittest
from tempfile import TemporaryDirectory
from unittest.mock import patch
from app import create_app
from app.config import Config
from app.email import EmailConfigurationError, send_transactional_email
class TransactionalEmailTest(unittest.TestCase):
def create_test_app(self, temp_dir: str, *, configured: bool):
class TestConfig(Config):
TESTING = True
SECRET_KEY = "test-secret"
SECRET_KEY_GENERATED = False
SQLALCHEMY_DATABASE_URI = f"sqlite:///{temp_dir}/test.db"
ADMIN_PASSWORD = None
RESEND_API_KEY = "re_test_key" if configured else ""
RESEND_FROM_EMAIL = "no-reply@auth.example.com" if configured else ""
return create_app(TestConfig, load_model_on_start=False)
def test_send_requires_resend_configuration(self) -> None:
with TemporaryDirectory() as temp_dir:
app = self.create_test_app(temp_dir, configured=False)
with app.app_context(), self.assertRaises(EmailConfigurationError):
send_transactional_email(
to="user@example.com",
subject="测试",
html="<p>测试</p>",
)
@patch("app.email.resend.Emails.send", return_value={"id": "email_123"})
def test_send_uses_configured_sender(self, send):
with TemporaryDirectory() as temp_dir:
app = self.create_test_app(temp_dir, configured=True)
with app.app_context():
result = send_transactional_email(
to="user@example.com",
subject="密码重置",
html="<p>重置链接</p>",
)
self.assertEqual(result, {"id": "email_123"})
send.assert_called_once_with(
{
"from": "no-reply@auth.example.com",
"to": ["user@example.com"],
"subject": "密码重置",
"html": "<p>重置链接</p>",
}
)
if __name__ == "__main__":
unittest.main()