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
+
+
+
+
+
+
+
+ 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