支持已绑定设备追加 NFC 卡

- 新增已绑定设备追加卡会话和小程序入口

- 允许同设备多张 active 卡,保留 card_uuid 唯一并补齐老库迁移

- 收紧未知卡自动绑定和跨设备卡复用

验证:talkingq-url/.venv/bin/python -m pytest tests;compileall;npm run build:weapp
This commit is contained in:
stu2not
2026-06-04 09:55:35 +08:00
parent 482fbf351f
commit 82c15b6abc
18 changed files with 646 additions and 92 deletions

View File

@@ -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() {
<Image className='back-icon' src={require('../../assets/tab-icons/arrow-left.png')} mode='aspectFit' />
<Text className='back-text'></Text>
</View>
<Text className='title'>{isPendingBinding ? '补全绑定' : '扫码贴卡绑定设备'}</Text>
<Text className='title'>{pageTitle}</Text>
</View>
{isPendingBinding ? (
{isAddingCard ? (
<View className='pending-device-card'>
<View className='icon-bg orange'>
<Image className='control-icon-img' src={require('../../assets/tab-icons/rings.png')} mode='aspectFit' />
</View>
<Text className='pending-device-title'></Text>
<Text className='pending-device-id'>{deviceId}</Text>
<Text className='pending-device-desc'></Text>
</View>
) : isPendingBinding ? (
<View className='pending-device-card'>
<View className='icon-bg orange'>
<Image className='control-icon-img' src={require('../../assets/tab-icons/rings.png')} mode='aspectFit' />
@@ -555,6 +595,7 @@ export default function Bind() {
)}
<View className='bind-form'>
{!isAddingCard && (
<View className='form-card'>
<Text className='form-title'></Text>
@@ -621,22 +662,24 @@ export default function Bind() {
: '也可以先不创建儿童,直接绑定设备,稍后再关联孩子'}
</Text>
</View>
)}
{!isPendingBinding && (
<View className='form-card'>
<Text className='form-title'></Text>
<Text className='form-title'>{isAddingCard ? '加卡状态' : '设备信息'}</Text>
<View className='field-item'>
<Text className='field-label'></Text>
<Input
className='field-input'
value={deviceId}
disabled={!canEditDeviceFields || isPendingBinding}
placeholder={canEditDeviceFields ? '请输入设备号' : '绑定确认中'}
disabled={isAddingCard || !canEditDeviceFields || isPendingBinding}
placeholder={isAddingCard ? '当前设备' : canEditDeviceFields ? '请输入设备号' : '绑定确认中'}
onInput={(event) => setDeviceId(event.detail.value)}
/>
</View>
{!isAddingCard && (
<View className='field-item'>
<Text className='field-label'></Text>
<Input
@@ -647,6 +690,7 @@ export default function Bind() {
onInput={(event) => setSerialNumber(event.detail.value)}
/>
</View>
)}
{bindHint ? (
<View className='pending-tip'>
@@ -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 ? '发送加卡指令' : '发送绑卡指令'}
</Button>
</View>
@@ -680,15 +730,15 @@ export default function Bind() {
<Text className='tips-title'></Text>
<View className='tip-item'>
<Text className='tip-number'>1</Text>
<Text className='tip-text'>{isPendingBinding ? '先选择已有儿童,或者在本页新建一个儿童' : '儿童资料可选,不选择时设备会先进入待关联状态'}</Text>
<Text className='tip-text'>{isAddingCard ? '确认当前设备号正确' : isPendingBinding ? '先选择已有儿童,或者在本页新建一个儿童' : '儿童资料可选,不选择时设备会先进入待关联状态'}</Text>
</View>
<View className='tip-item'>
<Text className='tip-number'>2</Text>
<Text className='tip-text'>{isPendingBinding ? '确认当前待关联设备,再选择要关联的孩子' : '扫描设备二维码,确认设备号和序列号正确'}</Text>
<Text className='tip-text'>{isAddingCard ? '点击发送加卡指令后,在设备上贴新卡' : isPendingBinding ? '确认当前待关联设备,再选择要关联的孩子' : '扫描设备二维码,确认设备号和序列号正确'}</Text>
</View>
<View className='tip-item'>
<Text className='tip-number'>3</Text>
<Text className='tip-text'>{isPendingBinding ? '点击完成儿童关联,设备会出现在该孩子下面' : '点击发送绑卡指令,然后在 10 分钟内去设备上贴自己的卡完成确认'}</Text>
<Text className='tip-text'>{isAddingCard ? '加卡成功后,新卡和原来的卡可以执行相同功能' : isPendingBinding ? '点击完成儿童关联,设备会出现在该孩子下面' : '点击发送绑卡指令,然后在 10 分钟内去设备上贴自己的卡完成确认'}</Text>
</View>
</View>
{systemBanner}

View File

@@ -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',

View File

@@ -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<NFCSes
})
}
export async function startNFCCardBind(data: NFCCardSessionStartPayload): Promise<NFCSessionStartResponse> {
return request<NFCSessionStartResponse>('/banban/bindings/cards/start', {
method: 'POST',
data,
})
}
export async function getNFCBindSession(bindToken: string): Promise<NFCSessionStatus> {
return request<NFCSessionStatus>(`/banban/bindings/sessions/${bindToken}`)
}

View File

@@ -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`)

View File

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

View File

@@ -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,

View File

@@ -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)

View File

@@ -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,

View File

@@ -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)

View File

@@ -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", "数据库表已成功创建")

View File

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

View File

@@ -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)

View File

@@ -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
# 设置目标设备

View File

@@ -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`)

View File

@@ -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()

View File

@@ -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

View File

@@ -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

View File

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