baoxiang/backend/app/services/game_service.py

546 lines
20 KiB
Python
Raw Normal View History

2025-12-16 18:06:50 +08:00
"""
游戏服务
"""
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:
db.rollback()
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
2025-12-16 21:39:32 +08:00
# 从数据库动态读取抽水比例
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)
2025-12-16 18:06:50 +08:00
distributable = total_pool - streamer_income - platform_income
# 主播分润
streamer = db.query(User).filter(User.id == chest.streamer_id).first()
if streamer:
streamer.balance += streamer_income
db.commit()
from ..services.user_service import UserService
UserService.create_transaction(
db=db,
user_id=streamer.id,
transaction_type="抽水分润",
amount=streamer_income,
balance_after=streamer.balance,
related_id=chest.id,
description=f"宝箱《{chest.title}》抽水分润"
)
2025-12-16 21:39:32 +08:00
# 平台抽水 - 直接到管理员账户
2025-12-17 11:43:50 +08:00
try:
admin_user = UserService.get_admin_user(db)
if admin_user and platform_income > 0:
# 记录转账前的余额
old_balance = admin_user.balance
admin_user.balance += platform_income
db.commit()
2025-12-16 21:39:32 +08:00
2025-12-17 11:43:50 +08:00
try:
UserService.create_transaction(
db=db,
user_id=admin_user.id,
transaction_type="平台抽水",
amount=platform_income,
balance_after=admin_user.balance,
related_id=chest.id,
description=f"宝箱《{chest.title}》平台抽水收益"
)
except Exception as e:
print(f"创建平台抽水交易记录失败: {str(e)}")
# 即使交易记录创建失败,也不应回滚余额变更
# 可以在这里添加告警通知或其他补偿措施
# 通知管理员余额更新
import asyncio
try:
# 尝试获取当前事件循环
loop = asyncio.get_running_loop()
# 局部导入避免循环导入
from ..routers.websocket import notify_user_balance_update
loop.create_task(notify_user_balance_update(admin_user.id, admin_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(admin_user.id, admin_user.balance)
asyncio.run(_run())
thread = threading.Thread(target=run_async, daemon=True)
thread.start()
print(f"平台抽水转账成功: {platform_income} 分至管理员账户 {admin_user.id}")
except Exception as e:
# 如果转账失败,记录错误并回滚
db.rollback()
print(f"平台抽水转账失败: {str(e)}")
# 可以在这里添加更详细的错误处理逻辑,比如发送告警通知
2025-12-16 18:06:50 +08:00
2025-12-17 13:19:55 +08:00
# 计算赔率 - 修改为只对获胜方抽水
2025-12-16 18:06:50 +08:00
winner_pool = chest.pool_a if winner == "A" else chest.pool_b
2025-12-17 13:19:55 +08:00
loser_pool = chest.pool_b if winner == "A" else chest.pool_a
2025-12-16 18:06:50 +08:00
if winner_pool > 0:
2025-12-17 13:19:55 +08:00
# 赔率计算改为:(获胜方奖池 + 失败方奖池 * 0.9) / 获胜方奖池
odds = (winner_pool + loser_pool * 0.9) / winner_pool
2025-12-16 18:06:50 +08:00
# 结算给获胜方
for bet in bets:
if bet.option == winner:
payout = int(bet.amount * odds)
bet.payout = payout
bet.status = "WON"
# 给用户加钱
user = db.query(User).filter(User.id == bet.user_id).first()
if user:
user.balance += payout
db.commit()
# 通知用户余额更新
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()
# 记录交易
UserService.create_transaction(
db=db,
user_id=user.id,
transaction_type="获胜",
amount=payout,
balance_after=user.balance,
related_id=chest.id,
description=f"宝箱《{chest.title}》获胜奖金"
)
else:
bet.status = "LOST"
db.commit()
@staticmethod
def _refund_chest(db: Session, chest: Chest) -> None:
"""
流局退款
"""
bets = db.query(Bet).filter(
Bet.chest_id == chest.id,
Bet.status == "PENDING"
).all()
for bet in bets:
# 退款给用户
user = db.query(User).filter(User.id == bet.user_id).first()
if user:
user.balance += bet.amount
db.commit()
# 通知用户余额更新
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.amount,
balance_after=user.balance,
related_id=chest.id,
description=f"宝箱《{chest.title}》流局退款"
)
bet.status = "REFUNDED"
# 重置奖池
chest.pool_a = 0
chest.pool_b = 0
db.commit()
# 更新缓存
update_pool_cache(chest.id, 0, 0)
@staticmethod
def get_chest_bets(db: Session, chest_id: int) -> list[Bet]:
"""
获取宝箱下注记录
"""
return db.query(Bet).filter(Bet.chest_id == chest_id).all()