Download api/chat_room.py from zlcy/study: direct link, hf CLI and curl.
- Browser
- Download file 19.3 kB
-
https://huggingface.co/spaces/zlcy/study/resolve/main/api/chat_room.py
- Command line
-
hf download hf://spaces/zlcy/study/api/chat_room.py
-
curl -L -o chat_room.py https://huggingface.co/spaces/zlcy/study/resolve/main/api/chat_room.py
19.3 kB
| """聊天室接口。 | |
| 一条公共大厅 + 若干私聊会话: | |
| - 公共大厅:所有登录用户可见,`conversation` 缺省即为此; | |
| - 私聊:前端传 `peer_id`,**会话键由服务端按当前身份与对方身份计算**, | |
| 前端拿不到也构造不出别人的会话键。 | |
| ⚠️ 所有读接口都要求登录:聊天记录里有昵称与头像,不对未登录开放。 | |
| """ | |
| from __future__ import annotations | |
| from fastapi import APIRouter, File, Header, HTTPException, UploadFile | |
| from fastapi.responses import FileResponse | |
| from pydantic import BaseModel | |
| from api.support import require_identity | |
| from services.chat_room_service import ( | |
| DEFAULT_PAGE_SIZE, | |
| MAX_PAGE_SIZE, | |
| conversation_key, | |
| is_direct_conversation, | |
| participant_ids, | |
| chat_room_service, | |
| ) | |
| from services.chat_upload_service import ChatUploadError, resolve_chat_image, save_chat_image | |
| from services.friend_service import friend_service | |
| from services.presence_service import presence_service, resolve_avatar_url | |
| class ChatMessageRequest(BaseModel): | |
| content: str = "" | |
| # 空 = 发到公共大厅;填对方身份 id = 发到与该用户的私聊(需先成为好友)。 | |
| peer_id: str = "" | |
| # 引用回复的目标消息 id;0 = 不引用。 | |
| reply_to_id: int = 0 | |
| # 已经通过上传接口拿到的图片地址;空 = 纯文本消息。 | |
| image_url: str = "" | |
| class FriendRequestBody(BaseModel): | |
| peer_id: str = "" | |
| note: str = "" | |
| class ChatPinRequest(BaseModel): | |
| """置顶 / 取消置顶。`pinned=True` 置顶,`False` 取消。""" | |
| pinned: bool = True | |
| class FriendRespondBody(BaseModel): | |
| peer_id: str = "" | |
| accept: bool = True | |
| def _is_admin(identity: dict[str, object]) -> bool: | |
| return str(identity.get("role") or "").strip().lower() == "admin" | |
| def _resolve_conversation( | |
| identity: dict[str, object], peer_id: str, *, require_online: bool | |
| ) -> str: | |
| """按当前身份 + 对方身份算出会话键。 | |
| ⚠️ 会话键**必须**由服务端计算:前端只提供「对方是谁」,拿不到也构造不出别人的键。 | |
| `require_online` 区分读 / 写: | |
| - 读历史(False):对方下线后聊天记录仍要能翻,只有「会话不存在」才当作空会话; | |
| - 发新消息(True):对方不在线就没有收件人,直接 404 提示,避免写出一堆无人可读的消息。 | |
| """ | |
| me = str(identity.get("id") or "").strip() | |
| peer = str(peer_id or "").strip() | |
| if peer and peer != me and require_online: | |
| # 私聊对象必须是在线用户,避免给不存在的 id 建出一堆空会话。 | |
| if not any(str(user.get("id")) == peer for user in presence_service.users()): | |
| raise HTTPException(status_code=404, detail={"error": "对方当前不在线,无法私聊"}) | |
| return conversation_key(me, peer) | |
| def _assert_can_direct(identity: dict[str, object], peer_id: str) -> None: | |
| """谁能**发起**私聊(新会话)。 | |
| ⚠️ 权限判定**只认服务端身份**,绝不接受前端传来的「我是管理员」之类声明。 | |
| 当前规则(用户 2026-10-15 明确): | |
| - **管理员**:可以直接私聊任意用户,**不需要加好友**; | |
| - **普通 / 内部用户**:不能主动发起新私聊。 | |
| ⚠️⚠️ 这个函数**只能用于「发起」**,绝不能拿去卡「读历史 / 回复」—— | |
| 否则管理员私聊普通用户后,对方会被 403 挡住,表现就是「收不到消息、也回不了」。 | |
| 收件人侧的读写放行见 `_assert_can_access_conversation`。 | |
| """ | |
| if _is_admin(identity): | |
| return | |
| raise HTTPException(status_code=403, detail={"error": "当前仅管理员可以发起私聊"}) | |
| def _assert_can_access_conversation(identity: dict[str, object], conversation: str) -> None: | |
| """能否读写这个私聊会话。 | |
| ⚠️ 规则:**会话双方都能访问**。管理员发起私聊后,收件人(普通用户)必须能 | |
| 读到这条消息并回复 —— 这正是「只允许 admin 发起」被误用到读/写路径后出的 bug。 | |
| 仍然拒绝「与己无关的第三方」构造出别人的会话键来偷看。 | |
| """ | |
| if not is_direct_conversation(conversation): | |
| return | |
| if _is_admin(identity): | |
| return | |
| me = str(identity.get("id") or "").strip() | |
| participants = participant_ids(conversation) | |
| if participants and me and me in participants: | |
| return | |
| raise HTTPException(status_code=403, detail={"error": "无权访问该私聊会话"}) | |
| def _has_history(conversation: str) -> bool: | |
| """这个私聊会话里是否已经有消息往来。 | |
| ⚠️⚠️ 用于区分「回复已有会话」与「发起全新会话」,两者权限**故意不同**: | |
| - 有历史 → 会话双方都能继续发(收件人能回话); | |
| - 无历史 → 只有管理员能开这个头。 | |
| 判据不能只看会话键里的参与者 —— 新会话的键天然包含发起人自己, | |
| 拿它当放行依据等于所有私聊都放行。 | |
| """ | |
| try: | |
| page = chat_room_service.history(conversation=conversation, after_id=0, limit=1) | |
| except Exception: # noqa: BLE001 - 查库失败按「无历史」保守处理 | |
| return False | |
| messages = page.get("messages") if isinstance(page, dict) else None | |
| return bool(messages) | |
| def _parse_marks(raw: str) -> dict[str, int]: | |
| """解析前端传来的已读位置 `peer_id:last_id,peer_id:last_id`。 | |
| ⚠️ 前端传进来的东西一律当**不可信输入**处理:格式不对的整段丢弃(宁可多显示未读, | |
| 也不能因为一段脏数据让接口 500)。前端只是为了少拉一次全量消息才带上它, | |
| 真正的安全边界不在这里(每个会话的可见性由 `_assert_can_access_conversation` 保证)。 | |
| """ | |
| marks: dict[str, int] = {} | |
| for chunk in str(raw or "").split(","): | |
| peer, _, value = chunk.partition(":") | |
| peer = peer.strip() | |
| if not peer: | |
| continue | |
| try: | |
| last_id = int(value.strip() or 0) | |
| except (TypeError, ValueError): | |
| continue | |
| if last_id > 0: | |
| marks[peer] = last_id | |
| return marks | |
| def create_router() -> APIRouter: | |
| router = APIRouter() | |
| async def list_messages( | |
| after_id: int = 0, | |
| limit: int = DEFAULT_PAGE_SIZE, | |
| peer_id: str = "", | |
| authorization: str | None = Header(default=None), | |
| ): | |
| """拉取消息。`after_id=0` 取最新一页,`after_id>0` 取增量(轮询用)。 | |
| `peer_id` 非空时读取的是与对方的私聊会话。 | |
| """ | |
| identity = require_identity(authorization) | |
| conversation = _resolve_conversation(identity, peer_id, require_online=False) | |
| # ⚠️ 读历史用「会话双方都能访问」,**不能**用 _assert_can_direct —— | |
| # 那样管理员发起私聊后,收件人永远读不到。 | |
| _assert_can_access_conversation(identity, conversation) | |
| payload = chat_room_service.history( | |
| conversation=conversation, after_id=after_id, limit=limit | |
| ) | |
| return { | |
| **payload, | |
| "conversation": conversation, | |
| "is_direct": is_direct_conversation(conversation), | |
| "online": presence_service.snapshot(), | |
| "pinned": chat_room_service.pinned_messages(conversation) if not is_direct_conversation(conversation) else [], | |
| } | |
| async def list_pinned( | |
| peer_id: str = "", | |
| authorization: str | None = Header(default=None), | |
| ): | |
| """当前会话的置顶消息(公告)。私聊不参与置顶。""" | |
| identity = require_identity(authorization) | |
| conversation = _resolve_conversation(identity, peer_id, require_online=False) | |
| _assert_can_access_conversation(identity, conversation) | |
| return { | |
| "conversation": conversation, | |
| "items": chat_room_service.pinned_messages(conversation), | |
| } | |
| async def pin_message( | |
| message_id: int, | |
| body: ChatPinRequest, | |
| authorization: str | None = Header(default=None), | |
| ): | |
| """置顶 / 取消置顶一条消息。⚠️ 仅管理员(服务端强制)。""" | |
| identity = require_identity(authorization) | |
| try: | |
| message = chat_room_service.set_pinned(identity, message_id, bool(body.pinned)) | |
| except PermissionError as exc: | |
| raise HTTPException(status_code=403, detail={"error": str(exc)}) from exc | |
| except ValueError as exc: | |
| raise HTTPException(status_code=400, detail={"error": str(exc)}) from exc | |
| return {"message": message} | |
| async def list_direct_conversations( | |
| mark: str = "", | |
| authorization: str | None = Header(default=None), | |
| ): | |
| """我的私聊收件箱。 | |
| ⚠️ 没有它,收件人**无从知道**有人给自己发了私聊(私聊历史只能按对方 id 查)。 | |
| 管理员私聊普通用户后,普通用户靠这个接口才能看到入口。 | |
| `mark` 是前端本地已读位置,格式 `peer_id:last_id,peer_id:last_id`。 | |
| ⚠️ **已读状态存在浏览器本地**(localStorage),服务端不替用户记 —— | |
| 这样不需要新建「每用户每会话已读表」,也就不存在多设备同步的额外负担。 | |
| 服务端只负责数「这个会话里,对方发的、id 大于我读过位置的消息有几条」。 | |
| """ | |
| identity = require_identity(authorization) | |
| me = str(identity.get("id") or "").strip() | |
| marks = _parse_marks(mark) | |
| items = chat_room_service.direct_conversations(me, marks=marks) | |
| # 补上对方当前是否在线 / 头像(对方可能已下线,此时用消息里的快照)。 | |
| online_ids = {str(user.get("id") or "") for user in presence_service.users()} | |
| for item in items: | |
| item["peer_online"] = str(item.get("peer_id") or "") in online_ids | |
| return {"items": items} | |
| async def online_users(authorization: str | None = Header(default=None)): | |
| """在线用户列表(左侧栏用)。排除自己。 | |
| `can_direct` 由服务端判定本条身份能否与对方私聊,前端只按它决定 | |
| 「点击进私聊」还是「仅展示」—— 避免前端各写一套角色判断而走样。 | |
| """ | |
| identity = require_identity(authorization) | |
| me = str(identity.get("id") or "").strip() | |
| # 当前规则:仅管理员可私聊(不需要加好友)。 | |
| can_direct = _is_admin(identity) | |
| users = [] | |
| for user in presence_service.users(): | |
| user_id = str(user.get("id") or "") | |
| if not user_id or user_id == me: | |
| continue | |
| users.append({**user, "can_direct": can_direct}) | |
| return { | |
| "users": users, | |
| "total": presence_service.snapshot().get("total", 0), | |
| "can_direct": can_direct, | |
| } | |
| async def chat_room_unread( | |
| after_id: int = 0, | |
| authorization: str | None = Header(default=None), | |
| ): | |
| """未读角标:公共大厅里比我本地已读位置更新、且不是我发的消息数。 | |
| `after_id` 由前端从 localStorage 提供(跨页面 / 跨标签共享), | |
| 服务端只做差集计算,不维护每个用户的已读游标 —— 少一张表、少一处一致性问题。 | |
| """ | |
| identity = require_identity(authorization) | |
| me = str(identity.get("id") or "").strip() | |
| return { | |
| "unread": chat_room_service.unread_from_others(me, after_id), | |
| "latest_id": chat_room_service.latest_id(), | |
| } | |
| async def chat_room_summary( | |
| peer_id: str = "", | |
| authorization: str | None = Header(default=None), | |
| ): | |
| identity = require_identity(authorization) | |
| conversation = _resolve_conversation(identity, peer_id, require_online=False) | |
| return { | |
| **chat_room_service.stats(conversation), | |
| "conversation": conversation, | |
| "is_direct": is_direct_conversation(conversation), | |
| "online": presence_service.snapshot(), | |
| "me": { | |
| "id": identity.get("id"), | |
| "name": identity.get("name"), | |
| "role": identity.get("role"), | |
| # ⚠️ 走 resolve_avatar_url:微信登录用户的头像在 wechat_avatar 里, | |
| # 直读 avatar_url 会得到空串 → 前端只能退化成首字占位。 | |
| "avatar_url": resolve_avatar_url(identity), | |
| }, | |
| } | |
| async def send_message(body: ChatMessageRequest, authorization: str | None = Header(default=None)): | |
| identity = require_identity(authorization) | |
| conversation = _resolve_conversation(identity, body.peer_id, require_online=True) | |
| # ⚠️ 私聊的发送权限分两种情形,别混用: | |
| # ① 管理员 → 可**主动发起**新私聊(不需要加好友); | |
| # ② 普通用户 → 不能发起,但**可以回复别人发给他的**私聊。 | |
| # 只看 _assert_can_direct 会把 ② 一起挡掉,表现就是「收到消息却回不了」。 | |
| if is_direct_conversation(conversation): | |
| if _is_admin(identity): | |
| pass | |
| elif _has_history(conversation): | |
| # 已有往来的会话:双方都能继续聊(这就是修「收到却回不了」的那条路径)。 | |
| _assert_can_access_conversation(identity, conversation) | |
| else: | |
| # ⚠️⚠️ 没有任何历史的**新会话**,只有管理员能发起。 | |
| # 不能走 _assert_can_access_conversation —— 新会话的键就是 `dm:me|peer`, | |
| # 「me 是参与者」恒成立,那个函数会无条件放行,等于普通用户也能随便私聊任何人。 | |
| _assert_can_direct(identity, body.peer_id) | |
| try: | |
| message = chat_room_service.post( | |
| identity, | |
| body.content, | |
| conversation=conversation, | |
| image_url=body.image_url, | |
| reply_to_id=body.reply_to_id, | |
| ) | |
| except ValueError as exc: | |
| raise HTTPException(status_code=400, detail={"error": str(exc)}) from exc | |
| return {"message": message} | |
| async def delete_message(message_id: int, authorization: str | None = Header(default=None)): | |
| """撤回 / 删除消息。 | |
| ⚠️ 权限在服务端强制(见 `ChatRoomService.delete`): | |
| 作者本人 2 分钟内可撤回;管理员可删任意消息。 | |
| 前端只负责按同样规则决定是否显示「撤回 / 删除」按钮,绝不用它做权限判定。 | |
| """ | |
| identity = require_identity(authorization) | |
| try: | |
| message = chat_room_service.delete( | |
| identity, message_id, is_admin=_is_admin(identity) | |
| ) | |
| except ValueError as exc: | |
| raise HTTPException(status_code=400, detail={"error": str(exc)}) from exc | |
| return {"message": message} | |
| async def upload_image( | |
| file: UploadFile = File(...), | |
| authorization: str | None = Header(default=None), | |
| ): | |
| """上传聊天图片,返回可直接放进消息里发送的 URL。""" | |
| require_identity(authorization) | |
| content = await file.read() | |
| await file.close() | |
| try: | |
| url = save_chat_image(content, file.content_type or "") | |
| except ChatUploadError as exc: | |
| raise HTTPException(status_code=400, detail={"error": str(exc)}) from exc | |
| return {"url": url} | |
| async def get_chat_image(filename: str): | |
| path = resolve_chat_image(filename) | |
| if path is None: | |
| raise HTTPException(status_code=404, detail={"error": "图片不存在"}) | |
| media_type = "image/gif" if path.suffix.lower() == ".gif" else "image/webp" | |
| return FileResponse( | |
| path, | |
| media_type=media_type, | |
| headers={"Cache-Control": "public, max-age=31536000, immutable"}, | |
| ) | |
| # ------------------------------------------------------------ 好友(私聊前置) | |
| async def list_friends(authorization: str | None = Header(default=None)): | |
| """我的好友 id 列表 + 收到的待处理申请。""" | |
| identity = require_identity(authorization) | |
| me = str(identity.get("id") or "").strip() | |
| incoming = friend_service.incoming_requests(me) | |
| # 申请方可能已经下线,这里把在线名册一并给出,前端用来补昵称。 | |
| names = {str(user.get("id")): user.get("name") for user in presence_service.users()} | |
| return { | |
| "friends": friend_service.friend_ids(me), | |
| "incoming": [ | |
| { | |
| **item, | |
| "from_name": names.get(str(item.get("from_id"))) or str(item.get("from_id")), | |
| } | |
| for item in incoming | |
| ], | |
| "outgoing": friend_service.outgoing_requests(me), | |
| } | |
| async def request_friend(body: FriendRequestBody, authorization: str | None = Header(default=None)): | |
| """发起好友申请。对方恰好也申请过我 → 直接互相成为好友。""" | |
| identity = require_identity(authorization) | |
| me = str(identity.get("id") or "").strip() | |
| try: | |
| return friend_service.request(me, body.peer_id, body.note) | |
| except ValueError as exc: | |
| raise HTTPException(status_code=400, detail={"error": str(exc)}) from exc | |
| async def respond_friend(body: FriendRespondBody, authorization: str | None = Header(default=None)): | |
| """同意 / 拒绝收到的申请。""" | |
| identity = require_identity(authorization) | |
| me = str(identity.get("id") or "").strip() | |
| try: | |
| return friend_service.respond(me, body.peer_id, bool(body.accept)) | |
| except ValueError as exc: | |
| raise HTTPException(status_code=400, detail={"error": str(exc)}) from exc | |
| async def remove_friend(body: FriendRequestBody, authorization: str | None = Header(default=None)): | |
| """解除好友关系(双向生效)。""" | |
| identity = require_identity(authorization) | |
| me = str(identity.get("id") or "").strip() | |
| removed = friend_service.remove(me, body.peer_id) | |
| return {"removed": removed} | |
| return router | |