baoxiang/backend/app/services/game_service.py
2025-12-18 13:28:29 +08:00

617 lines
23 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
游戏服务
"""
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()