From 050bc17f799b1f3521869010001150e6613f31de Mon Sep 17 00:00:00 2001 From: stu2not Date: Tue, 26 May 2026 17:12:51 +0800 Subject: [PATCH] =?UTF-8?q?=E5=90=88=E5=85=A5=E5=AE=B6=E5=BA=AD=E5=85=B1?= =?UTF-8?q?=E4=BA=AB=E4=B8=8E=E8=AE=BE=E5=A4=87=E6=B6=88=E6=81=AF=E8=83=BD?= =?UTF-8?q?=E5=8A=9B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- banban-mini/src/app.config.ts | 1 + banban-mini/src/pages/device/index.tsx | 6 +- .../src/pages/family-invite/index.config.ts | 3 + .../src/pages/family-invite/index.scss | 76 +++ banban-mini/src/pages/family-invite/index.tsx | 103 ++++ banban-mini/src/pages/location/index.tsx | 6 +- banban-mini/src/pages/login/index.tsx | 12 + banban-mini/src/pages/sleep/index.scss | 173 ++++++ banban-mini/src/pages/sleep/index.tsx | 285 ++++++++- banban-mini/src/services/api.ts | 3 + banban-mini/src/services/device.ts | 2 + banban-mini/src/services/family.ts | 71 +++ database/talkingq_shared_schema.sql | 114 ++++ talkingq-url/banban/dao/binding.py | 133 ++++- talkingq-url/banban/dao/child.py | 73 ++- talkingq-url/banban/dao/device.py | 26 +- talkingq-url/banban/dao/device_alarm.py | 3 - talkingq-url/banban/dao/device_setting.py | 24 +- talkingq-url/banban/dao/family.py | 540 ++++++++++++++++++ talkingq-url/banban/dao/im.py | 133 ++++- talkingq-url/banban/dao/location.py | 9 +- talkingq-url/banban/middleware/auth.py | 90 ++- talkingq-url/banban/routers/__init__.py | 2 + talkingq-url/banban/routers/devices.py | 5 +- talkingq-url/banban/routers/family.py | 154 +++++ talkingq-url/banban/routers/roles.py | 5 + talkingq-url/banban/service/binding.py | 8 + talkingq-url/banban/service/device.py | 135 ++++- talkingq-url/banban/service/device_alarm.py | 2 - talkingq-url/banban/service/device_setting.py | 37 +- .../banban/service/device_voice_archive.py | 7 + talkingq-url/banban/service/family.py | 208 +++++++ talkingq-url/banban/service/im.py | 241 ++++++-- .../banban/service/pending_voice_message.py | 52 ++ talkingq-url/database/init_db.py | 158 +++++ talkingq-url/database/models.py | 73 ++- talkingq-url/handlers/mqtt_handler.py | 21 + .../handlers/websocket_message_handler.py | 1 + talkingq-url/mysql/init/02-init.sql | 59 ++ talkingq-url/utils/audio_duration.py | 183 ++++++ 40 files changed, 3072 insertions(+), 165 deletions(-) create mode 100644 banban-mini/src/pages/family-invite/index.config.ts create mode 100644 banban-mini/src/pages/family-invite/index.scss create mode 100644 banban-mini/src/pages/family-invite/index.tsx create mode 100644 banban-mini/src/services/family.ts create mode 100644 talkingq-url/banban/dao/family.py create mode 100644 talkingq-url/banban/routers/family.py create mode 100644 talkingq-url/banban/service/family.py create mode 100644 talkingq-url/utils/audio_duration.py diff --git a/banban-mini/src/app.config.ts b/banban-mini/src/app.config.ts index 2d74c7b..cdd6667 100644 --- a/banban-mini/src/app.config.ts +++ b/banban-mini/src/app.config.ts @@ -8,6 +8,7 @@ export default defineAppConfig({ "pages/location/index", "pages/sleep/index", "pages/sleep-schedule/index", + "pages/family-invite/index", ], window: { backgroundTextStyle: "light", diff --git a/banban-mini/src/pages/device/index.tsx b/banban-mini/src/pages/device/index.tsx index 20bdeb8..71397ef 100644 --- a/banban-mini/src/pages/device/index.tsx +++ b/banban-mini/src/pages/device/index.tsx @@ -2,6 +2,7 @@ import { View, Text, Slider, Image } from '@tarojs/components' import { useState } from 'react' import Taro, { useDidShow } from '@tarojs/taro' import { getToken } from '@/services/auth' +import { DEVICE_UNAVAILABLE_MESSAGE } from '@/services/api' import { Binding, loadCurrentChildBindingContext } from '@/services/binding' import { Child } from '@/services/child' import { DeviceAlarmItem, DeviceStatus, getDeviceAlarms, getDeviceStatus, setDeviceRemoteSleepWake, setDeviceVolume } from '@/services/device' @@ -157,8 +158,11 @@ export default function Device() { }) } catch (error: any) { setVolumeValue(deviceStatus?.volume ?? fallbackLevel) + const message = error?.message === DEVICE_UNAVAILABLE_MESSAGE + ? '设备不在线或处于休眠中,暂时无法调节音量' + : error?.message || '音量设置失败' Taro.showToast({ - title: error?.message || '音量设置失败', + title: message, icon: 'none', }) } finally { diff --git a/banban-mini/src/pages/family-invite/index.config.ts b/banban-mini/src/pages/family-invite/index.config.ts new file mode 100644 index 0000000..e336c50 --- /dev/null +++ b/banban-mini/src/pages/family-invite/index.config.ts @@ -0,0 +1,3 @@ +export default definePageConfig({ + navigationBarTitleText: '家庭邀请', +}) diff --git a/banban-mini/src/pages/family-invite/index.scss b/banban-mini/src/pages/family-invite/index.scss new file mode 100644 index 0000000..d0f071d --- /dev/null +++ b/banban-mini/src/pages/family-invite/index.scss @@ -0,0 +1,76 @@ +.family-invite-page { + min-height: 100%; + background: #F5F7FA; + padding: 96px 24px 48px; + box-sizing: border-box; +} + +.invite-panel { + background: #FFFFFF; + border-radius: 20px; + padding: 32px 28px; + box-shadow: 0 2px 12px rgba(0, 0, 0, 0.04); +} + +.invite-title { + display: block; + font-size: 38px; + font-weight: 700; + color: #1A1A1A; + margin-bottom: 24px; +} + +.invite-desc { + display: block; + font-size: 28px; + line-height: 1.6; + color: #666666; + + &.danger { + color: #E5484D; + } +} + +.invite-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 24px; + padding: 22px 0; + border-bottom: 1px solid #F2F2F2; +} + +.invite-label { + flex-shrink: 0; + font-size: 28px; + color: #666666; +} + +.invite-value { + min-width: 0; + font-size: 29px; + color: #1A1A1A; + font-weight: 600; + text-align: right; + word-break: break-all; +} + +.invite-action { + height: 92px; + margin-top: 36px; + border-radius: 18px; + background: #FF8C42; + display: flex; + align-items: center; + justify-content: center; + + &.disabled { + background: #CBD5E1; + } +} + +.invite-action-text { + font-size: 30px; + color: #FFFFFF; + font-weight: 700; +} diff --git a/banban-mini/src/pages/family-invite/index.tsx b/banban-mini/src/pages/family-invite/index.tsx new file mode 100644 index 0000000..9c46431 --- /dev/null +++ b/banban-mini/src/pages/family-invite/index.tsx @@ -0,0 +1,103 @@ +import { View, Text } from '@tarojs/components' +import Taro, { useDidShow, useRouter } from '@tarojs/taro' +import { useState } from 'react' + +import { getToken } from '@/services/auth' +import { acceptFamilyInvitation, FamilyInvitation, getFamilyInvitation } from '@/services/family' +import './index.scss' + +export default function FamilyInvite() { + const router = useRouter() + const inviteToken = String(router.params?.token || '').trim() + const redirectUrl = `/pages/family-invite/index?token=${inviteToken}` + const [loading, setLoading] = useState(true) + const [joining, setJoining] = useState(false) + const [invitation, setInvitation] = useState(null) + const [errorText, setErrorText] = useState('') + + useDidShow(() => { + void loadInvitation() + }) + + const loadInvitation = async () => { + if (!getToken()) { + Taro.setStorageSync('postLoginRedirect', redirectUrl) + Taro.reLaunch({ url: '/pages/login/index' }) + return + } + if (!inviteToken) { + setErrorText('邀请无效') + setLoading(false) + return + } + + setLoading(true) + setErrorText('') + try { + const nextInvitation = await getFamilyInvitation(inviteToken) + setInvitation(nextInvitation) + } catch (error: any) { + setInvitation(null) + setErrorText(error?.message || '邀请加载失败') + } finally { + setLoading(false) + } + } + + const handleAccept = async () => { + if (!inviteToken || joining) return + + setJoining(true) + try { + await acceptFamilyInvitation(inviteToken) + Taro.showToast({ title: '已加入家庭', icon: 'success' }) + setTimeout(() => { + Taro.switchTab({ url: '/pages/device/index' }) + }, 600) + } catch (error: any) { + Taro.showToast({ + title: error?.message || '加入失败', + icon: 'none', + }) + } finally { + setJoining(false) + } + } + + const targetName = invitation?.child_name || invitation?.device_id || '--' + const statusText = invitation?.status === 1 ? '等待确认加入' : '邀请已失效' + + return ( + + + 家庭共享邀请 + {loading ? ( + 加载中... + ) : errorText ? ( + {errorText} + ) : ( + <> + + 共享对象 + {targetName} + + + 设备 + {invitation?.device_id || '--'} + + + 状态 + {statusText} + + + {joining ? '加入中...' : '加入家庭'} + + + )} + + + ) +} diff --git a/banban-mini/src/pages/location/index.tsx b/banban-mini/src/pages/location/index.tsx index 1de90fb..1851df6 100644 --- a/banban-mini/src/pages/location/index.tsx +++ b/banban-mini/src/pages/location/index.tsx @@ -2,6 +2,7 @@ import { View, Text, Map } from '@tarojs/components' import { useState } from 'react' import Taro, { useDidShow } from '@tarojs/taro' import { getToken } from '@/services/auth' +import { DEVICE_UNAVAILABLE_MESSAGE } from '@/services/api' import { loadCurrentChildBindingContext } from '@/services/binding' import { getDeviceOnlineStatus } from '@/services/device' import { @@ -248,8 +249,11 @@ export default function Location() { } setLoading(false) console.error('[location] load failed:', error) + const message = error?.message === DEVICE_UNAVAILABLE_MESSAGE + ? '设备不在线或处于休眠中,暂时无法获取位置' + : error?.message || '位置更新失败' Taro.showToast({ - title: error?.message || '位置更新失败', + title: message, icon: 'none', }) } finally { diff --git a/banban-mini/src/pages/login/index.tsx b/banban-mini/src/pages/login/index.tsx index 810a2b3..379311b 100644 --- a/banban-mini/src/pages/login/index.tsx +++ b/banban-mini/src/pages/login/index.tsx @@ -10,6 +10,12 @@ export default function Login() { useEffect(() => { if (getToken()) { + const redirectUrl = String(Taro.getStorageSync('postLoginRedirect') || '').trim() + if (redirectUrl) { + Taro.removeStorageSync('postLoginRedirect') + Taro.reLaunch({ url: redirectUrl }) + return + } Taro.reLaunch({ url: '/pages/device/index' }) return } @@ -64,6 +70,12 @@ export default function Login() { Taro.showToast({ title: '登录成功', icon: 'success' }) setTimeout(() => { + const redirectUrl = String(Taro.getStorageSync('postLoginRedirect') || '').trim() + if (redirectUrl) { + Taro.removeStorageSync('postLoginRedirect') + Taro.reLaunch({ url: redirectUrl }) + return + } Taro.reLaunch({ url: '/pages/device/index' }) }, 300) } catch (error: any) { diff --git a/banban-mini/src/pages/sleep/index.scss b/banban-mini/src/pages/sleep/index.scss index 0088242..ec0c4f6 100644 --- a/banban-mini/src/pages/sleep/index.scss +++ b/banban-mini/src/pages/sleep/index.scss @@ -384,6 +384,10 @@ max-height: 74vh; } +.family-card { + max-height: 76vh; +} + .device-switch-empty { display: block; font-size: 28px; @@ -401,6 +405,11 @@ overflow-y: auto; } +.family-list { + max-height: 44vh; + overflow-y: auto; +} + .device-switch-footer { margin-top: 20px; } @@ -514,6 +523,10 @@ &.danger { background: #E5484D; } + + &.muted { + background: #94A3B8; + } } .device-switch-name { @@ -541,3 +554,163 @@ color: #999999; } } + +.family-summary { + padding: 20px 22px; + border-radius: 18px; + background: #F7F8FA; + margin-bottom: 18px; +} + +.family-summary-text { + display: block; + font-size: 30px; + color: #1A1A1A; + font-weight: 700; +} + +.family-summary-subtitle { + display: block; + margin-top: 8px; + font-size: 24px; + line-height: 1.5; + color: #666666; +} + +.family-member-item { + display: flex; + align-items: center; + gap: 16px; + padding: 20px 0; + border-bottom: 1px solid #F2F2F2; + + &:last-child { + border-bottom: none; + } +} + +.family-member-avatar { + width: 72px; + height: 72px; + border-radius: 18px; + background: #FEF3E8; + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; + flex-shrink: 0; +} + +.family-member-avatar-img { + width: 100%; + height: 100%; +} + +.family-member-avatar-text { + font-size: 36px; +} + +.family-member-main { + flex: 1; + min-width: 0; +} + +.family-member-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.family-member-name { + min-width: 0; + font-size: 28px; + font-weight: 700; + color: #1A1A1A; + word-break: break-all; +} + +.family-member-role { + display: block; + margin-top: 8px; + font-size: 24px; + color: #666666; +} + +.family-member-remove { + flex-shrink: 0; + font-size: 26px; + color: #E5484D; + font-weight: 600; +} + +.family-primary-action, +.family-danger-action { + height: 84px; + margin-top: 22px; + border-radius: 18px; + display: flex; + align-items: center; + justify-content: center; + + &.disabled { + opacity: 0.5; + } +} + +.family-primary-action { + background: #FFF2E7; +} + +.family-invite-actions { + margin-top: 22px; +} + +.family-share-button { + width: 100%; + padding: 0; + border: none; + line-height: normal; + + &::after { + border: none; + } +} + +.family-invite-actions .family-primary-action { + margin-top: 0; +} + +.family-invite-tip { + display: block; + margin-top: 14px; + font-size: 24px; + line-height: 1.5; + color: #666666; + text-align: center; +} + +.family-copy-action { + display: block; + margin-top: 14px; + font-size: 26px; + color: #FF8C42; + font-weight: 600; + text-align: center; +} + +.family-danger-action { + background: #FFF1F2; +} + +.family-primary-action-text { + font-size: 28px; + color: #FF8C42; + font-weight: 700; +} + +.family-danger-action-text { + font-size: 28px; + color: #E5484D; + font-weight: 700; +} diff --git a/banban-mini/src/pages/sleep/index.tsx b/banban-mini/src/pages/sleep/index.tsx index 61710a0..56f176c 100644 --- a/banban-mini/src/pages/sleep/index.tsx +++ b/banban-mini/src/pages/sleep/index.tsx @@ -1,7 +1,9 @@ -import { View, Text, Image, Input } from '@tarojs/components' +import { View, Text, Image, Input, Button } from '@tarojs/components' import { useState } from 'react' -import Taro, { useDidShow } from '@tarojs/taro' +import Taro, { useDidShow, useShareAppMessage } from '@tarojs/taro' import { clearToken, getToken } from '@/services/auth' +import { DEVICE_UNAVAILABLE_MESSAGE } from '@/services/api' +import { getCurrentUserId } from '@/services/session' import { BindingListItem, clearSelectedBindingDeviceId, @@ -10,6 +12,14 @@ import { unbindDevice, } from '@/services/binding' import { Child, clearSelectedChildId, createChild, deleteChild, setSelectedChildId, updateChild } from '@/services/child' +import { + createFamilyInvitation, + FamilyMember, + FamilyMemberListResponse, + getFamilyMembers, + leaveFamily, + removeFamilyMember, +} from '@/services/family' import { DeviceCurrentRole, DeviceFirmwareStatus, @@ -47,10 +57,17 @@ export default function Sleep() { const [isUpdatingFirmware, setIsUpdatingFirmware] = useState(false) const [isLoadingRoles, setIsLoadingRoles] = useState(false) const [isUpdatingRole, setIsUpdatingRole] = useState(false) + const [familyInfo, setFamilyInfo] = useState(null) + const [isLoadingFamily, setIsLoadingFamily] = useState(false) + const [isCreatingInvite, setIsCreatingInvite] = useState(false) + const [isUpdatingFamily, setIsUpdatingFamily] = useState(false) + const [familyInvitePath, setFamilyInvitePath] = useState('') + const [familyInviteExpiresAt, setFamilyInviteExpiresAt] = useState('') const [showModal, setShowModal] = useState(false) const [showChildModal, setShowChildModal] = useState(false) const [showDeleteChildModal, setShowDeleteChildModal] = useState(false) const [showRoleModal, setShowRoleModal] = useState(false) + const [showFamilyModal, setShowFamilyModal] = useState(false) const [modalType, setModalType] = useState<'add' | 'edit'>('add') const [childName, setChildName] = useState('') const [editingChildId, setEditingChildId] = useState(null) @@ -60,6 +77,14 @@ export default function Sleep() { void loadData() }) + useShareAppMessage(() => { + const targetName = currentChild?.child_name || binding?.device_id || '孩子' + return { + title: `邀请你加入${targetName}的家庭共享`, + path: familyInvitePath || '/pages/device/index', + } + }) + const loadData = async () => { if (!getToken()) { Taro.reLaunch({ url: '/pages/login/index' }) @@ -77,6 +102,7 @@ export default function Sleep() { setParentInfo(Taro.getStorageSync('userInfo') || {}) void loadFirmwareStatus(nextBinding?.device_id) void loadRoleData(nextBinding?.device_id) + void loadFamilyData(nextBinding?.device_id) } catch (error: any) { console.error('[manage] load failed:', error) Taro.showToast({ @@ -144,6 +170,135 @@ export default function Sleep() { } } + const loadFamilyData = async (deviceId?: string | null) => { + const normalizedDeviceId = String(deviceId || '').trim() + if (!normalizedDeviceId) { + setFamilyInfo(null) + setFamilyInvitePath('') + setFamilyInviteExpiresAt('') + setIsLoadingFamily(false) + return + } + + setFamilyInvitePath('') + setFamilyInviteExpiresAt('') + setIsLoadingFamily(true) + try { + const nextFamilyInfo = await getFamilyMembers(normalizedDeviceId) + setFamilyInfo(nextFamilyInfo) + } catch (error: any) { + console.error('[manage] family load failed:', error) + setFamilyInfo(null) + setFamilyInvitePath('') + setFamilyInviteExpiresAt('') + if (showFamilyModal) { + Taro.showToast({ + title: error?.message || '家庭成员加载失败', + icon: 'none', + }) + } + } finally { + setIsLoadingFamily(false) + } + } + + const handleOpenFamilyModal = () => { + if (!binding?.device_id) { + Taro.showToast({ title: '当前没有可用设备', icon: 'none' }) + return + } + setShowFamilyModal(true) + void loadFamilyData(binding.device_id) + } + + const handleCreateFamilyInvite = async () => { + if (!binding?.device_id || isCreatingInvite) return + + setIsCreatingInvite(true) + try { + const invitation = await createFamilyInvitation(binding.device_id) + const invitePath = `/pages/family-invite/index?token=${invitation.invite_token}` + setFamilyInvitePath(invitePath) + setFamilyInviteExpiresAt(invitation.expires_at) + Taro.showToast({ title: '邀请已生成,请发送', icon: 'none' }) + } catch (error: any) { + Taro.showToast({ + title: error?.message || '创建邀请失败', + icon: 'none', + }) + } finally { + setIsCreatingInvite(false) + } + } + + const handleCopyFamilyInvite = () => { + if (!familyInvitePath) return + Taro.setClipboardData({ + data: familyInvitePath, + success: () => { + Taro.showToast({ title: '邀请路径已复制', icon: 'success' }) + }, + }) + } + + const handleRemoveFamilyMember = (member: FamilyMember) => { + if (!binding?.device_id || member.is_owner || isUpdatingFamily) return + + Taro.showModal({ + title: '移除成员', + content: `确定要移除 ${member.nickname || '该成员'} 吗?`, + confirmText: '移除', + confirmColor: '#E5484D', + success: async (result) => { + if (!result.confirm || !binding?.device_id) return + + setIsUpdatingFamily(true) + try { + await removeFamilyMember(binding.device_id, member.user_id) + Taro.showToast({ title: '已移除', icon: 'success' }) + await loadFamilyData(binding.device_id) + } catch (error: any) { + Taro.showToast({ + title: error?.message || '移除失败', + icon: 'none', + }) + } finally { + setIsUpdatingFamily(false) + } + }, + }) + } + + const handleLeaveFamily = () => { + if (!binding?.device_id || isUpdatingFamily) return + + Taro.showModal({ + title: '退出家庭', + content: '退出后将不能继续查看和操作这台设备。', + confirmText: '退出', + confirmColor: '#E5484D', + success: async (result) => { + if (!result.confirm || !binding?.device_id) return + + setIsUpdatingFamily(true) + try { + await leaveFamily(binding.device_id) + clearSelectedBindingDeviceId() + Taro.showToast({ title: '已退出', icon: 'success' }) + setShowFamilyModal(false) + await loadData() + } catch (error: any) { + Taro.showToast({ + title: error?.message || '退出失败', + icon: 'none', + }) + } finally { + setIsUpdatingFamily(false) + } + }, + }) + } + const handleOpenModal = (type: 'add' | 'edit', child?: Child) => { setModalType(type) setChildName(child?.child_name || '') @@ -169,6 +324,7 @@ export default function Sleep() { setBinding(nextBinding) void loadFirmwareStatus(nextBinding?.device_id) void loadRoleData(nextBinding?.device_id) + void loadFamilyData(nextBinding?.device_id) setShowChildModal(false) Taro.showToast({ title: '已切换当前孩子', @@ -262,7 +418,7 @@ export default function Sleep() { const onlineStatus = await getDeviceOnlineStatus(binding.device_id) if (onlineStatus && !onlineStatus.online) { Taro.showToast({ - title: '设备不在线,暂时无法发送更新指令', + title: '设备不在线或处于休眠中,暂时无法发送更新指令', icon: 'none', }) return @@ -275,8 +431,11 @@ export default function Sleep() { icon: 'success', }) } catch (error: any) { + const message = error?.message === DEVICE_UNAVAILABLE_MESSAGE + ? '设备不在线或处于休眠中,暂时无法发送更新指令' + : error?.message || '更新失败,请重试' Taro.showToast({ - title: error?.message || '更新失败,请重试', + title: message, icon: 'none', }) } finally { @@ -403,6 +562,11 @@ export default function Sleep() { return } + if (item.name === '家庭成员') { + handleOpenFamilyModal() + return + } + if (item.name === '解除设备绑定') { handleUnbind() } @@ -422,6 +586,8 @@ export default function Sleep() { const parentDisplayName = parentInfo.nickname?.trim() || '家长' const currentFirmwareLabel = firmwareStatus?.current_version || '--' const latestFirmwareLabel = firmwareStatus?.latest_version || '--' + const currentUserId = getCurrentUserId() + const isFamilyOwner = familyInfo?.current_user_role === 'owner' const canUpdateFirmware = Boolean(binding?.device_id && firmwareStatus?.can_update && !isLoadingFirmware && !isUpdatingFirmware) const firmwareActionText = isLoadingFirmware ? '查询中' : isUpdatingFirmware ? '发送中' : '更新' const roleValue = !binding?.device_id @@ -429,6 +595,11 @@ export default function Sleep() { : isLoadingRoles ? '查询中' : deviceRole?.name || '未设置' + const familyValue = !binding?.device_id + ? '未绑定' + : isLoadingFamily + ? '查询中' + : `${familyInfo?.total || 1}/4 人` const firmwareSubtitle = !binding?.device_id ? '绑定设备后可查看系统版本' : isLoadingFirmware @@ -481,6 +652,14 @@ export default function Sleep() { arrow: true, disabled: !binding, }, + { + icon: require('../../assets/tab-icons/rings.png'), + iconBgClass: 'green', + name: '家庭成员', + value: familyValue, + arrow: true, + disabled: !binding, + }, { icon: require('../../assets/tab-icons/broken-rings.png'), iconBgClass: 'red', @@ -744,6 +923,104 @@ export default function Sleep() { )} + {showFamilyModal && ( + setShowFamilyModal(false)}> + { + event.stopPropagation() + }} + > + 家庭成员 + {isLoadingFamily ? ( + 成员加载中... + ) : !familyInfo ? ( + 当前没有成员信息 + ) : ( + <> + + {familyInfo.total}/{familyInfo.max_members} 人 + + {isFamilyOwner ? '主成员可添加和移除成员' : '成员可查看和操作设备'} + + + + {familyInfo.items.map((member) => { + const canRemove = isFamilyOwner && !member.is_owner && !isUpdatingFamily + const isMe = currentUserId === member.user_id + return ( + + + {member.avatar_url ? ( + + ) : ( + 👤 + )} + + + + {member.nickname || `成员 ${member.user_id}`} + + {member.is_owner && 主成员} + {isMe && } + + + {member.is_owner ? '设备归属账号' : '家庭共享成员'} + + {canRemove && ( + handleRemoveFamilyMember(member)}> + 移除 + + )} + + ) + })} + + {isFamilyOwner ? ( + + {familyInvitePath ? ( + <> + + + 邀请有效期至 {familyInviteExpiresAt ? familyInviteExpiresAt.replace('T', ' ').slice(0, 16) : '24 小时内'} + + + 复制邀请路径 + + + ) : ( + = familyInfo.max_members || isCreatingInvite ? 'disabled' : ''}`} + onClick={familyInfo.total < familyInfo.max_members && !isCreatingInvite ? handleCreateFamilyInvite : undefined} + > + {isCreatingInvite ? '生成中...' : '邀请微信好友'} + + )} + + ) : ( + + 退出家庭 + + )} + + )} + + setShowFamilyModal(false)}> + 关闭 + + + + + )} {systemBanner} ) diff --git a/banban-mini/src/services/api.ts b/banban-mini/src/services/api.ts index 407b8ba..1e2ad10 100644 --- a/banban-mini/src/services/api.ts +++ b/banban-mini/src/services/api.ts @@ -4,9 +4,11 @@ import { getCurrentUserId, getToken, handleUnauthorized } from './session' const BASE_URL = API_BASE_URL const REQUEST_TIMEOUT_MS = 10000 +const DEVICE_UNAVAILABLE_MESSAGE = '设备不在线或处于休眠中,暂时无法执行该操作' const ERROR_DETAIL_MAP: Record = { 'Request failed': '请求失败', 'device is already bound, unbind it before binding again': '设备已绑定,请先解绑后再重新绑定', + '设备不在线或在休眠中': DEVICE_UNAVAILABLE_MESSAGE, } class ApiError extends Error { @@ -71,4 +73,5 @@ async function request( } export { request, ApiError, BASE_URL } +export { DEVICE_UNAVAILABLE_MESSAGE } export { getCurrentUserId, getToken } diff --git a/banban-mini/src/services/device.ts b/banban-mini/src/services/device.ts index c558184..36468a3 100644 --- a/banban-mini/src/services/device.ts +++ b/banban-mini/src/services/device.ts @@ -6,6 +6,8 @@ export interface DeviceStatus { child_id?: number | null child_name?: string | null sleep_mode?: number | null + manual_sleep_mode?: number | null + schedule_suppressed_until?: string | null disable_time_start?: string | null disable_time_end?: string | null timezone?: string | null diff --git a/banban-mini/src/services/family.ts b/banban-mini/src/services/family.ts new file mode 100644 index 0000000..7c1de19 --- /dev/null +++ b/banban-mini/src/services/family.ts @@ -0,0 +1,71 @@ +import { request } from './api' + +export interface FamilyMember { + user_id: number + nickname?: string | null + avatar_url?: string | null + role: 'owner' | 'member' + is_owner: boolean + joined_at?: string | null +} + +export interface FamilyMemberListResponse { + device_id: string + max_members: number + total: number + current_user_role: 'owner' | 'member' + items: FamilyMember[] +} + +export interface FamilyInvitation { + invite_token: string + device_id: string + child_id?: number | null + child_name?: string | null + status: number + expires_at: string +} + +export interface FamilyInvitationCreateResponse { + invite_token: string + device_id: string + expires_at: string +} + +export interface FamilyInvitationAcceptResponse { + device_id: string + child_id?: number | null + child_name?: string | null +} + +export async function getFamilyMembers(deviceId: string): Promise { + return request(`/banban/family/devices/${deviceId}/members`) +} + +export async function createFamilyInvitation(deviceId: string): Promise { + return request(`/banban/family/devices/${deviceId}/invitations`, { + method: 'POST', + }) +} + +export async function getFamilyInvitation(inviteToken: string): Promise { + return request(`/banban/family/invitations/${inviteToken}`) +} + +export async function acceptFamilyInvitation(inviteToken: string): Promise { + return request(`/banban/family/invitations/${inviteToken}/accept`, { + method: 'POST', + }) +} + +export async function removeFamilyMember(deviceId: string, memberUserId: number): Promise { + return request(`/banban/family/devices/${deviceId}/members/${memberUserId}`, { + method: 'DELETE', + }) +} + +export async function leaveFamily(deviceId: string): Promise { + return request(`/banban/family/devices/${deviceId}/me`, { + method: 'DELETE', + }) +} diff --git a/database/talkingq_shared_schema.sql b/database/talkingq_shared_schema.sql index d694bf7..48c5bb5 100644 --- a/database/talkingq_shared_schema.sql +++ b/database/talkingq_shared_schema.sql @@ -130,6 +130,21 @@ CREATE TABLE IF NOT EXISTS `device_auth` ( INDEX `idx_batch_id` (`batch_id` ASC) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +CREATE TABLE IF NOT EXISTS `device_imei_mapping` ( + `id` INT NOT NULL AUTO_INCREMENT, + `imei` VARCHAR(64) NOT NULL, + `device_id` VARCHAR(64) NOT NULL, + `serial_number` VARCHAR(64) NOT NULL, + `status` VARCHAR(32) NOT NULL DEFAULT 'pending', + `activated_at` DATETIME NULL, + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE INDEX `imei_UNIQUE` (`imei` ASC), + UNIQUE INDEX `device_id_UNIQUE` (`device_id` ASC), + INDEX `idx_device_imei_mapping_status` (`status` ASC) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + CREATE TABLE IF NOT EXISTS `device_firmware_update` ( `id` INT NOT NULL AUTO_INCREMENT, `device_id` VARCHAR(64) NOT NULL, @@ -257,6 +272,63 @@ CREATE TABLE IF NOT EXISTS `device_bindings` ( ON DELETE SET NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +CREATE TABLE IF NOT EXISTS `device_family_members` ( + `id` BIGINT NOT NULL AUTO_INCREMENT, + `device_id` VARCHAR(64) NOT NULL, + `user_id` BIGINT NOT NULL, + `role` TINYINT NOT NULL DEFAULT 2 COMMENT '1=owner,2=member', + `status` TINYINT NOT NULL DEFAULT 1, + `invited_by_user_id` BIGINT NULL, + `joined_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `removed_at` DATETIME NULL, + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + CONSTRAINT `uq_device_family_member` UNIQUE (`device_id`, `user_id`), + KEY `idx_device_family_members_device_status` (`device_id`, `status`), + KEY `idx_device_family_members_user_status` (`user_id`, `status`), + KEY `idx_device_family_members_invited_by` (`invited_by_user_id`), + CONSTRAINT `fk_device_family_members_device` + FOREIGN KEY (`device_id`) + REFERENCES `device_auth` (`device_id`), + CONSTRAINT `fk_device_family_members_user` + FOREIGN KEY (`user_id`) + REFERENCES `parents` (`user_id`), + CONSTRAINT `fk_device_family_members_invited_by` + FOREIGN KEY (`invited_by_user_id`) + REFERENCES `parents` (`user_id`) + ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `device_family_invitations` ( + `id` BIGINT NOT NULL AUTO_INCREMENT, + `invite_token` CHAR(36) NOT NULL, + `device_id` VARCHAR(64) NOT NULL, + `owner_user_id` BIGINT NOT NULL, + `status` TINYINT NOT NULL DEFAULT 1 COMMENT '1=pending,2=accepted,3=expired,4=cancelled', + `expires_at` DATETIME NOT NULL, + `accepted_by_user_id` BIGINT NULL, + `accepted_at` DATETIME NULL, + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `uq_device_family_invite_token` (`invite_token`), + KEY `idx_device_family_invites_device_status` (`device_id`, `status`), + KEY `idx_device_family_invites_owner_status` (`owner_user_id`, `status`), + KEY `idx_device_family_invites_expires_at` (`expires_at`), + KEY `idx_device_family_invites_accepted_by` (`accepted_by_user_id`), + CONSTRAINT `fk_device_family_invites_device` + FOREIGN KEY (`device_id`) + REFERENCES `device_auth` (`device_id`), + CONSTRAINT `fk_device_family_invites_owner` + FOREIGN KEY (`owner_user_id`) + REFERENCES `parents` (`user_id`), + CONSTRAINT `fk_device_family_invites_accepted_by` + FOREIGN KEY (`accepted_by_user_id`) + REFERENCES `parents` (`user_id`) + ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + CREATE TABLE IF NOT EXISTS `device_bind_sessions` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `bind_token` CHAR(36) NOT NULL, @@ -349,11 +421,16 @@ CREATE TABLE IF NOT EXISTS `device_settings` ( `setting_id` BIGINT NOT NULL AUTO_INCREMENT, `device_id` VARCHAR(64) NOT NULL, `sleep_mode` TINYINT NOT NULL DEFAULT 0, + `manual_sleep_mode` TINYINT NOT NULL DEFAULT 0, + `schedule_suppressed_until` DATETIME NULL, `disable_time_start` TIME NULL, `disable_time_end` TIME NULL, `timezone` VARCHAR(32) NOT NULL DEFAULT 'Asia/Shanghai', `volume` TINYINT UNSIGNED NULL, `brightness` TINYINT UNSIGNED NULL, + `power` TINYINT UNSIGNED NULL, + `signal` TINYINT UNSIGNED NULL, + `version` VARCHAR(64) NULL, `disable_weekdays` VARCHAR(32) NULL, `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, @@ -364,6 +441,20 @@ CREATE TABLE IF NOT EXISTS `device_settings` ( REFERENCES `device_auth` (`device_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +-- Device alarm events are reported by the device side through MQTT msg_id=010. +CREATE TABLE IF NOT EXISTS `device_alarm_events` ( + `alarm_id` BIGINT NOT NULL AUTO_INCREMENT, + `device_id` VARCHAR(64) NOT NULL, + `owner_user_id` BIGINT NULL, + `child_id` BIGINT NULL, + `source_msg_id` VARCHAR(8) NOT NULL DEFAULT '010', + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`alarm_id`), + KEY `idx_device_alarm_device_created` (`device_id`, `created_at`), + KEY `idx_device_alarm_owner_created` (`owner_user_id`, `created_at`), + KEY `idx_device_alarm_child_created` (`child_id`, `created_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + CREATE TABLE IF NOT EXISTS `im_conversations` ( `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, `conversation_type` TINYINT UNSIGNED NOT NULL, @@ -423,6 +514,29 @@ CREATE TABLE IF NOT EXISTS `im_messages` ( ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +-- Persistent unread voice-message index for device NFC owner-card playback. +-- Audio content is still stored by im_messages/media_file_key; this table only stores delivery state. +CREATE TABLE IF NOT EXISTS `device_pending_voice_messages` ( + `id` BIGINT NOT NULL AUTO_INCREMENT, + `target_device_id` VARCHAR(64) NOT NULL, + `sender_device_id` VARCHAR(64) NULL, + `im_message_id` BIGINT NOT NULL, + `media_file_key` VARCHAR(255) NOT NULL, + `audio_url` VARCHAR(512) NULL, + `source` VARCHAR(32) NOT NULL DEFAULT 'unknown', + `status` VARCHAR(32) NOT NULL DEFAULT 'pending', + `delivery_count` INT NOT NULL DEFAULT 0, + `delivered_at` DATETIME NULL, + `listened_at` DATETIME NULL, + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `uq_device_pending_voice_target_msg` (`target_device_id`, `im_message_id`), + KEY `idx_device_pending_voice_target_status_created` (`target_device_id`, `status`, `created_at`), + KEY `idx_device_pending_voice_sender` (`sender_device_id`), + KEY `idx_device_pending_voice_im_message` (`im_message_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + CREATE TABLE IF NOT EXISTS `child_location_current` ( `child_id` BIGINT NOT NULL, `device_id` VARCHAR(64) NOT NULL, diff --git a/talkingq-url/banban/dao/binding.py b/talkingq-url/banban/dao/binding.py index aa9b192..6a1d2cd 100644 --- a/talkingq-url/banban/dao/binding.py +++ b/talkingq-url/banban/dao/binding.py @@ -171,6 +171,40 @@ class BindingDAO(BaseDAO): {"device_id": device_id, "owner_user_id": user_id, "child_id": child_id}, ) + async def _ensure_owner_family_member(self, device_id: str, user_id: int) -> None: + await self.execute( + """ + INSERT INTO device_family_members ( + device_id, + user_id, + role, + status, + invited_by_user_id, + joined_at, + removed_at, + created_at, + updated_at + ) + VALUES ( + :device_id, + :user_id, + 1, + 1, + NULL, + CURRENT_TIMESTAMP, + NULL, + CURRENT_TIMESTAMP, + CURRENT_TIMESTAMP + ) + ON DUPLICATE KEY UPDATE + role = 1, + status = 1, + removed_at = NULL, + updated_at = CURRENT_TIMESTAMP + """, + {"device_id": device_id, "user_id": user_id}, + ) + async def start_bind(self, user_id: int, device_id: str, child_id: Optional[int]) -> tuple[str, datetime]: bind_token = str(uuid.uuid4()) expires_at = datetime.utcnow() + timedelta(minutes=10) @@ -279,6 +313,7 @@ class BindingDAO(BaseDAO): ) await self._bind_device(device_id=device_id, user_id=user_id, child_id=child_id) + await self._ensure_owner_family_member(device_id=device_id, user_id=user_id) await self._insert_bind_history( device_id=device_id, child_id=child_id, @@ -303,6 +338,7 @@ class BindingDAO(BaseDAO): ) await self._bind_device(device_id=device_id, user_id=user_id, child_id=child_id) + await self._ensure_owner_family_member(device_id=device_id, user_id=user_id) await self._insert_bind_history( device_id=device_id, child_id=child_id, @@ -315,6 +351,7 @@ class BindingDAO(BaseDAO): await self._upsert_parent_child_relation(user_id=user_id, child_id=child_id) await self._bind_device(device_id=device_id, user_id=user_id, child_id=child_id) + await self._ensure_owner_family_member(device_id=device_id, user_id=user_id) await self._insert_bind_history( device_id=device_id, child_id=child_id, @@ -327,11 +364,18 @@ class BindingDAO(BaseDAO): return ( await self.execute( """ - SELECT * - FROM device_bindings - WHERE owner_user_id = :user_id - AND status = 1 - ORDER BY bound_at DESC + SELECT db.* + FROM device_bindings AS db + LEFT JOIN device_family_members AS dfm + ON dfm.device_id = db.device_id + AND dfm.user_id = :user_id + AND dfm.status = 1 + WHERE db.status = 1 + AND ( + db.owner_user_id = :user_id + OR dfm.id IS NOT NULL + ) + ORDER BY db.bound_at DESC LIMIT 1 """, {"user_id": user_id}, @@ -340,7 +384,13 @@ class BindingDAO(BaseDAO): async def list_by_user(self, user_id: int, limit: int = 20, cursor: int = None) -> list[Mapping]: params = {"user_id": user_id, "limit": limit + 1} - where = "db.owner_user_id = :user_id AND db.status = 1" + where = """ + db.status = 1 + AND ( + db.owner_user_id = :user_id + OR dfm.id IS NOT NULL + ) + """ if cursor is not None: where += " AND db.id < :cursor" params["cursor"] = cursor @@ -359,6 +409,10 @@ class BindingDAO(BaseDAO): LEFT JOIN children AS c ON c.child_id = db.child_id AND c.status = 1 + LEFT JOIN device_family_members AS dfm + ON dfm.device_id = db.device_id + AND dfm.user_id = :user_id + AND dfm.status = 1 WHERE {where} ORDER BY db.id DESC LIMIT :limit @@ -372,25 +426,57 @@ class BindingDAO(BaseDAO): return ( await self.execute( """ - SELECT * - FROM device_bindings - WHERE device_id = :device_id - AND owner_user_id = :user_id - AND status = 1 + SELECT db.* + FROM device_bindings AS db + LEFT JOIN device_family_members AS dfm + ON dfm.device_id = db.device_id + AND dfm.user_id = :user_id + AND dfm.status = 1 + WHERE db.device_id = :device_id + AND db.status = 1 + AND ( + db.owner_user_id = :user_id + OR dfm.id IS NOT NULL + ) """, {"device_id": device_id, "user_id": user_id}, ) ).mappings().first() + async def get_by_child(self, child_id: int, user_id: int) -> Optional[Mapping]: + return ( + await self.execute( + """ + SELECT db.* + FROM device_bindings AS db + LEFT JOIN device_family_members AS dfm + ON dfm.device_id = db.device_id + AND dfm.user_id = :user_id + AND dfm.status = 1 + WHERE db.child_id = :child_id + AND db.status = 1 + AND ( + db.owner_user_id = :user_id + OR dfm.id IS NOT NULL + ) + LIMIT 1 + """, + {"child_id": child_id, "user_id": user_id}, + ) + ).mappings().first() + async def set_binding_child(self, device_id: str, child_id: int, user_id: int) -> bool: row = await self.get_by_device(device_id=device_id, user_id=user_id) if not row: return False + if int(row["owner_user_id"]) != user_id: + return False if row["child_id"] is not None: return False await self._upsert_parent_child_relation(user_id=user_id, child_id=child_id) await self._bind_device(device_id=device_id, user_id=user_id, child_id=child_id) + await self._ensure_owner_family_member(device_id=device_id, user_id=user_id) await self._insert_bind_history( device_id=device_id, child_id=child_id, @@ -401,7 +487,19 @@ class BindingDAO(BaseDAO): return True async def unbind(self, device_id: str, user_id: int) -> bool: - row = await self.get_by_device(device_id, user_id) + row = ( + await self.execute( + """ + SELECT * + FROM device_bindings + WHERE device_id = :device_id + AND owner_user_id = :user_id + AND status = 1 + LIMIT 1 + """, + {"device_id": device_id, "user_id": user_id}, + ) + ).mappings().first() if not row: return False @@ -409,6 +507,17 @@ class BindingDAO(BaseDAO): "UPDATE device_bindings SET status = 0, unbound_at = CURRENT_TIMESTAMP WHERE id = :id", {"id": row["id"]}, ) + await self.execute( + """ + UPDATE device_family_members + SET status = 0, + removed_at = CURRENT_TIMESTAMP, + updated_at = CURRENT_TIMESTAMP + WHERE device_id = :device_id + AND status = 1 + """, + {"device_id": device_id}, + ) await self.execute( """ diff --git a/talkingq-url/banban/dao/child.py b/talkingq-url/banban/dao/child.py index cd79377..9a7ee02 100644 --- a/talkingq-url/banban/dao/child.py +++ b/talkingq-url/banban/dao/child.py @@ -53,12 +53,24 @@ class ChildDAO(BaseDAO): """ SELECT c.* FROM children AS c - JOIN parent_child_relations AS pcr + LEFT JOIN parent_child_relations AS pcr ON pcr.child_id = c.child_id + AND pcr.user_id = :user_id + AND pcr.status = 1 + LEFT JOIN device_bindings AS db + ON db.child_id = c.child_id + AND db.status = 1 + LEFT JOIN device_family_members AS dfm + ON dfm.device_id = db.device_id + AND dfm.user_id = :user_id + AND dfm.status = 1 WHERE c.child_id = :child_id - AND pcr.user_id = :user_id AND c.status = 1 - AND pcr.status = 1 + AND ( + pcr.id IS NOT NULL + OR db.owner_user_id = :user_id + OR dfm.id IS NOT NULL + ) LIMIT 1 """, {"child_id": child_id, "user_id": user_id}, @@ -67,7 +79,14 @@ class ChildDAO(BaseDAO): async def list_by_parent(self, user_id: int, limit: int = 20, cursor: int = None) -> list[Mapping]: params = {"user_id": user_id, "limit": limit + 1} - where = "pcr.user_id = :user_id AND pcr.status = 1 AND c.status = 1" + where = """ + c.status = 1 + AND ( + pcr.id IS NOT NULL + OR db.owner_user_id = :user_id + OR dfm.id IS NOT NULL + ) + """ if cursor is not None: where += " AND c.child_id < :cursor" params["cursor"] = cursor @@ -75,10 +94,19 @@ class ChildDAO(BaseDAO): rows = ( await self.execute( f""" - SELECT c.* + SELECT DISTINCT c.* FROM children AS c - JOIN parent_child_relations AS pcr + LEFT JOIN parent_child_relations AS pcr ON pcr.child_id = c.child_id + AND pcr.user_id = :user_id + AND pcr.status = 1 + LEFT JOIN device_bindings AS db + ON db.child_id = c.child_id + AND db.status = 1 + LEFT JOIN device_family_members AS dfm + ON dfm.device_id = db.device_id + AND dfm.user_id = :user_id + AND dfm.status = 1 WHERE {where} ORDER BY c.child_id DESC LIMIT :limit @@ -108,6 +136,34 @@ class ChildDAO(BaseDAO): await self.commit() async def has_access(self, child_id: int, user_id: int) -> bool: + result = await self.execute( + """ + SELECT 1 + FROM children AS c + LEFT JOIN parent_child_relations AS pcr + ON pcr.child_id = c.child_id + AND pcr.user_id = :user_id + AND pcr.status = 1 + LEFT JOIN device_bindings AS db + ON db.child_id = c.child_id + AND db.status = 1 + LEFT JOIN device_family_members AS dfm + ON dfm.device_id = db.device_id + AND dfm.user_id = :user_id + AND dfm.status = 1 + WHERE c.child_id = :child_id + AND c.status = 1 + AND ( + pcr.id IS NOT NULL + OR db.owner_user_id = :user_id + OR dfm.id IS NOT NULL + ) + """, + {"child_id": child_id, "user_id": user_id}, + ) + return result.scalar_one_or_none() is not None + + async def has_direct_parent_access(self, child_id: int, user_id: int) -> bool: result = await self.execute( """ SELECT 1 @@ -118,13 +174,14 @@ class ChildDAO(BaseDAO): AND pcr.user_id = :user_id AND c.status = 1 AND pcr.status = 1 - """, + LIMIT 1 + """, {"child_id": child_id, "user_id": user_id}, ) return result.scalar_one_or_none() is not None async def soft_delete_for_parent(self, child_id: int, user_id: int) -> bool: - if not await self.has_access(child_id, user_id): + if not await self.has_direct_parent_access(child_id, user_id): return False await self.execute( diff --git a/talkingq-url/banban/dao/device.py b/talkingq-url/banban/dao/device.py index 467ae70..5951e28 100644 --- a/talkingq-url/banban/dao/device.py +++ b/talkingq-url/banban/dao/device.py @@ -13,10 +13,17 @@ class DeviceDAO(BaseDAO): text( """ SELECT 1 - FROM device_bindings - WHERE device_id = :device_id - AND owner_user_id = :user_id - AND status = 1 + FROM device_bindings AS db + LEFT JOIN device_family_members AS dfm + ON dfm.device_id = db.device_id + AND dfm.user_id = :user_id + AND dfm.status = 1 + WHERE db.device_id = :device_id + AND db.status = 1 + AND ( + db.owner_user_id = :user_id + OR dfm.id IS NOT NULL + ) LIMIT 1 """ ), @@ -128,6 +135,8 @@ class DeviceDAO(BaseDAO): db.child_id, c.child_name, ds.sleep_mode, + ds.manual_sleep_mode, + ds.schedule_suppressed_until, ds.disable_time_start, ds.disable_time_end, ds.timezone, @@ -149,6 +158,10 @@ class DeviceDAO(BaseDAO): cl.server_time, cl.updated_at AS location_updated_at FROM device_bindings AS db + LEFT JOIN device_family_members AS dfm + ON dfm.device_id = db.device_id + AND dfm.user_id = :user_id + AND dfm.status = 1 LEFT JOIN children AS c ON c.child_id = db.child_id AND c.status = 1 @@ -158,8 +171,11 @@ class DeviceDAO(BaseDAO): ON cl.child_id = db.child_id AND cl.device_id = db.device_id WHERE db.device_id = :device_id - AND db.owner_user_id = :user_id AND db.status = 1 + AND ( + db.owner_user_id = :user_id + OR dfm.id IS NOT NULL + ) LIMIT 1 """ ), diff --git a/talkingq-url/banban/dao/device_alarm.py b/talkingq-url/banban/dao/device_alarm.py index ea7faa5..02b3a69 100644 --- a/talkingq-url/banban/dao/device_alarm.py +++ b/talkingq-url/banban/dao/device_alarm.py @@ -53,7 +53,6 @@ class DeviceAlarmDAO(BaseDAO): self, *, device_id: str, - owner_user_id: int, limit: int, ) -> list[Mapping[str, Any]]: result = await self.execute( @@ -72,14 +71,12 @@ class DeviceAlarmDAO(BaseDAO): ON c.child_id = dae.child_id AND c.status = 1 WHERE dae.device_id = :device_id - AND dae.owner_user_id = :owner_user_id ORDER BY dae.created_at DESC, dae.alarm_id DESC LIMIT :limit """ ), { "device_id": device_id, - "owner_user_id": owner_user_id, "limit": limit, }, ) diff --git a/talkingq-url/banban/dao/device_setting.py b/talkingq-url/banban/dao/device_setting.py index 0c451cb..327cf12 100644 --- a/talkingq-url/banban/dao/device_setting.py +++ b/talkingq-url/banban/dao/device_setting.py @@ -1,5 +1,5 @@ from collections.abc import Mapping -from datetime import time +from datetime import datetime, time from typing import Optional from sqlalchemy import text @@ -12,6 +12,8 @@ class DeviceSettingDAO(BaseDAO): self, device_id: str, sleep_mode: int = 0, + manual_sleep_mode: int = 0, + schedule_suppressed_until: Optional[datetime] = None, disable_time_start: Optional[time] = None, disable_time_end: Optional[time] = None, timezone: str = "Asia/Shanghai", @@ -25,17 +27,19 @@ class DeviceSettingDAO(BaseDAO): result = await self.execute( """ INSERT INTO device_settings ( - device_id, sleep_mode, disable_time_start, disable_time_end, + device_id, sleep_mode, manual_sleep_mode, schedule_suppressed_until, disable_time_start, disable_time_end, timezone, volume, brightness, disable_weekdays, `signal`, `version`, power ) VALUES ( - :device_id, :sleep_mode, :disable_time_start, :disable_time_end, + :device_id, :sleep_mode, :manual_sleep_mode, :schedule_suppressed_until, :disable_time_start, :disable_time_end, :timezone, :volume, :brightness, :disable_weekdays, :signal, :version, :power ) """, { "device_id": device_id, "sleep_mode": sleep_mode, + "manual_sleep_mode": manual_sleep_mode, + "schedule_suppressed_until": schedule_suppressed_until, "disable_time_start": disable_time_start, "disable_time_end": disable_time_end, "timezone": timezone, @@ -70,6 +74,9 @@ class DeviceSettingDAO(BaseDAO): self, device_id: str, sleep_mode: Optional[int] = None, + manual_sleep_mode: Optional[int] = None, + schedule_suppressed_until: Optional[datetime] = None, + clear_schedule_suppression: bool = False, disable_time_start: Optional[time] = None, disable_time_end: Optional[time] = None, timezone: Optional[str] = None, @@ -84,6 +91,12 @@ class DeviceSettingDAO(BaseDAO): """ UPDATE device_settings SET sleep_mode = COALESCE(:sleep_mode, sleep_mode), + manual_sleep_mode = COALESCE(:manual_sleep_mode, manual_sleep_mode), + schedule_suppressed_until = CASE + WHEN :clear_schedule_suppression THEN NULL + WHEN :schedule_suppressed_until IS NOT NULL THEN :schedule_suppressed_until + ELSE schedule_suppressed_until + END, disable_time_start = COALESCE(:disable_time_start, disable_time_start), disable_time_end = COALESCE(:disable_time_end, disable_time_end), timezone = COALESCE(:timezone, timezone), @@ -98,6 +111,9 @@ class DeviceSettingDAO(BaseDAO): { "device_id": device_id, "sleep_mode": sleep_mode, + "manual_sleep_mode": manual_sleep_mode, + "schedule_suppressed_until": schedule_suppressed_until, + "clear_schedule_suppression": clear_schedule_suppression, "disable_time_start": disable_time_start, "disable_time_end": disable_time_end, "timezone": timezone, @@ -116,4 +132,4 @@ class DeviceSettingDAO(BaseDAO): "DELETE FROM device_settings WHERE device_id = :device_id", {"device_id": device_id}, ) - await self.commit() \ No newline at end of file + await self.commit() diff --git a/talkingq-url/banban/dao/family.py b/talkingq-url/banban/dao/family.py new file mode 100644 index 0000000..4117a14 --- /dev/null +++ b/talkingq-url/banban/dao/family.py @@ -0,0 +1,540 @@ +from collections.abc import Mapping +from datetime import datetime, timedelta +import uuid + +from sqlalchemy import text + +from banban.dao import BaseDAO + + +FAMILY_ROLE_OWNER = 1 +FAMILY_ROLE_MEMBER = 2 +FAMILY_STATUS_ACTIVE = 1 + +INVITE_STATUS_PENDING = 1 +INVITE_STATUS_ACCEPTED = 2 +INVITE_STATUS_EXPIRED = 3 +INVITE_STATUS_CANCELLED = 4 + +MAX_FAMILY_MEMBERS = 4 + + +class FamilyDAO(BaseDAO): + async def ensure_owner_member(self, *, device_id: str) -> None: + await self.execute( + text( + """ + INSERT INTO device_family_members ( + device_id, + user_id, + role, + status, + invited_by_user_id, + joined_at, + created_at, + updated_at + ) + SELECT + db.device_id, + db.owner_user_id, + :owner_role, + :active_status, + NULL, + COALESCE(db.bound_at, CURRENT_TIMESTAMP), + CURRENT_TIMESTAMP, + CURRENT_TIMESTAMP + FROM device_bindings AS db + LEFT JOIN device_family_members AS dfm + ON dfm.device_id = db.device_id + AND dfm.user_id = db.owner_user_id + WHERE db.device_id = :device_id + AND db.status = 1 + AND dfm.id IS NULL + """ + ), + { + "device_id": device_id, + "owner_role": FAMILY_ROLE_OWNER, + "active_status": FAMILY_STATUS_ACTIVE, + }, + ) + + async def get_binding_for_owner(self, *, device_id: str, user_id: int) -> Mapping | None: + return ( + await self.execute( + text( + """ + SELECT * + FROM device_bindings + WHERE device_id = :device_id + AND owner_user_id = :user_id + AND status = 1 + LIMIT 1 + """ + ), + {"device_id": device_id, "user_id": user_id}, + ) + ).mappings().first() + + async def get_binding_for_access(self, *, device_id: str, user_id: int) -> Mapping | None: + await self.ensure_owner_member(device_id=device_id) + return ( + await self.execute( + text( + """ + SELECT + db.*, + CASE + WHEN db.owner_user_id = :user_id THEN :owner_role + ELSE COALESCE(dfm.role, :member_role) + END AS family_role + FROM device_bindings AS db + LEFT JOIN device_family_members AS dfm + ON dfm.device_id = db.device_id + AND dfm.user_id = :user_id + AND dfm.status = :active_status + WHERE db.device_id = :device_id + AND db.status = 1 + AND ( + db.owner_user_id = :user_id + OR dfm.id IS NOT NULL + ) + LIMIT 1 + """ + ), + { + "device_id": device_id, + "user_id": user_id, + "owner_role": FAMILY_ROLE_OWNER, + "member_role": FAMILY_ROLE_MEMBER, + "active_status": FAMILY_STATUS_ACTIVE, + }, + ) + ).mappings().first() + + async def get_binding_by_child_for_access(self, *, child_id: int, user_id: int) -> Mapping | None: + return ( + await self.execute( + text( + """ + SELECT + db.*, + CASE + WHEN db.owner_user_id = :user_id THEN :owner_role + ELSE COALESCE(dfm.role, :member_role) + END AS family_role + FROM device_bindings AS db + LEFT JOIN device_family_members AS dfm + ON dfm.device_id = db.device_id + AND dfm.user_id = :user_id + AND dfm.status = :active_status + WHERE db.child_id = :child_id + AND db.status = 1 + AND ( + db.owner_user_id = :user_id + OR dfm.id IS NOT NULL + ) + LIMIT 1 + """ + ), + { + "child_id": child_id, + "user_id": user_id, + "owner_role": FAMILY_ROLE_OWNER, + "member_role": FAMILY_ROLE_MEMBER, + "active_status": FAMILY_STATUS_ACTIVE, + }, + ) + ).mappings().first() + + async def has_child_access(self, *, child_id: int, user_id: int) -> bool: + result = await self.execute( + text( + """ + SELECT 1 + FROM children AS c + LEFT JOIN parent_child_relations AS pcr + ON pcr.child_id = c.child_id + AND pcr.user_id = :user_id + AND pcr.status = 1 + LEFT JOIN device_bindings AS db + ON db.child_id = c.child_id + AND db.status = 1 + LEFT JOIN device_family_members AS dfm + ON dfm.device_id = db.device_id + AND dfm.user_id = :user_id + AND dfm.status = :active_status + WHERE c.child_id = :child_id + AND c.status = 1 + AND ( + pcr.id IS NOT NULL + OR db.owner_user_id = :user_id + OR dfm.id IS NOT NULL + ) + LIMIT 1 + """ + ), + { + "child_id": child_id, + "user_id": user_id, + "active_status": FAMILY_STATUS_ACTIVE, + }, + ) + return result.scalar_one_or_none() is not None + + async def get_child_for_access(self, *, child_id: int, user_id: int) -> Mapping | None: + return ( + await self.execute( + text( + """ + SELECT c.* + FROM children AS c + LEFT JOIN parent_child_relations AS pcr + ON pcr.child_id = c.child_id + AND pcr.user_id = :user_id + AND pcr.status = 1 + LEFT JOIN device_bindings AS db + ON db.child_id = c.child_id + AND db.status = 1 + LEFT JOIN device_family_members AS dfm + ON dfm.device_id = db.device_id + AND dfm.user_id = :user_id + AND dfm.status = :active_status + WHERE c.child_id = :child_id + AND c.status = 1 + AND ( + pcr.id IS NOT NULL + OR db.owner_user_id = :user_id + OR dfm.id IS NOT NULL + ) + LIMIT 1 + """ + ), + { + "child_id": child_id, + "user_id": user_id, + "active_status": FAMILY_STATUS_ACTIVE, + }, + ) + ).mappings().first() + + async def list_children_for_access(self, *, user_id: int, limit: int, cursor: int | None) -> list[Mapping]: + params = { + "user_id": user_id, + "limit": limit + 1, + "active_status": FAMILY_STATUS_ACTIVE, + } + cursor_where = "" + if cursor is not None: + cursor_where = "AND child_id < :cursor" + params["cursor"] = cursor + + rows = ( + await self.execute( + text( + f""" + SELECT * + FROM ( + SELECT DISTINCT + c.child_id, + c.child_name, + c.child_gender, + c.child_birthday, + c.status, + c.created_at, + c.updated_at + FROM children AS c + LEFT JOIN parent_child_relations AS pcr + ON pcr.child_id = c.child_id + AND pcr.user_id = :user_id + AND pcr.status = 1 + LEFT JOIN device_bindings AS db + ON db.child_id = c.child_id + AND db.status = 1 + LEFT JOIN device_family_members AS dfm + ON dfm.device_id = db.device_id + AND dfm.user_id = :user_id + AND dfm.status = :active_status + WHERE c.status = 1 + AND ( + pcr.id IS NOT NULL + OR db.owner_user_id = :user_id + OR dfm.id IS NOT NULL + ) + ) AS accessible_children + WHERE 1 = 1 + {cursor_where} + ORDER BY child_id DESC + LIMIT :limit + """ + ), + params, + ) + ).mappings().all() + return list(rows) + + async def list_members(self, *, device_id: str, user_id: int) -> list[Mapping]: + if await self.get_binding_for_access(device_id=device_id, user_id=user_id) is None: + return [] + + rows = ( + await self.execute( + text( + """ + SELECT + dfm.id, + dfm.device_id, + dfm.user_id, + dfm.role, + dfm.status, + dfm.joined_at, + dfm.invited_by_user_id, + p.nickname, + p.avatar_url, + CASE WHEN db.owner_user_id = dfm.user_id THEN 1 ELSE 0 END AS is_owner + FROM device_family_members AS dfm + JOIN device_bindings AS db + ON db.device_id = dfm.device_id + AND db.status = 1 + LEFT JOIN parents AS p + ON p.user_id = dfm.user_id + WHERE dfm.device_id = :device_id + AND dfm.status = :active_status + ORDER BY is_owner DESC, dfm.joined_at ASC, dfm.id ASC + """ + ), + { + "device_id": device_id, + "active_status": FAMILY_STATUS_ACTIVE, + }, + ) + ).mappings().all() + return list(rows) + + async def list_active_member_user_ids(self, *, device_id: str) -> list[int]: + await self.ensure_owner_member(device_id=device_id) + rows = ( + await self.execute( + text( + """ + SELECT user_id + FROM device_family_members + WHERE device_id = :device_id + AND status = :active_status + ORDER BY role ASC, joined_at ASC, id ASC + """ + ), + {"device_id": device_id, "active_status": FAMILY_STATUS_ACTIVE}, + ) + ).mappings().all() + return [int(row["user_id"]) for row in rows] + + async def count_active_members(self, *, device_id: str) -> int: + result = await self.execute( + text( + """ + SELECT COUNT(*) + FROM device_family_members + WHERE device_id = :device_id + AND status = :active_status + """ + ), + {"device_id": device_id, "active_status": FAMILY_STATUS_ACTIVE}, + ) + return int(result.scalar() or 0) + + async def create_invitation(self, *, device_id: str, owner_user_id: int) -> tuple[str, datetime]: + invite_token = str(uuid.uuid4()) + expires_at = datetime.utcnow() + timedelta(hours=24) + await self.execute( + text( + """ + INSERT INTO device_family_invitations ( + invite_token, + device_id, + owner_user_id, + status, + expires_at, + created_at, + updated_at + ) + VALUES ( + :invite_token, + :device_id, + :owner_user_id, + :pending_status, + :expires_at, + CURRENT_TIMESTAMP, + CURRENT_TIMESTAMP + ) + """ + ), + { + "invite_token": invite_token, + "device_id": device_id, + "owner_user_id": owner_user_id, + "pending_status": INVITE_STATUS_PENDING, + "expires_at": expires_at, + }, + ) + return invite_token, expires_at + + async def get_invitation(self, *, invite_token: str) -> Mapping | None: + return ( + await self.execute( + text( + """ + SELECT + dfi.*, + db.child_id, + c.child_name + FROM device_family_invitations AS dfi + JOIN device_bindings AS db + ON db.device_id = dfi.device_id + AND db.status = 1 + LEFT JOIN children AS c + ON c.child_id = db.child_id + AND c.status = 1 + WHERE dfi.invite_token = :invite_token + LIMIT 1 + """ + ), + {"invite_token": invite_token}, + ) + ).mappings().first() + + async def mark_invitation_expired(self, *, invitation_id: int) -> None: + await self.execute( + text( + """ + UPDATE device_family_invitations + SET status = :expired_status, + updated_at = CURRENT_TIMESTAMP + WHERE id = :id + """ + ), + {"id": invitation_id, "expired_status": INVITE_STATUS_EXPIRED}, + ) + + async def accept_invitation(self, *, invitation: Mapping, user_id: int) -> None: + await self.execute( + text( + """ + INSERT INTO device_family_members ( + device_id, + user_id, + role, + status, + invited_by_user_id, + joined_at, + removed_at, + created_at, + updated_at + ) + VALUES ( + :device_id, + :user_id, + :member_role, + :active_status, + :owner_user_id, + CURRENT_TIMESTAMP, + NULL, + CURRENT_TIMESTAMP, + CURRENT_TIMESTAMP + ) + ON DUPLICATE KEY UPDATE + status = VALUES(status), + role = CASE + WHEN role = :owner_role THEN role + ELSE VALUES(role) + END, + invited_by_user_id = VALUES(invited_by_user_id), + joined_at = CURRENT_TIMESTAMP, + removed_at = NULL, + updated_at = CURRENT_TIMESTAMP + """ + ), + { + "device_id": invitation["device_id"], + "user_id": user_id, + "owner_user_id": invitation["owner_user_id"], + "owner_role": FAMILY_ROLE_OWNER, + "member_role": FAMILY_ROLE_MEMBER, + "active_status": FAMILY_STATUS_ACTIVE, + }, + ) + await self.execute( + text( + """ + UPDATE device_family_invitations + SET status = :accepted_status, + accepted_by_user_id = :user_id, + accepted_at = CURRENT_TIMESTAMP, + updated_at = CURRENT_TIMESTAMP + WHERE id = :id + """ + ), + { + "id": invitation["id"], + "user_id": user_id, + "accepted_status": INVITE_STATUS_ACCEPTED, + }, + ) + + async def remove_member(self, *, device_id: str, member_user_id: int, removed_by_user_id: int) -> bool: + owner_binding = await self.get_binding_for_owner(device_id=device_id, user_id=removed_by_user_id) + if owner_binding is None: + return False + if int(owner_binding["owner_user_id"]) == member_user_id: + return False + + result = await self.execute( + text( + """ + UPDATE device_family_members + SET status = 0, + removed_at = CURRENT_TIMESTAMP, + updated_at = CURRENT_TIMESTAMP + WHERE device_id = :device_id + AND user_id = :member_user_id + AND status = :active_status + AND role <> :owner_role + """ + ), + { + "device_id": device_id, + "member_user_id": member_user_id, + "active_status": FAMILY_STATUS_ACTIVE, + "owner_role": FAMILY_ROLE_OWNER, + }, + ) + return bool(result.rowcount) + + async def leave_family(self, *, device_id: str, user_id: int) -> bool: + binding = await self.get_binding_for_access(device_id=device_id, user_id=user_id) + if binding is None: + return False + if int(binding["owner_user_id"]) == user_id: + return False + + result = await self.execute( + text( + """ + UPDATE device_family_members + SET status = 0, + removed_at = CURRENT_TIMESTAMP, + updated_at = CURRENT_TIMESTAMP + WHERE device_id = :device_id + AND user_id = :user_id + AND status = :active_status + AND role <> :owner_role + """ + ), + { + "device_id": device_id, + "user_id": user_id, + "active_status": FAMILY_STATUS_ACTIVE, + "owner_role": FAMILY_ROLE_OWNER, + }, + ) + return bool(result.rowcount) diff --git a/talkingq-url/banban/dao/im.py b/talkingq-url/banban/dao/im.py index 4b792a7..a4ad620 100644 --- a/talkingq-url/banban/dao/im.py +++ b/talkingq-url/banban/dao/im.py @@ -54,10 +54,25 @@ class ImDAO(BaseDAO): text( """ SELECT 1 - FROM parent_child_relations - WHERE user_id = :user_id - AND child_id = :child_id - AND status = 1 + FROM children AS c + LEFT JOIN parent_child_relations AS pcr + ON pcr.child_id = c.child_id + AND pcr.user_id = :user_id + AND pcr.status = 1 + LEFT JOIN device_bindings AS db + ON db.child_id = c.child_id + AND db.status = 1 + LEFT JOIN device_family_members AS dfm + ON dfm.device_id = db.device_id + AND dfm.user_id = :user_id + AND dfm.status = 1 + WHERE c.child_id = :child_id + AND c.status = 1 + AND ( + pcr.id IS NOT NULL + OR db.owner_user_id = :user_id + OR dfm.id IS NOT NULL + ) LIMIT 1 """ ), @@ -144,6 +159,55 @@ class ImDAO(BaseDAO): child_name=row["child_name"], owner_user_id=int(row["owner_user_id"]), ) + + async def get_bound_device_family_identities(self, *, device_id: str) -> list[DeviceOwnerIdentity]: + from banban.dao.family import FamilyDAO + + family_dao = FamilyDAO(self.db) + await family_dao.ensure_owner_member(device_id=device_id) + member_user_ids = await family_dao.list_active_member_user_ids(device_id=device_id) + if not member_user_ids: + return [] + + row = ( + await self.execute( + text( + """ + SELECT + da.device_id, + db.child_id, + c.child_name + FROM device_auth AS da + JOIN device_bindings AS db + ON db.device_id = da.device_id + AND db.status = 1 + LEFT JOIN children AS c + ON c.child_id = db.child_id + AND c.status = 1 + WHERE da.device_id = :device_id + AND da.is_active = 1 + LIMIT 1 + """ + ), + {"device_id": device_id}, + ) + ).mappings().first() + if not row: + from fastapi import HTTPException, status + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid device credentials") + if row["child_id"] is None: + from fastapi import HTTPException + raise HTTPException(status_code=404, detail="device not bound to a child") + + return [ + DeviceOwnerIdentity( + device_id=str(row["device_id"]), + child_id=int(row["child_id"]), + child_name=row["child_name"], + owner_user_id=member_user_id, + ) + for member_user_id in member_user_ids + ] async def get_device_by_id(self, *, device_id: str) -> DeviceIdentity: row = ( @@ -563,6 +627,67 @@ class ImDAO(BaseDAO): ) return result.mappings().first() + async def get_latest_voice_message_received_by_device( + self, + *, + device_id: str, + ) -> Mapping[str, Any] | None: + result = await self.execute( + text( + """ + SELECT + m.id, + m.conversation_id, + m.seq, + m.media_file_key, + m.media_mime_type, + m.media_size_bytes, + m.media_duration_ms, + m.created_at + FROM device_bindings AS db + JOIN im_conversations AS c + ON c.status = 1 + JOIN im_messages AS m + ON m.conversation_id = c.id + AND m.content_type = 2 + AND m.receiver_type = 2 + AND CAST(m.receiver_id AS UNSIGNED) = db.child_id + AND m.deleted_at IS NULL + AND m.media_file_key IS NOT NULL + AND TRIM(m.media_file_key) <> '' + WHERE db.device_id = :device_id + AND db.status = 1 + AND db.child_id IS NOT NULL + ORDER BY m.created_at DESC, m.id DESC + LIMIT 1 + """ + ), + {"device_id": device_id}, + ) + return result.mappings().first() + + async def update_message_media_duration( + self, + *, + message_id: int, + media_duration_ms: int, + ) -> bool: + result = await self.execute( + text( + """ + UPDATE im_messages + SET media_duration_ms = :media_duration_ms + WHERE id = :message_id + AND content_type = 2 + """ + ), + { + "message_id": message_id, + "media_duration_ms": media_duration_ms, + }, + ) + return bool(result.rowcount) + async def _next_primary_key(self, table_name: str) -> int | None: result = await self.execute(text(f"SELECT 1")) return None diff --git a/talkingq-url/banban/dao/location.py b/talkingq-url/banban/dao/location.py index cd9b484..d7bb3c0 100644 --- a/talkingq-url/banban/dao/location.py +++ b/talkingq-url/banban/dao/location.py @@ -43,12 +43,19 @@ class LocationDAO(BaseDAO): db.child_id, c.child_name FROM device_bindings AS db + LEFT JOIN device_family_members AS dfm + ON dfm.device_id = db.device_id + AND dfm.user_id = :user_id + AND dfm.status = 1 LEFT JOIN children AS c ON c.child_id = db.child_id AND c.status = 1 WHERE db.device_id = :device_id - AND db.owner_user_id = :user_id AND db.status = 1 + AND ( + db.owner_user_id = :user_id + OR dfm.id IS NOT NULL + ) LIMIT 1 """ ), diff --git a/talkingq-url/banban/middleware/auth.py b/talkingq-url/banban/middleware/auth.py index 9149399..578adb3 100644 --- a/talkingq-url/banban/middleware/auth.py +++ b/talkingq-url/banban/middleware/auth.py @@ -1,12 +1,11 @@ from collections.abc import Awaitable, Callable import logging -from datetime import datetime, time, timedelta from fastapi import FastAPI, HTTPException, Request from sqlalchemy import text from fastapi.responses import JSONResponse from database.connection import get_db_manager from banban.security import auth_error_response, decode_access_token -from banban.service.device import device_service +from banban.service.device import DEVICE_COMMAND_SLEEP_BLOCKED_DETAIL, device_service NO_AUTH_PATH_PREFIXES = ( "/banban/auth/login", @@ -34,27 +33,19 @@ def _is_no_auth_path(path: str) -> bool: return True return False -def convert_to_time(value): - """将字符串或timedelta转换为time对象""" - if value is None: - return None - if isinstance(value, time): - return value - if isinstance(value, timedelta): - # timedelta 可能表示时间间隔,例如 22:00:00 表示为 22小时 - total_seconds = value.total_seconds() - hours = int(total_seconds // 3600) - minutes = int((total_seconds % 3600) // 60) - seconds = int(total_seconds % 60) - return time(hours, minutes, seconds) - if isinstance(value, str): - # 支持 "HH:MM" 或 "HH:MM:SS" - parts = value.split(':') - if len(parts) == 2: - return datetime.strptime(value, "%H:%M").time() - elif len(parts) == 3: - return datetime.strptime(value, "%H:%M:%S").time() - raise ValueError(f"Unsupported type for time conversion: {type(value)}") +SLEEP_BLOCKED_DEVICE_ACTIONS = { + "volume", + "location", + "firmware/update", +} + + +def _is_device_command_path(path: str) -> bool: + parts = path.strip("/").split("/") + if len(parts) < 4 or parts[0] != "banban" or parts[1] != "devices": + return False + action_path = "/".join(parts[3:]) + return action_path in SLEEP_BLOCKED_DEVICE_ACTIONS def install_auth_middleware(app: FastAPI) -> None: @@ -144,50 +135,33 @@ def install_auth_middleware(app: FastAPI) -> None: await session.close() request.state.user_id = user_id - async def is_device_sleep(device_id: str) -> bool: - db_manager = await get_db_manager() - session = await db_manager.get_session() - try: - row = await device_service.get_device_status( - device_id=device_id, - user_id=user_id, - ) - sleep_start_str=row.get("disable_time_start") - sleep_end_str=row.get("disable_time_end") - print(sleep_start_str, sleep_end_str) - now = datetime.now() - current_time = now.time() - print(current_time) - start_time = convert_to_time(sleep_start_str) - end_time = convert_to_time(sleep_end_str) - print(start_time, end_time) - # 如果任意一个转换后为 None,视为未配置睡眠时间 - if start_time is None or end_time is None: - return True - now = datetime.now() - current_time = now.time() - - if start_time <= end_time: - return not (start_time <= current_time <= end_time) - else: - return not (current_time >= start_time or current_time <= end_time) - - finally: - await session.close() - path = request.url.path - # 仅拦截以 /banban/devices/ 开头的路径 - if path.startswith("/banban/devices/") and ('volume' in path or 'location' in path or 'firmware/update' in path) : + if _is_device_command_path(path): # 解析 device_id,假设路径格式为 /banban/devices/{device_id}/... 或 /banban/devices/{device_id} parts = path.split("/") # parts 示例: ['', 'banban', 'devices', 'device_id', 'action', ...] if len(parts) >= 4: device_id = parts[3] # 第四个片段即为 device_id if device_id: # 确保 device_id 非空 - if not await is_device_sleep(device_id): + block_reason = await device_service.get_device_command_block_reason( + device_id=device_id, + user_id=user_id, + ) + if block_reason is not None: + logger.info( + "device command blocked", + extra={ + "event": "device_command_sleep_blocked", + "request_id": getattr(request.state, "request_id", None), + "path": path, + "user_id": user_id, + "device_id": device_id, + "reason": block_reason, + }, + ) return JSONResponse( status_code=400, # 或 403 / 409 - content={"detail": f"设备不在线或在休眠中"} + content={"detail": DEVICE_COMMAND_SLEEP_BLOCKED_DETAIL} ) else: # 路径格式不符合预期,可以放行或返回错误(根据业务决定) diff --git a/talkingq-url/banban/routers/__init__.py b/talkingq-url/banban/routers/__init__.py index 6bc45f9..ec6643a 100644 --- a/talkingq-url/banban/routers/__init__.py +++ b/talkingq-url/banban/routers/__init__.py @@ -5,6 +5,7 @@ from banban.routers.children import router as children_router from banban.routers.device_im import router as device_im_router from banban.routers.device_location import router as device_location_router from banban.routers.devices import router as devices_router +from banban.routers.family import router as family_router from banban.routers.im import router as im_router from banban.routers.parents import router as parents_router from banban.routers.roles import router as roles_router @@ -19,6 +20,7 @@ banban_router.include_router(wechat_auth_router, tags=["banban-auth"]) banban_router.include_router(bindings_router, tags=["banban-bindings"]) banban_router.include_router(children_router, tags=["banban-children"]) banban_router.include_router(devices_router, tags=["banban-devices"]) +banban_router.include_router(family_router, tags=["banban-family"]) banban_router.include_router(device_im_router, tags=["banban-device-im"]) banban_router.include_router(device_location_router, tags=["banban-device-location"]) banban_router.include_router(im_router, tags=["banban-im"]) diff --git a/talkingq-url/banban/routers/devices.py b/talkingq-url/banban/routers/devices.py index d1ff7fc..48076e4 100644 --- a/talkingq-url/banban/routers/devices.py +++ b/talkingq-url/banban/routers/devices.py @@ -74,6 +74,8 @@ class DeviceStatusResponse(BaseModel): child_id: int | None = None child_name: str | None = None sleep_mode: int | None = None + manual_sleep_mode: int | None = None + schedule_suppressed_until: datetime | None = None disable_time_start: str | None = None disable_time_end: str | None = None timezone: str | None = None @@ -263,6 +265,8 @@ def _row_to_device_status_response(row: Mapping) -> DeviceStatusResponse: child_id=int(row["child_id"]) if row["child_id"] is not None else None, child_name=row.get("child_name"), sleep_mode=row.get("sleep_mode"), + manual_sleep_mode=row.get("manual_sleep_mode"), + schedule_suppressed_until=row.get("schedule_suppressed_until"), disable_time_start=_format_time_value(row.get("disable_time_start")), disable_time_end=_format_time_value(row.get("disable_time_end")), timezone=row.get("timezone"), @@ -469,7 +473,6 @@ async def list_device_alarms( await device_service.ensure_device_access(device_id=device_id, user_id=current_user_id) rows = await device_alarm_service.list_device_alarms( device_id=device_id, - owner_user_id=current_user_id, limit=limit, ) diff --git a/talkingq-url/banban/routers/family.py b/talkingq-url/banban/routers/family.py new file mode 100644 index 0000000..42a1524 --- /dev/null +++ b/talkingq-url/banban/routers/family.py @@ -0,0 +1,154 @@ +from datetime import datetime + +from fastapi import APIRouter, Depends, Request, status +from pydantic import BaseModel + +from banban.security import get_current_user_id +from banban.service.family import family_service + + +router = APIRouter(prefix="/family", tags=["family"]) + + +class FamilyMemberItem(BaseModel): + user_id: int + nickname: str | None = None + avatar_url: str | None = None + role: str + is_owner: bool + joined_at: datetime | None = None + + +class FamilyMemberListResponse(BaseModel): + device_id: str + max_members: int + total: int + current_user_role: str + items: list[FamilyMemberItem] + + +class FamilyInvitationCreateResponse(BaseModel): + invite_token: str + device_id: str + expires_at: datetime + + +class FamilyInvitationResponse(BaseModel): + invite_token: str + device_id: str + child_id: int | None = None + child_name: str | None = None + status: int + expires_at: datetime + + +class FamilyInvitationAcceptResponse(BaseModel): + device_id: str + child_id: int | None = None + child_name: str | None = None + + +def _role_name(value: int | str | None, *, is_owner: bool = False) -> str: + if is_owner: + return "owner" + try: + normalized = int(value or 0) + except (TypeError, ValueError): + normalized = 0 + return "owner" if normalized == 1 else "member" + + +@router.get("/devices/{device_id}/members", response_model=FamilyMemberListResponse) +async def list_family_members( + device_id: str, + request: Request, + current_user_id: int = Depends(get_current_user_id), +) -> FamilyMemberListResponse: + del request + rows = await family_service.list_members(device_id=device_id, user_id=current_user_id) + current_role = "member" + items: list[FamilyMemberItem] = [] + for row in rows: + is_owner = bool(row["is_owner"]) + role = _role_name(row["role"], is_owner=is_owner) + if int(row["user_id"]) == current_user_id: + current_role = role + items.append( + FamilyMemberItem( + user_id=int(row["user_id"]), + nickname=row.get("nickname"), + avatar_url=row.get("avatar_url"), + role=role, + is_owner=is_owner, + joined_at=row.get("joined_at"), + ) + ) + + return FamilyMemberListResponse( + device_id=device_id, + max_members=4, + total=len(items), + current_user_role=current_role, + items=items, + ) + + +@router.post("/devices/{device_id}/invitations", response_model=FamilyInvitationCreateResponse) +async def create_family_invitation( + device_id: str, + request: Request, + current_user_id: int = Depends(get_current_user_id), +) -> FamilyInvitationCreateResponse: + del request + invitation = await family_service.create_invitation(device_id=device_id, user_id=current_user_id) + return FamilyInvitationCreateResponse(**invitation) + + +@router.get("/invitations/{invite_token}", response_model=FamilyInvitationResponse) +async def get_family_invitation( + invite_token: str, + request: Request, + current_user_id: int = Depends(get_current_user_id), +) -> FamilyInvitationResponse: + del request, current_user_id + invitation = await family_service.get_invitation(invite_token=invite_token) + return FamilyInvitationResponse( + invite_token=str(invitation["invite_token"]), + device_id=str(invitation["device_id"]), + child_id=int(invitation["child_id"]) if invitation.get("child_id") is not None else None, + child_name=invitation.get("child_name"), + status=int(invitation["status"]), + expires_at=invitation["expires_at"], + ) + + +@router.post("/invitations/{invite_token}/accept", response_model=FamilyInvitationAcceptResponse) +async def accept_family_invitation( + invite_token: str, + request: Request, + current_user_id: int = Depends(get_current_user_id), +) -> FamilyInvitationAcceptResponse: + del request + result = await family_service.accept_invitation(invite_token=invite_token, user_id=current_user_id) + return FamilyInvitationAcceptResponse(**result) + + +@router.delete("/devices/{device_id}/members/{member_user_id}", status_code=status.HTTP_204_NO_CONTENT) +async def remove_family_member( + device_id: str, + member_user_id: int, + request: Request, + current_user_id: int = Depends(get_current_user_id), +) -> None: + del request + await family_service.remove_member(device_id=device_id, member_user_id=member_user_id, user_id=current_user_id) + + +@router.delete("/devices/{device_id}/me", status_code=status.HTTP_204_NO_CONTENT) +async def leave_family( + device_id: str, + request: Request, + current_user_id: int = Depends(get_current_user_id), +) -> None: + del request + await family_service.leave_family(device_id=device_id, user_id=current_user_id) diff --git a/talkingq-url/banban/routers/roles.py b/talkingq-url/banban/routers/roles.py index 14820b4..414fbb2 100644 --- a/talkingq-url/banban/routers/roles.py +++ b/talkingq-url/banban/routers/roles.py @@ -112,6 +112,11 @@ async def update_device_role( if language and available_languages and language not in available_languages: raise HTTPException(status_code=422, detail="language is not supported by role") + await device_service.ensure_device_command_allowed( + device_id=device_id, + user_id=current_user_id, + ) + await device_config_manager.set_config( device_id, DeviceConfig(selected_role_key=role_key, preferred_language=language), diff --git a/talkingq-url/banban/service/binding.py b/talkingq-url/banban/service/binding.py index eb9fd37..e93c3a4 100644 --- a/talkingq-url/banban/service/binding.py +++ b/talkingq-url/banban/service/binding.py @@ -167,6 +167,14 @@ class BindingService(DatabaseServiceBase): finally: await db_session.close() + async def get_child_binding(self, child_id: int, user_id: int) -> Optional[Mapping]: + db_session = await self.get_session() + try: + dao = BindingDAO(db_session) + return await dao.get_by_child(child_id, user_id) + finally: + await db_session.close() + async def get_current_binding(self, user_id: int) -> Optional[Mapping]: db_session = await self.get_session() try: diff --git a/talkingq-url/banban/service/device.py b/talkingq-url/banban/service/device.py index 0cfe1e0..4efda27 100644 --- a/talkingq-url/banban/service/device.py +++ b/talkingq-url/banban/service/device.py @@ -1,5 +1,5 @@ from collections.abc import Mapping -from datetime import time +from datetime import datetime, time, timedelta from typing import Any, List, Literal from services.database_service_base import DatabaseServiceBase @@ -11,10 +11,113 @@ from services.device_update_manager import device_firmware_update_manager from services.system_config_manager import system_config_manager +DEVICE_COMMAND_SLEEP_BLOCKED_DETAIL = "设备不在线或在休眠中" + + class DeviceService(DatabaseServiceBase): def __init__(self): super().__init__(service_name="device_service") + def _convert_to_time(self, value: Any) -> time | None: + if value is None: + return None + if isinstance(value, time): + return value + if isinstance(value, timedelta): + total_seconds = value.total_seconds() + hours = int(total_seconds // 3600) + minutes = int((total_seconds % 3600) // 60) + seconds = int(total_seconds % 60) + return time(hours, minutes, seconds) + if isinstance(value, str): + text = value.strip() + if not text: + return None + if len(text.split(":")) == 2: + return datetime.strptime(text, "%H:%M").time() + if len(text.split(":")) == 3: + return datetime.strptime(text, "%H:%M:%S").time() + raise ValueError(f"Unsupported type for time conversion: {type(value)}") + + def _is_time_in_range(self, current_time: time, start_time: time, end_time: time) -> bool: + if start_time <= end_time: + return start_time <= current_time <= end_time + return current_time >= start_time or current_time <= end_time + + def _parse_datetime(self, value: Any) -> datetime | None: + if value is None: + return None + if isinstance(value, datetime): + return value + if isinstance(value, str): + text = value.strip() + if not text: + return None + try: + return datetime.fromisoformat(text) + except ValueError: + return None + return None + + def _next_scheduled_end(self, *, now: datetime, start_time: time, end_time: time) -> datetime | None: + if not self._is_time_in_range(now.time(), start_time, end_time): + return None + + end_at = datetime.combine(now.date(), end_time) + if start_time > end_time and now.time() >= start_time: + end_at += timedelta(days=1) + if end_at <= now: + end_at += timedelta(days=1) + return end_at + + def _is_schedule_suppressed(self, row: Mapping[str, Any], now: datetime) -> bool: + suppressed_until = self._parse_datetime(row.get("schedule_suppressed_until")) + return suppressed_until is not None and now < suppressed_until + + async def get_device_command_block_reason_from_status( + self, + row: Mapping[str, Any], + *, + now: datetime | None = None, + ) -> str | None: + current = now or datetime.now() + if int(row.get("manual_sleep_mode") or 0) == 1: + return "manual_sleep" + + start_time = self._convert_to_time(row.get("disable_time_start")) + end_time = self._convert_to_time(row.get("disable_time_end")) + if start_time is None or end_time is None: + return None + + if self._is_schedule_suppressed(row, current): + return None + + if self._is_time_in_range(current.time(), start_time, end_time): + return "scheduled_sleep" + return None + + async def get_device_command_block_reason( + self, + *, + device_id: str, + user_id: int, + ) -> str | None: + row = await self.get_device_status(device_id=device_id, user_id=user_id) + return await self.get_device_command_block_reason_from_status(row) + + async def ensure_device_command_allowed( + self, + *, + device_id: str, + user_id: int, + ) -> None: + reason = await self.get_device_command_block_reason( + device_id=device_id, + user_id=user_id, + ) + if reason is not None: + raise HTTPException(status_code=400, detail=DEVICE_COMMAND_SLEEP_BLOCKED_DETAIL) + async def ensure_device_access(self, *, device_id: str, user_id: int) -> None: db_session = await self.get_session() try: @@ -128,14 +231,40 @@ class DeviceService(DatabaseServiceBase): user_id: int, switch: Literal["on", "off"], ) -> str: - await self.ensure_device_access(device_id=device_id, user_id=user_id) + status_row = await self.get_device_status(device_id=device_id, user_id=user_id) + now = datetime.now() + block_reason = await self.get_device_command_block_reason_from_status(status_row, now=now) + + if switch == "off" and block_reason is not None: + return "noop" from handlers.mqtt_handler import TalkingQMQTTService service = await TalkingQMQTTService.get_instance() if service is None: raise HTTPException(status_code=503, detail="MQTT 服务未初始化") - return await service.send_remote_sleep_wake_command(device_id, switch) + + schedule_suppressed_until = None + clear_schedule_suppression = switch == "off" + if switch == "on": + start_time = self._convert_to_time(status_row.get("disable_time_start")) + end_time = self._convert_to_time(status_row.get("disable_time_end")) + if start_time is not None and end_time is not None: + schedule_suppressed_until = self._next_scheduled_end( + now=now, + start_time=start_time, + end_time=end_time, + ) + clear_schedule_suppression = schedule_suppressed_until is None + + msg_id = await service.send_remote_sleep_wake_command(device_id, switch) + await device_setting_service.set_manual_sleep_mode( + device_id=device_id, + manual_sleep_mode=1 if switch == "off" else 0, + schedule_suppressed_until=schedule_suppressed_until, + clear_schedule_suppression=clear_schedule_suppression, + ) + return msg_id def _compare_versions(self, current_version: str | None, latest_version: str | None) -> bool: current = (current_version or "").strip() diff --git a/talkingq-url/banban/service/device_alarm.py b/talkingq-url/banban/service/device_alarm.py index efabfdb..c6e4f0c 100644 --- a/talkingq-url/banban/service/device_alarm.py +++ b/talkingq-url/banban/service/device_alarm.py @@ -29,7 +29,6 @@ class DeviceAlarmService(DatabaseServiceBase): self, *, device_id: str, - owner_user_id: int, limit: int, ) -> list[Mapping[str, Any]]: db_session = await self.get_session() @@ -37,7 +36,6 @@ class DeviceAlarmService(DatabaseServiceBase): dao = DeviceAlarmDAO(db_session) return await dao.list_by_device( device_id=device_id, - owner_user_id=owner_user_id, limit=limit, ) finally: diff --git a/talkingq-url/banban/service/device_setting.py b/talkingq-url/banban/service/device_setting.py index ec87791..f6d1391 100644 --- a/talkingq-url/banban/service/device_setting.py +++ b/talkingq-url/banban/service/device_setting.py @@ -1,5 +1,5 @@ from collections.abc import Mapping -from datetime import time +from datetime import datetime, time from typing import Optional from services.database_service_base import DatabaseServiceBase @@ -15,6 +15,8 @@ class DeviceSettingService(DatabaseServiceBase): self, device_id: str, sleep_mode: int = 0, + manual_sleep_mode: int = 0, + schedule_suppressed_until: Optional[datetime] = None, disable_time_start: Optional[time] = None, disable_time_end: Optional[time] = None, timezone: str = "Asia/Shanghai", @@ -31,6 +33,8 @@ class DeviceSettingService(DatabaseServiceBase): return await dao.create( device_id=device_id, sleep_mode=sleep_mode, + manual_sleep_mode=manual_sleep_mode, + schedule_suppressed_until=schedule_suppressed_until, disable_time_start=disable_time_start, disable_time_end=disable_time_end, timezone=timezone, @@ -56,6 +60,9 @@ class DeviceSettingService(DatabaseServiceBase): self, device_id: str, sleep_mode: Optional[int] = None, + manual_sleep_mode: Optional[int] = None, + schedule_suppressed_until: Optional[datetime] = None, + clear_schedule_suppression: bool = False, disable_time_start: Optional[time] = None, disable_time_end: Optional[time] = None, timezone: Optional[str] = None, @@ -72,6 +79,9 @@ class DeviceSettingService(DatabaseServiceBase): await dao.update( device_id=device_id, sleep_mode=sleep_mode, + manual_sleep_mode=manual_sleep_mode, + schedule_suppressed_until=schedule_suppressed_until, + clear_schedule_suppression=clear_schedule_suppression, disable_time_start=disable_time_start, disable_time_end=disable_time_end, timezone=timezone, @@ -124,6 +134,7 @@ class DeviceSettingService(DatabaseServiceBase): await self.update_setting( device_id=device_id, sleep_mode=sleep_mode, + clear_schedule_suppression=True, disable_time_start=disable_time_start, disable_time_end=disable_time_end, timezone=timezone, @@ -138,5 +149,29 @@ class DeviceSettingService(DatabaseServiceBase): timezone=timezone, ) + async def set_manual_sleep_mode( + self, + *, + device_id: str, + manual_sleep_mode: int, + schedule_suppressed_until: Optional[datetime] = None, + clear_schedule_suppression: bool = False, + ) -> None: + current_row = await self.get_setting_by_device_id(device_id=device_id) + if current_row: + await self.update_setting( + device_id=device_id, + manual_sleep_mode=manual_sleep_mode, + schedule_suppressed_until=schedule_suppressed_until, + clear_schedule_suppression=clear_schedule_suppression, + ) + return + + await self.create_setting( + device_id=device_id, + manual_sleep_mode=manual_sleep_mode, + schedule_suppressed_until=schedule_suppressed_until, + ) + # 创建全局 DeviceSettingService 实例 device_setting_service = DeviceSettingService() diff --git a/talkingq-url/banban/service/device_voice_archive.py b/talkingq-url/banban/service/device_voice_archive.py index 26407a8..bb17c4e 100644 --- a/talkingq-url/banban/service/device_voice_archive.py +++ b/talkingq-url/banban/service/device_voice_archive.py @@ -12,6 +12,7 @@ from banban.service.message_audio_storage import ( MessageAudioStorageError, message_audio_storage_service, ) +from banban.service.im import im_service from config import settings from services.database_service_base import DatabaseServiceBase from utils.audio_format import ( @@ -239,6 +240,12 @@ class DeviceVoiceArchiveService(DatabaseServiceBase): ) if not message_row: raise RuntimeError("message was not found after insert") + await im_service.schedule_message_media_duration_parse( + message_id=int(message_row["id"]), + audio_data=archive_audio_data, + mime_type=prepared_audio.mime_type, + source="device_peer_voice", + ) return DeviceVoiceArchiveResult( sender_device_id=sender_device_id, receiver_device_id=receiver_device_id, diff --git a/talkingq-url/banban/service/family.py b/talkingq-url/banban/service/family.py new file mode 100644 index 0000000..0dc0752 --- /dev/null +++ b/talkingq-url/banban/service/family.py @@ -0,0 +1,208 @@ +from collections.abc import Mapping +from datetime import datetime + +from fastapi import HTTPException + +from banban.dao.family import ( + FAMILY_ROLE_OWNER, + INVITE_STATUS_PENDING, + MAX_FAMILY_MEMBERS, + FamilyDAO, +) +from services.database_service_base import DatabaseServiceBase + + +class FamilyService(DatabaseServiceBase): + def __init__(self): + super().__init__(service_name="family_service") + + async def ensure_device_access(self, *, device_id: str, user_id: int) -> Mapping: + db_session = await self.get_session() + try: + dao = FamilyDAO(db_session) + binding = await dao.get_binding_for_access(device_id=device_id, user_id=user_id) + if binding is None: + raise HTTPException(status_code=404, detail="device not found") + return binding + finally: + await db_session.close() + + async def ensure_device_owner(self, *, device_id: str, user_id: int) -> Mapping: + db_session = await self.get_session() + try: + dao = FamilyDAO(db_session) + binding = await dao.get_binding_for_owner(device_id=device_id, user_id=user_id) + if binding is None: + raise HTTPException(status_code=403, detail="only owner can manage family") + await dao.ensure_owner_member(device_id=device_id) + await db_session.commit() + return binding + finally: + await db_session.close() + + async def has_child_access(self, *, child_id: int, user_id: int) -> bool: + db_session = await self.get_session() + try: + dao = FamilyDAO(db_session) + return await dao.has_child_access(child_id=child_id, user_id=user_id) + finally: + await db_session.close() + + async def get_child_for_access(self, *, child_id: int, user_id: int) -> Mapping | None: + db_session = await self.get_session() + try: + dao = FamilyDAO(db_session) + return await dao.get_child_for_access(child_id=child_id, user_id=user_id) + finally: + await db_session.close() + + async def list_children_for_access(self, *, user_id: int, limit: int, cursor: int | None) -> tuple[list[Mapping], bool]: + db_session = await self.get_session() + try: + dao = FamilyDAO(db_session) + rows = await dao.list_children_for_access(user_id=user_id, limit=limit, cursor=cursor) + has_more = len(rows) > limit + return rows[:limit], has_more + finally: + await db_session.close() + + async def list_members(self, *, device_id: str, user_id: int) -> list[Mapping]: + db_session = await self.get_session() + try: + dao = FamilyDAO(db_session) + await dao.ensure_owner_member(device_id=device_id) + rows = await dao.list_members(device_id=device_id, user_id=user_id) + if not rows: + raise HTTPException(status_code=404, detail="device not found") + await db_session.commit() + return rows + finally: + await db_session.close() + + async def create_invitation(self, *, device_id: str, user_id: int) -> Mapping: + db_session = await self.get_session() + try: + dao = FamilyDAO(db_session) + binding = await dao.get_binding_for_owner(device_id=device_id, user_id=user_id) + if binding is None: + raise HTTPException(status_code=403, detail="only owner can invite family members") + + await dao.ensure_owner_member(device_id=device_id) + member_count = await dao.count_active_members(device_id=device_id) + if member_count >= MAX_FAMILY_MEMBERS: + raise HTTPException(status_code=409, detail="family member limit reached") + + invite_token, expires_at = await dao.create_invitation(device_id=device_id, owner_user_id=user_id) + await db_session.commit() + return { + "invite_token": invite_token, + "device_id": device_id, + "expires_at": expires_at, + } + except Exception: + await db_session.rollback() + raise + finally: + await db_session.close() + + async def get_invitation(self, *, invite_token: str) -> Mapping: + db_session = await self.get_session() + try: + dao = FamilyDAO(db_session) + invitation = await dao.get_invitation(invite_token=invite_token) + if invitation is None: + raise HTTPException(status_code=404, detail="family invitation not found") + + status = int(invitation["status"]) + if status == INVITE_STATUS_PENDING and datetime.utcnow() > invitation["expires_at"]: + await dao.mark_invitation_expired(invitation_id=int(invitation["id"])) + await db_session.commit() + invitation = await dao.get_invitation(invite_token=invite_token) + if invitation is None: + raise HTTPException(status_code=404, detail="family invitation not found") + + return invitation + finally: + await db_session.close() + + async def accept_invitation(self, *, invite_token: str, user_id: int) -> Mapping: + db_session = await self.get_session() + try: + dao = FamilyDAO(db_session) + invitation = await dao.get_invitation(invite_token=invite_token) + if invitation is None: + raise HTTPException(status_code=404, detail="family invitation not found") + + if int(invitation["status"]) != INVITE_STATUS_PENDING: + raise HTTPException(status_code=409, detail="family invitation is not available") + if datetime.utcnow() > invitation["expires_at"]: + await dao.mark_invitation_expired(invitation_id=int(invitation["id"])) + await db_session.commit() + raise HTTPException(status_code=410, detail="family invitation expired") + + await dao.ensure_owner_member(device_id=str(invitation["device_id"])) + existing_access = await dao.get_binding_for_access(device_id=str(invitation["device_id"]), user_id=user_id) + if existing_access is None: + member_count = await dao.count_active_members(device_id=str(invitation["device_id"])) + if member_count >= MAX_FAMILY_MEMBERS: + raise HTTPException(status_code=409, detail="family member limit reached") + + await dao.accept_invitation(invitation=invitation, user_id=user_id) + await db_session.commit() + return { + "device_id": str(invitation["device_id"]), + "child_id": invitation.get("child_id"), + "child_name": invitation.get("child_name"), + } + except Exception: + await db_session.rollback() + raise + finally: + await db_session.close() + + async def remove_member(self, *, device_id: str, member_user_id: int, user_id: int) -> bool: + db_session = await self.get_session() + try: + dao = FamilyDAO(db_session) + removed = await dao.remove_member( + device_id=device_id, + member_user_id=member_user_id, + removed_by_user_id=user_id, + ) + if not removed: + owner_binding = await dao.get_binding_for_owner(device_id=device_id, user_id=user_id) + if owner_binding is None: + raise HTTPException(status_code=403, detail="only owner can remove family members") + if int(owner_binding["owner_user_id"]) == member_user_id: + raise HTTPException(status_code=400, detail="owner cannot be removed") + raise HTTPException(status_code=404, detail="family member not found") + await db_session.commit() + return True + except Exception: + await db_session.rollback() + raise + finally: + await db_session.close() + + async def leave_family(self, *, device_id: str, user_id: int) -> bool: + db_session = await self.get_session() + try: + dao = FamilyDAO(db_session) + left = await dao.leave_family(device_id=device_id, user_id=user_id) + if not left: + binding = await dao.get_binding_for_access(device_id=device_id, user_id=user_id) + if binding is None: + raise HTTPException(status_code=404, detail="device not found") + if int(binding["owner_user_id"]) == user_id: + raise HTTPException(status_code=400, detail="owner cannot leave family") + raise HTTPException(status_code=404, detail="family member not found") + await db_session.commit() + return True + except Exception: + await db_session.rollback() + raise + finally: + await db_session.close() + + +family_service = FamilyService() diff --git a/talkingq-url/banban/service/im.py b/talkingq-url/banban/service/im.py index 696e90f..b1a593a 100644 --- a/talkingq-url/banban/service/im.py +++ b/talkingq-url/banban/service/im.py @@ -10,6 +10,7 @@ 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 binding_service from banban.service.pending_voice_message import pending_voice_message_service +from services.task_manager import task_manager try: from banban.dao.im import ImDAO, DeviceIdentity, ConversationMessageCreateResult from banban.schemas.im import ( @@ -20,6 +21,7 @@ try: except ModuleNotFoundError: from banban.dao.im import ImDAO, DeviceIdentity, ConversationMessageCreateResult from banban.schemas.im import ChildConversationMessageItem, DeviceMessageCreateRequest, ParentChildMessageCreateRequest +from utils.audio_duration import parse_audio_duration_ms from utils.logger import session_logger @@ -80,6 +82,12 @@ def build_device_parent_leave_message_client_msg_id(*, device_id: str, media_fil return f"device-parent-{digest[:32]}" +def build_device_parent_leave_message_member_client_msg_id(*, device_id: str, parent_user_id: int, media_file_key: str) -> str: + raw = f"{device_id}|parent|{parent_user_id}|{media_file_key}" + digest = hashlib.sha256(raw.encode("utf-8")).hexdigest() + return f"device-parent-{digest[:32]}" + + def normalize_content_json(value: Any) -> dict[str, Any] | None: if value is None: return None @@ -179,6 +187,87 @@ class ImService(DatabaseServiceBase): super().__init__(service_name="im_service") self.audio_storage = MessageAudioStorageService() + async def _update_message_media_duration( + self, + *, + message_id: int, + media_duration_ms: int, + source: str, + ) -> None: + db_session = await self.get_session() + try: + dao = ImDAO(db_session) + updated = await dao.update_message_media_duration( + message_id=message_id, + media_duration_ms=media_duration_ms, + ) + await db_session.commit() + session_logger.info( + "system", + "audio_duration", + ( + "updated message audio duration: " + f"message_id={message_id}, media_duration_ms={media_duration_ms}, " + f"source={source}, updated={updated}" + ), + ) + except Exception: + await db_session.rollback() + raise + finally: + await db_session.close() + + async def _parse_and_update_message_media_duration( + self, + *, + message_id: int, + audio_data: bytes, + mime_type: str | None, + source: str, + ) -> None: + try: + duration_ms = await parse_audio_duration_ms( + audio_data, + mime_type=mime_type, + source=source, + ) + if duration_ms is None: + return + await self._update_message_media_duration( + message_id=message_id, + media_duration_ms=duration_ms, + source=source, + ) + except Exception as exc: + session_logger.warning( + "system", + "audio_duration", + ( + "failed to update message audio duration: " + f"message_id={message_id}, source={source}, error={exc}" + ), + ) + + async def schedule_message_media_duration_parse( + self, + *, + message_id: int, + audio_data: bytes, + mime_type: str | None, + source: str, + ) -> None: + if message_id <= 0 or not audio_data: + return + await task_manager.create_task( + self._parse_and_update_message_media_duration( + message_id=message_id, + audio_data=audio_data, + mime_type=mime_type, + source=source, + ), + task_type="audio_duration", + ) + async def assert_parent_child_access(self, *, user_id: int, child_id: int) -> Mapping[str, Any]: db_session = await self.get_session() try: @@ -303,6 +392,15 @@ class ImService(DatabaseServiceBase): if not content: raise HTTPException(status_code=400, detail="audio file is empty") + await self.assert_parent_child_access( + user_id=parent_user_id, + child_id=child_id, + ) + device = await binding_service.get_child_binding(child_id, parent_user_id) + if device is None: + raise HTTPException(status_code=404, detail="child has no bound device") + target_device_id = str(device["device_id"]) + extension, normalized_content_type = normalize_audio_extension( filename=filename, content_type=content_type, @@ -342,23 +440,28 @@ class ImService(DatabaseServiceBase): await self.audio_storage.delete_audio(stored_audio.file_key) except MessageAudioStorageError: pass - device = await binding_service.get_current_binding(parent_user_id) try: audio_url = await device_audio_cache_service.get_device_audio_url( pending_media_file_key, - device_id=device.device_id, + device_id=target_device_id, ) except Exception: - session_logger.error(device.device_id, "audio", f"failed to get device audio url: {pending_media_file_key}", exc_info=True) + session_logger.error(target_device_id, "audio", f"failed to get device audio url: {pending_media_file_key}", exc_info=True) audio_url = pending_media_file_key await pending_voice_message_service.add_pending_message( - target_device_id=device.device_id, + target_device_id=target_device_id, sender_device_id=None, im_message_id=result.message.id, media_file_key=pending_media_file_key, audio_url=f"{audio_url}", source="parent_child_voice", ) + await self.schedule_message_media_duration_parse( + message_id=result.message.id, + audio_data=content, + mime_type=normalized_content_type, + source="parent_weapp_voice", + ) except Exception: if result is None: try: @@ -502,6 +605,7 @@ class ImService(DatabaseServiceBase): media_transcript_text: str | None = None, client_msg_id: str | None = None, ext_json: dict[str, Any] | None = None, + audio_content: bytes | None = None, ) -> tuple[DeviceIdentity, ConversationMessageCreateResult]: normalized_media_file_key = str(media_file_key or "").strip() if not normalized_media_file_key: @@ -510,53 +614,106 @@ class ImService(DatabaseServiceBase): db_session = await self.get_session() try: dao = ImDAO(db_session) - owner_identity = await dao.get_bound_device_owner_identity(device_id=device_id) - device_identity = DeviceIdentity( - device_id=owner_identity.device_id, - child_id=owner_identity.child_id, - child_name=owner_identity.child_name, - ) - if ext_json: - resolved_ext_json = dict(ext_json) - else: - resolved_ext_json = {} - resolved_ext_json.update( - { - "message_kind": "leave_message", - "source": "device_mqtt_011", - "source_device_id": device_id, - } - ) - - payload = DeviceMessageCreateRequest( - conversation_type=PARENT_CHILD_CONVERSATION_TYPE, - parent_user_id=owner_identity.owner_user_id, - content_type=2, - media_file_key=normalized_media_file_key, - media_duration_ms=media_duration_ms, - media_mime_type=(media_mime_type or "").strip() or "audio/mpeg", - media_size_bytes=media_size_bytes, - media_transcript_text=(media_transcript_text or "").strip() or None, - client_msg_id=(client_msg_id or "").strip() - or build_device_parent_leave_message_client_msg_id( + family_identities = await dao.get_bound_device_family_identities(device_id=device_id) + if not family_identities: + raise HTTPException(status_code=404, detail="device family has no members") + first_result: ConversationMessageCreateResult | None = None + for owner_identity in family_identities: + member_client_msg_id = (client_msg_id or "").strip() + if len(family_identities) > 1 or not member_client_msg_id: + member_client_msg_id = build_device_parent_leave_message_member_client_msg_id( + device_id=device_id, + parent_user_id=owner_identity.owner_user_id, + media_file_key=normalized_media_file_key, + ) + member_result = await self._create_device_parent_leave_message_for_identity( + dao=dao, + owner_identity=owner_identity, device_id=device_id, - media_file_key=normalized_media_file_key, - ), - ext_json=resolved_ext_json, - ) + normalized_media_file_key=normalized_media_file_key, + media_duration_ms=media_duration_ms, + media_mime_type=media_mime_type, + media_size_bytes=media_size_bytes, + media_transcript_text=media_transcript_text, + client_msg_id=member_client_msg_id, + ext_json=ext_json, + ) + if audio_content: + await self.schedule_message_media_duration_parse( + message_id=member_result.message.id, + audio_data=audio_content, + mime_type=media_mime_type, + source=str((ext_json or {}).get("source") or "device_parent_leave_message"), + ) + if first_result is None: + first_result = member_result - result = await self._create_device_message_with_payload( - dao=dao, - device_identity=device_identity, - payload=payload, + if first_result is None: + raise HTTPException(status_code=404, detail="device family has no members") + return ( + DeviceIdentity( + device_id=family_identities[0].device_id, + child_id=family_identities[0].child_id, + child_name=family_identities[0].child_name, + ), + first_result, ) - return device_identity, result except Exception: await db_session.rollback() raise finally: await db_session.close() + async def _create_device_parent_leave_message_for_identity( + self, + *, + dao: ImDAO, + owner_identity: Any, + device_id: str, + normalized_media_file_key: str, + media_duration_ms: int | None, + media_mime_type: str | None, + media_size_bytes: int | None, + media_transcript_text: str | None, + client_msg_id: str, + ext_json: dict[str, Any] | None, + ) -> ConversationMessageCreateResult: + device_identity = DeviceIdentity( + device_id=owner_identity.device_id, + child_id=owner_identity.child_id, + child_name=owner_identity.child_name, + ) + if ext_json: + resolved_ext_json = dict(ext_json) + else: + resolved_ext_json = {} + resolved_ext_json.update( + { + "message_kind": "leave_message", + "source": "device_mqtt_011", + "source_device_id": device_id, + } + ) + + payload = DeviceMessageCreateRequest( + conversation_type=PARENT_CHILD_CONVERSATION_TYPE, + parent_user_id=owner_identity.owner_user_id, + content_type=2, + media_file_key=normalized_media_file_key, + media_duration_ms=media_duration_ms, + media_mime_type=(media_mime_type or "").strip() or "audio/mpeg", + media_size_bytes=media_size_bytes, + media_transcript_text=(media_transcript_text or "").strip() or None, + client_msg_id=client_msg_id, + ext_json=resolved_ext_json, + ) + + return await self._create_device_message_with_payload( + dao=dao, + device_identity=device_identity, + payload=payload, + ) + async def assert_child_exists(self, *, child_id: int) -> Mapping[str, Any]: db_session = await self.get_session() try: diff --git a/talkingq-url/banban/service/pending_voice_message.py b/talkingq-url/banban/service/pending_voice_message.py index f7729ba..b756458 100644 --- a/talkingq-url/banban/service/pending_voice_message.py +++ b/talkingq-url/banban/service/pending_voice_message.py @@ -1,5 +1,6 @@ from dataclasses import dataclass +from banban.dao.im import ImDAO from banban.dao.pending_voice_message import PendingVoiceMessageDAO from banban.service.device_audio_cache import device_audio_cache_service from services.database_service_base import DatabaseServiceBase @@ -15,6 +16,14 @@ class PendingVoicePlaybackItem: source: str +@dataclass(frozen=True) +class LatestVoicePlaybackItem: + audio_url: str + im_message_id: int + media_file_key: str + source: str = "latest_history_fallback" + + class PendingVoiceMessageService(DatabaseServiceBase): def __init__(self) -> None: super().__init__(service_name="pending_voice_message") @@ -107,6 +116,49 @@ class PendingVoiceMessageService(DatabaseServiceBase): ) return items + async def get_latest_history_playback_item( + self, + *, + target_device_id: str, + ) -> LatestVoicePlaybackItem | None: + db_session = await self.get_session() + try: + dao = ImDAO(db_session) + row = await dao.get_latest_voice_message_received_by_device( + device_id=target_device_id, + ) + finally: + await db_session.close() + + if not row: + return None + + media_file_key = str(row["media_file_key"] or "").strip() + if not media_file_key: + return None + try: + audio_url = await device_audio_cache_service.get_device_audio_url( + media_file_key, + device_id=target_device_id, + ) + except Exception as exc: + session_logger.error( + target_device_id, + "pending_voice", + ( + "最近历史留言音频URL生成失败: " + f"message_id={row['id']}, media_file_key={media_file_key}, error={exc}" + ), + exc_info=True, + ) + return None + + return LatestVoicePlaybackItem( + audio_url=audio_url, + im_message_id=int(row["id"]), + media_file_key=media_file_key, + ) + async def mark_delivered(self, pending_ids: list[int]) -> None: if not pending_ids: return diff --git a/talkingq-url/database/init_db.py b/talkingq-url/database/init_db.py index 4c218ee..214f2dc 100644 --- a/talkingq-url/database/init_db.py +++ b/talkingq-url/database/init_db.py @@ -1,10 +1,165 @@ import asyncio +from sqlalchemy import text from sqlalchemy.ext.asyncio import create_async_engine from database.models import Base from config import settings from utils.logger import session_logger import urllib.parse + +async def _ensure_manual_sleep_mode_column(conn) -> None: + result = await conn.execute( + text( + """ + SELECT COUNT(*) + FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'device_settings' + AND COLUMN_NAME = 'manual_sleep_mode' + """ + ) + ) + if int(result.scalar() or 0) > 0: + return + + await conn.execute( + text( + """ + ALTER TABLE device_settings + ADD COLUMN manual_sleep_mode TINYINT NOT NULL DEFAULT 0 + AFTER sleep_mode + """ + ) + ) + + +async def _ensure_schedule_suppressed_until_column(conn) -> None: + result = await conn.execute( + text( + """ + SELECT COUNT(*) + FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'device_settings' + AND COLUMN_NAME = 'schedule_suppressed_until' + """ + ) + ) + if int(result.scalar() or 0) > 0: + return + + await conn.execute( + text( + """ + ALTER TABLE device_settings + ADD COLUMN schedule_suppressed_until DATETIME NULL + AFTER manual_sleep_mode + """ + ) + ) + + +async def _ensure_device_family_tables(conn) -> None: + await conn.execute( + text( + """ + CREATE TABLE IF NOT EXISTS device_family_members ( + id BIGINT NOT NULL AUTO_INCREMENT, + device_id VARCHAR(64) NOT NULL, + user_id BIGINT NOT NULL, + role TINYINT NOT NULL DEFAULT 2, + status TINYINT NOT NULL DEFAULT 1, + invited_by_user_id BIGINT NULL, + joined_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + removed_at DATETIME NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (id), + UNIQUE KEY uq_device_family_member (device_id, user_id), + KEY idx_device_family_members_device_status (device_id, status), + KEY idx_device_family_members_user_status (user_id, status), + KEY idx_device_family_members_invited_by (invited_by_user_id), + CONSTRAINT fk_device_family_members_device + FOREIGN KEY (device_id) + REFERENCES device_auth (device_id), + CONSTRAINT fk_device_family_members_user + FOREIGN KEY (user_id) + REFERENCES parents (user_id), + CONSTRAINT fk_device_family_members_invited_by + FOREIGN KEY (invited_by_user_id) + REFERENCES parents (user_id) + ON DELETE SET NULL + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + """ + ) + ) + await conn.execute( + text( + """ + CREATE TABLE IF NOT EXISTS device_family_invitations ( + id BIGINT NOT NULL AUTO_INCREMENT, + invite_token CHAR(36) NOT NULL, + device_id VARCHAR(64) NOT NULL, + owner_user_id BIGINT NOT NULL, + status TINYINT NOT NULL DEFAULT 1, + expires_at DATETIME NOT NULL, + accepted_by_user_id BIGINT NULL, + accepted_at DATETIME NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (id), + UNIQUE KEY uq_device_family_invite_token (invite_token), + KEY idx_device_family_invites_device_status (device_id, status), + KEY idx_device_family_invites_owner_status (owner_user_id, status), + KEY idx_device_family_invites_expires_at (expires_at), + KEY idx_device_family_invites_accepted_by (accepted_by_user_id), + CONSTRAINT fk_device_family_invites_device + FOREIGN KEY (device_id) + REFERENCES device_auth (device_id), + CONSTRAINT fk_device_family_invites_owner + FOREIGN KEY (owner_user_id) + REFERENCES parents (user_id), + CONSTRAINT fk_device_family_invites_accepted_by + FOREIGN KEY (accepted_by_user_id) + REFERENCES parents (user_id) + ON DELETE SET NULL + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + """ + ) + ) + await conn.execute( + text( + """ + INSERT INTO device_family_members ( + device_id, + user_id, + role, + status, + invited_by_user_id, + joined_at, + created_at, + updated_at + ) + SELECT + db.device_id, + db.owner_user_id, + 1, + 1, + NULL, + COALESCE(db.bound_at, CURRENT_TIMESTAMP), + CURRENT_TIMESTAMP, + CURRENT_TIMESTAMP + FROM device_bindings AS db + LEFT JOIN device_family_members AS dfm + ON dfm.device_id = db.device_id + AND dfm.user_id = db.owner_user_id + WHERE db.status = 1 + AND dfm.id IS NULL + """ + ) + ) + + async def init_db(): """初始化数据库,创建所有表""" try: @@ -16,6 +171,9 @@ async def init_db(): ) async with engine.begin() as conn: await conn.run_sync(Base.metadata.create_all) + await _ensure_manual_sleep_mode_column(conn) + await _ensure_schedule_suppressed_until_column(conn) + await _ensure_device_family_tables(conn) await engine.dispose() session_logger.info("system", "database", "数据库表已成功创建") return True diff --git a/talkingq-url/database/models.py b/talkingq-url/database/models.py index e29d3c3..b34f6e5 100644 --- a/talkingq-url/database/models.py +++ b/talkingq-url/database/models.py @@ -169,7 +169,7 @@ class Card(Base): class Parent(Base): __tablename__ = "parents" - user_id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + user_id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) openid: Mapped[str] = mapped_column(String(64), unique=True, nullable=False) unionid: Mapped[Optional[str]] = mapped_column(String(64)) nickname: Mapped[Optional[str]] = mapped_column(String(64)) @@ -184,7 +184,7 @@ class Parent(Base): class Child(Base): __tablename__ = "children" - child_id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + child_id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) child_name: Mapped[str] = mapped_column(String(32), nullable=False) child_gender: Mapped[int] = mapped_column(Integer, server_default="2") child_birthday: Mapped[Optional[date]] = mapped_column(Date) @@ -201,9 +201,9 @@ class ParentChildRelation(Base): Index("idx_pcr_child_id", "child_id"), ) - id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) - user_id: Mapped[int] = mapped_column(Integer, nullable=False, index=True) - child_id: Mapped[int] = mapped_column(Integer, nullable=False, index=True) + id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) + user_id: Mapped[int] = mapped_column(BigInteger, nullable=False, index=True) + child_id: Mapped[int] = mapped_column(BigInteger, nullable=False, index=True) relation_type: Mapped[int] = mapped_column(Integer, server_default="9") is_primary: Mapped[bool] = mapped_column(Boolean, server_default="0") status: Mapped[int] = mapped_column(Integer, server_default="1") @@ -219,10 +219,10 @@ class DeviceBinding(Base): Index("idx_device_bindings_owner_user_id", "owner_user_id"), ) - id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) device_id: Mapped[str] = mapped_column(String(64), nullable=False, unique=True) - owner_user_id: Mapped[int] = mapped_column(Integer, nullable=False) - child_id: Mapped[Optional[int]] = mapped_column(Integer) + owner_user_id: Mapped[int] = mapped_column(BigInteger, nullable=False) + child_id: Mapped[Optional[int]] = mapped_column(BigInteger) status: Mapped[int] = mapped_column(Integer, server_default="1") bound_at: Mapped[datetime] = mapped_column(DateTime, nullable=False) unbound_at: Mapped[Optional[datetime]] = mapped_column(DateTime) @@ -230,14 +230,55 @@ class DeviceBinding(Base): updated_at: Mapped[Optional[datetime]] = mapped_column(DateTime, server_default=text("CURRENT_TIMESTAMP")) +class DeviceFamilyMember(Base): + __tablename__ = "device_family_members" + __table_args__ = ( + UniqueConstraint("device_id", "user_id", name="uq_device_family_member"), + Index("idx_device_family_members_device_status", "device_id", "status"), + Index("idx_device_family_members_user_status", "user_id", "status"), + ) + + id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) + device_id: Mapped[str] = mapped_column(String(64), ForeignKey("device_auth.device_id"), nullable=False) + user_id: Mapped[int] = mapped_column(BigInteger, ForeignKey("parents.user_id"), nullable=False) + role: Mapped[int] = mapped_column(Integer, server_default="2") + status: Mapped[int] = mapped_column(Integer, server_default="1") + invited_by_user_id: Mapped[Optional[int]] = mapped_column(BigInteger, ForeignKey("parents.user_id", ondelete="SET NULL")) + joined_at: Mapped[Optional[datetime]] = mapped_column(DateTime, server_default=text("CURRENT_TIMESTAMP")) + removed_at: Mapped[Optional[datetime]] = mapped_column(DateTime) + created_at: Mapped[Optional[datetime]] = mapped_column(DateTime, server_default=text("CURRENT_TIMESTAMP")) + updated_at: Mapped[Optional[datetime]] = mapped_column(DateTime, server_default=text("CURRENT_TIMESTAMP"), onupdate=datetime.utcnow) + + +class DeviceFamilyInvitation(Base): + __tablename__ = "device_family_invitations" + __table_args__ = ( + UniqueConstraint("invite_token", name="uq_device_family_invite_token"), + Index("idx_device_family_invites_device_status", "device_id", "status"), + Index("idx_device_family_invites_owner_status", "owner_user_id", "status"), + Index("idx_device_family_invites_expires_at", "expires_at"), + ) + + id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) + invite_token: Mapped[str] = mapped_column(String(36), nullable=False) + device_id: Mapped[str] = mapped_column(String(64), ForeignKey("device_auth.device_id"), nullable=False) + owner_user_id: Mapped[int] = mapped_column(BigInteger, ForeignKey("parents.user_id"), nullable=False) + status: Mapped[int] = mapped_column(Integer, server_default="1") + expires_at: Mapped[datetime] = mapped_column(DateTime, nullable=False) + accepted_by_user_id: Mapped[Optional[int]] = mapped_column(BigInteger, ForeignKey("parents.user_id", ondelete="SET NULL")) + accepted_at: Mapped[Optional[datetime]] = mapped_column(DateTime) + created_at: Mapped[Optional[datetime]] = mapped_column(DateTime, server_default=text("CURRENT_TIMESTAMP")) + updated_at: Mapped[Optional[datetime]] = mapped_column(DateTime, server_default=text("CURRENT_TIMESTAMP"), onupdate=datetime.utcnow) + + class DeviceBindSession(Base): __tablename__ = "device_bind_sessions" - id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) bind_token: Mapped[str] = mapped_column(String(36), unique=True, nullable=False) device_id: Mapped[str] = mapped_column(String(64), nullable=False) - initiator_user_id: Mapped[int] = mapped_column(Integer, nullable=False) - target_child_id: Mapped[Optional[int]] = mapped_column(Integer) + initiator_user_id: Mapped[int] = mapped_column(BigInteger, nullable=False) + target_child_id: Mapped[Optional[int]] = mapped_column(BigInteger) challenge_code_hash: Mapped[Optional[str]] = mapped_column(String(64)) challenge_set_at: Mapped[Optional[datetime]] = mapped_column(DateTime) expires_at: Mapped[datetime] = mapped_column(DateTime, nullable=False) @@ -253,11 +294,11 @@ class DeviceBindSession(Base): class DeviceBindHistory(Base): __tablename__ = "device_bind_history" - id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) device_id: Mapped[str] = mapped_column(String(64), nullable=False) - child_id: Mapped[Optional[int]] = mapped_column(Integer) - bound_by_user_id: Mapped[int] = mapped_column(Integer, nullable=False) - unbound_by_user_id: Mapped[Optional[int]] = mapped_column(Integer) + child_id: Mapped[Optional[int]] = mapped_column(BigInteger) + bound_by_user_id: Mapped[int] = mapped_column(BigInteger, nullable=False) + unbound_by_user_id: Mapped[Optional[int]] = mapped_column(BigInteger) bind_source: Mapped[int] = mapped_column(Integer, server_default="1") bound_at: Mapped[datetime] = mapped_column(DateTime, nullable=False) unbound_at: Mapped[Optional[datetime]] = mapped_column(DateTime) @@ -271,6 +312,8 @@ class DeviceSetting(Base): setting_id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) device_id: Mapped[str] = mapped_column(String(64), unique=True, nullable=False) sleep_mode: Mapped[int] = mapped_column(Integer, server_default="0") + manual_sleep_mode: Mapped[int] = mapped_column(Integer, nullable=False, server_default="0") + schedule_suppressed_until: Mapped[Optional[datetime]] = mapped_column(DateTime) disable_time_start: Mapped[Optional[time]] = mapped_column(Time) disable_time_end: Mapped[Optional[time]] = mapped_column(Time) timezone: Mapped[str] = mapped_column(String(32), server_default=text("'Asia/Shanghai'")) diff --git a/talkingq-url/handlers/mqtt_handler.py b/talkingq-url/handlers/mqtt_handler.py index d6c50d4..28bee31 100644 --- a/talkingq-url/handlers/mqtt_handler.py +++ b/talkingq-url/handlers/mqtt_handler.py @@ -489,6 +489,27 @@ class TalkingQMQTTService: await offline_audio_cache.clear_audio_urls(device_id) return + latest_item = await pending_voice_message_service.get_latest_history_playback_item( + target_device_id=device_id, + ) + if latest_item: + payload = { + "msg_id": "005", + "type": 0, + "params": {"url_1": latest_item.audio_url}, + } + await self._publish(topic, payload) + logger.info( + device_id, + "pending_voice", + ( + "待收听队列为空,回放最近历史留言: " + f"im_message_id={latest_item.im_message_id}, " + f"media_file_key={latest_item.media_file_key}" + ), + ) + return + audio_urls = await offline_audio_cache.pop_audio_urls(device_id) if audio_urls: params = {f"url_{k}": audio_url for k, audio_url in enumerate(audio_urls, start=1)} diff --git a/talkingq-url/handlers/websocket_message_handler.py b/talkingq-url/handlers/websocket_message_handler.py index bea8f05..ccc8e8f 100644 --- a/talkingq-url/handlers/websocket_message_handler.py +++ b/talkingq-url/handlers/websocket_message_handler.py @@ -298,6 +298,7 @@ async def process_parent_leave_message(device_id: str, audio_cache_key: str): "source_format": source_format, "archive_format": extension, }, + audio_content=archive_audio, ) session_logger.info( device_id, diff --git a/talkingq-url/mysql/init/02-init.sql b/talkingq-url/mysql/init/02-init.sql index c901dce..48c5bb5 100644 --- a/talkingq-url/mysql/init/02-init.sql +++ b/talkingq-url/mysql/init/02-init.sql @@ -272,6 +272,63 @@ CREATE TABLE IF NOT EXISTS `device_bindings` ( ON DELETE SET NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +CREATE TABLE IF NOT EXISTS `device_family_members` ( + `id` BIGINT NOT NULL AUTO_INCREMENT, + `device_id` VARCHAR(64) NOT NULL, + `user_id` BIGINT NOT NULL, + `role` TINYINT NOT NULL DEFAULT 2 COMMENT '1=owner,2=member', + `status` TINYINT NOT NULL DEFAULT 1, + `invited_by_user_id` BIGINT NULL, + `joined_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `removed_at` DATETIME NULL, + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + CONSTRAINT `uq_device_family_member` UNIQUE (`device_id`, `user_id`), + KEY `idx_device_family_members_device_status` (`device_id`, `status`), + KEY `idx_device_family_members_user_status` (`user_id`, `status`), + KEY `idx_device_family_members_invited_by` (`invited_by_user_id`), + CONSTRAINT `fk_device_family_members_device` + FOREIGN KEY (`device_id`) + REFERENCES `device_auth` (`device_id`), + CONSTRAINT `fk_device_family_members_user` + FOREIGN KEY (`user_id`) + REFERENCES `parents` (`user_id`), + CONSTRAINT `fk_device_family_members_invited_by` + FOREIGN KEY (`invited_by_user_id`) + REFERENCES `parents` (`user_id`) + ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `device_family_invitations` ( + `id` BIGINT NOT NULL AUTO_INCREMENT, + `invite_token` CHAR(36) NOT NULL, + `device_id` VARCHAR(64) NOT NULL, + `owner_user_id` BIGINT NOT NULL, + `status` TINYINT NOT NULL DEFAULT 1 COMMENT '1=pending,2=accepted,3=expired,4=cancelled', + `expires_at` DATETIME NOT NULL, + `accepted_by_user_id` BIGINT NULL, + `accepted_at` DATETIME NULL, + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `uq_device_family_invite_token` (`invite_token`), + KEY `idx_device_family_invites_device_status` (`device_id`, `status`), + KEY `idx_device_family_invites_owner_status` (`owner_user_id`, `status`), + KEY `idx_device_family_invites_expires_at` (`expires_at`), + KEY `idx_device_family_invites_accepted_by` (`accepted_by_user_id`), + CONSTRAINT `fk_device_family_invites_device` + FOREIGN KEY (`device_id`) + REFERENCES `device_auth` (`device_id`), + CONSTRAINT `fk_device_family_invites_owner` + FOREIGN KEY (`owner_user_id`) + REFERENCES `parents` (`user_id`), + CONSTRAINT `fk_device_family_invites_accepted_by` + FOREIGN KEY (`accepted_by_user_id`) + REFERENCES `parents` (`user_id`) + ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + CREATE TABLE IF NOT EXISTS `device_bind_sessions` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `bind_token` CHAR(36) NOT NULL, @@ -364,6 +421,8 @@ CREATE TABLE IF NOT EXISTS `device_settings` ( `setting_id` BIGINT NOT NULL AUTO_INCREMENT, `device_id` VARCHAR(64) NOT NULL, `sleep_mode` TINYINT NOT NULL DEFAULT 0, + `manual_sleep_mode` TINYINT NOT NULL DEFAULT 0, + `schedule_suppressed_until` DATETIME NULL, `disable_time_start` TIME NULL, `disable_time_end` TIME NULL, `timezone` VARCHAR(32) NOT NULL DEFAULT 'Asia/Shanghai', diff --git a/talkingq-url/utils/audio_duration.py b/talkingq-url/utils/audio_duration.py new file mode 100644 index 0000000..e05a2c4 --- /dev/null +++ b/talkingq-url/utils/audio_duration.py @@ -0,0 +1,183 @@ +import asyncio +import io +import math +import os +import shutil +import subprocess +import tempfile +import wave + +try: + import soundfile as sf +except Exception: # pragma: no cover - depends on runtime native libs + sf = None + +from utils.audio_format import ( + DEFAULT_CHANNELS, + DEFAULT_SAMPLE_RATE, + DEFAULT_SAMPLE_WIDTH, + detect_audio_format, +) +from utils.logger import session_logger + + +def _duration_from_wave(audio_data: bytes) -> int | None: + try: + with wave.open(io.BytesIO(audio_data), "rb") as wav_file: + frame_rate = wav_file.getframerate() + frame_count = wav_file.getnframes() + if frame_rate <= 0 or frame_count <= 0: + return None + return max(1, int(round(frame_count * 1000 / frame_rate))) + except Exception: + return None + + +def _duration_from_soundfile(audio_data: bytes) -> int | None: + if sf is None: + return None + try: + with sf.SoundFile(io.BytesIO(audio_data)) as audio_file: + sample_rate = int(audio_file.samplerate or 0) + frames = int(audio_file.frames or 0) + if sample_rate <= 0 or frames <= 0: + return None + return max(1, int(round(frames * 1000 / sample_rate))) + except Exception: + return None + + +def _duration_from_pcm_16k_mono(audio_data: bytes) -> int | None: + frame_size = DEFAULT_CHANNELS * DEFAULT_SAMPLE_WIDTH + if DEFAULT_SAMPLE_RATE <= 0 or frame_size <= 0 or not audio_data: + return None + frames = len(audio_data) / frame_size + if frames <= 0: + return None + return max(1, int(round(frames * 1000 / DEFAULT_SAMPLE_RATE))) + + +def _duration_from_ffprobe(audio_data: bytes, suffix: str) -> int | None: + ffprobe_path = shutil.which("ffprobe") + if not ffprobe_path: + return None + + tmp_path = None + try: + with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as tmp_file: + tmp_file.write(audio_data) + tmp_path = tmp_file.name + + process = subprocess.run( + [ + ffprobe_path, + "-v", + "error", + "-show_entries", + "format=duration", + "-of", + "default=noprint_wrappers=1:nokey=1", + tmp_path, + ], + check=False, + capture_output=True, + text=True, + timeout=5, + ) + if process.returncode != 0: + return None + duration_seconds = float((process.stdout or "").strip()) + if not math.isfinite(duration_seconds) or duration_seconds <= 0: + return None + return max(1, int(round(duration_seconds * 1000))) + except Exception: + return None + finally: + if tmp_path and os.path.exists(tmp_path): + try: + os.remove(tmp_path) + except OSError: + pass + + +def _suffix_for_audio(*, audio_format: str, mime_type: str | None) -> str: + normalized_mime = (mime_type or "").strip().lower() + if audio_format == "mp3" or normalized_mime in {"audio/mpeg", "audio/mp3"}: + return ".mp3" + if audio_format == "wav" or normalized_mime in {"audio/wav", "audio/x-wav"}: + return ".wav" + if normalized_mime in {"audio/aac", "audio/x-aac"}: + return ".aac" + if normalized_mime in {"audio/mp4", "audio/x-m4a", "audio/m4a"}: + return ".m4a" + if normalized_mime == "audio/webm": + return ".webm" + return ".audio" + + +def _can_treat_as_pcm(*, audio_format: str, mime_type: str | None) -> bool: + normalized_mime = (mime_type or "").strip().lower() + return audio_format == "pcm_s16le_16k_mono" and normalized_mime in { + "", + "application/octet-stream", + "audio/pcm", + "audio/raw", + "audio/s16le", + } + + +def parse_audio_duration_ms_sync( + audio_data: bytes, + *, + mime_type: str | None = None, + source: str = "audio", +) -> int | None: + if not audio_data: + return None + + audio_format = detect_audio_format(audio_data) + + duration_ms = _duration_from_wave(audio_data) + if duration_ms is not None: + return duration_ms + + duration_ms = _duration_from_soundfile(audio_data) + if duration_ms is not None: + return duration_ms + + duration_ms = _duration_from_ffprobe( + audio_data, + suffix=_suffix_for_audio(audio_format=audio_format, mime_type=mime_type), + ) + if duration_ms is not None: + return duration_ms + + if _can_treat_as_pcm(audio_format=audio_format, mime_type=mime_type): + duration_ms = _duration_from_pcm_16k_mono(audio_data) + if duration_ms is not None: + return duration_ms + + session_logger.warning( + "system", + "audio_duration", + ( + "failed to parse audio duration: " + f"source={source}, detected_format={audio_format}, " + f"mime_type={mime_type or 'unknown'}, size_bytes={len(audio_data)}" + ), + ) + return None + + +async def parse_audio_duration_ms( + audio_data: bytes, + *, + mime_type: str | None = None, + source: str = "audio", +) -> int | None: + return await asyncio.to_thread( + parse_audio_duration_ms_sync, + audio_data, + mime_type=mime_type, + source=source, + )