feat: v3.1 - 用户密码管理和邮箱验证功能

新增功能:
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 <noreply@anthropic.com>
This commit is contained in:
Jowe
2026-02-07 23:26:02 +08:00
parent 1be1f35568
commit c61969dfc9
9 changed files with 1242 additions and 1 deletions

241
app.py
View File

@@ -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"""
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<style>
body {{ font-family: Arial, sans-serif; line-height: 1.6; color: #333; }}
.container {{ max-width: 600px; margin: 0 auto; padding: 20px; }}
.header {{ background: linear-gradient(135deg, #0ea5e9 0%, #0284c7 100%); color: white; padding: 30px; text-align: center; border-radius: 8px 8px 0 0; }}
.content {{ background: #f8fafc; padding: 30px; border-radius: 0 0 8px 8px; }}
.button {{ display: inline-block; padding: 12px 30px; background: #0ea5e9; color: white; text-decoration: none; border-radius: 6px; margin: 20px 0; }}
.footer {{ text-align: center; margin-top: 20px; color: #64748b; font-size: 12px; }}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>验证您的邮箱</h1>
</div>
<div class="content">
<p>您好,{current_user.username}</p>
<p>感谢您注册 ZJPB。请点击下面的按钮验证您的邮箱地址</p>
<p style="text-align: center;">
<a href="{verify_url}" class="button">验证邮箱</a>
</p>
<p>或复制以下链接到浏览器:</p>
<p style="word-break: break-all; background: white; padding: 10px; border-radius: 4px;">{verify_url}</p>
<p style="color: #64748b; font-size: 14px;">此链接将在24小时后失效。</p>
</div>
<div class="footer">
<p>如果您没有注册 ZJPB请忽略此邮件。</p>
<p>&copy; 2025 ZJPB - 自己品吧</p>
</div>
</div>
</body>
</html>
"""
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/<token>')
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():