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"
+ )