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

463 lines
16 KiB
Python

import logging
import uuid
from collections.abc import Mapping
from datetime import datetime, timedelta
from typing import Optional
from banban.dao import BaseDAO
logger = logging.getLogger("banban.dao.binding")
SESSION_STATUS_PENDING = 1
SESSION_STATUS_COMPLETED = 2
SESSION_STATUS_EXPIRED = 3
SESSION_STATUS_FAILED = 4
SESSION_STATUS_CANCELLED = 5
BIND_SOURCE_SESSION_CONFIRM = 1
BIND_SOURCE_DIRECT = 2
BIND_SOURCE_SET_CHILD = 3
BIND_SOURCE_NFC = 4
class BindingDAO(BaseDAO):
async def get_device_auth(self, device_id: str) -> Optional[Mapping]:
return (
await self.execute(
"""
SELECT device_id, serial_number, is_active
FROM device_auth
WHERE device_id = :device_id
LIMIT 1
""",
{"device_id": device_id},
)
).mappings().first()
async def get_active_binding_by_device(self, device_id: str) -> Optional[Mapping]:
return (
await self.execute(
"""
SELECT id, device_id, owner_user_id, child_id, status
FROM device_bindings
WHERE device_id = :device_id
AND status = 1
LIMIT 1
""",
{"device_id": device_id},
)
).mappings().first()
async def _clear_child_from_binding(self, binding_row: Mapping[str, object]) -> None:
row_id = int(binding_row["id"])
status = int(binding_row["status"])
if status == 1:
await self.execute(
"""
UPDATE device_bindings
SET child_id = NULL,
status = 1,
unbound_at = NULL,
updated_at = CURRENT_TIMESTAMP
WHERE id = :id
""",
{"id": row_id},
)
return
await self.execute(
"""
UPDATE device_bindings
SET child_id = NULL,
updated_at = CURRENT_TIMESTAMP
WHERE id = :id
""",
{"id": row_id},
)
async def _upsert_parent_child_relation(self, user_id: int, child_id: int) -> None:
await self.execute(
"""
INSERT INTO parent_child_relations (user_id, child_id, relation_type, is_primary, status)
VALUES (:user_id, :child_id, 9, 0, 1)
ON DUPLICATE KEY UPDATE
status = VALUES(status),
updated_at = CURRENT_TIMESTAMP
""",
{"user_id": user_id, "child_id": child_id},
)
async def _insert_bind_history(self, device_id: str, child_id: Optional[int], user_id: int, bind_source: int) -> None:
await self.execute(
"""
INSERT INTO device_bind_history (device_id, child_id, bound_by_user_id, bind_source, bound_at)
VALUES (:device_id, :child_id, :user_id, :bind_source, CURRENT_TIMESTAMP)
""",
{
"device_id": device_id,
"child_id": child_id,
"user_id": user_id,
"bind_source": bind_source,
},
)
async def _bind_device(self, device_id: str, user_id: int, child_id: Optional[int]) -> None:
existing_by_device = (
await self.execute(
"SELECT id, status FROM device_bindings WHERE device_id = :device_id",
{"device_id": device_id},
)
).mappings().first()
if child_id is None:
if existing_by_device:
await self.execute(
"""
UPDATE device_bindings
SET owner_user_id = :owner_user_id,
child_id = NULL,
status = 1,
unbound_at = NULL,
updated_at = CURRENT_TIMESTAMP
WHERE device_id = :device_id
""",
{"owner_user_id": user_id, "device_id": device_id},
)
else:
await self.execute(
"""
INSERT INTO device_bindings (device_id, owner_user_id, child_id, status, bound_at)
VALUES (:device_id, :owner_user_id, NULL, 1, CURRENT_TIMESTAMP)
""",
{"device_id": device_id, "owner_user_id": user_id},
)
return
existing_by_child = (
await self.execute(
"SELECT id, status FROM device_bindings WHERE child_id = :child_id",
{"child_id": child_id},
)
).mappings().first()
if existing_by_device:
device_row_id = int(existing_by_device["id"])
if existing_by_child and int(existing_by_child["id"]) != device_row_id:
await self._clear_child_from_binding(existing_by_child)
await self.execute(
"""
UPDATE device_bindings
SET owner_user_id = :owner_user_id,
child_id = :child_id,
status = 1,
unbound_at = NULL,
updated_at = CURRENT_TIMESTAMP
WHERE device_id = :device_id
""",
{"owner_user_id": user_id, "child_id": child_id, "device_id": device_id},
)
return
if existing_by_child:
await self._clear_child_from_binding(existing_by_child)
await self.execute(
"""
INSERT INTO device_bindings (device_id, owner_user_id, child_id, status, bound_at)
VALUES (:device_id, :owner_user_id, :child_id, 1, CURRENT_TIMESTAMP)
""",
{"device_id": device_id, "owner_user_id": user_id, "child_id": child_id},
)
async def start_bind(self, user_id: int, device_id: str, child_id: Optional[int]) -> tuple[str, datetime]:
bind_token = str(uuid.uuid4())
expires_at = datetime.utcnow() + timedelta(minutes=10)
await self.execute(
"""
UPDATE device_bind_sessions
SET status = :cancelled_status,
consumed_at = COALESCE(consumed_at, CURRENT_TIMESTAMP),
updated_at = CURRENT_TIMESTAMP
WHERE device_id = :device_id
AND status = :pending_status
""",
{
"device_id": device_id,
"pending_status": SESSION_STATUS_PENDING,
"cancelled_status": SESSION_STATUS_CANCELLED,
},
)
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)
""",
{
"bind_token": bind_token,
"device_id": device_id,
"initiator_user_id": user_id,
"target_child_id": child_id,
"expires_at": expires_at,
"status": SESSION_STATUS_PENDING,
},
)
return bind_token, expires_at
async def get_session(self, bind_token: str, user_id: int) -> Optional[Mapping]:
return (
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
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
""",
{
"bind_token": bind_token,
"user_id": user_id,
"completed_status": SESSION_STATUS_COMPLETED,
},
)
).mappings().first()
async def get_latest_pending_session_by_device(self, device_id: str) -> Optional[Mapping]:
return (
await self.execute(
"""
SELECT *
FROM device_bind_sessions
WHERE device_id = :device_id
AND status = :status
ORDER BY id DESC
LIMIT 1
""",
{"device_id": device_id, "status": SESSION_STATUS_PENDING},
)
).mappings().first()
async def mark_session_status(self, session_id: int, status: int) -> None:
await self.execute(
"""
UPDATE device_bind_sessions
SET status = :status,
consumed_at = COALESCE(consumed_at, CURRENT_TIMESTAMP),
updated_at = CURRENT_TIMESTAMP
WHERE id = :id
""",
{"id": session_id, "status": status},
)
async def confirm_bind(self, session_id: int, 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)
await self.execute(
"""
UPDATE device_bind_sessions
SET status = :status,
confirmed_at = CURRENT_TIMESTAMP,
consumed_at = CURRENT_TIMESTAMP,
updated_at = CURRENT_TIMESTAMP
WHERE id = :id
""",
{"id": session_id, "status": SESSION_STATUS_COMPLETED},
)
await self._bind_device(device_id=device_id, user_id=user_id, child_id=child_id)
await self._insert_bind_history(
device_id=device_id,
child_id=child_id,
user_id=user_id,
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:
if child_id is not None:
await self._upsert_parent_child_relation(user_id=user_id, child_id=child_id)
await self.execute(
"""
UPDATE device_bind_sessions
SET status = :status,
confirmed_at = CURRENT_TIMESTAMP,
consumed_at = CURRENT_TIMESTAMP,
updated_at = CURRENT_TIMESTAMP
WHERE id = :id
""",
{"id": session_id, "status": SESSION_STATUS_COMPLETED},
)
await self._bind_device(device_id=device_id, user_id=user_id, child_id=child_id)
await self._insert_bind_history(
device_id=device_id,
child_id=child_id,
user_id=user_id,
bind_source=BIND_SOURCE_NFC,
)
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)
await self._bind_device(device_id=device_id, user_id=user_id, child_id=child_id)
await self._insert_bind_history(
device_id=device_id,
child_id=child_id,
user_id=user_id,
bind_source=BIND_SOURCE_DIRECT,
)
await self.commit()
async def get_current_by_user(self, user_id: int) -> Optional[Mapping]:
return (
await self.execute(
"""
SELECT *
FROM device_bindings
WHERE owner_user_id = :user_id
AND status = 1
ORDER BY bound_at DESC
LIMIT 1
""",
{"user_id": user_id},
)
).mappings().first()
async def list_by_user(self, user_id: int, limit: int = 20, cursor: int = None) -> list[Mapping]:
params = {"user_id": user_id, "limit": limit + 1}
where = "db.owner_user_id = :user_id AND db.status = 1"
if cursor is not None:
where += " AND db.id < :cursor"
params["cursor"] = cursor
rows = (
await self.execute(
f"""
SELECT
db.id,
db.device_id,
db.child_id,
c.child_name,
db.status,
db.bound_at
FROM device_bindings AS db
LEFT JOIN children AS c
ON c.child_id = db.child_id
AND c.status = 1
WHERE {where}
ORDER BY db.id DESC
LIMIT :limit
""",
params,
)
).mappings().all()
return rows
async def get_by_device(self, device_id: str, user_id: int) -> Optional[Mapping]:
return (
await self.execute(
"""
SELECT *
FROM device_bindings
WHERE device_id = :device_id
AND owner_user_id = :user_id
AND status = 1
""",
{"device_id": device_id, "user_id": user_id},
)
).mappings().first()
async def set_binding_child(self, device_id: str, child_id: int, user_id: int) -> bool:
row = await self.get_by_device(device_id=device_id, user_id=user_id)
if not row:
return False
if row["child_id"] is not None:
return False
await self._upsert_parent_child_relation(user_id=user_id, child_id=child_id)
await self._bind_device(device_id=device_id, user_id=user_id, child_id=child_id)
await self._insert_bind_history(
device_id=device_id,
child_id=child_id,
user_id=user_id,
bind_source=BIND_SOURCE_SET_CHILD,
)
await self.commit()
return True
async def unbind(self, device_id: str, user_id: int) -> bool:
row = await self.get_by_device(device_id, user_id)
if not row:
return False
await self.execute(
"UPDATE device_bindings SET status = 0, unbound_at = CURRENT_TIMESTAMP WHERE id = :id",
{"id": row["id"]},
)
await self.execute(
"""
INSERT INTO device_bind_history (
device_id,
child_id,
bound_by_user_id,
unbound_by_user_id,
bind_source,
bound_at,
unbound_at,
unbind_reason
)
SELECT
device_id,
child_id,
bound_by_user_id,
:user_id,
bind_source,
bound_at,
CURRENT_TIMESTAMP,
'user_unbind'
FROM device_bind_history
WHERE device_id = :device_id
AND unbound_at IS NULL
""",
{"device_id": device_id, "user_id": user_id},
)
await self.commit()
return True
async def list_history(self, device_id: str, limit: int = 20, cursor: datetime = None) -> list[Mapping]:
params = {"device_id": device_id, "limit": limit + 1}
where = "device_id = :device_id"
if cursor:
where += " AND bound_at < :cursor"
params["cursor"] = cursor
rows = (
await self.execute(
f"""
SELECT *
FROM device_bind_history
WHERE {where}
ORDER BY bound_at DESC
LIMIT :limit
""",
params,
)
).mappings().all()
return rows