add code merge from test-clean

This commit is contained in:
HycJack
2026-04-30 05:11:58 +08:00
parent 5423af707d
commit b3687fbc71
14 changed files with 847 additions and 357 deletions

View File

@@ -2,7 +2,15 @@ from collections.abc import Mapping
from datetime import datetime
from typing import Optional
from banban.dao.binding import BindingDAO
from banban.dao.binding import (
SESSION_STATUS_CANCELLED,
SESSION_STATUS_COMPLETED,
SESSION_STATUS_EXPIRED,
SESSION_STATUS_FAILED,
SESSION_STATUS_PENDING,
BindingDAO,
)
from services.card_service import card_service
from services.database_service_base import DatabaseServiceBase
@@ -26,6 +34,12 @@ class BindingService(DatabaseServiceBase):
if int(row["is_active"]) != 1:
raise BindingError("device is inactive", status_code=400)
def _normalize_session_status(self, session: Mapping) -> int:
status = int(session["status"])
if status == SESSION_STATUS_PENDING and datetime.utcnow() > session["expires_at"]:
return SESSION_STATUS_EXPIRED
return status
async def start_bind(
self,
user_id: int,
@@ -37,9 +51,15 @@ class BindingService(DatabaseServiceBase):
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)
bind_token, expires_at = await dao.start_bind(user_id, device_id, child_id)
await db_session.commit()
return bind_token, datetime.utcnow()
from handlers.mqtt_handler import TalkingQMQTTService
service = await TalkingQMQTTService.get_instance()
if service is None:
raise BindingError("MQTT service is unavailable", status_code=503)
await service.send_bind_nfc_command(device_id)
return bind_token, expires_at
finally:
await db_session.close()
@@ -52,7 +72,7 @@ class BindingService(DatabaseServiceBase):
raise ValueError("Bind session not found")
if datetime.utcnow() > session["expires_at"]:
raise ValueError("Bind session expired")
if session["status"] != 1:
if int(session["status"]) != SESSION_STATUS_PENDING:
raise ValueError("Bind session already processed")
await dao.confirm_bind(session["id"], session["device_id"], session["target_child_id"], user_id)
@@ -61,6 +81,76 @@ class BindingService(DatabaseServiceBase):
finally:
await db_session.close()
async def get_bind_session(self, bind_token: str, user_id: int) -> Optional[Mapping]:
db_session = await self.get_session()
try:
dao = BindingDAO(db_session)
session = await dao.get_session(bind_token, user_id)
if session is None:
return None
normalized_status = self._normalize_session_status(session)
if normalized_status == SESSION_STATUS_EXPIRED and int(session["status"]) != SESSION_STATUS_EXPIRED:
await dao.mark_session_status(int(session["id"]), SESSION_STATUS_EXPIRED)
await db_session.commit()
session = await dao.get_session(bind_token, user_id)
if session is None:
return None
normalized_status = SESSION_STATUS_EXPIRED
payload = dict(session)
payload["status"] = normalized_status
payload["card_uuid"] = payload.get("card_uuid")
return payload
finally:
await db_session.close()
async def finalize_nfc_bind(self, device_id: str, card_uuid: str) -> Optional[Mapping]:
db_session = await self.get_session()
try:
dao = BindingDAO(db_session)
session = await dao.get_latest_pending_session_by_device(device_id)
if not session:
await db_session.rollback()
return None
if datetime.utcnow() > session["expires_at"]:
await dao.mark_session_status(int(session["id"]), SESSION_STATUS_EXPIRED)
await db_session.commit()
return {
"device_id": device_id,
"bind_token": session["bind_token"],
"status": SESSION_STATUS_EXPIRED,
}
try:
await card_service.activate_card(
card_uuid=card_uuid,
device_id=device_id,
db_session=db_session,
)
await dao.complete_nfc_bind(
session_id=int(session["id"]),
device_id=device_id,
child_id=session["target_child_id"],
user_id=int(session["initiator_user_id"]),
)
await db_session.commit()
except Exception:
await dao.mark_session_status(int(session["id"]), SESSION_STATUS_FAILED)
await db_session.commit()
raise
return {
"device_id": device_id,
"bind_token": session["bind_token"],
"status": SESSION_STATUS_COMPLETED,
"child_id": session["target_child_id"],
"card_uuid": card_uuid,
}
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:

View File

@@ -3,6 +3,7 @@ from datetime import date
from typing import Optional
from banban.dao.child import ChildDAO
from banban.dao.im import ImDAO
from services.database_service_base import DatabaseServiceBase
@@ -19,10 +20,24 @@ class ChildService(DatabaseServiceBase):
) -> 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)
child_dao = ChildDAO(db_session)
im_dao = ImDAO(db_session)
child_id = await child_dao.create(
user_id,
child_name,
child_gender,
child_birthday,
auto_commit=False,
)
await im_dao.ensure_parent_child_conversation(
parent_user_id=user_id,
child_id=child_id,
)
await db_session.commit()
return await self.get(child_id)
return await child_dao.get_by_id(child_id)
except Exception:
await db_session.rollback()
raise
finally:
await db_session.close()

View File

@@ -1,10 +1,12 @@
from dataclasses import dataclass
import hashlib
import json
from collections.abc import Mapping
from typing import Any
from fastapi import HTTPException, status
from fastapi import HTTPException
from services.database_service_base import DatabaseServiceBase
from banban.service.message_audio_storage import MessageAudioStorageService, MessageAudioStorageError
try:
from banban.dao.im import ImDAO, DeviceIdentity, ConversationMessageCreateResult
@@ -43,6 +45,12 @@ def participant_type_name(participant_type: int) -> str:
return PARTICIPANT_TYPE_NAMES.get(participant_type, f"unknown_{participant_type}")
def build_device_audio_client_msg_id(*, device_id: str, target_device_id: str, audio_url: str) -> str:
raw = f"{device_id}|{target_device_id}|{audio_url}"
digest = hashlib.sha256(raw.encode("utf-8")).hexdigest()
return f"device-audio-{digest[:32]}"
def normalize_content_json(value: Any) -> dict[str, Any] | None:
if value is None:
return None
@@ -85,9 +93,24 @@ def row_to_message_item(row: Mapping[str, Any]) -> ChildConversationMessageItem:
)
async def present_message_item(
row: Mapping[str, Any],
*,
audio_storage: MessageAudioStorageService,
) -> ChildConversationMessageItem:
item = row_to_message_item(row)
if item.content_type == 2 and item.media_file_key:
try:
item.media_file_key = await audio_storage.get_audio_url(item.media_file_key)
except MessageAudioStorageError:
pass
return item
class ImService(DatabaseServiceBase):
def __init__(self):
super().__init__(service_name="im_service")
self.audio_storage = MessageAudioStorageService()
async def assert_parent_child_access(self, *, user_id: int, child_id: int) -> Mapping[str, Any]:
db_session = await self.get_session()
@@ -105,6 +128,34 @@ class ImService(DatabaseServiceBase):
finally:
await db_session.close()
async def _build_message_create_result(
self,
*,
dao: ImDAO,
idempotent: bool,
conversation_id: int,
conversation_type: int,
client_msg_id: str,
) -> ConversationMessageCreateResult:
message_row = await dao._get_message_by_conversation_client_id(
conversation_id=conversation_id,
client_msg_id=client_msg_id,
)
if not message_row:
raise RuntimeError("message was not found after insert")
presented_message = await present_message_item(
message_row,
audio_storage=self.audio_storage,
)
return ConversationMessageCreateResult(
idempotent=idempotent,
conversation_id=conversation_id,
conversation_type=conversation_type,
message=presented_message,
)
async def create_parent_child_message(
self,
*,
@@ -137,18 +188,12 @@ class ImService(DatabaseServiceBase):
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(
return await self._build_message_create_result(
dao=dao,
idempotent=idempotent,
conversation_id=conversation_id,
conversation_type=PARENT_CHILD_CONVERSATION_TYPE,
message=row_to_message_item(message_row),
client_msg_id=payload.client_msg_id,
)
except Exception:
await db_session.rollback()
@@ -156,13 +201,87 @@ class ImService(DatabaseServiceBase):
finally:
await db_session.close()
async def _create_device_message_with_payload(
self,
*,
dao: ImDAO,
device_identity: DeviceIdentity,
payload: DeviceMessageCreateRequest,
) -> ConversationMessageCreateResult:
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,
)
return await self._build_message_create_result(
dao=dao,
idempotent=idempotent,
conversation_id=conversation_id,
conversation_type=payload.conversation_type,
client_msg_id=payload.client_msg_id,
)
async def create_device_message(
self,
*,
device_id: str,
serial_number: str,
payload: DeviceMessageCreateRequest,
payload: DeviceMessageCreateRequest | None = None,
target_device_id: str | None = None,
audio_url: str | None = None,
) -> tuple[DeviceIdentity, ConversationMessageCreateResult]:
if payload is None and (not target_device_id or not audio_url):
raise ValueError("payload or target_device_id/audio_url is required")
if payload is not None and (target_device_id is not None or audio_url is not None):
raise ValueError("payload and target_device_id/audio_url cannot be used together")
db_session = await self.get_session()
try:
dao = ImDAO(db_session)
@@ -171,70 +290,31 @@ class ImService(DatabaseServiceBase):
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(
resolved_payload = payload
if resolved_payload is None:
target_device_identity = await dao.get_device_by_id(device_id=target_device_id)
resolved_payload = DeviceMessageCreateRequest(
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,
peer_child_id=target_device_identity.child_id,
content_type=2,
media_file_key=audio_url,
media_mime_type="audio/mpeg",
client_msg_id=build_device_audio_client_msg_id(
device_id=device_id,
target_device_id=target_device_id,
audio_url=audio_url,
),
ext_json={
"source": "device_audio_message",
"source_device_id": device_id,
"target_device_id": target_device_id,
},
)
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),
result = await self._create_device_message_with_payload(
dao=dao,
device_identity=device_identity,
payload=resolved_payload,
)
return device_identity, result
except Exception:
@@ -251,55 +331,5 @@ class ImService(DatabaseServiceBase):
finally:
await db_session.close()
'''
Todo 创建设备消息, 还不完善
'''
async def create_device_message(
self,
*,
device_id: str,
serial_number: str,
target_device_id: str,
audio_url: str,
):
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,
)
target_device_identity = await dao.get_device_by_id(device_id=target_device_id)
sender_child_row = await dao.assert_child_exists(child_id=device_identity.child_id)
receiver_child_row = await dao.assert_child_exists(child_id=target_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(receiver_child_row.child_id),
pair_key=f"{device_identity.child_id}:{target_device_identity.child_id}",
sender_type=CHILD_PARTICIPANT_TYPE,
sender_id=str(device_identity.child_id),
receiver_type=PARENT_PARTICIPANT_TYPE,
receiver_id=str(receiver_child_row.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=None,
)
return device_identity
except Exception:
await db_session.rollback()
raise
finally:
await db_session.close()
# 创建全局 ImService 实例
im_service = ImService()

View File

@@ -0,0 +1,129 @@
import asyncio
from dataclasses import dataclass
from datetime import UTC, datetime
from urllib.parse import urlparse
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
class MessageAudioStorageError(Exception):
pass
@dataclass(frozen=True)
class StoredMessageAudio:
file_key: str
public_url: str
class MessageAudioStorageService:
def __init__(self) -> None:
self._client = None
def _assert_ready(self) -> None:
if CosConfig is None or CosS3Client is None:
raise MessageAudioStorageError("COS SDK is not installed")
required_pairs = {
"COS_SECRET_ID": settings.cos_secret_id,
"COS_SECRET_KEY": settings.cos_secret_key,
"COS_REGION": settings.cos_region,
"COS_BUCKET_MESSAGE": settings.cos_bucket_message,
}
missing = [key for key, value in required_pairs.items() if not value]
if missing:
raise MessageAudioStorageError(f"missing COS message config: {', '.join(missing)}")
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 _build_key(self, *, device_id: str, extension: str) -> str:
prefix = settings.cos_message_prefix.strip("/") or "messages/audio"
now = datetime.now(UTC)
return (
f"{prefix}/{device_id}/{now.strftime('%Y/%m/%d')}/"
f"{uuid4().hex}.{extension}"
)
def _build_public_url(self, *, file_key: str) -> str:
base_url = settings.cos_public_base_url.strip().rstrip("/")
if not base_url:
base_url = f"https://{settings.cos_bucket_message}.cos.{settings.cos_region}.myqcloud.com"
return f"{base_url}/{file_key.lstrip('/')}"
def _normalize_file_key(self, file_key_or_url: str) -> str:
value = (file_key_or_url or "").strip()
if not value:
raise MessageAudioStorageError("audio file key is required")
if value.startswith("http://") or value.startswith("https://"):
parsed = urlparse(value)
path = parsed.path.lstrip("/")
if not path:
raise MessageAudioStorageError("audio file key is invalid")
return path
return value.lstrip("/")
async def upload_audio(
self,
*,
device_id: str,
content: bytes,
content_type: str = "audio/mpeg",
extension: str = "mp3",
) -> StoredMessageAudio:
self._assert_ready()
if not content:
raise MessageAudioStorageError("audio content is empty")
file_key = self._build_key(device_id=device_id, extension=extension)
await asyncio.to_thread(
self._upload_audio_sync,
file_key=file_key,
content=content,
content_type=content_type,
)
return StoredMessageAudio(
file_key=file_key,
public_url=self._build_public_url(file_key=file_key),
)
async def get_audio_url(self, file_key_or_url: str) -> str:
self._assert_ready()
normalized_key = self._normalize_file_key(file_key_or_url)
return await asyncio.to_thread(
self._get_client().get_presigned_url,
Bucket=settings.cos_bucket_message,
Key=normalized_key,
Method="GET",
Expired=settings.cos_avatar_url_expire_seconds,
)
def _upload_audio_sync(
self,
*,
file_key: str,
content: bytes,
content_type: str,
) -> None:
self._get_client().put_object(
Bucket=settings.cos_bucket_message,
Body=content,
Key=file_key,
ContentType=content_type,
EnableMD5=False,
)