新增 token 余额前置检查,余额不足时阻止使用功能

问题:用户 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) <noreply@anthropic.com>
This commit is contained in:
wsb1224 2026-06-07 11:41:20 +08:00
parent 141f984772
commit 1d21d8b236
4 changed files with 49 additions and 3 deletions

View File

@ -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),

View File

@ -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

View File

@ -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,

View File

@ -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