完善设备端体验与微信校验支持

This commit is contained in:
stu2not
2026-07-07 16:58:05 +08:00
parent b4fef6d2e0
commit 937e3023fc
38 changed files with 3377 additions and 51 deletions

View File

@@ -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())

View File

@@ -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:

View File

@@ -0,0 +1 @@
60756bcfc3d838155f64397bec395a44

View File

@@ -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. 触发立即唤醒 → 直接工作,休眠时间无效
优先级:立即唤醒>定时休眠
定时休眠 → 仅【工作态】有效
定时唤醒 → 仅【休眠态】有效

View File

@@ -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. 触发立即唤醒 → 直接工作,休眠时间无效
优先级:立即唤醒>定时休眠
定时休眠 → 仅【工作态】有效
定时唤醒 → 仅【休眠态】有效

View File

@@ -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},
},
)

View File

@@ -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)

View File

@@ -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",
},
},
)

View File

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

View File

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

View File

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

View File

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

View File

@@ -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"] == "小明微信"

View File

@@ -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",

View File

@@ -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",
}
]

View File

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

View File

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