合入音频支持http和https兼容,后端支持角色切换

This commit is contained in:
HycJack
2026-05-18 19:56:32 +08:00
parent f30c1b0679
commit d93bba10b9
17 changed files with 613 additions and 126 deletions

View File

@@ -43,6 +43,26 @@ class DeviceService(DatabaseServiceBase):
finally:
await db_session.close()
async def list_device_ai_conversations(
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_ai_conversations(
device_id=device_id,
cursor=cursor,
limit=limit,
)
finally:
await db_session.close()
async def get_device_status(
self,
*,

View File

@@ -0,0 +1,174 @@
import asyncio
import hashlib
import mimetypes
from pathlib import Path
from urllib.parse import urlparse
from urllib.request import Request, urlopen
from banban.service.message_audio_storage import (
MessageAudioStorageError,
MessageAudioStorageService,
)
from config import settings
from utils.logger import session_logger
_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",
}
class DeviceAudioCacheError(Exception):
pass
class DeviceAudioCacheService:
def __init__(self, audio_storage: MessageAudioStorageService | None = None) -> None:
self.audio_storage = audio_storage or MessageAudioStorageService()
def cache_dir(self) -> Path:
return Path(settings.device_audio_cache_dir)
def public_base_url(self) -> str:
configured = settings.device_audio_public_base_url.strip().rstrip("/")
if configured:
return configured
return f"http://{settings.server_host}:{settings.server_port}/device-audio"
async def get_device_audio_url(
self,
file_key_or_url: str,
*,
device_id: str | None = None,
) -> str:
source = (file_key_or_url or "").strip()
if not source:
raise DeviceAudioCacheError("audio source is required")
if source.startswith("http://"):
return source
cache_key = hashlib.sha256(source.encode("utf-8")).hexdigest()
cached = self._find_cached_file(cache_key)
if cached is not None:
return self._public_url(cached.name)
download_url = await self._resolve_download_url(source)
content, content_type = await asyncio.to_thread(self._download_audio, download_url)
extension = self._resolve_extension(source=source, content_type=content_type)
target_path = self.cache_dir() / f"{cache_key}.{extension}"
target_path.parent.mkdir(parents=True, exist_ok=True)
target_path.write_bytes(content)
session_logger.info(
device_id or "",
"device_audio",
f"cached device audio: source={source} file={target_path}",
)
return self._public_url(target_path.name)
async def save_device_audio(
self,
content: bytes,
*,
device_id: str,
extension: str = "mp3",
) -> tuple[str, Path]:
if not content:
raise DeviceAudioCacheError("audio content is empty")
normalized_extension = extension.strip().lower().lstrip(".") or "mp3"
if normalized_extension not in {"mp3", "aac", "m4a", "wav", "webm"}:
normalized_extension = "mp3"
digest = hashlib.sha256(
f"{device_id}:".encode("utf-8") + content
).hexdigest()
target_path = self.cache_dir() / f"{digest}.{normalized_extension}"
target_path.parent.mkdir(parents=True, exist_ok=True)
await asyncio.to_thread(target_path.write_bytes, content)
session_logger.info(
device_id,
"device_audio",
f"saved device audio cache: file={target_path}",
)
return self._public_url(target_path.name), target_path
async def _resolve_download_url(self, source: str) -> str:
if source.startswith("https://"):
if not self._is_allowed_https_source(source):
raise DeviceAudioCacheError("unsupported https audio source")
return source
try:
return await self.audio_storage.get_audio_url(source)
except MessageAudioStorageError as exc:
raise DeviceAudioCacheError(str(exc)) from exc
def _is_allowed_https_source(self, source: str) -> bool:
parsed = urlparse(source)
if not parsed.scheme == "https" or not parsed.netloc:
return False
configured_base = settings.cos_public_base_url.strip()
if configured_base:
configured_host = urlparse(configured_base).netloc
if configured_host and parsed.netloc == configured_host:
return True
bucket = settings.cos_bucket_message.strip()
region = settings.cos_region.strip()
if bucket and region and parsed.netloc == f"{bucket}.cos.{region}.myqcloud.com":
return True
return False
def _download_audio(self, url: str) -> tuple[bytes, str | None]:
request = Request(url, headers={"User-Agent": "banban-device-audio-cache/1.0"})
with urlopen(request, timeout=15) as response:
content_type = response.headers.get("Content-Type")
content_length = response.headers.get("Content-Length")
if content_length and int(content_length) > settings.device_audio_max_bytes:
raise DeviceAudioCacheError("audio file is too large")
content = response.read(settings.device_audio_max_bytes + 1)
if len(content) > settings.device_audio_max_bytes:
raise DeviceAudioCacheError("audio file is too large")
if not content:
raise DeviceAudioCacheError("audio file is empty")
return content, content_type
def _find_cached_file(self, cache_key: str) -> Path | None:
cache_dir = self.cache_dir()
if not cache_dir.exists():
return None
matches = list(cache_dir.glob(f"{cache_key}.*"))
if not matches:
return None
return matches[0]
def _resolve_extension(self, *, source: str, content_type: str | None) -> str:
normalized_content_type = (content_type or "").split(";", 1)[0].strip().lower()
if normalized_content_type in _AUDIO_CONTENT_TYPE_TO_EXT:
return _AUDIO_CONTENT_TYPE_TO_EXT[normalized_content_type]
suffix = Path(urlparse(source).path).suffix.lower().lstrip(".")
if suffix in {"mp3", "aac", "m4a", "wav", "webm"}:
return suffix
guessed = mimetypes.guess_extension(normalized_content_type or "")
if guessed:
extension = guessed.lstrip(".")
if extension in {"mp3", "aac", "m4a", "wav", "webm"}:
return extension
return "mp3"
def _public_url(self, filename: str) -> str:
return f"{self.public_base_url()}/{filename}"
device_audio_cache_service = DeviceAudioCacheService()

View File

@@ -125,8 +125,10 @@ class DeviceVoiceArchiveService(DatabaseServiceBase):
),
)
with open(prepared_audio.filepath, "rb") as archive_file:
archive_audio_data = archive_file.read()
archive_audio_data = await asyncio.to_thread(
self._read_file_bytes,
prepared_audio.filepath,
)
session_logger.info(
sender_device_id,
@@ -245,7 +247,7 @@ class DeviceVoiceArchiveService(DatabaseServiceBase):
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 asyncio.to_thread(os.remove, prepared_audio.filepath)
await db_session.close()
async def _prepare_archive_audio(
@@ -255,8 +257,10 @@ class DeviceVoiceArchiveService(DatabaseServiceBase):
archive_id: str,
local_audio_path: str,
) -> PreparedArchiveAudio | None:
with open(local_audio_path, "rb") as local_file:
local_audio_data = local_file.read()
local_audio_data = await asyncio.to_thread(
self._read_file_bytes,
local_audio_path,
)
source_format = detect_audio_format(local_audio_data)
local_size = len(local_audio_data)
@@ -288,8 +292,11 @@ class DeviceVoiceArchiveService(DatabaseServiceBase):
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)
await asyncio.to_thread(
self._write_file_bytes,
wav_path,
local_audio_data,
)
session_logger.info(
sender_device_id,
archive_id,
@@ -302,8 +309,11 @@ class DeviceVoiceArchiveService(DatabaseServiceBase):
channels=DEFAULT_CHANNELS,
sample_width=DEFAULT_SAMPLE_WIDTH,
)
with open(wav_path, "wb") as wav_file:
wav_file.write(wrapped_wav)
await asyncio.to_thread(
self._write_file_bytes,
wav_path,
wrapped_wav,
)
session_logger.info(
sender_device_id,
archive_id,
@@ -320,7 +330,7 @@ class DeviceVoiceArchiveService(DatabaseServiceBase):
source_path=wav_path,
target_path=mp3_path,
)
mp3_size = os.path.getsize(mp3_path)
mp3_size = await asyncio.to_thread(os.path.getsize, mp3_path)
session_logger.info(
sender_device_id,
archive_id,
@@ -339,7 +349,7 @@ class DeviceVoiceArchiveService(DatabaseServiceBase):
)
finally:
if os.path.exists(wav_path):
os.remove(wav_path)
await asyncio.to_thread(os.remove, wav_path)
async def _convert_to_mp3(
self,
@@ -423,5 +433,13 @@ class DeviceVoiceArchiveService(DatabaseServiceBase):
"child_name": row["child_name"],
}
def _read_file_bytes(self, filepath: str) -> bytes:
with open(filepath, "rb") as file:
return file.read()
def _write_file_bytes(self, filepath: str, content: bytes) -> None:
with open(filepath, "wb") as file:
file.write(content)
device_voice_archive_service = DeviceVoiceArchiveService()

View File

@@ -7,6 +7,7 @@ from typing import Any
from services.offline_audio_cache import offline_audio_cache
from fastapi import HTTPException
from services.database_service_base import DatabaseServiceBase
from banban.service.device_audio_cache import device_audio_cache_service
from banban.service.message_audio_storage import MessageAudioStorageService, MessageAudioStorageError
from banban.service.binding import BindingService
try:
@@ -19,7 +20,6 @@ try:
except ModuleNotFoundError:
from banban.dao.im import ImDAO, DeviceIdentity, ConversationMessageCreateResult
from banban.schemas.im import ChildConversationMessageItem, DeviceMessageCreateRequest, ParentChildMessageCreateRequest
from handlers.audio_file_handler import message_audio_storage_service
from utils.logger import session_logger
@@ -152,6 +152,28 @@ async def present_message_item(
return item
async def present_device_message_item(
row: Mapping[str, Any],
*,
device_id: str,
) -> ChildConversationMessageItem:
item = row_to_message_item(row)
if item.content_type == 2 and item.media_file_key:
try:
item.media_file_key = await device_audio_cache_service.get_device_audio_url(
item.media_file_key,
device_id=device_id,
)
except Exception as exc:
session_logger.error(
device_id,
"device_audio",
f"failed to prepare device audio url: {exc}",
exc_info=True,
)
return item
class ImService(DatabaseServiceBase):
def __init__(self):
super().__init__(service_name="im_service")
@@ -292,9 +314,12 @@ class ImService(DatabaseServiceBase):
binding_service = BindingService()
device = await binding_service.get_current_binding(parent_user_id)
try:
audio_url = await message_audio_storage_service.get_audio_url(stored_audio.file_key)
audio_url = await device_audio_cache_service.get_device_audio_url(
stored_audio.file_key,
device_id=device.device_id,
)
except Exception:
session_logger.error(device.device_id, "audio", f"failed to get audio url: {stored_audio.file_key}", exc_info=True)
session_logger.error(device.device_id, "audio", f"failed to get device audio url: {stored_audio.file_key}", exc_info=True)
audio_url = stored_audio.file_key
await offline_audio_cache.add_audio_url(device.device_id, f"{audio_url}")
except Exception:

View File

@@ -109,7 +109,7 @@ class LocationService(DatabaseServiceBase):
altitude_m: float | None = None,
speed_mps: float | None = None,
heading_deg: int | None = None,
source: int | None = None,
source: int | str | None = None,
battery_pct: int | None = None,
device_time: datetime | None = None,
) -> Mapping[str, Any] | None:
@@ -129,6 +129,8 @@ class LocationService(DatabaseServiceBase):
battery_pct: int | None
device_time: datetime
source_value = self._normalize_location_source(source)
db_session = await self.get_session()
try:
dao = LocationDAO(db_session)
@@ -144,7 +146,7 @@ class LocationService(DatabaseServiceBase):
altitude_m=altitude_m,
speed_mps=speed_mps,
heading_deg=heading_deg,
source=source if source is not None else 0,
source=source_value,
battery_pct=battery_pct,
device_time=device_time or datetime.now(),
)
@@ -156,5 +158,25 @@ class LocationService(DatabaseServiceBase):
finally:
await db_session.close()
@staticmethod
def _normalize_location_source(source: int | str | None) -> int:
if source is None:
return 0
if isinstance(source, int):
return source
text = str(source).strip().lower()
if not text:
return 0
if text.isdigit():
return int(text)
return {
"gps": 1,
"wifi": 2,
"cell": 3,
"base_station": 3,
"manual": 4,
"mock": 9,
}.get(text, 0)
# 创建全局 LocationService 实例
location_service = LocationService()