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

View File

@@ -15,6 +15,7 @@ from sqlalchemy import (
Text,
Time,
UniqueConstraint,
text,
)
from sqlalchemy.orm import Mapped, mapped_column
@@ -31,24 +32,20 @@ class Parent(Base):
avatar_url: Mapped[Optional[str]] = mapped_column(String(255))
phone: Mapped[Optional[str]] = mapped_column(String(20))
status: Mapped[int] = mapped_column(Integer, server_default="1")
created_at: Mapped[Optional[datetime]] = mapped_column(DateTime, server_default="CURRENT_TIMESTAMP")
updated_at: Mapped[Optional[datetime]] = mapped_column(DateTime, server_default="CURRENT_TIMESTAMP")
created_at: Mapped[Optional[datetime]] = mapped_column(DateTime, server_default=text("CURRENT_TIMESTAMP"))
updated_at: Mapped[Optional[datetime]] = mapped_column(DateTime, server_default=text("CURRENT_TIMESTAMP"))
class Child(Base):
__tablename__ = "children"
__table_args__ = (
Index("idx_child_parent_user_id", "parent_user_id"),
)
child_id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
parent_user_id: Mapped[int] = mapped_column(Integer, nullable=False)
child_name: Mapped[str] = mapped_column(String(32), nullable=False)
child_gender: Mapped[int] = mapped_column(Integer, server_default="2")
child_birthday: Mapped[Optional[date]] = mapped_column(Date)
status: Mapped[int] = mapped_column(Integer, server_default="1")
created_at: Mapped[Optional[datetime]] = mapped_column(DateTime, server_default="CURRENT_TIMESTAMP")
updated_at: Mapped[Optional[datetime]] = mapped_column(DateTime, server_default="CURRENT_TIMESTAMP")
created_at: Mapped[Optional[datetime]] = mapped_column(DateTime, server_default=text("CURRENT_TIMESTAMP"))
updated_at: Mapped[Optional[datetime]] = mapped_column(DateTime, server_default=text("CURRENT_TIMESTAMP"))
class ParentChildRelation(Base):
@@ -65,24 +62,27 @@ class ParentChildRelation(Base):
relation_type: Mapped[int] = mapped_column(Integer, server_default="9")
is_primary: Mapped[bool] = mapped_column(Boolean, server_default="0")
status: Mapped[int] = mapped_column(Integer, server_default="1")
created_at: Mapped[Optional[datetime]] = mapped_column(DateTime, server_default="CURRENT_TIMESTAMP")
updated_at: Mapped[Optional[datetime]] = mapped_column(DateTime, server_default="CURRENT_TIMESTAMP")
created_at: Mapped[Optional[datetime]] = mapped_column(DateTime, server_default=text("CURRENT_TIMESTAMP"))
updated_at: Mapped[Optional[datetime]] = mapped_column(DateTime, server_default=text("CURRENT_TIMESTAMP"))
class DeviceBinding(Base):
__tablename__ = "device_bindings"
__table_args__ = (
UniqueConstraint("device_id", name="uq_device_binding_device"),
UniqueConstraint("child_id", name="uq_device_binding_child"),
Index("idx_device_bindings_owner_user_id", "owner_user_id"),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
device_id: Mapped[str] = mapped_column(String(64), nullable=False, unique=True)
child_id: Mapped[int] = mapped_column(Integer, nullable=False)
owner_user_id: Mapped[int] = mapped_column(Integer, nullable=False)
child_id: Mapped[Optional[int]] = mapped_column(Integer)
status: Mapped[int] = mapped_column(Integer, server_default="1")
bound_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
unbound_at: Mapped[Optional[datetime]] = mapped_column(DateTime)
created_at: Mapped[Optional[datetime]] = mapped_column(DateTime, server_default="CURRENT_TIMESTAMP")
updated_at: Mapped[Optional[datetime]] = mapped_column(DateTime, server_default="CURRENT_TIMESTAMP")
created_at: Mapped[Optional[datetime]] = mapped_column(DateTime, server_default=text("CURRENT_TIMESTAMP"))
updated_at: Mapped[Optional[datetime]] = mapped_column(DateTime, server_default=text("CURRENT_TIMESTAMP"))
class DeviceBindSession(Base):
@@ -101,8 +101,8 @@ class DeviceBindSession(Base):
status: Mapped[int] = mapped_column(Integer, server_default="1")
confirmed_at: Mapped[Optional[datetime]] = mapped_column(DateTime)
consumed_at: Mapped[Optional[datetime]] = mapped_column(DateTime)
created_at: Mapped[Optional[datetime]] = mapped_column(DateTime, server_default="CURRENT_TIMESTAMP")
updated_at: Mapped[Optional[datetime]] = mapped_column(DateTime, server_default="CURRENT_TIMESTAMP")
created_at: Mapped[Optional[datetime]] = mapped_column(DateTime, server_default=text("CURRENT_TIMESTAMP"))
updated_at: Mapped[Optional[datetime]] = mapped_column(DateTime, server_default=text("CURRENT_TIMESTAMP"))
class DeviceBindHistory(Base):
@@ -110,14 +110,14 @@ class DeviceBindHistory(Base):
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
device_id: Mapped[str] = mapped_column(String(64), nullable=False)
child_id: Mapped[int] = mapped_column(Integer, nullable=False)
child_id: Mapped[Optional[int]] = mapped_column(Integer)
bound_by_user_id: Mapped[int] = mapped_column(Integer, nullable=False)
unbound_by_user_id: Mapped[Optional[int]] = mapped_column(Integer)
bind_source: Mapped[int] = mapped_column(Integer, server_default="1")
bound_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
unbound_at: Mapped[Optional[datetime]] = mapped_column(DateTime)
unbind_reason: Mapped[Optional[str]] = mapped_column(String(191))
created_at: Mapped[Optional[datetime]] = mapped_column(DateTime, server_default="CURRENT_TIMESTAMP")
created_at: Mapped[Optional[datetime]] = mapped_column(DateTime, server_default=text("CURRENT_TIMESTAMP"))
class Card(Base):
@@ -129,8 +129,8 @@ class Card(Base):
card_name: Mapped[Optional[str]] = mapped_column(String(64))
status: Mapped[int] = mapped_column(Integer, server_default="0")
total_swaps: Mapped[int] = mapped_column(Integer, server_default="0")
created_at: Mapped[Optional[datetime]] = mapped_column(DateTime, server_default="CURRENT_TIMESTAMP")
updated_at: Mapped[Optional[datetime]] = mapped_column(DateTime, server_default="CURRENT_TIMESTAMP")
created_at: Mapped[Optional[datetime]] = mapped_column(DateTime, server_default=text("CURRENT_TIMESTAMP"))
updated_at: Mapped[Optional[datetime]] = mapped_column(DateTime, server_default=text("CURRENT_TIMESTAMP"))
class DeviceSetting(Base):
@@ -141,14 +141,15 @@ class DeviceSetting(Base):
sleep_mode: Mapped[int] = mapped_column(Integer, server_default="0")
disable_time_start: Mapped[Optional[time]] = mapped_column(Time)
disable_time_end: Mapped[Optional[time]] = mapped_column(Time)
timezone: Mapped[str] = mapped_column(String(32), server_default="'Asia/Shanghai'")
timezone: Mapped[str] = mapped_column(String(32), server_default=text("'Asia/Shanghai'"))
volume: Mapped[Optional[int]] = mapped_column(Integer)
brightness: Mapped[Optional[int]] = mapped_column(Integer)
disable_weekdays: Mapped[Optional[str]] = mapped_column(String(32))
created_at: Mapped[Optional[datetime]] = mapped_column(DateTime, server_default="CURRENT_TIMESTAMP")
updated_at: Mapped[Optional[datetime]] = mapped_column(DateTime, server_default="CURRENT_TIMESTAMP")
updated_at: Mapped[datetime] = mapped_column(
DateTime, default=datetime.utcnow, onupdate=datetime.utcnow
created_at: Mapped[Optional[datetime]] = mapped_column(DateTime, server_default=text("CURRENT_TIMESTAMP"))
updated_at: Mapped[Optional[datetime]] = mapped_column(
DateTime,
server_default=text("CURRENT_TIMESTAMP"),
onupdate=datetime.utcnow,
)
@@ -255,4 +256,4 @@ class ChildLocationHistory(Base):
battery_pct: Mapped[Optional[int]] = mapped_column(Integer)
device_time: Mapped[datetime] = mapped_column(DateTime, nullable=False)
server_time: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)

View File

@@ -1,4 +1,5 @@
import logging
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, Request, status
from pydantic import BaseModel
@@ -19,7 +20,7 @@ logger = logging.getLogger("app.bindings")
class BindStartRequest(BaseModel):
device_id: str
child_id: int
child_id: int | None = None
class BindStartResponse(BaseModel):
@@ -34,21 +35,21 @@ class BindConfirmRequest(BaseModel):
class BindConfirmResponse(BaseModel):
device_id: str
child_id: int
child_id: int | None
class BindingGetResponse(BaseModel):
device_id: str
child_id: int
child_id: int | None
status: int
bound_at: str
bound_at: datetime
class BindHistoryItem(BaseModel):
device_id: str
child_id: int
bound_at: str
unbound_at: str | None
child_id: int | None
bound_at: datetime
unbound_at: datetime | None
class BindHistoryResponse(BaseModel):
@@ -86,11 +87,15 @@ def confirm_bind(
class DirectBindRequest(BaseModel):
device_id: str
child_id: int
child_id: int | None = None
class DirectBindResponse(BaseModel):
device_id: str
child_id: int | None
class BindSetChildRequest(BaseModel):
child_id: int
@@ -106,6 +111,22 @@ def direct_bind(
return DirectBindResponse(**result)
@router.patch("/{device_id}/child", response_model=DirectBindResponse)
def set_binding_child(
device_id: str,
payload: BindSetChildRequest,
request: Request,
current_user_id: int = Depends(get_current_user_id),
db=Depends(get_db_session),
) -> DirectBindResponse:
service = BindingService(db)
try:
result = service.set_binding_child(device_id=device_id, child_id=payload.child_id, user_id=current_user_id)
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
return DirectBindResponse(**result)
@router.get("/current", response_model=BindingGetResponse)
def get_current_binding(
request: Request,
@@ -154,11 +175,9 @@ def get_bind_history(
current_user_id: int = Depends(get_current_user_id),
db=Depends(get_db_session),
) -> BindHistoryResponse:
from datetime import datetime
cursor_dt = datetime.fromisoformat(cursor) if cursor else None
service = BindingService(db)
rows, has_more = service.list_history(device_id, limit, cursor_dt)
next_cursor = rows[-1]["bound_at"].isoformat() if has_more and rows else None
items = [BindHistoryItem(**row) for row in rows]
return BindHistoryResponse(items=items, total=len(items), next_cursor=next_cursor)
return BindHistoryResponse(items=items, total=len(items), next_cursor=next_cursor)

View File

@@ -9,7 +9,7 @@ class BindingService:
def __init__(self, db):
self.dao = BindingDAO(db)
def start_bind(self, user_id: int, device_id: str, child_id: int) -> tuple[str, datetime]:
def start_bind(self, user_id: int, device_id: str, child_id: int | None = None) -> tuple[str, datetime]:
bind_token = self.dao.start_bind(user_id, device_id, child_id)
return bind_token, datetime.utcnow()
@@ -31,10 +31,16 @@ class BindingService:
def get_current_binding(self, user_id: int) -> Optional[Mapping]:
return self.dao.get_current_by_user(user_id)
def direct_bind(self, device_id: str, child_id: int, user_id: int) -> Mapping:
def direct_bind(self, device_id: str, child_id: int | None, user_id: int) -> Mapping:
self.dao.direct_bind(device_id, child_id, user_id)
return {"device_id": device_id, "child_id": child_id}
def set_binding_child(self, device_id: str, child_id: int, user_id: int) -> Mapping:
ok = self.dao.set_binding_child(device_id=device_id, child_id=child_id, user_id=user_id)
if not ok:
raise ValueError("binding not found")
return {"device_id": device_id, "child_id": child_id}
def unbind(self, device_id: str, user_id: int) -> bool:
return self.dao.unbind(device_id, user_id)
@@ -42,4 +48,4 @@ class BindingService:
rows = self.dao.list_history(device_id, limit, cursor)
has_more = len(rows) > limit
rows = rows[:limit]
return rows, has_more
return rows, has_more

View File

@@ -48,7 +48,8 @@ CREATE TABLE IF NOT EXISTS parent_child_relations (
CREATE TABLE IF NOT EXISTS device_bindings (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
device_id VARCHAR(64) NOT NULL,
child_id BIGINT NOT NULL,
owner_user_id BIGINT NOT NULL,
child_id BIGINT,
status TINYINT NOT NULL DEFAULT 1,
bound_at DATETIME NOT NULL,
unbound_at DATETIME,
@@ -58,6 +59,8 @@ CREATE TABLE IF NOT EXISTS device_bindings (
UNIQUE (child_id)
);
CREATE INDEX IF NOT EXISTS idx_device_bindings_owner_user_id ON device_bindings(owner_user_id);
-- device_bind_sessions table
CREATE TABLE IF NOT EXISTS device_bind_sessions (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
@@ -81,7 +84,7 @@ CREATE TABLE IF NOT EXISTS device_bind_sessions (
CREATE TABLE IF NOT EXISTS device_bind_history (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
device_id VARCHAR(64) NOT NULL,
child_id BIGINT NOT NULL,
child_id BIGINT,
bound_by_user_id BIGINT NOT NULL,
unbound_by_user_id BIGINT,
bind_source TINYINT NOT NULL DEFAULT 1,
@@ -211,4 +214,4 @@ CREATE TABLE IF NOT EXISTS child_location_history (
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_child_loc_hist_child ON child_location_history(child_id, created_at);
CREATE INDEX IF NOT EXISTS idx_child_loc_hist_child ON child_location_history(child_id, created_at);

View File

@@ -0,0 +1,46 @@
-- Migration: support "bind device first, then set child profile"
-- Target: MySQL 8.x
-- 1) Add owner_user_id on device_bindings.
ALTER TABLE device_bindings
ADD COLUMN owner_user_id BIGINT NULL AFTER device_id;
-- 2) Backfill owner_user_id from active parent-child relation (prefer primary relation).
UPDATE device_bindings b
SET owner_user_id = (
SELECT r.user_id
FROM parent_child_relations r
WHERE r.child_id = b.child_id
AND r.status = 1
ORDER BY r.is_primary DESC, r.id ASC
LIMIT 1
)
WHERE b.owner_user_id IS NULL;
-- 3) Fallback backfill from latest bind history.
UPDATE device_bindings b
SET owner_user_id = (
SELECT h.bound_by_user_id
FROM device_bind_history h
WHERE h.device_id = b.device_id
ORDER BY h.bound_at DESC, h.id DESC
LIMIT 1
)
WHERE b.owner_user_id IS NULL;
-- 4) Ensure no NULL owner before setting NOT NULL.
-- If this query returns rows, handle manually before continuing:
-- SELECT id, device_id, child_id FROM device_bindings WHERE owner_user_id IS NULL;
-- 5) Make schema changes for new flow.
ALTER TABLE device_bindings
MODIFY COLUMN owner_user_id BIGINT NOT NULL;
ALTER TABLE device_bindings
MODIFY COLUMN child_id BIGINT NULL;
ALTER TABLE device_bindings
ADD INDEX idx_device_bindings_owner_user_id (owner_user_id);
ALTER TABLE device_bind_history
MODIFY COLUMN child_id BIGINT NULL;

View File

@@ -77,9 +77,217 @@ def test_binding_dao():
assert len(token) == 36
def test_confirm_bind_upserts_parent_child_relation():
"""Confirm bind should create parent-child relation when missing."""
from sqlalchemy import create_engine, text
from sqlalchemy.orm import sessionmaker
from app.models import Base
from app.dao.binding import BindingDAO
engine = create_engine("sqlite:///:memory:", echo=False)
Base.metadata.create_all(bind=engine)
Session = sessionmaker(bind=engine)
db = Session()
db.execute(text("INSERT INTO parents (openid, status) VALUES ('p_bind_confirm', 1)"))
db.execute(text("INSERT INTO children (child_name, child_gender, status) VALUES ('Kid Confirm', 2, 1)"))
db.commit()
user_id = int(db.execute(text("SELECT user_id FROM parents WHERE openid = 'p_bind_confirm'")).scalar_one())
child_id = int(
db.execute(text("SELECT child_id FROM children WHERE child_name = 'Kid Confirm'")).scalar_one()
)
dao = BindingDAO(db)
token = dao.start_bind(user_id, "device_confirm_001", child_id)
session = dao.get_session(token, user_id)
assert session is not None
dao.confirm_bind(int(session["id"]), "device_confirm_001", child_id, user_id)
relation = db.execute(
text(
"""
SELECT status
FROM parent_child_relations
WHERE user_id = :user_id AND child_id = :child_id
"""
),
{"user_id": user_id, "child_id": child_id},
).scalar_one_or_none()
assert relation == 1
def test_direct_bind_reactivates_parent_child_relation():
"""Direct bind should reactivate an existing disabled parent-child relation."""
from sqlalchemy import create_engine, text
from sqlalchemy.orm import sessionmaker
from app.models import Base
from app.dao.binding import BindingDAO
engine = create_engine("sqlite:///:memory:", echo=False)
Base.metadata.create_all(bind=engine)
Session = sessionmaker(bind=engine)
db = Session()
db.execute(text("INSERT INTO parents (openid, status) VALUES ('p_bind_direct', 1)"))
db.execute(text("INSERT INTO children (child_name, child_gender, status) VALUES ('Kid Direct', 2, 1)"))
db.commit()
user_id = int(db.execute(text("SELECT user_id FROM parents WHERE openid = 'p_bind_direct'")).scalar_one())
child_id = int(db.execute(text("SELECT child_id FROM children WHERE child_name = 'Kid Direct'")).scalar_one())
db.execute(
text(
"""
INSERT INTO parent_child_relations (user_id, child_id, relation_type, is_primary, status)
VALUES (:user_id, :child_id, 9, 0, 0)
"""
),
{"user_id": user_id, "child_id": child_id},
)
db.commit()
dao = BindingDAO(db)
dao.direct_bind("device_direct_001", child_id, user_id)
relation = db.execute(
text(
"""
SELECT status
FROM parent_child_relations
WHERE user_id = :user_id AND child_id = :child_id
"""
),
{"user_id": user_id, "child_id": child_id},
).scalar_one()
assert relation == 1
def test_direct_bind_without_child_then_set_child():
"""Direct bind should support empty child first and assign child later."""
from sqlalchemy import create_engine, text
from sqlalchemy.orm import sessionmaker
from app.models import Base
from app.dao.binding import BindingDAO
engine = create_engine("sqlite:///:memory:", echo=False)
Base.metadata.create_all(bind=engine)
Session = sessionmaker(bind=engine)
db = Session()
db.execute(text("INSERT INTO parents (openid, status) VALUES ('p_bind_later', 1)"))
db.execute(text("INSERT INTO children (child_name, child_gender, status) VALUES ('Kid Later', 2, 1)"))
db.commit()
user_id = int(db.execute(text("SELECT user_id FROM parents WHERE openid = 'p_bind_later'")).scalar_one())
child_id = int(db.execute(text("SELECT child_id FROM children WHERE child_name = 'Kid Later'")).scalar_one())
dao = BindingDAO(db)
dao.direct_bind("device_later_001", None, user_id)
current = dao.get_current_by_user(user_id)
assert current is not None
assert current["device_id"] == "device_later_001"
assert current["child_id"] is None
changed = dao.set_binding_child("device_later_001", child_id, user_id)
assert changed is True
current = dao.get_current_by_user(user_id)
assert current is not None
assert int(current["child_id"]) == child_id
relation = db.execute(
text(
"""
SELECT status
FROM parent_child_relations
WHERE user_id = :user_id AND child_id = :child_id
"""
),
{"user_id": user_id, "child_id": child_id},
).scalar_one()
assert relation == 1
def test_update_my_relation_switches_primary():
"""Relation update should switch primary parent on the same child."""
from sqlalchemy import create_engine, text
from sqlalchemy.orm import sessionmaker
from app.models import Base
from app.dao.relation import RelationDAO
engine = create_engine("sqlite:///:memory:", echo=False)
Base.metadata.create_all(bind=engine)
Session = sessionmaker(bind=engine)
db = Session()
db.execute(text("INSERT INTO parents (openid, status) VALUES ('p_relation_1', 1)"))
db.execute(text("INSERT INTO parents (openid, status) VALUES ('p_relation_2', 1)"))
db.execute(text("INSERT INTO children (child_name, child_gender, status) VALUES ('Kid Relation', 2, 1)"))
db.commit()
user_id_1 = int(db.execute(text("SELECT user_id FROM parents WHERE openid = 'p_relation_1'")).scalar_one())
user_id_2 = int(db.execute(text("SELECT user_id FROM parents WHERE openid = 'p_relation_2'")).scalar_one())
child_id = int(db.execute(text("SELECT child_id FROM children WHERE child_name = 'Kid Relation'")).scalar_one())
db.execute(
text(
"""
INSERT INTO parent_child_relations (user_id, child_id, relation_type, is_primary, status)
VALUES (:user_id, :child_id, :relation_type, :is_primary, 1)
"""
),
{"user_id": user_id_1, "child_id": child_id, "relation_type": 9, "is_primary": 0},
)
db.execute(
text(
"""
INSERT INTO parent_child_relations (user_id, child_id, relation_type, is_primary, status)
VALUES (:user_id, :child_id, :relation_type, :is_primary, 1)
"""
),
{"user_id": user_id_2, "child_id": child_id, "relation_type": 9, "is_primary": 1},
)
db.commit()
dao = RelationDAO(db)
updated = dao.update_my_relation(
child_id=child_id,
user_id=user_id_1,
relation_type=2,
is_primary=True,
)
assert updated is not None
assert int(updated["relation_type"]) == 2
assert int(updated["is_primary"]) == 1
assert int(updated["status"]) == 1
other_primary = int(
db.execute(
text(
"""
SELECT is_primary
FROM parent_child_relations
WHERE child_id = :child_id
AND user_id = :user_id
"""
),
{"child_id": child_id, "user_id": user_id_2},
).scalar_one()
)
assert other_primary == 0
if __name__ == "__main__":
test_sqlite_connection()
test_parent_dao()
test_child_dao()
test_binding_dao()
print("All DAO tests passed!")
test_confirm_bind_upserts_parent_child_relation()
test_direct_bind_reactivates_parent_child_relation()
test_direct_bind_without_child_then_set_child()
test_update_my_relation_switches_primary()
print("All DAO tests passed!")