初始化游戏小组管理系统后端项目
- 基于 NestJS + TypeScript + MySQL + Redis 架构 - 完整的模块化设计(认证、用户、小组、游戏、预约等) - JWT 认证和 RBAC 权限控制系统 - Docker 容器化部署支持 - 添加 CLAUDE.md 项目开发指南 - 配置 .gitignore 忽略文件 Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
302
src/modules/bets/bets.service.ts
Normal file
302
src/modules/bets/bets.service.ts
Normal file
@@ -0,0 +1,302 @@
|
||||
import {
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
} from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository, DataSource } from 'typeorm';
|
||||
import { Bet } from '../../entities/bet.entity';
|
||||
import { Appointment } from '../../entities/appointment.entity';
|
||||
import { Point } from '../../entities/point.entity';
|
||||
import { GroupMember } from '../../entities/group-member.entity';
|
||||
import { CreateBetDto, SettleBetDto } from './dto/bet.dto';
|
||||
import { BetStatus, GroupMemberRole, AppointmentStatus } from '../../common/enums';
|
||||
import { ErrorCode, ErrorMessage } from '../../common/interfaces/response.interface';
|
||||
|
||||
@Injectable()
|
||||
export class BetsService {
|
||||
constructor(
|
||||
@InjectRepository(Bet)
|
||||
private betRepository: Repository<Bet>,
|
||||
@InjectRepository(Appointment)
|
||||
private appointmentRepository: Repository<Appointment>,
|
||||
@InjectRepository(Point)
|
||||
private pointRepository: Repository<Point>,
|
||||
@InjectRepository(GroupMember)
|
||||
private groupMemberRepository: Repository<GroupMember>,
|
||||
private dataSource: DataSource,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 创建竞猜下注
|
||||
*/
|
||||
async create(userId: string, createDto: CreateBetDto) {
|
||||
const { appointmentId, amount, betOption } = createDto;
|
||||
|
||||
// 使用事务确保数据一致性
|
||||
const queryRunner = this.dataSource.createQueryRunner();
|
||||
await queryRunner.connect();
|
||||
await queryRunner.startTransaction();
|
||||
|
||||
try {
|
||||
// 验证预约存在
|
||||
const appointment = await queryRunner.manager.findOne(Appointment, {
|
||||
where: { id: appointmentId },
|
||||
});
|
||||
|
||||
if (!appointment) {
|
||||
throw new NotFoundException({
|
||||
code: ErrorCode.APPOINTMENT_NOT_FOUND,
|
||||
message: ErrorMessage[ErrorCode.APPOINTMENT_NOT_FOUND],
|
||||
});
|
||||
}
|
||||
|
||||
// 验证预约状态
|
||||
if (appointment.status !== AppointmentStatus.PENDING) {
|
||||
throw new BadRequestException({
|
||||
code: ErrorCode.INVALID_OPERATION,
|
||||
message: '预约已结束,无法下注',
|
||||
});
|
||||
}
|
||||
|
||||
// 验证用户积分是否足够
|
||||
const balance = await queryRunner.manager
|
||||
.createQueryBuilder(Point, 'point')
|
||||
.select('SUM(point.amount)', 'total')
|
||||
.where('point.userId = :userId', { userId })
|
||||
.andWhere('point.groupId = :groupId', { groupId: appointment.groupId })
|
||||
.getRawOne();
|
||||
|
||||
const currentBalance = parseInt(balance.total || '0');
|
||||
if (currentBalance < amount) {
|
||||
throw new BadRequestException({
|
||||
code: ErrorCode.INSUFFICIENT_POINTS,
|
||||
message: '积分不足',
|
||||
});
|
||||
}
|
||||
|
||||
// 检查是否已下注
|
||||
const existingBet = await queryRunner.manager.findOne(Bet, {
|
||||
where: { appointmentId, userId },
|
||||
});
|
||||
|
||||
if (existingBet) {
|
||||
throw new BadRequestException({
|
||||
code: ErrorCode.INVALID_OPERATION,
|
||||
message: '已下注,不能重复下注',
|
||||
});
|
||||
}
|
||||
|
||||
const bet = queryRunner.manager.create(Bet, {
|
||||
appointmentId,
|
||||
userId,
|
||||
betOption,
|
||||
amount,
|
||||
});
|
||||
|
||||
const savedBet = await queryRunner.manager.save(Bet, bet);
|
||||
|
||||
// 扣除积分
|
||||
const pointRecord = queryRunner.manager.create(Point, {
|
||||
userId,
|
||||
groupId: appointment.groupId,
|
||||
amount: -amount,
|
||||
reason: '竞猜下注',
|
||||
description: `预约: ${appointment.title}`,
|
||||
relatedId: savedBet.id,
|
||||
});
|
||||
|
||||
await queryRunner.manager.save(Point, pointRecord);
|
||||
|
||||
await queryRunner.commitTransaction();
|
||||
return savedBet;
|
||||
} catch (error) {
|
||||
await queryRunner.rollbackTransaction();
|
||||
throw error;
|
||||
} finally {
|
||||
await queryRunner.release();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询预约的所有竞猜
|
||||
*/
|
||||
async findAll(appointmentId: string) {
|
||||
const bets = await this.betRepository.find({
|
||||
where: { appointmentId },
|
||||
relations: ['user'],
|
||||
order: { createdAt: 'DESC' },
|
||||
});
|
||||
|
||||
// 统计各选项的下注情况
|
||||
const stats = bets.reduce((acc, bet) => {
|
||||
if (!acc[bet.betOption]) {
|
||||
acc[bet.betOption] = { count: 0, totalAmount: 0 };
|
||||
}
|
||||
acc[bet.betOption].count++;
|
||||
acc[bet.betOption].totalAmount += bet.amount;
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
return {
|
||||
bets,
|
||||
stats,
|
||||
totalBets: bets.length,
|
||||
totalAmount: bets.reduce((sum, bet) => sum + bet.amount, 0),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 结算竞猜(管理员)
|
||||
*/
|
||||
async settle(userId: string, appointmentId: string, settleDto: SettleBetDto) {
|
||||
const { winningOption } = settleDto;
|
||||
|
||||
// 验证预约存在
|
||||
const appointment = await this.appointmentRepository.findOne({
|
||||
where: { id: appointmentId },
|
||||
});
|
||||
|
||||
if (!appointment) {
|
||||
throw new NotFoundException({
|
||||
code: ErrorCode.APPOINTMENT_NOT_FOUND,
|
||||
message: ErrorMessage[ErrorCode.APPOINTMENT_NOT_FOUND],
|
||||
});
|
||||
}
|
||||
|
||||
// 验证权限
|
||||
const membership = await this.groupMemberRepository.findOne({
|
||||
where: { groupId: appointment.groupId, userId },
|
||||
});
|
||||
|
||||
if (!membership || (membership.role !== GroupMemberRole.ADMIN && membership.role !== GroupMemberRole.OWNER)) {
|
||||
throw new ForbiddenException({
|
||||
code: ErrorCode.NO_PERMISSION,
|
||||
message: '需要管理员权限',
|
||||
});
|
||||
}
|
||||
|
||||
// 使用事务确保数据一致性
|
||||
const queryRunner = this.dataSource.createQueryRunner();
|
||||
await queryRunner.connect();
|
||||
await queryRunner.startTransaction();
|
||||
|
||||
try {
|
||||
// 获取所有下注
|
||||
const bets = await queryRunner.manager.find(Bet, {
|
||||
where: { appointmentId },
|
||||
});
|
||||
|
||||
// 计算总奖池和赢家总下注
|
||||
const totalPool = bets.reduce((sum, bet) => sum + bet.amount, 0);
|
||||
const winningBets = bets.filter((bet) => bet.betOption === winningOption);
|
||||
const winningTotal = winningBets.reduce((sum, bet) => sum + bet.amount, 0);
|
||||
|
||||
if (winningTotal === 0) {
|
||||
throw new BadRequestException({
|
||||
code: ErrorCode.INVALID_OPERATION,
|
||||
message: '没有人下注该选项',
|
||||
});
|
||||
}
|
||||
|
||||
// 按比例分配奖池,修复精度损失问题
|
||||
let distributedAmount = 0;
|
||||
|
||||
for (let i = 0; i < winningBets.length; i++) {
|
||||
const bet = winningBets[i];
|
||||
let winAmount: number;
|
||||
|
||||
if (i === winningBets.length - 1) {
|
||||
// 最后一个赢家获得剩余所有积分,避免精度损失
|
||||
winAmount = totalPool - distributedAmount;
|
||||
} else {
|
||||
winAmount = Math.floor((bet.amount / winningTotal) * totalPool);
|
||||
distributedAmount += winAmount;
|
||||
}
|
||||
|
||||
bet.winAmount = winAmount;
|
||||
bet.status = BetStatus.WON;
|
||||
|
||||
// 返还积分
|
||||
const pointRecord = queryRunner.manager.create(Point, {
|
||||
userId: bet.userId,
|
||||
groupId: appointment.groupId,
|
||||
amount: winAmount,
|
||||
reason: '竞猜获胜',
|
||||
description: `预约: ${appointment.title}`,
|
||||
relatedId: bet.id,
|
||||
});
|
||||
|
||||
await queryRunner.manager.save(Point, pointRecord);
|
||||
await queryRunner.manager.save(Bet, bet);
|
||||
}
|
||||
|
||||
// 更新输家状态
|
||||
for (const bet of bets) {
|
||||
if (bet.betOption !== winningOption) {
|
||||
bet.status = BetStatus.LOST;
|
||||
await queryRunner.manager.save(Bet, bet);
|
||||
}
|
||||
}
|
||||
|
||||
await queryRunner.commitTransaction();
|
||||
|
||||
return {
|
||||
message: '结算成功',
|
||||
winningOption,
|
||||
totalPool,
|
||||
winners: winningBets.length,
|
||||
};
|
||||
} catch (error) {
|
||||
await queryRunner.rollbackTransaction();
|
||||
throw error;
|
||||
} finally {
|
||||
await queryRunner.release();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消竞猜(预约取消时)
|
||||
*/
|
||||
async cancel(appointmentId: string) {
|
||||
// 使用事务确保数据一致性
|
||||
const queryRunner = this.dataSource.createQueryRunner();
|
||||
await queryRunner.connect();
|
||||
await queryRunner.startTransaction();
|
||||
|
||||
try {
|
||||
const bets = await queryRunner.manager.find(Bet, {
|
||||
where: { appointmentId },
|
||||
relations: ['appointment'],
|
||||
});
|
||||
|
||||
for (const bet of bets) {
|
||||
if (bet.status === BetStatus.PENDING) {
|
||||
bet.status = BetStatus.CANCELLED;
|
||||
await queryRunner.manager.save(Bet, bet);
|
||||
|
||||
// 退还积分
|
||||
const pointRecord = queryRunner.manager.create(Point, {
|
||||
userId: bet.userId,
|
||||
groupId: bet.appointment.groupId,
|
||||
amount: bet.amount,
|
||||
reason: '竞猜取消退款',
|
||||
description: `预约: ${bet.appointment.title}`,
|
||||
relatedId: bet.id,
|
||||
});
|
||||
|
||||
await queryRunner.manager.save(Point, pointRecord);
|
||||
}
|
||||
}
|
||||
|
||||
await queryRunner.commitTransaction();
|
||||
return { message: '竞猜已取消,积分已退还' };
|
||||
} catch (error) {
|
||||
await queryRunner.rollbackTransaction();
|
||||
throw error;
|
||||
} finally {
|
||||
await queryRunner.release();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user