Pārlūkot izejas kodu

修复ai聊天功能

zkn 4 dienas atpakaļ
vecāks
revīzija
e14fdbf1d9

+ 3 - 1
.gitignore

@@ -49,4 +49,6 @@ shudao-go-backend/views/index.html
 .claude
 .code
 .augment
-.roo
+.roo
+
+.npm-cache

+ 37 - 44
shudao-chat-py/main.py

@@ -1,4 +1,3 @@
-from routers.report_compat import router as report_compat_router
 from fastapi import FastAPI, Request
 from fastapi.staticfiles import StaticFiles
 from fastapi.middleware.cors import CORSMiddleware
@@ -23,9 +22,9 @@ app.add_middleware(
     allow_origins=["*"],
     allow_credentials=True,
     allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"],
-    allow_headers=["Origin", "Authorization", "Access-Control-Allow-Origin",
+    allow_headers=["Origin", "Authorization", "Access-Control-Allow-Origin", 
                    "Access-Control-Allow-Headers", "Content-Type", "token"],
-    expose_headers=["Content-Length", "Access-Control-Allow-Origin",
+    expose_headers=["Content-Length", "Access-Control-Allow-Origin", 
                     "Access-Control-Allow-Headers", "Content-Type"]
 )
 
@@ -35,39 +34,38 @@ app.add_middleware(
 async def combined_middleware(request: Request, call_next):
     """组合中间件:日志 + 认证"""
     from fastapi.responses import JSONResponse
-    from utils.token import verify_local_token
-
+    from utils.token import verify_token
+    
     start_time = time.time()
     path = request.url.path
-
+    
     # 先打印,确认中间件被执行
     print(f"[DEBUG] 中间件执行 - 路径: {path}")
     logger.info(f"[中间件] 开始处理请求: {path}")
-
+    
     # 白名单路径(不需要认证)
-    whitelist_paths = ["/health", "/docs", "/redoc", "/openapi.json",
-                       "/static/", "/assets/", "/apiv1/auth/local_login", "/apiv1/auth/register"]
-
+    whitelist_paths = ["/health", "/docs", "/redoc", "/openapi.json", "/static/", "/assets/", "/apiv1/auth/local_login", "/apiv1/auth/register"]
+    
     # 检查是否在白名单中(精确匹配或以/结尾的前缀匹配)
-    is_whitelist = path == "/" or any(path.startswith(wp)
-                                      for wp in whitelist_paths)
-
+    is_whitelist = path == "/" or any(path.startswith(wp) for wp in whitelist_paths)
+    
     print(f"[DEBUG] 是否白名单: {is_whitelist}")
-
+    
     if is_whitelist:
         print(f"[DEBUG] 白名单路径,跳过认证")
         request.state.user = None
         response = await call_next(request)
     else:
         # 获取Token
-        token = request.headers.get("token") or request.headers.get(
-            "Authorization", "").replace("Bearer ", "")
-
+        auth_header = (request.headers.get("Authorization") or "").strip()
+        token = request.headers.get("token") or request.headers.get("Token") or auth_header
+        if auth_header.lower().startswith("bearer "):
+            token = auth_header[7:].strip()
+        
         print(f"[DEBUG] Token: {token[:20] if token else 'None'}...")
         logger.info(f"认证中间件 - 路径: {path}")
-        logger.info(
-            f"认证中间件 - Token (前20字符): {token[:20] if token else 'None'}...")
-
+        logger.info(f"认证中间件 - Token (前20字符): {token[:20] if token else 'None'}...")
+        
         if not token:
             print(f"[DEBUG] 未提供Token")
             logger.warning("认证中间件 - 未提供Token")
@@ -79,11 +77,14 @@ async def combined_middleware(request: Request, call_next):
             # 验证Token
             print(f"[DEBUG] 开始验证Token")
             logger.info("认证中间件 - 开始验证Token")
-            # 注意:verify_local_token 不是异步函数,直接调用
-            user_info = verify_local_token(token)
-
+            try:
+                user_info = await verify_token(token)
+            except Exception as e:
+                logger.error(f"Token验证异常: {e}")
+                user_info = None
+            
             print(f"[DEBUG] 验证结果: {user_info}")
-
+            
             if not user_info:
                 print(f"[DEBUG] Token验证失败")
                 logger.error("认证中间件 - Token验证失败,返回401")
@@ -92,31 +93,23 @@ async def combined_middleware(request: Request, call_next):
                     content={"statusCode": 401, "msg": "Token验证失败"}
                 )
             else:
-                # 为了不破坏后续代码依赖对象的结构,将 dict 转为带属性的类
-                class UserInfo:
-                    def __init__(self, d):
-                        self.__dict__.update(d)
-
-                user_obj = UserInfo(user_info)
-                print(
-                    f"[DEBUG] Token验证成功: {getattr(user_obj, 'username', 'unknown')}")
-                logger.info(
-                    f"认证中间件 - Token验证成功,用户: {getattr(user_obj, 'username', 'unknown')} ({getattr(user_obj, 'account', 'unknown')})")
-                request.state.user = user_obj
+                print(f"[DEBUG] Token验证成功: {user_info.username}")
+                logger.info(f"认证中间件 - Token验证成功,用户: {user_info.username} ({user_info.account})")
+                request.state.user = user_info
                 response = await call_next(request)
-
+    
     # 记录日志
     process_time = time.time() - start_time
     print(f"[DEBUG] 请求完成 - 状态码: {response.status_code}")
-    logger.info(
-        f"请求完成: {request.method} {path} - 状态码: {response.status_code} - 耗时: {process_time:.3f}s")
-
+    logger.info(f"请求完成: {request.method} {path} - 状态码: {response.status_code} - 耗时: {process_time:.3f}s")
+    
     return response
 
 # 注册路由
 app.include_router(api_router)
 
 # 单独注册报告兼容路由(避免双重前缀)
+from routers.report_compat import router as report_compat_router
 app.include_router(report_compat_router)
 
 # 创建静态文件目录
@@ -128,6 +121,8 @@ app.mount("/static", StaticFiles(directory="static"), name="static")
 app.mount("/assets", StaticFiles(directory="assets"), name="assets")
 
 
+
+
 @app.get("/", response_class=HTMLResponse)
 async def root():
     """根路径 - 欢迎页面"""
@@ -337,13 +332,11 @@ if __name__ == "__main__":
     logger.info("=" * 60)
     logger.info("🚀 Shudao Chat API 启动中...")
     logger.info(f"📍 服务地址: http://{settings.app.host}:{settings.app.port}")
-    logger.info(
-        f"📚 API 文档: http://{settings.app.host}:{settings.app.port}/docs")
-    logger.info(
-        f"🗄️ 数据库: {settings.database.host}:{settings.database.port}/{settings.database.database}")
+    logger.info(f"📚 API 文档: http://{settings.app.host}:{settings.app.port}/docs")
+    logger.info(f"🗄️ 数据库: {settings.database.host}:{settings.database.port}/{settings.database.database}")
     logger.info(f"🔧 调试模式: {'开启' if settings.app.debug else '关闭'}")
     logger.info("=" * 60)
-
+    
     uvicorn.run(
         "main:app",
         host=settings.app.host,

+ 14 - 0
shudao-chat-py/newport_md

@@ -27,3 +27,17 @@
 - **璺�緞**: `/apiv1/get_ppt_json`
 - **鏂规硶**: `POST`
 - **璇存槑**: 瀵瑰簲鍓嶇� `getPPTJson` 璇锋眰锛岀敤浜庡皢涓婁紶鐨� PPT 鏂囦欢瑙f瀽骞惰繑鍥炲�搴旂殑 JSON 澶х翰缁撴瀯銆�
+
+## 6. 鑾峰彇鍘嗗彶璁板綍锛堝�璇濆垪琛? / 娑堟伅璇︽儏锛?
+- **璺�緞**: `/apiv1/get_history_record`
+- **鏂规硶**: `GET`
+- **璇存槑**: 
+  - `ai_conversation_id=0` 锛氳繑鍥炲�璇濆垪琛ㄣ€佮:`data` 含 `id/title/content/business_type/exam_name/created_at/updated_at`,并返回 `total`。
+  - `ai_conversation_id>0` 锛氳繑鍥炶�瀵硅瘽鐨勬秷鎭��鎯呭垪琛ㄣ€佮:`data` 含 `id/ai_conversation_id/user_id/type/content/user_feedback/prev_user_id/search_source/guess_you_want/created_at/updated_at`。
+
+## 7. 鍒犻櫎瀵硅瘽锛堟寜瀵硅瘽 / 按AI消息)
+- **璺�緞**: `/apiv1/delete_conversation`
+- **鏂规硶**: `POST`
+- **璇存槑**:
+  - `ai_conversation_id` 有值时:软删除整段对话及其消息。
+  - `ai_message_id` 有值时:软删除指定 AI 消息,并同时删除其对应的上一条用户消息。

+ 245 - 30
shudao-chat-py/routers/chat.py

@@ -17,6 +17,74 @@ import httpx
 router = APIRouter()
 
 
+def _build_conversation_preview(content: str, limit: int = 50) -> str:
+    content = (content or "").strip()
+    if len(content) <= limit:
+        return content
+    return content[:limit] + "..."
+
+
+def _to_frontend_timestamp(timestamp: Optional[int]) -> Optional[int]:
+    if not timestamp:
+        return None
+    return timestamp if timestamp >= 10**12 else timestamp * 1000
+
+
+def _build_conversation_title(conversation: AIConversation) -> str:
+    if conversation.business_type == 3 and (conversation.exam_name or "").strip():
+        return conversation.exam_name.strip()
+    return _build_conversation_preview(conversation.content or "", limit=30)
+
+
+def _refresh_conversation_snapshot(db: Session, conversation_id: int, user_id: int) -> None:
+    latest_message = (
+        db.query(AIMessage)
+        .filter(
+            AIMessage.ai_conversation_id == conversation_id,
+            AIMessage.user_id == user_id,
+            AIMessage.is_deleted == 0,
+        )
+        .order_by(AIMessage.id.desc())
+        .first()
+    )
+
+    if not latest_message:
+        db.query(AIConversation).filter(
+            AIConversation.id == conversation_id,
+            AIConversation.user_id == user_id,
+        ).update({"is_deleted": 1, "updated_at": int(time.time())})
+        return
+
+    latest_user_message = (
+        db.query(AIMessage)
+        .filter(
+            AIMessage.ai_conversation_id == conversation_id,
+            AIMessage.user_id == user_id,
+            AIMessage.type == "user",
+            AIMessage.is_deleted == 0,
+        )
+        .order_by(AIMessage.id.desc())
+        .first()
+    )
+
+    preview_source = (
+        latest_user_message.content
+        if latest_user_message and latest_user_message.content
+        else latest_message.content
+    )
+    preview_content = _build_conversation_preview(preview_source or "", limit=100)
+
+    db.query(AIConversation).filter(
+        AIConversation.id == conversation_id,
+        AIConversation.user_id == user_id,
+    ).update(
+        {
+            "content": preview_content or " ",
+            "updated_at": int(time.time()),
+        }
+    )
+
+
 # ─────────────────────────────────────────────────────────────────────────
 # 辅助函数
 # ─────────────────────────────────────────────────────────────────────────
@@ -108,7 +176,7 @@ async def send_deepseek_message(
         # 创建或获取对话
         if not data.conversation_id:
             conversation = AIConversation(
-                user_id=user.userCode,
+                user_id=user.user_id,
                 content=message[:100],
                 business_type=data.business_type,
                 exam_name=data.exam_name if data.business_type == 3 else "",
@@ -122,6 +190,17 @@ async def send_deepseek_message(
             conv_id = conversation.id
         else:
             conv_id = data.conversation_id
+            db.query(AIConversation).filter(
+                AIConversation.id == conv_id,
+                AIConversation.user_id == user.user_id,
+                AIConversation.is_deleted == 0,
+            ).update({
+                "content": message[:100],
+                "business_type": data.business_type,
+                "exam_name": data.exam_name if data.business_type == 3 else "",
+                "updated_at": int(time.time()),
+            })
+            db.commit()
 
         response_text = ""
 
@@ -241,7 +320,7 @@ async def send_deepseek_message(
             "data": {
                 "conversation_id": conv_id,
                 "response": response_text,
-                "user_id": user.userCode,
+                "user_id": user.user_id,
                 "business_type": data.business_type,
             },
         }
@@ -251,32 +330,82 @@ async def send_deepseek_message(
 
 
 @router.get("/get_history_record")
-async def get_history_record(request: Request, db: Session = Depends(get_db)):
-    """获取对话历史记录列表"""
+async def get_history_record(
+    request: Request,
+    ai_conversation_id: int = 0,
+    business_type: Optional[int] = None,
+    db: Session = Depends(get_db),
+):
+    """兼容前端的历史记录查询:ai_conversation_id=0 返回对话列表,否则返回消息详情。"""
     user = request.state.user
     if not user:
         return {"statusCode": 401, "msg": "未授权"}
-    conversations = (
-        db.query(AIConversation)
-        .filter(
-            AIConversation.user_id == user.userCode,
-            AIConversation.is_deleted == 0,
+
+    if ai_conversation_id > 0:
+        messages = (
+            db.query(AIMessage)
+            .filter(
+                AIMessage.ai_conversation_id == ai_conversation_id,
+                AIMessage.user_id == user.user_id,
+                AIMessage.is_deleted == 0,
+            )
+            .order_by(AIMessage.id.asc())
+            .all()
+        )
+
+        return {
+            "statusCode": 200,
+            "msg": "success",
+            "total": len(messages),
+            "data": [
+                {
+                    "id": message.id,
+                    "ai_conversation_id": message.ai_conversation_id,
+                    "user_id": message.user_id,
+                    "type": message.type,
+                    "content": message.content,
+                    "user_feedback": message.user_feedback,
+                    "prev_user_id": message.prev_user_id,
+                    "search_source": message.search_source or "",
+                    "guess_you_want": message.guess_you_want or "",
+                    "created_at": _to_frontend_timestamp(message.created_at),
+                    "updated_at": _to_frontend_timestamp(message.updated_at),
+                }
+                for message in messages
+            ],
+        }
+
+    conversations_query = db.query(AIConversation).filter(
+        AIConversation.user_id == user.user_id,
+        AIConversation.is_deleted == 0,
+    )
+
+    if business_type is not None:
+        conversations_query = conversations_query.filter(
+            AIConversation.business_type == business_type
         )
-        .order_by(AIConversation.created_at.desc())
+
+    total = conversations_query.count()
+    conversations = (
+        conversations_query
+        .order_by(AIConversation.updated_at.desc(), AIConversation.id.desc())
         .limit(50)
         .all()
     )
+
     return {
         "statusCode": 200,
         "msg": "success",
+        "total": total,
         "data": [
             {
                 "id": conv.id,
-                "content": (conv.content or "")[:50]
-                + ("..." if len(conv.content or "") > 50 else ""),
+                "title": _build_conversation_title(conv),
+                "content": conv.content or "",
                 "business_type": conv.business_type,
-                "exam_name": conv.exam_name,
-                "created_at": conv.created_at,
+                "exam_name": conv.exam_name or "",
+                "created_at": _to_frontend_timestamp(conv.created_at),
+                "updated_at": _to_frontend_timestamp(conv.updated_at),
             }
             for conv in conversations
         ],
@@ -284,7 +413,8 @@ async def get_history_record(request: Request, db: Session = Depends(get_db)):
 
 
 class DeleteConversationRequest(BaseModel):
-    ai_conversation_id: int
+    ai_conversation_id: int = 0
+    ai_message_id: int = 0
 
 
 @router.post("/delete_conversation")
@@ -299,14 +429,50 @@ async def delete_conversation(
     if not user:
         return {"statusCode": 401, "msg": "未授权"}
 
+    now_ts = int(time.time())
+
+    if data.ai_message_id:
+        ai_message = (
+            db.query(AIMessage)
+            .filter(
+                AIMessage.id == data.ai_message_id,
+                AIMessage.user_id == user.user_id,
+                AIMessage.type == "ai",
+                AIMessage.is_deleted == 0,
+            )
+            .first()
+        )
+        if not ai_message:
+            return {"statusCode": 404, "msg": "消息不存在"}
+
+        db.query(AIMessage).filter(
+            AIMessage.id == ai_message.id,
+            AIMessage.user_id == user.user_id,
+        ).update({"is_deleted": 1, "updated_at": now_ts})
+
+        if ai_message.prev_user_id:
+            db.query(AIMessage).filter(
+                AIMessage.id == ai_message.prev_user_id,
+                AIMessage.user_id == user.user_id,
+                AIMessage.ai_conversation_id == ai_message.ai_conversation_id,
+            ).update({"is_deleted": 1, "updated_at": now_ts})
+
+        _refresh_conversation_snapshot(db, ai_message.ai_conversation_id, user.user_id)
+        db.commit()
+        return {"statusCode": 200, "msg": "删除成功"}
+
+    if not data.ai_conversation_id:
+        return {"statusCode": 400, "msg": "缺少删除参数"}
+
     db.query(AIConversation).filter(
         AIConversation.id == data.ai_conversation_id,
-        AIConversation.user_id == user.userCode,
-    ).update({"is_deleted": 1, "updated_at": int(time.time())})
+        AIConversation.user_id == user.user_id,
+    ).update({"is_deleted": 1, "updated_at": now_ts})
 
     db.query(AIMessage).filter(
-        AIMessage.ai_conversation_id == data.ai_conversation_id
-    ).update({"is_deleted": 1, "updated_at": int(time.time())})
+        AIMessage.ai_conversation_id == data.ai_conversation_id,
+        AIMessage.user_id == user.user_id,
+    ).update({"is_deleted": 1, "updated_at": now_ts})
 
     db.commit()
     return {"statusCode": 200, "msg": "删除成功"}
@@ -326,7 +492,7 @@ async def delete_history_record(
         return {"statusCode": 401, "msg": "未授权"}
     db.query(AIConversation).filter(
         AIConversation.id == data.ai_conversation_id,
-        AIConversation.user_id == user.userCode,
+        AIConversation.user_id == user.user_id,
     ).update({"is_deleted": 1, "updated_at": int(time.time())})
     db.commit()
     return {"statusCode": 200, "msg": "删除成功"}
@@ -426,8 +592,8 @@ async def stream_chat_with_db(request: Request, data: StreamChatWithDBRequest):
             # 1. 创建或获取对话
             if data.ai_conversation_id == 0:
                 conversation = AIConversation(
-                    user_id=user.userCode,
-                    content=message[:100],
+                    user_id=user.user_id,
+                    content=_build_conversation_preview(message, limit=100),
                     business_type=data.business_type,
                     exam_name=data.exam_name,
                     created_at=int(time.time()),
@@ -439,12 +605,49 @@ async def stream_chat_with_db(request: Request, data: StreamChatWithDBRequest):
                 db.refresh(conversation)
                 conv_id = conversation.id
             else:
-                conv_id = data.ai_conversation_id
+                existing_conversation = (
+                    db.query(AIConversation)
+                    .filter(
+                        AIConversation.id == data.ai_conversation_id,
+                        AIConversation.user_id == user.user_id,
+                        AIConversation.is_deleted == 0,
+                    )
+                    .first()
+                )
+
+                if existing_conversation:
+                    conv_id = existing_conversation.id
+                    db.query(AIConversation).filter(
+                        AIConversation.id == conv_id,
+                        AIConversation.user_id == user.user_id,
+                    ).update(
+                        {
+                            "content": _build_conversation_preview(message, limit=100),
+                            "business_type": data.business_type,
+                            "exam_name": data.exam_name if data.business_type == 3 else "",
+                            "updated_at": int(time.time()),
+                        }
+                    )
+                    db.commit()
+                else:
+                    conversation = AIConversation(
+                        user_id=user.user_id,
+                        content=_build_conversation_preview(message, limit=100),
+                        business_type=data.business_type,
+                        exam_name=data.exam_name if data.business_type == 3 else "",
+                        created_at=int(time.time()),
+                        updated_at=int(time.time()),
+                        is_deleted=0,
+                    )
+                    db.add(conversation)
+                    db.commit()
+                    db.refresh(conversation)
+                    conv_id = conversation.id
 
             # 2. 插入用户消息
             user_msg = AIMessage(
                 ai_conversation_id=conv_id,
-                user_id=user.userCode,
+                user_id=user.user_id,
                 type="user",
                 content=message,
                 created_at=int(time.time()),
@@ -458,7 +661,7 @@ async def stream_chat_with_db(request: Request, data: StreamChatWithDBRequest):
             # 3. 插入 AI 占位消息
             ai_msg = AIMessage(
                 ai_conversation_id=conv_id,
-                user_id=user.userCode,
+                user_id=user.user_id,
                 type="ai",
                 content="",
                 prev_user_id=user_msg.id,
@@ -530,8 +733,20 @@ async def stream_chat_with_db(request: Request, data: StreamChatWithDBRequest):
 
             # 9. 更新 AI 消息内容
             if full_response:
+                now_ts = int(time.time())
                 db.query(AIMessage).filter(AIMessage.id == ai_msg.id).update(
-                    {"content": full_response, "updated_at": int(time.time())}
+                    {"content": full_response, "updated_at": now_ts}
+                )
+                db.query(AIConversation).filter(
+                    AIConversation.id == conv_id,
+                    AIConversation.user_id == user.user_id,
+                ).update(
+                    {
+                        "content": _build_conversation_preview(message, limit=100),
+                        "business_type": data.business_type,
+                        "exam_name": data.exam_name if data.business_type == 3 else "",
+                        "updated_at": now_ts,
+                    }
                 )
                 db.commit()
 
@@ -661,7 +876,7 @@ async def online_search(question: str, request: Request, db: Session = Depends(g
                 "max_text_len": 4000  # 最大文本长度
             },
             "response_mode": "blocking",
-            "user": getattr(user, "account", str(user.userCode)),
+            "user": getattr(user, "account", str(user.user_id)),
         }
 
         async with httpx.AsyncClient(timeout=30.0) as client:
@@ -744,7 +959,7 @@ async def intent_recognition(
         if data.save_to_db and intent_type in ("greeting", "问候", "faq", "常见问题"):
             if data.ai_conversation_id == 0:
                 conversation = AIConversation(
-                    user_id=user.userCode,
+                    user_id=user.user_id,
                     content=data.message[:100],
                     business_type=0,
                     created_at=int(time.time()),
@@ -760,7 +975,7 @@ async def intent_recognition(
 
             user_msg = AIMessage(
                 ai_conversation_id=conv_id,
-                user_id=user.userCode,
+                user_id=user.user_id,
                 type="user",
                 content=data.message,
                 created_at=int(time.time()),
@@ -772,7 +987,7 @@ async def intent_recognition(
 
             ai_msg = AIMessage(
                 ai_conversation_id=conv_id,
-                user_id=user.userCode,
+                user_id=user.user_id,
                 type="ai",
                 content=response_text,
                 prev_user_id=user_msg.id,

+ 105 - 30
shudao-chat-py/routers/report_compat.py

@@ -6,6 +6,7 @@ from fastapi import APIRouter, Request
 from fastapi.responses import StreamingResponse, JSONResponse
 import httpx
 import json
+import time
 from typing import AsyncGenerator
 from models.report import (
     ReportCompleteFlowRequest,
@@ -45,31 +46,69 @@ def should_proxy_to_aichat(token: str) -> bool:
     return not is_local_token(token)
 
 
+def _ensure_proxy_conversation_id(
+    request_data: ReportCompleteFlowRequest,
+    request: Request
+) -> int:
+    """
+    为 aichat 生成可用的 ai_conversation_id。
+    目的:避免 ai_conversation_id=0 触发 aichat 内部 transfer_session 崩溃分支。
+    """
+    conversation_id = int(request_data.ai_conversation_id or 0)
+    if conversation_id > 0:
+        return conversation_id
+
+    # 生成一个正数会话ID(<= 2^53,前端 JS 可安全表示)
+    generated_id = int(time.time() * 1_000_000)
+
+    # 叠加少量用户特征,降低并发冲突概率
+    user = getattr(request.state, "user", None)
+    user_id = int(getattr(user, "user_id", 0) or 0)
+    if user_id > 0:
+        generated_id = generated_id - (generated_id % 1000) + (user_id % 1000)
+
+    if generated_id <= 0:
+        generated_id = int(time.time()) or 1
+
+    return generated_id
+
+
+def _build_aichat_complete_flow_body(
+    request_data: ReportCompleteFlowRequest,
+    request: Request
+) -> bytes:
+    """构建转发到 aichat 的请求体,并规避 ai_conversation_id=0 的崩溃场景。"""
+    payload = request_data.dict()
+    payload["ai_conversation_id"] = _ensure_proxy_conversation_id(request_data, request)
+    return json.dumps(payload, ensure_ascii=False).encode("utf-8")
+
+
 async def fallback_to_local_stream(
     request_data: ReportCompleteFlowRequest,
     request: Request
 ) -> StreamingResponse:
-    """降级到本地流式聊天"""
-    logger.info("[报告兼容] 降级到本地流式聊天")
-    
-    # 构建本地流式聊天请求
+    """降级为本地 SSE 兼容输出(匹配前端 report/complete-flow 事件格式)。"""
+    logger.info("[报告兼容] 降级为本地 SSE 兼容输出")
+
     stream_request = StreamChatRequest(
         message=request_data.user_question,
         ai_conversation_id=request_data.ai_conversation_id,
         business_type=0
     )
-    
-    # 调用本地流式聊天接口
+
     local_url = f"http://127.0.0.1:{settings.app.port}/apiv1/stream/chat-with-db"
-    
+
     async def stream_generator() -> AsyncGenerator[bytes, None]:
+        ai_conversation_id = request_data.ai_conversation_id or 0
+        ai_message_id = 0
+        full_response = ""
+
         try:
-            # 转发认证 headers
             headers = {"Content-Type": "application/json"}
             for header_name in ["Authorization", "Token", "token"]:
                 if header_value := request.headers.get(header_name):
                     headers[header_name] = header_value
-            
+
             async with httpx.AsyncClient(timeout=600) as client:
                 async with client.stream(
                     "POST",
@@ -79,20 +118,51 @@ async def fallback_to_local_stream(
                 ) as response:
                     if response.status_code != 200:
                         error_msg = f"data: {{\"type\": \"online_error\", \"message\": \"Local stream failed: {response.status_code}\"}}\n\n"
-                        yield error_msg.encode('utf-8')
+                        yield error_msg.encode("utf-8")
                         yield b"data: {\"type\": \"completed\"}\n\n"
                         return
-                    
-                    # 转发流式响应
-                    async for chunk in response.aiter_bytes(chunk_size=4096):
-                        yield chunk
-                        
+
+                    async for raw_chunk in response.aiter_bytes(chunk_size=4096):
+                        if not raw_chunk:
+                            continue
+                        chunk_text = raw_chunk.decode("utf-8", errors="ignore")
+                        for line in chunk_text.split("\n"):
+                            if not line.startswith("data: "):
+                                continue
+                            payload = line[6:].strip()
+                            if not payload:
+                                continue
+
+                            if payload == "[DONE]":
+                                if full_response:
+                                    online_answer = {
+                                        "type": "online_answer",
+                                        "content": full_response,
+                                        "ai_conversation_id": ai_conversation_id,
+                                        "ai_message_id": ai_message_id,
+                                    }
+                                    yield f"data: {json.dumps(online_answer, ensure_ascii=False)}\n\n".encode("utf-8")
+                                yield b"data: {\"type\": \"completed\"}\n\n"
+                                return
+
+                            if payload.startswith("{"):
+                                try:
+                                    data = json.loads(payload)
+                                except Exception:
+                                    data = None
+                                if isinstance(data, dict) and data.get("type") == "initial":
+                                    ai_conversation_id = data.get("ai_conversation_id") or ai_conversation_id
+                                    ai_message_id = data.get("ai_message_id") or ai_message_id
+                                    continue
+
+                            full_response += payload.replace("\\n", "\n")
+
         except Exception as e:
-            logger.error(f"[报告兼容] 本地流式聊天异常: {e}")
+            logger.error(f"[报告兼容] 本地 SSE 处理异常: {e}")
             error_msg = f"data: {{\"type\": \"online_error\", \"message\": \"Local stream error: {str(e)}\"}}\n\n"
-            yield error_msg.encode('utf-8')
+            yield error_msg.encode("utf-8")
             yield b"data: {\"type\": \"completed\"}\n\n"
-    
+
     return StreamingResponse(
         stream_generator(),
         media_type="text/event-stream",
@@ -133,22 +203,27 @@ async def complete_flow(request: Request):
             ]),
             media_type="text/event-stream"
         )
-    
-    # 获取 token 并判断路由策略
+
     token = get_request_token(request)
-    
+
     if should_proxy_to_aichat(token):
-        # 外部 token,代理到 aichat
-        logger.info("[报告兼容] 代理到 aichat 服务")
+        proxy_body = _build_aichat_complete_flow_body(request_data, request)
+
+        if int(request_data.ai_conversation_id or 0) <= 0:
+            proxy_data = json.loads(proxy_body.decode("utf-8"))
+            logger.info(
+                f"[报告兼容] ai_conversation_id=0 已重写为 {proxy_data.get('ai_conversation_id')},代理到 aichat"
+            )
+        else:
+            logger.info("[报告兼容] 代理 complete-flow 到 aichat")
+
         try:
-            return await aichat_proxy.proxy_sse("/report/complete-flow", request, request_body)
+            return await aichat_proxy.proxy_sse("/report/complete-flow", request, proxy_body)
         except Exception as e:
-            logger.error(f"[报告兼容] 代理到 aichat 失败: {e}")
-            # 降级到本地处理
-            return await fallback_to_local_stream(request_data, request)
-    else:
-        # 本地 token,降级到本地流式聊天
-        return await fallback_to_local_stream(request_data, request)
+            logger.error(f"[报告兼容] 代理 complete-flow 到 aichat 失败,降级本地: {e}")
+
+    # 本地 token 或代理失败:降级到本地兼容 SSE
+    return await fallback_to_local_stream(request_data, request)
 
 
 @router.post("/report/update-ai-message")

+ 23 - 2
shudao-chat-py/routers/tracking.py

@@ -13,6 +13,21 @@ class TrackingRequest(BaseModel):
     api_name: str = ""
 
 
+def _get_tracking_user_id(user) -> str:
+    """统一获取埋点所需的字符串用户标识。"""
+    if not user:
+        return ""
+
+    return str(
+        getattr(user, "userCode", "")
+        or getattr(user, "user_code", "")
+        or getattr(user, "account", "")
+        or getattr(user, "account_id", "")
+        or getattr(user, "user_id", "")
+        or ""
+    )
+
+
 @router.post("/tracking/record")
 async def record_tracking(
     request: Request,
@@ -21,6 +36,9 @@ async def record_tracking(
 ):
     """记录埋点"""
     user = request.state.user
+    user_id = _get_tracking_user_id(user)
+    if not user_id:
+        return {"statusCode": 401, "msg": "未授权"}
     
     # 获取客户端IP
     client_ip = request.client.host if request.client else ""
@@ -30,7 +48,7 @@ async def record_tracking(
     
     # 创建埋点记录
     record = TrackingRecord(
-        user_id=user.userCode,
+        user_id=user_id,
         api_path=data.api_path,
         api_name=data.api_name,
         method=request.method,
@@ -53,9 +71,12 @@ async def get_tracking_records(
 ):
     """获取埋点记录"""
     user = request.state.user
+    user_id = _get_tracking_user_id(user)
+    if not user_id:
+        return {"statusCode": 401, "msg": "未授权"}
     
     records = db.query(TrackingRecord).filter(
-        TrackingRecord.user_id == user.userCode
+        TrackingRecord.user_id == user_id
     ).order_by(TrackingRecord.created_at.desc()).limit(limit).all()
     
     return {

+ 2 - 3
shudao-chat-py/utils/__init__.py

@@ -1,12 +1,11 @@
 from .config import settings, get_base_url, get_proxy_url
-# from .token import TokenUserInfo, verify_token, get_user_info_from_token
-from .token import verify_local_token
+from .token import TokenUserInfo, verify_local_token, verify_token
 from .crypto import encrypt_url, decrypt_url
 from .string_match import levenshtein_distance, string_similarity, find_best_match
 
 __all__ = [
     "settings", "get_base_url", "get_proxy_url",
-    "TokenUserInfo", "verify_token", "get_user_info_from_token",
+    "TokenUserInfo", "verify_local_token", "verify_token",
     "encrypt_url", "decrypt_url",
     "levenshtein_distance", "string_similarity", "find_best_match"
 ]

+ 15 - 24
shudao-chat-py/utils/auth_middleware.py

@@ -1,12 +1,12 @@
-from fastapi import Request, HTTPException, status
+from fastapi import Request, status
 from fastapi.responses import JSONResponse
-from .token import verify_local_token
+
 from .logger import logger
+from .token import verify_token
 
 
 async def auth_middleware(request: Request, call_next):
-    """Token认证中间件"""
-    # 白名单路径(不需要认证)
+    """统一 token 认证中间件。"""
     whitelist_paths = [
         "/",
         "/health",
@@ -16,47 +16,38 @@ async def auth_middleware(request: Request, call_next):
         "/static",
         "/assets",
         "/apiv1/auth/local_login",
-        "/apiv1/auth/register"
+        "/apiv1/auth/register",
     ]
 
-    # 检查是否在白名单中
     path = request.url.path
     for whitelist_path in whitelist_paths:
         if path.startswith(whitelist_path):
-            # 白名单路径也设置一个默认user,避免后续访问出错
             request.state.user = None
             return await call_next(request)
 
-    # 获取Token
-    token = request.headers.get("token") or request.headers.get(
-        "Authorization", "").replace("Bearer ", "")
+    auth_header = (request.headers.get("Authorization") or "").strip()
+    token = request.headers.get("token") or request.headers.get("Token") or auth_header
+    if auth_header.lower().startswith("bearer "):
+        token = auth_header[7:].strip()
 
     logger.info(f"认证中间件 - 路径: {path}")
     logger.info(f"认证中间件 - Token (前20字符): {token[:20] if token else 'None'}...")
 
     if not token:
-        logger.warning("认证中间件 - 未提供Token")
+        logger.warning("认证中间件 - 未提供token")
         return JSONResponse(
             status_code=status.HTTP_401_UNAUTHORIZED,
-            content={"code": 401, "msg": "未提供认证Token"}
+            content={"statusCode": 401, "msg": "未提供认证Token"},
         )
 
-    # 验证Token
-    logger.info("认证中间件 - 开始验证Token")
-    user_info = await verify_local_token(token)
-
+    user_info = await verify_token(token)
     if not user_info:
         logger.error("认证中间件 - Token验证失败,返回401")
         return JSONResponse(
             status_code=status.HTTP_401_UNAUTHORIZED,
-            content={"code": 401, "msg": "Token验证失败"}
+            content={"statusCode": 401, "msg": "Token验证失败"},
         )
 
-    logger.info(
-        f"认证中间件 - Token验证成功,用户: {user_info.username} ({user_info.account})")
-
-    # 将用户信息存储到request.state中
+    logger.info(f"认证中间件 - Token验证成功,用户 {user_info.username} ({user_info.account})")
     request.state.user = user_info
-
-    response = await call_next(request)
-    return response
+    return await call_next(request)

+ 223 - 59
shudao-chat-py/utils/token.py

@@ -1,85 +1,249 @@
 """
-本地 Token 验证工具
-用于区分本地生成的 token 和外部系统的 token
+Token 解析与认证工具
+支持本地登录 token 和 4A 外部 token。
 """
+from dataclasses import dataclass, field
+import time
+from typing import Any, Dict, Optional, Tuple
+
+import httpx
 import jwt
-from typing import Optional
+
+from utils.config import settings
 from utils.logger import logger
 
+LOCAL_JWT_SECRET = "shudao-local-jwt-secret-2024"
+LOCAL_JWT_ALGORITHM = "HS256"
+EXTERNAL_TOKEN_CACHE_TTL = 60
+USER_ID_CACHE_TTL = 300
+
+_external_token_cache: Dict[str, Tuple[float, "TokenUserInfo"]] = {}
+_external_user_id_cache: Dict[str, Tuple[float, int]] = {}
+
+
+@dataclass
+class TokenUserInfo:
+    """统一的 request.state.user 对象。"""
+
+    user_id: int
+    account: str
+    username: str
+    role: str = "user"
+    source: str = "external"
+    name: str = ""
+    account_id: str = ""
+    user_code: str = ""
+    contact_number: str = ""
+    raw: Dict[str, Any] = field(default_factory=dict)
+
+    @property
+    def id(self) -> int:
+        return self.user_id
+
+    @property
+    def userCode(self) -> str:
+        """兼容旧代码中的 camelCase 字段访问。"""
+        return (
+            self.user_code
+            or self.account_id
+            or self.account
+            or self.username
+            or str(self.user_id)
+        )
+
+    @property
+    def accountID(self) -> str:
+        """兼容旧代码中的 camelCase 字段访问。"""
+        return self.account_id or self.account or self.username
+
+    @property
+    def contactNumber(self) -> str:
+        """兼容旧代码中的 camelCase 字段访问。"""
+        return self.contact_number
+
+
+def _normalize_token(token: str) -> str:
+    token = (token or "").strip()
+    if token.lower().startswith("bearer "):
+        return token[7:].strip()
+    return token
+
+
+def _get_cached_external_user(token: str) -> Optional["TokenUserInfo"]:
+    cache_item = _external_token_cache.get(token)
+    if not cache_item:
+        return None
 
-def verify_local_token(token: str) -> Optional[dict]:
-    """
-    验证是否为本地生成的 token
+    expires_at, user_info = cache_item
+    if expires_at <= time.time():
+        _external_token_cache.pop(token, None)
+        return None
+
+    return user_info
 
-    Args:
-        token: JWT token 字符串
 
-    Returns:
-        如果是本地 token 返回解码后的数据,否则返回 None
+def _cache_external_user(token: str, user_info: "TokenUserInfo", exp: Any = None) -> None:
+    ttl = EXTERNAL_TOKEN_CACHE_TTL
+    try:
+        if exp:
+            ttl = max(1, min(EXTERNAL_TOKEN_CACHE_TTL, int(exp) - int(time.time())))
+    except Exception:
+        ttl = EXTERNAL_TOKEN_CACHE_TTL
+
+    _external_token_cache[token] = (time.time() + ttl, user_info)
+
+
+def _get_cached_external_user_id(account_id: str) -> Optional[int]:
+    cache_item = _external_user_id_cache.get(account_id)
+    if not cache_item:
+        return None
+
+    expires_at, user_id = cache_item
+    if expires_at <= time.time():
+        _external_user_id_cache.pop(account_id, None)
+        return None
+
+    return user_id
+
+
+def _cache_external_user_id(account_id: str, user_id: int) -> None:
+    _external_user_id_cache[account_id] = (time.time() + USER_ID_CACHE_TTL, user_id)
+
+
+def verify_local_token(token: str) -> Optional[TokenUserInfo]:
+    """
+    验证是否为本地登录生成的 token。
     """
+    token = _normalize_token(token)
     if not token:
         return None
 
     try:
-        # 尝试解码 token(不验证签名,只检查格式)
-        # 本地 token 应该包含特定的字段,如 account, username 等
-        decoded = jwt.decode(token, options={"verify_signature": False})
-
-        # 将解码的 token 打印出来以供调试分析
-        logger.info(f"[Token验证] 解码后的 Token 负载: {decoded}")
-
-        # 检查是否包含本地 token 的特征字段
-        # 或者包含 user_id, id, sub, sub_id, name 等 (兼容各种其他系统的 token 格式)
-        if any(k in decoded for k in ["account", "username", "user_id", "id", "sub", "userId", "name", "email", "uid"]):
-            # 尽可能提取出唯一的用户名/标识
-            username = (
-                decoded.get('username') or
-                decoded.get('account') or
-                decoded.get('name') or
-                decoded.get('email') or
-                f"User_{decoded.get('user_id', decoded.get('id', decoded.get('sub', decoded.get('uid', 'unknown'))))}"
-            )
+        decoded = jwt.decode(token, LOCAL_JWT_SECRET, algorithms=[LOCAL_JWT_ALGORITHM])
+    except jwt.InvalidTokenError:
+        return None
+    except Exception as e:
+        logger.warning(f"[Token验证] 本地 token 校验异常: {e}")
+        return None
 
-            # 补全缺失的关键字段,避免后续代码报错
-            if 'username' not in decoded:
-                decoded['username'] = username
-            if 'account' not in decoded:
-                decoded['account'] = username
-            if 'id' not in decoded and 'user_id' in decoded:
-                decoded['id'] = decoded['user_id']
-            elif 'id' not in decoded and 'sub' in decoded:
-                decoded['id'] = decoded['sub']
-
-            logger.info(f"[Token验证] 识别为有效 token: {username}")
-            return decoded
-
-        # 如果以上所有字段都没有,但它是个合法的字典结构,我们也强行给它通过(作为游客)
-        if isinstance(decoded, dict):
-            logger.info("[Token验证] 未找到明确用户字段,作为匿名用户处理")
-            decoded['username'] = "Anonymous"
-            decoded['account'] = "Anonymous"
-            decoded['id'] = 0
-            return decoded
-
-        logger.info("[Token验证] 不是本地 token 格式")
+    if decoded.get("source") != "local" and not (
+        "user_id" in decoded and "username" in decoded
+    ):
         return None
 
-    except jwt.DecodeError:
-        logger.info("[Token验证] Token 解码失败,不是有效的 JWT")
+    username = str(decoded.get("username") or decoded.get("account") or "")
+    user_id = int(decoded.get("user_id") or 0)
+
+    logger.info(f"[Token验证] 识别为本地 token: {username or 'unknown'}")
+    return TokenUserInfo(
+        user_id=user_id,
+        account=str(decoded.get("account") or username),
+        username=username,
+        role=str(decoded.get("role") or "user"),
+        source="local",
+        name=str(decoded.get("name") or username),
+        raw=decoded,
+    )
+
+
+async def _resolve_external_user_id(account_id: str) -> int:
+    """将外部账号映射到本地 UserData 主键,便于复用现有业务表。"""
+    if not account_id:
+        return 0
+
+    cached_user_id = _get_cached_external_user_id(account_id)
+    if cached_user_id is not None:
+        return cached_user_id
+
+    try:
+        from database import SessionLocal
+        from models.user_data import UserData
+
+        db = SessionLocal()
+        try:
+            user_data = db.query(UserData).filter(UserData.accountID == account_id).first()
+            user_id = int(user_data.id) if user_data else 0
+            _cache_external_user_id(account_id, user_id)
+            return user_id
+        finally:
+            db.close()
+    except Exception as e:
+        logger.warning(f"[Token验证] 外部用户ID映射失败: {e}")
+        return 0
+
+
+async def verify_external_token(token: str) -> Optional[TokenUserInfo]:
+    """
+    调用 4A 验证外部 token。
+    """
+    token = _normalize_token(token)
+    if not token:
+        return None
+
+    cached_user = _get_cached_external_user(token)
+    if cached_user:
+        return cached_user
+
+    verify_url = getattr(settings.auth, "api_url", "")
+    if not verify_url:
+        logger.warning("[Token验证] 未配置外部 token 验证地址")
         return None
+
+    try:
+        async with httpx.AsyncClient(timeout=10.0) as client:
+            response = await client.post(
+                verify_url,
+                json={"token": token},
+                headers={"Content-Type": "application/json"},
+            )
+            response.raise_for_status()
+            result = response.json()
     except Exception as e:
-        logger.warning(f"[Token验证] Token 验证异常: {e}")
+        logger.warning(f"[Token验证] 外部 token 验证失败: {e}")
         return None
 
+    if not result.get("valid"):
+        logger.warning("[Token验证] 外部 token 无效或已过期")
+        return None
 
-def is_local_token(token: str) -> bool:
+    account_id = str(result.get("accountID") or "")
+    username = str(result.get("name") or account_id or "external_user")
+    user_id = await _resolve_external_user_id(account_id)
+
+    logger.info(f"[Token验证] 识别为外部 token: {account_id or username}")
+    user_info = TokenUserInfo(
+        user_id=user_id,
+        account=account_id,
+        username=username,
+        role=str(result.get("role") or "user"),
+        source="external",
+        name=username,
+        account_id=account_id,
+        user_code=str(result.get("userCode") or ""),
+        contact_number=str(result.get("contactNumber") or ""),
+        raw=result,
+    )
+    _cache_external_user(token, user_info, result.get("exp"))
+    return user_info
+
+
+async def verify_token(token: str) -> Optional[TokenUserInfo]:
     """
-    判断是否为本地 token
+    统一验证 token,优先走本地 token,再尝试外部 4A token。
+    """
+    normalized = _normalize_token(token)
+    if not normalized:
+        return None
+
+    local_user = verify_local_token(normalized)
+    if local_user:
+        return local_user
 
-    Args:
-        token: JWT token 字符串
+    return await verify_external_token(normalized)
 
-    Returns:
-        True 表示本地 token,False 表示外部 token
+
+def is_local_token(token: str) -> bool:
+    """
+    判断是否为本地 token。
     """
     return verify_local_token(token) is not None

+ 9 - 6
shudao-vue-frontend/src/request/axios.js

@@ -27,7 +27,6 @@ async function recordTrackingAsync(apiPath, method) {
             return
         }
         
-        // 异步调用埋点接口,不阻塞主请求
         const trackingData = {
             // ===== 已删除:user_id - 后端从token解析 =====
             api_path: apiPath,
@@ -51,7 +50,6 @@ async function recordTrackingAsync(apiPath, method) {
             headers['token'] = token
         }
         
-        // 使用 fetch 发送埋点请求,避免使用 axios 造成循环
         fetch(window.location.origin + BACKEND_API_PREFIX + '/tracking/record', {
             method: 'POST',
             headers: headers,
@@ -71,6 +69,9 @@ async function recordTrackingAsync(apiPath, method) {
     }
 }
 
+let lastTrackingAt = 0
+const TRACKING_THROTTLE_MS = 1500
+
 // 请求拦截器
 http.interceptors.request.use((config) => {
     console.log(config,'config')
@@ -102,15 +103,17 @@ http.interceptors.request.use((config) => {
         }
     }
     
-    // ===== 新增:记录埋点 =====
-    // 获取完整的接口路径
+    // ===== 新增:记录埋点(节流,避免页面初始化时过慢) =====
     const baseURL = config.baseURL || window.location.origin + BACKEND_API_PREFIX
     const url = config.url
     const fullPath = url.startsWith('http') ? url : baseURL.replace(/\/$/, '') + (url.startsWith('/') ? url : '/' + url)
     const method = (config.method || 'GET').toUpperCase()
     
-    // 异步记录埋点,不阻塞主请求
-    recordTrackingAsync(fullPath, method)
+    const now = Date.now()
+    if (now - lastTrackingAt > TRACKING_THROTTLE_MS) {
+        lastTrackingAt = now
+        recordTrackingAsync(fullPath, method)
+    }
     
     return config;
 }, error => { return Promise.reject(error) })

+ 11 - 21
shudao-vue-frontend/src/views/Chat.vue

@@ -657,7 +657,7 @@ import WebSearchSidebar from '@/components/WebSearchSidebar.vue'
 import WebSearchSummary from '@/components/WebSearchSummary.vue'
 import StatusAvatar from '@/components/StatusAvatar.vue'
 import { createSSEConnection, closeSSEConnection } from '@/utils/sse'
-import { getApiPrefix, getReportApiPrefix } from '@/utils/apiConfig'
+import { getApiPrefix } from '@/utils/apiConfig'
 import { Document } from '@element-plus/icons-vue'
 
 // 导入发送按钮图标
@@ -1220,27 +1220,17 @@ const getHistoryRecordList = async () => {
   try {
     isLoadingHistory.value = true
     
-    // 分别请求四种业务类型的历史记录
-    const businessTypes = [0, 1, 2, 3] // 0:AI问答, 1:安全培训, 2:AI写作, 3:考试工坊
-    
-    const requests = businessTypes.map(type => 
-      apis.getHistoryRecord({ 
-        ai_conversation_id: 0,
-        business_type: type
-      })
-    )
-    
-    const responses = await Promise.all(requests)
-    
+    const response = await apis.getHistoryRecord({
+      ai_conversation_id: 0
+    })
+
     let allRecords = []
     let totalCount = 0
-    
-    responses.forEach(response => {
-      if (response.statusCode === 200 && response.data) {
-        totalCount += response.total || response.data.length || 0
-        allRecords = allRecords.concat(response.data)
-      }
-    })
+
+    if (response.statusCode === 200 && response.data) {
+      totalCount = response.total || response.data.length || 0
+      allRecords = response.data
+    }
     
     // 按时间降序排序
     allRecords.sort((a, b) => new Date(b.updated_at) - new Date(a.updated_at))
@@ -2735,7 +2725,7 @@ const handleReportGeneratorSubmit = async (data) => {
   })
   
   try {
-    const apiPrefix = getReportApiPrefix() // 改为使用 getReportApiPrefix
+    const apiPrefix = getApiPrefix()
     const url = `${apiPrefix}/report/complete-flow`
 
     // 构建 POST 请求体

+ 9 - 3
shudao-vue-frontend/src/views/mobile/m-Chat.vue

@@ -3553,11 +3553,17 @@ const callStreamChatWithDB = async (messageToAI, aiMessage, userMessage, current
     aiMessage.isTyping = true
     aiMessage.isStreaming = true // 确保标记为流式
 
+    const headers = { 'Content-Type': 'application/json' }
+    const token = getToken()
+    const tokenType = getTokenType()
+    if (token && tokenType) {
+      const bearerType = tokenType.charAt(0).toUpperCase() + tokenType.slice(1).toLowerCase()
+      headers['Authorization'] = `${bearerType} ${token}`
+    }
+
     const response = await fetch(BACKEND_API_PREFIX + '/stream/chat-with-db', {
       method: 'POST',
-      headers: {
-        'Content-Type': 'application/json',
-      },
+      headers,
       body: JSON.stringify({
         message: messageToAI,
         // ===== 已删除:user_id - 后端从token解析 =====