小程序后端接入 talkingq 共享库并新增消息与定位能力
This commit is contained in:
541
mini-program/app/routers/im.py
Normal file
541
mini-program/app/routers/im.py
Normal file
@@ -0,0 +1,541 @@
|
||||
import json
|
||||
import logging
|
||||
from collections.abc import Mapping
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response, status
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
try:
|
||||
from app.db import get_db
|
||||
from app.security import get_current_user_id
|
||||
from app.schemas.im import (
|
||||
ChildConversationItem,
|
||||
ChildConversationListResponse,
|
||||
ChildConversationMessageItem,
|
||||
ChildConversationMessageListResponse,
|
||||
ConversationMessageCreateResponse,
|
||||
ParentChildMessageCreateRequest,
|
||||
)
|
||||
from app.service.im import create_parent_child_message
|
||||
except ModuleNotFoundError:
|
||||
from db import get_db
|
||||
from security import get_current_user_id
|
||||
from schemas.im import (
|
||||
ChildConversationItem,
|
||||
ChildConversationListResponse,
|
||||
ChildConversationMessageItem,
|
||||
ChildConversationMessageListResponse,
|
||||
ConversationMessageCreateResponse,
|
||||
ParentChildMessageCreateRequest,
|
||||
)
|
||||
from service.im import create_parent_child_message
|
||||
|
||||
|
||||
router = APIRouter(prefix="/children", tags=["im"])
|
||||
logger = logging.getLogger("app.im")
|
||||
|
||||
PARENT_PARTICIPANT_TYPE = 1
|
||||
CHILD_PARTICIPANT_TYPE = 2
|
||||
|
||||
CHILD_PEER_CONVERSATION_TYPE = 1
|
||||
PARENT_CHILD_CONVERSATION_TYPE = 2
|
||||
SUPPORTED_CONVERSATION_TYPES = {
|
||||
CHILD_PEER_CONVERSATION_TYPE,
|
||||
PARENT_CHILD_CONVERSATION_TYPE,
|
||||
}
|
||||
|
||||
CONVERSATION_TYPE_NAMES = {
|
||||
CHILD_PEER_CONVERSATION_TYPE: "child_peer",
|
||||
PARENT_CHILD_CONVERSATION_TYPE: "parent_child",
|
||||
}
|
||||
|
||||
PARTICIPANT_TYPE_NAMES = {
|
||||
PARENT_PARTICIPANT_TYPE: "parent",
|
||||
CHILD_PARTICIPANT_TYPE: "child",
|
||||
}
|
||||
|
||||
|
||||
def _normalize_content_json(value: Any) -> dict[str, Any] | None:
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, dict):
|
||||
return value
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
parsed = json.loads(value)
|
||||
if isinstance(parsed, dict):
|
||||
return parsed
|
||||
except json.JSONDecodeError:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def _participant_type_name(participant_type: int) -> str:
|
||||
return PARTICIPANT_TYPE_NAMES.get(participant_type, f"unknown_{participant_type}")
|
||||
|
||||
|
||||
def _conversation_type_name(conversation_type: int) -> str:
|
||||
return CONVERSATION_TYPE_NAMES.get(conversation_type, f"unknown_{conversation_type}")
|
||||
|
||||
|
||||
def _build_in_params(prefix: str, values: list[int]) -> tuple[str, dict[str, int]]:
|
||||
placeholders: list[str] = []
|
||||
params: dict[str, int] = {}
|
||||
for index, value in enumerate(values):
|
||||
key = f"{prefix}_{index}"
|
||||
placeholders.append(f":{key}")
|
||||
params[key] = value
|
||||
return ", ".join(placeholders), params
|
||||
|
||||
|
||||
def _fetch_parent_names(db: Session, user_ids: set[int]) -> dict[int, str | None]:
|
||||
if not user_ids:
|
||||
return {}
|
||||
|
||||
values = sorted(user_ids)
|
||||
placeholders, params = _build_in_params("user_id", values)
|
||||
rows = (
|
||||
db.execute(
|
||||
text(
|
||||
f"""
|
||||
SELECT user_id, nickname
|
||||
FROM parents
|
||||
WHERE status = 1
|
||||
AND user_id IN ({placeholders})
|
||||
"""
|
||||
),
|
||||
params,
|
||||
)
|
||||
.mappings()
|
||||
.all()
|
||||
)
|
||||
return {int(row["user_id"]): row["nickname"] for row in rows}
|
||||
|
||||
|
||||
def _fetch_child_names(db: Session, child_ids: set[int]) -> dict[int, str | None]:
|
||||
if not child_ids:
|
||||
return {}
|
||||
|
||||
values = sorted(child_ids)
|
||||
placeholders, params = _build_in_params("child_id", values)
|
||||
rows = (
|
||||
db.execute(
|
||||
text(
|
||||
f"""
|
||||
SELECT child_id, child_name
|
||||
FROM children
|
||||
WHERE status = 1
|
||||
AND child_id IN ({placeholders})
|
||||
"""
|
||||
),
|
||||
params,
|
||||
)
|
||||
.mappings()
|
||||
.all()
|
||||
)
|
||||
return {int(row["child_id"]): row["child_name"] for row in rows}
|
||||
|
||||
|
||||
def _assert_child_access(db: Session, child_id: int, user_id: int) -> None:
|
||||
child_row = (
|
||||
db.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT child_id
|
||||
FROM children
|
||||
WHERE child_id = :child_id
|
||||
AND status = 1
|
||||
LIMIT 1
|
||||
"""
|
||||
),
|
||||
{"child_id": child_id},
|
||||
)
|
||||
.mappings()
|
||||
.first()
|
||||
)
|
||||
if not child_row:
|
||||
raise HTTPException(status_code=404, detail="child not found")
|
||||
|
||||
has_access = (
|
||||
db.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT 1
|
||||
FROM parent_child_relations
|
||||
WHERE user_id = :user_id
|
||||
AND child_id = :child_id
|
||||
AND status = 1
|
||||
LIMIT 1
|
||||
"""
|
||||
),
|
||||
{"user_id": user_id, "child_id": child_id},
|
||||
).scalar_one_or_none()
|
||||
is not None
|
||||
)
|
||||
if not has_access:
|
||||
raise HTTPException(status_code=403, detail="no permission to access this child")
|
||||
|
||||
|
||||
def _get_conversation_for_child(
|
||||
db: Session,
|
||||
*,
|
||||
conversation_id: int,
|
||||
child_id: int,
|
||||
) -> Mapping[str, Any]:
|
||||
child_id_str = str(child_id)
|
||||
row = (
|
||||
db.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT
|
||||
id,
|
||||
conversation_type,
|
||||
participant_a_type,
|
||||
participant_a_id,
|
||||
participant_b_type,
|
||||
participant_b_id,
|
||||
status
|
||||
FROM im_conversations
|
||||
WHERE id = :conversation_id
|
||||
LIMIT 1
|
||||
"""
|
||||
),
|
||||
{"conversation_id": conversation_id},
|
||||
)
|
||||
.mappings()
|
||||
.first()
|
||||
)
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="conversation not found")
|
||||
|
||||
conversation_type = int(row["conversation_type"])
|
||||
if conversation_type not in SUPPORTED_CONVERSATION_TYPES or int(row["status"]) != 1:
|
||||
raise HTTPException(status_code=404, detail="conversation not found")
|
||||
|
||||
is_child_participant = (
|
||||
(
|
||||
int(row["participant_a_type"]) == CHILD_PARTICIPANT_TYPE
|
||||
and row["participant_a_id"] == child_id_str
|
||||
)
|
||||
or (
|
||||
int(row["participant_b_type"]) == CHILD_PARTICIPANT_TYPE
|
||||
and row["participant_b_id"] == child_id_str
|
||||
)
|
||||
)
|
||||
if not is_child_participant:
|
||||
raise HTTPException(status_code=404, detail="conversation not found")
|
||||
|
||||
return row
|
||||
|
||||
|
||||
def _row_to_conversation_item(
|
||||
row: Mapping[str, Any],
|
||||
*,
|
||||
child_id: int,
|
||||
parent_names: Mapping[int, str | None],
|
||||
child_names: Mapping[int, str | None],
|
||||
) -> ChildConversationItem:
|
||||
child_id_str = str(child_id)
|
||||
participant_a_type = int(row["participant_a_type"])
|
||||
participant_b_type = int(row["participant_b_type"])
|
||||
participant_a_id = str(row["participant_a_id"])
|
||||
participant_b_id = str(row["participant_b_id"])
|
||||
|
||||
if participant_a_type == CHILD_PARTICIPANT_TYPE and participant_a_id == child_id_str:
|
||||
peer_type = participant_b_type
|
||||
peer_id = participant_b_id
|
||||
else:
|
||||
peer_type = participant_a_type
|
||||
peer_id = participant_a_id
|
||||
|
||||
peer_name: str | None = None
|
||||
if peer_type == PARENT_PARTICIPANT_TYPE and peer_id.isdigit():
|
||||
peer_name = parent_names.get(int(peer_id))
|
||||
elif peer_type == CHILD_PARTICIPANT_TYPE and peer_id.isdigit():
|
||||
peer_name = child_names.get(int(peer_id))
|
||||
|
||||
return ChildConversationItem(
|
||||
conversation_id=int(row["id"]),
|
||||
conversation_type=int(row["conversation_type"]),
|
||||
conversation_type_name=_conversation_type_name(int(row["conversation_type"])),
|
||||
peer_type=_participant_type_name(peer_type),
|
||||
peer_id=peer_id,
|
||||
peer_name=peer_name,
|
||||
last_message_preview=row["last_message_preview"],
|
||||
last_message_at=row["last_message_at"],
|
||||
message_count=int(row["message_count"]),
|
||||
)
|
||||
|
||||
|
||||
def _row_to_message_item(row: Mapping[str, Any]) -> ChildConversationMessageItem:
|
||||
return ChildConversationMessageItem(
|
||||
id=int(row["id"]),
|
||||
conversation_id=int(row["conversation_id"]),
|
||||
seq=int(row["seq"]),
|
||||
sender_type=_participant_type_name(int(row["sender_type"])),
|
||||
sender_id=str(row["sender_id"]),
|
||||
receiver_type=_participant_type_name(int(row["receiver_type"])),
|
||||
receiver_id=str(row["receiver_id"]),
|
||||
content_type=int(row["content_type"]),
|
||||
content_text=row["content_text"],
|
||||
content_json=_normalize_content_json(row["content_json"]),
|
||||
media_file_key=row["media_file_key"],
|
||||
media_duration_ms=row["media_duration_ms"],
|
||||
media_mime_type=row.get("media_mime_type"),
|
||||
media_size_bytes=row.get("media_size_bytes"),
|
||||
media_transcript_text=row.get("media_transcript_text"),
|
||||
client_msg_id=row["client_msg_id"],
|
||||
sender_name_snapshot=row["sender_name_snapshot"],
|
||||
sender_avatar_snapshot=row.get("sender_avatar_snapshot"),
|
||||
receiver_name_snapshot=row["receiver_name_snapshot"],
|
||||
receiver_avatar_snapshot=row.get("receiver_avatar_snapshot"),
|
||||
ext_json=_normalize_content_json(row.get("ext_json")),
|
||||
created_at=row["created_at"],
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{child_id}/conversations", response_model=ChildConversationListResponse)
|
||||
def list_child_conversations(
|
||||
child_id: int,
|
||||
request: Request,
|
||||
conversation_type: int | None = Query(default=None),
|
||||
cursor: int | None = Query(default=None, ge=1),
|
||||
limit: int = Query(default=20, ge=1, le=100),
|
||||
current_user_id: int = Depends(get_current_user_id),
|
||||
db: Session = Depends(get_db),
|
||||
) -> ChildConversationListResponse:
|
||||
if conversation_type is not None and conversation_type not in SUPPORTED_CONVERSATION_TYPES:
|
||||
raise HTTPException(status_code=422, detail="unsupported conversation_type")
|
||||
|
||||
_assert_child_access(db=db, child_id=child_id, user_id=current_user_id)
|
||||
|
||||
params: dict[str, Any] = {
|
||||
"child_id_str": str(child_id),
|
||||
"child_participant_type": CHILD_PARTICIPANT_TYPE,
|
||||
"child_peer_conversation_type": CHILD_PEER_CONVERSATION_TYPE,
|
||||
"parent_child_conversation_type": PARENT_CHILD_CONVERSATION_TYPE,
|
||||
"fetch_limit": limit + 1,
|
||||
}
|
||||
where = """
|
||||
status = 1
|
||||
AND conversation_type IN (:child_peer_conversation_type, :parent_child_conversation_type)
|
||||
AND (
|
||||
(participant_a_type = :child_participant_type AND participant_a_id = :child_id_str)
|
||||
OR (participant_b_type = :child_participant_type AND participant_b_id = :child_id_str)
|
||||
)
|
||||
"""
|
||||
if conversation_type is not None:
|
||||
where += " AND conversation_type = :conversation_type"
|
||||
params["conversation_type"] = conversation_type
|
||||
if cursor is not None:
|
||||
where += " AND id < :cursor"
|
||||
params["cursor"] = cursor
|
||||
|
||||
rows = (
|
||||
db.execute(
|
||||
text(
|
||||
f"""
|
||||
SELECT
|
||||
id,
|
||||
conversation_type,
|
||||
participant_a_type,
|
||||
participant_a_id,
|
||||
participant_b_type,
|
||||
participant_b_id,
|
||||
last_message_preview,
|
||||
last_message_at,
|
||||
message_count,
|
||||
created_at
|
||||
FROM im_conversations
|
||||
WHERE {where}
|
||||
ORDER BY COALESCE(last_message_at, created_at) DESC, id DESC
|
||||
LIMIT :fetch_limit
|
||||
"""
|
||||
),
|
||||
params,
|
||||
)
|
||||
.mappings()
|
||||
.all()
|
||||
)
|
||||
|
||||
has_more = len(rows) > limit
|
||||
rows = rows[:limit]
|
||||
next_cursor = int(rows[-1]["id"]) if has_more and rows else None
|
||||
|
||||
peer_parent_ids: set[int] = set()
|
||||
peer_child_ids: set[int] = set()
|
||||
child_id_str = str(child_id)
|
||||
for row in rows:
|
||||
participant_a_type = int(row["participant_a_type"])
|
||||
participant_b_type = int(row["participant_b_type"])
|
||||
participant_a_id = str(row["participant_a_id"])
|
||||
participant_b_id = str(row["participant_b_id"])
|
||||
if participant_a_type == CHILD_PARTICIPANT_TYPE and participant_a_id == child_id_str:
|
||||
peer_type = participant_b_type
|
||||
peer_id = participant_b_id
|
||||
else:
|
||||
peer_type = participant_a_type
|
||||
peer_id = participant_a_id
|
||||
|
||||
if peer_id.isdigit():
|
||||
if peer_type == PARENT_PARTICIPANT_TYPE:
|
||||
peer_parent_ids.add(int(peer_id))
|
||||
elif peer_type == CHILD_PARTICIPANT_TYPE:
|
||||
peer_child_ids.add(int(peer_id))
|
||||
|
||||
parent_names = _fetch_parent_names(db, peer_parent_ids)
|
||||
child_names = _fetch_child_names(db, peer_child_ids)
|
||||
|
||||
items = [
|
||||
_row_to_conversation_item(
|
||||
row,
|
||||
child_id=child_id,
|
||||
parent_names=parent_names,
|
||||
child_names=child_names,
|
||||
)
|
||||
for row in rows
|
||||
]
|
||||
|
||||
logger.info(
|
||||
"child conversations listed",
|
||||
extra={
|
||||
"event": "child_conversation_list",
|
||||
"request_id": getattr(request.state, "request_id", None),
|
||||
"user_id": current_user_id,
|
||||
"child_id": child_id,
|
||||
"conversation_type": conversation_type,
|
||||
"count": len(items),
|
||||
},
|
||||
)
|
||||
|
||||
return ChildConversationListResponse(
|
||||
items=items,
|
||||
total=len(items),
|
||||
next_cursor=next_cursor,
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{child_id}/messages",
|
||||
response_model=ConversationMessageCreateResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
def create_child_message_for_parent(
|
||||
child_id: int,
|
||||
payload: ParentChildMessageCreateRequest,
|
||||
request: Request,
|
||||
response: Response,
|
||||
current_user_id: int = Depends(get_current_user_id),
|
||||
db: Session = Depends(get_db),
|
||||
) -> ConversationMessageCreateResponse:
|
||||
result = create_parent_child_message(
|
||||
db=db,
|
||||
parent_user_id=current_user_id,
|
||||
child_id=child_id,
|
||||
payload=payload,
|
||||
)
|
||||
if result.idempotent:
|
||||
response.status_code = status.HTTP_200_OK
|
||||
|
||||
logger.info(
|
||||
"parent child message created",
|
||||
extra={
|
||||
"event": "parent_child_message_create",
|
||||
"request_id": getattr(request.state, "request_id", None),
|
||||
"user_id": current_user_id,
|
||||
"child_id": child_id,
|
||||
"conversation_id": result.conversation_id,
|
||||
"idempotent": result.idempotent,
|
||||
},
|
||||
)
|
||||
|
||||
return ConversationMessageCreateResponse(
|
||||
idempotent=result.idempotent,
|
||||
conversation_id=result.conversation_id,
|
||||
conversation_type=result.conversation_type,
|
||||
conversation_type_name=result.conversation_type_name,
|
||||
message=result.message,
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{child_id}/conversations/{conversation_id}/messages",
|
||||
response_model=ChildConversationMessageListResponse,
|
||||
)
|
||||
def list_child_conversation_messages(
|
||||
child_id: int,
|
||||
conversation_id: int,
|
||||
request: Request,
|
||||
cursor_seq: int | None = Query(default=None, ge=1),
|
||||
limit: int = Query(default=20, ge=1, le=100),
|
||||
current_user_id: int = Depends(get_current_user_id),
|
||||
db: Session = Depends(get_db),
|
||||
) -> ChildConversationMessageListResponse:
|
||||
_assert_child_access(db=db, child_id=child_id, user_id=current_user_id)
|
||||
_get_conversation_for_child(db=db, conversation_id=conversation_id, child_id=child_id)
|
||||
|
||||
sql = """
|
||||
SELECT
|
||||
id,
|
||||
conversation_id,
|
||||
seq,
|
||||
sender_type,
|
||||
sender_id,
|
||||
receiver_type,
|
||||
receiver_id,
|
||||
content_type,
|
||||
content_text,
|
||||
content_json,
|
||||
media_file_key,
|
||||
media_duration_ms,
|
||||
media_mime_type,
|
||||
media_size_bytes,
|
||||
media_transcript_text,
|
||||
client_msg_id,
|
||||
sender_name_snapshot,
|
||||
sender_avatar_snapshot,
|
||||
receiver_name_snapshot,
|
||||
receiver_avatar_snapshot,
|
||||
ext_json,
|
||||
created_at
|
||||
FROM im_messages
|
||||
WHERE conversation_id = :conversation_id
|
||||
AND deleted_at IS NULL
|
||||
"""
|
||||
params: dict[str, Any] = {
|
||||
"conversation_id": conversation_id,
|
||||
"fetch_limit": limit + 1,
|
||||
}
|
||||
if cursor_seq is not None:
|
||||
sql += " AND seq < :cursor_seq"
|
||||
params["cursor_seq"] = cursor_seq
|
||||
sql += " ORDER BY seq DESC LIMIT :fetch_limit"
|
||||
|
||||
rows = db.execute(text(sql), params).mappings().all()
|
||||
has_more = len(rows) > limit
|
||||
rows = rows[:limit]
|
||||
rows.reverse()
|
||||
items = [_row_to_message_item(row) for row in rows]
|
||||
next_cursor_seq = items[0].seq if has_more and items else None
|
||||
|
||||
logger.info(
|
||||
"child conversation messages listed",
|
||||
extra={
|
||||
"event": "child_conversation_message_list",
|
||||
"request_id": getattr(request.state, "request_id", None),
|
||||
"user_id": current_user_id,
|
||||
"child_id": child_id,
|
||||
"conversation_id": conversation_id,
|
||||
"count": len(items),
|
||||
"has_more": has_more,
|
||||
},
|
||||
)
|
||||
|
||||
return ChildConversationMessageListResponse(
|
||||
conversation_id=conversation_id,
|
||||
has_more=has_more,
|
||||
next_cursor_seq=next_cursor_seq,
|
||||
items=items,
|
||||
)
|
||||
Reference in New Issue
Block a user