617 lines
23 KiB
Python
617 lines
23 KiB
Python
"""
|
||
游戏服务
|
||
"""
|
||
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() |