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 @@
+
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 %}
-
- | {{ user.username }} |
- {{ format_datetime(user.created_at) }} |
-
- |
-
-
-
-
- {% if pagination.total %}
- 共 {{ pagination.total }} 条
- {% endif %}
-
-
-
+
+
+
-
-
- | 用户 |
- 原始文件 |
- 上传时间 |
- 下载 |
-
-
-
- {% for record in records %}
-
- | {{ record.user.username }} |
- {{ record.original_filename }} |
- {{ format_datetime(record.upload_time) }} |
-
-
- |
-
- {% else %}
-
- | 暂无上传记录 |
-
- {% endfor %}
-
+ | 用户 | 文件 | 时间 |
+ {% for record in records %}| {{ record.user.username }} | {{ record.original_filename }} | {{ format_datetime(record.upload_time) }} |
{% endfor %}
{{ 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 %}
{{ current_user.username[:1]|upper }}
{{ current_user.username }}
@@ -33,7 +34,7 @@
-
+
{% endif %}