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 0000000..7fa41ac Binary files /dev/null and b/docs/fa05f36990d894df4e095839d10d9f07.png differ 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