zlwu 10 meses atrás
pai
commit
3062de5569

+ 1 - 1
backend/database.py

@@ -65,7 +65,7 @@ class DatabaseManager:
             pool_recycle=settings.MYSQL_POOL_RECYCLE,  # 连接回收
             pool_timeout=5.0,  # 🔥 连接池获取连接超时(秒),避免长时间等待
             echo=False,  # 生产环境关闭 SQL 日志
-            echo_pool="debug",  # ⭐ 新增:打印连接借出/归还的详细日志
+            # echo_pool="debug",  # ⭐ 新增:打印连接借出/归还的详细日志
             future=True,
             connect_args={
                 "connect_timeout": settings.MYSQL_CONNECT_TIMEOUT,  # 🔥 连接超时(aiomysql 支持)

+ 185 - 46
backend/main.py

@@ -1624,12 +1624,17 @@ async def wechat_callback(
 
 
 @app.get("/api/auth/qq/authorize")
-async def qq_authorize(request: Request, from_page: str = Query(None, alias="from", description="来源页面")):
+async def qq_authorize(
+    request: Request, 
+    from_page: str = Query(None, alias="from", description="来源页面"),
+    user_id: str = Query(None, description="可选的现有用户ID,如果提供则更新该用户而不是创建新用户")
+):
     """
     获取QQ授权URL
     
     Args:
         from_page: 来源页面(如 first-login),用于回调时决定重定向位置
+        user_id: 可选的现有用户ID,如果提供则更新该用户而不是创建新用户
     
     Returns:
         重定向到QQ授权页面
@@ -1641,22 +1646,75 @@ async def qq_authorize(request: Request, from_page: str = Query(None, alias="fro
     import secrets
     state_token = secrets.token_urlsafe(32)
     
-    # 如果有来源页面,编码到 state 中(格式:{token}_{from})
-    if from_page:
-        state = f"{state_token}_{from_page}"
-    else:
-        state = state_token
+    # state只存储token,from_page和user_id存储在Redis中(避免token包含下划线导致解析错误)
+    state = state_token
     
     # 将 state 存储到 Redis 以便回调校验(存储原始 token 和来源信息)
     redis_cache = getattr(request.app.state, 'redis_cache', None)
+    state_stored = False
+    state_key = f"oauth:qq:state:{state_token}"
+    
+    logger.info(f"📝 [QQ授权] 开始存储state - state_token={state_token}, from_page={from_page}, user_id={user_id}, state={state}, state_key={state_key}")
+    
     if redis_cache:
         try:
             redis_client = await redis_cache._get_redis()
-            # 存储原始 token 和来源信息
-            state_data = {"token": state_token, "from": from_page} if from_page else {"token": state_token}
-            await redis_client.setex(f"oauth:qq:state:{state_token}", 600, json.dumps(state_data))
+            
+            # 🔥 检查Redis连接状态
+            try:
+                ping_result = await redis_client.ping()
+                logger.debug(f"✅ [QQ授权] Redis连接正常: ping={ping_result}")
+            except Exception as ping_err:
+                logger.error(f"❌ [QQ授权] Redis ping失败: {ping_err}")
+            
+            # 🔥 检查Redis配置信息(DB、连接池等)
+            try:
+                redis_info = await redis_client.info('server')
+                redis_db = redis_client.connection_pool.connection_kwargs.get('db', 0)
+                logger.debug(f"🔍 [QQ授权] Redis配置 - DB={redis_db}, server_version={redis_info.get('redis_version', 'unknown')}")
+            except Exception as info_err:
+                logger.debug(f"⚠️ [QQ授权] 获取Redis信息失败: {info_err}")
+            
+            # 🔥 检查key是否已存在(防止重复)
+            existing_value = await redis_client.get(state_key)
+            if existing_value:
+                logger.warning(f"⚠️ [QQ授权] state key已存在,将被覆盖: key={state_key}, old_value={existing_value[:50]}...")
+            
+            # 存储原始 token、来源信息和 user_id
+            state_data = {"token": state_token}
+            if from_page:
+                state_data["from"] = from_page
+            if user_id:
+                state_data["user_id"] = user_id
+            state_value = json.dumps(state_data)
+            logger.debug(f"📦 [QQ授权] 准备存储 - key={state_key}, value={state_value}, TTL=1800s")
+            
+            # 🔥 增加TTL到1800秒(30分钟),避免授权流程过长导致过期
+            await redis_client.setex(state_key, 1800, state_value)
+            state_stored = True
+            
+            # 🔥 验证存储是否成功
+            verify_value = await redis_client.get(state_key)
+            if verify_value:
+                # 🔥 检查TTL
+                ttl = await redis_client.ttl(state_key)
+                logger.info(f"✅ [QQ授权] QQ state 已存储并验证成功 - key={state_key}, token={state_token[:16]}..., TTL={ttl}s, value_length={len(verify_value)}")
+            else:
+                logger.error(f"❌ [QQ授权] QQ state 存储后验证失败 - key={state_key}, token={state_token[:16]}...")
+                
+                # 🔥 尝试查找所有相关的keys
+                try:
+                    pattern = f"oauth:qq:state:*"
+                    keys = []
+                    async for key in redis_client.scan_iter(match=pattern, count=100):
+                        keys.append(key)
+                    logger.debug(f"🔍 [QQ授权] Redis中存在的QQ state keys: {keys[:10]}... (共{len(keys)}个)")
+                except Exception as scan_err:
+                    logger.debug(f"🔍 [QQ授权] 扫描keys失败: {scan_err}")
         except Exception as e:
-            logger.warning(f"存储 QQ state 失败,将跳过校验: {e}")
+            logger.error(f"❌ [QQ授权] 存储 QQ state 失败 - key={state_key}, error={e}", exc_info=True)
+    else:
+        logger.warning("⚠️ [QQ授权] Redis缓存服务不可用,QQ state 验证将被跳过")
     
     # 确保回调地址格式正确(去除末尾斜杠,确保与QQ应用配置完全一致)
     redirect_uri = settings.QQ_REDIRECT_URI.rstrip('/')
@@ -1697,39 +1755,107 @@ async def qq_callback(
         重定向到前端页面(带token)
     """
     try:
+        logger.info(f"📥 [QQ回调] 收到回调请求 - code={'***' if code else '(empty)'}, state={state}")
+        
         # 0. 验证 state 防止 CSRF,并提取来源信息
         from_page = None
-        state_token = state
+        user_id_from_state = None
+        state_token = state  # state就是token,不再需要分割
         if state:
-            # 解析 state(格式可能是 {token} 或 {token}_{from})
-            if "_" in state:
-                parts = state.split("_", 1)
-                state_token = parts[0]
-                from_page = parts[1] if len(parts) > 1 else None
-            else:
-                state_token = state
+            logger.debug(f"🔍 [QQ回调] 开始解析state - state={state}, length={len(state)}")
             
             redis_cache = getattr(request.app.state, 'redis_cache', None)
+            state_key = f"oauth:qq:state:{state_token}"
+            logger.info(f"🔑 [QQ回调] 准备验证state - state_token={state_token[:16]}..., state_key={state_key}")
+            
             if redis_cache:
                 try:
                     redis_client = await redis_cache._get_redis()
-                    state_key = f"oauth:qq:state:{state_token}"
-                    state_data_str = await redis_client.get(state_key)
-                    if not state_data_str:
-                        logger.warning(f"QQ state 验证失败: {state_token}")
-                        return RedirectResponse(
-                            url=f"{settings.FRONTEND_URL}/register?error=qq_auth_failed&reason=invalid_state"
-                        )
-                    # 如果存储的是 JSON 格式,解析获取来源信息
+                    
+                    # 🔥 检查Redis连接状态
                     try:
-                        state_data = json.loads(state_data_str)
-                        from_page = state_data.get("from") or from_page
-                    except (json.JSONDecodeError, TypeError):
-                        # 如果不是 JSON 格式,使用从 state 中解析的 from_page
-                        pass
-                    await redis_client.delete(state_key)
+                        ping_result = await redis_client.ping()
+                        logger.debug(f"✅ [QQ回调] Redis连接正常: ping={ping_result}")
+                    except Exception as ping_error:
+                        logger.error(f"❌ [QQ回调] Redis ping失败: {ping_error}", exc_info=True)
+                        # Redis连接失败时,跳过验证但记录警告(为了不影响用户体验)
+                        logger.warning("⚠️ [QQ回调] Redis连接失败,跳过state验证(可能存在安全风险)")
+                        # 继续处理,不阻止登录流程
+                    else:
+                        # 🔥 检查Redis配置信息(DB、连接池等)
+                        try:
+                            redis_db = redis_client.connection_pool.connection_kwargs.get('db', 0)
+                            logger.debug(f"🔍 [QQ回调] Redis配置 - DB={redis_db}")
+                        except Exception as info_err:
+                            logger.debug(f"⚠️ [QQ回调] 获取Redis配置失败: {info_err}")
+                        
+                        # 🔥 尝试查找key
+                        logger.debug(f"🔍 [QQ回调] 开始查找key: {state_key}")
+                        state_data_str = await redis_client.get(state_key)
+                        
+                        if not state_data_str:
+                            # 🔥 增加详细日志,帮助诊断问题
+                            logger.warning(f"❌ [QQ回调] QQ state 验证失败 - state_token={state_token[:16]}..., state_key={state_key}")
+                            
+                            # 🔥 检查key的TTL(如果key存在但值为None,可能是其他问题)
+                            try:
+                                exists = await redis_client.exists(state_key)
+                                logger.debug(f"🔍 [QQ回调] key存在性检查: exists={exists}")
+                                if exists:
+                                    ttl = await redis_client.ttl(state_key)
+                                    logger.debug(f"🔍 [QQ回调] key存在但值为空,TTL={ttl}s")
+                            except Exception as exists_err:
+                                logger.debug(f"🔍 [QQ回调] 检查key存在性失败: {exists_err}")
+                            
+                            # 🔥 尝试查找所有相关的state key(用于调试)
+                            try:
+                                pattern = f"oauth:qq:state:*"
+                                keys = []
+                                async for key in redis_client.scan_iter(match=pattern, count=100):
+                                    keys.append(key)
+                                if keys:
+                                    logger.warning(f"🔍 [QQ回调] Redis中存在其他QQ state keys (共{len(keys)}个): {keys[:10]}")
+                                    # 🔥 检查这些keys的TTL
+                                    for key in keys[:5]:
+                                        try:
+                                            ttl = await redis_client.ttl(key)
+                                            value = await redis_client.get(key)
+                                            logger.debug(f"🔍 [QQ回调] key详情 - {key}: TTL={ttl}s, value_length={len(value) if value else 0}")
+                                        except Exception as key_err:
+                                            logger.debug(f"🔍 [QQ回调] 获取key详情失败 {key}: {key_err}")
+                                else:
+                                    logger.warning(f"🔍 [QQ回调] Redis中不存在任何QQ state keys")
+                            except Exception as scan_error:
+                                logger.debug(f"🔍 [QQ回调] 扫描Redis keys失败: {scan_error}")
+                            
+                            return RedirectResponse(
+                                url=f"{settings.FRONTEND_URL}/register?error=qq_auth_failed&reason=invalid_state"
+                            )
+                        
+                        # 🔥 找到key,记录详细信息
+                        ttl = await redis_client.ttl(state_key)
+                        logger.info(f"✅ [QQ回调] 找到state key - key={state_key}, TTL={ttl}s, value_length={len(state_data_str)}")
+                        
+                        # 如果存储的是 JSON 格式,解析获取来源信息和user_id
+                        try:
+                            state_data = json.loads(state_data_str)
+                            from_page = state_data.get("from") or from_page
+                            user_id_from_state = state_data.get("user_id")
+                            logger.info(f"✅ [QQ回调] QQ state 验证成功 - state_token={state_token[:16]}..., 来源: {from_page}, user_id: {user_id_from_state}, data={state_data}")
+                        except (json.JSONDecodeError, TypeError) as json_err:
+                            # 如果不是 JSON 格式,使用从 state 中解析的 from_page
+                            logger.debug(f"✅ [QQ回调] QQ state 验证成功 (非JSON格式) - state_token={state_token[:16]}..., value={state_data_str[:50]}..., json_err={json_err}")
+                            pass
+                        
+                        # 🔥 删除已使用的state key
+                        deleted = await redis_client.delete(state_key)
+                        logger.debug(f"🗑️ [QQ回调] 删除state key - key={state_key}, deleted={deleted}")
+                        
                 except Exception as state_error:
-                    logger.warning(f"QQ state 验证异常: {state_error},继续流程")
+                    logger.error(f"❌ [QQ回调] QQ state 验证异常 - state_token={state_token[:16]}..., error={state_error}", exc_info=True)
+                    # 🔥 验证异常时不阻止流程,但记录详细错误
+            else:
+                logger.warning("⚠️ [QQ回调] Redis缓存服务不可用,跳过state验证")
         
         if not settings.QQ_APP_ID or not settings.QQ_APP_KEY:
             raise HTTPException(status_code=500, detail="QQ OAuth未配置")
@@ -1847,50 +1973,63 @@ async def qq_callback(
         if from_page == "login":
             # 从 login 页面来的,需要先检查用户是否存在
             # 如果用户不存在,不允许自动注册,应该提示用户先注册
+            logger.info(f"🔍 [QQ回调] 开始检查用户是否存在 - openid={openid[:16]}..., unionid={unionid[:16] if unionid else None}...")
             user_exists = await user_service.check_user_exists_by_qq_openid(openid=openid, unionid=unionid)
+            logger.info(f"🔍 [QQ回调] 用户存在性检查结果 - user_exists={user_exists}, openid={openid[:16]}...")
             
             if not user_exists:
-                # 用户不存在,重定向到 welcome 页面并提示
-                logger.info(f"⚠️ QQ用户未注册(来自login页面): openid={openid}")
+                # 用户不存在,重定向到 begin 页面并提示(和手机号登录保持一致)
+                logger.info(f"⚠️ QQ用户未注册(来自login页面): openid={openid[:16]}..., unionid={unionid[:16] if unionid else None}...")
                 return RedirectResponse(
-                    url=f"{settings.FRONTEND_URL}/welcome?error=user_not_registered&oauth=qq"
+                    url=f"{settings.FRONTEND_URL}/begin?error=user_not_registered&oauth=qq"
                 )
             
             # 用户存在,使用openid登录
+            # 不传递QQ返回的nickname,避免覆盖用户原有用户名
+            logger.info(f"✅ [QQ回调] 用户存在,开始登录 - openid={openid[:16]}...")
             user = await user_service.login_with_qq(
                 openid=openid,
-                username=nickname,
+                username="",  # 不使用QQ返回的nickname
                 unionid=unionid,
             )
             
-            # 重定向到聊天界面
-            frontend_url = f"{settings.FRONTEND_URL}/chatbot?oauth=qq&user_id={user['user_id']}&username={urllib.parse.quote(user['username'])}"
+            # 🔧 生成会话 token(前端需要 token 来调用 getUserInfo API)
+            session_data = await _generate_session_token(user['user_id'])
+            token = session_data['token']
+            logger.info(f"✅ [QQ回调] 已生成会话 token - user_id={user['user_id']}, token={token[:16]}...")
+            
+            # 重定向到聊天界面(传递 token,避免前端调用 ensureSession 时使用错误的 token)
+            logger.info(f"✅ [QQ回调] 登录成功,准备重定向到聊天界面 - user_id={user['user_id']}")
+            frontend_url = f"{settings.FRONTEND_URL}/chatbot?oauth=qq&user_id={user['user_id']}&token={token}"
             return RedirectResponse(url=frontend_url)
         
         elif from_page == "first-login":
             # 从 first-login 页面来的,允许自动注册
-            # 使用openid登录/注册(login_with_qq 会自动创建新用户)
+            # 如果提供了 user_id,则更新现有用户;否则创建新用户
+            # 不传递QQ返回的nickname,避免覆盖用户原有用户名
             user = await user_service.login_with_qq(
                 openid=openid,
-                username=nickname,
+                username="",  # 不使用QQ返回的nickname
                 unionid=unionid,
+                user_id=user_id_from_state,
             )
             
-            # 重定向到 first-login-reward
-            frontend_url = f"{settings.FRONTEND_URL}/first-login-reward?oauth=qq&user_id={user['user_id']}&username={urllib.parse.quote(user['username'])}"
+            # 重定向到 first-login-reward(不传递username,避免使用QQ返回的nickname)
+            frontend_url = f"{settings.FRONTEND_URL}/first-login-reward?oauth=qq&user_id={user['user_id']}"
             return RedirectResponse(url=frontend_url)
         
         else:
             # 其他来源(如 register 页面),允许自动注册
             # 使用openid登录/注册(login_with_qq 会自动创建新用户)
+            # 不传递QQ返回的nickname,避免使用QQ昵称作为用户名
             user = await user_service.login_with_qq(
                 openid=openid,
-                username=nickname,
+                username="",  # 不使用QQ返回的nickname
                 unionid=unionid,
             )
             
-            # 默认重定向到聊天界面
-            frontend_url = f"{settings.FRONTEND_URL}/chatbot?oauth=qq&user_id={user['user_id']}&username={urllib.parse.quote(user['username'])}"
+            # 默认重定向到聊天界面(不传递username,由前端从数据库同步)
+            frontend_url = f"{settings.FRONTEND_URL}/chatbot?oauth=qq&user_id={user['user_id']}"
             return RedirectResponse(url=frontend_url)
         
     except Exception as e:

+ 87 - 29
backend/services/user_service.py

@@ -915,6 +915,7 @@ class UserService:
         openid: str,
         username: str = "",
         unionid: Optional[str] = None,
+        user_id: Optional[str] = None,
     ) -> Dict:
         """
         使用QQ OpenID登录/注册
@@ -922,6 +923,8 @@ class UserService:
         Args:
             openid: QQ OpenID
             username: 用户名(可选,仅新用户时使用)
+            unionid: QQ UnionID(可选)
+            user_id: 可选的现有用户ID,如果提供则更新该用户而不是创建新用户
         
         Returns:
             用户信息字典
@@ -951,6 +954,10 @@ class UserService:
                         )
 
                 if existing_user:
+                    # 如果提供了 user_id,检查是否是同一个用户
+                    if user_id and existing_user.user_id != user_id:
+                        raise ValueError("该QQ账号已被其他用户绑定")
+                    
                     updated = False
                     if unionid and not existing_user.qq_unionid:
                         existing_user.qq_unionid = unionid
@@ -965,41 +972,88 @@ class UserService:
                     )
                     return existing_user.to_dict()
                 
-                # 新用户,创建账号
-                user_id = str(uuid.uuid4())
-                if not username or username.strip() == "":
-                    username = await self._generate_temp_username(session)
-                
-                user = User(
-                    user_id=user_id,
-                    username=username,
-                    qq_openid=openid,
-                    qq_unionid=unionid,
-                    user_type="registered",
-                    is_verified=True,
-                    registered_at=datetime.now(),
-                    current_background_image_id="default",  # 设置默认背景图片
-                )
-                session.add(user)
-                await session.flush()  # 先 flush 确保 user 有 ID,但不提交事务
-                
-                # 初始化背景图片
-                await self._init_user_background_image(session, user_id)
-                
-                # 初始化头像(注册用户:默认头像 + 注册赠送头像)
-                await self._init_user_avatar(session, user_id, user_type="registered")
-                
-                await session.commit()
-                await session.refresh(user)
+                # 如果提供了 user_id,使用现有用户
+                final_user_id = None
+                if user_id:
+                    result = await session.execute(
+                        select(User).where(User.user_id == user_id)
+                    )
+                    existing_user_by_id = result.scalar_one_or_none()
+                    
+                    if not existing_user_by_id:
+                        raise ValueError(f"用户ID {user_id} 不存在")
+                    
+                    if existing_user_by_id.user_type == "registered":
+                        raise ValueError("该用户已经注册过了")
+                    
+                    # 更新现有用户
+                    if not username or username.strip() == "":
+                        # 如果用户已有用户名,保留;否则生成新用户名
+                        if not existing_user_by_id.username or existing_user_by_id.username.strip() == "":
+                            username = await self._generate_temp_username(session)
+                        else:
+                            username = existing_user_by_id.username
+                    
+                    existing_user_by_id.qq_openid = openid
+                    if unionid:
+                        existing_user_by_id.qq_unionid = unionid
+                    existing_user_by_id.username = username
+                    existing_user_by_id.user_type = "registered"
+                    existing_user_by_id.is_verified = True
+                    existing_user_by_id.registered_at = datetime.now()
+                    
+                    user = existing_user_by_id
+                    final_user_id = user_id
+                    await session.flush()
+                    
+                    # 如果用户还没有背景图片,初始化背景图片
+                    if not existing_user_by_id.current_background_image_id or existing_user_by_id.current_background_image_id == "default":
+                        await self._init_user_background_image(session, user_id)
+                    
+                    # 初始化头像(注册用户:默认头像 + 注册赠送头像)
+                    await self._init_user_avatar(session, user_id, user_type="registered")
+                    
+                    await session.commit()
+                    await session.refresh(user)
+                else:
+                    # 新用户,创建账号
+                    new_user_id = str(uuid.uuid4())
+                    if not username or username.strip() == "":
+                        username = await self._generate_temp_username(session)
+                    
+                    user = User(
+                        user_id=new_user_id,
+                        username=username,
+                        qq_openid=openid,
+                        qq_unionid=unionid,
+                        user_type="registered",
+                        is_verified=True,
+                        registered_at=datetime.now(),
+                        current_background_image_id="default",  # 设置默认背景图片
+                    )
+                    session.add(user)
+                    await session.flush()  # 先 flush 确保 user 有 ID,但不提交事务
+                    
+                    final_user_id = new_user_id
+                    
+                    # 初始化背景图片
+                    await self._init_user_background_image(session, new_user_id)
+                    
+                    # 初始化头像(注册用户:默认头像 + 注册赠送头像)
+                    await self._init_user_avatar(session, new_user_id, user_type="registered")
+                    
+                    await session.commit()
+                    await session.refresh(user)
             
             # 初始化 user_ui_states 的 current_avatar_id(注册用户优先使用注册赠送头像)
+            final_user_id = final_user_id if final_user_id else user.user_id
             await self.upsert_user_ui_state(
-                user_id=user_id,
+                user_id=final_user_id,
                 current_avatar_id=REGISTERED_AVATAR_ID
             )
             
             logger.info(
-                f"✅ QQ注册成功: user_id={user_id}, openid={openid}, unionid={unionid}"
+                f"✅ QQ注册成功: user_id={final_user_id}, openid={openid}, unionid={unionid}"
             )
             return user.to_dict()
         except Exception as e:
@@ -1084,6 +1138,7 @@ class UserService:
                     select(User).where(User.qq_openid == openid)
                 )
                 existing_user = result.scalar_one_or_none()
+                logger.debug(f"🔍 [检查QQ用户] openid查询结果 - openid={openid[:16]}..., found={existing_user is not None}, user_id={existing_user.user_id if existing_user else None}")
                 
                 # 如果未命中且拿到了 unionid,则尝试用 unionid 查询
                 if not existing_user and unionid:
@@ -1091,8 +1146,11 @@ class UserService:
                         select(User).where(User.qq_unionid == unionid)
                     )
                     existing_user = union_result.scalar_one_or_none()
+                    logger.debug(f"🔍 [检查QQ用户] unionid查询结果 - unionid={unionid[:16] if unionid else None}..., found={existing_user is not None}, user_id={existing_user.user_id if existing_user else None}")
                 
-                return existing_user is not None
+                exists = existing_user is not None
+                logger.info(f"🔍 [检查QQ用户] 最终结果 - openid={openid[:16]}..., unionid={unionid[:16] if unionid else None}..., exists={exists}")
+                return exists
         except Exception as e:
             logger.error(f"❌ 检查QQ用户是否存在失败: {e}")
             return False

+ 85 - 3
frontend/src/components/UserGuard.tsx

@@ -5,10 +5,12 @@ import {
   getUserLevel, 
   getStorage, 
   getUserId,
+  setStorage,
+  setUserToken,
   STORAGE_KEYS,
   syncUserDataFromDatabase 
 } from '@/utils/storage';
-import { getUserInfo, getUserUIState } from '@/utils/api';
+import { getUserInfo, getUserUIState, ensureSession } from '@/utils/api';
 
 /**
  * 用户ID守卫组件
@@ -20,6 +22,7 @@ export const UserGuard = ({ children }: { children: React.ReactNode }) => {
   const navigate = useNavigate();
   const location = useLocation();
   const isCheckingRef = useRef(false); // 防止重复检查
+  const oauthUserIdRef = useRef<string | null>(null); // 记录 OAuth 回调的用户ID,避免被覆盖
 
   useEffect(() => {
     // 根路由交给 RootRedirect 处理
@@ -36,7 +39,71 @@ export const UserGuard = ({ children }: { children: React.ReactNode }) => {
         return;
       }
 
-      const userId = getUserId();
+      // 🔧 优先处理 OAuth 回调参数(QQ/微信登录)
+      // 如果 URL 中有 oauth 和 user_id 参数,立即同步设置到 localStorage,避免时序问题
+      // 注意:不设置 username,由后续的数据库同步逻辑来设置,避免使用QQ返回的nickname
+      const searchParams = new URLSearchParams(location.search);
+      const oauth = searchParams.get('oauth');
+      const userIdFromUrl = searchParams.get('user_id');
+      const tokenFromUrl = searchParams.get('token');
+      
+      // 🔧 标记是否正在处理 OAuth 回调,避免后续逻辑覆盖用户ID
+      const isOAuthCallback = !!(oauth && userIdFromUrl);
+      
+      if (isOAuthCallback) {
+        // ✅ OAuth 登录回调,立即同步设置到 localStorage(同步操作,不等待异步)
+        console.log(`[UserGuard] 🔧 检测到 OAuth 回调,立即设置到 localStorage - oauth=${oauth}, user_id=${userIdFromUrl}, token=${tokenFromUrl ? '已提供' : '未提供'}`);
+        
+        // 1. 立即设置用户 ID(同步操作)
+        setStorage(STORAGE_KEYS.USER_ID, userIdFromUrl);
+        
+        // 2. 立即设置登录状态(同步操作)
+        setStorage(STORAGE_KEYS.IS_LOGGED_IN, 'true');
+        
+        // 3. 如果 URL 中有 token,立即设置(同步操作)
+        if (tokenFromUrl) {
+          setUserToken(tokenFromUrl);
+          console.log(`[UserGuard] ✅ 已设置 OAuth token 到 localStorage`);
+        }
+        
+        // 4. 记录 OAuth 用户ID,避免后续被覆盖
+        oauthUserIdRef.current = userIdFromUrl;
+        
+        console.log(`[UserGuard] ✅ OAuth 回调参数已全部设置到 localStorage - USER_ID=${userIdFromUrl}, IS_LOGGED_IN=true, TOKEN=${tokenFromUrl ? '已设置' : '未提供'}`);
+        
+        // 🔧 如果没有 token,调用 ensureSession 初始化(兼容旧逻辑)
+        // 注意:此时 localStorage 中已有 user_id,ensureSession 不会创建新用户
+        if (!tokenFromUrl) {
+          try {
+            const session = await ensureSession();
+            console.log(`[UserGuard] ensureSession 完成 - user_id=${session.userId}`);
+            // 确保用户ID没有被覆盖
+            if (session.userId !== userIdFromUrl) {
+              console.warn(`[UserGuard] ensureSession 返回的用户ID不匹配,使用URL中的用户ID - session.userId=${session.userId}, userIdFromUrl=${userIdFromUrl}`);
+              setStorage(STORAGE_KEYS.USER_ID, userIdFromUrl);
+            }
+          } catch (error) {
+            console.warn('[UserGuard] OAuth回调时初始化会话失败:', error);
+            // 会话初始化失败不影响流程,继续执行后续检查
+          }
+        }
+        // 注意:这里不直接 return,让后续的检查逻辑继续执行,同步用户数据
+      }
+
+      let userId = getUserId();
+      
+      // 🔧 如果之前处理过 OAuth 回调,但当前 localStorage 中的 user_id 不匹配,说明被覆盖了,需要恢复
+      if (oauthUserIdRef.current && userId !== oauthUserIdRef.current) {
+        console.warn(`[UserGuard] 检测到用户ID被覆盖,恢复 OAuth 用户ID - 当前userId=${userId}, OAuth userId=${oauthUserIdRef.current}`);
+        setStorage(STORAGE_KEYS.USER_ID, oauthUserIdRef.current);
+        // 重新获取正确的用户ID
+        userId = getUserId();
+        if (!userId) {
+          console.error('[UserGuard] 恢复用户ID失败');
+          return;
+        }
+        console.log(`[UserGuard] 已恢复 OAuth 用户ID - userId=${userId}`);
+      }
 
       // 情况1: 没有 user_id
       if (!userId) {
@@ -52,11 +119,19 @@ export const UserGuard = ({ children }: { children: React.ReactNode }) => {
       isCheckingRef.current = true;
       try {
         
+        // 🔧 如果之前处理过 OAuth 回调,但当前用户ID不匹配,先恢复用户ID再获取信息
+        if (oauthUserIdRef.current && userId !== oauthUserIdRef.current) {
+          console.warn(`[UserGuard] 检测到用户ID不匹配,恢复 OAuth 用户ID - 当前userId=${userId}, OAuth userId=${oauthUserIdRef.current}`);
+          setStorage(STORAGE_KEYS.USER_ID, oauthUserIdRef.current);
+          userId = oauthUserIdRef.current;
+        }
+        
         // 并行获取用户信息和 UI 状态
+        console.log(`[UserGuard] 开始获取用户信息 - userId=${userId}, oauthUserId=${oauthUserIdRef.current}`);
         const [userInfoResult, uiStateResult] = await Promise.all([
           getUserInfo(userId).catch((error) => {
             // 如果用户不存在(404)或其他错误,返回 null
-            console.warn('[UserGuard] 获取用户信息失败:', error);
+            console.warn(`[UserGuard] 获取用户信息失败 - userId=${userId}, error:`, error);
             return null;
           }),
           getUserUIState(userId).catch((error) => {
@@ -68,7 +143,14 @@ export const UserGuard = ({ children }: { children: React.ReactNode }) => {
 
         // 检查用户是否存在
         if (!userInfoResult || !userInfoResult.success || !userInfoResult.data) {
+          // 🔧 如果是 OAuth 回调且用户不存在,可能是 token 问题,不清空 localStorage
+          if (oauthUserIdRef.current) {
+            console.error(`[UserGuard] OAuth 用户信息获取失败,但不清空 localStorage - userId=${userId}, oauthUserId=${oauthUserIdRef.current}`);
+            // 不清空,让用户重试(可能是网络问题或 token 问题)
+            return;
+          }
           // 用户不存在,清空 localStorage 并跳转
+          console.warn(`[UserGuard] 用户不存在,清空 localStorage 并跳转到 /welcome - userId=${userId}`);
           clearStorage();
           navigate('/welcome', { replace: true });
           return;

+ 6 - 6
frontend/src/pages/BeginPage.tsx

@@ -45,15 +45,15 @@ export const BeginPage = () => {
     
     if (error === 'user_not_registered' && oauth === 'qq') {
       antMessage.error('用户未注册,请先注册');
-      // 清除URL参数
-      navigate('/welcome', { replace: true });
+      // 清除URL参数(保持在当前页面)
+      navigate('/begin', { replace: true });
     }
   }, [searchParams, navigate]);
 
   useEffect(() => {
     // 记录用户当前所在页面
-    const ok = setStorage(STORAGE_KEYS.CURRENT_PAGE, 'login');
-    console.log('[BeginPage] set CURRENT_PAGE=login result:', ok);
+    const ok = setStorage(STORAGE_KEYS.CURRENT_PAGE, 'begin');
+    console.log('[BeginPage] set CURRENT_PAGE=begin result:', ok);
     if (!getStorage(STORAGE_KEYS.USER_LEVEL)) {
       setStorage(STORAGE_KEYS.USER_LEVEL, '0');
     }
@@ -62,14 +62,14 @@ export const BeginPage = () => {
     setStorage(STORAGE_KEYS.HAS_SHARED_AVATAR, 'false');
     
     // 记录路由访问
-    markRouteVisited('/login');
+    markRouteVisited('/begin');
 
     // 将页面与路由状态同步到后端(如果已有 user_id)
     const userId = getUserId();
     if (userId) {
       const visited = getVisitedRoutes();
       updateUserUIState(userId, {
-        current_page: 'login',
+        current_page: 'begin',
         visited_routes: visited,
       }).catch((error) => {
         console.warn('[BeginPage] 同步页面状态失败:', error);

+ 53 - 16
frontend/src/pages/ChatBotPage.tsx

@@ -23,7 +23,9 @@ import {
   syncBackgroundImageToStorage,
   setUserName,
   getUserToken,
+  setUserToken,
   clearUserSessionAndRedirectToBegin,
+  ROUTE_ORDER,
 } from '@/utils/storage';
 import { onScreenResize, calculateLayout, type CalculatedLayout, updateTooSmallFlag } from '@/utils/screenSize';
 import { performanceLogger } from '@/utils/logger';
@@ -1321,15 +1323,32 @@ export const ChatBotPage = () => {
   useEffect(() => {
     const oauth = searchParams.get('oauth');
     const userId = searchParams.get('user_id');
-    const usernameParam = searchParams.get('username');
+    const token = searchParams.get('token');
     
-    if (oauth === 'qq' && userId && usernameParam) {
-      // QQ登录成功,保存用户信息
+    // 如果 URL 中有 OAuth 回调参数,说明是登录成功回调
+    // ✅ 确保 user_id 和 token 已设置到 localStorage(双重保险,避免时序问题)
+    if (oauth === 'qq' && userId) {
+      console.log(`[ChatBotPage] 🔧 检测到 QQ OAuth 回调,确保参数已设置到 localStorage - user_id=${userId}, token=${token ? '已提供' : '未提供'}`);
+      
+      // 1. 确保用户 ID 已设置(同步操作)
       setStorage(STORAGE_KEYS.USER_ID, userId);
-      setStorage(STORAGE_KEYS.USER_NAME, decodeURIComponent(usernameParam));
+      
+      // 2. 如果 URL 中有 token,确保已设置(同步操作)
+      if (token) {
+        setUserToken(token);
+        console.log(`[ChatBotPage] ✅ 已设置 token 到 localStorage`);
+      }
+      
+      // 3. 确保登录状态已设置(同步操作)
+      setStorage(STORAGE_KEYS.IS_LOGGED_IN, 'true');
+      
+      console.log(`[ChatBotPage] ✅ QQ OAuth 回调参数已全部设置到 localStorage`);
       antMessage.success('QQ登录成功');
-      // 清除URL参数
-      navigate('/chatbot', { replace: true });
+      
+      // 清除URL参数(延迟清除,确保 UserGuard 能读取到参数)
+      setTimeout(() => {
+        navigate('/chatbot', { replace: true });
+      }, 100);
     }
   }, [searchParams, navigate]);
 
@@ -1446,12 +1465,21 @@ export const ChatBotPage = () => {
             setStorage(STORAGE_KEYS.CURRENT_PAGE, state.current_page);
           }
           
-          // Lyrria_visited_routes (无论是否为空数组,都从数据库同步)
+          // Lyrria_visited_routes (合并本地和数据库的路由,避免覆盖本地刚记录的路由)
+          const localVisited = getVisitedRoutes();
           if (Array.isArray(state.visited_routes)) {
-            setStorage(STORAGE_KEYS.VISITED_ROUTES, JSON.stringify(state.visited_routes));
+            // 合并本地和数据库的路由,去重并保持顺序
+            const mergedRoutes = [...new Set([...localVisited, ...state.visited_routes])];
+            // 按照 ROUTE_ORDER 的顺序排序
+            const sortedRoutes = ROUTE_ORDER.filter(route => mergedRoutes.includes(route));
+            setStorage(STORAGE_KEYS.VISITED_ROUTES, JSON.stringify(sortedRoutes));
           } else if (state.visited_routes === null || state.visited_routes === undefined) {
-            // 如果数据库中是 null 或 undefined,设置为空数组
-            setStorage(STORAGE_KEYS.VISITED_ROUTES, JSON.stringify([]));
+            // 如果数据库中是 null 或 undefined,保留本地已有的路由
+            if (localVisited.length > 0) {
+              setStorage(STORAGE_KEYS.VISITED_ROUTES, JSON.stringify(localVisited));
+            } else {
+              setStorage(STORAGE_KEYS.VISITED_ROUTES, JSON.stringify([]));
+            }
           }
           
           // Lyrria_has_shown_tap_to_chat
@@ -1773,12 +1801,21 @@ export const ChatBotPage = () => {
         playerRef.current = player;
 
         // 初始化WebSocket前确保会话已就绪(支持用户直接刷新/直达聊天页)
-        try {
-          await ensureSession();
-        } catch (error) {
-          console.error('[ChatBotPage] ensureSession 失败,无法初始化 WebSocket:', error);
-          clearUserSessionAndRedirectToBegin();
-          return;
+        // 🔧 如果 URL 中有 OAuth 回调参数,说明正在处理登录回调,不调用 ensureSession(避免创建新用户)
+        const oauthFromUrl = searchParams.get('oauth');
+        const userIdFromUrl = searchParams.get('user_id');
+        
+        if (!oauthFromUrl || !userIdFromUrl) {
+          // 只有在非 OAuth 回调时才调用 ensureSession
+          try {
+            await ensureSession();
+          } catch (error) {
+            console.error('[ChatBotPage] ensureSession 失败,无法初始化 WebSocket:', error);
+            clearUserSessionAndRedirectToBegin();
+            return;
+          }
+        } else {
+          console.log(`[ChatBotPage] 检测到 OAuth 回调,跳过 ensureSession - oauth=${oauthFromUrl}, user_id=${userIdFromUrl}`);
         }
 
         // 初始化WebSocket(添加 user_id + token 查询参数,单活)

+ 21 - 28
frontend/src/pages/FirstLoginPage.tsx

@@ -1,8 +1,8 @@
 import { useEffect, useState, useRef } from 'react';
 import { useLocation, useNavigate, useSearchParams } from 'react-router-dom';
 import { message } from 'antd';
-import { setStorage, STORAGE_KEYS, getUserId, getVisitedRoutes, setUserName } from '@/utils/storage';
-import { updateUserUIState, getQQAuthUrl, getUserInfo } from '@/utils/api';
+import { setStorage, STORAGE_KEYS, getUserId, getVisitedRoutes } from '@/utils/storage';
+import { updateUserUIState, getQQAuthUrl, getUserInfo, getUserUIState } from '@/utils/api';
 import { px2vmin } from '@/utils/viewport';
 import { getOssUrl } from '@/utils/oss';
 import { validatePhoneNumber, isValidEmail } from '@/utils/validation';
@@ -83,7 +83,6 @@ export const FirstLoginPage = () => {
   useEffect(() => {
     const oauth = searchParams.get('oauth');
     const userId = searchParams.get('user_id');
-    const usernameParam = searchParams.get('username');
     const error = searchParams.get('error');
 
     if (error === 'qq_auth_failed') {
@@ -101,38 +100,31 @@ export const FirstLoginPage = () => {
       return;
     }
 
-    if (oauth === 'qq' && userId && usernameParam) {
+    if (oauth === 'qq' && userId) {
       // QQ登录/注册成功,保存用户信息并完成登录流程
       const handleQQLoginSuccess = async () => {
         try {
-          // 保存基本用户信息
-          setStorage(STORAGE_KEYS.USER_ID, userId);
-          setStorage(STORAGE_KEYS.USER_NAME, decodeURIComponent(usernameParam));
-          
-          // 设置登录状态
-          setStorage(STORAGE_KEYS.IS_LOGGED_IN, 'true');
-          
-          // 获取完整用户信息并同步到 localStorage(刷新数据)
+          // 从数据库同步所有用户数据到 localStorage
           try {
-            const userInfo = await getUserInfo(userId);
+            const [userInfo, uiState] = await Promise.all([
+              getUserInfo(userId),
+              getUserUIState(userId),
+            ]);
+            
             if (userInfo.success && userInfo.data) {
-              // 同步用户名
-              if (userInfo.data.username) {
-                setUserName(userInfo.data.username);
-              }
-              // 同步机器人名字
-              if (userInfo.data.bot_name) {
-                setStorage(STORAGE_KEYS.LUMI_NAME, userInfo.data.bot_name);
-              }
-              // 同步用户等级
-              if (typeof userInfo.data.user_level === 'number') {
-                setStorage(STORAGE_KEYS.USER_LEVEL, String(userInfo.data.user_level));
-              }
-              // 背景图片会在 getUserInfo 中自动同步
+              // 使用 syncUserDataFromDatabase 完整同步所有数据
+              const { syncUserDataFromDatabase } = await import('@/utils/storage');
+              syncUserDataFromDatabase(
+                userInfo.data,
+                uiState.success && uiState.data ? uiState.data : undefined
+              );
             }
           } catch (error) {
             // 获取完整用户信息失败不影响登录流程,静默处理
             console.warn('获取完整用户信息失败:', error);
+            // 至少保存基本用户信息
+            setStorage(STORAGE_KEYS.USER_ID, userId);
+            setStorage(STORAGE_KEYS.IS_LOGGED_IN, 'true');
           }
           
           message.success('QQ登录成功!已获得帽子奖励');
@@ -471,9 +463,10 @@ export const FirstLoginPage = () => {
               },
             });
           } else if (selectedLoginType === 'qq') {
-            // QQ登录:跳转到QQ授权页面(传递来源信息)
+            // QQ登录:跳转到QQ授权页面(传递来源信息和当前user_id)
             try {
-              const qqAuthUrl = getQQAuthUrl('first-login');
+              const currentUserId = getUserId();
+              const qqAuthUrl = getQQAuthUrl('first-login', currentUserId);
               window.location.href = qqAuthUrl;
             } catch (error) {
               message.error('QQ登录失败,请重试');

+ 47 - 2
frontend/src/pages/FirstLoginRewardPage.tsx

@@ -1,5 +1,5 @@
 import { useEffect, useRef, useState, type TouchEvent } from 'react';
-import { useNavigate } from 'react-router-dom';
+import { useNavigate, useSearchParams } from 'react-router-dom';
 import { message } from 'antd';
 import { 
   setStorage, 
@@ -11,7 +11,7 @@ import {
   syncBackgroundImageToStorage,
   setHasSharedAvatar,
 } from '@/utils/storage';
-import { updateUserUIState, claimShareReward } from '@/utils/api';
+import { updateUserUIState, claimShareReward, getUserUIState, getUserInfo } from '@/utils/api';
 import { px2vmin, px2vw } from '@/utils/viewport';
 import { getOssUrl } from '@/utils/oss';
 
@@ -32,11 +32,56 @@ import { getShareImages, type ShareImageItem } from '@/utils/api';
 
 export const FirstLoginRewardPage = () => {
   const navigate = useNavigate();
+  const [searchParams] = useSearchParams();
   const [showShareModal, setShowShareModal] = useState(false);
   const [shareImages, setShareImages] = useState<ShareImageItem[]>([]);
   const [activeShareIndex, setActiveShareIndex] = useState(0);
   const touchStartXRef = useRef<number | null>(null);
 
+  // 处理QQ OAuth回调
+  useEffect(() => {
+    const oauth = searchParams.get('oauth');
+    const userId = searchParams.get('user_id');
+
+    if (oauth === 'qq' && userId) {
+      // QQ登录/注册成功,保存用户信息并完成登录流程
+      const handleQQLoginSuccess = async () => {
+        try {
+          // 从数据库同步所有用户数据到 localStorage
+          try {
+            const [userInfo, uiState] = await Promise.all([
+              getUserInfo(userId),
+              getUserUIState(userId),
+            ]);
+            
+            if (userInfo.success && userInfo.data) {
+              // 使用 syncUserDataFromDatabase 完整同步所有数据
+              const { syncUserDataFromDatabase } = await import('@/utils/storage');
+              syncUserDataFromDatabase(
+                userInfo.data,
+                uiState.success && uiState.data ? uiState.data : undefined
+              );
+            }
+          } catch (error) {
+            // 获取完整用户信息失败不影响登录流程,静默处理
+            console.warn('获取完整用户信息失败:', error);
+            // 至少保存基本用户信息
+            setStorage(STORAGE_KEYS.USER_ID, userId);
+            setStorage(STORAGE_KEYS.IS_LOGGED_IN, 'true');
+          }
+          
+          // 清除URL参数
+          navigate('/first-login-reward', { replace: true });
+        } catch (error) {
+          console.error('处理QQ登录成功回调失败:', error);
+          message.error('登录处理失败,请重试');
+        }
+      };
+
+      handleQQLoginSuccess();
+    }
+  }, [searchParams, navigate]);
+
   useEffect(() => {
     // 记录用户当前所在页面
     setStorage(STORAGE_KEYS.CURRENT_PAGE, 'first-login-reward');

+ 2 - 1
frontend/src/pages/GreetingPage.tsx

@@ -48,11 +48,12 @@ export const GreetingPage = () => {
     
     // 记录路由访问
     markRouteVisited('/naming');
-    const visited = getVisitedRoutes();
 
     // 将 API 调用放在下一个事件循环,不阻塞 UI 渲染
     // 注意:如果数据已在 welcome 页面预加载完成,这里会跳过大部分 API 调用
     const loadUserData = async () => {
+      // 在同步到数据库之前,重新获取已访问路由列表,确保包含最新的路由
+      const visited = getVisitedRoutes();
       
       // 检查数据是否已经预加载完成(userId 存在且 localStorage 中有用户名)
       const userId = getUserId();

+ 9 - 0
frontend/src/pages/WelcomePage.tsx

@@ -734,6 +734,14 @@ export const WelcomePage = () => {
           0%, 100% { opacity: 1; }
           50% { opacity: 0.6; }
         }
+        @keyframes fadeIn {
+          from {
+            opacity: 0;
+          }
+          to {
+            opacity: 1;
+          }
+        }
       `}</style>
       
       <div
@@ -823,6 +831,7 @@ export const WelcomePage = () => {
                     zIndex: 2,
                     touchAction: 'manipulation',
                     WebkitTapHighlightColor: 'transparent',
+                    animation: 'fadeIn 0.8s ease-in',
                   }}
                   aria-label="Start your journey"
                 >

+ 8 - 3
frontend/src/utils/api.ts

@@ -676,13 +676,18 @@ export const getWechatAuthUrl = (): string => {
 };
 
 // 获取QQ授权URL
-export const getQQAuthUrl = (from?: string): string => {
+export const getQQAuthUrl = (from?: string, user_id?: string | null): string => {
   const apiBaseUrl = getApiBaseUrl();
   const url = `${apiBaseUrl}/api/auth/qq/authorize`;
+  const params = new URLSearchParams();
   if (from) {
-    return `${url}?from=${encodeURIComponent(from)}`;
+    params.append('from', from);
   }
-  return url;
+  if (user_id) {
+    params.append('user_id', user_id);
+  }
+  const queryString = params.toString();
+  return queryString ? `${url}?${queryString}` : url;
 };
 
 /**

+ 14 - 3
frontend/src/utils/storage.ts

@@ -32,6 +32,7 @@ export const STORAGE_KEYS = {
 // 路由顺序表
 export const ROUTE_ORDER = [
   '/welcome', // 欢迎页
+  '/begin', // 开始页
   '/naming', // 用户命名页
   '/lumi-naming', // Lumi 命名页
   '/chatbot', // 聊天页
@@ -634,11 +635,21 @@ export const syncUserDataFromDatabase = (
       setStorage(STORAGE_KEYS.CURRENT_PAGE, uiState.current_page);
     }
 
-    // 同步已访问路由
+    // 同步已访问路由(合并本地和数据库的路由,避免覆盖本地刚记录的路由)
+    const localVisited = getVisitedRoutes();
     if (Array.isArray(uiState.visited_routes)) {
-      setStorage(STORAGE_KEYS.VISITED_ROUTES, JSON.stringify(uiState.visited_routes));
+      // 合并本地和数据库的路由,去重并保持顺序
+      const mergedRoutes = [...new Set([...localVisited, ...uiState.visited_routes])];
+      // 按照 ROUTE_ORDER 的顺序排序
+      const sortedRoutes = ROUTE_ORDER.filter(route => mergedRoutes.includes(route));
+      setStorage(STORAGE_KEYS.VISITED_ROUTES, JSON.stringify(sortedRoutes));
     } else if (uiState.visited_routes === null || uiState.visited_routes === undefined) {
-      setStorage(STORAGE_KEYS.VISITED_ROUTES, JSON.stringify([]));
+      // 如果数据库中是 null 或 undefined,保留本地已有的路由
+      if (localVisited.length > 0) {
+        setStorage(STORAGE_KEYS.VISITED_ROUTES, JSON.stringify(localVisited));
+      } else {
+        setStorage(STORAGE_KEYS.VISITED_ROUTES, JSON.stringify([]));
+      }
     }
 
     // 同步各种提示状态

+ 4 - 0
memory_summaries/summarize_memory.py

@@ -417,6 +417,10 @@ def setup_logging():
     error_file_handler.setFormatter(formatter)
     root_logger.addHandler(error_file_handler)
     
+    # 4. 设置 SQLAlchemy 连接池日志级别为 WARNING(避免 DEBUG 日志)
+    logging.getLogger("sqlalchemy.pool").setLevel(logging.WARNING)
+    logging.getLogger("sqlalchemy.engine").setLevel(logging.WARNING)
+    
     logger.info(f"✅ 日志配置完成:正常日志 -> {log_file}, 错误日志 -> {error_log_file}")
 
 

+ 345 - 0
项目文档/QQ登录完整流程说明.md

@@ -0,0 +1,345 @@
+# QQ登录完整流程说明
+
+## 概述
+
+本文档详细说明 `/login` 界面中 QQ 登录的完整逻辑流程,包括前端交互、后端处理和 QQ 授权流程。
+
+## 流程图
+
+```
+用户点击QQ登录
+    ↓
+前端调用 getQQAuthUrl('login')
+    ↓
+后端 /api/auth/qq/authorize
+    ├─ 生成 state token(防CSRF)
+    ├─ 存储 state 到 Redis(包含 from_page='login')
+    └─ 重定向到 QQ 授权页面
+    ↓
+用户在 QQ 授权页面确认授权
+    ↓
+QQ 回调到后端 /api/qq/mp/verify
+    ├─ 验证 state(从 Redis 获取)
+    ├─ 通过 code 获取 access_token
+    ├─ 通过 access_token 获取 openid 和 unionid
+    └─ 根据 from_page 决定处理逻辑
+    ↓
+from_page == 'login'
+    ├─ 检查用户是否存在(check_user_exists_by_qq_openid)
+    │   ├─ 用户不存在 → 重定向到 /begin?error=user_not_registered&oauth=qq
+    │   └─ 用户存在 → 调用 login_with_qq 登录
+    │       └─ 重定向到 /chatbot?oauth=qq&user_id=xxx&username=xxx
+    └─ 前端处理回调,保存用户信息
+```
+
+## 详细步骤
+
+### 1. 前端:用户点击 QQ 登录按钮
+
+**文件**: `frontend/src/pages/LoginPage.tsx`
+
+```typescript
+// QQ登录
+else if (selectedLoginType === 'qq') {
+  // 跳转到QQ授权页面(传递来源信息,标识这是登录页面)
+  const qqAuthUrl = getQQAuthUrl('login');
+  window.location.href = qqAuthUrl;
+}
+```
+
+**关键点**:
+- 调用 `getQQAuthUrl('login')` 生成授权 URL
+- `from` 参数设置为 `'login'`,用于后端识别来源
+
+### 2. 前端:生成 QQ 授权 URL
+
+**文件**: `frontend/src/utils/api.ts`
+
+```typescript
+export const getQQAuthUrl = (from?: string, user_id?: string | null): string => {
+  const apiBaseUrl = getApiBaseUrl();
+  const url = `${apiBaseUrl}/api/auth/qq/authorize`;
+  const params = new URLSearchParams();
+  if (from) {
+    params.append('from', from);  // 'login'
+  }
+  if (user_id) {
+    params.append('user_id', user_id);
+  }
+  const queryString = params.toString();
+  return queryString ? `${url}?${queryString}` : url;
+};
+```
+
+**生成的 URL**: `/api/auth/qq/authorize?from=login`
+
+### 3. 后端:生成授权 URL 并存储 state
+
+**文件**: `backend/main.py` - `qq_authorize` 函数
+
+**步骤**:
+
+1. **生成 state token**(防 CSRF 攻击)
+   ```python
+   import secrets
+   state_token = secrets.token_urlsafe(32)
+   ```
+
+2. **存储 state 到 Redis**
+   - Key: `oauth:qq:state:{state_token}`
+   - Value: `{"token": state_token, "from": "login"}`
+   - TTL: 1800 秒(30 分钟)
+
+3. **构建 QQ 授权 URL**
+   ```python
+   params = {
+       "response_type": "code",
+       "client_id": settings.QQ_APP_ID,
+       "redirect_uri": settings.QQ_REDIRECT_URI,
+       "state": state_token,
+       "scope": "get_user_info",
+   }
+   auth_url = f"https://graph.qq.com/oauth2.0/authorize?{urllib.parse.urlencode(params)}"
+   ```
+
+4. **重定向到 QQ 授权页面**
+   ```python
+   return RedirectResponse(url=auth_url)
+   ```
+
+### 4. QQ 授权页面
+
+用户在 QQ 授权页面确认授权后,QQ 会回调到后端:
+
+**回调 URL**: `/api/qq/mp/verify?code={授权码}&state={state_token}`
+
+### 5. 后端:处理 QQ 回调
+
+**文件**: `backend/main.py` - `qq_callback` 函数
+
+#### 5.1 验证 state(防 CSRF)
+
+```python
+# 从 Redis 获取 state 数据
+state_key = f"oauth:qq:state:{state_token}"
+state_data_str = await redis_client.get(state_key)
+
+# 解析来源信息
+state_data = json.loads(state_data_str)
+from_page = state_data.get("from")  # 'login'
+```
+
+**验证失败**: 重定向到 `/register?error=qq_auth_failed&reason=invalid_state`
+
+#### 5.2 通过 code 获取 access_token
+
+```python
+token_url = "https://graph.qq.com/oauth2.0/token"
+token_params = {
+    "grant_type": "authorization_code",
+    "client_id": settings.QQ_APP_ID,
+    "client_secret": settings.QQ_APP_KEY,
+    "code": code,
+    "redirect_uri": redirect_uri,
+    "fmt": "json",
+}
+token_response = await client.get(token_url, params=token_params)
+access_token = token_data.get("access_token")
+```
+
+#### 5.3 通过 access_token 获取 openid 和 unionid
+
+```python
+openid_url = "https://graph.qq.com/oauth2.0/me"
+openid_params = {
+    "access_token": access_token,
+    "unionid": 1,  # 请求 unionid
+    "fmt": "json",
+}
+openid_response = await client.get(openid_url, params=openid_params)
+openid = openid_data.get("openid")
+unionid = openid_data.get("unionid")
+```
+
+#### 5.4 可选:获取用户信息(昵称)
+
+```python
+user_info_url = "https://graph.qq.com/user/get_user_info"
+user_info_params = {
+    "access_token": access_token,
+    "oauth_consumer_key": settings.QQ_APP_ID,
+    "openid": openid,
+    "fmt": "json",
+}
+user_info_resp = await client.get(user_info_url, params=user_info_params)
+nickname = user_info.get("nickname", "")
+```
+
+**注意**: 后端不使用 QQ 返回的 nickname,避免覆盖用户原有用户名。
+
+### 6. 根据来源页面处理逻辑
+
+**当 `from_page == "login"` 时**:
+
+#### 6.1 检查用户是否存在
+
+```python
+user_exists = await user_service.check_user_exists_by_qq_openid(
+    openid=openid, 
+    unionid=unionid
+)
+```
+
+**检查逻辑** (`backend/services/user_service.py`):
+1. 优先用 `openid` 查询数据库
+2. 如果未命中且存在 `unionid`,则用 `unionid` 查询
+3. 返回用户是否存在(布尔值)
+
+#### 6.2 用户不存在 → 重定向到注册页面
+
+```python
+if not user_exists:
+    return RedirectResponse(
+        url=f"{settings.FRONTEND_URL}/begin?error=user_not_registered&oauth=qq"
+    )
+```
+
+**前端处理** (`frontend/src/pages/BeginPage.tsx`):
+```typescript
+if (error === 'user_not_registered' && oauth === 'qq') {
+  antMessage.error('用户未注册,请先注册');
+  navigate('/begin', { replace: true });
+}
+```
+
+#### 6.3 用户存在 → 登录并重定向到聊天界面
+
+```python
+# 调用登录服务
+user = await user_service.login_with_qq(
+    openid=openid,
+    username="",  # 不使用QQ返回的nickname
+    unionid=unionid,
+)
+
+# 重定向到聊天界面
+frontend_url = f"{settings.FRONTEND_URL}/chatbot?oauth=qq&user_id={user['user_id']}&username={urllib.parse.quote(user['username'])}"
+return RedirectResponse(url=frontend_url)
+```
+
+**登录逻辑** (`backend/services/user_service.py` - `login_with_qq`):
+1. 用 `openid` 或 `unionid` 查询用户
+2. 如果用户存在,更新 `unionid`(如果之前没有)
+3. 返回用户信息字典
+
+### 7. 前端:处理登录成功回调
+
+**文件**: `frontend/src/pages/ChatBotPage.tsx`
+
+前端检测到 URL 参数 `oauth=qq&user_id=xxx` 时,会:
+1. 从数据库同步用户数据到 localStorage
+2. 保存用户 ID 和登录状态
+3. 清除 URL 参数
+
+## 关键配置
+
+### 环境变量
+
+```bash
+# QQ OAuth 配置
+QQ_APP_ID=102822638
+QQ_APP_KEY=Mgt17CObjo2FPXt7
+QQ_REDIRECT_URI=https://www.lyrria.com/api/qq/mp/verify
+FRONTEND_URL=https://www.lyrria.com
+```
+
+### Redis 配置
+
+- **DB**: 1
+- **Key 格式**: `oauth:qq:state:{state_token}`
+- **TTL**: 1800 秒(30 分钟)
+- **Value 格式**: JSON `{"token": "...", "from": "login"}`
+
+## 错误处理
+
+### 1. QQ 授权失败
+
+- **原因**: 用户取消授权、网络错误等
+- **处理**: QQ 不会回调,用户停留在 QQ 授权页面
+
+### 2. State 验证失败
+
+- **原因**: Redis 连接失败、state 过期、state 被篡改
+- **处理**: 重定向到 `/register?error=qq_auth_failed&reason=invalid_state`
+
+### 3. Token 获取失败
+
+- **原因**: code 无效、已过期、网络错误
+- **处理**: 重定向到 `/register?error=qq_auth_failed&reason=token`
+
+### 4. OpenID 获取失败
+
+- **原因**: access_token 无效、网络错误
+- **处理**: 重定向到 `/register?error=qq_auth_failed`
+
+### 5. 用户不存在(来自 login 页面)
+
+- **原因**: 用户未注册
+- **处理**: 重定向到 `/begin?error=user_not_registered&oauth=qq`,提示用户先注册
+
+## 安全机制
+
+### 1. CSRF 防护
+
+- 使用随机生成的 `state_token`
+- 将 `state_token` 存储到 Redis
+- 回调时验证 `state_token` 是否存在于 Redis
+- 验证后立即删除 `state_token`(一次性使用)
+
+### 2. 来源页面标识
+
+- 通过 `from_page` 参数区分不同来源
+- `login`: 登录页面(不允许自动注册)
+- `first-login`: 首次登录页面(允许自动注册)
+- 其他: 注册页面(允许自动注册)
+
+### 3. 用户信息保护
+
+- 不使用 QQ 返回的 nickname,避免覆盖用户原有用户名
+- 仅在用户不存在时创建新用户
+- 登录页面不允许自动注册,必须用户先注册
+
+## 与手机号登录的对比
+
+| 对比项 | 手机号登录 | QQ登录 |
+|--------|-----------|--------|
+| **前端检查** | 调用 API 检查用户是否存在 | 直接跳转到授权页面 |
+| **用户不存在** | 提示错误,跳转到 `/begin` | 授权后检查,跳转到 `/begin` |
+| **用户存在** | 跳转到验证码页面 | 直接登录,跳转到 `/chatbot` |
+| **自动注册** | 不支持 | 不支持(来自 login 页面时) |
+
+## 相关文件
+
+### 前端
+- `frontend/src/pages/LoginPage.tsx` - 登录页面
+- `frontend/src/pages/BeginPage.tsx` - 注册页面(错误处理)
+- `frontend/src/pages/ChatBotPage.tsx` - 聊天页面(回调处理)
+- `frontend/src/utils/api.ts` - API 工具函数
+
+### 后端
+- `backend/main.py` - QQ 授权和回调处理
+- `backend/services/user_service.py` - 用户服务(登录、检查存在性)
+- `backend/config.py` - 配置管理
+
+## 注意事项
+
+1. **回调地址必须完全匹配**: QQ 开放平台要求 `redirect_uri` 必须与配置的回调域完全匹配(包括末尾斜杠)
+
+2. **State 有效期**: State token 在 Redis 中存储 30 分钟,超时后无法验证
+
+3. **UnionID 优先**: 如果同时有 `openid` 和 `unionid`,优先使用 `unionid` 查询(更稳定)
+
+4. **用户名保护**: 登录时不会使用 QQ 返回的 nickname,避免覆盖用户原有用户名
+
+5. **登录页面限制**: 从 `/login` 页面来的 QQ 登录,不允许自动注册,必须用户先注册
+

+ 130 - 0
项目文档/QQ登录跳转问题分析.md

@@ -0,0 +1,130 @@
+# QQ登录跳转问题分析
+
+## 问题描述
+
+从 `/login` 页面进行 QQ 登录时,登录成功后跳转到了 `/welcome` 页面,而不是预期的 `/chatbot` 页面。
+
+## 日志分析
+
+### 关键日志时间线
+
+```
+2025-12-19 17:42:02 - QQ授权开始,存储state
+2025-12-19 17:42:19 - QQ回调收到请求
+2025-12-19 17:42:19 - QQ state 验证成功
+2025-12-19 17:42:20 - QQ获取token成功
+2025-12-19 17:42:20 - ✅ QQ登录成功: user_id=59277b78-1406-4a1f-85c5-6d35ac489872
+2025-12-19 17:42:22 - ✅ 创建游客用户成功: user_id=34ab2f45-f9b5-40d3-92cf-3eaca12ce24c
+```
+
+### 问题分析
+
+1. **登录流程**:
+   - QQ 授权成功,获取到 `openid` 和 `unionid`
+   - 后端调用 `check_user_exists_by_qq_openid` 检查用户是否存在
+   - 如果用户不存在,重定向到 `/welcome?error=user_not_registered&oauth=qq`
+   - 如果用户存在,调用 `login_with_qq` 登录,然后重定向到 `/chatbot`
+
+2. **可能的原因**:
+   - `check_user_exists_by_qq_openid` 返回了 `False`(用户不存在)
+   - 代码重定向到了 `/welcome`
+   - 但是日志中显示的 `✅ QQ登录成功` 可能是之前某次调用留下的,或者是其他地方的调用
+
+3. **日志中的异常**:
+   - 日志显示创建了一个新的游客用户 `user_id=34ab2f45-f9b5-40d3-92cf-3eaca12ce24c`
+   - 这说明可能有其他地方在创建用户(可能是 WebSocket 连接初始化时)
+
+## 代码逻辑
+
+### 后端回调处理逻辑
+
+```python
+if from_page == "login":
+    # 检查用户是否存在
+    user_exists = await user_service.check_user_exists_by_qq_openid(openid=openid, unionid=unionid)
+    
+    if not user_exists:
+        # 用户不存在,重定向到 welcome 页面
+        return RedirectResponse(
+            url=f"{settings.FRONTEND_URL}/welcome?error=user_not_registered&oauth=qq"
+        )
+    
+    # 用户存在,使用openid登录
+    user = await user_service.login_with_qq(openid=openid, username="", unionid=unionid)
+    
+    # 重定向到聊天界面
+    frontend_url = f"{settings.FRONTEND_URL}/chatbot?oauth=qq&user_id={user['user_id']}&username={urllib.parse.quote(user['username'])}"
+    return RedirectResponse(url=frontend_url)
+```
+
+### 用户存在性检查逻辑
+
+```python
+async def check_user_exists_by_qq_openid(self, openid: str, unionid: Optional[str] = None) -> bool:
+    # 优先用 openid 查询
+    result = await session.execute(
+        select(User).where(User.qq_openid == openid)
+    )
+    existing_user = result.scalar_one_or_none()
+    
+    # 如果未命中且拿到了 unionid,则尝试用 unionid 查询
+    if not existing_user and unionid:
+        union_result = await session.execute(
+            select(User).where(User.qq_unionid == unionid)
+        )
+        existing_user = union_result.scalar_one_or_none()
+    
+    return existing_user is not None
+```
+
+## 已添加的调试日志
+
+为了帮助定位问题,已添加以下日志:
+
+1. **后端回调处理**:
+   - 开始检查用户是否存在
+   - 用户存在性检查结果
+   - 用户存在,开始登录
+   - 登录成功,准备重定向
+
+2. **用户存在性检查**:
+   - openid 查询结果
+   - unionid 查询结果
+   - 最终结果
+
+## 解决方案
+
+### 方案1:添加详细日志(已实施)
+
+已添加详细的日志输出,帮助定位问题:
+- 记录 `check_user_exists_by_qq_openid` 的查询过程和结果
+- 记录回调处理的每个步骤
+
+### 方案2:检查数据库状态
+
+需要检查数据库中是否存在对应的用户记录:
+- 检查 `qq_openid` 字段是否有值
+- 检查 `qq_unionid` 字段是否有值
+- 确认用户是否真的不存在
+
+### 方案3:修复逻辑问题
+
+如果确认用户确实存在,但 `check_user_exists_by_qq_openid` 返回了 `False`,可能需要:
+- 检查数据库查询逻辑是否正确
+- 检查是否有事务隔离级别问题
+- 检查是否有并发问题
+
+## 下一步行动
+
+1. **重新测试**:使用添加的日志重新测试 QQ 登录流程
+2. **查看日志**:检查新增的日志输出,确认 `check_user_exists_by_qq_openid` 的返回值
+3. **数据库检查**:检查数据库中是否存在对应的用户记录
+4. **修复问题**:根据日志和数据库检查结果,修复问题
+
+## 相关文件
+
+- `backend/main.py` - QQ 回调处理逻辑
+- `backend/services/user_service.py` - 用户存在性检查逻辑
+- `frontend/src/pages/LoginPage.tsx` - 登录页面
+- `frontend/src/pages/WelcomePage.tsx` - Welcome 页面错误处理
+