diff --git a/banban-mini/src/pages/bind/index.scss b/banban-mini/src/pages/bind/index.scss index 674f2a6..8dd6d8a 100644 --- a/banban-mini/src/pages/bind/index.scss +++ b/banban-mini/src/pages/bind/index.scss @@ -269,10 +269,19 @@ } .pending-tip-text { + display: block; font-size: 26px; color: #D46B08; } +.pending-tip-countdown { + display: block; + margin-top: 8px; + font-size: 28px; + color: #B45309; + font-weight: 700; +} + .pending-tip-subtext { display: block; margin-top: 8px; diff --git a/banban-mini/src/pages/bind/index.tsx b/banban-mini/src/pages/bind/index.tsx index 4f4361b..f06c1ed 100644 --- a/banban-mini/src/pages/bind/index.tsx +++ b/banban-mini/src/pages/bind/index.tsx @@ -27,6 +27,31 @@ const SESSION_STATUS_EXPIRED = 3 const SESSION_STATUS_FAILED = 4 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 } { const raw = String(rawValue || '').trim() if (!raw) return { deviceId: '', serialNumber: '' } @@ -99,23 +124,33 @@ export default function Bind() { const [bindStatus, setBindStatus] = useState(null) const [cardUUID, setCardUUID] = useState('') const [bindHint, setBindHint] = useState('') + const [bindExpiresAt, setBindExpiresAt] = useState('') + const [remainingBindSeconds, setRemainingBindSeconds] = useState(0) const pollingRef = useRef | null>(null) + const countdownRef = useRef | null>(null) useDidShow(() => { void loadPageData() + if (bindStatus === SESSION_STATUS_PENDING && bindToken) { + schedulePoll(bindToken) + } }) useDidHide(() => { stopPolling() }) - useEffect(() => () => stopPolling(), []) + useEffect(() => () => { + stopPolling() + stopCountdown() + }, []) const hasSelectedOrNewChild = selectedChildId !== null || Boolean(newChildName.trim()) const isPendingBinding = Boolean(pendingDeviceId) const isPollingBind = bindStatus === SESSION_STATUS_PENDING && Boolean(bindToken) + const isExpiredBind = bindStatus === SESSION_STATUS_EXPIRED const canEditDeviceFields = !isPendingBinding && !isPollingBind - const canSubmit = isPendingBinding ? hasSelectedOrNewChild : !isPollingBind + const canSubmit = isPendingBinding ? hasSelectedOrNewChild : isExpiredBind ? true : !isPollingBind const goDevicePage = () => { 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) => { stopPolling() pollingRef.current = setTimeout(() => { @@ -149,12 +191,52 @@ export default function Bind() { const resetBindSessionState = () => { stopPolling() + stopCountdown() setBindToken('') setBindStatus(null) setCardUUID('') 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 activeBindToken = (nextBindToken || bindToken).trim() if (!activeBindToken) return @@ -163,14 +245,20 @@ export default function Bind() { const session = await getNFCBindSession(activeBindToken) setBindStatus(session.status) setCardUUID(session.card_uuid || '') + setBindExpiresAt(session.expires_at || '') if (session.status === SESSION_STATUS_PENDING) { - setBindHint('已发送绑卡指令,请拿自己的卡去设备上贴一下') + if (getRemainingBindSeconds(session.expires_at) <= 0) { + resetExpiredBindSession() + return + } + setBindHint('已发送绑卡指令,请在倒计时结束前去设备上贴自己的卡') schedulePoll(activeBindToken) return } stopPolling() + stopCountdown() if (session.status === SESSION_STATUS_COMPLETED) { setSelectedBindingDeviceId(session.device_id) @@ -191,7 +279,7 @@ export default function Bind() { } if (session.status === SESSION_STATUS_EXPIRED) { - setBindHint('绑定会话已过期,请重新扫码并贴卡') + resetExpiredBindSession(false) return } @@ -271,6 +359,7 @@ export default function Bind() { success: (res) => { setIsScanning(false) const parsed = parseBindingPayload(res.result || '') + resetBindSessionState() if (parsed.deviceId) setDeviceId(parsed.deviceId) if (parsed.serialNumber) setSerialNumber(parsed.serialNumber) @@ -328,7 +417,9 @@ export default function Bind() { setBindToken(session.bind_token) setBindStatus(session.status) - setBindHint(childId ? '已发送绑卡指令,请去设备上贴自己的卡' : '已发送绑卡指令,贴卡后设备会先进入待关联状态') + setBindExpiresAt(session.expires_at) + setRemainingBindSeconds(getRemainingBindSeconds(session.expires_at)) + setBindHint(childId ? '已发送绑卡指令,请在倒计时结束前去设备上贴自己的卡' : '已发送绑卡指令,贴卡后设备会先进入待关联状态') schedulePoll(session.bind_token) Taro.showToast({ title: '请去设备上贴卡', icon: 'none' }) } catch (error: any) { @@ -492,6 +583,9 @@ export default function Bind() { {bindHint ? ( {bindHint} + {isPollingBind ? ( + 剩余贴卡时间:{formatRemainingTime(remainingBindSeconds)} + ) : null} {cardUUID ? 卡片 UUID:{cardUUID} : null} ) : null} @@ -508,9 +602,9 @@ export default function Bind() { className='submit-btn' loading={submitting} disabled={submitting || !canSubmit} - onClick={handleSubmit} + onClick={isExpiredBind && !isPendingBinding ? handleScanCode : handleSubmit} > - {isPendingBinding ? '完成儿童关联' : isPollingBind ? '等待贴卡确认' : '发送绑卡指令'} + {isPendingBinding ? '完成儿童关联' : isPollingBind ? '等待贴卡确认' : isExpiredBind ? '重新扫码绑定' : '发送绑卡指令'} @@ -526,7 +620,7 @@ export default function Bind() { 3 - {isPendingBinding ? '点击完成儿童关联,设备会出现在该孩子下面' : '点击发送绑卡指令,然后去设备上贴自己的卡完成确认'} + {isPendingBinding ? '点击完成儿童关联,设备会出现在该孩子下面' : '点击发送绑卡指令,然后在 10 分钟内去设备上贴自己的卡完成确认'} {systemBanner} diff --git a/talkingq-url/tests/test_binding_session_expiry.py b/talkingq-url/tests/test_binding_session_expiry.py new file mode 100644 index 0000000..3bb218d --- /dev/null +++ b/talkingq-url/tests/test_binding_session_expiry.py @@ -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