544 lines
20 KiB
Python
544 lines
20 KiB
Python
"""
|
||
游戏服务
|
||
"""
|
||
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
|
||
# 从数据库动态读取抽水比例
|
||
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)
|
||
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}》抽水分润"
|
||
)
|
||
|
||
# 平台抽水 - 直接到管理员账户
|
||
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()
|
||
|
||
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)}")
|
||
# 可以在这里添加更详细的错误处理逻辑,比如发送告警通知
|
||
|
||
# 计算赔率
|
||
winner_pool = chest.pool_a if winner == "A" else chest.pool_b
|
||
if winner_pool > 0:
|
||
odds = distributable / winner_pool
|
||
|
||
# 结算给获胜方
|
||
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() |