add banban service code
This commit is contained in:
12
talkingq-url/banban/service/__init__.py
Normal file
12
talkingq-url/banban/service/__init__.py
Normal file
@@ -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()
|
||||
154
talkingq-url/banban/service/avatar_storage.py
Normal file
154
talkingq-url/banban/service/avatar_storage.py
Normal file
@@ -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,
|
||||
)
|
||||
139
talkingq-url/banban/service/binding.py
Normal file
139
talkingq-url/banban/service/binding.py
Normal file
@@ -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()
|
||||
65
talkingq-url/banban/service/child.py
Normal file
65
talkingq-url/banban/service/child.py
Normal file
@@ -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()
|
||||
43
talkingq-url/banban/service/device.py
Normal file
43
talkingq-url/banban/service/device.py
Normal file
@@ -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()
|
||||
256
talkingq-url/banban/service/im.py
Normal file
256
talkingq-url/banban/service/im.py
Normal file
@@ -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()
|
||||
90
talkingq-url/banban/service/location.py
Normal file
90
talkingq-url/banban/service/location.py
Normal file
@@ -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()
|
||||
131
talkingq-url/banban/service/parent.py
Normal file
131
talkingq-url/banban/service/parent.py
Normal file
@@ -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
|
||||
126
talkingq-url/banban/service/wechat_login.py
Normal file
126
talkingq-url/banban/service/wechat_login.py
Normal file
@@ -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()
|
||||
Reference in New Issue
Block a user