"""聊天室接口。 一条公共大厅 + 若干私聊会话: - 公共大厅:所有登录用户可见,`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() @router.get("/api/chat-room/messages") 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 [], } @router.get("/api/chat-room/pinned") 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), } @router.post("/api/chat-room/messages/{message_id}/pin") 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} @router.get("/api/chat-room/direct-conversations") 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} @router.get("/api/chat-room/online") 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, } @router.get("/api/chat-room/unread") 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(), } @router.get("/api/chat-room/summary") 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), }, } @router.post("/api/chat-room/messages") 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} @router.delete("/api/chat-room/messages/{message_id}") 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} @router.post("/api/chat-room/uploads") 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} @router.get("/api/chat-uploads/{filename}") 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"}, ) # ------------------------------------------------------------ 好友(私聊前置) @router.get("/api/chat-room/friends") 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), } @router.post("/api/chat-room/friends/request") 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 @router.post("/api/chat-room/friends/respond") 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 @router.post("/api/chat-room/friends/remove") 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