yingsa/server/src/controllers/matchAdminController.js
ethanfly 02937ca33c feat(天梯): 新增选手定位功能并调整挑战赛权重
- 在小程序天梯排名页添加“定位我”按钮,点击可滚动到当前用户所在位置
- 新增获取用户排名接口 `/ladder/my-rank` 用于定位计算
- 调整挑战赛权重从 1.5 降至 1.0,与日常畅打保持一致
- 新增数据库脚本 `setChallengeMatchWeightTo1.js` 用于更新历史数据
- 在管理员界面创建天梯用户时,根据所选等级自动填充默认战力值
- 修复管理员更新比赛时挑战赛权重强制设置为 1.0 的问题
- 新增天梯汇总大屏页面及相关路由
- 添加大屏比赛列表接口 `/match/display-list` 用于展示进行中和近期比赛
- 优化用户详情页的胜负场和胜率显示逻辑
- 修复小程序用户注册时的性别选择逻辑
2026-02-02 03:22:36 +08:00

693 lines
21 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 } = 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, {
include: [
{ model: Store, as: 'store' },
{ model: User, as: 'referee' },
{
model: MatchPlayer,
as: 'players',
include: [{ model: LadderUser, as: 'ladderUser', include: [{ model: User, as: 'user' }] }]
},
{
model: MatchRound,
as: 'rounds',
include: [{ model: MatchGame, as: 'games' }]
},
{ model: MatchGame, as: 'games' }
]
});
if (!match) {
return res.status(404).json(error('比赛不存在', 404));
}
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: match.players.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 } = req.body; // 4, 8, 16
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 rankedPlayers = match.players.sort((a, b) => {
if (b.win_count !== a.win_count) return b.win_count - a.win_count;
return b.initial_power - a.initial_power;
});
const size = Math.min(elimination_size, rankedPlayers.length);
const qualifiedPlayers = rankedPlayers.slice(0, size);
// 更新比赛状态
await match.update({
stage: RANKING_STAGE.ELIMINATION,
elimination_size: size
}, { transaction: t });
// 创建淘汰赛首轮1 vs 最后, 2 vs 倒数第二...
const roundName = 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, ...
for (let i = 0; i < size / 2; i++) {
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 });
}
await t.commit();
res.json(success(null, '淘汰赛已开始'));
} catch (err) {
await t.rollback();
console.error('开始淘汰赛失败:', err);
res.status(500).json(error('操作失败'));
}
}
// 结束比赛
async endMatch(req, res) {
const t = await sequelize.transaction();
try {
const { id } = req.params;
const match = await Match.findByPk(id, {
include: [{ model: MatchPlayer, as: 'players' }]
});
if (!match) {
await t.rollback();
return res.status(404).json(error('比赛不存在', 404));
}
// 计算最终排名和战力值
const players = match.players.sort((a, b) => {
if (b.win_count !== a.win_count) return b.win_count - a.win_count;
return b.initial_power - a.initial_power;
});
for (let i = 0; i < players.length; i++) {
const player = players[i];
const ladderUser = await LadderUser.findByPk(player.ladder_user_id);
await player.update({
rank: i + 1,
final_power: ladderUser.power_score,
player_status: 'finished'
}, { transaction: t });
// 处理升降级(如果是月度排位赛)
if (match.type === MATCH_TYPES.RANKING) {
const promotion = PowerCalculator.determinePromotion(i + 1, players.length);
if (promotion === 'promote' && ladderUser.level < 5) {
await ladderUser.update({ level: ladderUser.level + 1 }, { transaction: t });
} else if (promotion === 'demote' && ladderUser.level > 1) {
await ladderUser.update({ level: ladderUser.level - 1 }, { transaction: t });
}
}
}
await match.update({
status: MATCH_STATUS.FINISHED,
stage: RANKING_STAGE.FINISHED,
end_time: new Date()
}, { transaction: t });
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 { id, gameId } = req.params;
const { player1_score, player2_score } = req.body;
const game = await MatchGame.findOne({
where: { id: gameId, match_id: 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 winnerId = player1_score > player2_score ? game.player1_id : game.player2_id;
const loserId = player1_score > player2_score ? game.player2_id : game.player1_id;
await game.update({
player1_score,
player2_score,
winner_id: winnerId,
loser_id: loserId
});
res.json(success(null, '更新成功'));
} catch (err) {
console.error('修改对局结果失败:', err);
res.status(500).json(error('修改失败'));
}
}
// 裁判确认对局结果
async confirmGameResult(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' }]
});
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
});
await t.commit();
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');
if (waitingPlayers.length < 2) return;
// 获取未完成的对局
const pendingGames = await MatchGame.findAll({
where: {
match_id: matchId,
status: 0
},
transaction
});
// 找到可以匹配的选手对
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) {
// 更新选手状态
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 });
// 从等待列表移除
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;
}
}
}
}
module.exports = new MatchAdminController();