428 lines
15 KiB
Python
428 lines
15 KiB
Python
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()
|