303 lines
9.1 KiB
TypeScript
303 lines
9.1 KiB
TypeScript
|
|
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();
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|