Files
banban/mini-program/app/dao/binding.py
ChengCan 1b4c47f77a feat: 支持先绑定设备后关联儿童档案
- 绑定模型新增 owner_user_id,支持 child_id 为空\n- 新增绑定后设置儿童接口 /bindings/{device_id}/child\n- 前端绑定页改为先绑设备后补充儿童\n- 补充数据库迁移脚本与 DAO 测试用例
2026-04-14 12:47:52 +08:00

326 lines
12 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 _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 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 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:
# Release child ownership from a different row before assigning to this device.
self.db.execute(
text(
"""
UPDATE device_bindings
SET child_id = NULL,
status = 0,
unbound_at = CURRENT_TIMESTAMP,
updated_at = CURRENT_TIMESTAMP
WHERE id = :id
"""
),
{"id": existing_by_child["id"]},
)
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:
self.db.execute(
text(
"""
UPDATE device_bindings
SET device_id = :device_id,
owner_user_id = :owner_user_id,
status = 1,
unbound_at = NULL,
updated_at = CURRENT_TIMESTAMP
WHERE id = :id
"""
),
{"device_id": device_id, "owner_user_id": user_id, "id": existing_by_child["id"]},
)
return
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 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