From 9f43fcb5ae22d79af085a6015fde7fe35cfdce6c Mon Sep 17 00:00:00 2001 From: stu2not Date: Thu, 4 Jun 2026 09:55:35 +0800 Subject: [PATCH] =?UTF-8?q?=E6=94=AF=E6=8C=81=E5=B7=B2=E7=BB=91=E5=AE=9A?= =?UTF-8?q?=E8=AE=BE=E5=A4=87=E8=BF=BD=E5=8A=A0=20NFC=20=E5=8D=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- banban-mini/src/pages/bind/index.tsx | 96 ++++++++--- banban-mini/src/pages/sleep/index.tsx | 14 ++ banban-mini/src/services/binding.ts | 11 ++ database/talkingq_shared_schema.sql | 5 +- mini-program/app/models/entities.py | 4 +- mini-program/database/init.sql | 4 +- talkingq-url/banban/dao/binding.py | 74 ++++++-- talkingq-url/banban/routers/bindings.py | 22 +++ talkingq-url/banban/service/binding.py | 53 ++++-- talkingq-url/database/init_db.py | 57 ++++++ talkingq-url/database/models.py | 2 + talkingq-url/handlers/mqtt_handler.py | 13 +- .../handlers/websocket_message_handler.py | 9 +- talkingq-url/mysql/init/02-init.sql | 5 +- talkingq-url/services/card_service.py | 33 +--- .../tests/test_binding_session_expiry.py | 162 +++++++++++++++++- talkingq-url/tests/test_card_service.py | 108 ++++++++++++ talkingq-url/tests/test_db_init_cards.py | 66 +++++++ 18 files changed, 646 insertions(+), 92 deletions(-) create mode 100644 talkingq-url/tests/test_card_service.py create mode 100644 talkingq-url/tests/test_db_init_cards.py diff --git a/banban-mini/src/pages/bind/index.tsx b/banban-mini/src/pages/bind/index.tsx index b873772..294f666 100644 --- a/banban-mini/src/pages/bind/index.tsx +++ b/banban-mini/src/pages/bind/index.tsx @@ -8,6 +8,7 @@ import { resolveActiveBinding, setBindingChild, setSelectedBindingDeviceId, + startNFCCardBind, startNFCBind, } from '@/services/binding' import { @@ -154,6 +155,8 @@ function parseBindingPayload(rawValue: string): { deviceId: string; serialNumber export default function Bind() { const router = useRouter() const isAssignMode = String(router.params?.mode || '').trim() === 'assign' + const isCardMode = String(router.params?.mode || '').trim() === 'card' + const cardModeDeviceId = getRouteParam(router.params, 'device_id') const systemBanner = useSystemBanner() const [loading, setLoading] = useState(true) const [submitting, setSubmitting] = useState(false) @@ -192,10 +195,12 @@ export default function Bind() { const hasSelectedOrNewChild = selectedChildId !== null || Boolean(newChildName.trim()) const isPendingBinding = Boolean(pendingDeviceId) + const isAddingCard = isCardMode && Boolean(deviceId.trim()) const isPollingBind = bindStatus === SESSION_STATUS_PENDING && Boolean(bindToken) const isExpiredBind = bindStatus === SESSION_STATUS_EXPIRED const canEditDeviceFields = !isPendingBinding && !isPollingBind const canSubmit = isPendingBinding ? hasSelectedOrNewChild : isExpiredBind ? true : !isPollingBind + const pageTitle = isPendingBinding ? '补全绑定' : isAddingCard ? '添加设备卡' : '扫码贴卡绑定设备' const goDevicePage = () => { Taro.reLaunch({ url: '/pages/device/index' }) @@ -246,7 +251,7 @@ export default function Bind() { } const applyLaunchQrPayload = () => { - if (isAssignMode) return false + if (isAssignMode || isCardMode) return false const launchPayload = getLaunchQrPayload(router.params) if (!launchPayload || appliedLaunchQrRef.current === launchPayload) return false @@ -274,12 +279,14 @@ export default function Bind() { setCardUUID('') setBindExpiresAt('') setRemainingBindSeconds(0) - setDeviceId('') - setSerialNumber('') - setBindHint('贴卡时间已过,请重新扫码并贴卡') + if (!isCardMode) { + setDeviceId('') + setSerialNumber('') + } + setBindHint(isCardMode ? '贴卡时间已过,请重新发送加卡指令' : '贴卡时间已过,请重新扫码并贴卡') if (showToast) { - Taro.showToast({ title: '贴卡已超时,请重新扫码', icon: 'none' }) + Taro.showToast({ title: isCardMode ? '贴卡已超时,请重新发送' : '贴卡已超时,请重新扫码', icon: 'none' }) } } @@ -318,7 +325,7 @@ export default function Bind() { resetExpiredBindSession() return } - setBindHint('已发送绑卡指令,请在倒计时结束前去设备上贴自己的卡') + setBindHint(isAddingCard ? '已发送加卡指令,请在倒计时结束前去设备上贴新卡' : '已发送绑卡指令,请在倒计时结束前去设备上贴自己的卡') schedulePoll(activeBindToken) return } @@ -329,13 +336,15 @@ export default function Bind() { if (session.status === SESSION_STATUS_COMPLETED) { setSelectedBindingDeviceId(session.device_id) setBindHint( - session.child_id + isAddingCard + ? session.card_uuid ? `新卡添加完成,卡号 ${session.card_uuid}` : '新卡添加完成' + : session.child_id ? session.card_uuid ? `绑定完成,卡号 ${session.card_uuid}` : '绑定完成' : session.card_uuid ? `绑定完成,卡号 ${session.card_uuid},稍后可关联孩子` : '绑定完成,稍后可关联孩子' ) - Taro.showToast({ title: '设备绑定成功', icon: 'success' }) + Taro.showToast({ title: isAddingCard ? '新卡添加成功' : '设备绑定成功', icon: 'success' }) setTimeout(() => { - if (session.child_id) { + if (isAddingCard || session.child_id) { goDevicePage() } else { goManagePage() @@ -350,7 +359,7 @@ export default function Bind() { } if (session.status === SESSION_STATUS_FAILED) { - setBindHint('贴卡绑定失败,请重试') + setBindHint(isAddingCard ? '添加新卡失败,请确认卡未绑定到其他设备后重试' : '贴卡绑定失败,请重试') return } @@ -379,7 +388,16 @@ export default function Bind() { setChildren(currentChildren) setSelectedChildId(currentChild?.child_id || null) - if (isAssignMode && activeBinding?.device_id && !activeBinding.child_id) { + if (isCardMode) { + const targetDeviceId = cardModeDeviceId || activeBinding?.device_id || '' + if (!targetDeviceId) { + throw new Error('当前没有可加卡的设备') + } + setPendingDeviceId(null) + setDeviceId(targetDeviceId) + setSerialNumber('') + setBindHint('确认当前设备后发送加卡指令') + } else if (isAssignMode && activeBinding?.device_id && !activeBinding.child_id) { setPendingDeviceId(activeBinding.device_id) setDeviceId(activeBinding.device_id) } else { @@ -451,10 +469,10 @@ export default function Bind() { if (submitting || isPollingBind) return setSubmitting(true) - Taro.showLoading({ title: isPendingBinding ? '关联中...' : '发送绑卡指令...' }) + Taro.showLoading({ title: isPendingBinding ? '关联中...' : isAddingCard ? '发送加卡指令...' : '发送绑卡指令...' }) try { - const childId = await ensureChildId() + const childId = isAddingCard ? null : await ensureChildId() if (isPendingBinding) { if (!childId) { @@ -472,6 +490,19 @@ export default function Bind() { if (!deviceId.trim()) { throw new Error('请输入设备号') } + if (isAddingCard) { + resetBindSessionState() + const session = await startNFCCardBind({ device_id: deviceId.trim() }) + + setBindToken(session.bind_token) + setBindStatus(session.status) + setBindExpiresAt(session.expires_at) + setRemainingBindSeconds(getRemainingBindSeconds(session.expires_at)) + setBindHint('已发送加卡指令,请在倒计时结束前去设备上贴新卡') + schedulePoll(session.bind_token) + Taro.showToast({ title: '请去设备上贴新卡', icon: 'none' }) + return + } if (!serialNumber.trim()) { throw new Error('请输入设备序列号') } @@ -521,10 +552,19 @@ export default function Bind() { 返回 - {isPendingBinding ? '补全绑定' : '扫码贴卡绑定设备'} + {pageTitle} - {isPendingBinding ? ( + {isAddingCard ? ( + + + + + 给当前设备添加卡 + {deviceId} + 新卡添加后会和已有卡具备相同功能 + + ) : isPendingBinding ? ( @@ -555,6 +595,7 @@ export default function Bind() { )} + {!isAddingCard && ( 儿童资料 @@ -621,22 +662,24 @@ export default function Bind() { : '也可以先不创建儿童,直接绑定设备,稍后再关联孩子'} + )} {!isPendingBinding && ( - 设备信息 + {isAddingCard ? '加卡状态' : '设备信息'} 设备号 setDeviceId(event.detail.value)} /> + {!isAddingCard && ( 设备序列号 setSerialNumber(event.detail.value)} /> + )} {bindHint ? ( @@ -670,9 +714,15 @@ export default function Bind() { className='submit-btn' loading={submitting} disabled={submitting || !canSubmit} - onClick={isExpiredBind && !isPendingBinding ? handleScanCode : handleSubmit} + onClick={isExpiredBind && !isPendingBinding && !isAddingCard ? handleScanCode : handleSubmit} > - {isPendingBinding ? '完成儿童关联' : isPollingBind ? '等待贴卡确认' : isExpiredBind ? '重新扫码绑定' : '发送绑卡指令'} + {isPendingBinding + ? '完成儿童关联' + : isPollingBind + ? '等待贴卡确认' + : isExpiredBind + ? isAddingCard ? '重新发送加卡指令' : '重新扫码绑定' + : isAddingCard ? '发送加卡指令' : '发送绑卡指令'} @@ -680,15 +730,15 @@ export default function Bind() { 绑定帮助 1 - {isPendingBinding ? '先选择已有儿童,或者在本页新建一个儿童' : '儿童资料可选,不选择时设备会先进入待关联状态'} + {isAddingCard ? '确认当前设备号正确' : isPendingBinding ? '先选择已有儿童,或者在本页新建一个儿童' : '儿童资料可选,不选择时设备会先进入待关联状态'} 2 - {isPendingBinding ? '确认当前待关联设备,再选择要关联的孩子' : '扫描设备二维码,确认设备号和序列号正确'} + {isAddingCard ? '点击发送加卡指令后,在设备上贴新卡' : isPendingBinding ? '确认当前待关联设备,再选择要关联的孩子' : '扫描设备二维码,确认设备号和序列号正确'} 3 - {isPendingBinding ? '点击完成儿童关联,设备会出现在该孩子下面' : '点击发送绑卡指令,然后在 10 分钟内去设备上贴自己的卡完成确认'} + {isAddingCard ? '加卡成功后,新卡和原来的卡可以执行相同功能' : isPendingBinding ? '点击完成儿童关联,设备会出现在该孩子下面' : '点击发送绑卡指令,然后在 10 分钟内去设备上贴自己的卡完成确认'} {systemBanner} diff --git a/banban-mini/src/pages/sleep/index.tsx b/banban-mini/src/pages/sleep/index.tsx index be07c66..46d6929 100644 --- a/banban-mini/src/pages/sleep/index.tsx +++ b/banban-mini/src/pages/sleep/index.tsx @@ -614,6 +614,12 @@ export default function Sleep() { return } + if (item.name === '添加设备卡') { + if (!binding?.device_id) return + Taro.navigateTo({ url: `/pages/bind/index?mode=card&device_id=${encodeURIComponent(binding.device_id)}` }) + return + } + if (item.name === 'AI 角色') { handleOpenRoleModal() return @@ -713,6 +719,14 @@ export default function Sleep() { value: binding?.device_id ? `当前: ${binding.device_id}` : '未绑定', arrow: true, }, + { + icon: require('../../assets/tab-icons/rings.png'), + iconBgClass: 'green', + name: '添加设备卡', + value: binding?.device_id ? '给当前设备加卡' : '未绑定', + arrow: true, + disabled: !binding, + }, { icon: require('../../assets/tab-icons/orange-robot.png'), iconBgClass: 'green', diff --git a/banban-mini/src/services/binding.ts b/banban-mini/src/services/binding.ts index 97e66db..fe852db 100644 --- a/banban-mini/src/services/binding.ts +++ b/banban-mini/src/services/binding.ts @@ -45,6 +45,10 @@ export interface NFCSessionStartPayload { child_id?: number } +export interface NFCCardSessionStartPayload { + device_id: string +} + export interface NFCSessionStartResponse { bind_token: string expires_at: string @@ -149,6 +153,13 @@ export async function startNFCBind(data: NFCSessionStartPayload): Promise { + return request('/banban/bindings/cards/start', { + method: 'POST', + data, + }) +} + export async function getNFCBindSession(bindToken: string): Promise { return request(`/banban/bindings/sessions/${bindToken}`) } diff --git a/database/talkingq_shared_schema.sql b/database/talkingq_shared_schema.sql index 1182dba..85b4f58 100644 --- a/database/talkingq_shared_schema.sql +++ b/database/talkingq_shared_schema.sql @@ -341,6 +341,8 @@ CREATE TABLE IF NOT EXISTS `device_bind_sessions` ( `max_attempt_count` TINYINT UNSIGNED NOT NULL DEFAULT 5, `attempt_count` TINYINT UNSIGNED NOT NULL DEFAULT 0, `status` TINYINT NOT NULL DEFAULT 1, + `bind_mode` TINYINT NOT NULL DEFAULT 1 COMMENT '1=device bind, 2=additional card bind', + `card_uuid` VARCHAR(64) NULL, `confirmed_at` DATETIME NULL, `consumed_at` DATETIME NULL, `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, @@ -351,6 +353,7 @@ CREATE TABLE IF NOT EXISTS `device_bind_sessions` ( KEY `idx_device_bind_sessions_initiator_user_id` (`initiator_user_id`), KEY `idx_device_bind_sessions_target_child_id` (`target_child_id`), KEY `idx_device_bind_sessions_expires_at` (`expires_at`), + KEY `idx_device_bind_sessions_card_uuid` (`card_uuid`), CONSTRAINT `fk_device_bind_sessions_device` FOREIGN KEY (`device_id`) REFERENCES `device_auth` (`device_id`), @@ -407,7 +410,7 @@ CREATE TABLE IF NOT EXISTS `cards` ( `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`card_id`), UNIQUE KEY `uq_cards_card_uuid` (`card_uuid`), - UNIQUE KEY `uq_cards_device_id` (`device_id`), + KEY `idx_cards_device_id` (`device_id`), KEY `idx_cards_status` (`status`), CONSTRAINT `fk_cards_device` FOREIGN KEY (`device_id`) diff --git a/mini-program/app/models/entities.py b/mini-program/app/models/entities.py index 470a41b..24d9bff 100644 --- a/mini-program/app/models/entities.py +++ b/mini-program/app/models/entities.py @@ -100,6 +100,8 @@ class DeviceBindSession(Base): max_attempt_count: Mapped[int] = mapped_column(Integer, server_default="5") attempt_count: Mapped[int] = mapped_column(Integer, server_default="0") status: Mapped[int] = mapped_column(Integer, server_default="1") + bind_mode: Mapped[int] = mapped_column(Integer, server_default="1") + card_uuid: Mapped[Optional[str]] = mapped_column(String(64)) 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=text("CURRENT_TIMESTAMP")) @@ -126,7 +128,7 @@ class Card(Base): card_id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) card_uuid: Mapped[str] = mapped_column(String(64), unique=True, nullable=False) - device_id: Mapped[Optional[str]] = mapped_column(String(64), unique=True) + device_id: Mapped[Optional[str]] = mapped_column(String(64)) 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") diff --git a/mini-program/database/init.sql b/mini-program/database/init.sql index d26de6c..d32932b 100644 --- a/mini-program/database/init.sql +++ b/mini-program/database/init.sql @@ -87,6 +87,8 @@ CREATE TABLE IF NOT EXISTS device_bind_sessions ( max_attempt_count TINYINT UNSIGNED NOT NULL DEFAULT 5, attempt_count TINYINT UNSIGNED NOT NULL DEFAULT 0, status TINYINT NOT NULL DEFAULT 1, + bind_mode TINYINT NOT NULL DEFAULT 1, + card_uuid VARCHAR(64), confirmed_at DATETIME, consumed_at DATETIME, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, @@ -111,7 +113,7 @@ CREATE TABLE IF NOT EXISTS device_bind_history ( CREATE TABLE IF NOT EXISTS cards ( card_id BIGINT PRIMARY KEY AUTO_INCREMENT, card_uuid VARCHAR(64) UNIQUE NOT NULL, - device_id VARCHAR(64) UNIQUE, + device_id VARCHAR(64), card_name VARCHAR(64), status TINYINT NOT NULL DEFAULT 0, total_swaps INT NOT NULL DEFAULT 0, diff --git a/talkingq-url/banban/dao/binding.py b/talkingq-url/banban/dao/binding.py index 6a1d2cd..817cc5b 100644 --- a/talkingq-url/banban/dao/binding.py +++ b/talkingq-url/banban/dao/binding.py @@ -14,6 +14,9 @@ SESSION_STATUS_EXPIRED = 3 SESSION_STATUS_FAILED = 4 SESSION_STATUS_CANCELLED = 5 +BIND_MODE_DEVICE = 1 +BIND_MODE_CARD = 2 + BIND_SOURCE_SESSION_CONFIRM = 1 BIND_SOURCE_DIRECT = 2 BIND_SOURCE_SET_CHILD = 3 @@ -205,7 +208,13 @@ class BindingDAO(BaseDAO): {"device_id": device_id, "user_id": user_id}, ) - async def start_bind(self, user_id: int, device_id: str, child_id: Optional[int]) -> tuple[str, datetime]: + async def start_bind( + self, + user_id: int, + device_id: str, + child_id: Optional[int], + bind_mode: int = BIND_MODE_DEVICE, + ) -> tuple[str, datetime]: bind_token = str(uuid.uuid4()) expires_at = datetime.utcnow() + timedelta(minutes=10) @@ -227,8 +236,24 @@ class BindingDAO(BaseDAO): await self.execute( """ - 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, :status) + INSERT INTO device_bind_sessions ( + bind_token, + device_id, + initiator_user_id, + target_child_id, + expires_at, + status, + bind_mode + ) + VALUES ( + :bind_token, + :device_id, + :initiator_user_id, + :target_child_id, + :expires_at, + :status, + :bind_mode + ) """, { "bind_token": bind_token, @@ -237,6 +262,7 @@ class BindingDAO(BaseDAO): "target_child_id": child_id, "expires_at": expires_at, "status": SESSION_STATUS_PENDING, + "bind_mode": bind_mode, }, ) return bind_token, expires_at @@ -246,17 +272,8 @@ class BindingDAO(BaseDAO): await self.execute( """ SELECT - s.*, - CASE - WHEN s.status = :completed_status - AND s.confirmed_at IS NOT NULL - AND c.updated_at >= s.confirmed_at - THEN c.card_uuid - ELSE NULL - END AS card_uuid + s.* FROM device_bind_sessions AS s - LEFT JOIN cards AS c - ON c.device_id = s.device_id WHERE s.bind_token = :bind_token AND s.initiator_user_id = :user_id LIMIT 1 @@ -264,7 +281,6 @@ class BindingDAO(BaseDAO): { "bind_token": bind_token, "user_id": user_id, - "completed_status": SESSION_STATUS_COMPLETED, }, ) ).mappings().first() @@ -321,7 +337,14 @@ class BindingDAO(BaseDAO): bind_source=BIND_SOURCE_SESSION_CONFIRM, ) - async def complete_nfc_bind(self, session_id: int, device_id: str, child_id: Optional[int], user_id: int) -> None: + async def complete_nfc_bind( + self, + session_id: int, + device_id: str, + child_id: Optional[int], + user_id: int, + card_uuid: Optional[str] = None, + ) -> None: if child_id is not None: await self._upsert_parent_child_relation(user_id=user_id, child_id=child_id) @@ -329,12 +352,13 @@ class BindingDAO(BaseDAO): """ UPDATE device_bind_sessions SET status = :status, + card_uuid = :card_uuid, confirmed_at = CURRENT_TIMESTAMP, consumed_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP WHERE id = :id """, - {"id": session_id, "status": SESSION_STATUS_COMPLETED}, + {"id": session_id, "status": SESSION_STATUS_COMPLETED, "card_uuid": card_uuid}, ) await self._bind_device(device_id=device_id, user_id=user_id, child_id=child_id) @@ -346,6 +370,24 @@ class BindingDAO(BaseDAO): bind_source=BIND_SOURCE_NFC, ) + async def complete_card_bind(self, session_id: int, card_uuid: str) -> None: + await self.execute( + """ + UPDATE device_bind_sessions + SET status = :status, + card_uuid = :card_uuid, + confirmed_at = CURRENT_TIMESTAMP, + consumed_at = CURRENT_TIMESTAMP, + updated_at = CURRENT_TIMESTAMP + WHERE id = :id + """, + { + "id": session_id, + "status": SESSION_STATUS_COMPLETED, + "card_uuid": card_uuid, + }, + ) + async def direct_bind(self, device_id: str, child_id: Optional[int], user_id: int) -> None: if child_id is not None: await self._upsert_parent_child_relation(user_id=user_id, child_id=child_id) diff --git a/talkingq-url/banban/routers/bindings.py b/talkingq-url/banban/routers/bindings.py index d880297..15933f1 100644 --- a/talkingq-url/banban/routers/bindings.py +++ b/talkingq-url/banban/routers/bindings.py @@ -31,6 +31,10 @@ class BindStartResponse(BaseModel): status: int +class CardBindStartRequest(BaseModel): + device_id: str + + class BindSessionResponse(BaseModel): bind_token: str device_id: str @@ -107,6 +111,24 @@ async def start_bind( ) +@router.post("/cards/start", response_model=BindStartResponse) +async def start_card_bind( + payload: CardBindStartRequest, + request: Request, + current_user_id: int = Depends(get_current_user_id), +) -> BindStartResponse: + del request + try: + bind_token, expires_at = await binding_service.start_card_bind(current_user_id, payload.device_id) + except BindingError as exc: + raise HTTPException(status_code=exc.status_code, detail=str(exc)) + return BindStartResponse( + bind_token=bind_token, + expires_at=expires_at.isoformat(), + status=SESSION_STATUS_PENDING, + ) + + @router.get("/sessions/{bind_token}", response_model=BindSessionResponse) async def get_bind_session( bind_token: str, diff --git a/talkingq-url/banban/service/binding.py b/talkingq-url/banban/service/binding.py index e93c3a4..ce61c3a 100644 --- a/talkingq-url/banban/service/binding.py +++ b/talkingq-url/banban/service/binding.py @@ -3,6 +3,8 @@ from datetime import datetime from typing import Optional from banban.dao.binding import ( + BIND_MODE_CARD, + BIND_MODE_DEVICE, SESSION_STATUS_CANCELLED, SESSION_STATUS_COMPLETED, SESSION_STATUS_EXPIRED, @@ -41,6 +43,14 @@ class BindingService(DatabaseServiceBase): if int(row["is_active"]) != 1: raise BindingError("device is inactive", status_code=400) + async def _send_bind_nfc_command(self, device_id: str) -> None: + from handlers.mqtt_handler import TalkingQMQTTService + + service = await TalkingQMQTTService.get_instance() + if service is None: + raise BindingError("MQTT service is unavailable", status_code=503) + await service.send_bind_nfc_command(device_id) + def _normalize_session_status(self, session: Mapping) -> int: status = int(session["status"]) if status == SESSION_STATUS_PENDING and datetime.utcnow() > session["expires_at"]: @@ -59,14 +69,26 @@ class BindingService(DatabaseServiceBase): await self._ensure_bindable_device(db_session, device_id, serial_number) await self._ensure_device_unbound(db_session, device_id) dao = BindingDAO(db_session) - bind_token, expires_at = await dao.start_bind(user_id, device_id, child_id) + bind_token, expires_at = await dao.start_bind(user_id, device_id, child_id, BIND_MODE_DEVICE) await db_session.commit() - from handlers.mqtt_handler import TalkingQMQTTService + await self._send_bind_nfc_command(device_id) + return bind_token, expires_at + finally: + await db_session.close() - service = await TalkingQMQTTService.get_instance() - if service is None: - raise BindingError("MQTT service is unavailable", status_code=503) - await service.send_bind_nfc_command(device_id) + async def start_card_bind(self, user_id: int, device_id: str) -> tuple[str, datetime]: + db_session = await self.get_session() + try: + dao = BindingDAO(db_session) + binding = await dao.get_by_device(device_id, user_id) + if binding is None: + raise BindingError("binding not found", status_code=404) + if int(binding["owner_user_id"]) != user_id: + raise BindingError("only the device owner can add cards", status_code=403) + + bind_token, expires_at = await dao.start_bind(user_id, device_id, binding["child_id"], BIND_MODE_CARD) + await db_session.commit() + await self._send_bind_nfc_command(device_id) return bind_token, expires_at finally: await db_session.close() @@ -132,17 +154,22 @@ class BindingService(DatabaseServiceBase): } try: - await card_service.activate_card( + card = await card_service.activate_card( card_uuid=card_uuid, device_id=device_id, db_session=db_session, ) - await dao.complete_nfc_bind( - session_id=int(session["id"]), - device_id=device_id, - child_id=session["target_child_id"], - user_id=int(session["initiator_user_id"]), - ) + bind_mode = int(session.get("bind_mode") or BIND_MODE_DEVICE) + if bind_mode == BIND_MODE_CARD: + await dao.complete_card_bind(session_id=int(session["id"]), card_uuid=card.card_uuid) + else: + await dao.complete_nfc_bind( + session_id=int(session["id"]), + device_id=device_id, + child_id=session["target_child_id"], + user_id=int(session["initiator_user_id"]), + card_uuid=card.card_uuid, + ) await db_session.commit() except Exception: await dao.mark_session_status(int(session["id"]), SESSION_STATUS_FAILED) diff --git a/talkingq-url/database/init_db.py b/talkingq-url/database/init_db.py index 115e28f..b6c1a4f 100644 --- a/talkingq-url/database/init_db.py +++ b/talkingq-url/database/init_db.py @@ -106,6 +106,61 @@ async def _ensure_child_location_address_columns(conn) -> None: ) +async def _ensure_bind_session_card_columns(conn) -> None: + await _ensure_columns( + conn, + table_name="device_bind_sessions", + columns=[ + ("bind_mode", "bind_mode TINYINT NOT NULL DEFAULT 1 AFTER status"), + ("card_uuid", "card_uuid VARCHAR(64) NULL AFTER bind_mode"), + ], + ) + + result = await conn.execute( + text( + """ + SELECT COUNT(*) + FROM INFORMATION_SCHEMA.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'device_bind_sessions' + AND INDEX_NAME = 'idx_device_bind_sessions_card_uuid' + """ + ) + ) + if int(result.scalar() or 0) == 0: + await conn.execute(text("ALTER TABLE device_bind_sessions ADD INDEX idx_device_bind_sessions_card_uuid (card_uuid)")) + + +async def _ensure_cards_allow_multiple_per_device(conn) -> None: + result = await conn.execute( + text( + """ + SELECT COUNT(*) + FROM INFORMATION_SCHEMA.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'cards' + AND INDEX_NAME = 'idx_cards_device_id' + """ + ) + ) + if int(result.scalar() or 0) == 0: + await conn.execute(text("ALTER TABLE cards ADD INDEX idx_cards_device_id (device_id)")) + + result = await conn.execute( + text( + """ + SELECT COUNT(*) + FROM INFORMATION_SCHEMA.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'cards' + AND INDEX_NAME = 'uq_cards_device_id' + """ + ) + ) + if int(result.scalar() or 0) > 0: + await conn.execute(text("ALTER TABLE cards DROP INDEX uq_cards_device_id")) + + async def _ensure_device_family_tables(conn) -> None: await conn.execute( text( @@ -222,6 +277,8 @@ async def init_db(): await _ensure_schedule_suppressed_until_column(conn) await _ensure_device_alarm_location_columns(conn) await _ensure_child_location_address_columns(conn) + await _ensure_bind_session_card_columns(conn) + await _ensure_cards_allow_multiple_per_device(conn) await _ensure_device_family_tables(conn) await engine.dispose() session_logger.info("system", "database", "数据库表已成功创建") diff --git a/talkingq-url/database/models.py b/talkingq-url/database/models.py index 9e9a65e..172bce4 100644 --- a/talkingq-url/database/models.py +++ b/talkingq-url/database/models.py @@ -285,6 +285,8 @@ class DeviceBindSession(Base): max_attempt_count: Mapped[int] = mapped_column(Integer, server_default="5") attempt_count: Mapped[int] = mapped_column(Integer, server_default="0") status: Mapped[int] = mapped_column(Integer, server_default="1") + bind_mode: Mapped[int] = mapped_column(Integer, server_default="1") + card_uuid: Mapped[Optional[str]] = mapped_column(String(64)) 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=text("CURRENT_TIMESTAMP")) diff --git a/talkingq-url/handlers/mqtt_handler.py b/talkingq-url/handlers/mqtt_handler.py index 9a89780..2a123b6 100644 --- a/talkingq-url/handlers/mqtt_handler.py +++ b/talkingq-url/handlers/mqtt_handler.py @@ -475,7 +475,7 @@ class TalkingQMQTTService: await self._publish(topic, payload) return - is_owner = await card_service.check_card_ownership(nfc_uuid, device_id) + is_owner = await card_service.is_active_for_device(nfc_uuid, device_id) if is_owner: playback_items = await pending_voice_message_service.get_playback_items( target_device_id=device_id, @@ -534,10 +534,17 @@ class TalkingQMQTTService: return existing_card = await card_service.get_card_by_uuid(nfc_uuid) - if existing_card: + if existing_card and existing_card.device_id and int(existing_card.status) == 1: target_device_id = existing_card.device_id else: - await card_service.activate_card(nfc_uuid, device_id) + payload = { + "msg_id": "005", + "type": 0, + "params": { + "url_1": f"http://{settings.server_host}:{settings.server_port}/assets/audio/error_card.mp3" + }, + } + await self._publish(topic, payload) return await device_target_cache.set_target(device_id, target_device_id) diff --git a/talkingq-url/handlers/websocket_message_handler.py b/talkingq-url/handlers/websocket_message_handler.py index a9222bc..797e660 100644 --- a/talkingq-url/handlers/websocket_message_handler.py +++ b/talkingq-url/handlers/websocket_message_handler.py @@ -84,14 +84,15 @@ async def handle_text_message(websocket: WebSocket, device_id: str, text_data: s # 检查卡片是否存在 existing_card = await card_service.get_card_by_uuid(card_uuid) - if existing_card: + if existing_card and existing_card.device_id and int(existing_card.status) == 1: # 卡片已存在,使用卡片绑定的设备ID作为目标设备ID target_device_id = existing_card.device_id session_logger.info(device_id, "card", f"卡片已存在,绑定的设备ID: {target_device_id}") else: - # 卡片不存在,创建新卡片并绑定到当前设备 - new_card = await card_service.activate_card(card_uuid, device_id) - session_logger.info(device_id, "card", f"新卡片{card_uuid}已创建并激活,绑定到设备: {device_id}") + await websocket.send_text( + f"TARGET_DEVICE_REGISTERED_URL:http://{settings.server_host}:{settings.server_port}/assets/audio/error_card.mp3" + ) + session_logger.warning(device_id, "card", f"未知卡片不能自动绑定: {card_uuid}") return # 设置目标设备 diff --git a/talkingq-url/mysql/init/02-init.sql b/talkingq-url/mysql/init/02-init.sql index 1182dba..85b4f58 100644 --- a/talkingq-url/mysql/init/02-init.sql +++ b/talkingq-url/mysql/init/02-init.sql @@ -341,6 +341,8 @@ CREATE TABLE IF NOT EXISTS `device_bind_sessions` ( `max_attempt_count` TINYINT UNSIGNED NOT NULL DEFAULT 5, `attempt_count` TINYINT UNSIGNED NOT NULL DEFAULT 0, `status` TINYINT NOT NULL DEFAULT 1, + `bind_mode` TINYINT NOT NULL DEFAULT 1 COMMENT '1=device bind, 2=additional card bind', + `card_uuid` VARCHAR(64) NULL, `confirmed_at` DATETIME NULL, `consumed_at` DATETIME NULL, `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, @@ -351,6 +353,7 @@ CREATE TABLE IF NOT EXISTS `device_bind_sessions` ( KEY `idx_device_bind_sessions_initiator_user_id` (`initiator_user_id`), KEY `idx_device_bind_sessions_target_child_id` (`target_child_id`), KEY `idx_device_bind_sessions_expires_at` (`expires_at`), + KEY `idx_device_bind_sessions_card_uuid` (`card_uuid`), CONSTRAINT `fk_device_bind_sessions_device` FOREIGN KEY (`device_id`) REFERENCES `device_auth` (`device_id`), @@ -407,7 +410,7 @@ CREATE TABLE IF NOT EXISTS `cards` ( `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`card_id`), UNIQUE KEY `uq_cards_card_uuid` (`card_uuid`), - UNIQUE KEY `uq_cards_device_id` (`device_id`), + KEY `idx_cards_device_id` (`device_id`), KEY `idx_cards_status` (`status`), CONSTRAINT `fk_cards_device` FOREIGN KEY (`device_id`) diff --git a/talkingq-url/services/card_service.py b/talkingq-url/services/card_service.py index 41957cf..63e1293 100644 --- a/talkingq-url/services/card_service.py +++ b/talkingq-url/services/card_service.py @@ -60,32 +60,6 @@ class CardService(DatabaseServiceBase): session_logger.error("card", "service", f"load card failed: {exc}") return None - async def _clear_existing_device_card( - self, - device_id: str, - card_uuid: str, - async_session: AsyncSession, - ) -> None: - query = select(DBCard).where( - DBCard.device_id == device_id, - DBCard.card_uuid != card_uuid, - ) - result = await async_session.execute(query) - existing_cards = result.scalars().all() - - for db_card in existing_cards: - db_card.device_id = None - db_card.status = 0 - - async with self.lock: - cached = self.cards.get(db_card.card_uuid) - if cached is not None: - cached.device_id = None - cached.status = 0 - - if existing_cards: - await async_session.flush() - async def _save_card_to_db(self, card: Card, async_session: AsyncSession, commit: bool = True) -> None: try: query = select(DBCard).where(DBCard.card_uuid == card.card_uuid) @@ -182,9 +156,10 @@ class CardService(DatabaseServiceBase): try: existing_card = await self.get_card_by_uuid(card_uuid, db_session=db_session) - await self._clear_existing_device_card(device_id=device_id, card_uuid=card_uuid, async_session=db_session) if existing_card: + if existing_card.device_id and existing_card.device_id != device_id: + raise ValueError("card is already bound to another device") existing_card.device_id = device_id existing_card.card_name = card_name existing_card.status = 1 @@ -230,5 +205,9 @@ class CardService(DatabaseServiceBase): card = await self.get_card_by_uuid(card_uuid) return bool(card and card.device_id == device_id) + async def is_active_for_device(self, card_uuid: str, device_id: str) -> bool: + card = await self.get_card_by_uuid(card_uuid) + return bool(card and card.device_id == device_id and int(card.status) == 1) + card_service = CardService() diff --git a/talkingq-url/tests/test_binding_session_expiry.py b/talkingq-url/tests/test_binding_session_expiry.py index 3bb218d..25f27ab 100644 --- a/talkingq-url/tests/test_binding_session_expiry.py +++ b/talkingq-url/tests/test_binding_session_expiry.py @@ -2,8 +2,8 @@ from datetime import datetime, timedelta import pytest -from banban.dao.binding import SESSION_STATUS_COMPLETED, SESSION_STATUS_EXPIRED -from banban.service.binding import BindingService +from banban.dao.binding import BIND_MODE_CARD, BIND_MODE_DEVICE, SESSION_STATUS_COMPLETED, SESSION_STATUS_EXPIRED +from banban.service.binding import BindingError, BindingService class FakeSession: @@ -22,6 +22,80 @@ class FakeSession: self.closed = True +@pytest.mark.asyncio +async def test_start_card_bind_requires_existing_authorized_binding_and_sends_nfc_command(monkeypatch): + service = BindingService() + db_session = FakeSession() + started_binds = [] + sent_commands = [] + + async def fake_get_session(): + return db_session + + async def fake_get_by_device(self, device_id, user_id): + assert device_id == "TalkingQ_device001" + assert user_id == 99 + return { + "device_id": "TalkingQ_device001", + "child_id": 7, + "owner_user_id": 99, + } + + async def fake_start_bind(self, user_id, device_id, child_id, bind_mode): + started_binds.append((user_id, device_id, child_id, bind_mode)) + return "bind-token-card", datetime.utcnow() + timedelta(minutes=10) + + async def fake_send_bind_nfc_command(device_id): + sent_commands.append(device_id) + + monkeypatch.setattr(service, "get_session", fake_get_session) + monkeypatch.setattr("banban.service.binding.BindingDAO.get_by_device", fake_get_by_device) + monkeypatch.setattr("banban.service.binding.BindingDAO.start_bind", fake_start_bind) + monkeypatch.setattr(service, "_send_bind_nfc_command", fake_send_bind_nfc_command) + + bind_token, expires_at = await service.start_card_bind(user_id=99, device_id="TalkingQ_device001") + + assert bind_token == "bind-token-card" + assert expires_at > datetime.utcnow() + assert started_binds == [(99, "TalkingQ_device001", 7, BIND_MODE_CARD)] + assert sent_commands == ["TalkingQ_device001"] + assert db_session.commits == 1 + assert db_session.closed is True + + +@pytest.mark.asyncio +async def test_start_card_bind_rejects_family_member_who_is_not_owner(monkeypatch): + service = BindingService() + db_session = FakeSession() + started_binds = [] + + async def fake_get_session(): + return db_session + + async def fake_get_by_device(self, device_id, user_id): + assert device_id == "TalkingQ_device001" + assert user_id == 88 + return { + "device_id": "TalkingQ_device001", + "child_id": 7, + "owner_user_id": 99, + } + + async def fake_start_bind(self, *args, **kwargs): + started_binds.append((args, kwargs)) + + monkeypatch.setattr(service, "get_session", fake_get_session) + monkeypatch.setattr("banban.service.binding.BindingDAO.get_by_device", fake_get_by_device) + monkeypatch.setattr("banban.service.binding.BindingDAO.start_bind", fake_start_bind) + + with pytest.raises(BindingError, match="only the device owner can add cards"): + await service.start_card_bind(user_id=88, device_id="TalkingQ_device001") + + assert started_binds == [] + assert db_session.commits == 0 + assert db_session.closed is True + + @pytest.mark.asyncio async def test_finalize_nfc_bind_expires_late_card_without_binding(monkeypatch): service = BindingService() @@ -92,6 +166,7 @@ async def test_finalize_nfc_bind_completes_before_expiry(monkeypatch): "target_child_id": 7, "initiator_user_id": 99, "expires_at": datetime.utcnow() + timedelta(minutes=10), + "bind_mode": BIND_MODE_DEVICE, } marked_statuses = [] activated_cards = [] @@ -110,6 +185,11 @@ async def test_finalize_nfc_bind_completes_before_expiry(monkeypatch): async def fake_activate_card(**kwargs): activated_cards.append(kwargs) + class FakeCard: + card_uuid = kwargs["card_uuid"] + + return FakeCard() + async def fake_complete_nfc_bind(self, **kwargs): completed_sessions.append(kwargs) @@ -148,8 +228,86 @@ async def test_finalize_nfc_bind_completes_before_expiry(monkeypatch): "device_id": "TalkingQ_device001", "child_id": 7, "user_id": 99, + "card_uuid": "CARD_UUID_ON_TIME", } ] assert db_session.commits == 1 assert db_session.rollbacks == 0 assert db_session.closed is True + + +@pytest.mark.asyncio +async def test_finalize_nfc_bind_adds_card_without_rebinding_device(monkeypatch): + service = BindingService() + db_session = FakeSession() + pending_session = { + "id": 44, + "bind_token": "bind-token-card", + "device_id": "TalkingQ_device001", + "target_child_id": 7, + "initiator_user_id": 99, + "expires_at": datetime.utcnow() + timedelta(minutes=10), + "bind_mode": BIND_MODE_CARD, + } + activated_cards = [] + completed_card_sessions = [] + completed_device_sessions = [] + + async def fake_get_session(): + return db_session + + async def fake_get_latest_pending_session_by_device(self, device_id): + assert device_id == "TalkingQ_device001" + return pending_session + + async def fake_mark_session_status(self, session_id, status): + raise AssertionError(f"unexpected status mark: {session_id}, {status}") + + async def fake_activate_card(**kwargs): + activated_cards.append(kwargs) + + class FakeCard: + card_uuid = kwargs["card_uuid"] + + return FakeCard() + + async def fake_complete_card_bind(self, **kwargs): + completed_card_sessions.append(kwargs) + + async def fake_complete_nfc_bind(self, **kwargs): + completed_device_sessions.append(kwargs) + + monkeypatch.setattr(service, "get_session", fake_get_session) + monkeypatch.setattr( + "banban.service.binding.BindingDAO.get_latest_pending_session_by_device", + fake_get_latest_pending_session_by_device, + ) + monkeypatch.setattr("banban.service.binding.BindingDAO.mark_session_status", fake_mark_session_status) + monkeypatch.setattr("banban.service.binding.card_service.activate_card", fake_activate_card) + monkeypatch.setattr("banban.service.binding.BindingDAO.complete_card_bind", fake_complete_card_bind) + monkeypatch.setattr("banban.service.binding.BindingDAO.complete_nfc_bind", fake_complete_nfc_bind) + + result = await service.finalize_nfc_bind( + device_id="TalkingQ_device001", + card_uuid="CARD_UUID_EXTRA", + ) + + assert result == { + "device_id": "TalkingQ_device001", + "bind_token": "bind-token-card", + "status": SESSION_STATUS_COMPLETED, + "child_id": 7, + "card_uuid": "CARD_UUID_EXTRA", + } + assert activated_cards == [ + { + "card_uuid": "CARD_UUID_EXTRA", + "device_id": "TalkingQ_device001", + "db_session": db_session, + } + ] + assert completed_card_sessions == [{"session_id": 44, "card_uuid": "CARD_UUID_EXTRA"}] + assert completed_device_sessions == [] + assert db_session.commits == 1 + assert db_session.rollbacks == 0 + assert db_session.closed is True diff --git a/talkingq-url/tests/test_card_service.py b/talkingq-url/tests/test_card_service.py new file mode 100644 index 0000000..7e84c36 --- /dev/null +++ b/talkingq-url/tests/test_card_service.py @@ -0,0 +1,108 @@ +import pytest + +from services.card_service import Card, CardService + + +class FakeSession: + pass + + +@pytest.mark.asyncio +async def test_activate_card_keeps_existing_device_cards(monkeypatch): + service = CardService() + existing = Card( + card_id=1, + card_uuid="CARD_A", + device_id="TalkingQ_device001", + status=1, + ) + saved_cards = [] + session = FakeSession() + + async def fake_init_database(): + return None + + async def fake_get_card_by_uuid(card_uuid, force_refresh=False, db_session=None): + del force_refresh, db_session + return None + + async def fake_save_card_to_db(card, async_session, commit=True): + del async_session, commit + saved_cards.append(card) + card.card_id = 2 + + monkeypatch.setattr(service, "_init_database", fake_init_database) + monkeypatch.setattr(service, "get_card_by_uuid", fake_get_card_by_uuid) + monkeypatch.setattr(service, "_save_card_to_db", fake_save_card_to_db) + + new_card = await service.activate_card( + card_uuid="CARD_B", + device_id="TalkingQ_device001", + db_session=session, + ) + + assert existing.device_id == "TalkingQ_device001" + assert existing.status == 1 + assert new_card.card_uuid == "CARD_B" + assert new_card.device_id == "TalkingQ_device001" + assert new_card.status == 1 + assert saved_cards == [new_card] + + +@pytest.mark.asyncio +async def test_activate_card_rejects_card_bound_to_other_device(monkeypatch): + service = CardService() + existing = Card( + card_id=1, + card_uuid="CARD_A", + device_id="TalkingQ_device002", + status=1, + ) + saved_cards = [] + session = FakeSession() + + async def fake_init_database(): + return None + + async def fake_get_card_by_uuid(card_uuid, force_refresh=False, db_session=None): + del force_refresh, db_session + return existing if card_uuid == "CARD_A" else None + + async def fake_save_card_to_db(card, async_session, commit=True): + del async_session, commit + saved_cards.append(card) + + monkeypatch.setattr(service, "_init_database", fake_init_database) + monkeypatch.setattr(service, "get_card_by_uuid", fake_get_card_by_uuid) + monkeypatch.setattr(service, "_save_card_to_db", fake_save_card_to_db) + + with pytest.raises(ValueError, match="card is already bound to another device"): + await service.activate_card( + card_uuid="CARD_A", + device_id="TalkingQ_device001", + db_session=session, + ) + + assert existing.device_id == "TalkingQ_device002" + assert saved_cards == [] + + +@pytest.mark.asyncio +async def test_is_active_for_device_requires_matching_active_card(monkeypatch): + service = CardService() + active_card = Card(card_uuid="CARD_A", device_id="TalkingQ_device001", status=1) + inactive_card = Card(card_uuid="CARD_B", device_id="TalkingQ_device001", status=0) + + async def fake_get_card_by_uuid(card_uuid): + return { + "CARD_A": active_card, + "CARD_B": inactive_card, + "CARD_C": Card(card_uuid="CARD_C", device_id="TalkingQ_device002", status=1), + }.get(card_uuid) + + monkeypatch.setattr(service, "get_card_by_uuid", fake_get_card_by_uuid) + + assert await service.is_active_for_device("CARD_A", "TalkingQ_device001") is True + assert await service.is_active_for_device("CARD_B", "TalkingQ_device001") is False + assert await service.is_active_for_device("CARD_C", "TalkingQ_device001") is False + assert await service.is_active_for_device("CARD_MISSING", "TalkingQ_device001") is False diff --git a/talkingq-url/tests/test_db_init_cards.py b/talkingq-url/tests/test_db_init_cards.py new file mode 100644 index 0000000..2ab0ca8 --- /dev/null +++ b/talkingq-url/tests/test_db_init_cards.py @@ -0,0 +1,66 @@ +import pytest + +from database.init_db import _ensure_bind_session_card_columns, _ensure_cards_allow_multiple_per_device + + +class FakeScalarResult: + def __init__(self, value): + self.value = value + + def scalar(self): + return self.value + + +class FakeConnection: + def __init__(self): + self.stat_counts = { + "idx_device_bind_sessions_card_uuid": 0, + "idx_cards_device_id": 0, + "uq_cards_device_id": 1, + } + self.column_counts = { + "bind_mode": 0, + "card_uuid": 0, + } + self.sql = [] + + async def execute(self, statement, params=None): + sql = str(statement) + self.sql.append(sql) + + if "INFORMATION_SCHEMA.COLUMNS" in sql: + return FakeScalarResult(self.column_counts.get(params["column_name"], 0)) + + if "INFORMATION_SCHEMA.STATISTICS" in sql: + for index_name, count in self.stat_counts.items(): + if f"INDEX_NAME = '{index_name}'" in sql: + return FakeScalarResult(count) + return FakeScalarResult(0) + + return FakeScalarResult(0) + + +@pytest.mark.asyncio +async def test_ensure_bind_session_card_columns_adds_missing_columns_and_index(): + conn = FakeConnection() + + await _ensure_bind_session_card_columns(conn) + + executed = "\n".join(conn.sql) + assert "ALTER TABLE device_bind_sessions ADD COLUMN bind_mode TINYINT NOT NULL DEFAULT 1 AFTER status" in executed + assert "ALTER TABLE device_bind_sessions ADD COLUMN card_uuid VARCHAR(64) NULL AFTER bind_mode" in executed + assert "ALTER TABLE device_bind_sessions ADD INDEX idx_device_bind_sessions_card_uuid (card_uuid)" in executed + + +@pytest.mark.asyncio +async def test_ensure_cards_allow_multiple_per_device_replaces_unique_device_index(): + conn = FakeConnection() + + await _ensure_cards_allow_multiple_per_device(conn) + + executed = "\n".join(conn.sql) + assert "ALTER TABLE cards ADD INDEX idx_cards_device_id (device_id)" in executed + assert "ALTER TABLE cards DROP INDEX uq_cards_device_id" in executed + assert executed.index("ALTER TABLE cards ADD INDEX idx_cards_device_id") < executed.index( + "ALTER TABLE cards DROP INDEX uq_cards_device_id" + )