From c61969dfc945367c9f0fcf628cdba7791890837d Mon Sep 17 00:00:00 2001 From: Jowe <123822645+Selei1983@users.noreply.github.com> Date: Sat, 7 Feb 2026 23:26:02 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20v3.1=20-=20=E7=94=A8=E6=88=B7=E5=AF=86?= =?UTF-8?q?=E7=A0=81=E7=AE=A1=E7=90=86=E5=92=8C=E9=82=AE=E7=AE=B1=E9=AA=8C?= =?UTF-8?q?=E8=AF=81=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增功能: 1. 修改密码功能 - 用户可以修改自己的密码 - 需要验证旧密码 - 新密码至少6位且不能与旧密码相同 2. 邮箱绑定功能 - 用户可以绑定/修改邮箱 - 邮箱格式验证和唯一性检查 - 修改邮箱后需要重新验证 3. 邮箱验证功能 - 发送验证邮件(24小时有效) - 点击邮件链接完成验证 - 验证状态显示 技术实现: - 新增4个数据库字段(email_verified等) - 封装邮件发送工具(utils/email_sender.py) - 新增5个API接口 - 新增修改密码页面 - 集成邮箱管理到个人中心 文件变更: - 修改:app.py, models.py, base_new.html, profile.html - 新增:change_password.html, email_sender.py, migrate_email_verification.py - 文档:server-update.md, SERVER_RESTART_GUIDE.md Co-Authored-By: Claude Sonnet 4.5 --- .claude/skills/server-update.md | 169 ++++++++++++++++ SERVER_RESTART_GUIDE.md | 212 ++++++++++++++++++++ app.py | 241 ++++++++++++++++++++++- migrate_email_verification.py | 69 +++++++ models.py | 4 + templates/base_new.html | 1 + templates/user/change_password.html | 290 ++++++++++++++++++++++++++++ templates/user/profile.html | 184 ++++++++++++++++++ utils/email_sender.py | 73 +++++++ 9 files changed, 1242 insertions(+), 1 deletion(-) create mode 100644 .claude/skills/server-update.md create mode 100644 SERVER_RESTART_GUIDE.md create mode 100644 migrate_email_verification.py create mode 100644 templates/user/change_password.html create mode 100644 utils/email_sender.py diff --git a/.claude/skills/server-update.md b/.claude/skills/server-update.md new file mode 100644 index 0000000..483ec3e --- /dev/null +++ b/.claude/skills/server-update.md @@ -0,0 +1,169 @@ +# ZJPB 服务器更新标准流程 + +## 适用场景 +- 代码更新后需要部署到服务器 +- 数据库结构变更需要迁移 +- 新功能上线 + +## 服务器环境 +- **部署方式**: 1Panel 管理 +- **项目路径**: `/opt/1panel/apps/zjpb` +- **运行方式**: Python 直接运行或 Gunicorn +- **数据库**: MySQL/MariaDB + +--- + +## 标准更新流程 + +### 第一步:提交代码到 Git + +```bash +# 在本地开发环境 +git add . +git commit -m "feat: 功能描述" +git push origin master +``` + +### 第二步:登录服务器 + +```bash +ssh your_username@server_ip +``` + +### 第三步:进入项目目录 + +```bash +cd /opt/1panel/apps/zjpb +``` + +### 第四步:停止应用 + +```bash +# 查找进程 +ps aux | grep python | grep -v grep + +# 停止进程(使用进程ID) +kill + +# 或者强制停止 +pkill -f "python app.py" +``` + +### 第五步:拉取最新代码 + +```bash +git pull origin master +``` + +### 第六步:激活虚拟环境 + +```bash +source venv/bin/activate +``` + +### 第七步:安装/更新依赖(如有) + +```bash +pip install -r requirements.txt +``` + +### 第八步:运行数据库迁移(如有) + +```bash +# 根据具体的迁移脚本名称 +python migrate_xxx.py +``` + +### 第九步:启动应用 + +```bash +# 后台运行 +nohup python app.py > app.log 2>&1 & + +# 或使用 Gunicorn +nohup gunicorn -w 4 -b 0.0.0.0:5000 app:app > gunicorn.log 2>&1 & +``` + +### 第十步:验证启动成功 + +```bash +# 检查进程 +ps aux | grep python | grep -v grep + +# 查看日志 +tail -f app.log + +# 测试访问 +curl http://localhost:5000/ +``` + +--- + +## 快速命令(一键执行) + +```bash +# 进入目录并更新 +cd /opt/1panel/apps/zjpb && \ +pkill -f "python app.py" && \ +git pull origin master && \ +source venv/bin/activate && \ +nohup python app.py > app.log 2>&1 & && \ +tail -f app.log +``` + +--- + +## 注意事项 + +1. **数据库迁移前必须备份** + ```bash + mysqldump -u root -p zjpb > backup_$(date +%Y%m%d_%H%M%S).sql + ``` + +2. **检查环境变量配置** + - 确保 `.env` 文件存在且配置正确 + - 新增的环境变量需要手动添加 + +3. **日志监控** + - 启动后观察日志至少 1-2 分钟 + - 确认没有错误信息 + +4. **回滚方案** + ```bash + # 如果出现问题,回滚到上一个版本 + git reset --hard HEAD~1 + # 重启应用 + ``` + +--- + +## 常见问题 + +### 问题1:端口被占用 +```bash +# 查找占用端口的进程 +lsof -i :5000 +# 杀死进程 +kill -9 +``` + +### 问题2:虚拟环境找不到 +```bash +# 重新创建虚拟环境 +python3 -m venv venv +source venv/bin/activate +pip install -r requirements.txt +``` + +### 问题3:数据库连接失败 +```bash +# 检查数据库服务状态 +systemctl status mysql +# 检查 .env 配置 +cat .env | grep DATABASE +``` + +--- + +**创建日期**: 2025-02-07 +**最后更新**: 2025-02-07 diff --git a/SERVER_RESTART_GUIDE.md b/SERVER_RESTART_GUIDE.md new file mode 100644 index 0000000..8deffa4 --- /dev/null +++ b/SERVER_RESTART_GUIDE.md @@ -0,0 +1,212 @@ +# ZJPB 服务器重启指南(1Panel 环境) + +## 第一步:确认当前部署方式 + +请在服务器上执行以下命令,找出应用的运行方式: + +### 1. 检查是否有运行的 Python 进程 + +```bash +ps aux | grep -E "python|gunicorn|flask|app.py" | grep -v grep +``` + +### 2. 检查 1Panel 的应用配置 + +```bash +# 查看 1Panel 应用目录 +ls -la /opt/1panel/apps/ + +# 查看项目实际路径 +pwd + +# 查看是否有启动脚本 +ls -la *.sh start.* run.* +``` + +### 3. 检查 Nginx 配置 + +```bash +# 查看 Nginx 配置 +cat /etc/nginx/sites-enabled/* | grep zjpb +# 或者 +cat /etc/nginx/conf.d/* | grep zjpb +``` + +### 4. 检查是否使用 Docker + +```bash +docker ps | grep zjpb +``` + +--- + +## 常见的 1Panel 部署方式 + +### 方式 1:直接运行 Python 脚本 + +如果找到类似这样的进程: +``` +python app.py +``` + +**重启方法:** +```bash +# 停止旧进程 +pkill -f "python app.py" + +# 启动新进程(后台运行) +nohup python app.py > app.log 2>&1 & +``` + +--- + +### 方式 2:使用 Gunicorn + +如果找到类似这样的进程: +``` +gunicorn -w 4 -b 0.0.0.0:5000 app:app +``` + +**重启方法:** +```bash +# 停止旧进程 +pkill -f gunicorn + +# 启动新进程 +gunicorn -w 4 -b 0.0.0.0:5000 app:app --daemon +``` + +--- + +### 方式 3:使用启动脚本 + +如果项目中有 `start.sh` 或类似脚本: + +**重启方法:** +```bash +# 停止 +./stop.sh +# 或者 +pkill -f "python app.py" + +# 启动 +./start.sh +``` + +--- + +### 方式 4:使用 Docker + +如果使用 Docker 容器: + +**重启方法:** +```bash +# 查看容器 +docker ps | grep zjpb + +# 重启容器 +docker restart +``` + +--- + +### 方式 5:1Panel 面板管理 + +如果通过 1Panel 面板部署: + +**重启方法:** +1. 登录 1Panel 管理面板 +2. 找到 ZJPB 应用 +3. 点击"重启"按钮 + +--- + +## 推荐的重启流程 + +### 步骤 1:找到当前进程 + +```bash +ps aux | grep python | grep -v grep +``` + +记录下进程 PID 和启动命令。 + +### 步骤 2:停止进程 + +```bash +# 方法 1:使用 PID +kill + +# 方法 2:使用进程名 +pkill -f "python app.py" + +# 方法 3:强制停止(如果上面不行) +pkill -9 -f "python app.py" +``` + +### 步骤 3:确认已停止 + +```bash +ps aux | grep python | grep -v grep +``` + +应该没有输出。 + +### 步骤 4:启动应用 + +```bash +# 进入项目目录 +cd /opt/1panel/apps/zjpb + +# 激活虚拟环境 +source venv/bin/activate + +# 启动应用(后台运行) +nohup python app.py > app.log 2>&1 & + +# 或者使用 Gunicorn +nohup gunicorn -w 4 -b 0.0.0.0:5000 app:app > gunicorn.log 2>&1 & +``` + +### 步骤 5:验证启动成功 + +```bash +# 检查进程 +ps aux | grep python | grep -v grep + +# 检查日志 +tail -f app.log +# 或者 +tail -f gunicorn.log + +# 测试访问 +curl http://localhost:5000/ +``` + +--- + +## 如果不确定如何重启 + +请执行以下命令并将结果发给我: + +```bash +echo "=== 当前目录 ===" +pwd + +echo -e "\n=== Python 进程 ===" +ps aux | grep python | grep -v grep + +echo -e "\n=== 项目文件 ===" +ls -la + +echo -e "\n=== 启动脚本 ===" +ls -la *.sh 2>/dev/null || echo "没有找到 .sh 脚本" + +echo -e "\n=== 最近的日志 ===" +ls -lt *.log 2>/dev/null | head -5 || echo "没有找到日志文件" +``` + +--- + +**创建日期:** 2025-02-06 +**适用版本:** v3.0.1 diff --git a/app.py b/app.py index 21706d3..2d498d2 100644 --- a/app.py +++ b/app.py @@ -2,17 +2,19 @@ import os import markdown import random import string +import secrets from io import BytesIO from flask import Flask, render_template, redirect, url_for, request, flash, jsonify, session, send_file from flask_login import LoginManager, login_user, logout_user, login_required, current_user from flask_admin import Admin, AdminIndexView, expose from flask_admin.contrib.sqla import ModelView -from datetime import datetime +from datetime import datetime, timedelta from config import config from models import db, Site, Tag, Admin as AdminModel, News, site_tags, PromptTemplate, User, Folder, Collection from utils.website_fetcher import WebsiteFetcher from utils.tag_generator import TagGenerator from utils.news_searcher import NewsSearcher +from utils.email_sender import EmailSender from PIL import Image, ImageDraw, ImageFont def create_app(config_name='default'): @@ -1130,6 +1132,16 @@ def create_app(config_name='default'): folders_count=folders_count, recent_collections=recent_collections) + @app.route('/user/change-password') + @login_required + def user_change_password_page(): + """修改密码页面""" + if not isinstance(current_user, User): + flash('仅普通用户可访问', 'error') + return redirect(url_for('index')) + + return render_template('user/change_password.html') + @app.route('/user/collections') @login_required def user_collections(): @@ -1201,6 +1213,233 @@ def create_app(config_name='default'): db.session.rollback() return jsonify({'success': False, 'message': f'更新失败:{str(e)}'}), 500 + @app.route('/api/user/change-password', methods=['PUT']) + @login_required + def user_change_password(): + """普通用户修改密码""" + if not isinstance(current_user, User): + return jsonify({'success': False, 'message': '仅普通用户可访问'}), 403 + + try: + data = request.get_json() or {} + old_password = data.get('old_password', '').strip() + new_password = data.get('new_password', '').strip() + confirm_password = data.get('confirm_password', '').strip() + + # 验证旧密码 + if not old_password: + return jsonify({'success': False, 'message': '请输入旧密码'}), 400 + + if not current_user.check_password(old_password): + return jsonify({'success': False, 'message': '旧密码错误'}), 400 + + # 验证新密码 + if not new_password: + return jsonify({'success': False, 'message': '请输入新密码'}), 400 + + if len(new_password) < 6: + return jsonify({'success': False, 'message': '新密码长度至少6位'}), 400 + + if new_password != confirm_password: + return jsonify({'success': False, 'message': '两次输入的新密码不一致'}), 400 + + if old_password == new_password: + return jsonify({'success': False, 'message': '新密码不能与旧密码相同'}), 400 + + # 更新密码 + current_user.set_password(new_password) + db.session.commit() + + return jsonify({ + 'success': True, + 'message': '密码修改成功' + }) + + except Exception as e: + db.session.rollback() + return jsonify({'success': False, 'message': f'修改失败:{str(e)}'}), 500 + + @app.route('/api/user/email', methods=['PUT']) + @login_required + def update_user_email(): + """更新用户邮箱""" + if not isinstance(current_user, User): + return jsonify({'success': False, 'message': '仅普通用户可访问'}), 403 + + try: + data = request.get_json() or {} + email = data.get('email', '').strip() + + # 验证邮箱格式 + if not email: + return jsonify({'success': False, 'message': '请输入邮箱地址'}), 400 + + import re + email_pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$' + if not re.match(email_pattern, email): + return jsonify({'success': False, 'message': '邮箱格式不正确'}), 400 + + # 检查邮箱是否已被其他用户使用 + existing_user = User.query.filter( + User.email == email, + User.id != current_user.id + ).first() + + if existing_user: + return jsonify({'success': False, 'message': '该邮箱已被其他用户使用'}), 400 + + # 更新邮箱 + current_user.email = email + # 重置验证状态 + current_user.email_verified = False + current_user.email_verified_at = None + current_user.email_verify_token = None + current_user.email_verify_token_expires = None + + db.session.commit() + + return jsonify({ + 'success': True, + 'message': '邮箱已更新,请验证新邮箱' + }) + + except Exception as e: + db.session.rollback() + return jsonify({'success': False, 'message': f'更新失败:{str(e)}'}), 500 + + @app.route('/api/user/send-verify-email', methods=['POST']) + @login_required + def send_verify_email(): + """发送邮箱验证邮件""" + if not isinstance(current_user, User): + return jsonify({'success': False, 'message': '仅普通用户可访问'}), 403 + + try: + # 检查是否已绑定邮箱 + if not current_user.email: + return jsonify({'success': False, 'message': '请先绑定邮箱'}), 400 + + # 检查是否已验证 + if current_user.email_verified: + return jsonify({'success': False, 'message': '邮箱已验证,无需重复验证'}), 400 + + # 生成验证令牌(32位随机字符串) + token = secrets.token_urlsafe(32) + expires = datetime.now() + timedelta(hours=24) + + # 保存令牌 + current_user.email_verify_token = token + current_user.email_verify_token_expires = expires + db.session.commit() + + # 发送验证邮件 + verify_url = url_for('verify_email', token=token, _external=True) + + html_content = f""" + + + + + + + +
+
+

验证您的邮箱

+
+
+

您好,{current_user.username}!

+

感谢您注册 ZJPB。请点击下面的按钮验证您的邮箱地址:

+

+ 验证邮箱 +

+

或复制以下链接到浏览器:

+

{verify_url}

+

此链接将在24小时后失效。

+
+ +
+ + + """ + + text_content = f""" + 验证您的邮箱 + + 您好,{current_user.username}! + + 感谢您注册 ZJPB。请访问以下链接验证您的邮箱地址: + + {verify_url} + + 此链接将在24小时后失效。 + + 如果您没有注册 ZJPB,请忽略此邮件。 + """ + + email_sender = EmailSender() + success = email_sender.send_email( + to_email=current_user.email, + subject='验证您的邮箱 - ZJPB', + html_content=html_content, + text_content=text_content + ) + + if success: + return jsonify({ + 'success': True, + 'message': '验证邮件已发送,请查收' + }) + else: + return jsonify({ + 'success': False, + 'message': '邮件发送失败,请稍后重试' + }), 500 + + except Exception as e: + db.session.rollback() + return jsonify({'success': False, 'message': f'发送失败:{str(e)}'}), 500 + + @app.route('/verify-email/') + def verify_email(token): + """验证邮箱""" + try: + # 查找令牌对应的用户 + user = User.query.filter_by(email_verify_token=token).first() + + if not user: + flash('验证链接无效', 'error') + return redirect(url_for('index')) + + # 检查令牌是否过期 + if user.email_verify_token_expires < datetime.now(): + flash('验证链接已过期,请重新发送', 'error') + return redirect(url_for('user_profile')) + + # 验证成功 + user.email_verified = True + user.email_verified_at = datetime.now() + user.email_verify_token = None + user.email_verify_token_expires = None + db.session.commit() + + flash('邮箱验证成功!', 'success') + return redirect(url_for('user_profile')) + + except Exception as e: + flash(f'验证失败:{str(e)}', 'error') + return redirect(url_for('index')) + @app.route('/admin/change-password', methods=['GET', 'POST']) @login_required def change_password(): diff --git a/migrate_email_verification.py b/migrate_email_verification.py new file mode 100644 index 0000000..5f385df --- /dev/null +++ b/migrate_email_verification.py @@ -0,0 +1,69 @@ +""" +添加邮箱验证相关字段的数据库迁移脚本 +运行方式: python migrate_email_verification.py +""" + +from app import create_app +from models import db + +def migrate(): + """执行数据库迁移""" + app = create_app() + + with app.app_context(): + try: + # 添加邮箱验证相关字段 + with db.engine.connect() as conn: + # 检查字段是否已存在 + result = conn.execute(db.text(""" + SELECT COUNT(*) as count + FROM information_schema.columns + WHERE table_name='users' AND column_name='email_verified' + """)) + exists = result.fetchone()[0] > 0 + + if not exists: + print("开始添加邮箱验证字段...") + + # 添加 email_verified 字段 + conn.execute(db.text(""" + ALTER TABLE users + ADD COLUMN email_verified BOOLEAN DEFAULT FALSE COMMENT '邮箱是否已验证' + """)) + conn.commit() + print("✓ 添加 email_verified 字段") + + # 添加 email_verified_at 字段 + conn.execute(db.text(""" + ALTER TABLE users + ADD COLUMN email_verified_at DATETIME COMMENT '邮箱验证时间' + """)) + conn.commit() + print("✓ 添加 email_verified_at 字段") + + # 添加 email_verify_token 字段 + conn.execute(db.text(""" + ALTER TABLE users + ADD COLUMN email_verify_token VARCHAR(100) COMMENT '邮箱验证令牌' + """)) + conn.commit() + print("✓ 添加 email_verify_token 字段") + + # 添加 email_verify_token_expires 字段 + conn.execute(db.text(""" + ALTER TABLE users + ADD COLUMN email_verify_token_expires DATETIME COMMENT '验证令牌过期时间' + """)) + conn.commit() + print("✓ 添加 email_verify_token_expires 字段") + + print("\n✅ 邮箱验证字段迁移完成!") + else: + print("⚠️ 邮箱验证字段已存在,跳过迁移") + + except Exception as e: + print(f"❌ 迁移失败: {str(e)}") + raise + +if __name__ == '__main__': + migrate() diff --git a/models.py b/models.py index abfe02a..0fccf5d 100644 --- a/models.py +++ b/models.py @@ -194,6 +194,10 @@ class User(UserMixin, db.Model): username = db.Column(db.String(50), unique=True, nullable=False, index=True, comment='用户名') password_hash = db.Column(db.String(255), nullable=False, comment='密码哈希') email = db.Column(db.String(100), unique=True, nullable=True, index=True, comment='邮箱') + email_verified = db.Column(db.Boolean, default=False, comment='邮箱是否已验证') + email_verified_at = db.Column(db.DateTime, comment='邮箱验证时间') + email_verify_token = db.Column(db.String(100), comment='邮箱验证令牌') + email_verify_token_expires = db.Column(db.DateTime, comment='验证令牌过期时间') avatar = db.Column(db.String(500), comment='头像URL') bio = db.Column(db.String(200), comment='个人简介') is_active = db.Column(db.Boolean, default=True, comment='是否启用') diff --git a/templates/base_new.html b/templates/base_new.html index 5cb6a92..73bd952 100644 --- a/templates/base_new.html +++ b/templates/base_new.html @@ -329,6 +329,7 @@ diff --git a/templates/user/change_password.html b/templates/user/change_password.html new file mode 100644 index 0000000..cdaedb2 --- /dev/null +++ b/templates/user/change_password.html @@ -0,0 +1,290 @@ +{% extends 'base_new.html' %} + +{% block title %}修改密码 - ZJPB{% endblock %} + +{% block extra_css %} + +{% endblock %} + +{% block content %} +
+ + arrow_back + 返回个人中心 + + +
+

修改密码

+

为了您的账户安全,请定期修改密码

+ +
+ +
+
+ +
+ + +
+
+ +
+ +
+ + +
+ 至少6个字符 +
+ +
+ +
+ + +
+
+ + +
+
+
+ + +{% endblock %} diff --git a/templates/user/profile.html b/templates/user/profile.html index 2b49b60..83d923e 100644 --- a/templates/user/profile.html +++ b/templates/user/profile.html @@ -193,6 +193,7 @@ @@ -213,6 +214,47 @@ + +
+

邮箱管理

+
+
+
+
当前邮箱
+
+ {{ current_user.email or '未绑定' }} +
+
+ {% if current_user.email %} +
+ {% if current_user.email_verified %} + + check_circle + 已验证 + + {% else %} + + warning + 未验证 + + {% endif %} +
+ {% endif %} +
+ +
+ + {% if current_user.email and not current_user.email_verified %} + + {% endif %} +
+
+
+

最近收藏

@@ -242,4 +284,146 @@
+ + + + + {% endblock %} diff --git a/utils/email_sender.py b/utils/email_sender.py new file mode 100644 index 0000000..cee739c --- /dev/null +++ b/utils/email_sender.py @@ -0,0 +1,73 @@ +""" +邮件发送工具模块 +支持发送验证邮件、通知邮件等 +""" + +import os +import smtplib +from email.mime.text import MIMEText +from email.mime.multipart import MIMEMultipart +from email.header import Header +import logging + +logger = logging.getLogger(__name__) + + +class EmailSender: + """邮件发送器""" + + def __init__(self): + """初始化邮件配置""" + self.smtp_server = os.environ.get('SMTP_SERVER', 'smtp.gmail.com') + self.smtp_port = int(os.environ.get('SMTP_PORT', '587')) + self.smtp_user = os.environ.get('SMTP_USER', '') + self.smtp_password = os.environ.get('SMTP_PASSWORD', '') + self.from_email = os.environ.get('FROM_EMAIL', self.smtp_user) + self.from_name = os.environ.get('FROM_NAME', 'ZJPB') + + def send_email(self, to_email, subject, html_content, text_content=None): + """ + 发送邮件 + + Args: + to_email: 收件人邮箱 + subject: 邮件主题 + html_content: HTML格式邮件内容 + text_content: 纯文本格式邮件内容(可选) + + Returns: + bool: 发送是否成功 + """ + try: + # 检查配置 + if not self.smtp_user or not self.smtp_password: + logger.error('邮件配置不完整,请设置SMTP_USER和SMTP_PASSWORD环境变量') + return False + + # 创建邮件对象 + message = MIMEMultipart('alternative') + message['From'] = f'{self.from_name} <{self.from_email}>' + message['To'] = to_email + message['Subject'] = Header(subject, 'utf-8') + + # 添加纯文本内容 + if text_content: + text_part = MIMEText(text_content, 'plain', 'utf-8') + message.attach(text_part) + + # 添加HTML内容 + html_part = MIMEText(html_content, 'html', 'utf-8') + message.attach(html_part) + + # 连接SMTP服务器并发送 + with smtplib.SMTP(self.smtp_server, self.smtp_port) as server: + server.starttls() # 启用TLS加密 + server.login(self.smtp_user, self.smtp_password) + server.send_message(message) + + logger.info(f'邮件发送成功: {to_email}') + return True + + except Exception as e: + logger.error(f'邮件发送失败: {str(e)}') + return False