Files
banban/talkingq-url/banban/service/binding.py
2026-05-05 16:33:21 +08:00

242 lines
9.6 KiB
Python

from collections.abc import Mapping
from datetime import datetime
from typing import Optional
from banban.dao.binding import (
SESSION_STATUS_CANCELLED,
SESSION_STATUS_COMPLETED,
SESSION_STATUS_EXPIRED,
SESSION_STATUS_FAILED,
SESSION_STATUS_PENDING,
BindingDAO,
)
from services.card_service import card_service
from services.database_service_base import DatabaseServiceBase
class BindingError(ValueError):
def __init__(self, message: str, status_code: int = 400) -> None:
super().__init__(message)
self.status_code = status_code
class BindingService(DatabaseServiceBase):
def __init__(self):
super().__init__(service_name="binding_service")
async def _ensure_device_unbound(self, db_session, device_id: str) -> None:
dao = BindingDAO(db_session)
active_binding = await dao.get_active_binding_by_device(device_id)
if active_binding is None:
return
raise BindingError("device is already bound, unbind it before binding again", status_code=409)
async def _ensure_bindable_device(self, db_session, device_id: str, serial_number: str) -> None:
dao = BindingDAO(db_session)
row = await dao.get_device_auth(device_id)
if row is None:
raise BindingError("device not found in device_auth", status_code=404)
if str(row["serial_number"]) != serial_number:
raise BindingError("serial_number does not match device_id", status_code=400)
if int(row["is_active"]) != 1:
raise BindingError("device is inactive", status_code=400)
def _normalize_session_status(self, session: Mapping) -> int:
status = int(session["status"])
if status == SESSION_STATUS_PENDING and datetime.utcnow() > session["expires_at"]:
return SESSION_STATUS_EXPIRED
return status
async def start_bind(
self,
user_id: int,
device_id: str,
serial_number: str,
child_id: int | None = None,
) -> tuple[str, datetime]:
db_session = await self.get_session()
try:
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)
await db_session.commit()
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)
return bind_token, expires_at
finally:
await db_session.close()
async def confirm_bind(self, bind_token: str, user_id: int) -> Mapping:
db_session = await self.get_session()
try:
dao = BindingDAO(db_session)
session = await dao.get_session(bind_token, user_id)
if not session:
raise ValueError("Bind session not found")
if datetime.utcnow() > session["expires_at"]:
raise ValueError("Bind session expired")
if int(session["status"]) != SESSION_STATUS_PENDING:
raise ValueError("Bind session already processed")
await dao.confirm_bind(session["id"], session["device_id"], session["target_child_id"], user_id)
await db_session.commit()
return {"device_id": session["device_id"], "child_id": session["target_child_id"]}
finally:
await db_session.close()
async def get_bind_session(self, bind_token: str, user_id: int) -> Optional[Mapping]:
db_session = await self.get_session()
try:
dao = BindingDAO(db_session)
session = await dao.get_session(bind_token, user_id)
if session is None:
return None
normalized_status = self._normalize_session_status(session)
if normalized_status == SESSION_STATUS_EXPIRED and int(session["status"]) != SESSION_STATUS_EXPIRED:
await dao.mark_session_status(int(session["id"]), SESSION_STATUS_EXPIRED)
await db_session.commit()
session = await dao.get_session(bind_token, user_id)
if session is None:
return None
normalized_status = SESSION_STATUS_EXPIRED
payload = dict(session)
payload["status"] = normalized_status
payload["card_uuid"] = payload.get("card_uuid")
return payload
finally:
await db_session.close()
async def finalize_nfc_bind(self, device_id: str, card_uuid: str) -> Optional[Mapping]:
db_session = await self.get_session()
try:
dao = BindingDAO(db_session)
session = await dao.get_latest_pending_session_by_device(device_id)
if not session:
await db_session.rollback()
return None
if datetime.utcnow() > session["expires_at"]:
await dao.mark_session_status(int(session["id"]), SESSION_STATUS_EXPIRED)
await db_session.commit()
return {
"device_id": device_id,
"bind_token": session["bind_token"],
"status": SESSION_STATUS_EXPIRED,
}
try:
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"]),
)
await db_session.commit()
except Exception:
await dao.mark_session_status(int(session["id"]), SESSION_STATUS_FAILED)
await db_session.commit()
raise
return {
"device_id": device_id,
"bind_token": session["bind_token"],
"status": SESSION_STATUS_COMPLETED,
"child_id": session["target_child_id"],
"card_uuid": card_uuid,
}
finally:
await db_session.close()
async def get_binding(self, device_id: str, user_id: int) -> Optional[Mapping]:
db_session = await self.get_session()
try:
dao = BindingDAO(db_session)
return await dao.get_by_device(device_id, user_id)
finally:
await db_session.close()
async def get_current_binding(self, user_id: int) -> Optional[Mapping]:
db_session = await self.get_session()
try:
dao = BindingDAO(db_session)
return await dao.get_current_by_user(user_id)
finally:
await db_session.close()
async def list_bindings(self, user_id: int, limit: int = 20, cursor: int = None) -> tuple[list, bool]:
db_session = await self.get_session()
try:
dao = BindingDAO(db_session)
rows = await dao.list_by_user(user_id, limit, cursor)
has_more = len(rows) > limit
rows = rows[:limit]
return rows, has_more
finally:
await db_session.close()
async def direct_bind(
self,
device_id: str,
serial_number: str,
child_id: int | None,
user_id: int,
) -> Mapping:
db_session = await self.get_session()
try:
await self._ensure_bindable_device(db_session, device_id, serial_number)
await self._ensure_device_unbound(db_session, device_id)
dao = BindingDAO(db_session)
await dao.direct_bind(device_id, child_id, user_id)
await db_session.commit()
return {"device_id": device_id, "child_id": child_id}
finally:
await db_session.close()
async def set_binding_child(self, device_id: str, child_id: int, user_id: int) -> Mapping:
db_session = await self.get_session()
try:
dao = BindingDAO(db_session)
ok = await dao.set_binding_child(device_id=device_id, child_id=child_id, user_id=user_id)
if not ok:
active_binding = await dao.get_active_binding_by_device(device_id)
if active_binding and active_binding["child_id"] is not None:
raise BindingError("device is already bound, unbind it before binding again", status_code=409)
raise ValueError("binding not found")
await db_session.commit()
return {"device_id": device_id, "child_id": child_id}
finally:
await db_session.close()
async def unbind(self, device_id: str, user_id: int) -> bool:
db_session = await self.get_session()
try:
dao = BindingDAO(db_session)
result = await dao.unbind(device_id, user_id)
await db_session.commit()
return result
finally:
await db_session.close()
async def list_history(self, device_id: str, limit: int = 20, cursor: datetime = None) -> tuple[list, bool]:
db_session = await self.get_session()
try:
dao = BindingDAO(db_session)
rows = await dao.list_history(device_id, limit, cursor)
has_more = len(rows) > limit
rows = rows[:limit]
return rows, has_more
finally:
await db_session.close()