add test-clean code merge
This commit is contained in:
@@ -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}
|
||||
|
||||
@@ -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()
|
||||
|
||||
427
talkingq-url/banban/service/device_voice_archive.py
Normal file
427
talkingq-url/banban/service/device_voice_archive.py
Normal 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()
|
||||
@@ -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,
|
||||
*,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
*,
|
||||
|
||||
Reference in New Issue
Block a user