Files
banban/mini-program/app/routers/im.py

542 lines
17 KiB
Python

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,
)