add banban service code

This commit is contained in:
HycJack
2026-04-27 18:40:20 +08:00
parent 4ceafa9059
commit e9a69577db
39 changed files with 4952 additions and 85 deletions

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

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

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

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

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

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

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

View 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

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