From e9a69577dbbce0fd15d6e3ef020cfc4c4d3a8c78 Mon Sep 17 00:00:00 2001 From: HycJack <772403255@qq.com> Date: Mon, 27 Apr 2026 18:40:20 +0800 Subject: [PATCH] add banban service code --- talkingq-url/banban/dao/__init__.py | 13 + talkingq-url/banban/dao/binding.py | 329 +++++++++++ talkingq-url/banban/dao/child.py | 106 ++++ talkingq-url/banban/dao/device.py | 63 +++ talkingq-url/banban/dao/im.py | 451 +++++++++++++++ talkingq-url/banban/dao/location.py | 292 ++++++++++ talkingq-url/banban/dao/parent.py | 125 ++++ talkingq-url/banban/db_compat.py | 22 + talkingq-url/banban/middleware/__init__.py | 1 + talkingq-url/banban/middleware/auth.py | 125 ++++ talkingq-url/banban/middleware/request_log.py | 70 +++ talkingq-url/banban/routers/__init__.py | 25 + talkingq-url/banban/routers/bindings.py | 232 ++++++++ talkingq-url/banban/routers/children.py | 96 ++++ talkingq-url/banban/routers/device_im.py | 315 +++++++++++ .../banban/routers/device_location.py | 58 ++ talkingq-url/banban/routers/devices.py | 206 +++++++ talkingq-url/banban/routers/im.py | 532 ++++++++++++++++++ talkingq-url/banban/routers/parents.py | 106 ++++ talkingq-url/banban/routers/wechat_auth.py | 75 +++ talkingq-url/banban/schemas/__init__.py | 1 + talkingq-url/banban/schemas/auth.py | 13 + talkingq-url/banban/schemas/im.py | 111 ++++ talkingq-url/banban/schemas/location.py | 53 ++ talkingq-url/banban/security.py | 64 +++ talkingq-url/banban/service/__init__.py | 12 + talkingq-url/banban/service/avatar_storage.py | 154 +++++ talkingq-url/banban/service/binding.py | 139 +++++ talkingq-url/banban/service/child.py | 65 +++ talkingq-url/banban/service/device.py | 43 ++ talkingq-url/banban/service/im.py | 256 +++++++++ talkingq-url/banban/service/location.py | 90 +++ talkingq-url/banban/service/parent.py | 131 +++++ talkingq-url/banban/service/wechat_login.py | 126 +++++ talkingq-url/config.py | 177 ++++-- talkingq-url/database/connection.py | 90 ++- talkingq-url/database/models.py | 247 +++++++- talkingq-url/main.py | 8 + talkingq-url/requirements.txt | 15 +- 39 files changed, 4952 insertions(+), 85 deletions(-) create mode 100644 talkingq-url/banban/dao/__init__.py create mode 100644 talkingq-url/banban/dao/binding.py create mode 100644 talkingq-url/banban/dao/child.py create mode 100644 talkingq-url/banban/dao/device.py create mode 100644 talkingq-url/banban/dao/im.py create mode 100644 talkingq-url/banban/dao/location.py create mode 100644 talkingq-url/banban/dao/parent.py create mode 100644 talkingq-url/banban/db_compat.py create mode 100644 talkingq-url/banban/middleware/__init__.py create mode 100644 talkingq-url/banban/middleware/auth.py create mode 100644 talkingq-url/banban/middleware/request_log.py create mode 100644 talkingq-url/banban/routers/__init__.py create mode 100644 talkingq-url/banban/routers/bindings.py create mode 100644 talkingq-url/banban/routers/children.py create mode 100644 talkingq-url/banban/routers/device_im.py create mode 100644 talkingq-url/banban/routers/device_location.py create mode 100644 talkingq-url/banban/routers/devices.py create mode 100644 talkingq-url/banban/routers/im.py create mode 100644 talkingq-url/banban/routers/parents.py create mode 100644 talkingq-url/banban/routers/wechat_auth.py create mode 100644 talkingq-url/banban/schemas/__init__.py create mode 100644 talkingq-url/banban/schemas/auth.py create mode 100644 talkingq-url/banban/schemas/im.py create mode 100644 talkingq-url/banban/schemas/location.py create mode 100644 talkingq-url/banban/security.py create mode 100644 talkingq-url/banban/service/__init__.py create mode 100644 talkingq-url/banban/service/avatar_storage.py create mode 100644 talkingq-url/banban/service/binding.py create mode 100644 talkingq-url/banban/service/child.py create mode 100644 talkingq-url/banban/service/device.py create mode 100644 talkingq-url/banban/service/im.py create mode 100644 talkingq-url/banban/service/location.py create mode 100644 talkingq-url/banban/service/parent.py create mode 100644 talkingq-url/banban/service/wechat_login.py diff --git a/talkingq-url/banban/dao/__init__.py b/talkingq-url/banban/dao/__init__.py new file mode 100644 index 0000000..fd6faa8 --- /dev/null +++ b/talkingq-url/banban/dao/__init__.py @@ -0,0 +1,13 @@ +from sqlalchemy import text +from sqlalchemy.ext.asyncio import AsyncSession + + +class BaseDAO: + def __init__(self, db: AsyncSession): + self.db = db + + async def execute(self, query, params: dict = None): + return await self.db.execute(text(query), params or {}) + + async def commit(self): + await self.db.commit() diff --git a/talkingq-url/banban/dao/binding.py b/talkingq-url/banban/dao/binding.py new file mode 100644 index 0000000..9ef916a --- /dev/null +++ b/talkingq-url/banban/dao/binding.py @@ -0,0 +1,329 @@ +import logging +import uuid +from collections.abc import Mapping +from datetime import datetime, timedelta +from typing import Optional + +from sqlalchemy import text +from sqlalchemy.exc import IntegrityError + +from banban.dao import BaseDAO + +logger = logging.getLogger("banban.dao.binding") + + +class BindingDAO(BaseDAO): + async def get_device_auth(self, device_id: str) -> Optional[Mapping]: + return ( + await self.execute( + """ + SELECT device_id, serial_number, is_active + FROM device_auth + WHERE device_id = :device_id + LIMIT 1 + """, + {"device_id": device_id}, + ) + ).mappings().first() + + async def _clear_child_from_binding(self, binding_row: Mapping[str, object]) -> None: + row_id = int(binding_row["id"]) + status = int(binding_row["status"]) + + if status == 1: + await self.execute( + """ + UPDATE device_bindings + SET child_id = NULL, + status = 1, + unbound_at = NULL, + updated_at = CURRENT_TIMESTAMP + WHERE id = :id + """, + {"id": row_id}, + ) + return + + await self.execute( + """ + UPDATE device_bindings + SET child_id = NULL, + updated_at = CURRENT_TIMESTAMP + WHERE id = :id + """, + {"id": row_id}, + ) + + async def _upsert_parent_child_relation(self, user_id: int, child_id: int) -> None: + updated = await self.execute( + """ + UPDATE parent_child_relations + SET status = 1, + updated_at = CURRENT_TIMESTAMP + WHERE user_id = :user_id + AND child_id = :child_id + """, + {"user_id": user_id, "child_id": child_id}, + ) + if updated.rowcount and updated.rowcount > 0: + return + + try: + await self.execute( + """ + INSERT INTO parent_child_relations (user_id, child_id, relation_type, is_primary, status) + VALUES (:user_id, :child_id, 9, 0, 1) + """, + {"user_id": user_id, "child_id": child_id}, + ) + except IntegrityError: + await self.db.rollback() + await self.execute( + """ + UPDATE parent_child_relations + SET status = 1, + updated_at = CURRENT_TIMESTAMP + WHERE user_id = :user_id + AND child_id = :child_id + """, + {"user_id": user_id, "child_id": child_id}, + ) + + async def _insert_bind_history(self, device_id: str, child_id: Optional[int], user_id: int, bind_source: int) -> None: + await self.execute( + """ + INSERT INTO device_bind_history (device_id, child_id, bound_by_user_id, bind_source, bound_at) + VALUES (:device_id, :child_id, :user_id, :bind_source, CURRENT_TIMESTAMP) + """, + { + "device_id": device_id, + "child_id": child_id, + "user_id": user_id, + "bind_source": bind_source, + }, + ) + + async def _bind_device(self, device_id: str, user_id: int, child_id: Optional[int]) -> None: + existing_by_device = ( + await self.execute( + "SELECT id, status FROM device_bindings WHERE device_id = :device_id", + {"device_id": device_id}, + ) + ).mappings().first() + + if child_id is None: + if existing_by_device: + await self.execute( + """ + UPDATE device_bindings + SET owner_user_id = :owner_user_id, + child_id = NULL, + status = 1, + unbound_at = NULL, + updated_at = CURRENT_TIMESTAMP + WHERE device_id = :device_id + """, + {"owner_user_id": user_id, "device_id": device_id}, + ) + else: + await self.execute( + """ + INSERT INTO device_bindings (device_id, owner_user_id, child_id, status, bound_at) + VALUES (:device_id, :owner_user_id, NULL, 1, CURRENT_TIMESTAMP) + """, + {"device_id": device_id, "owner_user_id": user_id}, + ) + return + + existing_by_child = ( + await self.execute( + "SELECT id, status FROM device_bindings WHERE child_id = :child_id", + {"child_id": child_id}, + ) + ).mappings().first() + + if existing_by_device: + device_row_id = int(existing_by_device["id"]) + if existing_by_child and int(existing_by_child["id"]) != device_row_id: + await self._clear_child_from_binding(existing_by_child) + + await self.execute( + """ + UPDATE device_bindings + SET owner_user_id = :owner_user_id, + child_id = :child_id, + status = 1, + unbound_at = NULL, + updated_at = CURRENT_TIMESTAMP + WHERE device_id = :device_id + """, + {"owner_user_id": user_id, "child_id": child_id, "device_id": device_id}, + ) + return + + if existing_by_child: + await self._clear_child_from_binding(existing_by_child) + + await self.execute( + """ + INSERT INTO device_bindings (device_id, owner_user_id, child_id, status, bound_at) + VALUES (:device_id, :owner_user_id, :child_id, 1, CURRENT_TIMESTAMP) + """, + {"device_id": device_id, "owner_user_id": user_id, "child_id": child_id}, + ) + + async def start_bind(self, user_id: int, device_id: str, child_id: Optional[int]) -> str: + bind_token = str(uuid.uuid4()) + expires_at = datetime.utcnow() + timedelta(minutes=10) + + await self.execute( + """ + INSERT INTO device_bind_sessions (bind_token, device_id, initiator_user_id, target_child_id, expires_at, status) + VALUES (:bind_token, :device_id, :initiator_user_id, :target_child_id, :expires_at, 1) + """, + { + "bind_token": bind_token, + "device_id": device_id, + "initiator_user_id": user_id, + "target_child_id": child_id, + "expires_at": expires_at, + }, + ) + await self.commit() + return bind_token + + async def get_session(self, bind_token: str, user_id: int) -> Optional[Mapping]: + return ( + await self.execute( + "SELECT * FROM device_bind_sessions WHERE bind_token = :bind_token AND initiator_user_id = :user_id", + {"bind_token": bind_token, "user_id": user_id}, + ) + ).mappings().first() + + async def confirm_bind(self, session_id: int, device_id: str, child_id: Optional[int], user_id: int) -> None: + if child_id is not None: + await self._upsert_parent_child_relation(user_id=user_id, child_id=child_id) + + await self.execute( + "UPDATE device_bind_sessions SET status = 2, confirmed_at = CURRENT_TIMESTAMP WHERE id = :id", + {"id": session_id}, + ) + + await self._bind_device(device_id=device_id, user_id=user_id, child_id=child_id) + await self._insert_bind_history(device_id=device_id, child_id=child_id, user_id=user_id, bind_source=1) + await self.commit() + + async def direct_bind(self, device_id: str, child_id: Optional[int], user_id: int) -> None: + if child_id is not None: + await self._upsert_parent_child_relation(user_id=user_id, child_id=child_id) + + await self._bind_device(device_id=device_id, user_id=user_id, child_id=child_id) + await self._insert_bind_history(device_id=device_id, child_id=child_id, user_id=user_id, bind_source=2) + await self.commit() + + async def get_current_by_user(self, user_id: int) -> Optional[Mapping]: + return ( + await self.execute( + """ + SELECT * + FROM device_bindings + WHERE owner_user_id = :user_id + AND status = 1 + ORDER BY bound_at DESC + LIMIT 1 + """, + {"user_id": user_id}, + ) + ).mappings().first() + + async def list_by_user(self, user_id: int, limit: int = 20, cursor: int = None) -> list[Mapping]: + params = {"user_id": user_id, "limit": limit + 1} + where = "db.owner_user_id = :user_id AND db.status = 1" + if cursor is not None: + where += " AND db.id < :cursor" + params["cursor"] = cursor + + rows = ( + await self.execute( + f""" + SELECT + db.id, + db.device_id, + db.child_id, + c.child_name, + db.status, + db.bound_at + FROM device_bindings AS db + LEFT JOIN children AS c + ON c.child_id = db.child_id + AND c.status = 1 + WHERE {where} + ORDER BY db.id DESC + LIMIT :limit + """, + params, + ) + ).mappings().all() + return rows + + async def get_by_device(self, device_id: str, user_id: int) -> Optional[Mapping]: + return ( + await self.execute( + """ + SELECT * + FROM device_bindings + WHERE device_id = :device_id + AND owner_user_id = :user_id + AND status = 1 + """, + {"device_id": device_id, "user_id": user_id}, + ) + ).mappings().first() + + async def set_binding_child(self, device_id: str, child_id: int, user_id: int) -> bool: + row = await self.get_by_device(device_id=device_id, user_id=user_id) + if not row: + return False + + await self._upsert_parent_child_relation(user_id=user_id, child_id=child_id) + await self._bind_device(device_id=device_id, user_id=user_id, child_id=child_id) + await self._insert_bind_history(device_id=device_id, child_id=child_id, user_id=user_id, bind_source=3) + await self.commit() + return True + + async def unbind(self, device_id: str, user_id: int) -> bool: + row = await self.get_by_device(device_id, user_id) + if not row: + return False + + await self.execute( + "UPDATE device_bindings SET status = 0, unbound_at = CURRENT_TIMESTAMP WHERE id = :id", + {"id": row["id"]}, + ) + + await self.execute( + "INSERT INTO device_bind_history (device_id, child_id, bound_by_user_id, unbound_by_user_id, bind_source, bound_at, unbound_at, unbind_reason) SELECT device_id, child_id, bound_by_user_id, :user_id, bind_source, bound_at, CURRENT_TIMESTAMP, 'user_unbind' FROM device_bind_history WHERE device_id = :device_id AND unbound_at IS NULL", + {"device_id": device_id, "user_id": user_id}, + ) + await self.commit() + return True + + async def list_history(self, device_id: str, limit: int = 20, cursor: datetime = None) -> list[Mapping]: + params = {"device_id": device_id, "limit": limit + 1} + where = "device_id = :device_id" + if cursor: + where += " AND bound_at < :cursor" + params["cursor"] = cursor + + rows = ( + await self.execute( + f""" + SELECT * FROM device_bind_history + WHERE {where} + ORDER BY bound_at DESC + LIMIT :limit + """, + params, + ) + ).mappings().all() + return rows diff --git a/talkingq-url/banban/dao/child.py b/talkingq-url/banban/dao/child.py new file mode 100644 index 0000000..65e072e --- /dev/null +++ b/talkingq-url/banban/dao/child.py @@ -0,0 +1,106 @@ +from collections.abc import Mapping +from datetime import date +from typing import Optional + +from sqlalchemy import text + +from banban.dao import BaseDAO +from banban.db_compat import inserted_primary_key + + +class ChildDAO(BaseDAO): + async def _create_relation(self, user_id: int, child_id: int) -> None: + await self.execute( + """ + INSERT INTO parent_child_relations (user_id, child_id, relation_type, is_primary, status) + VALUES (:user_id, :child_id, 9, 0, 1) + """, + {"user_id": user_id, "child_id": child_id}, + ) + + async def create( + self, + user_id: int, + child_name: str, + child_gender: int = 2, + child_birthday: Optional[date] = None, + ) -> int: + result = await self.execute( + """ + INSERT INTO children (child_name, child_gender, child_birthday, status) + VALUES (:child_name, :child_gender, :child_birthday, 1) + """, + {"child_name": child_name, "child_gender": child_gender, "child_birthday": child_birthday}, + ) + child_id = inserted_primary_key(result) + await self._create_relation(user_id, child_id) + await self.commit() + return child_id + + async def get_by_id(self, child_id: int) -> Optional[Mapping]: + return ( + await self.execute( + "SELECT * FROM children WHERE child_id = :child_id", + {"child_id": child_id}, + ) + ).mappings().first() + + async def list_by_parent(self, user_id: int, limit: int = 20, cursor: int = None) -> list[Mapping]: + params = {"user_id": user_id, "limit": limit + 1} + where = "pcr.user_id = :user_id AND pcr.status = 1 AND c.status = 1" + if cursor is not None: + where += " AND c.child_id < :cursor" + params["cursor"] = cursor + + rows = ( + await self.execute( + f""" + SELECT c.* + FROM children AS c + JOIN parent_child_relations AS pcr + ON pcr.child_id = c.child_id + WHERE {where} + ORDER BY c.child_id DESC + LIMIT :limit + """, + params, + ) + ).mappings().all() + return rows + + async def update( + self, + child_id: int, + child_name: Optional[str] = None, + child_gender: Optional[int] = None, + child_birthday: Optional[date] = None, + ) -> None: + await self.execute( + """ + UPDATE children + SET child_name = COALESCE(:child_name, child_name), + child_gender = COALESCE(:child_gender, child_gender), + child_birthday = COALESCE(:child_birthday, child_birthday) + WHERE child_id = :child_id + """, + {"child_id": child_id, "child_name": child_name, "child_gender": child_gender, "child_birthday": child_birthday}, + ) + await self.commit() + + async def has_access(self, child_id: int, user_id: int) -> bool: + return ( + await self.execute( + """ + SELECT 1 + FROM children AS c + JOIN parent_child_relations AS pcr + ON pcr.child_id = c.child_id + WHERE c.child_id = :child_id + AND pcr.user_id = :user_id + AND c.status = 1 + AND pcr.status = 1 + """, + {"child_id": child_id, "user_id": user_id}, + ).scalar_one_or_none() + is not None + ) diff --git a/talkingq-url/banban/dao/device.py b/talkingq-url/banban/dao/device.py new file mode 100644 index 0000000..91373ec --- /dev/null +++ b/talkingq-url/banban/dao/device.py @@ -0,0 +1,63 @@ +from collections.abc import Mapping +from typing import Any, List + +from sqlalchemy import text + +from banban.dao import BaseDAO + + +class DeviceDAO(BaseDAO): + async def ensure_device_access(self, *, device_id: str, user_id: int) -> None: + from fastapi import HTTPException, status + result = await self.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}, + ) + row = result.mappings().first() + if row is None: + raise HTTPException(status_code=404, detail="device not found") + + async def list_device_messages( + self, + *, + device_id: str, + cursor: int | None, + limit: int, + ) -> List[Mapping[str, Any]]: + 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 + + result = await self.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, + ) + return result.mappings().all() \ No newline at end of file diff --git a/talkingq-url/banban/dao/im.py b/talkingq-url/banban/dao/im.py new file mode 100644 index 0000000..d1b01b9 --- /dev/null +++ b/talkingq-url/banban/dao/im.py @@ -0,0 +1,451 @@ +import json +from collections.abc import Mapping +from dataclasses import dataclass +from typing import Any, Optional, Tuple + +from sqlalchemy import text +from sqlalchemy.exc import IntegrityError + +from banban.dao import BaseDAO +from banban.db_compat import inserted_primary_key +from banban.schemas.im import ChildConversationMessageItem + + +@dataclass(frozen=True) +class DeviceIdentity: + device_id: str + child_id: int + child_name: str | None + + +@dataclass(frozen=True) +class ConversationMessageCreateResult: + idempotent: bool + conversation_id: int + conversation_type: int + message: dict + + +class ImDAO(BaseDAO): + async def assert_parent_child_access(self, *, user_id: int, child_id: int) -> Mapping[str, Any]: + child_row = await self._get_child_row(child_id) + if not child_row: + from fastapi import HTTPException, status + raise HTTPException(status_code=404, detail="child not found") + + has_access = ( + await self.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: + from fastapi import HTTPException, status + raise HTTPException(status_code=403, detail="no permission to access this child") + + return child_row + + async def authenticate_device_identity(self, *, device_id: str, serial_number: str) -> DeviceIdentity: + row = ( + await self.execute( + text( + """ + SELECT + da.device_id, + db.child_id, + c.child_name + FROM device_auth AS da + LEFT JOIN device_bindings AS db + ON db.device_id = da.device_id + AND db.status = 1 + LEFT JOIN children AS c + ON c.child_id = db.child_id + AND c.status = 1 + WHERE da.device_id = :device_id + AND da.serial_number = :serial_number + AND da.is_active = 1 + LIMIT 1 + """ + ), + {"device_id": device_id, "serial_number": serial_number}, + ) + ).mappings().first() + if not row: + from fastapi import HTTPException, status + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid device credentials") + if row["child_id"] is None: + from fastapi import HTTPException, status + raise HTTPException(status_code=404, detail="device not bound to a child") + return DeviceIdentity( + device_id=str(row["device_id"]), + child_id=int(row["child_id"]), + child_name=row["child_name"], + ) + + async def assert_child_exists(self, *, child_id: int) -> Mapping[str, Any]: + child_row = await self._get_child_row(child_id) + if not child_row: + from fastapi import HTTPException, status + raise HTTPException(status_code=404, detail="child not found") + return child_row + + async def _get_child_row(self, child_id: int) -> Mapping[str, Any] | None: + result = await self.execute( + text( + """ + SELECT child_id, child_name, status + FROM children + WHERE child_id = :child_id + AND status = 1 + LIMIT 1 + """ + ), + {"child_id": child_id}, + ) + return result.mappings().first() + + async def _get_parent_row(self, user_id: int) -> Mapping[str, Any] | None: + result = await self.execute( + text( + """ + SELECT user_id, nickname, avatar_url, status + FROM parents + WHERE user_id = :user_id + AND status = 1 + LIMIT 1 + """ + ), + {"user_id": user_id}, + ) + return result.mappings().first() + + def _build_child_peer_pair(self, child_a_id: int, child_b_id: int) -> Tuple[str, str, str]: + low_id, high_id = sorted((child_a_id, child_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}" + + async def _build_preview(self, content_type: int, content_text: str | None) -> str: + if content_type == 1: + return (content_text or "").strip()[:255] + if content_type == 2: + return "[audio]" + if content_type == 3: + return "[image]" + return "[json]" + + async def create_message( + self, + *, + conversation_type: int, + participant_a_type: int, + participant_a_id: str, + participant_b_type: int, + participant_b_id: str, + pair_key: str, + sender_type: int, + sender_id: str, + receiver_type: int, + receiver_id: str, + sender_name_snapshot: str | None, + sender_avatar_snapshot: str | None, + receiver_name_snapshot: str | None, + receiver_avatar_snapshot: str | None, + payload: Any, + ) -> tuple[int, bool]: + conversation_id = await self._get_or_create_conversation( + conversation_type=conversation_type, + participant_a_type=participant_a_type, + participant_a_id=participant_a_id, + participant_b_type=participant_b_type, + participant_b_id=participant_b_id, + pair_key=pair_key, + ) + + existing = await self._get_message_by_conversation_client_id( + conversation_id=conversation_id, + client_msg_id=payload.client_msg_id, + ) + if existing: + return conversation_id, True + + now_sql = "CURRENT_TIMESTAMP(3)" + preview = await self._build_preview(payload.content_type, payload.content_text) + + try: + conversation_row = await self._get_conversation_by_id(conversation_id=conversation_id, lock=True) + if not conversation_row: + from fastapi import HTTPException, status + raise HTTPException(status_code=404, detail="conversation not found") + next_seq = int(conversation_row["last_seq"]) + 1 + + message_id = await self._next_primary_key("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, + 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 + ) 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, + :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, + {now_sql} + ) + """ + params: dict[str, Any] = { + "conversation_id": conversation_id, + "seq": next_seq, + "sender_type": sender_type, + "sender_id": sender_id, + "receiver_type": receiver_type, + "receiver_id": receiver_id, + "content_type": payload.content_type, + "content_text": payload.content_text, + "content_json": json.dumps(payload.content_json) if payload.content_json is not None else None, + "media_file_key": payload.media_file_key, + "media_duration_ms": payload.media_duration_ms, + "media_mime_type": payload.media_mime_type, + "media_size_bytes": payload.media_size_bytes, + "media_transcript_text": payload.media_transcript_text, + "client_msg_id": payload.client_msg_id, + "sender_name_snapshot": sender_name_snapshot, + "sender_avatar_snapshot": sender_avatar_snapshot, + "receiver_name_snapshot": receiver_name_snapshot, + "receiver_avatar_snapshot": receiver_avatar_snapshot, + "ext_json": json.dumps(payload.ext_json) if payload.ext_json is not None else None, + } + if message_id is not None: + params["id"] = message_id + + result = await self.execute(text(insert_sql), params) + if message_id is None: + message_id = inserted_primary_key(result) + + await self.execute( + text( + f""" + UPDATE im_conversations + SET status = 1, + 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, + }, + ) + await self.commit() + return conversation_id, False + except IntegrityError: + await self.db.rollback() + existing = await self._get_message_by_conversation_client_id( + conversation_id=conversation_id, + client_msg_id=payload.client_msg_id, + ) + if existing: + return conversation_id, True + raise + except Exception: + await self.db.rollback() + raise + + async def _get_or_create_conversation( + self, + *, + conversation_type: int, + participant_a_type: int, + participant_a_id: str, + participant_b_type: int, + participant_b_id: str, + pair_key: str, + ) -> int: + row = await self._get_conversation_by_pair( + conversation_type=conversation_type, + pair_key=pair_key, + lock=False, + ) + if row: + return int(row["id"]) + + now_sql = "CURRENT_TIMESTAMP(3)" + conversation_id = await self._next_primary_key("im_conversations") + 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": conversation_type, + "participant_a_type": participant_a_type, + "participant_a_id": participant_a_id, + "participant_b_type": participant_b_type, + "participant_b_id": participant_b_id, + "pair_key": pair_key, + } + if conversation_id is not None: + params["id"] = conversation_id + + try: + result = await self.execute(text(insert_sql), params) + if conversation_id is not None: + return conversation_id + return inserted_primary_key(result) + except IntegrityError: + await self.db.rollback() + row = await self._get_conversation_by_pair( + conversation_type=conversation_type, + pair_key=pair_key, + lock=False, + ) + if row: + return int(row["id"]) + raise + + async def _get_conversation_by_pair( + self, + *, + conversation_type: int, + pair_key: str, + lock: bool, + ) -> Mapping[str, Any] | None: + lock_clause = " FOR UPDATE" if lock else "" + result = await self.execute( + text( + f""" + SELECT id, conversation_type, last_seq, status + FROM im_conversations + WHERE conversation_type = :conversation_type + AND pair_key = :pair_key + {lock_clause} + LIMIT 1 + """ + ), + {"conversation_type": conversation_type, "pair_key": pair_key}, + ) + return result.mappings().first() + + async def _get_conversation_by_id( + self, + *, + conversation_id: int, + lock: bool, + ) -> Mapping[str, Any] | None: + lock_clause = " FOR UPDATE" if lock else "" + result = await self.execute( + text( + f""" + SELECT id, conversation_type, last_seq, status + FROM im_conversations + WHERE id = :conversation_id + {lock_clause} + LIMIT 1 + """ + ), + {"conversation_id": conversation_id}, + ) + return result.mappings().first() + + async def _get_message_by_conversation_client_id( + self, + *, + conversation_id: int, + client_msg_id: str, + ) -> Mapping[str, Any] | None: + result = await self.execute( + text( + """ + 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 client_msg_id = :client_msg_id + LIMIT 1 + """ + ), + {"conversation_id": conversation_id, "client_msg_id": client_msg_id}, + ) + return result.mappings().first() + + async def _next_primary_key(self, table_name: str) -> int | None: + result = await self.execute(text(f"SELECT 1")) + return None \ No newline at end of file diff --git a/talkingq-url/banban/dao/location.py b/talkingq-url/banban/dao/location.py new file mode 100644 index 0000000..fcddb9c --- /dev/null +++ b/talkingq-url/banban/dao/location.py @@ -0,0 +1,292 @@ +from collections.abc import Mapping +from dataclasses import dataclass +from datetime import datetime +from typing import Any, Optional + +from sqlalchemy import text +from sqlalchemy.exc import IntegrityError + +from banban.dao import BaseDAO +from banban.db_compat import inserted_primary_key + + +@dataclass(frozen=True) +class ParentDeviceAccess: + device_id: str + child_id: int + child_name: str | None + + +class LocationDAO(BaseDAO): + async def assert_parent_device_access(self, *, device_id: str, user_id: int) -> ParentDeviceAccess: + from fastapi import HTTPException, status + result = await self.execute( + text( + """ + SELECT + db.device_id, + db.child_id, + c.child_name + FROM device_bindings AS db + LEFT JOIN children AS c + ON c.child_id = db.child_id + AND c.status = 1 + WHERE db.device_id = :device_id + AND db.owner_user_id = :user_id + AND db.status = 1 + LIMIT 1 + """ + ), + {"device_id": device_id, "user_id": user_id}, + ) + row = result.mappings().first() + if not row: + raise HTTPException(status_code=404, detail="device not found") + if row["child_id"] is None: + raise HTTPException(status_code=404, detail="device not bound to a child") + + return ParentDeviceAccess( + device_id=str(row["device_id"]), + child_id=int(row["child_id"]), + child_name=row["child_name"], + ) + + async def report_device_location( + self, + *, + device_id: str, + child_id: int, + payload: Any, + ) -> Mapping[str, Any]: + now_sql = "CURRENT_TIMESTAMP(3)" + + try: + current_row = await self._get_current_location_row(child_id=child_id, lock=True) + params = { + "child_id": child_id, + "device_id": device_id, + "coord_type": payload.coord_type, + "lat": payload.lat, + "lng": payload.lng, + "accuracy_m": payload.accuracy_m, + "altitude_m": payload.altitude_m, + "speed_mps": payload.speed_mps, + "heading_deg": payload.heading_deg, + "source": payload.source, + "battery_pct": payload.battery_pct, + "device_time": payload.device_time, + } + if current_row: + await self.execute( + text( + f""" + UPDATE child_location_current + SET device_id = :device_id, + coord_type = :coord_type, + lat = :lat, + lng = :lng, + accuracy_m = :accuracy_m, + altitude_m = :altitude_m, + speed_mps = :speed_mps, + heading_deg = :heading_deg, + source = :source, + battery_pct = :battery_pct, + device_time = :device_time, + server_time = {now_sql}, + updated_at = {now_sql} + WHERE child_id = :child_id + """ + ), + params, + ) + else: + await self.execute( + text( + f""" + INSERT INTO child_location_current ( + child_id, + device_id, + coord_type, + lat, + lng, + accuracy_m, + altitude_m, + speed_mps, + heading_deg, + source, + battery_pct, + device_time, + server_time, + updated_at + ) VALUES ( + :child_id, + :device_id, + :coord_type, + :lat, + :lng, + :accuracy_m, + :altitude_m, + :speed_mps, + :heading_deg, + :source, + :battery_pct, + :device_time, + {now_sql}, + {now_sql} + ) + """ + ), + params, + ) + + history_id = await self._next_primary_key("child_location_history") + insert_history_sql = f""" + INSERT INTO child_location_history ( + {'id,' if history_id is not None else ''} + child_id, + device_id, + coord_type, + lat, + lng, + accuracy_m, + altitude_m, + speed_mps, + heading_deg, + source, + battery_pct, + device_time, + server_time, + created_at + ) VALUES ( + {':id,' if history_id is not None else ''} + :child_id, + :device_id, + :coord_type, + :lat, + :lng, + :accuracy_m, + :altitude_m, + :speed_mps, + :heading_deg, + :source, + :battery_pct, + :device_time, + {now_sql}, + {now_sql} + ) + """ + history_params = dict(params) + if history_id is not None: + history_params["id"] = history_id + await self.execute(text(insert_history_sql), history_params) + await self.commit() + except Exception: + await self.db.rollback() + raise + + current_row = await self._get_current_location_row(child_id=child_id, lock=False) + if not current_row: + raise RuntimeError("current location not found after report") + return current_row + + async def get_device_current_location(self, *, device_id: str, user_id: int) -> Mapping[str, Any]: + access = await self.assert_parent_device_access(device_id=device_id, user_id=user_id) + row = await self._get_current_location_row(child_id=access.child_id, lock=False) + if not row or str(row["device_id"]) != device_id: + from fastapi import HTTPException, status + raise HTTPException(status_code=404, detail="location not found") + return {**row, "child_name": access.child_name} + + async def get_device_trajectory( + self, + *, + device_id: str, + user_id: int, + start_at: datetime | None, + end_at: datetime | None, + limit: int, + ) -> tuple[ParentDeviceAccess, list[Mapping[str, Any]]]: + access = await self.assert_parent_device_access(device_id=device_id, user_id=user_id) + params: dict[str, Any] = { + "device_id": device_id, + "child_id": access.child_id, + "fetch_limit": limit, + } + where = """ + device_id = :device_id + AND child_id = :child_id + """ + if start_at is not None: + where += " AND device_time >= :start_at" + params["start_at"] = start_at + if end_at is not None: + where += " AND device_time <= :end_at" + params["end_at"] = end_at + + result = await self.execute( + text( + f""" + SELECT + id, + child_id, + device_id, + coord_type, + lat, + lng, + accuracy_m, + altitude_m, + speed_mps, + heading_deg, + source, + battery_pct, + device_time, + server_time, + created_at + FROM child_location_history + WHERE {where} + ORDER BY device_time DESC, id DESC + LIMIT :fetch_limit + """ + ), + params, + ) + rows = list(result.mappings().all()) + rows.reverse() + return access, rows + + async def _get_current_location_row( + self, + *, + child_id: int, + lock: bool, + ) -> Mapping[str, Any] | None: + lock_clause = " FOR UPDATE" if lock else "" + result = await self.execute( + text( + f""" + SELECT + child_id, + device_id, + coord_type, + lat, + lng, + accuracy_m, + altitude_m, + speed_mps, + heading_deg, + source, + battery_pct, + device_time, + server_time, + updated_at + FROM child_location_current + WHERE child_id = :child_id + LIMIT 1{lock_clause} + """ + ), + {"child_id": child_id}, + ) + return result.mappings().first() + + async def _next_primary_key(self, table_name: str) -> int | None: + return None \ No newline at end of file diff --git a/talkingq-url/banban/dao/parent.py b/talkingq-url/banban/dao/parent.py new file mode 100644 index 0000000..1d71cd1 --- /dev/null +++ b/talkingq-url/banban/dao/parent.py @@ -0,0 +1,125 @@ +from collections.abc import Mapping +from typing import Optional + +from sqlalchemy import text +from sqlalchemy.exc import IntegrityError + +from banban.dao import BaseDAO +from banban.db_compat import inserted_primary_key + + +class ParentDAO(BaseDAO): + async def create( + self, + openid: str, + unionid: Optional[str] = None, + nickname: Optional[str] = None, + avatar_url: Optional[str] = None, + ) -> int: + result = await self.execute( + """ + INSERT INTO parents (openid, unionid, nickname, avatar_url, status) + VALUES (:openid, :unionid, :nickname, :avatar_url, 1) + """, + {"openid": openid, "unionid": unionid, "nickname": nickname, "avatar_url": avatar_url}, + ) + user_id = inserted_primary_key(result) + await self.commit() + return user_id + + async def get_by_id(self, user_id: int) -> Optional[Mapping]: + return ( + await self.execute( + "SELECT * FROM parents WHERE user_id = :user_id", + {"user_id": user_id}, + ) + ).mappings().first() + + async def get_by_openid(self, openid: str) -> Optional[Mapping]: + return ( + await self.execute( + "SELECT * FROM parents WHERE openid = :openid", + {"openid": openid}, + ) + ).mappings().first() + + async def update( + self, + user_id: int, + nickname: Optional[str] = None, + avatar_url: Optional[str] = None, + phone: Optional[str] = None, + ) -> None: + await self.execute( + """ + UPDATE parents + SET nickname = COALESCE(:nickname, nickname), + avatar_url = COALESCE(:avatar_url, avatar_url), + phone = COALESCE(:phone, phone) + WHERE user_id = :user_id + """, + {"user_id": user_id, "nickname": nickname, "avatar_url": avatar_url, "phone": phone}, + ) + await self.commit() + + async def set_avatar_file_key(self, user_id: int, avatar_file_key: str) -> None: + await self.execute( + """ + UPDATE parents + SET avatar_file_key = :avatar_file_key, + avatar_url = NULL + WHERE user_id = :user_id + """, + {"user_id": user_id, "avatar_file_key": avatar_file_key}, + ) + await self.commit() + + async def update_from_wechat_login( + self, + user_id: int, + unionid: Optional[str] = None, + nickname: Optional[str] = None, + avatar_url: Optional[str] = None, + ) -> None: + await self.execute( + """ + UPDATE parents + SET unionid = CASE + WHEN unionid IS NULL AND :unionid IS NOT NULL THEN :unionid + ELSE unionid + END, + nickname = COALESCE(:nickname, nickname), + avatar_url = COALESCE(:avatar_url, avatar_url) + WHERE user_id = :user_id + """, + { + "user_id": user_id, + "unionid": unionid, + "nickname": nickname, + "avatar_url": avatar_url, + }, + ) + await self.commit() + + async def upsert( + self, + openid: str, + unionid: Optional[str] = None, + nickname: Optional[str] = None, + avatar_url: Optional[str] = None, + ) -> int: + existing = await self.get_by_openid(openid) + if existing: + await self.update_from_wechat_login(existing["user_id"], unionid, nickname, avatar_url) + return int(existing["user_id"]) + + try: + return await self.create(openid, unionid, nickname, avatar_url) + except IntegrityError: + await self.db.rollback() + existing = await self.get_by_openid(openid) + if not existing: + raise + if unionid or nickname or avatar_url: + await self.update_from_wechat_login(existing["user_id"], unionid, nickname, avatar_url) + return int(existing["user_id"]) diff --git a/talkingq-url/banban/db_compat.py b/talkingq-url/banban/db_compat.py new file mode 100644 index 0000000..42d474e --- /dev/null +++ b/talkingq-url/banban/db_compat.py @@ -0,0 +1,22 @@ +from collections.abc import Sequence +from typing import Any + +from sqlalchemy.orm import Session + + +def inserted_primary_key(result: Any) -> int: + lastrowid = getattr(result, "lastrowid", None) + if lastrowid is not None: + return int(lastrowid) + + try: + inserted_primary_key = result.inserted_primary_key + except Exception: + inserted_primary_key = None + + if isinstance(inserted_primary_key, Sequence) and inserted_primary_key: + primary_key = inserted_primary_key[0] + if primary_key is not None: + return int(primary_key) + + raise RuntimeError("Could not determine inserted primary key") diff --git a/talkingq-url/banban/middleware/__init__.py b/talkingq-url/banban/middleware/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/talkingq-url/banban/middleware/__init__.py @@ -0,0 +1 @@ + diff --git a/talkingq-url/banban/middleware/auth.py b/talkingq-url/banban/middleware/auth.py new file mode 100644 index 0000000..137c7cc --- /dev/null +++ b/talkingq-url/banban/middleware/auth.py @@ -0,0 +1,125 @@ +from collections.abc import Awaitable, Callable +import logging + +from fastapi import FastAPI, HTTPException, Request +from sqlalchemy import text + +from database.connection import get_db_manager +from banban.security import auth_error_response, decode_access_token + + +NO_AUTH_PATH_PREFIXES = ( + "/banban/auth/login", + "/banban/device-im", + "/banban/device-location", +) + + +def _is_no_auth_path(path: str) -> bool: + for prefix in NO_AUTH_PATH_PREFIXES: + if path == prefix or path.startswith(f"{prefix}/"): + return True + return False + + +def _is_excluded_path(path: str) -> bool: + return _is_no_auth_path(path) + +logger = logging.getLogger("app.auth") + + +def _is_no_auth_path(path: str) -> bool: + for prefix in NO_AUTH_PATH_PREFIXES: + if path == prefix or path.startswith(f"{prefix}/"): + return True + return False + + +def install_auth_middleware(app: FastAPI) -> None: + @app.middleware("http") + async def auth_middleware( + request: Request, + call_next: Callable[[Request], Awaitable], + ): + path = request.url.path + # 只对 banban 开头的路径应用认证中间件 + if not path.startswith("/banban"): + return await call_next(request) + + if request.method == "OPTIONS" or _is_no_auth_path(path): + return await call_next(request) + + auth_header = request.headers.get("Authorization") + if not auth_header: + logger.warning( + "auth failed: missing header", + extra={ + "event": "auth_check", + "request_id": getattr(request.state, "request_id", None), + "path": path, + "reason": "missing_authorization_header", + }, + ) + return auth_error_response("missing authorization header") + + parts = auth_header.split(" ", 1) + if len(parts) != 2 or parts[0].lower() != "bearer": + logger.warning( + "auth failed: invalid header format", + extra={ + "event": "auth_check", + "request_id": getattr(request.state, "request_id", None), + "path": path, + "reason": "invalid_authorization_format", + }, + ) + return auth_error_response("invalid authorization format") + + try: + user_id = decode_access_token(parts[1].strip()) + except HTTPException: + logger.warning( + "auth failed: invalid token", + extra={ + "event": "auth_check", + "request_id": getattr(request.state, "request_id", None), + "path": path, + "reason": "invalid_or_expired_token", + }, + ) + return auth_error_response("invalid or expired access token") + + # 使用异步数据库会话 + db_manager = await get_db_manager() + session = await db_manager.get_session() + try: + user_row = ( + await session.execute( + text( + """ + SELECT user_id, status + FROM parents + WHERE user_id = :user_id + LIMIT 1 + """ + ), + {"user_id": user_id}, + ) + ).mappings().first() + if not user_row or int(user_row["status"]) != 1: + logger.warning( + "auth failed: user unavailable", + extra={ + "event": "auth_check", + "request_id": getattr(request.state, "request_id", None), + "path": path, + "user_id": user_id, + "reason": "user_not_found_or_disabled", + }, + ) + return auth_error_response("user not found or disabled") + finally: + await session.close() + + request.state.user_id = user_id + return await call_next(request) diff --git a/talkingq-url/banban/middleware/request_log.py b/talkingq-url/banban/middleware/request_log.py new file mode 100644 index 0000000..1dfa035 --- /dev/null +++ b/talkingq-url/banban/middleware/request_log.py @@ -0,0 +1,70 @@ +import logging +import time +import uuid +from collections.abc import Awaitable, Callable + +from fastapi import FastAPI, Request +from starlette.responses import Response + + +logger = logging.getLogger("app.request") + + +def install_request_logging_middleware(app: FastAPI) -> None: + @app.middleware("http") + async def request_logging_middleware( + request: Request, + call_next: Callable[[Request], Awaitable[Response]], + ) -> Response: + path = request.url.path + # 只对 banban 开头的路径应用请求日志中间件 + if not path.startswith("/banban"): + return await call_next(request) + + request_id = request.headers.get("X-Request-ID") or uuid.uuid4().hex + request.state.request_id = request_id + started_at = time.perf_counter() + + try: + response = await call_next(request) + except Exception: + duration_ms = round((time.perf_counter() - started_at) * 1000, 2) + logger.exception( + "request failed", + extra={ + "event": "request", + "request_id": request_id, + "method": request.method, + "path": request.url.path, + "status_code": 500, + "duration_ms": duration_ms, + "client_ip": request.client.host if request.client else None, + "user_id": getattr(request.state, "user_id", None), + }, + ) + raise + + duration_ms = round((time.perf_counter() - started_at) * 1000, 2) + status_code = response.status_code + level = logging.INFO + if status_code >= 500: + level = logging.ERROR + elif status_code >= 400: + level = logging.WARNING + + logger.log( + level, + "request completed", + extra={ + "event": "request", + "request_id": request_id, + "method": request.method, + "path": request.url.path, + "status_code": status_code, + "duration_ms": duration_ms, + "client_ip": request.client.host if request.client else None, + "user_id": getattr(request.state, "user_id", None), + }, + ) + response.headers["X-Request-ID"] = request_id + return response diff --git a/talkingq-url/banban/routers/__init__.py b/talkingq-url/banban/routers/__init__.py new file mode 100644 index 0000000..a166022 --- /dev/null +++ b/talkingq-url/banban/routers/__init__.py @@ -0,0 +1,25 @@ +from fastapi import APIRouter + +from banban.routers.bindings import router as bindings_router +from banban.routers.children import router as children_router +from banban.routers.device_im import router as device_im_router +from banban.routers.device_location import router as device_location_router +from banban.routers.devices import router as devices_router +from banban.routers.im import router as im_router +from banban.routers.parents import router as parents_router +from banban.routers.wechat_auth import router as wechat_auth_router + +# 统一创建一个主路由,前缀为 /banban +banban_router = APIRouter(prefix="/banban") + +# 将所有子路由添加到主路由 +banban_router.include_router(wechat_auth_router, tags=["banban-auth"]) +banban_router.include_router(bindings_router, tags=["banban-bindings"]) +banban_router.include_router(children_router, tags=["banban-children"]) +banban_router.include_router(devices_router, tags=["banban-devices"]) +banban_router.include_router(device_im_router, tags=["banban-device-im"]) +banban_router.include_router(device_location_router, tags=["banban-device-location"]) +banban_router.include_router(im_router, tags=["banban-im"]) +banban_router.include_router(parents_router, tags=["banban-parents"]) + +__all__ = ["banban_router"] \ No newline at end of file diff --git a/talkingq-url/banban/routers/bindings.py b/talkingq-url/banban/routers/bindings.py new file mode 100644 index 0000000..8db2d62 --- /dev/null +++ b/talkingq-url/banban/routers/bindings.py @@ -0,0 +1,232 @@ +import logging +from datetime import datetime + +from fastapi import APIRouter, Depends, HTTPException, Query, Request, status +from pydantic import BaseModel + +try: + from banban.security import get_current_user_id + from banban.service.binding import BindingError, BindingService +except ModuleNotFoundError: + from banban.security import get_current_user_id + from banban.service.binding import BindingError, BindingService + + +router = APIRouter(prefix="/bindings", tags=["bindings"]) +logger = logging.getLogger("app.bindings") + + +class BindStartRequest(BaseModel): + device_id: str + serial_number: str + child_id: int | None = None + + +class BindStartResponse(BaseModel): + bind_token: str + expires_at: str + + +class BindConfirmRequest(BaseModel): + bind_token: str + challenge_code: str + + +class BindConfirmResponse(BaseModel): + device_id: str + child_id: int | None + + +class BindingGetResponse(BaseModel): + device_id: str + child_id: int | None + status: int + bound_at: datetime + + +class BindingListItem(BaseModel): + device_id: str + child_id: int | None + child_name: str | None = None + status: int + bound_at: datetime + + +class BindingListResponse(BaseModel): + items: list[BindingListItem] + total: int + next_cursor: int | None = None + + +class BindHistoryItem(BaseModel): + device_id: str + child_id: int | None + bound_at: datetime + unbound_at: datetime | None + + +class BindHistoryResponse(BaseModel): + items: list[BindHistoryItem] + total: int + next_cursor: str | None + + +@router.post("/start", response_model=BindStartResponse) +async def start_bind( + payload: BindStartRequest, + request: Request, + current_user_id: int = Depends(get_current_user_id), +) -> BindStartResponse: + service = BindingService() + try: + bind_token, expires_at = await 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()) + + +@router.post("/confirm", response_model=BindConfirmResponse) +async def confirm_bind( + payload: BindConfirmRequest, + request: Request, + current_user_id: int = Depends(get_current_user_id), +) -> BindConfirmResponse: + service = BindingService() + try: + result = await 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) + + +class DirectBindRequest(BaseModel): + device_id: str + serial_number: str + child_id: int | None = None + + +class DirectBindResponse(BaseModel): + device_id: str + child_id: int | None + + +class BindSetChildRequest(BaseModel): + child_id: int + + +@router.post("/direct", response_model=DirectBindResponse) +async def direct_bind( + payload: DirectBindRequest, + request: Request, + current_user_id: int = Depends(get_current_user_id), +) -> DirectBindResponse: + service = BindingService() + try: + result = await 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) + + +@router.patch("/{device_id}/child", response_model=DirectBindResponse) +async def set_binding_child( + device_id: str, + payload: BindSetChildRequest, + request: Request, + current_user_id: int = Depends(get_current_user_id), +) -> DirectBindResponse: + service = BindingService() + try: + result = await 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) + + +@router.get("/current", response_model=BindingGetResponse) +async def get_current_binding( + request: Request, + current_user_id: int = Depends(get_current_user_id), +): + service = BindingService() + binding = await service.get_current_binding(current_user_id) + if not binding: + raise HTTPException(status_code=404, detail="no binding found") + return binding + + +@router.get("", response_model=BindingListResponse) +async def list_bindings( + 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), +) -> BindingListResponse: + service = BindingService() + rows, has_more = await service.list_bindings(current_user_id, limit, cursor) + next_cursor = int(rows[-1]["id"]) if has_more and rows else None + items = [ + BindingListItem( + device_id=row["device_id"], + child_id=row["child_id"], + child_name=row["child_name"], + status=row["status"], + bound_at=row["bound_at"], + ) + for row in rows + ] + return BindingListResponse(items=items, total=len(items), next_cursor=next_cursor) + + +@router.get("/{device_id}", response_model=BindingGetResponse) +async def get_binding( + device_id: str, + request: Request, + current_user_id: int = Depends(get_current_user_id), +) -> BindingGetResponse: + service = BindingService() + binding = await service.get_binding(device_id, current_user_id) + if not binding: + raise HTTPException(status_code=404, detail="binding not found") + return BindingGetResponse(**binding) + + +@router.delete("/{device_id}", status_code=status.HTTP_204_NO_CONTENT) +async def unbind_device( + device_id: str, + request: Request, + current_user_id: int = Depends(get_current_user_id), +) -> None: + service = BindingService() + if not await service.unbind(device_id, current_user_id): + raise HTTPException(status_code=404, detail="binding not found") + + +@router.get("/history/{device_id}", response_model=BindHistoryResponse) +async def get_bind_history( + device_id: str, + request: Request, + cursor: str | None = None, + limit: int = 20, + current_user_id: int = Depends(get_current_user_id), +) -> BindHistoryResponse: + cursor_dt = datetime.fromisoformat(cursor) if cursor else None + service = BindingService() + rows, has_more = await service.list_history(device_id, limit, cursor_dt) + next_cursor = rows[-1]["bound_at"].isoformat() if has_more and rows else None + items = [BindHistoryItem(**row) for row in rows] + return BindHistoryResponse(items=items, total=len(items), next_cursor=next_cursor) diff --git a/talkingq-url/banban/routers/children.py b/talkingq-url/banban/routers/children.py new file mode 100644 index 0000000..6925b86 --- /dev/null +++ b/talkingq-url/banban/routers/children.py @@ -0,0 +1,96 @@ +import logging + +from fastapi import APIRouter, Depends, HTTPException, Request, status +from pydantic import BaseModel + +try: + from banban.security import get_current_user_id + from banban.service.child import ChildService +except ModuleNotFoundError: + from banban.security import get_current_user_id + from banban.service.child import ChildService + + +router = APIRouter(prefix="/children", tags=["children"]) +logger = logging.getLogger("banban.children") + + +class ChildCreateRequest(BaseModel): + child_name: str + child_gender: int = 2 + child_birthday: str | None = None + + +class ChildResponse(BaseModel): + child_id: int + child_name: str + child_gender: int + child_birthday: str | None = None + status: int + + +class ChildUpdateRequest(BaseModel): + child_name: str | None = None + child_gender: int | None = None + child_birthday: str | None = None + + +class ChildListResponse(BaseModel): + items: list[ChildResponse] + total: int + next_cursor: int | None = None + + +@router.post("", response_model=ChildResponse, status_code=status.HTTP_201_CREATED) +async def create_child( + payload: ChildCreateRequest, + request: Request, + current_user_id: int = Depends(get_current_user_id), +) -> ChildResponse: + service = ChildService() + child = await service.create(current_user_id, payload.child_name, payload.child_gender, payload.child_birthday) + return ChildResponse(**child) + + +@router.get("", response_model=ChildListResponse) +async def list_children( + request: Request, + cursor: int | None = None, + limit: int = 20, + current_user_id: int = Depends(get_current_user_id), +) -> ChildListResponse: + service = ChildService() + rows, has_more = await service.list_children(current_user_id, limit, cursor) + next_cursor = rows[-1]["child_id"] if has_more and rows else None + items = [ChildResponse(**row) for row in rows] + return ChildListResponse(items=items, total=len(items), next_cursor=next_cursor) + + +@router.get("/{child_id}", response_model=ChildResponse) +async def get_child( + child_id: int, + request: Request, + current_user_id: int = Depends(get_current_user_id), +) -> ChildResponse: + service = ChildService() + child = await service.get(child_id) + if not child: + raise HTTPException(status_code=404, detail="child not found") + return ChildResponse(**child) + + +@router.patch("/{child_id}", response_model=ChildResponse) +async def update_child( + child_id: int, + payload: ChildUpdateRequest, + request: Request, + current_user_id: int = Depends(get_current_user_id), +) -> ChildResponse: + service = ChildService() + try: + child = await service.update(child_id, current_user_id, payload.child_name, payload.child_gender, payload.child_birthday) + except PermissionError: + raise HTTPException(status_code=403, detail="no permission to access this child") + if not child: + raise HTTPException(status_code=404, detail="child not found") + return ChildResponse(**child) diff --git a/talkingq-url/banban/routers/device_im.py b/talkingq-url/banban/routers/device_im.py new file mode 100644 index 0000000..d923149 --- /dev/null +++ b/talkingq-url/banban/routers/device_im.py @@ -0,0 +1,315 @@ +import logging +from typing import Any + +from fastapi import APIRouter, Depends, Header, HTTPException, Query, Request, Response, status +from sqlalchemy import text + +try: + from banban.service import get_db_session + from banban.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 banban.schemas.im import ( + ChildConversationListResponse, + ChildConversationMessageListResponse, + ConversationMessageCreateResponse, + DeviceMessageCreateRequest, + ) + from banban.service.im import im_service +except ModuleNotFoundError: + from banban.service import get_db_session + from banban.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 banban.schemas.im import ( + ChildConversationListResponse, + ChildConversationMessageListResponse, + ConversationMessageCreateResponse, + DeviceMessageCreateRequest, + ) + from banban.service.im import im_service + + +router = APIRouter(prefix="/device-im", tags=["device-im"]) +logger = logging.getLogger("app.device_im") + + +@router.get("/{device_id}/conversations", response_model=ChildConversationListResponse) +async 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), +) -> 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 = await im_service.authenticate_device_identity( + device_id=device_id, + serial_number=device_serial, + ) + child_id = device_identity.child_id + + from services.database_service_base import DatabaseServiceBase + db_service = DatabaseServiceBase(service_name="device_im_router") + db_session = await db_service.get_session() + try: + 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 + + result = await db_session.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, + ) + rows = result.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 = await _fetch_parent_names(db_session, peer_parent_ids) + child_names = await _fetch_child_names(db_session, 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, + ) + finally: + await db_session.close() + + +@router.get( + "/{device_id}/conversations/{conversation_id}/messages", + response_model=ChildConversationMessageListResponse, +) +async 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), +) -> ChildConversationMessageListResponse: + device_identity = await im_service.authenticate_device_identity( + device_id=device_id, + serial_number=device_serial, + ) + from services.database_service_base import DatabaseServiceBase + db_service = DatabaseServiceBase(service_name="device_im_router") + db_session = await db_service.get_session() + try: + await _get_conversation_for_child( + db=db_session, + conversation_id=conversation_id, + child_id=device_identity.child_id, + ) + finally: + await db_session.close() + + from services.database_service_base import DatabaseServiceBase + db_service = DatabaseServiceBase(service_name="device_im_router") + db_session = await db_service.get_session() + try: + 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" + + result = await db_session.execute(text(sql), params) + rows = result.mappings().all() + has_more = len(rows) > limit + rows = rows[:limit] + rows = list(rows) + 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, + ) + finally: + await db_session.close() + + +@router.post( + "/{device_id}/messages", + response_model=ConversationMessageCreateResponse, + status_code=status.HTTP_201_CREATED, +) +async 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), +) -> ConversationMessageCreateResponse: + device_identity, result = await im_service.create_device_message( + 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, + ) \ No newline at end of file diff --git a/talkingq-url/banban/routers/device_location.py b/talkingq-url/banban/routers/device_location.py new file mode 100644 index 0000000..e48dd11 --- /dev/null +++ b/talkingq-url/banban/routers/device_location.py @@ -0,0 +1,58 @@ +import logging + +from fastapi import APIRouter, Depends, Header, Request + +try: + from banban.schemas.location import DeviceLocationReportRequest, DeviceLocationReportResponse + from banban.service.location import location_service +except ModuleNotFoundError: + from banban.schemas.location import DeviceLocationReportRequest, DeviceLocationReportResponse + from banban.service.location import location_service + + +router = APIRouter(prefix="/device-location", tags=["device-location"]) +logger = logging.getLogger("app.device_location") + + +@router.post("/{device_id}/reports", response_model=DeviceLocationReportResponse) +async def create_device_location_report( + device_id: str, + payload: DeviceLocationReportRequest, + request: Request, + device_serial: str = Header(alias="X-Device-Serial", min_length=1), +) -> DeviceLocationReportResponse: + device_identity, row = await location_service.report_device_location( + 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"], + ) \ No newline at end of file diff --git a/talkingq-url/banban/routers/devices.py b/talkingq-url/banban/routers/devices.py new file mode 100644 index 0000000..212b12e --- /dev/null +++ b/talkingq-url/banban/routers/devices.py @@ -0,0 +1,206 @@ +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 banban.security import get_current_user_id + from banban.schemas.location import ( + DeviceLocationCurrentResponse, + DeviceLocationTrajectoryItem, + DeviceLocationTrajectoryResponse, + ) + from banban.service.location import location_service + from banban.service.device import device_service +except ModuleNotFoundError: + from banban.security import get_current_user_id + from banban.schemas.location import ( + DeviceLocationCurrentResponse, + DeviceLocationTrajectoryItem, + DeviceLocationTrajectoryResponse, + ) + from banban.service.location import location_service + from banban.service.device import device_service + + +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 _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) +async 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), +) -> DeviceMessageListResponse: + rows = await device_service.list_device_messages( + device_id=device_id, + user_id=current_user_id, + cursor=cursor, + limit=limit, + ) + + 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) +async def get_current_device_location( + device_id: str, + request: Request, + current_user_id: int = Depends(get_current_user_id), +) -> DeviceLocationCurrentResponse: + row = await location_service.get_device_current_location( + 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) +async 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), +) -> 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 = await location_service.get_device_trajectory( + 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, + ) \ No newline at end of file diff --git a/talkingq-url/banban/routers/im.py b/talkingq-url/banban/routers/im.py new file mode 100644 index 0000000..757d1a2 --- /dev/null +++ b/talkingq-url/banban/routers/im.py @@ -0,0 +1,532 @@ +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 + +try: + from banban.security import get_current_user_id + from banban.schemas.im import ( + ChildConversationItem, + ChildConversationListResponse, + ChildConversationMessageItem, + ChildConversationMessageListResponse, + ConversationMessageCreateResponse, + ParentChildMessageCreateRequest, + ) + from banban.service.im import ImService, im_service +except ModuleNotFoundError: + from banban.security import get_current_user_id + from banban.schemas.im import ( + ChildConversationItem, + ChildConversationListResponse, + ChildConversationMessageItem, + ChildConversationMessageListResponse, + ConversationMessageCreateResponse, + ParentChildMessageCreateRequest, + ) + from banban.service.im import ImService, im_service + + +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 + + +async def _fetch_parent_names(db, 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) + result = await db.execute( + text( + f""" + SELECT user_id, nickname + FROM parents + WHERE status = 1 + AND user_id IN ({placeholders}) + """ + ), + params, + ) + return {int(row["user_id"]): row["nickname"] for row in result.mappings().all()} + + +async def _fetch_child_names(db, 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) + result = await db.execute( + text( + f""" + SELECT child_id, child_name + FROM children + WHERE status = 1 + AND child_id IN ({placeholders}) + """ + ), + params, + ) + return {int(row["child_id"]): row["child_name"] for row in result.mappings().all()} + + +async def _assert_child_access(db, child_id: int, user_id: int) -> None: + result = await db.execute( + text( + """ + SELECT child_id + FROM children + WHERE child_id = :child_id + AND status = 1 + LIMIT 1 + """ + ), + {"child_id": child_id}, + ) + child_row = result.mappings().first() + if not child_row: + raise HTTPException(status_code=404, detail="child not found") + + result = await 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}, + ) + has_access = result.scalar_one_or_none() is not None + if not has_access: + raise HTTPException(status_code=403, detail="no permission to access this child") + + +async def _get_conversation_for_child( + db, + *, + conversation_id: int, + child_id: int, +) -> Mapping[str, Any]: + child_id_str = str(child_id) + result = await 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}, + ) + row = result.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) +async 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), +) -> ChildConversationListResponse: + if conversation_type is not None and conversation_type not in SUPPORTED_CONVERSATION_TYPES: + raise HTTPException(status_code=422, detail="unsupported conversation_type") + + await im_service.assert_parent_child_access(user_id=current_user_id, child_id=child_id) + + # 从 DatabaseServiceBase 获取数据库会话 + from services.database_service_base import DatabaseServiceBase + db_service = DatabaseServiceBase(service_name="im_router") + db_session = await db_service.get_session() + try: + 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 + + result = await db_session.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, + ) + rows = result.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 = await _fetch_parent_names(db_session, peer_parent_ids) + child_names = await _fetch_child_names(db_session, 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, + ) + finally: + await db_session.close() + + +@router.post( + "/{child_id}/messages", + response_model=ConversationMessageCreateResponse, + status_code=status.HTTP_201_CREATED, +) +async def create_child_message_for_parent( + child_id: int, + payload: ParentChildMessageCreateRequest, + request: Request, + response: Response, + current_user_id: int = Depends(get_current_user_id), +) -> ConversationMessageCreateResponse: + result = await im_service.create_parent_child_message( + 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, +) +async 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), +) -> ChildConversationMessageListResponse: + await im_service.assert_parent_child_access(user_id=current_user_id, child_id=child_id) + + # 从 DatabaseServiceBase 获取数据库会话 + from services.database_service_base import DatabaseServiceBase + db_service = DatabaseServiceBase(service_name="im_router") + db_session = await db_service.get_session() + try: + await _get_conversation_for_child(db=db_session, 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" + + result = await db_session.execute(text(sql), params) + rows = result.mappings().all() + has_more = len(rows) > limit + rows = rows[:limit] + rows = list(rows) + 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, + ) + finally: + await db_session.close() diff --git a/talkingq-url/banban/routers/parents.py b/talkingq-url/banban/routers/parents.py new file mode 100644 index 0000000..a905968 --- /dev/null +++ b/talkingq-url/banban/routers/parents.py @@ -0,0 +1,106 @@ +import logging + +from fastapi import APIRouter, Depends, File, HTTPException, Request, UploadFile, status +from pydantic import BaseModel + +try: + from banban.service.parent import ParentService + from banban.service.avatar_storage import AvatarStorageError + from banban.security import get_current_user_id +except ModuleNotFoundError: + from banban.service.parent import ParentService + from banban.service.avatar_storage import AvatarStorageError + from banban.security import get_current_user_id + + +router = APIRouter(prefix="/parents", tags=["parents"]) +logger = logging.getLogger("app.parents") + + +class ParentCreateRequest(BaseModel): + openid: str + unionid: str | None = None + nickname: str | None = None + avatar_url: str | None = None + + +class ParentResponse(BaseModel): + user_id: int + openid: str + unionid: str | None = None + nickname: str | None = None + avatar_url: str | None = None + phone: str | None = None + status: int + + +class ParentUpdateRequest(BaseModel): + nickname: str | None = None + avatar_url: str | None = None + phone: str | None = None + + +class AvatarDownloadResponse(BaseModel): + avatar_url: str + expires_in: int | None = None + + +@router.post("", response_model=ParentResponse, status_code=status.HTTP_201_CREATED) +async def create_parent(payload: ParentCreateRequest, request: Request) -> ParentResponse: + service = ParentService() + parent = await service.create(payload.openid, payload.unionid, payload.nickname, payload.avatar_url) + return ParentResponse(**parent) + + +@router.post("/me/avatar", response_model=ParentResponse) +async def upload_my_avatar( + request: Request, + file: UploadFile = File(...), + current_user_id: int = Depends(get_current_user_id), +) -> ParentResponse: + del request + service = ParentService() + try: + content = file.file.read() + parent = await service.upload_avatar( + user_id=current_user_id, + filename=file.filename, + content_type=file.content_type, + content=content, + ) + except AvatarStorageError as exc: + raise HTTPException(status_code=exc.status_code, detail=str(exc)) from exc + finally: + file.file.close() + + if not parent: + raise HTTPException(status_code=404, detail="parent not found") + return ParentResponse(**parent) + + +@router.get("/{user_id}/avatar", response_model=AvatarDownloadResponse) +async def get_parent_avatar(user_id: int, request: Request) -> AvatarDownloadResponse: + del request + service = ParentService() + avatar = await service.get_avatar_download(user_id) + if not avatar: + raise HTTPException(status_code=404, detail="avatar not found") + return AvatarDownloadResponse(**avatar) + + +@router.get("/{user_id}", response_model=ParentResponse) +async def get_parent(user_id: int, request: Request) -> ParentResponse: + service = ParentService() + parent = await service.get(user_id) + if not parent: + raise HTTPException(status_code=404, detail="parent not found") + return ParentResponse(**parent) + + +@router.patch("/{user_id}", response_model=ParentResponse) +async def update_parent(user_id: int, payload: ParentUpdateRequest, request: Request) -> ParentResponse: + service = ParentService() + parent = await service.update(user_id, payload.nickname, payload.avatar_url, payload.phone) + if not parent: + raise HTTPException(status_code=404, detail="parent not found") + return ParentResponse(**parent) diff --git a/talkingq-url/banban/routers/wechat_auth.py b/talkingq-url/banban/routers/wechat_auth.py new file mode 100644 index 0000000..78ee56e --- /dev/null +++ b/talkingq-url/banban/routers/wechat_auth.py @@ -0,0 +1,75 @@ +import logging + +from fastapi import APIRouter, Depends, HTTPException, Request +from pydantic import BaseModel, Field + +try: + from banban.security import create_access_token + from banban.service.parent import ParentService + from banban.service.wechat_login import ( + WechatAuthError, + WechatAuthService, + get_wechat_auth_service, + ) +except ModuleNotFoundError: + from banban.security import create_access_token + from banban.service.parent import ParentService + from banban.service.wechat_login import WechatAuthError, WechatAuthService, get_wechat_auth_service + + +router = APIRouter(prefix="/auth", tags=["auth"]) +logger = logging.getLogger("app.auth") + + +class LoginRequest(BaseModel): + code: str = Field(min_length=1, max_length=191) + nickname: str | None = Field(default=None, max_length=64) + avatar_url: str | None = Field(default=None, max_length=255) + + +class LoginResponse(BaseModel): + access_token: str + token_type: str = "bearer" + expires_in: int + user_id: int + + +@router.post("/login", response_model=LoginResponse) +async def login( + payload: LoginRequest, + request: Request, + wechat_auth_service: WechatAuthService = Depends(get_wechat_auth_service), +) -> LoginResponse: + del request + logger.info("/auth/login called") + + try: + wechat_session = await wechat_auth_service.exchange_code(payload.code) + except WechatAuthError as exc: + logger.warning( + "wechat login failed", + extra={ + "event": "wechat_login_failed", + "status_code": exc.status_code, + "errcode": exc.errcode, + }, + ) + raise HTTPException(status_code=exc.status_code, detail=str(exc)) from exc + + parent_service = ParentService() + parent = await parent_service.create( + openid=wechat_session.openid, + unionid=wechat_session.unionid, + nickname=payload.nickname, + avatar_url=payload.avatar_url, + ) + user_id = int(parent["user_id"]) + + access_token, expires_in = create_access_token(user_id=user_id) + logger.info("wechat login succeeded", extra={"event": "wechat_login_succeeded", "user_id": user_id}) + return LoginResponse(access_token=access_token, expires_in=expires_in, user_id=user_id) + + +@router.post("/logout") +async def logout(request: Request): + return {"message": "logged out"} \ No newline at end of file diff --git a/talkingq-url/banban/schemas/__init__.py b/talkingq-url/banban/schemas/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/talkingq-url/banban/schemas/__init__.py @@ -0,0 +1 @@ + diff --git a/talkingq-url/banban/schemas/auth.py b/talkingq-url/banban/schemas/auth.py new file mode 100644 index 0000000..43197c8 --- /dev/null +++ b/talkingq-url/banban/schemas/auth.py @@ -0,0 +1,13 @@ +from pydantic import BaseModel, Field + + +class LoginRequest(BaseModel): + username: str = Field(min_length=1, max_length=191) + password: str = Field(min_length=1, max_length=128) + + +class LoginResponse(BaseModel): + access_token: str + token_type: str = "bearer" + expires_in: int + user_id: int diff --git a/talkingq-url/banban/schemas/im.py b/talkingq-url/banban/schemas/im.py new file mode 100644 index 0000000..cdab21d --- /dev/null +++ b/talkingq-url/banban/schemas/im.py @@ -0,0 +1,111 @@ +from datetime import datetime +from typing import Any + +from pydantic import BaseModel, Field, model_validator + + +class ChildConversationItem(BaseModel): + conversation_id: int + conversation_type: int + conversation_type_name: str + peer_type: str + peer_id: str + peer_name: str | None = None + last_message_preview: str | None = None + last_message_at: datetime | None = None + message_count: int + + +class ChildConversationListResponse(BaseModel): + items: list[ChildConversationItem] + total: int + next_cursor: int | None = None + + +class ChildConversationMessageItem(BaseModel): + id: int + conversation_id: int + seq: int + sender_type: str + sender_id: str + receiver_type: str + receiver_id: str + content_type: int + content_text: str | None = None + content_json: dict[str, Any] | None = None + media_file_key: str | None = None + media_duration_ms: int | None = None + media_mime_type: str | None = None + media_size_bytes: int | None = None + media_transcript_text: str | None = None + client_msg_id: str | None = None + sender_name_snapshot: str | None = None + sender_avatar_snapshot: str | None = None + receiver_name_snapshot: str | None = None + receiver_avatar_snapshot: str | None = None + ext_json: dict[str, Any] | None = None + created_at: datetime + + +class ChildConversationMessageListResponse(BaseModel): + conversation_id: int + has_more: bool + next_cursor_seq: int | None = None + items: list[ChildConversationMessageItem] + + +class BaseConversationMessageCreateRequest(BaseModel): + content_type: int = Field(ge=1, le=4) + content_text: str | None = None + content_json: dict[str, Any] | None = None + media_file_key: str | None = None + media_duration_ms: int | None = Field(default=None, ge=0) + media_mime_type: str | None = None + media_size_bytes: int | None = Field(default=None, ge=0) + media_transcript_text: str | None = None + client_msg_id: str = Field(min_length=1, max_length=64) + ext_json: dict[str, Any] | None = None + + @model_validator(mode="after") + def validate_message_payload(self) -> "BaseConversationMessageCreateRequest": + if self.content_type == 1 and not self.content_text: + raise ValueError("content_text is required when content_type=1") + if self.content_type == 2 and not self.media_file_key: + raise ValueError("media_file_key is required when content_type=2") + if self.content_type == 3 and not self.media_file_key: + raise ValueError("media_file_key is required when content_type=3") + if self.content_type == 4 and self.content_json is None: + raise ValueError("content_json is required when content_type=4") + return self + + +class ParentChildMessageCreateRequest(BaseConversationMessageCreateRequest): + pass + + +class DeviceMessageCreateRequest(BaseConversationMessageCreateRequest): + conversation_type: int = Field(ge=1, le=2) + peer_child_id: int | None = Field(default=None, ge=1) + parent_user_id: int | None = Field(default=None, ge=1) + + @model_validator(mode="after") + def validate_target(self) -> "DeviceMessageCreateRequest": + if self.conversation_type == 1: + if self.peer_child_id is None: + raise ValueError("peer_child_id is required when conversation_type=1") + if self.parent_user_id is not None: + raise ValueError("parent_user_id must be empty when conversation_type=1") + if self.conversation_type == 2: + if self.parent_user_id is None: + raise ValueError("parent_user_id is required when conversation_type=2") + if self.peer_child_id is not None: + raise ValueError("peer_child_id must be empty when conversation_type=2") + return self + + +class ConversationMessageCreateResponse(BaseModel): + idempotent: bool + conversation_id: int + conversation_type: int + conversation_type_name: str + message: ChildConversationMessageItem diff --git a/talkingq-url/banban/schemas/location.py b/talkingq-url/banban/schemas/location.py new file mode 100644 index 0000000..bbb412c --- /dev/null +++ b/talkingq-url/banban/schemas/location.py @@ -0,0 +1,53 @@ +from datetime import datetime + +from pydantic import BaseModel, Field + + +class DeviceLocationReportRequest(BaseModel): + coord_type: str = Field(default="gcj02", min_length=1, max_length=16) + lat: float = Field(ge=-90, le=90) + lng: float = Field(ge=-180, le=180) + accuracy_m: int | None = Field(default=None, ge=0) + altitude_m: float | None = None + speed_mps: float | None = None + heading_deg: int | None = Field(default=None, ge=0, le=360) + source: int = Field(ge=0) + battery_pct: int | None = Field(default=None, ge=0, le=100) + device_time: datetime + + +class DeviceLocationPoint(BaseModel): + child_id: int + child_name: str | None = None + device_id: str + coord_type: str + lat: float + lng: float + accuracy_m: int | None = None + altitude_m: float | None = None + speed_mps: float | None = None + heading_deg: int | None = None + source: int + battery_pct: int | None = None + device_time: datetime + server_time: datetime + + +class DeviceLocationCurrentResponse(DeviceLocationPoint): + updated_at: datetime + + +class DeviceLocationReportResponse(DeviceLocationCurrentResponse): + pass + + +class DeviceLocationTrajectoryItem(DeviceLocationPoint): + id: int + created_at: datetime + + +class DeviceLocationTrajectoryResponse(BaseModel): + items: list[DeviceLocationTrajectoryItem] + total: int + start_at: datetime | None = None + end_at: datetime | None = None diff --git a/talkingq-url/banban/security.py b/talkingq-url/banban/security.py new file mode 100644 index 0000000..b4e0f8c --- /dev/null +++ b/talkingq-url/banban/security.py @@ -0,0 +1,64 @@ +from datetime import UTC, datetime, timedelta + +import jwt +from fastapi import HTTPException, Request, status +from starlette.responses import JSONResponse + +try: + # For module mode: `uvicorn app.main:app` + from config import settings +except ModuleNotFoundError: + # For script mode: `python app/main.py` or VS Code "Run Python File" + from config import settings + + +def create_access_token(user_id: int) -> tuple[str, int]: + now = datetime.now(UTC) + expires_delta = timedelta(minutes=settings.jwt_access_token_expire_minutes) + expires_at = now + expires_delta + payload = { + "sub": str(user_id), + "token_type": "access", + "iat": int(now.timestamp()), + "exp": int(expires_at.timestamp()), + } + token = jwt.encode( + payload=payload, + key=settings.jwt_secret, + algorithm=settings.jwt_algorithm, + ) + return token, int(expires_delta.total_seconds()) + + +def decode_access_token(token: str) -> int: + try: + payload = jwt.decode( + jwt=token, + key=settings.jwt_secret, + algorithms=[settings.jwt_algorithm], + ) + except jwt.PyJWTError as exc: + raise HTTPException(status_code=401, detail="invalid or expired access token") from exc + + if payload.get("token_type") != "access": + raise HTTPException(status_code=401, detail="invalid token type") + + sub = payload.get("sub") + if not isinstance(sub, str) or not sub.isdigit(): + raise HTTPException(status_code=401, detail="invalid token subject") + return int(sub) + + +def auth_error_response(detail: str = "unauthorized") -> JSONResponse: + return JSONResponse( + status_code=status.HTTP_401_UNAUTHORIZED, + content={"detail": detail}, + headers={"WWW-Authenticate": "Bearer"}, + ) + + +def get_current_user_id(request: Request) -> int: + user_id = getattr(request.state, "user_id", None) + if not isinstance(user_id, int): + raise HTTPException(status_code=401, detail="unauthorized") + return user_id diff --git a/talkingq-url/banban/service/__init__.py b/talkingq-url/banban/service/__init__.py new file mode 100644 index 0000000..668eb5a --- /dev/null +++ b/talkingq-url/banban/service/__init__.py @@ -0,0 +1,12 @@ +from contextlib import asynccontextmanager +from database.connection import get_db_manager + + +@asynccontextmanager +async def get_db_session(): + db_manager = await get_db_manager() + session = await db_manager.get_session() + try: + yield session + finally: + await session.close() diff --git a/talkingq-url/banban/service/avatar_storage.py b/talkingq-url/banban/service/avatar_storage.py new file mode 100644 index 0000000..1163fc8 --- /dev/null +++ b/talkingq-url/banban/service/avatar_storage.py @@ -0,0 +1,154 @@ +import asyncio +from dataclasses import dataclass +from datetime import UTC, datetime +from pathlib import Path +from uuid import uuid4 + +try: + from qcloud_cos import CosConfig, CosS3Client +except ModuleNotFoundError: # pragma: no cover - exercised in runtime env + CosConfig = None + CosS3Client = None + +from config import settings + + +_CONTENT_TYPE_TO_EXT = { + "image/jpeg": "jpg", + "image/png": "png", + "image/webp": "webp", +} +_EXTENSION_ALIASES = { + ".jpg": "jpg", + ".jpeg": "jpg", + ".png": "png", + ".webp": "webp", +} +_EXT_TO_CONTENT_TYPE = { + "jpg": "image/jpeg", + "png": "image/png", + "webp": "image/webp", +} + + +class AvatarStorageError(Exception): + def __init__(self, message: str, status_code: int = 400) -> None: + super().__init__(message) + self.status_code = status_code + + +@dataclass(frozen=True) +class StoredAvatar: + file_key: str + + +class AvatarStorageService: + def __init__(self) -> None: + self._client = None + + def _assert_ready(self) -> None: + if CosConfig is None or CosS3Client is None: + raise AvatarStorageError("COS SDK is not installed", status_code=500) + + required_pairs = { + "COS_SECRET_ID": settings.cos_secret_id, + "COS_SECRET_KEY": settings.cos_secret_key, + "COS_REGION": settings.cos_region, + "COS_BUCKET_AVA": settings.cos_bucket_ava, + } + missing = [key for key, value in required_pairs.items() if not value] + if missing: + raise AvatarStorageError( + f"missing COS avatar config: {', '.join(missing)}", + status_code=500, + ) + + def _get_client(self): + if self._client is None: + config = CosConfig( + Region=settings.cos_region, + SecretId=settings.cos_secret_id, + SecretKey=settings.cos_secret_key, + Scheme="https", + ) + self._client = CosS3Client(config) + return self._client + + def _normalize_extension(self, *, filename: str | None, content_type: str | None) -> str: + if content_type in _CONTENT_TYPE_TO_EXT: + return _CONTENT_TYPE_TO_EXT[content_type] + + suffix = Path(filename or "").suffix.lower() + if suffix in _EXTENSION_ALIASES: + return _EXTENSION_ALIASES[suffix] + + raise AvatarStorageError("unsupported avatar file type", status_code=415) + + def _build_key(self, *, user_id: int, extension: str) -> str: + prefix = settings.cos_avatar_prefix.strip("/") or "avatars" + now = datetime.now(UTC) + return ( + f"{prefix}/{user_id}/{now.strftime('%Y/%m/%d')}/" + f"{uuid4().hex}.{extension}" + ) + + async def upload_avatar( + self, + *, + user_id: int, + filename: str | None, + content_type: str | None, + content: bytes, + ) -> StoredAvatar: + self._assert_ready() + normalized_ext = self._normalize_extension(filename=filename, content_type=content_type) + if not content: + raise AvatarStorageError("avatar file is empty") + if len(content) > settings.cos_avatar_max_bytes: + raise AvatarStorageError("avatar file too large", status_code=413) + + key = self._build_key(user_id=user_id, extension=normalized_ext) + return await asyncio.to_thread( + self._upload_avatar_sync, + key=key, + content=content, + content_type=_EXT_TO_CONTENT_TYPE[normalized_ext], + ) + + def _upload_avatar_sync( + self, + *, + key: str, + content: bytes, + content_type: str, + ) -> StoredAvatar: + self._get_client().put_object( + Bucket=settings.cos_bucket_ava, + Body=content, + Key=key, + ContentType=content_type, + EnableMD5=False, + ) + return StoredAvatar(file_key=key) + + async def get_avatar_url(self, file_key: str) -> str: + self._assert_ready() + if not file_key: + raise AvatarStorageError("avatar file key is required", status_code=500) + return await asyncio.to_thread( + self._get_client().get_presigned_url, + Bucket=settings.cos_bucket_ava, + Key=file_key, + Method="GET", + Expired=settings.cos_avatar_url_expire_seconds, + ) + + async def delete_avatar(self, file_key: str) -> None: + self._assert_ready() + if not file_key: + return + await asyncio.to_thread( + self._get_client().delete_object, + Bucket=settings.cos_bucket_ava, + Key=file_key, + ) \ No newline at end of file diff --git a/talkingq-url/banban/service/binding.py b/talkingq-url/banban/service/binding.py new file mode 100644 index 0000000..34bc87a --- /dev/null +++ b/talkingq-url/banban/service/binding.py @@ -0,0 +1,139 @@ +from collections.abc import Mapping +from datetime import datetime +from typing import Optional + +from banban.dao.binding import BindingDAO +from services.database_service_base import DatabaseServiceBase + + +class BindingError(ValueError): + def __init__(self, message: str, status_code: int = 400) -> None: + super().__init__(message) + self.status_code = status_code + + +class BindingService(DatabaseServiceBase): + def __init__(self): + super().__init__(service_name="binding_service") + + async def _ensure_bindable_device(self, db_session, device_id: str, serial_number: str) -> None: + dao = BindingDAO(db_session) + row = await dao.get_device_auth(device_id) + if row is None: + raise BindingError("device not found in device_auth", status_code=404) + if str(row["serial_number"]) != serial_number: + raise BindingError("serial_number does not match device_id", status_code=400) + if int(row["is_active"]) != 1: + raise BindingError("device is inactive", status_code=400) + + async def start_bind( + self, + user_id: int, + device_id: str, + serial_number: str, + child_id: int | None = None, + ) -> tuple[str, datetime]: + db_session = await self.get_session() + try: + await self._ensure_bindable_device(db_session, device_id, serial_number) + dao = BindingDAO(db_session) + bind_token = await dao.start_bind(user_id, device_id, child_id) + await db_session.commit() + return bind_token, datetime.utcnow() + finally: + await db_session.close() + + async def confirm_bind(self, bind_token: str, user_id: int) -> Mapping: + db_session = await self.get_session() + try: + dao = BindingDAO(db_session) + session = await dao.get_session(bind_token, user_id) + if not session: + raise ValueError("Bind session not found") + if datetime.utcnow() > session["expires_at"]: + raise ValueError("Bind session expired") + if session["status"] != 1: + raise ValueError("Bind session already processed") + + await dao.confirm_bind(session["id"], session["device_id"], session["target_child_id"], user_id) + await db_session.commit() + return {"device_id": session["device_id"], "child_id": session["target_child_id"]} + finally: + await db_session.close() + + async def get_binding(self, device_id: str, user_id: int) -> Optional[Mapping]: + db_session = await self.get_session() + try: + dao = BindingDAO(db_session) + return await dao.get_by_device(device_id, user_id) + finally: + await db_session.close() + + async def get_current_binding(self, user_id: int) -> Optional[Mapping]: + db_session = await self.get_session() + try: + dao = BindingDAO(db_session) + return await dao.get_current_by_user(user_id) + finally: + await db_session.close() + + async def list_bindings(self, user_id: int, limit: int = 20, cursor: int = None) -> tuple[list, bool]: + db_session = await self.get_session() + try: + dao = BindingDAO(db_session) + rows = await dao.list_by_user(user_id, limit, cursor) + has_more = len(rows) > limit + rows = rows[:limit] + return rows, has_more + finally: + await db_session.close() + + async def direct_bind( + self, + device_id: str, + serial_number: str, + child_id: int | None, + user_id: int, + ) -> Mapping: + db_session = await self.get_session() + try: + await self._ensure_bindable_device(db_session, device_id, serial_number) + dao = BindingDAO(db_session) + await dao.direct_bind(device_id, child_id, user_id) + await db_session.commit() + return {"device_id": device_id, "child_id": child_id} + finally: + await db_session.close() + + async def set_binding_child(self, device_id: str, child_id: int, user_id: int) -> Mapping: + db_session = await self.get_session() + try: + dao = BindingDAO(db_session) + ok = await dao.set_binding_child(device_id=device_id, child_id=child_id, user_id=user_id) + if not ok: + raise ValueError("binding not found") + await db_session.commit() + return {"device_id": device_id, "child_id": child_id} + finally: + await db_session.close() + + async def unbind(self, device_id: str, user_id: int) -> bool: + db_session = await self.get_session() + try: + dao = BindingDAO(db_session) + result = await dao.unbind(device_id, user_id) + await db_session.commit() + return result + finally: + await db_session.close() + + async def list_history(self, device_id: str, limit: int = 20, cursor: datetime = None) -> tuple[list, bool]: + db_session = await self.get_session() + try: + dao = BindingDAO(db_session) + rows = await dao.list_history(device_id, limit, cursor) + has_more = len(rows) > limit + rows = rows[:limit] + return rows, has_more + finally: + await db_session.close() diff --git a/talkingq-url/banban/service/child.py b/talkingq-url/banban/service/child.py new file mode 100644 index 0000000..269d6d2 --- /dev/null +++ b/talkingq-url/banban/service/child.py @@ -0,0 +1,65 @@ +from collections.abc import Mapping +from datetime import date +from typing import Optional + +from banban.dao.child import ChildDAO +from services.database_service_base import DatabaseServiceBase + + +class ChildService(DatabaseServiceBase): + def __init__(self): + super().__init__(service_name="child_service") + + async def create( + self, + user_id: int, + child_name: str, + child_gender: int = 2, + child_birthday: Optional[date] = None, + ) -> Mapping: + db_session = await self.get_session() + try: + dao = ChildDAO(db_session) + child_id = await dao.create(user_id, child_name, child_gender, child_birthday) + await db_session.commit() + return await self.get(child_id) + finally: + await db_session.close() + + async def list_children(self, user_id: int, limit: int = 20, cursor: int = None) -> tuple[list, bool]: + db_session = await self.get_session() + try: + dao = ChildDAO(db_session) + rows = await dao.list_by_parent(user_id, limit, cursor) + has_more = len(rows) > limit + rows = rows[:limit] + return rows, has_more + finally: + await db_session.close() + + async def get(self, child_id: int) -> Optional[Mapping]: + db_session = await self.get_session() + try: + dao = ChildDAO(db_session) + return await dao.get_by_id(child_id) + finally: + await db_session.close() + + async def update( + self, + child_id: int, + user_id: int, + child_name: Optional[str] = None, + child_gender: Optional[int] = None, + child_birthday: Optional[date] = None, + ) -> Mapping: + db_session = await self.get_session() + try: + dao = ChildDAO(db_session) + if not await dao.has_access(child_id, user_id): + raise PermissionError("No access to this child") + await dao.update(child_id, child_name, child_gender, child_birthday) + await db_session.commit() + return await self.get(child_id) + finally: + await db_session.close() diff --git a/talkingq-url/banban/service/device.py b/talkingq-url/banban/service/device.py new file mode 100644 index 0000000..571444a --- /dev/null +++ b/talkingq-url/banban/service/device.py @@ -0,0 +1,43 @@ +from collections.abc import Mapping +from typing import Any, List + +from services.database_service_base import DatabaseServiceBase + +from banban.dao.device import DeviceDAO + + +class DeviceService(DatabaseServiceBase): + def __init__(self): + super().__init__(service_name="device_service") + + async def ensure_device_access(self, *, device_id: str, user_id: int) -> None: + db_session = await self.get_session() + try: + dao = DeviceDAO(db_session) + await dao.ensure_device_access(device_id=device_id, user_id=user_id) + finally: + await db_session.close() + + async def list_device_messages( + self, + *, + device_id: str, + user_id: int, + cursor: int | None, + limit: int, + ) -> List[Mapping[str, Any]]: + db_session = await self.get_session() + try: + dao = DeviceDAO(db_session) + await dao.ensure_device_access(device_id=device_id, user_id=user_id) + return await dao.list_device_messages( + device_id=device_id, + cursor=cursor, + limit=limit, + ) + finally: + await db_session.close() + + +# 创建全局 DeviceService 实例 +device_service = DeviceService() \ No newline at end of file diff --git a/talkingq-url/banban/service/im.py b/talkingq-url/banban/service/im.py new file mode 100644 index 0000000..0d4d699 --- /dev/null +++ b/talkingq-url/banban/service/im.py @@ -0,0 +1,256 @@ +from dataclasses import dataclass +import json +from collections.abc import Mapping +from typing import Any + +from fastapi import HTTPException, status +from services.database_service_base import DatabaseServiceBase + +try: + from banban.dao.im import ImDAO, DeviceIdentity, ConversationMessageCreateResult + from banban.schemas.im import ( + ChildConversationMessageItem, + DeviceMessageCreateRequest, + ParentChildMessageCreateRequest, + ) +except ModuleNotFoundError: + from banban.dao.im import ImDAO, DeviceIdentity, ConversationMessageCreateResult + from banban.schemas.im import ChildConversationMessageItem, DeviceMessageCreateRequest, ParentChildMessageCreateRequest + + +PARENT_PARTICIPANT_TYPE = 1 +CHILD_PARTICIPANT_TYPE = 2 + +CHILD_PEER_CONVERSATION_TYPE = 1 +PARENT_CHILD_CONVERSATION_TYPE = 2 + +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 conversation_type_name(conversation_type: int) -> str: + return CONVERSATION_TYPE_NAMES.get(conversation_type, f"unknown_{conversation_type}") + + +def participant_type_name(participant_type: int) -> str: + return PARTICIPANT_TYPE_NAMES.get(participant_type, f"unknown_{participant_type}") + + +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) + except json.JSONDecodeError: + return None + if isinstance(parsed, dict): + return parsed + return None + + +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["media_mime_type"], + media_size_bytes=row["media_size_bytes"], + media_transcript_text=row["media_transcript_text"], + client_msg_id=row["client_msg_id"], + sender_name_snapshot=row["sender_name_snapshot"], + sender_avatar_snapshot=row["sender_avatar_snapshot"], + receiver_name_snapshot=row["receiver_name_snapshot"], + receiver_avatar_snapshot=row["receiver_avatar_snapshot"], + ext_json=normalize_content_json(row["ext_json"]), + created_at=row["created_at"], + ) + + +class ImService(DatabaseServiceBase): + def __init__(self): + super().__init__(service_name="im_service") + + async def assert_parent_child_access(self, *, user_id: int, child_id: int) -> Mapping[str, Any]: + db_session = await self.get_session() + try: + dao = ImDAO(db_session) + return await dao.assert_parent_child_access(user_id=user_id, child_id=child_id) + finally: + await db_session.close() + + async def authenticate_device_identity(self, *, device_id: str, serial_number: str) -> DeviceIdentity: + db_session = await self.get_session() + try: + dao = ImDAO(db_session) + return await dao.authenticate_device_identity(device_id=device_id, serial_number=serial_number) + finally: + await db_session.close() + + async def create_parent_child_message( + self, + *, + parent_user_id: int, + child_id: int, + payload: ParentChildMessageCreateRequest, + ) -> ConversationMessageCreateResult: + db_session = await self.get_session() + try: + dao = ImDAO(db_session) + child_row = await dao.assert_parent_child_access(user_id=parent_user_id, child_id=child_id) + parent_row = await dao._get_parent_row(parent_user_id) + if not parent_row: + raise HTTPException(status_code=404, detail="parent not found") + + conversation_id, idempotent = await dao.create_message( + conversation_type=PARENT_CHILD_CONVERSATION_TYPE, + participant_a_type=CHILD_PARTICIPANT_TYPE, + participant_a_id=str(child_id), + participant_b_type=PARENT_PARTICIPANT_TYPE, + participant_b_id=str(parent_user_id), + pair_key=f"{child_id}:{parent_user_id}", + sender_type=PARENT_PARTICIPANT_TYPE, + sender_id=str(parent_user_id), + receiver_type=CHILD_PARTICIPANT_TYPE, + receiver_id=str(child_id), + sender_name_snapshot=parent_row["nickname"], + sender_avatar_snapshot=parent_row["avatar_url"], + receiver_name_snapshot=child_row["child_name"], + receiver_avatar_snapshot=None, + payload=payload, + ) + message_row = await dao._get_message_by_conversation_client_id( + conversation_id=conversation_id, + client_msg_id=payload.client_msg_id, + ) + if not message_row: + raise RuntimeError("message was not found after insert") + + return ConversationMessageCreateResult( + idempotent=idempotent, + conversation_id=conversation_id, + conversation_type=PARENT_CHILD_CONVERSATION_TYPE, + message=row_to_message_item(message_row), + ) + except Exception: + await db_session.rollback() + raise + finally: + await db_session.close() + + async def create_device_message( + self, + *, + device_id: str, + serial_number: str, + payload: DeviceMessageCreateRequest, + ) -> tuple[DeviceIdentity, ConversationMessageCreateResult]: + db_session = await self.get_session() + try: + dao = ImDAO(db_session) + device_identity = await dao.authenticate_device_identity( + device_id=device_id, + serial_number=serial_number, + ) + + if payload.conversation_type == CHILD_PEER_CONVERSATION_TYPE: + if payload.peer_child_id == device_identity.child_id: + raise HTTPException(status_code=400, detail="peer_child_id must be different from current child") + sender_child_row = await dao.assert_child_exists(child_id=device_identity.child_id) + receiver_child_row = await dao.assert_child_exists(child_id=payload.peer_child_id) + participant_a_id, participant_b_id, pair_key = dao._build_child_peer_pair( + device_identity.child_id, + payload.peer_child_id, + ) + + conversation_id, idempotent = await dao.create_message( + conversation_type=CHILD_PEER_CONVERSATION_TYPE, + participant_a_type=CHILD_PARTICIPANT_TYPE, + participant_a_id=participant_a_id, + participant_b_type=CHILD_PARTICIPANT_TYPE, + participant_b_id=participant_b_id, + pair_key=pair_key, + sender_type=CHILD_PARTICIPANT_TYPE, + sender_id=str(device_identity.child_id), + receiver_type=CHILD_PARTICIPANT_TYPE, + receiver_id=str(payload.peer_child_id), + sender_name_snapshot=sender_child_row["child_name"], + sender_avatar_snapshot=None, + receiver_name_snapshot=receiver_child_row["child_name"], + receiver_avatar_snapshot=None, + payload=payload, + ) + else: + sender_child_row = await dao.assert_child_exists(child_id=device_identity.child_id) + parent_row = await dao._get_parent_row(payload.parent_user_id) + if not parent_row: + raise HTTPException(status_code=404, detail="parent not found") + await dao.assert_parent_child_access(user_id=payload.parent_user_id, child_id=device_identity.child_id) + + conversation_id, idempotent = await dao.create_message( + conversation_type=PARENT_CHILD_CONVERSATION_TYPE, + participant_a_type=CHILD_PARTICIPANT_TYPE, + participant_a_id=str(device_identity.child_id), + participant_b_type=PARENT_PARTICIPANT_TYPE, + participant_b_id=str(payload.parent_user_id), + pair_key=f"{device_identity.child_id}:{payload.parent_user_id}", + sender_type=CHILD_PARTICIPANT_TYPE, + sender_id=str(device_identity.child_id), + receiver_type=PARENT_PARTICIPANT_TYPE, + receiver_id=str(payload.parent_user_id), + sender_name_snapshot=sender_child_row["child_name"], + sender_avatar_snapshot=None, + receiver_name_snapshot=parent_row["nickname"], + receiver_avatar_snapshot=parent_row["avatar_url"], + payload=payload, + ) + + message_row = await dao._get_message_by_conversation_client_id( + conversation_id=conversation_id, + client_msg_id=payload.client_msg_id, + ) + if not message_row: + raise RuntimeError("message was not found after insert") + + result = ConversationMessageCreateResult( + idempotent=idempotent, + conversation_id=conversation_id, + conversation_type=payload.conversation_type, + message=row_to_message_item(message_row), + ) + return device_identity, result + except Exception: + await db_session.rollback() + raise + finally: + await db_session.close() + + async def assert_child_exists(self, *, child_id: int) -> Mapping[str, Any]: + db_session = await self.get_session() + try: + dao = ImDAO(db_session) + return await dao.assert_child_exists(child_id=child_id) + finally: + await db_session.close() + + +# 创建全局 ImService 实例 +im_service = ImService() diff --git a/talkingq-url/banban/service/location.py b/talkingq-url/banban/service/location.py new file mode 100644 index 0000000..e85d37e --- /dev/null +++ b/talkingq-url/banban/service/location.py @@ -0,0 +1,90 @@ +from collections.abc import Mapping +from dataclasses import dataclass +from datetime import datetime +from typing import Any + +from services.database_service_base import DatabaseServiceBase + +try: + from banban.dao.location import LocationDAO, ParentDeviceAccess + from banban.schemas.location import DeviceLocationReportRequest + from banban.service.im import im_service +except ModuleNotFoundError: + from banban.dao.location import LocationDAO, ParentDeviceAccess + from banban.schemas.location import DeviceLocationReportRequest + from banban.service.im import im_service + + +class LocationService(DatabaseServiceBase): + def __init__(self): + super().__init__(service_name="location_service") + + async def assert_parent_device_access(self, *, device_id: str, user_id: int) -> ParentDeviceAccess: + db_session = await self.get_session() + try: + dao = LocationDAO(db_session) + return await dao.assert_parent_device_access(device_id=device_id, user_id=user_id) + finally: + await db_session.close() + + async def report_device_location( + self, + *, + device_id: str, + serial_number: str, + payload: DeviceLocationReportRequest, + ) -> tuple[Any, Mapping[str, Any]]: + device_identity = await im_service.authenticate_device_identity( + device_id=device_id, + serial_number=serial_number, + ) + db_session = await self.get_session() + try: + dao = LocationDAO(db_session) + current_row = await dao.report_device_location( + device_id=device_id, + child_id=device_identity.child_id, + payload=payload, + ) + return device_identity, current_row + finally: + await db_session.close() + + async def get_device_current_location( + self, + *, + device_id: str, + user_id: int, + ) -> Mapping[str, Any]: + db_session = await self.get_session() + try: + dao = LocationDAO(db_session) + return await dao.get_device_current_location(device_id=device_id, user_id=user_id) + finally: + await db_session.close() + + async def get_device_trajectory( + self, + *, + device_id: str, + user_id: int, + start_at: datetime | None, + end_at: datetime | None, + limit: int, + ) -> tuple[ParentDeviceAccess, list[Mapping[str, Any]]]: + db_session = await self.get_session() + try: + dao = LocationDAO(db_session) + return await dao.get_device_trajectory( + device_id=device_id, + user_id=user_id, + start_at=start_at, + end_at=end_at, + limit=limit, + ) + finally: + await db_session.close() + + +# 创建全局 LocationService 实例 +location_service = LocationService() diff --git a/talkingq-url/banban/service/parent.py b/talkingq-url/banban/service/parent.py new file mode 100644 index 0000000..2788e82 --- /dev/null +++ b/talkingq-url/banban/service/parent.py @@ -0,0 +1,131 @@ +from collections.abc import Mapping +from typing import Optional + +from banban.dao.parent import ParentDAO +from banban.service.avatar_storage import AvatarStorageService +from config import settings +from services.database_service_base import DatabaseServiceBase + + +class ParentService(DatabaseServiceBase): + def __init__(self, avatar_storage: AvatarStorageService | None = None): + super().__init__(service_name="parent_service") + self.avatar_storage = avatar_storage or AvatarStorageService() + + async def create( + self, + openid: str, + unionid: Optional[str] = None, + nickname: Optional[str] = None, + avatar_url: Optional[str] = None, + ) -> Mapping: + db_session = await self.get_session() + try: + dao = ParentDAO(db_session) + user_id = await dao.upsert(openid, unionid, nickname, avatar_url) + return await self.get(user_id) + finally: + await db_session.close() + + async def get(self, user_id: int) -> Optional[Mapping]: + db_session = await self.get_session() + try: + dao = ParentDAO(db_session) + return await self._present_parent(await dao.get_by_id(user_id)) + finally: + await db_session.close() + + async def update( + self, + user_id: int, + nickname: Optional[str] = None, + avatar_url: Optional[str] = None, + phone: Optional[str] = None, + ) -> Mapping: + db_session = await self.get_session() + try: + dao = ParentDAO(db_session) + await dao.update(user_id, nickname, avatar_url, phone) + return await self.get(user_id) + finally: + await db_session.close() + + async def upload_avatar( + self, + *, + user_id: int, + filename: str | None, + content_type: str | None, + content: bytes, + ) -> Optional[Mapping]: + db_session = await self.get_session() + try: + dao = ParentDAO(db_session) + existing = await dao.get_by_id(user_id) + if not existing: + return None + + stored = await self.avatar_storage.upload_avatar( + user_id=user_id, + filename=filename, + content_type=content_type, + content=content, + ) + old_avatar_file_key = existing.get("avatar_file_key") + + try: + await dao.set_avatar_file_key(user_id, stored.file_key) + await db_session.commit() + except Exception: + await db_session.rollback() + try: + await self.avatar_storage.delete_avatar(stored.file_key) + except Exception: + pass + raise + + if old_avatar_file_key and old_avatar_file_key != stored.file_key: + try: + await self.avatar_storage.delete_avatar(old_avatar_file_key) + except Exception: + pass + + return await self.get(user_id) + finally: + await db_session.close() + + async def get_avatar_download(self, user_id: int) -> Optional[dict]: + db_session = await self.get_session() + try: + dao = ParentDAO(db_session) + parent = await dao.get_by_id(user_id) + if not parent: + return None + + avatar_file_key = parent.get("avatar_file_key") + if avatar_file_key: + avatar_url = await self.avatar_storage.get_avatar_url(avatar_file_key) + return { + "avatar_url": avatar_url, + "expires_in": settings.cos_avatar_url_expire_seconds, + } + + avatar_url = parent.get("avatar_url") + if avatar_url: + return { + "avatar_url": avatar_url, + "expires_in": None, + } + return None + finally: + await db_session.close() + + async def _present_parent(self, parent: Optional[Mapping]) -> Optional[dict]: + if not parent: + return None + + data = dict(parent) + avatar_file_key = data.get("avatar_file_key") + if avatar_file_key: + data["avatar_url"] = await self.avatar_storage.get_avatar_url(avatar_file_key) + return data \ No newline at end of file diff --git a/talkingq-url/banban/service/wechat_login.py b/talkingq-url/banban/service/wechat_login.py new file mode 100644 index 0000000..f824053 --- /dev/null +++ b/talkingq-url/banban/service/wechat_login.py @@ -0,0 +1,126 @@ +import asyncio +import logging +from dataclasses import dataclass + +import httpx + +try: + from config import settings +except ModuleNotFoundError: + from config import settings + + +logger = logging.getLogger("app.wechat_login") + +INVALID_CODE_ERRCODES = {40029, 40163} +MISCONFIGURED_APP_ERRCODES = {40013, 40125} + + +@dataclass(frozen=True) +class WechatCodeSession: + openid: str + session_key: str + unionid: str | None = None + + +class WechatAuthError(Exception): + def __init__(self, detail: str, *, status_code: int, errcode: int | None = None): + super().__init__(detail) + self.status_code = status_code + self.errcode = errcode + + +class WechatAuthService: + def __init__(self, client: httpx.AsyncClient | None = None): + self._client = client + + async def exchange_code(self, code: str) -> WechatCodeSession: + if not settings.wechat_app_id or not settings.wechat_app_secret: + raise WechatAuthError("wechat login is not configured", status_code=503) + + response = await self._request_code2session(code) + data = self._parse_response_json(response) + + errcode = self._parse_errcode(data.get("errcode")) + if errcode not in (None, 0): + errmsg = data.get("errmsg") + logger.warning( + "wechat code2session rejected login code", + extra={ + "event": "wechat_code2session_rejected", + "errcode": errcode, + "errmsg": errmsg, + }, + ) + raise self._map_exchange_error(errcode) + + openid = data.get("openid") + session_key = data.get("session_key") + unionid = data.get("unionid") + + if not isinstance(openid, str) or not openid: + raise WechatAuthError("wechat login response missing openid", status_code=502) + if not isinstance(session_key, str) or not session_key: + raise WechatAuthError("wechat login response missing session_key", status_code=502) + if not isinstance(unionid, str) or not unionid: + unionid = None + + return WechatCodeSession(openid=openid, session_key=session_key, unionid=unionid) + + async def _request_code2session(self, code: str) -> httpx.Response: + params = { + "appid": settings.wechat_app_id, + "secret": settings.wechat_app_secret, + "js_code": code, + "grant_type": "authorization_code", + } + + client = self._client + owns_client = client is None + if client is None: + client = httpx.AsyncClient( + base_url=settings.wechat_api_base_url.rstrip("/"), + timeout=settings.wechat_http_timeout_seconds, + ) + + try: + response = await client.get("/sns/jscode2session", params=params) + response.raise_for_status() + return response + except httpx.HTTPError as exc: + logger.warning( + "wechat code2session request failed", + extra={"event": "wechat_code2session_request_failed"}, + ) + raise WechatAuthError("wechat login service unavailable", status_code=502) from exc + finally: + if owns_client: + await client.aclose() + + def _parse_response_json(self, response: httpx.Response) -> dict: + try: + data = response.json() + except ValueError as exc: + raise WechatAuthError("invalid response from wechat login service", status_code=502) from exc + if not isinstance(data, dict): + raise WechatAuthError("invalid response from wechat login service", status_code=502) + return data + + def _parse_errcode(self, value: object) -> int | None: + if value is None: + return None + try: + return int(value) + except (TypeError, ValueError): + return None + + def _map_exchange_error(self, errcode: int) -> WechatAuthError: + if errcode in INVALID_CODE_ERRCODES: + return WechatAuthError("invalid or expired wechat login code", status_code=401, errcode=errcode) + if errcode in MISCONFIGURED_APP_ERRCODES: + return WechatAuthError("wechat login is not configured correctly", status_code=503, errcode=errcode) + return WechatAuthError("wechat login service unavailable", status_code=502, errcode=errcode) + + +def get_wechat_auth_service() -> WechatAuthService: + return WechatAuthService() \ No newline at end of file diff --git a/talkingq-url/config.py b/talkingq-url/config.py index 7e9b0ef..28ed176 100644 --- a/talkingq-url/config.py +++ b/talkingq-url/config.py @@ -1,55 +1,132 @@ -from pydantic_settings import BaseSettings -from pydantic import ConfigDict +from functools import lru_cache +from urllib.parse import quote_plus +from pathlib import Path +from pydantic import Field +from pydantic_settings import BaseSettings, SettingsConfigDict + +ENV_FILE = Path(__file__).resolve().parent / ".env" + class Settings(BaseSettings): - server_host: str - server_port: int = 8080 - asr_provider: str = "Aliyun" # 固定使用阿里云 - llm_provider: str = "Volcano" # 固定使用火山引擎 - tts_provider: str = "MiniMax" # 固定使用MiniMax - - volcano_api_key: str - volcano_base_url: str - volcano_model_id: str = "ep-20250226121739-jkd24" # Doubao-1.5-pro-32k - volcano_app_id: str = "7872932045" - volcano_access_token: str - - minimax_api_key: str = "" - minimax_group_id: str = "" - minimax_base_url: str = "https://api.minimax.chat/v1/t2a_v2" - - aliyun_api_key: str - aliyun_vocabulary_id: str - - assets_dir: str = "assets" - session_timeout: int = 600 - conversation_history_timeout: int = 1800 - max_conversation_history: int = 5 - cleanup_interval: int = 300 - llm_first_token_timeout: int = 5 # LLM首个token的超时时间(秒) - tts_request_timeout: int = 5 # TTS单次请求超时时间(秒) - selected_role_key: str - - db_host: str = "mysql" - db_port: int = 3306 - db_user: str = "talkingq" - db_password: str - db_name: str = "talkingq" - db_echo: bool = False # 是否打印SQL语句 + model_config = SettingsConfigDict( + env_file=str(ENV_FILE), + env_file_encoding="utf-8", + extra="ignore", + ) - # TalkingQ设备MQTT命令服务配置 - talkingq_mqtt_broker: str = "broker.emqx.io" - talkingq_mqtt_port: int = 1884 - talkingq_mqtt_username: str = "" - talkingq_mqtt_password: str = "" - talkingq_mqtt_device_prefix: str = "TalkingQ" - talkingq_mqtt_qos: int = 1 - talkingq_mqtt_keepalive: int = 60 - talkingq_mqtt_nfc_notice_interval: int = 600 + app_name: str = Field(default="banban-server", validation_alias="APP_NAME") + app_version: str = Field(default="0.1.0", validation_alias="APP_VERSION") + app_description: str = Field( + default="Backend service for TalkingQ device", + validation_alias="APP_DESCRIPTION", + ) - admin_api_key: str # 用于设备注册的管理员API密钥 - client_api_key: str # 用于微信小程序客户端验证的API密钥 - - model_config = ConfigDict(extra="ignore", env_file=".env") + server_host: str = Field(default="0.0.0.0", validation_alias="SERVER_HOST") + server_port: int = Field(default=8080, validation_alias="SERVER_PORT") -settings = Settings() + asr_provider: str = Field(default="Aliyun", validation_alias="ASR_PROVIDER") + llm_provider: str = Field(default="Volcano", validation_alias="LLM_PROVIDER") + tts_provider: str = Field(default="MiniMax", validation_alias="TTS_PROVIDER") + + volcano_api_key: str = Field(default="", validation_alias="VOLCANO_API_KEY") + volcano_base_url: str = Field(default="", validation_alias="VOLCANO_BASE_URL") + volcano_model_id: str = Field(default="ep-20250226121739-jkd24", validation_alias="VOLCANO_MODEL_ID") + volcano_app_id: str = Field(default="7872932045", validation_alias="VOLCANO_APP_ID") + volcano_access_token: str = Field(default="", validation_alias="VOLCANO_ACCESS_TOKEN") + + minimax_api_key: str = Field(default="", validation_alias="MINIMAX_API_KEY") + minimax_group_id: str = Field(default="", validation_alias="MINIMAX_GROUP_ID") + minimax_base_url: str = Field( + default="https://api.minimax.chat/v1/t2a_v2", + validation_alias="MINIMAX_BASE_URL", + ) + + aliyun_api_key: str = Field(default="", validation_alias="ALIYUN_API_KEY") + aliyun_vocabulary_id: str = Field(default="", validation_alias="ALIYUN_VOCABULARY_ID") + + assets_dir: str = Field(default="assets", validation_alias="ASSETS_DIR") + session_timeout: int = Field(default=600, validation_alias="SESSION_TIMEOUT") + conversation_history_timeout: int = Field(default=1800, validation_alias="CONVERSATION_HISTORY_TIMEOUT") + max_conversation_history: int = Field(default=5, validation_alias="MAX_CONVERSATION_HISTORY") + cleanup_interval: int = Field(default=300, validation_alias="CLEANUP_INTERVAL") + llm_first_token_timeout: int = Field(default=5, validation_alias="LLM_FIRST_TOKEN_TIMEOUT") + tts_request_timeout: int = Field(default=5, validation_alias="TTS_REQUEST_TIMEOUT") + selected_role_key: str = Field(default="", validation_alias="SELECTED_ROLE_KEY") + + db_host: str = Field(default="mysql", validation_alias="DB_HOST") + db_port: int = Field(default=3306, validation_alias="DB_PORT") + db_user: str = Field(default="talkingq", validation_alias="DB_USER") + db_password: str = Field(default="", validation_alias="DB_PASSWORD") + db_name: str = Field(default="talkingq", validation_alias="DB_NAME") + db_echo: bool = Field(default=False, validation_alias="DB_ECHO") + + talkingq_mqtt_broker: str = Field(default="broker.emqx.io", validation_alias="TALKINGQ_MQTT_BROKER") + talkingq_mqtt_port: int = Field(default=1884, validation_alias="TALKINGQ_MQTT_PORT") + talkingq_mqtt_username: str = Field(default="", validation_alias="TALKINGQ_MQTT_USERNAME") + talkingq_mqtt_password: str = Field(default="", validation_alias="TALKINGQ_MQTT_PASSWORD") + talkingq_mqtt_device_prefix: str = Field(default="TalkingQ", validation_alias="TALKINGQ_MQTT_DEVICE_PREFIX") + talkingq_mqtt_qos: int = Field(default=1, validation_alias="TALKINGQ_MQTT_QOS") + talkingq_mqtt_keepalive: int = Field(default=60, validation_alias="TALKINGQ_MQTT_KEEPALIVE") + talkingq_mqtt_nfc_notice_interval: int = Field(default=600, validation_alias="TALKINGQ_MQTT_NFC_NOTICE_INTERVAL") + + admin_api_key: str = Field(default="", validation_alias="ADMIN_API_KEY") + client_api_key: str = Field(default="", validation_alias="CLIENT_API_KEY") + + wechat_app_id: str = Field(default="", validation_alias="WECHAT_APP_ID") + wechat_app_secret: str = Field(default="", validation_alias="WECHAT_APP_SECRET") + wechat_api_base_url: str = Field( + default="https://api.weixin.qq.com", + validation_alias="WECHAT_API_BASE_URL", + ) + wechat_http_timeout_seconds: float = Field( + default=5.0, + validation_alias="WECHAT_HTTP_TIMEOUT_SECONDS", + ) + + cos_secret_id: str = Field(default="", validation_alias="COS_SECRET_ID") + cos_secret_key: str = Field(default="", validation_alias="COS_SECRET_KEY") + cos_region: str = Field(default="", validation_alias="COS_REGION") + cos_bucket_message: str = Field(default="", validation_alias="COS_BUCKET_MESSAGE") + cos_bucket_ava: str = Field(default="", validation_alias="COS_BUCKET_AVA") + cos_public_base_url: str = Field(default="", validation_alias="COS_PUBLIC_BASE_URL") + cos_avatar_prefix: str = Field(default="avatars/", validation_alias="COS_AVATAR_PREFIX") + cos_avatar_url_expire_seconds: int = Field( + default=86400, + validation_alias="COS_AVATAR_URL_EXPIRE_SECONDS", + ) + cos_avatar_max_bytes: int = Field( + default=2 * 1024 * 1024, + validation_alias="COS_AVATAR_MAX_BYTES", + ) + + jwt_secret: str = Field( + default="dev_only_change_jwt_secret", + validation_alias="JWT_SECRET", + ) + jwt_algorithm: str = Field(default="HS256", validation_alias="JWT_ALGORITHM") + jwt_access_token_expire_minutes: int = Field( + default=60, + validation_alias="JWT_ACCESS_TOKEN_EXPIRE_MINUTES", + ) + + log_level: str = Field(default="INFO", validation_alias="LOG_LEVEL") + log_json: bool = Field(default=False, validation_alias="LOG_JSON") + slow_sql_ms: int = Field(default=200, validation_alias="SLOW_SQL_MS") + + @property + def mysql_dsn(self) -> str: + user = quote_plus(self.db_user) + password = quote_plus(self.db_password) + auth = f"{user}:{password}" if password else user + return ( + f"mysql+pymysql://{auth}@{self.db_host}:{self.db_port}/" + f"{self.db_name}?charset=utf8mb4" + ) + + +@lru_cache(maxsize=1) +def get_settings() -> Settings: + return Settings() + + +settings = get_settings() diff --git a/talkingq-url/database/connection.py b/talkingq-url/database/connection.py index 02cbc6f..2e38509 100644 --- a/talkingq-url/database/connection.py +++ b/talkingq-url/database/connection.py @@ -1,71 +1,121 @@ from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession +from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker from config import settings from utils.logger import session_logger import asyncio import urllib.parse + +def _build_connection_string() -> str: + encoded_password = urllib.parse.quote_plus(settings.db_password) + return f"mysql+aiomysql://{settings.db_user}:{encoded_password}@{settings.db_host}:{settings.db_port}/{settings.db_name}?charset=utf8mb4" + + +def _build_sync_connection_string() -> str: + encoded_password = urllib.parse.quote_plus(settings.db_password) + return f"mysql+pymysql://{settings.db_user}:{encoded_password}@{settings.db_host}:{settings.db_port}/{settings.db_name}?charset=utf8mb4" + + class DatabaseManager: _instance = None _lock = asyncio.Lock() - + def __init__(self): - self.engine = None + self.async_engine = None + self.sync_engine = None self.async_session = None + self.sync_session_maker = None self._initialized = False - + @classmethod async def get_instance(cls): async with cls._lock: if cls._instance is None: cls._instance = DatabaseManager() - + if not cls._instance._initialized: await cls._instance.initialize() - + return cls._instance - + async def initialize(self): if self._initialized: return - + try: - encoded_password = urllib.parse.quote_plus(settings.db_password) - connection_string = f"mysql+aiomysql://{settings.db_user}:{encoded_password}@{settings.db_host}:{settings.db_port}/{settings.db_name}?charset=utf8mb4" - self.engine = create_async_engine( + connection_string = _build_connection_string() + self.async_engine = create_async_engine( connection_string, echo=settings.db_echo, pool_pre_ping=True, pool_recycle=3600, ) - + self.async_session = sessionmaker( - self.engine, expire_on_commit=False, class_=AsyncSession + self.async_engine, expire_on_commit=False, class_=AsyncSession ) - + + sync_connection_string = _build_sync_connection_string() + self.sync_engine = create_engine( + sync_connection_string, + echo=settings.db_echo, + pool_pre_ping=True, + pool_recycle=3600, + ) + + self.sync_session_maker = sessionmaker( + bind=self.sync_engine, expire_on_commit=False + ) + self._initialized = True session_logger.info("system", "database", f"数据库连接初始化成功: {settings.db_host}:{settings.db_port}/{settings.db_name}") except Exception as e: session_logger.error("system", "database", f"数据库连接初始化失败: {str(e)}") raise - + async def get_session(self): - """获取数据库会话""" + """获取异步数据库会话""" if not self._initialized: await self.initialize() return self.async_session() - + + def get_sync_session(self): + """获取同步数据库会话""" + if not self._initialized: + raise RuntimeError("DatabaseManager not initialized. Call get_instance() first.") + return self.sync_session_maker() + async def close(self): """关闭数据库连接""" - if self.engine: - await self.engine.dispose() - self._initialized = False - session_logger.info("system", "database", "数据库连接已关闭") + if self.async_engine: + await self.async_engine.dispose() + if self.sync_engine: + self.sync_engine.dispose() + self._initialized = False + session_logger.info("system", "database", "数据库连接已关闭") + db_manager = None + async def get_db_manager(): global db_manager if db_manager is None: db_manager = await DatabaseManager.get_instance() return db_manager + + +def get_sync_session_maker(): + """获取同步 session maker(同步版本,优先复用已有 manager)""" + global db_manager + if db_manager is not None and db_manager._initialized: + return db_manager.sync_session_maker + + sync_engine = create_engine( + _build_sync_connection_string(), + echo=settings.db_echo, + pool_pre_ping=True, + pool_recycle=3600, + ) + return sessionmaker(bind=sync_engine, expire_on_commit=False) diff --git a/talkingq-url/database/models.py b/talkingq-url/database/models.py index 5cb359c..41ab0e7 100644 --- a/talkingq-url/database/models.py +++ b/talkingq-url/database/models.py @@ -1,9 +1,11 @@ -from sqlalchemy import Column, String, Text, Float, DateTime, Integer, Boolean, func, ForeignKey, JSON -from sqlalchemy.ext.declarative import declarative_base -from sqlalchemy.schema import UniqueConstraint -import datetime +from typing import Optional +from sqlalchemy import Column, String, Text, Float, DateTime, Integer, Boolean, func, ForeignKey, JSON, UniqueConstraint, BigInteger, Numeric, SmallInteger, Date, Index, Time, text +from sqlalchemy.orm import DeclarativeBase, mapped_column, Mapped -Base = declarative_base() +from datetime import date, time, datetime + +class Base(DeclarativeBase): + pass class DeviceConfig(Base): __tablename__ = "device_configs" @@ -13,7 +15,7 @@ class DeviceConfig(Base): selected_role_key = Column(String(64), nullable=False) preferred_language = Column(String(10), nullable=True) volume = Column(Integer, nullable=True) - last_update_time = Column(Float, nullable=False, default=lambda: datetime.datetime.now().timestamp()) + last_update_time = Column(Float, nullable=False, default=lambda: datetime.now().timestamp()) created_at = Column(DateTime, nullable=False, server_default=func.now()) updated_at = Column(DateTime, nullable=False, server_default=func.now(), onupdate=func.now()) @@ -30,7 +32,7 @@ class ConversationHistory(Base): id = Column(Integer, primary_key=True, autoincrement=True) device_id = Column(String(64), nullable=False, index=True) role_key = Column(String(64), nullable=False, index=True) - last_interaction_time = Column(Float, nullable=False, default=lambda: datetime.datetime.now().timestamp()) + last_interaction_time = Column(Float, nullable=False, default=lambda: datetime.now().timestamp()) created_at = Column(DateTime, nullable=False, server_default=func.now()) updated_at = Column(DateTime, nullable=False, server_default=func.now(), onupdate=func.now()) __table_args__ = ( @@ -43,7 +45,7 @@ class ConversationMessage(Base): conversation_id = Column(Integer, ForeignKey("conversation_histories.id", ondelete="CASCADE"), nullable=False, index=True) is_user = Column(Boolean, nullable=False, default=False) # True表示用户消息,False表示助手消息 content = Column(Text, nullable=False) - timestamp = Column(Float, nullable=False, default=lambda: datetime.datetime.now().timestamp()) + timestamp = Column(Float, nullable=False, default=lambda: datetime.now().timestamp()) created_at = Column(DateTime, nullable=False, server_default=func.now()) updated_at = Column(DateTime, nullable=False, server_default=func.now(), onupdate=func.now()) __table_args__ = ( @@ -129,7 +131,7 @@ class SystemConfig(Base): class Card(Base): __tablename__ = "cards" - + card_id = Column(Integer, primary_key=True, autoincrement=True) card_uuid = Column(String(64), unique=True, nullable=False) device_id = Column(String(64), index=True) @@ -138,7 +140,232 @@ class Card(Base): total_swaps = Column(Integer, server_default="0") created_at = Column(DateTime, server_default=func.now()) updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now()) - + __table_args__ = ( {'mysql_charset': 'utf8mb4', 'mysql_collate': 'utf8mb4_unicode_ci'} ) + + +class Parent(Base): + __tablename__ = "parents" + + user_id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + openid: Mapped[str] = mapped_column(String(64), unique=True, nullable=False) + unionid: Mapped[Optional[str]] = mapped_column(String(64)) + nickname: Mapped[Optional[str]] = mapped_column(String(64)) + avatar_url: Mapped[Optional[str]] = mapped_column(String(255)) + avatar_file_key: Mapped[Optional[str]] = mapped_column(String(255)) + phone: Mapped[Optional[str]] = mapped_column(String(20)) + status: Mapped[int] = mapped_column(Integer, server_default="1") + created_at: Mapped[Optional[datetime]] = mapped_column(DateTime, server_default=text("CURRENT_TIMESTAMP")) + updated_at: Mapped[Optional[datetime]] = mapped_column(DateTime, server_default=text("CURRENT_TIMESTAMP")) + + +class Child(Base): + __tablename__ = "children" + + child_id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + child_name: Mapped[str] = mapped_column(String(32), nullable=False) + child_gender: Mapped[int] = mapped_column(Integer, server_default="2") + child_birthday: Mapped[Optional[date]] = mapped_column(Date) + status: Mapped[int] = mapped_column(Integer, server_default="1") + created_at: Mapped[Optional[datetime]] = mapped_column(DateTime, server_default=text("CURRENT_TIMESTAMP")) + updated_at: Mapped[Optional[datetime]] = mapped_column(DateTime, server_default=text("CURRENT_TIMESTAMP")) + + +class ParentChildRelation(Base): + __tablename__ = "parent_child_relations" + __table_args__ = ( + UniqueConstraint("user_id", "child_id", name="uq_user_child"), + Index("idx_pcr_user_id", "user_id"), + Index("idx_pcr_child_id", "child_id"), + ) + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + user_id: Mapped[int] = mapped_column(Integer, nullable=False, index=True) + child_id: Mapped[int] = mapped_column(Integer, nullable=False, index=True) + relation_type: Mapped[int] = mapped_column(Integer, server_default="9") + is_primary: Mapped[bool] = mapped_column(Boolean, server_default="0") + status: Mapped[int] = mapped_column(Integer, server_default="1") + created_at: Mapped[Optional[datetime]] = mapped_column(DateTime, server_default=text("CURRENT_TIMESTAMP")) + updated_at: Mapped[Optional[datetime]] = mapped_column(DateTime, server_default=text("CURRENT_TIMESTAMP")) + + +class DeviceBinding(Base): + __tablename__ = "device_bindings" + __table_args__ = ( + UniqueConstraint("device_id", name="uq_device_binding_device"), + UniqueConstraint("child_id", name="uq_device_binding_child"), + Index("idx_device_bindings_owner_user_id", "owner_user_id"), + ) + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + device_id: Mapped[str] = mapped_column(String(64), nullable=False, unique=True) + owner_user_id: Mapped[int] = mapped_column(Integer, nullable=False) + child_id: Mapped[Optional[int]] = mapped_column(Integer) + status: Mapped[int] = mapped_column(Integer, server_default="1") + bound_at: Mapped[datetime] = mapped_column(DateTime, nullable=False) + unbound_at: Mapped[Optional[datetime]] = mapped_column(DateTime) + created_at: Mapped[Optional[datetime]] = mapped_column(DateTime, server_default=text("CURRENT_TIMESTAMP")) + updated_at: Mapped[Optional[datetime]] = mapped_column(DateTime, server_default=text("CURRENT_TIMESTAMP")) + + +class DeviceBindSession(Base): + __tablename__ = "device_bind_sessions" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + bind_token: Mapped[str] = mapped_column(String(36), unique=True, nullable=False) + device_id: Mapped[str] = mapped_column(String(64), nullable=False) + initiator_user_id: Mapped[int] = mapped_column(Integer, nullable=False) + target_child_id: Mapped[Optional[int]] = mapped_column(Integer) + challenge_code_hash: Mapped[Optional[str]] = mapped_column(String(64)) + challenge_set_at: Mapped[Optional[datetime]] = mapped_column(DateTime) + expires_at: Mapped[datetime] = mapped_column(DateTime, nullable=False) + max_attempt_count: Mapped[int] = mapped_column(Integer, server_default="5") + attempt_count: Mapped[int] = mapped_column(Integer, server_default="0") + status: Mapped[int] = mapped_column(Integer, server_default="1") + confirmed_at: Mapped[Optional[datetime]] = mapped_column(DateTime) + consumed_at: Mapped[Optional[datetime]] = mapped_column(DateTime) + created_at: Mapped[Optional[datetime]] = mapped_column(DateTime, server_default=text("CURRENT_TIMESTAMP")) + updated_at: Mapped[Optional[datetime]] = mapped_column(DateTime, server_default=text("CURRENT_TIMESTAMP")) + + +class DeviceBindHistory(Base): + __tablename__ = "device_bind_history" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + device_id: Mapped[str] = mapped_column(String(64), nullable=False) + child_id: Mapped[Optional[int]] = mapped_column(Integer) + bound_by_user_id: Mapped[int] = mapped_column(Integer, nullable=False) + unbound_by_user_id: Mapped[Optional[int]] = mapped_column(Integer) + bind_source: Mapped[int] = mapped_column(Integer, server_default="1") + bound_at: Mapped[datetime] = mapped_column(DateTime, nullable=False) + unbound_at: Mapped[Optional[datetime]] = mapped_column(DateTime) + unbind_reason: Mapped[Optional[str]] = mapped_column(String(191)) + created_at: Mapped[Optional[datetime]] = mapped_column(DateTime, server_default=text("CURRENT_TIMESTAMP")) + + +class DeviceSetting(Base): + __tablename__ = "device_settings" + + setting_id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + device_id: Mapped[str] = mapped_column(String(64), unique=True, nullable=False) + sleep_mode: Mapped[int] = mapped_column(Integer, server_default="0") + disable_time_start: Mapped[Optional[time]] = mapped_column(Time) + disable_time_end: Mapped[Optional[time]] = mapped_column(Time) + timezone: Mapped[str] = mapped_column(String(32), server_default=text("'Asia/Shanghai'")) + volume: Mapped[Optional[int]] = mapped_column(Integer) + brightness: Mapped[Optional[int]] = mapped_column(Integer) + disable_weekdays: Mapped[Optional[str]] = mapped_column(String(32)) + created_at: Mapped[Optional[datetime]] = mapped_column(DateTime, server_default=text("CURRENT_TIMESTAMP")) + updated_at: Mapped[Optional[datetime]] = mapped_column( + DateTime, + server_default=text("CURRENT_TIMESTAMP"), + onupdate=datetime.utcnow, + ) + + +class IMConversation(Base): + __tablename__ = "im_conversations" + __table_args__ = ( + UniqueConstraint("conversation_type", "pair_key", name="uq_conv_type_pair"), + Index("idx_im_conv_participant_a", "participant_a_type", "participant_a_id"), + Index("idx_im_conv_participant_b", "participant_b_type", "participant_b_id"), + Index("idx_im_conv_last_message_at", "last_message_at"), + ) + + id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) + conversation_type: Mapped[int] = mapped_column(Integer, nullable=False) + participant_a_type: Mapped[int] = mapped_column(Integer, nullable=False) + participant_a_id: Mapped[str] = mapped_column(String(64), nullable=False) + participant_b_type: Mapped[int] = mapped_column(Integer, nullable=False) + participant_b_id: Mapped[str] = mapped_column(String(64), nullable=False) + pair_key: Mapped[str] = mapped_column(String(191), nullable=False) + status: Mapped[int] = mapped_column(Integer, default=1) + last_seq: Mapped[int] = mapped_column(BigInteger, default=0, index=True) + message_count: Mapped[int] = mapped_column(BigInteger, default=0) + last_message_preview: Mapped[Optional[str]] = mapped_column(String(255)) + last_message_at: Mapped[Optional[datetime]] = mapped_column(DateTime) + ext_json: Mapped[Optional[dict]] = mapped_column(JSON) + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) + updated_at: Mapped[datetime] = mapped_column( + DateTime, default=datetime.utcnow, onupdate=datetime.utcnow + ) + + +class IMMessage(Base): + __tablename__ = "im_messages" + __table_args__ = ( + UniqueConstraint("conversation_id", "seq", name="uq_im_msg_conv_seq"), + UniqueConstraint("conversation_id", "client_msg_id", name="uq_im_msg_client"), + Index("idx_im_msg_sender", "sender_type", "sender_id", "created_at"), + Index("idx_im_msg_receiver", "receiver_type", "receiver_id", "created_at"), + ) + + id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) + conversation_id: Mapped[int] = mapped_column(BigInteger, nullable=False) + seq: Mapped[int] = mapped_column(BigInteger, nullable=False) + sender_type: Mapped[int] = mapped_column(Integer, nullable=False) + sender_id: Mapped[str] = mapped_column(String(64), nullable=False) + receiver_type: Mapped[int] = mapped_column(Integer, nullable=False) + receiver_id: Mapped[str] = mapped_column(String(64), nullable=False) + content_type: Mapped[int] = mapped_column(Integer, nullable=False) + content_text: Mapped[Optional[str]] = mapped_column(Text) + content_json: Mapped[Optional[dict]] = mapped_column(JSON) + media_file_key: Mapped[Optional[str]] = mapped_column(String(255)) + media_duration_ms: Mapped[Optional[int]] = mapped_column(Integer) + media_mime_type: Mapped[Optional[str]] = mapped_column(String(64)) + media_size_bytes: Mapped[Optional[int]] = mapped_column(BigInteger) + media_transcript_text: Mapped[Optional[str]] = mapped_column(Text) + client_msg_id: Mapped[Optional[str]] = mapped_column(String(64)) + sender_name_snapshot: Mapped[Optional[str]] = mapped_column(String(64)) + sender_avatar_snapshot: Mapped[Optional[str]] = mapped_column(String(255)) + receiver_name_snapshot: Mapped[Optional[str]] = mapped_column(String(64)) + receiver_avatar_snapshot: Mapped[Optional[str]] = mapped_column(String(255)) + ext_json: Mapped[Optional[dict]] = mapped_column(JSON) + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) + deleted_at: Mapped[Optional[datetime]] = mapped_column(DateTime) + + +class ChildLocationCurrent(Base): + __tablename__ = "child_location_current" + + child_id: Mapped[int] = mapped_column(BigInteger, primary_key=True) + device_id: Mapped[str] = mapped_column(String(64), nullable=False) + coord_type: Mapped[str] = mapped_column(String(16), default="gcj02") + lat: Mapped[float] = mapped_column(Numeric(10, 7), nullable=False) + lng: Mapped[float] = mapped_column(Numeric(10, 7), nullable=False) + accuracy_m: Mapped[Optional[int]] = mapped_column(Integer) + altitude_m: Mapped[Optional[float]] = mapped_column(Numeric(8, 2)) + speed_mps: Mapped[Optional[float]] = mapped_column(Numeric(8, 2)) + heading_deg: Mapped[Optional[int]] = mapped_column(SmallInteger) + source: Mapped[int] = mapped_column(Integer, nullable=False) + battery_pct: Mapped[Optional[int]] = mapped_column(Integer) + device_time: Mapped[datetime] = mapped_column(DateTime, nullable=False) + server_time: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) + updated_at: Mapped[datetime] = mapped_column( + DateTime, default=datetime.utcnow, onupdate=datetime.utcnow + ) + + +class ChildLocationHistory(Base): + __tablename__ = "child_location_history" + __table_args__ = ( + Index("idx_child_loc_hist_child", "child_id", "created_at"), + ) + + id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) + child_id: Mapped[int] = mapped_column(BigInteger, nullable=False) + device_id: Mapped[str] = mapped_column(String(64), nullable=False) + coord_type: Mapped[str] = mapped_column(String(16), default="gcj02") + lat: Mapped[float] = mapped_column(Numeric(10, 7), nullable=False) + lng: Mapped[float] = mapped_column(Numeric(10, 7), nullable=False) + accuracy_m: Mapped[Optional[int]] = mapped_column(Integer) + altitude_m: Mapped[Optional[float]] = mapped_column(Numeric(8, 2)) + speed_mps: Mapped[Optional[float]] = mapped_column(Numeric(8, 2)) + heading_deg: Mapped[Optional[int]] = mapped_column(SmallInteger) + source: Mapped[int] = mapped_column(Integer, nullable=False) + battery_pct: Mapped[Optional[int]] = mapped_column(Integer) + device_time: Mapped[datetime] = mapped_column(DateTime, nullable=False) + server_time: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) diff --git a/talkingq-url/main.py b/talkingq-url/main.py index 3ab668b..f99b1dd 100644 --- a/talkingq-url/main.py +++ b/talkingq-url/main.py @@ -12,6 +12,9 @@ from config import settings from utils.logger import session_logger from api import api_router from api.assets import configure_static_assets +from banban.routers import banban_router +from banban.middleware.auth import install_auth_middleware +from banban.middleware.request_log import install_request_logging_middleware from database.init_db import init_db from database.connection import get_db_manager from services.firmware_scanner import firmware_scanner @@ -130,4 +133,9 @@ app = FastAPI(lifespan=lifespan) configure_static_assets(app) +# 安装中间件 +install_request_logging_middleware(app) +install_auth_middleware(app) + app.include_router(api_router) +app.include_router(banban_router) diff --git a/talkingq-url/requirements.txt b/talkingq-url/requirements.txt index 76552cf..c9f9a0e 100644 --- a/talkingq-url/requirements.txt +++ b/talkingq-url/requirements.txt @@ -1,11 +1,11 @@ -aiofiles==24.1.0 +aiofiles==25.1.0 aiohttp -fastapi==0.115.11 +fastapi==0.136.1 # pycld2==0.41 pydantic-settings==2.8.1 -python-multipart==0.0.20 +python-multipart==0.0.26 PyYAML==6.0.2 -uvicorn[standard]==0.34.0 +uvicorn[standard]==0.46.0 dashscope==1.22.2 aiomysql==0.2.0 sqlalchemy[asyncio]==2.0.40 @@ -19,4 +19,9 @@ librosa==0.10.1 setuptools==69.5.1 pycld2 aiomqtt>=2.0.0 -apscheduler>=3.10.0 \ No newline at end of file +apscheduler>=3.10.0 + +httpx==0.28.1 +cos-python-sdk-v5==1.9.41 +pytest==8.3.4 +pytest-asyncio==0.24.0 \ No newline at end of file