小程序后端接入 talkingq 共享库并新增消息与定位能力
This commit is contained in:
@@ -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)
|
||||
|
||||
305
mini-program/app/routers/device_im.py
Normal file
305
mini-program/app/routers/device_im.py
Normal 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,
|
||||
)
|
||||
63
mini-program/app/routers/device_location.py
Normal file
63
mini-program/app/routers/device_location.py
Normal 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"],
|
||||
)
|
||||
253
mini-program/app/routers/devices.py
Normal file
253
mini-program/app/routers/devices.py
Normal 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,
|
||||
)
|
||||
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,
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
Reference in New Issue
Block a user