家长端支持语音留言发送

This commit is contained in:
stu2not
2026-05-05 16:32:31 +08:00
parent 12a7a3a116
commit 2dba5bcf7c
7 changed files with 415 additions and 56 deletions

View File

@@ -178,11 +178,18 @@ class ImDAO(BaseDAO):
participant_b_id = str(high_id)
return participant_a_id, participant_b_id, f"{participant_a_id}:{participant_b_id}"
async def _build_preview(self, content_type: int, content_text: str | None) -> str:
async def _build_preview(
self,
content_type: int,
content_text: str | None,
ext_json: dict[str, Any] | None = None,
) -> str:
if content_type == 1:
return (content_text or "").strip()[:255]
if content_type == 2:
return "[audio]"
if (ext_json or {}).get("message_kind") == "leave_message":
return "[留言]"
return "[语音]"
if content_type == 3:
return "[image]"
return "[json]"
@@ -239,7 +246,11 @@ class ImDAO(BaseDAO):
return conversation_id, True
now_sql = "CURRENT_TIMESTAMP(3)"
preview = await self._build_preview(payload.content_type, payload.content_text)
preview = await self._build_preview(
payload.content_type,
payload.content_text,
payload.ext_json,
)
try:
conversation_row = await self._get_conversation_by_id(conversation_id=conversation_id, lock=True)

View File

@@ -3,7 +3,7 @@ import logging
from collections.abc import Mapping
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response, status
from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, Request, Response, UploadFile, status
from sqlalchemy import text
try:
@@ -442,6 +442,64 @@ async def create_child_message_for_parent(
)
@router.post(
"/{child_id}/voice-message",
response_model=ConversationMessageCreateResponse,
status_code=status.HTTP_201_CREATED,
)
async def create_child_voice_message_for_parent(
child_id: int,
request: Request,
response: Response,
file: UploadFile = File(...),
duration_ms: int = Form(..., ge=0),
client_msg_id: str = Form(..., min_length=1, max_length=64),
transcript_text: str | None = Form(default=None, max_length=1000),
current_user_id: int = Depends(get_current_user_id),
) -> ConversationMessageCreateResponse:
try:
content = await file.read()
result = await im_service.create_parent_child_voice_message(
parent_user_id=current_user_id,
child_id=child_id,
filename=file.filename,
content_type=file.content_type,
content=content,
media_duration_ms=duration_ms,
media_transcript_text=transcript_text,
client_msg_id=client_msg_id,
ext_json={
"message_kind": "leave_message",
"source": "parent_weapp_voice",
},
)
finally:
await file.close()
if result.idempotent:
response.status_code = status.HTTP_200_OK
logger.info(
"parent child voice message created",
extra={
"event": "parent_child_voice_message_create",
"request_id": getattr(request.state, "request_id", None),
"user_id": current_user_id,
"child_id": child_id,
"conversation_id": result.conversation_id,
"idempotent": result.idempotent,
},
)
return ConversationMessageCreateResponse(
idempotent=result.idempotent,
conversation_id=result.conversation_id,
conversation_type=result.conversation_type,
conversation_type_name=result.conversation_type_name,
message=result.message,
)
@router.get(
"/{child_id}/conversations/{conversation_id}/messages",
response_model=ChildConversationMessageListResponse,

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

@@ -113,6 +113,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,
*,