""" 游戏服务 """ from typing import List from sqlalchemy.orm import Session from sqlalchemy import and_, update from ..models.user import User from ..models.game import Chest, Bet, ChestStatus from ..schemas.game import ChestCreate, BetCreate from ..utils.redis import acquire_lock, release_lock, update_pool_cache, get_pool_cache from datetime import datetime class GameService: """游戏服务""" @staticmethod def create_chest(db: Session, streamer: User, chest_data: ChestCreate) -> Chest: """ 创建宝箱 """ # 检查主播同时进行的宝箱数量(最多3个) active_chests = db.query(Chest).filter( and_( Chest.streamer_id == streamer.id, Chest.status.in_([ChestStatus.BETTING, ChestStatus.LOCKED, ChestStatus.SETTLING]) ) ).count() if active_chests >= 3: raise ValueError("同时进行的宝箱数量已达上限(3个)") # 创建宝箱 db_chest = Chest( streamer_id=streamer.id, title=chest_data.title, option_a=chest_data.option_a, option_b=chest_data.option_b, countdown_seconds=chest_data.countdown_seconds ) db.add(db_chest) db.commit() db.refresh(db_chest) # 初始化缓存 update_pool_cache(db_chest.id, 0, 0) return db_chest @staticmethod def get_active_chests(db: Session, streamer_id: int = None) -> List[Chest]: """ 获取活跃宝箱 """ query = db.query(Chest).filter(Chest.status == ChestStatus.BETTING) if streamer_id: query = query.filter(Chest.streamer_id == streamer_id) return query.order_by(Chest.created_at.desc()).all() @staticmethod def get_chests_by_streamer(db: Session, streamer_id: int) -> List[Chest]: """ 获取指定主播的所有宝箱(包括历史宝箱) """ return db.query(Chest).filter(Chest.streamer_id == streamer_id).order_by(Chest.created_at.desc()).all() @staticmethod def get_chest_by_id(db: Session, chest_id: int) -> Chest: """ 根据ID获取宝箱 """ return db.query(Chest).filter(Chest.id == chest_id).first() @staticmethod def place_bet(db: Session, user: User, bet_data: BetCreate) -> Bet: """ 下注 """ lock_key = f"lock:user:{user.id}:bet" lock_value = f"{user.id}:{int(datetime.now().timestamp())}" # 获取分布式锁 if not acquire_lock(lock_key, lock_value, expire_seconds=10): raise ValueError("操作过于频繁,请稍后重试") try: # 验证宝箱状态 chest = GameService.get_chest_by_id(db, bet_data.chest_id) if not chest or chest.status != ChestStatus.BETTING: raise ValueError("宝箱不可下注") # 检查余额 if user.balance < bet_data.amount: raise ValueError("余额不足") # 检查用户是否已在该宝箱下注同一选项 existing_bet = db.query(Bet).filter( and_( Bet.user_id == user.id, Bet.chest_id == bet_data.chest_id, Bet.option == bet_data.option, Bet.status == "PENDING" ) ).first() # 如果用户已经对该选项下注,则累加金额而不是拒绝下注 if existing_bet: # 更新现有下注金额 existing_bet.amount += bet_data.amount db.commit() # 更新奖池 if bet_data.option == "A": chest.pool_a += bet_data.amount else: chest.pool_b += bet_data.amount chest.total_bets += 1 db.commit() # 更新缓存 update_pool_cache(chest.id, chest.pool_a, chest.pool_b) # 扣款 result = db.execute( update(User).where( and_( User.id == user.id, User.balance >= bet_data.amount, User.version == user.version ) ).values( balance=User.balance - bet_data.amount, version=User.version + 1 ) ) if result.rowcount == 0: # 回滚之前的更改 existing_bet.amount -= bet_data.amount db.commit() raise ValueError("余额不足或并发冲突,请重试") # 刷新用户信息 db.refresh(user) # 通知用户余额更新 import asyncio try: # 尝试获取当前事件循环 loop = asyncio.get_running_loop() # 局部导入避免循环导入 from ..routers.websocket import notify_user_balance_update loop.create_task(notify_user_balance_update(user.id, user.balance)) except RuntimeError: # 如果没有运行中的事件循环,则在新线程中处理 import threading def run_async(): async def _run(): # 局部导入避免循环导入 from ..routers.websocket import notify_user_balance_update await notify_user_balance_update(user.id, user.balance) asyncio.run(_run()) thread = threading.Thread(target=run_async, daemon=True) thread.start() # 记录交易 from ..services.user_service import UserService UserService.create_transaction( db=db, user_id=user.id, transaction_type="下注", amount=-bet_data.amount, balance_after=user.balance, related_id=chest.id, description=f"追加下注{chest.title}({bet_data.option}边)" ) return existing_bet # 使用乐观锁扣款 result = db.execute( update(User).where( and_( User.id == user.id, User.balance >= bet_data.amount, User.version == user.version ) ).values( balance=User.balance - bet_data.amount, version=User.version + 1 ) ) if result.rowcount == 0: raise ValueError("余额不足或并发冲突,请重试") # 刷新用户信息 db.refresh(user) # 通知用户余额更新 import asyncio try: # 尝试获取当前事件循环 loop = asyncio.get_running_loop() # 局部导入避免循环导入 from ..routers.websocket import notify_user_balance_update loop.create_task(notify_user_balance_update(user.id, user.balance)) except RuntimeError: # 如果没有运行中的事件循环,则在新线程中处理 import threading def run_async(): async def _run(): # 局部导入避免循环导入 from ..routers.websocket import notify_user_balance_update await notify_user_balance_update(user.id, user.balance) asyncio.run(_run()) thread = threading.Thread(target=run_async, daemon=True) thread.start() # 更新奖池 if bet_data.option == "A": chest.pool_a += bet_data.amount else: chest.pool_b += bet_data.amount chest.total_bets += 1 db.commit() # 更新缓存 update_pool_cache(chest.id, chest.pool_a, chest.pool_b) # 创建下注记录 db_bet = Bet( user_id=user.id, chest_id=chest.id, option=bet_data.option, amount=bet_data.amount, status="PENDING" ) db.add(db_bet) db.commit() db.refresh(db_bet) # 记录交易 from ..services.user_service import UserService UserService.create_transaction( db=db, user_id=user.id, transaction_type="下注", amount=-bet_data.amount, balance_after=user.balance, related_id=chest.id, description=f"下注{chest.title}({bet_data.option}边)" ) return db_bet finally: # 释放锁 release_lock(lock_key, lock_value) @staticmethod def lock_chest(db: Session, chest: Chest, streamer: User) -> bool: """ 封盘 """ if chest.streamer_id != streamer.id and streamer.role != "admin": raise ValueError("无权操作此宝箱") if chest.status != ChestStatus.BETTING: raise ValueError("宝箱状态不正确") chest.status = ChestStatus.LOCKED chest.locked_at = datetime.now() db.commit() return True @staticmethod def lock_expired_chest(db: Session, chest_id: int) -> Chest: """ 自动锁定过期的宝箱 """ chest = db.query(Chest).filter(Chest.id == chest_id).first() if not chest: return None if chest.status != ChestStatus.BETTING: return chest # 检查是否真的过期(增加一个小的容差值以确保准确性) # 使用本地时间进行计算(与数据库func.now()保持一致) from datetime import datetime elapsed = (datetime.now() - chest.created_at).total_seconds() if elapsed >= chest.countdown_seconds - 0.1: # 0.1秒的容差 chest.status = ChestStatus.LOCKED chest.locked_at = datetime.now() db.commit() db.refresh(chest) return chest return chest @staticmethod def settle_chest(db: Session, chest: Chest, result: str, streamer: User) -> bool: """ 结算宝箱 """ if chest.streamer_id != streamer.id and streamer.role != "admin": raise ValueError("无权操作此宝箱") if chest.status not in [ChestStatus.LOCKED, ChestStatus.SETTLING]: raise ValueError("宝箱状态不正确") # 锁定状态防止重复执行 chest.status = ChestStatus.SETTLING db.commit() try: if result == "REFUND": # 流局退款 GameService._refund_chest(db, chest) else: # 正常结算 GameService._settle_winner(db, chest, result) chest.status = ChestStatus.FINISHED chest.result = result chest.settled_at = datetime.now() db.commit() return True except Exception as e: # 确保在异常时正确回滚 try: db.rollback() except Exception: pass raise e @staticmethod def _settle_winner(db: Session, chest: Chest, winner: str) -> None: """ 结算获胜方 - 简化版本,避免数据库连接问题 """ # 获取所有下注 bets = db.query(Bet).filter( Bet.chest_id == chest.id, Bet.status == "PENDING" ).all() if not bets: return # 计算总奖池和抽水 total_pool = chest.pool_a + chest.pool_b # 从数据库动态读取抽水比例 from ..services.system_service import ConfigManager streamer_share = ConfigManager.get_streamer_share(db) platform_share = ConfigManager.get_platform_share(db) streamer_income = int(total_pool * streamer_share) platform_income = int(total_pool * platform_share) # 准备结算数据 settlement_data = { 'streamer_income': streamer_income, 'platform_income': platform_income, 'settlement_results': [], 'admin_user_info': None, 'streamer_user_info': None } # 主播分润 streamer = db.query(User).filter(User.id == chest.streamer_id).first() if streamer and streamer_income > 0: old_balance = streamer.balance streamer.balance += streamer_income settlement_data['streamer_user_info'] = { 'user_id': streamer.id, 'old_balance': old_balance, 'new_balance': streamer.balance, 'amount': streamer_income } # 平台抽水 - 获取管理员信息但不更新 admin_user = None if platform_income > 0: try: admin_user = db.query(User).filter(User.role == "admin").first() if admin_user: old_balance = admin_user.balance admin_user.balance += platform_income settlement_data['admin_user_info'] = { 'user_id': admin_user.id, 'old_balance': old_balance, 'new_balance': admin_user.balance, 'amount': platform_income } except Exception as e: print(f"获取管理员用户失败: {str(e)}") admin_user = None # 计算赔率 - 正确的赔率计算 winner_pool = chest.pool_a if winner == "A" else chest.pool_b loser_pool = chest.pool_b if winner == "A" else chest.pool_a if winner_pool > 0: # 赔率计算:失败方奖池的90%按比例分配给获胜方用户 odds = 1 + (loser_pool * 0.9 / winner_pool) # 结算给获胜方 for bet in bets: if bet.option == winner: # 给用户加钱 user = db.query(User).filter(User.id == bet.user_id).first() if user: # 计算奖金 payout = int(bet.amount * odds) bet.payout = payout bet.status = "WON" # 更新用户余额 old_balance = user.balance user.balance += payout settlement_data['settlement_results'].append({ 'bet_id': bet.id, 'user_id': user.id, 'old_balance': old_balance, 'new_balance': user.balance, 'payout': payout, 'success': True }) else: bet.status = "WON" bet.payout = 0 settlement_data['settlement_results'].append({ 'bet_id': bet.id, 'user_id': bet.user_id, 'error': '用户不存在', 'success': False }) else: bet.status = "LOST" # 一次性提交所有变更 db.commit() # 创建交易记录和发送通知(在提交后进行) from ..services.user_service import UserService # 主播交易记录 if settlement_data['streamer_user_info']: try: info = settlement_data['streamer_user_info'] UserService.create_transaction( db=db, user_id=info['user_id'], transaction_type="抽水分润", amount=info['amount'], balance_after=info['new_balance'], related_id=chest.id, description=f"宝箱《{chest.title}》抽水分润" ) # 发送通知 GameService._send_balance_notification(info['user_id'], info['new_balance']) except Exception as e: print(f"创建主播分润交易记录失败: {str(e)}") # 管理员交易记录 if settlement_data['admin_user_info']: try: info = settlement_data['admin_user_info'] UserService.create_transaction( db=db, user_id=info['user_id'], transaction_type="平台抽水", amount=info['amount'], balance_after=info['new_balance'], related_id=chest.id, description=f"宝箱《{chest.title}》平台抽水收益" ) # 发送通知 GameService._send_balance_notification(info['user_id'], info['new_balance']) except Exception as e: print(f"创建平台抽水交易记录失败: {str(e)}") # 用户交易记录 for result in settlement_data['settlement_results']: if result['success']: try: UserService.create_transaction( db=db, user_id=result['user_id'], transaction_type="获胜", amount=result['payout'], balance_after=result['new_balance'], related_id=chest.id, description=f"宝箱《{chest.title}》获胜奖金" ) # 发送通知 GameService._send_balance_notification(result['user_id'], result['new_balance']) except Exception as e: print(f"创建用户获胜交易记录失败 - 用户ID: {result['user_id']}, 错误: {str(e)}") @staticmethod def _send_balance_notification(user_id: int, new_balance: int) -> None: """ 发送余额更新通知 """ try: import asyncio try: # 尝试获取当前事件循环 loop = asyncio.get_running_loop() # 局部导入避免循环导入 from ..routers.websocket import notify_user_balance_update loop.create_task(notify_user_balance_update(user_id, new_balance)) except RuntimeError: # 如果没有运行中的事件循环,则在新线程中处理 import threading def run_async(): async def _run(): # 局部导入避免循环导入 from ..routers.websocket import notify_user_balance_update await notify_user_balance_update(user_id, new_balance) asyncio.run(_run()) thread = threading.Thread(target=run_async, daemon=True) thread.start() except Exception as e: print(f"发送余额通知失败 - 用户ID: {user_id}, 错误: {str(e)}") @staticmethod def _refund_chest(db: Session, chest: Chest) -> None: """ 流局退款 """ bets = db.query(Bet).filter( Bet.chest_id == chest.id, Bet.status == "PENDING" ).all() refund_results = [] for bet in bets: try: # 退款给用户 user = db.query(User).filter(User.id == bet.user_id).first() if user: old_balance = user.balance user.balance += bet.amount bet.status = "REFUNDED" refund_results.append({ 'bet_id': bet.id, 'user_id': user.id, 'old_balance': old_balance, 'new_balance': user.balance, 'refund_amount': bet.amount, 'success': True }) else: bet.status = "REFUNDED" refund_results.append({ 'bet_id': bet.id, 'user_id': bet.user_id, 'error': '用户不存在', 'success': False }) except Exception as e: # 记录错误但不中断其他用户的退款 print(f"退款失败 - 下注ID: {bet.id}, 错误: {str(e)}") bet.status = "REFUNDED" refund_results.append({ 'bet_id': bet.id, 'user_id': bet.user_id, 'error': str(e), 'success': False }) # 重置奖池 chest.pool_a = 0 chest.pool_b = 0 # 一次性提交所有变更 db.commit() # 更新缓存 update_pool_cache(chest.id, 0, 0) # 创建交易记录和发送通知(在提交后进行) for result in refund_results: if result['success']: user_id = result['user_id'] refund_amount = result['refund_amount'] new_balance = result['new_balance'] # 发送通知 GameService._send_balance_notification(user_id, new_balance) # 记录交易 try: from ..services.user_service import UserService UserService.create_transaction( db=db, user_id=user_id, transaction_type="退款", amount=refund_amount, balance_after=new_balance, related_id=chest.id, description=f"宝箱《{chest.title}》流局退款" ) except Exception as e: print(f"创建交易记录失败 - 用户ID: {user_id}, 错误: {str(e)}") # 即使交易记录创建失败,也不回滚余额(因为已经提交) @staticmethod def get_chest_bets(db: Session, chest_id: int) -> List[Bet]: """ 获取宝箱下注记录 """ return db.query(Bet).filter(Bet.chest_id == chest_id).all() # ==================== 宝箱模板相关方法 ==================== @staticmethod def get_templates_by_streamer(db: Session, streamer_id: int) -> List: """ 获取指定主播的所有宝箱模板 """ from ..models.game import ChestTemplate return db.query(ChestTemplate).filter( ChestTemplate.streamer_id == streamer_id ).order_by(ChestTemplate.created_at.desc()).all() @staticmethod def get_template_by_id(db: Session, template_id: int, streamer_id: int): """ 根据ID获取宝箱模板(验证所有权) """ from ..models.game import ChestTemplate return db.query(ChestTemplate).filter( and_( ChestTemplate.id == template_id, ChestTemplate.streamer_id == streamer_id ) ).first() @staticmethod def create_template(db: Session, streamer_id: int, template_data) -> 'ChestTemplate': """ 创建宝箱模板 """ from ..models.game import ChestTemplate # 检查模板数量限制(最多3个) existing_count = db.query(ChestTemplate).filter( ChestTemplate.streamer_id == streamer_id ).count() if existing_count >= 3: raise ValueError("模板数量已达上限(3个)") # 创建模板 db_template = ChestTemplate( streamer_id=streamer_id, name=template_data.name, title=template_data.title, option_a=template_data.option_a, option_b=template_data.option_b, countdown_seconds=template_data.countdown_seconds ) db.add(db_template) db.commit() db.refresh(db_template) return db_template @staticmethod def update_template(db: Session, template_id: int, streamer_id: int, template_data) -> 'ChestTemplate': """ 更新宝箱模板 """ from ..models.game import ChestTemplate # 查找模板并验证所有权 db_template = GameService.get_template_by_id(db, template_id, streamer_id) if not db_template: raise ValueError("模板不存在或无权操作") # 更新字段 if template_data.name is not None: db_template.name = template_data.name if template_data.title is not None: db_template.title = template_data.title if template_data.option_a is not None: db_template.option_a = template_data.option_a if template_data.option_b is not None: db_template.option_b = template_data.option_b if template_data.countdown_seconds is not None: db_template.countdown_seconds = template_data.countdown_seconds db.commit() db.refresh(db_template) return db_template @staticmethod def delete_template(db: Session, template_id: int, streamer_id: int) -> bool: """ 删除宝箱模板 """ from ..models.game import ChestTemplate # 查找模板并验证所有权 db_template = GameService.get_template_by_id(db, template_id, streamer_id) if not db_template: raise ValueError("模板不存在或无权操作") db.delete(db_template) db.commit() return True