From 1d21d8b2363608c811cce4ad617c4322ffd35797 Mon Sep 17 00:00:00 2001 From: wsb1224 Date: Sun, 7 Jun 2026 11:41:20 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=20token=20=E4=BD=99=E9=A2=9D?= =?UTF-8?q?=E5=89=8D=E7=BD=AE=E6=A3=80=E6=9F=A5=EF=BC=8C=E4=BD=99=E9=A2=9D?= =?UTF-8?q?=E4=B8=8D=E8=B6=B3=E6=97=B6=E9=98=BB=E6=AD=A2=E4=BD=BF=E7=94=A8?= =?UTF-8?q?=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 问题:用户 token 余额为 0 时仍能正常使用文字聊天、语音聊天、实时 通话。根因是前端没有任何前置检查,完全依赖后端"先用后扣"模式,而 后端扣费失败仅记日志不阻断响应。 修复措施: - 后端新增 GET /user/balance 轻量接口,返回 available_tokens - 前端 chatStore 新增 fetchUserBalance(),页面初始化时查询余额并 设置 _tokenExhausted / _realtimeTokenExhausted 标记 - sendTextMessage、toggleCallMode、toggleVoiceInput 入口处增加 isTokenExhausted() 前置检查 - canSend 计算属性加入 token 检查,余额不足时发送按钮置灰 Co-Authored-By: Claude Opus 4.7 (1M context) --- backend/api/user_router.py | 17 +++++++++++++++++ frontend/src/api/index.js | 3 ++- frontend/src/stores/chatStore.js | 16 +++++++++++++++- frontend/src/views/ChatView.vue | 16 +++++++++++++++- 4 files changed, 49 insertions(+), 3 deletions(-) diff --git a/backend/api/user_router.py b/backend/api/user_router.py index 8b49e49..22c458b 100644 --- a/backend/api/user_router.py +++ b/backend/api/user_router.py @@ -211,6 +211,23 @@ async def change_password( } +@user_router.get("/balance") +async def get_user_balance( + current_user: Dict = Depends(get_current_user), + db = Depends(get_db) +): + """Get user available token balance""" + from services.token_service import TokenService + token_service = TokenService(db) + quota = await token_service._check_user_quota(current_user.get('id')) + return { + "success": True, + "data": { + "available_tokens": quota["available_tokens"] + } + } + + @user_router.get("/stats") async def get_user_stats( current_user: Dict = Depends(get_current_user), diff --git a/frontend/src/api/index.js b/frontend/src/api/index.js index e2e55f6..fe4e4df 100644 --- a/frontend/src/api/index.js +++ b/frontend/src/api/index.js @@ -76,7 +76,8 @@ export const userAPI = { login: (loginData) => apiClient.post(API_ENDPOINTS.USER.LOGIN, loginData), getProfile: () => apiClient.get(API_ENDPOINTS.USER.PROFILE), update: (data) => apiClient.put(API_ENDPOINTS.USER.UPDATE, data), - delete: () => apiClient.delete(API_ENDPOINTS.USER.PROFILE) + delete: () => apiClient.delete(API_ENDPOINTS.USER.PROFILE), + getBalance: () => apiClient.get('/api/v1/user/balance') } export const authAPI = userAPI diff --git a/frontend/src/stores/chatStore.js b/frontend/src/stores/chatStore.js index 04b77f3..abff41e 100644 --- a/frontend/src/stores/chatStore.js +++ b/frontend/src/stores/chatStore.js @@ -5,7 +5,7 @@ import { defineStore } from 'pinia' import { ref, computed } from 'vue' import apiClient from '@/api/index' -import { chatAPI } from '@/api/index' +import { chatAPI, userAPI } from '@/api/index' import { ElMessage } from 'element-plus' // 统计更新事件回调 @@ -1744,6 +1744,19 @@ export const useChatStore = defineStore('chat', () => { _realtimeTokenExhausted = false } + async function fetchUserBalance() { + try { + const response = await userAPI.getBalance() + const available = response.data?.data?.available_tokens ?? 0 + _tokenExhausted = available <= 0 + _realtimeTokenExhausted = available <= 0 + return available + } catch (error) { + console.warn('[ChatStore] Failed to fetch user balance:', error) + return null + } + } + function updateSessionConfig(config) { currentSessionConfig.value = { ...currentSessionConfig.value, @@ -1814,6 +1827,7 @@ export const useChatStore = defineStore('chat', () => { updateSessionConfig, isTokenExhausted, resetTokenExhausted, + fetchUserBalance, // 其他 clearMessages, diff --git a/frontend/src/views/ChatView.vue b/frontend/src/views/ChatView.vue index cb2b05f..f32565c 100644 --- a/frontend/src/views/ChatView.vue +++ b/frontend/src/views/ChatView.vue @@ -1153,7 +1153,7 @@ export default { } const canSend = computed(() => { - return inputMessage.value.trim() && !sending.value && !isRecording.value && !isInCall.value + return inputMessage.value.trim() && !sending.value && !isRecording.value && !isInCall.value && !chatStore.isTokenExhausted() }) const normalizeDateValue = (value) => { @@ -1315,6 +1315,7 @@ export default { cacheSelectedPet(currentPet.value) saveChatConfig() syncStoreState() + await chatStore.fetchUserBalance() // ElMessage.success('聊天初始化成功') } catch (err) { console.error('Failed to init chat:', err) @@ -1604,6 +1605,10 @@ export default { const sendTextMessage = async () => { if (!inputMessage.value.trim() || sending.value) return + if (chatStore.isTokenExhausted()) { + ElMessage.error('Token已耗尽,请充值后再使用') + return + } const messageText = inputMessage.value.trim() inputMessage.value = '' @@ -1693,6 +1698,10 @@ export default { try { const hasPermission = await chatStore.requestMicrophonePermission() if (hasPermission) { + if (chatStore.isTokenExhausted()) { + ElMessage.error('Token已耗尽,请充值后再使用') + return + } await forceStopVoiceAndCall() // 先显示面板但不切换 chatMode,避免预连接期间的竞态 showInputPanel.value = true @@ -1826,6 +1835,11 @@ export default { pendingCallMode = true try { + if (chatStore.isTokenExhausted()) { + ElMessage.error('Token已耗尽,请充值后再使用') + return + } + const hasPermission = await chatStore.requestMicrophonePermission() if (!hasPermission) { return