diff --git a/.env.example b/.env.example index 59b2cec..fc89a7e 100644 --- a/.env.example +++ b/.env.example @@ -1,28 +1,40 @@ -# Copy to .env for local deployment. -# Generate SECRET_KEY with: python -c "import secrets; print(secrets.token_hex(32))" -SECRET_KEY=change-me-to-a-long-random-secret +# 环境变量模板:复制为 .env 后填写实际值。不要提交 .env 或 .env.local。 +# 优先级:系统环境变量 > .env.local > .env。 -# Runtime mode is not set by docker compose. Local python runs default to development; -# the Docker image sets APP_ENV=production and DEBUG=false in the image. +# 运行环境:production(生产)/ development(开发) +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_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 - -# Timezone used when displaying UTC timestamps. +# 页面展示的时区。 APP_TIMEZONE=Asia/Shanghai -# Default: 16 MiB +# 单个上传文件最大字节数,16 MiB = 16777216。 MAX_UPLOAD_BYTES=16777216 - -# Default RC1 fusion model core directory inside the Docker image. +# 模型核心目录。 FUSION_MODEL_CORE_DIR=/app/model_core - -# Keep public registration closed by default. +# 是否允许公开自助注册。 ALLOW_REGISTRATION=false -# Minutes before an admin-generated password reset link expires. -PASSWORD_RESET_TOKEN_MINUTES=30 +# Resend 邮件服务。发件地址必须已在 Resend 验证。 +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 diff --git a/app/__init__.py b/app/__init__.py index 9dbe372..52668e4 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -2,14 +2,17 @@ from __future__ import annotations import logging -from flask import Flask, abort, jsonify, request +from flask import Flask, abort, jsonify, request, redirect, session +from flask_login import current_user, logout_user from .config import Config, DATA_DIR, ensure_dirs from .extensions import db, login_manager from .models import AppSetting, User +from .migrations import upgrade_schema from .prediction import FEATURES, load_model from .security import csrf_token, validate_csrf_token from .time_utils import current_year_for_timezone, format_datetime_for_timezone +from .time_utils import utc_now def create_app(config_object: type[Config] = Config, *, load_model_on_start: bool = True) -> Flask: @@ -34,7 +37,7 @@ def create_app(config_object: type[Config] = Config, *, load_model_on_start: boo app.register_blueprint(bp) with app.app_context(): - db.create_all() + upgrade_schema() init_admin_user(app) if load_model_on_start: @@ -59,23 +62,26 @@ def configure_logging() -> None: def init_admin_user(app: Flask) -> None: admin_username = app.config["ADMIN_USERNAME"] admin_password = app.config["ADMIN_PASSWORD"] - if admin_password: + admin_email = app.config["ADMIN_EMAIL"] + if admin_password and admin_email: admin = User.query.filter_by(username=admin_username).first() if admin is None: - admin = User(username=admin_username, is_admin=True) + admin = User(username=admin_username, email=admin_email, is_admin=True, is_active_account=True) admin.set_password(admin_password) db.session.add(admin) else: admin.is_admin = True - if not admin.check_password(admin_password): - admin.set_password(admin_password) + if not admin.email: + admin.email = admin_email + admin.email_verified_at = utc_now() + admin.is_active_account = True db.session.commit() elif not User.query.filter_by(is_admin=True).first(): - logging.warning("未设置 ADMIN_PASSWORD,跳过自动创建管理员账号。") + logging.warning("未设置 ADMIN_PASSWORD 或 ADMIN_EMAIL,跳过自动创建管理员账号。") default_admin = User.query.filter_by(username="admin", is_admin=True).first() if default_admin and default_admin.check_password("admin123"): - logging.warning("检测到默认管理员密码 admin123,请立即通过 ADMIN_PASSWORD 更新。") + logging.warning("检测到默认管理员密码 admin123,请通过账户安全页立即更新。") def register_app_hooks(app: Flask) -> None: @@ -114,6 +120,10 @@ def register_app_hooks(app: Flask) -> None: @app.before_request def protect_csrf(): + if current_user.is_authenticated and session.get("auth_version") != current_user.auth_version: + logout_user() + session.clear() + return redirect("/login") if request.method not in {"POST", "PUT", "PATCH", "DELETE"}: return None if validate_csrf_token(): diff --git a/app/config.py b/app/config.py index ac42359..a7f244e 100644 --- a/app/config.py +++ b/app/config.py @@ -4,6 +4,8 @@ import os import secrets from pathlib import Path +from dotenv import dotenv_values + BASE_DIR = Path(__file__).resolve().parent.parent DATA_DIR = BASE_DIR / "data" STATIC_DIR = BASE_DIR / "static" @@ -11,6 +13,20 @@ UPLOAD_DIR = BASE_DIR / "uploads" IMAGE_DIR = STATIC_DIR / "images" +def load_local_environment() -> None: + """加载本地配置,优先级为系统环境变量 > .env.local > .env。""" + values = dotenv_values(BASE_DIR / ".env") + overrides = dotenv_values(BASE_DIR / ".env.local") + # .env.local 中的空值表示“沿用 .env”,避免本机模板清空密钥。 + values.update({key: value for key, value in overrides.items() if value}) + for key, value in values.items(): + if value: + os.environ.setdefault(key, value) + + +load_local_environment() + + def env_int(name: str, default: int) -> int: try: return int(os.environ.get(name, str(default))) @@ -42,6 +58,7 @@ class Config: MAX_CONTENT_LENGTH = env_int("MAX_UPLOAD_BYTES", 16 * 1024 * 1024) SESSION_COOKIE_HTTPONLY = True SESSION_COOKIE_SAMESITE = "Lax" + SESSION_COOKIE_SECURE = env_bool("SESSION_COOKIE_SECURE", APP_ENV == "production") FUSION_MODEL_CORE_DIR = os.environ.get( "FUSION_MODEL_CORE_DIR", str(BASE_DIR / "model_core"), @@ -49,8 +66,15 @@ class Config: ALLOW_REGISTRATION = env_bool("ALLOW_REGISTRATION", False) ADMIN_USERNAME = os.environ.get("ADMIN_USERNAME", "admin").strip() or "admin" ADMIN_PASSWORD = os.environ.get("ADMIN_PASSWORD") + ADMIN_EMAIL = os.environ.get("ADMIN_EMAIL", "").strip().lower() APP_TIMEZONE = os.environ.get("APP_TIMEZONE", "Asia/Shanghai").strip() or "Asia/Shanghai" PASSWORD_RESET_TOKEN_MINUTES = env_int("PASSWORD_RESET_TOKEN_MINUTES", 30) + RESEND_API_KEY = os.environ.get("RESEND_API_KEY", "").strip() + RESEND_FROM_EMAIL = os.environ.get("RESEND_FROM_EMAIL", "").strip() + EMAIL_CODE_MINUTES = env_int("EMAIL_CODE_MINUTES", 10) + EMAIL_CODE_RESEND_SECONDS = env_int("EMAIL_CODE_RESEND_SECONDS", 60) + EMAIL_CODE_MAX_ATTEMPTS = env_int("EMAIL_CODE_MAX_ATTEMPTS", 5) + TRUSTED_DEVICE_DAYS = env_int("TRUSTED_DEVICE_DAYS", 30) def ensure_dirs() -> None: diff --git a/app/models.py b/app/models.py index a701adc..4bf40e5 100644 --- a/app/models.py +++ b/app/models.py @@ -12,6 +12,10 @@ class User(UserMixin, db.Model): id = db.Column(db.Integer, primary_key=True) username = db.Column(db.String(100), unique=True, nullable=False) + email = db.Column(db.String(254), unique=True, index=True) + email_verified_at = db.Column(db.DateTime) + is_active_account = db.Column(db.Boolean, default=False, nullable=False) + auth_version = db.Column(db.Integer, default=1, nullable=False) password_hash = db.Column(db.String(255), nullable=False) is_admin = db.Column(db.Boolean, default=False, nullable=False) created_at = db.Column(db.DateTime, default=utc_now) @@ -22,6 +26,13 @@ class User(UserMixin, db.Model): def check_password(self, password: str) -> bool: return check_password_hash(self.password_hash, password) + @property + def is_active(self) -> bool: + return self.is_active_account + + def revoke_authentication(self) -> None: + self.auth_version += 1 + class PasswordResetToken(db.Model): __tablename__ = "password_reset_tokens" @@ -42,6 +53,34 @@ class PasswordResetToken(db.Model): created_by = db.relationship("User", foreign_keys=[created_by_id]) +class EmailVerificationCode(db.Model): + __tablename__ = "email_verification_codes" + + id = db.Column(db.Integer, primary_key=True) + email = db.Column(db.String(254), nullable=False, index=True) + purpose = db.Column(db.String(32), nullable=False, index=True) + code_hash = db.Column(db.String(64), nullable=False) + expires_at = db.Column(db.DateTime, nullable=False) + attempts = db.Column(db.Integer, default=0, nullable=False) + used_at = db.Column(db.DateTime) + requested_ip = db.Column(db.String(64)) + created_at = db.Column(db.DateTime, default=utc_now, nullable=False, index=True) + + +class TrustedDevice(db.Model): + __tablename__ = "trusted_devices" + + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False, index=True) + token_hash = db.Column(db.String(64), unique=True, nullable=False, index=True) + expires_at = db.Column(db.DateTime, nullable=False, index=True) + auth_version = db.Column(db.Integer, nullable=False) + created_at = db.Column(db.DateTime, default=utc_now, nullable=False) + last_used_at = db.Column(db.DateTime, default=utc_now, nullable=False) + + user = db.relationship("User", backref=db.backref("trusted_devices", lazy=True)) + + class UploadRecord(db.Model): __tablename__ = "upload_records" diff --git a/app/routes.py b/app/routes.py index d1edb31..950b68c 100644 --- a/app/routes.py +++ b/app/routes.py @@ -1,29 +1,19 @@ from __future__ import annotations -import os import hashlib +import os +import re import secrets from datetime import timedelta -from flask import ( - Blueprint, - abort, - current_app, - flash, - jsonify, - redirect, - render_template, - request, - send_file, - session, - url_for, -) +from flask import Blueprint, abort, current_app, flash, jsonify, redirect, render_template, request, send_file, session, url_for from flask_login import current_user, login_required, login_user, logout_user from sqlalchemy.orm import joinedload from .config import BASE_DIR +from .email import EmailConfigurationError, EmailDeliveryError, send_transactional_email from .extensions import db -from .models import AppSetting, PasswordResetToken, UploadRecord, User +from .models import AppSetting, EmailVerificationCode, TrustedDevice, UploadRecord, User from .prediction import PredictionError, run_prediction from .security import new_captcha from .time_utils import format_datetime_for_timezone, utc_now @@ -33,6 +23,36 @@ REFERENCE_PDF_NAME = "20260630标准文本——供水管道健康状态与剩 TEMPLATE_EXCEL_NAME = "管道预测数据模板.xlsx" REGISTRATION_SETTING_KEY = "allow_registration" RECORDS_PER_PAGE = 10 +EMAIL_RE = re.compile(r"^[^\s@]+@[^\s@]+\.[^\s@]+$") +EMAIL_CODE_PURPOSES = { + "register", + "login", + "reset", + "change_password", + "change_email_old", + "change_email_new", +} +EMAIL_CODE_LABELS = { + "register": "完成注册", + "login": "登录确认", + "reset": "重置密码", + "change_password": "修改密码", + "change_email_old": "确认原邮箱", + "change_email_new": "确认新邮箱", +} +TRUSTED_DEVICE_COOKIE = "trusted_device" + + +def normal_email(value: str) -> str: + return value.strip().lower() + + +def valid_email(value: str) -> bool: + return len(value) <= 254 and bool(EMAIL_RE.fullmatch(value)) + + +def valid_password(password: str) -> bool: + return 12 <= len(password) <= 128 def render_auth_template(mode: str, status_code: int = 200, captcha: str = ""): @@ -50,44 +70,8 @@ def render_auth_error(mode: str, message: str, status_code: int = 400): def captcha_is_valid() -> bool: - captcha_input = request.form.get("captcha", "").strip().upper() - return bool(captcha_input and captcha_input == session.get("captcha", "")) - - -def password_reset_token_hash(token: str) -> str: - return hashlib.sha256(token.encode("utf-8")).hexdigest() - - -def password_reset_expiry() -> datetime: - minutes = max(int(current_app.config["PASSWORD_RESET_TOKEN_MINUTES"]), 1) - return utc_now() + timedelta(minutes=minutes) - - -def format_app_datetime(value: datetime | None) -> str: - return format_datetime_for_timezone(value, current_app.config["APP_TIMEZONE"]) - - -def active_password_reset_token(token: str) -> PasswordResetToken | None: - reset_token = PasswordResetToken.query.filter_by( - token_hash=password_reset_token_hash(token) - ).first() - if ( - reset_token is None - or reset_token.used_at is not None - or reset_token.expires_at <= utc_now() - ): - return None - return reset_token - - -def render_password_reset_unavailable(status_code: int = 400): - flash("重置链接无效或已过期,请联系管理员重新生成。", "error") - return render_template( - "password_reset.html", - reset_token=None, - token="", - token_available=False, - ), status_code + value = request.form.get("captcha", "").strip().upper() + return bool(value and value == session.get("captcha", "")) def require_admin() -> None: @@ -96,10 +80,7 @@ def require_admin() -> None: def registration_allowed() -> bool: - return AppSetting.get_bool( - REGISTRATION_SETTING_KEY, - current_app.config["ALLOW_REGISTRATION"], - ) + return AppSetting.get_bool(REGISTRATION_SETTING_KEY, current_app.config["ALLOW_REGISTRATION"]) def requested_page() -> int: @@ -111,333 +92,331 @@ def requested_page() -> int: def paginated_uploads(query, endpoint: str): page = requested_page() - pagination = ( - query.order_by(UploadRecord.upload_time.desc()) - .paginate(page=page, per_page=RECORDS_PER_PAGE, error_out=False) + pagination = query.order_by(UploadRecord.upload_time.desc()).paginate( + page=page, + per_page=RECORDS_PER_PAGE, + error_out=False, ) if pagination.pages and page > pagination.pages: return pagination, redirect(url_for(endpoint, page=pagination.pages)) return pagination, None -def password_reset_users(): - return ( - User.query.filter(User.is_admin.is_(False)) - .order_by(User.username.asc(), User.id.asc()) - .all() +def code_hash(email: str, purpose: str, code: str) -> str: + return hashlib.sha256(f"{email}:{purpose}:{code}".encode()).hexdigest() + + +def issue_email_code(email: str, purpose: str) -> bool: + now = utc_now() + resend_at = now - timedelta(seconds=current_app.config["EMAIL_CODE_RESEND_SECONDS"]) + recent = ( + EmailVerificationCode.query.filter_by(email=email, purpose=purpose) + .filter(EmailVerificationCode.created_at >= resend_at) + .first() ) + ip = request.remote_addr or "" + hourly = EmailVerificationCode.query.filter( + EmailVerificationCode.created_at >= now - timedelta(hours=1), + EmailVerificationCode.requested_ip == ip, + ).count() + if recent or hourly >= 20: + return False + EmailVerificationCode.query.filter_by(email=email, purpose=purpose, used_at=None).update({"used_at": now}) + code = f"{secrets.randbelow(1_000_000):06d}" + record = EmailVerificationCode( + email=email, + purpose=purpose, + code_hash=code_hash(email, purpose, code), + expires_at=now + timedelta(minutes=current_app.config["EMAIL_CODE_MINUTES"]), + requested_ip=ip, + ) + db.session.add(record) + db.session.commit() + try: + send_transactional_email( + to=email, + subject=f"供水管道健康评估系统:{EMAIL_CODE_LABELS.get(purpose, '身份验证')}验证码", + html=( + "

你的验证码是:

" + f"

{code}

" + f"

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

" + ), + ) + except (EmailConfigurationError, EmailDeliveryError): + db.session.delete(record) + db.session.commit() + return False + return True -def prediction_result_payload(artifacts, record: UploadRecord) -> dict: - image_url = url_for("static", filename=f"images/{artifacts.image_filename}") - return { - "original_filename": artifacts.original_filename, - "generated_at": format_app_datetime(record.upload_time), - "image_url": image_url, - "excel_url": url_for("main.download_file", record_id=record.id, file_type="prediction"), - "result_url": url_for("main.result_page"), - "sample_count": int(artifacts.sample_count), - "summary_rows": artifacts.summary_rows[:6], - "analysis_text": artifacts.analysis_text, - "model_version": artifacts.model_version, - } +def consume_email_code(email: str, purpose: str, code: str) -> bool: + record = ( + EmailVerificationCode.query.filter_by(email=email, purpose=purpose, used_at=None) + .order_by(EmailVerificationCode.created_at.desc()) + .first() + ) + if ( + record is None + or record.expires_at <= utc_now() + or record.attempts >= current_app.config["EMAIL_CODE_MAX_ATTEMPTS"] + ): + return False + record.attempts += 1 + if not secrets.compare_digest(record.code_hash, code_hash(email, purpose, code.strip())): + db.session.commit() + return False + record.used_at = utc_now() + db.session.commit() + return True + + +def trusted_device_for(user: User): + token = request.cookies.get(TRUSTED_DEVICE_COOKIE, "") + if not token: + return None + record = TrustedDevice.query.filter_by( + token_hash=hashlib.sha256(token.encode()).hexdigest(), + user_id=user.id, + ).first() + if record and record.expires_at > utc_now() and record.auth_version == user.auth_version: + record.last_used_at = utc_now() + db.session.commit() + return record + return None + + +def login_response(user: User, remember: bool, trust_device: bool = False): + login_user(user, remember=remember) + session["auth_version"] = user.auth_version + response = redirect(url_for("main.home")) + if trust_device: + token = secrets.token_urlsafe(32) + device = TrustedDevice( + user_id=user.id, + token_hash=hashlib.sha256(token.encode()).hexdigest(), + expires_at=utc_now() + + timedelta(days=current_app.config["TRUSTED_DEVICE_DAYS"]), + auth_version=user.auth_version, + ) + db.session.add(device) + db.session.commit() + response.set_cookie( + TRUSTED_DEVICE_COOKIE, + token, + max_age=current_app.config["TRUSTED_DEVICE_DAYS"] * 86400, + secure=current_app.config["SESSION_COOKIE_SECURE"], + httponly=True, + samesite="Lax", + ) + return response + + +def verification_page(title: str, purpose: str): + return render_template("verification.html", title=title, purpose=purpose, email=session.get("pending_email", "")) @bp.route("/") def index(): - if current_user.is_authenticated: - return redirect(url_for("main.home")) - return redirect(url_for("main.login")) + return redirect(url_for("main.home" if current_user.is_authenticated else "main.login")) @bp.route("/login", methods=["GET", "POST"]) def login(): if current_user.is_authenticated: return redirect(url_for("main.home")) - if request.method == "GET": return render_auth_template("login", captcha=refresh_captcha()) - - username = request.form.get("username", "").strip() - password = request.form.get("password", "") - if not captcha_is_valid(): - return render_auth_error("login", "验证码错误") - - user = User.query.filter_by(username=username).first() - if not user or not user.check_password(password): - return render_auth_error("login", "用户名或密码错误") - - login_user(user, remember=bool(request.form.get("remember"))) - return redirect(url_for("main.home")) + email, password = normal_email(request.form.get("email", "")), request.form.get("password", "") + if not captcha_is_valid(): return render_auth_error("login", "验证码错误") + user = User.query.filter_by(email=email).first() + if not user or not user.is_active_account or not user.check_password(password): + return render_auth_error("login", "邮箱或密码错误") + remember = bool(request.form.get("remember")) + if trusted_device_for(user): + return login_response(user, remember) + session.update(pending_email=email, pending_user_id=user.id, pending_remember=remember, pending_purpose="login") + if not issue_email_code(email, "login"): + return render_auth_error("login", "验证码发送失败,请稍后重试", 503) + return redirect(url_for("main.verify_email", purpose="login")) @bp.route("/register", methods=["GET", "POST"]) def register(): - if request.method == "GET": - return render_auth_template("register", captcha=refresh_captcha()) - - username = request.form.get("username", "").strip() - password = request.form.get("password", "") - - if not captcha_is_valid(): - return render_auth_error("register", "验证码错误") - if not registration_allowed(): - return render_auth_error("register", "当前未开放自助注册,请联系管理员。", 403) - - if not username: - return render_auth_error("register", "用户名不能为空") - if len(password) < 6: - return render_auth_error("register", "密码至少需要 6 位") - if User.query.filter_by(username=username).first(): - return render_auth_error("register", "用户名已存在") - - user = User(username=username, is_admin=False) - user.set_password(password) - db.session.add(user) - db.session.commit() - flash("注册成功,请登录", "info") - return render_auth_template("login", captcha=refresh_captcha()) + if request.method == "GET": return render_auth_template("register", captcha=refresh_captcha()) + username, email, password = request.form.get("username", "").strip(), normal_email(request.form.get("email", "")), request.form.get("password", "") + if not captcha_is_valid(): return render_auth_error("register", "验证码错误") + if not registration_allowed(): return render_auth_error("register", "当前未开放自助注册,请联系管理员。", 403) + if not username or len(username) > 100: return render_auth_error("register", "显示名不能为空且不能超过100个字符") + if not valid_email(email): return render_auth_error("register", "请输入有效的邮箱地址") + if not valid_password(password): return render_auth_error("register", "密码长度应为12至128位") + if User.query.filter((User.username == username) | (User.email == email)).first(): return render_auth_error("register", "显示名或邮箱已被使用") + user = User(username=username, email=email, is_admin=False, is_active_account=False) + user.set_password(password); db.session.add(user); db.session.commit() + session.update(pending_email=email, pending_user_id=user.id, pending_purpose="register") + if not issue_email_code(email, "register"): return render_auth_error("register", "验证码发送失败,请稍后重试", 503) + return redirect(url_for("main.verify_email", purpose="register")) -@bp.route("/password-reset/", methods=["GET", "POST"]) -def password_reset(token: str): - reset_token = active_password_reset_token(token) - if reset_token is None: - return render_password_reset_unavailable() +@bp.route("/verify/", methods=["GET", "POST"]) +def verify_email(purpose: str): + if purpose not in EMAIL_CODE_PURPOSES or session.get("pending_purpose") != purpose: + abort(400) + if request.method == "GET": return verification_page("邮箱验证", purpose) + email = session.get("pending_email", "") + if not consume_email_code(email, purpose, request.form.get("code", "")): + flash("验证码无效、过期或尝试次数已用尽。", "error"); return verification_page("邮箱验证", purpose), 400 + user = db.session.get(User, session.get("pending_user_id")) + if not user or (purpose != "change_email_new" and user.email != email): abort(400) + if purpose == "register": + user.email_verified_at = utc_now(); user.is_active_account = True; db.session.commit(); session.clear(); flash("邮箱验证成功,请登录。", "info"); return redirect(url_for("main.login")) + if purpose == "login": + remember = bool(session.get("pending_remember")); session.clear(); return login_response(user, remember, trust_device=True) + if purpose == "reset": session["reset_verified_user_id"] = user.id; return redirect(url_for("main.set_password")) + if purpose == "change_email_old": + new_email = session.get("new_email", "") + session.update(pending_email=new_email, pending_purpose="change_email_new") + if not issue_email_code(new_email, "change_email_new"): + flash("新邮箱验证码发送失败,请稍后重试。", "error"); return redirect(url_for("main.account_security")) + return redirect(url_for("main.verify_email", purpose="change_email_new")) + if purpose == "change_email_new": + if User.query.filter(User.email == email, User.id != user.id).first(): + flash("该邮箱已被使用。", "error"); return redirect(url_for("main.account_security")) + user.email = email; user.email_verified_at = utc_now(); user.revoke_authentication(); TrustedDevice.query.filter_by(user_id=user.id).delete(); db.session.commit(); logout_user(); session.clear(); flash("邮箱已更新,请重新登录。", "info"); return redirect(url_for("main.login")) + session["password_change_verified"] = True; return redirect(url_for("main.account_security")) - if request.method == "GET": - return render_template( - "password_reset.html", - reset_token=reset_token, - token=token, - token_available=True, - ) - password = request.form.get("password", "") - password_confirm = request.form.get("password_confirm", "") - if len(password) < 6: - flash("密码至少需要 6 位", "error") - return render_template( - "password_reset.html", - reset_token=reset_token, - token=token, - token_available=True, - ), 400 - if password != password_confirm: - flash("两次输入的密码不一致", "error") - return render_template( - "password_reset.html", - reset_token=reset_token, - token=token, - token_available=True, - ), 400 +@bp.route("/verify//resend", methods=["POST"]) +def resend_code(purpose: str): + if purpose not in EMAIL_CODE_PURPOSES or session.get("pending_purpose") != purpose: + abort(400) + if not issue_email_code(session.get("pending_email", ""), purpose): flash("发送过于频繁或服务暂不可用,请稍后再试。", "error") + else: flash("验证码已发送,请查收邮箱。", "info") + return redirect(url_for("main.verify_email", purpose=purpose)) - reset_token.user.set_password(password) - reset_token.used_at = utc_now() - db.session.commit() - flash("密码已重置,请使用新密码登录", "info") - return render_auth_template("login", captcha=refresh_captcha()) + +@bp.route("/forgot-password", methods=["GET", "POST"]) +def forgot_password(): + if request.method == "GET": return render_template("forgot_password.html") + email = normal_email(request.form.get("email", "")); user = User.query.filter_by(email=email, is_active_account=True).first() + if user: + session.update(pending_email=email, pending_user_id=user.id, pending_purpose="reset") + issue_email_code(email, "reset") + flash("若该邮箱已注册,验证码将发送至邮箱。", "info") + return redirect(url_for("main.login")) + + +@bp.route("/set-password", methods=["GET", "POST"]) +def set_password(): + user = db.session.get(User, session.get("reset_verified_user_id")) + if not user: return redirect(url_for("main.forgot_password")) + if request.method == "GET": return render_template("set_password.html", title="设置新密码", action=url_for("main.set_password")) + password, confirm = request.form.get("password", ""), request.form.get("password_confirm", "") + if not valid_password(password) or password != confirm: flash("密码应为12至128位,且两次输入一致。", "error"); return render_template("set_password.html", title="设置新密码", action=url_for("main.set_password")), 400 + user.set_password(password); user.revoke_authentication(); TrustedDevice.query.filter_by(user_id=user.id).delete(); db.session.commit(); session.clear(); flash("密码已重置,请重新登录。", "info"); return redirect(url_for("main.login")) + + +@bp.route("/account/security", methods=["GET", "POST"]) +@login_required +def account_security(): + if request.method == "GET": return render_template("account_security.html") + action = request.form.get("action") + if action == "revoke_devices": + current_user.revoke_authentication(); TrustedDevice.query.filter_by(user_id=current_user.id).delete(); db.session.commit(); logout_user(); flash("所有受信设备已撤销,请重新登录。", "info"); return redirect(url_for("main.login")) + if not current_user.check_password(request.form.get("current_password", "")): + flash("当前密码不正确。", "error"); return redirect(url_for("main.account_security")) + if action == "change_email": + new_email = normal_email(request.form.get("new_email", "")) + if not valid_email(new_email) or User.query.filter_by(email=new_email).first(): + flash("请输入未被使用的有效邮箱地址。", "error"); return redirect(url_for("main.account_security")) + session.update(pending_email=current_user.email, pending_user_id=current_user.id, pending_purpose="change_email_old", new_email=new_email) + if not issue_email_code(current_user.email, "change_email_old"): flash("验证码发送失败,请稍后重试。", "error"); return redirect(url_for("main.account_security")) + return redirect(url_for("main.verify_email", purpose="change_email_old")) + session.update(pending_email=current_user.email, pending_user_id=current_user.id, pending_purpose="change_password") + if not issue_email_code(current_user.email, "change_password"): flash("验证码发送失败,请稍后重试。", "error"); return redirect(url_for("main.account_security")) + return redirect(url_for("main.verify_email", purpose="change_password")) + + +@bp.route("/account/change-password", methods=["POST"]) +@login_required +def change_password(): + if not session.pop("password_change_verified", False): abort(403) + password, confirm = request.form.get("password", ""), request.form.get("password_confirm", "") + if not valid_password(password) or password != confirm or current_user.check_password(password): flash("密码应为12至128位、两次一致且不能与当前密码相同。", "error"); return redirect(url_for("main.account_security")) + current_user.set_password(password); current_user.revoke_authentication(); TrustedDevice.query.filter_by(user_id=current_user.id).delete(); db.session.commit(); logout_user(); flash("密码已更新,请重新登录。", "info"); return redirect(url_for("main.login")) @bp.route("/logout", methods=["POST"]) @login_required -def logout(): - logout_user() - return redirect(url_for("main.login")) - +def logout(): logout_user(); return redirect(url_for("main.login")) @bp.route("/home") @login_required -def home(): - return render_template("home.html") - +def home(): return render_template("home.html") @bp.route("/history") @login_required def history_page(): - pagination, page_redirect = paginated_uploads( - UploadRecord.query.filter_by(user_id=current_user.id), - "main.history_page", - ) - if page_redirect: - return page_redirect - return render_template( - "history.html", - pagination=pagination, - records=pagination.items, - ) - + pagination, page_redirect = paginated_uploads(UploadRecord.query.filter_by(user_id=current_user.id), "main.history_page") + return page_redirect or render_template("history.html", pagination=pagination, records=pagination.items) @bp.route("/admin") @login_required def admin_dashboard(): - require_admin() - pagination, page_redirect = paginated_uploads( - UploadRecord.query.options(joinedload(UploadRecord.user)), - "main.admin_dashboard", - ) - if page_redirect: - return page_redirect - return render_template( - "admin.html", - pagination=pagination, - records=pagination.items, - password_reset_users=password_reset_users(), - registration_allowed=registration_allowed(), - ) - + require_admin(); pagination, page_redirect = paginated_uploads(UploadRecord.query.options(joinedload(UploadRecord.user)), "main.admin_dashboard") + return page_redirect or render_template("admin.html", pagination=pagination, records=pagination.items, password_reset_users=User.query.filter(User.is_admin.is_(False)).order_by(User.username).all(), registration_allowed=registration_allowed()) @bp.route("/admin/registration", methods=["POST"]) @login_required def update_registration_setting(): - require_admin() + require_admin(); enabled = request.form.get("allow_registration") == "on"; AppSetting.set_bool(REGISTRATION_SETTING_KEY, enabled); db.session.commit(); flash("已开放用户自助注册" if enabled else "已关闭用户自助注册", "info"); return redirect(url_for("main.admin_dashboard")) - allow_registration = request.form.get("allow_registration") == "on" - AppSetting.set_bool(REGISTRATION_SETTING_KEY, allow_registration) - db.session.commit() - - message = "已开放用户自助注册" if allow_registration else "已关闭用户自助注册" - if request.headers.get("X-Requested-With") == "XMLHttpRequest": - return jsonify( - { - "message": message, - "registration_allowed": allow_registration, - "status_label": "已开放" if allow_registration else "已关闭", - } - ) - - flash(message, "info") - return redirect(url_for("main.admin_dashboard")) - - -@bp.route("/admin/users//password-reset-link", methods=["POST"]) +@bp.route("/admin/users//password-reset", methods=["POST"]) @login_required -def create_password_reset_link(user_id: int): - require_admin() - - user = db.session.get(User, user_id) - if user is None: - return jsonify({"error": "用户不存在"}), 404 - if user.is_admin: - return jsonify({"error": "管理员账号不支持通过此入口重置密码"}), 403 - - now = utc_now() - PasswordResetToken.query.filter_by(user_id=user.id, used_at=None).update( - {"used_at": now} - ) - - token = secrets.token_urlsafe(32) - reset_token = PasswordResetToken( - user_id=user.id, - created_by_id=current_user.id, - token_hash=password_reset_token_hash(token), - expires_at=password_reset_expiry(), - ) - db.session.add(reset_token) - db.session.commit() - - return jsonify( - { - "message": f"已生成 {user.username} 的密码重置链接", - "reset_url": url_for("main.password_reset", token=token, _external=True), - "expires_at": format_app_datetime(reset_token.expires_at), - "user_id": user.id, - "username": user.username, - } - ) - +def admin_password_reset(user_id: int): + require_admin(); user = db.session.get(User, user_id) + if not user or user.is_admin: return jsonify({"error": "用户不存在或不支持此操作"}), 404 + try: + send_transactional_email( + to=user.email, + subject="供水管道健康评估系统:请重置密码", + html=f"

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

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

", + ) + except (EmailConfigurationError, EmailDeliveryError): + return jsonify({"error": "邮件发送失败"}), 503 + return jsonify({"message": "密码重置通知已发送至用户邮箱"}) @bp.route("/download//") @login_required def download_file(record_id: int, file_type: str): record = db.session.get(UploadRecord, record_id) - if record is None: - abort(404) - if not (current_user.is_admin or current_user.id == record.user_id): - abort(403) - file_path = { - "original": record.saved_path, - "prediction": record.prediction_path, - }.get(file_type) - if file_path is None: - abort(404) - if not os.path.exists(file_path): - abort(404) - return send_file(file_path, as_attachment=True) - + if record is None or not (current_user.is_admin or current_user.id == record.user_id): abort(404 if record is None else 403) + path = {"original": record.saved_path, "prediction": record.prediction_path}.get(file_type) + if path is None or not os.path.exists(path): abort(404) + return send_file(path, as_attachment=True) @bp.route("/download_template") -def download_template(): - template_path = BASE_DIR / "example.xlsx" - if not template_path.exists(): - abort(404) - return send_file(template_path, as_attachment=True, download_name=TEMPLATE_EXCEL_NAME) - +def download_template(): return send_file(BASE_DIR / "example.xlsx", as_attachment=True, download_name=TEMPLATE_EXCEL_NAME) @bp.route("/reference_pdf") @login_required -def reference_pdf(): - pdf_path = BASE_DIR / REFERENCE_PDF_NAME - if not pdf_path.exists(): - abort(404) - return send_file( - pdf_path, - as_attachment=False, - download_name=REFERENCE_PDF_NAME, - mimetype="application/pdf", - ) - +def reference_pdf(): return send_file(BASE_DIR / REFERENCE_PDF_NAME, as_attachment=False, download_name=REFERENCE_PDF_NAME, mimetype="application/pdf") @bp.route("/reference") @login_required -def reference_page(): - return render_template("reference.html") - +def reference_page(): return render_template("reference.html") @bp.route("/result") @login_required -def result_page(): - result = session.get("last_result") - return render_template("result.html", result=result) - +def result_page(): return render_template("result.html", result=session.get("last_result")) @bp.route("/predict", methods=["POST"]) @login_required def predict(): - model = current_app.config.get("RSF_MODEL") - if model is None: - return jsonify({"error": "模型未成功加载,请检查模型文件。"}), 500 - - uploaded = request.files.get("file") - if uploaded is None or uploaded.filename == "": - return jsonify({"error": "未选择文件"}), 400 - - try: - artifacts = run_prediction(uploaded, int(current_user.id), model) - except PredictionError as exc: - return jsonify({"error": exc.message}), exc.status_code - - record = UploadRecord( - user_id=current_user.id, - original_filename=artifacts.original_filename, - saved_path=str(artifacts.saved_path), - prediction_path=str(artifacts.excel_path), - image_path=str(artifacts.image_path), - ) - db.session.add(record) - db.session.commit() - - last_result = prediction_result_payload(artifacts, record) - session["last_result"] = last_result - - return jsonify( - { - "message": "预测成功", - "image_url": last_result["image_url"], - "excel_url": last_result["excel_url"], - "result_url": last_result["result_url"], - "sample_count": last_result["sample_count"], - "original_filename": artifacts.original_filename, - "model_version": last_result["model_version"], - } - ) + model = current_app.config.get("RSF_MODEL"); uploaded = request.files.get("file") + if model is None: return jsonify({"error": "模型未成功加载,请检查模型文件。"}), 500 + if uploaded is None or uploaded.filename == "": return jsonify({"error": "未选择文件"}), 400 + try: artifacts = run_prediction(uploaded, int(current_user.id), model) + except PredictionError as exc: return jsonify({"error": exc.message}), exc.status_code + record = UploadRecord(user_id=current_user.id, original_filename=artifacts.original_filename, saved_path=str(artifacts.saved_path), prediction_path=str(artifacts.excel_path), image_path=str(artifacts.image_path)); db.session.add(record); db.session.commit() + result = {"original_filename": artifacts.original_filename, "generated_at": format_datetime_for_timezone(record.upload_time, current_app.config["APP_TIMEZONE"]), "image_url": url_for("static", filename=f"images/{artifacts.image_filename}"), "excel_url": url_for("main.download_file", record_id=record.id, file_type="prediction"), "result_url": url_for("main.result_page"), "sample_count": int(artifacts.sample_count), "summary_rows": artifacts.summary_rows[:6], "analysis_text": artifacts.analysis_text, "model_version": artifacts.model_version}; session["last_result"] = result + return jsonify({"message": "预测成功", "image_url": result["image_url"], "excel_url": result["excel_url"], "result_url": result["result_url"], "sample_count": result["sample_count"], "original_filename": artifacts.original_filename, "model_version": result["model_version"]}) diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml new file mode 100644 index 0000000..d68606e --- /dev/null +++ b/docker-compose.dev.yml @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml index 212996d..da1ead0 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -6,15 +6,25 @@ services: container_name: pipeline-lifetime restart: unless-stopped environment: + APP_ENV: ${APP_ENV:-production} + DEBUG: ${DEBUG:-false} SECRET_KEY: ${SECRET_KEY:?Set SECRET_KEY in .env} ADMIN_USERNAME: ${ADMIN_USERNAME:-admin} 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} APP_TIMEZONE: ${APP_TIMEZONE:-Asia/Shanghai} MAX_UPLOAD_BYTES: ${MAX_UPLOAD_BYTES:-16777216} FUSION_MODEL_CORE_DIR: ${FUSION_MODEL_CORE_DIR:-/app/model_core} ALLOW_REGISTRATION: ${ALLOW_REGISTRATION:-false} 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: - "5005:5005" volumes: diff --git a/requirements.txt b/requirements.txt index eccf471..f8c38c8 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,9 +1,11 @@ Flask==3.1.3 Flask-SQLAlchemy==3.1.1 Flask-Login==0.6.3 +python-dotenv==1.2.2 Werkzeug==3.1.6 SQLAlchemy==2.0.48 gunicorn==23.0.0 +resend==2.35.0 pandas==2.3.3 numpy==2.0.2 diff --git a/templates/_login_form.html b/templates/_login_form.html new file mode 100644 index 0000000..f4d74c2 --- /dev/null +++ b/templates/_login_form.html @@ -0,0 +1 @@ +
diff --git a/templates/_register_form.html b/templates/_register_form.html new file mode 100644 index 0000000..ec745f2 --- /dev/null +++ b/templates/_register_form.html @@ -0,0 +1 @@ +
{% if not allow_registration %}

当前未开放自助注册,请联系管理员。

{% endif %}
diff --git a/templates/account_security.html b/templates/account_security.html new file mode 100644 index 0000000..30a8051 --- /dev/null +++ b/templates/account_security.html @@ -0,0 +1,12 @@ +{% extends "base.html" %} +{% block content %} +
+

账户安全

+

登录邮箱:{{ current_user.email }}

+ {% with messages=get_flashed_messages(with_categories=true) %}{% for c,m in messages %}

{{ m }}

{% endfor %}{% endwith %} +
+
+
+ {% if session.get('password_change_verified') %}
{% endif %} +
+{% endblock %} diff --git a/templates/admin.html b/templates/admin.html index a2fb58a..950460c 100644 --- a/templates/admin.html +++ b/templates/admin.html @@ -2,237 +2,64 @@ {% from "_pagination.html" import render_pagination %} {% set active_page = "admin" %} -{% block title %}管理台 | 供水管道健康评估系统{% endblock %} +{% block title %}管理台{% endblock %} {% block content %} -
-
-

管理台

-

管理系统注册状态、密码重置链接,查看所有用户的上传文件和预测结果。

-
- - arrow_back - 返回主页 - +
+

管理台

+

管理注册状态、密码重置和所有预测记录。

-
-
-
-

用户注册

-

- 当前状态:{{ '已开放' if registration_allowed else '已关闭' }} -

-
-
- - - -
-
+
+

用户注册

+
+ + + +
-
-
-
-

用户密码重置

-

为普通用户生成一次性重置链接,旧链接会自动失效。

-
- 共 {{ password_reset_users|length }} 位普通用户 -
- -
+
+

用户密码重置

+

向用户已验证邮箱发送重置通知。

+
- - - - - + + + - + {% for user in password_reset_users %} - - - - + + + - {% else %} - - - {% endfor %}
用户创建时间操作
显示名邮箱操作
{{ user.username }}{{ format_datetime(user.created_at) }} -
+
{{ user.username }}{{ user.email or '未设置' }} + {% if user.email %} + - + + {% endif %}
暂无普通用户
-
-
-
-

上传记录

- {% if pagination.total %} - 共 {{ pagination.total }} 条 - {% endif %} -
-
-
+
+

上传记录

+
- - - - - - - - - - {% for record in records %} - - - - - - - {% else %} - - - - {% endfor %} - + + {% for record in records %}{% endfor %}
用户原始文件上传时间下载
{{ record.user.username }}{{ record.original_filename }}{{ format_datetime(record.upload_time) }} - -
暂无上传记录
用户文件时间
{{ record.user.username }}{{ record.original_filename }}{{ format_datetime(record.upload_time) }}
{{ render_pagination(pagination, 'main.admin_dashboard') }}
{% endblock %} - -{% block scripts %} - -{% endblock %} diff --git a/templates/base.html b/templates/base.html index f3f0439..ff7c63d 100644 --- a/templates/base.html +++ b/templates/base.html @@ -20,11 +20,12 @@ 结果 历史 文档 + 安全 {% if current_user.is_admin %} 管理 {% endif %} -
+ {% if current_user.is_authenticated %}
+
{% endif %}