家长端支持语音留言发送
This commit is contained in:
@@ -218,36 +218,41 @@
|
|||||||
border-top: 1px solid #EEEEEE;
|
border-top: 1px solid #EEEEEE;
|
||||||
padding: 18px 20px calc(18px + env(safe-area-inset-bottom));
|
padding: 18px 20px calc(18px + env(safe-area-inset-bottom));
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
flex-direction: column;
|
||||||
gap: 16px;
|
gap: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.composer-input {
|
.composer-tip {
|
||||||
flex: 1;
|
text-align: center;
|
||||||
min-height: 76px;
|
|
||||||
background: #F5F7FA;
|
text {
|
||||||
border-radius: 38px;
|
font-size: 24px;
|
||||||
padding: 0 24px;
|
color: #999999;
|
||||||
font-size: 28px;
|
}
|
||||||
color: #1A1A1A;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.composer-send {
|
.composer-record {
|
||||||
min-width: 132px;
|
width: 100%;
|
||||||
height: 76px;
|
min-height: 88px;
|
||||||
border-radius: 38px;
|
border-radius: 44px;
|
||||||
background: #FF8C42;
|
background: #FF8C42;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
box-shadow: 0 10px 24px rgba(255, 140, 66, 0.2);
|
||||||
|
|
||||||
|
&.recording {
|
||||||
|
background: #FF6A3D;
|
||||||
|
}
|
||||||
|
|
||||||
&.disabled {
|
&.disabled {
|
||||||
opacity: 0.55;
|
opacity: 0.55;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.composer-send-text {
|
.composer-record-text {
|
||||||
font-size: 28px;
|
font-size: 30px;
|
||||||
color: #FFFFFF;
|
color: #FFFFFF;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
letter-spacing: 2px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { View, Text, ScrollView, Image, Input } from '@tarojs/components'
|
import { View, Text, ScrollView, Image } from '@tarojs/components'
|
||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import Taro, { useRouter } from '@tarojs/taro'
|
import Taro, { useRouter } from '@tarojs/taro'
|
||||||
import {
|
import {
|
||||||
getMessages,
|
getMessages,
|
||||||
getCharacter,
|
getCharacter,
|
||||||
sendParentMessage,
|
sendParentVoiceMessage,
|
||||||
ChatMessage,
|
ChatMessage,
|
||||||
Character,
|
Character,
|
||||||
ConversationSource,
|
ConversationSource,
|
||||||
@@ -88,10 +88,15 @@ export default function ChatDetail() {
|
|||||||
})
|
})
|
||||||
const [messages, setMessages] = useState<ChatMessage[]>([])
|
const [messages, setMessages] = useState<ChatMessage[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [inputText, setInputText] = useState('')
|
|
||||||
const [sending, setSending] = useState(false)
|
const [sending, setSending] = useState(false)
|
||||||
|
const [recording, setRecording] = useState(false)
|
||||||
const [playingMessageId, setPlayingMessageId] = useState<number | null>(null)
|
const [playingMessageId, setPlayingMessageId] = useState<number | null>(null)
|
||||||
|
const [recordHint, setRecordHint] = useState('长按开始留言')
|
||||||
const audioContextRef = useRef<Taro.InnerAudioContext | null>(null)
|
const audioContextRef = useRef<Taro.InnerAudioContext | null>(null)
|
||||||
|
const recorderManagerRef = useRef<Taro.RecorderManager | null>(null)
|
||||||
|
const activeConversationIdRef = useRef(initialConversationId)
|
||||||
|
const latestLoadChatDataRef = useRef<(conversationId: number) => Promise<void>>(async () => {})
|
||||||
|
const recordingChildIdRef = useRef<number | null>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const audioContext = Taro.createInnerAudioContext()
|
const audioContext = Taro.createInnerAudioContext()
|
||||||
@@ -114,6 +119,10 @@ export default function ChatDetail() {
|
|||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
activeConversationIdRef.current = activeConversationId
|
||||||
|
}, [activeConversationId])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void loadChatData(activeConversationId)
|
void loadChatData(activeConversationId)
|
||||||
}, [activeConversationId, conversationSource, conversationPeerKind])
|
}, [activeConversationId, conversationSource, conversationPeerKind])
|
||||||
@@ -152,39 +161,139 @@ export default function ChatDetail() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
latestLoadChatDataRef.current = loadChatData
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof Taro.getRecorderManager !== 'function') return
|
||||||
|
|
||||||
|
const recorderManager = Taro.getRecorderManager()
|
||||||
|
recorderManagerRef.current = recorderManager
|
||||||
|
|
||||||
|
recorderManager.onError((error) => {
|
||||||
|
recordingChildIdRef.current = null
|
||||||
|
setRecording(false)
|
||||||
|
setRecordHint('长按开始留言')
|
||||||
|
console.error('[chat-detail] recorder failed:', error)
|
||||||
|
Taro.showToast({ title: '录音失败,请重试', icon: 'none' })
|
||||||
|
})
|
||||||
|
|
||||||
|
recorderManager.onStop(async (result) => {
|
||||||
|
const currentChildId = recordingChildIdRef.current
|
||||||
|
recordingChildIdRef.current = null
|
||||||
|
setRecording(false)
|
||||||
|
setRecordHint('长按开始留言')
|
||||||
|
|
||||||
|
if (!currentChildId) return
|
||||||
|
if (!result.tempFilePath) {
|
||||||
|
Taro.showToast({ title: '录音文件不存在', icon: 'none' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const durationMs = Math.max(0, Number(result.duration || 0))
|
||||||
|
if (durationMs < 800) {
|
||||||
|
Taro.showToast({ title: '留言太短,请重试', icon: 'none' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setSending(true)
|
||||||
|
try {
|
||||||
|
const response = await sendParentVoiceMessage(currentChildId, {
|
||||||
|
filePath: result.tempFilePath,
|
||||||
|
durationMs,
|
||||||
|
})
|
||||||
|
if (response.conversation_id && response.conversation_id !== activeConversationIdRef.current) {
|
||||||
|
setActiveConversationId(response.conversation_id)
|
||||||
|
} else {
|
||||||
|
await latestLoadChatDataRef.current(activeConversationIdRef.current)
|
||||||
|
}
|
||||||
|
Taro.showToast({ title: '留言已发送', icon: 'success' })
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('[chat-detail] voice send failed:', error)
|
||||||
|
Taro.showToast({
|
||||||
|
title: error?.message || '留言发送失败,请重试',
|
||||||
|
icon: 'none',
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
setSending(false)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
recorderManagerRef.current = null
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
const handleBack = () => {
|
const handleBack = () => {
|
||||||
Taro.navigateBack()
|
Taro.navigateBack()
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSend = async () => {
|
const ensureRecordPermission = async (): Promise<boolean> => {
|
||||||
const normalizedContent = inputText.trim()
|
try {
|
||||||
if (!normalizedContent || sending) return
|
const settings = await Taro.getSetting()
|
||||||
|
if (settings.authSetting['scope.record']) return true
|
||||||
|
await Taro.authorize({ scope: 'scope.record' })
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
const modal = await Taro.showModal({
|
||||||
|
title: '需要录音权限',
|
||||||
|
content: '请允许小程序使用麦克风后再留言',
|
||||||
|
confirmText: '去设置',
|
||||||
|
})
|
||||||
|
if (modal.confirm) {
|
||||||
|
await Taro.openSetting()
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRecordStart = async () => {
|
||||||
|
if (sending || recording) return
|
||||||
if (!character.canSend || !character.childId) {
|
if (!character.canSend || !character.childId) {
|
||||||
Taro.showToast({ title: '当前会话不支持发送', icon: 'none' })
|
Taro.showToast({ title: '当前会话不支持发送', icon: 'none' })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
setSending(true)
|
const recorderManager = recorderManagerRef.current
|
||||||
|
if (!recorderManager) {
|
||||||
|
Taro.showToast({ title: '当前环境不支持录音', icon: 'none' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasPermission = await ensureRecordPermission()
|
||||||
|
if (!hasPermission) return
|
||||||
|
|
||||||
|
recordingChildIdRef.current = character.childId
|
||||||
|
setRecording(true)
|
||||||
|
setRecordHint('松开发送留言')
|
||||||
try {
|
try {
|
||||||
const response = await sendParentMessage(character.childId, normalizedContent)
|
recorderManager.start({
|
||||||
setInputText('')
|
duration: 60000,
|
||||||
if (response.conversation_id && response.conversation_id !== activeConversationId) {
|
format: 'mp3',
|
||||||
setActiveConversationId(response.conversation_id)
|
numberOfChannels: 1,
|
||||||
} else {
|
sampleRate: 16000,
|
||||||
await loadChatData(activeConversationId)
|
encodeBitRate: 96000,
|
||||||
}
|
})
|
||||||
Taro.showToast({ title: '已发送', icon: 'success' })
|
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('[chat-detail] send failed:', error)
|
recordingChildIdRef.current = null
|
||||||
|
setRecording(false)
|
||||||
|
setRecordHint('长按开始留言')
|
||||||
|
console.error('[chat-detail] recorder start failed:', error)
|
||||||
Taro.showToast({
|
Taro.showToast({
|
||||||
title: error?.message || '发送失败,请重试',
|
title: '无法开始录音,请重试',
|
||||||
icon: 'none',
|
icon: 'none',
|
||||||
})
|
})
|
||||||
} finally {
|
|
||||||
setSending(false)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleRecordStop = () => {
|
||||||
|
if (!recording) return
|
||||||
|
const recorderManager = recorderManagerRef.current
|
||||||
|
if (!recorderManager) return
|
||||||
|
|
||||||
|
setRecordHint('正在处理留言...')
|
||||||
|
recorderManager.stop()
|
||||||
|
}
|
||||||
|
|
||||||
const handlePlayAudio = (message: ChatMessage) => {
|
const handlePlayAudio = (message: ChatMessage) => {
|
||||||
if (message.contentType !== 2 || !message.mediaUrl) {
|
if (message.contentType !== 2 || !message.mediaUrl) {
|
||||||
Taro.showToast({ title: '语音地址不存在', icon: 'none' })
|
Taro.showToast({ title: '语音地址不存在', icon: 'none' })
|
||||||
@@ -213,10 +322,13 @@ export default function ChatDetail() {
|
|||||||
const renderMessageBody = (msg: ChatMessage) => {
|
const renderMessageBody = (msg: ChatMessage) => {
|
||||||
if (msg.contentType === 2 && msg.mediaUrl) {
|
if (msg.contentType === 2 && msg.mediaUrl) {
|
||||||
const isPlaying = playingMessageId === msg.id
|
const isPlaying = playingMessageId === msg.id
|
||||||
|
const isLeaveMessage = msg.extJson?.message_kind === 'leave_message'
|
||||||
return (
|
return (
|
||||||
<View className={`audio-bubble ${isPlaying ? 'playing' : ''}`} onClick={() => handlePlayAudio(msg)}>
|
<View className={`audio-bubble ${isPlaying ? 'playing' : ''}`} onClick={() => handlePlayAudio(msg)}>
|
||||||
<Text className='audio-bubble-icon'>{isPlaying ? '[]' : '>'}</Text>
|
<Text className='audio-bubble-icon'>{isPlaying ? '[]' : '>'}</Text>
|
||||||
<Text className='audio-bubble-text'>{msg.mediaTranscriptText || '点击播放语音'}</Text>
|
<Text className='audio-bubble-text'>
|
||||||
|
{msg.mediaTranscriptText || (isLeaveMessage ? '点击收听留言' : '点击播放语音')}
|
||||||
|
</Text>
|
||||||
<Text className='audio-bubble-duration'>{formatAudioDuration(msg.mediaDurationMs)}</Text>
|
<Text className='audio-bubble-duration'>{formatAudioDuration(msg.mediaDurationMs)}</Text>
|
||||||
</View>
|
</View>
|
||||||
)
|
)
|
||||||
@@ -250,7 +362,7 @@ export default function ChatDetail() {
|
|||||||
) : messages.length === 0 ? (
|
) : messages.length === 0 ? (
|
||||||
<View className='empty-chat'>
|
<View className='empty-chat'>
|
||||||
<Text className='empty-chat-text'>
|
<Text className='empty-chat-text'>
|
||||||
{character.canSend ? '还没有消息,发送第一条吧' : '这个会话还没有消息'}
|
{character.canSend ? '还没有留言,长按发送第一条吧' : '这个会话还没有消息'}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
) : (
|
) : (
|
||||||
@@ -294,17 +406,18 @@ export default function ChatDetail() {
|
|||||||
|
|
||||||
{character.canSend && character.childId && (
|
{character.canSend && character.childId && (
|
||||||
<View className='composer'>
|
<View className='composer'>
|
||||||
<Input
|
<View className='composer-tip'>
|
||||||
className='composer-input'
|
<Text>{sending ? '正在发送留言...' : recordHint}</Text>
|
||||||
value={inputText}
|
</View>
|
||||||
placeholder='输入要发给孩子的消息'
|
<View
|
||||||
maxlength={500}
|
className={`composer-record ${recording ? 'recording' : ''} ${sending ? 'disabled' : ''}`}
|
||||||
confirmType='send'
|
onLongPress={handleRecordStart}
|
||||||
onInput={(event) => setInputText(event.detail.value)}
|
onTouchEnd={handleRecordStop}
|
||||||
onConfirm={handleSend}
|
onTouchCancel={handleRecordStop}
|
||||||
/>
|
>
|
||||||
<View className={`composer-send ${sending ? 'disabled' : ''}`} onClick={handleSend}>
|
<Text className='composer-record-text'>
|
||||||
<Text className='composer-send-text'>{sending ? '发送中' : '发送'}</Text>
|
{sending ? '发送中...' : recording ? '松开发送' : '按住留言'}
|
||||||
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
|
import Taro from '@tarojs/taro'
|
||||||
import { getCurrentUserId } from './auth'
|
import { getCurrentUserId } from './auth'
|
||||||
import { request } from './api'
|
import { ApiError, BASE_URL, getToken, request } from './api'
|
||||||
import { loadCurrentChildBindingContext } from './binding'
|
import { loadCurrentChildBindingContext } from './binding'
|
||||||
|
import { handleUnauthorized } from './session'
|
||||||
|
|
||||||
export type ConversationSource = 'im' | 'ai'
|
export type ConversationSource = 'im' | 'ai'
|
||||||
export type PeerKind = 'child' | 'parent' | 'ai'
|
export type PeerKind = 'child' | 'parent' | 'ai'
|
||||||
@@ -49,6 +51,7 @@ export interface ChatMessage {
|
|||||||
senderName?: string
|
senderName?: string
|
||||||
receiverName?: string
|
receiverName?: string
|
||||||
channelLabel?: string
|
channelLabel?: string
|
||||||
|
extJson?: Record<string, any> | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Character {
|
export interface Character {
|
||||||
@@ -201,10 +204,18 @@ function formatDetailTime(value?: string | null): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function formatImPreview(
|
function formatImPreview(
|
||||||
item: Pick<ChildConversationMessageItem, 'content_type' | 'content_text' | 'media_transcript_text' | 'content_json'>
|
item: Pick<
|
||||||
|
ChildConversationMessageItem,
|
||||||
|
'content_type' | 'content_text' | 'media_transcript_text' | 'content_json' | 'ext_json'
|
||||||
|
>
|
||||||
): string {
|
): string {
|
||||||
if (item.content_type === 1) return item.content_text || '文本消息'
|
if (item.content_type === 1) return item.content_text || '文本消息'
|
||||||
if (item.content_type === 2) return item.media_transcript_text || '[语音]'
|
if (item.content_type === 2) {
|
||||||
|
if (item.ext_json?.message_kind === 'leave_message') {
|
||||||
|
return item.media_transcript_text || '[留言]'
|
||||||
|
}
|
||||||
|
return item.media_transcript_text || '[语音]'
|
||||||
|
}
|
||||||
if (item.content_type === 3) return '[图片]'
|
if (item.content_type === 3) return '[图片]'
|
||||||
if (item.content_type === 4) {
|
if (item.content_type === 4) {
|
||||||
const title = String(item.content_json?.title || item.content_json?.type || '').trim()
|
const title = String(item.content_json?.title || item.content_json?.type || '').trim()
|
||||||
@@ -361,7 +372,7 @@ function createSyntheticParentConversation(context: BindingContext): ChatConvers
|
|||||||
avatar: PEER_META.parent.icon,
|
avatar: PEER_META.parent.icon,
|
||||||
typeLabel: '家长沟通',
|
typeLabel: '家长沟通',
|
||||||
description: '家长通过微信小程序和孩子直接沟通',
|
description: '家长通过微信小程序和孩子直接沟通',
|
||||||
lastMessage: '还没有消息,点进去发送第一条',
|
lastMessage: '还没有留言,点进去发送第一条',
|
||||||
time: '',
|
time: '',
|
||||||
sortAt: '',
|
sortAt: '',
|
||||||
conversationTypeName: PARENT_CHILD_CONVERSATION_TYPE_NAME,
|
conversationTypeName: PARENT_CHILD_CONVERSATION_TYPE_NAME,
|
||||||
@@ -471,6 +482,7 @@ export async function getMessages(
|
|||||||
senderId: item.sender_id,
|
senderId: item.sender_id,
|
||||||
receiverType: item.receiver_type,
|
receiverType: item.receiver_type,
|
||||||
receiverId: item.receiver_id,
|
receiverId: item.receiver_id,
|
||||||
|
extJson: item.ext_json || null,
|
||||||
senderName:
|
senderName:
|
||||||
item.sender_type === 'parent' && item.sender_id === String(context.currentUserId)
|
item.sender_type === 'parent' && item.sender_id === String(context.currentUserId)
|
||||||
? '我'
|
? '我'
|
||||||
@@ -594,3 +606,68 @@ export async function sendParentMessage(childId: number, content: string): Promi
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseUploadResponseData(rawData: any): any {
|
||||||
|
if (typeof rawData !== 'string') return rawData
|
||||||
|
try {
|
||||||
|
return JSON.parse(rawData)
|
||||||
|
} catch {
|
||||||
|
return { detail: rawData }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getUploadErrorMessage(data: any): string {
|
||||||
|
if (!data) return '留言发送失败'
|
||||||
|
if (typeof data.detail === 'string') return data.detail
|
||||||
|
if (typeof data.message === 'string') return data.message
|
||||||
|
if (typeof data.errMsg === 'string') return data.errMsg
|
||||||
|
return '留言发送失败'
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sendParentVoiceMessage(
|
||||||
|
childId: number,
|
||||||
|
options: { filePath: string; durationMs: number; transcriptText?: string }
|
||||||
|
): Promise<ConversationMessageCreateResponse> {
|
||||||
|
if (!childId) {
|
||||||
|
throw new Error('当前会话不支持留言')
|
||||||
|
}
|
||||||
|
if (!options.filePath) {
|
||||||
|
throw new Error('录音文件不存在')
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = getToken()
|
||||||
|
const requestUrl = `${BASE_URL}/banban/children/${childId}/voice-message`
|
||||||
|
const uploadResponse: any = await new Promise((resolve, reject) => {
|
||||||
|
Taro.uploadFile({
|
||||||
|
url: requestUrl,
|
||||||
|
filePath: options.filePath,
|
||||||
|
name: 'file',
|
||||||
|
header: token
|
||||||
|
? {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
}
|
||||||
|
: {},
|
||||||
|
formData: {
|
||||||
|
duration_ms: `${Math.max(1, Math.round(options.durationMs || 0))}`,
|
||||||
|
client_msg_id: createClientMessageId(),
|
||||||
|
transcript_text: options.transcriptText || '',
|
||||||
|
},
|
||||||
|
success: resolve,
|
||||||
|
fail: reject,
|
||||||
|
})
|
||||||
|
}).catch((error: any) => {
|
||||||
|
const message = String(error?.errMsg || error?.message || 'request:fail')
|
||||||
|
throw new ApiError(0, message)
|
||||||
|
})
|
||||||
|
|
||||||
|
if (uploadResponse.statusCode === 401) {
|
||||||
|
handleUnauthorized({ redirect: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = parseUploadResponseData(uploadResponse.data)
|
||||||
|
if (uploadResponse.statusCode >= 400) {
|
||||||
|
throw new ApiError(uploadResponse.statusCode, getUploadErrorMessage(data))
|
||||||
|
}
|
||||||
|
|
||||||
|
return data as ConversationMessageCreateResponse
|
||||||
|
}
|
||||||
|
|||||||
@@ -178,11 +178,18 @@ class ImDAO(BaseDAO):
|
|||||||
participant_b_id = str(high_id)
|
participant_b_id = str(high_id)
|
||||||
return participant_a_id, participant_b_id, f"{participant_a_id}:{participant_b_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:
|
if content_type == 1:
|
||||||
return (content_text or "").strip()[:255]
|
return (content_text or "").strip()[:255]
|
||||||
if content_type == 2:
|
if content_type == 2:
|
||||||
return "[audio]"
|
if (ext_json or {}).get("message_kind") == "leave_message":
|
||||||
|
return "[留言]"
|
||||||
|
return "[语音]"
|
||||||
if content_type == 3:
|
if content_type == 3:
|
||||||
return "[image]"
|
return "[image]"
|
||||||
return "[json]"
|
return "[json]"
|
||||||
@@ -239,7 +246,11 @@ class ImDAO(BaseDAO):
|
|||||||
return conversation_id, True
|
return conversation_id, True
|
||||||
|
|
||||||
now_sql = "CURRENT_TIMESTAMP(3)"
|
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:
|
try:
|
||||||
conversation_row = await self._get_conversation_by_id(conversation_id=conversation_id, lock=True)
|
conversation_row = await self._get_conversation_by_id(conversation_id=conversation_id, lock=True)
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import logging
|
|||||||
from collections.abc import Mapping
|
from collections.abc import Mapping
|
||||||
from typing import Any
|
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
|
from sqlalchemy import text
|
||||||
|
|
||||||
try:
|
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(
|
@router.get(
|
||||||
"/{child_id}/conversations/{conversation_id}/messages",
|
"/{child_id}/conversations/{conversation_id}/messages",
|
||||||
response_model=ChildConversationMessageListResponse,
|
response_model=ChildConversationMessageListResponse,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ from dataclasses import dataclass
|
|||||||
import hashlib
|
import hashlib
|
||||||
import json
|
import json
|
||||||
from collections.abc import Mapping
|
from collections.abc import Mapping
|
||||||
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
@@ -36,6 +37,25 @@ PARTICIPANT_TYPE_NAMES = {
|
|||||||
CHILD_PARTICIPANT_TYPE: "child",
|
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:
|
def conversation_type_name(conversation_type: int) -> str:
|
||||||
return CONVERSATION_TYPE_NAMES.get(conversation_type, f"unknown_{conversation_type}")
|
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
|
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:
|
def row_to_message_item(row: Mapping[str, Any]) -> ChildConversationMessageItem:
|
||||||
return ChildConversationMessageItem(
|
return ChildConversationMessageItem(
|
||||||
id=int(row["id"]),
|
id=int(row["id"]),
|
||||||
@@ -201,6 +235,58 @@ class ImService(DatabaseServiceBase):
|
|||||||
finally:
|
finally:
|
||||||
await db_session.close()
|
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(
|
async def _create_device_message_with_payload(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
|
|||||||
@@ -113,6 +113,15 @@ class MessageAudioStorageService:
|
|||||||
Expired=settings.cos_avatar_url_expire_seconds,
|
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(
|
def _upload_audio_sync(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
|
|||||||
Reference in New Issue
Block a user