合并login
Change-Id: Ie06ed019cbb00d52e0b9e1f3c7a56c947b57a42c
diff --git a/Merge/back_rhj/.env.example b/Merge/back_rhj/.env.example
new file mode 100644
index 0000000..fc7fb37
--- /dev/null
+++ b/Merge/back_rhj/.env.example
@@ -0,0 +1,37 @@
+# 数据库配置
+SQLURL=mysql+pymysql://username:password@localhost:3306/redbook
+SQLPORT=3306
+SQLNAME=redbook
+SQLUSER=root
+SQLPWD=123456
+
+# JWT密钥
+JWT_SECRET_KEY=your-jwt-secret-key-here
+
+# Flask密钥
+SECRET_KEY=your-flask-secret-key-here
+
+# 邮件服务配置
+# QQ邮箱示例配置
+MAIL_SERVER=smtp.qq.com
+MAIL_PORT=587
+MAIL_USE_TLS=true
+MAIL_USERNAME=1650349938@qq.com
+MAIL_PASSWORD=
+MAIL_DEFAULT_SENDER=1650349938@qq.com
+
+# Gmail示例配置
+# MAIL_SERVER=smtp.gmail.com
+# MAIL_PORT=587
+# MAIL_USE_TLS=true
+# MAIL_USERNAME=your_email@gmail.com
+# MAIL_PASSWORD=your_app_password
+# MAIL_DEFAULT_SENDER=your_email@gmail.com
+
+# 163邮箱示例配置
+# MAIL_SERVER=smtp.163.com
+# MAIL_PORT=25
+# MAIL_USE_TLS=false
+# MAIL_USERNAME=your_email@163.com
+# MAIL_PASSWORD=your_email_password
+# MAIL_DEFAULT_SENDER=your_email@163.com
diff --git a/Merge/back_rhj/README.md b/Merge/back_rhj/README.md
new file mode 100644
index 0000000..f425dd3
--- /dev/null
+++ b/Merge/back_rhj/README.md
@@ -0,0 +1,99 @@
+# RHJ Backend API
+
+这是RHJ项目的后端API服务,提供用户认证和管理功能。
+
+## 功能特性
+
+- 用户注册/登录
+- JWT令牌认证
+- 密码加密存储
+- 用户信息管理
+- 跨域支持(CORS)
+
+## 安装运行
+
+### 1. 安装依赖
+
+```bash
+pip install -r requirements.txt
+```
+
+### 2. 配置环境变量
+
+复制 `.env.example` 为 `.env` 并配置数据库连接信息:
+
+```bash
+cp .env.example .env
+```
+
+编辑 `.env` 文件,配置你的数据库连接信息。
+
+### 3. 运行服务
+
+```bash
+python app.py
+```
+
+服务将在 `http://localhost:8081` 启动。
+
+## API接口
+
+### 用户认证
+
+#### 用户注册
+- **POST** `/register`
+- **参数:**
+ ```json
+ {
+ "username": "用户名",
+ "email": "邮箱",
+ "password": "密码"
+ }
+ ```
+
+#### 用户登录
+- **POST** `/login`
+- **参数:**
+ ```json
+ {
+ "username": "用户名或邮箱",
+ "password": "密码"
+ }
+ ```
+
+#### 获取用户信息
+- **GET** `/profile`
+- **Headers:** `Authorization: Bearer <token>`
+
+#### 用户登出
+- **POST** `/logout`
+- **Headers:** `Authorization: Bearer <token>`
+
+#### 健康检查
+- **GET** `/health`
+
+## 响应格式
+
+所有API返回统一的JSON格式:
+
+```json
+{
+ "success": true/false,
+ "message": "提示信息",
+ "data": "数据内容(可选)"
+}
+```
+
+## 数据库模型
+
+### users 表
+- id: 用户ID(主键)
+- username: 用户名(唯一)
+- password: 加密密码
+- email: 邮箱(唯一)
+- avatar: 头像URL
+- role: 角色(user/admin/superadmin)
+- bio: 个人简介
+- status: 账号状态(active/banned/muted)
+- created_at: 创建时间
+- updated_at: 更新时间
diff --git a/Merge/back_rhj/__pycache__/config.cpython-312.pyc b/Merge/back_rhj/__pycache__/config.cpython-312.pyc
new file mode 100644
index 0000000..59299dd
--- /dev/null
+++ b/Merge/back_rhj/__pycache__/config.cpython-312.pyc
Binary files differ
diff --git a/Merge/back_rhj/__pycache__/test_smtp.cpython-312.pyc b/Merge/back_rhj/__pycache__/test_smtp.cpython-312.pyc
new file mode 100644
index 0000000..6d91369
--- /dev/null
+++ b/Merge/back_rhj/__pycache__/test_smtp.cpython-312.pyc
Binary files differ
diff --git a/Merge/back_rhj/app.py b/Merge/back_rhj/app.py
new file mode 100644
index 0000000..df7a598
--- /dev/null
+++ b/Merge/back_rhj/app.py
@@ -0,0 +1,6 @@
+from app import create_app
+
+app = create_app()
+
+if __name__ == "__main__":
+ app.run(debug=True,port=8082,host='0.0.0.0')
\ No newline at end of file
diff --git a/Merge/back_rhj/app/__init__.py b/Merge/back_rhj/app/__init__.py
new file mode 100644
index 0000000..c50a674
--- /dev/null
+++ b/Merge/back_rhj/app/__init__.py
@@ -0,0 +1,23 @@
+from flask import Flask
+from flask_cors import CORS
+
+def create_app():
+ app = Flask(__name__)
+
+ # 启用CORS支持跨域请求
+ CORS(app)
+
+ # Load configuration
+ app.config.from_object('config.Config')
+
+ # Register blueprints or routes
+ from .routes import main as main_blueprint
+ app.register_blueprint(main_blueprint)
+
+ # Register recommendation blueprint
+ from .blueprints.recommend import recommend_bp
+ app.register_blueprint(recommend_bp)
+
+ return app
+
+app = create_app()
diff --git a/Merge/back_rhj/app/__pycache__/__init__.cpython-312.pyc b/Merge/back_rhj/app/__pycache__/__init__.cpython-312.pyc
new file mode 100644
index 0000000..7c7d017
--- /dev/null
+++ b/Merge/back_rhj/app/__pycache__/__init__.cpython-312.pyc
Binary files differ
diff --git a/Merge/back_rhj/app/__pycache__/routes.cpython-312.pyc b/Merge/back_rhj/app/__pycache__/routes.cpython-312.pyc
new file mode 100644
index 0000000..0ec74bd
--- /dev/null
+++ b/Merge/back_rhj/app/__pycache__/routes.cpython-312.pyc
Binary files differ
diff --git a/Merge/back_rhj/app/blueprints/__pycache__/__init__.cpython-312.pyc b/Merge/back_rhj/app/blueprints/__pycache__/__init__.cpython-312.pyc
new file mode 100644
index 0000000..1388273
--- /dev/null
+++ b/Merge/back_rhj/app/blueprints/__pycache__/__init__.cpython-312.pyc
Binary files differ
diff --git a/Merge/back_rhj/app/blueprints/__pycache__/recommend.cpython-312.pyc b/Merge/back_rhj/app/blueprints/__pycache__/recommend.cpython-312.pyc
new file mode 100644
index 0000000..29c786d
--- /dev/null
+++ b/Merge/back_rhj/app/blueprints/__pycache__/recommend.cpython-312.pyc
Binary files differ
diff --git a/Merge/back_rhj/app/blueprints/recommend.py b/Merge/back_rhj/app/blueprints/recommend.py
new file mode 100644
index 0000000..97b8908
--- /dev/null
+++ b/Merge/back_rhj/app/blueprints/recommend.py
@@ -0,0 +1,303 @@
+from flask import Blueprint, request, jsonify
+from app.services.recommendation_service import RecommendationService
+from app.functions.FAuth import FAuth
+from sqlalchemy import create_engine
+from sqlalchemy.orm import sessionmaker
+from config import Config
+from functools import wraps
+
+recommend_bp = Blueprint('recommend', __name__, url_prefix='/api/recommend')
+
+def token_required(f):
+ """装饰器:需要令牌验证"""
+ @wraps(f)
+ def decorated(*args, **kwargs):
+ token = request.headers.get('Authorization')
+ if not token:
+ return jsonify({'success': False, 'message': '缺少访问令牌'}), 401
+
+ session = None
+ try:
+ # 移除Bearer前缀
+ if token.startswith('Bearer '):
+ token = token[7:]
+
+ engine = create_engine(Config.SQLURL)
+ SessionLocal = sessionmaker(bind=engine)
+ session = SessionLocal()
+ f_auth = FAuth(session)
+
+ user = f_auth.get_user_by_token(token)
+ if not user:
+ return jsonify({'success': False, 'message': '无效的访问令牌'}), 401
+
+ # 将用户信息传递给路由函数
+ return f(user, *args, **kwargs)
+ except Exception as e:
+ if session:
+ session.rollback()
+ return jsonify({'success': False, 'message': '令牌验证失败'}), 401
+ finally:
+ if session:
+ session.close()
+
+ return decorated
+
+# 初始化推荐服务
+recommendation_service = RecommendationService()
+
+@recommend_bp.route('/get_recommendations', methods=['POST'])
+@token_required
+def get_recommendations(current_user):
+ """获取个性化推荐"""
+ try:
+ data = request.get_json()
+ user_id = data.get('user_id') or current_user.user_id
+ topk = data.get('topk', 2)
+
+ recommendations = recommendation_service.get_recommendations(user_id, topk)
+
+ return jsonify({
+ 'success': True,
+ 'data': {
+ 'user_id': user_id,
+ 'recommendations': recommendations,
+ 'count': len(recommendations)
+ },
+ 'message': '推荐获取成功'
+ })
+ except Exception as e:
+ return jsonify({
+ 'success': False,
+ 'message': f'推荐获取失败: {str(e)}'
+ }), 500
+
+@recommend_bp.route('/cold_start', methods=['GET'])
+def cold_start_recommendations():
+ """冷启动推荐(无需登录)"""
+ try:
+ topk = request.args.get('topk', 2, type=int)
+
+ recommendations = recommendation_service.user_cold_start(topk)
+
+ return jsonify({
+ 'success': True,
+ 'data': {
+ 'recommendations': recommendations,
+ 'count': len(recommendations),
+ 'type': 'cold_start'
+ },
+ 'message': '热门推荐获取成功'
+ })
+ except Exception as e:
+ return jsonify({
+ 'success': False,
+ 'message': f'推荐获取失败: {str(e)}'
+ }), 500
+
+@recommend_bp.route('/health', methods=['GET'])
+def health_check():
+ """推荐系统健康检查"""
+ try:
+ # 简单的健康检查
+ import torch
+ cuda_available = torch.cuda.is_available()
+
+ return jsonify({
+ 'success': True,
+ 'data': {
+ 'status': 'healthy',
+ 'cuda_available': cuda_available,
+ 'device': 'cuda' if cuda_available else 'cpu'
+ },
+ 'message': '推荐系统运行正常'
+ })
+ except Exception as e:
+ return jsonify({
+ 'success': False,
+ 'message': f'推荐系统异常: {str(e)}'
+ }), 500
+
+@recommend_bp.route('/multi_recall', methods=['POST'])
+@token_required
+def multi_recall_recommendations(current_user):
+ """多路召回推荐"""
+ try:
+ data = request.get_json()
+ user_id = data.get('user_id') or current_user.user_id
+ topk = data.get('topk', 2)
+
+ # 强制使用多路召回
+ result = recommendation_service.run_inference(user_id, topk, use_multi_recall=True)
+
+ # 如果是冷启动直接返回详细信息,否则查详情
+ if isinstance(result, list) and result and isinstance(result[0], dict):
+ recommendations = result
+ else:
+ # result 是 (topk_post_ids, topk_scores) 的元组
+ if isinstance(result, tuple) and len(result) == 2:
+ topk_post_ids, topk_scores = result
+ recommendations = recommendation_service.get_post_info(topk_post_ids, topk_scores)
+ else:
+ recommendations = recommendation_service.get_post_info(result)
+
+ return jsonify({
+ 'success': True,
+ 'data': {
+ 'user_id': user_id,
+ 'recommendations': recommendations,
+ 'count': len(recommendations),
+ 'type': 'multi_recall'
+ },
+ 'message': '多路召回推荐获取成功'
+ })
+ except Exception as e:
+ return jsonify({
+ 'success': False,
+ 'message': f'多路召回推荐获取失败: {str(e)}'
+ }), 500
+
+@recommend_bp.route('/lightgcn', methods=['POST'])
+@token_required
+def lightgcn_recommendations(current_user):
+ """LightGCN推荐"""
+ try:
+ data = request.get_json()
+ user_id = data.get('user_id') or current_user.user_id
+ topk = data.get('topk', 2)
+
+ # 强制使用LightGCN
+ result = recommendation_service.run_inference(user_id, topk, use_multi_recall=False)
+
+ # 如果是冷启动直接返回详细信息,否则查详情
+ if isinstance(result, list) and result and isinstance(result[0], dict):
+ recommendations = result
+ else:
+ # result 是 (topk_post_ids, topk_scores) 的元组
+ if isinstance(result, tuple) and len(result) == 2:
+ topk_post_ids, topk_scores = result
+ recommendations = recommendation_service.get_post_info(topk_post_ids, topk_scores)
+ else:
+ recommendations = recommendation_service.get_post_info(result)
+
+ return jsonify({
+ 'success': True,
+ 'data': {
+ 'user_id': user_id,
+ 'recommendations': recommendations,
+ 'count': len(recommendations),
+ 'type': 'lightgcn'
+ },
+ 'message': 'LightGCN推荐获取成功'
+ })
+ except Exception as e:
+ return jsonify({
+ 'success': False,
+ 'message': f'LightGCN推荐获取失败: {str(e)}'
+ }), 500
+
+@recommend_bp.route('/train_multi_recall', methods=['POST'])
+@token_required
+def train_multi_recall(current_user):
+ """训练多路召回模型"""
+ try:
+ # 只有管理员才能训练模型
+ if not hasattr(current_user, 'is_admin') or not current_user.is_admin:
+ return jsonify({
+ 'success': False,
+ 'message': '需要管理员权限'
+ }), 403
+
+ recommendation_service.train_multi_recall()
+
+ return jsonify({
+ 'success': True,
+ 'message': '多路召回模型训练完成'
+ })
+ except Exception as e:
+ return jsonify({
+ 'success': False,
+ 'message': f'模型训练失败: {str(e)}'
+ }), 500
+
+@recommend_bp.route('/recall_config', methods=['GET'])
+@token_required
+def get_recall_config(current_user):
+ """获取多路召回配置"""
+ try:
+ config = recommendation_service.recall_config
+ return jsonify({
+ 'success': True,
+ 'data': {
+ 'config': config,
+ 'multi_recall_enabled': recommendation_service.multi_recall_enabled
+ },
+ 'message': '配置获取成功'
+ })
+ except Exception as e:
+ return jsonify({
+ 'success': False,
+ 'message': f'配置获取失败: {str(e)}'
+ }), 500
+
+@recommend_bp.route('/recall_config', methods=['POST'])
+@token_required
+def update_recall_config(current_user):
+ """更新多路召回配置"""
+ try:
+ # 只有管理员才能更新配置
+ if not hasattr(current_user, 'is_admin') or not current_user.is_admin:
+ return jsonify({
+ 'success': False,
+ 'message': '需要管理员权限'
+ }), 403
+
+ data = request.get_json()
+ new_config = data.get('config', {})
+
+ # 更新多路召回启用状态
+ if 'multi_recall_enabled' in data:
+ recommendation_service.multi_recall_enabled = data['multi_recall_enabled']
+
+ # 更新具体配置
+ if new_config:
+ recommendation_service.update_recall_config(new_config)
+
+ return jsonify({
+ 'success': True,
+ 'data': {
+ 'config': recommendation_service.recall_config,
+ 'multi_recall_enabled': recommendation_service.multi_recall_enabled
+ },
+ 'message': '配置更新成功'
+ })
+ except Exception as e:
+ return jsonify({
+ 'success': False,
+ 'message': f'配置更新失败: {str(e)}'
+ }), 500
+
+@recommend_bp.route('/recall_stats/<int:user_id>', methods=['GET'])
+@token_required
+def get_recall_stats(current_user, user_id):
+ """获取用户的召回统计信息"""
+ try:
+ # 只允许查看自己的统计或管理员查看
+ if current_user.user_id != user_id and (not hasattr(current_user, 'is_admin') or not current_user.is_admin):
+ return jsonify({
+ 'success': False,
+ 'message': '权限不足'
+ }), 403
+
+ stats = recommendation_service.get_multi_recall_stats(user_id)
+
+ return jsonify({
+ 'success': True,
+ 'data': stats,
+ 'message': '统计信息获取成功'
+ })
+ except Exception as e:
+ return jsonify({
+ 'success': False,
+ 'message': f'统计信息获取失败: {str(e)}'
+ }), 500
diff --git a/Merge/back_rhj/app/functions/FAuth.py b/Merge/back_rhj/app/functions/FAuth.py
new file mode 100644
index 0000000..d6310a5
--- /dev/null
+++ b/Merge/back_rhj/app/functions/FAuth.py
@@ -0,0 +1,611 @@
+from ..models.users import User as users
+from ..models.email_verification import EmailVerification
+from sqlalchemy.orm import Session
+import hashlib
+import jwt
+import smtplib
+import pytz
+from email.mime.text import MIMEText
+from email.mime.multipart import MIMEMultipart
+from datetime import datetime, timedelta
+from config import Config
+
+class FAuth:
+ def __init__(self, session: Session):
+ self.session = session
+ return
+
+ def hash_password(self, password):
+ """密码加密"""
+ return hashlib.sha256(password.encode()).hexdigest()
+
+ def is_password_hashed(self, password):
+ """检查密码是否已经被哈希加密
+
+ Args:
+ password: 密码字符串
+
+ Returns:
+ bool: 是否为已加密的密码(64位十六进制字符串)
+ """
+ import re
+ if not password or not isinstance(password, str):
+ return False
+ # SHA256 加密后是64位十六进制字符串
+ return bool(re.match(r'^[a-f0-9]{64}$', password, re.IGNORECASE))
+
+ def safe_hash_password(self, password):
+ """安全的密码加密函数,避免重复加密
+
+ Args:
+ password: 密码字符串
+
+ Returns:
+ str: 加密后的密码
+ """
+ if not password:
+ raise ValueError('密码不能为空')
+
+ # 如果已经是加密的密码,直接返回
+ if self.is_password_hashed(password):
+ return password
+
+ # 否则进行加密
+ return self.hash_password(password)
+
+ def verify_password(self, password, hashed_password):
+ """验证密码"""
+ return self.hash_password(password) == hashed_password
+
+ def generate_token(self, user_id, role='user'):
+ """生成JWT令牌"""
+ payload = {
+ 'user_id': user_id,
+ 'role': role,
+ 'exp': datetime.utcnow() + timedelta(hours=24) # 24小时过期
+ }
+ return jwt.encode(payload, Config.JWT_SECRET_KEY, algorithm='HS256')
+
+ def verify_token(self, token):
+ """验证JWT令牌"""
+ try:
+ payload = jwt.decode(token, Config.JWT_SECRET_KEY, algorithms=['HS256'])
+ return {
+ 'user_id': payload['user_id'],
+ 'role': payload.get('role', 'user') # 默认角色为user,兼容旧令牌
+ }
+ except jwt.ExpiredSignatureError:
+ return None
+ except jwt.InvalidTokenError:
+ return None
+
+ def login(self, username_or_email, password):
+ """用户登录"""
+ try:
+ # 查找用户
+ user = self.session.query(users).filter(
+ (users.email == username_or_email)
+ ).first()
+
+ if not user:
+ return {'success': False, 'message': '用户不存在'}
+
+ # 检查账号状态
+ if user.status != 'active':
+ return {'success': False, 'message': '账号已被禁用'}
+
+ # 验证密码(前端已加密,后端使用安全比较)
+ if not self.safe_hash_password(password) == user.password:
+ return {'success': False, 'message': '密码错误'}
+
+ # 生成令牌
+ token = self.generate_token(user.id, user.role)
+
+ return {
+ 'success': True,
+ 'message': '登录成功',
+ 'token': token,
+ 'user': user.to_dict()
+ }
+ except Exception as e:
+ print(f"Login error: {str(e)}")
+ return {'success': False, 'message': f'登录失败: {str(e)}'}
+
+ def register(self, username, email, password, verification_code):
+ """用户注册"""
+ # 检查用户名是否存在
+ existing_user = self.session.query(users).filter(
+ (users.username == username) | (users.email == email)
+ ).first()
+
+ if existing_user:
+ if existing_user.username == username:
+ return {'success': False, 'message': '用户名已存在'}
+ else:
+ return {'success': False, 'message': '邮箱已被注册'}
+
+ verification = self.session.query(EmailVerification).filter(
+ EmailVerification.email == email,
+ EmailVerification.type == 'register',
+ EmailVerification.is_verified == False
+ ).order_by(EmailVerification.created_at.desc()).first()
+
+ if not verification:
+ return {
+ 'success': False,
+ 'message': '验证码不存在或已过期'
+ }
+
+ # 验证验证码(检查是否为已加密的验证码)
+ verification_success = False
+ if self.is_password_hashed(verification_code):
+ # 如果是已加密的验证码,直接比较
+ verification_success = verification.verify_hashed(verification_code)
+ else:
+ # 如果是明文验证码,先加密再比较
+ verification_success = verification.verify(verification_code)
+ if not verification_success:
+ return {
+ 'success': False,
+ 'message': '验证码错误或已过期'
+ }
+ # 如果验证码验证成功,标记为已验证
+ verification.is_verified = True
+ verification.verified_at = datetime.now(pytz.timezone('Asia/Shanghai')).replace(tzinfo=None)
+
+
+ # 创建新用户(使用安全加密函数避免重复加密)
+ hashed_password = self.safe_hash_password(password)
+ new_user = users(
+ username=username,
+ email=email,
+ password=hashed_password,
+ role='user',
+ status='active'
+ )
+
+ try:
+ self.session.add(new_user)
+ self.session.commit()
+
+ # 生成令牌
+ token = self.generate_token(new_user.id, new_user.role)
+
+ return {
+ 'success': True,
+ 'message': '注册成功',
+ 'token': token,
+ 'user': new_user.to_dict()
+ }
+ except Exception as e:
+ self.session.rollback()
+ return {'success': False, 'message': '注册失败,请稍后重试'}
+
+ def get_user_by_token(self, token):
+ """通过令牌获取用户信息"""
+ token_data = self.verify_token(token)
+ if not token_data:
+ return None
+
+ user_id = token_data['user_id'] if isinstance(token_data, dict) else token_data
+ user = self.session.query(users).filter(users.id == user_id).first()
+ return user
+
+ def send_verification_email(self, email, verification_type='register', user_id=None):
+ """发送邮箱验证码
+
+ Args:
+ email: 目标邮箱地址
+ verification_type: 验证类型 ('register', 'reset_password', 'email_change')
+ user_id: 用户ID(可选)
+
+ Returns:
+ dict: 发送结果
+ """
+ try:
+ # 检查邮件配置
+ if not all([Config.MAIL_USERNAME, Config.MAIL_PASSWORD, Config.MAIL_DEFAULT_SENDER]):
+ return {
+ 'success': False,
+ 'message': '邮件服务配置不完整,请联系管理员'
+ }
+
+ if verification_type not in ['register', 'reset_password', 'email_change']:
+ return {
+ 'success': False,
+ 'message': '无效的验证类型'
+ }
+
+ if verification_type == 'reset_password' or verification_type == 'email_change':
+ # 检查用户是否存在
+ user = self.session.query(users).filter(users.email == email).first()
+ if not user:
+ return {
+ 'success': False,
+ 'message': '用户不存在或邮箱不匹配'
+ }
+ elif verification_type == 'register':
+ # 检查邮箱是否已注册
+ existing_user = self.session.query(users).filter(users.email == email).first()
+ if existing_user:
+ return {
+ 'success': False,
+ 'message': '邮箱已被注册'
+ }
+
+ # 创建验证记录
+ verification = EmailVerification.create_verification(
+ email=email,
+ verification_type=verification_type,
+ user_id=user_id,
+ expires_minutes=15 # 15分钟过期
+ )
+
+ # 保存到数据库
+ self.session.add(verification)
+
+ # 获取验证码
+ verification_code = verification.get_raw_code()
+ if not verification_code:
+ return {
+ 'success': False,
+ 'message': '验证码生成失败'
+ }
+
+ # 发送邮件
+ result = self._send_email(email, verification_code, verification_type)
+
+ if result['success']:
+ return {
+ 'success': True,
+ 'message': '验证码已发送到您的邮箱',
+ 'verification_id': verification.id
+ }
+ else:
+ # 如果邮件发送失败,删除验证记录
+ self.session.delete(verification)
+ self.session.commit()
+ return result
+
+ except Exception as e:
+ self.session.rollback()
+ print(f"Send verification email error: {str(e)}")
+ return {
+ 'success': False,
+ 'message': f'发送验证码失败: {str(e)}'
+ }
+
+ def _send_email(self, to_email, verification_code, verification_type):
+ """发送邮件的具体实现
+
+ Args:
+ to_email: 收件人邮箱
+ verification_code: 验证码
+ verification_type: 验证类型
+
+ Returns:
+ dict: 发送结果
+ """
+ try:
+ # 根据验证类型设置邮件内容
+ subject_map = {
+ 'register': '注册验证码',
+ 'reset_password': '密码重置验证码',
+ 'email_change': '邮箱变更验证码'
+ }
+
+ message_map = {
+ 'register': '欢迎注册我们的平台!',
+ 'reset_password': '您正在重置密码',
+ 'email_change': '您正在变更邮箱地址'
+ }
+
+ subject = subject_map.get(verification_type, '验证码')
+ message_intro = message_map.get(verification_type, '验证码')
+
+ # 创建邮件内容
+ msg = MIMEMultipart('alternative')
+ msg['Subject'] = subject
+ msg['From'] = Config.MAIL_DEFAULT_SENDER
+ msg['To'] = to_email
+
+ # HTML邮件内容
+ html_body = f"""
+ <!DOCTYPE html>
+ <html>
+ <head>
+ <meta charset="utf-8">
+ <title>{subject}</title>
+ </head>
+ <body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;">
+ <div style="max-width: 600px; margin: 0 auto; padding: 20px;">
+ <div style="background-color: #f8f9fa; padding: 20px; border-radius: 8px; margin-bottom: 20px;">
+ <h2 style="color: #007bff; margin-top: 0;">{message_intro}</h2>
+ <p>您的验证码是:</p>
+ <div style="background-color: #007bff; color: white; padding: 15px; border-radius: 5px; text-align: center; font-size: 24px; font-weight: bold; letter-spacing: 3px; margin: 20px 0;">
+ {verification_code}
+ </div>
+ <p style="color: #666; font-size: 14px;">
+ • 验证码有效期为15分钟<br>
+ • 请勿将验证码透露给他人<br>
+ • 如果这不是您的操作,请忽略此邮件
+ </p>
+ </div>
+ <div style="text-align: center; color: #999; font-size: 12px; border-top: 1px solid #eee; padding-top: 20px;">
+ <p>此邮件由系统自动发送,请勿回复</p>
+ </div>
+ </div>
+ </body>
+ </html>
+ """
+
+ # 纯文本内容(备用)
+ text_body = f"""
+ {message_intro}
+
+ 您的验证码是:{verification_code}
+
+ 验证码有效期为15分钟
+ 请勿将验证码透露给他人
+ 如果这不是您的操作,请忽略此邮件
+
+ 此邮件由系统自动发送,请勿回复
+ """
+
+ # 添加邮件内容
+ text_part = MIMEText(text_body, 'plain', 'utf-8')
+ html_part = MIMEText(html_body, 'html', 'utf-8')
+
+ msg.attach(text_part)
+ msg.attach(html_part)
+
+ # 连接SMTP服务器并发送邮件
+ server = None
+ try:
+ server = smtplib.SMTP(Config.MAIL_SERVER, Config.MAIL_PORT)
+ if Config.MAIL_USE_TLS:
+ server.starttls()
+
+ server.login(Config.MAIL_USERNAME, Config.MAIL_PASSWORD)
+ result = server.send_message(msg)
+
+ # 检查发送结果
+ if result:
+ # 如果有失败的收件人,记录日志
+ print(f"邮件发送部分失败: {result}")
+
+ return {
+ 'success': True,
+ 'message': '邮件发送成功'
+ }
+ finally:
+ # 确保连接被正确关闭
+ if server:
+ try:
+ server.quit()
+ except Exception:
+ # 如果quit()失败,强制关闭连接
+ try:
+ server.close()
+ except Exception:
+ pass
+
+ except smtplib.SMTPAuthenticationError:
+ return {
+ 'success': False,
+ 'message': '邮件服务认证失败,请检查邮箱配置'
+ }
+ except smtplib.SMTPException as e:
+ return {
+ 'success': False,
+ 'message': f'邮件发送失败: {str(e)}'
+ }
+ except Exception as e:
+ return {
+ 'success': False,
+ 'message': f'发送邮件时发生错误: {str(e)}'
+ }
+
+ def verify_email_code(self, email, code, verification_type='register'):
+ """验证邮箱验证码
+
+ Args:
+ email: 邮箱地址
+ code: 验证码
+ verification_type: 验证类型
+
+ Returns:
+ dict: 验证结果
+ """
+ try:
+ # 查找最新的未验证的验证记录
+ verification = self.session.query(EmailVerification).filter(
+ EmailVerification.email == email,
+ EmailVerification.type == verification_type,
+ EmailVerification.is_verified == False
+ ).order_by(EmailVerification.created_at.desc()).first()
+
+ if not verification:
+ return {
+ 'success': False,
+ 'message': '验证码不存在或已过期'
+ }
+
+ # 验证验证码(检查是否为已加密的验证码)
+ verification_success = False
+ if self.is_password_hashed(code):
+ # 如果是已加密的验证码,直接比较
+ verification_success = verification.verify_hashed(code)
+ else:
+ # 如果是明文验证码,先加密再比较
+ verification_success = verification.verify(code)
+
+ if verification_success:
+ # 不在这里提交事务,留给调用者决定何时提交
+ # self.session.commit() # 注释掉立即提交
+ return {
+ 'success': True,
+ 'message': '验证成功',
+ 'verification_id': verification.id
+ }
+ else:
+ return {
+ 'success': False,
+ 'message': '验证码错误或已过期'
+ }
+
+ except Exception as e:
+ print(f"Verify email code error: {str(e)}")
+ return {
+ 'success': False,
+ 'message': f'验证失败: {str(e)}'
+ }
+
+ def reset_password(self, email, new_password, verification_code):
+ """重置用户密码
+
+ Args:
+ email: 用户邮箱
+ new_password: 新密码
+ verification_code: 验证码
+
+ Returns:
+ dict: 重置结果
+ """
+ try:
+ # 检查是否有最近已验证的重置密码验证记录(5分钟内)
+
+ china_tz = pytz.timezone('Asia/Shanghai')
+ current_time = datetime.now(china_tz).replace(tzinfo=None)
+ five_minutes_ago = current_time - timedelta(minutes=5)
+
+ # 查找最近5分钟内已验证的重置密码验证记录
+ recent_verification = self.session.query(EmailVerification).filter(
+ EmailVerification.email == email,
+ EmailVerification.type == 'reset_password',
+ EmailVerification.is_verified == True,
+ EmailVerification.verified_at >= five_minutes_ago
+ ).order_by(EmailVerification.verified_at.desc()).first()
+
+ if not recent_verification:
+ return {
+ 'success': False,
+ 'message': '验证码未验证或已过期,请重新验证'
+ }
+
+ # 查找用户
+ user = self.session.query(users).filter(users.email == email).first()
+ if not user:
+ return {
+ 'success': False,
+ 'message': '用户不存在'
+ }
+
+ # 检查账号状态
+ if user.status != 'active':
+ return {
+ 'success': False,
+ 'message': '账号已被禁用,无法重置密码'
+ }
+
+ # 更新密码(使用安全加密函数避免重复加密)
+ user.password = self.safe_hash_password(new_password)
+ # 使用中国时区时间
+ china_tz = pytz.timezone('Asia/Shanghai')
+ user.updated_at = datetime.now(china_tz).replace(tzinfo=None)
+
+ # 提交更改
+ self.session.commit()
+
+ return {
+ 'success': True,
+ 'message': '密码重置成功'
+ }
+
+ except Exception as e:
+ self.session.rollback()
+ print(f"Reset password error: {str(e)}")
+ return {
+ 'success': False,
+ 'message': f'密码重置失败: {str(e)}'
+ }
+
+ def reset_password_with_verification(self, email, new_password, verification_code):
+ """重置用户密码(一步完成验证码验证和密码重置)
+
+ Args:
+ email: 用户邮箱
+ new_password: 新密码
+ verification_code: 验证码
+
+ Returns:
+ dict: 重置结果
+ """
+ try:
+ # 查找用户
+ user = self.session.query(users).filter(users.email == email).first()
+ if not user:
+ return {
+ 'success': False,
+ 'message': '用户不存在'
+ }
+
+ # 检查账号状态
+ if user.status != 'active':
+ return {
+ 'success': False,
+ 'message': '账号已被禁用,无法重置密码'
+ }
+
+ # 验证验证码
+ verification = self.session.query(EmailVerification).filter(
+ EmailVerification.email == email,
+ EmailVerification.type == 'reset_password',
+ EmailVerification.is_verified == False
+ ).order_by(EmailVerification.created_at.desc()).first()
+
+ if not verification:
+ return {
+ 'success': False,
+ 'message': '验证码不存在或已过期'
+ }
+
+ # 验证验证码(检查是否为已加密的验证码)
+ verification_success = False
+ if self.is_password_hashed(verification_code):
+ # 如果是已加密的验证码,直接比较
+ verification_success = verification.verify_hashed(verification_code)
+ else:
+ # 如果是明文验证码,先加密再比较
+ verification_success = verification.verify(verification_code)
+ if not verification_success:
+ return {
+ 'success': False,
+ 'message': '验证码错误或已过期'
+ }
+ # 如果验证码验证成功,标记为已验证
+ verification.is_verified = True
+ verification.verified_at = datetime.now(pytz.timezone('Asia/Shanghai')).replace(tzinfo=None)
+
+ # 更新密码(使用安全加密函数避免重复加密)
+ user.password = self.safe_hash_password(new_password)
+
+ # 使用中国时区时间更新时间戳
+ china_tz = pytz.timezone('Asia/Shanghai')
+ user.updated_at = datetime.now(china_tz).replace(tzinfo=None)
+
+ # 提交更改
+ self.session.commit()
+
+ return {
+ 'success': True,
+ 'message': '密码重置成功'
+ }
+
+ except Exception as e:
+ self.session.rollback()
+ print(f"Reset password with verification error: {str(e)}")
+ return {
+ 'success': False,
+ 'message': f'密码重置失败: {str(e)}'
+ }
diff --git a/Merge/back_rhj/app/functions/__pycache__/FAuth.cpython-312.pyc b/Merge/back_rhj/app/functions/__pycache__/FAuth.cpython-312.pyc
new file mode 100644
index 0000000..49086a6
--- /dev/null
+++ b/Merge/back_rhj/app/functions/__pycache__/FAuth.cpython-312.pyc
Binary files differ
diff --git a/Merge/back_rhj/app/models/__init__.py b/Merge/back_rhj/app/models/__init__.py
new file mode 100644
index 0000000..179ba58
--- /dev/null
+++ b/Merge/back_rhj/app/models/__init__.py
@@ -0,0 +1,7 @@
+from sqlalchemy.ext.declarative import declarative_base
+
+Base = declarative_base()
+
+# 先定义好 Base,再把所有 model import 进来,让 SQLAlchemy 一次性注册它们
+from .users import User
+from .email_verification import EmailVerification
diff --git a/Merge/back_rhj/app/models/__pycache__/__init__.cpython-312.pyc b/Merge/back_rhj/app/models/__pycache__/__init__.cpython-312.pyc
new file mode 100644
index 0000000..a0814d8
--- /dev/null
+++ b/Merge/back_rhj/app/models/__pycache__/__init__.cpython-312.pyc
Binary files differ
diff --git a/Merge/back_rhj/app/models/__pycache__/email_verification.cpython-312.pyc b/Merge/back_rhj/app/models/__pycache__/email_verification.cpython-312.pyc
new file mode 100644
index 0000000..c1d6dfa
--- /dev/null
+++ b/Merge/back_rhj/app/models/__pycache__/email_verification.cpython-312.pyc
Binary files differ
diff --git a/Merge/back_rhj/app/models/__pycache__/users.cpython-312.pyc b/Merge/back_rhj/app/models/__pycache__/users.cpython-312.pyc
new file mode 100644
index 0000000..58af35e
--- /dev/null
+++ b/Merge/back_rhj/app/models/__pycache__/users.cpython-312.pyc
Binary files differ
diff --git a/Merge/back_rhj/app/models/email_verification.py b/Merge/back_rhj/app/models/email_verification.py
new file mode 100644
index 0000000..2d3f7da
--- /dev/null
+++ b/Merge/back_rhj/app/models/email_verification.py
@@ -0,0 +1,189 @@
+# filepath: /home/ronghanji/api/API-TRM/rhj/backend/app/models/email_verification.py
+from . import Base
+from sqlalchemy import (
+ Column, Integer, String, Boolean, TIMESTAMP, text, ForeignKey
+)
+from sqlalchemy.orm import relationship
+from datetime import datetime, timedelta
+import secrets
+import string
+import hashlib
+import pytz
+
+
+class EmailVerification(Base):
+ __tablename__ = 'email_verifications'
+
+ id = Column(Integer, primary_key=True, autoincrement=True, comment='验证记录ID')
+ email = Column(String(100), nullable=False, comment='邮箱地址')
+ code = Column(String(255), nullable=False, comment='验证码(加密存储)')
+ type = Column(String(50), nullable=False, comment='验证类型:register, reset_password, email_change')
+ user_id = Column(Integer, ForeignKey('users.id'), nullable=True, comment='关联用户ID')
+ is_verified = Column(Boolean, nullable=False, default=False, comment='是否已验证')
+ expires_at = Column(TIMESTAMP, nullable=False, comment='过期时间')
+ created_at = Column(
+ TIMESTAMP,
+ nullable=False,
+ server_default=text('CURRENT_TIMESTAMP'),
+ comment='创建时间'
+ )
+ verified_at = Column(TIMESTAMP, nullable=True, comment='验证时间')
+
+ # 关联用户表
+ user = relationship("User", back_populates="email_verifications")
+
+ def __init__(self, email, verification_type, user_id=None, expires_minutes=15):
+ """初始化邮箱验证记录
+
+ Args:
+ email: 邮箱地址
+ verification_type: 验证类型
+ user_id: 用户ID(可选)
+ expires_minutes: 过期时间(分钟)
+ """
+ self.email = email
+ self.type = verification_type
+ self.user_id = user_id
+ self.is_verified = False
+
+ # 使用中国时区时间,确保与数据库时间一致
+ china_tz = pytz.timezone('Asia/Shanghai')
+ current_time = datetime.now(china_tz).replace(tzinfo=None)
+ self.expires_at = current_time + timedelta(minutes=expires_minutes)
+
+ # 生成并加密验证码
+ raw_code = self._generate_code()
+ self.code = self._hash_code(raw_code)
+ self._raw_code = raw_code # 临时存储原始验证码用于发送邮件
+
+ @classmethod
+ def create_verification(cls, email, verification_type, user_id=None, expires_minutes=15):
+ """创建验证记录
+
+ Args:
+ email: 邮箱地址
+ verification_type: 验证类型
+ user_id: 用户ID(可选)
+ expires_minutes: 过期时间(分钟)
+
+ Returns:
+ EmailVerification: 验证记录实例
+ """
+ return cls(
+ email=email,
+ verification_type=verification_type,
+ user_id=user_id,
+ expires_minutes=expires_minutes
+ )
+
+ def _generate_code(self, length=6):
+ """生成随机验证码
+
+ Args:
+ length: 验证码长度
+
+ Returns:
+ str: 验证码
+ """
+ characters = string.digits
+ return ''.join(secrets.choice(characters) for _ in range(length))
+
+ def _hash_code(self, code):
+ """对验证码进行哈希加密
+
+ Args:
+ code: 原始验证码
+
+ Returns:
+ str: 加密后的验证码
+ """
+ return hashlib.sha256(code.encode()).hexdigest()
+
+ def verify(self, input_code):
+ """验证验证码
+
+ Args:
+ input_code: 用户输入的验证码
+
+ Returns:
+ bool: 验证是否成功
+ """
+ if self.is_verified:
+ return False
+
+ if self.is_expired():
+ return False
+
+ hashed_input = self._hash_code(input_code)
+ if hashed_input == self.code:
+ # 使用中国时区时间设置验证时间
+ china_tz = pytz.timezone('Asia/Shanghai')
+ self.verified_at = datetime.now(china_tz).replace(tzinfo=None)
+ self.is_verified = True
+ return True
+
+ return False
+
+ def verify_hashed(self, hashed_code):
+ """验证已经加密的验证码
+
+ Args:
+ hashed_code: 已经加密的验证码
+
+ Returns:
+ bool: 验证是否成功
+ """
+ if self.is_verified:
+ return False
+
+ if self.is_expired():
+ return False
+
+ # 直接比较加密后的验证码
+ if hashed_code == self.code:
+ # 使用中国时区时间设置验证时间
+ china_tz = pytz.timezone('Asia/Shanghai')
+ self.verified_at = datetime.now(china_tz).replace(tzinfo=None)
+ self.is_verified = True
+ return True
+
+ return False
+
+ def is_expired(self):
+ """检查是否已过期
+
+ Returns:
+ bool: 是否已过期
+ """
+ # 使用中国时区时间进行比较
+ china_tz = pytz.timezone('Asia/Shanghai')
+ current_time = datetime.now(china_tz).replace(tzinfo=None)
+ return current_time > self.expires_at
+
+ def get_raw_code(self):
+ """获取原始验证码(仅在创建时可用)
+
+ Returns:
+ str: 原始验证码
+ """
+ return getattr(self, '_raw_code', None)
+
+ def to_dict(self):
+ """转换为字典格式
+
+ Returns:
+ dict: 对象字典表示
+ """
+ return {
+ 'id': self.id,
+ 'email': self.email,
+ 'type': self.type,
+ 'user_id': self.user_id,
+ 'is_verified': self.is_verified,
+ 'expires_at': self.expires_at.isoformat() if self.expires_at else None,
+ 'created_at': self.created_at.isoformat() if self.created_at else None,
+ 'verified_at': self.verified_at.isoformat() if self.verified_at else None
+ }
+
+ def __repr__(self):
+ return f"<EmailVerification(id={self.id}, email='{self.email}', type='{self.type}', verified={self.is_verified})>"
\ No newline at end of file
diff --git a/Merge/back_rhj/app/models/recall/__init__.py b/Merge/back_rhj/app/models/recall/__init__.py
new file mode 100644
index 0000000..98d926b
--- /dev/null
+++ b/Merge/back_rhj/app/models/recall/__init__.py
@@ -0,0 +1,24 @@
+"""
+多路召回模块
+
+包含以下召回算法:
+- SwingRecall: Swing召回算法,基于物品相似度
+- HotRecall: 热度召回算法,基于物品热度
+- AdRecall: 广告召回算法,专门处理广告内容
+- UserCFRecall: 用户协同过滤召回算法
+- MultiRecallManager: 多路召回管理器,整合所有召回策略
+"""
+
+from .swing_recall import SwingRecall
+from .hot_recall import HotRecall
+from .ad_recall import AdRecall
+from .usercf_recall import UserCFRecall
+from .multi_recall_manager import MultiRecallManager
+
+__all__ = [
+ 'SwingRecall',
+ 'HotRecall',
+ 'AdRecall',
+ 'UserCFRecall',
+ 'MultiRecallManager'
+]
diff --git a/Merge/back_rhj/app/models/recall/__pycache__/__init__.cpython-312.pyc b/Merge/back_rhj/app/models/recall/__pycache__/__init__.cpython-312.pyc
new file mode 100644
index 0000000..d1cf37c
--- /dev/null
+++ b/Merge/back_rhj/app/models/recall/__pycache__/__init__.cpython-312.pyc
Binary files differ
diff --git a/Merge/back_rhj/app/models/recall/__pycache__/ad_recall.cpython-312.pyc b/Merge/back_rhj/app/models/recall/__pycache__/ad_recall.cpython-312.pyc
new file mode 100644
index 0000000..08a722c
--- /dev/null
+++ b/Merge/back_rhj/app/models/recall/__pycache__/ad_recall.cpython-312.pyc
Binary files differ
diff --git a/Merge/back_rhj/app/models/recall/__pycache__/bloom_filter.cpython-312.pyc b/Merge/back_rhj/app/models/recall/__pycache__/bloom_filter.cpython-312.pyc
new file mode 100644
index 0000000..c4dae7e
--- /dev/null
+++ b/Merge/back_rhj/app/models/recall/__pycache__/bloom_filter.cpython-312.pyc
Binary files differ
diff --git a/Merge/back_rhj/app/models/recall/__pycache__/hot_recall.cpython-312.pyc b/Merge/back_rhj/app/models/recall/__pycache__/hot_recall.cpython-312.pyc
new file mode 100644
index 0000000..cb6c725
--- /dev/null
+++ b/Merge/back_rhj/app/models/recall/__pycache__/hot_recall.cpython-312.pyc
Binary files differ
diff --git a/Merge/back_rhj/app/models/recall/__pycache__/multi_recall_manager.cpython-312.pyc b/Merge/back_rhj/app/models/recall/__pycache__/multi_recall_manager.cpython-312.pyc
new file mode 100644
index 0000000..9a95456
--- /dev/null
+++ b/Merge/back_rhj/app/models/recall/__pycache__/multi_recall_manager.cpython-312.pyc
Binary files differ
diff --git a/Merge/back_rhj/app/models/recall/__pycache__/swing_recall.cpython-312.pyc b/Merge/back_rhj/app/models/recall/__pycache__/swing_recall.cpython-312.pyc
new file mode 100644
index 0000000..d913d68
--- /dev/null
+++ b/Merge/back_rhj/app/models/recall/__pycache__/swing_recall.cpython-312.pyc
Binary files differ
diff --git a/Merge/back_rhj/app/models/recall/__pycache__/usercf_recall.cpython-312.pyc b/Merge/back_rhj/app/models/recall/__pycache__/usercf_recall.cpython-312.pyc
new file mode 100644
index 0000000..adb6177
--- /dev/null
+++ b/Merge/back_rhj/app/models/recall/__pycache__/usercf_recall.cpython-312.pyc
Binary files differ
diff --git a/Merge/back_rhj/app/models/recall/ad_recall.py b/Merge/back_rhj/app/models/recall/ad_recall.py
new file mode 100644
index 0000000..0fe3b0a
--- /dev/null
+++ b/Merge/back_rhj/app/models/recall/ad_recall.py
@@ -0,0 +1,207 @@
+import pymysql
+from typing import List, Tuple, Dict
+import random
+
+class AdRecall:
+ """
+ 广告召回算法实现
+ 专门用于召回广告类型的内容
+ """
+
+ def __init__(self, db_config: dict):
+ """
+ 初始化广告召回模型
+
+ Args:
+ db_config: 数据库配置
+ """
+ self.db_config = db_config
+ self.ad_items = []
+
+ def _get_ad_items(self):
+ """获取广告物品列表"""
+ conn = pymysql.connect(**self.db_config)
+ try:
+ cursor = conn.cursor()
+
+ # 获取所有广告帖子,按热度和发布时间排序
+ cursor.execute("""
+ SELECT
+ p.id,
+ p.heat,
+ p.created_at,
+ COUNT(DISTINCT b.user_id) as interaction_count,
+ DATEDIFF(NOW(), p.created_at) as days_since_created
+ FROM posts p
+ LEFT JOIN behaviors b ON p.id = b.post_id
+ WHERE p.is_advertisement = 1 AND p.status = 'published'
+ GROUP BY p.id, p.heat, p.created_at
+ ORDER BY p.heat DESC, p.created_at DESC
+ """)
+
+ results = cursor.fetchall()
+
+ # 计算广告分数
+ items_with_scores = []
+ for row in results:
+ post_id, heat, created_at, interaction_count, days_since_created = row
+
+ # 处理None值
+ heat = heat or 0
+ interaction_count = interaction_count or 0
+ days_since_created = days_since_created or 0
+
+ # 广告分数计算:热度 + 交互数 - 时间惩罚
+ # 新发布的广告给予更高权重
+ freshness_bonus = max(0, 30 - days_since_created) / 30.0 # 30天内的新鲜度奖励
+
+ ad_score = (
+ heat * 0.6 +
+ interaction_count * 0.3 +
+ freshness_bonus * 100 # 新鲜度奖励
+ )
+
+ items_with_scores.append((post_id, ad_score))
+
+ # 按广告分数排序
+ self.ad_items = sorted(items_with_scores, key=lambda x: x[1], reverse=True)
+
+ finally:
+ cursor.close()
+ conn.close()
+
+ def train(self):
+ """训练广告召回模型"""
+ print("开始获取广告物品...")
+ self._get_ad_items()
+ print(f"广告召回模型训练完成,共{len(self.ad_items)}个广告物品")
+
+ def recall(self, user_id: int, num_items: int = 10) -> List[Tuple[int, float]]:
+ """
+ 为用户召回广告物品
+
+ Args:
+ user_id: 用户ID
+ num_items: 召回物品数量
+
+ Returns:
+ List of (item_id, score) tuples
+ """
+ # 如果尚未训练,先进行训练
+ if not hasattr(self, 'ad_items') or not self.ad_items:
+ self.train()
+
+ # 获取用户已交互的广告,避免重复推荐
+ conn = pymysql.connect(**self.db_config)
+ try:
+ cursor = conn.cursor()
+ cursor.execute("""
+ SELECT DISTINCT b.post_id
+ FROM behaviors b
+ JOIN posts p ON b.post_id = p.id
+ WHERE b.user_id = %s AND p.is_advertisement = 1
+ AND b.type IN ('like', 'favorite', 'comment', 'view')
+ """, (user_id,))
+
+ user_interacted_ads = set(row[0] for row in cursor.fetchall())
+
+ # 获取用户的兴趣标签(基于历史行为)
+ cursor.execute("""
+ SELECT t.name, COUNT(*) as count
+ FROM behaviors b
+ JOIN posts p ON b.post_id = p.id
+ JOIN post_tags pt ON p.id = pt.post_id
+ JOIN tags t ON pt.tag_id = t.id
+ WHERE b.user_id = %s AND b.type IN ('like', 'favorite', 'comment')
+ GROUP BY t.name
+ ORDER BY count DESC
+ LIMIT 10
+ """, (user_id,))
+
+ user_interest_tags = set(row[0] for row in cursor.fetchall())
+
+ finally:
+ cursor.close()
+ conn.close()
+
+ # 过滤掉用户已交互的广告
+ filtered_ads = [
+ (item_id, score) for item_id, score in self.ad_items
+ if item_id not in user_interacted_ads
+ ]
+
+ # 如果没有未交互的广告,但有广告数据,返回评分最高的广告(可能用户会再次感兴趣)
+ if not filtered_ads and self.ad_items:
+ print(f"用户 {user_id} 已与所有广告交互,返回评分最高的广告")
+ filtered_ads = self.ad_items[:num_items]
+
+ # 如果用户有兴趣标签,可以进一步个性化广告推荐
+ if user_interest_tags and filtered_ads:
+ filtered_ads = self._personalize_ads(filtered_ads, user_interest_tags)
+
+ return filtered_ads[:num_items]
+
+ def _personalize_ads(self, ad_list: List[Tuple[int, float]], user_interest_tags: set) -> List[Tuple[int, float]]:
+ """
+ 根据用户兴趣标签个性化广告推荐
+
+ Args:
+ ad_list: 广告列表
+ user_interest_tags: 用户兴趣标签
+
+ Returns:
+ 个性化后的广告列表
+ """
+ conn = pymysql.connect(**self.db_config)
+ try:
+ cursor = conn.cursor()
+
+ personalized_ads = []
+ for ad_id, ad_score in ad_list:
+ # 获取广告的标签
+ cursor.execute("""
+ SELECT t.name
+ FROM post_tags pt
+ JOIN tags t ON pt.tag_id = t.id
+ WHERE pt.post_id = %s
+ """, (ad_id,))
+
+ ad_tags = set(row[0] for row in cursor.fetchall())
+
+ # 计算标签匹配度
+ tag_match_score = len(ad_tags & user_interest_tags) / max(len(user_interest_tags), 1)
+
+ # 调整广告分数
+ final_score = ad_score * (1 + tag_match_score)
+ personalized_ads.append((ad_id, final_score))
+
+ # 重新排序
+ personalized_ads.sort(key=lambda x: x[1], reverse=True)
+ return personalized_ads
+
+ finally:
+ cursor.close()
+ conn.close()
+
+ def get_random_ads(self, num_items: int = 5) -> List[Tuple[int, float]]:
+ """
+ 获取随机广告(用于多样性)
+
+ Args:
+ num_items: 返回物品数量
+
+ Returns:
+ List of (item_id, score) tuples
+ """
+ if len(self.ad_items) <= num_items:
+ return self.ad_items
+
+ # 随机选择但倾向于高分广告
+ weights = [score for _, score in self.ad_items]
+ selected_indices = random.choices(
+ range(len(self.ad_items)),
+ weights=weights,
+ k=num_items
+ )
+
+ return [self.ad_items[i] for i in selected_indices]
diff --git a/Merge/back_rhj/app/models/recall/bloom_filter.py b/Merge/back_rhj/app/models/recall/bloom_filter.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/Merge/back_rhj/app/models/recall/bloom_filter.py
diff --git a/Merge/back_rhj/app/models/recall/hot_recall.py b/Merge/back_rhj/app/models/recall/hot_recall.py
new file mode 100644
index 0000000..dbc716c
--- /dev/null
+++ b/Merge/back_rhj/app/models/recall/hot_recall.py
@@ -0,0 +1,163 @@
+import pymysql
+from typing import List, Tuple, Dict
+import numpy as np
+
+class HotRecall:
+ """
+ 热度召回算法实现
+ 基于物品的热度(热度分数、交互次数等)进行召回
+ """
+
+ def __init__(self, db_config: dict):
+ """
+ 初始化热度召回模型
+
+ Args:
+ db_config: 数据库配置
+ """
+ self.db_config = db_config
+ self.hot_items = []
+
+ def _calculate_heat_scores(self):
+ """计算物品热度分数"""
+ conn = pymysql.connect(**self.db_config)
+ try:
+ cursor = conn.cursor()
+
+ # 综合考虑多个热度指标
+ cursor.execute("""
+ SELECT
+ p.id,
+ p.heat,
+ COUNT(DISTINCT CASE WHEN b.type = 'like' THEN b.user_id END) as like_count,
+ COUNT(DISTINCT CASE WHEN b.type = 'favorite' THEN b.user_id END) as favorite_count,
+ COUNT(DISTINCT CASE WHEN b.type = 'comment' THEN b.user_id END) as comment_count,
+ COUNT(DISTINCT CASE WHEN b.type = 'view' THEN b.user_id END) as view_count,
+ COUNT(DISTINCT CASE WHEN b.type = 'share' THEN b.user_id END) as share_count,
+ DATEDIFF(NOW(), p.created_at) as days_since_created
+ FROM posts p
+ LEFT JOIN behaviors b ON p.id = b.post_id
+ WHERE p.status = 'published'
+ GROUP BY p.id, p.heat, p.created_at
+ """)
+
+ results = cursor.fetchall()
+
+ # 计算综合热度分数
+ items_with_scores = []
+ for row in results:
+ post_id, heat, like_count, favorite_count, comment_count, view_count, share_count, days_since_created = row
+
+ # 处理None值
+ heat = heat or 0
+ like_count = like_count or 0
+ favorite_count = favorite_count or 0
+ comment_count = comment_count or 0
+ view_count = view_count or 0
+ share_count = share_count or 0
+ days_since_created = days_since_created or 0
+
+ # 综合热度分数计算
+ # 基础热度 + 加权的用户行为 + 时间衰减
+ behavior_score = (
+ like_count * 1.0 +
+ favorite_count * 2.0 +
+ comment_count * 3.0 +
+ view_count * 0.1 +
+ share_count * 5.0
+ )
+
+ # 时间衰减因子(越新的内容热度越高)
+ time_decay = np.exp(-days_since_created / 30.0) # 30天半衰期
+
+ # 最终热度分数
+ final_score = (heat * 0.3 + behavior_score * 0.7) * time_decay
+
+ items_with_scores.append((post_id, final_score))
+
+ # 按热度排序
+ self.hot_items = sorted(items_with_scores, key=lambda x: x[1], reverse=True)
+
+ finally:
+ cursor.close()
+ conn.close()
+
+ def train(self):
+ """训练热度召回模型"""
+ print("开始计算热度分数...")
+ self._calculate_heat_scores()
+ print(f"热度召回模型训练完成,共{len(self.hot_items)}个物品")
+
+ def recall(self, user_id: int, num_items: int = 50) -> List[Tuple[int, float]]:
+ """
+ 为用户召回热门物品
+
+ Args:
+ user_id: 用户ID
+ num_items: 召回物品数量
+
+ Returns:
+ List of (item_id, score) tuples
+ """
+ # 如果尚未训练,先进行训练
+ if not hasattr(self, 'hot_items') or not self.hot_items:
+ self.train()
+
+ # 获取用户已交互的物品,避免重复推荐
+ conn = pymysql.connect(**self.db_config)
+ try:
+ cursor = conn.cursor()
+ cursor.execute("""
+ SELECT DISTINCT post_id
+ FROM behaviors
+ WHERE user_id = %s AND type IN ('like', 'favorite', 'comment')
+ """, (user_id,))
+
+ user_interacted_items = set(row[0] for row in cursor.fetchall())
+
+ finally:
+ cursor.close()
+ conn.close()
+
+ # 过滤掉用户已交互的物品
+ filtered_items = [
+ (item_id, score) for item_id, score in self.hot_items
+ if item_id not in user_interacted_items
+ ]
+
+ # 如果过滤后没有足够的候选,放宽条件:只过滤强交互(like, favorite, comment)
+ if len(filtered_items) < num_items:
+ print(f"热度召回:过滤后候选不足({len(filtered_items)}),放宽过滤条件")
+ conn = pymysql.connect(**self.db_config)
+ try:
+ cursor = conn.cursor()
+ cursor.execute("""
+ SELECT DISTINCT post_id
+ FROM behaviors
+ WHERE user_id = %s AND type IN ('like', 'favorite', 'comment')
+ """, (user_id,))
+
+ strong_interacted_items = set(row[0] for row in cursor.fetchall())
+
+ finally:
+ cursor.close()
+ conn.close()
+
+ filtered_items = [
+ (item_id, score) for item_id, score in self.hot_items
+ if item_id not in strong_interacted_items
+ ]
+
+ return filtered_items[:num_items]
+
+ def get_top_hot_items(self, num_items: int = 100) -> List[Tuple[int, float]]:
+ """
+ 获取全局热门物品(不考虑用户个性化)
+
+ Args:
+ num_items: 返回物品数量
+
+ Returns:
+ List of (item_id, score) tuples
+ """
+ return self.hot_items[:num_items]
diff --git a/Merge/back_rhj/app/models/recall/multi_recall_manager.py b/Merge/back_rhj/app/models/recall/multi_recall_manager.py
new file mode 100644
index 0000000..03cb3f8
--- /dev/null
+++ b/Merge/back_rhj/app/models/recall/multi_recall_manager.py
@@ -0,0 +1,253 @@
+from typing import List, Tuple, Dict, Any
+import numpy as np
+from collections import defaultdict
+
+from .swing_recall import SwingRecall
+from .hot_recall import HotRecall
+from .ad_recall import AdRecall
+from .usercf_recall import UserCFRecall
+
+class MultiRecallManager:
+ """
+ 多路召回管理器
+ 整合Swing、热度召回、广告召回和UserCF等多种召回策略
+ """
+
+ def __init__(self, db_config: dict, recall_config: dict = None):
+ """
+ 初始化多路召回管理器
+
+ Args:
+ db_config: 数据库配置
+ recall_config: 召回配置,包含各个召回器的参数和召回数量
+ """
+ self.db_config = db_config
+
+ # 默认配置
+ default_config = {
+ 'swing': {
+ 'enabled': True,
+ 'num_items': 15,
+ 'alpha': 0.5
+ },
+ 'hot': {
+ 'enabled': True,
+ 'num_items': 10
+ },
+ 'ad': {
+ 'enabled': True,
+ 'num_items': 2
+ },
+ 'usercf': {
+ 'enabled': True,
+ 'num_items': 10,
+ 'min_common_items': 3,
+ 'num_similar_users': 50
+ }
+ }
+
+ # 合并用户配置
+ self.config = default_config
+ if recall_config:
+ for key, value in recall_config.items():
+ if key in self.config:
+ self.config[key].update(value)
+ else:
+ self.config[key] = value
+
+ # 初始化各个召回器
+ self.recalls = {}
+ self._init_recalls()
+
+ def _init_recalls(self):
+ """初始化各个召回器"""
+ if self.config['swing']['enabled']:
+ self.recalls['swing'] = SwingRecall(
+ self.db_config,
+ alpha=self.config['swing']['alpha']
+ )
+
+ if self.config['hot']['enabled']:
+ self.recalls['hot'] = HotRecall(self.db_config)
+
+ if self.config['ad']['enabled']:
+ self.recalls['ad'] = AdRecall(self.db_config)
+
+ if self.config['usercf']['enabled']:
+ self.recalls['usercf'] = UserCFRecall(
+ self.db_config,
+ min_common_items=self.config['usercf']['min_common_items']
+ )
+
+ def train_all(self):
+ """训练所有召回器"""
+ print("开始训练多路召回模型...")
+
+ for name, recall_model in self.recalls.items():
+ print(f"训练 {name} 召回器...")
+ recall_model.train()
+
+ print("所有召回器训练完成!")
+
+ def recall(self, user_id: int, total_items: int = 200) -> Tuple[List[int], List[float], Dict[str, List[Tuple[int, float]]]]:
+ """
+ 执行多路召回
+
+ Args:
+ user_id: 用户ID
+ total_items: 总召回物品数量
+
+ Returns:
+ Tuple containing:
+ - List of item IDs
+ - List of scores
+ - Dict of recall results by source
+ """
+ recall_results = {}
+ all_candidates = defaultdict(list) # item_id -> [(source, score), ...]
+
+ # 执行各路召回
+ for source, recall_model in self.recalls.items():
+ if not self.config[source]['enabled']:
+ continue
+
+ num_items = self.config[source]['num_items']
+
+ # 特殊处理UserCF的参数
+ if source == 'usercf':
+ items_scores = recall_model.recall(
+ user_id,
+ num_items=num_items,
+ num_similar_users=self.config[source]['num_similar_users']
+ )
+ else:
+ items_scores = recall_model.recall(user_id, num_items=num_items)
+
+ recall_results[source] = items_scores
+
+ # 收集候选物品
+ for item_id, score in items_scores:
+ all_candidates[item_id].append((source, score))
+
+ # 融合多路召回结果
+ final_candidates = self._merge_recall_results(all_candidates, total_items)
+
+ # 分离item_ids和scores
+ item_ids = [item_id for item_id, _ in final_candidates]
+ scores = [score for _, score in final_candidates]
+
+ return item_ids, scores, recall_results
+
+ def _merge_recall_results(self, all_candidates: Dict[int, List[Tuple[str, float]]],
+ total_items: int) -> List[Tuple[int, float]]:
+ """
+ 融合多路召回结果
+
+ Args:
+ all_candidates: 所有候选物品及其来源和分数
+ total_items: 最终返回的物品数量
+
+ Returns:
+ List of (item_id, final_score) tuples
+ """
+ # 定义各召回源的权重
+ source_weights = {
+ 'swing': 0.3,
+ 'hot': 0.2,
+ 'ad': 0.1,
+ 'usercf': 0.4
+ }
+
+ final_scores = []
+
+ for item_id, source_scores in all_candidates.items():
+ # 计算加权平均分数
+ weighted_score = 0.0
+ total_weight = 0.0
+
+ for source, score in source_scores:
+ weight = source_weights.get(source, 0.1)
+ weighted_score += weight * score
+ total_weight += weight
+
+ # 归一化
+ if total_weight > 0:
+ final_score = weighted_score / total_weight
+ else:
+ final_score = 0.0
+
+ # 多样性奖励:如果物品来自多个召回源,给予额外分数
+ diversity_bonus = len(source_scores) * 0.1
+ final_score += diversity_bonus
+
+ final_scores.append((item_id, final_score))
+
+ # 按最终分数排序
+ final_scores.sort(key=lambda x: x[1], reverse=True)
+
+ return final_scores[:total_items]
+
+ def get_recall_stats(self, user_id: int) -> Dict[str, Any]:
+ """
+ 获取召回统计信息
+
+ Args:
+ user_id: 用户ID
+
+ Returns:
+ 召回统计字典
+ """
+ stats = {
+ 'user_id': user_id,
+ 'enabled_recalls': list(self.recalls.keys()),
+ 'config': self.config
+ }
+
+ # 获取各召回器的统计信息
+ if 'usercf' in self.recalls:
+ try:
+ user_profile = self.recalls['usercf'].get_user_profile(user_id)
+ stats['user_profile'] = user_profile
+
+ neighbors = self.recalls['usercf'].get_user_neighbors(user_id, 5)
+ stats['similar_users'] = neighbors
+ except:
+ pass
+
+ return stats
+
+ def update_config(self, new_config: dict):
+ """
+ 更新召回配置
+
+ Args:
+ new_config: 新的配置字典
+ """
+ for key, value in new_config.items():
+ if key in self.config:
+ self.config[key].update(value)
+ else:
+ self.config[key] = value
+
+ # 重新初始化召回器
+ self._init_recalls()
+
+ def get_recall_breakdown(self, user_id: int) -> Dict[str, int]:
+ """
+ 获取各召回源的物品数量分布
+
+ Args:
+ user_id: 用户ID
+
+ Returns:
+ 各召回源的物品数量字典
+ """
+ breakdown = {}
+
+ for source in self.recalls.keys():
+ if self.config[source]['enabled']:
+ breakdown[source] = self.config[source]['num_items']
+ else:
+ breakdown[source] = 0
+
+ return breakdown
diff --git a/Merge/back_rhj/app/models/recall/swing_recall.py b/Merge/back_rhj/app/models/recall/swing_recall.py
new file mode 100644
index 0000000..bf7fdd6
--- /dev/null
+++ b/Merge/back_rhj/app/models/recall/swing_recall.py
@@ -0,0 +1,126 @@
+import numpy as np
+import pymysql
+from collections import defaultdict
+import math
+from typing import List, Tuple, Dict
+
+class SwingRecall:
+ """
+ Swing召回算法实现
+ 基于物品相似度的协同过滤算法,能够有效处理热门物品的问题
+ """
+
+ def __init__(self, db_config: dict, alpha: float = 0.5):
+ """
+ 初始化Swing召回模型
+
+ Args:
+ db_config: 数据库配置
+ alpha: 控制热门物品惩罚的参数,值越大惩罚越强
+ """
+ self.db_config = db_config
+ self.alpha = alpha
+ self.item_similarity = {}
+ self.user_items = defaultdict(set)
+ self.item_users = defaultdict(set)
+
+ def _get_interaction_data(self):
+ """获取用户-物品交互数据"""
+ conn = pymysql.connect(**self.db_config)
+ try:
+ cursor = conn.cursor()
+ # 获取用户行为数据(点赞、收藏、评论等)
+ cursor.execute("""
+ SELECT DISTINCT user_id, post_id
+ FROM behaviors
+ WHERE type IN ('like', 'favorite', 'comment')
+ """)
+ interactions = cursor.fetchall()
+
+ for user_id, post_id in interactions:
+ self.user_items[user_id].add(post_id)
+ self.item_users[post_id].add(user_id)
+
+ finally:
+ cursor.close()
+ conn.close()
+
+ def _calculate_swing_similarity(self):
+ """计算Swing相似度矩阵"""
+ print("开始计算Swing相似度...")
+
+ # 获取所有物品对
+ items = list(self.item_users.keys())
+
+ for i, item_i in enumerate(items):
+ if i % 100 == 0:
+ print(f"处理进度: {i}/{len(items)}")
+
+ self.item_similarity[item_i] = {}
+
+ for item_j in items[i+1:]:
+ # 获取同时交互过两个物品的用户
+ common_users = self.item_users[item_i] & self.item_users[item_j]
+
+ if len(common_users) < 2: # 需要至少2个共同用户
+ similarity = 0.0
+ else:
+ # 计算Swing相似度
+ similarity = 0.0
+ for u in common_users:
+ for v in common_users:
+ if u != v:
+ # Swing算法的核心公式
+ swing_weight = 1.0 / (self.alpha + len(self.user_items[u] & self.user_items[v]))
+ similarity += swing_weight
+
+ # 归一化
+ similarity = similarity / (len(common_users) * (len(common_users) - 1))
+
+ self.item_similarity[item_i][item_j] = similarity
+ # 对称性
+ if item_j not in self.item_similarity:
+ self.item_similarity[item_j] = {}
+ self.item_similarity[item_j][item_i] = similarity
+
+ print("Swing相似度计算完成")
+
+ def train(self):
+ """训练Swing模型"""
+ self._get_interaction_data()
+ self._calculate_swing_similarity()
+
+ def recall(self, user_id: int, num_items: int = 50) -> List[Tuple[int, float]]:
+ """
+ 为用户召回相似物品
+
+ Args:
+ user_id: 用户ID
+ num_items: 召回物品数量
+
+ Returns:
+ List of (item_id, score) tuples
+ """
+ # 如果尚未训练,先进行训练
+ if not hasattr(self, 'item_similarity') or not self.item_similarity:
+ self.train()
+
+ if user_id not in self.user_items:
+ return []
+
+ # 获取用户历史交互的物品
+ user_interacted_items = self.user_items[user_id]
+
+ # 计算候选物品的分数
+ candidate_scores = defaultdict(float)
+
+ for item_i in user_interacted_items:
+ if item_i in self.item_similarity:
+ for item_j, similarity in self.item_similarity[item_i].items():
+ # 排除用户已经交互过的物品
+ if item_j not in user_interacted_items:
+ candidate_scores[item_j] += similarity
+
+ # 按分数排序并返回top-N
+ sorted_candidates = sorted(candidate_scores.items(), key=lambda x: x[1], reverse=True)
+ return sorted_candidates[:num_items]
diff --git a/Merge/back_rhj/app/models/recall/usercf_recall.py b/Merge/back_rhj/app/models/recall/usercf_recall.py
new file mode 100644
index 0000000..d75e6d8
--- /dev/null
+++ b/Merge/back_rhj/app/models/recall/usercf_recall.py
@@ -0,0 +1,235 @@
+import pymysql
+from typing import List, Tuple, Dict, Set
+from collections import defaultdict
+import math
+import numpy as np
+
+class UserCFRecall:
+ """
+ UserCF (User-based Collaborative Filtering) 召回算法实现
+ 基于用户相似度的协同过滤算法
+ """
+
+ def __init__(self, db_config: dict, min_common_items: int = 3):
+ """
+ 初始化UserCF召回模型
+
+ Args:
+ db_config: 数据库配置
+ min_common_items: 计算用户相似度时的最小共同物品数
+ """
+ self.db_config = db_config
+ self.min_common_items = min_common_items
+ self.user_items = defaultdict(set)
+ self.item_users = defaultdict(set)
+ self.user_similarity = {}
+
+ def _get_user_item_interactions(self):
+ """获取用户-物品交互数据"""
+ conn = pymysql.connect(**self.db_config)
+ try:
+ cursor = conn.cursor()
+
+ # 获取用户行为数据,考虑不同行为的权重
+ cursor.execute("""
+ SELECT user_id, post_id, type, COUNT(*) as count
+ FROM behaviors
+ WHERE type IN ('like', 'favorite', 'comment', 'view')
+ GROUP BY user_id, post_id, type
+ """)
+
+ interactions = cursor.fetchall()
+
+ # 构建用户-物品交互矩阵(考虑行为权重)
+ user_item_scores = defaultdict(lambda: defaultdict(float))
+
+ # 定义不同行为的权重
+ behavior_weights = {
+ 'like': 1.0,
+ 'favorite': 2.0,
+ 'comment': 3.0,
+ 'view': 0.1
+ }
+
+ for user_id, post_id, behavior_type, count in interactions:
+ weight = behavior_weights.get(behavior_type, 1.0)
+ score = weight * count
+ user_item_scores[user_id][post_id] += score
+
+ # 转换为集合形式(用于相似度计算)
+ for user_id, items in user_item_scores.items():
+ # 只保留分数大于阈值的物品
+ threshold = 1.0 # 可调整阈值
+ for item_id, score in items.items():
+ if score >= threshold:
+ self.user_items[user_id].add(item_id)
+ self.item_users[item_id].add(user_id)
+
+ finally:
+ cursor.close()
+ conn.close()
+
+ def _calculate_user_similarity(self):
+ """计算用户相似度矩阵"""
+ print("开始计算用户相似度...")
+
+ users = list(self.user_items.keys())
+ total_pairs = len(users) * (len(users) - 1) // 2
+ processed = 0
+
+ for i, user_i in enumerate(users):
+ self.user_similarity[user_i] = {}
+
+ for user_j in users[i+1:]:
+ processed += 1
+ if processed % 10000 == 0:
+ print(f"处理进度: {processed}/{total_pairs}")
+
+ # 获取两个用户共同交互的物品
+ common_items = self.user_items[user_i] & self.user_items[user_j]
+
+ if len(common_items) < self.min_common_items:
+ similarity = 0.0
+ else:
+ # 计算余弦相似度
+ numerator = len(common_items)
+ denominator = math.sqrt(len(self.user_items[user_i]) * len(self.user_items[user_j]))
+ similarity = numerator / denominator if denominator > 0 else 0.0
+
+ self.user_similarity[user_i][user_j] = similarity
+ # 对称性
+ if user_j not in self.user_similarity:
+ self.user_similarity[user_j] = {}
+ self.user_similarity[user_j][user_i] = similarity
+
+ print("用户相似度计算完成")
+
+ def train(self):
+ """训练UserCF模型"""
+ self._get_user_item_interactions()
+ self._calculate_user_similarity()
+
+ def recall(self, user_id: int, num_items: int = 50, num_similar_users: int = 50) -> List[Tuple[int, float]]:
+ """
+ 为用户召回相似用户喜欢的物品
+
+ Args:
+ user_id: 目标用户ID
+ num_items: 召回物品数量
+ num_similar_users: 考虑的相似用户数量
+
+ Returns:
+ List of (item_id, score) tuples
+ """
+ # 如果尚未训练,先进行训练
+ if not hasattr(self, 'user_similarity') or not self.user_similarity:
+ self.train()
+
+ if user_id not in self.user_similarity or user_id not in self.user_items:
+ return []
+
+ # 获取最相似的用户
+ similar_users = sorted(
+ self.user_similarity[user_id].items(),
+ key=lambda x: x[1],
+ reverse=True
+ )[:num_similar_users]
+
+ # 获取目标用户已交互的物品
+ user_interacted_items = self.user_items[user_id]
+
+ # 计算候选物品的分数
+ candidate_scores = defaultdict(float)
+
+ for similar_user_id, similarity in similar_users:
+ if similarity <= 0:
+ continue
+
+ # 获取相似用户交互的物品
+ similar_user_items = self.user_items[similar_user_id]
+
+ for item_id in similar_user_items:
+ # 排除目标用户已经交互过的物品
+ if item_id not in user_interacted_items:
+ candidate_scores[item_id] += similarity
+
+ # 按分数排序并返回top-N
+ sorted_candidates = sorted(candidate_scores.items(), key=lambda x: x[1], reverse=True)
+ return sorted_candidates[:num_items]
+
+ def get_user_neighbors(self, user_id: int, num_neighbors: int = 10) -> List[Tuple[int, float]]:
+ """
+ 获取用户的相似邻居
+
+ Args:
+ user_id: 用户ID
+ num_neighbors: 邻居数量
+
+ Returns:
+ List of (neighbor_user_id, similarity) tuples
+ """
+ if user_id not in self.user_similarity:
+ return []
+
+ neighbors = sorted(
+ self.user_similarity[user_id].items(),
+ key=lambda x: x[1],
+ reverse=True
+ )[:num_neighbors]
+
+ return neighbors
+
+ def get_user_profile(self, user_id: int) -> Dict:
+ """
+ 获取用户画像信息
+
+ Args:
+ user_id: 用户ID
+
+ Returns:
+ 用户画像字典
+ """
+ if user_id not in self.user_items:
+ return {}
+
+ conn = pymysql.connect(**self.db_config)
+ try:
+ cursor = conn.cursor()
+
+ # 获取用户交互的物品类别统计
+ user_item_list = list(self.user_items[user_id])
+ if not user_item_list:
+ return {}
+
+ format_strings = ','.join(['%s'] * len(user_item_list))
+ cursor.execute(f"""
+ SELECT t.name, COUNT(*) as count
+ FROM post_tags pt
+ JOIN tags t ON pt.tag_id = t.id
+ WHERE pt.post_id IN ({format_strings})
+ GROUP BY t.name
+ ORDER BY count DESC
+ """, tuple(user_item_list))
+
+ tag_preferences = cursor.fetchall()
+
+ # 获取用户行为统计
+ cursor.execute("""
+ SELECT type, COUNT(*) as count
+ FROM behaviors
+ WHERE user_id = %s
+ GROUP BY type
+ """, (user_id,))
+
+ behavior_stats = cursor.fetchall()
+
+ return {
+ 'user_id': user_id,
+ 'total_interactions': len(self.user_items[user_id]),
+ 'tag_preferences': dict(tag_preferences),
+ 'behavior_stats': dict(behavior_stats)
+ }
+
+ finally:
+ cursor.close()
+ conn.close()
diff --git a/Merge/back_rhj/app/models/recommend/LightGCN.py b/Merge/back_rhj/app/models/recommend/LightGCN.py
new file mode 100644
index 0000000..38b1732
--- /dev/null
+++ b/Merge/back_rhj/app/models/recommend/LightGCN.py
@@ -0,0 +1,121 @@
+import torch
+import torch.nn as nn
+import torch.nn.functional as F
+import numpy as np
+import scipy.sparse as sp
+import math
+import networkx as nx
+import random
+from copy import deepcopy
+from app.utils.parse_args import args
+from app.models.recommend.base_model import BaseModel
+from app.models.recommend.operators import EdgelistDrop
+from app.models.recommend.operators import scatter_add, scatter_sum
+
+
+init = nn.init.xavier_uniform_
+
+class LightGCN(BaseModel):
+ def __init__(self, dataset, pretrained_model=None, phase='pretrain'):
+ super().__init__(dataset)
+ self.adj = self._make_binorm_adj(dataset.graph)
+ self.edges = self.adj._indices().t()
+ self.edge_norm = self.adj._values()
+
+ self.phase = phase
+
+ self.emb_gate = lambda x: x
+
+ if self.phase == 'pretrain' or self.phase == 'vanilla' or self.phase == 'for_tune':
+ self.user_embedding = nn.Parameter(init(torch.empty(self.num_users, self.emb_size)))
+ self.item_embedding = nn.Parameter(init(torch.empty(self.num_items, self.emb_size)))
+
+
+ elif self.phase == 'finetune':
+ pre_user_emb, pre_item_emb = pretrained_model.generate()
+ self.user_embedding = nn.Parameter(pre_user_emb).requires_grad_(True)
+ self.item_embedding = nn.Parameter(pre_item_emb).requires_grad_(True)
+
+ elif self.phase == 'continue_tune':
+ # re-initialize for loading state dict
+ self.user_embedding = nn.Parameter(init(torch.empty(self.num_users, self.emb_size)))
+ self.item_embedding = nn.Parameter(init(torch.empty(self.num_items, self.emb_size)))
+
+ self.edge_dropout = EdgelistDrop()
+
+ def _agg(self, all_emb, edges, edge_norm):
+ src_emb = all_emb[edges[:, 0]]
+
+ # bi-norm
+ src_emb = src_emb * edge_norm.unsqueeze(1)
+
+ # conv
+ dst_emb = scatter_sum(src_emb, edges[:, 1], dim=0, dim_size=self.num_users+self.num_items)
+ return dst_emb
+
+ def _edge_binorm(self, edges):
+ user_degs = scatter_add(torch.ones_like(edges[:, 0]), edges[:, 0], dim=0, dim_size=self.num_users)
+ user_degs = user_degs[edges[:, 0]]
+ item_degs = scatter_add(torch.ones_like(edges[:, 1]), edges[:, 1], dim=0, dim_size=self.num_items)
+ item_degs = item_degs[edges[:, 1]]
+ norm = torch.pow(user_degs, -0.5) * torch.pow(item_degs, -0.5)
+ return norm
+
+ def forward(self, edges, edge_norm, return_layers=False):
+ all_emb = torch.cat([self.user_embedding, self.item_embedding], dim=0)
+ all_emb = self.emb_gate(all_emb)
+ res_emb = [all_emb]
+ for l in range(args.num_layers):
+ all_emb = self._agg(res_emb[-1], edges, edge_norm)
+ res_emb.append(all_emb)
+ if not return_layers:
+ res_emb = sum(res_emb)
+ user_res_emb, item_res_emb = res_emb.split([self.num_users, self.num_items], dim=0)
+ else:
+ user_res_emb, item_res_emb = [], []
+ for emb in res_emb:
+ u_emb, i_emb = emb.split([self.num_users, self.num_items], dim=0)
+ user_res_emb.append(u_emb)
+ item_res_emb.append(i_emb)
+ return user_res_emb, item_res_emb
+
+ def cal_loss(self, batch_data):
+ edges, dropout_mask = self.edge_dropout(self.edges, 1-args.edge_dropout, return_mask=True)
+ edge_norm = self.edge_norm[dropout_mask]
+
+ # forward
+ users, pos_items, neg_items = batch_data
+ user_emb, item_emb = self.forward(edges, edge_norm)
+ batch_user_emb = user_emb[users]
+ pos_item_emb = item_emb[pos_items]
+ neg_item_emb = item_emb[neg_items]
+ rec_loss = self._bpr_loss(batch_user_emb, pos_item_emb, neg_item_emb)
+ reg_loss = args.weight_decay * self._reg_loss(users, pos_items, neg_items)
+
+ loss = rec_loss + reg_loss
+ loss_dict = {
+ "rec_loss": rec_loss.item(),
+ "reg_loss": reg_loss.item(),
+ }
+ return loss, loss_dict
+
+ @torch.no_grad()
+ def generate(self, return_layers=False):
+ return self.forward(self.edges, self.edge_norm, return_layers=return_layers)
+
+ @torch.no_grad()
+ def generate_lgn(self, return_layers=False):
+ return self.forward(self.edges, self.edge_norm, return_layers=return_layers)
+
+ @torch.no_grad()
+ def rating(self, user_emb, item_emb):
+ return torch.matmul(user_emb, item_emb.t())
+
+ def _reg_loss(self, users, pos_items, neg_items):
+ u_emb = self.user_embedding[users]
+ pos_i_emb = self.item_embedding[pos_items]
+ neg_i_emb = self.item_embedding[neg_items]
+ reg_loss = (1/2)*(u_emb.norm(2).pow(2) +
+ pos_i_emb.norm(2).pow(2) +
+ neg_i_emb.norm(2).pow(2))/float(len(users))
+ return reg_loss
diff --git a/Merge/back_rhj/app/models/recommend/LightGCN_pretrained.pt b/Merge/back_rhj/app/models/recommend/LightGCN_pretrained.pt
new file mode 100644
index 0000000..825e0e2
--- /dev/null
+++ b/Merge/back_rhj/app/models/recommend/LightGCN_pretrained.pt
Binary files differ
diff --git a/Merge/back_rhj/app/models/recommend/__pycache__/LightGCN.cpython-312.pyc b/Merge/back_rhj/app/models/recommend/__pycache__/LightGCN.cpython-312.pyc
new file mode 100644
index 0000000..c87435f
--- /dev/null
+++ b/Merge/back_rhj/app/models/recommend/__pycache__/LightGCN.cpython-312.pyc
Binary files differ
diff --git a/Merge/back_rhj/app/models/recommend/__pycache__/base_model.cpython-312.pyc b/Merge/back_rhj/app/models/recommend/__pycache__/base_model.cpython-312.pyc
new file mode 100644
index 0000000..b9d8c72
--- /dev/null
+++ b/Merge/back_rhj/app/models/recommend/__pycache__/base_model.cpython-312.pyc
Binary files differ
diff --git a/Merge/back_rhj/app/models/recommend/__pycache__/lightgcn_scorer.cpython-312.pyc b/Merge/back_rhj/app/models/recommend/__pycache__/lightgcn_scorer.cpython-312.pyc
new file mode 100644
index 0000000..b0887a9
--- /dev/null
+++ b/Merge/back_rhj/app/models/recommend/__pycache__/lightgcn_scorer.cpython-312.pyc
Binary files differ
diff --git a/Merge/back_rhj/app/models/recommend/__pycache__/operators.cpython-312.pyc b/Merge/back_rhj/app/models/recommend/__pycache__/operators.cpython-312.pyc
new file mode 100644
index 0000000..13bb375
--- /dev/null
+++ b/Merge/back_rhj/app/models/recommend/__pycache__/operators.cpython-312.pyc
Binary files differ
diff --git a/Merge/back_rhj/app/models/recommend/base_model.py b/Merge/back_rhj/app/models/recommend/base_model.py
new file mode 100644
index 0000000..6c59aa6
--- /dev/null
+++ b/Merge/back_rhj/app/models/recommend/base_model.py
@@ -0,0 +1,111 @@
+import torch
+import torch.nn as nn
+from app.utils.parse_args import args
+from scipy.sparse import csr_matrix
+import scipy.sparse as sp
+import numpy as np
+import torch.nn.functional as F
+
+
+class BaseModel(nn.Module):
+ def __init__(self, dataloader):
+ super(BaseModel, self).__init__()
+ self.num_users = dataloader.num_users
+ self.num_items = dataloader.num_items
+ self.emb_size = args.emb_size
+
+ def forward(self):
+ pass
+
+ def cal_loss(self, batch_data):
+ pass
+
+ def _check_inf(self, loss, pos_score, neg_score, edge_weight):
+ # find inf idx
+ inf_idx = torch.isinf(loss) | torch.isnan(loss)
+ if inf_idx.any():
+ print("find inf in loss")
+ if type(edge_weight) != int:
+ print(edge_weight[inf_idx])
+ print(f"pos_score: {pos_score[inf_idx]}")
+ print(f"neg_score: {neg_score[inf_idx]}")
+ raise ValueError("find inf in loss")
+
+ def _make_binorm_adj(self, mat):
+ a = csr_matrix((self.num_users, self.num_users))
+ b = csr_matrix((self.num_items, self.num_items))
+ mat = sp.vstack(
+ [sp.hstack([a, mat]), sp.hstack([mat.transpose(), b])])
+ mat = (mat != 0) * 1.0
+ # mat = (mat + sp.eye(mat.shape[0])) * 1.0# MARK
+ degree = np.array(mat.sum(axis=-1))
+ d_inv_sqrt = np.reshape(np.power(degree, -0.5), [-1])
+ d_inv_sqrt[np.isinf(d_inv_sqrt)] = 0.0
+ d_inv_sqrt_mat = sp.diags(d_inv_sqrt)
+ mat = mat.dot(d_inv_sqrt_mat).transpose().dot(
+ d_inv_sqrt_mat).tocoo()
+
+ # make torch tensor
+ idxs = torch.from_numpy(np.vstack([mat.row, mat.col]).astype(np.int64))
+ vals = torch.from_numpy(mat.data.astype(np.float32))
+ shape = torch.Size(mat.shape)
+ return torch.sparse.FloatTensor(idxs, vals, shape).to(args.device)
+
+ def _make_binorm_adj_self_loop(self, mat):
+ a = csr_matrix((self.num_users, self.num_users))
+ b = csr_matrix((self.num_items, self.num_items))
+ mat = sp.vstack(
+ [sp.hstack([a, mat]), sp.hstack([mat.transpose(), b])])
+ mat = (mat != 0) * 1.0
+ mat = (mat + sp.eye(mat.shape[0])) * 1.0 # self loop
+ degree = np.array(mat.sum(axis=-1))
+ d_inv_sqrt = np.reshape(np.power(degree, -0.5), [-1])
+ d_inv_sqrt[np.isinf(d_inv_sqrt)] = 0.0
+ d_inv_sqrt_mat = sp.diags(d_inv_sqrt)
+ mat = mat.dot(d_inv_sqrt_mat).transpose().dot(
+ d_inv_sqrt_mat).tocoo()
+
+ # make torch tensor
+ idxs = torch.from_numpy(np.vstack([mat.row, mat.col]).astype(np.int64))
+ vals = torch.from_numpy(mat.data.astype(np.float32))
+ shape = torch.Size(mat.shape)
+ return torch.sparse.FloatTensor(idxs, vals, shape).to(args.device)
+
+
+ def _sp_matrix_to_sp_tensor(self, sp_matrix):
+ coo = sp_matrix.tocoo()
+ indices = torch.LongTensor([coo.row, coo.col])
+ values = torch.FloatTensor(coo.data)
+ return torch.sparse.FloatTensor(indices, values, coo.shape).coalesce().to(args.device)
+
+ def _bpr_loss(self, user_emb, pos_item_emb, neg_item_emb):
+ pos_score = (user_emb * pos_item_emb).sum(dim=1)
+ neg_score = (user_emb * neg_item_emb).sum(dim=1)
+ loss = -torch.log(1e-10 + torch.sigmoid((pos_score - neg_score)))
+ self._check_inf(loss, pos_score, neg_score, 0)
+ return loss.mean()
+
+ def _nce_loss(self, pos_score, neg_score, edge_weight=1):
+ numerator = torch.exp(pos_score)
+ denominator = torch.exp(pos_score) + torch.exp(neg_score).sum(dim=1)
+ loss = -torch.log(numerator/denominator) * edge_weight
+ self._check_inf(loss, pos_score, neg_score, edge_weight)
+ return loss.mean()
+
+ def _infonce_loss(self, pos_1, pos_2, negs, tau):
+ pos_1 = self.cl_mlp(pos_1)
+ pos_2 = self.cl_mlp(pos_2)
+ negs = self.cl_mlp(negs)
+ pos_1 = F.normalize(pos_1, dim=-1)
+ pos_2 = F.normalize(pos_2, dim=-1)
+ negs = F.normalize(negs, dim=-1)
+ pos_score = torch.mul(pos_1, pos_2).sum(dim=1)
+ # B, 1, E * B, E, N -> B, N
+ neg_score = torch.bmm(pos_1.unsqueeze(1), negs.transpose(1, 2)).squeeze(1)
+ # infonce loss
+ numerator = torch.exp(pos_score / tau)
+ denominator = torch.exp(pos_score / tau) + torch.exp(neg_score / tau).sum(dim=1)
+ loss = -torch.log(numerator/denominator)
+ self._check_inf(loss, pos_score, neg_score, 0)
+ return loss.mean()
+
\ No newline at end of file
diff --git a/Merge/back_rhj/app/models/recommend/operators.py b/Merge/back_rhj/app/models/recommend/operators.py
new file mode 100644
index 0000000..a508966
--- /dev/null
+++ b/Merge/back_rhj/app/models/recommend/operators.py
@@ -0,0 +1,52 @@
+import torch
+from typing import Optional, Tuple
+from torch import nn
+
+def broadcast(src: torch.Tensor, other: torch.Tensor, dim: int):
+ if dim < 0:
+ dim = other.dim() + dim
+ if src.dim() == 1:
+ for _ in range(0, dim):
+ src = src.unsqueeze(0)
+ for _ in range(src.dim(), other.dim()):
+ src = src.unsqueeze(-1)
+ src = src.expand(other.size())
+ return src
+
+def scatter_sum(src: torch.Tensor, index: torch.Tensor, dim: int = -1,
+ out: Optional[torch.Tensor] = None,
+ dim_size: Optional[int] = None) -> torch.Tensor:
+ index = broadcast(index, src, dim)
+ if out is None:
+ size = list(src.size())
+ if dim_size is not None:
+ size[dim] = dim_size
+ elif index.numel() == 0:
+ size[dim] = 0
+ else:
+ size[dim] = int(index.max()) + 1
+ out = torch.zeros(size, dtype=src.dtype, device=src.device)
+ return out.scatter_add_(dim, index, src)
+ else:
+ return out.scatter_add_(dim, index, src)
+
+def scatter_add(src: torch.Tensor, index: torch.Tensor, dim: int = -1,
+ out: Optional[torch.Tensor] = None,
+ dim_size: Optional[int] = None) -> torch.Tensor:
+ return scatter_sum(src, index, dim, out, dim_size)
+
+
+class EdgelistDrop(nn.Module):
+ def __init__(self):
+ super(EdgelistDrop, self).__init__()
+
+ def forward(self, edgeList, keep_rate, return_mask=False):
+ if keep_rate == 1.0:
+ return edgeList, torch.ones(edgeList.size(0)).type(torch.bool)
+ edgeNum = edgeList.size(0)
+ mask = (torch.rand(edgeNum) + keep_rate).floor().type(torch.bool)
+ newEdgeList = edgeList[mask, :]
+ if return_mask:
+ return newEdgeList, mask
+ else:
+ return newEdgeList
diff --git a/Merge/back_rhj/app/models/users.py b/Merge/back_rhj/app/models/users.py
new file mode 100644
index 0000000..8edc8be
--- /dev/null
+++ b/Merge/back_rhj/app/models/users.py
@@ -0,0 +1,53 @@
+from . import Base
+from sqlalchemy import (
+ Column, Integer, String, Enum, TIMESTAMP, text
+)
+from sqlalchemy.ext.declarative import declarative_base
+from sqlalchemy.orm import relationship
+
+
+class User(Base):
+ __tablename__ = 'users'
+
+ def to_dict(self):
+ return {
+ 'id': self.id,
+ 'username': self.username if self.username else None,
+ 'email': self.email if self.email else None,
+ 'avatar': self.avatar if self.avatar else None,
+ 'role': self.role if self.role else None,
+ 'bio': self.bio if self.bio else None,
+ 'status': self.status if self.status else None,
+ 'created_at': self.created_at.isoformat() if self.created_at else None,
+ 'updated_at': self.updated_at.isoformat() if self.updated_at else None
+ }
+
+ id = Column(Integer, primary_key=True, autoincrement=True, comment='用户ID')
+ username = Column(String(50), nullable=False, unique=True, comment='用户名')
+ password = Column(String(255), nullable=False, comment='加密密码')
+ email = Column(String(100), nullable=False, unique=True, comment='邮箱')
+ avatar = Column(String(255), comment='头像URL')
+ role = Column(Enum('user', 'admin', 'superadmin', name='user_role'), comment='角色')
+ bio = Column(String(255), comment='个人简介')
+ status = Column(
+ Enum('active','banned','muted', name='user_status'),
+ nullable=False,
+ server_default=text("'active'"),
+ comment='账号状态'
+ )
+ created_at = Column(
+ TIMESTAMP,
+ nullable=True,
+ server_default=text('CURRENT_TIMESTAMP'),
+ comment='创建时间'
+ )
+ updated_at = Column(
+ TIMESTAMP,
+ nullable=True,
+ server_default=text('CURRENT_TIMESTAMP'),
+ onupdate=text('CURRENT_TIMESTAMP'),
+ comment='更新时间'
+ )
+
+ # 关联关系
+ email_verifications = relationship("EmailVerification", back_populates="user")
diff --git a/Merge/back_rhj/app/routes.py b/Merge/back_rhj/app/routes.py
new file mode 100644
index 0000000..23ff49b
--- /dev/null
+++ b/Merge/back_rhj/app/routes.py
@@ -0,0 +1,325 @@
+from flask import Blueprint, request, jsonify
+from .functions.FAuth import FAuth
+from sqlalchemy import create_engine
+from sqlalchemy.orm import sessionmaker
+from config import Config
+from functools import wraps
+from datetime import datetime
+
+main = Blueprint('main', __name__)
+
+def token_required(f):
+ """装饰器:需要令牌验证"""
+ @wraps(f)
+ def decorated(*args, **kwargs):
+ token = request.headers.get('Authorization')
+ if not token:
+ return jsonify({'success': False, 'message': '缺少访问令牌'}), 401
+
+ session = None
+ try:
+ # 移除Bearer前缀
+ if token.startswith('Bearer '):
+ token = token[7:]
+
+ engine = create_engine(Config.SQLURL)
+ SessionLocal = sessionmaker(bind=engine)
+ session = SessionLocal()
+ f_auth = FAuth(session)
+
+ user = f_auth.get_user_by_token(token)
+ if not user:
+ return jsonify({'success': False, 'message': '无效的访问令牌'}), 401
+
+ # 将用户信息传递给路由函数
+ return f(user, *args, **kwargs)
+ except Exception as e:
+ if session:
+ session.rollback()
+ return jsonify({'success': False, 'message': '令牌验证失败'}), 401
+ finally:
+ if session:
+ session.close()
+
+ return decorated
+
+@main.route('/login', methods=['POST'])
+def login():
+ """用户登录接口"""
+ session = None
+ try:
+ data = request.get_json()
+
+ # 验证必填字段
+ if not data or not data.get('email') or not data.get('password'):
+ return jsonify({
+ 'success': False,
+ 'message': '用户名和密码不能为空'
+ }), 400
+
+ email = data['email']
+ password = data['password']
+
+ # 创建数据库连接
+ engine = create_engine(Config.SQLURL)
+ SessionLocal = sessionmaker(bind=engine)
+ session = SessionLocal()
+
+ # 执行登录
+ f_auth = FAuth(session)
+ result = f_auth.login(email, password)
+
+ if result['success']:
+ session.commit()
+ return jsonify(result), 200
+ else:
+ return jsonify(result), 401
+
+ except Exception as e:
+ if session:
+ session.rollback()
+ return jsonify({
+ 'success': False,
+ 'message': '服务器内部错误'
+ }), 500
+ finally:
+ if session:
+ session.close()
+
+@main.route('/register', methods=['POST'])
+def register():
+ """用户注册接口"""
+
+ engine = create_engine(Config.SQLURL)
+ SessionLocal = sessionmaker(bind=engine)
+ session = SessionLocal()
+
+ try:
+ data = request.get_json()
+
+ # 验证必填字段
+ if not data or not data.get('username') or not data.get('email') or not data.get('password') or not data.get('verification_code'):
+ return jsonify({
+ 'success': False,
+ 'message': '用户名、邮箱和密码不能为空'
+ }), 400
+
+ username = data['username']
+ email = data['email']
+ password = data['password']
+ verification_code = data['verification_code']
+
+ # 简单的邮箱格式验证
+ if '@' not in email or '.' not in email:
+ return jsonify({
+ 'success': False,
+ 'message': '邮箱格式不正确'
+ }), 400
+
+ # 密码长度验证
+ if len(password) < 6:
+ return jsonify({
+ 'success': False,
+ 'message': '密码长度不能少于6位'
+ }), 400
+
+ # 执行注册
+ f_auth = FAuth(session)
+ result = f_auth.register(username, email, password, verification_code)
+
+ if result['success']:
+ session.commit()
+ return jsonify(result), 201
+ else:
+ return jsonify(result), 400
+
+ except Exception as e:
+ session.rollback()
+ return jsonify({
+ 'success': False,
+ 'message': '服务器内部错误'
+ }), 500
+ finally:
+ session.close()
+
+@main.route('/profile', methods=['GET'])
+@token_required
+def get_profile(current_user):
+ """获取用户信息接口(需要登录)"""
+ try:
+ return jsonify({
+ 'success': True,
+ 'user': current_user.to_dict()
+ }), 200
+ except Exception as e:
+ return jsonify({
+ 'success': False,
+ 'message': '获取用户信息失败'
+ }), 500
+
+@main.route('/logout', methods=['POST'])
+@token_required
+def logout(current_user):
+ """用户登出接口(需要登录)"""
+ try:
+ # 这里可以将令牌加入黑名单(如果需要的话)
+ return jsonify({
+ 'success': True,
+ 'message': '登出成功'
+ }), 200
+ except Exception as e:
+ return jsonify({
+ 'success': False,
+ 'message': '登出失败'
+ }), 500
+
+@main.route('/send-verification-code', methods=['POST'])
+def send_verification_code():
+ """发送邮箱验证码接口"""
+
+ engine = create_engine(Config.SQLURL)
+ SessionLocal = sessionmaker(bind=engine)
+ session = SessionLocal()
+
+ try:
+ data = request.get_json()
+
+ # 验证必填字段
+ if not data or not data.get('email'):
+ return jsonify({
+ 'success': False,
+ 'message': '邮箱地址不能为空'
+ }), 400
+
+ email = data['email']
+ verification_type = data.get('type', 'register') # 默认为注册验证码
+
+ # 简单的邮箱格式验证
+ if '@' not in email or '.' not in email:
+ return jsonify({
+ 'success': False,
+ 'message': '邮箱格式不正确'
+ }), 400
+
+ # 发送验证码
+ f_auth = FAuth(session)
+ result = f_auth.send_verification_email(email, verification_type)
+
+ if result['success']:
+ session.commit()
+ return jsonify(result), 200
+ else:
+ return jsonify(result), 400
+
+ except Exception as e:
+ session.rollback()
+ return jsonify({
+ 'success': False,
+ 'message': '服务器内部错误'
+ }), 500
+ finally:
+ session.close()
+
+@main.route('/reset-password', methods=['POST'])
+def reset_password():
+ """重置密码接口"""
+
+ engine = create_engine(Config.SQLURL)
+ SessionLocal = sessionmaker(bind=engine)
+ session = SessionLocal()
+
+ try:
+ data = request.get_json()
+
+ # 验证必填字段
+ if not data or not data.get('email') or not data.get('new_password') or not data.get('verification_code'):
+ return jsonify({
+ 'success': False,
+ 'message': '邮箱地址、新密码和验证码不能为空'
+ }), 400
+
+ email = data['email']
+ new_password = data['new_password']
+ verification_code = data['verification_code']
+
+ # 简单的邮箱格式验证
+ if '@' not in email or '.' not in email:
+ return jsonify({
+ 'success': False,
+ 'message': '邮箱格式不正确'
+ }), 400
+
+ # 密码长度验证
+ if len(new_password) < 6:
+ return jsonify({
+ 'success': False,
+ 'message': '密码长度不能少于6位'
+ }), 400
+
+ # 重置密码
+ f_auth = FAuth(session)
+ result = f_auth.reset_password_with_verification(email, new_password, verification_code)
+
+ if result['success']:
+ session.commit()
+ return jsonify(result), 200
+ else:
+ return jsonify(result), 400
+
+ except Exception as e:
+ session.rollback()
+ return jsonify({
+ 'success': False,
+ 'message': '服务器内部错误'
+ }), 500
+ finally:
+ session.close()
+
+@main.route('/test-jwt', methods=['POST'])
+@token_required
+def test_jwt(current_user):
+ """测试JWT令牌接口(需要登录)"""
+ try:
+ # 获取当前请求的token(从装饰器已验证的Authorization header)
+ auth_header = request.headers.get('Authorization')
+ current_token = auth_header[7:] if auth_header and auth_header.startswith('Bearer ') else None
+
+ print(f"当前用户: {current_user.username}")
+ print(f"当前用户ID: {current_user.id}")
+ print(current_user.role)
+ print(f"Token验证成功: {current_token[:20]}..." if current_token else "No token")
+
+ # 可选:检查请求体中是否有额外的token需要验证
+ data = request.get_json() or {}
+ additional_token = data.get('token')
+
+ response_data = {
+ 'success': True,
+ 'message': 'JWT令牌验证成功',
+ 'user': current_user.to_dict(),
+ 'token_info': {
+ 'header_token_verified': True,
+ 'token_preview': current_token[:20] + "..." if current_token else None
+ }
+ }
+
+ # 如果请求体中有额外的token,也验证一下
+ if additional_token:
+ try:
+ additional_result = FAuth.verify_token(additional_token)
+ response_data['additional_token_verification'] = additional_result
+ print(f"额外token验证结果: {additional_result}")
+ except Exception as e:
+ response_data['additional_token_verification'] = {
+ 'success': False,
+ 'message': f'额外token验证失败: {str(e)}'
+ }
+
+ return jsonify(response_data), 200
+
+ except Exception as e:
+ print(f"test_jwt 错误: {str(e)}")
+ return jsonify({
+ 'success': False,
+ 'message': f'JWT令牌验证失败: {str(e)}'
+ }), 500
\ No newline at end of file
diff --git a/Merge/back_rhj/app/services/__pycache__/__init__.cpython-312.pyc b/Merge/back_rhj/app/services/__pycache__/__init__.cpython-312.pyc
new file mode 100644
index 0000000..769373b
--- /dev/null
+++ b/Merge/back_rhj/app/services/__pycache__/__init__.cpython-312.pyc
Binary files differ
diff --git a/Merge/back_rhj/app/services/__pycache__/lightgcn_scorer.cpython-312.pyc b/Merge/back_rhj/app/services/__pycache__/lightgcn_scorer.cpython-312.pyc
new file mode 100644
index 0000000..2c86f52
--- /dev/null
+++ b/Merge/back_rhj/app/services/__pycache__/lightgcn_scorer.cpython-312.pyc
Binary files differ
diff --git a/Merge/back_rhj/app/services/__pycache__/recommendation_service.cpython-312.pyc b/Merge/back_rhj/app/services/__pycache__/recommendation_service.cpython-312.pyc
new file mode 100644
index 0000000..da8389f
--- /dev/null
+++ b/Merge/back_rhj/app/services/__pycache__/recommendation_service.cpython-312.pyc
Binary files differ
diff --git a/Merge/back_rhj/app/services/lightgcn_scorer.py b/Merge/back_rhj/app/services/lightgcn_scorer.py
new file mode 100644
index 0000000..f6aeb19
--- /dev/null
+++ b/Merge/back_rhj/app/services/lightgcn_scorer.py
@@ -0,0 +1,295 @@
+"""
+LightGCN评分服务
+用于对多路召回的结果进行LightGCN打分
+"""
+
+import torch
+import numpy as np
+from typing import List, Tuple, Dict, Any
+from app.models.recommend.LightGCN import LightGCN
+from app.utils.parse_args import args
+from app.utils.data_loader import EdgeListData
+from app.utils.graph_build import build_user_post_graph
+
+
+class LightGCNScorer:
+ """
+ LightGCN评分器
+ 专门用于对多路召回结果进行精准打分
+ """
+
+ def __init__(self):
+ """初始化LightGCN评分器"""
+ # 设备配置
+ args.device = 'cuda:7' if torch.cuda.is_available() else 'cpu'
+ args.data_path = './app/user_post_graph.txt'
+ args.pre_model_path = './app/models/recommend/LightGCN_pretrained.pt'
+
+ # 模型相关变量
+ self.model = None
+ self.user2idx = None
+ self.post2idx = None
+ self.idx2post = None
+ self.dataset = None
+ self.user_embeddings = None
+ self.item_embeddings = None
+
+ # 是否已初始化
+ self._initialized = False
+
+ def _initialize_model(self):
+ """初始化LightGCN模型"""
+ if self._initialized:
+ return
+
+ print("初始化LightGCN评分模型...")
+
+ # 构建用户-物品映射
+ self.user2idx, self.post2idx = build_user_post_graph(return_mapping=True)
+ self.idx2post = {v: k for k, v in self.post2idx.items()}
+
+ # 加载数据集
+ self.dataset = EdgeListData(args.data_path, args.data_path)
+
+ # 加载预训练模型
+ pretrained_dict = torch.load(args.pre_model_path, map_location=args.device, weights_only=True)
+ pretrained_dict['user_embedding'] = pretrained_dict['user_embedding'][:self.dataset.num_users]
+ pretrained_dict['item_embedding'] = pretrained_dict['item_embedding'][:self.dataset.num_items]
+
+ # 初始化模型
+ self.model = LightGCN(self.dataset, phase='vanilla').to(args.device)
+ self.model.load_state_dict(pretrained_dict, strict=False)
+ self.model.eval()
+
+ # 预先计算所有用户和物品的嵌入表示
+ with torch.no_grad():
+ self.user_embeddings, self.item_embeddings = self.model.generate()
+
+ self._initialized = True
+ print("LightGCN评分模型初始化完成")
+
+ def score_candidates(self, user_id: int, candidate_post_ids: List[int]) -> List[float]:
+ """
+ 对候选物品进行LightGCN打分
+
+ Args:
+ user_id: 用户ID
+ candidate_post_ids: 候选物品ID列表
+
+ Returns:
+ List[float]: 每个候选物品的LightGCN分数
+ """
+ self._initialize_model()
+
+ # 检查用户是否存在
+ if user_id not in self.user2idx:
+ print(f"用户 {user_id} 不在训练数据中,返回零分数")
+ return [0.0] * len(candidate_post_ids)
+
+ user_idx = self.user2idx[user_id]
+ scores = []
+
+ print(len(candidate_post_ids), "候选物品数量")
+
+ with torch.no_grad():
+ user_emb = self.user_embeddings[user_idx].unsqueeze(0) # [1, emb_size]
+
+ for post_id in candidate_post_ids:
+ if post_id not in self.post2idx:
+ # 物品不在训练数据中,给予默认分数
+ scores.append(0.0)
+ continue
+
+ post_idx = self.post2idx[post_id]
+ item_emb = self.item_embeddings[post_idx].unsqueeze(0) # [1, emb_size]
+
+ # 计算评分:用户嵌入和物品嵌入的内积
+ score = torch.matmul(user_emb, item_emb.t()).item()
+ scores.append(float(score))
+
+ return scores
+
+ def score_batch_candidates(self, user_id: int, candidate_post_ids: List[int]) -> List[float]:
+ """
+ 批量对候选物品进行LightGCN打分(更高效)
+
+ Args:
+ user_id: 用户ID
+ candidate_post_ids: 候选物品ID列表
+
+ Returns:
+ List[float]: 每个候选物品的LightGCN分数
+ """
+ self._initialize_model()
+
+ # 检查用户是否存在
+ if user_id not in self.user2idx:
+ print(f"用户 {user_id} 不在训练数据中,返回零分数")
+ return [0.0] * len(candidate_post_ids)
+
+ print(len(candidate_post_ids), "候选物品数量")
+
+ user_idx = self.user2idx[user_id]
+
+ # 过滤出存在于训练数据中的物品
+ valid_items = []
+ valid_indices = []
+ for i, post_id in enumerate(candidate_post_ids):
+ if post_id in self.post2idx:
+ valid_items.append(self.post2idx[post_id])
+ valid_indices.append(i)
+
+ scores = [0.0] * len(candidate_post_ids)
+
+ if not valid_items:
+ return scores
+
+ with torch.no_grad():
+ user_emb = self.user_embeddings[user_idx].unsqueeze(0) # [1, emb_size]
+
+ # 批量获取物品嵌入
+ valid_item_indices = torch.tensor(valid_items, device=args.device)
+ valid_item_embs = self.item_embeddings[valid_item_indices] # [num_valid_items, emb_size]
+
+ # 批量计算评分
+ batch_scores = torch.matmul(user_emb, valid_item_embs.t()).squeeze(0) # [num_valid_items]
+
+ # 将分数填回原位置
+ for i, score in enumerate(batch_scores.cpu().numpy()):
+ original_idx = valid_indices[i]
+ scores[original_idx] = float(score)
+
+ return scores
+
+ def get_user_profile(self, user_id: int) -> Dict[str, Any]:
+ """
+ 获取用户在LightGCN中的表示和统计信息
+
+ Args:
+ user_id: 用户ID
+
+ Returns:
+ Dict: 用户画像信息
+ """
+ self._initialize_model()
+
+ if user_id not in self.user2idx:
+ return {
+ 'user_id': user_id,
+ 'exists_in_model': False,
+ 'message': '用户不在训练数据中'
+ }
+
+ user_idx = self.user2idx[user_id]
+
+ with torch.no_grad():
+ user_emb = self.user_embeddings[user_idx]
+
+ # 计算用户嵌入的统计信息
+ emb_norm = torch.norm(user_emb).item()
+ emb_mean = torch.mean(user_emb).item()
+ emb_std = torch.std(user_emb).item()
+
+ # 找到与用户最相似的物品(基于余弦相似度)
+ user_emb_normalized = user_emb / torch.norm(user_emb)
+ item_embs_normalized = self.item_embeddings / torch.norm(self.item_embeddings, dim=1, keepdim=True)
+
+ similarities = torch.matmul(user_emb_normalized.unsqueeze(0), item_embs_normalized.t()).squeeze(0)
+ top_k_indices = torch.topk(similarities, k=10).indices.cpu().numpy()
+
+ top_similar_items = []
+ for idx in top_k_indices:
+ if idx < len(self.idx2post):
+ post_id = self.idx2post[idx]
+ similarity = similarities[idx].item()
+ top_similar_items.append({
+ 'post_id': post_id,
+ 'similarity': float(similarity)
+ })
+
+ return {
+ 'user_id': user_id,
+ 'user_idx': user_idx,
+ 'exists_in_model': True,
+ 'embedding_stats': {
+ 'norm': float(emb_norm),
+ 'mean': float(emb_mean),
+ 'std': float(emb_std),
+ 'dimension': user_emb.shape[0]
+ },
+ 'top_similar_items': top_similar_items
+ }
+
+ def compare_scoring_methods(self, user_id: int, candidate_post_ids: List[int]) -> Dict[str, List[float]]:
+ """
+ 比较不同的评分方法
+
+ Args:
+ user_id: 用户ID
+ candidate_post_ids: 候选物品ID列表
+
+ Returns:
+ Dict: 包含不同评分方法结果的字典
+ """
+ self._initialize_model()
+
+ if user_id not in self.user2idx:
+ zero_scores = [0.0] * len(candidate_post_ids)
+ return {
+ 'lightgcn_inner_product': zero_scores,
+ 'lightgcn_cosine_similarity': zero_scores,
+ 'message': f'用户 {user_id} 不在训练数据中'
+ }
+
+ user_idx = self.user2idx[user_id]
+
+ inner_product_scores = []
+ cosine_similarity_scores = []
+
+ with torch.no_grad():
+ user_emb = self.user_embeddings[user_idx]
+ user_emb_normalized = user_emb / torch.norm(user_emb)
+
+ for post_id in candidate_post_ids:
+ if post_id not in self.post2idx:
+ inner_product_scores.append(0.0)
+ cosine_similarity_scores.append(0.0)
+ continue
+
+ post_idx = self.post2idx[post_id]
+ item_emb = self.item_embeddings[post_idx]
+ item_emb_normalized = item_emb / torch.norm(item_emb)
+
+ # 内积评分
+ inner_score = torch.dot(user_emb, item_emb).item()
+ inner_product_scores.append(float(inner_score))
+
+ # 余弦相似度评分
+ cosine_score = torch.dot(user_emb_normalized, item_emb_normalized).item()
+ cosine_similarity_scores.append(float(cosine_score))
+
+ return {
+ 'lightgcn_inner_product': inner_product_scores,
+ 'lightgcn_cosine_similarity': cosine_similarity_scores
+ }
+
+ def get_model_info(self) -> Dict[str, Any]:
+ """
+ 获取LightGCN模型的基本信息
+
+ Returns:
+ Dict: 模型信息
+ """
+ self._initialize_model()
+
+ return {
+ 'model_type': 'LightGCN',
+ 'device': str(args.device),
+ 'num_users': self.dataset.num_users,
+ 'num_items': self.dataset.num_items,
+ 'embedding_size': self.user_embeddings.shape[1],
+ 'num_layers': args.num_layers,
+ 'pretrained_model_path': args.pre_model_path,
+ 'data_path': args.data_path,
+ 'initialized': self._initialized
+ }
diff --git a/Merge/back_rhj/app/services/recommendation_service.py b/Merge/back_rhj/app/services/recommendation_service.py
new file mode 100644
index 0000000..2f4de13
--- /dev/null
+++ b/Merge/back_rhj/app/services/recommendation_service.py
@@ -0,0 +1,719 @@
+import torch
+import pymysql
+import numpy as np
+import random
+from app.models.recommend.LightGCN import LightGCN
+from app.models.recall import MultiRecallManager
+from app.services.lightgcn_scorer import LightGCNScorer
+from app.utils.parse_args import args
+from app.utils.data_loader import EdgeListData
+from app.utils.graph_build import build_user_post_graph
+from config import Config
+
+class RecommendationService:
+ def __init__(self):
+ # 数据库连接配置 - 修改为redbook数据库
+ self.db_config = {
+ 'host': '10.126.59.25',
+ 'port': 3306,
+ 'user': 'root',
+ 'password': '123456',
+ 'database': 'redbook', # 使用redbook数据库
+ 'charset': 'utf8mb4'
+ }
+
+ # 模型配置
+ args.device = 'cuda:7' if torch.cuda.is_available() else 'cpu'
+ args.data_path = './app/user_post_graph.txt' # 修改为帖子图文件
+ args.pre_model_path = './app/models/recommend/LightGCN_pretrained.pt'
+
+ self.topk = 2 # 默认推荐数量
+
+ # 初始化多路召回管理器
+ self.multi_recall = None
+ self.multi_recall_enabled = True # 控制是否启用多路召回
+
+ # 初始化LightGCN评分器
+ self.lightgcn_scorer = None
+ self.use_lightgcn_rerank = True # 控制是否使用LightGCN对多路召回结果重新打分
+
+ # 多路召回配置
+ self.recall_config = {
+ 'swing': {
+ 'enabled': True,
+ 'num_items': 20, # 增加召回数量
+ 'alpha': 0.5
+ },
+ 'hot': {
+ 'enabled': True,
+ 'num_items': 15 # 增加热度召回数量
+ },
+ 'ad': {
+ 'enabled': True,
+ 'num_items': 5 # 增加广告召回数量
+ },
+ 'usercf': {
+ 'enabled': True,
+ 'num_items': 15,
+ 'min_common_items': 1, # 降低阈值,从3改为1
+ 'num_similar_users': 20 # 减少相似用户数量以提高效率
+ }
+ }
+
+ def calculate_tag_similarity(self, tags1, tags2):
+ """
+ 计算两个帖子标签的相似度
+ 输入: tags1, tags2 - 标签字符串,以逗号分隔
+ 输出: 相似度分数(0-1之间)
+ """
+ if not tags1 or not tags2:
+ return 0.0
+
+ # 将标签字符串转换为集合
+ set1 = set(tag.strip() for tag in tags1.split(',') if tag.strip())
+ set2 = set(tag.strip() for tag in tags2.split(',') if tag.strip())
+
+ if not set1 or not set2:
+ return 0.0
+
+ # 计算标签重叠比例(Jaccard相似度)
+ intersection = len(set1.intersection(set2))
+ union = len(set1.union(set2))
+
+ return intersection / union if union > 0 else 0.0
+
+ def mmr_rerank_with_ads(self, post_ids, scores, theta=0.5, target_size=None):
+ """
+ 使用MMR算法重新排序推荐结果,并在过程中加入广告约束
+ 输入:
+ - post_ids: 帖子ID列表
+ - scores: 对应的推荐分数列表
+ - theta: 平衡相关性和多样性的参数(0.5表示各占一半)
+ - target_size: 目标结果数量,默认与输入相同
+ 输出: 重排后的(post_ids, scores),每5条帖子包含1条广告
+ """
+ if target_size is None:
+ target_size = len(post_ids)
+
+ if len(post_ids) <= 1:
+ return post_ids, scores
+
+ # 获取帖子标签信息和广告标识
+ conn = pymysql.connect(**self.db_config)
+ cursor = conn.cursor()
+
+ try:
+ # 查询所有候选帖子的标签和广告标识
+ format_strings = ','.join(['%s'] * len(post_ids))
+ cursor.execute(
+ f"""SELECT p.id, p.is_advertisement,
+ COALESCE(GROUP_CONCAT(t.name), '') as tags
+ FROM posts p
+ LEFT JOIN post_tags pt ON p.id = pt.post_id
+ LEFT JOIN tags t ON pt.tag_id = t.id
+ WHERE p.id IN ({format_strings}) AND p.status = 'published'
+ GROUP BY p.id, p.is_advertisement""",
+ tuple(post_ids)
+ )
+ post_info_rows = cursor.fetchall()
+ post_tags = {}
+ post_is_ad = {}
+
+ for row in post_info_rows:
+ post_id, is_ad, tags = row
+ post_tags[post_id] = tags or ""
+ post_is_ad[post_id] = bool(is_ad)
+
+ # 对于没有查询到的帖子,设置默认值
+ for post_id in post_ids:
+ if post_id not in post_tags:
+ post_tags[post_id] = ""
+ post_is_ad[post_id] = False
+
+ # 获取额外的广告帖子作为候选
+ cursor.execute("""
+ SELECT id, heat FROM posts
+ WHERE is_advertisement = 1 AND status = 'published'
+ AND id NOT IN ({})
+ ORDER BY heat DESC
+ LIMIT 50
+ """.format(format_strings), tuple(post_ids))
+ extra_ad_rows = cursor.fetchall()
+
+ finally:
+ cursor.close()
+ conn.close()
+
+ # 分离普通帖子和广告帖子
+ normal_candidates = []
+ ad_candidates = []
+
+ for post_id, score in zip(post_ids, scores):
+ if post_is_ad[post_id]:
+ ad_candidates.append((post_id, score))
+ else:
+ normal_candidates.append((post_id, score))
+
+ # 添加额外的广告候选
+ for ad_id, heat in extra_ad_rows:
+ # 为广告帖子设置标签和广告标识
+ post_tags[ad_id] = "" # 广告帖子暂时设置为空标签
+ post_is_ad[ad_id] = True
+ ad_score = float(heat) / 1000.0 # 将热度转换为分数
+ ad_candidates.append((ad_id, ad_score))
+
+ # 排序候选列表
+ normal_candidates.sort(key=lambda x: x[1], reverse=True)
+ ad_candidates.sort(key=lambda x: x[1], reverse=True)
+
+ # MMR算法实现,加入广告约束
+ selected = []
+ normal_idx = 0
+ ad_idx = 0
+
+ while len(selected) < target_size:
+ current_position = len(selected)
+
+ # 检查是否需要插入广告(每5个位置插入1个广告)
+ if (current_position + 1) % 5 == 0 and ad_idx < len(ad_candidates):
+ # 插入广告
+ selected.append(ad_candidates[ad_idx])
+ ad_idx += 1
+ else:
+ # 使用MMR选择普通帖子
+ if normal_idx >= len(normal_candidates):
+ break
+
+ best_score = -float('inf')
+ best_local_idx = normal_idx
+
+ # 在剩余的普通候选中选择最佳的
+ for i in range(normal_idx, min(normal_idx + 10, len(normal_candidates))):
+ post_id, relevance_score = normal_candidates[i]
+
+ # 计算与已选帖子的最大相似度
+ max_similarity = 0.0
+ current_tags = post_tags[post_id]
+
+ for selected_post_id, _ in selected:
+ selected_tags = post_tags[selected_post_id]
+ similarity = self.calculate_tag_similarity(current_tags, selected_tags)
+ max_similarity = max(max_similarity, similarity)
+
+ # 计算MMR分数
+ mmr_score = theta * relevance_score - (1 - theta) * max_similarity
+
+ if mmr_score > best_score:
+ best_score = mmr_score
+ best_local_idx = i
+
+ # 选择最佳候选
+ selected.append(normal_candidates[best_local_idx])
+ # 将选中的元素移到已处理区域
+ normal_candidates[normal_idx], normal_candidates[best_local_idx] = \
+ normal_candidates[best_local_idx], normal_candidates[normal_idx]
+ normal_idx += 1
+
+ # 提取重排后的结果
+ reranked_post_ids = [post_id for post_id, _ in selected]
+ reranked_scores = [score for _, score in selected]
+
+ return reranked_post_ids, reranked_scores
+
+ def insert_advertisements(self, post_ids, scores):
+ """
+ 在推荐结果中插入广告,每5条帖子插入1条广告
+ 输入: post_ids, scores - 原始推荐结果
+ 输出: 插入广告后的(post_ids, scores)
+ """
+ # 获取可用的广告帖子
+ conn = pymysql.connect(**self.db_config)
+ cursor = conn.cursor()
+
+ try:
+ cursor.execute("""
+ SELECT id, heat FROM posts
+ WHERE is_advertisement = 1 AND status = 'published'
+ ORDER BY heat DESC
+ LIMIT 50
+ """)
+ ad_rows = cursor.fetchall()
+
+ if not ad_rows:
+ # 没有广告,直接返回原结果
+ return post_ids, scores
+
+ # 可用的广告帖子(排除已在推荐结果中的)
+ available_ads = [(ad_id, heat) for ad_id, heat in ad_rows if ad_id not in post_ids]
+
+ if not available_ads:
+ # 没有可用的新广告,直接返回原结果
+ return post_ids, scores
+
+ finally:
+ cursor.close()
+ conn.close()
+
+ # 插入广告的逻辑
+ result_posts = []
+ result_scores = []
+ ad_index = 0
+
+ for i, (post_id, score) in enumerate(zip(post_ids, scores)):
+ result_posts.append(post_id)
+ result_scores.append(score)
+
+ # 每5条帖子后插入一条广告
+ if (i + 1) % 5 == 0 and ad_index < len(available_ads):
+ ad_id, ad_heat = available_ads[ad_index]
+ result_posts.append(ad_id)
+ result_scores.append(float(ad_heat) / 1000.0) # 将热度转换为分数范围
+ ad_index += 1
+
+ return result_posts, result_scores
+
+ def user_cold_start(self, topk=None):
+ """
+ 冷启动:直接返回热度最高的topk个帖子详细信息
+ """
+ if topk is None:
+ topk = self.topk
+
+ conn = pymysql.connect(**self.db_config)
+ cursor = conn.cursor()
+
+ try:
+ # 查询热度最高的topk个帖子
+ cursor.execute("""
+ SELECT p.id, p.user_id, p.title, p.content, p.type, p.heat, p.created_at
+ FROM posts p
+ WHERE p.status = 'published'
+ ORDER BY p.heat DESC
+ LIMIT %s
+ """, (topk,))
+ post_rows = cursor.fetchall()
+ post_ids = [row[0] for row in post_rows]
+ post_map = {row[0]: row for row in post_rows}
+
+ # 查询用户信息
+ owner_ids = list(set(row[1] for row in post_rows))
+ if owner_ids:
+ format_strings_user = ','.join(['%s'] * len(owner_ids))
+ cursor.execute(
+ f"SELECT id, username FROM users WHERE id IN ({format_strings_user})",
+ tuple(owner_ids)
+ )
+ user_rows = cursor.fetchall()
+ user_map = {row[0]: row[1] for row in user_rows}
+ else:
+ user_map = {}
+
+ # 查询帖子标签
+ if post_ids:
+ format_strings = ','.join(['%s'] * len(post_ids))
+ cursor.execute(
+ f"""SELECT pt.post_id, GROUP_CONCAT(t.name) as tags
+ FROM post_tags pt
+ JOIN tags t ON pt.tag_id = t.id
+ WHERE pt.post_id IN ({format_strings})
+ GROUP BY pt.post_id""",
+ tuple(post_ids)
+ )
+ tag_rows = cursor.fetchall()
+ tag_map = {row[0]: row[1] for row in tag_rows}
+ else:
+ tag_map = {}
+
+ post_list = []
+ for post_id in post_ids:
+ row = post_map.get(post_id)
+ if not row:
+ continue
+ owner_user_id = row[1]
+ post_list.append({
+ 'post_id': post_id,
+ 'title': row[2],
+ 'content': row[3][:200] + '...' if len(row[3]) > 200 else row[3], # 截取前200字符
+ 'type': row[4],
+ 'username': user_map.get(owner_user_id, ""),
+ 'heat': row[5],
+ 'tags': tag_map.get(post_id, ""),
+ 'created_at': str(row[6]) if row[6] else ""
+ })
+ return post_list
+ finally:
+ cursor.close()
+ conn.close()
+
+ def run_inference(self, user_id, topk=None, use_multi_recall=None):
+ """
+ 推荐推理主函数
+
+ Args:
+ user_id: 用户ID
+ topk: 推荐数量
+ use_multi_recall: 是否使用多路召回,None表示使用默认设置
+ """
+ if topk is None:
+ topk = self.topk
+
+ # 决定使用哪种召回方式
+ if use_multi_recall is None:
+ use_multi_recall = self.multi_recall_enabled
+
+ return self._run_multi_recall_inference(user_id, topk)
+
+ def _run_multi_recall_inference(self, user_id, topk):
+ """使用多路召回进行推荐,并可选择使用LightGCN重新打分"""
+ try:
+ # 初始化多路召回(如果尚未初始化)
+ self.init_multi_recall()
+
+ # 执行多路召回,召回更多候选物品
+ total_candidates = min(topk * 10, 500) # 召回候选数是最终推荐数的10倍
+ candidate_post_ids, candidate_scores, recall_breakdown = self.multi_recall_inference(
+ user_id, total_candidates
+ )
+
+ if not candidate_post_ids:
+ # 如果多路召回没有结果,回退到冷启动
+ print(f"用户 {user_id} 多路召回无结果,使用冷启动")
+ return self.user_cold_start(topk)
+
+ print(f"用户 {user_id} 多路召回候选数量: {len(candidate_post_ids)}")
+ print(f"召回来源分布: {self._get_recall_source_stats(recall_breakdown)}")
+
+ # 如果启用LightGCN重新打分,使用LightGCN对候选结果进行评分
+ if self.use_lightgcn_rerank:
+ print("使用LightGCN对多路召回结果进行重新打分...")
+ lightgcn_scores = self._get_lightgcn_scores(user_id, candidate_post_ids)
+
+ # 直接使用LightGCN分数,不进行融合
+ final_scores = lightgcn_scores
+
+ print(f"LightGCN打分完成,分数范围: [{min(lightgcn_scores):.4f}, {max(lightgcn_scores):.4f}]")
+ print(f"使用LightGCN分数进行重排")
+ else:
+ # 使用原始多路召回分数
+ final_scores = candidate_scores
+
+ # 使用MMR算法重排,包含广告约束
+ final_post_ids, final_scores = self.mmr_rerank_with_ads(
+ candidate_post_ids, final_scores, theta=0.5, target_size=topk
+ )
+
+ return final_post_ids, final_scores
+
+ except Exception as e:
+ print(f"多路召回失败: {str(e)},回退到LightGCN")
+ return self._run_lightgcn_inference(user_id, topk)
+
+ def _run_lightgcn_inference(self, user_id, topk):
+ """使用原始LightGCN进行推荐"""
+ user2idx, post2idx = build_user_post_graph(return_mapping=True)
+ idx2post = {v: k for k, v in post2idx.items()}
+
+ if user_id not in user2idx:
+ # 冷启动
+ return self.user_cold_start(topk)
+
+ user_idx = user2idx[user_id]
+
+ dataset = EdgeListData(args.data_path, args.data_path)
+ pretrained_dict = torch.load(args.pre_model_path, map_location=args.device, weights_only=True)
+ pretrained_dict['user_embedding'] = pretrained_dict['user_embedding'][:dataset.num_users]
+ pretrained_dict['item_embedding'] = pretrained_dict['item_embedding'][:dataset.num_items]
+
+ model = LightGCN(dataset, phase='vanilla').to(args.device)
+ model.load_state_dict(pretrained_dict, strict=False)
+ model.eval()
+
+ with torch.no_grad():
+ user_emb, item_emb = model.generate()
+ user_vec = user_emb[user_idx].unsqueeze(0)
+ scores = model.rating(user_vec, item_emb).squeeze(0)
+
+ # 获取所有物品的分数(而不是只取top候选)
+ all_scores = scores.cpu().numpy()
+ all_post_ids = [idx2post[idx] for idx in range(len(all_scores))]
+
+ # 过滤掉分数为负的物品,只保留正分数的候选
+ positive_candidates = [(post_id, score) for post_id, score in zip(all_post_ids, all_scores) if score > 0]
+
+ if not positive_candidates:
+ # 如果没有正分数的候选,取分数最高的一些
+ sorted_candidates = sorted(zip(all_post_ids, all_scores), key=lambda x: x[1], reverse=True)
+ positive_candidates = sorted_candidates[:min(100, len(sorted_candidates))]
+
+ candidate_post_ids = [post_id for post_id, _ in positive_candidates]
+ candidate_scores = [score for _, score in positive_candidates]
+
+ print(f"用户 {user_id} 的LightGCN候选物品数量: {len(candidate_post_ids)}")
+
+ # 使用MMR算法重排,包含广告约束,theta=0.5平衡相关性和多样性
+ final_post_ids, final_scores = self.mmr_rerank_with_ads(
+ candidate_post_ids, candidate_scores, theta=0.5, target_size=topk
+ )
+
+ return final_post_ids, final_scores
+
+ def _get_recall_source_stats(self, recall_breakdown):
+ """获取召回来源统计"""
+ stats = {}
+ for source, items in recall_breakdown.items():
+ stats[source] = len(items)
+ return stats
+
+ def get_post_info(self, topk_post_ids, topk_scores=None):
+ """
+ 输入: topk_post_ids(帖子ID列表),topk_scores(对应的打分列表,可选)
+ 输出: 推荐帖子的详细信息列表,每个元素为dict
+ """
+ if not topk_post_ids:
+ return []
+
+ print(f"获取帖子详细信息,帖子ID列表: {topk_post_ids}")
+ if topk_scores is not None:
+ print(f"对应的推荐打分: {topk_scores}")
+
+ conn = pymysql.connect(**self.db_config)
+ cursor = conn.cursor()
+
+ try:
+ # 查询帖子基本信息
+ format_strings = ','.join(['%s'] * len(topk_post_ids))
+ cursor.execute(
+ f"""SELECT p.id, p.user_id, p.title, p.content, p.type, p.heat, p.created_at, p.is_advertisement
+ FROM posts p
+ WHERE p.id IN ({format_strings}) AND p.status = 'published'""",
+ tuple(topk_post_ids)
+ )
+ post_rows = cursor.fetchall()
+ post_map = {row[0]: row for row in post_rows}
+
+ # 查询用户信息
+ owner_ids = list(set(row[1] for row in post_rows))
+ if owner_ids:
+ format_strings_user = ','.join(['%s'] * len(owner_ids))
+ cursor.execute(
+ f"SELECT id, username FROM users WHERE id IN ({format_strings_user})",
+ tuple(owner_ids)
+ )
+ user_rows = cursor.fetchall()
+ user_map = {row[0]: row[1] for row in user_rows}
+ else:
+ user_map = {}
+
+ # 查询帖子标签
+ cursor.execute(
+ f"""SELECT pt.post_id, GROUP_CONCAT(t.name) as tags
+ FROM post_tags pt
+ JOIN tags t ON pt.tag_id = t.id
+ WHERE pt.post_id IN ({format_strings})
+ GROUP BY pt.post_id""",
+ tuple(topk_post_ids)
+ )
+ tag_rows = cursor.fetchall()
+ tag_map = {row[0]: row[1] for row in tag_rows}
+
+ # 查询行为统计(点赞数、评论数等)
+ cursor.execute(
+ f"""SELECT post_id, type, COUNT(*) as count
+ FROM behaviors
+ WHERE post_id IN ({format_strings})
+ GROUP BY post_id, type""",
+ tuple(topk_post_ids)
+ )
+ behavior_rows = cursor.fetchall()
+ behavior_stats = {}
+ for row in behavior_rows:
+ post_id, behavior_type, count = row
+ if post_id not in behavior_stats:
+ behavior_stats[post_id] = {}
+ behavior_stats[post_id][behavior_type] = count
+
+ post_list = []
+ for i, post_id in enumerate(topk_post_ids):
+ row = post_map.get(post_id)
+ if not row:
+ print(f"帖子ID {post_id} 不存在或未发布,跳过")
+ continue
+ owner_user_id = row[1]
+ stats = behavior_stats.get(post_id, {})
+ post_info = {
+ 'post_id': post_id,
+ 'title': row[2],
+ 'content': row[3][:200] + '...' if len(row[3]) > 200 else row[3],
+ 'type': row[4],
+ 'username': user_map.get(owner_user_id, ""),
+ 'heat': row[5],
+ 'tags': tag_map.get(post_id, ""),
+ 'created_at': str(row[6]) if row[6] else "",
+ 'is_advertisement': bool(row[7]), # 添加广告标识
+ 'like_count': stats.get('like', 0),
+ 'comment_count': stats.get('comment', 0),
+ 'favorite_count': stats.get('favorite', 0),
+ 'view_count': stats.get('view', 0),
+ 'share_count': stats.get('share', 0)
+ }
+
+ # 如果有推荐打分,添加到结果中
+ if topk_scores is not None and i < len(topk_scores):
+ post_info['recommendation_score'] = float(topk_scores[i])
+
+ post_list.append(post_info)
+ return post_list
+ finally:
+ cursor.close()
+ conn.close()
+
+ def get_recommendations(self, user_id, topk=None):
+ """
+ 获取推荐结果的主要接口
+ """
+ try:
+ result = self.run_inference(user_id, topk)
+ # 如果是冷启动直接返回详细信息,否则查详情
+ if isinstance(result, list) and result and isinstance(result[0], dict):
+ return result
+ else:
+ # result 现在是 (topk_post_ids, topk_scores) 的元组
+ if isinstance(result, tuple) and len(result) == 2:
+ topk_post_ids, topk_scores = result
+ return self.get_post_info(topk_post_ids, topk_scores)
+ else:
+ # 兼容旧的返回格式
+ return self.get_post_info(result)
+ except Exception as e:
+ raise Exception(f"推荐系统错误: {str(e)}")
+
+ def get_all_item_scores(self, user_id):
+ """
+ 获取用户对所有物品的打分
+ 输入: user_id
+ 输出: (post_ids, scores) - 所有帖子ID和对应的打分
+ """
+ user2idx, post2idx = build_user_post_graph(return_mapping=True)
+ idx2post = {v: k for k, v in post2idx.items()}
+
+ if user_id not in user2idx:
+ # 用户不存在,返回空结果
+ return [], []
+
+ user_idx = user2idx[user_id]
+
+ dataset = EdgeListData(args.data_path, args.data_path)
+ pretrained_dict = torch.load(args.pre_model_path, map_location=args.device, weights_only=True)
+ pretrained_dict['user_embedding'] = pretrained_dict['user_embedding'][:dataset.num_users]
+ pretrained_dict['item_embedding'] = pretrained_dict['item_embedding'][:dataset.num_items]
+
+ model = LightGCN(dataset, phase='vanilla').to(args.device)
+ model.load_state_dict(pretrained_dict, strict=False)
+ model.eval()
+
+ with torch.no_grad():
+ user_emb, item_emb = model.generate()
+ user_vec = user_emb[user_idx].unsqueeze(0)
+ scores = model.rating(user_vec, item_emb).squeeze(0)
+
+ # 获取所有物品的ID和分数
+ all_scores = scores.cpu().numpy()
+ all_post_ids = [idx2post[idx] for idx in range(len(all_scores))]
+
+ return all_post_ids, all_scores
+
+ def init_multi_recall(self):
+ """初始化多路召回管理器"""
+ if self.multi_recall is None:
+ print("初始化多路召回管理器...")
+ self.multi_recall = MultiRecallManager(self.db_config, self.recall_config)
+ print("多路召回管理器初始化完成")
+
+ def init_lightgcn_scorer(self):
+ """初始化LightGCN评分器"""
+ if self.lightgcn_scorer is None:
+ print("初始化LightGCN评分器...")
+ self.lightgcn_scorer = LightGCNScorer()
+ print("LightGCN评分器初始化完成")
+
+ def _get_lightgcn_scores(self, user_id, candidate_post_ids):
+ """
+ 获取候选物品的LightGCN分数
+
+ Args:
+ user_id: 用户ID
+ candidate_post_ids: 候选物品ID列表
+
+ Returns:
+ List[float]: LightGCN分数列表
+ """
+ self.init_lightgcn_scorer()
+ return self.lightgcn_scorer.score_batch_candidates(user_id, candidate_post_ids)
+
+ def _fuse_scores(self, multi_recall_scores, lightgcn_scores, alpha=0.6):
+ """
+ 融合多路召回分数和LightGCN分数
+
+ Args:
+ multi_recall_scores: 多路召回分数列表
+ lightgcn_scores: LightGCN分数列表
+ alpha: LightGCN分数的权重(0-1之间)
+
+ Returns:
+ List[float]: 融合后的分数列表
+ """
+ if len(multi_recall_scores) != len(lightgcn_scores):
+ raise ValueError("分数列表长度不匹配")
+
+ # 对分数进行归一化
+ def normalize_scores(scores):
+ scores = np.array(scores)
+ min_score = np.min(scores)
+ max_score = np.max(scores)
+ if max_score == min_score:
+ return np.ones_like(scores) * 0.5
+ return (scores - min_score) / (max_score - min_score)
+
+ norm_multi_scores = normalize_scores(multi_recall_scores)
+ norm_lightgcn_scores = normalize_scores(lightgcn_scores)
+
+ # 加权融合
+ fused_scores = alpha * norm_lightgcn_scores + (1 - alpha) * norm_multi_scores
+
+ return fused_scores.tolist()
+
+ def train_multi_recall(self):
+ """训练多路召回模型"""
+ self.init_multi_recall()
+ self.multi_recall.train_all()
+
+ def update_recall_config(self, new_config):
+ """更新多路召回配置"""
+ self.recall_config.update(new_config)
+ if self.multi_recall:
+ self.multi_recall.update_config(new_config)
+
+ def multi_recall_inference(self, user_id, total_items=200):
+ """
+ 使用多路召回进行推荐
+
+ Args:
+ user_id: 用户ID
+ total_items: 总召回物品数量
+
+ Returns:
+ Tuple of (item_ids, scores, recall_breakdown)
+ """
+ self.init_multi_recall()
+
+ # 执行多路召回
+ item_ids, scores, recall_results = self.multi_recall.recall(user_id, total_items)
+
+ return item_ids, scores, recall_results
+
+ def get_multi_recall_stats(self, user_id):
+ """获取多路召回统计信息"""
+ if self.multi_recall is None:
+ return {"error": "多路召回未初始化"}
+
+ return self.multi_recall.get_recall_stats(user_id)
diff --git a/Merge/back_rhj/app/user_post_graph.txt b/Merge/back_rhj/app/user_post_graph.txt
new file mode 100644
index 0000000..2c66fd1
--- /dev/null
+++ b/Merge/back_rhj/app/user_post_graph.txt
@@ -0,0 +1,11 @@
+0 1 0 0 2 1 2 0 1 2 1 0 42 32 62 52 0 12 22 1749827292 1749827292 1749953091 1749953091 1749953091 1749953480 1749953480 1749953480 1749954059 1749954059 1749954059 1749955282 1749955282 1749955282 1749955282 1749955282 1749955282 1749955282 1 5 5 2 1 2 5 1 2 1 5 5 2 2 1 1 5 1
+1 2 0 0 43 33 53 1 5 13 23 1749827292 1749953091 1749953480 1749955282 1749955282 1749955282 1749955282 1749955282 1749955282 1749955282 5 5 5 1 5 2 2 5 1 2
+2 7 6 6 7 44 34 54 2 14 24 1749953091 1749953091 1749953480 1749953480 1749955282 1749955282 1749955282 1749955282 1749955282 1749955282 2 1 1 2 2 1 5 5 2 5
+3 3 0 3 0 0 1 45 35 55 15 25 1749953091 1749953091 1749953480 1749953480 1749954059 1749954059 1749955282 1749955282 1749955282 1749955282 1749955282 2 2 2 2 1 2 5 2 1 5 1
+4 0 0 2 46 36 56 6 16 26 1749953091 1749953480 1749954059 1749955282 1749955282 1749955282 1749955282 1749955282 1749955282 5 5 5 1 5 2 5 1 2
+5 37 47 57 3 7 17 27 1749955282 1749955282 1749955282 1749955282 1749955282 1749955282 1749955282 1 2 5 5 1 2 5
+6 38 48 58 8 18 28 1749955282 1749955282 1749955282 1749955282 1749955282 1749955282 2 5 1 2 5 1
+7 39 49 59 4 9 19 29 1749955282 1749955282 1749955282 1749955282 1749955282 1749955282 1749955282 5 1 2 2 5 1 2
+8 40 50 60 10 20 30 1749955282 1749955282 1749955282 1749955282 1749955282 1749955282 1 2 5 1 2 5
+9 41 51 61 11 31 21 1749955282 1749955282 1749955282 1749955282 1749955282 1749955282 2 5 1 2 1 5
+10 13 1749894174 5
diff --git a/Merge/back_rhj/app/utils/__pycache__/bloom_filter.cpython-312.pyc b/Merge/back_rhj/app/utils/__pycache__/bloom_filter.cpython-312.pyc
new file mode 100644
index 0000000..5c90537
--- /dev/null
+++ b/Merge/back_rhj/app/utils/__pycache__/bloom_filter.cpython-312.pyc
Binary files differ
diff --git a/Merge/back_rhj/app/utils/__pycache__/bloom_filter_manager.cpython-312.pyc b/Merge/back_rhj/app/utils/__pycache__/bloom_filter_manager.cpython-312.pyc
new file mode 100644
index 0000000..268f1fb
--- /dev/null
+++ b/Merge/back_rhj/app/utils/__pycache__/bloom_filter_manager.cpython-312.pyc
Binary files differ
diff --git a/Merge/back_rhj/app/utils/__pycache__/data_loader.cpython-312.pyc b/Merge/back_rhj/app/utils/__pycache__/data_loader.cpython-312.pyc
new file mode 100644
index 0000000..10b3571
--- /dev/null
+++ b/Merge/back_rhj/app/utils/__pycache__/data_loader.cpython-312.pyc
Binary files differ
diff --git a/Merge/back_rhj/app/utils/__pycache__/graph_build.cpython-312.pyc b/Merge/back_rhj/app/utils/__pycache__/graph_build.cpython-312.pyc
new file mode 100644
index 0000000..a560e74
--- /dev/null
+++ b/Merge/back_rhj/app/utils/__pycache__/graph_build.cpython-312.pyc
Binary files differ
diff --git a/Merge/back_rhj/app/utils/__pycache__/parse_args.cpython-312.pyc b/Merge/back_rhj/app/utils/__pycache__/parse_args.cpython-312.pyc
new file mode 100644
index 0000000..a88ee3b
--- /dev/null
+++ b/Merge/back_rhj/app/utils/__pycache__/parse_args.cpython-312.pyc
Binary files differ
diff --git a/Merge/back_rhj/app/utils/data_loader.py b/Merge/back_rhj/app/utils/data_loader.py
new file mode 100644
index 0000000..c882a12
--- /dev/null
+++ b/Merge/back_rhj/app/utils/data_loader.py
@@ -0,0 +1,97 @@
+from app.utils.parse_args import args
+from os import path
+from tqdm import tqdm
+import numpy as np
+import scipy.sparse as sp
+import torch
+import networkx as nx
+from copy import deepcopy
+from collections import defaultdict
+import pandas as pd
+
+
+class EdgeListData:
+ def __init__(self, train_file, test_file, phase='pretrain', pre_dataset=None, has_time=True):
+ self.phase = phase
+ self.has_time = has_time
+ self.pre_dataset = pre_dataset
+
+ self.hour_interval = args.hour_interval_pre if phase == 'pretrain' else args.hour_interval_f
+
+ self.edgelist = []
+ self.edge_time = []
+ self.num_users = 0
+ self.num_items = 0
+ self.num_edges = 0
+
+ self.train_user_dict = {}
+ self.test_user_dict = {}
+
+ self._load_data(train_file, test_file, has_time)
+
+ if phase == 'pretrain':
+ self.user_hist_dict = self.train_user_dict
+
+ users_has_hist = set(list(self.user_hist_dict.keys()))
+ all_users = set(list(range(self.num_users)))
+ users_no_hist = all_users - users_has_hist
+ for u in users_no_hist:
+ self.user_hist_dict[u] = []
+
+ def _read_file(self, train_file, test_file, has_time=True):
+ with open(train_file, 'r') as f:
+ for line in f:
+ line = line.strip().split('\t')
+ if not has_time:
+ user, items = line[:2]
+ times = " ".join(["0"] * len(items.split(" ")))
+ weights = " ".join(["1"] * len(items.split(" "))) if len(line) < 4 else line[3]
+ else:
+ if len(line) >= 4: # 包含权重信息
+ user, items, times, weights = line
+ else:
+ user, items, times = line
+ weights = " ".join(["1"] * len(items.split(" ")))
+
+ for i in items.split(" "):
+ self.edgelist.append((int(user), int(i)))
+ for i in times.split(" "):
+ self.edge_time.append(int(i))
+ self.train_user_dict[int(user)] = [int(i) for i in items.split(" ")]
+
+ self.test_edge_num = 0
+ with open(test_file, 'r') as f:
+ for line in f:
+ line = line.strip().split('\t')
+ user, items = line[:2]
+ self.test_user_dict[int(user)] = [int(i) for i in items.split(" ")]
+ self.test_edge_num += len(self.test_user_dict[int(user)])
+
+ def _load_data(self, train_file, test_file, has_time=True):
+ self._read_file(train_file, test_file, has_time)
+
+ self.edgelist = np.array(self.edgelist, dtype=np.int32)
+ self.edge_time = 1 + self.timestamp_to_time_step(np.array(self.edge_time, dtype=np.int32))
+ self.num_edges = len(self.edgelist)
+ if self.pre_dataset is not None:
+ self.num_users = self.pre_dataset.num_users
+ self.num_items = self.pre_dataset.num_items
+ else:
+ self.num_users = max([np.max(self.edgelist[:, 0]) + 1, np.max(list(self.test_user_dict.keys())) + 1])
+ self.num_items = max([np.max(self.edgelist[:, 1]) + 1, np.max([np.max(self.test_user_dict[u]) for u in self.test_user_dict.keys()]) + 1])
+
+ self.graph = sp.coo_matrix((np.ones(self.num_edges), (self.edgelist[:, 0], self.edgelist[:, 1])), shape=(self.num_users, self.num_items))
+
+ if self.has_time:
+ self.edge_time_dict = defaultdict(dict)
+ for i in range(len(self.edgelist)):
+ self.edge_time_dict[self.edgelist[i][0]][self.edgelist[i][1]+self.num_users] = self.edge_time[i]
+ self.edge_time_dict[self.edgelist[i][1]+self.num_users][self.edgelist[i][0]] = self.edge_time[i]
+
+ def timestamp_to_time_step(self, timestamp_arr, least_time=None):
+ interval_hour = self.hour_interval
+ if least_time is None:
+ least_time = np.min(timestamp_arr)
+ timestamp_arr = timestamp_arr - least_time
+ timestamp_arr = timestamp_arr // (interval_hour * 3600)
+ return timestamp_arr
diff --git a/Merge/back_rhj/app/utils/graph_build.py b/Merge/back_rhj/app/utils/graph_build.py
new file mode 100644
index 0000000..a453e4e
--- /dev/null
+++ b/Merge/back_rhj/app/utils/graph_build.py
@@ -0,0 +1,115 @@
+import pymysql
+import datetime
+from collections import defaultdict
+
+SqlURL = "10.126.59.25"
+SqlPort = 3306
+Database = "redbook" # 修改为redbook数据库
+SqlUsername = "root"
+SqlPassword = "123456"
+
+
+def fetch_user_post_data():
+ """
+ 从redbook数据库的behaviors表获取用户-帖子交互数据,只包含已发布的帖子
+ """
+ conn = pymysql.connect(
+ host=SqlURL,
+ port=SqlPort,
+ user=SqlUsername,
+ password=SqlPassword,
+ database=Database,
+ charset="utf8mb4"
+ )
+ cursor = conn.cursor()
+ # 获取用户行为数据,只包含已发布帖子的行为数据
+ cursor.execute("""
+ SELECT b.user_id, b.post_id, b.type, b.value, b.created_at
+ FROM behaviors b
+ INNER JOIN posts p ON b.post_id = p.id
+ WHERE b.type IN ('like', 'favorite', 'comment', 'view', 'share')
+ AND p.status = 'published'
+ ORDER BY b.created_at
+ """)
+ behavior_rows = cursor.fetchall()
+ cursor.close()
+ conn.close()
+ return behavior_rows
+
+
+def process_records(behavior_rows):
+ """
+ 处理用户行为记录,为不同类型的行为分配权重
+ """
+ records = []
+ user_set = set()
+ post_set = set()
+
+ # 为不同行为类型分配权重
+ behavior_weights = {
+ 'view': 1,
+ 'like': 2,
+ 'comment': 3,
+ 'share': 4,
+ 'favorite': 5
+ }
+
+ for row in behavior_rows:
+ user_id, post_id, behavior_type, value, created_at = row
+ user_set.add(user_id)
+ post_set.add(post_id)
+
+ if isinstance(created_at, datetime.datetime):
+ ts = int(created_at.timestamp())
+ else:
+ ts = 0
+
+ # 使用行为权重
+ weight = behavior_weights.get(behavior_type, 1) * (value or 1)
+ records.append((user_id, post_id, ts, weight))
+
+ return records, user_set, post_set
+
+
+def build_id_maps(user_set, post_set):
+ """
+ 构建用户和帖子的ID映射
+ """
+ user2idx = {uid: idx for idx, uid in enumerate(sorted(user_set))}
+ post2idx = {pid: idx for idx, pid in enumerate(sorted(post_set))}
+ return user2idx, post2idx
+
+
+def group_and_write(records, user2idx, post2idx, output_path="./app/user_post_graph.txt"):
+ """
+ 将记录按用户分组并写入文件,支持行为权重
+ """
+ user_items = defaultdict(list)
+ user_times = defaultdict(list)
+ user_weights = defaultdict(list)
+
+ for user_id, post_id, ts, weight in records:
+ uid = user2idx[user_id]
+ pid = post2idx[post_id]
+ user_items[uid].append(pid)
+ user_times[uid].append(ts)
+ user_weights[uid].append(weight)
+
+ with open(output_path, "w", encoding="utf-8") as f:
+ for uid in sorted(user_items.keys()):
+ items = " ".join(str(item) for item in user_items[uid])
+ times = " ".join(str(t) for t in user_times[uid])
+ weights = " ".join(str(w) for w in user_weights[uid])
+ f.write(f"{uid}\t{items}\t{times}\t{weights}\n")
+
+
+def build_user_post_graph(return_mapping=False):
+ """
+ 构建用户-帖子交互图
+ """
+ behavior_rows = fetch_user_post_data()
+ records, user_set, post_set = process_records(behavior_rows)
+ user2idx, post2idx = build_id_maps(user_set, post_set)
+ group_and_write(records, user2idx, post2idx)
+ if return_mapping:
+ return user2idx, post2idx
\ No newline at end of file
diff --git a/Merge/back_rhj/app/utils/parse_args.py b/Merge/back_rhj/app/utils/parse_args.py
new file mode 100644
index 0000000..82b3bb4
--- /dev/null
+++ b/Merge/back_rhj/app/utils/parse_args.py
@@ -0,0 +1,77 @@
+import argparse
+import sys
+
+def parse_args():
+ parser = argparse.ArgumentParser(description='GraphPro')
+ parser.add_argument('--phase', type=str, default='pretrain')
+ parser.add_argument('--plugin', action='store_true', default=False)
+ parser.add_argument('--save_path', type=str, default="saved" ,help='where to save model and logs')
+ parser.add_argument('--data_path', type=str, default="dataset/yelp",help='where to load data')
+ parser.add_argument('--exp_name', type=str, default='1')
+ parser.add_argument('--desc', type=str, default='')
+ parser.add_argument('--ab', type=str, default='full')
+ parser.add_argument('--log', type=int, default=1)
+
+ parser.add_argument('--device', type=str, default="cuda")
+ parser.add_argument('--model', type=str, default='GraphPro')
+ parser.add_argument('--pre_model', type=str, default='GraphPro')
+ parser.add_argument('--f_model', type=str, default='GraphPro')
+ parser.add_argument('--pre_model_path', type=str, default='pretrained_model.pt')
+
+ parser.add_argument('--hour_interval_pre', type=float, default=1)
+ parser.add_argument('--hour_interval_f', type=int, default=1)
+ parser.add_argument('--emb_dropout', type=float, default=0)
+
+ parser.add_argument('--updt_inter', type=int, default=1)
+ parser.add_argument('--samp_decay', type=float, default=0.05)
+
+ parser.add_argument('--edge_dropout', type=float, default=0.5)
+ parser.add_argument('--emb_size', type=int, default=64)
+ parser.add_argument('--batch_size', type=int, default=2048)
+ parser.add_argument('--eval_batch_size', type=int, default=512)
+ parser.add_argument('--seed', type=int, default=2023)
+ parser.add_argument('--num_epochs', type=int, default=300)
+ parser.add_argument('--neighbor_sample_num', type=int, default=5)
+ parser.add_argument('--lr', type=float, default=0.001)
+ parser.add_argument('--weight_decay', type=float, default=1e-4)
+ parser.add_argument('--metrics', type=str, default='recall;ndcg')
+ parser.add_argument('--metrics_k', type=str, default='20')
+ parser.add_argument('--early_stop_patience', type=int, default=10)
+ parser.add_argument('--neg_num', type=int, default=1)
+ parser.add_argument('--num_layers', type=int, default=3)
+ parser.add_argument('--n_layers', type=int, default=3)
+ parser.add_argument('--ssl_reg', type=float, default=1e-4)
+ parser.add_argument('--ssl_alpha', type=float, default=1)
+ parser.add_argument('--ssl_temp', type=float, default=0.2)
+ parser.add_argument('--epoch', type=int, default=200)
+ parser.add_argument('--decay', type=float, default=1e-3)
+ parser.add_argument('--model_reg', type=float, default=1e-4)
+ parser.add_argument('--topk', type=int, default=[1, 5, 10, 20], nargs='+')
+ parser.add_argument('--aug_type', type=str, default='ED')
+ parser.add_argument('--metric_topk', type=int, default=10)
+ parser.add_argument('--n_neighbors', type=int, default=32)
+ parser.add_argument('--n_samp', type=int, default=7)
+ parser.add_argument('--temp', type=float, default=1)
+ parser.add_argument('--temp_f', type=float, default=1)
+
+ return parser
+
+# 创建默认args,支持在没有命令行参数时使用
+try:
+ # 如果是在Flask应用中运行,使用默认参数
+ if len(sys.argv) == 1 or any(x in sys.argv[0] for x in ['flask', 'app.py', 'gunicorn']):
+ parser = parse_args()
+ args = parser.parse_args([]) # 使用空参数列表
+ else:
+ parser = parse_args()
+ args = parser.parse_args()
+except SystemExit:
+ # 如果parse_args失败,使用默认参数
+ parser = parse_args()
+ args = parser.parse_args([])
+
+if hasattr(args, 'pre_model') and hasattr(args, 'f_model'):
+ if args.pre_model == args.f_model:
+ args.model = args.pre_model
+ elif args.pre_model != 'LightGCN':
+ args.model = args.pre_model
diff --git a/Merge/back_rhj/config.py b/Merge/back_rhj/config.py
new file mode 100644
index 0000000..c249660
--- /dev/null
+++ b/Merge/back_rhj/config.py
@@ -0,0 +1,23 @@
+import os
+from dotenv import load_dotenv
+
+load_dotenv()
+
+class Config:
+ SECRET_KEY = os.environ.get('SECRET_KEY') or 'a_default_secret_key'
+ SQLALCHEMY_DATABASE_URI = os.environ.get('DATABASE_URL') or 'sqlite:///site.db'
+ SQLALCHEMY_TRACK_MODIFICATIONS = False
+ SQLURL = os.getenv('SQLURL')
+ SQLPORT = os.getenv('SQLPORT')
+ SQLNAME = os.getenv('SQLNAME')
+ SQLUSER = os.getenv('SQLUSER')
+ SQLPWD = os.getenv('SQLPWD')
+ JWT_SECRET_KEY = os.environ.get('JWT_SECRET_KEY') or 'jwt-secret-string'
+
+ # 邮件配置
+ MAIL_SERVER = os.environ.get('MAIL_SERVER') or 'smtp.qq.com'
+ MAIL_PORT = int(os.environ.get('MAIL_PORT') or 587)
+ MAIL_USE_TLS = os.environ.get('MAIL_USE_TLS', 'true').lower() in ['true', 'on', '1']
+ MAIL_USERNAME = os.environ.get('MAIL_USERNAME')
+ MAIL_PASSWORD = os.environ.get('MAIL_PASSWORD')
+ MAIL_DEFAULT_SENDER = os.environ.get('MAIL_DEFAULT_SENDER')
\ No newline at end of file
diff --git a/Merge/back_rhj/test_bloom_filter.py b/Merge/back_rhj/test_bloom_filter.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/Merge/back_rhj/test_bloom_filter.py
diff --git a/Merge/back_rhj/test_redbook_recommendation.py b/Merge/back_rhj/test_redbook_recommendation.py
new file mode 100644
index 0000000..d025ace
--- /dev/null
+++ b/Merge/back_rhj/test_redbook_recommendation.py
@@ -0,0 +1,279 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""
+测试基于redbook数据库的推荐系统
+"""
+
+import sys
+import os
+import time
+sys.path.append(os.path.dirname(os.path.abspath(__file__)))
+
+from app.services.recommendation_service import RecommendationService
+from app.utils.graph_build import build_user_post_graph
+import pymysql
+
+def test_database_connection():
+ """测试数据库连接"""
+ print("=== 测试数据库连接 ===")
+ try:
+ db_config = {
+ 'host': '10.126.59.25',
+ 'port': 3306,
+ 'user': 'root',
+ 'password': '123456',
+ 'database': 'redbook',
+ 'charset': 'utf8mb4'
+ }
+ conn = pymysql.connect(**db_config)
+ cursor = conn.cursor()
+
+ # 检查用户数量
+ cursor.execute("SELECT COUNT(*) FROM users")
+ user_count = cursor.fetchone()[0]
+ print(f"用户总数: {user_count}")
+
+ # 检查帖子数量
+ cursor.execute("SELECT COUNT(*) FROM posts WHERE status = 'published'")
+ post_count = cursor.fetchone()[0]
+ print(f"已发布帖子数: {post_count}")
+
+ # 检查行为数据
+ cursor.execute("SELECT type, COUNT(*) FROM behaviors GROUP BY type")
+ behavior_stats = cursor.fetchall()
+ print("行为统计:")
+ for behavior_type, count in behavior_stats:
+ print(f" {behavior_type}: {count}")
+
+ cursor.close()
+ conn.close()
+ print("数据库连接测试成功!")
+ return True
+ except Exception as e:
+ print(f"数据库连接失败: {e}")
+ return False
+
+def test_graph_building():
+ """测试图构建"""
+ print("\n=== 测试图构建 ===")
+ try:
+ user2idx, post2idx = build_user_post_graph(return_mapping=True)
+ print(f"用户数量: {len(user2idx)}")
+ print(f"帖子数量: {len(post2idx)}")
+
+ # 显示前几个用户和帖子的映射
+ print("前5个用户映射:")
+ for i, (user_id, idx) in enumerate(list(user2idx.items())[:5]):
+ print(f" 用户{user_id} -> 索引{idx}")
+
+ print("前5个帖子映射:")
+ for i, (post_id, idx) in enumerate(list(post2idx.items())[:5]):
+ print(f" 帖子{post_id} -> 索引{idx}")
+
+ print("图构建测试成功!")
+ return True
+ except Exception as e:
+ print(f"图构建失败: {e}")
+ return False
+
+def test_cold_start_recommendation():
+ """测试冷启动推荐"""
+ print("\n=== 测试冷启动推荐 ===")
+ try:
+ service = RecommendationService()
+
+ # 使用一个不存在的用户ID进行冷启动测试
+ fake_user_id = 999999
+
+ # 计时开始
+ start_time = time.time()
+ recommendations = service.get_recommendations(fake_user_id, topk=10)
+ end_time = time.time()
+
+ # 计算推荐耗时
+ recommendation_time = end_time - start_time
+ print(f"冷启动推荐耗时: {recommendation_time:.4f} 秒")
+
+ print(f"冷启动推荐结果(用户{fake_user_id}):")
+ for i, rec in enumerate(recommendations):
+ print(f" {i+1}. 帖子ID: {rec['post_id']}, 标题: {rec['title'][:50]}...")
+ print(f" 作者: {rec['username']}, 热度: {rec['heat']}")
+ print(f" 点赞: {rec.get('like_count', 0)}, 评论: {rec.get('comment_count', 0)}")
+
+ print("冷启动推荐测试成功!")
+ return True
+ except Exception as e:
+ print(f"冷启动推荐失败: {e}")
+ return False
+
+def test_user_recommendation():
+ """测试用户推荐"""
+ print("\n=== 测试用户推荐 ===")
+ try:
+ service = RecommendationService()
+
+ # 获取一个真实用户ID
+ db_config = service.db_config
+ conn = pymysql.connect(**db_config)
+ cursor = conn.cursor()
+ cursor.execute("SELECT DISTINCT user_id FROM behaviors LIMIT 1")
+ result = cursor.fetchone()
+
+ if result:
+ user_id = result[0]
+ print(f"测试用户ID: {user_id}")
+
+ # 查看用户的历史行为
+ cursor.execute("""
+ SELECT b.type, COUNT(*) as count
+ FROM behaviors b
+ WHERE b.user_id = %s
+ GROUP BY b.type
+ """, (user_id,))
+ user_behaviors = cursor.fetchall()
+ print("用户历史行为:")
+ for behavior_type, count in user_behaviors:
+ print(f" {behavior_type}: {count}")
+
+ cursor.close()
+ conn.close()
+
+ # 尝试获取推荐 - 添加计时
+ print("开始生成推荐...")
+ start_time = time.time()
+ recommendations = service.get_recommendations(user_id, topk=10)
+ end_time = time.time()
+
+ # 计算推荐耗时
+ recommendation_time = end_time - start_time
+ print(f"用户推荐耗时: {recommendation_time:.4f} 秒")
+
+ print(f"用户推荐结果(用户{user_id}):")
+ for i, rec in enumerate(recommendations):
+ print(f" {i+1}. 帖子ID: {rec['post_id']}, 标题: {rec['title'][:50]}...")
+ print(f" 作者: {rec['username']}, 热度: {rec['heat']}")
+ print(f" 点赞: {rec.get('like_count', 0)}, 评论: {rec.get('comment_count', 0)}")
+ if 'recommendation_score' in rec:
+ print(f" 推荐分数: {rec['recommendation_score']:.4f}")
+ else:
+ print(f" 热度分数: {rec['heat']}")
+
+ print("用户推荐测试成功!")
+ return True
+ else:
+ print("没有找到有行为记录的用户")
+ cursor.close()
+ conn.close()
+ return False
+
+ except Exception as e:
+ print(f"用户推荐失败: {e}")
+ return False
+
+def test_recommendation_performance():
+ """测试推荐性能 - 多次调用统计"""
+ print("\n=== 测试推荐性能 ===")
+ try:
+ service = RecommendationService()
+
+ # 获取几个真实用户ID进行测试
+ db_config = service.db_config
+ conn = pymysql.connect(**db_config)
+ cursor = conn.cursor()
+ cursor.execute("SELECT DISTINCT user_id FROM behaviors LIMIT 5")
+ user_ids = [row[0] for row in cursor.fetchall()]
+ cursor.close()
+ conn.close()
+
+ if not user_ids:
+ print("没有找到有行为记录的用户")
+ return False
+
+ print(f"测试用户数量: {len(user_ids)}")
+
+ # 进行多次推荐测试
+ times = []
+ test_rounds = 3 # 每个用户测试3轮
+
+ for round_num in range(test_rounds):
+ print(f"\n第 {round_num + 1} 轮测试:")
+ round_times = []
+
+ for i, user_id in enumerate(user_ids):
+ start_time = time.time()
+ recommendations = service.get_recommendations(user_id, topk=10)
+ end_time = time.time()
+
+ recommendation_time = end_time - start_time
+ round_times.append(recommendation_time)
+ times.append(recommendation_time)
+
+ print(f" 用户 {user_id}: {recommendation_time:.4f}s, 推荐数量: {len(recommendations)}")
+
+ # 计算本轮统计
+ avg_time = sum(round_times) / len(round_times)
+ min_time = min(round_times)
+ max_time = max(round_times)
+ print(f" 本轮平均耗时: {avg_time:.4f}s, 最快: {min_time:.4f}s, 最慢: {max_time:.4f}s")
+
+ # 计算总体统计
+ print(f"\n=== 性能统计总结 ===")
+ print(f"总测试次数: {len(times)}")
+ print(f"平均推荐耗时: {sum(times) / len(times):.4f} 秒")
+ print(f"最快推荐耗时: {min(times):.4f} 秒")
+ print(f"最慢推荐耗时: {max(times):.4f} 秒")
+ print(f"推荐耗时标准差: {(sum([(t - sum(times)/len(times))**2 for t in times]) / len(times))**0.5:.4f} 秒")
+
+ # 性能等级评估
+ avg_time = sum(times) / len(times)
+ if avg_time < 0.1:
+ performance_level = "优秀"
+ elif avg_time < 0.5:
+ performance_level = "良好"
+ elif avg_time < 1.0:
+ performance_level = "一般"
+ else:
+ performance_level = "需要优化"
+
+ print(f"性能评级: {performance_level}")
+
+ print("推荐性能测试成功!")
+ return True
+
+ except Exception as e:
+ print(f"推荐性能测试失败: {e}")
+ return False
+
+def main():
+ """主测试函数"""
+ print("开始测试基于redbook数据库的推荐系统")
+ print("=" * 50)
+
+ tests = [
+ test_database_connection,
+ test_graph_building,
+ test_cold_start_recommendation,
+ test_user_recommendation,
+ test_recommendation_performance
+ ]
+
+ passed = 0
+ total = len(tests)
+
+ for test in tests:
+ try:
+ if test():
+ passed += 1
+ except Exception as e:
+ print(f"测试异常: {e}")
+
+ print("\n" + "=" * 50)
+ print(f"测试完成: {passed}/{total} 通过")
+
+ if passed == total:
+ print("所有测试通过!")
+ else:
+ print("部分测试失败,请检查配置和代码")
+
+if __name__ == "__main__":
+ main()
diff --git "a/Merge/back_rhj/\351\202\256\344\273\266\346\234\215\345\212\241\351\205\215\347\275\256\346\214\207\345\215\227.md" "b/Merge/back_rhj/\351\202\256\344\273\266\346\234\215\345\212\241\351\205\215\347\275\256\346\214\207\345\215\227.md"
new file mode 100644
index 0000000..b784771
--- /dev/null
+++ "b/Merge/back_rhj/\351\202\256\344\273\266\346\234\215\345\212\241\351\205\215\347\275\256\346\214\207\345\215\227.md"
@@ -0,0 +1,211 @@
+# 邮件服务配置指南
+
+## 概述
+本系统支持通过SMTP协议发送验证码邮件,支持主流邮件服务提供商,包括QQ邮箱、Gmail、163邮箱等。
+
+## 配置步骤
+
+### 1. 创建 .env 文件
+在项目根目录 `/home/ronghanji/api/API-TRM/rhj/backend/` 下创建 `.env` 文件(如果不存在),参考 `.env.example` 文件。
+
+### 2. 邮件服务配置选项
+
+#### QQ邮箱配置(推荐)
+```bash
+MAIL_SERVER=smtp.qq.com
+MAIL_PORT=587
+MAIL_USE_TLS=true
+MAIL_USERNAME=your_email@qq.com
+MAIL_PASSWORD=your_app_password
+MAIL_DEFAULT_SENDER=your_email@qq.com
+```
+
+**QQ邮箱授权码获取方法:**
+1. 登录QQ邮箱 (https://mail.qq.com)
+2. 点击"设置" → "账户"
+3. 找到"POP3/IMAP/SMTP/Exchange/CardDAV/CalDAV服务"
+4. 开启"POP3/SMTP服务"或"IMAP/SMTP服务"
+5. 按照提示发送短信验证
+6. 获得16位授权码,这个授权码就是MAIL_PASSWORD
+
+#### Gmail配置
+```bash
+MAIL_SERVER=smtp.gmail.com
+MAIL_PORT=587
+MAIL_USE_TLS=true
+MAIL_USERNAME=your_email@gmail.com
+MAIL_PASSWORD=your_app_password
+MAIL_DEFAULT_SENDER=your_email@gmail.com
+```
+
+**Gmail应用密码获取方法:**
+1. 登录Google账号管理 (https://myaccount.google.com)
+2. 启用两步验证
+3. 转到"安全性" → "应用密码"
+4. 选择"邮件"和设备类型
+5. 生成16位应用密码
+
+#### 163邮箱配置
+```bash
+MAIL_SERVER=smtp.163.com
+MAIL_PORT=25
+MAIL_USE_TLS=false
+MAIL_USERNAME=your_email@163.com
+MAIL_PASSWORD=your_email_password
+MAIL_DEFAULT_SENDER=your_email@163.com
+```
+
+**163邮箱客户端授权密码获取方法:**
+1. 登录163邮箱
+2. 点击"设置" → "POP3/SMTP/IMAP"
+3. 开启"POP3/SMTP服务"
+4. 设置客户端授权密码
+
+#### 企业邮箱配置
+```bash
+MAIL_SERVER=your_company_smtp_server
+MAIL_PORT=587
+MAIL_USE_TLS=true
+MAIL_USERNAME=your_email@company.com
+MAIL_PASSWORD=your_password
+MAIL_DEFAULT_SENDER=your_email@company.com
+```
+
+### 3. 完整的 .env 配置示例
+
+```bash
+# 数据库配置
+SQLURL=mysql+pymysql://username:password@localhost:3306/redbook
+SQLPORT=3306
+SQLNAME=redbook
+SQLUSER=root
+SQLPWD=123456
+
+# JWT密钥
+JWT_SECRET_KEY=your-jwt-secret-key-here
+
+# Flask密钥
+SECRET_KEY=your-flask-secret-key-here
+
+# 邮件服务配置(选择一种)
+MAIL_SERVER=smtp.qq.com
+MAIL_PORT=587
+MAIL_USE_TLS=true
+MAIL_USERNAME=your_email@qq.com
+MAIL_PASSWORD=your_qq_auth_code
+MAIL_DEFAULT_SENDER=your_email@qq.com
+```
+
+## 配置验证
+
+### 1. 测试邮件配置
+创建一个测试脚本来验证邮件配置是否正确:
+
+```python
+# test_email.py
+import sys
+import os
+sys.path.append(os.path.dirname(os.path.abspath(__file__)))
+
+from config import Config
+from app.functions.FAuth import FAuth
+from app.models import db
+from app import create_app
+
+def test_email_config():
+ """测试邮件配置"""
+ app = create_app()
+
+ with app.app_context():
+ # 检查配置
+ print("邮件服务器配置:")
+ print(f"MAIL_SERVER: {Config.MAIL_SERVER}")
+ print(f"MAIL_PORT: {Config.MAIL_PORT}")
+ print(f"MAIL_USE_TLS: {Config.MAIL_USE_TLS}")
+ print(f"MAIL_USERNAME: {Config.MAIL_USERNAME}")
+ print(f"MAIL_DEFAULT_SENDER: {Config.MAIL_DEFAULT_SENDER}")
+
+ if not all([Config.MAIL_USERNAME, Config.MAIL_PASSWORD, Config.MAIL_DEFAULT_SENDER]):
+ print("❌ 邮件配置不完整!")
+ return False
+
+ # 发送测试邮件
+ auth = FAuth(db.session)
+ result = auth.send_verification_email(
+ email="test@example.com", # 替换为您的测试邮箱
+ verification_type="register"
+ )
+
+ if result['success']:
+ print("✅ 邮件配置正确,测试邮件发送成功!")
+ return True
+ else:
+ print(f"❌ 邮件发送失败: {result['message']}")
+ return False
+
+if __name__ == "__main__":
+ test_email_config()
+```
+
+### 2. 运行测试
+```bash
+cd /home/ronghanji/api/API-TRM/rhj/backend
+python test_email.py
+```
+
+## 常见问题和解决方案
+
+### 1. 认证失败 (535 Authentication failed)
+**原因:** 用户名或密码错误
+**解决方案:**
+- 检查邮箱地址是否正确
+- 确认使用的是授权码/应用密码,不是登录密码
+- QQ/163邮箱需要开启SMTP服务并获取授权码
+
+### 2. 连接超时
+**原因:** 网络问题或服务器地址错误
+**解决方案:**
+- 检查MAIL_SERVER和MAIL_PORT是否正确
+- 确认网络连接正常
+- 尝试使用不同的端口(如465用于SSL)
+
+### 3. TLS错误
+**原因:** TLS配置问题
+**解决方案:**
+- 检查MAIL_USE_TLS设置是否正确
+- 某些邮件服务器需要使用SSL(端口465)而不是TLS
+
+### 4. 发送频率限制
+**原因:** 邮件服务商限制发送频率
+**解决方案:**
+- 减少发送频率
+- 使用企业邮箱服务
+- 考虑使用专业的邮件服务(如SendGrid、阿里云邮件推送等)
+
+## 生产环境建议
+
+### 1. 安全性
+- 使用环境变量存储敏感信息
+- 定期更换邮箱密码和授权码
+- 使用专用的邮箱账号发送系统邮件
+
+### 2. 可靠性
+- 配置备用邮件服务器
+- 实现邮件发送重试机制
+- 监控邮件发送状态
+
+### 3. 性能优化
+- 使用异步邮件发送
+- 实现邮件队列
+- 限制发送频率
+
+## 专业邮件服务替代方案
+
+如果需要更高的可靠性和发送量,建议使用专业邮件服务:
+
+1. **阿里云邮件推送**
+2. **腾讯云邮件推送**
+3. **SendGrid**
+4. **Mailgun**
+
+这些服务通常提供更好的送达率和更高的发送限制。
diff --git a/Merge/back_trm/app.py b/Merge/back_trm/app.py
index 3c7fb86..af7fefc 100644
--- a/Merge/back_trm/app.py
+++ b/Merge/back_trm/app.py
@@ -1,8 +1,47 @@
from app import create_app
from flask_cors import CORS
+import os
+import psutil
+from flask import Flask,g,request
+import time
+from sqlalchemy import create_engine
+from sqlalchemy.orm import sessionmaker
+from config import Config
+from app.functions.Fpost import Fpost;
app = create_app()
CORS(app, resources={r"/*": {"origins": "*"}})
+proc=psutil.Process(os.getpid())
+@app.before_request
+def before_request():
+ g.start_time=time.time()
+ g.start_cpu=proc.cpu_times()
+ g.start_mem=proc.memory_info()
+
+@app.after_request
+def after_request(response):
+ end_time = time.time()
+ end_cpu = proc.cpu_times()
+ end_mem = proc.memory_info()
+
+ elapsed = end_time - g.start_time
+ cpu_user = end_cpu.user - g.start_cpu.user
+ cpu_sys = end_cpu.system - g.start_cpu.system
+ mem_rss = end_mem.rss - g.start_mem.rss
+
+ #写入性能消耗
+ engine=create_engine(Config.SQLURL)
+ SessionLocal = sessionmaker(bind=engine)
+ session = SessionLocal()
+ f=Fpost(session)
+ f.recordsyscost(
+ request.path,
+ elapsed,
+ cpu_user,
+ cpu_sys,
+ mem_rss
+ )
+ return response
if __name__ == "__main__":
app.run(debug=True,port=5713,host='0.0.0.0')
\ No newline at end of file
diff --git a/Merge/back_trm/app/__pycache__/__init__.cpython-310.pyc b/Merge/back_trm/app/__pycache__/__init__.cpython-310.pyc
deleted file mode 100644
index f713fad..0000000
--- a/Merge/back_trm/app/__pycache__/__init__.cpython-310.pyc
+++ /dev/null
Binary files differ
diff --git a/Merge/back_trm/app/__pycache__/__init__.cpython-312.pyc b/Merge/back_trm/app/__pycache__/__init__.cpython-312.pyc
deleted file mode 100644
index ec28c7e..0000000
--- a/Merge/back_trm/app/__pycache__/__init__.cpython-312.pyc
+++ /dev/null
Binary files differ
diff --git a/Merge/back_trm/app/__pycache__/routes.cpython-310.pyc b/Merge/back_trm/app/__pycache__/routes.cpython-310.pyc
deleted file mode 100644
index 5166bf4..0000000
--- a/Merge/back_trm/app/__pycache__/routes.cpython-310.pyc
+++ /dev/null
Binary files differ
diff --git a/Merge/back_trm/app/functions/Fpost.py b/Merge/back_trm/app/functions/Fpost.py
index 7d6ccd2..2237815 100644
--- a/Merge/back_trm/app/functions/Fpost.py
+++ b/Merge/back_trm/app/functions/Fpost.py
@@ -4,6 +4,8 @@
import hashlib
from datetime import datetime, timedelta
from sqlalchemy.orm import Session
+from ..models.logs import Log
+from ..models.syscost import PerformanceData
class Fpost:
def __init__(self,session:Session):
self.session=session
@@ -99,4 +101,56 @@
except Exception as e:
self.session.rollback()
- raise Exception(f"创建token失败: {str(e)}")
\ No newline at end of file
+ raise Exception(f"创建token失败: {str(e)}")
+
+ def recordlog(self,user_id,log_type,content,ip):
+ """
+ 记录日志
+ :param user_id: 用户ID
+ :param log_type: 日志类型,'access','error','behavior','system'
+ :param content: 日志内容
+ :param ip: IP地址
+ """
+ try:
+ new_log = Log(
+ user_id=user_id,
+ type=log_type,
+ content=content,
+ ip=ip
+ )
+ self.session.add(new_log)
+ self.session.commit()
+ except Exception as e:
+ self.session.rollback()
+ raise Exception(f"记录日志失败: {str(e)}")
+
+ def getrecordlog(self):
+ res= self.session.query(Log).all()
+ return res
+
+ def recordsyscost(self, endpoint: str, elapsed_time: float, cpu_user: float, cpu_system: float, memory_rss: int):
+ """
+ 记录系统性能消耗到 performance_data 表
+ :param endpoint: 请求接口路径
+ :param elapsed_time: 总耗时(秒)
+ :param cpu_user: 用户态 CPU 时间差(秒)
+ :param cpu_system: 系统态 CPU 时间差(秒)
+ :param memory_rss: RSS 内存增量(字节)
+ """
+ try:
+ new_record = PerformanceData(
+ endpoint=endpoint,
+ elapsed_time=elapsed_time,
+ cpu_user=cpu_user,
+ cpu_system=cpu_system,
+ memory_rss=memory_rss
+ )
+ self.session.add(new_record)
+ self.session.commit()
+ except Exception as e:
+ self.session.rollback()
+ raise Exception(f"记录系统性能消耗失败: {e}")
+
+ def getsyscost(self):
+ res= self.session.query(PerformanceData).all()
+ return res
\ No newline at end of file
diff --git a/Merge/back_trm/app/functions/__init__.py b/Merge/back_trm/app/functions/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/Merge/back_trm/app/functions/__init__.py
diff --git a/Merge/back_trm/app/functions/__pycache__/Fpost.cpython-310.pyc b/Merge/back_trm/app/functions/__pycache__/Fpost.cpython-310.pyc
index f9b1bc6..3a49c6c 100644
--- a/Merge/back_trm/app/functions/__pycache__/Fpost.cpython-310.pyc
+++ b/Merge/back_trm/app/functions/__pycache__/Fpost.cpython-310.pyc
Binary files differ
diff --git a/Merge/back_trm/app/functions/__pycache__/__init__.cpython-310.pyc b/Merge/back_trm/app/functions/__pycache__/__init__.cpython-310.pyc
new file mode 100644
index 0000000..ed5a973
--- /dev/null
+++ b/Merge/back_trm/app/functions/__pycache__/__init__.cpython-310.pyc
Binary files differ
diff --git a/Merge/back_trm/app/models/__pycache__/logs.cpython-310.pyc b/Merge/back_trm/app/models/__pycache__/logs.cpython-310.pyc
new file mode 100644
index 0000000..f1e86e3
--- /dev/null
+++ b/Merge/back_trm/app/models/__pycache__/logs.cpython-310.pyc
Binary files differ
diff --git a/Merge/back_trm/app/models/__pycache__/syscost.cpython-310.pyc b/Merge/back_trm/app/models/__pycache__/syscost.cpython-310.pyc
new file mode 100644
index 0000000..9831ffd
--- /dev/null
+++ b/Merge/back_trm/app/models/__pycache__/syscost.cpython-310.pyc
Binary files differ
diff --git a/Merge/back_trm/app/models/logs.py b/Merge/back_trm/app/models/logs.py
new file mode 100644
index 0000000..ac8cf1c
--- /dev/null
+++ b/Merge/back_trm/app/models/logs.py
@@ -0,0 +1,19 @@
+from sqlalchemy import Column, BigInteger, Integer, Enum, Text, String, TIMESTAMP, ForeignKey, Index
+from sqlalchemy.sql import func
+from . import Base # adjust if Base lives elsewhere
+
+class Log(Base):
+ __tablename__ = 'logs'
+ __table_args__ = (
+ Index('user_id', 'user_id'),
+ Index('idx_logs_created', 'created_at'),
+ )
+
+ id = Column(BigInteger, primary_key=True, autoincrement=True, comment='日志ID')
+ user_id = Column(Integer, ForeignKey('users.id', ondelete='SET NULL'), comment='用户ID')
+ type = Column(Enum('access', 'error', 'behavior', 'system',
+ name='logs_type_enum'), nullable=False, comment='日志类型')
+ content = Column(Text, nullable=False, comment='日志内容')
+ ip = Column(String(45), nullable=True, comment='IP地址')
+ created_at = Column(TIMESTAMP, server_default=func.current_timestamp(),
+ nullable=True, comment='记录时间')
diff --git a/Merge/back_trm/app/models/syscost.py b/Merge/back_trm/app/models/syscost.py
new file mode 100644
index 0000000..bbde029
--- /dev/null
+++ b/Merge/back_trm/app/models/syscost.py
@@ -0,0 +1,15 @@
+from sqlalchemy import Column, BigInteger, DateTime, String, Float, func
+from sqlalchemy.ext.declarative import declarative_base
+
+Base = declarative_base()
+
+class PerformanceData(Base):
+ __tablename__ = 'performance_data'
+
+ id = Column(BigInteger, primary_key=True, autoincrement=True)
+ record_time = Column(DateTime, nullable=False, server_default=func.now(), comment='记录时间')
+ endpoint = Column(String(255), nullable=True, comment='请求接口路径')
+ elapsed_time = Column(Float, nullable=False, comment='总耗时(秒)')
+ cpu_user = Column(Float, nullable=False, comment='用户态 CPU 时间差(秒)')
+ cpu_system = Column(Float, nullable=False, comment='系统态 CPU 时间差(秒)')
+ memory_rss = Column(BigInteger, nullable=False, comment='RSS 内存增量(字节)')
\ No newline at end of file
diff --git a/Merge/back_trm/app/routes.py b/Merge/back_trm/app/routes.py
index 41b022b..625ea6d 100644
--- a/Merge/back_trm/app/routes.py
+++ b/Merge/back_trm/app/routes.py
@@ -5,8 +5,10 @@
from config import Config
from flask import jsonify,request
+
main = Blueprint('main', __name__)
+
@main.route('/sgiveadmin',methods=['POST','GET'])
def giveadmin():
data=request.get_json()
@@ -17,12 +19,23 @@
f=Fpost(session)
checres=f.checkid(data['userid'],'superadmin')
if(not checres):
+ f.recordlog(data['userid'],
+ 'error',
+ '系统需要超级管理员才能执行修改用户角色的操作,但是当前用户不是超级管理员',
+ request.remote_addr)
return jsonify({'status': 'error', 'message': 'Unauthorized'})
res=f.giveadmin(data['targetid'])
if not res:
+ f.recordlog(data['userid'],
+ 'error',
+ f"尝试修改用户{data['targetid']}角色为admin失败,用户不存在",
+ request.remote_addr)
return jsonify({'status': 'error', 'message': 'User not found'})
-
+ f.recordlog(data['userid'],
+ 'behavior',
+ f'用户角色为admin修改成功,用户ID: {data["targetid"]} 被修改为管理员',
+ request.remote_addr)
return jsonify({'status': 'success', 'message': 'User role updated to admin'})
@main.route('/sgiveuser',methods=['POST','GET'])
@@ -35,12 +48,23 @@
f=Fpost(session)
checres=f.checkid(data['userid'],'superadmin')
if(not checres):
+ f.recordlog(data['userid'],
+ 'error',
+ '系统需要超级管理员才能执行修改用户角色的操作,但是当前用户不是超级管理员',
+ request.remote_addr)
return jsonify({'status': 'error', 'message': 'Unauthorized'})
res=f.giveuser(data['targetid'])
if not res:
+ f.recordlog(data['userid'],
+ 'error',
+ f"尝试修改用户{data['targetid']}为user失败,用户不存在",
+ request.remote_addr)
return jsonify({'status': 'error', 'message': 'User not found'})
-
+ f.recordlog(data['userid'],
+ 'behavior',
+ f'用户角色修改成功,用户ID: {data["targetid"]} 被修改为普通用户',
+ request.remote_addr)
return jsonify({'status': 'success', 'message': 'User role updated to user'})
@@ -54,12 +78,23 @@
f=Fpost(session)
checres=f.checkid(data['userid'],'superadmin')
if(not checres):
+ f.recordlog(data['userid'],
+ 'error',
+ '系统需要超级管理员才能执行修改用户角色的操作,但是当前用户不是超级管理员',
+ request.remote_addr)
return jsonify({'status': 'error', 'message': 'Unauthorized'})
res=f.givesuperadmin(data['targetid'])
if not res:
+ f.recordlog(data['userid'],
+ 'error',
+ f'尝试修改用户{data["targetid"]}角色为superadmin失败,用户不存在',
+ request.remote_addr)
return jsonify({'status': 'error', 'message': 'User not found'})
-
+ f.recordlog(data['userid'],
+ 'behavior',
+ f'用户角色修改成功,用户ID: {data["targetid"]} 被修改为超级管理员',
+ request.remote_addr)
return jsonify({'status': 'success', 'message': 'User role updated to superadmin'})
@main.route('/sgetuserlist',methods=['POST','GET'])
@@ -72,6 +107,10 @@
f=Fpost(session)
checres=f.checkid(data['userid'],'superadmin')
if(not checres):
+ f.recordlog(data['userid'],
+ 'error',
+ '系统需要超级管理员才能执行获取用户列表的操作,但是当前用户不是超级管理员',
+ request.remote_addr)
return jsonify({'status': 'error', 'message': 'Unauthorized'})
res=f.getuserlist()
respons=[]
@@ -81,6 +120,11 @@
'username': datai[1],
'role': datai[2]
})
+
+ f.recordlog(data['userid'],
+ 'access',
+ '获取用户列表成功',
+ request.remote_addr)
return jsonify(respons)
@main.route('/apostlist',methods=['POST','GET'])
@@ -93,6 +137,10 @@
f=Fpost(session)
checres=f.checkid(data['userid'],'admin')
if(not checres):
+ f.recordlog(data['userid'],
+ 'error',
+ '系统需要管理员才能执行获取帖子列表的操作,但是当前用户不是管理员',
+ request.remote_addr)
return jsonify({'status': 'error', 'message': 'Unauthorized'})
res=f.getlist()
respons=[]
@@ -102,6 +150,10 @@
'title': datai[1],
'status': datai[2]
})
+ f.recordlog(data['userid'],
+ 'access',
+ '获取帖子列表成功',
+ request.remote_addr)
return jsonify(respons)
@main.route('/agetpost',methods=['POST','GET'])
@@ -113,9 +165,22 @@
f=Fpost(session)
checres=f.checkid(data['userid'],'admin')
if(not checres):
+ f.recordlog(data['userid'],
+ 'error',
+ '系统需要管理员才能执行获取帖子详情的操作,但是当前用户不是管理员',
+ request.remote_addr)
return jsonify({'status': 'error', 'message': 'Unauthorized'})
res=f.getpost(data['postid'])
-
+ if not res:
+ f.recordlog(data['userid'],
+ 'error',
+ f'尝试获取帖子{data["postid"]}失败,帖子不存在',
+ request.remote_addr)
+ return jsonify({'status': 'error', 'message': 'Post not found'})
+ f.recordlog(data['userid'],
+ 'access',
+ f'获取帖子详情成功,帖子ID: {data["postid"]}',
+ request.remote_addr)
return jsonify(res.to_dict() if res else {})
@main.route('/areview',methods=['POST','GET'])
@@ -127,12 +192,23 @@
f=Fpost(session)
checres=f.checkid(data['userid'],'admin')
if(not checres):
+ f.recordlog(data['userid'],
+ 'error',
+ '系统需要管理员才能执行帖子审核的操作,但是当前用户不是管理员',
+ request.remote_addr)
return jsonify({'status': 'error', 'message': 'Unauthorized'})
res=f.review(data['postid'],data['status'])
if not res:
+ f.recordlog(data['userid'],
+ 'error',
+ f'尝试审核帖子{data["postid"]}失败,帖子不存在',
+ request.remote_addr)
return jsonify({'status': 'error', 'message': 'Post not found'})
-
+ f.recordlog(data['userid'],
+ 'behavior',
+ f'帖子审核成功,帖子ID: {data["postid"]} 状态更新为 {data["status"]}',
+ request.remote_addr)
return jsonify({'status': 'success', 'message': 'Post reviewed successfully'})
@@ -146,10 +222,102 @@
f=Fpost(session)
checres=f.checkid(data['userid'],'admin')
if(not checres):
+ f.recordlog(data['userid'],
+ 'error',
+ '系统需要管理员才能执行Nginx认证的操作,但是当前用户不是管理员',
+ request.remote_addr)
return jsonify({'status': 'error', 'message': 'Unauthorized'})
res=f.nginxauth(data['postid'],data['status'])
if not res:
+ f.recordlog(data['userid'],
+ 'error',
+ f'尝试更新Nginx认证状态失败,帖子{data["postid"]}不存在',
+ request.remote_addr)
return jsonify({'status': 'error', 'message': 'Post not found'})
+ f.recordlog(data['userid'],
+ 'behavior',
+ f'Nginx认证状态更新成功,帖子ID: {data["postid"]} 状态更新为 {data["status"]}',
+ request.remote_addr)
+ return jsonify({'status': 'success', 'message': 'Nginx auth updated successfully'})
+
+@main.route('/getsyscost',methods=['POST','GET'])
+def getsyscost():
+ data=request.get_json()
+ engine=create_engine(Config.SQLURL)
+ SessionLocal = sessionmaker(bind=engine)
+ session = SessionLocal()
+ f=Fpost(session)
+ checres=f.checkid(data['userid'],'superadmin')
+ if(not checres):
+ f.recordlog(data['userid'],
+ 'error',
+ '系统需要管理员才能执行获取系统性能消耗的操作,但是当前用户不是管理员',
+ request.remote_addr)
+ return jsonify({'status': 'error', 'message': 'Unauthorized'})
- return jsonify({'status': 'success', 'message': 'Nginx auth updated successfully'})
\ No newline at end of file
+ res=f.getsyscost()
+ if not res:
+ f.recordlog(data['userid'],
+ 'error',
+ '尝试获取系统性能消耗数据失败,数据不存在',
+ request.remote_addr)
+ return jsonify({'status': 'error', 'message': 'No performance data found'})
+
+ f.recordlog(data['userid'],
+ 'access',
+ '获取系统性能消耗数据成功',
+ request.remote_addr)
+ resdata = []
+ for datai in res:
+ resdata.append({
+ 'id': datai.id,
+ 'record_time': datai.record_time.isoformat(),
+ 'endpoint': datai.endpoint,
+ 'elapsed_time': datai.elapsed_time,
+ 'cpu_user': datai.cpu_user,
+ 'cpu_system': datai.cpu_system,
+ 'memory_rss': datai.memory_rss
+ })
+ return jsonify(resdata)
+@main.route('/getrecordlog',methods=['POST','GET'])
+def getrecordlog():
+ data=request.get_json()
+ engine=create_engine(Config.SQLURL)
+ SessionLocal = sessionmaker(bind=engine)
+ session = SessionLocal()
+ f=Fpost(session)
+ checres=f.checkid(data['userid'],'admin')
+ if(not checres):
+ f.recordlog(data['userid'],
+ 'error',
+ '系统需要管理员才能执行获取日志的操作,但是当前用户不是管理员',
+ request.remote_addr)
+ return jsonify({'status': 'error', 'message': 'Unauthorized'})
+
+ res=f.getrecordlog()
+ if not res:
+ f.recordlog(data['userid'],
+ 'error',
+ '尝试获取日志失败,日志不存在',
+ request.remote_addr)
+ return jsonify({'status': 'error', 'message': 'No logs found'})
+
+ f.recordlog(data['userid'],
+ 'access',
+ '获取日志成功',
+ request.remote_addr)
+
+ resdata = []
+ for datai in res:
+ resdata.append({
+ 'id': datai.id,
+ 'user_id': datai.user_id,
+ 'type': datai.type,
+ 'content': datai.content,
+ 'ip': datai.ip,
+ 'created_at': datai.created_at.isoformat()
+ })
+
+ return jsonify(resdata)
+
diff --git a/Merge/back_wzy/models/logs.py b/Merge/back_wzy/models/logs.py
new file mode 100644
index 0000000..3170dbd
--- /dev/null
+++ b/Merge/back_wzy/models/logs.py
@@ -0,0 +1,19 @@
+from sqlalchemy import Column, BigInteger, Integer, Enum, Text, String, TIMESTAMP, ForeignKey, Index
+from sqlalchemy.sql import func
+from extensions import db # adjust if Base lives elsewhere
+
+class Log(db.Model):
+ __tablename__ = 'logs'
+ __table_args__ = (
+ Index('user_id', 'user_id'),
+ Index('idx_logs_created', 'created_at'),
+ )
+
+ id = Column(BigInteger, primary_key=True, autoincrement=True, comment='日志ID')
+ user_id = Column(Integer, ForeignKey('users.id', ondelete='SET NULL'), comment='用户ID')
+ type = Column(Enum('access', 'error', 'behavior', 'system',
+ name='logs_type_enum'), nullable=False, comment='日志类型')
+ content = Column(Text, nullable=False, comment='日志内容')
+ ip = Column(String(45), nullable=True, comment='IP地址')
+ created_at = Column(TIMESTAMP, server_default=func.current_timestamp(),
+ nullable=True, comment='记录时间')
diff --git a/Merge/front/package.json b/Merge/front/package.json
index f394aac..31c1d3e 100644
--- a/Merge/front/package.json
+++ b/Merge/front/package.json
@@ -13,7 +13,8 @@
"react-scripts": "^5.0.1",
"web-vitals": "^2.1.4",
"lucide-react": "^0.468.0",
- "antd": "^4.24.0"
+ "antd": "^4.24.0",
+ "crypto-js": "^4.2.0"
},
"scripts": {
"start": "react-scripts start",
diff --git a/Merge/front/src/components/LogoutButton.js b/Merge/front/src/components/LogoutButton.js
new file mode 100644
index 0000000..a10e716
--- /dev/null
+++ b/Merge/front/src/components/LogoutButton.js
@@ -0,0 +1,77 @@
+import React from 'react';
+import { Button, Modal } from 'antd';
+import { LogoutOutlined } from '@ant-design/icons';
+import { clearAuthInfo, getUserInfo } from '../utils/auth';
+
+const LogoutButton = ({ style = {}, onLogout = null }) => {
+ const userInfo = getUserInfo();
+
+ const handleLogout = () => {
+ Modal.confirm({
+ title: '确认退出',
+ content: '您确定要退出登录吗?',
+ okText: '确定',
+ cancelText: '取消',
+ onOk: () => {
+ // 清除认证信息,但保留记住的登录信息
+ clearAuthInfo(false);
+
+ // 执行回调函数
+ if (onLogout) {
+ onLogout();
+ } else {
+ // 默认跳转到登录页
+ window.location.href = '/';
+ }
+ }
+ });
+ };
+
+ const handleCompleteLogout = () => {
+ Modal.confirm({
+ title: '完全退出',
+ content: '这将清除所有保存的登录信息,包括"记住我"的设置。确定要继续吗?',
+ okText: '确定',
+ cancelText: '取消',
+ onOk: () => {
+ // 清除所有认证信息,包括记住的登录信息
+ clearAuthInfo(true);
+
+ // 执行回调函数
+ if (onLogout) {
+ onLogout();
+ } else {
+ // 默认跳转到登录页
+ window.location.href = '/';
+ }
+ }
+ });
+ };
+
+ if (!userInfo) {
+ return null;
+ }
+
+ return (
+ <div style={style}>
+ <Button
+ type="default"
+ icon={<LogoutOutlined />}
+ onClick={handleLogout}
+ style={{ marginRight: 8 }}
+ >
+ 退出登录
+ </Button>
+ <Button
+ type="link"
+ size="small"
+ onClick={handleCompleteLogout}
+ style={{ color: '#ff4d4f' }}
+ >
+ 完全退出
+ </Button>
+ </div>
+ );
+};
+
+export default LogoutButton;
diff --git a/Merge/front/src/pages/ForgotPasswordPage/ForgotPasswordPage.css b/Merge/front/src/pages/ForgotPasswordPage/ForgotPasswordPage.css
new file mode 100644
index 0000000..6af35e6
--- /dev/null
+++ b/Merge/front/src/pages/ForgotPasswordPage/ForgotPasswordPage.css
@@ -0,0 +1,915 @@
+/* 忘记密码页面 - 继承注册页面样式 */
+
+/* 导入注册页面的所有样式 */
+@import url('../RegisterPage/RegisterPage.css');
+
+/* 小红书风格忘记密码卡片 */
+.forgot-password-card {
+ background: #fff;
+ border-radius: 8px;
+ padding: 40px; /* 增加桌面端内边距 */
+ box-shadow: 0 1px 4px rgba(0, 0, 0, 0.1);
+ border: 1px solid #e1e1e1;
+ width: 100%;
+ max-width: 450px; /* 增加桌面端卡片最大宽度 */
+ transition: none;
+}
+
+/* 忘记密码头部 */
+.forgot-password-header {
+ text-align: center;
+ margin-bottom: 40px;
+}
+
+/* Logo样式 */
+.logo-section {
+ margin-bottom: 24px;
+}
+
+.logo-icon {
+ width: 60px;
+ height: 60px;
+ background: #ff2442;
+ border-radius: 12px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 28px;
+ margin: 0 auto 16px;
+ box-shadow: 0 4px 12px rgba(255, 36, 66, 0.2);
+}
+
+.forgot-password-title {
+ font-size: 24px;
+ font-weight: 600;
+ color: #333;
+ margin: 0 0 12px 0;
+ text-align: center;
+}
+
+.forgot-password-title::after {
+ display: none;
+}
+
+.forgot-password-subtitle {
+ font-size: 14px;
+ color: #999;
+ margin: 0 0 32px 0;
+ font-weight: 400;
+ text-align: center;
+}
+
+/* 表单样式 */
+.forgot-password-form {
+ display: flex;
+ flex-direction: column;
+ gap: 20px;
+ width: 100%;
+}
+
+.form-group {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+ width: 100%;
+ box-sizing: border-box;
+ margin-bottom: 2px;
+ position: relative; /* 为绝对定位的错误提示提供参考点 */
+}
+
+.form-group .input-wrapper {
+ width: 100%;
+ box-sizing: border-box;
+}
+
+.form-label {
+ font-size: 14px;
+ font-weight: 500;
+ color: #333;
+ margin-bottom: 8px;
+}
+
+.input-wrapper {
+ position: relative;
+ display: flex;
+ align-items: center;
+ width: 100%;
+ box-sizing: border-box;
+}
+
+.form-input {
+ width: 100% !important;
+ height: 44px;
+ padding: 12px 16px 12px 48px;
+ border: 1px solid #e1e1e1;
+ border-radius: 6px;
+ font-size: 14px;
+ transition: border-color 0.2s ease;
+ background: #fff;
+ color: #333;
+ box-sizing: border-box !important;
+ flex: 1;
+ min-width: 0;
+}
+
+/* 针对 Antd Input 组件的特定样式 */
+.form-input.ant-input,
+.form-input.ant-input-affix-wrapper {
+ width: 100% !important;
+ height: 44px !important;
+ border: 1px solid #e1e1e1 !important;
+ border-radius: 6px !important;
+ padding: 12px 48px 12px 48px !important;
+ font-size: 14px !important;
+ background: #fff !important;
+ color: #333 !important;
+ box-sizing: border-box !important;
+ flex: 1 !important;
+ min-width: 0 !important;
+ display: flex !important;
+ align-items: center !important;
+}
+
+.form-input.ant-input-affix-wrapper .ant-input {
+ width: 100% !important;
+ height: 100% !important;
+ padding: 0 !important;
+ border: none !important;
+ background: transparent !important;
+ flex: 1 !important;
+ min-width: 0 !important;
+ box-sizing: border-box !important;
+}
+
+.form-input:focus {
+ outline: none;
+ border-color: #ff2442;
+ box-shadow: none;
+ transform: none;
+}
+
+/* Antd Input focus 样式 */
+.form-input.ant-input:focus,
+.form-input.ant-input-affix-wrapper:focus,
+.form-input.ant-input-affix-wrapper-focused {
+ outline: none !important;
+ border-color: #ff2442 !important;
+ box-shadow: none !important;
+ transform: none !important;
+}
+
+.form-input::placeholder {
+ color: #9ca3af;
+}
+
+.input-icon {
+ position: absolute;
+ left: 16px;
+ top: 50%;
+ transform: translateY(-50%);
+ color: #9ca3af;
+ pointer-events: none;
+ transition: color 0.3s ease;
+ z-index: 2;
+}
+
+.form-input:focus + .input-icon {
+ color: #ff2442;
+}
+
+/* 邮箱验证码输入框容器 */
+.email-code-wrapper {
+ display: flex;
+ gap: 8px;
+ width: 100%;
+ box-sizing: border-box;
+ align-items: flex-start;
+}
+
+.email-code-input {
+ flex: 1;
+ min-width: 0;
+}
+
+.send-code-button {
+ height: 44px !important;
+ padding: 0 16px !important;
+ background: #ff2442 !important;
+ border-color: #ff2442 !important;
+ border-radius: 6px !important;
+ font-size: 14px !important;
+ font-weight: 500 !important;
+ white-space: nowrap !important;
+ flex-shrink: 0 !important;
+ min-width: 100px !important;
+ display: flex !important;
+ align-items: center !important;
+ justify-content: center !important;
+ transition: all 0.2s ease !important;
+ box-sizing: border-box !important;
+}
+
+.send-code-button:hover:not(:disabled) {
+ background: #d91e3a !important;
+ border-color: #d91e3a !important;
+ transform: none !important;
+ box-shadow: none !important;
+}
+
+.send-code-button:disabled {
+ background: #f5f5f5 !important;
+ border-color: #d9d9d9 !important;
+ color: #bfbfbf !important;
+ cursor: not-allowed !important;
+}
+
+.send-code-button.ant-btn-loading {
+ background: #ff2442 !important;
+ border-color: #ff2442 !important;
+ color: white !important;
+}
+
+/* 小红书风格忘记密码按钮 */
+.forgot-password-button {
+ width: 100%;
+ height: 48px; /* 固定高度,防止布局变化 */
+ padding: 12px;
+ background: #ff2442;
+ color: white;
+ border: none;
+ border-radius: 6px;
+ font-size: 14px;
+ font-weight: 500;
+ cursor: pointer;
+ transition: background-color 0.2s ease;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 8px;
+ margin-top: 8px;
+ position: relative; /* 为绝对定位的加载状态做准备 */
+ box-sizing: border-box; /* 确保padding包含在总尺寸内 */
+ min-width: 0; /* 防止flex子元素造成宽度变化 */
+}
+
+.forgot-password-button:hover:not(:disabled) {
+ background: #d91e3a;
+ transform: none;
+ box-shadow: none;
+}
+
+.forgot-password-button:active:not(:disabled) {
+ transform: none;
+}
+
+.forgot-password-button:disabled {
+ background: #ccc;
+ cursor: not-allowed;
+ opacity: 0.8;
+}
+
+.forgot-password-button.loading {
+ background: #ff7b8a;
+ cursor: not-allowed;
+}
+
+.loading-spinner {
+ width: 16px;
+ height: 16px;
+ border: 2px solid rgba(255, 255, 255, 0.3);
+ border-radius: 50%;
+ border-top-color: #fff;
+ animation: spin 1s ease-in-out infinite;
+}
+
+@keyframes spin {
+ to { transform: rotate(360deg); }
+}
+
+/* 加载遮罩层 */
+.loading-overlay {
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background: rgba(0, 0, 0, 0.5);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ z-index: 1000;
+ backdrop-filter: blur(4px);
+}
+
+.loading-content {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 16px;
+ background: white;
+ padding: 32px;
+ border-radius: 12px;
+ box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
+}
+
+.loading-spinner-large {
+ width: 40px;
+ height: 40px;
+ border: 4px solid rgba(255, 36, 66, 0.2);
+ border-radius: 50%;
+ border-top-color: #ff2442;
+ animation: spin 1s ease-in-out infinite;
+}
+
+.loading-text {
+ margin: 0;
+ color: #333;
+ font-size: 16px;
+ font-weight: 500;
+}
+
+/* 登录链接 */
+.login-link {
+ text-align: center;
+ margin-top: 20px;
+ padding-top: 20px;
+ border-top: 1px solid #e5e7eb;
+}
+
+.login-link p {
+ margin: 0 0 8px 0;
+ font-size: 14px;
+ color: #64748b;
+}
+
+.login-link p:last-child {
+ margin-bottom: 0;
+}
+
+.login-link a {
+ color: #ff2442;
+ text-decoration: none;
+ font-weight: 500;
+ transition: color 0.2s ease;
+}
+
+.login-link a:hover {
+ color: #d91e3a;
+ text-decoration: underline;
+}
+
+/* 返回邮箱输入样式 */
+.back-to-email {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ padding: 12px 16px;
+ background: #f8f8f8;
+ border-radius: 6px;
+ border: 1px solid #e1e1e1;
+}
+
+.back-button {
+ background: none;
+ border: none;
+ color: #ff2442;
+ font-size: 14px;
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ padding: 0;
+ transition: color 0.2s ease;
+ width: fit-content;
+}
+
+.back-button:hover {
+ color: #d91e3a;
+}
+
+.email-display {
+ font-size: 14px;
+ color: #666;
+ font-weight: 500;
+}
+
+/* 有左侧图标时的内边距调整 */
+.input-wrapper.has-icon .form-input {
+ padding-left: 48px !important;
+}
+
+.input-wrapper.has-icon .form-input.ant-input,
+.input-wrapper.has-icon .form-input.ant-input-affix-wrapper {
+ padding-left: 48px !important;
+}
+
+/* 有右侧切换按钮时的内边距调整 */
+.input-wrapper.has-toggle .form-input {
+ padding-right: 48px !important;
+}
+
+.input-wrapper.has-toggle .form-input.ant-input,
+.input-wrapper.has-toggle .form-input.ant-input-affix-wrapper {
+ padding-right: 48px !important;
+}
+
+/* 没有图标时的内边距调整 */
+.input-wrapper:not(.has-icon) .form-input {
+ padding-left: 16px !important;
+}
+
+.input-wrapper:not(.has-icon) .form-input.ant-input,
+.input-wrapper:not(.has-icon) .form-input.ant-input-affix-wrapper {
+ padding-left: 16px !important;
+}
+
+/* 没有切换按钮时的内边距调整 */
+.input-wrapper:not(.has-toggle) .form-input {
+ padding-right: 16px !important;
+}
+
+.input-wrapper:not(.has-toggle) .form-input.ant-input,
+.input-wrapper:not(.has-toggle) .form-input.ant-input-affix-wrapper {
+ padding-right: 16px !important;
+}
+
+/* 确保输入框内容完全填充 */
+.form-input.ant-input-affix-wrapper .ant-input-suffix {
+ position: absolute !important;
+ right: 12px !important;
+ top: 50% !important;
+ transform: translateY(-50%) !important;
+ margin: 0 !important;
+ padding: 0 !important;
+}
+
+.form-input.ant-input-affix-wrapper .ant-input-prefix {
+ position: absolute !important;
+ left: 16px !important;
+ top: 50% !important;
+ transform: translateY(-50%) !important;
+ margin: 0 !important;
+ padding: 0 !important;
+}
+
+/* 确保所有输入框完全填充其容器 */
+.form-group {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ width: 100%;
+ box-sizing: border-box;
+}
+
+.form-group .input-wrapper {
+ width: 100%;
+ box-sizing: border-box;
+}
+
+/* 防止输入框溢出容器 */
+.form-input,
+.form-input.ant-input,
+.form-input.ant-input-affix-wrapper {
+ max-width: 100% !important;
+ overflow: hidden !important;
+}
+
+/* 确保内部输入元素不会超出边界 */
+.form-input.ant-input-affix-wrapper .ant-input {
+ max-width: 100% !important;
+ overflow: hidden !important;
+ text-overflow: ellipsis !important;
+}
+
+/* 精细间距控制 */
+.forgot-password-header + .forgot-password-form {
+ margin-top: -4px;
+}
+
+.forgot-password-form .form-group:not(:last-child) {
+ margin-bottom: 2px;
+}
+
+.forgot-password-form .form-group:last-of-type {
+ margin-bottom: 6px;
+}
+
+.forgot-password-button + .login-link {
+ margin-top: 14px;
+}
+
+/* 响应式设计 */
+@media (max-width: 768px) {
+ /* 重置body和html确保一致性 */
+ html, body {
+ height: 100%;
+ height: 100dvh;
+ margin: 0;
+ padding: 0;
+ overflow-x: hidden;
+ box-sizing: border-box;
+ }
+
+ .forgot-password-container {
+ padding: 16px;
+ align-items: center;
+ justify-content: center;
+ min-height: 100vh;
+ min-height: 100dvh; /* 动态视口高度 */
+ /* 强制重置可能影响定位的样式 */
+ margin: 0;
+ box-sizing: border-box;
+ /* 防止内容溢出影响布局 */
+ overflow-x: hidden;
+ overflow-y: auto;
+ /* 确保flexbox在所有移动设备上表现一致 */
+ -webkit-box-align: center;
+ -webkit-box-pack: center;
+ display: flex !important;
+ position: relative;
+ }
+
+ .forgot-password-content {
+ max-width: 100%;
+ padding: 20px;
+ /* 确保内容区域稳定 */
+ margin: 0 auto;
+ box-sizing: border-box;
+ /* 防止宽度计算问题 */
+ width: calc(100% - 40px);
+ max-width: 480px; /* 增加最大宽度 */
+ position: relative;
+ display: flex;
+ justify-content: center;
+ }
+
+ .forgot-password-card {
+ padding: 32px 28px; /* 增加内边距 */
+ border-radius: 16px;
+ /* 确保卡片稳定定位 */
+ margin: 0;
+ box-sizing: border-box;
+ width: 100%;
+ max-width: 450px; /* 增加卡片最大宽度 */
+ /* 防止backdrop-filter导致的渲染差异 */
+ will-change: auto;
+ position: relative;
+ /* 防止触摸操作影响布局 */
+ -webkit-touch-callout: none;
+ -webkit-user-select: none;
+ -moz-user-select: none;
+ -ms-user-select: none;
+ user-select: none;
+ -webkit-tap-highlight-color: transparent;
+ }
+
+ .forgot-password-title {
+ font-size: 24px;
+ }
+
+ .form-input {
+ width: 100% !important;
+ height: 44px !important;
+ padding: 12px 48px 12px 48px;
+ font-size: 16px; /* 防止iOS Safari缩放 */
+ box-sizing: border-box !important;
+ flex: 1 !important;
+ min-width: 0 !important;
+ }
+
+ .form-input.ant-input,
+ .form-input.ant-input-affix-wrapper {
+ width: 100% !important;
+ height: 44px !important;
+ padding: 12px 48px 12px 48px !important;
+ font-size: 16px !important;
+ box-sizing: border-box !important;
+ flex: 1 !important;
+ min-width: 0 !important;
+ }
+
+ .form-input.ant-input-affix-wrapper .ant-input {
+ width: 100% !important;
+ height: 100% !important;
+ padding: 0 !important;
+ border: none !important;
+ background: transparent !important;
+ flex: 1 !important;
+ min-width: 0 !important;
+ box-sizing: border-box !important;
+ }
+}
+
+@media (max-width: 480px) {
+ .forgot-password-container {
+ padding: 16px;
+ align-items: center;
+ justify-content: center;
+ min-height: 100vh;
+ min-height: 100dvh; /* 动态视口高度 */
+ /* 强制重置样式 */
+ margin: 0;
+ box-sizing: border-box;
+ position: relative;
+ /* 确保垂直居中 */
+ display: flex !important;
+ }
+
+ .forgot-password-content {
+ /* 更严格的尺寸控制 */
+ width: calc(100vw - 32px);
+ max-width: 420px; /* 增加最大宽度 */
+ padding: 16px;
+ margin: 0 auto;
+ box-sizing: border-box;
+ display: flex;
+ justify-content: center;
+ }
+
+ .forgot-password-card {
+ padding: 28px 24px; /* 增加内边距 */
+ border-radius: 12px;
+ /* 确保卡片完全稳定 */
+ margin: 0;
+ box-sizing: border-box;
+ width: 100%;
+ position: relative;
+ /* 防止变换导致的位置偏移 */
+ transform: none !important;
+ /* 防止触摸操作影响布局 */
+ -webkit-touch-callout: none;
+ -webkit-user-select: none;
+ -khtml-user-select: none;
+ -moz-user-select: none;
+ -ms-user-select: none;
+ user-select: none;
+ /* 防止点击时的高亮效果影响布局 */
+ -webkit-tap-highlight-color: transparent;
+ }
+
+ .forgot-password-title {
+ font-size: 22px;
+ }
+
+ .form-input {
+ width: 100% !important;
+ height: 44px !important;
+ padding: 12px 48px 12px 48px;
+ font-size: 16px; /* 防止iOS Safari缩放 */
+ box-sizing: border-box !important;
+ flex: 1 !important;
+ min-width: 0 !important;
+ }
+
+ .form-input.ant-input,
+ .form-input.ant-input-affix-wrapper {
+ width: 100% !important;
+ height: 44px !important;
+ padding: 12px 48px 12px 48px !important;
+ font-size: 16px !important;
+ box-sizing: border-box !important;
+ flex: 1 !important;
+ min-width: 0 !important;
+ }
+
+ .form-input.ant-input-affix-wrapper .ant-input {
+ width: 100% !important;
+ height: 100% !important;
+ padding: 0 !important;
+ border: none !important;
+ background: transparent !important;
+ flex: 1 !important;
+ min-width: 0 !important;
+ box-sizing: border-box !important;
+ }
+
+ /* 移动端优化 */
+ .background-pattern {
+ display: none;
+ }
+
+ /* 禁用可能影响位置的悬停效果 */
+ .forgot-password-card:hover {
+ transform: none !important;
+ }
+}
+
+/* 高对比度模式支持 */
+@media (prefers-contrast: high) {
+ .forgot-password-card {
+ background: white;
+ border: 2px solid #000;
+ }
+
+ .form-input {
+ border-color: #000;
+ }
+
+ .form-input:focus {
+ border-color: #0066cc;
+ box-shadow: 0 0 0 2px #0066cc;
+ }
+}
+
+/* 减少动画模式 */
+@media (prefers-reduced-motion: reduce) {
+ .background-pattern {
+ animation: none;
+ }
+
+ .forgot-password-card,
+ .form-input,
+ .forgot-password-button {
+ transition: none;
+ }
+}
+
+/* 深色模式支持 */
+@media (prefers-color-scheme: dark) {
+ .forgot-password-background {
+ background: linear-gradient(135deg, #1a202c 0%, #2d3748 100%);
+ }
+
+ .forgot-password-card {
+ background: rgba(26, 32, 44, 0.95);
+ border-color: rgba(255, 255, 255, 0.1);
+ }
+
+ .forgot-password-title {
+ color: #f7fafc;
+ }
+
+ .forgot-password-subtitle {
+ color: #a0aec0;
+ }
+
+ .form-label {
+ color: #e2e8f0;
+ }
+
+ .form-input {
+ background: #2d3748;
+ border-color: #4a5568;
+ color: #f7fafc;
+ }
+
+ .form-input:focus {
+ border-color: #ff2442;
+ }
+
+ .login-link {
+ border-color: #4a5568;
+ }
+
+ .login-link p {
+ color: #a0aec0;
+ }
+
+ /* 深色模式下的错误提示样式 */
+ .error-message {
+ background: rgba(26, 32, 44, 0.95);
+ color: #ff6b6b;
+ }
+
+ .back-to-email {
+ background: #2d3748;
+ border-color: #4a5568;
+ }
+
+ .email-display {
+ color: #a0aec0;
+ }
+}
+
+/* 错误提示样式 - 使用绝对定位避免影响布局 */
+.error-message {
+ position: absolute;
+ top: 95%;
+ left: 4px;
+ right: 4px;
+ font-size: 12px;
+ color: #ff4d4f;
+ margin-top: 4px;
+ display: flex;
+ align-items: center;
+ min-height: 16px;
+ animation: fadeInDown 0.3s ease-out;
+ font-weight: 400;
+ line-height: 1.2;
+ background: rgba(255, 255, 255, 0.95);
+ backdrop-filter: blur(4px);
+ padding: 2px 4px;
+ border-radius: 4px;
+ z-index: 10;
+ pointer-events: none; /* 避免干扰用户交互 */
+}
+
+@keyframes fadeInDown {
+ from {
+ opacity: 0;
+ transform: translateY(-8px);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
+
+/* 输入框错误状态样式 */
+.form-input.input-error,
+.form-input.input-error.ant-input,
+.form-input.input-error.ant-input-affix-wrapper {
+ border-color: #ff4d4f !important;
+ box-shadow: 0 0 0 2px rgba(255, 77, 79, 0.1) !important;
+ transition: all 0.3s ease !important;
+}
+
+.form-input.input-error:focus,
+.form-input.input-error.ant-input:focus,
+.form-input.input-error.ant-input-affix-wrapper:focus,
+.form-input.input-error.ant-input-affix-wrapper-focused {
+ border-color: #ff4d4f !important;
+ box-shadow: 0 0 0 2px rgba(255, 77, 79, 0.2) !important;
+}
+
+/* 错误状态下的图标颜色 */
+.form-input.input-error .anticon {
+ color: #ff4d4f !important;
+}
+
+/* 确保表单组间距一致 */
+.form-group {
+ margin-bottom: 0px;
+}
+
+.form-group:last-of-type {
+ margin-bottom: 0px;
+}
+
+/* 错误弹窗样式 */
+.error-modal .ant-modal-header {
+ background: #fff;
+ border-bottom: 1px solid #f0f0f0;
+ padding: 16px 24px;
+}
+
+.error-modal .ant-modal-title {
+ color: #333;
+ font-weight: 600;
+ font-size: 16px;
+}
+
+.error-modal .ant-modal-body {
+ padding: 16px 24px 24px;
+}
+
+.error-modal .ant-modal-footer {
+ padding: 12px 24px 24px;
+ border-top: none;
+ text-align: center;
+}
+
+.error-modal .ant-btn-primary {
+ background: #ff2442;
+ border-color: #ff2442;
+ font-weight: 500;
+ height: 40px;
+ padding: 0 24px;
+ border-radius: 6px;
+ transition: all 0.2s ease;
+}
+
+.error-modal .ant-btn-primary:hover {
+ background: #d91e3a;
+ border-color: #d91e3a;
+ transform: translateY(-1px);
+ box-shadow: 0 4px 12px rgba(255, 36, 66, 0.3);
+}
+
+.error-modal .ant-modal-content {
+ border-radius: 12px;
+ overflow: hidden;
+ box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15);
+}
+
+/* 错误弹窗遮罩层 */
+.error-modal .ant-modal-mask {
+ background: rgba(0, 0, 0, 0.6);
+ backdrop-filter: blur(4px);
+}
+
+/* 错误弹窗动画 */
+.error-modal .ant-modal {
+ animation: errorModalSlideIn 0.3s ease-out;
+}
+
+@keyframes errorModalSlideIn {
+ from {
+ opacity: 0;
+ transform: translateY(-20px) scale(0.95);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0) scale(1);
+ }
+}
diff --git a/Merge/front/src/pages/ForgotPasswordPage/ForgotPasswordPage.js b/Merge/front/src/pages/ForgotPasswordPage/ForgotPasswordPage.js
new file mode 100644
index 0000000..d0437d5
--- /dev/null
+++ b/Merge/front/src/pages/ForgotPasswordPage/ForgotPasswordPage.js
@@ -0,0 +1,567 @@
+import React, { useState } from 'react';
+import { Link, useNavigate } from 'react-router-dom';
+import { Input, Button, message, Modal, Alert } from 'antd';
+import { MailOutlined, LockOutlined, SafetyOutlined, ExclamationCircleOutlined, CheckCircleOutlined } from '@ant-design/icons';
+import { hashPassword } from '../../utils/crypto';
+import './ForgotPasswordPage.css';
+
+const baseURL = 'http://10.126.59.25:8082';
+
+const ForgotPasswordPage = () => {
+ const [formData, setFormData] = useState({
+ email: '',
+ emailCode: '',
+ newPassword: '',
+ confirmPassword: ''
+ });
+
+ const [errors, setErrors] = useState({
+ email: '',
+ emailCode: '',
+ newPassword: '',
+ confirmPassword: ''
+ });
+
+ const [emailCodeSent, setEmailCodeSent] = useState(false);
+ const [countdown, setCountdown] = useState(0);
+ const [sendingCode, setSendingCode] = useState(false);
+ const [isLoading, setIsLoading] = useState(false);
+ const [errorModal, setErrorModal] = useState({
+ visible: false,
+ title: '',
+ content: ''
+ });
+ const [successAlert, setSuccessAlert] = useState({
+ visible: false,
+ message: ''
+ });
+ const [emailCodeSuccessAlert, setEmailCodeSuccessAlert] = useState({
+ visible: false,
+ message: ''
+ });
+
+ const navigate = useNavigate();
+
+ // 显示错误弹窗
+ const showErrorModal = (title, content) => {
+ setErrorModal({
+ visible: true,
+ title: title,
+ content: content
+ });
+ };
+
+ // 关闭错误弹窗
+ const closeErrorModal = () => {
+ setErrorModal({
+ visible: false,
+ title: '',
+ content: ''
+ });
+ };
+
+ // 显示成功提示
+ const showSuccessAlert = (message) => {
+ setSuccessAlert({
+ visible: true,
+ message: message
+ });
+
+ // 3秒后自动隐藏
+ setTimeout(() => {
+ setSuccessAlert({
+ visible: false,
+ message: ''
+ });
+ }, 3000);
+ };
+
+ // 显示邮件验证码发送成功提示
+ const showEmailCodeSuccessAlert = (message) => {
+ setEmailCodeSuccessAlert({
+ visible: true,
+ message: message
+ });
+
+ // 5秒后自动隐藏
+ setTimeout(() => {
+ setEmailCodeSuccessAlert({
+ visible: false,
+ message: ''
+ });
+ }, 5000);
+ };
+
+ // 倒计时效果
+ React.useEffect(() => {
+ let timer;
+ if (countdown > 0) {
+ timer = setTimeout(() => {
+ setCountdown(countdown - 1);
+ }, 1000);
+ }
+ return () => clearTimeout(timer);
+ }, [countdown]);
+
+ // 发送邮箱验证码
+ const sendEmailCode = async () => {
+ // 验证邮箱格式
+ if (!formData.email || typeof formData.email !== 'string' || !formData.email.trim()) {
+ setErrors(prev => ({
+ ...prev,
+ email: '请先输入邮箱地址'
+ }));
+ return;
+ }
+
+ if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email)) {
+ setErrors(prev => ({
+ ...prev,
+ email: '请输入有效的邮箱地址'
+ }));
+ return;
+ }
+
+ setSendingCode(true);
+
+ try {
+ // 调用后端API发送验证码
+ const response = await fetch(baseURL + '/send-verification-code', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({
+ email: formData.email,
+ type: 'reset_password'
+ })
+ });
+
+ if (!response.ok) {
+ throw new Error(`HTTP ${response.status}: ${response.statusText}`);
+ }
+
+ const result = await response.json();
+
+ if (result.success) {
+ showEmailCodeSuccessAlert('验证码已发送到您的邮箱');
+ setEmailCodeSent(true);
+ setCountdown(60); // 60秒倒计时
+
+ // 清除邮箱错误提示
+ setErrors(prev => ({
+ ...prev,
+ email: ''
+ }));
+ } else {
+ // 根据具体错误信息进行处理
+ const errorMessage = result.message || '发送验证码失败,请稍后再试';
+
+ if (errorMessage.includes('用户不存在') || errorMessage.includes('邮箱未注册')) {
+ setErrors(prev => ({
+ ...prev,
+ email: '该邮箱尚未注册,请检查邮箱地址或先注册账户'
+ }));
+ } else {
+ showErrorModal('发送验证码失败', errorMessage);
+ }
+ }
+
+ } catch (error) {
+ console.error('发送验证码失败:', error);
+
+ // 根据错误类型显示不同的错误信息
+ if (error.name === 'TypeError' && error.message.includes('Failed to fetch')) {
+ showErrorModal('网络连接失败', '无法连接到服务器,请检查您的网络连接后重试。');
+ } else if (error.message.includes('HTTP 500')) {
+ showErrorModal('服务器错误', '服务器出现了内部错误,请稍后重试。');
+ } else if (error.message.includes('HTTP 429')) {
+ showErrorModal('发送频率限制', '验证码发送过于频繁,请稍后再试。');
+ } else if (error.message.includes('HTTP 400')) {
+ showErrorModal('请求错误', '邮箱格式错误,请检查邮箱地址是否正确。');
+ } else {
+ showErrorModal('发送失败', '发送验证码失败,请稍后重试。');
+ }
+ } finally {
+ setSendingCode(false);
+ }
+ };
+
+ const handleInputChange = (field) => (e) => {
+ const value = e.target.value;
+ setFormData(prev => ({
+ ...prev,
+ [field]: value
+ }));
+
+ // 清除对应字段的错误提示
+ if (errors[field]) {
+ setErrors(prev => ({
+ ...prev,
+ [field]: ''
+ }));
+ }
+ };
+
+ const handlePasswordChange = (e) => {
+ const value = e.target.value;
+ setFormData(prev => ({
+ ...prev,
+ newPassword: value
+ }));
+
+ // 清除密码错误提示
+ if (errors.newPassword) {
+ setErrors(prev => ({
+ ...prev,
+ newPassword: ''
+ }));
+ }
+ };
+
+ const handleConfirmPasswordChange = (e) => {
+ const value = e.target.value;
+ setFormData(prev => ({
+ ...prev,
+ confirmPassword: value
+ }));
+
+ // 清除确认密码错误提示
+ if (errors.confirmPassword) {
+ setErrors(prev => ({
+ ...prev,
+ confirmPassword: ''
+ }));
+ }
+ };
+
+ const validateForm = () => {
+ const newErrors = {
+ email: '',
+ emailCode: '',
+ newPassword: '',
+ confirmPassword: ''
+ };
+
+ let hasError = false;
+
+ // 验证邮箱
+ if (!formData.email || typeof formData.email !== 'string' || !formData.email.trim()) {
+ newErrors.email = '请输入邮箱地址';
+ hasError = true;
+ } else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email)) {
+ newErrors.email = '请输入有效的邮箱地址';
+ hasError = true;
+ }
+
+ // 验证邮箱验证码
+ if (!formData.emailCode || typeof formData.emailCode !== 'string' || !formData.emailCode.trim()) {
+ newErrors.emailCode = '请输入邮箱验证码';
+ hasError = true;
+ } else if (formData.emailCode.length !== 6 || !/^\d{6}$/.test(formData.emailCode)) {
+ newErrors.emailCode = '请输入6位数字验证码';
+ hasError = true;
+ }
+
+ // 验证新密码
+ if (!formData.newPassword || typeof formData.newPassword !== 'string' || !formData.newPassword.trim()) {
+ newErrors.newPassword = '请输入新密码';
+ hasError = true;
+ } else if (formData.newPassword.length < 6) {
+ newErrors.newPassword = '密码长度至少6位';
+ hasError = true;
+ } else if (formData.newPassword.length > 20) {
+ newErrors.newPassword = '密码长度不能超过20位';
+ hasError = true;
+ }
+
+ // 验证确认密码
+ if (!formData.confirmPassword || typeof formData.confirmPassword !== 'string' || !formData.confirmPassword.trim()) {
+ newErrors.confirmPassword = '请确认新密码';
+ hasError = true;
+ } else if (formData.newPassword !== formData.confirmPassword) {
+ newErrors.confirmPassword = '两次输入的密码不一致';
+ hasError = true;
+ }
+
+ setErrors(newErrors);
+ return !hasError;
+ };
+
+ const handleSubmit = async (e) => {
+ e.preventDefault();
+
+ // 验证表单
+ if (!validateForm()) {
+ return;
+ }
+
+ setIsLoading(true);
+
+ try {
+ // 验证码验证成功,重置密码
+ const resetResponse = await fetch(baseURL + '/reset-password', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({
+ email: formData.email,
+ new_password: hashPassword(formData.newPassword), // 前端加密密码
+ verification_code: hashPassword(formData.emailCode) // 前端加密验证码
+ })
+ });
+
+ if (!resetResponse.ok) {
+ throw new Error(`HTTP ${resetResponse.status}: ${resetResponse.statusText}`);
+ }
+
+ const resetResult = await resetResponse.json();
+
+ if (resetResult.success) {
+ showSuccessAlert('密码重置成功!请使用新密码登录,正在跳转到登录页面...');
+ // 清空表单数据
+ setFormData({
+ email: '',
+ emailCode: '',
+ newPassword: '',
+ confirmPassword: ''
+ });
+ setErrors({
+ email: '',
+ emailCode: '',
+ newPassword: '',
+ confirmPassword: ''
+ });
+ // 延迟跳转到登录页面,让用户看到成功提示
+ setTimeout(() => {
+ navigate('/login');
+ }, 2000);
+ } else {
+ // 处理重置密码失败的情况
+ const errorMessage = resetResult.message || '密码重置失败,请稍后再试';
+ showErrorModal('密码重置失败', errorMessage);
+ }
+
+ } catch (error) {
+ console.error('密码重置失败:', error);
+
+ // 根据错误类型显示不同的错误信息
+ if (error.name === 'TypeError' && error.message.includes('Failed to fetch')) {
+ showErrorModal('网络连接失败', '无法连接到服务器,请检查您的网络连接后重试。');
+ } else if (error.message.includes('HTTP 500')) {
+ showErrorModal('服务器内部错误', '服务器出现了内部错误,请稍后重试。');
+ } else if (error.message.includes('HTTP 400')) {
+ showErrorModal('请求参数错误', '请求参数有误,请检查您输入的信息是否正确。');
+ } else if (error.message.includes('HTTP 409')) {
+ showErrorModal('操作冲突', '重置操作发生冲突,请稍后重试。');
+ } else {
+ showErrorModal('重置失败', '密码重置失败,请稍后重试。');
+ }
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ return (
+ <div className="register-container">
+ <div className="register-background"></div>
+
+ {isLoading && (
+ <div className="loading-overlay">
+ <div className="loading-content">
+ <div className="loading-spinner-large"></div>
+ <p className="loading-text">正在重置密码...</p>
+ </div>
+ </div>
+ )}
+
+ <div className="register-content">
+ <div className="register-card">
+ {/* 成功提示 */}
+ {successAlert.visible && (
+ <div style={{ marginBottom: '16px' }}>
+ <Alert
+ message={successAlert.message}
+ type="success"
+ icon={<CheckCircleOutlined />}
+ showIcon
+ closable
+ onClose={() => setSuccessAlert({ visible: false, message: '' })}
+ style={{
+ borderRadius: '8px',
+ border: '1px solid #b7eb8f',
+ backgroundColor: '#f6ffed'
+ }}
+ />
+ </div>
+ )}
+
+ {/* 邮件验证码发送成功提示 */}
+ {emailCodeSuccessAlert.visible && (
+ <div style={{ marginBottom: '16px' }}>
+ <Alert
+ message={emailCodeSuccessAlert.message}
+ type="success"
+ icon={<CheckCircleOutlined />}
+ showIcon
+ closable
+ onClose={() => setEmailCodeSuccessAlert({ visible: false, message: '' })}
+ style={{
+ borderRadius: '8px',
+ border: '1px solid #b7eb8f',
+ backgroundColor: '#f6ffed'
+ }}
+ />
+ </div>
+ )}
+
+ <div className="register-header">
+ <h1 className="register-title">重置密码</h1>
+ <p className="register-subtitle">请输入邮箱地址和新密码</p>
+ </div>
+
+ <form className="register-form" onSubmit={handleSubmit}>
+ <div className="form-group">
+ <Input
+ type="email"
+ id="email"
+ name="email"
+ className={`form-input ${errors.email ? 'input-error' : ''}`}
+ placeholder="请输入邮箱地址"
+ value={formData.email}
+ onChange={handleInputChange('email')}
+ prefix={<MailOutlined />}
+ size="large"
+ title=""
+ status={errors.email ? 'error' : ''}
+ />
+ {errors.email && (
+ <div className="error-message">
+ {errors.email}
+ </div>
+ )}
+ </div>
+
+ <div className="form-group">
+ <div className="email-code-wrapper">
+ <Input
+ type="text"
+ id="emailCode"
+ name="emailCode"
+ className={`form-input email-code-input ${errors.emailCode ? 'input-error' : ''}`}
+ placeholder="请输入6位验证码"
+ value={formData.emailCode}
+ onChange={handleInputChange('emailCode')}
+ prefix={<SafetyOutlined />}
+ maxLength={6}
+ size="large"
+ title=""
+ status={errors.emailCode ? 'error' : ''}
+ />
+ <Button
+ type="primary"
+ className="send-code-button"
+ onClick={sendEmailCode}
+ loading={sendingCode}
+ disabled={countdown > 0 || !formData.email || sendingCode}
+ size="large"
+ >
+ {countdown > 0 ? `${countdown}s后重发` : (emailCodeSent ? '重新发送' : '发送验证码')}
+ </Button>
+ </div>
+ {errors.emailCode && (
+ <div className="error-message">
+ {errors.emailCode}
+ </div>
+ )}
+ </div>
+
+ <div className="form-group">
+ <Input.Password
+ id="newPassword"
+ name="newPassword"
+ className={`form-input ${errors.newPassword ? 'input-error' : ''}`}
+ placeholder="请输入新密码"
+ value={formData.newPassword}
+ onChange={handlePasswordChange}
+ prefix={<LockOutlined />}
+ size="large"
+ title=""
+ status={errors.newPassword ? 'error' : ''}
+ />
+ {errors.newPassword && (
+ <div className="error-message">
+ {errors.newPassword}
+ </div>
+ )}
+ </div>
+
+ <div className="form-group">
+ <Input.Password
+ id="confirmPassword"
+ name="confirmPassword"
+ className={`form-input ${errors.confirmPassword ? 'input-error' : ''}`}
+ placeholder="请确认新密码"
+ value={formData.confirmPassword}
+ onChange={handleConfirmPasswordChange}
+ prefix={<LockOutlined />}
+ size="large"
+ title=""
+ status={errors.confirmPassword ? 'error' : ''}
+ />
+ {errors.confirmPassword && (
+ <div className="error-message">
+ {errors.confirmPassword}
+ </div>
+ )}
+ </div>
+
+ <button
+ type="submit"
+ className={`register-button ${isLoading ? 'loading' : ''}`}
+ disabled={isLoading}
+ >
+ {isLoading ? (
+ <>
+ <div className="loading-spinner"></div>
+ 重置中...
+ </>
+ ) : (
+ '重置密码'
+ )}
+ </button>
+ </form>
+
+ <div className="login-link">
+ <p>想起密码了? <Link to="/login">立即登录</Link></p>
+ <p>还没有账户? <Link to="/register">立即注册</Link></p>
+ </div>
+ </div>
+ </div>
+
+ {/* 错误弹窗 */}
+ <Modal
+ title={
+ <div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
+ <ExclamationCircleOutlined style={{ color: '#ff4d4f', fontSize: '18px' }} />
+ {errorModal.title}
+ </div>
+ }
+ open={errorModal.visible}
+ onOk={closeErrorModal}
+ onCancel={closeErrorModal}
+ okText="我知道了"
+ cancelButtonProps={{ style: { display: 'none' } }}
+ centered
+ className="error-modal"
+ >
+ <div style={{ padding: '16px 0', fontSize: '14px', lineHeight: '1.6' }}>
+ {errorModal.content}
+ </div>
+ </Modal>
+ </div>
+ );
+};
+
+export default ForgotPasswordPage;
diff --git a/Merge/front/src/pages/LoginPage/LoginPage.css b/Merge/front/src/pages/LoginPage/LoginPage.css
new file mode 100644
index 0000000..ab1e24c
--- /dev/null
+++ b/Merge/front/src/pages/LoginPage/LoginPage.css
@@ -0,0 +1,1288 @@
+/* 登录页面容器 */
+.login-container {
+ min-height: 100vh;
+ min-height: 100dvh; /* 动态视口高度,避免移动端地址栏影响 */
+ height: 100vh;
+ height: 100dvh;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ position: relative;
+ overflow: hidden;
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
+ 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
+ sans-serif;
+ /* 确保容器稳定定位 */
+ box-sizing: border-box;
+ margin: 0;
+ padding: 0;
+ /* 重置文本对齐 */
+ text-align: initial;
+}
+
+/* 小红书风格背景 */
+.login-background {
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background: #f8f8f8;
+ z-index: -1;
+}
+
+/* 登录内容区域 */
+.login-content {
+ width: 100%;
+ max-width: 500px; /* 增加桌面端最大宽度 */
+ padding: 0;
+ z-index: 1;
+ /* 确保内容稳定定位 */
+ box-sizing: border-box;
+ position: relative;
+ display: flex;
+ justify-content: center;
+}
+
+/* 小红书风格登录卡片 */
+.login-card {
+ background: #fff;
+ border-radius: 8px;
+ padding: 40px; /* 增加桌面端内边距 */
+ box-shadow: 0 1px 4px rgba(0, 0, 0, 0.1);
+ border: 1px solid #e1e1e1;
+ width: 100%;
+ max-width: 450px; /* 增加桌面端卡片最大宽度 */
+ transition: none;
+}
+
+/* 登录头部 */
+.login-header {
+ text-align: center;
+ margin-bottom: 40px;
+}
+
+/* Logo样式 */
+.logo-section {
+ margin-bottom: 24px;
+}
+
+.logo-icon {
+ width: 60px;
+ height: 60px;
+ background: #ff2442;
+ border-radius: 12px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 28px;
+ margin: 0 auto 16px;
+ box-shadow: 0 4px 12px rgba(255, 36, 66, 0.2);
+}
+
+.login-title {
+ font-size: 24px;
+ font-weight: 600;
+ color: #333;
+ margin: 0 0 12px 0;
+ text-align: center;
+}
+
+.login-title::after {
+ display: none;
+}
+
+.login-subtitle {
+ font-size: 14px;
+ color: #999;
+ margin: 0 0 32px 0;
+ font-weight: 400;
+ text-align: center;
+}
+
+/* 表单样式 */
+.login-form {
+ display: flex;
+ flex-direction: column;
+ gap: 24px;
+ width: 100%;
+}
+
+.form-group {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+ width: 100%;
+ box-sizing: border-box;
+ margin-bottom: 2px;
+ position: relative; /* 为绝对定位的错误提示提供参考点 */
+}
+
+.form-group .input-wrapper {
+ width: 100%;
+ box-sizing: border-box;
+}
+
+.form-label {
+ font-size: 14px;
+ font-weight: 500;
+ color: #333;
+ margin-bottom: 8px;
+}
+
+.input-wrapper {
+ position: relative;
+ display: flex;
+ align-items: center;
+ width: 100%;
+ box-sizing: border-box;
+}
+
+.form-input {
+ width: 100% !important;
+ height: 44px;
+ padding: 12px 16px 12px 48px;
+ border: 1px solid #e1e1e1;
+ border-radius: 6px;
+ font-size: 14px;
+ transition: border-color 0.2s ease;
+ background: #fff;
+ color: #333;
+ box-sizing: border-box !important;
+ flex: 1;
+ min-width: 0;
+}
+
+/* 针对 Antd Input 组件的特定样式 */
+.form-input.ant-input,
+.form-input.ant-input-affix-wrapper {
+ width: 100% !important;
+ height: 44px !important;
+ border: 1px solid #e1e1e1 !important;
+ border-radius: 6px !important;
+ padding: 12px 48px 12px 48px !important;
+ font-size: 14px !important;
+ background: #fff !important;
+ color: #333 !important;
+ box-sizing: border-box !important;
+ flex: 1 !important;
+ min-width: 0 !important;
+ display: flex !important;
+ align-items: center !important;
+}
+
+.form-input.ant-input-affix-wrapper .ant-input {
+ width: 100% !important;
+ height: 100% !important;
+ padding: 0 !important;
+ border: none !important;
+ background: transparent !important;
+ flex: 1 !important;
+ min-width: 0 !important;
+ box-sizing: border-box !important;
+}
+
+.form-input:focus {
+ outline: none;
+ border-color: #ff2442;
+ box-shadow: none;
+ transform: none;
+}
+
+/* Antd Input focus 样式 */
+.form-input.ant-input:focus,
+.form-input.ant-input-affix-wrapper:focus,
+.form-input.ant-input-affix-wrapper-focused {
+ outline: none !important;
+ border-color: #ff2442 !important;
+ box-shadow: none !important;
+ transform: none !important;
+}
+
+.form-input::placeholder {
+ color: #9ca3af;
+}
+
+.input-icon {
+ position: absolute;
+ left: 16px;
+ top: 50%;
+ transform: translateY(-50%);
+ color: #9ca3af;
+ pointer-events: none;
+ transition: color 0.3s ease;
+ z-index: 2;
+}
+
+.form-input:focus + .input-icon {
+ color: #ff2442;
+}
+
+.password-toggle {
+ position: absolute;
+ right: 12px;
+ top: 50%;
+ transform: translateY(-50%);
+ background: none;
+ border: none;
+ color: #9ca3af;
+ cursor: pointer;
+ padding: 4px;
+ border-radius: 4px;
+ transition: all 0.3s ease;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ z-index: 2;
+ width: 24px;
+ height: 24px;
+}
+
+.password-toggle:hover {
+ color: #ff2442;
+ background-color: rgba(255, 36, 66, 0.1);
+}
+
+/* 表单选项 */
+.form-options {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-top: 8px;
+ margin-bottom: 16px;
+ width: 100%;
+ flex-wrap: nowrap; /* 确保不换行 */
+ min-height: 24px;
+ gap: 8px; /* 添加基础间距 */
+}
+
+/* Ant Design Checkbox 样式兼容 */
+.form-options .ant-checkbox-wrapper {
+ flex: 0 0 auto; /* 不伸缩,保持原始大小 */
+ font-size: 14px;
+ color: #64748b;
+ white-space: nowrap; /* 防止文字换行 */
+ overflow: hidden;
+ text-overflow: ellipsis;
+ max-width: 50%; /* 限制最大宽度 */
+}
+
+.form-options .ant-checkbox-wrapper .ant-checkbox {
+ margin-right: 8px;
+}
+
+.form-options .forgot-password {
+ flex: 0 0 auto; /* 不伸缩,保持原始大小 */
+ margin-left: auto;
+ white-space: nowrap;
+ color: #ff2442;
+ text-decoration: none;
+ font-size: 14px;
+ transition: color 0.3s ease;
+ max-width: 45%; /* 限制最大宽度 */
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.form-options .forgot-password:hover {
+ color: #ff1a3a;
+ text-decoration: underline;
+}
+
+.checkbox-wrapper {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ cursor: pointer;
+ font-size: 14px;
+ color: #64748b;
+ -webkit-user-select: none;
+ -moz-user-select: none;
+ -ms-user-select: none;
+ user-select: none;
+ flex-shrink: 0;
+}
+
+.checkbox {
+ position: relative;
+ width: 18px;
+ height: 18px;
+ margin: 0;
+ cursor: pointer;
+ opacity: 0;
+}
+
+.checkmark {
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 18px;
+ height: 18px;
+ background-color: #fff;
+ border: 1px solid #e1e1e1;
+ border-radius: 3px;
+ transition: all 0.2s ease;
+}
+
+.checkbox:checked + .checkmark {
+ background-color: #ff2442;
+ border-color: #ff2442;
+}
+
+.checkmark:after {
+ content: "";
+ position: absolute;
+ display: none;
+ left: 5px;
+ top: 2px;
+ width: 4px;
+ height: 8px;
+ border: solid white;
+ border-width: 0 2px 2px 0;
+ transform: rotate(45deg);
+}
+
+.checkbox:checked + .checkmark:after {
+ display: block;
+}
+
+.forgot-password {
+ font-size: 14px;
+ color: #ff2442;
+ text-decoration: none;
+ font-weight: 400;
+ transition: color 0.2s ease;
+ margin-left: auto;
+ flex-shrink: 0;
+ white-space: nowrap;
+}
+
+.forgot-password:hover {
+ color: #d91e3a;
+ text-decoration: underline;
+}
+
+/* 小红书风格登录按钮 */
+.login-button {
+ width: 100%;
+ height: 48px; /* 固定高度,防止布局变化 */
+ padding: 12px;
+ background: #ff2442;
+ color: white;
+ border: none;
+ border-radius: 6px;
+ font-size: 14px;
+ font-weight: 500;
+ cursor: pointer;
+ transition: background-color 0.2s ease;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 8px;
+ margin-top: 8px;
+ box-sizing: border-box; /* 确保padding包含在总尺寸内 */
+ min-width: 0; /* 防止flex子元素造成宽度变化 */
+}
+
+.login-button:hover:not(:disabled) {
+ background: #d91e3a;
+ transform: none;
+ box-shadow: none;
+}
+
+.login-button:active:not(:disabled) {
+ transform: none;
+}
+
+.login-button:disabled {
+ background: #ccc;
+ cursor: not-allowed;
+ opacity: 0.8;
+}
+
+.login-button.loading {
+ background: #ff7b8a;
+ cursor: not-allowed;
+}
+
+.loading-spinner {
+ width: 16px;
+ height: 16px;
+ border: 2px solid rgba(255, 255, 255, 0.3);
+ border-radius: 50%;
+ border-top-color: #fff;
+ animation: spin 1s ease-in-out infinite;
+}
+
+@keyframes spin {
+ to { transform: rotate(360deg); }
+}
+
+/* 加载遮罩层 */
+.loading-overlay {
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background: rgba(0, 0, 0, 0.5);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ z-index: 1000;
+ backdrop-filter: blur(4px);
+}
+
+.loading-content {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 16px;
+ background: white;
+ padding: 32px;
+ border-radius: 12px;
+ box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
+}
+
+.loading-spinner-large {
+ width: 40px;
+ height: 40px;
+ border: 4px solid rgba(255, 36, 66, 0.2);
+ border-radius: 50%;
+ border-top-color: #ff2442;
+ animation: spin 1s ease-in-out infinite;
+}
+
+.loading-text {
+ margin: 0;
+ color: #333;
+ font-size: 16px;
+ font-weight: 500;
+}
+
+/* 分隔线 */
+.login-divider {
+ position: relative;
+ text-align: center;
+ margin: 32px 0;
+ color: #9ca3af;
+ font-size: 14px;
+}
+
+.login-divider::before {
+ content: '';
+ position: absolute;
+ top: 50%;
+ left: 0;
+ right: 0;
+ height: 1px;
+ background: linear-gradient(to right, transparent, #e5e7eb, transparent);
+}
+
+.login-divider span {
+ background: rgba(255, 255, 255, 0.95);
+ padding: 0 16px;
+ position: relative;
+ z-index: 1;
+}
+
+/* 社交登录 */
+.social-login {
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+}
+
+.social-button {
+ width: 100%;
+ padding: 12px 16px;
+ border: 1px solid #e1e1e1;
+ border-radius: 6px;
+ background: white;
+ color: #333;
+ font-size: 14px;
+ font-weight: 400;
+ cursor: pointer;
+ transition: border-color 0.2s ease;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 8px;
+}
+
+.social-button:hover {
+ border-color: #ccc;
+}
+
+.social-button.google:hover {
+ border-color: #4285f4;
+ box-shadow: 0 4px 12px rgba(66, 133, 244, 0.2);
+}
+
+.social-button.github:hover {
+ border-color: #333;
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
+}
+
+.social-button.xiaohongshu:hover {
+ border-color: #ff2442;
+ box-shadow: 0 4px 12px rgba(255, 36, 66, 0.2);
+}
+
+/* 注册链接 */
+.signup-link {
+ text-align: center;
+ margin-top: 20px;
+ padding-top: 20px;
+ border-top: 1px solid #e5e7eb;
+}
+
+.signup-link p {
+ margin: 0;
+ font-size: 14px;
+ color: #64748b;
+}
+
+.signup-link a {
+ color: #ff2442;
+ text-decoration: none;
+ font-weight: 500;
+ transition: color 0.2s ease;
+}
+
+.signup-link a:hover {
+ color: #d91e3a;
+ text-decoration: underline;
+}
+
+/* 有左侧图标时的内边距调整 */
+.input-wrapper.has-icon .form-input {
+ padding-left: 48px !important;
+}
+
+.input-wrapper.has-icon .form-input.ant-input,
+.input-wrapper.has-icon .form-input.ant-input-affix-wrapper {
+ padding-left: 48px !important;
+}
+
+/* 有右侧切换按钮时的内边距调整 */
+.input-wrapper.has-toggle .form-input {
+ padding-right: 48px !important;
+}
+
+.input-wrapper.has-toggle .form-input.ant-input,
+.input-wrapper.has-toggle .form-input.ant-input-affix-wrapper {
+ padding-right: 48px !important;
+}
+
+/* 没有图标时的内边距调整 */
+.input-wrapper:not(.has-icon) .form-input {
+ padding-left: 16px !important;
+}
+
+.input-wrapper:not(.has-icon) .form-input.ant-input,
+.input-wrapper:not(.has-icon) .form-input.ant-input-affix-wrapper {
+ padding-left: 16px !important;
+}
+
+/* 没有切换按钮时的内边距调整 */
+.input-wrapper:not(.has-toggle) .form-input {
+ padding-right: 16px !important;
+}
+
+.input-wrapper:not(.has-toggle) .form-input.ant-input,
+.input-wrapper:not(.has-toggle) .form-input.ant-input-affix-wrapper {
+ padding-right: 16px !important;
+}
+
+/* 确保输入框内容完全填充 */
+.form-input.ant-input-affix-wrapper .ant-input-suffix {
+ position: absolute !important;
+ right: 12px !important;
+ top: 50% !important;
+ transform: translateY(-50%) !important;
+ margin: 0 !important;
+ padding: 0 !important;
+}
+
+.form-input.ant-input-affix-wrapper .ant-input-prefix {
+ position: absolute !important;
+ left: 16px !important;
+ top: 50% !important;
+ transform: translateY(-50%) !important;
+ margin: 0 !important;
+ padding: 0 !important;
+}
+
+/* 确保所有输入框完全填充其容器 */
+.form-group {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ width: 100%;
+ box-sizing: border-box;
+}
+
+.form-group .input-wrapper {
+ width: 100%;
+ box-sizing: border-box;
+}
+
+/* 防止输入框溢出容器 */
+.form-input,
+.form-input.ant-input,
+.form-input.ant-input-affix-wrapper {
+ max-width: 100% !important;
+ overflow: hidden !important;
+}
+
+/* 确保内部输入元素不会超出边界 */
+.form-input.ant-input-affix-wrapper .ant-input {
+ max-width: 100% !important;
+ overflow: hidden !important;
+ text-overflow: ellipsis !important;
+}
+
+/* 精细间距控制 */
+.login-header + .login-form {
+ margin-top: -4px;
+}
+
+.login-form .form-group:not(:last-child) {
+ margin-bottom: 2px;
+}
+
+.login-form .form-group:last-of-type {
+ margin-bottom: 6px;
+}
+
+.login-button + .signup-link {
+ margin-top: 14px;
+}
+
+/* 响应式设计 */
+@media (max-width: 768px) {
+ /* 重置body和html确保一致性 */
+ html, body {
+ height: 100%;
+ height: 100dvh;
+ margin: 0;
+ padding: 0;
+ overflow-x: hidden;
+ box-sizing: border-box;
+ }
+
+ .login-container {
+ padding: 16px;
+ align-items: center;
+ justify-content: center;
+ min-height: 100vh;
+ min-height: 100dvh; /* 动态视口高度 */
+ /* 强制重置可能影响定位的样式 */
+ margin: 0;
+ box-sizing: border-box;
+ /* 防止内容溢出影响布局 */
+ overflow-x: hidden;
+ overflow-y: auto;
+ /* 确保flexbox在所有移动设备上表现一致 */
+ -webkit-box-align: center;
+ -webkit-box-pack: center;
+ display: flex !important;
+ position: relative;
+ }
+
+ .login-content {
+ max-width: 100%;
+ padding: 20px;
+ /* 确保内容区域稳定 */
+ margin: 0 auto;
+ box-sizing: border-box;
+ /* 防止宽度计算问题 */
+ width: calc(100% - 40px);
+ max-width: 480px; /* 增加最大宽度 */
+ position: relative;
+ display: flex;
+ justify-content: center;
+ }
+
+ .login-card {
+ padding: 32px 28px; /* 增加内边距 */
+ border-radius: 16px;
+ /* 确保卡片稳定定位 */
+ margin: 0;
+ box-sizing: border-box;
+ width: 100%;
+ max-width: 450px; /* 增加卡片最大宽度 */
+ /* 防止backdrop-filter导致的渲染差异 */
+ will-change: auto;
+ position: relative;
+ /* 防止触摸操作影响布局 */
+ -webkit-touch-callout: none;
+ -webkit-user-select: none;
+ -moz-user-select: none;
+ -ms-user-select: none;
+ user-select: none;
+ -webkit-tap-highlight-color: transparent;
+ }
+
+ .login-title {
+ font-size: 24px;
+ }
+
+ .form-input {
+ width: 100% !important;
+ height: 44px !important;
+ padding: 12px 48px 12px 48px;
+ font-size: 16px; /* 防止iOS Safari缩放 */
+ box-sizing: border-box !important;
+ flex: 1 !important;
+ min-width: 0 !important;
+ }
+
+ .form-input.ant-input,
+ .form-input.ant-input-affix-wrapper {
+ width: 100% !important;
+ height: 44px !important;
+ padding: 12px 48px 12px 48px !important;
+ font-size: 16px !important;
+ box-sizing: border-box !important;
+ flex: 1 !important;
+ min-width: 0 !important;
+ }
+
+ .form-input.ant-input-affix-wrapper .ant-input {
+ width: 100% !important;
+ height: 100% !important;
+ padding: 0 !important;
+ border: none !important;
+ background: transparent !important;
+ flex: 1 !important;
+ min-width: 0 !important;
+ box-sizing: border-box !important;
+ }
+
+ .social-login {
+ gap: 10px;
+ }
+
+ .social-button {
+ padding: 12px 16px;
+ font-size: 14px;
+ }
+
+ .form-options {
+ display: flex !important;
+ flex-direction: row !important;
+ justify-content: space-between !important;
+ align-items: center !important;
+ gap: 8px !important;
+ width: 100% !important;
+ flex-wrap: nowrap !important;
+ min-height: 22px !important;
+ margin-top: 8px !important;
+ margin-bottom: 16px !important;
+ }
+
+ .checkbox-wrapper {
+ font-size: 13px;
+ flex: 0 0 auto !important;
+ white-space: nowrap !important;
+ overflow: hidden !important;
+ text-overflow: ellipsis !important;
+ max-width: 48% !important;
+ }
+
+ .forgot-password {
+ font-size: 13px;
+ margin-left: auto;
+ flex: 0 0 auto !important;
+ white-space: nowrap !important;
+ overflow: hidden !important;
+ text-overflow: ellipsis !important;
+ max-width: 48% !important;
+ }
+
+ /* Ant Design Checkbox 的特殊处理 */
+ .form-options .ant-checkbox-wrapper {
+ flex: 0 0 auto !important;
+ font-size: 13px !important;
+ white-space: nowrap !important;
+ overflow: hidden !important;
+ text-overflow: ellipsis !important;
+ max-width: 48% !important;
+ }
+}
+
+@media (max-width: 480px) {
+ .login-container {
+ padding: 16px;
+ align-items: center;
+ justify-content: center;
+ min-height: 100vh;
+ min-height: 100dvh; /* 动态视口高度 */
+ /* 强制重置样式 */
+ margin: 0;
+ box-sizing: border-box;
+ position: relative;
+ /* 确保垂直居中 */
+ display: flex !important;
+ }
+
+ .login-content {
+ /* 更严格的尺寸控制 */
+ width: calc(100vw - 32px);
+ max-width: 420px; /* 增加最大宽度 */
+ padding: 16px;
+ margin: 0 auto;
+ box-sizing: border-box;
+ display: flex;
+ justify-content: center;
+ }
+
+ .login-card {
+ padding: 28px 24px; /* 增加内边距 */
+ border-radius: 12px;
+ /* 确保卡片完全稳定 */
+ margin: 0;
+ box-sizing: border-box;
+ width: 100%;
+ position: relative;
+ /* 防止变换导致的位置偏移 */
+ transform: none !important;
+ /* 防止触摸操作影响布局 */
+ -webkit-touch-callout: none;
+ -webkit-user-select: none;
+ -khtml-user-select: none;
+ -moz-user-select: none;
+ -ms-user-select: none;
+ user-select: none;
+ /* 防止点击时的高亮效果影响布局 */
+ -webkit-tap-highlight-color: transparent;
+ }
+
+ .login-title {
+ font-size: 22px;
+ }
+
+ .form-input {
+ width: 100% !important;
+ height: 44px !important;
+ padding: 12px 48px 12px 48px;
+ font-size: 16px; /* 防止iOS Safari缩放 */
+ box-sizing: border-box !important;
+ flex: 1 !important;
+ min-width: 0 !important;
+ }
+
+ .form-input.ant-input,
+ .form-input.ant-input-affix-wrapper {
+ width: 100% !important;
+ height: 44px !important;
+ padding: 12px 48px 12px 48px !important;
+ font-size: 16px !important;
+ box-sizing: border-box !important;
+ flex: 1 !important;
+ min-width: 0 !important;
+ }
+
+ .form-input.ant-input-affix-wrapper .ant-input {
+ width: 100% !important;
+ height: 100% !important;
+ padding: 0 !important;
+ border: none !important;
+ background: transparent !important;
+ flex: 1 !important;
+ min-width: 0 !important;
+ box-sizing: border-box !important;
+ }
+
+ .social-login {
+ gap: 10px;
+ }
+
+ .social-button {
+ padding: 12px 16px;
+ font-size: 14px;
+ }
+
+ .form-options {
+ display: flex !important;
+ flex-direction: row !important;
+ justify-content: space-between !important;
+ align-items: center !important;
+ gap: 6px !important;
+ width: 100% !important;
+ min-height: 20px !important;
+ flex-wrap: nowrap !important;
+ margin-top: 8px !important;
+ margin-bottom: 16px !important;
+ }
+
+ .checkbox-wrapper {
+ flex: 0 0 auto !important;
+ font-size: 12px !important;
+ white-space: nowrap !important;
+ overflow: hidden !important;
+ text-overflow: ellipsis !important;
+ max-width: 45% !important;
+ }
+
+ .forgot-password {
+ flex: 0 0 auto !important;
+ font-size: 12px !important;
+ margin-left: auto !important;
+ white-space: nowrap !important;
+ overflow: hidden !important;
+ text-overflow: ellipsis !important;
+ max-width: 45% !important;
+ }
+
+/* 移动端优化 */
+ .background-pattern {
+ display: none;
+ }
+
+ /* 禁用可能影响位置的悬停效果 */
+ .login-card:hover {
+ transform: none !important;
+ }
+}
+
+/* 超小屏幕优化(320px及以下) */
+@media (max-width: 320px) {
+ .login-content {
+ padding: 16px;
+ }
+
+ .login-card {
+ padding: 24px;
+ }
+
+ .form-options {
+ display: flex !important;
+ flex-direction: row !important;
+ justify-content: space-between !important;
+ align-items: center !important;
+ gap: 4px !important;
+ width: 100% !important;
+ flex-wrap: nowrap !important;
+ min-height: 18px !important;
+ margin-top: 6px !important;
+ margin-bottom: 14px !important;
+ }
+
+ .checkbox-wrapper {
+ flex: 0 0 auto !important;
+ font-size: 11px !important;
+ white-space: nowrap !important;
+ overflow: hidden !important;
+ text-overflow: ellipsis !important;
+ max-width: 42% !important;
+ line-height: 1.2 !important;
+ }
+
+ .forgot-password {
+ flex: 0 0 auto !important;
+ font-size: 11px !important;
+ margin-left: auto !important;
+ white-space: nowrap !important;
+ overflow: hidden !important;
+ text-overflow: ellipsis !important;
+ max-width: 42% !important;
+ line-height: 1.2 !important;
+ }
+
+ /* Ant Design Checkbox 的特殊处理 */
+ .form-options .ant-checkbox-wrapper {
+ flex: 0 0 auto !important;
+ font-size: 11px !important;
+ white-space: nowrap !important;
+ overflow: hidden !important;
+ text-overflow: ellipsis !important;
+ max-width: 42% !important;
+ line-height: 1.2 !important;
+ }
+}
+
+/* 极小屏幕优化(280px及以下) */
+@media (max-width: 280px) {
+ .form-options {
+ display: flex !important;
+ flex-direction: row !important;
+ justify-content: space-between !important;
+ align-items: center !important;
+ gap: 2px !important;
+ width: 100% !important;
+ flex-wrap: nowrap !important;
+ min-height: 16px !important;
+ margin-top: 6px !important;
+ margin-bottom: 14px !important;
+ }
+
+ .form-options .ant-checkbox-wrapper {
+ flex: 0 0 auto !important;
+ font-size: 10px !important;
+ white-space: nowrap !important;
+ overflow: hidden !important;
+ text-overflow: ellipsis !important;
+ max-width: 40% !important;
+ line-height: 1.1 !important;
+ }
+
+ .form-options .forgot-password {
+ flex: 0 0 auto !important;
+ font-size: 10px !important;
+ margin-left: auto !important;
+ white-space: nowrap !important;
+ overflow: hidden !important;
+ text-overflow: ellipsis !important;
+ max-width: 40% !important;
+ line-height: 1.1 !important;
+ }
+
+ .form-options .ant-checkbox-wrapper .ant-checkbox {
+ margin-right: 2px !important;
+ transform: scale(0.8) !important; /* 进一步缩小checkbox */
+ }
+}
+
+/* 高对比度模式支持 */
+@media (prefers-contrast: high) {
+ .login-card {
+ background: white;
+ border: 2px solid #000;
+ }
+
+ .form-input {
+ border-color: #000;
+ }
+
+ .form-input:focus {
+ border-color: #0066cc;
+ box-shadow: 0 0 0 2px #0066cc;
+ }
+}
+
+/* 减少动画模式 */
+@media (prefers-reduced-motion: reduce) {
+ .background-pattern {
+ animation: none;
+ }
+
+ .login-card,
+ .form-input,
+ .login-button,
+ .social-button {
+ transition: none;
+ }
+}
+
+/* 深色模式支持 */
+@media (prefers-color-scheme: dark) {
+ .login-background {
+ background: linear-gradient(135deg, #1a202c 0%, #2d3748 100%);
+ }
+
+ .login-card {
+ background: rgba(26, 32, 44, 0.95);
+ border-color: rgba(255, 255, 255, 0.1);
+ }
+
+ .login-title {
+ color: #f7fafc;
+ }
+
+ .login-subtitle {
+ color: #a0aec0;
+ }
+
+ .form-label {
+ color: #e2e8f0;
+ }
+
+ .form-input {
+ background: #2d3748;
+ border-color: #4a5568;
+ color: #f7fafc;
+ }
+
+ .form-input:focus {
+ border-color: #ff2442;
+ }
+
+ .social-button {
+ background: #2d3748;
+ border-color: #4a5568;
+ color: #f7fafc;
+ }
+
+ .signup-link {
+ border-color: #4a5568;
+ }
+
+ .signup-link p {
+ color: #a0aec0;
+ }
+
+ /* 深色模式下的错误提示样式 */
+ .error-message {
+ background: rgba(26, 32, 44, 0.95);
+ color: #ff6b6b;
+ }
+
+ /* 深色模式下的错误弹窗样式 */
+ .error-modal .ant-modal-header {
+ background: #2d3748;
+ border-color: #4a5568;
+ }
+
+ .error-modal .ant-modal-title {
+ color: #f7fafc;
+ }
+
+ .error-modal .ant-modal-body {
+ background: #2d3748;
+ color: #f7fafc;
+ }
+
+ .error-modal .ant-modal-footer {
+ background: #2d3748;
+ }
+
+ .error-modal .ant-modal-content {
+ background: #2d3748;
+ }
+}
+
+/* 错误提示样式 - 使用绝对定位避免影响布局 */
+.error-message {
+ position: absolute;
+ top: 95%;
+ left: 4px;
+ right: 4px;
+ font-size: 12px;
+ color: #ff4d4f;
+ margin-top: 4px;
+ display: flex;
+ align-items: center;
+ min-height: 16px;
+ animation: fadeInDown 0.3s ease-out;
+ font-weight: 400;
+ line-height: 1.2;
+ background: rgba(255, 255, 255, 0.95);
+ backdrop-filter: blur(4px);
+ padding: 2px 4px;
+ border-radius: 4px;
+ z-index: 10;
+ pointer-events: none; /* 避免干扰用户交互 */
+}
+
+@keyframes fadeInDown {
+ from {
+ opacity: 0;
+ transform: translateY(-8px);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
+
+/* 输入框错误状态样式 */
+.form-input.input-error,
+.form-input.input-error.ant-input,
+.form-input.input-error.ant-input-affix-wrapper {
+ border-color: #ff4d4f !important;
+ box-shadow: 0 0 0 2px rgba(255, 77, 79, 0.1) !important;
+ transition: all 0.3s ease !important;
+}
+
+.form-input.input-error:focus,
+.form-input.input-error.ant-input:focus,
+.form-input.input-error.ant-input-affix-wrapper:focus,
+.form-input.input-error.ant-input-affix-wrapper-focused {
+ border-color: #ff4d4f !important;
+ box-shadow: 0 0 0 2px rgba(255, 77, 79, 0.2) !important;
+}
+
+/* 错误状态下的图标颜色 */
+.form-input.input-error .anticon {
+ color: #ff4d4f !important;
+}
+
+/* 确保表单组间距一致 */
+.form-group {
+ margin-bottom: 0px;
+}
+
+.form-group:last-of-type {
+ margin-bottom: 0px;
+}
+
+/* 错误弹窗样式 */
+.error-modal .ant-modal-header {
+ background: #fff;
+ border-bottom: 1px solid #f0f0f0;
+ padding: 16px 24px;
+}
+
+.error-modal .ant-modal-title {
+ color: #333;
+ font-weight: 600;
+ font-size: 16px;
+}
+
+.error-modal .ant-modal-body {
+ padding: 16px 24px 24px;
+}
+
+.error-modal .ant-modal-footer {
+ padding: 12px 24px 24px;
+ border-top: none;
+ text-align: center;
+}
+
+.error-modal .ant-btn-primary {
+ background: #ff2442;
+ border-color: #ff2442;
+ font-weight: 500;
+ height: 40px;
+ padding: 0 24px;
+ border-radius: 6px;
+ transition: all 0.2s ease;
+}
+
+.error-modal .ant-btn-primary:hover {
+ background: #d91e3a;
+ border-color: #d91e3a;
+ transform: translateY(-1px);
+ box-shadow: 0 4px 12px rgba(255, 36, 66, 0.3);
+}
+
+.error-modal .ant-modal-content {
+ border-radius: 12px;
+ overflow: hidden;
+ box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15);
+}
+
+/* 错误弹窗遮罩层 */
+.error-modal .ant-modal-mask {
+ background: rgba(0, 0, 0, 0.6);
+ backdrop-filter: blur(4px);
+}
+
+/* 错误弹窗动画 */
+.error-modal .ant-modal {
+ animation: errorModalSlideIn 0.3s ease-out;
+}
+
+@keyframes errorModalSlideIn {
+ from {
+ opacity: 0;
+ transform: translateY(-20px) scale(0.95);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0) scale(1);
+ }
+}
diff --git a/Merge/front/src/pages/LoginPage/LoginPage.js b/Merge/front/src/pages/LoginPage/LoginPage.js
new file mode 100644
index 0000000..c315b7d
--- /dev/null
+++ b/Merge/front/src/pages/LoginPage/LoginPage.js
@@ -0,0 +1,380 @@
+import React, { useState, useEffect } from 'react';
+import { Link } from 'react-router-dom';
+import { Input, Checkbox, Modal, Alert } from 'antd';
+import { MailOutlined, LockOutlined, ExclamationCircleOutlined, CheckCircleOutlined } from '@ant-design/icons';
+import {
+ getRememberedLoginInfo,
+ saveRememberedLoginInfo,
+ saveAuthInfo,
+ isLoggedIn
+} from '../../utils/auth';
+import { hashPassword } from '../../utils/crypto';
+import './LoginPage.css';
+
+const baseURL = 'http://10.126.59.25:8082';
+
+const LoginPage = () => {
+ const [formData, setFormData] = useState({
+ email: '',
+ password: ''
+ });
+
+ const [rememberMe, setRememberMe] = useState(false);
+ const [isLoading, setIsLoading] = useState(false);
+ const [errors, setErrors] = useState({
+ email: '',
+ password: ''
+ });
+ const [errorModal, setErrorModal] = useState({
+ visible: false,
+ title: '',
+ content: ''
+ });
+ const [successAlert, setSuccessAlert] = useState({
+ visible: false,
+ message: ''
+ });
+
+ // 显示错误弹窗
+ const showErrorModal = (title, content) => {
+ setErrorModal({
+ visible: true,
+ title: title,
+ content: content
+ });
+ };
+
+ // 关闭错误弹窗
+ const closeErrorModal = () => {
+ setErrorModal({
+ visible: false,
+ title: '',
+ content: ''
+ });
+ };
+
+ // 显示成功提示
+ const showSuccessAlert = (message) => {
+ setSuccessAlert({
+ visible: true,
+ message: message
+ });
+
+ // 3秒后自动隐藏
+ setTimeout(() => {
+ setSuccessAlert({
+ visible: false,
+ message: ''
+ });
+ }, 3000);
+ };
+
+ // 页面加载时检查是否有记住的登录信息
+ useEffect(() => {
+ // 检查是否已经登录
+ if (isLoggedIn()) {
+ // 如果已经有token,可以选择直接跳转到主页面
+ // window.location.href = '/test-dashboard';
+ console.log('用户已登录');
+ }
+
+ // 获取记住的登录信息
+ const rememberedInfo = getRememberedLoginInfo();
+ if (rememberedInfo.rememberMe && rememberedInfo.email) {
+ setFormData({
+ email: rememberedInfo.email,
+ password: rememberedInfo.password
+ });
+ setRememberMe(true);
+ }
+ }, []);
+
+ const handleEmailChange = (e) => {
+ const value = e.target.value;
+ setFormData(prev => ({
+ ...prev,
+ email: value
+ }));
+
+ // 清除邮箱错误提示
+ if (errors.email) {
+ setErrors(prev => ({
+ ...prev,
+ email: ''
+ }));
+ }
+ };
+
+ const handlePasswordChange = (e) => {
+ const value = e.target.value;
+ setFormData(prev => ({
+ ...prev,
+ password: value
+ }));
+
+ // 清除密码错误提示
+ if (errors.password) {
+ setErrors(prev => ({
+ ...prev,
+ password: ''
+ }));
+ }
+ };
+
+ const handleRememberMeChange = (e) => {
+ const checked = e.target.checked;
+ setRememberMe(checked);
+
+ // 如果取消记住我,清除已保存的登录信息
+ if (!checked) {
+ saveRememberedLoginInfo('', '', false);
+ }
+ };
+
+ const validateForm = () => {
+ const newErrors = {
+ email: '',
+ password: ''
+ };
+
+ let hasError = false;
+
+ // 验证邮箱
+ if (!formData.email || typeof formData.email !== 'string' || !formData.email.trim()) {
+ newErrors.email = '请输入邮箱地址';
+ hasError = true;
+ } else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email)) {
+ newErrors.email = '请输入有效的邮箱地址';
+ hasError = true;
+ }
+
+ // 验证密码
+ if (!formData.password || typeof formData.password !== 'string' || !formData.password.trim()) {
+ newErrors.password = '请输入密码';
+ hasError = true;
+ } else if (formData.password.length < 6) {
+ newErrors.password = '密码长度至少6位';
+ hasError = true;
+ }
+
+ setErrors(newErrors);
+ return !hasError;
+ };
+
+ const handleSubmit = async (e) => {
+ e.preventDefault();
+
+ // 验证表单
+ if (!validateForm()) {
+ return;
+ }
+
+ setIsLoading(true);
+
+ try {
+ // 发送登录请求到后端
+ const response = await fetch(baseURL + '/login', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({
+ email: formData.email, // 后端支持邮箱登录
+ password: hashPassword(formData.password) // 前端加密密码
+ })
+ });
+
+ const result = await response.json();
+
+ if (result.success) {
+ // 显示成功提示
+ showSuccessAlert('登录成功!正在跳转...');
+
+ // 保存认证信息
+ saveAuthInfo(result.token, result.user, rememberMe);
+
+ // 保存或清除记住的登录信息
+ saveRememberedLoginInfo(formData.email, formData.password, rememberMe);
+
+ // 延迟跳转,让用户看到成功提示
+ setTimeout(() => {
+ window.location.href = '/test-dashboard';
+ }, 1500);
+ } else {
+ // 登录失败,显示错误信息
+ let errorTitle = '登录失败';
+ let errorContent = result.message || '登录失败,请检查您的邮箱和密码';
+
+ // 根据错误类型提供更详细的信息
+ if (result.message) {
+ if (result.message.includes('邮箱') || result.message.includes('email')) {
+ errorTitle = '邮箱验证失败';
+ errorContent = '您输入的邮箱地址不存在或格式不正确,请检查后重试。';
+ } else if (result.message.includes('密码') || result.message.includes('password')) {
+ errorTitle = '密码验证失败';
+ errorContent = '您输入的密码不正确,请检查后重试。如果忘记密码,请点击"忘记密码"进行重置。';
+ } else if (result.message.includes('用户不存在')) {
+ errorTitle = '用户不存在';
+ errorContent = '该邮箱尚未注册,请先注册账户或检查邮箱地址是否正确。';
+ } else if (result.message.includes('账户被锁定') || result.message.includes('locked')) {
+ errorTitle = '账户被锁定';
+ errorContent = '您的账户因安全原因被暂时锁定,请联系客服或稍后重试。';
+ }
+ }
+
+ showErrorModal(errorTitle, errorContent);
+ }
+ } catch (error) {
+ console.error('登录请求失败:', error);
+
+ // 根据错误类型显示不同的错误信息
+ if (error.name === 'TypeError' && error.message.includes('fetch')) {
+ showErrorModal('网络连接失败', '无法连接到服务器,请检查您的网络连接后重试。如果问题持续存在,请联系客服。');
+ } else if (error.name === 'AbortError') {
+ showErrorModal('请求超时', '请求超时,请检查网络连接后重试。');
+ } else {
+ showErrorModal('登录失败', '网络连接失败,请检查网络或稍后重试。如果问题持续存在,请联系客服。');
+ }
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ return (
+ <div className="login-container">
+ <div className="login-background"></div>
+
+ {isLoading && (
+ <div className="loading-overlay">
+ <div className="loading-content">
+ <div className="loading-spinner-large"></div>
+ <p className="loading-text">正在登录...</p>
+ </div>
+ </div>
+ )}
+
+ <div className="login-content">
+ <div className="login-card">
+ {/* 成功提示 */}
+ {successAlert.visible && (
+ <div style={{ marginBottom: '16px' }}>
+ <Alert
+ message={successAlert.message}
+ type="success"
+ icon={<CheckCircleOutlined />}
+ showIcon
+ closable
+ onClose={() => setSuccessAlert({ visible: false, message: '' })}
+ style={{
+ borderRadius: '8px',
+ border: '1px solid #b7eb8f',
+ backgroundColor: '#f6ffed'
+ }}
+ />
+ </div>
+ )}
+
+ <div className="login-header">
+ <h1 className="login-title">欢迎来到小红书</h1>
+ <p className="login-subtitle">标记我的生活</p>
+ </div>
+
+ <form className="login-form" onSubmit={handleSubmit}>
+ <div className="form-group">
+ <Input
+ type="email"
+ id="email"
+ name="email"
+ className={`form-input ${errors.email ? 'input-error' : ''}`}
+ placeholder="请输入您的邮箱"
+ value={formData.email}
+ onChange={handleEmailChange}
+ prefix={<MailOutlined />}
+ size="large"
+ title=""
+ status={errors.email ? 'error' : ''}
+ />
+ {errors.email && (
+ <div className="error-message">
+ {errors.email}
+ </div>
+ )}
+ </div>
+
+ <div className="form-group">
+ <Input.Password
+ id="password"
+ name="password"
+ className={`form-input ${errors.password ? 'input-error' : ''}`}
+ placeholder="请输入您的密码"
+ value={formData.password}
+ onChange={handlePasswordChange}
+ prefix={<LockOutlined />}
+ size="large"
+ title=""
+ status={errors.password ? 'error' : ''}
+ />
+ {errors.password && (
+ <div className="error-message">
+ {errors.password}
+ </div>
+ )}
+ </div>
+
+ <div className="form-options">
+ <Checkbox
+ checked={rememberMe}
+ onChange={handleRememberMeChange}
+ >
+ 记住我
+ </Checkbox>
+ <Link to="/forgot-password" className="forgot-password">忘记密码?</Link>
+ </div>
+
+ <button
+ type="submit"
+ className={`login-button ${isLoading ? 'loading' : ''}`}
+ disabled={isLoading}
+ >
+ {isLoading ? (
+ <>
+ <div className="loading-spinner"></div>
+ 登录中...
+ </>
+ ) : (
+ '登录'
+ )}
+ </button>
+ </form>
+
+ <div className="signup-link">
+ <p>还没有账户? <Link to="/register">立即注册</Link></p>
+ </div>
+ </div>
+ </div>
+
+ {/* 错误弹窗 */}
+ <Modal
+ title={
+ <div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
+ <ExclamationCircleOutlined style={{ color: '#ff4d4f', fontSize: '18px' }} />
+ {errorModal.title}
+ </div>
+ }
+ open={errorModal.visible}
+ onOk={closeErrorModal}
+ onCancel={closeErrorModal}
+ okText="我知道了"
+ cancelButtonProps={{ style: { display: 'none' } }}
+ centered
+ className="error-modal"
+ >
+ <div style={{ padding: '16px 0', fontSize: '14px', lineHeight: '1.6' }}>
+ {errorModal.content}
+ </div>
+ </Modal>
+ </div>
+ );
+};
+
+export default LoginPage;
diff --git a/Merge/front/src/pages/RegisterPage/RegisterPage.css b/Merge/front/src/pages/RegisterPage/RegisterPage.css
new file mode 100644
index 0000000..fc03361
--- /dev/null
+++ b/Merge/front/src/pages/RegisterPage/RegisterPage.css
@@ -0,0 +1,1027 @@
+/* 注册页面容器 */
+.register-container {
+ min-height: 100vh;
+ min-height: 100dvh; /* 动态视口高度,避免移动端地址栏影响 */
+ height: 100vh;
+ height: 100dvh;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ position: relative;
+ overflow: hidden;
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
+ 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
+ sans-serif;
+ /* 确保容器稳定定位 */
+ box-sizing: border-box;
+ margin: 0;
+ padding: 0;
+ /* 重置文本对齐 */
+ text-align: initial;
+}
+
+/* 小红书风格背景 */
+.register-background {
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background: #f8f8f8;
+ z-index: -1;
+}
+
+/* 注册内容区域 */
+.register-content {
+ width: 100%;
+ max-width: 500px; /* 增加桌面端最大宽度 */
+ padding: 0;
+ z-index: 1;
+ /* 确保内容稳定定位 */
+ box-sizing: border-box;
+ position: relative;
+ display: flex;
+ justify-content: center;
+}
+
+/* 小红书风格注册卡片 */
+.register-card {
+ background: #fff;
+ border-radius: 8px;
+ padding: 40px; /* 增加桌面端内边距 */
+ box-shadow: 0 1px 4px rgba(0, 0, 0, 0.1);
+ border: 1px solid #e1e1e1;
+ width: 100%;
+ max-width: 450px; /* 增加桌面端卡片最大宽度 */
+ transition: none;
+}
+
+/* 注册头部 */
+.register-header {
+ text-align: center;
+ margin-bottom: 40px;
+}
+
+/* Logo样式 */
+.logo-section {
+ margin-bottom: 24px;
+}
+
+.logo-icon {
+ width: 60px;
+ height: 60px;
+ background: #ff2442;
+ border-radius: 12px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 28px;
+ margin: 0 auto 16px;
+ box-shadow: 0 4px 12px rgba(255, 36, 66, 0.2);
+}
+
+.register-title {
+ font-size: 24px;
+ font-weight: 600;
+ color: #333;
+ margin: 0 0 12px 0;
+ text-align: center;
+}
+
+.register-title::after {
+ display: none;
+}
+
+.register-subtitle {
+ font-size: 14px;
+ color: #999;
+ margin: 0 0 32px 0;
+ font-weight: 400;
+ text-align: center;
+}
+
+/* 表单样式 */
+.register-form {
+ display: flex;
+ flex-direction: column;
+ gap: 20px;
+ width: 100%;
+}
+
+.form-group {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+ width: 100%;
+ box-sizing: border-box;
+ margin-bottom: 2px;
+ position: relative; /* 为绝对定位的错误提示提供参考点 */
+}
+
+.form-group .input-wrapper {
+ width: 100%;
+ box-sizing: border-box;
+}
+
+.form-label {
+ font-size: 14px;
+ font-weight: 500;
+ color: #333;
+ margin-bottom: 8px;
+}
+
+.input-wrapper {
+ position: relative;
+ display: flex;
+ align-items: center;
+ width: 100%;
+ box-sizing: border-box;
+}
+
+.form-input {
+ width: 100% !important;
+ height: 44px;
+ padding: 12px 16px 12px 48px;
+ border: 1px solid #e1e1e1;
+ border-radius: 6px;
+ font-size: 14px;
+ transition: border-color 0.2s ease;
+ background: #fff;
+ color: #333;
+ box-sizing: border-box !important;
+ flex: 1;
+ min-width: 0;
+}
+
+/* 针对 Antd Input 组件的特定样式 */
+.form-input.ant-input,
+.form-input.ant-input-affix-wrapper {
+ width: 100% !important;
+ height: 44px !important;
+ border: 1px solid #e1e1e1 !important;
+ border-radius: 6px !important;
+ padding: 12px 48px 12px 48px !important;
+ font-size: 14px !important;
+ background: #fff !important;
+ color: #333 !important;
+ box-sizing: border-box !important;
+ flex: 1 !important;
+ min-width: 0 !important;
+ display: flex !important;
+ align-items: center !important;
+}
+
+.form-input.ant-input-affix-wrapper .ant-input {
+ width: 100% !important;
+ height: 100% !important;
+ padding: 0 !important;
+ border: none !important;
+ background: transparent !important;
+ flex: 1 !important;
+ min-width: 0 !important;
+ box-sizing: border-box !important;
+}
+
+.form-input:focus {
+ outline: none;
+ border-color: #ff2442;
+ box-shadow: none;
+ transform: none;
+}
+
+/* Antd Input focus 样式 */
+.form-input.ant-input:focus,
+.form-input.ant-input-affix-wrapper:focus,
+.form-input.ant-input-affix-wrapper-focused {
+ outline: none !important;
+ border-color: #ff2442 !important;
+ box-shadow: none !important;
+ transform: none !important;
+}
+
+.form-input::placeholder {
+ color: #9ca3af;
+}
+
+.input-icon {
+ position: absolute;
+ left: 16px;
+ top: 50%;
+ transform: translateY(-50%);
+ color: #9ca3af;
+ pointer-events: none;
+ transition: color 0.3s ease;
+ z-index: 2;
+}
+
+.form-input:focus + .input-icon {
+ color: #ff2442;
+}
+
+.password-toggle {
+ position: absolute;
+ right: 12px;
+ top: 50%;
+ transform: translateY(-50%);
+ background: none;
+ border: none;
+ color: #9ca3af;
+ cursor: pointer;
+ padding: 4px;
+ border-radius: 4px;
+ transition: all 0.3s ease;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ z-index: 2;
+ width: 24px;
+ height: 24px;
+}
+
+.password-toggle:hover {
+ color: #ff2442;
+ background-color: rgba(255, 36, 66, 0.1);
+}
+
+/* 邮箱验证码输入框容器 */
+.email-code-wrapper {
+ display: flex;
+ gap: 8px;
+ width: 100%;
+ box-sizing: border-box;
+ align-items: flex-start;
+}
+
+.email-code-input {
+ flex: 1;
+ min-width: 0;
+}
+
+.send-code-button {
+ height: 44px !important;
+ padding: 0 16px !important;
+ background: #ff2442 !important;
+ border-color: #ff2442 !important;
+ border-radius: 6px !important;
+ font-size: 14px !important;
+ font-weight: 500 !important;
+ white-space: nowrap !important;
+ flex-shrink: 0 !important;
+ min-width: 100px !important;
+ display: flex !important;
+ align-items: center !important;
+ justify-content: center !important;
+ transition: all 0.2s ease !important;
+ box-sizing: border-box !important;
+}
+
+.send-code-button:hover:not(:disabled) {
+ background: #d91e3a !important;
+ border-color: #d91e3a !important;
+ transform: none !important;
+ box-shadow: none !important;
+}
+
+.send-code-button:disabled {
+ background: #f5f5f5 !important;
+ border-color: #d9d9d9 !important;
+ color: #bfbfbf !important;
+ cursor: not-allowed !important;
+}
+
+.send-code-button.ant-btn-loading {
+ background: #ff2442 !important;
+ border-color: #ff2442 !important;
+ color: white !important;
+}
+
+/* 小红书风格注册按钮 */
+.register-button {
+ width: 100%;
+ height: 48px; /* 固定高度,防止布局变化 */
+ padding: 12px;
+ background: #ff2442;
+ color: white;
+ border: none;
+ border-radius: 6px;
+ font-size: 14px;
+ font-weight: 500;
+ cursor: pointer;
+ transition: background-color 0.2s ease;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 8px;
+ margin-top: 8px;
+ position: relative; /* 为绝对定位的加载状态做准备 */
+ box-sizing: border-box; /* 确保padding包含在总尺寸内 */
+ min-width: 0; /* 防止flex子元素造成宽度变化 */
+}
+
+.register-button:hover:not(:disabled) {
+ background: #d91e3a;
+ transform: none;
+ box-shadow: none;
+}
+
+.register-button:active:not(:disabled) {
+ transform: none;
+}
+
+.register-button:disabled {
+ background: #ccc;
+ cursor: not-allowed;
+ opacity: 0.8;
+}
+
+.register-button.loading {
+ background: #ff7b8a;
+ cursor: not-allowed;
+}
+
+.loading-spinner {
+ width: 16px;
+ height: 16px;
+ border: 2px solid rgba(255, 255, 255, 0.3);
+ border-radius: 50%;
+ border-top-color: #fff;
+ animation: spin 1s ease-in-out infinite;
+}
+
+@keyframes spin {
+ to { transform: rotate(360deg); }
+}
+
+/* 加载遮罩层 */
+.loading-overlay {
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background: rgba(0, 0, 0, 0.5);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ z-index: 1000;
+ backdrop-filter: blur(4px);
+}
+
+.loading-content {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 16px;
+ background: white;
+ padding: 32px;
+ border-radius: 12px;
+ box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
+}
+
+.loading-spinner-large {
+ width: 40px;
+ height: 40px;
+ border: 4px solid rgba(255, 36, 66, 0.2);
+ border-radius: 50%;
+ border-top-color: #ff2442;
+ animation: spin 1s ease-in-out infinite;
+}
+
+.loading-text {
+ margin: 0;
+ color: #333;
+ font-size: 16px;
+ font-weight: 500;
+}
+
+/* 分隔线 */
+.register-divider {
+ position: relative;
+ text-align: center;
+ margin: 32px 0;
+ color: #9ca3af;
+ font-size: 14px;
+}
+
+.register-divider::before {
+ content: '';
+ position: absolute;
+ top: 50%;
+ left: 0;
+ right: 0;
+ height: 1px;
+ background: linear-gradient(to right, transparent, #e5e7eb, transparent);
+}
+
+.register-divider span {
+ background: rgba(255, 255, 255, 0.95);
+ padding: 0 16px;
+ position: relative;
+ z-index: 1;
+}
+
+/* 社交登录 */
+.social-login {
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+}
+
+.social-button {
+ width: 100%;
+ padding: 12px 16px;
+ border: 1px solid #e1e1e1;
+ border-radius: 6px;
+ background: white;
+ color: #333;
+ font-size: 14px;
+ font-weight: 400;
+ cursor: pointer;
+ transition: border-color 0.2s ease;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 8px;
+}
+
+.social-button:hover {
+ border-color: #ccc;
+}
+
+.social-button.google:hover {
+ border-color: #4285f4;
+ box-shadow: 0 4px 12px rgba(66, 133, 244, 0.2);
+}
+
+.social-button.github:hover {
+ border-color: #333;
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
+}
+
+.social-button.xiaohongshu:hover {
+ border-color: #ff2442;
+ box-shadow: 0 4px 12px rgba(255, 36, 66, 0.2);
+}
+
+/* 登录链接 */
+.login-link {
+ text-align: center;
+ margin-top: 20px;
+ padding-top: 20px;
+ border-top: 1px solid #e5e7eb;
+}
+
+.login-link p {
+ margin: 0;
+ font-size: 14px;
+ color: #64748b;
+}
+
+.login-link a {
+ color: #ff2442;
+ text-decoration: none;
+ font-weight: 500;
+ transition: color 0.2s ease;
+}
+
+.login-link a:hover {
+ color: #d91e3a;
+ text-decoration: underline;
+}
+
+/* 有左侧图标时的内边距调整 */
+.input-wrapper.has-icon .form-input {
+ padding-left: 48px !important;
+}
+
+.input-wrapper.has-icon .form-input.ant-input,
+.input-wrapper.has-icon .form-input.ant-input-affix-wrapper {
+ padding-left: 48px !important;
+}
+
+/* 有右侧切换按钮时的内边距调整 */
+.input-wrapper.has-toggle .form-input {
+ padding-right: 48px !important;
+}
+
+.input-wrapper.has-toggle .form-input.ant-input,
+.input-wrapper.has-toggle .form-input.ant-input-affix-wrapper {
+ padding-right: 48px !important;
+}
+
+/* 没有图标时的内边距调整 */
+.input-wrapper:not(.has-icon) .form-input {
+ padding-left: 16px !important;
+}
+
+.input-wrapper:not(.has-icon) .form-input.ant-input,
+.input-wrapper:not(.has-icon) .form-input.ant-input-affix-wrapper {
+ padding-left: 16px !important;
+}
+
+/* 没有切换按钮时的内边距调整 */
+.input-wrapper:not(.has-toggle) .form-input {
+ padding-right: 16px !important;
+}
+
+.input-wrapper:not(.has-toggle) .form-input.ant-input,
+.input-wrapper:not(.has-toggle) .form-input.ant-input-affix-wrapper {
+ padding-right: 16px !important;
+}
+
+/* 确保输入框内容完全填充 */
+.form-input.ant-input-affix-wrapper .ant-input-suffix {
+ position: absolute !important;
+ right: 12px !important;
+ top: 50% !important;
+ transform: translateY(-50%) !important;
+ margin: 0 !important;
+ padding: 0 !important;
+}
+
+.form-input.ant-input-affix-wrapper .ant-input-prefix {
+ position: absolute !important;
+ left: 16px !important;
+ top: 50% !important;
+ transform: translateY(-50%) !important;
+ margin: 0 !important;
+ padding: 0 !important;
+}
+
+/* 确保所有输入框完全填充其容器 */
+.form-group {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ width: 100%;
+ box-sizing: border-box;
+}
+
+.form-group .input-wrapper {
+ width: 100%;
+ box-sizing: border-box;
+}
+
+/* 防止输入框溢出容器 */
+.form-input,
+.form-input.ant-input,
+.form-input.ant-input-affix-wrapper {
+ max-width: 100% !important;
+ overflow: hidden !important;
+}
+
+/* 确保内部输入元素不会超出边界 */
+.form-input.ant-input-affix-wrapper .ant-input {
+ max-width: 100% !important;
+ overflow: hidden !important;
+ text-overflow: ellipsis !important;
+}
+
+/* 精细间距控制 */
+.register-header + .register-form {
+ margin-top: -4px;
+}
+
+.register-form .form-group:not(:last-child) {
+ margin-bottom: 2px;
+}
+
+.register-form .form-group:last-of-type {
+ margin-bottom: 6px;
+}
+
+.register-button + .login-link {
+ margin-top: 14px;
+}
+
+/* 响应式设计 */
+@media (max-width: 768px) {
+ /* 重置body和html确保一致性 */
+ html, body {
+ height: 100%;
+ height: 100dvh;
+ margin: 0;
+ padding: 0;
+ overflow-x: hidden;
+ box-sizing: border-box;
+ }
+
+ .register-container {
+ padding: 16px;
+ align-items: center;
+ justify-content: center;
+ min-height: 100vh;
+ min-height: 100dvh; /* 动态视口高度 */
+ /* 强制重置可能影响定位的样式 */
+ margin: 0;
+ box-sizing: border-box;
+ /* 防止内容溢出影响布局 */
+ overflow-x: hidden;
+ overflow-y: auto;
+ /* 确保flexbox在所有移动设备上表现一致 */
+ -webkit-box-align: center;
+ -webkit-box-pack: center;
+ display: flex !important;
+ position: relative;
+ }
+
+ .register-content {
+ max-width: 100%;
+ padding: 20px;
+ /* 确保内容区域稳定 */
+ margin: 0 auto;
+ box-sizing: border-box;
+ /* 防止宽度计算问题 */
+ width: calc(100% - 40px);
+ max-width: 480px; /* 增加最大宽度 */
+ position: relative;
+ display: flex;
+ justify-content: center;
+ }
+
+ .register-card {
+ padding: 32px 28px; /* 增加内边距 */
+ border-radius: 16px;
+ /* 确保卡片稳定定位 */
+ margin: 0;
+ box-sizing: border-box;
+ width: 100%;
+ max-width: 450px; /* 增加卡片最大宽度 */
+ /* 防止backdrop-filter导致的渲染差异 */
+ will-change: auto;
+ position: relative;
+ /* 防止触摸操作影响布局 */
+ -webkit-touch-callout: none;
+ -webkit-user-select: none;
+ -moz-user-select: none;
+ -ms-user-select: none;
+ user-select: none;
+ -webkit-tap-highlight-color: transparent;
+ }
+
+ .register-title {
+ font-size: 24px;
+ }
+
+ .form-input {
+ width: 100% !important;
+ height: 44px !important;
+ padding: 12px 48px 12px 48px;
+ font-size: 16px; /* 防止iOS Safari缩放 */
+ box-sizing: border-box !important;
+ flex: 1 !important;
+ min-width: 0 !important;
+ }
+
+ .form-input.ant-input,
+ .form-input.ant-input-affix-wrapper {
+ width: 100% !important;
+ height: 44px !important;
+ padding: 12px 48px 12px 48px !important;
+ font-size: 16px !important;
+ box-sizing: border-box !important;
+ flex: 1 !important;
+ min-width: 0 !important;
+ }
+
+ .form-input.ant-input-affix-wrapper .ant-input {
+ width: 100% !important;
+ height: 100% !important;
+ padding: 0 !important;
+ border: none !important;
+ background: transparent !important;
+ flex: 1 !important;
+ min-width: 0 !important;
+ box-sizing: border-box !important;
+ }
+
+ .social-login {
+ gap: 10px;
+ }
+
+ .social-button {
+ padding: 12px 16px;
+ font-size: 14px;
+ }
+}
+
+@media (max-width: 480px) {
+ .register-container {
+ padding: 16px;
+ align-items: center;
+ justify-content: center;
+ min-height: 100vh;
+ min-height: 100dvh; /* 动态视口高度 */
+ /* 强制重置样式 */
+ margin: 0;
+ box-sizing: border-box;
+ position: relative;
+ /* 确保垂直居中 */
+ display: flex !important;
+ }
+
+ .register-content {
+ /* 更严格的尺寸控制 */
+ width: calc(100vw - 32px);
+ max-width: 420px; /* 增加最大宽度 */
+ padding: 16px;
+ margin: 0 auto;
+ box-sizing: border-box;
+ display: flex;
+ justify-content: center;
+ }
+
+ .register-card {
+ padding: 28px 24px; /* 增加内边距 */
+ border-radius: 12px;
+ /* 确保卡片完全稳定 */
+ margin: 0;
+ box-sizing: border-box;
+ width: 100%;
+ position: relative;
+ /* 防止变换导致的位置偏移 */
+ transform: none !important;
+ /* 防止触摸操作影响布局 */
+ -webkit-touch-callout: none;
+ -webkit-user-select: none;
+ -khtml-user-select: none;
+ -moz-user-select: none;
+ -ms-user-select: none;
+ user-select: none;
+ /* 防止点击时的高亮效果影响布局 */
+ -webkit-tap-highlight-color: transparent;
+ }
+
+ .register-title {
+ font-size: 22px;
+ }
+
+ .form-input {
+ width: 100% !important;
+ height: 44px !important;
+ padding: 12px 48px 12px 48px;
+ font-size: 16px; /* 防止iOS Safari缩放 */
+ box-sizing: border-box !important;
+ flex: 1 !important;
+ min-width: 0 !important;
+ }
+
+ .form-input.ant-input,
+ .form-input.ant-input-affix-wrapper {
+ width: 100% !important;
+ height: 44px !important;
+ padding: 12px 48px 12px 48px !important;
+ font-size: 16px !important;
+ box-sizing: border-box !important;
+ flex: 1 !important;
+ min-width: 0 !important;
+ }
+
+ .form-input.ant-input-affix-wrapper .ant-input {
+ width: 100% !important;
+ height: 100% !important;
+ padding: 0 !important;
+ border: none !important;
+ background: transparent !important;
+ flex: 1 !important;
+ min-width: 0 !important;
+ box-sizing: border-box !important;
+ }
+
+ .social-login {
+ gap: 10px;
+ }
+
+ .social-button {
+ padding: 12px 16px;
+ font-size: 14px;
+ }
+
+ /* 移动端优化 */
+ .background-pattern {
+ display: none;
+ }
+
+ /* 禁用可能影响位置的悬停效果 */
+ .register-card:hover {
+ transform: none !important;
+ }
+}
+
+/* 高对比度模式支持 */
+@media (prefers-contrast: high) {
+ .register-card {
+ background: white;
+ border: 2px solid #000;
+ }
+
+ .form-input {
+ border-color: #000;
+ }
+
+ .form-input:focus {
+ border-color: #0066cc;
+ box-shadow: 0 0 0 2px #0066cc;
+ }
+}
+
+/* 减少动画模式 */
+@media (prefers-reduced-motion: reduce) {
+ .background-pattern {
+ animation: none;
+ }
+
+ .register-card,
+ .form-input,
+ .register-button,
+ .social-button {
+ transition: none;
+ }
+}
+
+/* 深色模式支持 */
+@media (prefers-color-scheme: dark) {
+ .register-background {
+ background: linear-gradient(135deg, #1a202c 0%, #2d3748 100%);
+ }
+
+ .register-card {
+ background: rgba(26, 32, 44, 0.95);
+ border-color: rgba(255, 255, 255, 0.1);
+ }
+
+ .register-title {
+ color: #f7fafc;
+ }
+
+ .register-subtitle {
+ color: #a0aec0;
+ }
+
+ .form-label {
+ color: #e2e8f0;
+ }
+
+ .form-input {
+ background: #2d3748;
+ border-color: #4a5568;
+ color: #f7fafc;
+ }
+
+ .form-input:focus {
+ border-color: #ff2442;
+ }
+
+ .social-button {
+ background: #2d3748;
+ border-color: #4a5568;
+ color: #f7fafc;
+ }
+
+ .login-link {
+ border-color: #4a5568;
+ }
+
+ .login-link p {
+ color: #a0aec0;
+ }
+
+ /* 深色模式下的错误提示样式 */
+ .error-message {
+ background: rgba(26, 32, 44, 0.95);
+ color: #ff6b6b;
+ }
+}
+
+/* 错误提示样式 - 使用绝对定位避免影响布局 */
+.error-message {
+ position: absolute;
+ top: 95%;
+ left: 4px;
+ right: 4px;
+ font-size: 12px;
+ color: #ff4d4f;
+ margin-top: 4px;
+ display: flex;
+ align-items: center;
+ min-height: 16px;
+ animation: fadeInDown 0.3s ease-out;
+ font-weight: 400;
+ line-height: 1.2;
+ background: rgba(255, 255, 255, 0.95);
+ backdrop-filter: blur(4px);
+ padding: 2px 4px;
+ border-radius: 4px;
+ z-index: 10;
+ pointer-events: none; /* 避免干扰用户交互 */
+}
+
+@keyframes fadeInDown {
+ from {
+ opacity: 0;
+ transform: translateY(-8px);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
+
+/* 输入框错误状态样式 */
+.form-input.input-error,
+.form-input.input-error.ant-input,
+.form-input.input-error.ant-input-affix-wrapper {
+ border-color: #ff4d4f !important;
+ box-shadow: 0 0 0 2px rgba(255, 77, 79, 0.1) !important;
+ transition: all 0.3s ease !important;
+}
+
+.form-input.input-error:focus,
+.form-input.input-error.ant-input:focus,
+.form-input.input-error.ant-input-affix-wrapper:focus,
+.form-input.input-error.ant-input-affix-wrapper-focused {
+ border-color: #ff4d4f !important;
+ box-shadow: 0 0 0 2px rgba(255, 77, 79, 0.2) !important;
+}
+
+/* 错误状态下的图标颜色 */
+.form-input.input-error .anticon {
+ color: #ff4d4f !important;
+}
+
+/* 确保表单组间距一致 */
+.form-group {
+ margin-bottom: 0px;
+}
+
+.form-group:last-of-type {
+ margin-bottom: 0px;
+}
+
+/* 错误弹窗样式 */
+.error-modal .ant-modal-header {
+ background: #fff;
+ border-bottom: 1px solid #f0f0f0;
+ padding: 16px 24px;
+}
+
+.error-modal .ant-modal-title {
+ color: #333;
+ font-weight: 600;
+ font-size: 16px;
+}
+
+.error-modal .ant-modal-body {
+ padding: 16px 24px 24px;
+}
+
+.error-modal .ant-modal-footer {
+ padding: 12px 24px 24px;
+ border-top: none;
+ text-align: center;
+}
+
+.error-modal .ant-btn-primary {
+ background: #ff2442;
+ border-color: #ff2442;
+ font-weight: 500;
+ height: 40px;
+ padding: 0 24px;
+ border-radius: 6px;
+ transition: all 0.2s ease;
+}
+
+.error-modal .ant-btn-primary:hover {
+ background: #d91e3a;
+ border-color: #d91e3a;
+ transform: translateY(-1px);
+ box-shadow: 0 4px 12px rgba(255, 36, 66, 0.3);
+}
+
+.error-modal .ant-modal-content {
+ border-radius: 12px;
+ overflow: hidden;
+ box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15);
+}
+
+/* 错误弹窗遮罩层 */
+.error-modal .ant-modal-mask {
+ background: rgba(0, 0, 0, 0.6);
+ backdrop-filter: blur(4px);
+}
+
+/* 错误弹窗动画 */
+.error-modal .ant-modal {
+ animation: errorModalSlideIn 0.3s ease-out;
+}
+
+@keyframes errorModalSlideIn {
+ from {
+ opacity: 0;
+ transform: translateY(-20px) scale(0.95);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0) scale(1);
+ }
+}
diff --git a/Merge/front/src/pages/RegisterPage/RegisterPage.js b/Merge/front/src/pages/RegisterPage/RegisterPage.js
new file mode 100644
index 0000000..836e1cf
--- /dev/null
+++ b/Merge/front/src/pages/RegisterPage/RegisterPage.js
@@ -0,0 +1,625 @@
+import React, { useState } from 'react';
+import { Link, useNavigate } from 'react-router-dom';
+import { Input, Button, message, Modal, Alert } from 'antd';
+import { MailOutlined, LockOutlined, UserOutlined, SafetyOutlined, ExclamationCircleOutlined, CheckCircleOutlined } from '@ant-design/icons';
+import { hashPassword } from '../../utils/crypto';
+import './RegisterPage.css';
+
+const baseURL = 'http://10.126.59.25:8082';
+
+const RegisterPage = () => {
+ const [formData, setFormData] = useState({
+ username: '',
+ email: '',
+ emailCode: '',
+ password: '',
+ confirmPassword: ''
+ });
+
+ const [errors, setErrors] = useState({
+ username: '',
+ email: '',
+ emailCode: '',
+ password: '',
+ confirmPassword: ''
+ });
+
+ const [emailCodeSent, setEmailCodeSent] = useState(false);
+ const [countdown, setCountdown] = useState(0);
+ const [sendingCode, setSendingCode] = useState(false);
+ const [isLoading, setIsLoading] = useState(false);
+ const [errorModal, setErrorModal] = useState({
+ visible: false,
+ title: '',
+ content: ''
+ });
+ const [successAlert, setSuccessAlert] = useState({
+ visible: false,
+ message: ''
+ });
+ const [emailCodeSuccessAlert, setEmailCodeSuccessAlert] = useState({
+ visible: false,
+ message: ''
+ });
+
+ const navigate = useNavigate();
+
+ // 显示错误弹窗
+ const showErrorModal = (title, content) => {
+ setErrorModal({
+ visible: true,
+ title: title,
+ content: content
+ });
+ };
+
+ // 关闭错误弹窗
+ const closeErrorModal = () => {
+ setErrorModal({
+ visible: false,
+ title: '',
+ content: ''
+ });
+ };
+
+ // 显示成功提示
+ const showSuccessAlert = (message) => {
+ setSuccessAlert({
+ visible: true,
+ message: message
+ });
+
+ // 3秒后自动隐藏
+ setTimeout(() => {
+ setSuccessAlert({
+ visible: false,
+ message: ''
+ });
+ }, 3000);
+ };
+
+ // 显示邮件验证码发送成功提示
+ const showEmailCodeSuccessAlert = (message) => {
+ setEmailCodeSuccessAlert({
+ visible: true,
+ message: message
+ });
+
+ // 5秒后自动隐藏
+ setTimeout(() => {
+ setEmailCodeSuccessAlert({
+ visible: false,
+ message: ''
+ });
+ }, 5000);
+ };
+
+ // 倒计时效果
+ React.useEffect(() => {
+ let timer;
+ if (countdown > 0) {
+ timer = setTimeout(() => {
+ setCountdown(countdown - 1);
+ }, 1000);
+ }
+ return () => clearTimeout(timer);
+ }, [countdown]);
+
+ // 发送邮箱验证码
+ const sendEmailCode = async () => {
+ // 验证邮箱格式
+ if (!formData.email || typeof formData.email !== 'string' || !formData.email.trim()) {
+ setErrors(prev => ({
+ ...prev,
+ email: '请先输入邮箱地址'
+ }));
+ return;
+ }
+
+ if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email)) {
+ setErrors(prev => ({
+ ...prev,
+ email: '请输入有效的邮箱地址'
+ }));
+ return;
+ }
+
+ setSendingCode(true);
+
+ try {
+ // 调用后端API发送验证码
+ const response = await fetch(baseURL + '/send-verification-code', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({
+ email: formData.email,
+ verification_type: 'register'
+ })
+ });
+
+ if (!response.ok) {
+ throw new Error(`HTTP ${response.status}: ${response.statusText}`);
+ }
+
+ const result = await response.json();
+
+ if (result.success) {
+ showEmailCodeSuccessAlert('验证码已发送到您的邮箱');
+ setEmailCodeSent(true);
+ setCountdown(60); // 60秒倒计时
+
+ // 清除邮箱错误提示
+ setErrors(prev => ({
+ ...prev,
+ email: ''
+ }));
+ } else {
+ // 根据具体错误信息进行处理
+ const errorMessage = result.message || '发送验证码失败,请稍后再试';
+
+ if (errorMessage.includes('邮箱') && (errorMessage.includes('已注册') || errorMessage.includes('已存在'))) {
+ setErrors(prev => ({
+ ...prev,
+ email: errorMessage
+ }));
+ } else {
+ showErrorModal('发送验证码失败', errorMessage);
+ }
+ }
+
+ } catch (error) {
+ console.error('发送验证码失败:', error);
+
+ // 根据错误类型显示不同的错误信息
+ if (error.name === 'TypeError' && error.message.includes('Failed to fetch')) {
+ showErrorModal('网络连接失败', '无法连接到服务器,请检查您的网络连接后重试。如果问题持续存在,请联系客服。');
+ } else if (error.message.includes('HTTP 500')) {
+ showErrorModal('服务器错误', '服务器出现了内部错误,请稍后重试。如果问题持续存在,请联系客服。');
+ } else if (error.message.includes('HTTP 429')) {
+ showErrorModal('发送频率限制', '验证码发送过于频繁,请稍后再试。为了防止垃圾邮件,系统限制了发送频率。');
+ } else if (error.message.includes('HTTP 400')) {
+ showErrorModal('请求错误', '邮箱格式错误,请检查邮箱地址是否正确。');
+ } else {
+ showErrorModal('发送失败', '发送验证码失败,请稍后重试。如果问题持续存在,请联系客服。');
+ }
+ } finally {
+ setSendingCode(false);
+ }
+ };
+
+ const handleInputChange = (field) => (e) => {
+ const value = e.target.value;
+ setFormData(prev => ({
+ ...prev,
+ [field]: value
+ }));
+
+ // 清除对应字段的错误提示
+ if (errors[field]) {
+ setErrors(prev => ({
+ ...prev,
+ [field]: ''
+ }));
+ }
+ };
+
+ const handlePasswordChange = (e) => {
+ const value = e.target.value;
+ setFormData(prev => ({
+ ...prev,
+ password: value
+ }));
+
+ // 清除密码错误提示
+ if (errors.password) {
+ setErrors(prev => ({
+ ...prev,
+ password: ''
+ }));
+ }
+ };
+
+ const handleConfirmPasswordChange = (e) => {
+ const value = e.target.value;
+ setFormData(prev => ({
+ ...prev,
+ confirmPassword: value
+ }));
+
+ // 清除确认密码错误提示
+ if (errors.confirmPassword) {
+ setErrors(prev => ({
+ ...prev,
+ confirmPassword: ''
+ }));
+ }
+ };
+
+ const validateForm = () => {
+ const newErrors = {
+ username: '',
+ email: '',
+ emailCode: '',
+ password: '',
+ confirmPassword: ''
+ };
+
+ let hasError = false;
+
+ // 验证用户名
+ if (!formData.username || typeof formData.username !== 'string' || !formData.username.trim()) {
+ newErrors.username = '请输入用户名';
+ hasError = true;
+ } else if (formData.username.length < 2) {
+ newErrors.username = '用户名至少2个字符';
+ hasError = true;
+ } else if (formData.username.length > 20) {
+ newErrors.username = '用户名不能超过20个字符';
+ hasError = true;
+ }
+
+ // 验证邮箱
+ if (!formData.email || typeof formData.email !== 'string' || !formData.email.trim()) {
+ newErrors.email = '请输入邮箱地址';
+ hasError = true;
+ } else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email)) {
+ newErrors.email = '请输入有效的邮箱地址';
+ hasError = true;
+ }
+
+ // 验证邮箱验证码
+ if (!formData.emailCode || typeof formData.emailCode !== 'string' || !formData.emailCode.trim()) {
+ newErrors.emailCode = '请输入邮箱验证码';
+ hasError = true;
+ } else if (formData.emailCode.length !== 6 || !/^\d{6}$/.test(formData.emailCode)) {
+ newErrors.emailCode = '请输入6位数字验证码';
+ hasError = true;
+ }
+
+ // 验证密码
+ if (!formData.password || typeof formData.password !== 'string' || !formData.password.trim()) {
+ newErrors.password = '请输入密码';
+ hasError = true;
+ } else if (formData.password.length < 6) {
+ newErrors.password = '密码长度至少6位';
+ hasError = true;
+ } else if (formData.password.length > 20) {
+ newErrors.password = '密码长度不能超过20位';
+ hasError = true;
+ }
+
+ // 验证确认密码
+ if (!formData.confirmPassword || typeof formData.confirmPassword !== 'string' || !formData.confirmPassword.trim()) {
+ newErrors.confirmPassword = '请确认密码';
+ hasError = true;
+ } else if (formData.password !== formData.confirmPassword) {
+ newErrors.confirmPassword = '两次输入的密码不一致';
+ hasError = true;
+ }
+
+ setErrors(newErrors);
+ return !hasError;
+ };
+
+ const handleSubmit = async (e) => {
+ e.preventDefault();
+
+ // 验证表单
+ if (!validateForm()) {
+ return;
+ }
+
+ setIsLoading(true);
+
+ try {
+ // 调用后端API进行注册
+ const registerResponse = await fetch(baseURL + '/register', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({
+ username: formData.username,
+ email: formData.email,
+ password: hashPassword(formData.password), // 前端加密密码
+ verification_code: hashPassword(formData.emailCode) // 前端加密验证码
+ })
+ });
+
+ if (!registerResponse.ok) {
+ throw new Error(`HTTP ${registerResponse.status}: ${registerResponse.statusText}`);
+ }
+
+ const registerResult = await registerResponse.json();
+
+ if (registerResult.success) {
+ showSuccessAlert('注册成功!欢迎加入小红书,正在跳转到登录页面...');
+ // 清空表单数据
+ setFormData({
+ username: '',
+ email: '',
+ emailCode: '',
+ password: '',
+ confirmPassword: ''
+ });
+ setErrors({
+ username: '',
+ email: '',
+ emailCode: '',
+ password: '',
+ confirmPassword: ''
+ });
+ // 延迟跳转到登录页面,让用户看到成功提示
+ setTimeout(() => {
+ navigate('/login');
+ }, 2000);
+ } else {
+ // 处理不同的注册失败情况
+ const errorMessage = registerResult.message || '注册失败,请稍后再试';
+
+ // 如果是邮箱已存在的错误,将错误显示在邮箱字段下方
+ if (errorMessage.includes('邮箱') && (errorMessage.includes('已存在') || errorMessage.includes('已注册'))) {
+ setErrors(prev => ({
+ ...prev,
+ email: errorMessage
+ }));
+ }
+ // 如果是用户名已存在的错误,将错误显示在用户名字段下方
+ else if (errorMessage.includes('用户名') && (errorMessage.includes('已存在') || errorMessage.includes('已被使用'))) {
+ setErrors(prev => ({
+ ...prev,
+ username: errorMessage
+ }));
+ }
+ else {
+ // 其他错误显示在消息框中
+ message.error(errorMessage);
+ }
+ }
+
+ } catch (error) {
+ console.error('注册失败:', error);
+
+ // 根据错误类型显示不同的错误信息
+ if (error.name === 'TypeError' && error.message.includes('Failed to fetch')) {
+ showErrorModal('网络连接失败', '无法连接到服务器,请检查您的网络连接后重试。如果问题持续存在,请联系客服。');
+ } else if (error.message.includes('HTTP 500')) {
+ showErrorModal('服务器内部错误', '服务器出现了内部错误,请稍后重试或联系客服。技术团队已收到通知。');
+ } else if (error.message.includes('HTTP 400')) {
+ showErrorModal('请求参数错误', '请求参数有误,请检查您输入的信息是否正确。');
+ } else if (error.message.includes('HTTP 409')) {
+ showErrorModal('用户信息冲突', '您输入的邮箱或用户名可能已被其他用户使用,请尝试使用其他邮箱或用户名。');
+ } else if (error.message.includes('HTTP')) {
+ showErrorModal('请求失败', `请求失败 (${error.message}),请稍后重试。如果问题持续存在,请联系客服。`);
+ } else {
+ showErrorModal('注册失败', '注册过程中发生未知错误,请稍后重试。如果问题持续存在,请联系客服。');
+ }
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ return (
+ <div className="register-container">
+ <div className="register-background"></div>
+
+ {isLoading && (
+ <div className="loading-overlay">
+ <div className="loading-content">
+ <div className="loading-spinner-large"></div>
+ <p className="loading-text">正在注册...</p>
+ </div>
+ </div>
+ )}
+
+ <div className="register-content">
+ <div className="register-card">
+ {/* 成功提示 */}
+ {successAlert.visible && (
+ <div style={{ marginBottom: '16px' }}>
+ <Alert
+ message={successAlert.message}
+ type="success"
+ icon={<CheckCircleOutlined />}
+ showIcon
+ closable
+ onClose={() => setSuccessAlert({ visible: false, message: '' })}
+ style={{
+ borderRadius: '8px',
+ border: '1px solid #b7eb8f',
+ backgroundColor: '#f6ffed'
+ }}
+ />
+ </div>
+ )}
+
+ {/* 邮件验证码发送成功提示 */}
+ {emailCodeSuccessAlert.visible && (
+ <div style={{ marginBottom: '16px' }}>
+ <Alert
+ message={emailCodeSuccessAlert.message}
+ type="success"
+ icon={<CheckCircleOutlined />}
+ showIcon
+ closable
+ onClose={() => setEmailCodeSuccessAlert({ visible: false, message: '' })}
+ style={{
+ borderRadius: '8px',
+ border: '1px solid #b7eb8f',
+ backgroundColor: '#f6ffed'
+ }}
+ />
+ </div>
+ )}
+
+ <div className="register-header">
+ <h1 className="register-title">加入小红书</h1>
+ <p className="register-subtitle">发现美好生活,分享精彩瞬间</p>
+ </div>
+
+ <form className="register-form" onSubmit={handleSubmit}>
+ <div className="form-group">
+ <Input
+ type="text"
+ id="username"
+ name="username"
+ className={`form-input ${errors.username ? 'input-error' : ''}`}
+ placeholder="请输入用户名"
+ value={formData.username}
+ onChange={handleInputChange('username')}
+ prefix={<UserOutlined />}
+ size="large"
+ title=""
+ status={errors.username ? 'error' : ''}
+ />
+ {errors.username && (
+ <div className="error-message">
+ {errors.username}
+ </div>
+ )}
+ </div>
+
+ <div className="form-group">
+ <Input
+ type="email"
+ id="email"
+ name="email"
+ className={`form-input ${errors.email ? 'input-error' : ''}`}
+ placeholder="请输入邮箱地址"
+ value={formData.email}
+ onChange={handleInputChange('email')}
+ prefix={<MailOutlined />}
+ size="large"
+ title=""
+ status={errors.email ? 'error' : ''}
+ />
+ {errors.email && (
+ <div className="error-message">
+ {errors.email}
+ </div>
+ )}
+ </div>
+
+ <div className="form-group">
+ <div className="email-code-wrapper">
+ <Input
+ type="text"
+ id="emailCode"
+ name="emailCode"
+ className={`form-input email-code-input ${errors.emailCode ? 'input-error' : ''}`}
+ placeholder="请输入6位验证码"
+ value={formData.emailCode}
+ onChange={handleInputChange('emailCode')}
+ prefix={<SafetyOutlined />}
+ maxLength={6}
+ size="large"
+ title=""
+ status={errors.emailCode ? 'error' : ''}
+ />
+ <Button
+ type="primary"
+ className="send-code-button"
+ onClick={sendEmailCode}
+ loading={sendingCode}
+ disabled={countdown > 0 || !formData.email || sendingCode}
+ size="large"
+ >
+ {countdown > 0 ? `${countdown}s后重发` : (emailCodeSent ? '重新发送' : '发送验证码')}
+ </Button>
+ </div>
+ {errors.emailCode && (
+ <div className="error-message">
+ {errors.emailCode}
+ </div>
+ )}
+ </div>
+
+ <div className="form-group">
+ <Input.Password
+ id="password"
+ name="password"
+ className={`form-input ${errors.password ? 'input-error' : ''}`}
+ placeholder="请输入密码"
+ value={formData.password}
+ onChange={handlePasswordChange}
+ prefix={<LockOutlined />}
+ size="large"
+ title=""
+ status={errors.password ? 'error' : ''}
+ />
+ {errors.password && (
+ <div className="error-message">
+ {errors.password}
+ </div>
+ )}
+ </div>
+
+ <div className="form-group">
+ <Input.Password
+ id="confirmPassword"
+ name="confirmPassword"
+ className={`form-input ${errors.confirmPassword ? 'input-error' : ''}`}
+ placeholder="请确认密码"
+ value={formData.confirmPassword}
+ onChange={handleConfirmPasswordChange}
+ prefix={<LockOutlined />}
+ size="large"
+ title=""
+ status={errors.confirmPassword ? 'error' : ''}
+ />
+ {errors.confirmPassword && (
+ <div className="error-message">
+ {errors.confirmPassword}
+ </div>
+ )}
+ </div>
+
+ <button
+ type="submit"
+ className={`register-button ${isLoading ? 'loading' : ''}`}
+ disabled={isLoading}
+ >
+ {isLoading ? (
+ <>
+ <div className="loading-spinner"></div>
+ 注册中...
+ </>
+ ) : (
+ '立即注册'
+ )}
+ </button>
+ </form>
+
+ <div className="login-link">
+ <p>已有账户? <Link to="/login">立即登录</Link></p>
+ </div>
+ </div>
+ </div>
+
+ {/* 错误弹窗 */}
+ <Modal
+ title={
+ <div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
+ <ExclamationCircleOutlined style={{ color: '#ff4d4f', fontSize: '18px' }} />
+ {errorModal.title}
+ </div>
+ }
+ open={errorModal.visible}
+ onOk={closeErrorModal}
+ onCancel={closeErrorModal}
+ okText="我知道了"
+ cancelButtonProps={{ style: { display: 'none' } }}
+ centered
+ className="error-modal"
+ >
+ <div style={{ padding: '16px 0', fontSize: '14px', lineHeight: '1.6' }}>
+ {errorModal.content}
+ </div>
+ </Modal>
+ </div>
+ );
+};
+
+export default RegisterPage;
diff --git a/Merge/front/src/pages/TestDashboard/TestDashboard.css b/Merge/front/src/pages/TestDashboard/TestDashboard.css
new file mode 100644
index 0000000..a9e1c80
--- /dev/null
+++ b/Merge/front/src/pages/TestDashboard/TestDashboard.css
@@ -0,0 +1,189 @@
+.test-dashboard {
+ min-height: 100vh;
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+ padding: 20px;
+}
+
+.dashboard-header {
+ text-align: center;
+ margin-bottom: 30px;
+ color: white;
+}
+
+.dashboard-header h1 {
+ font-size: 2.5rem;
+ margin-bottom: 10px;
+ text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.3);
+}
+
+.dashboard-header p {
+ font-size: 1.1rem;
+ opacity: 0.9;
+}
+
+.dashboard-content {
+ max-width: 1200px;
+ margin: 0 auto;
+ display: flex;
+ flex-direction: column;
+ gap: 20px;
+}
+
+.user-info-card,
+.token-info-card,
+.api-test-card {
+ background: white;
+ border-radius: 12px;
+ box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
+ border: none;
+}
+
+.user-info-card .ant-card-head {
+ background: linear-gradient(90deg, #4f46e5 0%, #7c3aed 100%);
+ border-radius: 12px 12px 0 0;
+ border-bottom: none;
+}
+
+.user-info-card .ant-card-head-title {
+ color: white;
+ font-weight: 600;
+}
+
+.user-info-card .ant-card-extra .ant-btn {
+ color: white;
+ border-color: rgba(255, 255, 255, 0.3);
+}
+
+.user-info-card .ant-card-extra .ant-btn:hover {
+ background: rgba(255, 255, 255, 0.1);
+ border-color: rgba(255, 255, 255, 0.5);
+}
+
+.user-info-card .ant-card-extra .ant-btn-danger {
+ background: #ef4444;
+ border-color: #ef4444;
+}
+
+.user-info-card .ant-card-extra .ant-btn-danger:hover {
+ background: #dc2626;
+ border-color: #dc2626;
+}
+
+.token-info-card .ant-card-head {
+ background: linear-gradient(90deg, #059669 0%, #0d9488 100%);
+ border-radius: 12px 12px 0 0;
+ border-bottom: none;
+}
+
+.token-info-card .ant-card-head-title {
+ color: white;
+ font-weight: 600;
+}
+
+.api-test-card .ant-card-head {
+ background: linear-gradient(90deg, #ea580c 0%, #dc2626 100%);
+ border-radius: 12px 12px 0 0;
+ border-bottom: none;
+}
+
+.api-test-card .ant-card-head-title {
+ color: white;
+ font-weight: 600;
+}
+
+.token-display {
+ background: #f8fafc;
+ padding: 20px;
+ border-radius: 8px;
+ border: 1px solid #e2e8f0;
+}
+
+.token-text {
+ background: #1e293b;
+ color: #10b981;
+ padding: 12px;
+ border-radius: 6px;
+ font-family: 'Courier New', monospace;
+ font-size: 14px;
+ word-break: break-all;
+ margin: 10px 0;
+ border: 1px solid #334155;
+}
+
+.token-note {
+ color: #64748b;
+ font-size: 12px;
+ font-style: italic;
+ margin: 10px 0 0 0;
+}
+
+.loading-container {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ height: 100vh;
+ color: white;
+}
+
+.spinner {
+ width: 40px;
+ height: 40px;
+ border: 4px solid rgba(255, 255, 255, 0.3);
+ border-left-color: white;
+ border-radius: 50%;
+ animation: spin 1s linear infinite;
+ margin-bottom: 20px;
+}
+
+@keyframes spin {
+ to {
+ transform: rotate(360deg);
+ }
+}
+
+.loading-container p {
+ font-size: 1.1rem;
+ opacity: 0.9;
+}
+
+/* 响应式设计 */
+@media (max-width: 768px) {
+ .test-dashboard {
+ padding: 10px;
+ }
+
+ .dashboard-header h1 {
+ font-size: 2rem;
+ }
+
+ .dashboard-content {
+ gap: 15px;
+ }
+
+ .user-info-card .ant-card-extra {
+ flex-direction: column;
+ gap: 8px;
+ }
+
+ .token-text {
+ font-size: 12px;
+ }
+}
+
+/* Ant Design 组件样式覆写 */
+.ant-descriptions-item-label {
+ font-weight: 600;
+ color: #374151;
+ background: #f9fafb;
+}
+
+.ant-descriptions-item-content {
+ color: #111827;
+}
+
+.ant-tag {
+ font-weight: 500;
+ border-radius: 6px;
+ padding: 2px 8px;
+}
diff --git a/Merge/front/src/pages/TestDashboard/TestDashboard.js b/Merge/front/src/pages/TestDashboard/TestDashboard.js
new file mode 100644
index 0000000..0cc9914
--- /dev/null
+++ b/Merge/front/src/pages/TestDashboard/TestDashboard.js
@@ -0,0 +1,295 @@
+import React, { useState, useEffect } from 'react';
+import { Card, Button, Descriptions, Avatar, Tag, Space, message } from 'antd';
+import { UserOutlined, LogoutOutlined, ReloadOutlined } from '@ant-design/icons';
+import { getUserInfo, getAuthToken, isLoggedIn, saveAuthInfo, createAuthenticatedRequest } from '../../utils/auth';
+import LogoutButton from '../../components/LogoutButton';
+import './TestDashboard.css';
+
+const TestDashboard = () => {
+ const [userInfo, setUserInfo] = useState(null);
+ const [token, setToken] = useState(null);
+ const [loading, setLoading] = useState(false);
+ const [jwtTestLoading, setJwtTestLoading] = useState(false);
+
+ useEffect(() => {
+ // 检查用户是否已登录
+ if (!isLoggedIn()) {
+ window.location.href = '/';
+ return;
+ }
+
+ // 获取用户信息和token
+ const authToken = getAuthToken();
+ const authUserInfo = getUserInfo();
+
+ setToken(authToken);
+ setUserInfo(authUserInfo);
+ }, []);
+
+ const handleRefreshProfile = async () => {
+ if (!token) {
+ message.error('未找到认证token');
+ return;
+ }
+
+ setLoading(true);
+ try {
+ const response = await fetch('http://10.126.59.25:8082/profile', createAuthenticatedRequest());
+
+ const result = await response.json();
+
+ if (result.success) {
+ setUserInfo(result.user);
+ // 更新存储的用户信息,保持原有的存储方式(localStorage或sessionStorage)
+ const isRemembered = localStorage.getItem('authToken');
+ saveAuthInfo(token, result.user, !!isRemembered);
+ message.success('用户信息刷新成功');
+ } else {
+ message.error(`获取用户信息失败: ${result.message}`);
+ }
+ } catch (error) {
+ console.error('刷新用户信息失败:', error);
+ message.error('网络连接失败');
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const handleLogout = async () => {
+ if (!token) {
+ // 清除存储并跳转
+ localStorage.removeItem('authToken');
+ localStorage.removeItem('userInfo');
+ sessionStorage.removeItem('authToken');
+ sessionStorage.removeItem('userInfo');
+ window.location.href = '/';
+ return;
+ }
+
+ try {
+ const response = await fetch('http://10.126.59.25:8082/logout', {
+ method: 'POST',
+ headers: {
+ 'Authorization': `Bearer ${token}`,
+ 'Content-Type': 'application/json',
+ }
+ });
+
+ const result = await response.json();
+
+ if (result.success) {
+ message.success('退出登录成功');
+ } else {
+ message.warning(`退出登录: ${result.message}`);
+ }
+ } catch (error) {
+ console.error('退出登录请求失败:', error);
+ message.warning('网络请求失败,但将清除本地数据');
+ } finally {
+ // 无论请求成功与否,都清除本地存储并跳转
+ localStorage.removeItem('authToken');
+ localStorage.removeItem('userInfo');
+ sessionStorage.removeItem('authToken');
+ sessionStorage.removeItem('userInfo');
+ window.location.href = '/';
+ }
+ };
+
+ const handleTestJWT = async () => {
+ if (!token) {
+ message.error('未找到认证token');
+ return;
+ }
+
+ setJwtTestLoading(true);
+ try {
+ const response = await fetch('http://10.126.59.25:8082/test-jwt', {
+ method: 'POST',
+ headers: {
+ 'Authorization': `Bearer ${token}`,
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({
+ token: token, // 可选:在请求体中也发送token进行额外验证
+ test_purpose: 'frontend_jwt_test'
+ })
+ });
+
+ const result = await response.json();
+
+ if (result.success) {
+ message.success(`JWT令牌验证成功!用户: ${result.user.username}`);
+ console.log('JWT验证详细结果:', result);
+
+ // 如果有额外的token验证结果,也显示出来
+ if (result.additional_token_verification) {
+ console.log('额外token验证:', result.additional_token_verification);
+ }
+ } else {
+ message.error(`JWT令牌验证失败: ${result.message}`);
+ }
+ } catch (error) {
+ console.error('JWT令牌验证失败:', error);
+ message.error('网络连接失败');
+ } finally {
+ setJwtTestLoading(false);
+ }
+ };
+
+ const getRoleColor = (role) => {
+ switch (role) {
+ case 'superadmin':
+ return 'red';
+ case 'admin':
+ return 'orange';
+ case 'user':
+ default:
+ return 'blue';
+ }
+ };
+
+ const getStatusColor = (status) => {
+ switch (status) {
+ case 'active':
+ return 'green';
+ case 'banned':
+ return 'red';
+ case 'muted':
+ return 'orange';
+ default:
+ return 'default';
+ }
+ };
+
+ if (!userInfo) {
+ return (
+ <div className="test-dashboard">
+ <div className="loading-container">
+ <div className="spinner"></div>
+ <p>加载用户信息中...</p>
+ </div>
+ </div>
+ );
+ }
+
+ return (
+ <div className="test-dashboard">
+ <div className="dashboard-header">
+ <h1>测试仪表板</h1>
+ <p>登录成功!以下是从后端返回的用户信息:</p>
+ </div>
+
+ <div className="dashboard-content">
+ <Card
+ title={
+ <Space>
+ <Avatar size={40} icon={<UserOutlined />} src={userInfo.avatar} />
+ <span>用户信息</span>
+ </Space>
+ }
+ extra={
+ <Space>
+ <Button
+ type="primary"
+ icon={<ReloadOutlined />}
+ loading={loading}
+ onClick={handleRefreshProfile}
+ >
+ 刷新信息
+ </Button>
+ <LogoutButton onLogout={() => window.location.href = '/'} />
+ </Space>
+ }
+ className="user-info-card"
+ >
+ <Descriptions column={2} bordered>
+ <Descriptions.Item label="用户ID">{userInfo.id}</Descriptions.Item>
+ <Descriptions.Item label="用户名">{userInfo.username}</Descriptions.Item>
+ <Descriptions.Item label="邮箱">{userInfo.email}</Descriptions.Item>
+ <Descriptions.Item label="角色">
+ <Tag color={getRoleColor(userInfo.role)}>
+ {userInfo.role}
+ </Tag>
+ </Descriptions.Item>
+ <Descriptions.Item label="账号状态">
+ <Tag color={getStatusColor(userInfo.status)}>
+ {userInfo.status}
+ </Tag>
+ </Descriptions.Item>
+ <Descriptions.Item label="个人简介" span={2}>
+ {userInfo.bio || '暂无个人简介'}
+ </Descriptions.Item>
+ <Descriptions.Item label="创建时间">
+ {userInfo.created_at ? new Date(userInfo.created_at).toLocaleString() : '未知'}
+ </Descriptions.Item>
+ <Descriptions.Item label="更新时间">
+ {userInfo.updated_at ? new Date(userInfo.updated_at).toLocaleString() : '未知'}
+ </Descriptions.Item>
+ </Descriptions>
+ </Card>
+
+ <Card title="登录状态信息" className="login-status-card">
+ <div className="login-status-display">
+ <Descriptions column={1} bordered>
+ <Descriptions.Item label="登录方式">
+ <Tag color={localStorage.getItem('authToken') ? 'green' : 'blue'}>
+ {localStorage.getItem('authToken') ? '记住我登录 (持久化)' : '普通登录 (会话)'}
+ </Tag>
+ </Descriptions.Item>
+ <Descriptions.Item label="Token存储位置">
+ {localStorage.getItem('authToken') ? 'localStorage (浏览器关闭后仍保持登录)' : 'sessionStorage (浏览器关闭后需重新登录)'}
+ </Descriptions.Item>
+ <Descriptions.Item label="记住的登录信息">
+ {localStorage.getItem('rememberMe') === 'true' ?
+ `已保存邮箱: ${localStorage.getItem('rememberedEmail') || '无'}` :
+ '未保存登录信息'
+ }
+ </Descriptions.Item>
+ </Descriptions>
+ </div>
+ </Card>
+
+ <Card title="Token信息" className="token-info-card">
+ <div className="token-display">
+ <p><strong>认证Token:</strong></p>
+ <div className="token-text">
+ {token ? `${token.substring(0, 50)}...` : '未找到token'}
+ </div>
+ <p className="token-note">
+ * Token已被安全截断显示,完整token存储在浏览器存储中
+ </p>
+ </div>
+ </Card>
+
+ <Card title="API测试" className="api-test-card">
+ <Space direction="vertical" style={{ width: '100%' }}>
+ <p>您可以使用以下按钮测试不同的API接口:</p>
+ <Space wrap>
+ <Button onClick={handleRefreshProfile} loading={loading}>
+ 测试 GET /profile
+ </Button>
+ <Button onClick={handleLogout}>
+ 测试 POST /logout
+ </Button>
+ <Button
+ onClick={handleTestJWT}
+ loading={jwtTestLoading}
+ type="primary"
+ >
+ 测试 POST /test-jwt
+ </Button>
+ <Button
+ type="dashed"
+ onClick={() => window.open('http://10.126.59.25:8082/health', '_blank')}
+ >
+ 测试 GET /health
+ </Button>
+ </Space>
+ </Space>
+ </Card>
+ </div>
+ </div>
+ );
+};
+
+export default TestDashboard;
diff --git a/Merge/front/src/reportWebVitals.js b/Merge/front/src/reportWebVitals.js
new file mode 100644
index 0000000..5253d3a
--- /dev/null
+++ b/Merge/front/src/reportWebVitals.js
@@ -0,0 +1,13 @@
+const reportWebVitals = onPerfEntry => {
+ if (onPerfEntry && onPerfEntry instanceof Function) {
+ import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => {
+ getCLS(onPerfEntry);
+ getFID(onPerfEntry);
+ getFCP(onPerfEntry);
+ getLCP(onPerfEntry);
+ getTTFB(onPerfEntry);
+ });
+ }
+};
+
+export default reportWebVitals;
diff --git a/Merge/front/src/router/App.js b/Merge/front/src/router/App.js
index 1a7fe0e..e1b9454 100644
--- a/Merge/front/src/router/App.js
+++ b/Merge/front/src/router/App.js
@@ -15,6 +15,11 @@
import UploadPage from '../components/UploadPage' // src/components/UploadPage.jsx
+import LoginPage from '../pages/LoginPage/LoginPage';
+import RegisterPage from '../pages/RegisterPage/RegisterPage';
+import ForgotPasswordPage from '../pages/ForgotPasswordPage/ForgotPasswordPage';
+import TestDashboard from '../pages/TestDashboard/TestDashboard';
+
export default function AppRoutes() {
return (
<Routes>
@@ -31,7 +36,13 @@
<Route path="/dashboard/*" element={<UploadPage />} />
{/* 根路径重定向到 dashboard */}
- <Route path="/" element={<Navigate to="/dashboard/overview" replace />} />
+ {/* <Route path="/" element={<Navigate to="/dashboard/overview" replace />} /> */}
+
+ <Route path="/" element={<LoginPage />} />
+ <Route path="/login" element={<LoginPage />} />
+ <Route path="/register" element={<RegisterPage />} />
+ <Route path="/forgot-password" element={<ForgotPasswordPage />} />
+ <Route path="/test-dashboard" element={<TestDashboard />} />
{/* 最后一个兜底 */}
<Route path="*" element={<PlaceholderPage pageId="home" />} />
diff --git a/Merge/front/src/utils/auth.js b/Merge/front/src/utils/auth.js
new file mode 100644
index 0000000..d04e102
--- /dev/null
+++ b/Merge/front/src/utils/auth.js
@@ -0,0 +1,155 @@
+// 认证相关的工具函数
+
+/**
+ * 获取当前用户的认证token
+ * @returns {string|null} 认证token,如果未登录则返回null
+ */
+export const getAuthToken = () => {
+ // 优先从localStorage获取(记住我的情况)
+ const localToken = localStorage.getItem('authToken');
+ if (localToken) {
+ return localToken;
+ }
+
+ // 然后从sessionStorage获取(不记住我的情况)
+ const sessionToken = sessionStorage.getItem('authToken');
+ return sessionToken;
+};
+
+/**
+ * 获取当前用户信息
+ * @returns {object|null} 用户信息,如果未登录则返回null
+ */
+export const getUserInfo = () => {
+ // 优先从localStorage获取
+ const localUserInfo = localStorage.getItem('userInfo');
+ if (localUserInfo) {
+ try {
+ return JSON.parse(localUserInfo);
+ } catch (error) {
+ console.error('解析localStorage中的用户信息失败:', error);
+ }
+ }
+
+ // 然后从sessionStorage获取
+ const sessionUserInfo = sessionStorage.getItem('userInfo');
+ if (sessionUserInfo) {
+ try {
+ return JSON.parse(sessionUserInfo);
+ } catch (error) {
+ console.error('解析sessionStorage中的用户信息失败:', error);
+ }
+ }
+
+ return null;
+};
+
+/**
+ * 检查用户是否已登录
+ * @returns {boolean} 是否已登录
+ */
+export const isLoggedIn = () => {
+ const token = getAuthToken();
+ return !!token;
+};
+
+/**
+ * 获取记住的登录信息
+ * @returns {object} 包含email, password, rememberMe的对象
+ */
+export const getRememberedLoginInfo = () => {
+ const email = localStorage.getItem('rememberedEmail') || '';
+ const password = localStorage.getItem('rememberedPassword') || '';
+ const rememberMe = localStorage.getItem('rememberMe') === 'true';
+
+ return {
+ email,
+ password,
+ rememberMe
+ };
+};
+
+/**
+ * 保存记住的登录信息
+ * @param {string} email 邮箱
+ * @param {string} password 密码
+ * @param {boolean} remember 是否记住
+ */
+export const saveRememberedLoginInfo = (email, password, remember) => {
+ if (remember) {
+ localStorage.setItem('rememberedEmail', email);
+ localStorage.setItem('rememberedPassword', password);
+ localStorage.setItem('rememberMe', 'true');
+ } else {
+ localStorage.removeItem('rememberedEmail');
+ localStorage.removeItem('rememberedPassword');
+ localStorage.removeItem('rememberMe');
+ }
+};
+
+/**
+ * 保存用户认证信息
+ * @param {string} token 认证token
+ * @param {object} userInfo 用户信息
+ * @param {boolean} remember 是否记住登录状态
+ */
+export const saveAuthInfo = (token, userInfo, remember = false) => {
+ if (remember) {
+ // 记住我:保存到localStorage
+ localStorage.setItem('authToken', token);
+ localStorage.setItem('userInfo', JSON.stringify(userInfo));
+
+ // 清除sessionStorage
+ sessionStorage.removeItem('authToken');
+ sessionStorage.removeItem('userInfo');
+ } else {
+ // 不记住我:保存到sessionStorage
+ sessionStorage.setItem('authToken', token);
+ sessionStorage.setItem('userInfo', JSON.stringify(userInfo));
+
+ // 清除localStorage中的认证信息(但保留记住的登录表单信息)
+ localStorage.removeItem('authToken');
+ localStorage.removeItem('userInfo');
+ }
+};
+
+/**
+ * 清除所有认证信息(退出登录)
+ * @param {boolean} clearRemembered 是否同时清除记住的登录信息
+ */
+export const clearAuthInfo = (clearRemembered = false) => {
+ // 清除认证token和用户信息
+ localStorage.removeItem('authToken');
+ localStorage.removeItem('userInfo');
+ sessionStorage.removeItem('authToken');
+ sessionStorage.removeItem('userInfo');
+
+ // 如果需要,清除记住的登录信息
+ if (clearRemembered) {
+ localStorage.removeItem('rememberedEmail');
+ localStorage.removeItem('rememberedPassword');
+ localStorage.removeItem('rememberMe');
+ }
+};
+
+/**
+ * 创建带认证头的fetch请求配置
+ * @param {object} options 原始fetch配置
+ * @returns {object} 带认证头的fetch配置
+ */
+export const createAuthenticatedRequest = (options = {}) => {
+ const token = getAuthToken();
+
+ if (!token) {
+ throw new Error('用户未登录');
+ }
+
+ return {
+ ...options,
+ headers: {
+ ...options.headers,
+ 'Authorization': `Bearer ${token}`,
+ 'Content-Type': 'application/json'
+ }
+ };
+};
diff --git a/Merge/front/src/utils/crypto.js b/Merge/front/src/utils/crypto.js
new file mode 100644
index 0000000..eac10f0
--- /dev/null
+++ b/Merge/front/src/utils/crypto.js
@@ -0,0 +1,48 @@
+// 密码加密工具函数
+import CryptoJS from 'crypto-js';
+
+/**
+ * 使用 SHA256 加密密码
+ * @param {string} password 原始密码
+ * @returns {string} 加密后的密码
+ */
+export const hashPassword = (password) => {
+ if (!password || typeof password !== 'string') {
+ throw new Error('密码必须是非空字符串');
+ }
+
+ return CryptoJS.SHA256(password).toString();
+};
+
+/**
+ * 验证密码是否已经被加密
+ * @param {string} password 密码字符串
+ * @returns {boolean} 是否为已加密的密码(64位十六进制字符串)
+ */
+export const isEncryptedPassword = (password) => {
+ if (!password || typeof password !== 'string') {
+ return false;
+ }
+
+ // SHA256 加密后是64位十六进制字符串
+ return /^[a-f0-9]{64}$/i.test(password);
+};
+
+/**
+ * 安全的密码加密函数,避免重复加密
+ * @param {string} password 密码
+ * @returns {string} 加密后的密码
+ */
+export const safeHashPassword = (password) => {
+ if (!password) {
+ throw new Error('密码不能为空');
+ }
+
+ // 如果已经是加密的密码,直接返回
+ if (isEncryptedPassword(password)) {
+ return password;
+ }
+
+ // 否则进行加密
+ return hashPassword(password);
+};