小程序后端接入 talkingq 共享库并新增消息与定位能力

This commit is contained in:
stu2not
2026-04-20 20:07:35 +08:00
parent e7fe92b863
commit 24b1d1025b
22 changed files with 3469 additions and 762 deletions

View File

@@ -6,11 +6,11 @@ from pydantic import BaseModel
try:
from app.security import get_current_user_id
from app.service.binding import BindingService
from app.service.binding import BindingError, BindingService
from app.service import get_db_session
except ModuleNotFoundError:
from security import get_current_user_id
from service.binding import BindingService
from service.binding import BindingError, BindingService
from service import get_db_session
@@ -20,6 +20,7 @@ logger = logging.getLogger("app.bindings")
class BindStartRequest(BaseModel):
device_id: str
serial_number: str
child_id: int | None = None
@@ -80,7 +81,15 @@ def start_bind(
db=Depends(get_db_session),
) -> BindStartResponse:
service = BindingService(db)
bind_token, expires_at = service.start_bind(current_user_id, payload.device_id, payload.child_id)
try:
bind_token, expires_at = service.start_bind(
current_user_id,
payload.device_id,
payload.serial_number,
payload.child_id,
)
except BindingError as e:
raise HTTPException(status_code=e.status_code, detail=str(e))
return BindStartResponse(bind_token=bind_token, expires_at=expires_at.isoformat())
@@ -94,6 +103,8 @@ def confirm_bind(
service = BindingService(db)
try:
result = service.confirm_bind(payload.bind_token, current_user_id)
except BindingError as e:
raise HTTPException(status_code=e.status_code, detail=str(e))
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
return BindConfirmResponse(**result)
@@ -101,6 +112,7 @@ def confirm_bind(
class DirectBindRequest(BaseModel):
device_id: str
serial_number: str
child_id: int | None = None
@@ -121,7 +133,15 @@ def direct_bind(
db=Depends(get_db_session),
) -> DirectBindResponse:
service = BindingService(db)
result = service.direct_bind(payload.device_id, payload.child_id, current_user_id)
try:
result = service.direct_bind(
payload.device_id,
payload.serial_number,
payload.child_id,
current_user_id,
)
except BindingError as e:
raise HTTPException(status_code=e.status_code, detail=str(e))
return DirectBindResponse(**result)
@@ -136,6 +156,8 @@ def set_binding_child(
service = BindingService(db)
try:
result = service.set_binding_child(device_id=device_id, child_id=payload.child_id, user_id=current_user_id)
except BindingError as e:
raise HTTPException(status_code=e.status_code, detail=str(e))
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
return DirectBindResponse(**result)

View File

@@ -0,0 +1,305 @@
import logging
from typing import Any
from fastapi import APIRouter, Depends, Header, HTTPException, Query, Request, Response, status
from sqlalchemy import text
from sqlalchemy.orm import Session
try:
from app.db import get_db
from app.routers.im import (
CHILD_PARTICIPANT_TYPE,
PARENT_PARTICIPANT_TYPE,
SUPPORTED_CONVERSATION_TYPES,
_fetch_child_names,
_fetch_parent_names,
_get_conversation_for_child,
_row_to_conversation_item,
_row_to_message_item,
)
from app.schemas.im import (
ChildConversationListResponse,
ChildConversationMessageListResponse,
ConversationMessageCreateResponse,
DeviceMessageCreateRequest,
)
from app.service.im import authenticate_device_identity, create_device_message
except ModuleNotFoundError:
from db import get_db
from routers.im import (
CHILD_PARTICIPANT_TYPE,
PARENT_PARTICIPANT_TYPE,
SUPPORTED_CONVERSATION_TYPES,
_fetch_child_names,
_fetch_parent_names,
_get_conversation_for_child,
_row_to_conversation_item,
_row_to_message_item,
)
from schemas.im import (
ChildConversationListResponse,
ChildConversationMessageListResponse,
ConversationMessageCreateResponse,
DeviceMessageCreateRequest,
)
from service.im import authenticate_device_identity, create_device_message
router = APIRouter(prefix="/device-im", tags=["device-im"])
logger = logging.getLogger("app.device_im")
@router.get("/{device_id}/conversations", response_model=ChildConversationListResponse)
def list_device_conversations(
device_id: str,
request: Request,
device_serial: str = Header(alias="X-Device-Serial", min_length=1),
conversation_type: int | None = Query(default=None),
cursor: int | None = Query(default=None, ge=1),
limit: int = Query(default=20, ge=1, le=100),
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")
device_identity = authenticate_device_identity(
db=db,
device_id=device_id,
serial_number=device_serial,
)
child_id = device_identity.child_id
params: dict[str, Any] = {
"child_id_str": str(child_id),
"child_participant_type": CHILD_PARTICIPANT_TYPE,
"fetch_limit": limit + 1,
}
where = """
status = 1
AND conversation_type IN (1, 2)
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(
"device conversations listed",
extra={
"event": "device_conversation_list",
"request_id": getattr(request.state, "request_id", None),
"device_id": device_id,
"child_id": child_id,
"conversation_type": conversation_type,
"count": len(items),
},
)
return ChildConversationListResponse(
items=items,
total=len(items),
next_cursor=next_cursor,
)
@router.get(
"/{device_id}/conversations/{conversation_id}/messages",
response_model=ChildConversationMessageListResponse,
)
def list_device_conversation_messages(
device_id: str,
conversation_id: int,
request: Request,
device_serial: str = Header(alias="X-Device-Serial", min_length=1),
cursor_seq: int | None = Query(default=None, ge=1),
limit: int = Query(default=20, ge=1, le=100),
db: Session = Depends(get_db),
) -> ChildConversationMessageListResponse:
device_identity = authenticate_device_identity(
db=db,
device_id=device_id,
serial_number=device_serial,
)
_get_conversation_for_child(
db=db,
conversation_id=conversation_id,
child_id=device_identity.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(
"device conversation messages listed",
extra={
"event": "device_conversation_message_list",
"request_id": getattr(request.state, "request_id", None),
"device_id": device_id,
"child_id": device_identity.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,
)
@router.post(
"/{device_id}/messages",
response_model=ConversationMessageCreateResponse,
status_code=status.HTTP_201_CREATED,
)
def create_message_from_device(
device_id: str,
payload: DeviceMessageCreateRequest,
request: Request,
response: Response,
device_serial: str = Header(alias="X-Device-Serial", min_length=1),
db: Session = Depends(get_db),
) -> ConversationMessageCreateResponse:
device_identity, result = create_device_message(
db=db,
device_id=device_id,
serial_number=device_serial,
payload=payload,
)
if result.idempotent:
response.status_code = status.HTTP_200_OK
logger.info(
"device message created",
extra={
"event": "device_message_create",
"request_id": getattr(request.state, "request_id", None),
"device_id": device_id,
"child_id": device_identity.child_id,
"conversation_id": result.conversation_id,
"conversation_type": result.conversation_type,
"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,
)

View File

@@ -0,0 +1,63 @@
import logging
from fastapi import APIRouter, Depends, Header, Request
from sqlalchemy.orm import Session
try:
from app.db import get_db
from app.schemas.location import DeviceLocationReportRequest, DeviceLocationReportResponse
from app.service.location import report_device_location
except ModuleNotFoundError:
from db import get_db
from schemas.location import DeviceLocationReportRequest, DeviceLocationReportResponse
from service.location import report_device_location
router = APIRouter(prefix="/device-location", tags=["device-location"])
logger = logging.getLogger("app.device_location")
@router.post("/{device_id}/reports", response_model=DeviceLocationReportResponse)
def create_device_location_report(
device_id: str,
payload: DeviceLocationReportRequest,
request: Request,
device_serial: str = Header(alias="X-Device-Serial", min_length=1),
db: Session = Depends(get_db),
) -> DeviceLocationReportResponse:
device_identity, row = report_device_location(
db=db,
device_id=device_id,
serial_number=device_serial,
payload=payload,
)
logger.info(
"device location reported",
extra={
"event": "device_location_report",
"request_id": getattr(request.state, "request_id", None),
"device_id": device_id,
"child_id": device_identity.child_id,
"lat": float(row["lat"]),
"lng": float(row["lng"]),
},
)
return DeviceLocationReportResponse(
child_id=int(row["child_id"]),
child_name=device_identity.child_name,
device_id=str(row["device_id"]),
coord_type=str(row["coord_type"]),
lat=float(row["lat"]),
lng=float(row["lng"]),
accuracy_m=row["accuracy_m"],
altitude_m=float(row["altitude_m"]) if row["altitude_m"] is not None else None,
speed_mps=float(row["speed_mps"]) if row["speed_mps"] is not None else None,
heading_deg=row["heading_deg"],
source=int(row["source"]),
battery_pct=row["battery_pct"],
device_time=row["device_time"],
server_time=row["server_time"],
updated_at=row["updated_at"],
)

View File

@@ -0,0 +1,253 @@
import logging
from collections.abc import Mapping
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from pydantic import BaseModel
from sqlalchemy import text
try:
from app.security import get_current_user_id
from app.service import get_db_session
from app.schemas.location import (
DeviceLocationCurrentResponse,
DeviceLocationTrajectoryItem,
DeviceLocationTrajectoryResponse,
)
from app.service.location import get_device_current_location, get_device_trajectory
except ModuleNotFoundError:
from security import get_current_user_id
from service import get_db_session
from schemas.location import (
DeviceLocationCurrentResponse,
DeviceLocationTrajectoryItem,
DeviceLocationTrajectoryResponse,
)
from service.location import get_device_current_location, get_device_trajectory
router = APIRouter(prefix="/devices", tags=["devices"])
logger = logging.getLogger("app.devices")
class DeviceMessageItem(BaseModel):
id: int
conversation_id: int
role_key: str
is_user: bool
speaker: str
content: str
timestamp: float
created_at: datetime
class DeviceMessageListResponse(BaseModel):
items: list[DeviceMessageItem]
total: int
next_cursor: int | None = None
def _ensure_device_access(db, device_id: str, user_id: int) -> None:
row = (
db.execute(
text(
"""
SELECT 1
FROM device_bindings
WHERE device_id = :device_id
AND owner_user_id = :user_id
AND status = 1
LIMIT 1
"""
),
{"device_id": device_id, "user_id": user_id},
)
.mappings()
.first()
)
if row is None:
raise HTTPException(status_code=404, detail="device not found")
def _row_to_message_item(row: Mapping) -> DeviceMessageItem:
is_user = bool(row["is_user"])
return DeviceMessageItem(
id=int(row["id"]),
conversation_id=int(row["conversation_id"]),
role_key=str(row["role_key"]),
is_user=is_user,
speaker="user" if is_user else "assistant",
content=str(row["content"]),
timestamp=float(row["timestamp"]),
created_at=row["created_at"],
)
def _row_to_current_location_response(row: Mapping) -> DeviceLocationCurrentResponse:
return DeviceLocationCurrentResponse(
child_id=int(row["child_id"]),
child_name=row.get("child_name"),
device_id=str(row["device_id"]),
coord_type=str(row["coord_type"]),
lat=float(row["lat"]),
lng=float(row["lng"]),
accuracy_m=row["accuracy_m"],
altitude_m=float(row["altitude_m"]) if row["altitude_m"] is not None else None,
speed_mps=float(row["speed_mps"]) if row["speed_mps"] is not None else None,
heading_deg=row["heading_deg"],
source=int(row["source"]),
battery_pct=row["battery_pct"],
device_time=row["device_time"],
server_time=row["server_time"],
updated_at=row["updated_at"],
)
def _row_to_trajectory_item(row: Mapping, *, child_name: str | None) -> DeviceLocationTrajectoryItem:
return DeviceLocationTrajectoryItem(
id=int(row["id"]),
child_id=int(row["child_id"]),
child_name=child_name,
device_id=str(row["device_id"]),
coord_type=str(row["coord_type"]),
lat=float(row["lat"]),
lng=float(row["lng"]),
accuracy_m=row["accuracy_m"],
altitude_m=float(row["altitude_m"]) if row["altitude_m"] is not None else None,
speed_mps=float(row["speed_mps"]) if row["speed_mps"] is not None else None,
heading_deg=row["heading_deg"],
source=int(row["source"]),
battery_pct=row["battery_pct"],
device_time=row["device_time"],
server_time=row["server_time"],
created_at=row["created_at"],
)
@router.get("/{device_id}/messages", response_model=DeviceMessageListResponse)
def list_device_messages(
device_id: str,
request: Request,
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=Depends(get_db_session),
) -> DeviceMessageListResponse:
_ensure_device_access(db=db, device_id=device_id, user_id=current_user_id)
params = {"device_id": device_id, "limit": limit + 1}
where = "ch.device_id = :device_id"
if cursor is not None:
where += " AND cm.id < :cursor"
params["cursor"] = cursor
rows = (
db.execute(
text(
f"""
SELECT
cm.id,
ch.id AS conversation_id,
ch.role_key,
cm.is_user,
cm.content,
cm.timestamp,
cm.created_at
FROM conversation_messages AS cm
JOIN conversation_histories AS ch
ON ch.id = cm.conversation_id
WHERE {where}
ORDER BY cm.id DESC
LIMIT :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
logger.info(
"listed device ai messages",
extra={
"event": "device_messages",
"request_id": getattr(request.state, "request_id", None),
"user_id": current_user_id,
"device_id": device_id,
"returned_count": len(rows),
},
)
return DeviceMessageListResponse(
items=[_row_to_message_item(row) for row in rows],
total=len(rows),
next_cursor=next_cursor,
)
@router.get("/{device_id}/location", response_model=DeviceLocationCurrentResponse)
def get_current_device_location(
device_id: str,
request: Request,
current_user_id: int = Depends(get_current_user_id),
db=Depends(get_db_session),
) -> DeviceLocationCurrentResponse:
row = get_device_current_location(db=db, device_id=device_id, user_id=current_user_id)
logger.info(
"device current location fetched",
extra={
"event": "device_current_location",
"request_id": getattr(request.state, "request_id", None),
"user_id": current_user_id,
"device_id": device_id,
"child_id": int(row["child_id"]),
},
)
return _row_to_current_location_response(row)
@router.get("/{device_id}/trajectory", response_model=DeviceLocationTrajectoryResponse)
def get_device_location_trajectory(
device_id: str,
request: Request,
start_at: datetime | None = Query(default=None),
end_at: datetime | None = Query(default=None),
limit: int = Query(default=200, ge=1, le=1000),
current_user_id: int = Depends(get_current_user_id),
db=Depends(get_db_session),
) -> DeviceLocationTrajectoryResponse:
if start_at and end_at and start_at > end_at:
raise HTTPException(status_code=422, detail="start_at must be earlier than end_at")
access, rows = get_device_trajectory(
db=db,
device_id=device_id,
user_id=current_user_id,
start_at=start_at,
end_at=end_at,
limit=limit,
)
logger.info(
"device trajectory fetched",
extra={
"event": "device_trajectory",
"request_id": getattr(request.state, "request_id", None),
"user_id": current_user_id,
"device_id": device_id,
"child_id": access.child_id,
"count": len(rows),
},
)
return DeviceLocationTrajectoryResponse(
items=[_row_to_trajectory_item(row, child_name=access.child_name) for row in rows],
total=len(rows),
start_at=start_at,
end_at=end_at,
)

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

View File

@@ -1,627 +0,0 @@
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.exc import IntegrityError
from sqlalchemy.orm import Session
try:
# For module mode: `uvicorn app.main:app`
from app.db import get_db
from app.db_compat import (
current_timestamp_sql,
get_db_dialect_name,
inserted_primary_key,
select_for_update_clause,
)
from app.security import get_current_user_id
from app.schemas.message import (
MessageCreateRequest,
MessageCreateResponse,
MessageItem,
MessageListResponse,
)
except ModuleNotFoundError:
# For script mode: `python app/main.py` or VS Code "Run Python File"
from db import get_db
from db_compat import (
current_timestamp_sql,
get_db_dialect_name,
inserted_primary_key,
select_for_update_clause,
)
from security import get_current_user_id
from schemas.message import (
MessageCreateRequest,
MessageCreateResponse,
MessageItem,
MessageListResponse,
)
router = APIRouter(prefix="/messages", tags=["messages"])
logger = logging.getLogger("app.messages")
PARENT_PARTICIPANT_TYPE = 1
PARENT_DIRECT_CONVERSATION_TYPE = 3
def _build_preview(content_type: int, content_text: str | None) -> str:
if content_type == 1:
preview = f"user: {(content_text or '').strip()}"
elif content_type == 2:
preview = "user: [audio]"
elif content_type == 3:
preview = "user: [image]"
else:
preview = "user: [json]"
return preview[:255]
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 _row_to_message_item(row: Mapping[str, Any]) -> MessageItem:
sender_user_id = row.get("sender_user_id")
if sender_user_id is not None:
sender_user_id = int(sender_user_id)
return MessageItem(
id=int(row["id"]),
conversation_id=int(row["conversation_id"]),
seq=int(row["seq"]),
sender_user_id=sender_user_id,
role=int(row.get("role", 1)),
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"],
client_msg_id=row["client_msg_id"],
created_at=row["created_at"],
)
def _get_existing_message(
db: Session, conversation_id: int, client_msg_id: str
) -> Mapping[str, Any] | None:
return (
db.execute(
text(
"""
SELECT
id,
conversation_id,
seq,
CASE
WHEN sender_type = :parent_participant_type THEN sender_id
ELSE NULL
END AS sender_user_id,
1 AS role,
content_type,
content_text,
content_json,
media_file_key,
media_duration_ms,
client_msg_id,
created_at
FROM im_messages
WHERE conversation_id = :conversation_id
AND client_msg_id = :client_msg_id
LIMIT 1
"""
),
{
"conversation_id": conversation_id,
"client_msg_id": client_msg_id,
"parent_participant_type": PARENT_PARTICIPANT_TYPE,
},
)
.mappings()
.first()
)
def _build_parent_direct_pair(user_a_id: int, user_b_id: int) -> tuple[str, str, str]:
low_id, high_id = sorted((user_a_id, user_b_id))
participant_a_id = str(low_id)
participant_b_id = str(high_id)
return participant_a_id, participant_b_id, f"{participant_a_id}:{participant_b_id}"
def _next_primary_key(db: Session, table_name: str) -> int:
if table_name not in {"im_conversations", "im_messages"}:
raise ValueError(f"unsupported table name: {table_name}")
return int(
db.execute(text(f"SELECT COALESCE(MAX(id), 0) + 1 FROM {table_name}")).scalar_one()
)
def _get_direct_conversation(
db: Session,
user_low_id: int,
user_high_id: int,
) -> Mapping[str, Any] | None:
_, _, pair_key = _build_parent_direct_pair(user_low_id, user_high_id)
return (
db.execute(
text(
"""
SELECT id, last_seq, status
FROM im_conversations
WHERE conversation_type = :conversation_type
AND pair_key = :pair_key
LIMIT 1
"""
),
{
"conversation_type": PARENT_DIRECT_CONVERSATION_TYPE,
"pair_key": pair_key,
},
)
.mappings()
.first()
)
def _get_or_create_direct_conversation(
db: Session,
user_low_id: int,
user_high_id: int,
) -> int:
existing = _get_direct_conversation(
db=db,
user_low_id=user_low_id,
user_high_id=user_high_id,
)
if existing:
return int(existing["id"])
now_sql = current_timestamp_sql(db)
participant_a_id, participant_b_id, pair_key = _build_parent_direct_pair(user_low_id, user_high_id)
conversation_id = None
if get_db_dialect_name(db) == "sqlite":
conversation_id = _next_primary_key(db, "im_conversations")
try:
insert_sql = f"""
INSERT INTO im_conversations (
{'id,' if conversation_id is not None else ''}
conversation_type,
participant_a_type,
participant_a_id,
participant_b_type,
participant_b_id,
pair_key,
status,
last_seq,
message_count,
created_at,
updated_at
)
VALUES (
{':id,' if conversation_id is not None else ''}
:conversation_type,
:participant_a_type,
:participant_a_id,
:participant_b_type,
:participant_b_id,
:pair_key,
1,
0,
0,
{now_sql},
{now_sql}
)
"""
params = {
"conversation_type": PARENT_DIRECT_CONVERSATION_TYPE,
"participant_a_type": PARENT_PARTICIPANT_TYPE,
"participant_a_id": participant_a_id,
"participant_b_type": PARENT_PARTICIPANT_TYPE,
"participant_b_id": participant_b_id,
"pair_key": pair_key,
}
if conversation_id is not None:
params["id"] = conversation_id
result = db.execute(
text(
insert_sql
),
params,
)
if conversation_id is not None:
return conversation_id
return inserted_primary_key(result)
except IntegrityError:
existing = _get_direct_conversation(
db=db,
user_low_id=user_low_id,
user_high_id=user_high_id,
)
if existing:
return int(existing["id"])
raise
def _get_active_parent_profiles(
db: Session,
*,
sender_user_id: int,
peer_user_id: int,
) -> dict[int, Mapping[str, Any]]:
rows = (
db.execute(
text(
"""
SELECT user_id, nickname, avatar_url
FROM parents
WHERE user_id IN (:sender_user_id, :peer_user_id)
AND status = 1
"""
),
{"sender_user_id": sender_user_id, "peer_user_id": peer_user_id},
)
.mappings()
.all()
)
return {int(row["user_id"]): row for row in rows}
def _assert_conversation_access(
db: Session,
conversation_id: int,
current_user_id: int,
) -> None:
conversation_row = (
db.execute(
text(
"""
SELECT
id,
participant_a_type,
participant_a_id,
participant_b_type,
participant_b_id
FROM im_conversations
WHERE id = :conversation_id
LIMIT 1
"""
),
{"conversation_id": conversation_id},
)
.mappings()
.first()
)
if not conversation_row:
raise HTTPException(status_code=404, detail="conversation not found")
current_user_id_str = str(current_user_id)
is_participant = (
(
int(conversation_row["participant_a_type"]) == PARENT_PARTICIPANT_TYPE
and conversation_row["participant_a_id"] == current_user_id_str
)
or (
int(conversation_row["participant_b_type"]) == PARENT_PARTICIPANT_TYPE
and conversation_row["participant_b_id"] == current_user_id_str
)
)
if not is_participant:
raise HTTPException(status_code=403, detail="no permission for this conversation")
@router.post("", response_model=MessageCreateResponse, status_code=status.HTTP_201_CREATED)
def create_message(
payload: MessageCreateRequest,
request: Request,
response: Response,
current_user_id: int = Depends(get_current_user_id),
db: Session = Depends(get_db),
) -> MessageCreateResponse:
sender_user_id = current_user_id
peer_user_id = payload.peer_user_id
if sender_user_id == peer_user_id:
raise HTTPException(status_code=422, detail="peer_user_id cannot be same as current user")
user_low_id = min(sender_user_id, peer_user_id)
user_high_id = max(sender_user_id, peer_user_id)
now_sql = current_timestamp_sql(db)
with db.begin():
parents = _get_active_parent_profiles(
db,
sender_user_id=sender_user_id,
peer_user_id=peer_user_id,
)
if len(parents) != 2:
raise HTTPException(status_code=404, detail="sender or peer user not found")
conversation_id = _get_or_create_direct_conversation(
db=db,
user_low_id=user_low_id,
user_high_id=user_high_id,
)
conversation = (
db.execute(
text(
f"""
SELECT id, last_seq, status
FROM im_conversations
WHERE id = :conversation_id{select_for_update_clause(db)}
"""
),
{"conversation_id": conversation_id},
)
.mappings()
.first()
)
if not conversation:
raise HTTPException(status_code=500, detail="failed to load conversation")
if int(conversation["status"]) != 1:
raise HTTPException(status_code=409, detail="conversation is not active")
existing = _get_existing_message(db, conversation_id, payload.client_msg_id)
if existing:
logger.info(
"message idempotent hit",
extra={
"event": "message_create",
"request_id": getattr(request.state, "request_id", None),
"user_id": sender_user_id,
"conversation_id": conversation_id,
"message_id": int(existing["id"]),
"seq": int(existing["seq"]),
"client_msg_id": payload.client_msg_id,
"idempotent": True,
},
)
response.status_code = status.HTTP_200_OK
return MessageCreateResponse(
idempotent=True,
message=_row_to_message_item(existing),
)
next_seq = int(conversation["last_seq"]) + 1
preview = _build_preview(payload.content_type, payload.content_text)
message_id = None
if get_db_dialect_name(db) == "sqlite":
message_id = _next_primary_key(db, "im_messages")
insert_sql = f"""
INSERT INTO im_messages (
{'id,' if message_id is not None else ''}
conversation_id,
seq,
sender_type,
sender_id,
receiver_type,
receiver_id,
content_type,
content_text,
content_json,
media_file_key,
media_duration_ms,
client_msg_id,
sender_name_snapshot,
sender_avatar_snapshot,
receiver_name_snapshot,
receiver_avatar_snapshot,
created_at
)
VALUES (
{':id,' if message_id is not None else ''}
:conversation_id,
:seq,
:sender_type,
:sender_id,
:receiver_type,
:receiver_id,
:content_type,
:content_text,
:content_json,
:media_file_key,
:media_duration_ms,
:client_msg_id,
:sender_name_snapshot,
:sender_avatar_snapshot,
:receiver_name_snapshot,
:receiver_avatar_snapshot,
{now_sql}
)
"""
insert_params = {
"conversation_id": conversation_id,
"seq": next_seq,
"sender_type": PARENT_PARTICIPANT_TYPE,
"sender_id": str(sender_user_id),
"receiver_type": PARENT_PARTICIPANT_TYPE,
"receiver_id": str(peer_user_id),
"content_type": payload.content_type,
"content_text": payload.content_text,
"content_json": json.dumps(payload.content_json, ensure_ascii=False)
if payload.content_json is not None
else None,
"media_file_key": payload.media_file_key,
"media_duration_ms": payload.media_duration_ms,
"client_msg_id": payload.client_msg_id,
"sender_name_snapshot": parents[sender_user_id]["nickname"],
"sender_avatar_snapshot": parents[sender_user_id]["avatar_url"],
"receiver_name_snapshot": parents[peer_user_id]["nickname"],
"receiver_avatar_snapshot": parents[peer_user_id]["avatar_url"],
}
if message_id is not None:
insert_params["id"] = message_id
insert_result = db.execute(
text(
insert_sql
),
insert_params,
)
db.execute(
text(
f"""
UPDATE im_conversations
SET
last_seq = :last_seq,
message_count = message_count + 1,
last_message_preview = :last_message_preview,
last_message_at = {now_sql},
updated_at = {now_sql}
WHERE id = :conversation_id
"""
),
{
"conversation_id": conversation_id,
"last_seq": next_seq,
"last_message_preview": preview,
},
)
created = (
db.execute(
text(
"""
SELECT
id,
conversation_id,
seq,
CASE
WHEN sender_type = :parent_participant_type THEN sender_id
ELSE NULL
END AS sender_user_id,
1 AS role,
content_type,
content_text,
content_json,
media_file_key,
media_duration_ms,
client_msg_id,
created_at
FROM im_messages
WHERE id = :message_id
LIMIT 1
"""
),
{
"message_id": message_id if message_id is not None else inserted_primary_key(insert_result),
"parent_participant_type": PARENT_PARTICIPANT_TYPE,
},
)
.mappings()
.first()
)
if not created:
raise HTTPException(status_code=500, detail="failed to load created message")
logger.info(
"message created",
extra={
"event": "message_create",
"request_id": getattr(request.state, "request_id", None),
"user_id": sender_user_id,
"conversation_id": conversation_id,
"message_id": int(created["id"]),
"seq": int(created["seq"]),
"client_msg_id": payload.client_msg_id,
"idempotent": False,
},
)
return MessageCreateResponse(
idempotent=False,
message=_row_to_message_item(created),
)
@router.get("", response_model=MessageListResponse)
def list_messages(
request: Request,
conversation_id: int = Query(gt=0),
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),
) -> MessageListResponse:
_assert_conversation_access(
db=db,
conversation_id=conversation_id,
current_user_id=current_user_id,
)
sql = """
SELECT
id,
conversation_id,
seq,
CASE
WHEN sender_type = :parent_participant_type THEN sender_id
ELSE NULL
END AS sender_user_id,
1 AS role,
content_type,
content_text,
content_json,
media_file_key,
media_duration_ms,
client_msg_id,
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,
"parent_participant_type": PARENT_PARTICIPANT_TYPE,
}
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(
"messages listed",
extra={
"event": "message_list",
"request_id": getattr(request.state, "request_id", None),
"user_id": current_user_id,
"conversation_id": conversation_id,
"cursor_seq": cursor_seq,
"limit": limit,
"count": len(items),
"has_more": has_more,
},
)
return MessageListResponse(
conversation_id=conversation_id,
has_more=has_more,
next_cursor_seq=next_cursor_seq,
items=items,
)