修复token无法统计功能1.0

This commit is contained in:
taiyi 2026-05-26 09:56:45 +08:00
parent 7924b9744d
commit 41371b8c47
9 changed files with 163 additions and 90 deletions

View File

@ -232,8 +232,13 @@ async def get_admin_stats(
)
period_chats = period_chats_result.scalar() or 0
# 周期内 token 总消耗
# 周期内 token 总消耗:优先使用钱包累计消耗,其次使用日志统计
period_tokens_result = await db.execute(
select(func.coalesce(func.sum(UserWallet.total_consumed), 0))
)
wallet_period_tokens = period_tokens_result.scalar() or 0
chatlog_tokens_result = await db.execute(
select(
func.coalesce(func.sum(ChatLog.tokens_input), 0) +
func.coalesce(func.sum(ChatLog.tokens_output), 0)
@ -244,7 +249,8 @@ async def get_admin_stats(
)
)
)
period_tokens = period_tokens_result.scalar() or 0
chatlog_period_tokens = chatlog_tokens_result.scalar() or 0
period_tokens = max(wallet_period_tokens, chatlog_period_tokens)
# 周期内语音聊天 / 实时通话 token 消耗
voice_tokens_result = await db.execute(
@ -461,6 +467,8 @@ async def get_admin_stats(
},
"tokens": {
"period_total": period_tokens,
"chatlog_total": chatlog_period_tokens,
"wallet_total": wallet_period_tokens,
"voice_chat_total": voice_tokens,
"realtime_call_total": realtime_tokens,
"voice_total": voice_total_tokens

View File

@ -85,7 +85,7 @@ async def send_message_endpoint(
charge_result = await token_service.charge_tokens(
user_id=current_user["id"],
mode="text_chat_send",
mode="text_chat",
tokens_used=result.get("tokens_used"),
fallback_amount=1
)
@ -98,7 +98,8 @@ async def send_message_endpoint(
return SendMessageResponse(
response=result["response"],
conversation_id=result["conversation_id"],
tokens_used=result["tokens_used"]
tokens_used=result["tokens_used"],
remaining_tokens=charge_result.get("available_tokens")
)
except ValueError as e:
raise HTTPException(
@ -256,24 +257,28 @@ async def websocket_chat_endpoint(websocket: WebSocket, user_id: str):
conversation_id=conversation_id
):
if isinstance(chunk, dict):
remaining_tokens = None
if chunk.get("type") == "stats" and not is_guest:
charge_result = await token_service.charge_tokens(
user_id=numeric_user_id,
mode="text_chat",
tokens_used=chunk.get("tokens_used"),
fallback_amount=1
)
if not charge_result.get("success"):
logger.warning(
f"Failed to charge tokens for user {user_id} in text_chat: "
f"{charge_result.get('message')}"
)
remaining_tokens = charge_result.get("available_tokens")
chunk_data = dict(chunk) if isinstance(chunk, dict) else chunk
if remaining_tokens is not None:
chunk_data["remaining_tokens"] = remaining_tokens
await websocket.send_text(json.dumps({
"type": "message_stats",
"data": chunk,
"data": chunk_data,
"timestamp": datetime.now().isoformat()
}))
if chunk.get("type") == "stats":
if not is_guest:
charge_result = await token_service.charge_tokens(
user_id=numeric_user_id,
mode="text_chat_stream",
tokens_used=chunk.get("tokens_used"),
fallback_amount=1
)
if not charge_result.get("success"):
logger.warning(
f"Failed to charge tokens for user {user_id} in text_chat_stream: "
f"{charge_result.get('message')}"
)
else:
full_text += chunk
await websocket.send_text(json.dumps({

View File

@ -18,6 +18,7 @@ class SendMessageResponse(BaseModel):
response: str = Field(..., description="AI回复")
conversation_id: str = Field(..., description="对话ID")
tokens_used: Dict[str, int] = Field(..., description="Token使用统计")
remaining_tokens: Optional[int] = Field(None, description="剩余可用Token")
class ChatHistoryItem(BaseModel):
"""聊天历史项"""

View File

@ -1063,32 +1063,54 @@ async def receive_events_loop(
logger.debug(f"ASR started: {event.data}")
elif event.event_type == 'usage':
usage_data = event.data.get('usage', {}) if isinstance(event.data, dict) else {}
token_charge_config = get_token_charge_config()
fallback_amount_map = {
"voice_chat": token_charge_config.get("voice_chat_fallback_tokens", 200),
"voice_realtime_call": token_charge_config.get("voice_realtime_call_fallback_tokens", 200),
}
charge_result = await _charge_user_tokens(
user_id=user_id,
mode=charge_mode,
tokens_used=usage_data,
fallback_amount=fallback_amount_map.get(charge_mode, 200)
)
if charge_result.get("success"):
# 避免重复扣费:如果已经扣费成功则跳过
if session_state.get("token_charged"):
logger.info(
"[VOICE_CHARGE_SKIP] user_id=%s mode=%s reason=%s already_charged=True",
user_id, charge_mode, "usage_event_duplicate"
)
# 仍然更新 tokens_used 以便 ChatLog 记录
session_state['tokens_used'] = usage_data
else:
logger.warning(
f"Failed to charge tokens for user {user_id} in {charge_mode}: "
f"{charge_result.get('message')}"
token_charge_config = get_token_charge_config()
fallback_amount_map = {
"voice_chat": token_charge_config.get("voice_chat_fallback_tokens", 200),
"voice_realtime_call": token_charge_config.get("voice_realtime_call_fallback_tokens", 200),
}
charge_result = await _charge_user_tokens(
user_id=user_id,
mode=charge_mode,
tokens_used=usage_data,
fallback_amount=fallback_amount_map.get(charge_mode, 200)
)
await websocket.send_text(json.dumps({
"type": "error",
"error": "insufficient_tokens",
"message": "Token不足请充值",
"available_tokens": charge_result.get("available_tokens", 0),
"timestamp": datetime.now().isoformat()
}))
if charge_result.get("success"):
session_state['tokens_used'] = usage_data
session_state['token_charged'] = True
# 通知客户端剩余token数便于前端实时更新统计
try:
await websocket.send_text(json.dumps({
"type": "token_update",
"remaining_tokens": charge_result.get("available_tokens", 0),
"consumed": charge_result.get("amount", 0),
"mode": charge_mode,
"timestamp": datetime.now().isoformat()
}))
except Exception:
pass
else:
logger.warning(
f"Failed to charge tokens for user {user_id} in {charge_mode}: "
f"{charge_result.get('message')}"
)
await websocket.send_text(json.dumps({
"type": "error",
"error": "insufficient_tokens",
"message": "Token不足请充值",
"available_tokens": charge_result.get("available_tokens", 0),
"timestamp": datetime.now().isoformat()
}))
elif event.event_type == 'dialog_error':
logger.warning(
f"DialogCommonError 599: status_code={event.data.get('status_code')} "

View File

@ -12,7 +12,7 @@ from datetime import datetime
import logging
import asyncio
from models.database import Pet, Background, BackgroundPetConfig, ChatLog
from models.database import ChatLog, Pet, Background, BackgroundPetConfig, UserWallet
from services.doubao_service import doubao_service
from config.settings import get_chat_context_rounds
@ -100,6 +100,13 @@ async def send_message(db_session: AsyncSession, user_id: int, message: str, pet
db_session.add(chat_log)
await db_session.commit()
# 查询用户实际剩余token
wallet_result = await db_session.execute(
sa_select(UserWallet).where(UserWallet.user_id == user_id)
)
wallet = wallet_result.scalar_one_or_none()
remaining_tokens = (wallet.available_tokens or 0) if wallet else 0
logger.info(
f"Message processed with Doubao: user={user_id}, pet={pet_id}, bg={background_id}, "
f"response_length={len(ai_response)}"
@ -115,7 +122,7 @@ async def send_message(db_session: AsyncSession, user_id: int, message: str, pet
"total": doubao_result.get("total_tokens", 0)
},
"duration_ms": doubao_result.get("duration_ms", 1000),
"remaining_quota": 100
"remaining_tokens": remaining_tokens
}
except ValueError as e:
@ -300,6 +307,13 @@ async def send_message_stream(db_session: AsyncSession, user_id: int, message: s
db_session.add(chat_log)
await db_session.commit()
# 查询用户实际剩余token
wallet_result = await db_session.execute(
sa_select(UserWallet).where(UserWallet.user_id == user_id)
)
wallet = wallet_result.scalar_one_or_none()
remaining_tokens = (wallet.available_tokens or 0) if wallet else 0
yield {
"type": "stats",
"tokens_used": {
@ -308,7 +322,7 @@ async def send_message_stream(db_session: AsyncSession, user_id: int, message: s
"total": input_tokens + output_tokens
},
"duration_ms": duration_ms,
"remaining_quota": 100,
"remaining_tokens": remaining_tokens,
"conversation_id": conversation_id,
"trace_id": trace_id
}

View File

@ -6,7 +6,7 @@ Token计费统计服务
from typing import Optional, Dict, Any, List
from datetime import datetime, date
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func, and_
from sqlalchemy import select, func, and_, desc
from models.database import ChatLog, UserWallet
from utils.db_utils import ChatLogManager, UserWalletManager
import logging
@ -424,60 +424,31 @@ class TokenService:
统计信息字典
"""
# 构建查询条件(使用 coalesce 避免 NULL
total_input_query = select(
func.coalesce(func.sum(ChatLog.tokens_input), 0)
).where(ChatLog.user_id == user_id)
base_conditions = [ChatLog.user_id == user_id]
if start_date:
base_conditions.append(ChatLog.created_at >= start_date)
if end_date:
base_conditions.append(ChatLog.created_at <= end_date)
total_output_query = select(
func.coalesce(func.sum(ChatLog.tokens_output), 0)
).where(ChatLog.user_id == user_id)
total_chats_query = select(
func.count(ChatLog.id)
).where(ChatLog.user_id == user_id)
total_input_query = select(func.coalesce(func.sum(ChatLog.tokens_input), 0)).where(*base_conditions)
total_output_query = select(func.coalesce(func.sum(ChatLog.tokens_output), 0)).where(*base_conditions)
total_chats_query = select(func.count(ChatLog.id)).where(*base_conditions)
voice_query = select(
func.coalesce(func.sum(ChatLog.tokens_input), 0) +
func.coalesce(func.sum(ChatLog.tokens_output), 0)
).where(
ChatLog.user_id == user_id,
*base_conditions,
ChatLog.conversation_id.like("voice-%")
)
realtime_query = select(
func.coalesce(func.sum(ChatLog.tokens_input), 0) +
func.coalesce(func.sum(ChatLog.tokens_output), 0)
).where(
ChatLog.user_id == user_id,
*base_conditions,
ChatLog.conversation_id.like("realtime-%")
)
# 添加日期过滤(所有查询统一应用)
if start_date:
total_input_query = total_input_query.where(
ChatLog.created_at >= start_date
)
total_output_query = total_output_query.where(
ChatLog.created_at >= start_date
)
total_chats_query = total_chats_query.where(
ChatLog.created_at >= start_date
)
voice_query = voice_query.where(ChatLog.created_at >= start_date)
realtime_query = realtime_query.where(ChatLog.created_at >= start_date)
if end_date:
total_input_query = total_input_query.where(
ChatLog.created_at <= end_date
)
total_output_query = total_output_query.where(
ChatLog.created_at <= end_date
)
total_chats_query = total_chats_query.where(
ChatLog.created_at <= end_date
)
voice_query = voice_query.where(ChatLog.created_at <= end_date)
realtime_query = realtime_query.where(ChatLog.created_at <= end_date)
# 执行查询
total_input = (await self.db_session.execute(total_input_query)).scalar() or 0
total_output = (await self.db_session.execute(total_output_query)).scalar() or 0
@ -485,20 +456,29 @@ class TokenService:
voice_tokens = (await self.db_session.execute(voice_query)).scalar() or 0
realtime_tokens = (await self.db_session.execute(realtime_query)).scalar() or 0
# 获取用户配额信息
# 钱包维度的消耗:以钱包总消耗为准,更适合反映实际扣费
quota_info = await self._check_user_quota(user_id)
wallet_consumed = quota_info.get("total_consumed", 0) or 0
chatlog_total = total_input + total_output
# 如果钱包没有累计到消耗,但日志里有数据,则优先展示日志值;反之展示钱包值
total_tokens = max(chatlog_total, wallet_consumed)
if total_tokens <= 0:
total_tokens = chatlog_total or wallet_consumed or 0
# 计算文字聊天TokenChatLog总Token - 语音聊天Token - 实时通话Token
text_chat_tokens = max(0, chatlog_total - voice_tokens - realtime_tokens)
return {
"user_id": user_id,
"total_input_tokens": total_input,
"total_output_tokens": total_output,
"total_tokens": max(chatlog_total, wallet_consumed),
"total_tokens": total_tokens,
"chatlog_tokens": chatlog_total,
"wallet_consumed_tokens": wallet_consumed,
"voice_tokens": voice_tokens,
"realtime_tokens": realtime_tokens,
"text_chat_tokens": text_chat_tokens,
"total_voice_tokens": voice_tokens + realtime_tokens,
"total_chats": total_chats,
"remaining_quota": quota_info["remaining_quota"],

View File

@ -524,9 +524,8 @@ export default {
const totalTokens = stats.total_tokens || stats.wallet_consumed_tokens || 0
const voiceChatTokens = stats.voice_tokens || 0
const realtimeTokens = stats.realtime_tokens || 0
const chatlogTokens = stats.chatlog_tokens || 0
// Token = ChatLogToken - Token - Token
const textChatTokens = Math.max(0, chatlogTokens - voiceChatTokens - realtimeTokens)
// 使 text_chat_tokens chatlog_tokens
const textChatTokens = stats.text_chat_tokens ?? Math.max(0, (stats.chatlog_tokens || 0) - voiceChatTokens - realtimeTokens)
userStats.value = {
chatCount: stats.total_chats || 0,
totalTokens,

View File

@ -340,17 +340,32 @@ export default {
resizeCharts()
}
let refreshTimer = null
const handleVisibilityChange = () => {
if (!document.hidden) {
loadStats()
}
}
onMounted(() => {
loadStats()
refreshTimer = window.setInterval(loadStats, 30000)
window.addEventListener('resize', handleWindowResize)
window.addEventListener('admin-menu-change', handleMenuChange)
window.addEventListener('admin-refresh', loadStats)
document.addEventListener('visibilitychange', handleVisibilityChange)
})
onBeforeUnmount(() => {
if (refreshTimer) {
clearInterval(refreshTimer)
refreshTimer = null
}
window.removeEventListener('resize', handleWindowResize)
window.removeEventListener('admin-menu-change', handleMenuChange)
window.removeEventListener('admin-refresh', loadStats)
document.removeEventListener('visibilitychange', handleVisibilityChange)
Object.keys(chartInstances).forEach((key) => {
const instance = chartInstances[key]
if (instance && !instance.isDisposed()) {

View File

@ -177,7 +177,11 @@
<el-descriptions-item label="总对话次数">{{ userDetailData.total_chats || 0 }}</el-descriptions-item>
<el-descriptions-item label="输入Token">{{ userDetailData.total_input_tokens || 0 }}</el-descriptions-item>
<el-descriptions-item label="输出Token">{{ userDetailData.total_output_tokens || 0 }}</el-descriptions-item>
<el-descriptions-item label="总消耗Token">{{ (userDetailData.total_input_tokens || 0) + (userDetailData.total_output_tokens || 0) }}</el-descriptions-item>
<el-descriptions-item label="语音聊天Token">{{ userDetailData.voice_tokens || 0 }}</el-descriptions-item>
<el-descriptions-item label="实时通话Token">{{ userDetailData.realtime_tokens || 0 }}</el-descriptions-item>
<el-descriptions-item label="日志统计总Token">{{ userDetailData.chatlog_tokens || 0 }}</el-descriptions-item>
<el-descriptions-item label="钱包消耗总Token">{{ userDetailData.wallet_consumed_tokens || 0 }}</el-descriptions-item>
<el-descriptions-item label="统一总消耗Token">{{ userDetailData.total_tokens || 0 }}</el-descriptions-item>
</el-descriptions>
</div>
</el-dialog>
@ -282,7 +286,12 @@ export default {
...userData,
total_chats: statsRes?.data?.data?.total_chats || 0,
total_input_tokens: statsRes?.data?.data?.total_input_tokens || 0,
total_output_tokens: statsRes?.data?.data?.total_output_tokens || 0
total_output_tokens: statsRes?.data?.data?.total_output_tokens || 0,
voice_tokens: statsRes?.data?.data?.voice_tokens || 0,
realtime_tokens: statsRes?.data?.data?.realtime_tokens || 0,
chatlog_tokens: statsRes?.data?.data?.chatlog_tokens || 0,
wallet_consumed_tokens: statsRes?.data?.data?.wallet_consumed_tokens || 0,
total_tokens: statsRes?.data?.data?.total_tokens || 0
}
}
} catch (error) {
@ -393,8 +402,28 @@ export default {
console.log('Selected users:', selection)
}
let refreshTimer = null
const handleVisibilityChange = () => {
if (!document.hidden) {
loadUsers()
}
}
onMounted(() => {
loadUsers()
refreshTimer = window.setInterval(() => {
loadUsers()
}, 30000)
document.addEventListener('visibilitychange', handleVisibilityChange)
})
onBeforeUnmount(() => {
if (refreshTimer) {
clearInterval(refreshTimer)
refreshTimer = null
}
document.removeEventListener('visibilitychange', handleVisibilityChange)
})
return {