Files
banban/mini-program/app/dao/binding.py

384 lines
13 KiB
Python

import logging
import uuid
from collections.abc import Mapping
from datetime import datetime, timedelta
from typing import Optional
from sqlalchemy import text
from sqlalchemy.exc import IntegrityError
from app.dao import BaseDAO
logger = logging.getLogger("app.dao.binding")
class BindingDAO(BaseDAO):
def get_device_auth(self, device_id: str) -> Optional[Mapping]:
return (
self.db.execute(
text(
"""
SELECT device_id, serial_number, is_active
FROM device_auth
WHERE device_id = :device_id
LIMIT 1
"""
),
{"device_id": device_id},
)
.mappings()
.first()
)
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:
self.db.execute(
text(
"""
UPDATE device_bindings
SET child_id = NULL,
status = 1,
unbound_at = NULL,
updated_at = CURRENT_TIMESTAMP
WHERE id = :id
"""
),
{"id": row_id},
)
return
self.db.execute(
text(
"""
UPDATE device_bindings
SET child_id = NULL,
updated_at = CURRENT_TIMESTAMP
WHERE id = :id
"""
),
{"id": row_id},
)
def _upsert_parent_child_relation(self, user_id: int, child_id: int) -> None:
updated = self.db.execute(
text(
"""
UPDATE parent_child_relations
SET status = 1,
updated_at = CURRENT_TIMESTAMP
WHERE user_id = :user_id
AND child_id = :child_id
"""
),
{"user_id": user_id, "child_id": child_id},
)
if updated.rowcount and updated.rowcount > 0:
return
try:
self.db.execute(
text(
"""
INSERT INTO parent_child_relations (user_id, child_id, relation_type, is_primary, status)
VALUES (:user_id, :child_id, 9, 0, 1)
"""
),
{"user_id": user_id, "child_id": child_id},
)
except IntegrityError:
# Handle race: another transaction inserted the same (user_id, child_id) row.
self.db.execute(
text(
"""
UPDATE parent_child_relations
SET status = 1,
updated_at = CURRENT_TIMESTAMP
WHERE user_id = :user_id
AND child_id = :child_id
"""
),
{"user_id": user_id, "child_id": child_id},
)
def _insert_bind_history(self, device_id: str, child_id: Optional[int], user_id: int, bind_source: int) -> None:
self.db.execute(
text(
"""
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,
},
)
def _bind_device(self, device_id: str, user_id: int, child_id: Optional[int]) -> None:
existing_by_device = (
self.db.execute(
text("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:
self.db.execute(
text(
"""
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:
self.db.execute(
text(
"""
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 = (
self.db.execute(
text("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:
# Keep the old device row visible; only release its child assignment.
self._clear_child_from_binding(existing_by_child)
self.db.execute(
text(
"""
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:
# Do not overwrite the previous device row; keep it as an unbound device.
self._clear_child_from_binding(existing_by_child)
self.db.execute(
text(
"""
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},
)
def start_bind(self, user_id: int, device_id: str, child_id: Optional[int]) -> str:
bind_token = str(uuid.uuid4())
expires_at = datetime.utcnow() + timedelta(minutes=10)
self.db.execute(
text(
"""
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, 1)
"""
),
{
"bind_token": bind_token,
"device_id": device_id,
"initiator_user_id": user_id,
"target_child_id": child_id,
"expires_at": expires_at,
},
)
self.commit()
return bind_token
def get_session(self, bind_token: str, user_id: int) -> Optional[Mapping]:
return (
self.db.execute(
text(
"SELECT * FROM device_bind_sessions WHERE bind_token = :bind_token AND initiator_user_id = :user_id"
),
{"bind_token": bind_token, "user_id": user_id},
)
.mappings()
.first()
)
def confirm_bind(self, session_id: int, device_id: str, child_id: Optional[int], user_id: int) -> None:
if child_id is not None:
self._upsert_parent_child_relation(user_id=user_id, child_id=child_id)
self.db.execute(
text("UPDATE device_bind_sessions SET status = 2, confirmed_at = CURRENT_TIMESTAMP WHERE id = :id"),
{"id": session_id},
)
self._bind_device(device_id=device_id, user_id=user_id, child_id=child_id)
self._insert_bind_history(device_id=device_id, child_id=child_id, user_id=user_id, bind_source=1)
self.commit()
def direct_bind(self, device_id: str, child_id: Optional[int], user_id: int) -> None:
if child_id is not None:
self._upsert_parent_child_relation(user_id=user_id, child_id=child_id)
self._bind_device(device_id=device_id, user_id=user_id, child_id=child_id)
self._insert_bind_history(device_id=device_id, child_id=child_id, user_id=user_id, bind_source=2)
self.commit()
def get_current_by_user(self, user_id: int) -> Optional[Mapping]:
return (
self.db.execute(
text(
"""
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()
)
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 = (
self.db.execute(
text(
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
def get_by_device(self, device_id: str, user_id: int) -> Optional[Mapping]:
return (
self.db.execute(
text(
"""
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()
)
def set_binding_child(self, device_id: str, child_id: int, user_id: int) -> bool:
row = self.get_by_device(device_id=device_id, user_id=user_id)
if not row:
return False
self._upsert_parent_child_relation(user_id=user_id, child_id=child_id)
self._bind_device(device_id=device_id, user_id=user_id, child_id=child_id)
self._insert_bind_history(device_id=device_id, child_id=child_id, user_id=user_id, bind_source=3)
self.commit()
return True
def unbind(self, device_id: str, user_id: int) -> bool:
row = self.get_by_device(device_id, user_id)
if not row:
return False
self.db.execute(
text("UPDATE device_bindings SET status = 0, unbound_at = CURRENT_TIMESTAMP WHERE id = :id"),
{"id": row["id"]},
)
self.db.execute(
text(
"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},
)
self.commit()
return True
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 = (
self.db.execute(
text(
f"""
SELECT * FROM device_bind_history
WHERE {where}
ORDER BY bound_at DESC
LIMIT :limit
"""
),
params,
)
.mappings()
.all()
)
return rows