baoxiang/backend/app/services/game_service.py

718 lines
26 KiB
Python
Raw Normal View History

2025-12-16 18:06:50 +08:00
"""
游戏服务
"""
2025-12-17 18:41:25 +08:00
from typing import List
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
2025-12-17 18:41:25 +08:00
def get_active_chests(db: Session, streamer_id: int = None) -> List[Chest]:
2025-12-16 18:06:50 +08:00
"""
获取活跃宝箱
"""
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
2025-12-17 18:41:25 +08:00
def get_chests_by_streamer(db: Session, streamer_id: int) -> List[Chest]:
2025-12-16 18:06:50 +08:00
"""
获取指定主播的所有宝箱包括历史宝箱
"""
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:
2025-12-18 13:28:29 +08:00
# 确保在异常时正确回滚
try:
db.rollback()
except Exception:
pass
2025-12-16 18:06:50 +08:00
raise e
@staticmethod
def _settle_winner(db: Session, chest: Chest, winner: str) -> None:
"""
2025-12-18 13:28:29 +08:00
结算获胜方 - 简化版本避免数据库连接问题
2025-12-16 18:06:50 +08:00
"""
# 获取所有下注
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-18 13:28:29 +08:00
# 准备结算数据
settlement_data = {
'streamer_income': streamer_income,
'platform_income': platform_income,
'settlement_results': [],
'admin_user_info': None,
'streamer_user_info': None
}
2025-12-16 18:06:50 +08:00
# 主播分润
streamer = db.query(User).filter(User.id == chest.streamer_id).first()
2025-12-18 13:28:29 +08:00
if streamer and streamer_income > 0:
old_balance = streamer.balance
2025-12-16 18:06:50 +08:00
streamer.balance += streamer_income
2025-12-18 13:28:29 +08:00
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
# 计算赔率 - 正确的赔率计算
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-18 13:28:29 +08:00
# 赔率计算失败方奖池的90%按比例分配给获胜方用户
odds = 1 + (loser_pool * 0.9 / winner_pool)
2025-12-16 18:06:50 +08:00
# 结算给获胜方
for bet in bets:
if bet.option == winner:
# 给用户加钱
user = db.query(User).filter(User.id == bet.user_id).first()
if user:
2025-12-18 13:28:29 +08:00
# 计算奖金
payout = int(bet.amount * odds)
bet.payout = payout
bet.status = "WON"
# 更新用户余额
old_balance = user.balance
2025-12-16 18:06:50 +08:00
user.balance += payout
2025-12-18 13:28:29 +08:00
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
})
2025-12-16 18:06:50 +08:00
else:
bet.status = "LOST"
2025-12-18 13:28:29 +08:00
# 一次性提交所有变更
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)}")
2025-12-16 18:06:50 +08:00
@staticmethod
def _refund_chest(db: Session, chest: Chest) -> None:
"""
流局退款
"""
bets = db.query(Bet).filter(
Bet.chest_id == chest.id,
Bet.status == "PENDING"
).all()
2025-12-18 13:28:29 +08:00
refund_results = []
2025-12-16 18:06:50 +08:00
for bet in bets:
2025-12-18 13:28:29 +08:00
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
})
2025-12-16 18:06:50 +08:00
# 重置奖池
chest.pool_a = 0
chest.pool_b = 0
2025-12-18 13:28:29 +08:00
# 一次性提交所有变更
2025-12-16 18:06:50 +08:00
db.commit()
# 更新缓存
update_pool_cache(chest.id, 0, 0)
2025-12-18 13:28:29 +08:00
# 创建交易记录和发送通知(在提交后进行)
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)}")
# 即使交易记录创建失败,也不回滚余额(因为已经提交)
2025-12-16 18:06:50 +08:00
@staticmethod
2025-12-17 18:41:25 +08:00
def get_chest_bets(db: Session, chest_id: int) -> List[Bet]:
2025-12-16 18:06:50 +08:00
"""
获取宝箱下注记录
"""
2025-12-18 15:03:25 +08:00
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