study / api /chat_room.py
xiaolongmr
聊天室:修复置顶 422,未读角标改挂到用户行
ca6cef4
Raw History Blame Contribute Delete
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()
@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