支持已绑定设备追加 NFC 卡
- 新增已绑定设备追加卡会话和小程序入口 - 允许同设备多张 active 卡,保留 card_uuid 唯一并补齐老库迁移 - 收紧未知卡自动绑定和跨设备卡复用 验证:talkingq-url/.venv/bin/python -m pytest tests;compileall;npm run build:weapp
This commit is contained in:
@@ -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}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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}`)
|
||||
}
|
||||
|
||||
@@ -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`)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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", "数据库表已成功创建")
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
# 设置目标设备
|
||||
|
||||
@@ -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`)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
108
talkingq-url/tests/test_card_service.py
Normal file
108
talkingq-url/tests/test_card_service.py
Normal 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
|
||||
66
talkingq-url/tests/test_db_init_cards.py
Normal file
66
talkingq-url/tests/test_db_init_cards.py
Normal 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"
|
||||
)
|
||||
Reference in New Issue
Block a user