From 937e3023fcc891dbe9ff1b468624d064ca7b9563 Mon Sep 17 00:00:00 2001 From: stu2not Date: Tue, 7 Jul 2026 16:58:05 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=8C=E5=96=84=E8=AE=BE=E5=A4=87=E7=AB=AF?= =?UTF-8?q?=E4=BD=93=E9=AA=8C=E4=B8=8E=E5=BE=AE=E4=BF=A1=E6=A0=A1=E9=AA=8C?= =?UTF-8?q?=E6=94=AF=E6=8C=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- MP_verify_9ixdRu6JqQrntouG.txt | 1 + banban-mini/project.config.json | 2 +- banban-mini/src/pages/chat/index.scss | 142 ++++- banban-mini/src/pages/chat/index.tsx | 91 ++- banban-mini/src/pages/device/index.tsx | 2 +- banban-mini/src/pages/location/index.scss | 60 ++ banban-mini/src/pages/location/index.tsx | 28 +- banban-mini/src/pages/login/index.scss | 14 + banban-mini/src/pages/login/index.tsx | 8 + banban-mini/src/pages/sleep/index.scss | 15 + banban-mini/src/pages/sleep/index.tsx | 33 +- docs/architecture/01-system-overview.md | 69 ++ docs/architecture/02-device-bootstrap.md | 85 +++ docs/architecture/03-binding-flow.md | 90 +++ docs/architecture/04-voice-and-im-flow.md | 74 +++ docs/architecture/05-location-flow.md | 88 +++ .../06-alarm-and-notification-flow.md | 75 +++ docs/architecture/07-deployment.md | 60 ++ docs/architecture/README.md | 20 + docs/architecture/index.html | 600 ++++++++++++++++++ docs/fa05f36990d894df4e095839d10d9f07.png | Bin 0 -> 134501 bytes talkingq-url/api/wechat_verify.py | 18 + .../banban/service/wechat_mp_notification.py | 59 +- talkingq-url/docs/HXNd3XMbPq.txt | 1 + talkingq-url/docs/mqtt协议_V1.9.txt | 320 ++++++++++ talkingq-url/docs/mqtt协议_V2.0.txt | 324 ++++++++++ talkingq-url/handlers/mqtt_handler.py | 6 +- talkingq-url/main.py | 2 + .../tests/test_alarm_event_response_audio.py | 2 +- .../tests/test_alarm_gps_location_refresh.py | 180 +++++- .../tests/test_alarm_location_stale.py | 39 ++ .../tests/test_binding_session_expiry.py | 219 +++++++ talkingq-url/tests/test_db_init_cards.py | 19 +- .../tests/test_family_display_name.py | 354 +++++++++++ .../tests/test_mqtt_device_info_report.py | 8 + .../test_parent_voice_mp3_normalization.py | 215 +++++++ .../tests/test_wechat_mp_notification.py | 83 ++- talkingq-url/tests/test_wechat_verify.py | 22 + 38 files changed, 3377 insertions(+), 51 deletions(-) create mode 100644 MP_verify_9ixdRu6JqQrntouG.txt create mode 100644 docs/architecture/01-system-overview.md create mode 100644 docs/architecture/02-device-bootstrap.md create mode 100644 docs/architecture/03-binding-flow.md create mode 100644 docs/architecture/04-voice-and-im-flow.md create mode 100644 docs/architecture/05-location-flow.md create mode 100644 docs/architecture/06-alarm-and-notification-flow.md create mode 100644 docs/architecture/07-deployment.md create mode 100644 docs/architecture/README.md create mode 100644 docs/architecture/index.html create mode 100644 docs/fa05f36990d894df4e095839d10d9f07.png create mode 100644 talkingq-url/api/wechat_verify.py create mode 100644 talkingq-url/docs/HXNd3XMbPq.txt create mode 100644 talkingq-url/docs/mqtt协议_V1.9.txt create mode 100644 talkingq-url/docs/mqtt协议_V2.0.txt create mode 100644 talkingq-url/tests/test_alarm_location_stale.py create mode 100644 talkingq-url/tests/test_family_display_name.py create mode 100644 talkingq-url/tests/test_parent_voice_mp3_normalization.py create mode 100644 talkingq-url/tests/test_wechat_verify.py diff --git a/MP_verify_9ixdRu6JqQrntouG.txt b/MP_verify_9ixdRu6JqQrntouG.txt new file mode 100644 index 0000000..a95abe6 --- /dev/null +++ b/MP_verify_9ixdRu6JqQrntouG.txt @@ -0,0 +1 @@ +9ixdRu6JqQrntouG \ No newline at end of file diff --git a/banban-mini/project.config.json b/banban-mini/project.config.json index 18a8e44..dfee6aa 100644 --- a/banban-mini/project.config.json +++ b/banban-mini/project.config.json @@ -1,7 +1,7 @@ { "miniprogramRoot": "dist/", "projectname": "banban-mini", - "appid": "", + "appid": "wx29ce99ce96b56cd9", "compileType": "miniprogram", "setting": { "urlCheck": false, diff --git a/banban-mini/src/pages/chat/index.scss b/banban-mini/src/pages/chat/index.scss index 2e71362..91fc769 100644 --- a/banban-mini/src/pages/chat/index.scss +++ b/banban-mini/src/pages/chat/index.scss @@ -25,8 +25,11 @@ overflow: hidden; } -.guest-card { +.guest-chat-shell { margin: 0 24px; +} + +.guest-card { padding: 44px 32px; border-radius: 20px; background: #FFFFFF; @@ -64,6 +67,10 @@ margin-top: 30px; border-radius: 16px; background: #07C160; + + &.secondary { + background: #FF8C42; + } } .guest-card-btn-text { @@ -72,6 +79,139 @@ color: #FFFFFF; } +.guest-demo-card, +.guest-guide-card { + margin-top: 20px; + padding: 28px 24px; + border-radius: 20px; + background: #FFFFFF; + box-shadow: 0 2px 12px rgba(0, 0, 0, 0.04); +} + +.guest-demo-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 20px; +} + +.guest-demo-title, +.guest-guide-title { + display: block; + font-size: 30px; + line-height: 1.35; + font-weight: 700; + color: #1A1A1A; +} + +.guest-demo-tag { + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + min-width: 92px; + height: 52px; + padding: 0 18px; + border-radius: 999px; + background: #FFF2E7; + + &:active { + opacity: 0.72; + } +} + +.guest-demo-tag-text { + font-size: 24px; + font-weight: 700; + color: #FF8C42; +} + +.guest-demo-item { + display: flex; + align-items: center; + padding: 20px 0; + border-top: 1px solid #F2F2F2; + + &:first-of-type { + border-top: none; + } + + &:active { + opacity: 0.72; + } +} + +.guest-demo-avatar { + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + width: 76px; + height: 76px; + margin-right: 20px; + border-radius: 20px; + background: #FFF2E7; + + text { + font-size: 30px; + font-weight: 700; + color: #FF8C42; + } + + &.parent { + background: #E8F8F0; + + text { + color: #16A34A; + } + } +} + +.guest-demo-content { + flex: 1; + min-width: 0; +} + +.guest-demo-action { + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + margin-left: 16px; + min-width: 86px; + height: 56px; + padding: 0 18px; + border-radius: 14px; + background: #F5F7FA; + + &:active { + opacity: 0.72; + } +} + +.guest-demo-action-text { + font-size: 24px; + font-weight: 700; + color: #FF8C42; +} + +.guest-demo-name { + display: block; + font-size: 28px; + line-height: 1.35; + font-weight: 700; + color: #1A1A1A; +} + +.guest-demo-message, +.guest-guide-desc { + display: block; + margin-top: 8px; + font-size: 25px; + line-height: 1.55; + color: #666666; +} + .conversation-item { background: #FFFFFF; border-radius: 20px; diff --git a/banban-mini/src/pages/chat/index.tsx b/banban-mini/src/pages/chat/index.tsx index 9c40e9a..6815c24 100644 --- a/banban-mini/src/pages/chat/index.tsx +++ b/banban-mini/src/pages/chat/index.tsx @@ -64,6 +64,15 @@ export default function Chat() { Taro.navigateTo({ url: '/pages/login/index' }) } + const handleGuestPreviewTap = (title: string, content: string) => { + Taro.showModal({ + title, + content, + showCancel: false, + confirmText: '我知道了', + }) + } + return ( @@ -71,12 +80,82 @@ export default function Chat() { {isGuest ? ( - - 💬 - 亲子互动会话 - 绑定设备后,可以在这里查看宝贝、家长和 AI 玩伴之间的消息记录。 - - 登录后查看 + + + 💬 + 亲子互动会话 + 这里会汇总宝贝、家长和 AI 玩伴之间的消息。绑定设备后,真实会话会按宝贝和设备自动归档。 + + + + + 会话示例 + handleGuestPreviewTap( + '会话预览', + '这里展示玩伴页的会话能力。绑定设备后,会显示宝贝、家长和 AI 玩伴的真实消息记录。' + )} + > + 预览 + + + handleGuestPreviewTap( + 'AI 玩伴', + '绑定设备后,可以在这里查看宝贝与 AI 玩伴的真实会话记录,包括聊天内容、时间和对应宝贝。' + )} + > + + + + + AI 玩伴 + 陪宝贝聊天、讲故事,也能留下可回看记录。 + + handleGuestPreviewTap( + 'AI 玩伴', + '绑定设备后,可以在这里查看宝贝与 AI 玩伴的真实会话记录,包括聊天内容、时间和对应宝贝。' + )} + > + 查看 + + + handleGuestPreviewTap( + '家长留言', + '绑定设备后,家长可以查看消息记录,也可以给宝贝发送语音留言,设备端会收到对应提醒。' + )} + > + + + + + 家长留言 + 家长可查看设备消息,并给宝贝发送语音留言。 + + handleGuestPreviewTap( + '家长留言', + '绑定设备后,家长可以查看消息记录,也可以给宝贝发送语音留言,设备端会收到对应提醒。' + )} + > + 查看 + + + + + + 开始使用 + 浏览功能不需要登录;当你需要绑定设备、查看真实会话记录时,再进入登录和绑定流程。 + + 去登录 / 绑定设备 + ) : loading ? ( diff --git a/banban-mini/src/pages/device/index.tsx b/banban-mini/src/pages/device/index.tsx index 0c3849a..6ebea12 100644 --- a/banban-mini/src/pages/device/index.tsx +++ b/banban-mini/src/pages/device/index.tsx @@ -318,7 +318,7 @@ export default function Device() { 准备绑定或管理设备 登录后可绑定设备,并在绑定流程中创建或选择宝贝资料。 goLoginWithRedirect('/pages/device/index')}> - 登录后继续 + 去登录 / 绑定设备 {systemBanner} diff --git a/banban-mini/src/pages/location/index.scss b/banban-mini/src/pages/location/index.scss index 25b902a..731e528 100644 --- a/banban-mini/src/pages/location/index.scss +++ b/banban-mini/src/pages/location/index.scss @@ -165,6 +165,10 @@ margin: 0 24px; } +.guest-location-panel { + margin: 0 24px; +} + .location-empty-card { background: #FFFFFF; border-radius: 20px; @@ -211,6 +215,10 @@ display: flex; align-items: center; justify-content: center; + + &.secondary { + background: #07C160; + } } .location-empty-action-text { @@ -218,3 +226,55 @@ color: #FFFFFF; font-weight: 600; } + +.guest-location-card { + margin-top: 20px; + padding: 28px 24px; + border-radius: 20px; + background: #FFFFFF; + box-shadow: 0 2px 12px rgba(0, 0, 0, 0.04); +} + +.guest-location-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 24px; + padding: 20px 0; + border-top: 1px solid #F2F2F2; + + &:first-child { + border-top: none; + } +} + +.guest-location-label { + flex-shrink: 0; + font-size: 27px; + font-weight: 700; + color: #1A1A1A; +} + +.guest-location-value { + min-width: 0; + font-size: 26px; + line-height: 1.45; + color: #666666; + text-align: right; +} + +.guest-location-title { + display: block; + font-size: 30px; + font-weight: 700; + line-height: 1.35; + color: #1A1A1A; +} + +.guest-location-desc { + display: block; + margin-top: 10px; + font-size: 26px; + line-height: 1.55; + color: #666666; +} diff --git a/banban-mini/src/pages/location/index.tsx b/banban-mini/src/pages/location/index.tsx index 69ef400..dc17b04 100644 --- a/banban-mini/src/pages/location/index.tsx +++ b/banban-mini/src/pages/location/index.tsx @@ -394,18 +394,38 @@ export default function Location() { }} /> - + 设备定位与轨迹 - 绑定设备后,可在这里查看宝贝设备的位置、更新时间和定位精度。 + 这里用于展示设备当前位置、更新时间、定位精度和轨迹点。上方地图为功能预览,真实位置会在绑定设备后显示。 + + + + + 当前位置 + 绑定设备后显示 + + + 今日轨迹 + 按上报时间形成轨迹 + + + 定位信息 + 坐标、精度、电量和地址 + + + + + 开始使用 + 浏览地图和功能说明不需要登录;绑定设备或查看真实定位数据时,再进入登录流程。 { Taro.setStorageSync('postLoginRedirect', '/pages/location/index') Taro.navigateTo({ url: '/pages/login/index' }) }} > - 登录后查看 + 去登录 / 绑定设备 diff --git a/banban-mini/src/pages/login/index.scss b/banban-mini/src/pages/login/index.scss index dc85522..cc50fcb 100644 --- a/banban-mini/src/pages/login/index.scss +++ b/banban-mini/src/pages/login/index.scss @@ -127,6 +127,20 @@ opacity: 0.8; } } + + .guest-browse-btn { + margin-top: 24px; + height: 72px; + display: flex; + align-items: center; + justify-content: center; + } + + .guest-browse-text { + font-size: 28px; + color: #1677FF; + font-weight: 500; + } } .agreement-panel { diff --git a/banban-mini/src/pages/login/index.tsx b/banban-mini/src/pages/login/index.tsx index e845391..6ca37a2 100644 --- a/banban-mini/src/pages/login/index.tsx +++ b/banban-mini/src/pages/login/index.tsx @@ -81,6 +81,11 @@ export default function Login() { } } + const handleGuestBrowse = () => { + Taro.removeStorageSync('postLoginRedirect') + Taro.reLaunch({ url: '/pages/device/index' }) + } + const handleAgreementChange = (event) => { const values = event.detail.value || [] setAgreementAccepted(values.includes('accepted')) @@ -161,6 +166,9 @@ export default function Login() { 🌐 {submitting ? '登录中...' : '登录后继续'} + + 暂不登录,先浏览 + diff --git a/banban-mini/src/pages/sleep/index.scss b/banban-mini/src/pages/sleep/index.scss index e18dae2..55554d7 100644 --- a/banban-mini/src/pages/sleep/index.scss +++ b/banban-mini/src/pages/sleep/index.scss @@ -109,6 +109,11 @@ background: #FFFFFF; box-shadow: 0 2px 12px rgba(0, 0, 0, 0.04); text-align: center; + + &.action-card { + padding: 32px 28px; + text-align: left; + } } .guest-manage-icon { @@ -123,6 +128,12 @@ font-size: 34px; font-weight: 700; color: #1A1A1A; + + &.small { + margin-top: 0; + font-size: 30px; + line-height: 1.35; + } } .guest-manage-desc { @@ -141,6 +152,10 @@ margin-top: 30px; border-radius: 16px; background: #07C160; + + &.secondary { + background: #FF8C42; + } } .guest-manage-btn-text { diff --git a/banban-mini/src/pages/sleep/index.tsx b/banban-mini/src/pages/sleep/index.tsx index ad9c578..53aca79 100644 --- a/banban-mini/src/pages/sleep/index.tsx +++ b/banban-mini/src/pages/sleep/index.tsx @@ -980,16 +980,7 @@ export default function Sleep() { ⚙️ 管理宝贝资料和设备 - 登录后可创建宝贝资料、绑定伴伴设备、管理卡片、设置休眠时间和家庭成员。 - { - Taro.setStorageSync('postLoginRedirect', '/pages/sleep/index') - Taro.navigateTo({ url: '/pages/login/index' }) - }} - > - 登录后管理 - + 这里可以管理宝贝资料、设备绑定、卡片、休眠时间、系统更新和家庭成员。当前页面为功能预览,真实数据会在绑定设备后展示。 @@ -1005,6 +996,28 @@ export default function Sleep() { 休眠设置 设置设备休眠时间和远程控制。 + + 系统更新 + 查看设备版本,并在设备在线时发起更新。 + + + 家庭共享 + 邀请家庭成员一起查看和管理同一台设备。 + + + + + 准备绑定设备 + 浏览功能不需要登录;创建宝贝资料、绑定设备或查看真实设备数据时,再进入登录流程。 + { + Taro.setStorageSync('postLoginRedirect', '/pages/sleep/index') + Taro.navigateTo({ url: '/pages/login/index' }) + }} + > + 去登录 / 绑定设备 + {systemBanner} diff --git a/docs/architecture/01-system-overview.md b/docs/architecture/01-system-overview.md new file mode 100644 index 0000000..2edcfbd --- /dev/null +++ b/docs/architecture/01-system-overview.md @@ -0,0 +1,69 @@ +# System Overview + +## Runtime Architecture + +```mermaid +flowchart LR + subgraph WeChat["WeChat Ecosystem"] + Mini["banban-mini\nTaro WeChat mini-program"] + WxLogin["WeChat login / phone APIs"] + WxMP["WeChat Official Account\nnotifications and bind"] + end + + subgraph Edge["Public Edge"] + Domain["banban.api.talkingq.com"] + Nginx["Nginx / TLS"] + end + + subgraph Backend["talkingq-url FastAPI backend"] + Main["main.py\nFastAPI app + lifespan"] + AuthMW["banban auth middleware"] + BanbanRouter["banban routers\nchildren, devices, binding, chat,\nlocation, alarms, firmware, roles,\nwechat-mp"] + MqttSvc["TalkingQMQTTService"] + Scheduler["TaskScheduler\ncleanup and periodic tasks"] + StaticAssets["/assets static files"] + end + + subgraph Data["Stateful Services"] + MySQL["MySQL\nshared banban schema"] + COS["Tencent COS\nmessage audio files"] + MQTT["MQTT broker"] + end + + subgraph DeviceSide["Device Side"] + Device["TalkingQ device"] + NFC["NFC card"] + end + + subgraph External["External Providers"] + MapSvc["Map / reverse geocoding provider"] + SMS["SMS provider"] + end + + Mini -->|HTTPS /banban/*| Domain --> Nginx --> Main + Main --> AuthMW --> BanbanRouter + Main --> StaticAssets + Main --> Scheduler + Main --> MqttSvc + BanbanRouter --> MySQL + MqttSvc --> MQTT + Device -->|device/{id}/event,response| MQTT + MQTT -->|subscribed messages| MqttSvc + MqttSvc --> MySQL + Device --> NFC + BanbanRouter --> COS + MqttSvc --> COS + BanbanRouter --> WxLogin + BanbanRouter --> WxMP + BanbanRouter --> SMS + MqttSvc --> MapSvc +``` + +## Source Anchors + +- Mini-program API base and services: `banban-mini/src/services/*` +- FastAPI startup and router registration: `talkingq-url/main.py` +- Banban route aggregation: `talkingq-url/banban/routers/*` +- MQTT service and device message handlers: `talkingq-url/handlers/mqtt_handler.py` +- Shared database schema: `database/talkingq_shared_schema.sql` +- Runtime database initialization: `talkingq-url/database/init_db.py` diff --git a/docs/architecture/02-device-bootstrap.md b/docs/architecture/02-device-bootstrap.md new file mode 100644 index 0000000..759185c --- /dev/null +++ b/docs/architecture/02-device-bootstrap.md @@ -0,0 +1,85 @@ +# Device Bootstrap Flow + +## Purpose + +Device bootstrap maps a factory IMEI to Banban's internal `device_id` and +`serial_number`. Devices do not need to know those IDs before first bootstrap. + +## Flow + +```mermaid +sequenceDiagram + autonumber + participant Factory as Factory Excel / import CSV + participant Mapping as device_imei_mapping + participant Device as TalkingQ device + participant MQTT as MQTT broker + participant Handler as TalkingQMQTTService + participant Init as device_identity_initializer + participant Auth as device_auth + + Factory->>Mapping: Insert imei, device_id, serial_number, status=pending + Device->>MQTT: Publish device/{imei}/event msg_id=012 + MQTT->>Handler: Deliver identity init event + Handler->>Init: initialize_by_imei(imei) + Init->>Mapping: SELECT mapping by imei + alt mapping missing or incomplete + Init-->>Handler: DeviceIdentityInitializationError + Handler-->>MQTT: device/{imei}/event_resp status=failed + else mapping found + Init->>Auth: SELECT device_auth by device_id + alt no auth row + Init->>Auth: INSERT device_id, serial_number, is_active=1 + else auth row exists and serial matches + Init->>Auth: UPDATE is_active=1 + else serial mismatch + Init-->>Handler: device_auth serial_number conflict + Handler-->>MQTT: device/{imei}/event_resp status=failed + end + Init->>Mapping: UPDATE status=activated, activated_at=now + Handler-->>MQTT: device/{imei}/event_resp with device_id and device_sn + MQTT-->>Device: Receive assigned identity + end +``` + +## Data Model + +```mermaid +erDiagram + device_imei_mapping { + int id PK + varchar imei UK + varchar device_id UK + varchar serial_number + varchar status + datetime activated_at + datetime created_at + datetime updated_at + } + + device_auth { + int id PK + varchar device_id UK + varchar serial_number + varchar batch_id + tinyint is_active + datetime created_at + datetime updated_at + } + + device_imei_mapping ||--o| device_auth : activates +``` + +## Current ID Style + +```text +device_id: TalkingQ_ZHWK00002001 +serial_number: TQ_ZHWK000000002001 +``` + +## Source Anchors + +- Bootstrap handler: `talkingq-url/handlers/mqtt_handler.py` +- Identity initializer: `talkingq-url/services/device_identity_initializer.py` +- Import script: `talkingq-url/scripts/import_device_imei_mapping.py` +- Tables: `database/talkingq_shared_schema.sql` diff --git a/docs/architecture/03-binding-flow.md b/docs/architecture/03-binding-flow.md new file mode 100644 index 0000000..96a1fbb --- /dev/null +++ b/docs/architecture/03-binding-flow.md @@ -0,0 +1,90 @@ +# Binding Flow + +## Device Binding + +The mini-program binding flow validates an active `device_auth` row before it +creates a pending bind session. Final binding is completed after the device +returns an NFC card UUID. + +```mermaid +sequenceDiagram + autonumber + participant Parent as Parent in mini-program + participant Mini as banban-mini bind page + participant API as /banban/bind APIs + participant Binding as BindingService + participant DAO as BindingDAO + participant Auth as device_auth + participant MQTT as TalkingQMQTTService + participant Device as TalkingQ device + participant Card as cards + participant Bindings as device_bindings + participant History as device_bind_history + + Parent->>Mini: Scan QR or open bind URL + Mini->>API: start_bind(device_id, serial_number, child_id) + API->>Binding: start_bind(user_id, device_id, serial_number, child_id) + Binding->>Auth: get_device_auth(device_id) + alt device missing, inactive, or serial mismatch + Binding-->>API: BindingError + API-->>Mini: 4xx error + else device is bindable + Binding->>DAO: ensure no active device binding + Binding->>DAO: create device_bind_session + Binding->>MQTT: send_bind_nfc_command(msg_id=006) + MQTT-->>Device: device/{device_id}/command + API-->>Mini: bind_token, expires_at + end + + Device->>MQTT: NFC response / card UUID + MQTT->>Binding: finalize_nfc_bind(device_id, card_uuid) + Binding->>DAO: load latest pending bind session + Binding->>Card: activate_card(card_uuid, device_id) + Binding->>Bindings: create active binding + Binding->>History: write bind audit history + Binding->>DAO: mark session completed +``` + +## Related Tables + +```mermaid +erDiagram + parents ||--o{ children : owns + parents ||--o{ device_bindings : owns + children ||--o{ device_bindings : current_child + device_auth ||--o{ device_bindings : device + device_auth ||--o{ device_bind_sessions : pending_bind + device_auth ||--o{ cards : card_owner_device + device_bindings ||--o{ device_bind_history : audit + + device_auth { + varchar device_id UK + varchar serial_number + tinyint is_active + } + + device_bindings { + int binding_id PK + varchar device_id FK + int owner_user_id FK + int child_id FK + int status + } + + device_bind_sessions { + int id PK + varchar bind_token UK + varchar device_id FK + int initiator_user_id + int target_child_id + int status + datetime expires_at + } +``` + +## Source Anchors + +- Mini-program bind page: `banban-mini/src/pages/bind/index.tsx` +- Binding service: `talkingq-url/banban/service/binding.py` +- Binding DAO: `talkingq-url/banban/dao/binding.py` +- MQTT bind command handling: `talkingq-url/handlers/mqtt_handler.py` diff --git a/docs/architecture/04-voice-and-im-flow.md b/docs/architecture/04-voice-and-im-flow.md new file mode 100644 index 0000000..44cadc4 --- /dev/null +++ b/docs/architecture/04-voice-and-im-flow.md @@ -0,0 +1,74 @@ +# Voice And IM Flow + +## Parent Voice Message To Device + +```mermaid +sequenceDiagram + autonumber + participant Parent as Parent + participant Mini as Chat detail page + participant API as /banban chat APIs + participant Audio as Upload / audio handling + participant COS as Tencent COS + participant IM as IM service / DAO + participant DB as im_conversations + im_messages + participant Pending as device_pending_voice_messages + participant MQTT as MQTT broker + participant Device as TalkingQ device + + Parent->>Mini: Long press "press to leave message" + Mini->>Mini: Taro.getRecorderManager records mp3/aac + Mini->>API: upload voice file + metadata + API->>Audio: validate and store audio + Audio->>COS: upload message audio + COS-->>Audio: media_file_key / public URL + API->>IM: create parent voice IM message + IM->>DB: upsert conversation and insert message + IM->>Pending: create pending voice delivery row + API-->>Mini: message item with media URL + + Device->>MQTT: device/{device_id}/event msg_id=005 NFC listen + MQTT->>API: MQTT handler receives event + API->>Pending: find pending voice for card/device + API-->>MQTT: device/{device_id}/event_resp with audio URL(s) + MQTT-->>Device: Play pending audio + API->>Pending: mark delivered / increment delivery count +``` + +## Device / Child Voice Into IM + +```mermaid +flowchart TD + Device["Device short press / voice upload\nmsg_id=011 or compatibility path"] + MQTT["TalkingQMQTTService"] + Upload["Audio upload / archive service"] + Resolve["Resolve bound owner and child\nfrom device_bindings"] + IM["IM service"] + Tables["im_conversations\nim_messages"] + Mini["banban-mini chat list/detail"] + + Device --> MQTT --> Upload --> Resolve --> IM --> Tables + Tables -->|GET chat APIs| Mini +``` + +## Audio Domain Notes + +```mermaid +flowchart LR + Message["im_messages.media_file_key"] + Presenter["present_message_item / chat service"] + URL["mediaUrl returned to mini-program"] + Player["Taro InnerAudioContext"] + COS["COS downloadFile legal domain"] + + Message --> Presenter --> URL --> Player + URL --> COS +``` + +## Source Anchors + +- Chat detail recording/playback: `banban-mini/src/pages/chat/detail/index.tsx` +- Chat service client: `banban-mini/src/services/chat.ts` +- Backend IM service and DAO: `talkingq-url/banban/service/im.py`, `talkingq-url/banban/dao/im.py` +- Audio upload and COS: `talkingq-url/handlers/audio_file_handler.py`, `talkingq-url/banban/service/device_audio_cache.py` +- Pending voice table: `database/talkingq_shared_schema.sql` diff --git a/docs/architecture/05-location-flow.md b/docs/architecture/05-location-flow.md new file mode 100644 index 0000000..5546fae --- /dev/null +++ b/docs/architecture/05-location-flow.md @@ -0,0 +1,88 @@ +# Location Flow + +## Device-Sourced Location + +The mini-program location page displays the bound device's reported location. +It is not the same as reading the parent's phone location. + +```mermaid +sequenceDiagram + autonumber + participant Device as TalkingQ device + participant MQTT as MQTT broker + participant Handler as TalkingQMQTTService + participant Location as Location service + participant Current as child_location_current + participant History as child_location_history + participant Mini as banban-mini location page + participant API as /banban/devices/{device_id}/location + + Device->>MQTT: msg_id=000 with latitude/longitude or GPS response + MQTT->>Handler: deliver device event/response + Handler->>Location: report_mqtt_device_location(...) + Location->>Location: resolve active child binding + Location->>Current: upsert latest location + Location->>History: insert location history point + Handler-->>MQTT: success event_resp + + Mini->>API: GET current location + API->>Current: read current row for bound child/device + API-->>Mini: current location, accuracy, battery, address fields + + Mini->>API: GET trajectory + API->>History: read recent/today points + API-->>Mini: trajectory points +``` + +## Data Model + +```mermaid +erDiagram + device_auth ||--o{ child_location_current : device + children ||--o{ child_location_current : child + device_auth ||--o{ child_location_history : device + children ||--o{ child_location_history : child + device_bindings ||--o{ child_location_current : active_binding_source + + child_location_current { + int id PK + int child_id FK + varchar device_id FK + decimal lat + decimal lng + varchar coord_type + decimal accuracy_m + int battery_pct + datetime device_time + datetime server_time + varchar address + } + + child_location_history { + int id PK + int child_id FK + varchar device_id FK + decimal lat + decimal lng + varchar coord_type + datetime device_time + datetime server_time + } +``` + +## Review / Audit Wording + +```text +Location is sourced from the bound child device and shown to the parent for +device tracking, trajectory, and alarm context. It is not used to get the +parent phone's current location unless a separate phone-location feature is +added later. +``` + +## Source Anchors + +- Mini-program location page: `banban-mini/src/pages/location/index.tsx` +- Location API client: `banban-mini/src/services/location.ts` +- Device location routes: `talkingq-url/banban/routers/devices.py` +- Location service: `talkingq-url/banban/service/location.py` +- MQTT persistence: `talkingq-url/handlers/mqtt_handler.py` diff --git a/docs/architecture/06-alarm-and-notification-flow.md b/docs/architecture/06-alarm-and-notification-flow.md new file mode 100644 index 0000000..364a10e --- /dev/null +++ b/docs/architecture/06-alarm-and-notification-flow.md @@ -0,0 +1,75 @@ +# Alarm And Notification Flow + +## Device Alarm To User Notification + +```mermaid +sequenceDiagram + autonumber + participant Device as TalkingQ device + participant MQTT as MQTT broker + participant Handler as TalkingQMQTTService + participant Alarm as Alarm service + participant Location as Location snapshot + participant DB as device_alarm_events + participant Banner as system banner polling + participant Mini as banban-mini + participant WxMP as WeChat Official Account + participant SMS as SMS provider + + Device->>MQTT: alarm event msg_id=010 + MQTT->>Handler: deliver alarm payload + Handler->>Alarm: create alarm event + Alarm->>Location: attach current location snapshot if available + Alarm->>DB: insert device_alarm_events + Alarm->>WxMP: schedule/send template notification if bound + Alarm->>SMS: send SMS if phone notification is enabled + Banner->>DB: poll latest/new alarm + Mini->>Banner: show banner / alarm history on device home + Handler-->>MQTT: alarm event_resp +``` + +## Supporting Notification Bind + +```mermaid +flowchart TD + Mini["banban-mini management page"] + Phone["WeChat phone authorization"] + WxBind["WeChat MP bind start/status"] + Backend["talkingq-url wechat-mp routers/services"] + Parent["parents / parent_wechat_* tables"] + Notify["wechat_mp_notification_service"] + + Mini --> Phone --> Backend --> Parent + Mini --> WxBind --> Backend --> Parent + Parent --> Notify +``` + +## Key Tables + +```mermaid +erDiagram + device_auth ||--o{ device_alarm_events : reports + children ||--o{ device_alarm_events : alarm_owner + parents ||--o{ parent_wechat_accounts : mp_account + parents ||--o{ parent_wechat_identities : wx_identity + parents ||--o{ wechat_mp_bind_states : bind_state + + device_alarm_events { + int id PK + varchar device_id FK + int child_id FK + varchar source_msg_id + decimal location_lat + decimal location_lng + datetime location_updated_at + datetime created_at + } +``` + +## Source Anchors + +- Alarm schema: `database/talkingq_shared_schema.sql` +- Alarm location refresh tests: `talkingq-url/tests/test_alarm_gps_location_refresh.py` +- System banner client: `banban-mini/src/services/system-banner.ts` +- Device home alarm UI: `banban-mini/src/pages/device/index.tsx` +- WeChat MP notification service: `talkingq-url/banban/service/wechat_mp_notification.py` diff --git a/docs/architecture/07-deployment.md b/docs/architecture/07-deployment.md new file mode 100644 index 0000000..963f206 --- /dev/null +++ b/docs/architecture/07-deployment.md @@ -0,0 +1,60 @@ +# Deployment + +## Public Deployment Shape + +```mermaid +flowchart TD + User["WeChat mini-program user"] + Domain["banban.api.talkingq.com"] + Nginx["Nginx container\nTLS + reverse proxy + static verify files"] + FastAPI["talkingq-url FastAPI process\n/home/ubuntu/banban-backend"] + MySQL["1Panel MySQL container\nMySQL 8.x"] + MQTT["Configured MQTT broker"] + COS["Tencent COS"] + Certbot["certbot container\nLet's Encrypt renewals"] + + User -->|HTTPS| Domain --> Nginx --> FastAPI + FastAPI --> MySQL + FastAPI --> MQTT + FastAPI --> COS + Certbot --> Nginx +``` + +## Startup Responsibilities + +```mermaid +sequenceDiagram + autonumber + participant Proc as FastAPI process + participant Init as init_db + participant Scheduler as TaskScheduler + participant MQTT as TalkingQMQTTService + participant Roles as role_manager + participant Firmware as firmware_scanner + + Proc->>Init: initialize and migrate database tables + Proc->>Scheduler: start periodic tasks + Proc->>MQTT: connect to configured broker + Proc->>Roles: initialize role definitions + Proc->>Firmware: scan firmware files + Proc-->>Proc: serve API routes and assets +``` + +## Deployment Notes + +- The mini-program production API base should resolve to + `https://banban.api.talkingq.com`. +- WeChat legal domains must include the API host and any audio/COS download + domains used by `mediaUrl`. +- MySQL table shape should be checked against the live database before direct + data work, because deployment state can drift from local schema files. +- Device identity data should normally enter through `device_imei_mapping`; + `device_auth` is activated by device bootstrap unless a deliberate pre-activation + path is required. + +## Source Anchors + +- Docker/Nginx assets: `talkingq-url/docker-compose.yml`, `talkingq-url/nginx.conf` +- Runtime startup: `talkingq-url/main.py` +- Database initialization: `talkingq-url/database/init_db.py` +- Mini-program API config: `banban-mini/config/prod.js`, `banban-mini/src/config/env.ts` diff --git a/docs/architecture/README.md b/docs/architecture/README.md new file mode 100644 index 0000000..a91e0c9 --- /dev/null +++ b/docs/architecture/README.md @@ -0,0 +1,20 @@ +# Banban Architecture Diagrams + +This folder contains repo-backed Mermaid diagrams for the Banban mini-program, +backend, device, database, and notification flows. + +## Diagrams + +- [01 System Overview](./01-system-overview.md) +- [02 Device Bootstrap](./02-device-bootstrap.md) +- [03 Binding Flow](./03-binding-flow.md) +- [04 Voice And IM Flow](./04-voice-and-im-flow.md) +- [05 Location Flow](./05-location-flow.md) +- [06 Alarm And Notification Flow](./06-alarm-and-notification-flow.md) +- [07 Deployment](./07-deployment.md) + +## Maintenance Notes + +- Keep diagrams tied to current code paths and table names. +- Prefer several narrow diagrams over one overloaded diagram. +- Update the source anchors when moving routers, services, handlers, or tables. diff --git a/docs/architecture/index.html b/docs/architecture/index.html new file mode 100644 index 0000000..4a7b6f3 --- /dev/null +++ b/docs/architecture/index.html @@ -0,0 +1,600 @@ + + + + + + Banban Architecture Diagrams + + + +
+
+

Banban Architecture Diagrams

+

+ Rendered Mermaid viewer for the architecture docs. Requires internet + access for the Mermaid CDN when opened as a local file. +

+ +
+
+ +
+
+

01 System Overview

+

Public edge, mini-program, backend, database, MQTT, device, and external providers.

+

Runtime Architecture

+
+flowchart LR
+  subgraph WeChat["WeChat Ecosystem"]
+    Mini["banban-mini\nTaro WeChat mini-program"]
+    WxLogin["WeChat login / phone APIs"]
+    WxMP["WeChat Official Account\nnotifications and bind"]
+  end
+
+  subgraph Edge["Public Edge"]
+    Domain["banban.api.talkingq.com"]
+    Nginx["Nginx / TLS"]
+  end
+
+  subgraph Backend["talkingq-url FastAPI backend"]
+    Main["main.py\nFastAPI app + lifespan"]
+    AuthMW["banban auth middleware"]
+    BanbanRouter["banban routers\nchildren, devices, binding, chat,\nlocation, alarms, firmware, roles,\nwechat-mp"]
+    MqttSvc["TalkingQMQTTService"]
+    Scheduler["TaskScheduler\ncleanup and periodic tasks"]
+    StaticAssets["/assets static files"]
+  end
+
+  subgraph Data["Stateful Services"]
+    MySQL["MySQL\nshared banban schema"]
+    COS["Tencent COS\nmessage audio files"]
+    MQTT["MQTT broker"]
+  end
+
+  subgraph DeviceSide["Device Side"]
+    Device["TalkingQ device"]
+    NFC["NFC card"]
+  end
+
+  subgraph External["External Providers"]
+    MapSvc["Map / reverse geocoding provider"]
+    SMS["SMS provider"]
+  end
+
+  Mini -->|HTTPS /banban/*| Domain --> Nginx --> Main
+  Main --> AuthMW --> BanbanRouter
+  Main --> StaticAssets
+  Main --> Scheduler
+  Main --> MqttSvc
+  BanbanRouter --> MySQL
+  MqttSvc --> MQTT
+  Device -->|device/{id}/event,response| MQTT
+  MQTT -->|subscribed messages| MqttSvc
+  MqttSvc --> MySQL
+  Device --> NFC
+  BanbanRouter --> COS
+  MqttSvc --> COS
+  BanbanRouter --> WxLogin
+  BanbanRouter --> WxMP
+  BanbanRouter --> SMS
+  MqttSvc --> MapSvc
+        
+
Source: talkingq-url/main.py, banban-mini/src/services/*, database/talkingq_shared_schema.sql
+
+ +
+

02 Device Bootstrap

+

How IMEI mappings become active Banban device identities.

+

Bootstrap Flow

+
+sequenceDiagram
+  autonumber
+  participant Factory as Factory Excel / import CSV
+  participant Mapping as device_imei_mapping
+  participant Device as TalkingQ device
+  participant MQTT as MQTT broker
+  participant Handler as TalkingQMQTTService
+  participant Init as device_identity_initializer
+  participant Auth as device_auth
+
+  Factory->>Mapping: Insert imei, device_id, serial_number, status=pending
+  Device->>MQTT: Publish device/{imei}/event msg_id=012
+  MQTT->>Handler: Deliver identity init event
+  Handler->>Init: initialize_by_imei(imei)
+  Init->>Mapping: SELECT mapping by imei
+  alt mapping missing or incomplete
+    Init-->>Handler: DeviceIdentityInitializationError
+    Handler-->>MQTT: device/{imei}/event_resp status=failed
+  else mapping found
+    Init->>Auth: SELECT device_auth by device_id
+    alt no auth row
+      Init->>Auth: INSERT device_id, serial_number, is_active=1
+    else auth row exists and serial matches
+      Init->>Auth: UPDATE is_active=1
+    else serial mismatch
+      Init-->>Handler: device_auth serial_number conflict
+      Handler-->>MQTT: device/{imei}/event_resp status=failed
+    end
+    Init->>Mapping: UPDATE status=activated, activated_at=now
+    Handler-->>MQTT: device/{imei}/event_resp with device_id and device_sn
+    MQTT-->>Device: Receive assigned identity
+  end
+        
+

Data Model

+
+erDiagram
+  device_imei_mapping {
+    int id PK
+    varchar imei UK
+    varchar device_id UK
+    varchar serial_number
+    varchar status
+    datetime activated_at
+    datetime created_at
+    datetime updated_at
+  }
+
+  device_auth {
+    int id PK
+    varchar device_id UK
+    varchar serial_number
+    varchar batch_id
+    tinyint is_active
+    datetime created_at
+    datetime updated_at
+  }
+
+  device_imei_mapping ||--o| device_auth : activates
+        
+
Source: talkingq-url/services/device_identity_initializer.py, talkingq-url/handlers/mqtt_handler.py
+
+ +
+

03 Binding Flow

+

How a parent binds an authenticated device to a child through NFC confirmation.

+

Device Binding

+
+sequenceDiagram
+  autonumber
+  participant Parent as Parent in mini-program
+  participant Mini as banban-mini bind page
+  participant API as /banban/bind APIs
+  participant Binding as BindingService
+  participant DAO as BindingDAO
+  participant Auth as device_auth
+  participant MQTT as TalkingQMQTTService
+  participant Device as TalkingQ device
+  participant Card as cards
+  participant Bindings as device_bindings
+  participant History as device_bind_history
+
+  Parent->>Mini: Scan QR or open bind URL
+  Mini->>API: start_bind(device_id, serial_number, child_id)
+  API->>Binding: start_bind(user_id, device_id, serial_number, child_id)
+  Binding->>Auth: get_device_auth(device_id)
+  alt device missing, inactive, or serial mismatch
+    Binding-->>API: BindingError
+    API-->>Mini: 4xx error
+  else device is bindable
+    Binding->>DAO: ensure no active device binding
+    Binding->>DAO: create device_bind_session
+    Binding->>MQTT: send_bind_nfc_command(msg_id=006)
+    MQTT-->>Device: device/{device_id}/command
+    API-->>Mini: bind_token, expires_at
+  end
+
+  Device->>MQTT: NFC response / card UUID
+  MQTT->>Binding: finalize_nfc_bind(device_id, card_uuid)
+  Binding->>DAO: load latest pending bind session
+  Binding->>Card: activate_card(card_uuid, device_id)
+  Binding->>Bindings: create active binding
+  Binding->>History: write bind audit history
+  Binding->>DAO: mark session completed
+        
+

Related Tables

+
+erDiagram
+  parents ||--o{ children : owns
+  parents ||--o{ device_bindings : owns
+  children ||--o{ device_bindings : current_child
+  device_auth ||--o{ device_bindings : device
+  device_auth ||--o{ device_bind_sessions : pending_bind
+  device_auth ||--o{ cards : card_owner_device
+  device_bindings ||--o{ device_bind_history : audit
+        
+
Source: banban-mini/src/pages/bind/index.tsx, talkingq-url/banban/service/binding.py
+
+ +
+

04 Voice And IM Flow

+

Parent voice upload, IM persistence, COS audio, and device delivery.

+

Parent Voice Message To Device

+
+sequenceDiagram
+  autonumber
+  participant Parent as Parent
+  participant Mini as Chat detail page
+  participant API as /banban chat APIs
+  participant Audio as Upload / audio handling
+  participant COS as Tencent COS
+  participant IM as IM service / DAO
+  participant DB as im_conversations + im_messages
+  participant Pending as device_pending_voice_messages
+  participant MQTT as MQTT broker
+  participant Device as TalkingQ device
+
+  Parent->>Mini: Long press "press to leave message"
+  Mini->>Mini: Taro.getRecorderManager records mp3/aac
+  Mini->>API: upload voice file + metadata
+  API->>Audio: validate and store audio
+  Audio->>COS: upload message audio
+  COS-->>Audio: media_file_key / public URL
+  API->>IM: create parent voice IM message
+  IM->>DB: upsert conversation and insert message
+  IM->>Pending: create pending voice delivery row
+  API-->>Mini: message item with media URL
+
+  Device->>MQTT: device/{device_id}/event msg_id=005 NFC listen
+  MQTT->>API: MQTT handler receives event
+  API->>Pending: find pending voice for card/device
+  API-->>MQTT: device/{device_id}/event_resp with audio URL(s)
+  MQTT-->>Device: Play pending audio
+  API->>Pending: mark delivered / increment delivery count
+        
+

Device / Child Voice Into IM

+
+flowchart TD
+  Device["Device short press / voice upload\nmsg_id=011 or compatibility path"]
+  MQTT["TalkingQMQTTService"]
+  Upload["Audio upload / archive service"]
+  Resolve["Resolve bound owner and child\nfrom device_bindings"]
+  IM["IM service"]
+  Tables["im_conversations\nim_messages"]
+  Mini["banban-mini chat list/detail"]
+
+  Device --> MQTT --> Upload --> Resolve --> IM --> Tables
+  Tables -->|GET chat APIs| Mini
+        
+

Audio Domain

+
+flowchart LR
+  Message["im_messages.media_file_key"]
+  Presenter["present_message_item / chat service"]
+  URL["mediaUrl returned to mini-program"]
+  Player["Taro InnerAudioContext"]
+  COS["COS downloadFile legal domain"]
+
+  Message --> Presenter --> URL --> Player
+  URL --> COS
+        
+
Source: banban-mini/src/pages/chat/detail/index.tsx, talkingq-url/banban/service/im.py
+
+ +
+

05 Location Flow

+

Device-sourced current location and trajectory shown in the mini-program.

+

Device-Sourced Location

+
+sequenceDiagram
+  autonumber
+  participant Device as TalkingQ device
+  participant MQTT as MQTT broker
+  participant Handler as TalkingQMQTTService
+  participant Location as Location service
+  participant Current as child_location_current
+  participant History as child_location_history
+  participant Mini as banban-mini location page
+  participant API as /banban/devices/{device_id}/location
+
+  Device->>MQTT: msg_id=000 with latitude/longitude or GPS response
+  MQTT->>Handler: deliver device event/response
+  Handler->>Location: report_mqtt_device_location(...)
+  Location->>Location: resolve active child binding
+  Location->>Current: upsert latest location
+  Location->>History: insert location history point
+  Handler-->>MQTT: success event_resp
+
+  Mini->>API: GET current location
+  API->>Current: read current row for bound child/device
+  API-->>Mini: current location, accuracy, battery, address fields
+
+  Mini->>API: GET trajectory
+  API->>History: read recent/today points
+  API-->>Mini: trajectory points
+        
+

Data Model

+
+erDiagram
+  device_auth ||--o{ child_location_current : device
+  children ||--o{ child_location_current : child
+  device_auth ||--o{ child_location_history : device
+  children ||--o{ child_location_history : child
+  device_bindings ||--o{ child_location_current : active_binding_source
+        
+
Source: banban-mini/src/pages/location/index.tsx, talkingq-url/banban/service/location.py
+
+ +
+

06 Alarm And Notification Flow

+

Device alarm persistence and user-visible notifications.

+

Device Alarm To User Notification

+
+sequenceDiagram
+  autonumber
+  participant Device as TalkingQ device
+  participant MQTT as MQTT broker
+  participant Handler as TalkingQMQTTService
+  participant Alarm as Alarm service
+  participant Location as Location snapshot
+  participant DB as device_alarm_events
+  participant Banner as system banner polling
+  participant Mini as banban-mini
+  participant WxMP as WeChat Official Account
+  participant SMS as SMS provider
+
+  Device->>MQTT: alarm event msg_id=010
+  MQTT->>Handler: deliver alarm payload
+  Handler->>Alarm: create alarm event
+  Alarm->>Location: attach current location snapshot if available
+  Alarm->>DB: insert device_alarm_events
+  Alarm->>WxMP: schedule/send template notification if bound
+  Alarm->>SMS: send SMS if phone notification is enabled
+  Banner->>DB: poll latest/new alarm
+  Mini->>Banner: show banner / alarm history on device home
+  Handler-->>MQTT: alarm event_resp
+        
+

Supporting Notification Bind

+
+flowchart TD
+  Mini["banban-mini management page"]
+  Phone["WeChat phone authorization"]
+  WxBind["WeChat MP bind start/status"]
+  Backend["talkingq-url wechat-mp routers/services"]
+  Parent["parents / parent_wechat_* tables"]
+  Notify["wechat_mp_notification_service"]
+
+  Mini --> Phone --> Backend --> Parent
+  Mini --> WxBind --> Backend --> Parent
+  Parent --> Notify
+        
+

Key Tables

+
+erDiagram
+  device_auth ||--o{ device_alarm_events : reports
+  children ||--o{ device_alarm_events : alarm_owner
+  parents ||--o{ parent_wechat_accounts : mp_account
+  parents ||--o{ parent_wechat_identities : wx_identity
+  parents ||--o{ wechat_mp_bind_states : bind_state
+        
+
Source: banban-mini/src/services/system-banner.ts, talkingq-url/banban/service/wechat_mp_notification.py
+
+ +
+

07 Deployment

+

Public API host, reverse proxy, backend process, database, MQTT, COS, and certificate renewal.

+

Public Deployment Shape

+
+flowchart TD
+  User["WeChat mini-program user"]
+  Domain["banban.api.talkingq.com"]
+  Nginx["Nginx container\nTLS + reverse proxy + static verify files"]
+  FastAPI["talkingq-url FastAPI process\n/home/ubuntu/banban-backend"]
+  MySQL["1Panel MySQL container\nMySQL 8.x"]
+  MQTT["Configured MQTT broker"]
+  COS["Tencent COS"]
+  Certbot["certbot container\nLet's Encrypt renewals"]
+
+  User -->|HTTPS| Domain --> Nginx --> FastAPI
+  FastAPI --> MySQL
+  FastAPI --> MQTT
+  FastAPI --> COS
+  Certbot --> Nginx
+        
+

Startup Responsibilities

+
+sequenceDiagram
+  autonumber
+  participant Proc as FastAPI process
+  participant Init as init_db
+  participant Scheduler as TaskScheduler
+  participant MQTT as TalkingQMQTTService
+  participant Roles as role_manager
+  participant Firmware as firmware_scanner
+
+  Proc->>Init: initialize and migrate database tables
+  Proc->>Scheduler: start periodic tasks
+  Proc->>MQTT: connect to configured broker
+  Proc->>Roles: initialize role definitions
+  Proc->>Firmware: scan firmware files
+  Proc-->>Proc: serve API routes and assets
+        
+
Source: talkingq-url/docker-compose.yml, talkingq-url/nginx.conf, talkingq-url/main.py
+
+ +

+ If diagrams do not render, open this file with network access enabled or + render the Markdown files in a Mermaid-capable viewer such as GitHub, + GitLab, VS Code with Mermaid support, or Mermaid Live Editor. +

+
+ + + + diff --git a/docs/fa05f36990d894df4e095839d10d9f07.png b/docs/fa05f36990d894df4e095839d10d9f07.png new file mode 100644 index 0000000000000000000000000000000000000000..7fa41acfb3ef5939de91194da4bd9ea37b01417e GIT binary patch literal 134501 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 literal 0 HcmV?d00001 diff --git a/talkingq-url/api/wechat_verify.py b/talkingq-url/api/wechat_verify.py new file mode 100644 index 0000000..494e18f --- /dev/null +++ b/talkingq-url/api/wechat_verify.py @@ -0,0 +1,18 @@ +from pathlib import Path + +from fastapi import APIRouter, HTTPException +from fastapi.responses import PlainTextResponse + +router = APIRouter() + +_VERIFY_FILES = { + "HXNd3XMbPq.txt": Path(__file__).resolve().parent.parent / "docs" / "HXNd3XMbPq.txt", +} + + +@router.get("/{filename}", response_class=PlainTextResponse, include_in_schema=False) +async def get_wechat_verify_file(filename: str) -> PlainTextResponse: + file_path = _VERIFY_FILES.get(filename) + if file_path is None or not file_path.is_file(): + raise HTTPException(status_code=404, detail="Not Found") + return PlainTextResponse(file_path.read_text(encoding="utf-8").strip()) diff --git a/talkingq-url/banban/service/wechat_mp_notification.py b/talkingq-url/banban/service/wechat_mp_notification.py index 07ce081..e695444 100644 --- a/talkingq-url/banban/service/wechat_mp_notification.py +++ b/talkingq-url/banban/service/wechat_mp_notification.py @@ -118,8 +118,6 @@ class WechatMpNotificationService(DatabaseServiceBase): payload = self._decode_notification_token(token) if payload.get("type") != "leave_message": raise HTTPException(status_code=400, detail="unsupported notification type") - if int(payload.get("user_id") or 0) != user_id: - raise HTTPException(status_code=403, detail="notification does not belong to current user") conversation_id = int(payload.get("conversation_id") or 0) message_id = int(payload.get("message_id") or 0) @@ -136,15 +134,16 @@ class WechatMpNotificationService(DatabaseServiceBase): text( """ SELECT - c.id AS conversation_id, + target_c.id AS conversation_id, + target_m.id AS message_id, ch.child_id, ch.child_name, db.device_id - FROM im_conversations AS c - JOIN im_messages AS m - ON m.id = :message_id - AND m.conversation_id = c.id - AND m.deleted_at IS NULL + FROM im_messages AS source_m + JOIN im_conversations AS source_c + ON source_c.id = source_m.conversation_id + AND source_c.status = 1 + AND source_c.conversation_type = 2 JOIN children AS ch ON ch.child_id = :child_id AND ch.status = 1 @@ -155,32 +154,49 @@ class WechatMpNotificationService(DatabaseServiceBase): LEFT JOIN device_bindings AS db ON db.child_id = ch.child_id AND db.status = 1 + AND (:device_id = '' OR db.device_id = :device_id) 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.id = :conversation_id - AND c.status = 1 - AND c.conversation_type = 2 + JOIN im_conversations AS target_c + ON target_c.status = 1 + AND target_c.conversation_type = 2 AND ( ( - c.participant_a_type = 2 - AND c.participant_a_id = :child_id_str - AND c.participant_b_type = 1 - AND c.participant_b_id = :user_id_str + target_c.participant_a_type = 2 + AND target_c.participant_a_id = :child_id_str + AND target_c.participant_b_type = 1 + AND target_c.participant_b_id = :user_id_str ) OR ( - c.participant_b_type = 2 - AND c.participant_b_id = :child_id_str - AND c.participant_a_type = 1 - AND c.participant_a_id = :user_id_str + target_c.participant_b_type = 2 + AND target_c.participant_b_id = :child_id_str + AND target_c.participant_a_type = 1 + AND target_c.participant_a_id = :user_id_str ) ) + JOIN im_messages AS target_m + ON target_m.conversation_id = target_c.id + AND target_m.deleted_at IS NULL + AND target_m.content_type = source_m.content_type + AND target_m.media_file_key = source_m.media_file_key + AND target_m.sender_type = 2 + AND target_m.sender_id = :child_id_str + AND target_m.receiver_type = 1 + AND target_m.receiver_id = :user_id_str + WHERE source_m.id = :message_id + AND source_m.conversation_id = :conversation_id + AND source_m.deleted_at IS NULL + AND source_m.content_type = 2 + AND source_m.media_file_key IS NOT NULL + AND TRIM(source_m.media_file_key) <> '' AND ( pcr.id IS NOT NULL OR db.owner_user_id = :user_id OR dfm.id IS NOT NULL ) + ORDER BY target_m.created_at DESC, target_m.id DESC LIMIT 1 """ ), @@ -189,6 +205,7 @@ class WechatMpNotificationService(DatabaseServiceBase): "conversation_id": conversation_id, "child_id": child_id, "child_id_str": child_id_str, + "device_id": device_id, "user_id": user_id, "user_id_str": user_id_str, }, @@ -202,7 +219,7 @@ class WechatMpNotificationService(DatabaseServiceBase): return { "route": "pages/chat/detail/index", "params": { - "id": conversation_id, + "id": int(row["conversation_id"]), "source": "im", "name": "家长沟通", "peerKind": "parent", @@ -215,7 +232,7 @@ class WechatMpNotificationService(DatabaseServiceBase): "deviceId": resolved_device_id, "channelLabel": "微信小程序", "canSend": "1", - "messageId": message_id, + "messageId": int(row["message_id"]), }, } finally: diff --git a/talkingq-url/docs/HXNd3XMbPq.txt b/talkingq-url/docs/HXNd3XMbPq.txt new file mode 100644 index 0000000..9bcb208 --- /dev/null +++ b/talkingq-url/docs/HXNd3XMbPq.txt @@ -0,0 +1 @@ +60756bcfc3d838155f64397bec395a44 \ No newline at end of file diff --git a/talkingq-url/docs/mqtt协议_V1.9.txt b/talkingq-url/docs/mqtt协议_V1.9.txt new file mode 100644 index 0000000..e12f5b2 --- /dev/null +++ b/talkingq-url/docs/mqtt协议_V1.9.txt @@ -0,0 +1,320 @@ +device/imei/command # 服务器下发命令(下行) +device/imei/response # 设备响应(上行) +device/imei/event # 设备主动事件 +device/imei/event_resp # 事件返回 + +消息格式(JSON) +device/imei/event +0.设备定时10分钟上报信息 +{ + "msg_id": "000", + "data": { + "id":"TalkingQ_xxx", + "power": 20, 剩余电量每个间隔20,从0-100 + "signal": 3, 信号强度1是最弱的 5是最强 + "voice":60, 每个间隔20,从40-100 + "imei":"861921071409242", + "version":"v1.0", + "latitude":23.1173393, + "longitude":113.2855975 + "device_type":0, 0 no NFC+no GPS 1 NFC 2、NFC+GPS + } +} +服务器返回 +device/imei/event_resp +{ + "msg_id": "000", + "status": "success" +} + +1. GPS位置查询 +device/imei/command +服务器下发: +{ + "msg_id": "001" +} + + +device/imei/response +4G模块响应: +{ + "msg_id": "001", + "status": "success", + "data": { + "latitude": 39.9042, + "longitude": 116.4074 + } +} + + +2. 音量调节 +device/imei/command + 每个间隔20,从40-100 +服务器下发: +{ + "msg_id": "002", + "params": { + "level": 100 + } + "time":utc 16位时间戳 +} +device/imei/response +4G模块响应: +{ + "msg_id": "002", + "status": "success", + "data": { + "current_level": 60 + } + "time":utc 16位时间戳 +} + +3. OTA升级 +device/imei/command +服务器下发: +{ + "msg_id": "003", + "params": { + "url": "http://101.35.224.118:8080/assets/firmware/3.0.7.bin", + "version": "3.0.5" + } +} + +device/imei/response +4G模块响应(立即确认): +{ + "msg_id": "003", + "status": "accepted", + "data": { + "current_version": "2.0.5", + "target_version": "2.1.0" + } +} + +升级完成 +{ + "msg_id": "003", + "status": "success", + "data": { + "new_version": "2.1.0" + } +} + +4、NFC留言下发提示 +device/imei/command +如果设备在线,且有未读留言服务器定时10分钟下发一次: +{ + "msg_id": "004", + "params": { + "url": "http://xxx.mp3" //您有新的留言请注意查收 + } +} +device/imei/response +4G模块响应: +{ + "msg_id": "004", + "status": "success", +} + +5、发送或收听留言 +device/imei/event +NFC触发,设备端上报 +{ + "msg_id": "005", + "params": { + "uuid": "53C12B6DA20001" + } +} + + + +//如果不是本机本地UUID,则下发“你好,你的朋友已在线请留言” +//如果本机本地UUID但没有留言,"你好,已没可读留言" +服务器下发 +device/imei/event_resp + +{ + "msg_id": "005", + "status": "success", + "type": 0, //0代表收听留言 1 代表需要发送留言 + "params": { + "url_1": "http://xxx.mp3" + "url_2": "http://xxx.mp3" + } +} + +{ + "msg_id": "005", + "type": 0, + "params": + { + "url_1": "http://175.24.73.253:8080/assets/audio/no_message.mp3" + } + } + +6、NFC绑定uuid +device/imei/command +{ + "msg_id": "006" +} +device/imei/response +4G模块响应: +{ + "msg_id": "006", + "status": "success", + "data": { + "uuid": "53D22B6DA20001" + } +} + +7、开关状态 +//0查询状态 1 控制打开 2控制关闭 +device/imei/command +{ + "msg_id": "007", + "type": 0 +} +device/imei/response +4G模块响应: +{ + "msg_id": "007", + "status": "success", + "data": { + "status": "on" //on或off 返回当前状态 + } +} + +8、定时休眠 +device/imei/command +{ + "msg_id": "008", + "params": { + "start": "22:00" , //设置时和分 + "end": "07:00" //设置时和分 + } +} +device/imei/response +4G模块响应: +{ + "msg_id": "008", + "status": "success" +} + +9、远程休眠和唤醒 (用7来替代) +device/imei/command +{ + "msg_id": "009", + "params": { + "switch": "on" // on 唤醒 off 休眠 + } +} +device/imei/response +4G模块响应: +{ + "msg_id": "009", + "status": "success" +} + +10、额外按键长按发送告警信息 +设备上报 +device/imei/event +{ + "msg_id": "010" //发送告警 +} +服务器返回 +device/imei/event_resp +4G模块响应: +{ + "msg_id": "010", + "status": "success" +} + +11、额外按键短按发送留言给家长 +设备上报 +device/imei/event +{ + "msg_id": "011", + "params": { + "uuid": "TalkingQ_xxx" + } +} +服务器返回 +device/imei/event_resp +{ + "msg_id": "011", + "type": 0, + "params": + { + "url": "http://175.24.73.253:8080/assets/audio/message_ok.mp3" + } + } + +12、增加后台自动下发DeviceID和SN流程 +设备端上报 +device/imei/event +{ + "msg_id": "012" +} +服务器下发 +device/imei/event_resp +{ + "msg_id": "012", + "params": + { + "device_id":"TalkingQ_XQSN00001003", + "device_sn":"TQ_XQSN000000001003" + } + } + +TalkingQ_XQSN00001003 TQ_XQSN000000001003 +53C12B6DA20001 +53D92B6DA20001 +53DA2B6DA20001 + +set id TalkingQ_XQSN00001003 +set sn TQ_XQSN000000001003 + +TalkingQ_XQSN00001004 TQ_XQSN000000001004 +53E12B6DA20001 +53D12B6DA20001 +53D22B6DA20001 + +TalkingQ_XQSN00001005 TQ_XQSN000000001005 +53C22B6DA20001 + +1000000189ABCDEFGHIJKLMNOPQRSTUV +1000000089ABCDEFGHIJKLMNOPQRSTUV + + +1、额外按钮 + 短按 直接与父母留言 + 长按 播报紧急报警系统 + +2、休眠时间段设置功能 + 在设定时间内,设备不可启动 + 设定时间过后,可以正常使用 + + +3、远程一键休眠和一键唤醒 + + +mqtt地址 +http://175.24.73.253:18083/#/login?to=/clients +admin +emqx_DPWQyk + + +工作态:定时休眠 + 中途反复触发立即休眠 +1. 开启定时休眠 →规定时间休眠中 +2. 触发立即休眠 → 无反应 +3. 触发立即唤醒 → 恢复工作,休眠时间无效 + +休眠态:定时休眠 + 中途触发立即唤醒 +1. 开启定时休眠 → 规定时间休眠中 +2. 触发立即唤醒 → 直接工作,休眠时间无效 + + +优先级:立即唤醒>定时休眠 + +定时休眠 → 仅【工作态】有效 +定时唤醒 → 仅【休眠态】有效 diff --git a/talkingq-url/docs/mqtt协议_V2.0.txt b/talkingq-url/docs/mqtt协议_V2.0.txt new file mode 100644 index 0000000..e4e416f --- /dev/null +++ b/talkingq-url/docs/mqtt协议_V2.0.txt @@ -0,0 +1,324 @@ +device/imei/command # 服务器下发命令(下行) +device/imei/response # 设备响应(上行) +device/imei/event # 设备主动事件 +device/imei/event_resp # 事件返回 + +消息格式(JSON) +device/imei/event +0.设备定时10分钟上报信息 +{ + "msg_id": "000", + "data": { + "id":"TalkingQ_xxx", + "power": 20, 剩余电量每个间隔20,从0-100 + "signal": 3, 信号强度1是最弱的 5是最强 + "voice":60, 每个间隔20,从40-100 + "imei":"861921071409242", + "version":"v1.0", + "latitude":23.1173393, + "longitude":113.2855975 + "device_type":0, 0 no NFC+no GPS 1 NFC 2、NFC+GPS + } +} +服务器返回 +device/imei/event_resp +{ + "msg_id": "000", + "status": "success" +} + +1. GPS位置查询 +device/imei/command +服务器下发: +{ + "msg_id": "001" +} + + +device/imei/response +4G模块响应: +{ + "msg_id": "001", + "status": "success", + "data": { + "latitude": 39.9042, + "longitude": 116.4074 + } +} + + +2. 音量调节 +device/imei/command + 每个间隔20,从40-100 +服务器下发: +{ + "msg_id": "002", + "params": { + "level": 100 + } + "time":utc 16位时间戳 +} +device/imei/response +4G模块响应: +{ + "msg_id": "002", + "status": "success", + "data": { + "current_level": 60 + } + "time":utc 16位时间戳 +} + +3. OTA升级 +device/imei/command +服务器下发: +{ + "msg_id": "003", + "params": { + "url": "http://101.35.224.118:8080/assets/firmware/3.0.7.bin", + "version": "3.0.5" + } +} + +device/imei/response +4G模块响应(立即确认): +{ + "msg_id": "003", + "status": "accepted", + "data": { + "current_version": "2.0.5", + "target_version": "2.1.0" + } +} + +升级完成 +{ + "msg_id": "003", + "status": "success", + "data": { + "new_version": "2.1.0" + } +} + +4、NFC留言下发提示 +device/imei/command +如果设备在线,且有未读留言服务器定时10分钟下发一次: +{ + "msg_id": "004", + "params": { + "url": "http://xxx.mp3" //您有新的留言请注意查收 + } +} +device/imei/response +4G模块响应: +{ + "msg_id": "004", + "status": "success", +} + +5、发送或收听留言 +device/imei/event +NFC触发,设备端上报 +{ + "msg_id": "005", + "params": { + "uuid": "53C12B6DA20001" + } +} + + + +//如果不是本机本地UUID,则下发“你好,你的朋友已在线请留言” +//如果本机本地UUID但没有留言,"你好,已没可读留言" +服务器下发 +device/imei/event_resp + +{ + "msg_id": "005", + "status": "success", + "type": 0, //0代表收听留言 1 代表需要发送留言 + "params": { + "url_1": "http://xxx.mp3" + "url_2": "http://xxx.mp3" + } +} + +{ + "msg_id": "005", + "type": 0, + "params": + { + "url_1": "http://175.24.73.253:8080/assets/audio/no_message.mp3" + } + } + +6、NFC绑定uuid +device/imei/command +{ + "msg_id": "006" +} +device/imei/response +4G模块响应: +{ + "msg_id": "006", + "status": "success", + "data": { + "uuid": "53D22B6DA20001" + } +} + +7、开关状态 +//0查询状态 1 控制打开 2控制关闭 +device/imei/command +{ + "msg_id": "007", + "type": 0 +} +device/imei/response +4G模块响应: +{ + "msg_id": "007", + "status": "success", + "data": { + "status": "on" //on或off 返回当前状态 + } +} + +8、定时休眠 +device/imei/command +{ + "msg_id": "008", + "params": { + "start": "22:00" , //设置时和分 + "end": "07:00" //设置时和分 + } +} +device/imei/response +4G模块响应: +{ + "msg_id": "008", + "status": "success" +} + +9、远程休眠和唤醒 (用7来替代) +device/imei/command +{ + "msg_id": "009", + "params": { + "switch": "on" // on 唤醒 off 休眠 + } +} +device/imei/response +4G模块响应: +{ + "msg_id": "009", + "status": "success" +} + +10、额外按键长按发送告警信息 +设备上报 +device/imei/event +{ + "msg_id": "010" //发送告警 +} +服务器返回 +device/imei/event_resp +4G模块响应: +{ + "msg_id": "010", + "type": 0, + "params": + { + "url": "http://175.24.73.253:8080/assets/audio/message_ok.mp3" + } +} + +11、额外按键短按发送留言给家长 +设备上报 +device/imei/event +{ + "msg_id": "011", + "params": { + "uuid": "TalkingQ_xxx" + } +} +服务器返回 +device/imei/event_resp +{ + "msg_id": "011", + "type": 0, + "params": + { + "url": "http://175.24.73.253:8080/assets/audio/message_ok.mp3" + } + } + +12、增加后台自动下发DeviceID和SN流程 +设备端上报 +device/imei/event +{ + "msg_id": "012" +} +服务器下发 +device/imei/event_resp +{ + "msg_id": "012", + "params": + { + "device_id":"TalkingQ_XQSN00001003", + "device_sn":"TQ_XQSN000000001003" + } + } + +TalkingQ_XQSN00001003 TQ_XQSN000000001003 +53C12B6DA20001 +53D92B6DA20001 +53DA2B6DA20001 + +set id TalkingQ_XQSN00001003 +set sn TQ_XQSN000000001003 + +TalkingQ_XQSN00001004 TQ_XQSN000000001004 +53E12B6DA20001 +53D12B6DA20001 +53D22B6DA20001 + +TalkingQ_XQSN00001005 TQ_XQSN000000001005 +53C22B6DA20001 + +1000000189ABCDEFGHIJKLMNOPQRSTUV +1000000089ABCDEFGHIJKLMNOPQRSTUV + + +1、额外按钮 + 短按 直接与父母留言 + 长按 播报紧急报警系统 + +2、休眠时间段设置功能 + 在设定时间内,设备不可启动 + 设定时间过后,可以正常使用 + + +3、远程一键休眠和一键唤醒 + + +mqtt地址 +http://175.24.73.253:18083/#/login?to=/clients +admin +emqx_DPWQyk + + +工作态:定时休眠 + 中途反复触发立即休眠 +1. 开启定时休眠 →规定时间休眠中 +2. 触发立即休眠 → 无反应 +3. 触发立即唤醒 → 恢复工作,休眠时间无效 + +休眠态:定时休眠 + 中途触发立即唤醒 +1. 开启定时休眠 → 规定时间休眠中 +2. 触发立即唤醒 → 直接工作,休眠时间无效 + + +优先级:立即唤醒>定时休眠 + +定时休眠 → 仅【工作态】有效 +定时唤醒 → 仅【休眠态】有效 diff --git a/talkingq-url/handlers/mqtt_handler.py b/talkingq-url/handlers/mqtt_handler.py index 866be3f..879555d 100644 --- a/talkingq-url/handlers/mqtt_handler.py +++ b/talkingq-url/handlers/mqtt_handler.py @@ -435,6 +435,8 @@ class TalkingQMQTTService: async def _handle_alarm_report(self, device_id: str, payload: dict): logger.info(device_id, "", f"[告警] 设备 {device_id} 发送紧急报警") + alarm_audio_url = f"http://{settings.server_host}:{settings.server_port}/assets/roles/banban/warn_parent_zh.mp3" + async def _record_alarm_and_notify(): alarm_id = await device_alarm_service.record_alarm_event( device_id=device_id, @@ -466,9 +468,7 @@ class TalkingQMQTTService: "msg_id": "010", "status": "success", "type": 0, - "params": { - "url": f"http://{settings.server_host}:{settings.server_port}/assets/roles/banban/warn_parent_zh.mp3" - }, + "params": {"url": alarm_audio_url}, }, ) diff --git a/talkingq-url/main.py b/talkingq-url/main.py index 37fe7d6..2da9f1c 100644 --- a/talkingq-url/main.py +++ b/talkingq-url/main.py @@ -12,6 +12,7 @@ from config import settings from utils.logger import session_logger from api import api_router from api.assets import configure_static_assets +from api.wechat_verify import router as wechat_verify_router from banban.routers import banban_router from banban.middleware.auth import install_auth_middleware from banban.middleware.request_log import install_request_logging_middleware @@ -137,5 +138,6 @@ configure_static_assets(app) install_request_logging_middleware(app) install_auth_middleware(app) +app.include_router(wechat_verify_router) app.include_router(api_router) app.include_router(banban_router) diff --git a/talkingq-url/tests/test_alarm_event_response_audio.py b/talkingq-url/tests/test_alarm_event_response_audio.py index e05178e..9e0b586 100644 --- a/talkingq-url/tests/test_alarm_event_response_audio.py +++ b/talkingq-url/tests/test_alarm_event_response_audio.py @@ -31,7 +31,7 @@ async def test_alarm_event_response_includes_v2_audio_url(monkeypatch): "status": "success", "type": 0, "params": { - "url": "http://101.35.224.118:8080/assets/audio/message_ok.mp3", + "url": "http://101.35.224.118:8080/assets/roles/banban/warn_parent_zh.mp3", }, }, ) diff --git a/talkingq-url/tests/test_alarm_gps_location_refresh.py b/talkingq-url/tests/test_alarm_gps_location_refresh.py index 2bbf5c2..e21b956 100644 --- a/talkingq-url/tests/test_alarm_gps_location_refresh.py +++ b/talkingq-url/tests/test_alarm_gps_location_refresh.py @@ -1,8 +1,18 @@ import pytest +from banban.routers.devices import _row_to_current_location_response +from banban.service.device_alarm import DeviceAlarmService +from banban.service.location import LocationService, is_valid_coordinate_pair from handlers.mqtt_handler import TalkingQMQTTService +def test_zero_zero_coordinate_is_invalid(): + assert is_valid_coordinate_pair(23.2413923, 113.6051866) is True + assert is_valid_coordinate_pair(0, 0) is False + assert is_valid_coordinate_pair("0.0000000", "0.0000000") is False + assert is_valid_coordinate_pair(None, 113.6051866) is False + + @pytest.mark.asyncio async def test_alarm_requests_fresh_gps_before_sms(monkeypatch): service = TalkingQMQTTService({"broker": "127.0.0.1"}) @@ -58,7 +68,7 @@ async def test_alarm_requests_fresh_gps_before_sms(monkeypatch): "status": "success", "type": 0, "params": { - "url": "http://192.168.101.78:8080/assets/audio/message_ok.mp3", + "url": "http://192.168.101.78:8080/assets/roles/banban/warn_parent_zh.mp3", }, }, ), @@ -94,6 +104,125 @@ async def test_alarm_requests_fresh_gps_before_sms(monkeypatch): ] +@pytest.mark.asyncio +async def test_alarm_ignores_invalid_zero_zero_location(monkeypatch): + service = DeviceAlarmService() + calls = [] + + async def fake_update_alarm_location(**kwargs): + calls.append(("update_alarm_location", kwargs)) + return True + + async def fake_resolve_alarm_address(**kwargs): + calls.append(("resolve_alarm_address", kwargs)) + + monkeypatch.setattr(service, "update_alarm_location", fake_update_alarm_location) + monkeypatch.setattr(service, "resolve_alarm_address", fake_resolve_alarm_address) + + updated = await service.apply_alarm_location( + alarm_id=7, + device_id="TalkingQ_device001", + location={ + "coord_type": "gcj02", + "lat": 0, + "lng": 0, + "updated_at": "2026-06-22 10:00:00", + }, + ) + + assert updated is False + assert calls == [] + + +@pytest.mark.asyncio +async def test_alarm_current_location_reuses_existing_address(monkeypatch): + service = DeviceAlarmService() + calls = [] + + async def fake_get_alarm_event(**kwargs): + calls.append(("get_alarm_event", kwargs)) + return { + "alarm_id": 7, + "device_id": "TalkingQ_device001", + "lat": 23.2413923, + "lng": 113.6051866, + "address": "广东省广州市增城区中新镇叶岭新村街", + } + + async def fake_resolve_alarm_address(**kwargs): + calls.append(("resolve_alarm_address", kwargs)) + + monkeypatch.setattr(service, "get_alarm_event", fake_get_alarm_event) + monkeypatch.setattr(service, "resolve_alarm_address", fake_resolve_alarm_address) + + resolved = await service.resolve_alarm_current_location( + alarm_id=7, + device_id="TalkingQ_device001", + ) + + assert resolved is True + assert calls == [ + ("get_alarm_event", {"alarm_id": 7, "device_id": "TalkingQ_device001"}), + ] + + +@pytest.mark.asyncio +async def test_alarm_record_ignores_invalid_current_location_snapshot(monkeypatch): + service = DeviceAlarmService() + calls = [] + + class FakeDAO: + def __init__(self, session): + del session + + async def get_active_binding_context(self, *, device_id): + calls.append(("binding", device_id)) + return {"owner_user_id": 3, "child_id": 9} + + async def get_current_location_snapshot(self, *, child_id): + calls.append(("snapshot", child_id)) + return { + "coord_type": "gcj02", + "lat": 0, + "lng": 0, + "updated_at": "2026-06-22 10:00:00", + "address": None, + } + + async def create(self, **kwargs): + calls.append(("create", kwargs)) + return 7 + + class FakeSession: + async def close(self): + calls.append(("close",)) + + async def fake_get_session(): + return FakeSession() + + monkeypatch.setattr(service, "get_session", fake_get_session) + monkeypatch.setattr("banban.service.device_alarm.DeviceAlarmDAO", FakeDAO) + + alarm_id = await service.record_alarm_event(device_id="TalkingQ_device001") + + assert alarm_id == 7 + assert calls == [ + ("binding", "TalkingQ_device001"), + ("snapshot", 9), + ( + "create", + { + "device_id": "TalkingQ_device001", + "owner_user_id": 3, + "child_id": 9, + "source_msg_id": "010", + "location": None, + }, + ), + ("close",), + ] + + @pytest.mark.asyncio async def test_alarm_falls_back_to_existing_alarm_location_when_gps_times_out(monkeypatch): service = TalkingQMQTTService({"broker": "127.0.0.1"}) @@ -148,6 +277,27 @@ async def test_alarm_falls_back_to_existing_alarm_location_when_gps_times_out(mo ] +@pytest.mark.asyncio +async def test_mqtt_location_report_ignores_zero_zero(monkeypatch): + service = LocationService() + sessions = [] + + async def fail_get_session(): + sessions.append("called") + raise AssertionError("invalid location should not open a database session") + + monkeypatch.setattr(service, "get_session", fail_get_session) + + row = await service.report_mqtt_device_location( + device_id="TalkingQ_device001", + latitude=0, + longitude=0, + ) + + assert row is None + assert sessions == [] + + @pytest.mark.asyncio async def test_gps_waiter_returns_persisted_location(monkeypatch): service = TalkingQMQTTService({"broker": "127.0.0.1"}) @@ -198,3 +348,31 @@ async def test_gps_waiter_returns_persisted_location(monkeypatch): ] assert location["lat"] == 30.245 assert location["lng"] == 120.215 + + +def test_current_location_without_address_displays_pending_text(): + row = { + "child_id": 9, + "child_name": "测试孩子", + "device_id": "TalkingQ_device001", + "coord_type": "gcj02", + "lat": 23.2413923, + "lng": 113.6051866, + "accuracy_m": None, + "altitude_m": None, + "speed_mps": None, + "heading_deg": None, + "source": 1, + "battery_pct": 80, + "device_time": "2026-06-22 10:00:00", + "server_time": "2026-06-22 10:00:01", + "updated_at": "2026-06-22 10:00:01", + "address": None, + "address_resolved_at": None, + "address_resolve_status": 2, + } + + response = _row_to_current_location_response(row) + + assert response.address == "地址解析中" + assert response.address_resolve_status == 2 diff --git a/talkingq-url/tests/test_alarm_location_stale.py b/talkingq-url/tests/test_alarm_location_stale.py new file mode 100644 index 0000000..e5995b0 --- /dev/null +++ b/talkingq-url/tests/test_alarm_location_stale.py @@ -0,0 +1,39 @@ +from datetime import datetime, timedelta + +from banban.routers.devices import _row_to_alarm_item + + +def _alarm_row(created_at: datetime, location_updated_at: datetime): + return { + "alarm_id": 1, + "device_id": "TalkingQ_device001", + "child_id": 2, + "child_name": "测试孩子", + "source_msg_id": "010", + "coord_type": "gcj02", + "lat": 30.245, + "lng": 120.215, + "location_updated_at": location_updated_at, + "address": "浙江省杭州市上城区四季青街道剧院路杭州大剧院", + "address_resolved_at": location_updated_at, + "address_resolve_status": 1, + "created_at": created_at, + } + + +def test_alarm_location_updated_after_alarm_is_not_stale(): + created_at = datetime(2026, 6, 8, 13, 56, 24) + row = _alarm_row(created_at, created_at + timedelta(seconds=2)) + + item = _row_to_alarm_item(row) + + assert item.location_stale is False + + +def test_alarm_location_far_from_alarm_time_is_stale(): + created_at = datetime(2026, 6, 8, 13, 56, 24) + row = _alarm_row(created_at, created_at - timedelta(minutes=20)) + + item = _row_to_alarm_item(row) + + assert item.location_stale is True diff --git a/talkingq-url/tests/test_binding_session_expiry.py b/talkingq-url/tests/test_binding_session_expiry.py index 25f27ab..759a2eb 100644 --- a/talkingq-url/tests/test_binding_session_expiry.py +++ b/talkingq-url/tests/test_binding_session_expiry.py @@ -96,6 +96,91 @@ async def test_start_card_bind_rejects_family_member_who_is_not_owner(monkeypatc assert db_session.closed is True +@pytest.mark.asyncio +async def test_list_card_groups_groups_cards_by_visible_device(monkeypatch): + service = BindingService() + db_session = FakeSession() + + async def fake_get_session(): + return db_session + + async def fake_list_card_groups_by_user(self, user_id): + assert user_id == 99 + return [ + { + "device_id": "TalkingQ_device001", + "child_id": 7, + "child_name": "孩子一", + "bound_at": datetime(2026, 6, 8, 10, 0, 0), + "card_id": 11, + "card_uuid": "CARD_UUID_A", + "card_name": None, + "card_status": 1, + "card_updated_at": datetime(2026, 6, 8, 10, 1, 0), + }, + { + "device_id": "TalkingQ_device001", + "child_id": 7, + "child_name": "孩子一", + "bound_at": datetime(2026, 6, 8, 10, 0, 0), + "card_id": 10, + "card_uuid": "CARD_UUID_B", + "card_name": "备用卡", + "card_status": 1, + "card_updated_at": datetime(2026, 6, 8, 10, 0, 30), + }, + { + "device_id": "TalkingQ_device002", + "child_id": None, + "child_name": None, + "bound_at": datetime(2026, 6, 8, 9, 0, 0), + "card_id": None, + "card_uuid": None, + "card_name": None, + "card_status": None, + "card_updated_at": None, + }, + ] + + monkeypatch.setattr(service, "get_session", fake_get_session) + monkeypatch.setattr("banban.service.binding.BindingDAO.list_card_groups_by_user", fake_list_card_groups_by_user) + + groups = await service.list_card_groups(user_id=99) + + assert groups == [ + { + "device_id": "TalkingQ_device001", + "child_id": 7, + "child_name": "孩子一", + "bound_at": datetime(2026, 6, 8, 10, 0, 0), + "cards": [ + { + "card_id": 11, + "card_uuid": "CARD_UUID_A", + "card_name": None, + "status": 1, + "updated_at": datetime(2026, 6, 8, 10, 1, 0), + }, + { + "card_id": 10, + "card_uuid": "CARD_UUID_B", + "card_name": "备用卡", + "status": 1, + "updated_at": datetime(2026, 6, 8, 10, 0, 30), + }, + ], + }, + { + "device_id": "TalkingQ_device002", + "child_id": None, + "child_name": None, + "bound_at": datetime(2026, 6, 8, 9, 0, 0), + "cards": [], + }, + ] + assert db_session.closed is True + + @pytest.mark.asyncio async def test_finalize_nfc_bind_expires_late_card_without_binding(monkeypatch): service = BindingService() @@ -311,3 +396,137 @@ async def test_finalize_nfc_bind_adds_card_without_rebinding_device(monkeypatch) assert db_session.commits == 1 assert db_session.rollbacks == 0 assert db_session.closed is True + + +@pytest.mark.asyncio +async def test_update_card_name_allows_device_owner(monkeypatch): + service = BindingService() + db_session = FakeSession() + updated_cards = [] + + async def fake_get_session(): + return db_session + + async def fake_get_visible_card_by_id(self, card_id, user_id): + assert card_id == 11 + assert user_id == 99 + return { + "card_id": 11, + "card_uuid": "CARD_UUID_A", + "card_name": None, + "card_status": 1, + "card_updated_at": datetime(2026, 6, 8, 10, 1, 0), + "device_id": "TalkingQ_device001", + "owner_user_id": 99, + } + + async def fake_update_card_name(self, card_id, card_name): + updated_cards.append((card_id, card_name)) + + monkeypatch.setattr(service, "get_session", fake_get_session) + monkeypatch.setattr("banban.service.binding.BindingDAO.get_visible_card_by_id", fake_get_visible_card_by_id) + monkeypatch.setattr("banban.service.binding.BindingDAO.update_card_name", fake_update_card_name) + + result = await service.update_card_name(card_id=11, card_name=" 奶奶的卡 ", user_id=99) + + assert result == { + "card_id": 11, + "card_uuid": "CARD_UUID_A", + "card_name": "奶奶的卡", + "status": 1, + "updated_at": datetime(2026, 6, 8, 10, 1, 0), + } + assert updated_cards == [(11, "奶奶的卡")] + assert db_session.commits == 1 + assert db_session.closed is True + + +@pytest.mark.asyncio +async def test_update_card_name_rejects_family_member(monkeypatch): + service = BindingService() + db_session = FakeSession() + updated_cards = [] + + async def fake_get_session(): + return db_session + + async def fake_get_visible_card_by_id(self, card_id, user_id): + assert card_id == 11 + assert user_id == 88 + return { + "card_id": 11, + "card_uuid": "CARD_UUID_A", + "card_name": None, + "card_status": 1, + "card_updated_at": datetime(2026, 6, 8, 10, 1, 0), + "device_id": "TalkingQ_device001", + "owner_user_id": 99, + } + + async def fake_update_card_name(self, card_id, card_name): + updated_cards.append((card_id, card_name)) + + monkeypatch.setattr(service, "get_session", fake_get_session) + monkeypatch.setattr("banban.service.binding.BindingDAO.get_visible_card_by_id", fake_get_visible_card_by_id) + monkeypatch.setattr("banban.service.binding.BindingDAO.update_card_name", fake_update_card_name) + + with pytest.raises(BindingError, match="only the device owner can rename cards"): + await service.update_card_name(card_id=11, card_name="备用卡", user_id=88) + + assert updated_cards == [] + assert db_session.commits == 0 + assert db_session.closed is True + + +@pytest.mark.asyncio +async def test_update_card_name_allows_clearing_name(monkeypatch): + service = BindingService() + db_session = FakeSession() + updated_cards = [] + + async def fake_get_session(): + return db_session + + async def fake_get_visible_card_by_id(self, card_id, user_id): + assert card_id == 11 + assert user_id == 99 + return { + "card_id": 11, + "card_uuid": "CARD_UUID_A", + "card_name": "备用卡", + "card_status": 1, + "card_updated_at": None, + "device_id": "TalkingQ_device001", + "owner_user_id": 99, + } + + async def fake_update_card_name(self, card_id, card_name): + updated_cards.append((card_id, card_name)) + + monkeypatch.setattr(service, "get_session", fake_get_session) + monkeypatch.setattr("banban.service.binding.BindingDAO.get_visible_card_by_id", fake_get_visible_card_by_id) + monkeypatch.setattr("banban.service.binding.BindingDAO.update_card_name", fake_update_card_name) + + result = await service.update_card_name(card_id=11, card_name=" ", user_id=99) + + assert result["card_name"] is None + assert updated_cards == [(11, None)] + assert db_session.commits == 1 + assert db_session.closed is True + + +@pytest.mark.asyncio +async def test_update_card_name_rejects_too_long_name(monkeypatch): + service = BindingService() + db_session = FakeSession() + + async def fake_get_session(): + return db_session + + monkeypatch.setattr(service, "get_session", fake_get_session) + + with pytest.raises(BindingError, match="64 characters or fewer"): + await service.update_card_name(card_id=11, card_name="卡" * 65, user_id=99) + + assert db_session.commits == 0 + assert db_session.closed is False diff --git a/talkingq-url/tests/test_db_init_cards.py b/talkingq-url/tests/test_db_init_cards.py index 2ab0ca8..67bbcd6 100644 --- a/talkingq-url/tests/test_db_init_cards.py +++ b/talkingq-url/tests/test_db_init_cards.py @@ -1,6 +1,10 @@ import pytest -from database.init_db import _ensure_bind_session_card_columns, _ensure_cards_allow_multiple_per_device +from database.init_db import ( + _ensure_bind_session_card_columns, + _ensure_cards_allow_multiple_per_device, + _ensure_device_family_tables, +) class FakeScalarResult: @@ -21,6 +25,7 @@ class FakeConnection: self.column_counts = { "bind_mode": 0, "card_uuid": 0, + "display_name": 0, } self.sql = [] @@ -64,3 +69,15 @@ async def test_ensure_cards_allow_multiple_per_device_replaces_unique_device_ind assert executed.index("ALTER TABLE cards ADD INDEX idx_cards_device_id") < executed.index( "ALTER TABLE cards DROP INDEX uq_cards_device_id" ) + + +@pytest.mark.asyncio +async def test_ensure_device_family_tables_adds_display_name_column(): + conn = FakeConnection() + + await _ensure_device_family_tables(conn) + + executed = "\n".join(conn.sql) + assert "CREATE TABLE IF NOT EXISTS device_family_members" in executed + assert "display_name VARCHAR(64) NULL" in executed + assert "ALTER TABLE device_family_members ADD COLUMN display_name VARCHAR(64) NULL AFTER role" in executed diff --git a/talkingq-url/tests/test_family_display_name.py b/talkingq-url/tests/test_family_display_name.py new file mode 100644 index 0000000..bdc01a3 --- /dev/null +++ b/talkingq-url/tests/test_family_display_name.py @@ -0,0 +1,354 @@ +import sqlite3 + +import pytest +from fastapi import HTTPException + +from banban.dao.family import FAMILY_ROLE_MEMBER, FAMILY_ROLE_OWNER, FAMILY_STATUS_ACTIVE, FamilyDAO +from banban.service.family import FamilyService + + +class FakeSession: + def __init__(self): + self.commits = 0 + self.rollbacks = 0 + self.closed = False + + async def commit(self): + self.commits += 1 + + async def rollback(self): + self.rollbacks += 1 + + async def close(self): + self.closed = True + + +class SqliteMappingResult: + def __init__(self, rows): + self.rows = rows + + def all(self): + return self.rows + + def first(self): + return self.rows[0] if self.rows else None + + +class SqliteResult: + def __init__(self, rows, rowcount): + self.rows = rows + self.rowcount = rowcount + + def mappings(self): + return SqliteMappingResult(self.rows) + + def scalar(self): + if not self.rows: + return None + return next(iter(self.rows[0].values())) + + def scalar_one_or_none(self): + return self.scalar() + + +class SqliteAsyncSession: + def __init__(self): + self.conn = sqlite3.connect(":memory:") + self.conn.row_factory = sqlite3.Row + + async def execute(self, statement, params=None): + cursor = self.conn.execute(str(statement), params or {}) + rows = [dict(row) for row in cursor.fetchall()] if cursor.description else [] + return SqliteResult(rows, cursor.rowcount) + + +def _create_family_dao_session() -> SqliteAsyncSession: + session = SqliteAsyncSession() + conn = session.conn + conn.executescript( + """ + CREATE TABLE parents ( + user_id INTEGER PRIMARY KEY, + nickname TEXT, + avatar_url TEXT, + status INTEGER NOT NULL DEFAULT 1 + ); + + CREATE TABLE device_bindings ( + device_id TEXT PRIMARY KEY, + owner_user_id INTEGER NOT NULL, + child_id INTEGER, + status INTEGER NOT NULL, + bound_at TEXT + ); + + CREATE TABLE device_family_members ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + device_id TEXT NOT NULL, + user_id INTEGER NOT NULL, + role INTEGER NOT NULL, + display_name TEXT, + status INTEGER NOT NULL, + invited_by_user_id INTEGER, + joined_at TEXT NOT NULL, + removed_at TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + UNIQUE (device_id, user_id) + ); + """ + ) + conn.executemany( + "INSERT INTO parents (user_id, nickname, avatar_url, status) VALUES (?, ?, ?, 1)", + [ + (10, "妈妈微信", None), + (20, "爸爸微信", None), + (77, "姑姑微信", None), + (88, "小明微信", None), + ], + ) + conn.executemany( + "INSERT INTO device_bindings (device_id, owner_user_id, child_id, status, bound_at) VALUES (?, ?, ?, 1, ?)", + [ + ("TalkingQ_family_a", 10, 1001, "2026-06-08 10:00:00"), + ("TalkingQ_family_b", 20, 1002, "2026-06-08 10:00:00"), + ], + ) + conn.executemany( + """ + INSERT INTO device_family_members ( + device_id, + user_id, + role, + display_name, + status, + invited_by_user_id, + joined_at, + removed_at, + created_at, + updated_at + ) + VALUES (?, ?, ?, ?, ?, ?, ?, NULL, ?, ?) + """, + [ + ("TalkingQ_family_a", 10, FAMILY_ROLE_OWNER, None, FAMILY_STATUS_ACTIVE, None, "2026-06-08 10:00:00", "2026-06-08 10:00:00", "2026-06-08 10:00:00"), + ("TalkingQ_family_a", 88, FAMILY_ROLE_MEMBER, None, FAMILY_STATUS_ACTIVE, 10, "2026-06-08 10:01:00", "2026-06-08 10:01:00", "2026-06-08 10:01:00"), + ("TalkingQ_family_a", 77, FAMILY_ROLE_MEMBER, None, FAMILY_STATUS_ACTIVE, 10, "2026-06-08 10:02:00", "2026-06-08 10:02:00", "2026-06-08 10:02:00"), + ("TalkingQ_family_b", 20, FAMILY_ROLE_OWNER, None, FAMILY_STATUS_ACTIVE, None, "2026-06-08 10:00:00", "2026-06-08 10:00:00", "2026-06-08 10:00:00"), + ("TalkingQ_family_b", 88, FAMILY_ROLE_MEMBER, None, FAMILY_STATUS_ACTIVE, 20, "2026-06-08 10:01:00", "2026-06-08 10:01:00", "2026-06-08 10:01:00"), + ], + ) + conn.commit() + return session + + +def _row_by_user(rows, user_id): + return next(row for row in rows if int(row["user_id"]) == user_id) + + +@pytest.mark.asyncio +async def test_update_member_display_name_allows_owner(monkeypatch): + service = FamilyService() + db_session = FakeSession() + updates = [] + + async def fake_get_session(): + return db_session + + async def fake_get_binding_for_owner(self, device_id, user_id): + assert device_id == "TalkingQ_device001" + assert user_id == 99 + return {"device_id": device_id, "owner_user_id": user_id} + + async def fake_update_member_display_name(self, **kwargs): + updates.append(kwargs) + return { + "user_id": 88, + "nickname": "奶奶", + "display_name": "奶奶", + "account_nickname": "微信昵称", + "avatar_url": None, + "role": 2, + "is_owner": 0, + "joined_at": None, + } + + monkeypatch.setattr(service, "get_session", fake_get_session) + monkeypatch.setattr("banban.service.family.FamilyDAO.get_binding_for_owner", fake_get_binding_for_owner) + monkeypatch.setattr("banban.service.family.FamilyDAO.update_member_display_name", fake_update_member_display_name) + + result = await service.update_member_display_name( + device_id="TalkingQ_device001", + member_user_id=88, + display_name=" 奶奶 ", + user_id=99, + ) + + assert result["display_name"] == "奶奶" + assert updates == [ + { + "device_id": "TalkingQ_device001", + "member_user_id": 88, + "owner_user_id": 99, + "display_name": "奶奶", + } + ] + assert db_session.commits == 1 + assert db_session.rollbacks == 0 + assert db_session.closed is True + + +@pytest.mark.asyncio +async def test_update_member_display_name_rejects_non_owner(monkeypatch): + service = FamilyService() + db_session = FakeSession() + updates = [] + + async def fake_get_session(): + return db_session + + async def fake_get_binding_for_owner(self, device_id, user_id): + return None + + async def fake_update_member_display_name(self, **kwargs): + updates.append(kwargs) + + monkeypatch.setattr(service, "get_session", fake_get_session) + monkeypatch.setattr("banban.service.family.FamilyDAO.get_binding_for_owner", fake_get_binding_for_owner) + monkeypatch.setattr("banban.service.family.FamilyDAO.update_member_display_name", fake_update_member_display_name) + + with pytest.raises(HTTPException) as exc: + await service.update_member_display_name( + device_id="TalkingQ_device001", + member_user_id=88, + display_name="奶奶", + user_id=77, + ) + + assert exc.value.status_code == 403 + assert updates == [] + assert db_session.commits == 0 + assert db_session.rollbacks == 1 + assert db_session.closed is True + + +@pytest.mark.asyncio +async def test_update_member_display_name_allows_clearing(monkeypatch): + service = FamilyService() + db_session = FakeSession() + updates = [] + + async def fake_get_session(): + return db_session + + async def fake_get_binding_for_owner(self, device_id, user_id): + return {"device_id": device_id, "owner_user_id": user_id} + + async def fake_update_member_display_name(self, **kwargs): + updates.append(kwargs) + return { + "user_id": 88, + "nickname": "微信昵称", + "display_name": None, + "account_nickname": "微信昵称", + "avatar_url": None, + "role": 2, + "is_owner": 0, + "joined_at": None, + } + + monkeypatch.setattr(service, "get_session", fake_get_session) + monkeypatch.setattr("banban.service.family.FamilyDAO.get_binding_for_owner", fake_get_binding_for_owner) + monkeypatch.setattr("banban.service.family.FamilyDAO.update_member_display_name", fake_update_member_display_name) + + result = await service.update_member_display_name( + device_id="TalkingQ_device001", + member_user_id=88, + display_name=" ", + user_id=99, + ) + + assert result["display_name"] is None + assert updates[0]["display_name"] is None + assert db_session.commits == 1 + assert db_session.closed is True + + +@pytest.mark.asyncio +async def test_update_member_display_name_rejects_too_long_value(): + service = FamilyService() + + with pytest.raises(HTTPException) as exc: + await service.update_member_display_name( + device_id="TalkingQ_device001", + member_user_id=88, + display_name="名" * 65, + user_id=99, + ) + + assert exc.value.status_code == 400 + + +@pytest.mark.asyncio +async def test_display_name_is_scoped_per_family_and_visible_to_every_member_view(): + session = _create_family_dao_session() + dao = FamilyDAO(session) + + updated = await dao.update_member_display_name( + device_id="TalkingQ_family_a", + member_user_id=88, + owner_user_id=10, + display_name="奶奶", + ) + + assert updated is not None + assert updated["user_id"] == 88 + assert updated["display_name"] == "奶奶" + assert updated["nickname"] == "奶奶" + assert updated["account_nickname"] == "小明微信" + + for viewer_user_id in (10, 88, 77): + rows = await dao.list_members(device_id="TalkingQ_family_a", user_id=viewer_user_id) + renamed_member = _row_by_user(rows, 88) + assert renamed_member["display_name"] == "奶奶" + assert renamed_member["nickname"] == "奶奶" + assert renamed_member["account_nickname"] == "小明微信" + + for viewer_user_id in (20, 88): + rows = await dao.list_members(device_id="TalkingQ_family_b", user_id=viewer_user_id) + same_account_in_other_family = _row_by_user(rows, 88) + assert same_account_in_other_family["display_name"] is None + assert same_account_in_other_family["nickname"] == "小明微信" + assert same_account_in_other_family["account_nickname"] == "小明微信" + + parent_row = session.conn.execute("SELECT nickname FROM parents WHERE user_id = ?", (88,)).fetchone() + assert parent_row["nickname"] == "小明微信" + + +@pytest.mark.asyncio +async def test_cleared_display_name_falls_back_to_account_nickname_from_self_view(): + session = _create_family_dao_session() + dao = FamilyDAO(session) + + await dao.update_member_display_name( + device_id="TalkingQ_family_a", + member_user_id=88, + owner_user_id=10, + display_name="奶奶", + ) + cleared = await dao.update_member_display_name( + device_id="TalkingQ_family_a", + member_user_id=88, + owner_user_id=10, + display_name=None, + ) + + assert cleared is not None + assert cleared["display_name"] is None + assert cleared["nickname"] == "小明微信" + assert cleared["account_nickname"] == "小明微信" + + rows = await dao.list_members(device_id="TalkingQ_family_a", user_id=88) + self_view = _row_by_user(rows, 88) + assert self_view["display_name"] is None + assert self_view["nickname"] == "小明微信" diff --git a/talkingq-url/tests/test_mqtt_device_info_report.py b/talkingq-url/tests/test_mqtt_device_info_report.py index cd8c244..ad454c5 100644 --- a/talkingq-url/tests/test_mqtt_device_info_report.py +++ b/talkingq-url/tests/test_mqtt_device_info_report.py @@ -28,6 +28,10 @@ async def test_device_info_report_persists_metadata_and_periodic_location(monkey monkeypatch.setattr(service, "_schedule_persistence", fake_schedule_persistence) monkeypatch.setattr("handlers.mqtt_handler.device_setting_service.insert_or_update", fake_insert_or_update) monkeypatch.setattr("handlers.mqtt_handler.location_service.report_mqtt_device_location", fake_report_mqtt_device_location) + monkeypatch.setattr( + "handlers.mqtt_handler.wechat_mp_notification_service.schedule_low_battery_notification", + lambda **kwargs: None, + ) await service._handle_device_info( "TalkingQ_XQSN00001005", @@ -102,6 +106,10 @@ async def test_device_info_report_without_location_does_not_write_location(monke monkeypatch.setattr(service, "_publish", fake_publish) monkeypatch.setattr(service, "_schedule_persistence", fake_schedule_persistence) monkeypatch.setattr("handlers.mqtt_handler.device_setting_service.insert_or_update", fake_insert_or_update) + monkeypatch.setattr( + "handlers.mqtt_handler.wechat_mp_notification_service.schedule_low_battery_notification", + lambda **kwargs: None, + ) await service._handle_device_info( "TalkingQ_XQSN00001005", diff --git a/talkingq-url/tests/test_parent_voice_mp3_normalization.py b/talkingq-url/tests/test_parent_voice_mp3_normalization.py new file mode 100644 index 0000000..c1d21ca --- /dev/null +++ b/talkingq-url/tests/test_parent_voice_mp3_normalization.py @@ -0,0 +1,215 @@ +from datetime import datetime, timezone +from types import SimpleNamespace + +import pytest + +from banban.dao.im import ConversationMessageCreateResult +from banban.schemas.im import ChildConversationMessageItem +from banban.service.im import ImService +from banban.service.message_audio_storage import StoredMessageAudio +from utils.audio_transcode import infer_audio_source_format, prepare_audio_as_mp3 + + +def test_infer_audio_source_format_distinguishes_aac_from_mp3_frame_sync(): + assert infer_audio_source_format(b"\xff\xf1P\x80\x00\x1f\xfc") == "aac" + assert infer_audio_source_format(b"\xff\xfb\x90\x64" + b"\x00" * 32) == "mp3" + + +@pytest.mark.asyncio +async def test_prepare_audio_as_mp3_keeps_mp3_without_transcoding(monkeypatch): + async def fail_transcode(*args, **kwargs): + raise AssertionError("mp3 upload should not be transcoded") + + monkeypatch.setattr("utils.audio_transcode.transcode_audio_to_mp3", fail_transcode) + + original = b"ID3" + b"\x00" * 32 + prepared = await prepare_audio_as_mp3( + original, + filename="voice.aac", + content_type="audio/aac", + ) + + assert prepared.content == original + assert prepared.mime_type == "audio/mpeg" + assert prepared.extension == "mp3" + assert prepared.size_bytes == len(original) + assert prepared.source_format == "mp3" + assert prepared.transcoded is False + + +@pytest.mark.asyncio +async def test_prepare_audio_as_mp3_transcodes_aac_upload(monkeypatch): + calls = [] + + async def fake_transcode(audio_data, **kwargs): + calls.append({"audio_data": audio_data, **kwargs}) + return b"ID3 converted" + + monkeypatch.setattr("utils.audio_transcode.transcode_audio_to_mp3", fake_transcode) + + original = b"\xff\xf1P\x80\x00\x1f\xfc" + prepared = await prepare_audio_as_mp3( + original, + filename="voice.aac", + content_type="audio/aac", + ) + + assert prepared.content == b"ID3 converted" + assert prepared.mime_type == "audio/mpeg" + assert prepared.extension == "mp3" + assert prepared.size_bytes == len(b"ID3 converted") + assert prepared.source_format == "aac" + assert prepared.original_mime_type == "audio/aac" + assert prepared.original_extension == "aac" + assert prepared.transcoded is True + assert calls == [ + { + "audio_data": original, + "source_format": "aac", + "source_suffix": ".aac", + "session_device_id": "system", + "session_id": "audio_transcode", + } + ] + + +@pytest.mark.asyncio +async def test_parent_voice_upload_stores_and_queues_mp3_for_device(monkeypatch): + service = ImService() + uploaded = [] + pending_messages = [] + duration_jobs = [] + + async def fake_assert_parent_child_access(**kwargs): + return {"child_id": kwargs["child_id"], "child_name": "child"} + + async def fake_get_child_binding(child_id, parent_user_id): + return {"device_id": "TalkingQ_XQSN00001007"} + + async def fake_upload_audio(**kwargs): + uploaded.append(kwargs) + return StoredMessageAudio( + file_key="messages/audio/parent-4/2026/06/11/converted.mp3", + public_url="https://cos.example/messages/audio/parent-4/converted.mp3", + ) + + async def fake_create_parent_child_message(*, parent_user_id, child_id, payload): + message = ChildConversationMessageItem( + id=181, + conversation_id=11, + seq=1, + sender_type="parent", + sender_id=str(parent_user_id), + receiver_type="child", + receiver_id=str(child_id), + content_type=payload.content_type, + content_text=payload.content_text, + content_json=payload.content_json, + media_file_key=payload.media_file_key, + media_duration_ms=payload.media_duration_ms, + media_mime_type=payload.media_mime_type, + media_size_bytes=payload.media_size_bytes, + media_transcript_text=payload.media_transcript_text, + client_msg_id=payload.client_msg_id, + sender_name_snapshot="parent", + sender_avatar_snapshot=None, + receiver_name_snapshot="child", + receiver_avatar_snapshot=None, + ext_json=payload.ext_json, + created_at=datetime.now(timezone.utc), + ) + return ConversationMessageCreateResult( + idempotent=False, + conversation_id=11, + conversation_type=2, + message=message, + ) + + async def fake_get_raw_message_media_file_key(**kwargs): + return "messages/audio/parent-4/2026/06/11/converted.mp3" + + async def fake_get_device_audio_url(file_key_or_url, *, device_id=None): + assert file_key_or_url.endswith(".mp3") + assert device_id == "TalkingQ_XQSN00001007" + return "http://127.0.0.1:8080/device-audio/converted.mp3" + + async def fake_add_pending_message(**kwargs): + pending_messages.append(kwargs) + + async def fake_schedule_message_media_duration_parse(**kwargs): + duration_jobs.append(kwargs) + + async def fake_prepare_audio_as_mp3(content, **kwargs): + assert content == b"\xff\xf1 aac" + assert kwargs["filename"] == "voice.aac" + assert kwargs["content_type"] == "audio/aac" + return SimpleNamespace( + content=b"ID3 converted mp3", + mime_type="audio/mpeg", + extension="mp3", + size_bytes=len(b"ID3 converted mp3"), + source_format="aac", + original_mime_type="audio/aac", + original_extension="aac", + transcoded=True, + ) + + monkeypatch.setattr(service, "assert_parent_child_access", fake_assert_parent_child_access) + monkeypatch.setattr("banban.service.im.binding_service.get_child_binding", fake_get_child_binding) + monkeypatch.setattr(service.audio_storage, "upload_audio", fake_upload_audio) + monkeypatch.setattr(service, "create_parent_child_message", fake_create_parent_child_message) + monkeypatch.setattr(service, "_get_raw_message_media_file_key", fake_get_raw_message_media_file_key) + monkeypatch.setattr("banban.service.im.device_audio_cache_service.get_device_audio_url", fake_get_device_audio_url) + monkeypatch.setattr("banban.service.im.pending_voice_message_service.add_pending_message", fake_add_pending_message) + monkeypatch.setattr(service, "schedule_message_media_duration_parse", fake_schedule_message_media_duration_parse) + monkeypatch.setattr("banban.service.im.prepare_audio_as_mp3", fake_prepare_audio_as_mp3) + + result = await service.create_parent_child_voice_message( + parent_user_id=4, + child_id=7, + filename="voice.aac", + content_type="audio/aac", + content=b"\xff\xf1 aac", + media_duration_ms=1234, + media_transcript_text=None, + client_msg_id="client-1", + ext_json={ + "message_kind": "leave_message", + "source": "parent_weapp_voice", + }, + ) + + assert result.message.media_file_key.endswith(".mp3") + assert result.message.media_mime_type == "audio/mpeg" + assert result.message.media_size_bytes == len(b"ID3 converted mp3") + assert result.message.ext_json["message_kind"] == "leave_message" + assert result.message.ext_json["normalized_audio_format"] == "mp3" + assert result.message.ext_json["source_audio_format"] == "aac" + assert result.message.ext_json["audio_transcoded"] is True + + assert uploaded == [ + { + "device_id": "parent-4", + "content": b"ID3 converted mp3", + "content_type": "audio/mpeg", + "extension": "mp3", + } + ] + assert pending_messages == [ + { + "target_device_id": "TalkingQ_XQSN00001007", + "sender_device_id": None, + "im_message_id": 181, + "media_file_key": "messages/audio/parent-4/2026/06/11/converted.mp3", + "audio_url": "http://127.0.0.1:8080/device-audio/converted.mp3", + "source": "parent_child_voice", + } + ] + assert duration_jobs == [ + { + "message_id": 181, + "audio_data": b"ID3 converted mp3", + "mime_type": "audio/mpeg", + "source": "parent_weapp_voice", + } + ] diff --git a/talkingq-url/tests/test_wechat_mp_notification.py b/talkingq-url/tests/test_wechat_mp_notification.py index e0464cd..54b64ea 100644 --- a/talkingq-url/tests/test_wechat_mp_notification.py +++ b/talkingq-url/tests/test_wechat_mp_notification.py @@ -175,6 +175,7 @@ async def test_resolve_notification_link_returns_chat_detail_params(monkeypatch) def first(self): return { "conversation_id": 6, + "message_id": 209, "child_id": 8, "child_name": "小明", "device_id": "TalkingQ_XQSN00001007", @@ -186,6 +187,7 @@ async def test_resolve_notification_link_returns_chat_detail_params(monkeypatch) assert params["conversation_id"] == 6 assert params["child_id"] == 8 assert params["user_id"] == 4 + assert params["device_id"] == "TalkingQ_XQSN00001007" return FakeResult() async def close(self): @@ -231,10 +233,89 @@ async def test_resolve_notification_link_rejects_other_user(monkeypatch): } ) + class FakeResult: + def mappings(self): + return self + + def first(self): + return None + + class FakeSession: + async def execute(self, sql, params): + assert params["message_id"] == 209 + assert params["conversation_id"] == 6 + assert params["child_id"] == 8 + assert params["user_id"] == 5 + return FakeResult() + + async def close(self): + pass + + async def fake_get_session(): + return FakeSession() + + monkeypatch.setattr(service, "get_session", fake_get_session) + with pytest.raises(HTTPException) as exc_info: await service.resolve_notification_link(token=token, user_id=5) - assert exc_info.value.status_code == 403 + assert exc_info.value.status_code == 404 + + +@pytest.mark.asyncio +async def test_resolve_notification_link_redirects_to_current_family_member_message(monkeypatch): + configure_enabled_wechat_mp(monkeypatch) + service = WechatMpNotificationService() + token = service._create_notification_token( + { + "type": "leave_message", + "user_id": 4, + "conversation_id": 6, + "message_id": 209, + "child_id": 8, + "child_name": "小明", + "device_id": "TalkingQ_XQSN00001007", + } + ) + + class FakeResult: + def mappings(self): + return self + + def first(self): + return { + "conversation_id": 22, + "message_id": 216, + "child_id": 8, + "child_name": "小明", + "device_id": "TalkingQ_XQSN00001007", + } + + class FakeSession: + async def execute(self, sql, params): + assert params["message_id"] == 209 + assert params["conversation_id"] == 6 + assert params["child_id"] == 8 + assert params["user_id"] == 3 + assert params["user_id_str"] == "3" + assert params["device_id"] == "TalkingQ_XQSN00001007" + return FakeResult() + + async def close(self): + pass + + async def fake_get_session(): + return FakeSession() + + monkeypatch.setattr(service, "get_session", fake_get_session) + + result = await service.resolve_notification_link(token=token, user_id=3) + + assert result["route"] == "pages/chat/detail/index" + assert result["params"]["id"] == 22 + assert result["params"]["parentUserId"] == 3 + assert result["params"]["peerId"] == "3" + assert result["params"]["messageId"] == 216 @pytest.mark.asyncio diff --git a/talkingq-url/tests/test_wechat_verify.py b/talkingq-url/tests/test_wechat_verify.py new file mode 100644 index 0000000..03931b6 --- /dev/null +++ b/talkingq-url/tests/test_wechat_verify.py @@ -0,0 +1,22 @@ +import pytest +from httpx import ASGITransport, AsyncClient + +from main import app + + +@pytest.mark.asyncio +async def test_wechat_verify_file_served_at_root(): + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://testserver") as client: + response = await client.get("/HXNd3XMbPq.txt") + + assert response.status_code == 200 + assert response.text == "60756bcfc3d838155f64397bec395a44" + assert response.headers["content-type"].startswith("text/plain") + + +@pytest.mark.asyncio +async def test_unknown_root_txt_file_is_not_exposed(): + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://testserver") as client: + response = await client.get("/unknown-wechat-verify.txt") + + assert response.status_code == 404