- Removed unnecessary "peer" flags from various dependencies in package-lock.json to streamline package management. - Updated Vite dependencies and their corresponding metadata for improved performance and compatibility. - Adjusted import paths in CSS files to reflect the correct directory structure. - Deleted unused CSS files related to the "col" component to clean up the project.
1286 lines
42 KiB
JavaScript
1286 lines
42 KiB
JavaScript
const { Match, MatchGame, MatchPlayer, MatchRound, LadderUser, User, Store, sequelize } = require('../models');
|
||
const { MATCH_TYPES, MATCH_STATUS, RANKING_STAGE, CONFIRM_STATUS } = require('../config/constants');
|
||
const { generateMatchCode, success, error, getPagination, pageResult } = require('../utils/helper');
|
||
const PowerCalculator = require('../services/powerCalculator');
|
||
const { broadcastToUsers, sendMatchNotification } = require('../websocket');
|
||
const { Op } = require('sequelize');
|
||
|
||
class MatchAdminController {
|
||
// 获取比赛列表
|
||
async getList(req, res) {
|
||
try {
|
||
const { page = 1, pageSize = 20, type, status, store_id } = req.query;
|
||
const { limit, offset } = getPagination(page, pageSize);
|
||
const admin = req.admin;
|
||
|
||
const where = {};
|
||
|
||
// 门店权限过滤
|
||
if (admin.role === 'super_admin') {
|
||
if (store_id) {
|
||
where.store_id = store_id;
|
||
}
|
||
} else {
|
||
where.store_id = admin.store_id;
|
||
}
|
||
|
||
if (type) {
|
||
where.type = type;
|
||
}
|
||
if (status !== undefined && status !== '') {
|
||
where.status = status;
|
||
}
|
||
|
||
const { rows, count } = await Match.findAndCountAll({
|
||
where,
|
||
include: [
|
||
{ model: Store, as: 'store', attributes: ['id', 'name'] },
|
||
{ model: User, as: 'referee', attributes: ['id', 'nickname'] }
|
||
],
|
||
order: [['created_at', 'DESC']],
|
||
limit,
|
||
offset
|
||
});
|
||
|
||
res.json(pageResult(rows.map(match => ({
|
||
id: match.id,
|
||
matchCode: match.match_code,
|
||
type: match.type,
|
||
name: match.name,
|
||
weight: match.weight,
|
||
storeId: match.store_id,
|
||
storeName: match.store?.name,
|
||
refereeName: match.referee?.nickname,
|
||
status: match.status,
|
||
stage: match.stage,
|
||
startTime: match.start_time,
|
||
endTime: match.end_time,
|
||
createdAt: match.created_at
|
||
})), count, page, pageSize));
|
||
} catch (err) {
|
||
console.error('获取比赛列表失败:', err);
|
||
res.status(500).json(error('获取失败'));
|
||
}
|
||
}
|
||
|
||
// 创建排位赛
|
||
async create(req, res) {
|
||
try {
|
||
const { store_id, name, weight, referee_id } = req.body;
|
||
const admin = req.admin;
|
||
|
||
const targetStoreId = admin.role === 'super_admin' ? store_id : admin.store_id;
|
||
if (!targetStoreId) {
|
||
return res.status(400).json(error('请选择门店', 400));
|
||
}
|
||
|
||
const match = await Match.create({
|
||
store_id: targetStoreId,
|
||
match_code: generateMatchCode(),
|
||
type: MATCH_TYPES.RANKING,
|
||
name: name || `排位赛 ${new Date().toLocaleDateString()}`,
|
||
weight: weight || 1.5,
|
||
referee_id,
|
||
status: MATCH_STATUS.PENDING,
|
||
stage: RANKING_STAGE.SIGNUP
|
||
});
|
||
|
||
res.json(success({
|
||
id: match.id,
|
||
matchCode: match.match_code
|
||
}, '创建成功'));
|
||
} catch (err) {
|
||
console.error('创建比赛失败:', err);
|
||
res.status(500).json(error('创建失败'));
|
||
}
|
||
}
|
||
|
||
// 获取比赛详情
|
||
async getDetail(req, res) {
|
||
try {
|
||
const { id } = req.params;
|
||
|
||
const match = await Match.findByPk(id, {
|
||
attributes: [
|
||
'id',
|
||
'match_code',
|
||
'type',
|
||
'name',
|
||
'weight',
|
||
'status',
|
||
'stage',
|
||
'elimination_size',
|
||
'store_id',
|
||
'referee_id',
|
||
'start_time',
|
||
'end_time'
|
||
],
|
||
include: [
|
||
{
|
||
model: Store,
|
||
as: 'store',
|
||
attributes: ['id', 'name']
|
||
},
|
||
{
|
||
model: User,
|
||
as: 'referee',
|
||
attributes: ['id', 'nickname']
|
||
},
|
||
{
|
||
model: MatchPlayer,
|
||
as: 'players',
|
||
separate: true,
|
||
attributes: [
|
||
'id',
|
||
'ladder_user_id',
|
||
'initial_power',
|
||
'final_power',
|
||
'win_count',
|
||
'lose_count',
|
||
'rank',
|
||
'player_status'
|
||
],
|
||
include: [
|
||
{
|
||
model: LadderUser,
|
||
as: 'ladderUser',
|
||
attributes: ['id', 'real_name', 'level'],
|
||
include: [
|
||
{
|
||
model: User,
|
||
as: 'user',
|
||
attributes: ['id', 'nickname']
|
||
}
|
||
]
|
||
}
|
||
]
|
||
},
|
||
{
|
||
model: MatchRound,
|
||
as: 'rounds',
|
||
separate: true,
|
||
attributes: ['id', 'round_number', 'round_type', 'round_name', 'status'],
|
||
include: [
|
||
{
|
||
model: MatchGame,
|
||
as: 'games',
|
||
attributes: [
|
||
'id',
|
||
'player1_id',
|
||
'player2_id',
|
||
'player1_score',
|
||
'player2_score',
|
||
'winner_id',
|
||
'confirm_status',
|
||
'status'
|
||
]
|
||
}
|
||
]
|
||
},
|
||
{
|
||
model: MatchGame,
|
||
as: 'games',
|
||
separate: true,
|
||
attributes: [
|
||
'id',
|
||
'player1_id',
|
||
'player2_id',
|
||
'player1_score',
|
||
'player2_score',
|
||
'winner_id',
|
||
'confirm_status',
|
||
'status'
|
||
]
|
||
}
|
||
]
|
||
});
|
||
|
||
if (!match) {
|
||
return res.status(404).json(error('比赛不存在', 404));
|
||
}
|
||
|
||
// 对选手进行排序:如果比赛已结束且有 rank,按 rank 排序;否则按胜场、总得分、初始战力排序
|
||
const sortedPlayers = [...match.players];
|
||
if (match.status === MATCH_STATUS.FINISHED && sortedPlayers.some(p => p.rank)) {
|
||
// 比赛已结束:按 rank 排序
|
||
sortedPlayers.sort((a, b) => {
|
||
if (a.rank && b.rank) return a.rank - b.rank;
|
||
if (a.rank) return -1;
|
||
if (b.rank) return 1;
|
||
return 0;
|
||
});
|
||
} else {
|
||
// 比赛进行中:按胜场、总得分、初始战力排序
|
||
// 需要计算总得分
|
||
const allGames = match.games || [];
|
||
const scoreMap = new Map();
|
||
allGames.forEach(g => {
|
||
if (g.confirm_status === CONFIRM_STATUS.CONFIRMED) {
|
||
const key1 = String(g.player1_id);
|
||
const key2 = String(g.player2_id);
|
||
scoreMap.set(key1, (scoreMap.get(key1) || 0) + (g.player1_score || 0));
|
||
scoreMap.set(key2, (scoreMap.get(key2) || 0) + (g.player2_score || 0));
|
||
}
|
||
});
|
||
sortedPlayers.sort((a, b) => {
|
||
if (b.win_count !== a.win_count) return b.win_count - a.win_count;
|
||
const scoreA = scoreMap.get(String(a.ladder_user_id)) || 0;
|
||
const scoreB = scoreMap.get(String(b.ladder_user_id)) || 0;
|
||
if (scoreB !== scoreA) return scoreB - scoreA;
|
||
return b.initial_power - a.initial_power;
|
||
});
|
||
}
|
||
|
||
res.json(success({
|
||
id: match.id,
|
||
matchCode: match.match_code,
|
||
type: match.type,
|
||
name: match.name,
|
||
weight: match.weight,
|
||
status: match.status,
|
||
stage: match.stage,
|
||
eliminationSize: match.elimination_size,
|
||
storeId: match.store_id,
|
||
storeName: match.store?.name,
|
||
refereeId: match.referee_id,
|
||
refereeName: match.referee?.nickname,
|
||
startTime: match.start_time,
|
||
endTime: match.end_time,
|
||
players: sortedPlayers.map(p => ({
|
||
id: p.id,
|
||
ladderUserId: p.ladder_user_id,
|
||
realName: p.ladderUser?.real_name,
|
||
nickname: p.ladderUser?.user?.nickname,
|
||
level: p.ladderUser?.level,
|
||
initialPower: p.initial_power,
|
||
finalPower: p.final_power,
|
||
winCount: p.win_count,
|
||
loseCount: p.lose_count,
|
||
rank: p.rank,
|
||
status: p.player_status
|
||
})),
|
||
rounds: match.rounds.map(r => ({
|
||
id: r.id,
|
||
roundNumber: r.round_number,
|
||
roundType: r.round_type,
|
||
roundName: r.round_name,
|
||
status: r.status,
|
||
games: r.games.map(g => ({
|
||
id: g.id,
|
||
player1Id: g.player1_id,
|
||
player2Id: g.player2_id,
|
||
player1Score: g.player1_score,
|
||
player2Score: g.player2_score,
|
||
winnerId: g.winner_id,
|
||
confirmStatus: g.confirm_status,
|
||
status: g.status
|
||
}))
|
||
})),
|
||
games: match.games
|
||
}));
|
||
} catch (err) {
|
||
console.error('获取比赛详情失败:', err);
|
||
res.status(500).json(error('获取失败'));
|
||
}
|
||
}
|
||
|
||
// 更新比赛
|
||
async update(req, res) {
|
||
try {
|
||
const { id } = req.params;
|
||
const { name, weight, referee_id } = req.body;
|
||
|
||
const match = await Match.findByPk(id);
|
||
if (!match) {
|
||
return res.status(404).json(error('比赛不存在', 404));
|
||
}
|
||
|
||
const updateData = {};
|
||
if (name !== undefined) updateData.name = name;
|
||
if (referee_id !== undefined) updateData.referee_id = referee_id;
|
||
|
||
if (weight !== undefined) {
|
||
const normalizedWeight = parseFloat(weight);
|
||
if (!Number.isFinite(normalizedWeight) || normalizedWeight <= 0) {
|
||
return res.status(400).json(error('权重参数无效', 400));
|
||
}
|
||
updateData.weight = normalizedWeight;
|
||
}
|
||
|
||
if (match.type === MATCH_TYPES.CHALLENGE) {
|
||
updateData.weight = 1.0;
|
||
}
|
||
|
||
await match.update(updateData);
|
||
res.json(success(null, '更新成功'));
|
||
} catch (err) {
|
||
console.error('更新比赛失败:', err);
|
||
res.status(500).json(error('更新失败'));
|
||
}
|
||
}
|
||
|
||
// 开始排位赛(进入循环赛)
|
||
startMatch = async (req, res) => {
|
||
const t = await sequelize.transaction();
|
||
try {
|
||
const { id } = req.params;
|
||
|
||
const match = await Match.findByPk(id, {
|
||
include: [{ model: MatchPlayer, as: 'players' }],
|
||
transaction: t
|
||
});
|
||
|
||
if (!match || match.type !== MATCH_TYPES.RANKING) {
|
||
await t.rollback();
|
||
return res.status(400).json(error('比赛不存在或类型错误', 400));
|
||
}
|
||
|
||
if (match.stage !== RANKING_STAGE.SIGNUP) {
|
||
await t.rollback();
|
||
return res.status(400).json(error('比赛已开始', 400));
|
||
}
|
||
|
||
if (match.players.length < 2) {
|
||
await t.rollback();
|
||
return res.status(400).json(error('参赛人数不足', 400));
|
||
}
|
||
|
||
// 更新比赛状态
|
||
await match.update({
|
||
status: MATCH_STATUS.ONGOING,
|
||
stage: RANKING_STAGE.ROUND_ROBIN,
|
||
start_time: new Date()
|
||
}, { transaction: t });
|
||
|
||
// 创建循环赛轮次和对局
|
||
const players = match.players;
|
||
const n = players.length;
|
||
|
||
// 生成单循环赛配对
|
||
const games = [];
|
||
for (let i = 0; i < n; i++) {
|
||
for (let j = i + 1; j < n; j++) {
|
||
games.push({
|
||
player1_id: players[i].ladder_user_id,
|
||
player2_id: players[j].ladder_user_id
|
||
});
|
||
}
|
||
}
|
||
|
||
// 随机打乱顺序
|
||
for (let i = games.length - 1; i > 0; i--) {
|
||
const j = Math.floor(Math.random() * (i + 1));
|
||
[games[i], games[j]] = [games[j], games[i]];
|
||
}
|
||
|
||
// 创建轮次
|
||
const round = await MatchRound.create({
|
||
match_id: match.id,
|
||
round_number: 1,
|
||
round_type: 'round_robin',
|
||
round_name: '循环赛',
|
||
status: 1
|
||
}, { transaction: t });
|
||
|
||
// 创建对局
|
||
for (const game of games) {
|
||
await MatchGame.create({
|
||
match_id: match.id,
|
||
round_id: round.id,
|
||
player1_id: game.player1_id,
|
||
player2_id: game.player2_id,
|
||
status: 0
|
||
}, { transaction: t });
|
||
}
|
||
|
||
// 自动匹配第一轮
|
||
// 注意:这里需要传入 transaction,并且在 _autoMatchPlayers 中使用该 transaction 查询刚创建的数据
|
||
await this._autoMatchPlayers(match.id, t);
|
||
|
||
await t.commit();
|
||
res.json(success(null, '比赛已开始'));
|
||
} catch (err) {
|
||
await t.rollback();
|
||
console.error('开始比赛失败 (Stack):', err.stack || err); // 打印完整堆栈
|
||
console.error('开始比赛失败 (Msg):', err.message || err);
|
||
res.status(500).json(error('操作失败: ' + (err.message || '未知错误')));
|
||
}
|
||
}
|
||
|
||
// 开始淘汰赛
|
||
async startElimination(req, res) {
|
||
const t = await sequelize.transaction();
|
||
try {
|
||
const { id } = req.params;
|
||
const { elimination_size, selected_player_ids } = req.body; // elimination_size: 2/4/8..., selected_player_ids: 可选,自定义选手ID列表(ladder_user_id)
|
||
|
||
const match = await Match.findByPk(id, {
|
||
include: [{ model: MatchPlayer, as: 'players' }]
|
||
});
|
||
|
||
if (!match || match.stage !== RANKING_STAGE.ROUND_ROBIN) {
|
||
await t.rollback();
|
||
return res.status(400).json(error('比赛状态错误', 400));
|
||
}
|
||
|
||
// 检查循环赛是否结束
|
||
const unfinishedGames = await MatchGame.count({
|
||
where: {
|
||
match_id: id,
|
||
confirm_status: { [Op.ne]: CONFIRM_STATUS.CONFIRMED }
|
||
},
|
||
include: [{
|
||
model: MatchRound,
|
||
as: 'round',
|
||
where: { round_type: 'round_robin' }
|
||
}]
|
||
});
|
||
|
||
if (unfinishedGames > 0) {
|
||
await t.rollback();
|
||
return res.status(400).json(error('循环赛尚未结束', 400));
|
||
}
|
||
|
||
// 根据胜场和总得分排名(循环赛阶段只统计循环赛已确认对局)
|
||
const confirmedGames = await MatchGame.findAll({
|
||
where: {
|
||
match_id: id,
|
||
confirm_status: CONFIRM_STATUS.CONFIRMED
|
||
},
|
||
include: [{
|
||
model: MatchRound,
|
||
as: 'round',
|
||
where: { round_type: 'round_robin' }
|
||
}]
|
||
});
|
||
|
||
const scoreMap = new Map(); // ladder_user_id -> 总得分(自己得分之和)
|
||
const addScore = (playerId, score) => {
|
||
const key = String(playerId);
|
||
const prev = scoreMap.get(key) || 0;
|
||
scoreMap.set(key, prev + (Number(score) || 0));
|
||
};
|
||
confirmedGames.forEach(g => {
|
||
addScore(g.player1_id, g.player1_score);
|
||
addScore(g.player2_id, g.player2_score);
|
||
});
|
||
|
||
const rankedPlayers = match.players.sort((a, b) => {
|
||
if (b.win_count !== a.win_count) return b.win_count - a.win_count;
|
||
const scoreA = scoreMap.get(String(a.ladder_user_id)) || 0;
|
||
const scoreB = scoreMap.get(String(b.ladder_user_id)) || 0;
|
||
if (scoreB !== scoreA) return scoreB - scoreA;
|
||
// 再次平局时保持初始战力作为第三排序因子
|
||
return b.initial_power - a.initial_power;
|
||
});
|
||
|
||
// 支持手动选择淘汰赛选手:
|
||
// - 如果传入 selected_player_ids(ladder_user_id 列表),则优先使用该列表
|
||
// - 否则按排名取前 elimination_size 名
|
||
let size;
|
||
let qualifiedPlayers;
|
||
if (Array.isArray(selected_player_ids) && selected_player_ids.length >= 2) {
|
||
const idSet = new Set(selected_player_ids.map((v) => String(v)));
|
||
const picked = match.players.filter((p) =>
|
||
idSet.has(String(p.ladder_user_id)),
|
||
);
|
||
if (picked.length < 2) {
|
||
await t.rollback();
|
||
return res.status(400).json(error('所选选手数量不足 2 人', 400));
|
||
}
|
||
size = picked.length;
|
||
qualifiedPlayers = picked;
|
||
} else {
|
||
const safeSize = Math.min(elimination_size || 2, rankedPlayers.length);
|
||
if (safeSize < 2) {
|
||
await t.rollback();
|
||
return res.status(400).json(error('参赛人数不足以开启淘汰赛', 400));
|
||
}
|
||
size = safeSize;
|
||
qualifiedPlayers = rankedPlayers.slice(0, size);
|
||
}
|
||
|
||
// 更新比赛状态
|
||
await match.update({
|
||
stage: RANKING_STAGE.ELIMINATION,
|
||
elimination_size: size
|
||
}, { transaction: t });
|
||
|
||
// 根据人数确定轮次名称
|
||
// 2人:直接决赛
|
||
// 4人:半决赛
|
||
// 8人:四分之一决赛
|
||
// 其他:淘汰赛首轮
|
||
const roundName = size === 2 ? '决赛' : size === 4 ? '半决赛' : size === 8 ? '四分之一决赛' : '淘汰赛首轮';
|
||
const round = await MatchRound.create({
|
||
match_id: match.id,
|
||
round_number: 100, // 淘汰赛轮次从100开始
|
||
round_type: 'elimination',
|
||
round_name: roundName,
|
||
status: 1
|
||
}, { transaction: t });
|
||
|
||
// 配对:1 vs size, 2 vs size-1, ...
|
||
const createdGames = [];
|
||
for (let i = 0; i < size / 2; i++) {
|
||
const game = await MatchGame.create({
|
||
match_id: match.id,
|
||
round_id: round.id,
|
||
player1_id: qualifiedPlayers[i].ladder_user_id,
|
||
player2_id: qualifiedPlayers[size - 1 - i].ladder_user_id,
|
||
status: 0
|
||
}, { transaction: t });
|
||
createdGames.push({
|
||
game,
|
||
player1: qualifiedPlayers[i],
|
||
player2: qualifiedPlayers[size - 1 - i]
|
||
});
|
||
}
|
||
|
||
// 将晋级淘汰赛的选手状态标记为进行中(playing),方便前端展示
|
||
await MatchPlayer.update(
|
||
{ player_status: 'playing' },
|
||
{
|
||
where: {
|
||
match_id: match.id,
|
||
ladder_user_id: qualifiedPlayers.map(p => p.ladder_user_id)
|
||
},
|
||
transaction: t
|
||
}
|
||
);
|
||
|
||
await t.commit();
|
||
|
||
// 发送WebSocket通知给所有匹配的选手
|
||
try {
|
||
for (const { game, player1, player2 } of createdGames) {
|
||
// 获取选手的天梯用户信息(包含user_id)
|
||
const [ladder1, ladder2] = await Promise.all([
|
||
LadderUser.findByPk(player1.ladder_user_id, {
|
||
include: [{ model: User, as: 'user', attributes: ['id', 'nickname', 'avatar'] }]
|
||
}),
|
||
LadderUser.findByPk(player2.ladder_user_id, {
|
||
include: [{ model: User, as: 'user', attributes: ['id', 'nickname', 'avatar'] }]
|
||
})
|
||
]);
|
||
|
||
// 通知选手1
|
||
if (ladder1 && ladder1.user_id) {
|
||
sendMatchNotification(ladder1.user_id, {
|
||
matchId: match.id,
|
||
matchCode: match.match_code,
|
||
opponent: {
|
||
id: ladder2?.id,
|
||
realName: ladder2?.real_name,
|
||
level: ladder2?.level,
|
||
powerScore: ladder2?.power_score,
|
||
nickname: ladder2?.user?.nickname,
|
||
avatar: ladder2?.user?.avatar
|
||
}
|
||
});
|
||
}
|
||
|
||
// 通知选手2
|
||
if (ladder2 && ladder2.user_id) {
|
||
sendMatchNotification(ladder2.user_id, {
|
||
matchId: match.id,
|
||
matchCode: match.match_code,
|
||
opponent: {
|
||
id: ladder1?.id,
|
||
realName: ladder1?.real_name,
|
||
level: ladder1?.level,
|
||
powerScore: ladder1?.power_score,
|
||
nickname: ladder1?.user?.nickname,
|
||
avatar: ladder1?.user?.avatar
|
||
}
|
||
});
|
||
}
|
||
}
|
||
} catch (notifyErr) {
|
||
console.error('发送淘汰赛匹配通知失败:', notifyErr);
|
||
// 通知失败不影响主流程,只记录错误
|
||
}
|
||
|
||
res.json(success(null, '淘汰赛已开始'));
|
||
} catch (err) {
|
||
await t.rollback();
|
||
console.error('开始淘汰赛失败:', err);
|
||
res.status(500).json(error('操作失败'));
|
||
}
|
||
}
|
||
|
||
// 结束比赛(手动触发,一般由后台按钮调用)
|
||
endMatch = async (req, res) => {
|
||
const t = await sequelize.transaction();
|
||
try {
|
||
const { id } = req.params;
|
||
|
||
const finished = await this._autoFinishRankingMatch(id, t, true);
|
||
if (!finished) {
|
||
await t.rollback();
|
||
return res.status(404).json(error('比赛不存在或尚未结束所有对局', 404));
|
||
}
|
||
|
||
await t.commit();
|
||
res.json(success(null, '比赛已结束'));
|
||
} catch (err) {
|
||
await t.rollback();
|
||
console.error('结束比赛失败:', err);
|
||
res.status(500).json(error('操作失败'));
|
||
}
|
||
}
|
||
|
||
// 修改对局结果
|
||
async updateGameResult(req, res) {
|
||
try {
|
||
const { matchId, id, gameId } = req.params;
|
||
const match_id = matchId ?? id;
|
||
const { player1_score, player2_score } = req.body;
|
||
|
||
if (!match_id || !gameId) {
|
||
return res.status(400).json(error('参数缺失', 400));
|
||
}
|
||
|
||
const game = await MatchGame.findOne({
|
||
where: { id: gameId, match_id }
|
||
});
|
||
|
||
if (!game) {
|
||
return res.status(404).json(error('对局不存在', 404));
|
||
}
|
||
|
||
if (game.confirm_status === CONFIRM_STATUS.CONFIRMED) {
|
||
return res.status(400).json(error('已确认的对局不能修改', 400));
|
||
}
|
||
|
||
const p1 = Number(player1_score);
|
||
const p2 = Number(player2_score);
|
||
if (!Number.isFinite(p1) || !Number.isFinite(p2)) {
|
||
return res.status(400).json(error('比分无效', 400));
|
||
}
|
||
if (p1 < 0 || p2 < 0) {
|
||
return res.status(400).json(error('比分不能为负数', 400));
|
||
}
|
||
if (p1 === p2) {
|
||
return res.status(400).json(error('比分不能相同', 400));
|
||
}
|
||
|
||
const winnerId = p1 > p2 ? game.player1_id : game.player2_id;
|
||
const loserId = p1 > p2 ? game.player2_id : game.player1_id;
|
||
|
||
await game.update({
|
||
player1_score: p1,
|
||
player2_score: p2,
|
||
winner_id: winnerId,
|
||
loser_id: loserId
|
||
});
|
||
|
||
res.json(success(null, '更新成功'));
|
||
} catch (err) {
|
||
console.error('修改对局结果失败:', err);
|
||
res.status(500).json(error('修改失败'));
|
||
}
|
||
}
|
||
|
||
// 裁判确认对局结果
|
||
confirmGameResult = async (req, res) => {
|
||
const t = await sequelize.transaction();
|
||
try {
|
||
const { id, gameId } = req.params;
|
||
|
||
const game = await MatchGame.findOne({
|
||
where: { id: gameId, match_id: id },
|
||
include: [{ model: Match, as: 'match' }],
|
||
transaction: t
|
||
});
|
||
|
||
if (!game) {
|
||
await t.rollback();
|
||
return res.status(404).json(error('对局不存在', 404));
|
||
}
|
||
|
||
if (!game.winner_id) {
|
||
await t.rollback();
|
||
return res.status(400).json(error('请先填写比分', 400));
|
||
}
|
||
|
||
// 计算战力值变动
|
||
const winner = await LadderUser.findByPk(game.winner_id);
|
||
const loser = await LadderUser.findByPk(game.loser_id);
|
||
|
||
const { winnerChange, loserChange } = PowerCalculator.calculate(
|
||
{ powerScore: winner.power_score, level: winner.level, protectedMatches: winner.protected_matches },
|
||
{ powerScore: loser.power_score, level: loser.level, protectedMatches: loser.protected_matches },
|
||
parseFloat(game.match.weight)
|
||
);
|
||
|
||
// 更新游戏记录
|
||
await game.update({
|
||
confirm_status: CONFIRM_STATUS.CONFIRMED,
|
||
confirmed_by: req.admin.id,
|
||
confirmed_at: new Date(),
|
||
winner_power_change: winnerChange,
|
||
loser_power_change: loserChange,
|
||
status: 2
|
||
}, { transaction: t });
|
||
|
||
// 更新战力值
|
||
await winner.update({
|
||
power_score: winner.power_score + winnerChange,
|
||
match_count: winner.match_count + 1,
|
||
monthly_match_count: winner.monthly_match_count + 1,
|
||
win_count: winner.win_count + 1,
|
||
last_match_time: new Date()
|
||
}, { transaction: t });
|
||
|
||
await loser.update({
|
||
power_score: Math.max(0, loser.power_score + loserChange),
|
||
match_count: loser.match_count + 1,
|
||
monthly_match_count: loser.monthly_match_count + 1,
|
||
last_match_time: new Date()
|
||
}, { transaction: t });
|
||
|
||
// 更新选手统计
|
||
await MatchPlayer.increment('win_count', {
|
||
where: { match_id: id, ladder_user_id: game.winner_id },
|
||
transaction: t
|
||
});
|
||
await MatchPlayer.increment('lose_count', {
|
||
where: { match_id: id, ladder_user_id: game.loser_id },
|
||
transaction: t
|
||
});
|
||
|
||
// 排位赛:根据阶段做不同处理
|
||
if (game.match && game.match.type === MATCH_TYPES.RANKING) {
|
||
if (game.match.stage === RANKING_STAGE.ROUND_ROBIN) {
|
||
// 循环赛:确认后双方回到 waiting,并自动匹配下一位空闲对手
|
||
await MatchPlayer.update(
|
||
{ player_status: 'waiting', current_opponent_id: null },
|
||
{
|
||
where: {
|
||
match_id: id,
|
||
ladder_user_id: [game.winner_id, game.loser_id]
|
||
},
|
||
transaction: t
|
||
}
|
||
);
|
||
|
||
await this._autoMatchPlayers(game.match.id, t);
|
||
} else if (game.match.stage === RANKING_STAGE.ELIMINATION) {
|
||
// 淘汰赛:先检查是否需要自动生成下一轮(半决赛 -> 决赛)
|
||
await this._maybeStartNextEliminationRound(game.match.id, t);
|
||
}
|
||
|
||
// 排位赛:若所有对局均已确认,自动结算比赛并生成最终排名
|
||
await this._autoFinishRankingMatch(game.match.id, t);
|
||
}
|
||
|
||
await t.commit();
|
||
|
||
// 排位赛:通知双方刷新当前对局(含新匹配)
|
||
if (game.match && game.match.type === MATCH_TYPES.RANKING) {
|
||
const rankingUserIds = [winner.user_id, loser.user_id].filter(Boolean);
|
||
if (rankingUserIds.length > 0) {
|
||
broadcastToUsers(rankingUserIds, {
|
||
type: 'ranking_game_updated',
|
||
data: {
|
||
matchId: game.match.id,
|
||
matchCode: game.match.match_code,
|
||
gameId: game.id
|
||
}
|
||
});
|
||
}
|
||
}
|
||
|
||
res.json(success({ winnerChange, loserChange }, '确认成功'));
|
||
} catch (err) {
|
||
await t.rollback();
|
||
console.error('确认对局失败:', err);
|
||
res.status(500).json(error('确认失败'));
|
||
}
|
||
}
|
||
|
||
// 手动添加比赛选手
|
||
async addPlayer(req, res) {
|
||
try {
|
||
const { id } = req.params;
|
||
const { user_id } = req.body;
|
||
|
||
const match = await Match.findByPk(id);
|
||
if (!match) {
|
||
return res.status(404).json(error('比赛不存在', 404));
|
||
}
|
||
|
||
if (match.status !== MATCH_STATUS.PENDING) {
|
||
return res.status(400).json(error('比赛已开始或结束,无法添加选手', 400));
|
||
}
|
||
|
||
// 查找该用户在对应门店的选手信息
|
||
const ladderUser = await LadderUser.findOne({
|
||
where: {
|
||
user_id,
|
||
store_id: match.store_id
|
||
}
|
||
});
|
||
|
||
if (!ladderUser) {
|
||
return res.status(400).json(error('该用户未在本店注册选手信息', 400));
|
||
}
|
||
|
||
// 检查是否已参赛
|
||
const existingPlayer = await MatchPlayer.findOne({
|
||
where: {
|
||
match_id: id,
|
||
ladder_user_id: ladderUser.id
|
||
}
|
||
});
|
||
|
||
if (existingPlayer) {
|
||
return res.status(400).json(error('该选手已在比赛中', 400));
|
||
}
|
||
|
||
// 添加选手
|
||
await MatchPlayer.create({
|
||
match_id: id,
|
||
ladder_user_id: ladderUser.id,
|
||
initial_power: ladderUser.power_score,
|
||
player_status: 'waiting'
|
||
});
|
||
|
||
res.json(success(null, '添加成功'));
|
||
} catch (err) {
|
||
console.error('添加选手失败:', err);
|
||
res.status(500).json(error('添加失败'));
|
||
}
|
||
}
|
||
|
||
// 移除比赛选手
|
||
async removePlayer(req, res) {
|
||
try {
|
||
const { id, playerId } = req.params;
|
||
|
||
const match = await Match.findByPk(id);
|
||
if (!match) {
|
||
return res.status(404).json(error('比赛不存在', 404));
|
||
}
|
||
|
||
if (match.status !== MATCH_STATUS.PENDING) {
|
||
return res.status(400).json(error('比赛已开始或结束,无法移除选手', 400));
|
||
}
|
||
|
||
// 查找选手记录
|
||
const matchPlayer = await MatchPlayer.findOne({
|
||
where: {
|
||
match_id: id,
|
||
id: playerId
|
||
}
|
||
});
|
||
|
||
if (!matchPlayer) {
|
||
return res.status(404).json(error('该选手未参加此比赛', 404));
|
||
}
|
||
|
||
// 移除选手
|
||
await matchPlayer.destroy();
|
||
|
||
res.json(success(null, '移除成功'));
|
||
} catch (err) {
|
||
console.error('移除选手失败:', err);
|
||
res.status(500).json(error('移除失败'));
|
||
}
|
||
}
|
||
|
||
// 自动匹配选手
|
||
async _autoMatchPlayers(matchId, transaction) {
|
||
const match = await Match.findByPk(matchId, {
|
||
include: [{ model: MatchPlayer, as: 'players' }],
|
||
transaction
|
||
});
|
||
|
||
// 获取等待中的选手
|
||
const waitingPlayers = match.players.filter(p => p.player_status === 'waiting');
|
||
console.log(`[自动匹配] 比赛 ${matchId}: 等待中的选手数量=${waitingPlayers.length}`, waitingPlayers.map(p => ({ id: p.ladder_user_id, status: p.player_status })));
|
||
if (waitingPlayers.length < 2) {
|
||
console.log(`[自动匹配] 等待中的选手不足2人,无法匹配`);
|
||
return;
|
||
}
|
||
|
||
// 获取未完成的对局(排除已确认的对局)
|
||
// 注意:status=0 表示待比赛,status=1 表示比赛中,status=2 表示已结束
|
||
// 只有 status=0 且未确认的对局才能被匹配
|
||
let pendingGames = await MatchGame.findAll({
|
||
where: {
|
||
match_id: matchId,
|
||
status: 0,
|
||
confirm_status: { [Op.ne]: CONFIRM_STATUS.CONFIRMED }
|
||
},
|
||
transaction
|
||
});
|
||
console.log(`[自动匹配] 比赛 ${matchId}: 待匹配的对局数量=${pendingGames.length}`, pendingGames.map(g => ({ id: g.id, p1: g.player1_id, p2: g.player2_id, status: g.status, confirm_status: g.confirm_status })));
|
||
|
||
// 如果待匹配的对局数量为0,检查是否有状态异常的对局(status=1但未确认,可能是之前匹配过但确认失败)
|
||
// 如果两个选手都是 waiting 状态,说明对局应该被重置,允许重新匹配
|
||
if (pendingGames.length === 0) {
|
||
const abnormalGames = await MatchGame.findAll({
|
||
where: {
|
||
match_id: matchId,
|
||
status: 1, // 只检查 status=1 的对局(status=2 且未确认的可能是其他情况)
|
||
confirm_status: { [Op.ne]: CONFIRM_STATUS.CONFIRMED }
|
||
},
|
||
transaction
|
||
});
|
||
if (abnormalGames.length > 0) {
|
||
console.log(`[自动匹配] 发现 ${abnormalGames.length} 个状态异常的对局(已开始但未确认)`, abnormalGames.map(g => ({
|
||
id: g.id,
|
||
p1: g.player1_id,
|
||
p2: g.player2_id,
|
||
status: g.status,
|
||
confirm_status: g.confirm_status
|
||
})));
|
||
|
||
// 检查这些异常对局的两个选手是否都是 waiting 状态
|
||
for (const game of abnormalGames) {
|
||
const p1 = waitingPlayers.find(p => String(p.ladder_user_id) === String(game.player1_id));
|
||
const p2 = waitingPlayers.find(p => String(p.ladder_user_id) === String(game.player2_id));
|
||
|
||
if (p1 && p2) {
|
||
// 两个选手都是 waiting,说明对局应该被重置
|
||
console.log(`[自动匹配] 重置异常对局 ${game.id}: 选手 ${game.player1_id} 和 ${game.player2_id} 都是 waiting 状态`);
|
||
await game.update({
|
||
status: 0, // 重置为待比赛状态
|
||
player1_score: null,
|
||
player2_score: null,
|
||
winner_id: null,
|
||
loser_id: null,
|
||
submit_by: null
|
||
}, { transaction });
|
||
// 将这个对局添加到待匹配列表
|
||
pendingGames.push(game);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// 找到可以匹配的选手对
|
||
for (const game of pendingGames) {
|
||
// 使用 == 比较,避免 string/number 类型不一致问题
|
||
// 注意:player1_id 和 player2_id 是 bigint,可能是 string 类型
|
||
// ladder_user_id 也是 bigint,可能是 string 类型
|
||
const player1 = waitingPlayers.find(p => String(p.ladder_user_id) === String(game.player1_id));
|
||
const player2 = waitingPlayers.find(p => String(p.ladder_user_id) === String(game.player2_id));
|
||
|
||
if (player1 && player2) {
|
||
console.log(`[自动匹配] 匹配成功: 对局 ${game.id}, 选手1=${player1.ladder_user_id}, 选手2=${player2.ladder_user_id}`);
|
||
// 更新选手状态
|
||
await MatchPlayer.update(
|
||
{ player_status: 'playing', current_opponent_id: game.player2_id },
|
||
{ where: { id: player1.id }, transaction }
|
||
);
|
||
await MatchPlayer.update(
|
||
{ player_status: 'playing', current_opponent_id: game.player1_id },
|
||
{ where: { id: player2.id }, transaction }
|
||
);
|
||
|
||
// 更新对局状态
|
||
await game.update({ status: 1 }, { transaction });
|
||
|
||
// 发送匹配成功 WebSocket 通知给双方
|
||
try {
|
||
const [ladder1, ladder2] = await Promise.all([
|
||
LadderUser.findByPk(game.player1_id, {
|
||
include: [{ model: User, as: 'user', attributes: ['id', 'nickname', 'avatar'] }],
|
||
transaction
|
||
}),
|
||
LadderUser.findByPk(game.player2_id, {
|
||
include: [{ model: User, as: 'user', attributes: ['id', 'nickname', 'avatar'] }],
|
||
transaction
|
||
})
|
||
]);
|
||
|
||
if (ladder1 && ladder1.user_id) {
|
||
sendMatchNotification(ladder1.user_id, {
|
||
matchId: match.id,
|
||
matchCode: match.match_code,
|
||
opponent: {
|
||
id: ladder2?.id,
|
||
realName: ladder2?.real_name,
|
||
level: ladder2?.level,
|
||
powerScore: ladder2?.power_score,
|
||
nickname: ladder2?.user?.nickname,
|
||
avatar: ladder2?.user?.avatar
|
||
}
|
||
});
|
||
}
|
||
|
||
if (ladder2 && ladder2.user_id) {
|
||
sendMatchNotification(ladder2.user_id, {
|
||
matchId: match.id,
|
||
matchCode: match.match_code,
|
||
opponent: {
|
||
id: ladder1?.id,
|
||
realName: ladder1?.real_name,
|
||
level: ladder1?.level,
|
||
powerScore: ladder1?.power_score,
|
||
nickname: ladder1?.user?.nickname,
|
||
avatar: ladder1?.user?.avatar
|
||
}
|
||
});
|
||
}
|
||
} catch (notifyErr) {
|
||
console.error('发送排位赛匹配通知失败:', notifyErr);
|
||
}
|
||
|
||
// 从等待列表移除
|
||
const idx1 = waitingPlayers.indexOf(player1);
|
||
const idx2 = waitingPlayers.indexOf(player2);
|
||
waitingPlayers.splice(Math.max(idx1, idx2), 1);
|
||
waitingPlayers.splice(Math.min(idx1, idx2), 1);
|
||
|
||
if (waitingPlayers.length < 2) break;
|
||
} else {
|
||
console.log(`[自动匹配] 对局 ${game.id} 无法匹配: player1=${player1 ? player1.ladder_user_id : 'null'}, player2=${player2 ? player2.ladder_user_id : 'null'}`);
|
||
}
|
||
}
|
||
console.log(`[自动匹配] 比赛 ${matchId}: 匹配完成,剩余等待选手=${waitingPlayers.length}`);
|
||
}
|
||
|
||
// 手动触发匹配(用于调试)
|
||
async triggerMatch(req, res) {
|
||
const t = await sequelize.transaction();
|
||
try {
|
||
const { id } = req.params;
|
||
const match = await Match.findByPk(id, {
|
||
include: [{ model: MatchPlayer, as: 'players' }],
|
||
transaction: t
|
||
});
|
||
|
||
if (!match || match.type !== MATCH_TYPES.RANKING) {
|
||
await t.rollback();
|
||
return res.status(400).json(error('比赛不存在或类型错误', 400));
|
||
}
|
||
|
||
if (match.stage !== RANKING_STAGE.ROUND_ROBIN) {
|
||
await t.rollback();
|
||
return res.status(400).json(error('只有循环赛阶段才能手动触发匹配', 400));
|
||
}
|
||
|
||
await this._autoMatchPlayers(match.id, t);
|
||
await t.commit();
|
||
|
||
res.json(success(null, '匹配已触发'));
|
||
} catch (err) {
|
||
await t.rollback();
|
||
console.error('手动触发匹配失败:', err);
|
||
res.status(500).json(error('操作失败'));
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 当淘汰赛某轮全部结束时,自动生成下一轮。
|
||
* 当前仅支持:4 强(半决赛)全部确认后自动生成决赛。
|
||
*/
|
||
async _maybeStartNextEliminationRound(matchId, transaction) {
|
||
const match = await Match.findByPk(matchId, {
|
||
include: [{
|
||
model: MatchRound,
|
||
as: 'rounds',
|
||
include: [{ model: MatchGame, as: 'games' }]
|
||
}],
|
||
transaction
|
||
});
|
||
|
||
if (!match || match.stage !== RANKING_STAGE.ELIMINATION || match.elimination_size !== 4) {
|
||
return;
|
||
}
|
||
|
||
// 如果已经有决赛轮次,则不再生成
|
||
const hasFinal = match.rounds.some(r => r.round_type === 'elimination' && r.round_name === '决赛');
|
||
if (hasFinal) return;
|
||
|
||
// 查找半决赛轮次
|
||
const semiRound = match.rounds.find(r => r.round_type === 'elimination' && r.round_name === '半决赛');
|
||
if (!semiRound || !semiRound.games || semiRound.games.length < 2) return;
|
||
|
||
// 必须两场都已确认
|
||
const allConfirmed = semiRound.games.every(g => g.confirm_status === CONFIRM_STATUS.CONFIRMED);
|
||
if (!allConfirmed) return;
|
||
|
||
const winnerIds = semiRound.games.map(g => g.winner_id).filter(Boolean);
|
||
if (winnerIds.length < 2) return;
|
||
|
||
// 创建决赛轮次
|
||
const finalRound = await MatchRound.create({
|
||
match_id: match.id,
|
||
round_number: semiRound.round_number + 1,
|
||
round_type: 'elimination',
|
||
round_name: '决赛',
|
||
status: 1
|
||
}, { transaction });
|
||
|
||
// 创建决赛对局(两位半决赛胜者对决)
|
||
await MatchGame.create({
|
||
match_id: match.id,
|
||
round_id: finalRound.id,
|
||
player1_id: winnerIds[0],
|
||
player2_id: winnerIds[1],
|
||
status: 0
|
||
}, { transaction });
|
||
|
||
// 将两名决赛选手标记为进行中
|
||
await MatchPlayer.update(
|
||
{ player_status: 'playing', current_opponent_id: null },
|
||
{
|
||
where: {
|
||
match_id: match.id,
|
||
ladder_user_id: winnerIds
|
||
},
|
||
transaction
|
||
}
|
||
);
|
||
}
|
||
|
||
/**
|
||
* 在所有对局都确认后,自动结算排位赛,生成最终排名。
|
||
* 如果 force=true,则只检查比赛是否存在,不再检查“是否还有未确认对局”(用于手动结束按钮)。
|
||
*/
|
||
async _autoFinishRankingMatch(matchId, transaction, force = false) {
|
||
const match = await Match.findByPk(matchId, {
|
||
include: [
|
||
{ model: MatchPlayer, as: 'players' },
|
||
{
|
||
model: MatchRound,
|
||
as: 'rounds',
|
||
include: [{ model: MatchGame, as: 'games' }]
|
||
}
|
||
],
|
||
transaction
|
||
});
|
||
|
||
if (!match) return false;
|
||
|
||
// 如果不是排位赛,暂不处理
|
||
if (match.type !== MATCH_TYPES.RANKING) return false;
|
||
|
||
// 如果还在循环赛阶段,不自动结束(需要手动开始淘汰赛)
|
||
if (match.stage === RANKING_STAGE.ROUND_ROBIN) {
|
||
return false;
|
||
}
|
||
|
||
// 如果不在淘汰赛阶段,也不自动结束(可能是其他状态)
|
||
if (match.stage !== RANKING_STAGE.ELIMINATION) {
|
||
return false;
|
||
}
|
||
|
||
if (!force) {
|
||
// 检查是否还有未确认的对局,有的话暂不结束
|
||
const remainingGames = await MatchGame.count({
|
||
where: {
|
||
match_id: matchId,
|
||
confirm_status: { [Op.ne]: CONFIRM_STATUS.CONFIRMED }
|
||
},
|
||
transaction
|
||
});
|
||
if (remainingGames > 0) {
|
||
return false;
|
||
}
|
||
|
||
// 检查是否有决赛,并且决赛是否已确认
|
||
const finalRound = match.rounds.find(r => r.round_type === 'elimination' && r.round_name === '决赛');
|
||
if (!finalRound || !finalRound.games || finalRound.games.length === 0) {
|
||
// 没有决赛,不自动结束
|
||
return false;
|
||
}
|
||
|
||
// 检查决赛是否已确认
|
||
const finalGame = finalRound.games[0];
|
||
if (finalGame.confirm_status !== CONFIRM_STATUS.CONFIRMED) {
|
||
// 决赛未确认,不自动结束
|
||
return false;
|
||
}
|
||
}
|
||
|
||
// 计算最终排名:先按胜场,再按总得分(自己得分总和),最后按初始战力
|
||
const confirmedGamesAll = await MatchGame.findAll({
|
||
where: {
|
||
match_id: matchId,
|
||
confirm_status: CONFIRM_STATUS.CONFIRMED
|
||
},
|
||
transaction
|
||
});
|
||
|
||
const scoreMap = new Map(); // ladder_user_id -> 总得分
|
||
const addScore = (playerId, score) => {
|
||
const key = String(playerId);
|
||
const prev = scoreMap.get(key) || 0;
|
||
scoreMap.set(key, prev + (Number(score) || 0));
|
||
};
|
||
confirmedGamesAll.forEach(g => {
|
||
addScore(g.player1_id, g.player1_score);
|
||
addScore(g.player2_id, g.player2_score);
|
||
});
|
||
|
||
// 先创建一个副本进行排序,避免修改原数组
|
||
const players = [...match.players].sort((a, b) => {
|
||
// 第一优先级:胜场数(降序)
|
||
if (b.win_count !== a.win_count) {
|
||
return b.win_count - a.win_count;
|
||
}
|
||
// 第二优先级:总得分(降序)
|
||
const scoreA = scoreMap.get(String(a.ladder_user_id)) || 0;
|
||
const scoreB = scoreMap.get(String(b.ladder_user_id)) || 0;
|
||
if (scoreB !== scoreA) {
|
||
return scoreB - scoreA;
|
||
}
|
||
// 第三优先级:初始战力(降序)
|
||
return b.initial_power - a.initial_power;
|
||
});
|
||
|
||
// 调试日志:打印排序结果
|
||
console.log(`[最终排名] 比赛 ${matchId} 排序结果:`, players.map((p, idx) => ({
|
||
rank: idx + 1,
|
||
name: p.ladderUser?.real_name || p.ladder_user_id,
|
||
win_count: p.win_count,
|
||
total_score: scoreMap.get(String(p.ladder_user_id)) || 0,
|
||
initial_power: p.initial_power
|
||
})));
|
||
|
||
for (let i = 0; i < players.length; i++) {
|
||
const player = players[i];
|
||
const ladderUser = await LadderUser.findByPk(player.ladder_user_id, { transaction });
|
||
if (!ladderUser) continue;
|
||
|
||
await player.update(
|
||
{
|
||
rank: i + 1,
|
||
final_power: ladderUser.power_score,
|
||
player_status: 'finished',
|
||
},
|
||
{ transaction }
|
||
);
|
||
|
||
// 处理升降级(排位赛规则)
|
||
const promotion = PowerCalculator.determinePromotion(i + 1, players.length);
|
||
if (promotion === 'promote' && ladderUser.level < 5) {
|
||
// 冠军:段位 +1
|
||
await ladderUser.update({ level: ladderUser.level + 1 }, { transaction });
|
||
} else if (promotion === 'demote' && (ladderUser.level === 4 || ladderUser.level === 5)) {
|
||
// 末位:只有 4、5 段才降一级
|
||
await ladderUser.update({ level: ladderUser.level - 1 }, { transaction });
|
||
} // 其他情况不变
|
||
}
|
||
|
||
await match.update(
|
||
{
|
||
status: MATCH_STATUS.FINISHED,
|
||
stage: RANKING_STAGE.FINISHED,
|
||
end_time: new Date(),
|
||
},
|
||
{ transaction }
|
||
);
|
||
|
||
return true;
|
||
}
|
||
}
|
||
|
||
module.exports = new MatchAdminController();
|