feat: 支持先绑定设备后关联儿童档案

- 绑定模型新增 owner_user_id,支持 child_id 为空\n- 新增绑定后设置儿童接口 /bindings/{device_id}/child\n- 前端绑定页改为先绑设备后补充儿童\n- 补充数据库迁移脚本与 DAO 测试用例
This commit is contained in:
ChengCan
2026-04-14 12:47:52 +08:00
parent a22fcafea9
commit 1b4c47f77a
11 changed files with 699 additions and 246 deletions

View File

@@ -5,6 +5,7 @@ from datetime import datetime, timedelta
from typing import Optional
from sqlalchemy import text
from sqlalchemy.exc import IntegrityError
from app.dao import BaseDAO
@@ -12,7 +13,172 @@ logger = logging.getLogger("app.dao.binding")
class BindingDAO(BaseDAO):
def start_bind(self, user_id: int, device_id: str, child_id: int) -> str:
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)
@@ -46,106 +212,25 @@ class BindingDAO(BaseDAO):
.first()
)
def confirm_bind(self, session_id: int, device_id: str, child_id: int, user_id: int) -> None:
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},
)
existing = self.db.execute(
text("SELECT id FROM device_bindings WHERE device_id = :device_id OR child_id = :child_id"),
{"device_id": device_id, "child_id": child_id},
).scalar_one_or_none()
if existing:
self.db.execute(
text(
"UPDATE device_bindings SET child_id = :child_id, status = 1, unbound_at = NULL WHERE device_id = :device_id"
),
{"device_id": device_id, "child_id": child_id},
)
else:
self.db.execute(
text(
"INSERT INTO device_bindings (device_id, child_id, status, bound_at) VALUES (:device_id, :child_id, 1, CURRENT_TIMESTAMP)"
),
{"device_id": device_id, "child_id": child_id},
)
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, 1, CURRENT_TIMESTAMP)"
),
{"device_id": device_id, "child_id": child_id, "user_id": user_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: int, user_id: int) -> None:
# Check if binding exists for this device_id
existing_by_device = (
self.db.execute(
text("SELECT id FROM device_bindings WHERE device_id = :device_id"),
{"device_id": device_id},
).fetchone()
)
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)
# Check if this child is already bound to a different device
existing_by_child = (
self.db.execute(
text("SELECT id FROM device_bindings WHERE child_id = :child_id AND status = 1"),
{"child_id": child_id},
).fetchone()
)
if existing_by_device:
# Update existing binding for this device
self.db.execute(
text(
"UPDATE device_bindings SET child_id = :child_id, status = 1, unbound_at = NULL WHERE device_id = :device_id"
),
{"device_id": device_id, "child_id": child_id},
)
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, 2, CURRENT_TIMESTAMP)"
),
{"device_id": device_id, "child_id": child_id, "user_id": user_id},
)
self.commit()
return
elif existing_by_child:
# Child already bound to another device - update that binding
self.db.execute(
text(
"UPDATE device_bindings SET device_id = :device_id, status = 1, unbound_at = NULL WHERE child_id = :child_id AND status = 1"
),
{"device_id": device_id, "child_id": child_id},
)
# Skip INSERT since we updated
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, 2, CURRENT_TIMESTAMP)"
),
{"device_id": device_id, "child_id": child_id, "user_id": user_id},
)
self.commit()
return
else:
# Insert new binding
self.db.execute(
text(
"INSERT INTO device_bindings (device_id, child_id, status, bound_at) VALUES (:device_id, :child_id, 1, CURRENT_TIMESTAMP)"
),
{"device_id": device_id, "child_id": child_id},
)
# Add history
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, 2, CURRENT_TIMESTAMP)"
),
{"device_id": device_id, "child_id": child_id, "user_id": user_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]:
@@ -153,10 +238,11 @@ class BindingDAO(BaseDAO):
self.db.execute(
text(
"""
SELECT b.* FROM device_bindings b
JOIN children c ON b.child_id = c.child_id
WHERE c.parent_user_id = :user_id AND b.status = 1
ORDER BY b.bound_at DESC
SELECT *
FROM device_bindings
WHERE owner_user_id = :user_id
AND status = 1
ORDER BY bound_at DESC
LIMIT 1
"""
),
@@ -171,9 +257,11 @@ class BindingDAO(BaseDAO):
self.db.execute(
text(
"""
SELECT b.* FROM device_bindings b
JOIN children c ON b.child_id = c.child_id
WHERE b.device_id = :device_id AND c.parent_user_id = :user_id AND b.status = 1
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},
@@ -182,6 +270,17 @@ class BindingDAO(BaseDAO):
.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:
@@ -223,4 +322,4 @@ class BindingDAO(BaseDAO):
.mappings()
.all()
)
return rows
return rows