完善设备端体验与微信校验支持
This commit is contained in:
18
talkingq-url/api/wechat_verify.py
Normal file
18
talkingq-url/api/wechat_verify.py
Normal 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())
|
||||
@@ -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:
|
||||
|
||||
1
talkingq-url/docs/HXNd3XMbPq.txt
Normal file
1
talkingq-url/docs/HXNd3XMbPq.txt
Normal file
@@ -0,0 +1 @@
|
||||
60756bcfc3d838155f64397bec395a44
|
||||
320
talkingq-url/docs/mqtt协议_V1.9.txt
Normal file
320
talkingq-url/docs/mqtt协议_V1.9.txt
Normal 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. 触发立即唤醒 → 直接工作,休眠时间无效
|
||||
|
||||
|
||||
优先级:立即唤醒>定时休眠
|
||||
|
||||
定时休眠 → 仅【工作态】有效
|
||||
定时唤醒 → 仅【休眠态】有效
|
||||
324
talkingq-url/docs/mqtt协议_V2.0.txt
Normal file
324
talkingq-url/docs/mqtt协议_V2.0.txt
Normal 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. 触发立即唤醒 → 直接工作,休眠时间无效
|
||||
|
||||
|
||||
优先级:立即唤醒>定时休眠
|
||||
|
||||
定时休眠 → 仅【工作态】有效
|
||||
定时唤醒 → 仅【休眠态】有效
|
||||
@@ -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},
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
39
talkingq-url/tests/test_alarm_location_stale.py
Normal file
39
talkingq-url/tests/test_alarm_location_stale.py
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
354
talkingq-url/tests/test_family_display_name.py
Normal file
354
talkingq-url/tests/test_family_display_name.py
Normal 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"] == "小明微信"
|
||||
@@ -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",
|
||||
|
||||
215
talkingq-url/tests/test_parent_voice_mp3_normalization.py
Normal file
215
talkingq-url/tests/test_parent_voice_mp3_normalization.py
Normal 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",
|
||||
}
|
||||
]
|
||||
@@ -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
|
||||
|
||||
22
talkingq-url/tests/test_wechat_verify.py
Normal file
22
talkingq-url/tests/test_wechat_verify.py
Normal 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
|
||||
Reference in New Issue
Block a user