持久化设备留言待收听状态并支持重启恢复

This commit is contained in:
stu2not
2026-05-19 14:06:16 +08:00
parent 3164d81bb0
commit 6e104201fb
21 changed files with 1147 additions and 68 deletions

View File

@@ -40,6 +40,7 @@ MINI_PROGRAM_TABLES = [
"device_alarm_events",
"im_conversations",
"im_messages",
"device_pending_voice_messages",
"child_location_current",
"child_location_history",
]

View File

@@ -455,6 +455,29 @@ CREATE TABLE IF NOT EXISTS `im_messages` (
ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Persistent unread voice-message index for device NFC owner-card playback.
-- Audio content is still stored by im_messages/media_file_key; this table only stores delivery state.
CREATE TABLE IF NOT EXISTS `device_pending_voice_messages` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`target_device_id` VARCHAR(64) NOT NULL,
`sender_device_id` VARCHAR(64) NULL,
`im_message_id` BIGINT NOT NULL,
`media_file_key` VARCHAR(255) NOT NULL,
`audio_url` VARCHAR(512) NULL,
`source` VARCHAR(32) NOT NULL DEFAULT 'unknown',
`status` VARCHAR(32) NOT NULL DEFAULT 'pending',
`delivery_count` INT NOT NULL DEFAULT 0,
`delivered_at` DATETIME NULL,
`listened_at` DATETIME NULL,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uq_device_pending_voice_target_msg` (`target_device_id`, `im_message_id`),
KEY `idx_device_pending_voice_target_status_created` (`target_device_id`, `status`, `created_at`),
KEY `idx_device_pending_voice_sender` (`sender_device_id`),
KEY `idx_device_pending_voice_im_message` (`im_message_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `child_location_current` (
`child_id` BIGINT NOT NULL,
`device_id` VARCHAR(64) NOT NULL,

View File

@@ -190,6 +190,28 @@ CREATE TABLE IF NOT EXISTS im_messages (
CREATE INDEX idx_im_msg_sender ON im_messages(sender_type, sender_id, created_at);
CREATE INDEX idx_im_msg_receiver ON im_messages(receiver_type, receiver_id, created_at);
-- device_pending_voice_messages
CREATE TABLE IF NOT EXISTS device_pending_voice_messages (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
target_device_id VARCHAR(64) NOT NULL,
sender_device_id VARCHAR(64),
im_message_id BIGINT NOT NULL,
media_file_key VARCHAR(255) NOT NULL,
audio_url VARCHAR(512),
source VARCHAR(32) NOT NULL DEFAULT 'unknown',
status VARCHAR(32) NOT NULL DEFAULT 'pending',
delivery_count INT NOT NULL DEFAULT 0,
delivered_at DATETIME,
listened_at DATETIME,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT uq_device_pending_voice_target_msg UNIQUE (target_device_id, im_message_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE INDEX idx_device_pending_voice_target_status_created ON device_pending_voice_messages(target_device_id, status, created_at);
CREATE INDEX idx_device_pending_voice_sender ON device_pending_voice_messages(sender_device_id);
CREATE INDEX idx_device_pending_voice_im_message ON device_pending_voice_messages(im_message_id);
-- child_location_current
CREATE TABLE IF NOT EXISTS child_location_current (
child_id BIGINT UNSIGNED PRIMARY KEY,

View File

@@ -0,0 +1,175 @@
from collections.abc import Mapping
from typing import Any
from banban.dao import BaseDAO
PENDING_VOICE_STATUS_PENDING = "pending"
PENDING_VOICE_STATUS_DELIVERED = "delivered"
PENDING_VOICE_STATUS_LISTENED = "listened"
PENDING_VOICE_STATUS_EXPIRED = "expired"
class PendingVoiceMessageDAO(BaseDAO):
async def upsert_pending(
self,
*,
target_device_id: str,
im_message_id: int,
media_file_key: str,
sender_device_id: str | None = None,
audio_url: str | None = None,
source: str = "unknown",
) -> None:
await self.execute(
"""
INSERT INTO device_pending_voice_messages (
target_device_id,
sender_device_id,
im_message_id,
media_file_key,
audio_url,
source,
status
) VALUES (
:target_device_id,
:sender_device_id,
:im_message_id,
:media_file_key,
:audio_url,
:source,
:status
)
ON DUPLICATE KEY UPDATE
sender_device_id = COALESCE(sender_device_id, VALUES(sender_device_id)),
audio_url = COALESCE(audio_url, VALUES(audio_url)),
source = VALUES(source),
updated_at = CURRENT_TIMESTAMP
""",
{
"target_device_id": target_device_id,
"sender_device_id": sender_device_id,
"im_message_id": im_message_id,
"media_file_key": media_file_key,
"audio_url": audio_url,
"source": source,
"status": PENDING_VOICE_STATUS_PENDING,
},
)
async def list_pending_for_delivery(
self,
*,
target_device_id: str,
limit: int = 10,
) -> list[Mapping[str, Any]]:
rows = (
await self.execute(
"""
SELECT
id,
target_device_id,
sender_device_id,
im_message_id,
media_file_key,
audio_url,
source,
status,
delivery_count,
created_at
FROM device_pending_voice_messages
WHERE target_device_id = :target_device_id
AND status = :status
ORDER BY created_at ASC, id ASC
LIMIT :limit
""",
{
"target_device_id": target_device_id,
"status": PENDING_VOICE_STATUS_PENDING,
"limit": limit,
},
)
).mappings().all()
return rows
async def list_devices_with_pending(self, *, limit: int = 1000) -> list[Mapping[str, Any]]:
rows = (
await self.execute(
"""
SELECT
target_device_id,
COUNT(*) AS pending_count,
MIN(created_at) AS first_pending_at
FROM device_pending_voice_messages
WHERE status = :status
GROUP BY target_device_id
ORDER BY first_pending_at ASC
LIMIT :limit
""",
{
"status": PENDING_VOICE_STATUS_PENDING,
"limit": limit,
},
)
).mappings().all()
return rows
async def mark_delivered(self, message_ids: list[int]) -> None:
if not message_ids:
return
placeholders, params = self._build_id_params(message_ids)
await self.execute(
f"""
UPDATE device_pending_voice_messages
SET status = :status,
delivery_count = delivery_count + 1,
delivered_at = COALESCE(delivered_at, CURRENT_TIMESTAMP),
updated_at = CURRENT_TIMESTAMP
WHERE id IN ({placeholders})
AND status = :pending_status
""",
{
**params,
"status": PENDING_VOICE_STATUS_DELIVERED,
"pending_status": PENDING_VOICE_STATUS_PENDING,
},
)
async def mark_listened(self, message_ids: list[int]) -> None:
if not message_ids:
return
placeholders, params = self._build_id_params(message_ids)
await self.execute(
f"""
UPDATE device_pending_voice_messages
SET status = :status,
listened_at = COALESCE(listened_at, CURRENT_TIMESTAMP),
updated_at = CURRENT_TIMESTAMP
WHERE id IN ({placeholders})
""",
{
**params,
"status": PENDING_VOICE_STATUS_LISTENED,
},
)
async def clear_pending_for_device(self, *, target_device_id: str) -> None:
await self.execute(
"""
UPDATE device_pending_voice_messages
SET status = :status,
updated_at = CURRENT_TIMESTAMP
WHERE target_device_id = :target_device_id
AND status = :pending_status
""",
{
"target_device_id": target_device_id,
"status": PENDING_VOICE_STATUS_EXPIRED,
"pending_status": PENDING_VOICE_STATUS_PENDING,
},
)
def _build_id_params(self, message_ids: list[int]) -> tuple[str, dict[str, int]]:
params = {f"id_{index}": int(message_id) for index, message_id in enumerate(message_ids)}
placeholders = ", ".join(f":{key}" for key in params)
return placeholders, params

View File

@@ -12,7 +12,7 @@ from banban.dao.binding import (
SESSION_STATUS_PENDING,
)
from banban.security import get_current_user_id
from banban.service.binding import BindingError, BindingService
from banban.service.binding import BindingError, binding_service
router = APIRouter(prefix="/bindings", tags=["bindings"])
@@ -91,9 +91,8 @@ async def start_bind(
current_user_id: int = Depends(get_current_user_id),
) -> BindStartResponse:
del request
service = BindingService()
try:
bind_token, expires_at = await service.start_bind(
bind_token, expires_at = await binding_service.start_bind(
current_user_id,
payload.device_id,
payload.serial_number,
@@ -115,8 +114,7 @@ async def get_bind_session(
current_user_id: int = Depends(get_current_user_id),
) -> BindSessionResponse:
del request
service = BindingService()
session = await service.get_bind_session(bind_token, current_user_id)
session = await binding_service.get_bind_session(bind_token, current_user_id)
if not session:
raise HTTPException(status_code=404, detail="bind session not found")
@@ -137,9 +135,8 @@ async def confirm_bind(
current_user_id: int = Depends(get_current_user_id),
) -> BindConfirmResponse:
del request
service = BindingService()
try:
result = await service.confirm_bind(payload.bind_token, current_user_id)
result = await binding_service.confirm_bind(payload.bind_token, current_user_id)
except BindingError as exc:
raise HTTPException(status_code=exc.status_code, detail=str(exc))
except ValueError as exc:
@@ -169,9 +166,8 @@ async def direct_bind(
current_user_id: int = Depends(get_current_user_id),
) -> DirectBindResponse:
del request
service = BindingService()
try:
result = await service.direct_bind(
result = await binding_service.direct_bind(
payload.device_id,
payload.serial_number,
payload.child_id,
@@ -190,9 +186,8 @@ async def set_binding_child(
current_user_id: int = Depends(get_current_user_id),
) -> DirectBindResponse:
del request
service = BindingService()
try:
result = await service.set_binding_child(device_id=device_id, child_id=payload.child_id, user_id=current_user_id)
result = await binding_service.set_binding_child(device_id=device_id, child_id=payload.child_id, user_id=current_user_id)
except BindingError as exc:
raise HTTPException(status_code=exc.status_code, detail=str(exc))
except ValueError as exc:
@@ -206,8 +201,7 @@ async def get_current_binding(
current_user_id: int = Depends(get_current_user_id),
):
del request
service = BindingService()
binding = await service.get_current_binding(current_user_id)
binding = await binding_service.get_current_binding(current_user_id)
if not binding:
raise HTTPException(status_code=404, detail="no binding found")
return binding
@@ -221,8 +215,7 @@ async def list_bindings(
current_user_id: int = Depends(get_current_user_id),
) -> BindingListResponse:
del request
service = BindingService()
rows, has_more = await service.list_bindings(current_user_id, limit, cursor)
rows, has_more = await binding_service.list_bindings(current_user_id, limit, cursor)
next_cursor = int(rows[-1]["id"]) if has_more and rows else None
items = [
BindingListItem(
@@ -244,8 +237,7 @@ async def get_binding(
current_user_id: int = Depends(get_current_user_id),
) -> BindingGetResponse:
del request
service = BindingService()
binding = await service.get_binding(device_id, current_user_id)
binding = await binding_service.get_binding(device_id, current_user_id)
if not binding:
raise HTTPException(status_code=404, detail="binding not found")
return BindingGetResponse(**binding)
@@ -258,8 +250,7 @@ async def unbind_device(
current_user_id: int = Depends(get_current_user_id),
) -> None:
del request
service = BindingService()
if not await service.unbind(device_id, current_user_id):
if not await binding_service.unbind(device_id, current_user_id):
raise HTTPException(status_code=404, detail="binding not found")
@@ -273,8 +264,7 @@ async def get_bind_history(
) -> BindHistoryResponse:
del request, current_user_id
cursor_dt = datetime.fromisoformat(cursor) if cursor else None
service = BindingService()
rows, has_more = await service.list_history(device_id, limit, cursor_dt)
rows, has_more = await binding_service.list_history(device_id, limit, cursor_dt)
next_cursor = rows[-1]["bound_at"].isoformat() if has_more and rows else None
items = [BindHistoryItem(**row) for row in rows]
return BindHistoryResponse(items=items, total=len(items), next_cursor=next_cursor)

View File

@@ -2,6 +2,7 @@ import logging
from typing import Optional
from fastapi import APIRouter, HTTPException, Query
from banban.service.device_audio_cache import device_audio_cache_service
from banban.service.pending_voice_message import pending_voice_message_service
from services.offline_audio_cache import offline_audio_cache
from banban.schemas.mqtt_models import (
GPSQueryRequest,
@@ -110,6 +111,7 @@ async def clear_nfc_unread(
# current_user_id: int = Depends(get_current_user_id)
):
await offline_audio_cache.clear_audio_urls(device_id)
await pending_voice_message_service.clear_pending_for_device(target_device_id=device_id)
return CommandResponse(msg_id="", device_id=device_id, message="已清除未读留言")

View File

@@ -239,3 +239,6 @@ class BindingService(DatabaseServiceBase):
return rows, has_more
finally:
await db_session.close()
binding_service = BindingService()

View File

@@ -37,6 +37,15 @@ class PreparedArchiveAudio:
archive_format: str
@dataclass(frozen=True)
class DeviceVoiceArchiveResult:
sender_device_id: str
receiver_device_id: str
conversation_id: int
message_id: int
media_file_key: str
class DeviceVoiceArchiveService(DatabaseServiceBase):
def __init__(self) -> None:
super().__init__(service_name="device_voice_archive")
@@ -47,7 +56,7 @@ class DeviceVoiceArchiveService(DatabaseServiceBase):
sender_device_id: str,
receiver_device_id: str,
local_audio_path: str,
) -> bool:
) -> DeviceVoiceArchiveResult | None:
archive_id = f"voice_archive:{uuid4().hex[:12]}"
session_logger.info(
sender_device_id,
@@ -65,7 +74,7 @@ class DeviceVoiceArchiveService(DatabaseServiceBase):
archive_id,
f"跳过语音归档: 本地音频文件不存在, local_audio_path={local_audio_path}",
)
return False
return None
prepared_audio = None
db_session = await self.get_session()
@@ -76,7 +85,7 @@ class DeviceVoiceArchiveService(DatabaseServiceBase):
local_audio_path=local_audio_path,
)
if prepared_audio is None:
return False
return None
dao = ImDAO(db_session)
sender_identity = await self._get_child_identity_by_device_id(
@@ -94,14 +103,14 @@ class DeviceVoiceArchiveService(DatabaseServiceBase):
archive_id,
f"跳过语音归档: 发送设备未绑定有效 child, device_id={sender_device_id}",
)
return False
return None
if receiver_identity is None:
session_logger.warning(
sender_device_id,
archive_id,
f"跳过语音归档: 接收设备未绑定有效 child, device_id={receiver_device_id}",
)
return False
return None
if sender_identity["child_id"] == receiver_identity["child_id"]:
session_logger.warning(
sender_device_id,
@@ -111,7 +120,7 @@ class DeviceVoiceArchiveService(DatabaseServiceBase):
f"child_id={sender_identity['child_id']}"
),
)
return False
return None
session_logger.info(
sender_device_id,
@@ -228,14 +237,22 @@ class DeviceVoiceArchiveService(DatabaseServiceBase):
f"archive_format={prepared_audio.archive_format}"
),
)
return True
if not message_row:
raise RuntimeError("message was not found after insert")
return DeviceVoiceArchiveResult(
sender_device_id=sender_device_id,
receiver_device_id=receiver_device_id,
conversation_id=int(conversation_id),
message_id=int(message_row["id"]),
media_file_key=stored_audio.file_key,
)
except MessageAudioStorageError as exc:
session_logger.error(
sender_device_id,
archive_id,
f"设备语音归档失败: COS 上传异常: {exc}",
)
return False
return None
except Exception as exc:
session_logger.error(
sender_device_id,
@@ -243,7 +260,7 @@ class DeviceVoiceArchiveService(DatabaseServiceBase):
f"设备语音归档失败: {exc}",
exc_info=True,
)
return False
return None
finally:
if prepared_audio and prepared_audio.archive_format == "mp3" and prepared_audio.filepath != local_audio_path:
if os.path.exists(prepared_audio.filepath):

View File

@@ -4,12 +4,12 @@ import json
from collections.abc import Mapping
from pathlib import Path
from typing import Any
from services.offline_audio_cache import offline_audio_cache
from fastapi import HTTPException
from services.database_service_base import DatabaseServiceBase
from banban.service.device_audio_cache import device_audio_cache_service
from banban.service.message_audio_storage import MessageAudioStorageService, MessageAudioStorageError
from banban.service.binding import BindingService
from banban.service.binding import binding_service
from banban.service.pending_voice_message import pending_voice_message_service
try:
from banban.dao.im import ImDAO, DeviceIdentity, ConversationMessageCreateResult
from banban.schemas.im import (
@@ -223,6 +223,25 @@ class ImService(DatabaseServiceBase):
message=presented_message,
)
async def _get_raw_message_media_file_key(
self,
*,
conversation_id: int,
client_msg_id: str,
) -> str | None:
db_session = await self.get_session()
try:
dao = ImDAO(db_session)
message_row = await dao._get_message_by_conversation_client_id(
conversation_id=conversation_id,
client_msg_id=client_msg_id,
)
if not message_row:
return None
return message_row["media_file_key"]
finally:
await db_session.close()
async def create_parent_child_message(
self,
*,
@@ -305,28 +324,47 @@ class ImService(DatabaseServiceBase):
ext_json=ext_json,
)
result = None
try:
result = await self.create_parent_child_message(
parent_user_id=parent_user_id,
child_id=child_id,
payload=payload,
)
binding_service = BindingService()
pending_media_file_key = await self._get_raw_message_media_file_key(
conversation_id=result.conversation_id,
client_msg_id=client_msg_id,
)
if not pending_media_file_key:
pending_media_file_key = stored_audio.file_key
if result.idempotent:
try:
await self.audio_storage.delete_audio(stored_audio.file_key)
except MessageAudioStorageError:
pass
device = await binding_service.get_current_binding(parent_user_id)
try:
audio_url = await device_audio_cache_service.get_device_audio_url(
stored_audio.file_key,
pending_media_file_key,
device_id=device.device_id,
)
except Exception:
session_logger.error(device.device_id, "audio", f"failed to get device audio url: {stored_audio.file_key}", exc_info=True)
audio_url = stored_audio.file_key
await offline_audio_cache.add_audio_url(device.device_id, f"{audio_url}")
session_logger.error(device.device_id, "audio", f"failed to get device audio url: {pending_media_file_key}", exc_info=True)
audio_url = pending_media_file_key
await pending_voice_message_service.add_pending_message(
target_device_id=device.device_id,
sender_device_id=None,
im_message_id=result.message.id,
media_file_key=pending_media_file_key,
audio_url=f"{audio_url}",
source="parent_child_voice",
)
except Exception:
try:
await self.audio_storage.delete_audio(stored_audio.file_key)
except MessageAudioStorageError:
pass
if result is None:
try:
await self.audio_storage.delete_audio(stored_audio.file_key)
except MessageAudioStorageError:
pass
raise
return result

View File

@@ -0,0 +1,146 @@
from dataclasses import dataclass
from banban.dao.pending_voice_message import PendingVoiceMessageDAO
from banban.service.device_audio_cache import device_audio_cache_service
from services.database_service_base import DatabaseServiceBase
from utils.logger import session_logger
@dataclass(frozen=True)
class PendingVoicePlaybackItem:
pending_id: int
audio_url: str
im_message_id: int
media_file_key: str
source: str
class PendingVoiceMessageService(DatabaseServiceBase):
def __init__(self) -> None:
super().__init__(service_name="pending_voice_message")
async def add_pending_message(
self,
*,
target_device_id: str,
im_message_id: int,
media_file_key: str,
sender_device_id: str | None = None,
audio_url: str | None = None,
source: str = "unknown",
) -> None:
normalized_target_device_id = (target_device_id or "").strip()
normalized_media_file_key = (media_file_key or "").strip()
if not normalized_target_device_id or not normalized_media_file_key:
raise ValueError("target_device_id and media_file_key are required")
db_session = await self.get_session()
try:
dao = PendingVoiceMessageDAO(db_session)
await dao.upsert_pending(
target_device_id=normalized_target_device_id,
sender_device_id=(sender_device_id or "").strip() or None,
im_message_id=int(im_message_id),
media_file_key=normalized_media_file_key,
audio_url=(audio_url or "").strip() or None,
source=source,
)
await db_session.commit()
session_logger.info(
normalized_target_device_id,
"pending_voice",
(
"待收听留言已持久化: "
f"im_message_id={im_message_id}, source={source}"
),
)
except Exception:
await db_session.rollback()
raise
finally:
await db_session.close()
async def get_playback_items(
self,
*,
target_device_id: str,
limit: int = 10,
) -> list[PendingVoicePlaybackItem]:
db_session = await self.get_session()
try:
dao = PendingVoiceMessageDAO(db_session)
rows = await dao.list_pending_for_delivery(
target_device_id=target_device_id,
limit=limit,
)
finally:
await db_session.close()
items: list[PendingVoicePlaybackItem] = []
for row in rows:
media_file_key = str(row["media_file_key"])
fallback_audio_url = (row["audio_url"] or "").strip()
try:
audio_url = await device_audio_cache_service.get_device_audio_url(
media_file_key,
device_id=target_device_id,
)
except Exception as exc:
if fallback_audio_url:
audio_url = fallback_audio_url
else:
session_logger.error(
target_device_id,
"pending_voice",
f"待收听留言音频URL生成失败: media_file_key={media_file_key}, error={exc}",
exc_info=True,
)
continue
items.append(
PendingVoicePlaybackItem(
pending_id=int(row["id"]),
audio_url=audio_url,
im_message_id=int(row["im_message_id"]),
media_file_key=media_file_key,
source=str(row["source"] or "unknown"),
)
)
return items
async def mark_delivered(self, pending_ids: list[int]) -> None:
if not pending_ids:
return
db_session = await self.get_session()
try:
dao = PendingVoiceMessageDAO(db_session)
await dao.mark_delivered(pending_ids)
await db_session.commit()
except Exception:
await db_session.rollback()
raise
finally:
await db_session.close()
async def clear_pending_for_device(self, *, target_device_id: str) -> None:
db_session = await self.get_session()
try:
dao = PendingVoiceMessageDAO(db_session)
await dao.clear_pending_for_device(target_device_id=target_device_id)
await db_session.commit()
except Exception:
await db_session.rollback()
raise
finally:
await db_session.close()
async def list_devices_with_pending(self, *, limit: int = 1000) -> list[str]:
db_session = await self.get_session()
try:
dao = PendingVoiceMessageDAO(db_session)
rows = await dao.list_devices_with_pending(limit=limit)
finally:
await db_session.close()
return [str(row["target_device_id"]) for row in rows]
pending_voice_message_service = PendingVoiceMessageService()

View File

@@ -365,6 +365,34 @@ class IMMessage(Base):
deleted_at: Mapped[Optional[datetime]] = mapped_column(DateTime)
class DevicePendingVoiceMessage(Base):
__tablename__ = "device_pending_voice_messages"
__table_args__ = (
UniqueConstraint("target_device_id", "im_message_id", name="uq_device_pending_voice_target_msg"),
Index("idx_device_pending_voice_target_status_created", "target_device_id", "status", "created_at"),
Index("idx_device_pending_voice_sender", "sender_device_id"),
Index("idx_device_pending_voice_im_message", "im_message_id"),
)
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
target_device_id: Mapped[str] = mapped_column(String(64), nullable=False)
sender_device_id: Mapped[Optional[str]] = mapped_column(String(64))
im_message_id: Mapped[int] = mapped_column(BigInteger, nullable=False)
media_file_key: Mapped[str] = mapped_column(String(255), nullable=False)
audio_url: Mapped[Optional[str]] = mapped_column(String(512))
source: Mapped[str] = mapped_column(String(32), nullable=False, server_default=text("'unknown'"))
status: Mapped[str] = mapped_column(String(32), nullable=False, server_default=text("'pending'"))
delivery_count: Mapped[int] = mapped_column(Integer, nullable=False, server_default=text("0"))
delivered_at: Mapped[Optional[datetime]] = mapped_column(DateTime)
listened_at: Mapped[Optional[datetime]] = mapped_column(DateTime)
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=text("CURRENT_TIMESTAMP"))
updated_at: Mapped[datetime] = mapped_column(
DateTime,
server_default=text("CURRENT_TIMESTAMP"),
onupdate=datetime.utcnow,
)
class ChildLocationCurrent(Base):
__tablename__ = "child_location_current"

View File

@@ -6,11 +6,12 @@ from typing import Awaitable, Callable, Dict, Optional
import aiomqtt
from banban.service.binding import BindingService
from banban.service.binding import binding_service
from banban.service.device_alarm import device_alarm_service
from banban.service.device_setting import device_setting_service
from banban.service.im import im_service
from banban.service.location import location_service
from banban.service.pending_voice_message import pending_voice_message_service
from config import settings
from services.card_service import card_service
from services.device_target_cache import device_target_cache
@@ -275,8 +276,7 @@ class TalkingQMQTTService:
nfc_uuid = data.get("uuid")
# 卡片与设备绑定
# await card_service.activate_card(device_id=device_id, card_uuid=nfc_uuid)
service = BindingService()
result = await service.finalize_nfc_bind(device_id=device_id, card_uuid=nfc_uuid)
result = await binding_service.finalize_nfc_bind(device_id=device_id, card_uuid=nfc_uuid)
if result is None:
logger.warning(device_id, "", "[NFC bind] no pending bind session found")
return
@@ -431,6 +431,23 @@ class TalkingQMQTTService:
is_owner = await card_service.check_card_ownership(nfc_uuid, device_id)
if is_owner:
playback_items = await pending_voice_message_service.get_playback_items(
target_device_id=device_id,
)
if playback_items:
params = {
f"url_{k}": item.audio_url
for k, item in enumerate(playback_items, start=1)
}
payload = {"msg_id": "005", "type": 0, "params": params}
published = await self._publish(topic, payload)
if published:
await pending_voice_message_service.mark_delivered(
[item.pending_id for item in playback_items]
)
await offline_audio_cache.clear_audio_urls(device_id)
return
audio_urls = await offline_audio_cache.pop_audio_urls(device_id)
if audio_urls:
params = {f"url_{k}": audio_url for k, audio_url in enumerate(audio_urls, start=1)}
@@ -511,13 +528,15 @@ class TalkingQMQTTService:
if not self._connected:
await self.connect()
async def _publish(self, topic: str, payload: dict):
async def _publish(self, topic: str, payload: dict) -> bool:
await self._ensure_connected()
try:
await self._client.publish(topic, json.dumps(payload, ensure_ascii=False), qos=self.qos)
logger.info("", "", f"mqtt publish topic={topic} payload={payload}")
return True
except Exception as exc:
logger.error("", "", f"mqtt publish failed: {exc}")
return False
async def send_gps_query(self, device_id: str) -> str:
await self._publish(f"device/{device_id}/command", {"msg_id": "001"})

View File

@@ -7,6 +7,7 @@ from handlers.audio_session_handler import handle_websocket_data
from handlers.audio_file_handler import upload_message_audio
from banban.service.device_audio_cache import device_audio_cache_service
from banban.service.device_voice_archive import device_voice_archive_service
from banban.service.pending_voice_message import pending_voice_message_service
from services.audio_session import audio_session_manager
from services.interrupt_handler import interrupt_handler
from services.task_manager import task_manager
@@ -340,22 +341,32 @@ async def process_cached_audio(device_id: str, target_device_id: str, serial_num
cached_audio,
device_id=target_device_id,
)
await offline_audio_cache.add_audio_url(target_device_id, audio_url)
session_logger.info(
device_id,
"target",
f"设备留言已加入待收听队列: target_device_id={target_device_id}, audio_url={audio_url}",
archive_result = await device_voice_archive_service.archive_peer_voice_message(
sender_device_id=device_id,
receiver_device_id=target_device_id,
local_audio_path=str(local_audio_path),
)
await task_manager.create_task(
device_voice_archive_service.archive_peer_voice_message(
if archive_result is not None:
await pending_voice_message_service.add_pending_message(
target_device_id=target_device_id,
sender_device_id=device_id,
receiver_device_id=target_device_id,
local_audio_path=str(local_audio_path),
),
device_id=device_id,
task_type="device_voice_archive",
)
im_message_id=archive_result.message_id,
media_file_key=archive_result.media_file_key,
audio_url=audio_url,
source="device_peer_voice",
)
session_logger.info(
device_id,
"target",
f"设备留言已加入待收听队列: target_device_id={target_device_id}, audio_url={audio_url}",
)
else:
await offline_audio_cache.add_audio_url(target_device_id, audio_url)
session_logger.warning(
device_id,
"target",
"设备留言已写入内存待收听队列,但归档失败,重启后无法恢复这条留言",
)
websocket = await connection_manager.get_connection(device_id)
if websocket and websocket.client_state.name == "CONNECTED":

View File

@@ -35,6 +35,13 @@ async def lifespan(app: FastAPI):
)
init_directories()
try:
session_logger.system_info("startup", "初始化数据库...")
await init_db()
session_logger.system_info("startup", "数据库初始化和迁移完成")
except Exception as e:
session_logger.system_info("startup", f"数据库初始化失败: {str(e)}")
scheduler = TaskScheduler.get_instance()
scheduler.start()
scheduler.add_interval_task(
@@ -57,12 +64,6 @@ async def lifespan(app: FastAPI):
else:
session_logger.system_info("startup", "未配置 TalkingQ_MQTTMQTT 服务链接未启动")
exit(1)
try:
session_logger.system_info("startup", "初始化数据库...")
await init_db()
session_logger.system_info("startup", "数据库初始化和迁移完成")
except Exception as e:
session_logger.system_info("startup", f"数据库初始化失败: {str(e)}")
await role_manager.initialize()
await register_services()

View File

@@ -455,6 +455,29 @@ CREATE TABLE IF NOT EXISTS `im_messages` (
ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Persistent unread voice-message index for device NFC owner-card playback.
-- Audio content is still stored by im_messages/media_file_key; this table only stores delivery state.
CREATE TABLE IF NOT EXISTS `device_pending_voice_messages` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`target_device_id` VARCHAR(64) NOT NULL,
`sender_device_id` VARCHAR(64) NULL,
`im_message_id` BIGINT NOT NULL,
`media_file_key` VARCHAR(255) NOT NULL,
`audio_url` VARCHAR(512) NULL,
`source` VARCHAR(32) NOT NULL DEFAULT 'unknown',
`status` VARCHAR(32) NOT NULL DEFAULT 'pending',
`delivery_count` INT NOT NULL DEFAULT 0,
`delivered_at` DATETIME NULL,
`listened_at` DATETIME NULL,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uq_device_pending_voice_target_msg` (`target_device_id`, `im_message_id`),
KEY `idx_device_pending_voice_target_status_created` (`target_device_id`, `status`, `created_at`),
KEY `idx_device_pending_voice_sender` (`sender_device_id`),
KEY `idx_device_pending_voice_im_message` (`im_message_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `child_location_current` (
`child_id` BIGINT NOT NULL,
`device_id` VARCHAR(64) NOT NULL,

View File

@@ -0,0 +1,293 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import asyncio
import json
import sys
import time
import uuid
from dataclasses import dataclass
from pathlib import Path
from typing import Any
import aiomqtt
import pymysql
from pymysql.cursors import DictCursor
PROJECT_ROOT = Path(__file__).resolve().parents[1]
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
from config import settings
@dataclass(frozen=True)
class DeviceCardContext:
device_id: str
serial_number: str
child_id: int
card_uuid: str
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Simulate a device NFC listen event and verify pending voice delivery through MQTT."
)
parser.add_argument("--device-id", default="TalkingQ_XQSN00001001")
parser.add_argument("--broker", default=settings.talkingq_mqtt_broker or "127.0.0.1")
parser.add_argument("--port", type=int, default=settings.talkingq_mqtt_port or 1883)
parser.add_argument("--username", default=settings.talkingq_mqtt_username or None)
parser.add_argument("--password", default=settings.talkingq_mqtt_password or None)
parser.add_argument("--timeout", type=float, default=10.0)
parser.add_argument("--audio-url", default="http://127.0.0.1:8080/assets/audio/welcome.mp3")
parser.add_argument("--media-file-key", default="integration/nfc-pending-voice.mp3")
parser.add_argument("--keep-row", action="store_true", help="Keep the synthetic pending row after verification.")
return parser.parse_args()
def connect_db():
return pymysql.connect(
host=settings.db_host,
port=settings.db_port,
user=settings.db_user,
password=settings.db_password,
database=settings.db_name,
charset="utf8mb4",
cursorclass=DictCursor,
autocommit=True,
)
def ensure_pending_table_exists() -> None:
with connect_db() as connection:
with connection.cursor() as cursor:
cursor.execute(
"""
SELECT COUNT(*) AS cnt
FROM information_schema.tables
WHERE table_schema = %s
AND table_name = 'device_pending_voice_messages'
""",
(settings.db_name,),
)
row = cursor.fetchone()
if int(row["cnt"]) == 0:
raise RuntimeError(
"device_pending_voice_messages table does not exist; start the backend once or run init_db first"
)
def load_device_context(device_id: str) -> DeviceCardContext:
with connect_db() as connection:
with connection.cursor() as cursor:
cursor.execute(
"""
SELECT
da.device_id,
da.serial_number,
db.child_id,
c.card_uuid
FROM device_auth AS da
LEFT JOIN device_bindings AS db
ON db.device_id = da.device_id
AND db.status = 1
LEFT JOIN cards AS c
ON c.device_id = da.device_id
AND c.status = 1
WHERE da.device_id = %s
AND da.is_active = 1
LIMIT 1
""",
(device_id,),
)
row = cursor.fetchone()
if not row:
raise RuntimeError(f"active device_auth row not found for {device_id}")
if row["child_id"] is None:
raise RuntimeError(f"device {device_id} is not bound to any child")
if not row["card_uuid"]:
raise RuntimeError(f"device {device_id} does not have an active owner card")
return DeviceCardContext(
device_id=str(row["device_id"]),
serial_number=str(row["serial_number"]),
child_id=int(row["child_id"]),
card_uuid=str(row["card_uuid"]),
)
def insert_synthetic_pending(ctx: DeviceCardContext, *, audio_url: str, media_file_key: str) -> int:
synthetic_message_id = int(time.time() * 1000)
source = f"integration_nfc_{uuid.uuid4().hex[:10]}"
with connect_db() as connection:
with connection.cursor() as cursor:
cursor.execute(
"""
INSERT INTO device_pending_voice_messages (
target_device_id,
sender_device_id,
im_message_id,
media_file_key,
audio_url,
source,
status
) VALUES (%s, %s, %s, %s, %s, %s, 'pending')
""",
(
ctx.device_id,
"integration-sender",
synthetic_message_id,
media_file_key,
audio_url,
source,
),
)
pending_id = int(cursor.lastrowid)
print(
json.dumps(
{
"inserted_pending_id": pending_id,
"device_id": ctx.device_id,
"im_message_id": synthetic_message_id,
"source": source,
},
ensure_ascii=False,
),
flush=True,
)
return pending_id
def fetch_pending_row(pending_id: int) -> dict[str, Any] | None:
with connect_db() as connection:
with connection.cursor() as cursor:
cursor.execute(
"""
SELECT
id,
target_device_id,
im_message_id,
audio_url,
source,
status,
delivery_count,
delivered_at
FROM device_pending_voice_messages
WHERE id = %s
""",
(pending_id,),
)
return cursor.fetchone()
async def wait_for_pending_status(
pending_id: int,
*,
expected_status: str,
timeout: float,
) -> dict[str, Any]:
deadline = asyncio.get_running_loop().time() + timeout
last_row = None
while asyncio.get_running_loop().time() < deadline:
last_row = fetch_pending_row(pending_id)
if not last_row:
raise RuntimeError(f"pending row disappeared unexpectedly: id={pending_id}")
if last_row["status"] == expected_status:
return last_row
await asyncio.sleep(0.2)
raise RuntimeError(
f"expected pending row status {expected_status}, "
f"got {last_row['status'] if last_row else 'missing'}"
)
def delete_pending_row(pending_id: int) -> None:
with connect_db() as connection:
with connection.cursor() as cursor:
cursor.execute("DELETE FROM device_pending_voice_messages WHERE id = %s", (pending_id,))
async def wait_for_delivery_response(client: aiomqtt.Client, *, timeout: float) -> dict[str, Any]:
deadline = asyncio.get_running_loop().time() + timeout
async for message in client.messages:
remaining = deadline - asyncio.get_running_loop().time()
if remaining <= 0:
break
try:
payload = json.loads(message.payload.decode("utf-8"))
except json.JSONDecodeError:
continue
print(
json.dumps(
{
"received_topic": str(message.topic),
"payload": payload,
},
ensure_ascii=False,
),
flush=True,
)
if payload.get("msg_id") == "005" and payload.get("type") == 0:
params = payload.get("params") or {}
if any(str(key).startswith("url_") for key in params):
return payload
raise TimeoutError("timed out waiting for msg_id=005 delivery response")
async def run() -> None:
args = parse_args()
ensure_pending_table_exists()
ctx = load_device_context(args.device_id)
pending_id = insert_synthetic_pending(
ctx,
audio_url=args.audio_url,
media_file_key=args.media_file_key,
)
try:
event_topic = f"device/{ctx.device_id}/event"
response_topic = f"device/{ctx.device_id}/event_resp"
nfc_payload = {"msg_id": "005", "params": {"uuid": ctx.card_uuid}}
async with aiomqtt.Client(
hostname=args.broker,
port=args.port,
username=args.username,
password=args.password,
) as client:
await client.subscribe(response_topic)
await client.publish(event_topic, json.dumps(nfc_payload, ensure_ascii=False))
print(
json.dumps(
{
"published_topic": event_topic,
"payload": nfc_payload,
"subscribed_topic": response_topic,
},
ensure_ascii=False,
),
flush=True,
)
response = await asyncio.wait_for(
wait_for_delivery_response(client, timeout=args.timeout),
timeout=args.timeout,
)
row = await wait_for_pending_status(
pending_id,
expected_status="delivered",
timeout=args.timeout,
)
print(json.dumps({"pending_after_delivery": row}, ensure_ascii=False, default=str), flush=True)
if int(row["delivery_count"]) != 1:
raise RuntimeError(f"expected delivery_count 1, got {row['delivery_count']}")
params = response.get("params") or {}
if args.audio_url not in params.values():
raise RuntimeError(f"expected delivered url {args.audio_url}, got {params}")
finally:
if not args.keep_row:
delete_pending_row(pending_id)
if __name__ == "__main__":
asyncio.run(run())

View File

@@ -3,6 +3,8 @@ from utils.logger import session_logger
from database.connection import get_db_manager
class DatabaseServiceBase:
_logged_initialized_services = set()
"""
数据库服务基类,提供通用的数据库初始化和会话管理功能
所有需要访问数据库的服务类都应该继承此基类
@@ -19,7 +21,9 @@ class DatabaseServiceBase:
try:
self.db_manager = await get_db_manager()
self._db_initialized = True
session_logger.info("system", self.service_name, f"{self.service_name}数据库连接初始化成功")
if self.service_name not in self._logged_initialized_services:
self._logged_initialized_services.add(self.service_name)
session_logger.info("system", self.service_name, f"{self.service_name}数据库连接初始化成功")
except Exception as e:
session_logger.error("system", self.service_name, f"{self.service_name}数据库连接初始化失败: {str(e)}")
raise

View File

@@ -3,6 +3,7 @@ from typing import Optional, Dict
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from apscheduler.triggers.interval import IntervalTrigger
from banban.service.pending_voice_message import pending_voice_message_service
from services.connection_manager import connection_manager
from handlers.mqtt_handler import TalkingQMQTTService
from services.offline_audio_cache import offline_audio_cache
@@ -40,11 +41,14 @@ class TaskScheduler:
async def _execute_task(self):
try:
service = await TalkingQMQTTService.get_instance()
pending_devices = await pending_voice_message_service.list_devices_with_pending()
audio_cache = await offline_audio_cache.get_all_audio_cache()
if not audio_cache:
fallback_devices = list(audio_cache.keys()) if audio_cache else []
device_ids = list(dict.fromkeys([*pending_devices, *fallback_devices]))
if not device_ids:
logger.warning("", "", "无离线音频缓存,跳过本次执行")
return
for device_id, audio_urls in audio_cache.items():
for device_id in device_ids:
websocket = await connection_manager.get_connection(device_id)
if websocket is not None:
audio_url = f"http://{settings.server_host}:{settings.server_port}/assets/audio/new_message.mp3"

View File

@@ -0,0 +1,7 @@
import sys
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parents[1]
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))

View File

@@ -0,0 +1,42 @@
import pytest
from services import database_service_base
from services.database_service_base import DatabaseServiceBase
@pytest.mark.asyncio
async def test_database_init_success_log_is_once_per_service(monkeypatch):
logs = []
async def fake_get_db_manager():
return object()
def fake_info(device_id, session_id, message):
logs.append((device_id, session_id, message))
DatabaseServiceBase._logged_initialized_services.clear()
monkeypatch.setattr(database_service_base, "get_db_manager", fake_get_db_manager)
monkeypatch.setattr(database_service_base.session_logger, "info", fake_info)
first = DatabaseServiceBase(service_name="binding_service")
second = DatabaseServiceBase(service_name="binding_service")
await first._init_database()
await second._init_database()
assert logs == [
(
"system",
"binding_service",
"binding_service数据库连接初始化成功",
)
]
def test_binding_service_singleton_is_used_by_router_and_mqtt_handler():
from banban.routers import bindings
from banban.service.binding import binding_service
from handlers import mqtt_handler
assert bindings.binding_service is binding_service
assert mqtt_handler.binding_service is binding_service

View File

@@ -0,0 +1,230 @@
from types import SimpleNamespace
import pytest
from banban.dao.pending_voice_message import PendingVoiceMessageDAO
from banban.service.pending_voice_message import (
PendingVoiceMessageService,
PendingVoicePlaybackItem,
)
from handlers.mqtt_handler import TalkingQMQTTService
class FakeResult:
def __init__(self, rows=None):
self._rows = rows or []
def mappings(self):
return self
def all(self):
return self._rows
class FakeSession:
def __init__(self, rows=None):
self.rows = rows or []
self.calls = []
self.commits = 0
self.rollbacks = 0
self.closed = False
async def execute(self, statement, params=None):
self.calls.append((str(statement), params or {}))
return FakeResult(self.rows)
async def commit(self):
self.commits += 1
async def rollback(self):
self.rollbacks += 1
async def close(self):
self.closed = True
@pytest.mark.asyncio
async def test_upsert_pending_keeps_original_media_file_key_on_duplicate():
session = FakeSession()
dao = PendingVoiceMessageDAO(session)
await dao.upsert_pending(
target_device_id="TalkingQ_XQSN00001001",
sender_device_id="TalkingQ_XQSN00001002",
im_message_id=123,
media_file_key="audio/original.mp3",
audio_url="http://example.test/audio.mp3",
source="device_peer_voice",
)
sql, params = session.calls[0]
update_sql = sql.split("ON DUPLICATE KEY UPDATE", 1)[1]
assert "ON DUPLICATE KEY UPDATE" in sql
assert "media_file_key" not in update_sql
assert params["target_device_id"] == "TalkingQ_XQSN00001001"
assert params["im_message_id"] == 123
assert params["media_file_key"] == "audio/original.mp3"
assert params["status"] == "pending"
@pytest.mark.asyncio
async def test_mark_delivered_only_advances_pending_rows():
session = FakeSession()
dao = PendingVoiceMessageDAO(session)
await dao.mark_delivered([7, 9])
sql, params = session.calls[0]
assert "SET status = :status" in sql
assert "delivery_count = delivery_count + 1" in sql
assert "AND status = :pending_status" in sql
assert params == {
"id_0": 7,
"id_1": 9,
"status": "delivered",
"pending_status": "pending",
}
@pytest.mark.asyncio
async def test_get_playback_items_uses_fallback_url_when_signed_url_fails(monkeypatch):
service = PendingVoiceMessageService()
session = FakeSession(
rows=[
{
"id": 11,
"target_device_id": "TalkingQ_XQSN00001001",
"sender_device_id": "TalkingQ_XQSN00001002",
"im_message_id": 123,
"media_file_key": "cos/audio.mp3",
"audio_url": "http://cached.example/audio.mp3",
"source": "device_peer_voice",
"status": "pending",
"delivery_count": 0,
"created_at": None,
}
]
)
async def fake_get_session():
return session
class FailingAudioCache:
async def get_device_audio_url(self, media_file_key, device_id=None):
raise RuntimeError("cos unavailable")
monkeypatch.setattr(service, "get_session", fake_get_session)
monkeypatch.setattr(
"banban.service.pending_voice_message.device_audio_cache_service",
FailingAudioCache(),
)
items = await service.get_playback_items(target_device_id="TalkingQ_XQSN00001001")
assert items == [
PendingVoicePlaybackItem(
pending_id=11,
audio_url="http://cached.example/audio.mp3",
im_message_id=123,
media_file_key="cos/audio.mp3",
source="device_peer_voice",
)
]
assert session.closed is True
@pytest.mark.asyncio
async def test_nfc_owner_listen_marks_pending_delivered_only_after_publish(monkeypatch):
service = TalkingQMQTTService({"device_prefix": "TalkingQ"})
published = []
marked = []
cleared = []
class FakeCardService:
async def get_card_by_uuid(self, nfc_uuid):
return SimpleNamespace(device_id="TalkingQ_XQSN00001001")
async def check_card_ownership(self, nfc_uuid, device_id):
return True
class FakePendingVoiceService:
async def get_playback_items(self, target_device_id):
return [
PendingVoicePlaybackItem(
pending_id=11,
audio_url="http://cached.example/audio.mp3",
im_message_id=123,
media_file_key="cos/audio.mp3",
source="device_peer_voice",
)
]
async def mark_delivered(self, pending_ids):
marked.extend(pending_ids)
class FakeOfflineAudioCache:
async def clear_audio_urls(self, device_id):
cleared.append(device_id)
async def fake_publish(topic, payload):
published.append((topic, payload))
return True
monkeypatch.setattr("handlers.mqtt_handler.card_service", FakeCardService())
monkeypatch.setattr("handlers.mqtt_handler.pending_voice_message_service", FakePendingVoiceService())
monkeypatch.setattr("handlers.mqtt_handler.offline_audio_cache", FakeOfflineAudioCache())
monkeypatch.setattr(service, "_publish", fake_publish)
await service._send_nfc_listen_response("TalkingQ_XQSN00001001", "53DA2B6DA20001")
assert published == [
(
"device/TalkingQ_XQSN00001001/event_resp",
{
"msg_id": "005",
"type": 0,
"params": {"url_1": "http://cached.example/audio.mp3"},
},
)
]
assert marked == [11]
assert cleared == ["TalkingQ_XQSN00001001"]
@pytest.mark.asyncio
async def test_nfc_owner_listen_keeps_pending_when_publish_fails(monkeypatch):
service = TalkingQMQTTService({"device_prefix": "TalkingQ"})
marked = []
class FakeCardService:
async def get_card_by_uuid(self, nfc_uuid):
return SimpleNamespace(device_id="TalkingQ_XQSN00001001")
async def check_card_ownership(self, nfc_uuid, device_id):
return True
class FakePendingVoiceService:
async def get_playback_items(self, target_device_id):
return [
PendingVoicePlaybackItem(
pending_id=11,
audio_url="http://cached.example/audio.mp3",
im_message_id=123,
media_file_key="cos/audio.mp3",
source="device_peer_voice",
)
]
async def mark_delivered(self, pending_ids):
marked.extend(pending_ids)
async def fake_publish(topic, payload):
return False
monkeypatch.setattr("handlers.mqtt_handler.card_service", FakeCardService())
monkeypatch.setattr("handlers.mqtt_handler.pending_voice_message_service", FakePendingVoiceService())
monkeypatch.setattr(service, "_publish", fake_publish)
await service._send_nfc_listen_response("TalkingQ_XQSN00001001", "53DA2B6DA20001")
assert marked == []