绑定页支持贴卡倒计时过期重扫
This commit is contained in:
@@ -269,10 +269,19 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.pending-tip-text {
|
.pending-tip-text {
|
||||||
|
display: block;
|
||||||
font-size: 26px;
|
font-size: 26px;
|
||||||
color: #D46B08;
|
color: #D46B08;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.pending-tip-countdown {
|
||||||
|
display: block;
|
||||||
|
margin-top: 8px;
|
||||||
|
font-size: 28px;
|
||||||
|
color: #B45309;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
.pending-tip-subtext {
|
.pending-tip-subtext {
|
||||||
display: block;
|
display: block;
|
||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
|
|||||||
@@ -27,6 +27,31 @@ const SESSION_STATUS_EXPIRED = 3
|
|||||||
const SESSION_STATUS_FAILED = 4
|
const SESSION_STATUS_FAILED = 4
|
||||||
const SESSION_STATUS_CANCELLED = 5
|
const SESSION_STATUS_CANCELLED = 5
|
||||||
|
|
||||||
|
function normalizeExpiresAt(expiresAt: string): string {
|
||||||
|
let normalized = String(expiresAt || '').trim()
|
||||||
|
if (!normalized) return ''
|
||||||
|
|
||||||
|
const hasTimezone = /(?:Z|[+-]\d{2}:?\d{2})$/i.test(normalized)
|
||||||
|
if (!hasTimezone) normalized = `${normalized}Z`
|
||||||
|
|
||||||
|
return normalized.replace(/\.(\d{3})\d+(Z|[+-]\d{2}:?\d{2})$/i, '.$1$2')
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRemainingBindSeconds(expiresAt: string): number {
|
||||||
|
const timestamp = Date.parse(normalizeExpiresAt(expiresAt))
|
||||||
|
if (!Number.isFinite(timestamp)) return 0
|
||||||
|
return Math.max(0, Math.ceil((timestamp - Date.now()) / 1000))
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatRemainingTime(totalSeconds: number): string {
|
||||||
|
const safeSeconds = Math.max(0, totalSeconds)
|
||||||
|
const minutes = Math.floor(safeSeconds / 60)
|
||||||
|
const seconds = safeSeconds % 60
|
||||||
|
const minuteText = minutes < 10 ? `0${minutes}` : String(minutes)
|
||||||
|
const secondText = seconds < 10 ? `0${seconds}` : String(seconds)
|
||||||
|
return `${minuteText}:${secondText}`
|
||||||
|
}
|
||||||
|
|
||||||
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: '' }
|
||||||
@@ -99,23 +124,33 @@ export default function Bind() {
|
|||||||
const [bindStatus, setBindStatus] = useState<number | null>(null)
|
const [bindStatus, setBindStatus] = useState<number | null>(null)
|
||||||
const [cardUUID, setCardUUID] = useState('')
|
const [cardUUID, setCardUUID] = useState('')
|
||||||
const [bindHint, setBindHint] = useState('')
|
const [bindHint, setBindHint] = useState('')
|
||||||
|
const [bindExpiresAt, setBindExpiresAt] = useState('')
|
||||||
|
const [remainingBindSeconds, setRemainingBindSeconds] = useState(0)
|
||||||
const pollingRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
const pollingRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||||
|
const countdownRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||||
|
|
||||||
useDidShow(() => {
|
useDidShow(() => {
|
||||||
void loadPageData()
|
void loadPageData()
|
||||||
|
if (bindStatus === SESSION_STATUS_PENDING && bindToken) {
|
||||||
|
schedulePoll(bindToken)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
useDidHide(() => {
|
useDidHide(() => {
|
||||||
stopPolling()
|
stopPolling()
|
||||||
})
|
})
|
||||||
|
|
||||||
useEffect(() => () => stopPolling(), [])
|
useEffect(() => () => {
|
||||||
|
stopPolling()
|
||||||
|
stopCountdown()
|
||||||
|
}, [])
|
||||||
|
|
||||||
const hasSelectedOrNewChild = selectedChildId !== null || Boolean(newChildName.trim())
|
const hasSelectedOrNewChild = selectedChildId !== null || Boolean(newChildName.trim())
|
||||||
const isPendingBinding = Boolean(pendingDeviceId)
|
const isPendingBinding = Boolean(pendingDeviceId)
|
||||||
const isPollingBind = bindStatus === SESSION_STATUS_PENDING && Boolean(bindToken)
|
const isPollingBind = bindStatus === SESSION_STATUS_PENDING && Boolean(bindToken)
|
||||||
|
const isExpiredBind = bindStatus === SESSION_STATUS_EXPIRED
|
||||||
const canEditDeviceFields = !isPendingBinding && !isPollingBind
|
const canEditDeviceFields = !isPendingBinding && !isPollingBind
|
||||||
const canSubmit = isPendingBinding ? hasSelectedOrNewChild : !isPollingBind
|
const canSubmit = isPendingBinding ? hasSelectedOrNewChild : isExpiredBind ? true : !isPollingBind
|
||||||
|
|
||||||
const goDevicePage = () => {
|
const goDevicePage = () => {
|
||||||
Taro.reLaunch({ url: '/pages/device/index' })
|
Taro.reLaunch({ url: '/pages/device/index' })
|
||||||
@@ -140,6 +175,13 @@ export default function Bind() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const stopCountdown = () => {
|
||||||
|
if (countdownRef.current) {
|
||||||
|
clearInterval(countdownRef.current)
|
||||||
|
countdownRef.current = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const schedulePoll = (nextBindToken?: string) => {
|
const schedulePoll = (nextBindToken?: string) => {
|
||||||
stopPolling()
|
stopPolling()
|
||||||
pollingRef.current = setTimeout(() => {
|
pollingRef.current = setTimeout(() => {
|
||||||
@@ -149,12 +191,52 @@ export default function Bind() {
|
|||||||
|
|
||||||
const resetBindSessionState = () => {
|
const resetBindSessionState = () => {
|
||||||
stopPolling()
|
stopPolling()
|
||||||
|
stopCountdown()
|
||||||
setBindToken('')
|
setBindToken('')
|
||||||
setBindStatus(null)
|
setBindStatus(null)
|
||||||
setCardUUID('')
|
setCardUUID('')
|
||||||
setBindHint('')
|
setBindHint('')
|
||||||
|
setBindExpiresAt('')
|
||||||
|
setRemainingBindSeconds(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const resetExpiredBindSession = (showToast = true) => {
|
||||||
|
stopPolling()
|
||||||
|
stopCountdown()
|
||||||
|
setBindToken('')
|
||||||
|
setBindStatus(SESSION_STATUS_EXPIRED)
|
||||||
|
setCardUUID('')
|
||||||
|
setBindExpiresAt('')
|
||||||
|
setRemainingBindSeconds(0)
|
||||||
|
setDeviceId('')
|
||||||
|
setSerialNumber('')
|
||||||
|
setBindHint('贴卡时间已过,请重新扫码并贴卡')
|
||||||
|
|
||||||
|
if (showToast) {
|
||||||
|
Taro.showToast({ title: '贴卡已超时,请重新扫码', icon: 'none' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (bindStatus !== SESSION_STATUS_PENDING || !bindExpiresAt) {
|
||||||
|
stopCountdown()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateRemainingTime = () => {
|
||||||
|
const nextRemainingSeconds = getRemainingBindSeconds(bindExpiresAt)
|
||||||
|
setRemainingBindSeconds(nextRemainingSeconds)
|
||||||
|
if (nextRemainingSeconds <= 0) {
|
||||||
|
resetExpiredBindSession()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateRemainingTime()
|
||||||
|
stopCountdown()
|
||||||
|
countdownRef.current = setInterval(updateRemainingTime, 1000)
|
||||||
|
return () => stopCountdown()
|
||||||
|
}, [bindStatus, bindExpiresAt])
|
||||||
|
|
||||||
const pollBindSession = async (nextBindToken?: string) => {
|
const pollBindSession = async (nextBindToken?: string) => {
|
||||||
const activeBindToken = (nextBindToken || bindToken).trim()
|
const activeBindToken = (nextBindToken || bindToken).trim()
|
||||||
if (!activeBindToken) return
|
if (!activeBindToken) return
|
||||||
@@ -163,14 +245,20 @@ export default function Bind() {
|
|||||||
const session = await getNFCBindSession(activeBindToken)
|
const session = await getNFCBindSession(activeBindToken)
|
||||||
setBindStatus(session.status)
|
setBindStatus(session.status)
|
||||||
setCardUUID(session.card_uuid || '')
|
setCardUUID(session.card_uuid || '')
|
||||||
|
setBindExpiresAt(session.expires_at || '')
|
||||||
|
|
||||||
if (session.status === SESSION_STATUS_PENDING) {
|
if (session.status === SESSION_STATUS_PENDING) {
|
||||||
setBindHint('已发送绑卡指令,请拿自己的卡去设备上贴一下')
|
if (getRemainingBindSeconds(session.expires_at) <= 0) {
|
||||||
|
resetExpiredBindSession()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setBindHint('已发送绑卡指令,请在倒计时结束前去设备上贴自己的卡')
|
||||||
schedulePoll(activeBindToken)
|
schedulePoll(activeBindToken)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
stopPolling()
|
stopPolling()
|
||||||
|
stopCountdown()
|
||||||
|
|
||||||
if (session.status === SESSION_STATUS_COMPLETED) {
|
if (session.status === SESSION_STATUS_COMPLETED) {
|
||||||
setSelectedBindingDeviceId(session.device_id)
|
setSelectedBindingDeviceId(session.device_id)
|
||||||
@@ -191,7 +279,7 @@ export default function Bind() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (session.status === SESSION_STATUS_EXPIRED) {
|
if (session.status === SESSION_STATUS_EXPIRED) {
|
||||||
setBindHint('绑定会话已过期,请重新扫码并贴卡')
|
resetExpiredBindSession(false)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -271,6 +359,7 @@ export default function Bind() {
|
|||||||
success: (res) => {
|
success: (res) => {
|
||||||
setIsScanning(false)
|
setIsScanning(false)
|
||||||
const parsed = parseBindingPayload(res.result || '')
|
const parsed = parseBindingPayload(res.result || '')
|
||||||
|
resetBindSessionState()
|
||||||
if (parsed.deviceId) setDeviceId(parsed.deviceId)
|
if (parsed.deviceId) setDeviceId(parsed.deviceId)
|
||||||
if (parsed.serialNumber) setSerialNumber(parsed.serialNumber)
|
if (parsed.serialNumber) setSerialNumber(parsed.serialNumber)
|
||||||
|
|
||||||
@@ -328,7 +417,9 @@ export default function Bind() {
|
|||||||
|
|
||||||
setBindToken(session.bind_token)
|
setBindToken(session.bind_token)
|
||||||
setBindStatus(session.status)
|
setBindStatus(session.status)
|
||||||
setBindHint(childId ? '已发送绑卡指令,请去设备上贴自己的卡' : '已发送绑卡指令,贴卡后设备会先进入待关联状态')
|
setBindExpiresAt(session.expires_at)
|
||||||
|
setRemainingBindSeconds(getRemainingBindSeconds(session.expires_at))
|
||||||
|
setBindHint(childId ? '已发送绑卡指令,请在倒计时结束前去设备上贴自己的卡' : '已发送绑卡指令,贴卡后设备会先进入待关联状态')
|
||||||
schedulePoll(session.bind_token)
|
schedulePoll(session.bind_token)
|
||||||
Taro.showToast({ title: '请去设备上贴卡', icon: 'none' })
|
Taro.showToast({ title: '请去设备上贴卡', icon: 'none' })
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
@@ -492,6 +583,9 @@ export default function Bind() {
|
|||||||
{bindHint ? (
|
{bindHint ? (
|
||||||
<View className='pending-tip'>
|
<View className='pending-tip'>
|
||||||
<Text className='pending-tip-text'>{bindHint}</Text>
|
<Text className='pending-tip-text'>{bindHint}</Text>
|
||||||
|
{isPollingBind ? (
|
||||||
|
<Text className='pending-tip-countdown'>剩余贴卡时间:{formatRemainingTime(remainingBindSeconds)}</Text>
|
||||||
|
) : null}
|
||||||
{cardUUID ? <Text className='pending-tip-subtext'>卡片 UUID:{cardUUID}</Text> : null}
|
{cardUUID ? <Text className='pending-tip-subtext'>卡片 UUID:{cardUUID}</Text> : null}
|
||||||
</View>
|
</View>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -508,9 +602,9 @@ export default function Bind() {
|
|||||||
className='submit-btn'
|
className='submit-btn'
|
||||||
loading={submitting}
|
loading={submitting}
|
||||||
disabled={submitting || !canSubmit}
|
disabled={submitting || !canSubmit}
|
||||||
onClick={handleSubmit}
|
onClick={isExpiredBind && !isPendingBinding ? handleScanCode : handleSubmit}
|
||||||
>
|
>
|
||||||
{isPendingBinding ? '完成儿童关联' : isPollingBind ? '等待贴卡确认' : '发送绑卡指令'}
|
{isPendingBinding ? '完成儿童关联' : isPollingBind ? '等待贴卡确认' : isExpiredBind ? '重新扫码绑定' : '发送绑卡指令'}
|
||||||
</Button>
|
</Button>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
@@ -526,7 +620,7 @@ export default function Bind() {
|
|||||||
</View>
|
</View>
|
||||||
<View className='tip-item'>
|
<View className='tip-item'>
|
||||||
<Text className='tip-number'>3</Text>
|
<Text className='tip-number'>3</Text>
|
||||||
<Text className='tip-text'>{isPendingBinding ? '点击完成儿童关联,设备会出现在该孩子下面' : '点击发送绑卡指令,然后去设备上贴自己的卡完成确认'}</Text>
|
<Text className='tip-text'>{isPendingBinding ? '点击完成儿童关联,设备会出现在该孩子下面' : '点击发送绑卡指令,然后在 10 分钟内去设备上贴自己的卡完成确认'}</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
{systemBanner}
|
{systemBanner}
|
||||||
|
|||||||
155
talkingq-url/tests/test_binding_session_expiry.py
Normal file
155
talkingq-url/tests/test_binding_session_expiry.py
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from banban.dao.binding import SESSION_STATUS_COMPLETED, SESSION_STATUS_EXPIRED
|
||||||
|
from banban.service.binding import BindingService
|
||||||
|
|
||||||
|
|
||||||
|
class FakeSession:
|
||||||
|
def __init__(self):
|
||||||
|
self.commits = 0
|
||||||
|
self.rollbacks = 0
|
||||||
|
self.closed = False
|
||||||
|
|
||||||
|
async def commit(self):
|
||||||
|
self.commits += 1
|
||||||
|
|
||||||
|
async def rollback(self):
|
||||||
|
self.rollbacks += 1
|
||||||
|
|
||||||
|
async def close(self):
|
||||||
|
self.closed = True
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_finalize_nfc_bind_expires_late_card_without_binding(monkeypatch):
|
||||||
|
service = BindingService()
|
||||||
|
db_session = FakeSession()
|
||||||
|
expired_session = {
|
||||||
|
"id": 42,
|
||||||
|
"bind_token": "bind-token-expired",
|
||||||
|
"device_id": "TalkingQ_device001",
|
||||||
|
"target_child_id": 7,
|
||||||
|
"initiator_user_id": 99,
|
||||||
|
"expires_at": datetime.utcnow() - timedelta(seconds=1),
|
||||||
|
}
|
||||||
|
marked_statuses = []
|
||||||
|
activated_cards = []
|
||||||
|
completed_sessions = []
|
||||||
|
|
||||||
|
async def fake_get_session():
|
||||||
|
return db_session
|
||||||
|
|
||||||
|
async def fake_get_latest_pending_session_by_device(self, device_id):
|
||||||
|
assert device_id == "TalkingQ_device001"
|
||||||
|
return expired_session
|
||||||
|
|
||||||
|
async def fake_mark_session_status(self, session_id, status):
|
||||||
|
marked_statuses.append((session_id, status))
|
||||||
|
|
||||||
|
async def fake_activate_card(**kwargs):
|
||||||
|
activated_cards.append(kwargs)
|
||||||
|
|
||||||
|
async def fake_complete_nfc_bind(self, **kwargs):
|
||||||
|
completed_sessions.append(kwargs)
|
||||||
|
|
||||||
|
monkeypatch.setattr(service, "get_session", fake_get_session)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"banban.service.binding.BindingDAO.get_latest_pending_session_by_device",
|
||||||
|
fake_get_latest_pending_session_by_device,
|
||||||
|
)
|
||||||
|
monkeypatch.setattr("banban.service.binding.BindingDAO.mark_session_status", fake_mark_session_status)
|
||||||
|
monkeypatch.setattr("banban.service.binding.card_service.activate_card", fake_activate_card)
|
||||||
|
monkeypatch.setattr("banban.service.binding.BindingDAO.complete_nfc_bind", fake_complete_nfc_bind)
|
||||||
|
|
||||||
|
result = await service.finalize_nfc_bind(
|
||||||
|
device_id="TalkingQ_device001",
|
||||||
|
card_uuid="CARD_UUID_LATE",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result == {
|
||||||
|
"device_id": "TalkingQ_device001",
|
||||||
|
"bind_token": "bind-token-expired",
|
||||||
|
"status": SESSION_STATUS_EXPIRED,
|
||||||
|
}
|
||||||
|
assert marked_statuses == [(42, SESSION_STATUS_EXPIRED)]
|
||||||
|
assert activated_cards == []
|
||||||
|
assert completed_sessions == []
|
||||||
|
assert db_session.commits == 1
|
||||||
|
assert db_session.rollbacks == 0
|
||||||
|
assert db_session.closed is True
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_finalize_nfc_bind_completes_before_expiry(monkeypatch):
|
||||||
|
service = BindingService()
|
||||||
|
db_session = FakeSession()
|
||||||
|
pending_session = {
|
||||||
|
"id": 43,
|
||||||
|
"bind_token": "bind-token-active",
|
||||||
|
"device_id": "TalkingQ_device001",
|
||||||
|
"target_child_id": 7,
|
||||||
|
"initiator_user_id": 99,
|
||||||
|
"expires_at": datetime.utcnow() + timedelta(minutes=10),
|
||||||
|
}
|
||||||
|
marked_statuses = []
|
||||||
|
activated_cards = []
|
||||||
|
completed_sessions = []
|
||||||
|
|
||||||
|
async def fake_get_session():
|
||||||
|
return db_session
|
||||||
|
|
||||||
|
async def fake_get_latest_pending_session_by_device(self, device_id):
|
||||||
|
assert device_id == "TalkingQ_device001"
|
||||||
|
return pending_session
|
||||||
|
|
||||||
|
async def fake_mark_session_status(self, session_id, status):
|
||||||
|
marked_statuses.append((session_id, status))
|
||||||
|
|
||||||
|
async def fake_activate_card(**kwargs):
|
||||||
|
activated_cards.append(kwargs)
|
||||||
|
|
||||||
|
async def fake_complete_nfc_bind(self, **kwargs):
|
||||||
|
completed_sessions.append(kwargs)
|
||||||
|
|
||||||
|
monkeypatch.setattr(service, "get_session", fake_get_session)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"banban.service.binding.BindingDAO.get_latest_pending_session_by_device",
|
||||||
|
fake_get_latest_pending_session_by_device,
|
||||||
|
)
|
||||||
|
monkeypatch.setattr("banban.service.binding.BindingDAO.mark_session_status", fake_mark_session_status)
|
||||||
|
monkeypatch.setattr("banban.service.binding.card_service.activate_card", fake_activate_card)
|
||||||
|
monkeypatch.setattr("banban.service.binding.BindingDAO.complete_nfc_bind", fake_complete_nfc_bind)
|
||||||
|
|
||||||
|
result = await service.finalize_nfc_bind(
|
||||||
|
device_id="TalkingQ_device001",
|
||||||
|
card_uuid="CARD_UUID_ON_TIME",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result == {
|
||||||
|
"device_id": "TalkingQ_device001",
|
||||||
|
"bind_token": "bind-token-active",
|
||||||
|
"status": SESSION_STATUS_COMPLETED,
|
||||||
|
"child_id": 7,
|
||||||
|
"card_uuid": "CARD_UUID_ON_TIME",
|
||||||
|
}
|
||||||
|
assert marked_statuses == []
|
||||||
|
assert activated_cards == [
|
||||||
|
{
|
||||||
|
"card_uuid": "CARD_UUID_ON_TIME",
|
||||||
|
"device_id": "TalkingQ_device001",
|
||||||
|
"db_session": db_session,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
assert completed_sessions == [
|
||||||
|
{
|
||||||
|
"session_id": 43,
|
||||||
|
"device_id": "TalkingQ_device001",
|
||||||
|
"child_id": 7,
|
||||||
|
"user_id": 99,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
assert db_session.commits == 1
|
||||||
|
assert db_session.rollbacks == 0
|
||||||
|
assert db_session.closed is True
|
||||||
Reference in New Issue
Block a user