feat(voice): upload device audio to cos and enable playback

This commit is contained in:
ChengCan
2026-04-30 01:03:34 +08:00
parent 282d0874d8
commit efb13de8ed
17 changed files with 640 additions and 323 deletions

View File

@@ -174,6 +174,33 @@
} }
} }
.audio-bubble {
min-width: 220px;
display: flex;
align-items: center;
gap: 14px;
&.playing {
opacity: 0.82;
}
}
.audio-bubble-icon {
font-size: 24px;
font-weight: 700;
line-height: 1;
}
.audio-bubble-text {
flex: 1;
font-size: 28px;
}
.audio-bubble-duration {
font-size: 24px;
opacity: 0.8;
}
.empty-chat { .empty-chat {
min-height: 320px; min-height: 320px;
display: flex; display: flex;

View File

@@ -1,5 +1,5 @@
import { View, Text, ScrollView, Image, Input } from '@tarojs/components' import { View, Text, ScrollView, Image, Input } from '@tarojs/components'
import { useEffect, 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,
@@ -47,6 +47,11 @@ function formatParticipantLabel(message: ChatMessage): string {
return `${senderType} ${senderId}` return `${senderType} ${senderId}`
} }
function formatAudioDuration(durationMs?: number | null): string {
const totalSeconds = Math.max(1, Math.round((durationMs || 0) / 1000))
return `${totalSeconds}s`
}
export default function ChatDetail() { export default function ChatDetail() {
const router = useRouter() const router = useRouter()
const params = router.params const params = router.params
@@ -85,6 +90,29 @@ export default function ChatDetail() {
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [inputText, setInputText] = useState('') const [inputText, setInputText] = useState('')
const [sending, setSending] = useState(false) const [sending, setSending] = useState(false)
const [playingMessageId, setPlayingMessageId] = useState<number | null>(null)
const audioContextRef = useRef<Taro.InnerAudioContext | null>(null)
useEffect(() => {
const audioContext = Taro.createInnerAudioContext()
audioContextRef.current = audioContext
audioContext.onEnded(() => {
setPlayingMessageId(null)
})
audioContext.onStop(() => {
setPlayingMessageId(null)
})
audioContext.onError(() => {
setPlayingMessageId(null)
Taro.showToast({ title: '语音播放失败', icon: 'none' })
})
return () => {
audioContext.destroy()
audioContextRef.current = null
}
}, [])
useEffect(() => { useEffect(() => {
void loadChatData(activeConversationId) void loadChatData(activeConversationId)
@@ -157,6 +185,46 @@ export default function ChatDetail() {
} }
} }
const handlePlayAudio = (message: ChatMessage) => {
if (message.contentType !== 2 || !message.mediaUrl) {
Taro.showToast({ title: '语音地址不存在', icon: 'none' })
return
}
const audioContext = audioContextRef.current
if (!audioContext) {
Taro.showToast({ title: '播放器未就绪', icon: 'none' })
return
}
if (playingMessageId === message.id) {
audioContext.stop()
setPlayingMessageId(null)
return
}
audioContext.stop()
audioContext.src = message.mediaUrl
audioContext.autoplay = true
audioContext.play()
setPlayingMessageId(message.id)
}
const renderMessageBody = (msg: ChatMessage) => {
if (msg.contentType === 2 && msg.mediaUrl) {
const isPlaying = playingMessageId === msg.id
return (
<View className={`audio-bubble ${isPlaying ? 'playing' : ''}`} onClick={() => handlePlayAudio(msg)}>
<Text className='audio-bubble-icon'>{isPlaying ? '[]' : '>'}</Text>
<Text className='audio-bubble-text'>{msg.mediaTranscriptText || '点击播放语音'}</Text>
<Text className='audio-bubble-duration'>{formatAudioDuration(msg.mediaDurationMs)}</Text>
</View>
)
}
return <Text>{msg.content}</Text>
}
return ( return (
<View className='chat-detail-page'> <View className='chat-detail-page'>
<View className='chat-header'> <View className='chat-header'>
@@ -199,7 +267,7 @@ export default function ChatDetail() {
<View className='message-main'> <View className='message-main'>
<Text className='message-meta'>{formatParticipantLabel(msg)}</Text> <Text className='message-meta'>{formatParticipantLabel(msg)}</Text>
<View className='message-bubble'> <View className='message-bubble'>
<Text>{msg.content}</Text> {renderMessageBody(msg)}
</View> </View>
</View> </View>
<View className='user-avatar'> <View className='user-avatar'>
@@ -214,7 +282,7 @@ export default function ChatDetail() {
<View className='message-main'> <View className='message-main'>
<Text className='message-meta'>{formatParticipantLabel(msg)}</Text> <Text className='message-meta'>{formatParticipantLabel(msg)}</Text>
<View className='message-bubble'> <View className='message-bubble'>
<Text>{msg.content}</Text> {renderMessageBody(msg)}
</View> </View>
</View> </View>
</View> </View>

View File

@@ -65,7 +65,7 @@ export default function Chat() {
</View> </View>
) : conversations.length === 0 ? ( ) : conversations.length === 0 ? (
<View className='empty-card'> <View className='empty-card'>
<Text className='empty-title'></Text> <Text className='empty-title'></Text>
</View> </View>
) : ( ) : (
<ScrollView className='conversation-list' scrollY> <ScrollView className='conversation-list' scrollY>

View File

@@ -348,6 +348,14 @@
color: #1A1A1A; color: #1A1A1A;
} }
.empty-subtitle {
display: block;
margin-top: 16px;
font-size: 26px;
line-height: 1.6;
color: #666666;
}
.empty-btn { .empty-btn {
margin-top: 28px; margin-top: 28px;
height: 92px; height: 92px;
@@ -378,3 +386,46 @@
font-weight: 600; font-weight: 600;
color: #1A1A1A; color: #1A1A1A;
} }
.child-focus-card {
display: flex;
align-items: center;
background: #FFFFFF;
border-radius: 24px;
margin: 0 24px 24px;
padding: 28px 24px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.04);
}
.child-focus-avatar {
width: 96px;
height: 96px;
border-radius: 50%;
background: #FEF3E8;
display: flex;
align-items: center;
justify-content: center;
margin-right: 20px;
}
.child-focus-icon {
font-size: 40px;
}
.child-focus-info {
flex: 1;
}
.child-focus-name {
display: block;
font-size: 32px;
font-weight: 700;
color: #1A1A1A;
}
.child-focus-desc {
display: block;
margin-top: 8px;
font-size: 26px;
color: #666666;
}

View File

@@ -2,8 +2,8 @@ import { View, Text, Switch, Slider, Image } from '@tarojs/components'
import { useState } from 'react' import { useState } from 'react'
import Taro, { useDidShow } from '@tarojs/taro' import Taro, { useDidShow } from '@tarojs/taro'
import { getToken } from '@/services/auth' import { getToken } from '@/services/auth'
import { Binding, resolveActiveBinding } from '@/services/binding' import { Binding, loadCurrentChildBindingContext } from '@/services/binding'
import { Child, getChild } from '@/services/child' import { Child } from '@/services/child'
import './index.scss' import './index.scss'
export default function Device() { export default function Device() {
@@ -25,20 +25,9 @@ export default function Device() {
setIsLoading(true) setIsLoading(true)
try { try {
const activeBinding = await resolveActiveBinding() const context = await loadCurrentChildBindingContext()
setBinding(activeBinding) setChild(context.currentChild)
setBinding(context.currentBinding)
if (activeBinding?.child_id) {
try {
const currentChild = await getChild(activeBinding.child_id)
setChild(currentChild)
} catch (error) {
console.error('[device] load child failed:', error)
setChild(null)
}
} else {
setChild(null)
}
} catch (error: any) { } catch (error: any) {
console.error('[device] load failed:', error) console.error('[device] load failed:', error)
Taro.showToast({ Taro.showToast({
@@ -72,7 +61,7 @@ export default function Device() {
) )
} }
if (!binding) { if (!child) {
return ( return (
<View className='device-page'> <View className='device-page'>
<View className='page-header'> <View className='page-header'>
@@ -83,7 +72,39 @@ export default function Device() {
<View className='empty-robot'> <View className='empty-robot'>
<Image className='robot-img' src={require('../../assets/tab-icons/robot.png')} mode='aspectFit' /> <Image className='robot-img' src={require('../../assets/tab-icons/robot.png')} mode='aspectFit' />
</View> </View>
<Text className='empty-title'></Text> <Text className='empty-title'></Text>
<Text className='empty-subtitle'></Text>
<View className='empty-btn' onClick={() => Taro.switchTab({ url: '/pages/sleep/index' })}>
<Text className='empty-btn-text'></Text>
</View>
</View>
</View>
)
}
if (!binding) {
return (
<View className='device-page'>
<View className='page-header'>
<Text className='page-title'>{child.child_name} </Text>
</View>
<View className='child-focus-card'>
<View className='child-focus-avatar'>
<Text className='child-focus-icon'>🧒</Text>
</View>
<View className='child-focus-info'>
<Text className='child-focus-name'>{child.child_name}</Text>
<Text className='child-focus-desc'></Text>
</View>
</View>
<View className='empty-card'>
<View className='empty-robot'>
<Image className='robot-img' src={require('../../assets/tab-icons/robot.png')} mode='aspectFit' />
</View>
<Text className='empty-title'></Text>
<Text className='empty-subtitle'></Text>
<View className='empty-btn' onClick={() => Taro.navigateTo({ url: '/pages/bind/index' })}> <View className='empty-btn' onClick={() => Taro.navigateTo({ url: '/pages/bind/index' })}>
<Text className='empty-btn-text'></Text> <Text className='empty-btn-text'></Text>
</View> </View>
@@ -92,8 +113,8 @@ export default function Device() {
) )
} }
const childName = child?.child_name || '未设置' const childName = child.child_name || '未设置'
const pageTitle = child?.child_name ? `${child.child_name} 的伴伴` : '伴伴设备' const pageTitle = `${childName} 的伴伴`
const battery = 82 const battery = 82
const daysLeft = 4 const daysLeft = 4
@@ -106,7 +127,7 @@ export default function Device() {
<View className='status-card'> <View className='status-card'>
<View className='status-badge'> <View className='status-badge'>
<View className='status-dot'></View> <View className='status-dot'></View>
<Text className='status-text'>{binding.child_id ? '已完成绑定' : '待关联儿童'}</Text> <Text className='status-text'></Text>
</View> </View>
<View className='battery-section'> <View className='battery-section'>
<Text className='battery-percent'> <Text className='battery-percent'>
@@ -124,12 +145,12 @@ export default function Device() {
<View className='detail-card'> <View className='detail-card'>
<View className='detail-row'> <View className='detail-row'>
<Text className='detail-label'></Text> <Text className='detail-label'></Text>
<Text className='detail-value'>{binding.device_id}</Text> <Text className='detail-value'>{childName}</Text>
</View> </View>
<View className='detail-row'> <View className='detail-row'>
<Text className='detail-label'></Text> <Text className='detail-label'></Text>
<Text className='detail-value'>{childName}</Text> <Text className='detail-value'>{binding.device_id}</Text>
</View> </View>
<View className='detail-row'> <View className='detail-row'>
<Text className='detail-label'></Text> <Text className='detail-label'></Text>
@@ -195,12 +216,6 @@ export default function Device() {
</View> </View>
</View> </View>
</View> </View>
{!binding.child_id && (
<View className='helper-card' onClick={() => Taro.navigateTo({ url: '/pages/bind/index' })}>
<Text className='helper-title'></Text>
</View>
)}
</View> </View>
) )
} }

View File

@@ -339,7 +339,7 @@ export default function Location() {
</View> </View>
) : ( ) : (
<View className='location-empty'> <View className='location-empty'>
<Text className='location-empty-title'></Text> <Text className='location-empty-title'></Text>
</View> </View>
) )
) : trajectory.length > 0 ? ( ) : trajectory.length > 0 ? (
@@ -384,7 +384,7 @@ export default function Location() {
<View className='location-empty'> <View className='location-empty'>
<Text className='location-empty-title'></Text> <Text className='location-empty-title'></Text>
<Text className='location-empty-desc'> <Text className='location-empty-desc'>
{trajectoryMode === 'today' ? '今天还没有新的历史点位。' : '最近时间范围内没有新的历史点位。'} {trajectoryMode === 'today' ? '当前孩子今天还没有新的历史点位。' : '当前孩子最近没有新的历史点位。'}
</Text> </Text>
</View> </View>
)} )}

View File

@@ -5,13 +5,11 @@ import { clearToken, getCurrentUserId, getToken } from '@/services/auth'
import { import {
BindingListItem, BindingListItem,
clearSelectedBindingDeviceId, clearSelectedBindingDeviceId,
getBindings, loadCurrentChildBindingContext,
resolveBindingSelection,
setBindingChild,
setSelectedBindingDeviceId, setSelectedBindingDeviceId,
unbindDevice, unbindDevice,
} from '@/services/binding' } from '@/services/binding'
import { Child, createChild, getChildren, updateChild } from '@/services/child' import { Child, clearSelectedChildId, createChild, setSelectedChildId, updateChild } from '@/services/child'
import './index.scss' import './index.scss'
interface MenuItem { interface MenuItem {
@@ -28,8 +26,9 @@ export default function Sleep() {
const [children, setChildren] = useState<Child[]>([]) const [children, setChildren] = useState<Child[]>([])
const [bindings, setBindings] = useState<BindingListItem[]>([]) const [bindings, setBindings] = useState<BindingListItem[]>([])
const [binding, setBinding] = useState<BindingListItem | null>(null) const [binding, setBinding] = useState<BindingListItem | null>(null)
const [currentChild, setCurrentChild] = useState<Child | null>(null)
const [showModal, setShowModal] = useState(false) const [showModal, setShowModal] = useState(false)
const [showDeviceModal, setShowDeviceModal] = useState(false) const [showChildModal, setShowChildModal] = useState(false)
const [modalType, setModalType] = useState<'add' | 'edit'>('add') const [modalType, setModalType] = useState<'add' | 'edit'>('add')
const [childName, setChildName] = useState('') const [childName, setChildName] = useState('')
const [editingChildId, setEditingChildId] = useState<number | null>(null) const [editingChildId, setEditingChildId] = useState<number | null>(null)
@@ -47,13 +46,11 @@ export default function Sleep() {
setLoading(true) setLoading(true)
try { try {
const [childResponse, bindingResponse] = await Promise.all([getChildren(), getBindings(undefined, 100)]) const context = await loadCurrentChildBindingContext()
const bindingItems = bindingResponse.items || [] setChildren(context.children)
const activeBinding = resolveBindingSelection(bindingItems) setBindings(context.bindings as BindingListItem[])
setCurrentChild(context.currentChild)
setChildren(childResponse.items || []) setBinding((context.currentBinding as BindingListItem | null) || null)
setBindings(bindingItems)
setBinding(activeBinding)
setParentInfo(Taro.getStorageSync('userInfo') || {}) setParentInfo(Taro.getStorageSync('userInfo') || {})
} catch (error: any) { } catch (error: any) {
console.error('[manage] load failed:', error) console.error('[manage] load failed:', error)
@@ -66,8 +63,7 @@ export default function Sleep() {
} }
} }
const currentChild = binding?.child_id ? children.find((item) => item.child_id === binding.child_id) || null : null const currentChildName = currentChild?.child_name || '未设置'
const currentChildName = currentChild?.child_name || binding?.child_name || (binding && !binding.child_id ? '待关联' : '未设置')
const handleOpenModal = (type: 'add' | 'edit', child?: Child) => { const handleOpenModal = (type: 'add' | 'edit', child?: Child) => {
setModalType(type) setModalType(type)
@@ -82,12 +78,19 @@ export default function Sleep() {
setEditingChildId(null) setEditingChildId(null)
} }
const handleSelectBinding = (targetBinding: BindingListItem) => { const handleSelectChild = (child: Child) => {
setSelectedBindingDeviceId(targetBinding.device_id) setSelectedChildId(child.child_id)
setBinding(targetBinding) const nextBinding = bindings.find((item) => item.child_id === child.child_id) || null
setShowDeviceModal(false) if (nextBinding?.device_id) {
setSelectedBindingDeviceId(nextBinding.device_id)
} else {
clearSelectedBindingDeviceId()
}
setCurrentChild(child)
setBinding(nextBinding)
setShowChildModal(false)
Taro.showToast({ Taro.showToast({
title: '已切换设备', title: '已切换当前孩子',
icon: 'success', icon: 'success',
}) })
} }
@@ -102,12 +105,8 @@ export default function Sleep() {
try { try {
if (modalType === 'add') { if (modalType === 'add') {
const createdChild = await createChild({ child_name: normalizedName }) const createdChild = await createChild({ child_name: normalizedName })
if (binding && !binding.child_id) { setSelectedChildId(createdChild.child_id)
await setBindingChild(binding.device_id, { child_id: createdChild.child_id })
Taro.showToast({ title: '已创建并关联', icon: 'success' })
} else {
Taro.showToast({ title: '创建成功', icon: 'success' }) Taro.showToast({ title: '创建成功', icon: 'success' })
}
} else if (editingChildId) { } else if (editingChildId) {
await updateChild(editingChildId, { child_name: normalizedName }) await updateChild(editingChildId, { child_name: normalizedName })
Taro.showToast({ title: '修改成功', icon: 'success' }) Taro.showToast({ title: '修改成功', icon: 'success' })
@@ -124,54 +123,19 @@ export default function Sleep() {
} }
} }
const handleChildMenu = () => {
if (binding && !binding.child_id && children.length > 0) {
Taro.showActionSheet({
itemList: [...children.map((item) => item.child_name), '新建儿童资料'],
success: async (result) => {
if (result.tapIndex === children.length) {
handleOpenModal('add')
return
}
const targetChild = children[result.tapIndex]
if (!targetChild) return
try {
await setBindingChild(binding.device_id, { child_id: targetChild.child_id })
Taro.showToast({ title: '关联成功', icon: 'success' })
await loadData()
} catch (error: any) {
Taro.showToast({
title: error?.message || '关联失败,请重试',
icon: 'none',
})
}
},
})
return
}
if (currentChild) {
handleOpenModal('edit', currentChild)
return
}
handleOpenModal('add')
}
const handleUnbind = () => { const handleUnbind = () => {
if (!binding) return if (!binding) return
Taro.showModal({ Taro.showModal({
title: '解除设备绑定', title: '解除设备绑定',
content: '确定要解除当前设备绑定吗?', content: `确定要解除 ${currentChildName} 当前绑定的设备吗?`,
confirmColor: '#FF8C42', confirmColor: '#FF8C42',
success: async (result) => { success: async (result) => {
if (!result.confirm) return if (!result.confirm) return
try { try {
await unbindDevice(binding.device_id) await unbindDevice(binding.device_id)
clearSelectedBindingDeviceId()
Taro.showToast({ title: '已解绑', icon: 'success' }) Taro.showToast({ title: '已解绑', icon: 'success' })
await loadData() await loadData()
} catch (error: any) { } catch (error: any) {
@@ -193,6 +157,7 @@ export default function Sleep() {
if (!result.confirm) return if (!result.confirm) return
clearToken() clearToken()
clearSelectedBindingDeviceId() clearSelectedBindingDeviceId()
clearSelectedChildId()
Taro.removeStorageSync('userInfo') Taro.removeStorageSync('userInfo')
Taro.reLaunch({ url: '/pages/login/index' }) Taro.reLaunch({ url: '/pages/login/index' })
}, },
@@ -202,21 +167,33 @@ export default function Sleep() {
const handleMenuClick = (item: MenuItem) => { const handleMenuClick = (item: MenuItem) => {
if (item.disabled) return if (item.disabled) return
if (item.name === '儿童资料 (用于称呼)') { if (item.name === '当前孩子') {
handleChildMenu() setShowChildModal(true)
return return
} }
if (item.name === '绑定新设备') { if (item.name === '编辑当前孩子') {
if (currentChild) {
handleOpenModal('edit', currentChild)
} else {
handleOpenModal('add')
}
return
}
if (item.name === '绑定设备') {
if (!currentChild) {
handleOpenModal('add')
Taro.showToast({
title: '请先添加儿童资料',
icon: 'none',
})
return
}
Taro.navigateTo({ url: '/pages/bind/index' }) Taro.navigateTo({ url: '/pages/bind/index' })
return return
} }
if (item.name === '切换设备') {
setShowDeviceModal(true)
return
}
if (item.name === '解除设备绑定') { if (item.name === '解除设备绑定') {
handleUnbind() handleUnbind()
} }
@@ -237,24 +214,24 @@ export default function Sleep() {
{ {
icon: require('../../assets/tab-icons/orange-robot.png'), icon: require('../../assets/tab-icons/orange-robot.png'),
iconBgClass: 'orange', iconBgClass: 'orange',
name: '儿童资料 (用于称呼)', name: '当前孩子',
value: currentChildName, value: currentChildName,
arrow: true, arrow: true,
}, },
{
icon: require('../../assets/tab-icons/rings.png'),
iconBgClass: 'green',
name: '切换设备',
value: bindings.length > 0 ? `${bindings.length}` : '暂无设备',
arrow: true,
},
{ {
icon: require('../../assets/tab-icons/orange-robot.png'), icon: require('../../assets/tab-icons/orange-robot.png'),
iconBgClass: 'orange', iconBgClass: 'orange',
name: '绑定新设备', name: '编辑当前孩子',
value: '', value: '',
arrow: true, arrow: true,
}, },
{
icon: require('../../assets/tab-icons/rings.png'),
iconBgClass: 'green',
name: '绑定设备',
value: binding?.device_id ? `当前: ${binding.device_id}` : '未绑定',
arrow: true,
},
{ {
icon: require('../../assets/tab-icons/broken-rings.png'), icon: require('../../assets/tab-icons/broken-rings.png'),
iconBgClass: 'red', iconBgClass: 'red',
@@ -284,23 +261,23 @@ export default function Sleep() {
<Text className='user-role'> ID: {userId || '--'}</Text> <Text className='user-role'> ID: {userId || '--'}</Text>
</View> </View>
<View className='verified-badge'> <View className='verified-badge'>
<Text>{bindings.length > 0 ? `已绑定 ${bindings.length}` : '未绑定设备'}</Text> <Text>{currentChild ? '已选择当前孩子' : '未选择孩子'}</Text>
</View> </View>
</View> </View>
<View className='summary-card'> <View className='summary-card'>
<View className='summary-row'> <View className='summary-row'>
<Text className='summary-label'></Text> <Text className='summary-label'></Text>
<Text className='summary-value'>{bindings.length} </Text> <Text className='summary-value'>{children.length} </Text>
</View>
<View className='summary-row'>
<Text className='summary-label'></Text>
<Text className='summary-value'>{binding?.device_id || '未绑定'}</Text>
</View> </View>
<View className='summary-row'> <View className='summary-row'>
<Text className='summary-label'></Text> <Text className='summary-label'></Text>
<Text className='summary-value'>{currentChildName}</Text> <Text className='summary-value'>{currentChildName}</Text>
</View> </View>
<View className='summary-row'>
<Text className='summary-label'></Text>
<Text className='summary-value'>{binding?.device_id || '未绑定'}</Text>
</View>
</View> </View>
<View className='menu-card'> <View className='menu-card'>
@@ -333,39 +310,40 @@ export default function Sleep() {
<Text className='version-text'> Companion V1.1.0</Text> <Text className='version-text'> Companion V1.1.0</Text>
</View> </View>
{showDeviceModal && ( {showChildModal && (
<View className='modal-mask' onClick={() => setShowDeviceModal(false)}> <View className='modal-mask' onClick={() => setShowChildModal(false)}>
<View <View
className='modal-card device-switch-card' className='modal-card device-switch-card'
onClick={(event) => { onClick={(event) => {
event.stopPropagation() event.stopPropagation()
}} }}
> >
<Text className='modal-title'></Text> <Text className='modal-title'></Text>
{bindings.length === 0 ? ( {children.length === 0 ? (
<Text className='device-switch-empty'></Text> <Text className='device-switch-empty'></Text>
) : ( ) : (
<View className='device-switch-list'> <View className='device-switch-list'>
{bindings.map((item) => { {children.map((item) => {
const isActive = binding?.device_id === item.device_id const isActive = currentChild?.child_id === item.child_id
const itemBinding = bindings.find((bindingItem) => bindingItem.child_id === item.child_id) || null
return ( return (
<View <View
key={item.device_id} key={item.child_id}
className={`device-switch-item ${isActive ? 'active' : ''}`} className={`device-switch-item ${isActive ? 'active' : ''}`}
onClick={() => handleSelectBinding(item)} onClick={() => handleSelectChild(item)}
> >
<View className='device-switch-head'> <View className='device-switch-head'>
<Text className='device-switch-id'>{item.device_id}</Text> <Text className='device-switch-id'>{item.child_name}</Text>
{isActive && <Text className='device-switch-tag'></Text>} {isActive && <Text className='device-switch-tag'></Text>}
</View> </View>
<Text className='device-switch-name'>{item.child_name || '待关联儿童'}</Text> <Text className='device-switch-name'>{itemBinding?.device_id || '未绑定设备'}</Text>
</View> </View>
) )
})} })}
</View> </View>
)} )}
<View className='modal-actions'> <View className='modal-actions'>
<Text className='modal-action cancel' onClick={() => setShowDeviceModal(false)}> <Text className='modal-action cancel' onClick={() => setShowChildModal(false)}>
</Text> </Text>
</View> </View>

View File

@@ -1,7 +1,6 @@
import { getCurrentUserId } from './auth' import { getCurrentUserId } from './auth'
import { request } from './api' import { request } from './api'
import { resolveActiveBinding } from './binding' import { loadCurrentChildBindingContext } from './binding'
import { getChild } from './child'
export type ConversationSource = 'im' | 'ai' export type ConversationSource = 'im' | 'ai'
export type PeerKind = 'child' | 'parent' | 'ai' export type PeerKind = 'child' | 'parent' | 'ai'
@@ -38,6 +37,11 @@ export interface ChatMessage {
content: string content: string
time: string time: string
conversationId: number conversationId: number
contentType?: number
mediaUrl?: string | null
mediaDurationMs?: number | null
mediaMimeType?: string | null
mediaTranscriptText?: string | null
senderType?: string senderType?: string
senderId?: string senderId?: string
receiverType?: string receiverType?: string
@@ -240,21 +244,12 @@ function getConversationRank(conversation: ChatConversation): number {
} }
async function getBindingContext(): Promise<BindingContext> { async function getBindingContext(): Promise<BindingContext> {
const binding = await resolveActiveBinding() const context = await loadCurrentChildBindingContext()
let childName = String(binding?.child_name || '').trim() const childName = String(context.currentChild?.child_name || context.currentBinding?.child_name || '').trim()
if (!childName && binding?.child_id) {
try {
const child = await getChild(binding.child_id)
childName = String(child?.child_name || '').trim()
} catch (error) {
console.error('[chat] load child name failed:', error)
}
}
return { return {
deviceId: binding?.device_id || '', deviceId: context.currentBinding?.device_id || '',
childId: binding?.child_id || null, childId: context.currentChild?.child_id || null,
childName, childName,
currentUserId: getCurrentUserId(), currentUserId: getCurrentUserId(),
} }
@@ -265,7 +260,7 @@ async function getDeviceMessages(context?: BindingContext): Promise<DeviceAiMess
if (!deviceId) return [] if (!deviceId) return []
try { try {
const response = await request<DeviceAiMessageListResponse>(`/devices/${deviceId}/messages?limit=100`) const response = await request<DeviceAiMessageListResponse>(`/banban/devices/${deviceId}/messages?limit=100`)
return response.items || [] return response.items || []
} catch (error: any) { } catch (error: any) {
if (error?.status === 404) return [] if (error?.status === 404) return []
@@ -278,7 +273,7 @@ async function getChildConversations(context?: BindingContext): Promise<ChildCon
if (!childId) return [] if (!childId) return []
try { try {
const response = await request<ChildConversationListResponse>(`/children/${childId}/conversations?limit=100`) const response = await request<ChildConversationListResponse>(`/banban/children/${childId}/conversations?limit=100`)
return response.items || [] return response.items || []
} catch (error: any) { } catch (error: any) {
if (error?.status === 404) return [] if (error?.status === 404) return []
@@ -294,7 +289,7 @@ async function getChildConversationMessages(
if (!childId || conversationId <= 0) return [] if (!childId || conversationId <= 0) return []
const response = await request<ChildConversationMessageListResponse>( const response = await request<ChildConversationMessageListResponse>(
`/children/${childId}/conversations/${conversationId}/messages?limit=100` `/banban/children/${childId}/conversations/${conversationId}/messages?limit=100`
) )
return response.items || [] return response.items || []
} }
@@ -437,6 +432,11 @@ export async function getMessages(
content: item.content || '暂无内容', content: item.content || '暂无内容',
time: formatDetailTime(item.created_at), time: formatDetailTime(item.created_at),
conversationId: item.conversation_id, conversationId: item.conversation_id,
contentType: 1,
mediaUrl: null,
mediaDurationMs: null,
mediaMimeType: null,
mediaTranscriptText: null,
senderType: item.is_user ? 'device' : 'ai', senderType: item.is_user ? 'device' : 'ai',
senderId: item.is_user ? context.deviceId : item.role_key, senderId: item.is_user ? context.deviceId : item.role_key,
receiverType: item.is_user ? 'ai' : 'device', receiverType: item.is_user ? 'ai' : 'device',
@@ -462,6 +462,11 @@ export async function getMessages(
content: formatImPreview(item), content: formatImPreview(item),
time: formatDetailTime(item.created_at), time: formatDetailTime(item.created_at),
conversationId: item.conversation_id, conversationId: item.conversation_id,
contentType: item.content_type,
mediaUrl: item.media_file_key || null,
mediaDurationMs: item.media_duration_ms || null,
mediaMimeType: item.media_mime_type || null,
mediaTranscriptText: item.media_transcript_text || null,
senderType: item.sender_type, senderType: item.sender_type,
senderId: item.sender_id, senderId: item.sender_id,
receiverType: item.receiver_type, receiverType: item.receiver_type,
@@ -580,7 +585,7 @@ export async function sendParentMessage(childId: number, content: string): Promi
throw new Error('请输入消息内容') throw new Error('请输入消息内容')
} }
return request<ConversationMessageCreateResponse>(`/children/${childId}/messages`, { return request<ConversationMessageCreateResponse>(`/banban/children/${childId}/messages`, {
method: 'POST', method: 'POST',
data: { data: {
content_type: 1, content_type: 1,

View File

@@ -1,5 +1,5 @@
import { request } from './api' import { request } from './api'
import { resolveActiveBinding } from './binding' import { loadCurrentChildBindingContext } from './binding'
export interface DeviceLocation { export interface DeviceLocation {
child_id: number child_id: number
@@ -32,11 +32,12 @@ export interface DeviceTrajectoryResponse {
} }
export async function getCurrentDeviceLocation(deviceId?: string): Promise<DeviceLocation | null> { export async function getCurrentDeviceLocation(deviceId?: string): Promise<DeviceLocation | null> {
const resolvedDeviceId = String(deviceId || '').trim() || (await resolveActiveBinding())?.device_id || '' const resolvedDeviceId =
String(deviceId || '').trim() || (await loadCurrentChildBindingContext()).currentBinding?.device_id || ''
if (!resolvedDeviceId) return null if (!resolvedDeviceId) return null
try { try {
return await request<DeviceLocation>(`/devices/${resolvedDeviceId}/location`) return await request<DeviceLocation>(`/banban/devices/${resolvedDeviceId}/location`)
} catch (error: any) { } catch (error: any) {
if (error?.status === 404) return null if (error?.status === 404) return null
throw error throw error
@@ -49,7 +50,8 @@ export async function getDeviceTrajectory(params?: {
endAt?: string endAt?: string
limit?: number limit?: number
}): Promise<DeviceTrajectoryResponse | null> { }): Promise<DeviceTrajectoryResponse | null> {
const resolvedDeviceId = String(params?.deviceId || '').trim() || (await resolveActiveBinding())?.device_id || '' const resolvedDeviceId =
String(params?.deviceId || '').trim() || (await loadCurrentChildBindingContext()).currentBinding?.device_id || ''
if (!resolvedDeviceId) return null if (!resolvedDeviceId) return null
const query = new URLSearchParams() const query = new URLSearchParams()
@@ -59,7 +61,7 @@ export async function getDeviceTrajectory(params?: {
try { try {
const suffix = query.toString() ? `?${query.toString()}` : '' const suffix = query.toString() ? `?${query.toString()}` : ''
return await request<DeviceTrajectoryResponse>(`/devices/${resolvedDeviceId}/trajectory${suffix}`) return await request<DeviceTrajectoryResponse>(`/banban/devices/${resolvedDeviceId}/trajectory${suffix}`)
} catch (error: any) { } catch (error: any) {
if (error?.status === 404) return null if (error?.status === 404) return null
throw error throw error

View File

@@ -7,7 +7,8 @@ class BaseDAO:
self.db = db self.db = db
async def execute(self, query, params: dict = None): async def execute(self, query, params: dict = None):
return await self.db.execute(text(query), params or {}) statement = query if hasattr(query, "_execute_on_connection") else text(query)
return await self.db.execute(statement, params or {})
async def commit(self): async def commit(self):
await self.db.commit() await self.db.commit()

View File

@@ -25,6 +25,14 @@ class ConversationMessageCreateResult:
conversation_type: int conversation_type: int
message: dict message: dict
@property
def conversation_type_name(self) -> str:
if self.conversation_type == 1:
return "child_peer"
if self.conversation_type == 2:
return "parent_child"
return f"unknown_{self.conversation_type}"
class ImDAO(BaseDAO): class ImDAO(BaseDAO):
async def assert_parent_child_access(self, *, user_id: int, child_id: int) -> Mapping[str, Any]: async def assert_parent_child_access(self, *, user_id: int, child_id: int) -> Mapping[str, Any]:
@@ -179,6 +187,22 @@ class ImDAO(BaseDAO):
return "[image]" return "[image]"
return "[json]" return "[json]"
async def ensure_parent_child_conversation(self, *, parent_user_id: int, child_id: int) -> int:
child_row = await self.assert_parent_child_access(user_id=parent_user_id, child_id=child_id)
parent_row = await self._get_parent_row(parent_user_id)
if not parent_row:
from fastapi import HTTPException
raise HTTPException(status_code=404, detail="parent not found")
return await self._get_or_create_conversation(
conversation_type=2,
participant_a_type=2,
participant_a_id=str(child_id),
participant_b_type=1,
participant_b_id=str(parent_user_id),
pair_key=f"{child_id}:{parent_user_id}",
)
async def create_message( async def create_message(
self, self,
*, *,
@@ -428,8 +452,8 @@ class ImDAO(BaseDAO):
FROM im_conversations FROM im_conversations
WHERE conversation_type = :conversation_type WHERE conversation_type = :conversation_type
AND pair_key = :pair_key AND pair_key = :pair_key
{lock_clause}
LIMIT 1 LIMIT 1
{lock_clause}
""" """
), ),
{"conversation_type": conversation_type, "pair_key": pair_key}, {"conversation_type": conversation_type, "pair_key": pair_key},
@@ -449,8 +473,8 @@ class ImDAO(BaseDAO):
SELECT id, conversation_type, last_seq, status SELECT id, conversation_type, last_seq, status
FROM im_conversations FROM im_conversations
WHERE id = :conversation_id WHERE id = :conversation_id
{lock_clause}
LIMIT 1 LIMIT 1
{lock_clause}
""" """
), ),
{"conversation_id": conversation_id}, {"conversation_id": conversation_id},

View File

@@ -16,7 +16,7 @@ try:
ConversationMessageCreateResponse, ConversationMessageCreateResponse,
ParentChildMessageCreateRequest, ParentChildMessageCreateRequest,
) )
from banban.service.im import ImService, im_service from banban.service.im import ImService, im_service, present_message_item
except ModuleNotFoundError: except ModuleNotFoundError:
from banban.security import get_current_user_id from banban.security import get_current_user_id
from banban.schemas.im import ( from banban.schemas.im import (
@@ -27,7 +27,7 @@ except ModuleNotFoundError:
ConversationMessageCreateResponse, ConversationMessageCreateResponse,
ParentChildMessageCreateRequest, ParentChildMessageCreateRequest,
) )
from banban.service.im import ImService, im_service from banban.service.im import ImService, im_service, present_message_item
router = APIRouter(prefix="/children", tags=["im"]) router = APIRouter(prefix="/children", tags=["im"])
@@ -506,7 +506,7 @@ async def list_child_conversation_messages(
rows = rows[:limit] rows = rows[:limit]
rows = list(rows) rows = list(rows)
rows.reverse() rows.reverse()
items = [_row_to_message_item(row) for row in rows] items = [await present_message_item(row, audio_storage=im_service.audio_storage) for row in rows]
next_cursor_seq = items[0].seq if has_more and items else None next_cursor_seq = items[0].seq if has_more and items else None
logger.info( logger.info(

View File

@@ -1,10 +1,12 @@
from dataclasses import dataclass from dataclasses import dataclass
import hashlib
import json import json
from collections.abc import Mapping from collections.abc import Mapping
from typing import Any from typing import Any
from fastapi import HTTPException, status from fastapi import HTTPException
from services.database_service_base import DatabaseServiceBase from services.database_service_base import DatabaseServiceBase
from banban.service.message_audio_storage import MessageAudioStorageService, MessageAudioStorageError
try: try:
from banban.dao.im import ImDAO, DeviceIdentity, ConversationMessageCreateResult from banban.dao.im import ImDAO, DeviceIdentity, ConversationMessageCreateResult
@@ -43,6 +45,12 @@ def participant_type_name(participant_type: int) -> str:
return PARTICIPANT_TYPE_NAMES.get(participant_type, f"unknown_{participant_type}") return PARTICIPANT_TYPE_NAMES.get(participant_type, f"unknown_{participant_type}")
def build_device_audio_client_msg_id(*, device_id: str, target_device_id: str, audio_url: str) -> str:
raw = f"{device_id}|{target_device_id}|{audio_url}"
digest = hashlib.sha256(raw.encode("utf-8")).hexdigest()
return f"device-audio-{digest[:32]}"
def normalize_content_json(value: Any) -> dict[str, Any] | None: def normalize_content_json(value: Any) -> dict[str, Any] | None:
if value is None: if value is None:
return None return None
@@ -85,9 +93,24 @@ def row_to_message_item(row: Mapping[str, Any]) -> ChildConversationMessageItem:
) )
async def present_message_item(
row: Mapping[str, Any],
*,
audio_storage: MessageAudioStorageService,
) -> ChildConversationMessageItem:
item = row_to_message_item(row)
if item.content_type == 2 and item.media_file_key:
try:
item.media_file_key = await audio_storage.get_audio_url(item.media_file_key)
except MessageAudioStorageError:
pass
return item
class ImService(DatabaseServiceBase): class ImService(DatabaseServiceBase):
def __init__(self): def __init__(self):
super().__init__(service_name="im_service") super().__init__(service_name="im_service")
self.audio_storage = MessageAudioStorageService()
async def assert_parent_child_access(self, *, user_id: int, child_id: int) -> Mapping[str, Any]: async def assert_parent_child_access(self, *, user_id: int, child_id: int) -> Mapping[str, Any]:
db_session = await self.get_session() db_session = await self.get_session()
@@ -105,6 +128,34 @@ class ImService(DatabaseServiceBase):
finally: finally:
await db_session.close() await db_session.close()
async def _build_message_create_result(
self,
*,
dao: ImDAO,
idempotent: bool,
conversation_id: int,
conversation_type: int,
client_msg_id: str,
) -> ConversationMessageCreateResult:
message_row = await dao._get_message_by_conversation_client_id(
conversation_id=conversation_id,
client_msg_id=client_msg_id,
)
if not message_row:
raise RuntimeError("message was not found after insert")
presented_message = await present_message_item(
message_row,
audio_storage=self.audio_storage,
)
return ConversationMessageCreateResult(
idempotent=idempotent,
conversation_id=conversation_id,
conversation_type=conversation_type,
message=presented_message,
)
async def create_parent_child_message( async def create_parent_child_message(
self, self,
*, *,
@@ -137,18 +188,12 @@ class ImService(DatabaseServiceBase):
receiver_avatar_snapshot=None, receiver_avatar_snapshot=None,
payload=payload, payload=payload,
) )
message_row = await dao._get_message_by_conversation_client_id( return await self._build_message_create_result(
conversation_id=conversation_id, dao=dao,
client_msg_id=payload.client_msg_id,
)
if not message_row:
raise RuntimeError("message was not found after insert")
return ConversationMessageCreateResult(
idempotent=idempotent, idempotent=idempotent,
conversation_id=conversation_id, conversation_id=conversation_id,
conversation_type=PARENT_CHILD_CONVERSATION_TYPE, conversation_type=PARENT_CHILD_CONVERSATION_TYPE,
message=row_to_message_item(message_row), client_msg_id=payload.client_msg_id,
) )
except Exception: except Exception:
await db_session.rollback() await db_session.rollback()
@@ -156,21 +201,13 @@ class ImService(DatabaseServiceBase):
finally: finally:
await db_session.close() await db_session.close()
async def create_device_message( async def _create_device_message_with_payload(
self, self,
*, *,
device_id: str, dao: ImDAO,
serial_number: str, device_identity: DeviceIdentity,
payload: DeviceMessageCreateRequest, payload: DeviceMessageCreateRequest,
) -> tuple[DeviceIdentity, ConversationMessageCreateResult]: ) -> ConversationMessageCreateResult:
db_session = await self.get_session()
try:
dao = ImDAO(db_session)
device_identity = await dao.authenticate_device_identity(
device_id=device_id,
serial_number=serial_number,
)
if payload.conversation_type == CHILD_PEER_CONVERSATION_TYPE: if payload.conversation_type == CHILD_PEER_CONVERSATION_TYPE:
if payload.peer_child_id == device_identity.child_id: if payload.peer_child_id == device_identity.child_id:
raise HTTPException(status_code=400, detail="peer_child_id must be different from current child") raise HTTPException(status_code=400, detail="peer_child_id must be different from current child")
@@ -223,18 +260,61 @@ class ImService(DatabaseServiceBase):
payload=payload, payload=payload,
) )
message_row = await dao._get_message_by_conversation_client_id( return await self._build_message_create_result(
conversation_id=conversation_id, dao=dao,
client_msg_id=payload.client_msg_id,
)
if not message_row:
raise RuntimeError("message was not found after insert")
result = ConversationMessageCreateResult(
idempotent=idempotent, idempotent=idempotent,
conversation_id=conversation_id, conversation_id=conversation_id,
conversation_type=payload.conversation_type, conversation_type=payload.conversation_type,
message=row_to_message_item(message_row), client_msg_id=payload.client_msg_id,
)
async def create_device_message(
self,
*,
device_id: str,
serial_number: str,
payload: DeviceMessageCreateRequest | None = None,
target_device_id: str | None = None,
audio_url: str | None = None,
) -> tuple[DeviceIdentity, ConversationMessageCreateResult]:
if payload is None and (not target_device_id or not audio_url):
raise ValueError("payload or target_device_id/audio_url is required")
if payload is not None and (target_device_id is not None or audio_url is not None):
raise ValueError("payload and target_device_id/audio_url cannot be used together")
db_session = await self.get_session()
try:
dao = ImDAO(db_session)
device_identity = await dao.authenticate_device_identity(
device_id=device_id,
serial_number=serial_number,
)
resolved_payload = payload
if resolved_payload is None:
target_device_identity = await dao.get_device_by_id(device_id=target_device_id)
resolved_payload = DeviceMessageCreateRequest(
conversation_type=CHILD_PEER_CONVERSATION_TYPE,
peer_child_id=target_device_identity.child_id,
content_type=2,
media_file_key=audio_url,
media_mime_type="audio/mpeg",
client_msg_id=build_device_audio_client_msg_id(
device_id=device_id,
target_device_id=target_device_id,
audio_url=audio_url,
),
ext_json={
"source": "device_audio_message",
"source_device_id": device_id,
"target_device_id": target_device_id,
},
)
result = await self._create_device_message_with_payload(
dao=dao,
device_identity=device_identity,
payload=resolved_payload,
) )
return device_identity, result return device_identity, result
except Exception: except Exception:
@@ -251,55 +331,5 @@ class ImService(DatabaseServiceBase):
finally: finally:
await db_session.close() await db_session.close()
'''
Todo 创建设备消息, 还不完善
'''
async def create_device_message(
self,
*,
device_id: str,
serial_number: str,
target_device_id: str,
audio_url: str,
):
db_session = await self.get_session()
try:
dao = ImDAO(db_session)
device_identity = await dao.authenticate_device_identity(
device_id=device_id,
serial_number=serial_number,
)
target_device_identity = await dao.get_device_by_id(device_id=target_device_id)
sender_child_row = await dao.assert_child_exists(child_id=device_identity.child_id)
receiver_child_row = await dao.assert_child_exists(child_id=target_device_identity.child_id)
conversation_id, idempotent = await dao.create_message(
conversation_type=PARENT_CHILD_CONVERSATION_TYPE,
participant_a_type=CHILD_PARTICIPANT_TYPE,
participant_a_id=str(device_identity.child_id),
participant_b_type=PARENT_PARTICIPANT_TYPE,
participant_b_id=str(receiver_child_row.child_id),
pair_key=f"{device_identity.child_id}:{target_device_identity.child_id}",
sender_type=CHILD_PARTICIPANT_TYPE,
sender_id=str(device_identity.child_id),
receiver_type=PARENT_PARTICIPANT_TYPE,
receiver_id=str(receiver_child_row.child_id),
sender_name_snapshot=sender_child_row["child_name"],
sender_avatar_snapshot=None,
receiver_name_snapshot=receiver_child_row["child_name"],
receiver_avatar_snapshot=None,
payload=None,
)
return device_identity
except Exception:
await db_session.rollback()
raise
finally:
await db_session.close()
# 创建全局 ImService 实例
im_service = ImService() im_service = ImService()

View File

@@ -0,0 +1,129 @@
import asyncio
from dataclasses import dataclass
from datetime import UTC, datetime
from urllib.parse import urlparse
from uuid import uuid4
try:
from qcloud_cos import CosConfig, CosS3Client
except ModuleNotFoundError: # pragma: no cover - exercised in runtime env
CosConfig = None
CosS3Client = None
from config import settings
class MessageAudioStorageError(Exception):
pass
@dataclass(frozen=True)
class StoredMessageAudio:
file_key: str
public_url: str
class MessageAudioStorageService:
def __init__(self) -> None:
self._client = None
def _assert_ready(self) -> None:
if CosConfig is None or CosS3Client is None:
raise MessageAudioStorageError("COS SDK is not installed")
required_pairs = {
"COS_SECRET_ID": settings.cos_secret_id,
"COS_SECRET_KEY": settings.cos_secret_key,
"COS_REGION": settings.cos_region,
"COS_BUCKET_MESSAGE": settings.cos_bucket_message,
}
missing = [key for key, value in required_pairs.items() if not value]
if missing:
raise MessageAudioStorageError(f"missing COS message config: {', '.join(missing)}")
def _get_client(self):
if self._client is None:
config = CosConfig(
Region=settings.cos_region,
SecretId=settings.cos_secret_id,
SecretKey=settings.cos_secret_key,
Scheme="https",
)
self._client = CosS3Client(config)
return self._client
def _build_key(self, *, device_id: str, extension: str) -> str:
prefix = settings.cos_message_prefix.strip("/") or "messages/audio"
now = datetime.now(UTC)
return (
f"{prefix}/{device_id}/{now.strftime('%Y/%m/%d')}/"
f"{uuid4().hex}.{extension}"
)
def _build_public_url(self, *, file_key: str) -> str:
base_url = settings.cos_public_base_url.strip().rstrip("/")
if not base_url:
base_url = f"https://{settings.cos_bucket_message}.cos.{settings.cos_region}.myqcloud.com"
return f"{base_url}/{file_key.lstrip('/')}"
def _normalize_file_key(self, file_key_or_url: str) -> str:
value = (file_key_or_url or "").strip()
if not value:
raise MessageAudioStorageError("audio file key is required")
if value.startswith("http://") or value.startswith("https://"):
parsed = urlparse(value)
path = parsed.path.lstrip("/")
if not path:
raise MessageAudioStorageError("audio file key is invalid")
return path
return value.lstrip("/")
async def upload_audio(
self,
*,
device_id: str,
content: bytes,
content_type: str = "audio/mpeg",
extension: str = "mp3",
) -> StoredMessageAudio:
self._assert_ready()
if not content:
raise MessageAudioStorageError("audio content is empty")
file_key = self._build_key(device_id=device_id, extension=extension)
await asyncio.to_thread(
self._upload_audio_sync,
file_key=file_key,
content=content,
content_type=content_type,
)
return StoredMessageAudio(
file_key=file_key,
public_url=self._build_public_url(file_key=file_key),
)
async def get_audio_url(self, file_key_or_url: str) -> str:
self._assert_ready()
normalized_key = self._normalize_file_key(file_key_or_url)
return await asyncio.to_thread(
self._get_client().get_presigned_url,
Bucket=settings.cos_bucket_message,
Key=normalized_key,
Method="GET",
Expired=settings.cos_avatar_url_expire_seconds,
)
def _upload_audio_sync(
self,
*,
file_key: str,
content: bytes,
content_type: str,
) -> None:
self._get_client().put_object(
Bucket=settings.cos_bucket_message,
Body=content,
Key=file_key,
ContentType=content_type,
EnableMD5=False,
)

View File

@@ -89,6 +89,7 @@ class Settings(BaseSettings):
cos_bucket_message: str = Field(default="", validation_alias="COS_BUCKET_MESSAGE") cos_bucket_message: str = Field(default="", validation_alias="COS_BUCKET_MESSAGE")
cos_bucket_ava: str = Field(default="", validation_alias="COS_BUCKET_AVA") cos_bucket_ava: str = Field(default="", validation_alias="COS_BUCKET_AVA")
cos_public_base_url: str = Field(default="", validation_alias="COS_PUBLIC_BASE_URL") cos_public_base_url: str = Field(default="", validation_alias="COS_PUBLIC_BASE_URL")
cos_message_prefix: str = Field(default="messages/audio/", validation_alias="COS_MESSAGE_PREFIX")
cos_avatar_prefix: str = Field(default="avatars/", validation_alias="COS_AVATAR_PREFIX") cos_avatar_prefix: str = Field(default="avatars/", validation_alias="COS_AVATAR_PREFIX")
cos_avatar_url_expire_seconds: int = Field( cos_avatar_url_expire_seconds: int = Field(
default=86400, default=86400,

View File

@@ -1,36 +1,19 @@
import os from banban.service.message_audio_storage import MessageAudioStorageService
import uuid
from config import settings
from utils.logger import session_logger from utils.logger import session_logger
# from utils.audio_denoiser import reduce_background_noise
message_audio_storage_service = MessageAudioStorageService()
async def save_audio_file(audio_data: bytes, device_id: str) -> str: async def save_audio_file(audio_data: bytes, device_id: str) -> str:
""" """Upload device audio to COS and return its object key."""
保存音频数据到 assets/audio 目录
Args:
audio_data: 音频二进制数据
device_id: 设备ID
Returns:
音频文件的相对路径
"""
try: try:
audio_dir = os.path.join(settings.assets_dir, "audio") stored = await message_audio_storage_service.upload_audio(
os.makedirs(audio_dir, exist_ok=True) device_id=device_id,
content=audio_data,
filename = f"{device_id}_{uuid.uuid4().hex[:8]}.mp3" )
filepath = os.path.join(audio_dir, filename) session_logger.info(device_id, "audio", f"audio uploaded to COS: {stored.file_key}")
return stored.file_key
with open(filepath, 'wb') as f:
f.write(audio_data)
# relative_path = f"assets/audio/{filename}"
session_logger.info(device_id, "audio", f"音频文件已保存: {filepath}")
# reduce_background_noise(filepath, relative_path,noise_path='assets/audio/noise_sample.wav',normalize_volume=True)
return filepath
except Exception as e: except Exception as e:
session_logger.error(device_id, "audio", f"保存音频文件时出错: {e}", exc_info=True) session_logger.error(device_id, "audio", f"failed to store audio: {e}", exc_info=True)
raise raise

View File

@@ -4,7 +4,7 @@ import asyncio
from fastapi import WebSocket from fastapi import WebSocket
from handlers.audio_packet_parser import parse_packet from handlers.audio_packet_parser import parse_packet
from handlers.audio_session_handler import handle_websocket_data from handlers.audio_session_handler import handle_websocket_data
from handlers.audio_file_handler import save_audio_file from handlers.audio_file_handler import message_audio_storage_service, save_audio_file
from services.audio_session import audio_session_manager from services.audio_session import audio_session_manager
from services.interrupt_handler import interrupt_handler from services.interrupt_handler import interrupt_handler
from services.task_manager import task_manager from services.task_manager import task_manager
@@ -265,10 +265,13 @@ async def process_cached_audio(device_id: str, target_device_id: str, serial_num
return return
# 保存音频文件 # 保存音频文件
audio_path = await save_audio_file(cached_audio, device_id) audio_file_key = await save_audio_file(cached_audio, device_id)
audio_url = f"http://{settings.server_host}:{settings.server_port}/{audio_path}"
# 将音频URL保存到数据库 im_conversation和im_message # 将音频URL保存到数据库 im_conversation和im_message
await im_conversation_service.create_device_message(device_id=device_id, serial_number=serial_number, target_device_id=target_device_id, audio_url=audio_url) await im_conversation_service.create_device_message(device_id=device_id, serial_number=serial_number, target_device_id=target_device_id, audio_url=audio_file_key)
try:
audio_url = await message_audio_storage_service.get_audio_url(audio_file_key)
except Exception:
audio_url = audio_file_key
# 发送URL给目标设备 # 发送URL给目标设备
# target_websocket = await connection_manager.get_connection(target_device_id) # target_websocket = await connection_manager.get_connection(target_device_id)