支持已绑定设备追加 NFC 卡
This commit is contained in:
@@ -8,6 +8,7 @@ import {
|
|||||||
resolveActiveBinding,
|
resolveActiveBinding,
|
||||||
setBindingChild,
|
setBindingChild,
|
||||||
setSelectedBindingDeviceId,
|
setSelectedBindingDeviceId,
|
||||||
|
startNFCCardBind,
|
||||||
startNFCBind,
|
startNFCBind,
|
||||||
} from '@/services/binding'
|
} from '@/services/binding'
|
||||||
import {
|
import {
|
||||||
@@ -154,6 +155,8 @@ function parseBindingPayload(rawValue: string): { deviceId: string; serialNumber
|
|||||||
export default function Bind() {
|
export default function Bind() {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const isAssignMode = String(router.params?.mode || '').trim() === 'assign'
|
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 systemBanner = useSystemBanner()
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [submitting, setSubmitting] = useState(false)
|
const [submitting, setSubmitting] = useState(false)
|
||||||
@@ -192,10 +195,12 @@ export default function Bind() {
|
|||||||
|
|
||||||
const hasSelectedOrNewChild = selectedChildId !== null || Boolean(newChildName.trim())
|
const hasSelectedOrNewChild = selectedChildId !== null || Boolean(newChildName.trim())
|
||||||
const isPendingBinding = Boolean(pendingDeviceId)
|
const isPendingBinding = Boolean(pendingDeviceId)
|
||||||
|
const isAddingCard = isCardMode && Boolean(deviceId.trim())
|
||||||
const isPollingBind = bindStatus === SESSION_STATUS_PENDING && Boolean(bindToken)
|
const isPollingBind = bindStatus === SESSION_STATUS_PENDING && Boolean(bindToken)
|
||||||
const isExpiredBind = bindStatus === SESSION_STATUS_EXPIRED
|
const isExpiredBind = bindStatus === SESSION_STATUS_EXPIRED
|
||||||
const canEditDeviceFields = !isPendingBinding && !isPollingBind
|
const canEditDeviceFields = !isPendingBinding && !isPollingBind
|
||||||
const canSubmit = isPendingBinding ? hasSelectedOrNewChild : isExpiredBind ? true : !isPollingBind
|
const canSubmit = isPendingBinding ? hasSelectedOrNewChild : isExpiredBind ? true : !isPollingBind
|
||||||
|
const pageTitle = isPendingBinding ? '补全绑定' : isAddingCard ? '添加设备卡' : '扫码贴卡绑定设备'
|
||||||
|
|
||||||
const goDevicePage = () => {
|
const goDevicePage = () => {
|
||||||
Taro.reLaunch({ url: '/pages/device/index' })
|
Taro.reLaunch({ url: '/pages/device/index' })
|
||||||
@@ -246,7 +251,7 @@ export default function Bind() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const applyLaunchQrPayload = () => {
|
const applyLaunchQrPayload = () => {
|
||||||
if (isAssignMode) return false
|
if (isAssignMode || isCardMode) return false
|
||||||
|
|
||||||
const launchPayload = getLaunchQrPayload(router.params)
|
const launchPayload = getLaunchQrPayload(router.params)
|
||||||
if (!launchPayload || appliedLaunchQrRef.current === launchPayload) return false
|
if (!launchPayload || appliedLaunchQrRef.current === launchPayload) return false
|
||||||
@@ -274,12 +279,14 @@ export default function Bind() {
|
|||||||
setCardUUID('')
|
setCardUUID('')
|
||||||
setBindExpiresAt('')
|
setBindExpiresAt('')
|
||||||
setRemainingBindSeconds(0)
|
setRemainingBindSeconds(0)
|
||||||
setDeviceId('')
|
if (!isCardMode) {
|
||||||
setSerialNumber('')
|
setDeviceId('')
|
||||||
setBindHint('贴卡时间已过,请重新扫码并贴卡')
|
setSerialNumber('')
|
||||||
|
}
|
||||||
|
setBindHint(isCardMode ? '贴卡时间已过,请重新发送加卡指令' : '贴卡时间已过,请重新扫码并贴卡')
|
||||||
|
|
||||||
if (showToast) {
|
if (showToast) {
|
||||||
Taro.showToast({ title: '贴卡已超时,请重新扫码', icon: 'none' })
|
Taro.showToast({ title: isCardMode ? '贴卡已超时,请重新发送' : '贴卡已超时,请重新扫码', icon: 'none' })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -318,7 +325,7 @@ export default function Bind() {
|
|||||||
resetExpiredBindSession()
|
resetExpiredBindSession()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
setBindHint('已发送绑卡指令,请在倒计时结束前去设备上贴自己的卡')
|
setBindHint(isAddingCard ? '已发送加卡指令,请在倒计时结束前去设备上贴新卡' : '已发送绑卡指令,请在倒计时结束前去设备上贴自己的卡')
|
||||||
schedulePoll(activeBindToken)
|
schedulePoll(activeBindToken)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -329,13 +336,15 @@ export default function Bind() {
|
|||||||
if (session.status === SESSION_STATUS_COMPLETED) {
|
if (session.status === SESSION_STATUS_COMPLETED) {
|
||||||
setSelectedBindingDeviceId(session.device_id)
|
setSelectedBindingDeviceId(session.device_id)
|
||||||
setBindHint(
|
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}` : '绑定完成'
|
||||||
: session.card_uuid ? `绑定完成,卡号 ${session.card_uuid},稍后可关联孩子` : '绑定完成,稍后可关联孩子'
|
: session.card_uuid ? `绑定完成,卡号 ${session.card_uuid},稍后可关联孩子` : '绑定完成,稍后可关联孩子'
|
||||||
)
|
)
|
||||||
Taro.showToast({ title: '设备绑定成功', icon: 'success' })
|
Taro.showToast({ title: isAddingCard ? '新卡添加成功' : '设备绑定成功', icon: 'success' })
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (session.child_id) {
|
if (isAddingCard || session.child_id) {
|
||||||
goDevicePage()
|
goDevicePage()
|
||||||
} else {
|
} else {
|
||||||
goManagePage()
|
goManagePage()
|
||||||
@@ -350,7 +359,7 @@ export default function Bind() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (session.status === SESSION_STATUS_FAILED) {
|
if (session.status === SESSION_STATUS_FAILED) {
|
||||||
setBindHint('贴卡绑定失败,请重试')
|
setBindHint(isAddingCard ? '添加新卡失败,请确认卡未绑定到其他设备后重试' : '贴卡绑定失败,请重试')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -379,7 +388,16 @@ export default function Bind() {
|
|||||||
setChildren(currentChildren)
|
setChildren(currentChildren)
|
||||||
setSelectedChildId(currentChild?.child_id || null)
|
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)
|
setPendingDeviceId(activeBinding.device_id)
|
||||||
setDeviceId(activeBinding.device_id)
|
setDeviceId(activeBinding.device_id)
|
||||||
} else {
|
} else {
|
||||||
@@ -451,10 +469,10 @@ export default function Bind() {
|
|||||||
if (submitting || isPollingBind) return
|
if (submitting || isPollingBind) return
|
||||||
|
|
||||||
setSubmitting(true)
|
setSubmitting(true)
|
||||||
Taro.showLoading({ title: isPendingBinding ? '关联中...' : '发送绑卡指令...' })
|
Taro.showLoading({ title: isPendingBinding ? '关联中...' : isAddingCard ? '发送加卡指令...' : '发送绑卡指令...' })
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const childId = await ensureChildId()
|
const childId = isAddingCard ? null : await ensureChildId()
|
||||||
|
|
||||||
if (isPendingBinding) {
|
if (isPendingBinding) {
|
||||||
if (!childId) {
|
if (!childId) {
|
||||||
@@ -472,6 +490,19 @@ export default function Bind() {
|
|||||||
if (!deviceId.trim()) {
|
if (!deviceId.trim()) {
|
||||||
throw new Error('请输入设备号')
|
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()) {
|
if (!serialNumber.trim()) {
|
||||||
throw new Error('请输入设备序列号')
|
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' />
|
<Image className='back-icon' src={require('../../assets/tab-icons/arrow-left.png')} mode='aspectFit' />
|
||||||
<Text className='back-text'>返回</Text>
|
<Text className='back-text'>返回</Text>
|
||||||
</View>
|
</View>
|
||||||
<Text className='title'>{isPendingBinding ? '补全绑定' : '扫码贴卡绑定设备'}</Text>
|
<Text className='title'>{pageTitle}</Text>
|
||||||
</View>
|
</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='pending-device-card'>
|
||||||
<View className='icon-bg orange'>
|
<View className='icon-bg orange'>
|
||||||
<Image className='control-icon-img' src={require('../../assets/tab-icons/rings.png')} mode='aspectFit' />
|
<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'>
|
<View className='bind-form'>
|
||||||
|
{!isAddingCard && (
|
||||||
<View className='form-card'>
|
<View className='form-card'>
|
||||||
<Text className='form-title'>儿童资料</Text>
|
<Text className='form-title'>儿童资料</Text>
|
||||||
|
|
||||||
@@ -621,22 +662,24 @@ export default function Bind() {
|
|||||||
: '也可以先不创建儿童,直接绑定设备,稍后再关联孩子'}
|
: '也可以先不创建儿童,直接绑定设备,稍后再关联孩子'}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
{!isPendingBinding && (
|
{!isPendingBinding && (
|
||||||
<View className='form-card'>
|
<View className='form-card'>
|
||||||
<Text className='form-title'>设备信息</Text>
|
<Text className='form-title'>{isAddingCard ? '加卡状态' : '设备信息'}</Text>
|
||||||
|
|
||||||
<View className='field-item'>
|
<View className='field-item'>
|
||||||
<Text className='field-label'>设备号</Text>
|
<Text className='field-label'>设备号</Text>
|
||||||
<Input
|
<Input
|
||||||
className='field-input'
|
className='field-input'
|
||||||
value={deviceId}
|
value={deviceId}
|
||||||
disabled={!canEditDeviceFields || isPendingBinding}
|
disabled={isAddingCard || !canEditDeviceFields || isPendingBinding}
|
||||||
placeholder={canEditDeviceFields ? '请输入设备号' : '绑定确认中'}
|
placeholder={isAddingCard ? '当前设备' : canEditDeviceFields ? '请输入设备号' : '绑定确认中'}
|
||||||
onInput={(event) => setDeviceId(event.detail.value)}
|
onInput={(event) => setDeviceId(event.detail.value)}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
{!isAddingCard && (
|
||||||
<View className='field-item'>
|
<View className='field-item'>
|
||||||
<Text className='field-label'>设备序列号</Text>
|
<Text className='field-label'>设备序列号</Text>
|
||||||
<Input
|
<Input
|
||||||
@@ -647,6 +690,7 @@ export default function Bind() {
|
|||||||
onInput={(event) => setSerialNumber(event.detail.value)}
|
onInput={(event) => setSerialNumber(event.detail.value)}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
{bindHint ? (
|
{bindHint ? (
|
||||||
<View className='pending-tip'>
|
<View className='pending-tip'>
|
||||||
@@ -670,9 +714,15 @@ export default function Bind() {
|
|||||||
className='submit-btn'
|
className='submit-btn'
|
||||||
loading={submitting}
|
loading={submitting}
|
||||||
disabled={submitting || !canSubmit}
|
disabled={submitting || !canSubmit}
|
||||||
onClick={isExpiredBind && !isPendingBinding ? handleScanCode : handleSubmit}
|
onClick={isExpiredBind && !isPendingBinding && !isAddingCard ? handleScanCode : handleSubmit}
|
||||||
>
|
>
|
||||||
{isPendingBinding ? '完成儿童关联' : isPollingBind ? '等待贴卡确认' : isExpiredBind ? '重新扫码绑定' : '发送绑卡指令'}
|
{isPendingBinding
|
||||||
|
? '完成儿童关联'
|
||||||
|
: isPollingBind
|
||||||
|
? '等待贴卡确认'
|
||||||
|
: isExpiredBind
|
||||||
|
? isAddingCard ? '重新发送加卡指令' : '重新扫码绑定'
|
||||||
|
: isAddingCard ? '发送加卡指令' : '发送绑卡指令'}
|
||||||
</Button>
|
</Button>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
@@ -680,15 +730,15 @@ export default function Bind() {
|
|||||||
<Text className='tips-title'>绑定帮助</Text>
|
<Text className='tips-title'>绑定帮助</Text>
|
||||||
<View className='tip-item'>
|
<View className='tip-item'>
|
||||||
<Text className='tip-number'>1</Text>
|
<Text className='tip-number'>1</Text>
|
||||||
<Text className='tip-text'>{isPendingBinding ? '先选择已有儿童,或者在本页新建一个儿童' : '儿童资料可选,不选择时设备会先进入待关联状态'}</Text>
|
<Text className='tip-text'>{isAddingCard ? '确认当前设备号正确' : isPendingBinding ? '先选择已有儿童,或者在本页新建一个儿童' : '儿童资料可选,不选择时设备会先进入待关联状态'}</Text>
|
||||||
</View>
|
</View>
|
||||||
<View className='tip-item'>
|
<View className='tip-item'>
|
||||||
<Text className='tip-number'>2</Text>
|
<Text className='tip-number'>2</Text>
|
||||||
<Text className='tip-text'>{isPendingBinding ? '确认当前待关联设备,再选择要关联的孩子' : '扫描设备二维码,确认设备号和序列号正确'}</Text>
|
<Text className='tip-text'>{isAddingCard ? '点击发送加卡指令后,在设备上贴新卡' : isPendingBinding ? '确认当前待关联设备,再选择要关联的孩子' : '扫描设备二维码,确认设备号和序列号正确'}</Text>
|
||||||
</View>
|
</View>
|
||||||
<View className='tip-item'>
|
<View className='tip-item'>
|
||||||
<Text className='tip-number'>3</Text>
|
<Text className='tip-number'>3</Text>
|
||||||
<Text className='tip-text'>{isPendingBinding ? '点击完成儿童关联,设备会出现在该孩子下面' : '点击发送绑卡指令,然后在 10 分钟内去设备上贴自己的卡完成确认'}</Text>
|
<Text className='tip-text'>{isAddingCard ? '加卡成功后,新卡和原来的卡可以执行相同功能' : isPendingBinding ? '点击完成儿童关联,设备会出现在该孩子下面' : '点击发送绑卡指令,然后在 10 分钟内去设备上贴自己的卡完成确认'}</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
{systemBanner}
|
{systemBanner}
|
||||||
|
|||||||
@@ -614,6 +614,12 @@ export default function Sleep() {
|
|||||||
return
|
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 角色') {
|
if (item.name === 'AI 角色') {
|
||||||
handleOpenRoleModal()
|
handleOpenRoleModal()
|
||||||
return
|
return
|
||||||
@@ -713,6 +719,14 @@ export default function Sleep() {
|
|||||||
value: binding?.device_id ? `当前: ${binding.device_id}` : '未绑定',
|
value: binding?.device_id ? `当前: ${binding.device_id}` : '未绑定',
|
||||||
arrow: true,
|
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'),
|
icon: require('../../assets/tab-icons/orange-robot.png'),
|
||||||
iconBgClass: 'green',
|
iconBgClass: 'green',
|
||||||
|
|||||||
@@ -45,6 +45,10 @@ export interface NFCSessionStartPayload {
|
|||||||
child_id?: number
|
child_id?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface NFCCardSessionStartPayload {
|
||||||
|
device_id: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface NFCSessionStartResponse {
|
export interface NFCSessionStartResponse {
|
||||||
bind_token: string
|
bind_token: string
|
||||||
expires_at: 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> {
|
export async function getNFCBindSession(bindToken: string): Promise<NFCSessionStatus> {
|
||||||
return request<NFCSessionStatus>(`/banban/bindings/sessions/${bindToken}`)
|
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,
|
`max_attempt_count` TINYINT UNSIGNED NOT NULL DEFAULT 5,
|
||||||
`attempt_count` TINYINT UNSIGNED NOT NULL DEFAULT 0,
|
`attempt_count` TINYINT UNSIGNED NOT NULL DEFAULT 0,
|
||||||
`status` TINYINT NOT NULL DEFAULT 1,
|
`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,
|
`confirmed_at` DATETIME NULL,
|
||||||
`consumed_at` DATETIME NULL,
|
`consumed_at` DATETIME NULL,
|
||||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
`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_initiator_user_id` (`initiator_user_id`),
|
||||||
KEY `idx_device_bind_sessions_target_child_id` (`target_child_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_expires_at` (`expires_at`),
|
||||||
|
KEY `idx_device_bind_sessions_card_uuid` (`card_uuid`),
|
||||||
CONSTRAINT `fk_device_bind_sessions_device`
|
CONSTRAINT `fk_device_bind_sessions_device`
|
||||||
FOREIGN KEY (`device_id`)
|
FOREIGN KEY (`device_id`)
|
||||||
REFERENCES `device_auth` (`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,
|
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
PRIMARY KEY (`card_id`),
|
PRIMARY KEY (`card_id`),
|
||||||
UNIQUE KEY `uq_cards_card_uuid` (`card_uuid`),
|
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`),
|
KEY `idx_cards_status` (`status`),
|
||||||
CONSTRAINT `fk_cards_device`
|
CONSTRAINT `fk_cards_device`
|
||||||
FOREIGN KEY (`device_id`)
|
FOREIGN KEY (`device_id`)
|
||||||
|
|||||||
@@ -100,6 +100,8 @@ class DeviceBindSession(Base):
|
|||||||
max_attempt_count: Mapped[int] = mapped_column(Integer, server_default="5")
|
max_attempt_count: Mapped[int] = mapped_column(Integer, server_default="5")
|
||||||
attempt_count: Mapped[int] = mapped_column(Integer, server_default="0")
|
attempt_count: Mapped[int] = mapped_column(Integer, server_default="0")
|
||||||
status: Mapped[int] = mapped_column(Integer, server_default="1")
|
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)
|
confirmed_at: Mapped[Optional[datetime]] = mapped_column(DateTime)
|
||||||
consumed_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"))
|
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_id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
card_uuid: Mapped[str] = mapped_column(String(64), unique=True, nullable=False)
|
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))
|
card_name: Mapped[Optional[str]] = mapped_column(String(64))
|
||||||
status: Mapped[int] = mapped_column(Integer, server_default="0")
|
status: Mapped[int] = mapped_column(Integer, server_default="0")
|
||||||
total_swaps: 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,
|
max_attempt_count TINYINT UNSIGNED NOT NULL DEFAULT 5,
|
||||||
attempt_count TINYINT UNSIGNED NOT NULL DEFAULT 0,
|
attempt_count TINYINT UNSIGNED NOT NULL DEFAULT 0,
|
||||||
status TINYINT NOT NULL DEFAULT 1,
|
status TINYINT NOT NULL DEFAULT 1,
|
||||||
|
bind_mode TINYINT NOT NULL DEFAULT 1,
|
||||||
|
card_uuid VARCHAR(64),
|
||||||
confirmed_at DATETIME,
|
confirmed_at DATETIME,
|
||||||
consumed_at DATETIME,
|
consumed_at DATETIME,
|
||||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
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 (
|
CREATE TABLE IF NOT EXISTS cards (
|
||||||
card_id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
card_id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||||
card_uuid VARCHAR(64) UNIQUE NOT NULL,
|
card_uuid VARCHAR(64) UNIQUE NOT NULL,
|
||||||
device_id VARCHAR(64) UNIQUE,
|
device_id VARCHAR(64),
|
||||||
card_name VARCHAR(64),
|
card_name VARCHAR(64),
|
||||||
status TINYINT NOT NULL DEFAULT 0,
|
status TINYINT NOT NULL DEFAULT 0,
|
||||||
total_swaps INT 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_FAILED = 4
|
||||||
SESSION_STATUS_CANCELLED = 5
|
SESSION_STATUS_CANCELLED = 5
|
||||||
|
|
||||||
|
BIND_MODE_DEVICE = 1
|
||||||
|
BIND_MODE_CARD = 2
|
||||||
|
|
||||||
BIND_SOURCE_SESSION_CONFIRM = 1
|
BIND_SOURCE_SESSION_CONFIRM = 1
|
||||||
BIND_SOURCE_DIRECT = 2
|
BIND_SOURCE_DIRECT = 2
|
||||||
BIND_SOURCE_SET_CHILD = 3
|
BIND_SOURCE_SET_CHILD = 3
|
||||||
@@ -205,7 +208,13 @@ class BindingDAO(BaseDAO):
|
|||||||
{"device_id": device_id, "user_id": user_id},
|
{"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())
|
bind_token = str(uuid.uuid4())
|
||||||
expires_at = datetime.utcnow() + timedelta(minutes=10)
|
expires_at = datetime.utcnow() + timedelta(minutes=10)
|
||||||
|
|
||||||
@@ -227,8 +236,24 @@ class BindingDAO(BaseDAO):
|
|||||||
|
|
||||||
await self.execute(
|
await self.execute(
|
||||||
"""
|
"""
|
||||||
INSERT INTO device_bind_sessions (bind_token, device_id, initiator_user_id, target_child_id, expires_at, status)
|
INSERT INTO device_bind_sessions (
|
||||||
VALUES (:bind_token, :device_id, :initiator_user_id, :target_child_id, :expires_at, :status)
|
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,
|
"bind_token": bind_token,
|
||||||
@@ -237,6 +262,7 @@ class BindingDAO(BaseDAO):
|
|||||||
"target_child_id": child_id,
|
"target_child_id": child_id,
|
||||||
"expires_at": expires_at,
|
"expires_at": expires_at,
|
||||||
"status": SESSION_STATUS_PENDING,
|
"status": SESSION_STATUS_PENDING,
|
||||||
|
"bind_mode": bind_mode,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
return bind_token, expires_at
|
return bind_token, expires_at
|
||||||
@@ -246,17 +272,8 @@ class BindingDAO(BaseDAO):
|
|||||||
await self.execute(
|
await self.execute(
|
||||||
"""
|
"""
|
||||||
SELECT
|
SELECT
|
||||||
s.*,
|
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
|
|
||||||
FROM device_bind_sessions AS 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
|
WHERE s.bind_token = :bind_token
|
||||||
AND s.initiator_user_id = :user_id
|
AND s.initiator_user_id = :user_id
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
@@ -264,7 +281,6 @@ class BindingDAO(BaseDAO):
|
|||||||
{
|
{
|
||||||
"bind_token": bind_token,
|
"bind_token": bind_token,
|
||||||
"user_id": user_id,
|
"user_id": user_id,
|
||||||
"completed_status": SESSION_STATUS_COMPLETED,
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
).mappings().first()
|
).mappings().first()
|
||||||
@@ -321,7 +337,14 @@ class BindingDAO(BaseDAO):
|
|||||||
bind_source=BIND_SOURCE_SESSION_CONFIRM,
|
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:
|
if child_id is not None:
|
||||||
await self._upsert_parent_child_relation(user_id=user_id, child_id=child_id)
|
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
|
UPDATE device_bind_sessions
|
||||||
SET status = :status,
|
SET status = :status,
|
||||||
|
card_uuid = :card_uuid,
|
||||||
confirmed_at = CURRENT_TIMESTAMP,
|
confirmed_at = CURRENT_TIMESTAMP,
|
||||||
consumed_at = CURRENT_TIMESTAMP,
|
consumed_at = CURRENT_TIMESTAMP,
|
||||||
updated_at = CURRENT_TIMESTAMP
|
updated_at = CURRENT_TIMESTAMP
|
||||||
WHERE id = :id
|
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)
|
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,
|
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:
|
async def direct_bind(self, device_id: str, child_id: Optional[int], user_id: int) -> None:
|
||||||
if child_id is not None:
|
if child_id is not None:
|
||||||
await self._upsert_parent_child_relation(user_id=user_id, child_id=child_id)
|
await self._upsert_parent_child_relation(user_id=user_id, child_id=child_id)
|
||||||
|
|||||||
@@ -31,6 +31,10 @@ class BindStartResponse(BaseModel):
|
|||||||
status: int
|
status: int
|
||||||
|
|
||||||
|
|
||||||
|
class CardBindStartRequest(BaseModel):
|
||||||
|
device_id: str
|
||||||
|
|
||||||
|
|
||||||
class BindSessionResponse(BaseModel):
|
class BindSessionResponse(BaseModel):
|
||||||
bind_token: str
|
bind_token: str
|
||||||
device_id: 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)
|
@router.get("/sessions/{bind_token}", response_model=BindSessionResponse)
|
||||||
async def get_bind_session(
|
async def get_bind_session(
|
||||||
bind_token: str,
|
bind_token: str,
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ from datetime import datetime
|
|||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from banban.dao.binding import (
|
from banban.dao.binding import (
|
||||||
|
BIND_MODE_CARD,
|
||||||
|
BIND_MODE_DEVICE,
|
||||||
SESSION_STATUS_CANCELLED,
|
SESSION_STATUS_CANCELLED,
|
||||||
SESSION_STATUS_COMPLETED,
|
SESSION_STATUS_COMPLETED,
|
||||||
SESSION_STATUS_EXPIRED,
|
SESSION_STATUS_EXPIRED,
|
||||||
@@ -41,6 +43,14 @@ class BindingService(DatabaseServiceBase):
|
|||||||
if int(row["is_active"]) != 1:
|
if int(row["is_active"]) != 1:
|
||||||
raise BindingError("device is inactive", status_code=400)
|
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:
|
def _normalize_session_status(self, session: Mapping) -> int:
|
||||||
status = int(session["status"])
|
status = int(session["status"])
|
||||||
if status == SESSION_STATUS_PENDING and datetime.utcnow() > session["expires_at"]:
|
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_bindable_device(db_session, device_id, serial_number)
|
||||||
await self._ensure_device_unbound(db_session, device_id)
|
await self._ensure_device_unbound(db_session, device_id)
|
||||||
dao = BindingDAO(db_session)
|
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()
|
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()
|
async def start_card_bind(self, user_id: int, device_id: str) -> tuple[str, datetime]:
|
||||||
if service is None:
|
db_session = await self.get_session()
|
||||||
raise BindingError("MQTT service is unavailable", status_code=503)
|
try:
|
||||||
await service.send_bind_nfc_command(device_id)
|
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
|
return bind_token, expires_at
|
||||||
finally:
|
finally:
|
||||||
await db_session.close()
|
await db_session.close()
|
||||||
@@ -132,17 +154,22 @@ class BindingService(DatabaseServiceBase):
|
|||||||
}
|
}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
await card_service.activate_card(
|
card = await card_service.activate_card(
|
||||||
card_uuid=card_uuid,
|
card_uuid=card_uuid,
|
||||||
device_id=device_id,
|
device_id=device_id,
|
||||||
db_session=db_session,
|
db_session=db_session,
|
||||||
)
|
)
|
||||||
await dao.complete_nfc_bind(
|
bind_mode = int(session.get("bind_mode") or BIND_MODE_DEVICE)
|
||||||
session_id=int(session["id"]),
|
if bind_mode == BIND_MODE_CARD:
|
||||||
device_id=device_id,
|
await dao.complete_card_bind(session_id=int(session["id"]), card_uuid=card.card_uuid)
|
||||||
child_id=session["target_child_id"],
|
else:
|
||||||
user_id=int(session["initiator_user_id"]),
|
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()
|
await db_session.commit()
|
||||||
except Exception:
|
except Exception:
|
||||||
await dao.mark_session_status(int(session["id"]), SESSION_STATUS_FAILED)
|
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:
|
async def _ensure_device_family_tables(conn) -> None:
|
||||||
await conn.execute(
|
await conn.execute(
|
||||||
text(
|
text(
|
||||||
@@ -222,6 +277,8 @@ async def init_db():
|
|||||||
await _ensure_schedule_suppressed_until_column(conn)
|
await _ensure_schedule_suppressed_until_column(conn)
|
||||||
await _ensure_device_alarm_location_columns(conn)
|
await _ensure_device_alarm_location_columns(conn)
|
||||||
await _ensure_child_location_address_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 _ensure_device_family_tables(conn)
|
||||||
await engine.dispose()
|
await engine.dispose()
|
||||||
session_logger.info("system", "database", "数据库表已成功创建")
|
session_logger.info("system", "database", "数据库表已成功创建")
|
||||||
|
|||||||
@@ -285,6 +285,8 @@ class DeviceBindSession(Base):
|
|||||||
max_attempt_count: Mapped[int] = mapped_column(Integer, server_default="5")
|
max_attempt_count: Mapped[int] = mapped_column(Integer, server_default="5")
|
||||||
attempt_count: Mapped[int] = mapped_column(Integer, server_default="0")
|
attempt_count: Mapped[int] = mapped_column(Integer, server_default="0")
|
||||||
status: Mapped[int] = mapped_column(Integer, server_default="1")
|
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)
|
confirmed_at: Mapped[Optional[datetime]] = mapped_column(DateTime)
|
||||||
consumed_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"))
|
created_at: Mapped[Optional[datetime]] = mapped_column(DateTime, server_default=text("CURRENT_TIMESTAMP"))
|
||||||
|
|||||||
@@ -475,7 +475,7 @@ class TalkingQMQTTService:
|
|||||||
await self._publish(topic, payload)
|
await self._publish(topic, payload)
|
||||||
return
|
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:
|
if is_owner:
|
||||||
playback_items = await pending_voice_message_service.get_playback_items(
|
playback_items = await pending_voice_message_service.get_playback_items(
|
||||||
target_device_id=device_id,
|
target_device_id=device_id,
|
||||||
@@ -534,10 +534,17 @@ class TalkingQMQTTService:
|
|||||||
return
|
return
|
||||||
|
|
||||||
existing_card = await card_service.get_card_by_uuid(nfc_uuid)
|
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
|
target_device_id = existing_card.device_id
|
||||||
else:
|
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
|
return
|
||||||
|
|
||||||
await device_target_cache.set_target(device_id, target_device_id)
|
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)
|
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
|
# 卡片已存在,使用卡片绑定的设备ID作为目标设备ID
|
||||||
target_device_id = existing_card.device_id
|
target_device_id = existing_card.device_id
|
||||||
session_logger.info(device_id, "card", f"卡片已存在,绑定的设备ID: {target_device_id}")
|
session_logger.info(device_id, "card", f"卡片已存在,绑定的设备ID: {target_device_id}")
|
||||||
else:
|
else:
|
||||||
# 卡片不存在,创建新卡片并绑定到当前设备
|
await websocket.send_text(
|
||||||
new_card = await card_service.activate_card(card_uuid, device_id)
|
f"TARGET_DEVICE_REGISTERED_URL:http://{settings.server_host}:{settings.server_port}/assets/audio/error_card.mp3"
|
||||||
session_logger.info(device_id, "card", f"新卡片{card_uuid}已创建并激活,绑定到设备: {device_id}")
|
)
|
||||||
|
session_logger.warning(device_id, "card", f"未知卡片不能自动绑定: {card_uuid}")
|
||||||
return
|
return
|
||||||
|
|
||||||
# 设置目标设备
|
# 设置目标设备
|
||||||
|
|||||||
@@ -341,6 +341,8 @@ CREATE TABLE IF NOT EXISTS `device_bind_sessions` (
|
|||||||
`max_attempt_count` TINYINT UNSIGNED NOT NULL DEFAULT 5,
|
`max_attempt_count` TINYINT UNSIGNED NOT NULL DEFAULT 5,
|
||||||
`attempt_count` TINYINT UNSIGNED NOT NULL DEFAULT 0,
|
`attempt_count` TINYINT UNSIGNED NOT NULL DEFAULT 0,
|
||||||
`status` TINYINT NOT NULL DEFAULT 1,
|
`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,
|
`confirmed_at` DATETIME NULL,
|
||||||
`consumed_at` DATETIME NULL,
|
`consumed_at` DATETIME NULL,
|
||||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
`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_initiator_user_id` (`initiator_user_id`),
|
||||||
KEY `idx_device_bind_sessions_target_child_id` (`target_child_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_expires_at` (`expires_at`),
|
||||||
|
KEY `idx_device_bind_sessions_card_uuid` (`card_uuid`),
|
||||||
CONSTRAINT `fk_device_bind_sessions_device`
|
CONSTRAINT `fk_device_bind_sessions_device`
|
||||||
FOREIGN KEY (`device_id`)
|
FOREIGN KEY (`device_id`)
|
||||||
REFERENCES `device_auth` (`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,
|
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
PRIMARY KEY (`card_id`),
|
PRIMARY KEY (`card_id`),
|
||||||
UNIQUE KEY `uq_cards_card_uuid` (`card_uuid`),
|
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`),
|
KEY `idx_cards_status` (`status`),
|
||||||
CONSTRAINT `fk_cards_device`
|
CONSTRAINT `fk_cards_device`
|
||||||
FOREIGN KEY (`device_id`)
|
FOREIGN KEY (`device_id`)
|
||||||
|
|||||||
@@ -60,32 +60,6 @@ class CardService(DatabaseServiceBase):
|
|||||||
session_logger.error("card", "service", f"load card failed: {exc}")
|
session_logger.error("card", "service", f"load card failed: {exc}")
|
||||||
return None
|
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:
|
async def _save_card_to_db(self, card: Card, async_session: AsyncSession, commit: bool = True) -> None:
|
||||||
try:
|
try:
|
||||||
query = select(DBCard).where(DBCard.card_uuid == card.card_uuid)
|
query = select(DBCard).where(DBCard.card_uuid == card.card_uuid)
|
||||||
@@ -182,9 +156,10 @@ class CardService(DatabaseServiceBase):
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
existing_card = await self.get_card_by_uuid(card_uuid, db_session=db_session)
|
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:
|
||||||
|
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.device_id = device_id
|
||||||
existing_card.card_name = card_name
|
existing_card.card_name = card_name
|
||||||
existing_card.status = 1
|
existing_card.status = 1
|
||||||
@@ -230,5 +205,9 @@ class CardService(DatabaseServiceBase):
|
|||||||
card = await self.get_card_by_uuid(card_uuid)
|
card = await self.get_card_by_uuid(card_uuid)
|
||||||
return bool(card and card.device_id == device_id)
|
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()
|
card_service = CardService()
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ from datetime import datetime, timedelta
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from banban.dao.binding import SESSION_STATUS_COMPLETED, SESSION_STATUS_EXPIRED
|
from banban.dao.binding import BIND_MODE_CARD, BIND_MODE_DEVICE, SESSION_STATUS_COMPLETED, SESSION_STATUS_EXPIRED
|
||||||
from banban.service.binding import BindingService
|
from banban.service.binding import BindingError, BindingService
|
||||||
|
|
||||||
|
|
||||||
class FakeSession:
|
class FakeSession:
|
||||||
@@ -22,6 +22,80 @@ class FakeSession:
|
|||||||
self.closed = True
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_finalize_nfc_bind_expires_late_card_without_binding(monkeypatch):
|
async def test_finalize_nfc_bind_expires_late_card_without_binding(monkeypatch):
|
||||||
service = BindingService()
|
service = BindingService()
|
||||||
@@ -92,6 +166,7 @@ async def test_finalize_nfc_bind_completes_before_expiry(monkeypatch):
|
|||||||
"target_child_id": 7,
|
"target_child_id": 7,
|
||||||
"initiator_user_id": 99,
|
"initiator_user_id": 99,
|
||||||
"expires_at": datetime.utcnow() + timedelta(minutes=10),
|
"expires_at": datetime.utcnow() + timedelta(minutes=10),
|
||||||
|
"bind_mode": BIND_MODE_DEVICE,
|
||||||
}
|
}
|
||||||
marked_statuses = []
|
marked_statuses = []
|
||||||
activated_cards = []
|
activated_cards = []
|
||||||
@@ -110,6 +185,11 @@ async def test_finalize_nfc_bind_completes_before_expiry(monkeypatch):
|
|||||||
async def fake_activate_card(**kwargs):
|
async def fake_activate_card(**kwargs):
|
||||||
activated_cards.append(kwargs)
|
activated_cards.append(kwargs)
|
||||||
|
|
||||||
|
class FakeCard:
|
||||||
|
card_uuid = kwargs["card_uuid"]
|
||||||
|
|
||||||
|
return FakeCard()
|
||||||
|
|
||||||
async def fake_complete_nfc_bind(self, **kwargs):
|
async def fake_complete_nfc_bind(self, **kwargs):
|
||||||
completed_sessions.append(kwargs)
|
completed_sessions.append(kwargs)
|
||||||
|
|
||||||
@@ -148,8 +228,86 @@ async def test_finalize_nfc_bind_completes_before_expiry(monkeypatch):
|
|||||||
"device_id": "TalkingQ_device001",
|
"device_id": "TalkingQ_device001",
|
||||||
"child_id": 7,
|
"child_id": 7,
|
||||||
"user_id": 99,
|
"user_id": 99,
|
||||||
|
"card_uuid": "CARD_UUID_ON_TIME",
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
assert db_session.commits == 1
|
assert db_session.commits == 1
|
||||||
assert db_session.rollbacks == 0
|
assert db_session.rollbacks == 0
|
||||||
assert db_session.closed is True
|
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