forums.py 9.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274
  1. from fastapi import APIRouter, Depends, HTTPException, WebSocket, WebSocketDisconnect
  2. from typing import List, Annotated, Any
  3. import json
  4. from app.db.session import get_db
  5. from app.schemas import (
  6. ForumCreate,
  7. ForumResponse,
  8. MessageCreate,
  9. MessageResponse,
  10. SystemLogResponse,
  11. ForumStartRequest
  12. )
  13. from app.crud import get_forum, get_forum_messages, get_forum_participants
  14. from app.crud.crud_system_log import get_system_logs
  15. from app.api.deps import get_current_user
  16. from app.core.websockets import manager
  17. from app.services.forum_service import ForumService
  18. from app.db.client import fetch_all, fetch_one, RowObject
  19. from app.core.cache import cache_service
  20. router = APIRouter()
  21. def get_forum_service(db: Any = Depends(get_db)) -> ForumService:
  22. return ForumService(db)
  23. def forum_list_cache_key(user_id: int, skip: int, limit: int):
  24. return f"forums:list:{user_id}:{skip}:{limit}"
  25. def obj_to_dict(obj):
  26. if isinstance(obj, list):
  27. return [obj_to_dict(i) for i in obj]
  28. if hasattr(obj, '__dict__'):
  29. d = obj.__dict__.copy()
  30. for k, v in d.items():
  31. d[k] = obj_to_dict(v)
  32. return d
  33. return obj
  34. @router.post("/", response_model=ForumResponse)
  35. def create_new_forum(
  36. forum: ForumCreate,
  37. current_user: Annotated[Any, Depends(get_current_user)],
  38. service: ForumService = Depends(get_forum_service)
  39. ):
  40. try:
  41. result = service.create_new_forum(forum, current_user.id)
  42. # Invalidate list cache for this user
  43. cache_service.delete_keys_pattern(f"forums:list:{current_user.id}:*")
  44. # Ensure result is compatible with ForumResponse
  45. # If result.summary_history is a string, it might need parsing if Pydantic doesn't handle it
  46. # But Pydantic validator in ForumResponse should handle it.
  47. # However, if result is a RowObject, Pydantic's from_attributes=True should handle it.
  48. return result
  49. except Exception as e:
  50. # Check if it's a validation error or known exception
  51. if isinstance(e, HTTPException):
  52. raise e
  53. # Log unexpected errors
  54. import logging
  55. logging.getLogger(__name__).error(f"Error creating forum: {e}", exc_info=True)
  56. raise HTTPException(status_code=500, detail="Failed to create forum")
  57. @router.get("/", response_model=List[ForumResponse])
  58. def list_forums(
  59. db: Any = Depends(get_db),
  60. skip: int = 0,
  61. limit: int = 100,
  62. current_user: Annotated[Any, Depends(get_current_user)] = None
  63. ):
  64. # Cache Aside
  65. cache_key = forum_list_cache_key(current_user.id, skip, limit)
  66. # Increased TTL to 30s to balance responsiveness and DB load
  67. # Invalidation is handled by create/delete endpoints
  68. cached_data = cache_service.get_cache(cache_key)
  69. if cached_data:
  70. # Reconstruct RowObjects from dicts isn't strictly necessary for Pydantic response,
  71. # Pydantic can validate from dicts.
  72. return cached_data
  73. rs = db.execute(
  74. "SELECT * FROM forums WHERE creator_id = ? ORDER BY start_time DESC LIMIT ? OFFSET ?",
  75. [current_user.id, limit, skip]
  76. )
  77. forums = fetch_all(rs)
  78. for forum in forums:
  79. # Populate participants
  80. participants = get_forum_participants(db, forum.id)
  81. # Convert participants to dicts for caching immediately?
  82. # No, fetch_all returns RowObjects.
  83. # We attach RowObjects.
  84. setattr(forum, "participants", participants)
  85. # Populate moderator
  86. if forum.moderator_id:
  87. rs_mod = db.execute("SELECT * FROM moderators WHERE id = ?", [forum.moderator_id])
  88. mod = fetch_one(rs_mod)
  89. setattr(forum, "moderator", mod)
  90. else:
  91. setattr(forum, "moderator", None)
  92. # Cache Write
  93. # Serialize to dicts
  94. forums_data = obj_to_dict(forums)
  95. cache_service.set_cache(cache_key, forums_data, expire=30) # Increased TTL to 30s
  96. return forums
  97. def _authorized_forum(forum_id: int, db: Any, current_user: Any):
  98. db_forum = get_forum(db, forum_id=forum_id)
  99. if db_forum is None:
  100. raise HTTPException(status_code=404, detail="Forum not found")
  101. if db_forum.creator_id != current_user.id and current_user.role != "admin":
  102. raise HTTPException(status_code=403, detail="Not authorized")
  103. return db_forum
  104. @router.get("/{forum_id}", response_model=ForumResponse)
  105. def read_forum(
  106. forum_id: int,
  107. db: Any = Depends(get_db),
  108. current_user: Annotated[Any, Depends(get_current_user)] = None,
  109. ):
  110. return _authorized_forum(forum_id, db, current_user)
  111. @router.delete("/{forum_id}")
  112. async def delete_forum_endpoint(
  113. forum_id: int,
  114. current_user: Annotated[Any, Depends(get_current_user)],
  115. service: ForumService = Depends(get_forum_service)
  116. ):
  117. is_admin = current_user.role == 'admin'
  118. success = await service.delete_forum(forum_id, current_user.id, is_admin)
  119. if not success:
  120. raise HTTPException(status_code=500, detail="Failed to delete forum")
  121. # Invalidate list cache for this user
  122. cache_service.delete_keys_pattern(f"forums:list:{current_user.id}:*")
  123. return {"message": "Forum deleted successfully"}
  124. @router.post("/{forum_id}/stop")
  125. async def stop_forum_endpoint(
  126. forum_id: int,
  127. current_user: Annotated[Any, Depends(get_current_user)],
  128. service: ForumService = Depends(get_forum_service),
  129. ):
  130. is_admin = current_user.role == "admin"
  131. return await service.stop_forum(forum_id, current_user.id, is_admin)
  132. @router.post("/{forum_id}/start")
  133. async def start_forum_endpoint(
  134. forum_id: int,
  135. request: ForumStartRequest = None,
  136. current_user: Annotated[Any, Depends(get_current_user)] = None,
  137. service: ForumService = Depends(get_forum_service)
  138. ):
  139. if current_user is None:
  140. raise HTTPException(status_code=401, detail="Not authenticated")
  141. is_admin = current_user.role == 'admin'
  142. ablation_flags = request.ablation_flags if request else None
  143. return await service.start_forum(forum_id, current_user.id, is_admin, ablation_flags)
  144. @router.post("/{forum_id}/chat", status_code=202)
  145. async def user_chat(
  146. forum_id: int,
  147. request: dict,
  148. db: Any = Depends(get_db),
  149. current_user: Annotated[Any, Depends(get_current_user)] = None,
  150. ):
  151. """
  152. Inject a user message into the forum loop.
  153. Request body: {"speaker": "User", "content": "Hello"}
  154. """
  155. _authorized_forum(forum_id, db, current_user)
  156. speaker = request.get("speaker", "观众")
  157. content = str(request.get("content", "")).strip()
  158. if not content:
  159. raise HTTPException(status_code=400, detail="Content is required")
  160. from app.services.forum_scheduler import scheduler
  161. await scheduler.push_user_message(forum_id, speaker, content)
  162. return {"status": "queued"}
  163. @router.post("/{forum_id}/messages", response_model=MessageResponse)
  164. async def post_message(
  165. forum_id: int,
  166. message: MessageCreate,
  167. service: ForumService = Depends(get_forum_service),
  168. current_user: Annotated[Any, Depends(get_current_user)] = None,
  169. ):
  170. _authorized_forum(forum_id, service.db, current_user)
  171. return await service.post_message(forum_id, message)
  172. @router.get("/{forum_id}/messages", response_model=List[MessageResponse])
  173. def get_messages(
  174. forum_id: int,
  175. db: Any = Depends(get_db),
  176. current_user: Annotated[Any, Depends(get_current_user)] = None,
  177. ):
  178. _authorized_forum(forum_id, db, current_user)
  179. return get_forum_messages(db, forum_id=forum_id)
  180. @router.get("/{forum_id}/logs", response_model=List[SystemLogResponse])
  181. def get_forum_logs(
  182. forum_id: int,
  183. db: Any = Depends(get_db),
  184. current_user: Annotated[Any, Depends(get_current_user)] = None,
  185. ):
  186. _authorized_forum(forum_id, db, current_user)
  187. return get_system_logs(db, forum_id=forum_id)
  188. @router.websocket("/{forum_id}/ws")
  189. async def websocket_endpoint(websocket: WebSocket, forum_id: int):
  190. # print(f"WS: Received connection request for forum {forum_id}")
  191. async def reject_connection():
  192. # Accept then close so real browser clients observe the policy close
  193. # code instead of an opaque HTTP handshake rejection.
  194. await websocket.accept()
  195. await websocket.close(code=1008)
  196. token = websocket.query_params.get("token")
  197. if not token:
  198. await reject_connection()
  199. return
  200. from app.db.session import db_manager
  201. try:
  202. db = db_manager.get_connection()
  203. try:
  204. from app.core.security import SECRET_KEY, ALGORITHM
  205. from jose import jwt
  206. from app.crud import get_user_by_username
  207. payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
  208. username = payload.get("sub")
  209. user = get_user_by_username(db, username) if username else None
  210. if not user:
  211. raise ValueError("invalid user")
  212. _authorized_forum(forum_id, db, user)
  213. finally:
  214. db.close()
  215. except Exception:
  216. await reject_connection()
  217. return
  218. try:
  219. await manager.connect(websocket, forum_id)
  220. # print(f"WS: Connection accepted for forum {forum_id}")
  221. except Exception as e:
  222. print(f"WS: Connection failed for forum {forum_id}: {e}")
  223. return
  224. try:
  225. while True:
  226. try:
  227. data = await websocket.receive_text()
  228. if data == "ping":
  229. await websocket.send_text("pong")
  230. except RuntimeError as e:
  231. # print(f"WS: RuntimeError in loop for forum {forum_id}: {e}")
  232. break
  233. except WebSocketDisconnect:
  234. # print(f"WS: Client disconnected for forum {forum_id}")
  235. break
  236. except Exception as e:
  237. print(f"WS: Unexpected error for forum {forum_id}: {e}")
  238. finally:
  239. # print(f"WS: Cleaning up connection for forum {forum_id}")
  240. await manager.disconnect(websocket, forum_id)