diff --git a/backend/api/admin_router.py b/backend/api/admin_router.py index 0be11de..9c5ec14 100644 --- a/backend/api/admin_router.py +++ b/backend/api/admin_router.py @@ -83,12 +83,16 @@ async def list_users( wallet_result = await db.execute(select(UserWallet).where(UserWallet.user_id == user.id)) wallet = wallet_result.scalar_one_or_none() + # 将状态从整数转换为字符串格式 + # status: 1 或非零 = active, 0 或 null = disabled + status_str = 'active' if (user.status and user.status != 0) else 'disabled' + user_dict = { "id": user.id, "phone": user.phone, "nickname": user.nickname, "is_admin": user.role == 2, - "status": user.status, + "status": status_str, "available_tokens": wallet.available_tokens if wallet else 0, "daily_quota": wallet.daily_quota if wallet else 0, "extra_quota": wallet.extra_quota if wallet else 0, diff --git a/backend/api/chat_router.py b/backend/api/chat_router.py index 92598ea..653dbce 100644 --- a/backend/api/chat_router.py +++ b/backend/api/chat_router.py @@ -61,9 +61,12 @@ async def send_message_endpoint( 实现分层提示:基础提示 + 场景提示 + 记忆 + 用户输入 """ try: + # 解析用户ID为数字类型 + numeric_user_id = await resolve_user_id(current_user["id"], db) + token_service = TokenService(db) - token_check = await token_service.check_available_tokens(current_user["id"]) + token_check = await token_service.check_available_tokens(numeric_user_id) if not token_check["has_tokens"]: raise HTTPException( status_code=402, @@ -76,7 +79,7 @@ async def send_message_endpoint( result = await send_message( db, - user_id=current_user["id"], + user_id=numeric_user_id, message=message_data.message, pet_id=message_data.pet_id, background_id=message_data.background_id, @@ -84,14 +87,14 @@ async def send_message_endpoint( ) charge_result = await token_service.charge_tokens( - user_id=current_user["id"], + user_id=numeric_user_id, mode="text_chat", tokens_used=result.get("tokens_used"), fallback_amount=1 ) if not charge_result.get("success"): logger.warning( - f"Failed to charge tokens for user {current_user['id']} in text_chat_send: " + f"Failed to charge tokens for user {numeric_user_id} in text_chat_send: " f"{charge_result.get('message')}" ) @@ -224,22 +227,20 @@ async def websocket_chat_endpoint(websocket: WebSocket, user_id: str): async for db_session in get_db(): try: - is_guest = isinstance(user_id, str) and user_id.startswith("guest_") numeric_user_id = await resolve_user_id(user_id, db_session) token_service = TokenService(db_session) - # guest用户跳过token检查 - if not is_guest: - token_check = await token_service.check_available_tokens(numeric_user_id) - if not token_check["has_tokens"]: - await websocket.send_text(json.dumps({ - "type": "error", - "error": "insufficient_tokens", - "message": "您的可用token已耗尽,请联系管理员充值", - "available_tokens": token_check["available_tokens"], - "timestamp": datetime.now().isoformat() - })) - break + # 检查用户token + token_check = await token_service.check_available_tokens(numeric_user_id) + if not token_check["has_tokens"]: + await websocket.send_text(json.dumps({ + "type": "error", + "error": "insufficient_tokens", + "message": "您的可用token已耗尽,请联系管理员充值", + "available_tokens": token_check["available_tokens"], + "timestamp": datetime.now().isoformat() + })) + break await websocket.send_text(json.dumps({ "type": "typing_start", @@ -258,7 +259,7 @@ async def websocket_chat_endpoint(websocket: WebSocket, user_id: str): ): if isinstance(chunk, dict): remaining_tokens = None - if chunk.get("type") == "stats" and not is_guest: + if chunk.get("type") == "stats" and not user_id.startswith("guest_"): charge_result = await token_service.charge_tokens( user_id=numeric_user_id, mode="text_chat", @@ -1000,6 +1001,8 @@ async def websocket_volcano_bidirectional_endpoint(websocket: WebSocket, user_id 3. 支持流式音频返回 """ from config.settings import settings + from services.token_service import TokenService + from utils.database import get_db import uuid await websocket.accept() @@ -1023,6 +1026,27 @@ async def websocket_volcano_bidirectional_endpoint(websocket: WebSocket, user_id message_type = message.get("type") if message_type == "start_session": + # 检查用户token + token_check_result = None + async for db_session in get_db(): + try: + numeric_user_id = await resolve_user_id(user_id, db_session) + token_service = TokenService(db_session) + token_check_result = await token_service.check_available_tokens(numeric_user_id) + finally: + await db_session.close() + break + + if token_check_result and not token_check_result.get("has_tokens"): + await websocket.send_text(json.dumps({ + "type": "error", + "error": "insufficient_tokens", + "message": "您的可用token已耗尽,请联系管理员充值", + "available_tokens": token_check_result.get("available_tokens", 0), + "timestamp": datetime.now().isoformat() + })) + continue + text = message.get("text", "欢迎使用AI宠物语音助手") voice_type = message.get("voice_type", "zh_female_xiaohe_uranus_bigtts") encoding = message.get("encoding", "mp3") diff --git a/backend/api/voice_chat_router.py b/backend/api/voice_chat_router.py index 10787d5..b676e7f 100644 --- a/backend/api/voice_chat_router.py +++ b/backend/api/voice_chat_router.py @@ -60,6 +60,8 @@ async def _charge_voice_tokens( ) -> Dict[str, Any]: """统一语音扣费入口,补充清晰日志。""" session_state = session_state or {} + + # 检查是否已经扣费 if session_state.get("token_charged"): logger.info( "[VOICE_CHARGE_SKIP] user_id=%s mode=%s reason=%s conversation_id=%s trace_id=%s already_charged=True", @@ -71,27 +73,49 @@ async def _charge_voice_tokens( ) return {"success": True, "skipped": True, "message": "already charged"} + # 获取或创建数据库会话和token服务 token_service = session_state.get("token_service") db_session = session_state.get("db_session") - if token_service is None: + + # 如果没有提供会话,则创建新会话并在同一上下文中完成所有操作 + if token_service is None or db_session is None: async with AsyncSessionLocal() as db: token_service = TokenService(db) numeric_user_id = await resolve_user_id(user_id, db) - return await _charge_voice_tokens( - user_id=user_id, + + charge_cfg_fallback = fallback_amount if fallback_amount is not None else VOICE_TOKEN_FALLBACKS.get(mode, 200) + result = await token_service.charge_tokens( + user_id=numeric_user_id, mode=mode, - session_state={**session_state, "token_service": token_service, "db_session": db}, tokens_used=tokens_used, - fallback_amount=fallback_amount, - reason=reason, + fallback_amount=charge_cfg_fallback, ) - if db_session is None: - async with AsyncSessionLocal() as db: - numeric_user_id = await resolve_user_id(user_id, db) - else: - numeric_user_id = await resolve_user_id(user_id, db_session) - + token_used_amount = int(result.get("amount") or 0) + logger.info( + "[VOICE_CHARGE_%s] user_id=%s mode=%s reason=%s conversation_id=%s trace_id=%s amount=%s available_tokens=%s success=%s message=%s", + "OK" if result.get("success") else "FAIL", + user_id, + mode, + reason, + session_state.get("conversation_id"), + session_state.get("trace_id"), + token_used_amount, + result.get("available_tokens"), + result.get("success"), + result.get("message"), + ) + if result.get("success"): + session_state["token_charged"] = True + session_state["tokens_used"] = tokens_used or session_state.get("tokens_used") + + # 确保事务提交(虽然charge_tokens已经提交,但这里再次确认) + await db.commit() + return result + + # 使用已提供的会话 + numeric_user_id = await resolve_user_id(user_id, db_session) + charge_cfg_fallback = fallback_amount if fallback_amount is not None else VOICE_TOKEN_FALLBACKS.get(mode, 200) result = await token_service.charge_tokens( user_id=numeric_user_id, @@ -677,13 +701,6 @@ def _is_guest_user_id(user_id: Optional[str]) -> bool: async def _check_user_has_tokens(user_id: str) -> Dict[str, Any]: """检查用户是否还有可用token""" - if _is_guest_user_id(user_id): - return { - "has_tokens": True, - "available_tokens": 999999, - "message": "Guest user bypassed token check" - } - async with AsyncSessionLocal() as db: token_service = TokenService(db) try: diff --git a/backend/services/token_service.py b/backend/services/token_service.py index 0f42fe6..3287470 100644 --- a/backend/services/token_service.py +++ b/backend/services/token_service.py @@ -52,7 +52,6 @@ class TokenService: Returns: 包含统计信息的字典 """ - # 计算总Token数 total_tokens = input_tokens + output_tokens # 创建聊天记录 @@ -70,14 +69,13 @@ class TokenService: created_at=datetime.now() ) - # 保存聊天记录(直接使用 db_session,不依赖 ChatLogManager) + # 保存聊天记录 self.db_session.add(chat_log) - await self.db_session.commit() # 检查用户配额 quota_info = await self._check_user_quota(user_id) if not quota_info["has_quota"]: - logger.warning(f"User {user_id} has no remaining quota") + logger.warning(f"[TOKEN] User {user_id} has no remaining quota") return { "success": False, "reason": "insufficient_quota", @@ -87,10 +85,10 @@ class TokenService: "total_tokens": total_tokens } - # 扣除可用token(按实际消耗的token数) + # 扣除可用token consume_result = await self.consume_available_tokens(user_id, total_tokens) if not consume_result["success"]: - logger.warning(f"User {user_id} failed to consume tokens: {consume_result['message']}") + logger.warning(f"[TOKEN] User {user_id} failed to consume tokens: {consume_result['message']}") return { "success": False, "reason": "insufficient_quota", @@ -100,8 +98,11 @@ class TokenService: "total_tokens": total_tokens } + # 提交事务 + await self.db_session.commit() + logger.info( - f"Token usage recorded: user={user_id}, " + f"[TOKEN] Token usage recorded: user={user_id}, " f"input_tokens={input_tokens}, output_tokens={output_tokens}, " f"total={total_tokens}, remaining={consume_result['available_tokens']}" ) @@ -119,12 +120,6 @@ class TokenService: async def _check_user_quota(self, user_id: int) -> Dict[str, Any]: """ 检查用户配额情况 - - Args: - user_id: 用户ID - - Returns: - 配额信息字典 """ wallet = await self.wallet_manager.get_user_wallet(user_id) @@ -153,12 +148,6 @@ class TokenService: async def check_available_tokens(self, user_id: int) -> Dict[str, Any]: """ 检查用户可用token是否充足 - - Args: - user_id: 用户ID - - Returns: - 可用token信息字典 """ wallet = await self.wallet_manager.get_user_wallet(user_id) @@ -183,21 +172,28 @@ class TokenService: Args: user_id: 用户ID - amount: 消耗数量(按对话次数) + amount: 消耗数量 Returns: 消耗结果信息字典 """ - wallet = await self.wallet_manager.get_user_wallet(user_id) + logger.info(f"[TOKEN] Attempting to consume {amount} tokens for user {user_id}") + # 使用FOR UPDATE锁定记录,防止并发问题 + wallet = await self._get_wallet_for_update(user_id) + if not wallet: + logger.error(f"[TOKEN] User {user_id} wallet not found") return { "success": False, "message": "User wallet not found" } available_tokens = wallet.available_tokens or 0 + logger.debug(f"[TOKEN] User {user_id} has {available_tokens} tokens available, needs {amount}") + if available_tokens < amount: + logger.warning(f"[TOKEN] User {user_id} insufficient tokens: available={available_tokens}, required={amount}") return { "success": False, "message": "Insufficient available tokens", @@ -205,21 +201,33 @@ class TokenService: "required": amount } + # 执行扣费 wallet.available_tokens = available_tokens - amount wallet.total_consumed = (wallet.total_consumed or 0) + amount wallet.updated_at = datetime.now() - await self.wallet_manager.update(wallet) + # 使用merge确保对象被正确跟踪 + updated_wallet = await self.db_session.merge(wallet) - logger.info(f"Consumed {amount} tokens from user {user_id}, remaining: {wallet.available_tokens}") + logger.info(f"[TOKEN] Consumed {amount} tokens from user {user_id}, remaining: {updated_wallet.available_tokens}") return { "success": True, "message": "Tokens consumed successfully", - "available_tokens": wallet.available_tokens, + "available_tokens": updated_wallet.available_tokens, "consumed": amount } + async def _get_wallet_for_update(self, user_id: int): + """ + 获取钱包记录并加锁,防止并发修改 + """ + from models.database import UserWallet + result = await self.db_session.execute( + select(UserWallet).where(UserWallet.user_id == user_id).with_for_update() + ) + return result.scalar_one_or_none() + async def charge_tokens( self, user_id: int, @@ -239,8 +247,11 @@ class TokenService: Returns: 计费结果信息字典 """ + logger.info(f"[TOKEN] Charging tokens for user {user_id}, mode={mode}") + token_check = await self.check_available_tokens(user_id) if not token_check.get("has_tokens"): + logger.warning(f"[TOKEN] User {user_id} has no tokens remaining") return { "success": False, "reason": "insufficient_tokens", @@ -252,12 +263,20 @@ class TokenService: amount = 0 if isinstance(tokens_used, dict): - amount = int(tokens_used.get("total") or 0) + # 优先查找 total 或 total_tokens 字段 + amount = int(tokens_used.get("total") or tokens_used.get("total_tokens") or 0) + + # 如果没有 total 字段,则计算 input + output + if amount <= 0: + input_tokens = int(tokens_used.get("input") or tokens_used.get("input_tokens") or 0) + output_tokens = int(tokens_used.get("output") or tokens_used.get("output_tokens") or 0) + amount = input_tokens + output_tokens if amount <= 0: amount = int(fallback_amount or 0) if amount <= 0: + logger.info(f"[TOKEN] No charge required for user {user_id}, amount={amount}") return { "success": True, "mode": mode, @@ -268,6 +287,7 @@ class TokenService: consume_result = await self.consume_available_tokens(user_id, amount) if not consume_result.get("success"): + logger.warning(f"[TOKEN] Failed to consume tokens for user {user_id}: {consume_result.get('message')}") return { "success": False, "reason": "insufficient_tokens", @@ -277,6 +297,11 @@ class TokenService: "message": consume_result.get("message", "Failed to consume tokens") } + # 提交事务 + await self.db_session.commit() + + logger.info(f"[TOKEN] Successfully charged {amount} tokens for user {user_id}, mode={mode}") + return { "success": True, "mode": mode, @@ -315,12 +340,12 @@ class TokenService: input_tokens = int(tokens_used.get("input") or 0) output_tokens = int(tokens_used.get("output") or 0) if input_tokens + output_tokens <= 0: - total = int(tokens_used.get("total") or 0) + total = int(tokens_used.get("total") or tokens_used.get("total_tokens") or 0) input_tokens = total if input_tokens + output_tokens <= 0: input_tokens = int(fallback_amount or 0) - # 直接通过 db_session 写入 ChatLog,不依赖 ChatLogManager(其 create 方法为桩实现) + # 写入 ChatLog chat_log = ChatLog( user_id=user_id, trace_id=trace_id, @@ -335,9 +360,9 @@ class TokenService: created_at=datetime.now() ) self.db_session.add(chat_log) - await self.db_session.commit() if skip_charge: + await self.db_session.commit() return { "success": True, "mode": mode, @@ -355,54 +380,40 @@ class TokenService: async def _reset_daily_quota_if_needed(self, wallet: UserWallet) -> None: """ 检查并重置每日配额(如果跨天了) - - Args: - wallet: 用户钱包对象 """ today = date.today() - # 如果上次重置时间不是今天,则重置每日配额 if wallet.last_reset_time != today: - logger.info(f"Resetting daily quota for user {wallet.user_id}") - wallet.daily_quota = 20 # 重置为默认每日配额 + logger.info(f"[TOKEN] Resetting daily quota for user {wallet.user_id}") + wallet.daily_quota = 20 wallet.last_reset_time = today - await self.wallet_manager.update(wallet) + await self.db_session.merge(wallet) + await self.db_session.commit() async def _consume_quota(self, user_id: int, amount: int = 1) -> bool: """ - 扣除用户配额 - - Args: - user_id: 用户ID - amount: 扣除数量 - - Returns: - 是否成功扣除 + 扣除用户配额(旧版方法,保留兼容性) """ - wallet = await self.wallet_manager.get_user_wallet(user_id) + wallet = await self._get_wallet_for_update(user_id) if not wallet: return False - # 检查剩余配额 remaining = wallet.daily_quota + wallet.extra_quota if remaining < amount: return False - # 优先扣除每日配额,再扣除额外配额 if wallet.daily_quota >= amount: wallet.daily_quota -= amount else: - # 扣除所有每日配额 amount_to_deduct_from_extra = amount - wallet.daily_quota wallet.daily_quota = 0 wallet.extra_quota -= amount_to_deduct_from_extra - # 增加总消耗数 wallet.total_consumed += amount - # 更新钱包 - await self.wallet_manager.update(wallet) + await self.db_session.merge(wallet) + await self.db_session.commit() return True @@ -414,16 +425,7 @@ class TokenService: ) -> Dict[str, Any]: """ 获取用户Token使用统计 - - Args: - user_id: 用户ID - start_date: 开始日期 - end_date: 结束日期 - - Returns: - 统计信息字典 """ - # 构建查询条件(使用 coalesce 避免 NULL) base_conditions = [ChatLog.user_id == user_id] if start_date: base_conditions.append(ChatLog.created_at >= start_date) @@ -449,24 +451,20 @@ class TokenService: ChatLog.conversation_id.like("realtime-%") ) - # 执行查询 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 total_chats = (await self.db_session.execute(total_chats_query)).scalar() or 0 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 - # 计算文字聊天Token(ChatLog总Token - 语音聊天Token - 实时通话Token) text_chat_tokens = max(0, chatlog_total - voice_tokens - realtime_tokens) return { @@ -498,37 +496,24 @@ class TokenService: ) -> Dict[str, Any]: """ 获取系统整体Token使用统计 - - Args: - start_date: 开始日期 - end_date: 结束日期 - - Returns: - 系统统计信息字典 """ - # 构建基础查询 base_query = select(ChatLog) - # 添加日期过滤 if start_date: base_query = base_query.where(ChatLog.created_at >= start_date) if end_date: base_query = base_query.where(ChatLog.created_at <= end_date) - # 获取所有记录 result = await self.db_session.execute(base_query) all_logs = result.scalars().all() - # 计算统计信息 total_input_tokens = sum(log.tokens_input for log in all_logs) total_output_tokens = sum(log.tokens_output for log in all_logs) total_chats = len(all_logs) - # 获取唯一用户数 unique_users = len(set(log.user_id for log in all_logs)) - # 计算平均响应时间 avg_response_time = 0 if all_logs: avg_response_time = sum(log.duration_ms for log in all_logs) / len(all_logs) @@ -549,26 +534,66 @@ class TokenService: async def gift_quota(self, user_id: int, amount: int, quota_type: str = "extra") -> bool: """ 管理员赠送配额给用户 - - Args: - user_id: 用户ID - amount: 赠送数量 - quota_type: 配额类型 ("daily" 或 "extra") - - Returns: - 是否成功 """ - wallet = await self.wallet_manager.get_user_wallet(user_id) + wallet = await self._get_wallet_for_update(user_id) if not wallet: return False if quota_type == "daily": wallet.daily_quota += amount - else: # extra + else: wallet.extra_quota += amount - await self.wallet_manager.update(wallet) + await self.db_session.merge(wallet) + await self.db_session.commit() - logger.info(f"Gifted {amount} {quota_type} quota to user {user_id}") + logger.info(f"[TOKEN] Gifted {amount} {quota_type} quota to user {user_id}") + return True + + async def set_available_tokens(self, user_id: int, amount: int) -> bool: + """ + 设置用户可用token(管理员操作) + """ + wallet = await self._get_wallet_for_update(user_id) + + if not wallet: + # 创建新钱包 + wallet = UserWallet( + user_id=user_id, + available_tokens=amount, + daily_quota=0, + extra_quota=0, + total_consumed=0, + last_reset_time=date.today() + ) + self.db_session.add(wallet) + else: + wallet.available_tokens = amount + + await self.db_session.commit() + logger.info(f"[TOKEN] Set available tokens for user {user_id} to {amount}") + return True + + async def add_available_tokens(self, user_id: int, amount: int) -> bool: + """ + 增加用户可用token(管理员操作) + """ + wallet = await self._get_wallet_for_update(user_id) + + if not wallet: + wallet = UserWallet( + user_id=user_id, + available_tokens=amount, + daily_quota=0, + extra_quota=0, + total_consumed=0, + last_reset_time=date.today() + ) + self.db_session.add(wallet) + else: + wallet.available_tokens = (wallet.available_tokens or 0) + amount + + await self.db_session.commit() + logger.info(f"[TOKEN] Added {amount} tokens to user {user_id}") return True diff --git a/backend/test_token_charge.py b/backend/test_token_charge.py new file mode 100644 index 0000000..797a531 --- /dev/null +++ b/backend/test_token_charge.py @@ -0,0 +1,115 @@ +""" +Token扣费流程测试脚本 +用于验证token扣费逻辑是否正常工作 +""" + +import asyncio +import sys +import os + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from sqlalchemy import select +from services.token_service import TokenService +from utils.database import AsyncSessionLocal + +async def test_token_charge(): + """测试token扣费流程""" + print("=== Token扣费流程测试 ===") + + async with AsyncSessionLocal() as session: + # 创建测试用户钱包 + test_user_id = 1 + + from models.database import UserWallet + + # 先检查是否已有钱包 + result = await session.execute( + select(UserWallet).where(UserWallet.user_id == test_user_id) + ) + wallet = result.scalar_one_or_none() + + if not wallet: + # 创建新钱包 + wallet = UserWallet( + user_id=test_user_id, + available_tokens=100, + daily_quota=0, + extra_quota=0, + total_consumed=0 + ) + session.add(wallet) + await session.commit() + await session.refresh(wallet) + print(f"创建测试钱包: user_id={wallet.user_id}, available_tokens={wallet.available_tokens}") + else: + print(f"找到现有钱包: user_id={wallet.user_id}, available_tokens={wallet.available_tokens}") + + # 重置钱包金额以便测试 + wallet.available_tokens = 100 + wallet.total_consumed = 0 + await session.commit() + await session.refresh(wallet) + + # 创建token服务 + token_service = TokenService(session) + + # 测试1: 检查可用token + print("\n--- 测试1: 检查可用token ---") + check_result = await token_service.check_available_tokens(test_user_id) + print(f"检查结果: {check_result}") + + # 测试2: 执行扣费 + print("\n--- 测试2: 执行扣费 ---") + print(f"扣费前可用token: {wallet.available_tokens}") + + charge_result = await token_service.charge_tokens( + user_id=test_user_id, + mode="test", + tokens_used={"total": 10}, + fallback_amount=5 + ) + + print(f"扣费结果: {charge_result}") + + # 重新查询钱包状态 + await session.refresh(wallet) + print(f"扣费后可用token: {wallet.available_tokens}") + print(f"总消耗token: {wallet.total_consumed}") + + # 测试3: 再次扣费(测试多次扣费) + print("\n--- 测试3: 再次扣费 ---") + charge_result2 = await token_service.charge_tokens( + user_id=test_user_id, + mode="test2", + tokens_used=None, + fallback_amount=20 + ) + + print(f"扣费结果: {charge_result2}") + + await session.refresh(wallet) + print(f"第二次扣费后可用token: {wallet.available_tokens}") + print(f"总消耗token: {wallet.total_consumed}") + + # 测试4: 测试余额不足的情况 + print("\n--- 测试4: 测试余额不足 ---") + # 设置余额为0 + wallet.available_tokens = 0 + await session.commit() + + check_result = await token_service.check_available_tokens(test_user_id) + print(f"余额为0时检查结果: {check_result}") + + charge_result3 = await token_service.charge_tokens( + user_id=test_user_id, + mode="test3", + tokens_used={"total": 5} + ) + + print(f"余额不足时扣费结果: {charge_result3}") + + print("\n=== 测试完成 ===") + +if __name__ == "__main__": + asyncio.run(test_token_charge()) \ No newline at end of file diff --git a/backend/utils/db_utils.py b/backend/utils/db_utils.py index 9bae299..a2726d8 100644 --- a/backend/utils/db_utils.py +++ b/backend/utils/db_utils.py @@ -22,8 +22,6 @@ async def get_db() -> AsyncGenerator[Session, None]: 获取数据库会话 这是一个简化版本,实际应该使用异步数据库会话 """ - # 在实际应用中,这里应该创建异步数据库会话 - # 目前返回模拟会话 class MockSession: def execute(self, query): return MockResult() @@ -80,9 +78,11 @@ class UserWalletManager(BaseManager): async def update(self, wallet): """更新钱包记录""" + # 使用 merge 方法确保对象被当前会话跟踪 + merged_wallet = await self.db_session.merge(wallet) await self.db_session.commit() - await self.db_session.refresh(wallet) - return wallet + await self.db_session.refresh(merged_wallet) + return merged_wallet class ChatLogManager(BaseManager): async def get_user_chat_history(self, user_id: int, conversation_id: str, limit: int = 20, offset: int = 0): diff --git a/frontend/src/stores/chatStore.js b/frontend/src/stores/chatStore.js index e465847..61886d1 100644 --- a/frontend/src/stores/chatStore.js +++ b/frontend/src/stores/chatStore.js @@ -8,6 +8,28 @@ import apiClient from '@/api/index' import { chatAPI } from '@/api/index' import { ElMessage } from 'element-plus' +// 统计更新事件回调 +let onStatsUpdateCallbacks = [] + +// 注册统计更新回调 +export function onChatStatsUpdate(callback) { + onStatsUpdateCallbacks.push(callback) + return () => { + onStatsUpdateCallbacks = onStatsUpdateCallbacks.filter(cb => cb !== callback) + } +} + +// 触发统计更新 +function triggerStatsUpdate() { + onStatsUpdateCallbacks.forEach(cb => { + try { + cb() + } catch (error) { + console.error('[ChatStore] Stats update callback error:', error) + } + }) +} + export const useChatStore = defineStore('chat', () => { // ============ 状态 ============ const messages = ref([]) @@ -24,7 +46,8 @@ export const useChatStore = defineStore('chat', () => { const audioContext = ref(null) const mediaRecorder = ref(null) const audioChunks = ref([]) - + const permissionStream = ref(null) + // 麦克风权限状态 const microphonePermissionState = ref('prompt') // 'granted' | 'denied' | 'prompt' const hasRequestedPermission = ref(false) @@ -474,7 +497,7 @@ export const useChatStore = defineStore('chat', () => { break case 'chat_ended': - // AI回复结束 + // AI 回复结束 console.log('[ChatStore] Chat ended, total content:', pendingChatContent.value?.slice(0, 50)) const streamingMsg = messages.value.find(m => m.isStreaming) if (streamingMsg) { @@ -482,6 +505,8 @@ export const useChatStore = defineStore('chat', () => { streamingMsg.isStreaming = false } pendingChatContent.value = '' + // 触发统计更新 + triggerStatsUpdate() break case 'tts_start': @@ -520,6 +545,8 @@ export const useChatStore = defineStore('chat', () => { case 'session_stopped': console.log('[ChatStore] Voice session stopped') resetAudioState() + // 触发统计更新 + triggerStatsUpdate() break case 'dialog_error': @@ -600,6 +627,8 @@ export const useChatStore = defineStore('chat', () => { background_id: currentBackgroundId.value } + voiceSessionReady.value = false + const sent = sendVoiceMessage({ type: 'start_session', ...sessionConfig @@ -608,6 +637,37 @@ export const useChatStore = defineStore('chat', () => { if (!sent) { throw new Error('语音连接中断,请稍后重试') } + + const isSessionReady = await waitForVoiceSessionReady(5000) + if (!isSessionReady) { + throw new Error('语音会话启动失败,请稍后重试') + } + } + + function waitForVoiceSessionReady(timeoutMs = 5000) { + return new Promise((resolve) => { + if (voiceSessionReady.value) { + resolve(true) + return + } + + const start = Date.now() + const tick = () => { + if (voiceSessionReady.value) { + resolve(true) + return + } + + if (Date.now() - start >= timeoutMs) { + resolve(false) + return + } + + setTimeout(tick, 50) + } + + tick() + }) } /** @@ -700,6 +760,7 @@ export const useChatStore = defineStore('chat', () => { }, 300) } isVoiceConnected.value = false + voiceSessionReady.value = false } // ============ 实时通话功能 ============ @@ -889,6 +950,8 @@ export const useChatStore = defineStore('chat', () => { console.log('[ChatStore] Call ended') callStartTime.value = null resetAudioState() + // 触发统计更新 + triggerStatsUpdate() break case 'error': @@ -1073,18 +1136,20 @@ export const useChatStore = defineStore('chat', () => { try { console.log('[ChatStore] Requesting microphone permission...'); - // 先尝试简单的约束,提高移动端兼容性 - const stream = await navigator.mediaDevices.getUserMedia({ - audio: true + const stream = await navigator.mediaDevices.getUserMedia({ + audio: { + sampleRate: 16000, + channelCount: 1, + echoCancellation: true, + noiseSuppression: true + } }) console.log('[ChatStore] Microphone permission granted'); microphonePermissionState.value = 'granted' hasRequestedPermission.value = true - - // 获取到权限后立即释放流,避免占用麦克风 - stream.getTracks().forEach(track => track.stop()) - + permissionStream.value = stream + return true } catch (error) { console.error('请求麦克风权限失败:', error) @@ -1134,21 +1199,22 @@ export const useChatStore = defineStore('chat', () => { return false } - let stream; - try { - // 尝试最佳配置 - stream = await navigator.mediaDevices.getUserMedia({ - audio: { - sampleRate: 16000, - channelCount: 1, - echoCancellation: true, - noiseSuppression: true - } - }) - } catch (e) { - console.warn('[ChatStore] Failed to get user media with strict constraints, falling back to basic audio', e); - // 回退到基础音频配置 - stream = await navigator.mediaDevices.getUserMedia({ audio: true }); + let stream = permissionStream.value + if (!stream || stream.getTracks().every(t => t.readyState === 'ended')) { + try { + stream = await navigator.mediaDevices.getUserMedia({ + audio: { + sampleRate: 16000, + channelCount: 1, + echoCancellation: true, + noiseSuppression: true + } + }) + } catch (e) { + console.warn('[ChatStore] Failed to get user media with strict constraints, falling back to basic audio', e); + stream = await navigator.mediaDevices.getUserMedia({ audio: true }); + } + permissionStream.value = stream } // 创建或恢复 AudioContext diff --git a/frontend/src/stores/userStore.js b/frontend/src/stores/userStore.js index 15fb1b7..2394d67 100644 --- a/frontend/src/stores/userStore.js +++ b/frontend/src/stores/userStore.js @@ -60,6 +60,7 @@ export const useUserStore = defineStore('user', { localStorage.setItem('access_token', access_token) localStorage.setItem('refresh_token', refresh_token) + localStorage.setItem('user', JSON.stringify(user)) console.log('[UserStore] Token saved to localStorage, checking:', localStorage.getItem('access_token')?.substring(0, 50) + '...') @@ -86,6 +87,7 @@ export const useUserStore = defineStore('user', { localStorage.setItem('access_token', access_token) localStorage.setItem('refresh_token', refresh_token) + localStorage.setItem('user', JSON.stringify(user)) return { success: true, user } } catch (error) { @@ -110,6 +112,7 @@ export const useUserStore = defineStore('user', { localStorage.setItem('access_token', access_token) localStorage.setItem('refresh_token', refresh_token) + localStorage.setItem('user', JSON.stringify(user)) return { success: true, user } } catch (error) { @@ -171,6 +174,7 @@ export const useUserStore = defineStore('user', { localStorage.removeItem('access_token') localStorage.removeItem('refresh_token') + localStorage.removeItem('user') }, // 加载用户资料 diff --git a/frontend/src/views/ProfileView.vue b/frontend/src/views/ProfileView.vue index 9a10d99..bd568bc 100644 --- a/frontend/src/views/ProfileView.vue +++ b/frontend/src/views/ProfileView.vue @@ -429,7 +429,7 @@