From 1b4c47f77a4063bf87ee4b22fd2a222f292b10e1 Mon Sep 17 00:00:00 2001 From: ChengCan <783785929@qq.com> Date: Tue, 14 Apr 2026 12:47:52 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=94=AF=E6=8C=81=E5=85=88=E7=BB=91?= =?UTF-8?q?=E5=AE=9A=E8=AE=BE=E5=A4=87=E5=90=8E=E5=85=B3=E8=81=94=E5=84=BF?= =?UTF-8?q?=E7=AB=A5=E6=A1=A3=E6=A1=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 绑定模型新增 owner_user_id,支持 child_id 为空\n- 新增绑定后设置儿童接口 /bindings/{device_id}/child\n- 前端绑定页改为先绑设备后补充儿童\n- 补充数据库迁移脚本与 DAO 测试用例 --- banban-mini/src/pages/bind/index.tsx | 237 ++++++++------ banban-mini/src/pages/device/index.tsx | 9 + banban-mini/src/pages/sleep/index.tsx | 4 +- banban-mini/src/services/binding.ts | 25 +- mini-program/app/dao/binding.py | 301 ++++++++++++------ mini-program/app/models/entities.py | 51 +-- mini-program/app/routers/bindings.py | 41 ++- mini-program/app/service/binding.py | 12 +- mini-program/database/sql/003_new_tables.sql | 9 +- ...4_device_bindings_owner_user_migration.sql | 46 +++ mini-program/tests/test_dao.py | 210 +++++++++++- 11 files changed, 699 insertions(+), 246 deletions(-) create mode 100644 mini-program/database/sql/004_device_bindings_owner_user_migration.sql diff --git a/banban-mini/src/pages/bind/index.tsx b/banban-mini/src/pages/bind/index.tsx index 287b0bb..3720e28 100644 --- a/banban-mini/src/pages/bind/index.tsx +++ b/banban-mini/src/pages/bind/index.tsx @@ -1,8 +1,8 @@ -import { View, Text, Button, Image, Input } from '@tarojs/components' +import { View, Text, Image, Input } from '@tarojs/components' import { useState, useEffect } from 'react' import Taro from '@tarojs/taro' import { getToken } from '@/services/api' -import { getCurrentBinding, directBind } from '@/services/binding' +import { getCurrentBinding, directBind, setBindingChild } from '@/services/binding' import { getChildren, createChild } from '@/services/child' import './index.scss' @@ -17,11 +17,23 @@ export default function Bind() { const [children, setChildren] = useState([]) const [newChildName, setNewChildName] = useState('') const [saving, setSaving] = useState(false) + const [pendingDeviceId, setPendingDeviceId] = useState(null) useEffect(() => { checkAuth() }, []) + const goDevicePage = () => { + setTimeout(() => { + Taro.reLaunch({ url: '/pages/device/index' }) + }, 500) + } + + const completeChildLink = async (deviceId: string, childId: number) => { + await setBindingChild(deviceId, { child_id: childId }) + setPendingDeviceId(null) + } + const checkAuth = async () => { const token = getToken() if (!token) { @@ -31,42 +43,76 @@ export default function Bind() { try { const childRes = await getChildren() - setChildren(childRes.items || []) - - if (childRes.items?.length === 0) { - setLoading(false) - return - } - } catch (err) { - console.error('Get children failed:', err) - } + const currentChildren = childRes.items || [] + setChildren(currentChildren) - try { const binding = await getCurrentBinding() if (binding) { - Taro.showToast({ title: '已有设备绑定', icon: 'none' }) - setTimeout(() => { - Taro.reLaunch({ url: '/pages/device/index' }) - }, 1000) - return + if (binding.child_id) { + Taro.showToast({ title: 'Already bound', icon: 'none' }) + goDevicePage() + return + } + + setPendingDeviceId(binding.device_id) + if (currentChildren.length > 0) { + await completeChildLink(binding.device_id, currentChildren[0].child_id) + Taro.showToast({ title: 'Binding completed', icon: 'success' }) + goDevicePage() + return + } } } catch (err) { + console.error('Load bind page data failed:', err) + } finally { + setLoading(false) + } + } + + const doBind = async (deviceId: string) => { + Taro.showLoading({ title: 'Binding...' }) + + try { + await directBind({ device_id: deviceId }) + + if (children.length > 0) { + await completeChildLink(deviceId, children[0].child_id) + Taro.hideLoading() + Taro.showToast({ + title: 'Bind success', + icon: 'success', + duration: 1200, + }) + goDevicePage() + return + } + + setPendingDeviceId(deviceId) + Taro.hideLoading() + Taro.showToast({ title: 'Device bound, add child next', icon: 'none' }) + } catch (err: any) { + Taro.hideLoading() + Taro.showToast({ title: err.message || 'Bind failed', icon: 'none' }) } - setLoading(false) } const handleScanCode = () => { + if (pendingDeviceId) { + Taro.showToast({ title: 'Complete child setup first', icon: 'none' }) + return + } + setIsScanning(true) - + Taro.scanCode({ onlyFromCamera: true, scanType: ['qrCode', 'barCode'], success: async (res) => { setIsScanning(false) const deviceId = res.result - + if (!deviceId) { - Taro.showToast({ title: '无效的设备码', icon: 'none' }) + Taro.showToast({ title: 'Invalid device code', icon: 'none' }) return } @@ -77,98 +123,79 @@ export default function Bind() { if (err.errMsg && err.errMsg.includes('cancel')) { return } - Taro.showToast({ title: '扫码失败', icon: 'none' }) - } + Taro.showToast({ title: 'Scan failed', icon: 'none' }) + }, }) } - const doBind = async (deviceId: string) => { - if (children.length === 0) { - Taro.showToast({ title: '请先添加儿童', icon: 'none' }) + const handleManualInput = () => { + if (pendingDeviceId) { + Taro.showToast({ title: 'Complete child setup first', icon: 'none' }) return } - Taro.showLoading({ title: '绑定中...' }) - - try { - await directBind({ - device_id: deviceId, - child_id: children[0].child_id - }) - Taro.hideLoading() - Taro.showToast({ - title: '设备绑定成功', - icon: 'success', - duration: 1500, - complete: () => { - setTimeout(() => { - Taro.reLaunch({ url: '/pages/device/index' }) - }, 500) - } - }) - } catch (err: any) { - Taro.hideLoading() - Taro.showToast({ title: err.message || '绑定失败', icon: 'none' }) - } - } - - const handleManualInput = () => { Taro.showModal({ - title: '手动输入设备码', + title: 'Manual input', editable: true, - placeholderText: '请输入设备底部的设备码', + placeholderText: 'Enter device code', success: (res) => { if (res.confirm && res.content) { doBind(res.content) } - } + }, }) } const handleAddChild = async () => { if (!newChildName.trim()) { - Taro.showToast({ title: '请输入儿童姓名', icon: 'none' }) + Taro.showToast({ title: 'Please enter child name', icon: 'none' }) return } + setSaving(true) try { - await createChild({ child_name: newChildName.trim() }) - Taro.showToast({ title: '添加成功', icon: 'success' }) + const child = await createChild({ child_name: newChildName.trim() }) + setNewChildName('') + + if (pendingDeviceId) { + await completeChildLink(pendingDeviceId, child.child_id) + Taro.showToast({ title: 'Child linked', icon: 'success' }) + goDevicePage() + return + } + const childRes = await getChildren() setChildren(childRes.items || []) + Taro.showToast({ title: 'Child created', icon: 'success' }) } catch (err) { - Taro.showToast({ title: '添加失败', icon: 'none' }) + Taro.showToast({ title: 'Create child failed', icon: 'none' }) } finally { setSaving(false) } } + const handleLogout = () => { + Taro.showModal({ + title: 'Logout', + content: 'Clear login info and go to login page?', + confirmColor: '#FF8C42', + success: (res) => { + if (!res.confirm) return + Taro.removeStorageSync('token') + Taro.removeStorageSync('user_id') + Taro.removeStorageSync('expires_in') + Taro.removeStorageSync('userInfo') + Taro.removeStorageSync('hasDevice') + Taro.removeStorageSync('deviceInfo') + Taro.reLaunch({ url: '/pages/login/index' }) + }, + }) + } + if (loading) { return ( - 加载中... - - ) - } - - if (children.length === 0) { - return ( - - - 请先添加儿童 - 绑定设备前需要先添加儿童信息 - - setNewChildName(e.detail.value)} - /> - - - {saving ? '保存中...' : '确认添加'} - - + Loading... ) } @@ -176,8 +203,8 @@ export default function Bind() { return ( - 绑定设备 - 请扫描设备底部的二维码进行绑定 + Bind Device + Scan QR/device code to bind first, then set child profile. @@ -189,8 +216,8 @@ export default function Bind() { mode='aspectFit' /> - {isScanning ? '正在扫码...' : '点击扫码'} - 请将二维码放入框内 + {isScanning ? 'Scanning...' : 'Tap to scan'} + Place QR/barcode in frame @@ -203,27 +230,51 @@ export default function Bind() { - ⌨️ - 手动输入设备码 - + # + Manual device code input + {'>'} + {pendingDeviceId && ( + + Device bound: {pendingDeviceId} + Add child profile to complete setup + + setNewChildName(e.detail.value)} + /> + + + {saving ? 'Saving...' : 'Create and link child'} + + + )} + - 绑定帮助 + Binding Tips 1 - 找到设备底部的二维码或设备码 + Find QR code or device code on device. 2 - 点击上方扫码区域进行扫描 + Bind device first by scan/manual input. 3 - 等待绑定成功提示 + Create or select child profile to finish setup. + + + + + + Logout ) -} \ No newline at end of file +} diff --git a/banban-mini/src/pages/device/index.tsx b/banban-mini/src/pages/device/index.tsx index 210aff6..e4bff30 100644 --- a/banban-mini/src/pages/device/index.tsx +++ b/banban-mini/src/pages/device/index.tsx @@ -40,6 +40,15 @@ export default function Device() { console.log('[Device] Got binding:', binding) if (binding) { + if (!binding.child_id) { + setHasDevice(false) + Taro.showToast({ title: 'Please link child profile first', icon: 'none' }) + setTimeout(() => { + Taro.redirectTo({ url: '/pages/bind/index' }) + }, 600) + return + } + setHasDevice(true) Taro.setStorageSync('hasDevice', '1') Taro.setStorageSync('deviceInfo', { diff --git a/banban-mini/src/pages/sleep/index.tsx b/banban-mini/src/pages/sleep/index.tsx index 0fdeab9..e93c8f3 100644 --- a/banban-mini/src/pages/sleep/index.tsx +++ b/banban-mini/src/pages/sleep/index.tsx @@ -15,7 +15,7 @@ interface Child { interface Binding { device_id: string - child_id: number + child_id: number | null status: number } @@ -285,4 +285,4 @@ export default function Sleep() { )} ) -} \ No newline at end of file +} diff --git a/banban-mini/src/services/binding.ts b/banban-mini/src/services/binding.ts index ee81cd8..79270dc 100644 --- a/banban-mini/src/services/binding.ts +++ b/banban-mini/src/services/binding.ts @@ -2,7 +2,7 @@ import { request } from './api' export interface Binding { device_id: string - child_id: number + child_id: number | null status: number bound_at: string } @@ -14,7 +14,7 @@ export interface BindStartResponse { export interface BindHistoryItem { device_id: string - child_id: number + child_id: number | null bound_at: string unbound_at?: string } @@ -40,7 +40,7 @@ export async function getCurrentBinding(): Promise { // 开始绑定设备 export async function startBind(data: { device_id: string - child_id: number + child_id?: number }): Promise { return request('/bindings/start', { method: 'POST', @@ -52,7 +52,7 @@ export async function startBind(data: { export async function confirmBind(data: { bind_token: string challenge_code: string -}): Promise<{ device_id: string; child_id: number }> { +}): Promise<{ device_id: string; child_id: number | null }> { return request('/bindings/confirm', { method: 'POST', data, @@ -83,14 +83,25 @@ export async function unbindDevice(deviceId: string): Promise { // 直接绑定设备(手动输入设备码) export async function directBind(data: { device_id: string - child_id: number -}): Promise<{ device_id: string; child_id: number }> { + child_id?: number +}): Promise<{ device_id: string; child_id: number | null }> { return request('/bindings/direct', { method: 'POST', data, }) } +// Bind child profile after device has been bound to parent account. +export async function setBindingChild( + deviceId: string, + data: { child_id: number } +): Promise<{ device_id: string; child_id: number | null }> { + return request(`/bindings/${deviceId}/child`, { + method: 'PATCH', + data, + }) +} + // 获取绑定历史 export async function getBindHistory( deviceId: string, @@ -102,4 +113,4 @@ export async function getBindHistory( params.append('limit', String(limit)) return request(`/bindings/history/${deviceId}?${params.toString()}`) -} \ No newline at end of file +} diff --git a/mini-program/app/dao/binding.py b/mini-program/app/dao/binding.py index ce099d8..87bfa00 100644 --- a/mini-program/app/dao/binding.py +++ b/mini-program/app/dao/binding.py @@ -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 \ No newline at end of file + return rows diff --git a/mini-program/app/models/entities.py b/mini-program/app/models/entities.py index 600d72e..08a0233 100644 --- a/mini-program/app/models/entities.py +++ b/mini-program/app/models/entities.py @@ -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) \ No newline at end of file + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) diff --git a/mini-program/app/routers/bindings.py b/mini-program/app/routers/bindings.py index 28fcb19..8fffd5a 100644 --- a/mini-program/app/routers/bindings.py +++ b/mini-program/app/routers/bindings.py @@ -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) \ No newline at end of file + return BindHistoryResponse(items=items, total=len(items), next_cursor=next_cursor) diff --git a/mini-program/app/service/binding.py b/mini-program/app/service/binding.py index f8ee472..abe1611 100644 --- a/mini-program/app/service/binding.py +++ b/mini-program/app/service/binding.py @@ -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 \ No newline at end of file + return rows, has_more diff --git a/mini-program/database/sql/003_new_tables.sql b/mini-program/database/sql/003_new_tables.sql index ef29f1d..6981c73 100644 --- a/mini-program/database/sql/003_new_tables.sql +++ b/mini-program/database/sql/003_new_tables.sql @@ -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); \ No newline at end of file +CREATE INDEX IF NOT EXISTS idx_child_loc_hist_child ON child_location_history(child_id, created_at); diff --git a/mini-program/database/sql/004_device_bindings_owner_user_migration.sql b/mini-program/database/sql/004_device_bindings_owner_user_migration.sql new file mode 100644 index 0000000..47ead87 --- /dev/null +++ b/mini-program/database/sql/004_device_bindings_owner_user_migration.sql @@ -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; diff --git a/mini-program/tests/test_dao.py b/mini-program/tests/test_dao.py index 969693d..e659b77 100644 --- a/mini-program/tests/test_dao.py +++ b/mini-program/tests/test_dao.py @@ -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!") \ No newline at end of file + 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!")