add test-clean code merge

This commit is contained in:
HycJack
2026-05-06 03:33:28 +08:00
parent bb87bff0d3
commit 326e4bac28
31 changed files with 3275 additions and 75 deletions

View File

@@ -24,6 +24,13 @@ class BindingService(DatabaseServiceBase):
def __init__(self):
super().__init__(service_name="binding_service")
async def _ensure_device_unbound(self, db_session, device_id: str) -> None:
dao = BindingDAO(db_session)
active_binding = await dao.get_active_binding_by_device(device_id)
if active_binding is None:
return
raise BindingError("device is already bound, unbind it before binding again", status_code=409)
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)
@@ -50,6 +57,7 @@ class BindingService(DatabaseServiceBase):
db_session = await self.get_session()
try:
await self._ensure_bindable_device(db_session, device_id, serial_number)
await self._ensure_device_unbound(db_session, device_id)
dao = BindingDAO(db_session)
bind_token, expires_at = await dao.start_bind(user_id, device_id, child_id)
await db_session.commit()
@@ -188,6 +196,7 @@ class BindingService(DatabaseServiceBase):
db_session = await self.get_session()
try:
await self._ensure_bindable_device(db_session, device_id, serial_number)
await self._ensure_device_unbound(db_session, device_id)
dao = BindingDAO(db_session)
await dao.direct_bind(device_id, child_id, user_id)
await db_session.commit()
@@ -201,6 +210,9 @@ class BindingService(DatabaseServiceBase):
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:
active_binding = await dao.get_active_binding_by_device(device_id)
if active_binding and active_binding["child_id"] is not None:
raise BindingError("device is already bound, unbind it before binding again", status_code=409)
raise ValueError("binding not found")
await db_session.commit()
return {"device_id": device_id, "child_id": child_id}

View File

@@ -2,6 +2,7 @@ from collections.abc import Mapping
from typing import Any, List
from services.database_service_base import DatabaseServiceBase
from fastapi import HTTPException
from banban.dao.device import DeviceDAO
@@ -38,6 +39,35 @@ class DeviceService(DatabaseServiceBase):
finally:
await db_session.close()
async def get_device_status(
self,
*,
device_id: str,
user_id: int,
) -> Mapping[str, Any]:
db_session = await self.get_session()
try:
dao = DeviceDAO(db_session)
return await dao.get_device_status(device_id=device_id, user_id=user_id)
finally:
await db_session.close()
async def set_device_volume(
self,
*,
device_id: str,
user_id: int,
level: int,
) -> str:
await self.ensure_device_access(device_id=device_id, user_id=user_id)
from handlers.mqtt_handler import TalkingQMQTTService
service = await TalkingQMQTTService.get_instance()
if service is None:
raise HTTPException(status_code=503, detail="MQTT 服务未初始化")
return await service.send_volume_command(device_id, level)
# 创建全局 DeviceService 实例
device_service = DeviceService()
device_service = DeviceService()

View File

@@ -0,0 +1,427 @@
import asyncio
import os
import shutil
from dataclasses import dataclass
from uuid import uuid4
from sqlalchemy import text
from banban.dao.im import ImDAO
from banban.schemas.im import DeviceMessageCreateRequest
from banban.service.message_audio_storage import (
MessageAudioStorageError,
message_audio_storage_service,
)
from config import settings
from services.database_service_base import DatabaseServiceBase
from utils.audio_format import (
DEFAULT_CHANNELS,
DEFAULT_SAMPLE_RATE,
DEFAULT_SAMPLE_WIDTH,
detect_audio_format,
wrap_pcm_as_wav,
)
from utils.logger import session_logger
CHILD_PARTICIPANT_TYPE = 2
CHILD_PEER_CONVERSATION_TYPE = 1
@dataclass(frozen=True)
class PreparedArchiveAudio:
filepath: str
mime_type: str
size_bytes: int
source_format: str
archive_format: str
class DeviceVoiceArchiveService(DatabaseServiceBase):
def __init__(self) -> None:
super().__init__(service_name="device_voice_archive")
async def archive_peer_voice_message(
self,
*,
sender_device_id: str,
receiver_device_id: str,
local_audio_path: str,
) -> bool:
archive_id = f"voice_archive:{uuid4().hex[:12]}"
session_logger.info(
sender_device_id,
archive_id,
(
"开始归档设备语音消息: "
f"sender_device_id={sender_device_id}, receiver_device_id={receiver_device_id}, "
f"local_audio_path={local_audio_path}"
),
)
if not local_audio_path or not os.path.exists(local_audio_path):
session_logger.warning(
sender_device_id,
archive_id,
f"跳过语音归档: 本地音频文件不存在, local_audio_path={local_audio_path}",
)
return False
prepared_audio = None
db_session = await self.get_session()
try:
prepared_audio = await self._prepare_archive_audio(
sender_device_id=sender_device_id,
archive_id=archive_id,
local_audio_path=local_audio_path,
)
if prepared_audio is None:
return False
dao = ImDAO(db_session)
sender_identity = await self._get_child_identity_by_device_id(
db_session=db_session,
device_id=sender_device_id,
)
receiver_identity = await self._get_child_identity_by_device_id(
db_session=db_session,
device_id=receiver_device_id,
)
if sender_identity is None:
session_logger.warning(
sender_device_id,
archive_id,
f"跳过语音归档: 发送设备未绑定有效 child, device_id={sender_device_id}",
)
return False
if receiver_identity is None:
session_logger.warning(
sender_device_id,
archive_id,
f"跳过语音归档: 接收设备未绑定有效 child, device_id={receiver_device_id}",
)
return False
if sender_identity["child_id"] == receiver_identity["child_id"]:
session_logger.warning(
sender_device_id,
archive_id,
(
"跳过语音归档: 发送和接收设备映射到了同一个 child, "
f"child_id={sender_identity['child_id']}"
),
)
return False
session_logger.info(
sender_device_id,
archive_id,
(
"设备绑定解析完成: "
f"sender_child_id={sender_identity['child_id']}, "
f"sender_child_name={sender_identity['child_name'] or 'unknown'}, "
f"receiver_child_id={receiver_identity['child_id']}, "
f"receiver_child_name={receiver_identity['child_name'] or 'unknown'}"
),
)
with open(prepared_audio.filepath, "rb") as archive_file:
archive_audio_data = archive_file.read()
session_logger.info(
sender_device_id,
archive_id,
(
"开始上传语音到 COS: "
f"bucket={settings.cos_bucket_message or 'unset'}, "
f"media_mime_type={prepared_audio.mime_type}, "
f"audio_size_bytes={prepared_audio.size_bytes}, "
f"archive_format={prepared_audio.archive_format}"
),
)
stored_audio = await message_audio_storage_service.upload_audio(
sender_device_id=sender_device_id,
receiver_device_id=receiver_device_id,
content=archive_audio_data,
content_type=prepared_audio.mime_type,
)
session_logger.info(
sender_device_id,
archive_id,
f"COS 上传成功: file_key={stored_audio.file_key}",
)
participant_a_id, participant_b_id, pair_key = dao._build_child_peer_pair(
int(sender_identity["child_id"]),
int(receiver_identity["child_id"]),
)
client_msg_id = f"device_voice_{uuid4().hex[:24]}"
payload = DeviceMessageCreateRequest(
conversation_type=CHILD_PEER_CONVERSATION_TYPE,
peer_child_id=int(receiver_identity["child_id"]),
content_type=2,
media_file_key=stored_audio.file_key,
media_mime_type=prepared_audio.mime_type,
media_size_bytes=prepared_audio.size_bytes,
client_msg_id=client_msg_id,
ext_json={
"archive_source": "device_voice",
"sender_device_id": sender_device_id,
"receiver_device_id": receiver_device_id,
"local_audio_path": local_audio_path,
"archive_format": prepared_audio.archive_format,
"source_format": prepared_audio.source_format,
},
)
session_logger.info(
sender_device_id,
archive_id,
(
"开始写入 IM 消息: "
f"conversation_type={CHILD_PEER_CONVERSATION_TYPE}, "
f"pair_key={pair_key}, client_msg_id={client_msg_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(sender_identity["child_id"]),
receiver_type=CHILD_PARTICIPANT_TYPE,
receiver_id=str(receiver_identity["child_id"]),
sender_name_snapshot=sender_identity["child_name"],
sender_avatar_snapshot=None,
receiver_name_snapshot=receiver_identity["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=client_msg_id,
)
session_logger.info(
sender_device_id,
archive_id,
(
"IM 消息写入完成: "
f"conversation_id={conversation_id}, "
f"message_id={message_row['id'] if message_row else 'unknown'}, "
f"seq={message_row['seq'] if message_row else 'unknown'}, "
f"idempotent={idempotent}"
),
)
session_logger.info(
sender_device_id,
archive_id,
(
"设备语音归档成功: "
f"sender_device_id={sender_device_id}, "
f"receiver_device_id={receiver_device_id}, "
f"conversation_id={conversation_id}, "
f"file_key={stored_audio.file_key}, "
f"archive_format={prepared_audio.archive_format}"
),
)
return True
except MessageAudioStorageError as exc:
session_logger.error(
sender_device_id,
archive_id,
f"设备语音归档失败: COS 上传异常: {exc}",
)
return False
except Exception as exc:
session_logger.error(
sender_device_id,
archive_id,
f"设备语音归档失败: {exc}",
exc_info=True,
)
return False
finally:
if prepared_audio and prepared_audio.archive_format == "mp3" and prepared_audio.filepath != local_audio_path:
if os.path.exists(prepared_audio.filepath):
os.remove(prepared_audio.filepath)
await db_session.close()
async def _prepare_archive_audio(
self,
*,
sender_device_id: str,
archive_id: str,
local_audio_path: str,
) -> PreparedArchiveAudio | None:
with open(local_audio_path, "rb") as local_file:
local_audio_data = local_file.read()
source_format = detect_audio_format(local_audio_data)
local_size = len(local_audio_data)
session_logger.info(
sender_device_id,
archive_id,
(
"异步归档开始判断音频格式: "
f"local_audio_path={local_audio_path}, source_format={source_format}, "
f"local_size_bytes={local_size}"
),
)
if source_format == "mp3":
session_logger.info(
sender_device_id,
archive_id,
"异步归档判断结果: 已是 MP3无需转码",
)
return PreparedArchiveAudio(
filepath=local_audio_path,
mime_type="audio/mpeg",
size_bytes=local_size,
source_format=source_format,
archive_format="mp3",
)
wav_path = f"{local_audio_path}.archive.wav"
mp3_path = f"{local_audio_path}.archive.mp3"
try:
if source_format == "wav":
with open(wav_path, "wb") as wav_file:
wav_file.write(local_audio_data)
session_logger.info(
sender_device_id,
archive_id,
f"异步归档检测到 WAV准备转 MP3: wav_path={wav_path}",
)
else:
wrapped_wav = wrap_pcm_as_wav(
local_audio_data,
sample_rate=DEFAULT_SAMPLE_RATE,
channels=DEFAULT_CHANNELS,
sample_width=DEFAULT_SAMPLE_WIDTH,
)
with open(wav_path, "wb") as wav_file:
wav_file.write(wrapped_wav)
session_logger.info(
sender_device_id,
archive_id,
(
"异步归档将原始字节封装为 WAV: "
f"wav_path={wav_path}, sample_rate={DEFAULT_SAMPLE_RATE}, "
f"channels={DEFAULT_CHANNELS}, sample_width={DEFAULT_SAMPLE_WIDTH}"
),
)
await self._convert_to_mp3(
sender_device_id=sender_device_id,
archive_id=archive_id,
source_path=wav_path,
target_path=mp3_path,
)
mp3_size = os.path.getsize(mp3_path)
session_logger.info(
sender_device_id,
archive_id,
(
"异步归档转码完成: "
f"mp3_path={mp3_path}, mp3_size_bytes={mp3_size}, "
f"source_format={source_format}"
),
)
return PreparedArchiveAudio(
filepath=mp3_path,
mime_type="audio/mpeg",
size_bytes=mp3_size,
source_format=source_format,
archive_format="mp3",
)
finally:
if os.path.exists(wav_path):
os.remove(wav_path)
async def _convert_to_mp3(
self,
*,
sender_device_id: str,
archive_id: str,
source_path: str,
target_path: str,
) -> None:
ffmpeg_path = shutil.which("ffmpeg")
if not ffmpeg_path:
raise RuntimeError("ffmpeg not found in PATH")
command = [
ffmpeg_path,
"-y",
"-loglevel",
"error",
"-i",
source_path,
"-codec:a",
"libmp3lame",
"-b:a",
"32k",
target_path,
]
session_logger.info(
sender_device_id,
archive_id,
f"异步归档开始转码为 MP3: command={' '.join(command)}",
)
process = await asyncio.create_subprocess_exec(
*command,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, stderr = await process.communicate()
if process.returncode != 0:
stderr_text = stderr.decode("utf-8", errors="ignore").strip()
raise RuntimeError(
f"ffmpeg convert failed, returncode={process.returncode}, stderr={stderr_text}"
)
session_logger.info(
sender_device_id,
archive_id,
(
"异步归档 FFmpeg 转码完成: "
f"source_path={source_path}, target_path={target_path}, "
f"ffmpeg_stdout={stdout.decode('utf-8', errors='ignore').strip() or 'empty'}"
),
)
async def _get_child_identity_by_device_id(self, *, db_session, device_id: str):
result = await db_session.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.is_active = 1
LIMIT 1
"""
),
{"device_id": device_id},
)
row = result.mappings().first()
if not row or row["child_id"] is None:
return None
return {
"device_id": str(row["device_id"]),
"child_id": int(row["child_id"]),
"child_name": row["child_name"],
}
device_voice_archive_service = DeviceVoiceArchiveService()

View File

@@ -2,6 +2,7 @@ from dataclasses import dataclass
import hashlib
import json
from collections.abc import Mapping
from pathlib import Path
from typing import Any
from fastapi import HTTPException
@@ -36,6 +37,25 @@ PARTICIPANT_TYPE_NAMES = {
CHILD_PARTICIPANT_TYPE: "child",
}
_AUDIO_CONTENT_TYPE_TO_EXT = {
"audio/mpeg": "mp3",
"audio/mp3": "mp3",
"audio/aac": "aac",
"audio/wav": "wav",
"audio/x-wav": "wav",
"audio/x-m4a": "m4a",
"audio/mp4": "m4a",
"audio/webm": "webm",
"application/octet-stream": "mp3",
}
_AUDIO_EXTENSION_ALIASES = {
".mp3": "mp3",
".aac": "aac",
".m4a": "m4a",
".wav": "wav",
".webm": "webm",
}
def conversation_type_name(conversation_type: int) -> str:
return CONVERSATION_TYPE_NAMES.get(conversation_type, f"unknown_{conversation_type}")
@@ -66,6 +86,20 @@ def normalize_content_json(value: Any) -> dict[str, Any] | None:
return None
def normalize_audio_extension(*, filename: str | None, content_type: str | None) -> tuple[str, str]:
normalized_content_type = (content_type or "").strip().lower() or "audio/mpeg"
if normalized_content_type in _AUDIO_CONTENT_TYPE_TO_EXT:
return _AUDIO_CONTENT_TYPE_TO_EXT[normalized_content_type], normalized_content_type
suffix = Path(filename or "").suffix.lower()
if suffix in _AUDIO_EXTENSION_ALIASES:
extension = _AUDIO_EXTENSION_ALIASES[suffix]
fallback_content_type = "audio/m4a" if extension == "m4a" else f"audio/{extension}"
return extension, fallback_content_type
raise HTTPException(status_code=415, detail="unsupported audio file type")
def row_to_message_item(row: Mapping[str, Any]) -> ChildConversationMessageItem:
return ChildConversationMessageItem(
id=int(row["id"]),
@@ -201,6 +235,58 @@ class ImService(DatabaseServiceBase):
finally:
await db_session.close()
async def create_parent_child_voice_message(
self,
*,
parent_user_id: int,
child_id: int,
filename: str | None,
content_type: str | None,
content: bytes,
media_duration_ms: int | None,
media_transcript_text: str | None,
client_msg_id: str,
ext_json: dict[str, Any] | None = None,
) -> ConversationMessageCreateResult:
if not content:
raise HTTPException(status_code=400, detail="audio file is empty")
extension, normalized_content_type = normalize_audio_extension(
filename=filename,
content_type=content_type,
)
stored_audio = await self.audio_storage.upload_audio(
device_id=f"parent-{parent_user_id}",
content=content,
content_type=normalized_content_type,
extension=extension,
)
payload = ParentChildMessageCreateRequest(
content_type=2,
media_file_key=stored_audio.file_key,
media_duration_ms=media_duration_ms,
media_mime_type=normalized_content_type,
media_size_bytes=len(content),
media_transcript_text=(media_transcript_text or "").strip() or None,
client_msg_id=client_msg_id,
ext_json=ext_json,
)
try:
result = await self.create_parent_child_message(
parent_user_id=parent_user_id,
child_id=child_id,
payload=payload,
)
except Exception:
try:
await self.audio_storage.delete_audio(stored_audio.file_key)
except MessageAudioStorageError:
pass
raise
return result
async def _create_device_message_with_payload(
self,
*,

View File

@@ -89,7 +89,7 @@ class LocationService(DatabaseServiceBase):
db_session = await self.get_session()
try:
dao = LocationDAO(db_session)
current_row = await dao.get_device_current_location_by_device_id(device_id=device_id)
current_row = await dao.get_current_location_by_device_id(device_id=device_id)
if current_row:
await dao.update(device_id=device_id, latitude=location.lat, longitude=location.lng)
# 更新成功加到历史记录表
@@ -98,5 +98,63 @@ class LocationService(DatabaseServiceBase):
finally:
await db_session.close()
async def report_mqtt_device_location(
self,
*,
device_id: str,
latitude: float | None,
longitude: float | None,
coord_type: str | None = None,
accuracy_m: int | None = None,
altitude_m: float | None = None,
speed_mps: float | None = None,
heading_deg: int | None = None,
source: int | None = None,
battery_pct: int | None = None,
device_time: datetime | None = None,
) -> Mapping[str, Any] | None:
if latitude is None or longitude is None:
return None
@dataclass(frozen=True)
class _MQTTLocationPayload:
coord_type: str
lat: float
lng: float
accuracy_m: int | None
altitude_m: float | None
speed_mps: float | None
heading_deg: int | None
source: int
battery_pct: int | None
device_time: datetime
db_session = await self.get_session()
try:
dao = LocationDAO(db_session)
binding_row = await dao.get_active_binding_by_device(device_id=device_id)
if not binding_row or binding_row["child_id"] is None:
return None
payload = _MQTTLocationPayload(
coord_type=(coord_type or "gcj02").strip() or "gcj02",
lat=float(latitude),
lng=float(longitude),
accuracy_m=accuracy_m,
altitude_m=altitude_m,
speed_mps=speed_mps,
heading_deg=heading_deg,
source=source if source is not None else 0,
battery_pct=battery_pct,
device_time=device_time or datetime.now(),
)
return await dao.report_device_location(
device_id=device_id,
child_id=int(binding_row["child_id"]),
payload=payload,
)
finally:
await db_session.close()
# 创建全局 LocationService 实例
location_service = LocationService()

View File

@@ -119,6 +119,15 @@ class MessageAudioStorageService:
Expired=settings.cos_avatar_url_expire_seconds,
)
async def delete_audio(self, file_key_or_url: str) -> None:
self._assert_ready()
normalized_key = self._normalize_file_key(file_key_or_url)
await asyncio.to_thread(
self._get_client().delete_object,
Bucket=settings.cos_bucket_message,
Key=normalized_key,
)
def _upload_audio_sync(
self,
*,