支持已绑定设备追加 NFC 卡

This commit is contained in:
stu2not
2026-06-04 09:55:35 +08:00
parent 482fbf351f
commit 9f43fcb5ae
18 changed files with 646 additions and 92 deletions

View File

@@ -14,6 +14,9 @@ SESSION_STATUS_EXPIRED = 3
SESSION_STATUS_FAILED = 4
SESSION_STATUS_CANCELLED = 5
BIND_MODE_DEVICE = 1
BIND_MODE_CARD = 2
BIND_SOURCE_SESSION_CONFIRM = 1
BIND_SOURCE_DIRECT = 2
BIND_SOURCE_SET_CHILD = 3
@@ -205,7 +208,13 @@ class BindingDAO(BaseDAO):
{"device_id": device_id, "user_id": user_id},
)
async def start_bind(self, user_id: int, device_id: str, child_id: Optional[int]) -> tuple[str, datetime]:
async def start_bind(
self,
user_id: int,
device_id: str,
child_id: Optional[int],
bind_mode: int = BIND_MODE_DEVICE,
) -> tuple[str, datetime]:
bind_token = str(uuid.uuid4())
expires_at = datetime.utcnow() + timedelta(minutes=10)
@@ -227,8 +236,24 @@ class BindingDAO(BaseDAO):
await self.execute(
"""
INSERT INTO device_bind_sessions (bind_token, device_id, initiator_user_id, target_child_id, expires_at, status)
VALUES (:bind_token, :device_id, :initiator_user_id, :target_child_id, :expires_at, :status)
INSERT INTO device_bind_sessions (
bind_token,
device_id,
initiator_user_id,
target_child_id,
expires_at,
status,
bind_mode
)
VALUES (
:bind_token,
:device_id,
:initiator_user_id,
:target_child_id,
:expires_at,
:status,
:bind_mode
)
""",
{
"bind_token": bind_token,
@@ -237,6 +262,7 @@ class BindingDAO(BaseDAO):
"target_child_id": child_id,
"expires_at": expires_at,
"status": SESSION_STATUS_PENDING,
"bind_mode": bind_mode,
},
)
return bind_token, expires_at
@@ -246,17 +272,8 @@ class BindingDAO(BaseDAO):
await self.execute(
"""
SELECT
s.*,
CASE
WHEN s.status = :completed_status
AND s.confirmed_at IS NOT NULL
AND c.updated_at >= s.confirmed_at
THEN c.card_uuid
ELSE NULL
END AS card_uuid
s.*
FROM device_bind_sessions AS s
LEFT JOIN cards AS c
ON c.device_id = s.device_id
WHERE s.bind_token = :bind_token
AND s.initiator_user_id = :user_id
LIMIT 1
@@ -264,7 +281,6 @@ class BindingDAO(BaseDAO):
{
"bind_token": bind_token,
"user_id": user_id,
"completed_status": SESSION_STATUS_COMPLETED,
},
)
).mappings().first()
@@ -321,7 +337,14 @@ class BindingDAO(BaseDAO):
bind_source=BIND_SOURCE_SESSION_CONFIRM,
)
async def complete_nfc_bind(self, session_id: int, device_id: str, child_id: Optional[int], user_id: int) -> None:
async def complete_nfc_bind(
self,
session_id: int,
device_id: str,
child_id: Optional[int],
user_id: int,
card_uuid: Optional[str] = None,
) -> None:
if child_id is not None:
await self._upsert_parent_child_relation(user_id=user_id, child_id=child_id)
@@ -329,12 +352,13 @@ class BindingDAO(BaseDAO):
"""
UPDATE device_bind_sessions
SET status = :status,
card_uuid = :card_uuid,
confirmed_at = CURRENT_TIMESTAMP,
consumed_at = CURRENT_TIMESTAMP,
updated_at = CURRENT_TIMESTAMP
WHERE id = :id
""",
{"id": session_id, "status": SESSION_STATUS_COMPLETED},
{"id": session_id, "status": SESSION_STATUS_COMPLETED, "card_uuid": card_uuid},
)
await self._bind_device(device_id=device_id, user_id=user_id, child_id=child_id)
@@ -346,6 +370,24 @@ class BindingDAO(BaseDAO):
bind_source=BIND_SOURCE_NFC,
)
async def complete_card_bind(self, session_id: int, card_uuid: str) -> None:
await self.execute(
"""
UPDATE device_bind_sessions
SET status = :status,
card_uuid = :card_uuid,
confirmed_at = CURRENT_TIMESTAMP,
consumed_at = CURRENT_TIMESTAMP,
updated_at = CURRENT_TIMESTAMP
WHERE id = :id
""",
{
"id": session_id,
"status": SESSION_STATUS_COMPLETED,
"card_uuid": card_uuid,
},
)
async def direct_bind(self, device_id: str, child_id: Optional[int], user_id: int) -> None:
if child_id is not None:
await self._upsert_parent_child_relation(user_id=user_id, child_id=child_id)

View File

@@ -31,6 +31,10 @@ class BindStartResponse(BaseModel):
status: int
class CardBindStartRequest(BaseModel):
device_id: str
class BindSessionResponse(BaseModel):
bind_token: str
device_id: str
@@ -107,6 +111,24 @@ async def start_bind(
)
@router.post("/cards/start", response_model=BindStartResponse)
async def start_card_bind(
payload: CardBindStartRequest,
request: Request,
current_user_id: int = Depends(get_current_user_id),
) -> BindStartResponse:
del request
try:
bind_token, expires_at = await binding_service.start_card_bind(current_user_id, payload.device_id)
except BindingError as exc:
raise HTTPException(status_code=exc.status_code, detail=str(exc))
return BindStartResponse(
bind_token=bind_token,
expires_at=expires_at.isoformat(),
status=SESSION_STATUS_PENDING,
)
@router.get("/sessions/{bind_token}", response_model=BindSessionResponse)
async def get_bind_session(
bind_token: str,

View File

@@ -3,6 +3,8 @@ from datetime import datetime
from typing import Optional
from banban.dao.binding import (
BIND_MODE_CARD,
BIND_MODE_DEVICE,
SESSION_STATUS_CANCELLED,
SESSION_STATUS_COMPLETED,
SESSION_STATUS_EXPIRED,
@@ -41,6 +43,14 @@ class BindingService(DatabaseServiceBase):
if int(row["is_active"]) != 1:
raise BindingError("device is inactive", status_code=400)
async def _send_bind_nfc_command(self, device_id: str) -> None:
from handlers.mqtt_handler import TalkingQMQTTService
service = await TalkingQMQTTService.get_instance()
if service is None:
raise BindingError("MQTT service is unavailable", status_code=503)
await service.send_bind_nfc_command(device_id)
def _normalize_session_status(self, session: Mapping) -> int:
status = int(session["status"])
if status == SESSION_STATUS_PENDING and datetime.utcnow() > session["expires_at"]:
@@ -59,14 +69,26 @@ class BindingService(DatabaseServiceBase):
await self._ensure_bindable_device(db_session, device_id, serial_number)
await self._ensure_device_unbound(db_session, device_id)
dao = BindingDAO(db_session)
bind_token, expires_at = await dao.start_bind(user_id, device_id, child_id)
bind_token, expires_at = await dao.start_bind(user_id, device_id, child_id, BIND_MODE_DEVICE)
await db_session.commit()
from handlers.mqtt_handler import TalkingQMQTTService
await self._send_bind_nfc_command(device_id)
return bind_token, expires_at
finally:
await db_session.close()
service = await TalkingQMQTTService.get_instance()
if service is None:
raise BindingError("MQTT service is unavailable", status_code=503)
await service.send_bind_nfc_command(device_id)
async def start_card_bind(self, user_id: int, device_id: str) -> tuple[str, datetime]:
db_session = await self.get_session()
try:
dao = BindingDAO(db_session)
binding = await dao.get_by_device(device_id, user_id)
if binding is None:
raise BindingError("binding not found", status_code=404)
if int(binding["owner_user_id"]) != user_id:
raise BindingError("only the device owner can add cards", status_code=403)
bind_token, expires_at = await dao.start_bind(user_id, device_id, binding["child_id"], BIND_MODE_CARD)
await db_session.commit()
await self._send_bind_nfc_command(device_id)
return bind_token, expires_at
finally:
await db_session.close()
@@ -132,17 +154,22 @@ class BindingService(DatabaseServiceBase):
}
try:
await card_service.activate_card(
card = await card_service.activate_card(
card_uuid=card_uuid,
device_id=device_id,
db_session=db_session,
)
await dao.complete_nfc_bind(
session_id=int(session["id"]),
device_id=device_id,
child_id=session["target_child_id"],
user_id=int(session["initiator_user_id"]),
)
bind_mode = int(session.get("bind_mode") or BIND_MODE_DEVICE)
if bind_mode == BIND_MODE_CARD:
await dao.complete_card_bind(session_id=int(session["id"]), card_uuid=card.card_uuid)
else:
await dao.complete_nfc_bind(
session_id=int(session["id"]),
device_id=device_id,
child_id=session["target_child_id"],
user_id=int(session["initiator_user_id"]),
card_uuid=card.card_uuid,
)
await db_session.commit()
except Exception:
await dao.mark_session_status(int(session["id"]), SESSION_STATUS_FAILED)

View File

@@ -106,6 +106,61 @@ async def _ensure_child_location_address_columns(conn) -> None:
)
async def _ensure_bind_session_card_columns(conn) -> None:
await _ensure_columns(
conn,
table_name="device_bind_sessions",
columns=[
("bind_mode", "bind_mode TINYINT NOT NULL DEFAULT 1 AFTER status"),
("card_uuid", "card_uuid VARCHAR(64) NULL AFTER bind_mode"),
],
)
result = await conn.execute(
text(
"""
SELECT COUNT(*)
FROM INFORMATION_SCHEMA.STATISTICS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'device_bind_sessions'
AND INDEX_NAME = 'idx_device_bind_sessions_card_uuid'
"""
)
)
if int(result.scalar() or 0) == 0:
await conn.execute(text("ALTER TABLE device_bind_sessions ADD INDEX idx_device_bind_sessions_card_uuid (card_uuid)"))
async def _ensure_cards_allow_multiple_per_device(conn) -> None:
result = await conn.execute(
text(
"""
SELECT COUNT(*)
FROM INFORMATION_SCHEMA.STATISTICS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'cards'
AND INDEX_NAME = 'idx_cards_device_id'
"""
)
)
if int(result.scalar() or 0) == 0:
await conn.execute(text("ALTER TABLE cards ADD INDEX idx_cards_device_id (device_id)"))
result = await conn.execute(
text(
"""
SELECT COUNT(*)
FROM INFORMATION_SCHEMA.STATISTICS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'cards'
AND INDEX_NAME = 'uq_cards_device_id'
"""
)
)
if int(result.scalar() or 0) > 0:
await conn.execute(text("ALTER TABLE cards DROP INDEX uq_cards_device_id"))
async def _ensure_device_family_tables(conn) -> None:
await conn.execute(
text(
@@ -222,6 +277,8 @@ async def init_db():
await _ensure_schedule_suppressed_until_column(conn)
await _ensure_device_alarm_location_columns(conn)
await _ensure_child_location_address_columns(conn)
await _ensure_bind_session_card_columns(conn)
await _ensure_cards_allow_multiple_per_device(conn)
await _ensure_device_family_tables(conn)
await engine.dispose()
session_logger.info("system", "database", "数据库表已成功创建")

View File

@@ -285,6 +285,8 @@ class DeviceBindSession(Base):
max_attempt_count: Mapped[int] = mapped_column(Integer, server_default="5")
attempt_count: Mapped[int] = mapped_column(Integer, server_default="0")
status: Mapped[int] = mapped_column(Integer, server_default="1")
bind_mode: Mapped[int] = mapped_column(Integer, server_default="1")
card_uuid: Mapped[Optional[str]] = mapped_column(String(64))
confirmed_at: Mapped[Optional[datetime]] = mapped_column(DateTime)
consumed_at: Mapped[Optional[datetime]] = mapped_column(DateTime)
created_at: Mapped[Optional[datetime]] = mapped_column(DateTime, server_default=text("CURRENT_TIMESTAMP"))

View File

@@ -475,7 +475,7 @@ class TalkingQMQTTService:
await self._publish(topic, payload)
return
is_owner = await card_service.check_card_ownership(nfc_uuid, device_id)
is_owner = await card_service.is_active_for_device(nfc_uuid, device_id)
if is_owner:
playback_items = await pending_voice_message_service.get_playback_items(
target_device_id=device_id,
@@ -534,10 +534,17 @@ class TalkingQMQTTService:
return
existing_card = await card_service.get_card_by_uuid(nfc_uuid)
if existing_card:
if existing_card and existing_card.device_id and int(existing_card.status) == 1:
target_device_id = existing_card.device_id
else:
await card_service.activate_card(nfc_uuid, device_id)
payload = {
"msg_id": "005",
"type": 0,
"params": {
"url_1": f"http://{settings.server_host}:{settings.server_port}/assets/audio/error_card.mp3"
},
}
await self._publish(topic, payload)
return
await device_target_cache.set_target(device_id, target_device_id)

View File

@@ -84,14 +84,15 @@ async def handle_text_message(websocket: WebSocket, device_id: str, text_data: s
# 检查卡片是否存在
existing_card = await card_service.get_card_by_uuid(card_uuid)
if existing_card:
if existing_card and existing_card.device_id and int(existing_card.status) == 1:
# 卡片已存在使用卡片绑定的设备ID作为目标设备ID
target_device_id = existing_card.device_id
session_logger.info(device_id, "card", f"卡片已存在绑定的设备ID: {target_device_id}")
else:
# 卡片不存在,创建新卡片并绑定到当前设备
new_card = await card_service.activate_card(card_uuid, device_id)
session_logger.info(device_id, "card", f"新卡片{card_uuid}已创建并激活,绑定到设备: {device_id}")
await websocket.send_text(
f"TARGET_DEVICE_REGISTERED_URL:http://{settings.server_host}:{settings.server_port}/assets/audio/error_card.mp3"
)
session_logger.warning(device_id, "card", f"未知卡片不能自动绑定: {card_uuid}")
return
# 设置目标设备

View File

@@ -341,6 +341,8 @@ CREATE TABLE IF NOT EXISTS `device_bind_sessions` (
`max_attempt_count` TINYINT UNSIGNED NOT NULL DEFAULT 5,
`attempt_count` TINYINT UNSIGNED NOT NULL DEFAULT 0,
`status` TINYINT NOT NULL DEFAULT 1,
`bind_mode` TINYINT NOT NULL DEFAULT 1 COMMENT '1=device bind, 2=additional card bind',
`card_uuid` VARCHAR(64) NULL,
`confirmed_at` DATETIME NULL,
`consumed_at` DATETIME NULL,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
@@ -351,6 +353,7 @@ CREATE TABLE IF NOT EXISTS `device_bind_sessions` (
KEY `idx_device_bind_sessions_initiator_user_id` (`initiator_user_id`),
KEY `idx_device_bind_sessions_target_child_id` (`target_child_id`),
KEY `idx_device_bind_sessions_expires_at` (`expires_at`),
KEY `idx_device_bind_sessions_card_uuid` (`card_uuid`),
CONSTRAINT `fk_device_bind_sessions_device`
FOREIGN KEY (`device_id`)
REFERENCES `device_auth` (`device_id`),
@@ -407,7 +410,7 @@ CREATE TABLE IF NOT EXISTS `cards` (
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`card_id`),
UNIQUE KEY `uq_cards_card_uuid` (`card_uuid`),
UNIQUE KEY `uq_cards_device_id` (`device_id`),
KEY `idx_cards_device_id` (`device_id`),
KEY `idx_cards_status` (`status`),
CONSTRAINT `fk_cards_device`
FOREIGN KEY (`device_id`)

View File

@@ -60,32 +60,6 @@ class CardService(DatabaseServiceBase):
session_logger.error("card", "service", f"load card failed: {exc}")
return None
async def _clear_existing_device_card(
self,
device_id: str,
card_uuid: str,
async_session: AsyncSession,
) -> None:
query = select(DBCard).where(
DBCard.device_id == device_id,
DBCard.card_uuid != card_uuid,
)
result = await async_session.execute(query)
existing_cards = result.scalars().all()
for db_card in existing_cards:
db_card.device_id = None
db_card.status = 0
async with self.lock:
cached = self.cards.get(db_card.card_uuid)
if cached is not None:
cached.device_id = None
cached.status = 0
if existing_cards:
await async_session.flush()
async def _save_card_to_db(self, card: Card, async_session: AsyncSession, commit: bool = True) -> None:
try:
query = select(DBCard).where(DBCard.card_uuid == card.card_uuid)
@@ -182,9 +156,10 @@ class CardService(DatabaseServiceBase):
try:
existing_card = await self.get_card_by_uuid(card_uuid, db_session=db_session)
await self._clear_existing_device_card(device_id=device_id, card_uuid=card_uuid, async_session=db_session)
if existing_card:
if existing_card.device_id and existing_card.device_id != device_id:
raise ValueError("card is already bound to another device")
existing_card.device_id = device_id
existing_card.card_name = card_name
existing_card.status = 1
@@ -230,5 +205,9 @@ class CardService(DatabaseServiceBase):
card = await self.get_card_by_uuid(card_uuid)
return bool(card and card.device_id == device_id)
async def is_active_for_device(self, card_uuid: str, device_id: str) -> bool:
card = await self.get_card_by_uuid(card_uuid)
return bool(card and card.device_id == device_id and int(card.status) == 1)
card_service = CardService()

View File

@@ -2,8 +2,8 @@ from datetime import datetime, timedelta
import pytest
from banban.dao.binding import SESSION_STATUS_COMPLETED, SESSION_STATUS_EXPIRED
from banban.service.binding import BindingService
from banban.dao.binding import BIND_MODE_CARD, BIND_MODE_DEVICE, SESSION_STATUS_COMPLETED, SESSION_STATUS_EXPIRED
from banban.service.binding import BindingError, BindingService
class FakeSession:
@@ -22,6 +22,80 @@ class FakeSession:
self.closed = True
@pytest.mark.asyncio
async def test_start_card_bind_requires_existing_authorized_binding_and_sends_nfc_command(monkeypatch):
service = BindingService()
db_session = FakeSession()
started_binds = []
sent_commands = []
async def fake_get_session():
return db_session
async def fake_get_by_device(self, device_id, user_id):
assert device_id == "TalkingQ_device001"
assert user_id == 99
return {
"device_id": "TalkingQ_device001",
"child_id": 7,
"owner_user_id": 99,
}
async def fake_start_bind(self, user_id, device_id, child_id, bind_mode):
started_binds.append((user_id, device_id, child_id, bind_mode))
return "bind-token-card", datetime.utcnow() + timedelta(minutes=10)
async def fake_send_bind_nfc_command(device_id):
sent_commands.append(device_id)
monkeypatch.setattr(service, "get_session", fake_get_session)
monkeypatch.setattr("banban.service.binding.BindingDAO.get_by_device", fake_get_by_device)
monkeypatch.setattr("banban.service.binding.BindingDAO.start_bind", fake_start_bind)
monkeypatch.setattr(service, "_send_bind_nfc_command", fake_send_bind_nfc_command)
bind_token, expires_at = await service.start_card_bind(user_id=99, device_id="TalkingQ_device001")
assert bind_token == "bind-token-card"
assert expires_at > datetime.utcnow()
assert started_binds == [(99, "TalkingQ_device001", 7, BIND_MODE_CARD)]
assert sent_commands == ["TalkingQ_device001"]
assert db_session.commits == 1
assert db_session.closed is True
@pytest.mark.asyncio
async def test_start_card_bind_rejects_family_member_who_is_not_owner(monkeypatch):
service = BindingService()
db_session = FakeSession()
started_binds = []
async def fake_get_session():
return db_session
async def fake_get_by_device(self, device_id, user_id):
assert device_id == "TalkingQ_device001"
assert user_id == 88
return {
"device_id": "TalkingQ_device001",
"child_id": 7,
"owner_user_id": 99,
}
async def fake_start_bind(self, *args, **kwargs):
started_binds.append((args, kwargs))
monkeypatch.setattr(service, "get_session", fake_get_session)
monkeypatch.setattr("banban.service.binding.BindingDAO.get_by_device", fake_get_by_device)
monkeypatch.setattr("banban.service.binding.BindingDAO.start_bind", fake_start_bind)
with pytest.raises(BindingError, match="only the device owner can add cards"):
await service.start_card_bind(user_id=88, device_id="TalkingQ_device001")
assert started_binds == []
assert db_session.commits == 0
assert db_session.closed is True
@pytest.mark.asyncio
async def test_finalize_nfc_bind_expires_late_card_without_binding(monkeypatch):
service = BindingService()
@@ -92,6 +166,7 @@ async def test_finalize_nfc_bind_completes_before_expiry(monkeypatch):
"target_child_id": 7,
"initiator_user_id": 99,
"expires_at": datetime.utcnow() + timedelta(minutes=10),
"bind_mode": BIND_MODE_DEVICE,
}
marked_statuses = []
activated_cards = []
@@ -110,6 +185,11 @@ async def test_finalize_nfc_bind_completes_before_expiry(monkeypatch):
async def fake_activate_card(**kwargs):
activated_cards.append(kwargs)
class FakeCard:
card_uuid = kwargs["card_uuid"]
return FakeCard()
async def fake_complete_nfc_bind(self, **kwargs):
completed_sessions.append(kwargs)
@@ -148,8 +228,86 @@ async def test_finalize_nfc_bind_completes_before_expiry(monkeypatch):
"device_id": "TalkingQ_device001",
"child_id": 7,
"user_id": 99,
"card_uuid": "CARD_UUID_ON_TIME",
}
]
assert db_session.commits == 1
assert db_session.rollbacks == 0
assert db_session.closed is True
@pytest.mark.asyncio
async def test_finalize_nfc_bind_adds_card_without_rebinding_device(monkeypatch):
service = BindingService()
db_session = FakeSession()
pending_session = {
"id": 44,
"bind_token": "bind-token-card",
"device_id": "TalkingQ_device001",
"target_child_id": 7,
"initiator_user_id": 99,
"expires_at": datetime.utcnow() + timedelta(minutes=10),
"bind_mode": BIND_MODE_CARD,
}
activated_cards = []
completed_card_sessions = []
completed_device_sessions = []
async def fake_get_session():
return db_session
async def fake_get_latest_pending_session_by_device(self, device_id):
assert device_id == "TalkingQ_device001"
return pending_session
async def fake_mark_session_status(self, session_id, status):
raise AssertionError(f"unexpected status mark: {session_id}, {status}")
async def fake_activate_card(**kwargs):
activated_cards.append(kwargs)
class FakeCard:
card_uuid = kwargs["card_uuid"]
return FakeCard()
async def fake_complete_card_bind(self, **kwargs):
completed_card_sessions.append(kwargs)
async def fake_complete_nfc_bind(self, **kwargs):
completed_device_sessions.append(kwargs)
monkeypatch.setattr(service, "get_session", fake_get_session)
monkeypatch.setattr(
"banban.service.binding.BindingDAO.get_latest_pending_session_by_device",
fake_get_latest_pending_session_by_device,
)
monkeypatch.setattr("banban.service.binding.BindingDAO.mark_session_status", fake_mark_session_status)
monkeypatch.setattr("banban.service.binding.card_service.activate_card", fake_activate_card)
monkeypatch.setattr("banban.service.binding.BindingDAO.complete_card_bind", fake_complete_card_bind)
monkeypatch.setattr("banban.service.binding.BindingDAO.complete_nfc_bind", fake_complete_nfc_bind)
result = await service.finalize_nfc_bind(
device_id="TalkingQ_device001",
card_uuid="CARD_UUID_EXTRA",
)
assert result == {
"device_id": "TalkingQ_device001",
"bind_token": "bind-token-card",
"status": SESSION_STATUS_COMPLETED,
"child_id": 7,
"card_uuid": "CARD_UUID_EXTRA",
}
assert activated_cards == [
{
"card_uuid": "CARD_UUID_EXTRA",
"device_id": "TalkingQ_device001",
"db_session": db_session,
}
]
assert completed_card_sessions == [{"session_id": 44, "card_uuid": "CARD_UUID_EXTRA"}]
assert completed_device_sessions == []
assert db_session.commits == 1
assert db_session.rollbacks == 0
assert db_session.closed is True

View File

@@ -0,0 +1,108 @@
import pytest
from services.card_service import Card, CardService
class FakeSession:
pass
@pytest.mark.asyncio
async def test_activate_card_keeps_existing_device_cards(monkeypatch):
service = CardService()
existing = Card(
card_id=1,
card_uuid="CARD_A",
device_id="TalkingQ_device001",
status=1,
)
saved_cards = []
session = FakeSession()
async def fake_init_database():
return None
async def fake_get_card_by_uuid(card_uuid, force_refresh=False, db_session=None):
del force_refresh, db_session
return None
async def fake_save_card_to_db(card, async_session, commit=True):
del async_session, commit
saved_cards.append(card)
card.card_id = 2
monkeypatch.setattr(service, "_init_database", fake_init_database)
monkeypatch.setattr(service, "get_card_by_uuid", fake_get_card_by_uuid)
monkeypatch.setattr(service, "_save_card_to_db", fake_save_card_to_db)
new_card = await service.activate_card(
card_uuid="CARD_B",
device_id="TalkingQ_device001",
db_session=session,
)
assert existing.device_id == "TalkingQ_device001"
assert existing.status == 1
assert new_card.card_uuid == "CARD_B"
assert new_card.device_id == "TalkingQ_device001"
assert new_card.status == 1
assert saved_cards == [new_card]
@pytest.mark.asyncio
async def test_activate_card_rejects_card_bound_to_other_device(monkeypatch):
service = CardService()
existing = Card(
card_id=1,
card_uuid="CARD_A",
device_id="TalkingQ_device002",
status=1,
)
saved_cards = []
session = FakeSession()
async def fake_init_database():
return None
async def fake_get_card_by_uuid(card_uuid, force_refresh=False, db_session=None):
del force_refresh, db_session
return existing if card_uuid == "CARD_A" else None
async def fake_save_card_to_db(card, async_session, commit=True):
del async_session, commit
saved_cards.append(card)
monkeypatch.setattr(service, "_init_database", fake_init_database)
monkeypatch.setattr(service, "get_card_by_uuid", fake_get_card_by_uuid)
monkeypatch.setattr(service, "_save_card_to_db", fake_save_card_to_db)
with pytest.raises(ValueError, match="card is already bound to another device"):
await service.activate_card(
card_uuid="CARD_A",
device_id="TalkingQ_device001",
db_session=session,
)
assert existing.device_id == "TalkingQ_device002"
assert saved_cards == []
@pytest.mark.asyncio
async def test_is_active_for_device_requires_matching_active_card(monkeypatch):
service = CardService()
active_card = Card(card_uuid="CARD_A", device_id="TalkingQ_device001", status=1)
inactive_card = Card(card_uuid="CARD_B", device_id="TalkingQ_device001", status=0)
async def fake_get_card_by_uuid(card_uuid):
return {
"CARD_A": active_card,
"CARD_B": inactive_card,
"CARD_C": Card(card_uuid="CARD_C", device_id="TalkingQ_device002", status=1),
}.get(card_uuid)
monkeypatch.setattr(service, "get_card_by_uuid", fake_get_card_by_uuid)
assert await service.is_active_for_device("CARD_A", "TalkingQ_device001") is True
assert await service.is_active_for_device("CARD_B", "TalkingQ_device001") is False
assert await service.is_active_for_device("CARD_C", "TalkingQ_device001") is False
assert await service.is_active_for_device("CARD_MISSING", "TalkingQ_device001") is False

View File

@@ -0,0 +1,66 @@
import pytest
from database.init_db import _ensure_bind_session_card_columns, _ensure_cards_allow_multiple_per_device
class FakeScalarResult:
def __init__(self, value):
self.value = value
def scalar(self):
return self.value
class FakeConnection:
def __init__(self):
self.stat_counts = {
"idx_device_bind_sessions_card_uuid": 0,
"idx_cards_device_id": 0,
"uq_cards_device_id": 1,
}
self.column_counts = {
"bind_mode": 0,
"card_uuid": 0,
}
self.sql = []
async def execute(self, statement, params=None):
sql = str(statement)
self.sql.append(sql)
if "INFORMATION_SCHEMA.COLUMNS" in sql:
return FakeScalarResult(self.column_counts.get(params["column_name"], 0))
if "INFORMATION_SCHEMA.STATISTICS" in sql:
for index_name, count in self.stat_counts.items():
if f"INDEX_NAME = '{index_name}'" in sql:
return FakeScalarResult(count)
return FakeScalarResult(0)
return FakeScalarResult(0)
@pytest.mark.asyncio
async def test_ensure_bind_session_card_columns_adds_missing_columns_and_index():
conn = FakeConnection()
await _ensure_bind_session_card_columns(conn)
executed = "\n".join(conn.sql)
assert "ALTER TABLE device_bind_sessions ADD COLUMN bind_mode TINYINT NOT NULL DEFAULT 1 AFTER status" in executed
assert "ALTER TABLE device_bind_sessions ADD COLUMN card_uuid VARCHAR(64) NULL AFTER bind_mode" in executed
assert "ALTER TABLE device_bind_sessions ADD INDEX idx_device_bind_sessions_card_uuid (card_uuid)" in executed
@pytest.mark.asyncio
async def test_ensure_cards_allow_multiple_per_device_replaces_unique_device_index():
conn = FakeConnection()
await _ensure_cards_allow_multiple_per_device(conn)
executed = "\n".join(conn.sql)
assert "ALTER TABLE cards ADD INDEX idx_cards_device_id (device_id)" in executed
assert "ALTER TABLE cards DROP INDEX uq_cards_device_id" in executed
assert executed.index("ALTER TABLE cards ADD INDEX idx_cards_device_id") < executed.index(
"ALTER TABLE cards DROP INDEX uq_cards_device_id"
)