feat(binding): support qr scan and nfc card binding flow
This commit is contained in:
@@ -218,6 +218,13 @@
|
|||||||
color: #D46B08;
|
color: #D46B08;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.pending-tip-subtext {
|
||||||
|
display: block;
|
||||||
|
margin-top: 8px;
|
||||||
|
font-size: 24px;
|
||||||
|
color: #8C5400;
|
||||||
|
}
|
||||||
|
|
||||||
.child-list {
|
.child-list {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
|||||||
@@ -1,11 +1,31 @@
|
|||||||
import { View, Text, Image, Input, Button } from '@tarojs/components'
|
import { View, Text, Image, Input, Button } from '@tarojs/components'
|
||||||
import { useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import Taro, { useDidShow } from '@tarojs/taro'
|
import Taro, { useDidShow, useDidHide } from '@tarojs/taro'
|
||||||
|
|
||||||
import { getToken } from '@/services/auth'
|
import { getToken } from '@/services/auth'
|
||||||
import { directBind, resolveActiveBinding, setBindingChild, setSelectedBindingDeviceId } from '@/services/binding'
|
import {
|
||||||
import { Child, createChild, getChildren } from '@/services/child'
|
getNFCBindSession,
|
||||||
|
resolveActiveBinding,
|
||||||
|
setBindingChild,
|
||||||
|
setSelectedBindingDeviceId,
|
||||||
|
startNFCBind,
|
||||||
|
} from '@/services/binding'
|
||||||
|
import {
|
||||||
|
Child,
|
||||||
|
createChild,
|
||||||
|
getChildren,
|
||||||
|
resolveChildSelection,
|
||||||
|
setSelectedChildId as setStoredSelectedChildId,
|
||||||
|
} from '@/services/child'
|
||||||
|
|
||||||
import './index.scss'
|
import './index.scss'
|
||||||
|
|
||||||
|
const SESSION_STATUS_PENDING = 1
|
||||||
|
const SESSION_STATUS_COMPLETED = 2
|
||||||
|
const SESSION_STATUS_EXPIRED = 3
|
||||||
|
const SESSION_STATUS_FAILED = 4
|
||||||
|
const SESSION_STATUS_CANCELLED = 5
|
||||||
|
|
||||||
function parseBindingPayload(rawValue: string): { deviceId: string; serialNumber: string } {
|
function parseBindingPayload(rawValue: string): { deviceId: string; serialNumber: string } {
|
||||||
const raw = String(rawValue || '').trim()
|
const raw = String(rawValue || '').trim()
|
||||||
if (!raw) return { deviceId: '', serialNumber: '' }
|
if (!raw) return { deviceId: '', serialNumber: '' }
|
||||||
@@ -71,15 +91,99 @@ export default function Bind() {
|
|||||||
const [selectedChildId, setSelectedChildId] = useState<number | null>(null)
|
const [selectedChildId, setSelectedChildId] = useState<number | null>(null)
|
||||||
const [newChildName, setNewChildName] = useState('')
|
const [newChildName, setNewChildName] = useState('')
|
||||||
const [pendingDeviceId, setPendingDeviceId] = useState<string | null>(null)
|
const [pendingDeviceId, setPendingDeviceId] = useState<string | null>(null)
|
||||||
|
const [bindToken, setBindToken] = useState('')
|
||||||
|
const [bindStatus, setBindStatus] = useState<number | null>(null)
|
||||||
|
const [cardUUID, setCardUUID] = useState('')
|
||||||
|
const [bindHint, setBindHint] = useState('')
|
||||||
|
const pollingRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||||
|
|
||||||
useDidShow(() => {
|
useDidShow(() => {
|
||||||
void loadPageData()
|
void loadPageData()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
useDidHide(() => {
|
||||||
|
stopPolling()
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => () => stopPolling(), [])
|
||||||
|
|
||||||
|
const hasSelectedOrNewChild = selectedChildId !== null || Boolean(newChildName.trim())
|
||||||
|
const isPendingBinding = Boolean(pendingDeviceId)
|
||||||
|
const isPollingBind = bindStatus === SESSION_STATUS_PENDING && Boolean(bindToken)
|
||||||
|
const canEditDeviceFields = hasSelectedOrNewChild && !isPollingBind
|
||||||
|
|
||||||
const goDevicePage = () => {
|
const goDevicePage = () => {
|
||||||
Taro.reLaunch({ url: '/pages/device/index' })
|
Taro.reLaunch({ url: '/pages/device/index' })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const stopPolling = () => {
|
||||||
|
if (pollingRef.current) {
|
||||||
|
clearTimeout(pollingRef.current)
|
||||||
|
pollingRef.current = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const schedulePoll = () => {
|
||||||
|
stopPolling()
|
||||||
|
pollingRef.current = setTimeout(() => {
|
||||||
|
void pollBindSession()
|
||||||
|
}, 1500)
|
||||||
|
}
|
||||||
|
|
||||||
|
const resetBindSessionState = () => {
|
||||||
|
stopPolling()
|
||||||
|
setBindToken('')
|
||||||
|
setBindStatus(null)
|
||||||
|
setCardUUID('')
|
||||||
|
setBindHint('')
|
||||||
|
}
|
||||||
|
|
||||||
|
const pollBindSession = async () => {
|
||||||
|
if (!bindToken) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const session = await getNFCBindSession(bindToken)
|
||||||
|
setBindStatus(session.status)
|
||||||
|
setCardUUID(session.card_uuid || '')
|
||||||
|
|
||||||
|
if (session.status === SESSION_STATUS_PENDING) {
|
||||||
|
setBindHint('已发送绑卡指令,请拿自己的卡去设备上贴一下')
|
||||||
|
schedulePoll()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
stopPolling()
|
||||||
|
|
||||||
|
if (session.status === SESSION_STATUS_COMPLETED) {
|
||||||
|
setSelectedBindingDeviceId(session.device_id)
|
||||||
|
setBindHint(session.card_uuid ? `绑定完成,卡号 ${session.card_uuid}` : '绑定完成')
|
||||||
|
Taro.showToast({ title: '设备绑定成功', icon: 'success' })
|
||||||
|
setTimeout(() => {
|
||||||
|
goDevicePage()
|
||||||
|
}, 500)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (session.status === SESSION_STATUS_EXPIRED) {
|
||||||
|
setBindHint('绑定会话已过期,请重新扫码并贴卡')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (session.status === SESSION_STATUS_FAILED) {
|
||||||
|
setBindHint('贴卡绑定失败,请重试')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (session.status === SESSION_STATUS_CANCELLED) {
|
||||||
|
setBindHint('当前绑定已被新的绑定流程替代,请重新扫码')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('[bind] poll bind session failed:', error)
|
||||||
|
setBindHint(error?.message || '查询绑定状态失败,请重试')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const loadPageData = async () => {
|
const loadPageData = async () => {
|
||||||
if (!getToken()) {
|
if (!getToken()) {
|
||||||
Taro.reLaunch({ url: '/pages/login/index' })
|
Taro.reLaunch({ url: '/pages/login/index' })
|
||||||
@@ -90,17 +194,15 @@ export default function Bind() {
|
|||||||
try {
|
try {
|
||||||
const [childResponse, activeBinding] = await Promise.all([getChildren(), resolveActiveBinding()])
|
const [childResponse, activeBinding] = await Promise.all([getChildren(), resolveActiveBinding()])
|
||||||
const currentChildren = childResponse.items || []
|
const currentChildren = childResponse.items || []
|
||||||
|
const currentChild = resolveChildSelection(currentChildren)
|
||||||
setChildren(currentChildren)
|
setChildren(currentChildren)
|
||||||
setSelectedChildId((currentSelectedChildId) => currentSelectedChildId || currentChildren[0]?.child_id || null)
|
setSelectedChildId(currentChild?.child_id || null)
|
||||||
|
|
||||||
if (activeBinding?.device_id && !activeBinding.child_id) {
|
if (activeBinding?.device_id && !activeBinding.child_id) {
|
||||||
setPendingDeviceId(activeBinding.device_id)
|
setPendingDeviceId(activeBinding.device_id)
|
||||||
setDeviceId(activeBinding.device_id)
|
setDeviceId(activeBinding.device_id)
|
||||||
} else {
|
} else {
|
||||||
setPendingDeviceId(null)
|
setPendingDeviceId(null)
|
||||||
setDeviceId('')
|
|
||||||
setSerialNumber('')
|
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('[bind] load failed:', error)
|
console.error('[bind] load failed:', error)
|
||||||
@@ -122,13 +224,21 @@ export default function Bind() {
|
|||||||
const child = await createChild({ child_name: childName })
|
const child = await createChild({ child_name: childName })
|
||||||
setChildren((currentChildren) => [child, ...currentChildren])
|
setChildren((currentChildren) => [child, ...currentChildren])
|
||||||
setSelectedChildId(child.child_id)
|
setSelectedChildId(child.child_id)
|
||||||
|
setStoredSelectedChildId(child.child_id)
|
||||||
setNewChildName('')
|
setNewChildName('')
|
||||||
return child.child_id
|
return child.child_id
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleScanCode = () => {
|
const handleScanCode = () => {
|
||||||
setIsScanning(true)
|
if (!canEditDeviceFields) {
|
||||||
|
Taro.showToast({
|
||||||
|
title: '请先选择或新建儿童资料',
|
||||||
|
icon: 'none',
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsScanning(true)
|
||||||
Taro.scanCode({
|
Taro.scanCode({
|
||||||
onlyFromCamera: true,
|
onlyFromCamera: true,
|
||||||
scanType: ['qrCode', 'barCode'],
|
scanType: ['qrCode', 'barCode'],
|
||||||
@@ -145,11 +255,7 @@ export default function Bind() {
|
|||||||
},
|
},
|
||||||
fail: (err) => {
|
fail: (err) => {
|
||||||
setIsScanning(false)
|
setIsScanning(false)
|
||||||
|
if (err.errMsg && err.errMsg.includes('cancel')) return
|
||||||
if (err.errMsg && err.errMsg.includes('cancel')) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
Taro.showToast({
|
Taro.showToast({
|
||||||
title: '扫码失败,请重试',
|
title: '扫码失败,请重试',
|
||||||
icon: 'none',
|
icon: 'none',
|
||||||
@@ -159,22 +265,21 @@ export default function Bind() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
if (submitting) return
|
if (submitting || isPollingBind) return
|
||||||
|
|
||||||
setSubmitting(true)
|
setSubmitting(true)
|
||||||
Taro.showLoading({ title: pendingDeviceId ? '关联中...' : '绑定中...' })
|
Taro.showLoading({ title: isPendingBinding ? '关联中...' : '发送绑卡指令...' })
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const childId = await ensureChildId()
|
const childId = await ensureChildId()
|
||||||
|
|
||||||
if (pendingDeviceId) {
|
|
||||||
if (!childId) {
|
if (!childId) {
|
||||||
throw new Error('请选择儿童或填写新儿童昵称')
|
throw new Error('请先选择儿童或填写新的儿童昵称')
|
||||||
}
|
}
|
||||||
|
|
||||||
await setBindingChild(pendingDeviceId, { child_id: childId })
|
if (isPendingBinding) {
|
||||||
|
await setBindingChild(pendingDeviceId!, { child_id: childId })
|
||||||
setSelectedBindingDeviceId(pendingDeviceId)
|
setSelectedBindingDeviceId(pendingDeviceId)
|
||||||
Taro.showToast({ title: '绑定完成', icon: 'success' })
|
Taro.showToast({ title: '关联完成', icon: 'success' })
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
goDevicePage()
|
goDevicePage()
|
||||||
}, 300)
|
}, 300)
|
||||||
@@ -188,30 +293,21 @@ export default function Bind() {
|
|||||||
throw new Error('请输入设备序列号')
|
throw new Error('请输入设备序列号')
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await directBind({
|
resetBindSessionState()
|
||||||
|
const session = await startNFCBind({
|
||||||
device_id: deviceId.trim(),
|
device_id: deviceId.trim(),
|
||||||
serial_number: serialNumber.trim(),
|
serial_number: serialNumber.trim(),
|
||||||
...(childId ? { child_id: childId } : {}),
|
child_id: childId,
|
||||||
})
|
})
|
||||||
|
|
||||||
setSelectedBindingDeviceId(result.device_id)
|
setBindToken(session.bind_token)
|
||||||
|
setBindStatus(session.status)
|
||||||
if (childId || result.child_id) {
|
setBindHint('已发送绑卡指令,请去设备上贴自己的卡')
|
||||||
Taro.showToast({ title: '设备绑定成功', icon: 'success' })
|
schedulePoll()
|
||||||
setTimeout(() => {
|
Taro.showToast({ title: '请去设备上贴卡', icon: 'none' })
|
||||||
goDevicePage()
|
|
||||||
}, 300)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
setPendingDeviceId(result.device_id)
|
|
||||||
setDeviceId(result.device_id)
|
|
||||||
Taro.showToast({
|
|
||||||
title: '设备已绑定,请继续关联儿童',
|
|
||||||
icon: 'none',
|
|
||||||
})
|
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('[bind] submit failed:', error)
|
console.error('[bind] submit failed:', error)
|
||||||
|
setBindHint(error?.message || '绑定失败,请重试')
|
||||||
Taro.showToast({
|
Taro.showToast({
|
||||||
title: error?.message || '绑定失败,请重试',
|
title: error?.message || '绑定失败,请重试',
|
||||||
icon: 'none',
|
icon: 'none',
|
||||||
@@ -235,23 +331,25 @@ export default function Bind() {
|
|||||||
return (
|
return (
|
||||||
<View className='bind-page'>
|
<View className='bind-page'>
|
||||||
<View className='bind-header'>
|
<View className='bind-header'>
|
||||||
<Text className='title'>{pendingDeviceId ? '补全绑定' : '绑定设备'}</Text>
|
<Text className='title'>{isPendingBinding ? '补全绑定' : '扫码贴卡绑定设备'}</Text>
|
||||||
<Text className='subtitle'>
|
<Text className='subtitle'>
|
||||||
{pendingDeviceId ? '设备已绑到当前账号,请补充儿童资料完成配置' : '扫描二维码或手动填写设备号和序列号'}
|
{isPendingBinding
|
||||||
|
? '当前设备已存在待补全关系,请先补充儿童资料'
|
||||||
|
: '先选择儿童,再扫码设备,系统会下发绑卡指令,随后去设备上贴卡确认'}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View className='scan-area'>
|
<View className='scan-area'>
|
||||||
<View className='scan-frame' onClick={handleScanCode}>
|
<View className='scan-frame' onClick={handleScanCode}>
|
||||||
<View className='icon-bg orange'>
|
<View className='icon-bg orange'>
|
||||||
<Image
|
<Image className='control-icon-img' src={require('../../assets/tab-icons/rings.png')} mode='aspectFit' />
|
||||||
className='control-icon-img'
|
|
||||||
src={require('../../assets/tab-icons/rings.png')}
|
|
||||||
mode='aspectFit'
|
|
||||||
/>
|
|
||||||
</View>
|
</View>
|
||||||
<Text className='scan-text'>{isScanning ? '正在扫码...' : '点击扫码'}</Text>
|
<Text className='scan-text'>
|
||||||
<Text className='scan-hint'>请将二维码放入框内</Text>
|
{!canEditDeviceFields ? '请先完成儿童资料' : isScanning ? '正在扫码...' : '点击扫码'}
|
||||||
|
</Text>
|
||||||
|
<Text className='scan-hint'>
|
||||||
|
{!canEditDeviceFields ? '先选儿童,再扫设备二维码' : '扫描设备二维码或条码'}
|
||||||
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View className='scan-corners'>
|
<View className='scan-corners'>
|
||||||
@@ -264,39 +362,7 @@ export default function Bind() {
|
|||||||
|
|
||||||
<View className='bind-form'>
|
<View className='bind-form'>
|
||||||
<View className='form-card'>
|
<View className='form-card'>
|
||||||
<Text className='form-title'>设备信息</Text>
|
<Text className='form-title'>儿童资料</Text>
|
||||||
|
|
||||||
<View className='field-item'>
|
|
||||||
<Text className='field-label'>设备号</Text>
|
|
||||||
<Input
|
|
||||||
className='field-input'
|
|
||||||
value={deviceId}
|
|
||||||
disabled={!!pendingDeviceId}
|
|
||||||
placeholder='请输入设备号'
|
|
||||||
onInput={(event) => setDeviceId(event.detail.value)}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<View className='field-item'>
|
|
||||||
<Text className='field-label'>设备序列号</Text>
|
|
||||||
<Input
|
|
||||||
className='field-input'
|
|
||||||
value={serialNumber}
|
|
||||||
disabled={!!pendingDeviceId}
|
|
||||||
placeholder='请输入设备序列号'
|
|
||||||
onInput={(event) => setSerialNumber(event.detail.value)}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{pendingDeviceId && (
|
|
||||||
<View className='pending-tip'>
|
|
||||||
<Text className='pending-tip-text'>当前待完成设备:{pendingDeviceId}</Text>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<View className='form-card'>
|
|
||||||
<Text className='form-title'>儿童信息</Text>
|
|
||||||
|
|
||||||
{children.length > 0 && (
|
{children.length > 0 && (
|
||||||
<View className='child-list'>
|
<View className='child-list'>
|
||||||
@@ -305,7 +371,11 @@ export default function Bind() {
|
|||||||
key={child.child_id}
|
key={child.child_id}
|
||||||
className={`child-item ${selectedChildId === child.child_id ? 'selected' : ''}`}
|
className={`child-item ${selectedChildId === child.child_id ? 'selected' : ''}`}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setSelectedChildId((currentChildId) => (currentChildId === child.child_id ? null : child.child_id))
|
setSelectedChildId((currentChildId) => {
|
||||||
|
const nextChildId = currentChildId === child.child_id ? null : child.child_id
|
||||||
|
setStoredSelectedChildId(nextChildId)
|
||||||
|
return nextChildId
|
||||||
|
})
|
||||||
setNewChildName('')
|
setNewChildName('')
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -325,16 +395,65 @@ export default function Bind() {
|
|||||||
setNewChildName(event.detail.value)
|
setNewChildName(event.detail.value)
|
||||||
if (event.detail.value) {
|
if (event.detail.value) {
|
||||||
setSelectedChildId(null)
|
setSelectedChildId(null)
|
||||||
|
setStoredSelectedChildId(null)
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<Text className='field-hint'>优先选择已有儿童,也可以直接输入一个新昵称。</Text>
|
<Text className='field-hint'>
|
||||||
|
{children.length > 0
|
||||||
|
? '可以选已有儿童,也可以在这里新建一个儿童后再绑定设备'
|
||||||
|
: '当前还没有儿童资料,请先创建一个儿童资料'}
|
||||||
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<Button className='submit-btn' loading={submitting} disabled={submitting} onClick={handleSubmit}>
|
<View className='form-card'>
|
||||||
{pendingDeviceId ? '完成儿童关联' : '绑定设备'}
|
<Text className='form-title'>设备信息</Text>
|
||||||
|
|
||||||
|
<View className='field-item'>
|
||||||
|
<Text className='field-label'>设备号</Text>
|
||||||
|
<Input
|
||||||
|
className='field-input'
|
||||||
|
value={deviceId}
|
||||||
|
disabled={!canEditDeviceFields || isPendingBinding}
|
||||||
|
placeholder={canEditDeviceFields ? '请输入设备号' : '请先选择儿童资料'}
|
||||||
|
onInput={(event) => setDeviceId(event.detail.value)}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className='field-item'>
|
||||||
|
<Text className='field-label'>设备序列号</Text>
|
||||||
|
<Input
|
||||||
|
className='field-input'
|
||||||
|
value={serialNumber}
|
||||||
|
disabled={!canEditDeviceFields || isPendingBinding}
|
||||||
|
placeholder={canEditDeviceFields ? '请输入设备序列号' : '请先选择儿童资料'}
|
||||||
|
onInput={(event) => setSerialNumber(event.detail.value)}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{bindHint ? (
|
||||||
|
<View className='pending-tip'>
|
||||||
|
<Text className='pending-tip-text'>{bindHint}</Text>
|
||||||
|
{cardUUID ? <Text className='pending-tip-subtext'>卡片 UUID:{cardUUID}</Text> : null}
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{isPendingBinding && (
|
||||||
|
<View className='pending-tip'>
|
||||||
|
<Text className='pending-tip-text'>当前待补全设备:{pendingDeviceId}</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
className='submit-btn'
|
||||||
|
loading={submitting}
|
||||||
|
disabled={submitting || !hasSelectedOrNewChild || isPollingBind}
|
||||||
|
onClick={handleSubmit}
|
||||||
|
>
|
||||||
|
{isPendingBinding ? '完成儿童关联' : isPollingBind ? '等待贴卡确认' : '发送绑卡指令'}
|
||||||
</Button>
|
</Button>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
@@ -342,15 +461,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'>先扫描二维码,自动填入设备号和序列号。</Text>
|
<Text className='tip-text'>先选择已有儿童,或者在本页新建一个儿童</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'>如果二维码里只有设备号,请手动补充序列号。</Text>
|
<Text className='tip-text'>扫描设备二维码,确认设备号和序列号正确</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'>没有儿童资料时,可以直接在本页新建并完成绑定。</Text>
|
<Text className='tip-text'>点击发送绑卡指令,然后去设备上贴自己的卡完成确认</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import Taro from '@tarojs/taro'
|
import Taro from '@tarojs/taro'
|
||||||
|
|
||||||
import { request } from './api'
|
import { request } from './api'
|
||||||
|
import { Child, clearSelectedChildId, getChildren, resolveChildSelection, setSelectedChildId } from './child'
|
||||||
|
|
||||||
const SELECTED_BINDING_DEVICE_ID_KEY = 'selectedBindingDeviceId'
|
const SELECTED_BINDING_DEVICE_ID_KEY = 'selectedBindingDeviceId'
|
||||||
|
|
||||||
@@ -26,12 +28,38 @@ export interface BindingListResponse {
|
|||||||
next_cursor?: number | null
|
next_cursor?: number | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface BindingChildContext {
|
||||||
|
currentChild: Child | null
|
||||||
|
currentBinding: Binding | null
|
||||||
|
}
|
||||||
|
|
||||||
export interface DirectBindPayload {
|
export interface DirectBindPayload {
|
||||||
device_id: string
|
device_id: string
|
||||||
serial_number: string
|
serial_number: string
|
||||||
child_id?: number
|
child_id?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface NFCSessionStartPayload {
|
||||||
|
device_id: string
|
||||||
|
serial_number: string
|
||||||
|
child_id?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NFCSessionStartResponse {
|
||||||
|
bind_token: string
|
||||||
|
expires_at: string
|
||||||
|
status: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NFCSessionStatus {
|
||||||
|
bind_token: string
|
||||||
|
device_id: string
|
||||||
|
child_id: number | null
|
||||||
|
status: number
|
||||||
|
expires_at: string
|
||||||
|
card_uuid?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
export function getSelectedBindingDeviceId(): string | null {
|
export function getSelectedBindingDeviceId(): string | null {
|
||||||
const value = Taro.getStorageSync(SELECTED_BINDING_DEVICE_ID_KEY)
|
const value = Taro.getStorageSync(SELECTED_BINDING_DEVICE_ID_KEY)
|
||||||
const deviceId = String(value || '').trim()
|
const deviceId = String(value || '').trim()
|
||||||
@@ -67,7 +95,7 @@ export function resolveBindingSelection<T extends { device_id: string }>(items:
|
|||||||
|
|
||||||
export async function getCurrentBinding(): Promise<Binding | null> {
|
export async function getCurrentBinding(): Promise<Binding | null> {
|
||||||
try {
|
try {
|
||||||
return await request<Binding>('/bindings/current')
|
return await request<Binding>('/banban/bindings/current')
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
if (error?.status === 404) return null
|
if (error?.status === 404) return null
|
||||||
throw error
|
throw error
|
||||||
@@ -75,16 +103,13 @@ export async function getCurrentBinding(): Promise<Binding | null> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function getBindings(cursor?: number, limit: number = 20): Promise<BindingListResponse> {
|
export async function getBindings(cursor?: number, limit: number = 20): Promise<BindingListResponse> {
|
||||||
const query = buildQuery({
|
const query = buildQuery({ cursor, limit })
|
||||||
cursor,
|
return request<BindingListResponse>(`/banban/bindings?${query}`)
|
||||||
limit,
|
|
||||||
})
|
|
||||||
return request<BindingListResponse>(`/bindings?${query}`)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getBinding(deviceId: string): Promise<Binding | null> {
|
export async function getBinding(deviceId: string): Promise<Binding | null> {
|
||||||
try {
|
try {
|
||||||
return await request<Binding>(`/bindings/${deviceId}`)
|
return await request<Binding>(`/banban/bindings/${deviceId}`)
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
if (error?.status === 404) return null
|
if (error?.status === 404) return null
|
||||||
throw error
|
throw error
|
||||||
@@ -100,7 +125,6 @@ export async function resolveActiveBinding(): Promise<Binding | null> {
|
|||||||
setSelectedBindingDeviceId(selectedBinding.device_id)
|
setSelectedBindingDeviceId(selectedBinding.device_id)
|
||||||
return selectedBinding
|
return selectedBinding
|
||||||
}
|
}
|
||||||
|
|
||||||
clearSelectedBindingDeviceId()
|
clearSelectedBindingDeviceId()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -115,24 +139,92 @@ export async function resolveActiveBinding(): Promise<Binding | null> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function directBind(data: DirectBindPayload): Promise<{ device_id: string; child_id: number | null }> {
|
export async function directBind(data: DirectBindPayload): Promise<{ device_id: string; child_id: number | null }> {
|
||||||
return request('/bindings/direct', {
|
return request('/banban/bindings/direct', { method: 'POST', data })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function startNFCBind(data: NFCSessionStartPayload): Promise<NFCSessionStartResponse> {
|
||||||
|
return request<NFCSessionStartResponse>('/banban/bindings/start', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
data,
|
data,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getNFCBindSession(bindToken: string): Promise<NFCSessionStatus> {
|
||||||
|
return request<NFCSessionStatus>(`/banban/bindings/sessions/${bindToken}`)
|
||||||
|
}
|
||||||
|
|
||||||
export async function setBindingChild(
|
export async function setBindingChild(
|
||||||
deviceId: string,
|
deviceId: string,
|
||||||
data: { child_id: number }
|
data: { child_id: number }
|
||||||
): Promise<{ device_id: string; child_id: number | null }> {
|
): Promise<{ device_id: string; child_id: number | null }> {
|
||||||
return request(`/bindings/${deviceId}/child`, {
|
return request(`/banban/bindings/${deviceId}/child`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
data,
|
data,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function unbindDevice(deviceId: string): Promise<void> {
|
export async function unbindDevice(deviceId: string): Promise<void> {
|
||||||
return request(`/bindings/${deviceId}`, {
|
return request(`/banban/bindings/${deviceId}`, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function resolveBindingForChild<T extends { child_id: number | null }>(
|
||||||
|
items: T[],
|
||||||
|
childId?: number | null
|
||||||
|
): T | null {
|
||||||
|
const normalizedChildId = Number(childId || 0)
|
||||||
|
if (!Number.isFinite(normalizedChildId) || normalizedChildId <= 0) return null
|
||||||
|
|
||||||
|
const childBindings = items.filter((item) => item.child_id === normalizedChildId)
|
||||||
|
if (childBindings.length === 0) return null
|
||||||
|
|
||||||
|
const selectedDeviceId = getSelectedBindingDeviceId()
|
||||||
|
if (selectedDeviceId) {
|
||||||
|
const selectedBinding = childBindings.find((item: any) => item.device_id === selectedDeviceId) || null
|
||||||
|
if (selectedBinding) return selectedBinding
|
||||||
|
}
|
||||||
|
|
||||||
|
return childBindings[0] || null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function syncChildAndBindingSelection(children: Child[], bindings: Binding[]): BindingChildContext {
|
||||||
|
const currentChild = resolveChildSelection(children)
|
||||||
|
const currentBinding = currentChild ? resolveBindingForChild(bindings, currentChild.child_id) : null
|
||||||
|
|
||||||
|
if (currentChild) {
|
||||||
|
setSelectedChildId(currentChild.child_id)
|
||||||
|
} else {
|
||||||
|
clearSelectedChildId()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentBinding?.device_id) {
|
||||||
|
setSelectedBindingDeviceId(currentBinding.device_id)
|
||||||
|
} else {
|
||||||
|
clearSelectedBindingDeviceId()
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
currentChild,
|
||||||
|
currentBinding,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadCurrentChildBindingContext(): Promise<{
|
||||||
|
children: Child[]
|
||||||
|
bindings: Binding[]
|
||||||
|
currentChild: Child | null
|
||||||
|
currentBinding: Binding | null
|
||||||
|
}> {
|
||||||
|
const [childResponse, bindingResponse] = await Promise.all([getChildren(undefined, 100), getBindings(undefined, 100)])
|
||||||
|
const children = childResponse.items || []
|
||||||
|
const bindings = bindingResponse.items || []
|
||||||
|
const { currentChild, currentBinding } = syncChildAndBindingSelection(children, bindings)
|
||||||
|
|
||||||
|
return {
|
||||||
|
children,
|
||||||
|
bindings,
|
||||||
|
currentChild,
|
||||||
|
currentBinding,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
|
import Taro from '@tarojs/taro'
|
||||||
import { request } from './api'
|
import { request } from './api'
|
||||||
|
|
||||||
|
const SELECTED_CHILD_ID_KEY = 'selectedChildId'
|
||||||
|
|
||||||
function buildQuery(params: Record<string, string | number | undefined | null>): string {
|
function buildQuery(params: Record<string, string | number | undefined | null>): string {
|
||||||
return Object.entries(params)
|
return Object.entries(params)
|
||||||
.filter(([, value]) => value !== undefined && value !== null && value !== '')
|
.filter(([, value]) => value !== undefined && value !== null && value !== '')
|
||||||
@@ -21,12 +24,44 @@ export interface ChildListResponse {
|
|||||||
next_cursor?: number | null
|
next_cursor?: number | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getSelectedChildId(): number | null {
|
||||||
|
const value = Number(Taro.getStorageSync(SELECTED_CHILD_ID_KEY) || 0)
|
||||||
|
return Number.isFinite(value) && value > 0 ? value : null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setSelectedChildId(childId?: number | null) {
|
||||||
|
const normalizedChildId = Number(childId || 0)
|
||||||
|
if (!Number.isFinite(normalizedChildId) || normalizedChildId <= 0) {
|
||||||
|
Taro.removeStorageSync(SELECTED_CHILD_ID_KEY)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
Taro.setStorageSync(SELECTED_CHILD_ID_KEY, normalizedChildId)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearSelectedChildId() {
|
||||||
|
Taro.removeStorageSync(SELECTED_CHILD_ID_KEY)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveChildSelection<T extends { child_id: number }>(items: T[]): T | null {
|
||||||
|
const selectedChildId = getSelectedChildId()
|
||||||
|
const selectedChild = selectedChildId ? items.find((item) => item.child_id === selectedChildId) || null : null
|
||||||
|
const currentChild = selectedChild || items[0] || null
|
||||||
|
|
||||||
|
if (currentChild) {
|
||||||
|
setSelectedChildId(currentChild.child_id)
|
||||||
|
} else {
|
||||||
|
clearSelectedChildId()
|
||||||
|
}
|
||||||
|
|
||||||
|
return currentChild
|
||||||
|
}
|
||||||
|
|
||||||
export async function createChild(data: {
|
export async function createChild(data: {
|
||||||
child_name: string
|
child_name: string
|
||||||
child_gender?: number
|
child_gender?: number
|
||||||
child_birthday?: string
|
child_birthday?: string
|
||||||
}): Promise<Child> {
|
}): Promise<Child> {
|
||||||
return request<Child>('/children', {
|
return request<Child>('/banban/children', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
data,
|
data,
|
||||||
})
|
})
|
||||||
@@ -37,18 +72,18 @@ export async function getChildren(cursor?: number, limit: number = 20): Promise<
|
|||||||
cursor,
|
cursor,
|
||||||
limit,
|
limit,
|
||||||
})
|
})
|
||||||
return request<ChildListResponse>(`/children?${query}`)
|
return request<ChildListResponse>(`/banban/children?${query}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getChild(childId: number): Promise<Child> {
|
export async function getChild(childId: number): Promise<Child> {
|
||||||
return request<Child>(`/children/${childId}`)
|
return request<Child>(`/banban/children/${childId}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateChild(
|
export async function updateChild(
|
||||||
childId: number,
|
childId: number,
|
||||||
data: { child_name?: string; child_gender?: number; child_birthday?: string }
|
data: { child_name?: string; child_gender?: number; child_birthday?: string }
|
||||||
): Promise<Child> {
|
): Promise<Child> {
|
||||||
return request<Child>(`/children/${childId}`, {
|
return request<Child>(`/banban/children/${childId}`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
data,
|
data,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -4,13 +4,21 @@ from collections.abc import Mapping
|
|||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from sqlalchemy import text
|
|
||||||
from sqlalchemy.exc import IntegrityError
|
|
||||||
|
|
||||||
from banban.dao import BaseDAO
|
from banban.dao import BaseDAO
|
||||||
|
|
||||||
logger = logging.getLogger("banban.dao.binding")
|
logger = logging.getLogger("banban.dao.binding")
|
||||||
|
|
||||||
|
SESSION_STATUS_PENDING = 1
|
||||||
|
SESSION_STATUS_COMPLETED = 2
|
||||||
|
SESSION_STATUS_EXPIRED = 3
|
||||||
|
SESSION_STATUS_FAILED = 4
|
||||||
|
SESSION_STATUS_CANCELLED = 5
|
||||||
|
|
||||||
|
BIND_SOURCE_SESSION_CONFIRM = 1
|
||||||
|
BIND_SOURCE_DIRECT = 2
|
||||||
|
BIND_SOURCE_SET_CHILD = 3
|
||||||
|
BIND_SOURCE_NFC = 4
|
||||||
|
|
||||||
|
|
||||||
class BindingDAO(BaseDAO):
|
class BindingDAO(BaseDAO):
|
||||||
async def get_device_auth(self, device_id: str) -> Optional[Mapping]:
|
async def get_device_auth(self, device_id: str) -> Optional[Mapping]:
|
||||||
@@ -55,36 +63,13 @@ class BindingDAO(BaseDAO):
|
|||||||
)
|
)
|
||||||
|
|
||||||
async def _upsert_parent_child_relation(self, user_id: int, child_id: int) -> None:
|
async def _upsert_parent_child_relation(self, user_id: int, child_id: int) -> None:
|
||||||
updated = await self.execute(
|
|
||||||
"""
|
|
||||||
UPDATE parent_child_relations
|
|
||||||
SET status = 1,
|
|
||||||
updated_at = CURRENT_TIMESTAMP
|
|
||||||
WHERE user_id = :user_id
|
|
||||||
AND child_id = :child_id
|
|
||||||
""",
|
|
||||||
{"user_id": user_id, "child_id": child_id},
|
|
||||||
)
|
|
||||||
if updated.rowcount and updated.rowcount > 0:
|
|
||||||
return
|
|
||||||
|
|
||||||
try:
|
|
||||||
await self.execute(
|
await self.execute(
|
||||||
"""
|
"""
|
||||||
INSERT INTO parent_child_relations (user_id, child_id, relation_type, is_primary, status)
|
INSERT INTO parent_child_relations (user_id, child_id, relation_type, is_primary, status)
|
||||||
VALUES (:user_id, :child_id, 9, 0, 1)
|
VALUES (:user_id, :child_id, 9, 0, 1)
|
||||||
""",
|
ON DUPLICATE KEY UPDATE
|
||||||
{"user_id": user_id, "child_id": child_id},
|
status = VALUES(status),
|
||||||
)
|
|
||||||
except IntegrityError:
|
|
||||||
await self.db.rollback()
|
|
||||||
await self.execute(
|
|
||||||
"""
|
|
||||||
UPDATE parent_child_relations
|
|
||||||
SET status = 1,
|
|
||||||
updated_at = CURRENT_TIMESTAMP
|
updated_at = CURRENT_TIMESTAMP
|
||||||
WHERE user_id = :user_id
|
|
||||||
AND child_id = :child_id
|
|
||||||
""",
|
""",
|
||||||
{"user_id": user_id, "child_id": child_id},
|
{"user_id": user_id, "child_id": child_id},
|
||||||
)
|
)
|
||||||
@@ -172,14 +157,30 @@ class BindingDAO(BaseDAO):
|
|||||||
{"device_id": device_id, "owner_user_id": user_id, "child_id": child_id},
|
{"device_id": device_id, "owner_user_id": user_id, "child_id": child_id},
|
||||||
)
|
)
|
||||||
|
|
||||||
async def start_bind(self, user_id: int, device_id: str, child_id: Optional[int]) -> str:
|
async def start_bind(self, user_id: int, device_id: str, child_id: Optional[int]) -> 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)
|
||||||
|
|
||||||
|
await self.execute(
|
||||||
|
"""
|
||||||
|
UPDATE device_bind_sessions
|
||||||
|
SET status = :cancelled_status,
|
||||||
|
consumed_at = COALESCE(consumed_at, CURRENT_TIMESTAMP),
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE device_id = :device_id
|
||||||
|
AND status = :pending_status
|
||||||
|
""",
|
||||||
|
{
|
||||||
|
"device_id": device_id,
|
||||||
|
"pending_status": SESSION_STATUS_PENDING,
|
||||||
|
"cancelled_status": SESSION_STATUS_CANCELLED,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
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 (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, 1)
|
VALUES (:bind_token, :device_id, :initiator_user_id, :target_child_id, :expires_at, :status)
|
||||||
""",
|
""",
|
||||||
{
|
{
|
||||||
"bind_token": bind_token,
|
"bind_token": bind_token,
|
||||||
@@ -187,38 +188,125 @@ class BindingDAO(BaseDAO):
|
|||||||
"initiator_user_id": user_id,
|
"initiator_user_id": user_id,
|
||||||
"target_child_id": child_id,
|
"target_child_id": child_id,
|
||||||
"expires_at": expires_at,
|
"expires_at": expires_at,
|
||||||
|
"status": SESSION_STATUS_PENDING,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
await self.commit()
|
return bind_token, expires_at
|
||||||
return bind_token
|
|
||||||
|
|
||||||
async def get_session(self, bind_token: str, user_id: int) -> Optional[Mapping]:
|
async def get_session(self, bind_token: str, user_id: int) -> Optional[Mapping]:
|
||||||
return (
|
return (
|
||||||
await self.execute(
|
await self.execute(
|
||||||
"SELECT * FROM device_bind_sessions WHERE bind_token = :bind_token AND initiator_user_id = :user_id",
|
"""
|
||||||
{"bind_token": bind_token, "user_id": user_id},
|
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
|
||||||
|
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
|
||||||
|
""",
|
||||||
|
{
|
||||||
|
"bind_token": bind_token,
|
||||||
|
"user_id": user_id,
|
||||||
|
"completed_status": SESSION_STATUS_COMPLETED,
|
||||||
|
},
|
||||||
)
|
)
|
||||||
).mappings().first()
|
).mappings().first()
|
||||||
|
|
||||||
|
async def get_latest_pending_session_by_device(self, device_id: str) -> Optional[Mapping]:
|
||||||
|
return (
|
||||||
|
await self.execute(
|
||||||
|
"""
|
||||||
|
SELECT *
|
||||||
|
FROM device_bind_sessions
|
||||||
|
WHERE device_id = :device_id
|
||||||
|
AND status = :status
|
||||||
|
ORDER BY id DESC
|
||||||
|
LIMIT 1
|
||||||
|
""",
|
||||||
|
{"device_id": device_id, "status": SESSION_STATUS_PENDING},
|
||||||
|
)
|
||||||
|
).mappings().first()
|
||||||
|
|
||||||
|
async def mark_session_status(self, session_id: int, status: int) -> None:
|
||||||
|
await self.execute(
|
||||||
|
"""
|
||||||
|
UPDATE device_bind_sessions
|
||||||
|
SET status = :status,
|
||||||
|
consumed_at = COALESCE(consumed_at, CURRENT_TIMESTAMP),
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = :id
|
||||||
|
""",
|
||||||
|
{"id": session_id, "status": status},
|
||||||
|
)
|
||||||
|
|
||||||
async def confirm_bind(self, session_id: int, device_id: str, child_id: Optional[int], user_id: int) -> None:
|
async def confirm_bind(self, session_id: int, 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)
|
||||||
|
|
||||||
await self.execute(
|
await self.execute(
|
||||||
"UPDATE device_bind_sessions SET status = 2, confirmed_at = CURRENT_TIMESTAMP WHERE id = :id",
|
"""
|
||||||
{"id": session_id},
|
UPDATE device_bind_sessions
|
||||||
|
SET status = :status,
|
||||||
|
confirmed_at = CURRENT_TIMESTAMP,
|
||||||
|
consumed_at = CURRENT_TIMESTAMP,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = :id
|
||||||
|
""",
|
||||||
|
{"id": session_id, "status": SESSION_STATUS_COMPLETED},
|
||||||
)
|
)
|
||||||
|
|
||||||
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)
|
||||||
await self._insert_bind_history(device_id=device_id, child_id=child_id, user_id=user_id, bind_source=1)
|
await self._insert_bind_history(
|
||||||
await self.commit()
|
device_id=device_id,
|
||||||
|
child_id=child_id,
|
||||||
|
user_id=user_id,
|
||||||
|
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:
|
||||||
|
if child_id is not None:
|
||||||
|
await self._upsert_parent_child_relation(user_id=user_id, child_id=child_id)
|
||||||
|
|
||||||
|
await self.execute(
|
||||||
|
"""
|
||||||
|
UPDATE device_bind_sessions
|
||||||
|
SET status = :status,
|
||||||
|
confirmed_at = CURRENT_TIMESTAMP,
|
||||||
|
consumed_at = CURRENT_TIMESTAMP,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = :id
|
||||||
|
""",
|
||||||
|
{"id": session_id, "status": SESSION_STATUS_COMPLETED},
|
||||||
|
)
|
||||||
|
|
||||||
|
await self._bind_device(device_id=device_id, user_id=user_id, child_id=child_id)
|
||||||
|
await self._insert_bind_history(
|
||||||
|
device_id=device_id,
|
||||||
|
child_id=child_id,
|
||||||
|
user_id=user_id,
|
||||||
|
bind_source=BIND_SOURCE_NFC,
|
||||||
|
)
|
||||||
|
|
||||||
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)
|
||||||
|
|
||||||
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)
|
||||||
await self._insert_bind_history(device_id=device_id, child_id=child_id, user_id=user_id, bind_source=2)
|
await self._insert_bind_history(
|
||||||
|
device_id=device_id,
|
||||||
|
child_id=child_id,
|
||||||
|
user_id=user_id,
|
||||||
|
bind_source=BIND_SOURCE_DIRECT,
|
||||||
|
)
|
||||||
await self.commit()
|
await self.commit()
|
||||||
|
|
||||||
async def get_current_by_user(self, user_id: int) -> Optional[Mapping]:
|
async def get_current_by_user(self, user_id: int) -> Optional[Mapping]:
|
||||||
@@ -287,7 +375,12 @@ class BindingDAO(BaseDAO):
|
|||||||
|
|
||||||
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)
|
||||||
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)
|
||||||
await self._insert_bind_history(device_id=device_id, child_id=child_id, user_id=user_id, bind_source=3)
|
await self._insert_bind_history(
|
||||||
|
device_id=device_id,
|
||||||
|
child_id=child_id,
|
||||||
|
user_id=user_id,
|
||||||
|
bind_source=BIND_SOURCE_SET_CHILD,
|
||||||
|
)
|
||||||
await self.commit()
|
await self.commit()
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@@ -302,7 +395,30 @@ class BindingDAO(BaseDAO):
|
|||||||
)
|
)
|
||||||
|
|
||||||
await self.execute(
|
await self.execute(
|
||||||
"INSERT INTO device_bind_history (device_id, child_id, bound_by_user_id, unbound_by_user_id, bind_source, bound_at, unbound_at, unbind_reason) SELECT device_id, child_id, bound_by_user_id, :user_id, bind_source, bound_at, CURRENT_TIMESTAMP, 'user_unbind' FROM device_bind_history WHERE device_id = :device_id AND unbound_at IS NULL",
|
"""
|
||||||
|
INSERT INTO device_bind_history (
|
||||||
|
device_id,
|
||||||
|
child_id,
|
||||||
|
bound_by_user_id,
|
||||||
|
unbound_by_user_id,
|
||||||
|
bind_source,
|
||||||
|
bound_at,
|
||||||
|
unbound_at,
|
||||||
|
unbind_reason
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
device_id,
|
||||||
|
child_id,
|
||||||
|
bound_by_user_id,
|
||||||
|
:user_id,
|
||||||
|
bind_source,
|
||||||
|
bound_at,
|
||||||
|
CURRENT_TIMESTAMP,
|
||||||
|
'user_unbind'
|
||||||
|
FROM device_bind_history
|
||||||
|
WHERE device_id = :device_id
|
||||||
|
AND unbound_at IS NULL
|
||||||
|
""",
|
||||||
{"device_id": device_id, "user_id": user_id},
|
{"device_id": device_id, "user_id": user_id},
|
||||||
)
|
)
|
||||||
await self.commit()
|
await self.commit()
|
||||||
@@ -318,7 +434,8 @@ class BindingDAO(BaseDAO):
|
|||||||
rows = (
|
rows = (
|
||||||
await self.execute(
|
await self.execute(
|
||||||
f"""
|
f"""
|
||||||
SELECT * FROM device_bind_history
|
SELECT *
|
||||||
|
FROM device_bind_history
|
||||||
WHERE {where}
|
WHERE {where}
|
||||||
ORDER BY bound_at DESC
|
ORDER BY bound_at DESC
|
||||||
LIMIT :limit
|
LIMIT :limit
|
||||||
|
|||||||
@@ -4,12 +4,15 @@ from datetime import datetime
|
|||||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
|
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
try:
|
from banban.dao.binding import (
|
||||||
from banban.security import get_current_user_id
|
SESSION_STATUS_CANCELLED,
|
||||||
from banban.service.binding import BindingError, BindingService
|
SESSION_STATUS_COMPLETED,
|
||||||
except ModuleNotFoundError:
|
SESSION_STATUS_EXPIRED,
|
||||||
from banban.security import get_current_user_id
|
SESSION_STATUS_FAILED,
|
||||||
from banban.service.binding import BindingError, BindingService
|
SESSION_STATUS_PENDING,
|
||||||
|
)
|
||||||
|
from banban.security import get_current_user_id
|
||||||
|
from banban.service.binding import BindingError, BindingService
|
||||||
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/bindings", tags=["bindings"])
|
router = APIRouter(prefix="/bindings", tags=["bindings"])
|
||||||
@@ -25,6 +28,16 @@ class BindStartRequest(BaseModel):
|
|||||||
class BindStartResponse(BaseModel):
|
class BindStartResponse(BaseModel):
|
||||||
bind_token: str
|
bind_token: str
|
||||||
expires_at: str
|
expires_at: str
|
||||||
|
status: int
|
||||||
|
|
||||||
|
|
||||||
|
class BindSessionResponse(BaseModel):
|
||||||
|
bind_token: str
|
||||||
|
device_id: str
|
||||||
|
child_id: int | None = None
|
||||||
|
status: int
|
||||||
|
expires_at: str
|
||||||
|
card_uuid: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class BindConfirmRequest(BaseModel):
|
class BindConfirmRequest(BaseModel):
|
||||||
@@ -77,6 +90,7 @@ async def start_bind(
|
|||||||
request: Request,
|
request: Request,
|
||||||
current_user_id: int = Depends(get_current_user_id),
|
current_user_id: int = Depends(get_current_user_id),
|
||||||
) -> BindStartResponse:
|
) -> BindStartResponse:
|
||||||
|
del request
|
||||||
service = BindingService()
|
service = BindingService()
|
||||||
try:
|
try:
|
||||||
bind_token, expires_at = await service.start_bind(
|
bind_token, expires_at = await service.start_bind(
|
||||||
@@ -85,9 +99,35 @@ async def start_bind(
|
|||||||
payload.serial_number,
|
payload.serial_number,
|
||||||
payload.child_id,
|
payload.child_id,
|
||||||
)
|
)
|
||||||
except BindingError as e:
|
except BindingError as exc:
|
||||||
raise HTTPException(status_code=e.status_code, detail=str(e))
|
raise HTTPException(status_code=exc.status_code, detail=str(exc))
|
||||||
return BindStartResponse(bind_token=bind_token, expires_at=expires_at.isoformat())
|
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,
|
||||||
|
request: Request,
|
||||||
|
current_user_id: int = Depends(get_current_user_id),
|
||||||
|
) -> BindSessionResponse:
|
||||||
|
del request
|
||||||
|
service = BindingService()
|
||||||
|
session = await service.get_bind_session(bind_token, current_user_id)
|
||||||
|
if not session:
|
||||||
|
raise HTTPException(status_code=404, detail="bind session not found")
|
||||||
|
|
||||||
|
return BindSessionResponse(
|
||||||
|
bind_token=session["bind_token"],
|
||||||
|
device_id=session["device_id"],
|
||||||
|
child_id=session["target_child_id"],
|
||||||
|
status=int(session["status"]),
|
||||||
|
expires_at=session["expires_at"].isoformat(),
|
||||||
|
card_uuid=session.get("card_uuid"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/confirm", response_model=BindConfirmResponse)
|
@router.post("/confirm", response_model=BindConfirmResponse)
|
||||||
@@ -96,13 +136,14 @@ async def confirm_bind(
|
|||||||
request: Request,
|
request: Request,
|
||||||
current_user_id: int = Depends(get_current_user_id),
|
current_user_id: int = Depends(get_current_user_id),
|
||||||
) -> BindConfirmResponse:
|
) -> BindConfirmResponse:
|
||||||
|
del request
|
||||||
service = BindingService()
|
service = BindingService()
|
||||||
try:
|
try:
|
||||||
result = await service.confirm_bind(payload.bind_token, current_user_id)
|
result = await service.confirm_bind(payload.bind_token, current_user_id)
|
||||||
except BindingError as e:
|
except BindingError as exc:
|
||||||
raise HTTPException(status_code=e.status_code, detail=str(e))
|
raise HTTPException(status_code=exc.status_code, detail=str(exc))
|
||||||
except ValueError as e:
|
except ValueError as exc:
|
||||||
raise HTTPException(status_code=400, detail=str(e))
|
raise HTTPException(status_code=400, detail=str(exc))
|
||||||
return BindConfirmResponse(**result)
|
return BindConfirmResponse(**result)
|
||||||
|
|
||||||
|
|
||||||
@@ -127,6 +168,7 @@ async def direct_bind(
|
|||||||
request: Request,
|
request: Request,
|
||||||
current_user_id: int = Depends(get_current_user_id),
|
current_user_id: int = Depends(get_current_user_id),
|
||||||
) -> DirectBindResponse:
|
) -> DirectBindResponse:
|
||||||
|
del request
|
||||||
service = BindingService()
|
service = BindingService()
|
||||||
try:
|
try:
|
||||||
result = await service.direct_bind(
|
result = await service.direct_bind(
|
||||||
@@ -135,8 +177,8 @@ async def direct_bind(
|
|||||||
payload.child_id,
|
payload.child_id,
|
||||||
current_user_id,
|
current_user_id,
|
||||||
)
|
)
|
||||||
except BindingError as e:
|
except BindingError as exc:
|
||||||
raise HTTPException(status_code=e.status_code, detail=str(e))
|
raise HTTPException(status_code=exc.status_code, detail=str(exc))
|
||||||
return DirectBindResponse(**result)
|
return DirectBindResponse(**result)
|
||||||
|
|
||||||
|
|
||||||
@@ -147,13 +189,14 @@ async def set_binding_child(
|
|||||||
request: Request,
|
request: Request,
|
||||||
current_user_id: int = Depends(get_current_user_id),
|
current_user_id: int = Depends(get_current_user_id),
|
||||||
) -> DirectBindResponse:
|
) -> DirectBindResponse:
|
||||||
|
del request
|
||||||
service = BindingService()
|
service = BindingService()
|
||||||
try:
|
try:
|
||||||
result = await service.set_binding_child(device_id=device_id, child_id=payload.child_id, user_id=current_user_id)
|
result = await service.set_binding_child(device_id=device_id, child_id=payload.child_id, user_id=current_user_id)
|
||||||
except BindingError as e:
|
except BindingError as exc:
|
||||||
raise HTTPException(status_code=e.status_code, detail=str(e))
|
raise HTTPException(status_code=exc.status_code, detail=str(exc))
|
||||||
except ValueError as e:
|
except ValueError as exc:
|
||||||
raise HTTPException(status_code=404, detail=str(e))
|
raise HTTPException(status_code=404, detail=str(exc))
|
||||||
return DirectBindResponse(**result)
|
return DirectBindResponse(**result)
|
||||||
|
|
||||||
|
|
||||||
@@ -162,6 +205,7 @@ async def get_current_binding(
|
|||||||
request: Request,
|
request: Request,
|
||||||
current_user_id: int = Depends(get_current_user_id),
|
current_user_id: int = Depends(get_current_user_id),
|
||||||
):
|
):
|
||||||
|
del request
|
||||||
service = BindingService()
|
service = BindingService()
|
||||||
binding = await service.get_current_binding(current_user_id)
|
binding = await service.get_current_binding(current_user_id)
|
||||||
if not binding:
|
if not binding:
|
||||||
@@ -176,6 +220,7 @@ async def list_bindings(
|
|||||||
limit: int = Query(default=20, ge=1, le=100),
|
limit: int = Query(default=20, ge=1, le=100),
|
||||||
current_user_id: int = Depends(get_current_user_id),
|
current_user_id: int = Depends(get_current_user_id),
|
||||||
) -> BindingListResponse:
|
) -> BindingListResponse:
|
||||||
|
del request
|
||||||
service = BindingService()
|
service = BindingService()
|
||||||
rows, has_more = await service.list_bindings(current_user_id, limit, cursor)
|
rows, has_more = await service.list_bindings(current_user_id, limit, cursor)
|
||||||
next_cursor = int(rows[-1]["id"]) if has_more and rows else None
|
next_cursor = int(rows[-1]["id"]) if has_more and rows else None
|
||||||
@@ -198,6 +243,7 @@ async def get_binding(
|
|||||||
request: Request,
|
request: Request,
|
||||||
current_user_id: int = Depends(get_current_user_id),
|
current_user_id: int = Depends(get_current_user_id),
|
||||||
) -> BindingGetResponse:
|
) -> BindingGetResponse:
|
||||||
|
del request
|
||||||
service = BindingService()
|
service = BindingService()
|
||||||
binding = await service.get_binding(device_id, current_user_id)
|
binding = await service.get_binding(device_id, current_user_id)
|
||||||
if not binding:
|
if not binding:
|
||||||
@@ -211,6 +257,7 @@ async def unbind_device(
|
|||||||
request: Request,
|
request: Request,
|
||||||
current_user_id: int = Depends(get_current_user_id),
|
current_user_id: int = Depends(get_current_user_id),
|
||||||
) -> None:
|
) -> None:
|
||||||
|
del request
|
||||||
service = BindingService()
|
service = BindingService()
|
||||||
if not await service.unbind(device_id, current_user_id):
|
if not await service.unbind(device_id, current_user_id):
|
||||||
raise HTTPException(status_code=404, detail="binding not found")
|
raise HTTPException(status_code=404, detail="binding not found")
|
||||||
@@ -224,6 +271,7 @@ async def get_bind_history(
|
|||||||
limit: int = 20,
|
limit: int = 20,
|
||||||
current_user_id: int = Depends(get_current_user_id),
|
current_user_id: int = Depends(get_current_user_id),
|
||||||
) -> BindHistoryResponse:
|
) -> BindHistoryResponse:
|
||||||
|
del request, current_user_id
|
||||||
cursor_dt = datetime.fromisoformat(cursor) if cursor else None
|
cursor_dt = datetime.fromisoformat(cursor) if cursor else None
|
||||||
service = BindingService()
|
service = BindingService()
|
||||||
rows, has_more = await service.list_history(device_id, limit, cursor_dt)
|
rows, has_more = await service.list_history(device_id, limit, cursor_dt)
|
||||||
|
|||||||
@@ -2,7 +2,15 @@ from collections.abc import Mapping
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from banban.dao.binding import BindingDAO
|
from banban.dao.binding import (
|
||||||
|
SESSION_STATUS_CANCELLED,
|
||||||
|
SESSION_STATUS_COMPLETED,
|
||||||
|
SESSION_STATUS_EXPIRED,
|
||||||
|
SESSION_STATUS_FAILED,
|
||||||
|
SESSION_STATUS_PENDING,
|
||||||
|
BindingDAO,
|
||||||
|
)
|
||||||
|
from services.card_service import card_service
|
||||||
from services.database_service_base import DatabaseServiceBase
|
from services.database_service_base import DatabaseServiceBase
|
||||||
|
|
||||||
|
|
||||||
@@ -26,6 +34,12 @@ 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)
|
||||||
|
|
||||||
|
def _normalize_session_status(self, session: Mapping) -> int:
|
||||||
|
status = int(session["status"])
|
||||||
|
if status == SESSION_STATUS_PENDING and datetime.utcnow() > session["expires_at"]:
|
||||||
|
return SESSION_STATUS_EXPIRED
|
||||||
|
return status
|
||||||
|
|
||||||
async def start_bind(
|
async def start_bind(
|
||||||
self,
|
self,
|
||||||
user_id: int,
|
user_id: int,
|
||||||
@@ -37,9 +51,15 @@ class BindingService(DatabaseServiceBase):
|
|||||||
try:
|
try:
|
||||||
await self._ensure_bindable_device(db_session, device_id, serial_number)
|
await self._ensure_bindable_device(db_session, device_id, serial_number)
|
||||||
dao = BindingDAO(db_session)
|
dao = BindingDAO(db_session)
|
||||||
bind_token = await dao.start_bind(user_id, device_id, child_id)
|
bind_token, expires_at = await dao.start_bind(user_id, device_id, child_id)
|
||||||
await db_session.commit()
|
await db_session.commit()
|
||||||
return bind_token, datetime.utcnow()
|
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)
|
||||||
|
return bind_token, expires_at
|
||||||
finally:
|
finally:
|
||||||
await db_session.close()
|
await db_session.close()
|
||||||
|
|
||||||
@@ -52,7 +72,7 @@ class BindingService(DatabaseServiceBase):
|
|||||||
raise ValueError("Bind session not found")
|
raise ValueError("Bind session not found")
|
||||||
if datetime.utcnow() > session["expires_at"]:
|
if datetime.utcnow() > session["expires_at"]:
|
||||||
raise ValueError("Bind session expired")
|
raise ValueError("Bind session expired")
|
||||||
if session["status"] != 1:
|
if int(session["status"]) != SESSION_STATUS_PENDING:
|
||||||
raise ValueError("Bind session already processed")
|
raise ValueError("Bind session already processed")
|
||||||
|
|
||||||
await dao.confirm_bind(session["id"], session["device_id"], session["target_child_id"], user_id)
|
await dao.confirm_bind(session["id"], session["device_id"], session["target_child_id"], user_id)
|
||||||
@@ -61,6 +81,76 @@ class BindingService(DatabaseServiceBase):
|
|||||||
finally:
|
finally:
|
||||||
await db_session.close()
|
await db_session.close()
|
||||||
|
|
||||||
|
async def get_bind_session(self, bind_token: str, user_id: int) -> Optional[Mapping]:
|
||||||
|
db_session = await self.get_session()
|
||||||
|
try:
|
||||||
|
dao = BindingDAO(db_session)
|
||||||
|
session = await dao.get_session(bind_token, user_id)
|
||||||
|
if session is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
normalized_status = self._normalize_session_status(session)
|
||||||
|
if normalized_status == SESSION_STATUS_EXPIRED and int(session["status"]) != SESSION_STATUS_EXPIRED:
|
||||||
|
await dao.mark_session_status(int(session["id"]), SESSION_STATUS_EXPIRED)
|
||||||
|
await db_session.commit()
|
||||||
|
session = await dao.get_session(bind_token, user_id)
|
||||||
|
if session is None:
|
||||||
|
return None
|
||||||
|
normalized_status = SESSION_STATUS_EXPIRED
|
||||||
|
|
||||||
|
payload = dict(session)
|
||||||
|
payload["status"] = normalized_status
|
||||||
|
payload["card_uuid"] = payload.get("card_uuid")
|
||||||
|
return payload
|
||||||
|
finally:
|
||||||
|
await db_session.close()
|
||||||
|
|
||||||
|
async def finalize_nfc_bind(self, device_id: str, card_uuid: str) -> Optional[Mapping]:
|
||||||
|
db_session = await self.get_session()
|
||||||
|
try:
|
||||||
|
dao = BindingDAO(db_session)
|
||||||
|
session = await dao.get_latest_pending_session_by_device(device_id)
|
||||||
|
if not session:
|
||||||
|
await db_session.rollback()
|
||||||
|
return None
|
||||||
|
|
||||||
|
if datetime.utcnow() > session["expires_at"]:
|
||||||
|
await dao.mark_session_status(int(session["id"]), SESSION_STATUS_EXPIRED)
|
||||||
|
await db_session.commit()
|
||||||
|
return {
|
||||||
|
"device_id": device_id,
|
||||||
|
"bind_token": session["bind_token"],
|
||||||
|
"status": SESSION_STATUS_EXPIRED,
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
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"]),
|
||||||
|
)
|
||||||
|
await db_session.commit()
|
||||||
|
except Exception:
|
||||||
|
await dao.mark_session_status(int(session["id"]), SESSION_STATUS_FAILED)
|
||||||
|
await db_session.commit()
|
||||||
|
raise
|
||||||
|
|
||||||
|
return {
|
||||||
|
"device_id": device_id,
|
||||||
|
"bind_token": session["bind_token"],
|
||||||
|
"status": SESSION_STATUS_COMPLETED,
|
||||||
|
"child_id": session["target_child_id"],
|
||||||
|
"card_uuid": card_uuid,
|
||||||
|
}
|
||||||
|
finally:
|
||||||
|
await db_session.close()
|
||||||
|
|
||||||
async def get_binding(self, device_id: str, user_id: int) -> Optional[Mapping]:
|
async def get_binding(self, device_id: str, user_id: int) -> Optional[Mapping]:
|
||||||
db_session = await self.get_session()
|
db_session = await self.get_session()
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -1,16 +1,18 @@
|
|||||||
|
import asyncio
|
||||||
import json
|
import json
|
||||||
import time
|
import time
|
||||||
import asyncio
|
from typing import Awaitable, Callable, Dict, Optional
|
||||||
from typing import Optional, Dict, Callable, Awaitable
|
|
||||||
from banban.service.device_setting import device_setting_service
|
|
||||||
from config import settings
|
|
||||||
from services.card_service import card_service
|
|
||||||
from services.offline_audio_cache import offline_audio_cache
|
|
||||||
import aiomqtt
|
|
||||||
from banban.service.location import location_service
|
|
||||||
from database.models import ChildLocationCurrent
|
|
||||||
|
|
||||||
|
import aiomqtt
|
||||||
|
|
||||||
|
from banban.service.binding import BindingService
|
||||||
|
from banban.service.device_setting import device_setting_service
|
||||||
|
from banban.service.location import location_service
|
||||||
|
from config import settings
|
||||||
|
from database.models import ChildLocationCurrent
|
||||||
|
from services.card_service import card_service
|
||||||
from services.device_target_cache import device_target_cache
|
from services.device_target_cache import device_target_cache
|
||||||
|
from services.offline_audio_cache import offline_audio_cache
|
||||||
from utils.logger import session_logger as logger
|
from utils.logger import session_logger as logger
|
||||||
|
|
||||||
|
|
||||||
@@ -64,130 +66,117 @@ class TalkingQMQTTService:
|
|||||||
try:
|
try:
|
||||||
topic = str(message.topic)
|
topic = str(message.topic)
|
||||||
parts = topic.split("/")
|
parts = topic.split("/")
|
||||||
if parts[0] == "device":
|
if not parts or parts[0] != "device":
|
||||||
device_id = parts[1] if len(parts) >= 2 else "unknown"
|
continue
|
||||||
|
|
||||||
|
device_id = parts[1] if len(parts) >= 2 else "unknown"
|
||||||
if not device_id.startswith(f"{self.device_prefix}_"):
|
if not device_id.startswith(f"{self.device_prefix}_"):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
payload = json.loads(message.payload.decode("utf-8"))
|
payload = json.loads(message.payload.decode("utf-8"))
|
||||||
|
|
||||||
msg_id = payload.get("msg_id")
|
msg_id = payload.get("msg_id")
|
||||||
|
|
||||||
handler = self._msg_handlers.get(msg_id)
|
handler = self._msg_handlers.get(msg_id)
|
||||||
if handler:
|
if handler:
|
||||||
await handler(device_id, payload)
|
await handler(device_id, payload)
|
||||||
else:
|
else:
|
||||||
logger.warning(device_id, "", f"未知 msg_id: {msg_id}, 设备: {device_id}")
|
logger.warning(device_id, "", f"unknown msg_id={msg_id}")
|
||||||
|
except json.JSONDecodeError as exc:
|
||||||
except json.JSONDecodeError as e:
|
logger.error("", "", f"invalid mqtt payload: {exc}")
|
||||||
logger.error("", "", f"消息解析失败: {e}")
|
except Exception as exc:
|
||||||
except Exception as e:
|
logger.error("", "", f"mqtt message handling failed: {exc}")
|
||||||
logger.error("", "", f"消息处理异常: {e}")
|
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
pass
|
pass
|
||||||
except Exception as e:
|
except Exception as exc:
|
||||||
logger.error("", "", f"消息循环异常: {e}")
|
logger.error("", "", f"mqtt loop failed: {exc}")
|
||||||
self._connected = False
|
self._connected = False
|
||||||
|
|
||||||
async def _handle_device_info(self, device_id: str, payload: dict):
|
async def _handle_device_info(self, device_id: str, payload: dict):
|
||||||
# status = payload.get("status")
|
|
||||||
data = payload.get("data", {})
|
data = payload.get("data", {})
|
||||||
# if status == "success":
|
await device_setting_service.insert_or_update(
|
||||||
d_id = data.get("id")
|
device_id=device_id,
|
||||||
power = data.get("power")
|
power=data.get("power"),
|
||||||
signal = data.get("signal")
|
signal_strength=data.get("signal"),
|
||||||
version = data.get("version")
|
version_str=data.get("version"),
|
||||||
voice = data.get("voice")
|
volume=data.get("voice"),
|
||||||
logger.info(device_id, "", f"[设备信息] 设备 {d_id} 信息: 电量={power}, 信号强度={signal}, 版本号={version}, 音量={voice}")
|
)
|
||||||
# 插入到数据库
|
|
||||||
await device_setting_service.insert_or_update(device_id=device_id, power=power, signal_strength=signal, version_str=version, volume=voice)
|
|
||||||
# else:
|
|
||||||
# logger.warning(device_id, "", f"[设备信息] 设备 {device_id} 查询失败: {payload}")
|
|
||||||
await self._publish(f"device/{device_id}/event_resp", {"msg_id": "000", "status": "success"})
|
await self._publish(f"device/{device_id}/event_resp", {"msg_id": "000", "status": "success"})
|
||||||
|
|
||||||
async def _handle_gps_response(self, device_id: str, payload: dict):
|
async def _handle_gps_response(self, device_id: str, payload: dict):
|
||||||
status = payload.get("status")
|
if payload.get("status") != "success":
|
||||||
|
logger.warning(device_id, "", f"[GPS] query failed: {payload}")
|
||||||
|
return
|
||||||
|
|
||||||
data = payload.get("data", {})
|
data = payload.get("data", {})
|
||||||
if status == "success":
|
location = ChildLocationCurrent(
|
||||||
lat = data.get("latitude")
|
device_id=device_id,
|
||||||
lon = data.get("longitude")
|
lat=data.get("latitude"),
|
||||||
logger.info(device_id, "", f"[GPS] 设备 {device_id} 位置: 纬度={lat}, 经度={lon}")
|
lon=data.get("longitude"),
|
||||||
# 插入到数据库
|
)
|
||||||
location = ChildLocationCurrent(device_id=device_id, lat=lat, lon=lon)
|
|
||||||
await location_service.insert_or_update(device_id=device_id, location=location)
|
await location_service.insert_or_update(device_id=device_id, location=location)
|
||||||
else:
|
|
||||||
logger.warning(device_id, "", f"[GPS] 设备 {device_id} 查询失败: {payload}")
|
|
||||||
|
|
||||||
async def _handle_volume_response(self, device_id: str, payload: dict):
|
async def _handle_volume_response(self, device_id: str, payload: dict):
|
||||||
status = payload.get("status")
|
if payload.get("status") != "success":
|
||||||
data = payload.get("data", {})
|
logger.warning(device_id, "", f"[volume] command failed: {payload}")
|
||||||
if status == "success":
|
return
|
||||||
level = data.get("current_level")
|
|
||||||
logger.info(device_id, "", f"[音量] 设备 {device_id} 当前音量: {level}")
|
|
||||||
# 更新设备音量
|
|
||||||
await device_setting_service.insert_or_update(device_id=device_id, volume=level)
|
|
||||||
|
|
||||||
else:
|
data = payload.get("data", {})
|
||||||
logger.warning(device_id, "", f"[音量] 设备 {device_id} 调节失败: {payload}")
|
await device_setting_service.insert_or_update(device_id=device_id, volume=data.get("current_level"))
|
||||||
|
|
||||||
async def _handle_ota_response(self, device_id: str, payload: dict):
|
async def _handle_ota_response(self, device_id: str, payload: dict):
|
||||||
status = payload.get("status")
|
status = payload.get("status")
|
||||||
data = payload.get("data", {})
|
data = payload.get("data", {})
|
||||||
if status == "accepted":
|
if status == "success":
|
||||||
current = data.get("current_version")
|
await device_setting_service.insert_or_update(device_id=device_id, version_str=data.get("new_version"))
|
||||||
target = data.get("target_version")
|
elif status != "accepted":
|
||||||
logger.info(device_id, "", f"[OTA] 设备 {device_id} 已接受升级: {current} -> {target}")
|
logger.warning(device_id, "", f"[OTA] command failed: {payload}")
|
||||||
elif status == "success":
|
|
||||||
new_ver = data.get("new_version")
|
|
||||||
logger.info(device_id, "", f"[OTA] 设备 {device_id} 升级完成: {new_ver}")
|
|
||||||
# 更新设备版本号
|
|
||||||
await device_setting_service.insert_or_update(device_id=device_id, version_str=new_ver)
|
|
||||||
else:
|
|
||||||
logger.warning(device_id, "", f"[OTA] 设备 {device_id} 升级异常: {payload}")
|
|
||||||
|
|
||||||
async def _handle_nfc_notice_response(self, device_id: str, payload: dict):
|
async def _handle_nfc_notice_response(self, device_id: str, payload: dict):
|
||||||
status = payload.get("status")
|
status = payload.get("status")
|
||||||
if status == "success":
|
if status != "success":
|
||||||
logger.info(device_id, "", f"[NFC通知] 设备 {device_id} 已收到留言提示")
|
logger.warning(device_id, "", f"[NFC notice] command failed: {payload}")
|
||||||
else:
|
|
||||||
logger.warning(device_id, "", f"[NFC通知] 设备 {device_id} 留言提示失败: {payload}")
|
|
||||||
|
|
||||||
async def _handle_nfc_listen_report(self, device_id: str, payload: dict):
|
async def _handle_nfc_listen_report(self, device_id: str, payload: dict):
|
||||||
params = payload.get("params", {})
|
params = payload.get("params", {})
|
||||||
nfc_uuid = params.get("uuid")
|
nfc_uuid = params.get("uuid")
|
||||||
logger.info(device_id, "", f"[NFC收听] 设备 {device_id} 请求收听留言, UUID={nfc_uuid}")
|
|
||||||
await self._send_nfc_listen_response(device_id, nfc_uuid)
|
await self._send_nfc_listen_response(device_id, nfc_uuid)
|
||||||
|
|
||||||
async def _handle_bind_response(self, device_id: str, payload: dict):
|
async def _handle_bind_response(self, device_id: str, payload: dict):
|
||||||
params = payload.get("params", {})
|
params = payload.get("params", {})
|
||||||
status = params.get("status")
|
data = payload.get("data", {})
|
||||||
logger.info(device_id, "", f"[NFC绑定] 设备 {device_id} 请求绑定卡片返回状态: {status}")
|
status = payload.get("status") or params.get("status")
|
||||||
|
logger.info(device_id, "", f"[NFC bind] device={device_id} status={status}")
|
||||||
|
|
||||||
nfc_uuid = params.get("uuid")
|
if status not in (None, "success", "accepted"):
|
||||||
# 卡片与设备绑定
|
logger.warning(device_id, "", f"[NFC bind] bind failed: {payload}")
|
||||||
await card_service.activate_card(device_id=device_id, card_uuid=nfc_uuid)
|
return
|
||||||
logger.info(device_id, "", f"[NFC绑定] 设备 {device_id} 请求绑定卡片, UUID={nfc_uuid}")
|
|
||||||
|
nfc_uuid = data.get("uuid") or params.get("uuid")
|
||||||
|
if not nfc_uuid:
|
||||||
|
logger.warning(device_id, "", f"[NFC bind] missing uuid: {payload}")
|
||||||
|
return
|
||||||
|
|
||||||
|
service = BindingService()
|
||||||
|
result = await service.finalize_nfc_bind(device_id=device_id, card_uuid=nfc_uuid)
|
||||||
|
if result is None:
|
||||||
|
logger.warning(device_id, "", "[NFC bind] no pending bind session found")
|
||||||
|
return
|
||||||
|
|
||||||
|
logger.info(device_id, "", f"[NFC bind] bind completed, uuid={nfc_uuid}, result={result}")
|
||||||
|
|
||||||
async def _handle_open_response(self, device_id: str, payload: dict):
|
async def _handle_open_response(self, device_id: str, payload: dict):
|
||||||
params = payload.get("params", {})
|
params = payload.get("params", {})
|
||||||
status = params.get("status")
|
logger.info(device_id, "", f"[device open] response={params}")
|
||||||
logger.info(device_id, "", f"[设备状态] 设备 {device_id} 请求返回设备状态: {status}")
|
|
||||||
data = params.get("data", {})
|
|
||||||
device_status = data.get("status")
|
|
||||||
logger.info(device_id, "", f"[设备状态] 设备 {device_id} 请求设备状态: {device_status}")
|
|
||||||
|
|
||||||
async def _send_nfc_listen_response(self, device_id: str, nfc_uuid: str):
|
async def _send_nfc_listen_response(self, device_id: str, nfc_uuid: str):
|
||||||
topic = f"device/{device_id}/event_resp"
|
topic = f"device/{device_id}/event_resp"
|
||||||
card = await card_service.get_card_by_uuid(nfc_uuid)
|
card = await card_service.get_card_by_uuid(nfc_uuid)
|
||||||
if not card:
|
if not card:
|
||||||
logger.warning("", "", f"[NFC收听] 设备 {device_id} 没有找到卡片{nfc_uuid},无法发送留言提示")
|
|
||||||
payload = {
|
payload = {
|
||||||
"msg_id": "005",
|
"msg_id": "005",
|
||||||
"type": 0,
|
"type": 0,
|
||||||
"params": {
|
"params": {
|
||||||
"url_1": f"http://{settings.server_host}:{settings.server_port}/assets/audio/error_card.mp3"
|
"url_1": f"http://{settings.server_host}:{settings.server_port}/assets/audio/error_card.mp3"
|
||||||
}
|
},
|
||||||
}
|
}
|
||||||
await self._publish(topic, payload)
|
await self._publish(topic, payload)
|
||||||
return
|
return
|
||||||
@@ -201,73 +190,52 @@ class TalkingQMQTTService:
|
|||||||
"type": 0,
|
"type": 0,
|
||||||
"params": {
|
"params": {
|
||||||
"url_1": f"http://{settings.server_host}:{settings.server_port}/assets/audio/test_zh.mp3"
|
"url_1": f"http://{settings.server_host}:{settings.server_port}/assets/audio/test_zh.mp3"
|
||||||
}
|
},
|
||||||
}
|
}
|
||||||
await self._publish(topic, payload)
|
await self._publish(topic, payload)
|
||||||
return
|
return
|
||||||
|
|
||||||
if has_pending:
|
if has_pending:
|
||||||
audio_urls = await offline_audio_cache.get_audio_urls(device_id)
|
audio_urls = await offline_audio_cache.get_audio_urls(device_id)
|
||||||
# 53D92B6DA20001 测试卡片
|
|
||||||
if nfc_uuid == "53D92B6DA20001":
|
|
||||||
payload = {
|
|
||||||
"msg_id": "005",
|
|
||||||
"type": 0,
|
|
||||||
"params": {
|
|
||||||
"url_1": f"http://{settings.server_host}:{settings.server_port}/assets/audio/test_zh.mp3"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
await self._publish(topic, payload)
|
|
||||||
return
|
|
||||||
if len(audio_urls) == 0:
|
if len(audio_urls) == 0:
|
||||||
payload = {
|
payload = {
|
||||||
"msg_id": "005",
|
"msg_id": "005",
|
||||||
"type": 0,
|
"type": 0,
|
||||||
"params": {
|
"params": {
|
||||||
"url_1": f"http://{settings.server_host}:{settings.server_port}/assets/audio/no_message.mp3"
|
"url_1": f"http://{settings.server_host}:{settings.server_port}/assets/audio/no_message.mp3"
|
||||||
}
|
},
|
||||||
}
|
}
|
||||||
else:
|
else:
|
||||||
params = {}
|
params = {f"url_{k}": audio_url for k, audio_url in enumerate(audio_urls, start=1)}
|
||||||
for k, audio_url in enumerate(audio_urls, start=1):
|
payload = {"msg_id": "005", "type": 0, "params": params}
|
||||||
params[f"url_{k}"] = audio_url
|
|
||||||
payload = {
|
|
||||||
"msg_id": "005",
|
|
||||||
"type": 0,
|
|
||||||
"params": params
|
|
||||||
}
|
|
||||||
await offline_audio_cache.clear_audio_urls(device_id)
|
await offline_audio_cache.clear_audio_urls(device_id)
|
||||||
await self._publish(topic, payload)
|
await self._publish(topic, payload)
|
||||||
else:
|
return
|
||||||
|
|
||||||
payload = {
|
payload = {
|
||||||
"msg_id": "005",
|
"msg_id": "005",
|
||||||
"type": 0,
|
"type": 0,
|
||||||
"params": {
|
"params": {
|
||||||
"url_1": f"http://{settings.server_host}:{settings.server_port}/assets/audio/no_message.mp3"
|
"url_1": f"http://{settings.server_host}:{settings.server_port}/assets/audio/no_message.mp3"
|
||||||
}
|
},
|
||||||
}
|
}
|
||||||
await self._publish(topic, payload)
|
await self._publish(topic, payload)
|
||||||
else:
|
|
||||||
# 检查卡片是否存在
|
|
||||||
existing_card = await card_service.get_card_by_uuid(nfc_uuid)
|
|
||||||
|
|
||||||
if existing_card:
|
|
||||||
# 卡片已存在,使用卡片绑定的设备ID作为目标设备ID
|
|
||||||
target_device_id = existing_card.device_id
|
|
||||||
logger.info(device_id, "card", f"卡片已存在,绑定的设备ID: {target_device_id}")
|
|
||||||
else:
|
|
||||||
# 卡片不存在,创建新卡片并绑定到当前设备
|
|
||||||
new_card = await card_service.activate_card(nfc_uuid, device_id)
|
|
||||||
logger.info(device_id, "card", f"新卡片{nfc_uuid}已创建并激活,绑定到设备: {device_id}")
|
|
||||||
return
|
return
|
||||||
|
|
||||||
# 设置目标设备
|
existing_card = await card_service.get_card_by_uuid(nfc_uuid)
|
||||||
|
if existing_card:
|
||||||
|
target_device_id = existing_card.device_id
|
||||||
|
else:
|
||||||
|
await card_service.activate_card(nfc_uuid, device_id)
|
||||||
|
return
|
||||||
|
|
||||||
await device_target_cache.set_target(device_id, target_device_id)
|
await device_target_cache.set_target(device_id, target_device_id)
|
||||||
payload = {
|
payload = {
|
||||||
"msg_id": "005",
|
"msg_id": "005",
|
||||||
"type": 1,
|
"type": 1,
|
||||||
"params": {
|
"params": {
|
||||||
"url_1": f"http://{settings.server_host}:{settings.server_port}/assets/audio/welcome.mp3"
|
"url_1": f"http://{settings.server_host}:{settings.server_port}/assets/audio/welcome.mp3"
|
||||||
}
|
},
|
||||||
}
|
}
|
||||||
await self._publish(topic, payload)
|
await self._publish(topic, payload)
|
||||||
|
|
||||||
@@ -286,19 +254,15 @@ class TalkingQMQTTService:
|
|||||||
)
|
)
|
||||||
await self._client.__aenter__()
|
await self._client.__aenter__()
|
||||||
self._connected = True
|
self._connected = True
|
||||||
logger.system_info("", f"TalkingQ MQTT 已连接: {self.broker}:{self.port}")
|
|
||||||
|
|
||||||
await self._client.subscribe("device/+/response", qos=self.qos)
|
await self._client.subscribe("device/+/response", qos=self.qos)
|
||||||
logger.system_info("", "已订阅: device/+/response")
|
|
||||||
|
|
||||||
await self._client.subscribe("device/+/event", qos=self.qos)
|
await self._client.subscribe("device/+/event", qos=self.qos)
|
||||||
logger.system_info("", "已订阅: device/+/event")
|
|
||||||
|
|
||||||
self._message_task = asyncio.create_task(self._message_loop())
|
self._message_task = asyncio.create_task(self._message_loop())
|
||||||
logger.info("", "", "TalkingQ MQTT 服务启动中...")
|
logger.info("", "", f"mqtt connected: {self.broker}:{self.port}")
|
||||||
except Exception as e:
|
except Exception as exc:
|
||||||
self._connected = False
|
self._connected = False
|
||||||
logger.error("", "", f"TalkingQ MQTT 连接失败: {e}")
|
logger.error("", "", f"mqtt connect failed: {exc}")
|
||||||
|
|
||||||
async def disconnect(self):
|
async def disconnect(self):
|
||||||
if self._message_task and not self._message_task.done():
|
if self._message_task and not self._message_task.done():
|
||||||
@@ -325,69 +289,48 @@ class TalkingQMQTTService:
|
|||||||
await self._ensure_connected()
|
await self._ensure_connected()
|
||||||
try:
|
try:
|
||||||
await self._client.publish(topic, json.dumps(payload, ensure_ascii=False), qos=self.qos)
|
await self._client.publish(topic, json.dumps(payload, ensure_ascii=False), qos=self.qos)
|
||||||
logger.info("", "", f"下发命令 -> {topic}: {payload}")
|
logger.info("", "", f"mqtt publish topic={topic} payload={payload}")
|
||||||
except Exception as e:
|
except Exception as exc:
|
||||||
logger.error("", "", f"命令下发失败: {e}")
|
logger.error("", "", f"mqtt publish failed: {exc}")
|
||||||
|
|
||||||
async def send_gps_query(self, device_id: str) -> str:
|
async def send_gps_query(self, device_id: str) -> str:
|
||||||
topic = f"device/{device_id}/command"
|
await self._publish(f"device/{device_id}/command", {"msg_id": "001"})
|
||||||
payload = {"msg_id": "001"}
|
|
||||||
await self._publish(topic, payload)
|
|
||||||
return "001"
|
return "001"
|
||||||
|
|
||||||
async def send_volume_command(self, device_id: str, level: int) -> str:
|
async def send_volume_command(self, device_id: str, level: int) -> str:
|
||||||
topic = f"device/{device_id}/command"
|
await self._publish(
|
||||||
payload = {
|
f"device/{device_id}/command",
|
||||||
"msg_id": "002",
|
{"msg_id": "002", "params": {"level": level}},
|
||||||
"params": {"level": level}
|
)
|
||||||
}
|
|
||||||
await self._publish(topic, payload)
|
|
||||||
return "002"
|
return "002"
|
||||||
|
|
||||||
async def send_ota_command(self, device_id: str, url: str, version: str) -> str:
|
async def send_ota_command(self, device_id: str, url: str, version: str) -> str:
|
||||||
topic = f"device/{device_id}/command"
|
await self._publish(
|
||||||
payload = {
|
f"device/{device_id}/command",
|
||||||
"msg_id": "003",
|
{"msg_id": "003", "params": {"url": url, "version": version}},
|
||||||
"params": {
|
)
|
||||||
"url": url,
|
|
||||||
"version": version,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
await self._publish(topic, payload)
|
|
||||||
return "003"
|
return "003"
|
||||||
|
|
||||||
async def send_nfc_notice(self, device_id: str, url: str) -> str:
|
async def send_nfc_notice(self, device_id: str, url: str) -> str:
|
||||||
topic = f"device/{device_id}/command"
|
await self._publish(
|
||||||
payload = {
|
f"device/{device_id}/command",
|
||||||
"msg_id": "004",
|
{"msg_id": "004", "params": {"url_1": url}},
|
||||||
"params": {"url_1": url}
|
)
|
||||||
}
|
|
||||||
await self._publish(topic, payload)
|
|
||||||
return "004"
|
return "004"
|
||||||
|
|
||||||
|
async def send_bind_nfc_command(self, device_id: str, uuid: Optional[str] = None) -> str:
|
||||||
async def send_bind_nfc_command(self, device_id: str, uuid: str) -> str:
|
del uuid
|
||||||
topic = f"device/{device_id}/command"
|
await self._publish(f"device/{device_id}/command", {"msg_id": "006"})
|
||||||
payload = {
|
|
||||||
"msg_id": "006"
|
|
||||||
}
|
|
||||||
await self._publish(topic, payload)
|
|
||||||
return "006"
|
return "006"
|
||||||
|
|
||||||
async def send_open_command(self, device_id: str, open_type: int, is_open: int) -> str:
|
async def send_open_command(self, device_id: str, open_type: int, is_open: int) -> str:
|
||||||
topic = f"device/{device_id}/command"
|
|
||||||
if open_type not in [0, 1]:
|
if open_type not in [0, 1]:
|
||||||
raise ValueError("open_type must be 0 or 1")
|
raise ValueError("open_type must be 0 or 1")
|
||||||
|
|
||||||
if open_type == 0:
|
if open_type == 0:
|
||||||
payload = {
|
payload = {"msg_id": "007", "type": open_type}
|
||||||
"msg_id": "007",
|
else:
|
||||||
"type": open_type
|
payload = {"msg_id": "007", "type": open_type, "status": is_open}
|
||||||
}
|
|
||||||
elif open_type == 1:
|
await self._publish(f"device/{device_id}/command", payload)
|
||||||
payload = {
|
|
||||||
"msg_id": "007",
|
|
||||||
"type": open_type,
|
|
||||||
"status": is_open
|
|
||||||
}
|
|
||||||
await self._publish(topic, payload)
|
|
||||||
return "007"
|
return "007"
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
from typing import Optional, Dict
|
from typing import Dict, Optional
|
||||||
import time
|
|
||||||
from sqlalchemy import select, update, insert
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
from utils.logger import session_logger
|
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from database.models import Card as DBCard
|
from database.models import Card as DBCard
|
||||||
from services.database_service_base import DatabaseServiceBase
|
from services.database_service_base import DatabaseServiceBase
|
||||||
|
from utils.logger import session_logger
|
||||||
|
|
||||||
|
|
||||||
class Card(BaseModel):
|
class Card(BaseModel):
|
||||||
card_id: Optional[int] = None
|
card_id: Optional[int] = None
|
||||||
@@ -20,7 +22,6 @@ class Card(BaseModel):
|
|||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_db_model(cls, db_model: DBCard):
|
def from_db_model(cls, db_model: DBCard):
|
||||||
"""从数据库模型创建卡片对象"""
|
|
||||||
return cls(
|
return cls(
|
||||||
card_id=db_model.card_id,
|
card_id=db_model.card_id,
|
||||||
card_uuid=db_model.card_uuid,
|
card_uuid=db_model.card_uuid,
|
||||||
@@ -29,87 +30,123 @@ class Card(BaseModel):
|
|||||||
status=db_model.status,
|
status=db_model.status,
|
||||||
total_swaps=db_model.total_swaps,
|
total_swaps=db_model.total_swaps,
|
||||||
created_at=db_model.created_at.timestamp() if db_model.created_at else None,
|
created_at=db_model.created_at.timestamp() if db_model.created_at else None,
|
||||||
updated_at=db_model.updated_at.timestamp() if db_model.updated_at else None
|
updated_at=db_model.updated_at.timestamp() if db_model.updated_at else None,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class CardService(DatabaseServiceBase):
|
class CardService(DatabaseServiceBase):
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
super().__init__(service_name="card")
|
super().__init__(service_name="card")
|
||||||
self.cards: Dict[str, Card] = {}
|
self.cards: Dict[str, Card] = {}
|
||||||
self.lock = asyncio.Lock()
|
self.lock = asyncio.Lock()
|
||||||
|
|
||||||
|
async def _cache_card(self, card: Card) -> None:
|
||||||
|
async with self.lock:
|
||||||
|
self.cards[card.card_uuid] = card
|
||||||
|
|
||||||
async def _load_card_from_db(self, card_uuid: str, async_session: AsyncSession) -> Optional[Card]:
|
async def _load_card_from_db(self, card_uuid: str, async_session: AsyncSession) -> Optional[Card]:
|
||||||
"""从数据库加载卡片信息"""
|
|
||||||
try:
|
try:
|
||||||
query = select(DBCard).where(DBCard.card_uuid == card_uuid)
|
query = select(DBCard).where(DBCard.card_uuid == card_uuid)
|
||||||
result = await async_session.execute(query)
|
result = await async_session.execute(query)
|
||||||
db_card = result.scalar_one_or_none()
|
db_card = result.scalar_one_or_none()
|
||||||
|
|
||||||
if db_card:
|
if not db_card:
|
||||||
card = Card.from_db_model(db_card)
|
|
||||||
async with self.lock:
|
|
||||||
self.cards[card_uuid] = card
|
|
||||||
return card
|
|
||||||
return None
|
|
||||||
except Exception as e:
|
|
||||||
session_logger.error("card", "service", f"从数据库加载卡片失败: {str(e)}")
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
async def _save_card_to_db(self, card: Card, async_session: AsyncSession):
|
card = Card.from_db_model(db_card)
|
||||||
"""保存卡片信息到数据库"""
|
await self._cache_card(card)
|
||||||
|
return card
|
||||||
|
except Exception as exc:
|
||||||
|
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:
|
try:
|
||||||
query = select(DBCard).where(DBCard.card_uuid == card.card_uuid)
|
query = select(DBCard).where(DBCard.card_uuid == card.card_uuid)
|
||||||
result = await async_session.execute(query)
|
result = await async_session.execute(query)
|
||||||
existing_card = result.scalar_one_or_none()
|
existing_card = result.scalar_one_or_none()
|
||||||
|
|
||||||
if existing_card:
|
if existing_card:
|
||||||
stmt = update(DBCard).where(
|
existing_card.device_id = card.device_id
|
||||||
DBCard.card_uuid == card.card_uuid
|
existing_card.card_name = card.card_name
|
||||||
).values(
|
existing_card.status = card.status
|
||||||
device_id=card.device_id,
|
existing_card.total_swaps = card.total_swaps
|
||||||
card_name=card.card_name,
|
db_card = existing_card
|
||||||
status=card.status,
|
|
||||||
total_swaps=card.total_swaps
|
|
||||||
)
|
|
||||||
else:
|
else:
|
||||||
stmt = insert(DBCard).values(
|
db_card = DBCard(
|
||||||
card_uuid=card.card_uuid,
|
card_uuid=card.card_uuid,
|
||||||
device_id=card.device_id,
|
device_id=card.device_id,
|
||||||
card_name=card.card_name,
|
card_name=card.card_name,
|
||||||
status=card.status,
|
status=card.status,
|
||||||
total_swaps=card.total_swaps
|
total_swaps=card.total_swaps,
|
||||||
)
|
)
|
||||||
|
async_session.add(db_card)
|
||||||
|
|
||||||
await async_session.execute(stmt)
|
await async_session.flush()
|
||||||
|
if commit:
|
||||||
await async_session.commit()
|
await async_session.commit()
|
||||||
|
|
||||||
session_logger.info("card", "service", f"卡片已保存到数据库: {card.card_uuid}")
|
card.card_id = db_card.card_id
|
||||||
except Exception as e:
|
await self._cache_card(card)
|
||||||
|
session_logger.info("card", "service", f"card saved: {card.card_uuid}")
|
||||||
|
except Exception as exc:
|
||||||
|
if commit:
|
||||||
await async_session.rollback()
|
await async_session.rollback()
|
||||||
session_logger.error("card", "service", f"保存卡片到数据库失败: {str(e)}")
|
session_logger.error("card", "service", f"save card failed: {exc}")
|
||||||
raise
|
raise
|
||||||
|
|
||||||
async def get_card_by_uuid(self, card_uuid: str, force_refresh: bool = False) -> Optional[Card]:
|
async def get_card_by_uuid(
|
||||||
"""根据UUID获取卡片信息"""
|
self,
|
||||||
|
card_uuid: str,
|
||||||
|
force_refresh: bool = False,
|
||||||
|
db_session: Optional[AsyncSession] = None,
|
||||||
|
) -> Optional[Card]:
|
||||||
await self._init_database()
|
await self._init_database()
|
||||||
|
|
||||||
card = None
|
card = None
|
||||||
|
|
||||||
if not force_refresh:
|
if not force_refresh:
|
||||||
async with self.lock:
|
async with self.lock:
|
||||||
card = self.cards.get(card_uuid)
|
card = self.cards.get(card_uuid)
|
||||||
|
|
||||||
if not card:
|
if card:
|
||||||
db_session = await self.db_manager.get_session()
|
|
||||||
try:
|
|
||||||
card = await self._load_card_from_db(card_uuid, db_session)
|
|
||||||
finally:
|
|
||||||
await db_session.close()
|
|
||||||
|
|
||||||
return card
|
return card
|
||||||
|
|
||||||
|
if db_session is not None:
|
||||||
|
return await self._load_card_from_db(card_uuid, db_session)
|
||||||
|
|
||||||
|
session = await self.db_manager.get_session()
|
||||||
|
try:
|
||||||
|
return await self._load_card_from_db(card_uuid, session)
|
||||||
|
finally:
|
||||||
|
await session.close()
|
||||||
|
|
||||||
async def get_card_by_device_id(self, device_id: str) -> list[Card]:
|
async def get_card_by_device_id(self, device_id: str) -> list[Card]:
|
||||||
"""根据设备ID获取卡片列表"""
|
|
||||||
await self._init_database()
|
await self._init_database()
|
||||||
|
|
||||||
db_session = await self.db_manager.get_session()
|
db_session = await self.db_manager.get_session()
|
||||||
@@ -121,70 +158,77 @@ class CardService(DatabaseServiceBase):
|
|||||||
cards = []
|
cards = []
|
||||||
for db_card in db_cards:
|
for db_card in db_cards:
|
||||||
card = Card.from_db_model(db_card)
|
card = Card.from_db_model(db_card)
|
||||||
async with self.lock:
|
await self._cache_card(card)
|
||||||
self.cards[card.card_uuid] = card
|
|
||||||
cards.append(card)
|
cards.append(card)
|
||||||
|
|
||||||
return cards
|
return cards
|
||||||
except Exception as e:
|
except Exception as exc:
|
||||||
session_logger.error(device_id, "card", f"根据设备ID获取卡片失败: {str(e)}")
|
session_logger.error(device_id, "card", f"load device cards failed: {exc}")
|
||||||
return []
|
return []
|
||||||
finally:
|
finally:
|
||||||
await db_session.close()
|
await db_session.close()
|
||||||
|
|
||||||
async def activate_card(self, card_uuid: str, device_id: str, card_name: Optional[str] = None) -> Card:
|
async def activate_card(
|
||||||
"""激活卡片并绑定到设备"""
|
self,
|
||||||
|
card_uuid: str,
|
||||||
|
device_id: str,
|
||||||
|
card_name: Optional[str] = None,
|
||||||
|
db_session: Optional[AsyncSession] = None,
|
||||||
|
) -> Card:
|
||||||
await self._init_database()
|
await self._init_database()
|
||||||
|
|
||||||
|
owns_session = db_session is None
|
||||||
|
if db_session is None:
|
||||||
db_session = await self.db_manager.get_session()
|
db_session = await self.db_manager.get_session()
|
||||||
|
|
||||||
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)
|
await self._clear_existing_device_card(device_id=device_id, card_uuid=card_uuid, async_session=db_session)
|
||||||
|
|
||||||
if existing_card:
|
if existing_card:
|
||||||
# 更新现有卡片
|
|
||||||
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
|
||||||
await self._save_card_to_db(existing_card, db_session)
|
await self._save_card_to_db(existing_card, db_session, commit=owns_session)
|
||||||
session_logger.info(device_id, "card", f"卡片已激活并绑定到设备: {card_uuid}")
|
session_logger.info(device_id, "card", f"card activated: {card_uuid}")
|
||||||
return existing_card
|
return existing_card
|
||||||
else:
|
|
||||||
# 创建新卡片
|
|
||||||
new_card = Card(
|
new_card = Card(
|
||||||
card_uuid=card_uuid,
|
card_uuid=card_uuid,
|
||||||
device_id=device_id,
|
device_id=device_id,
|
||||||
card_name=card_name,
|
card_name=card_name,
|
||||||
status=1, # 激活状态
|
status=1,
|
||||||
total_swaps=0
|
total_swaps=0,
|
||||||
)
|
)
|
||||||
await self._save_card_to_db(new_card, db_session)
|
await self._save_card_to_db(new_card, db_session, commit=owns_session)
|
||||||
session_logger.info(device_id, "card", f"新卡片已创建并激活: {card_uuid}")
|
session_logger.info(device_id, "card", f"new card activated: {card_uuid}")
|
||||||
return new_card
|
return new_card
|
||||||
|
except Exception:
|
||||||
|
if owns_session:
|
||||||
|
await db_session.rollback()
|
||||||
|
raise
|
||||||
finally:
|
finally:
|
||||||
|
if owns_session:
|
||||||
await db_session.close()
|
await db_session.close()
|
||||||
|
|
||||||
async def increment_swap_count(self, card_uuid: str) -> Optional[Card]:
|
async def increment_swap_count(self, card_uuid: str) -> Optional[Card]:
|
||||||
"""增加卡片交换次数"""
|
|
||||||
await self._init_database()
|
await self._init_database()
|
||||||
|
|
||||||
card = await self.get_card_by_uuid(card_uuid)
|
card = await self.get_card_by_uuid(card_uuid)
|
||||||
if card:
|
if not card:
|
||||||
|
return None
|
||||||
|
|
||||||
card.total_swaps += 1
|
card.total_swaps += 1
|
||||||
db_session = await self.db_manager.get_session()
|
db_session = await self.db_manager.get_session()
|
||||||
try:
|
try:
|
||||||
await self._save_card_to_db(card, db_session)
|
await self._save_card_to_db(card, db_session)
|
||||||
session_logger.info("card", "service", f"卡片交换次数已增加: {card_uuid}, 总次数: {card.total_swaps}")
|
session_logger.info("card", "service", f"swap count incremented: {card_uuid}, total={card.total_swaps}")
|
||||||
return card
|
return card
|
||||||
finally:
|
finally:
|
||||||
await db_session.close()
|
await db_session.close()
|
||||||
return None
|
|
||||||
|
|
||||||
async def check_card_ownership(self, card_uuid: str, device_id: str) -> bool:
|
async def check_card_ownership(self, card_uuid: str, device_id: str) -> bool:
|
||||||
"""检查卡片是否属于指定设备"""
|
|
||||||
card = await self.get_card_by_uuid(card_uuid)
|
card = await self.get_card_by_uuid(card_uuid)
|
||||||
if card and card.device_id == device_id:
|
return bool(card and card.device_id == device_id)
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
card_service = CardService()
|
card_service = CardService()
|
||||||
|
|||||||
Reference in New Issue
Block a user