新增即時觀戰房間並整理 README

This commit is contained in:
2026-04-19 12:46:59 +08:00
parent c097ceb9ad
commit 896c24547b
10 changed files with 1283 additions and 61 deletions

View File

@@ -2,7 +2,7 @@ import 'dotenv/config'
import express from 'express'
import mysql from 'mysql2/promise'
import path from 'node:path'
import { existsSync } from 'node:fs'
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
const app = express()
@@ -16,6 +16,8 @@ const currentFilePath = fileURLToPath(import.meta.url)
const currentDir = path.dirname(currentFilePath)
const projectRoot = path.resolve(currentDir, '..')
const distDir = path.join(projectRoot, 'dist')
const roomDataDir = path.join(projectRoot, 'server', 'data')
const roomsFilePath = path.join(roomDataDir, 'live-rooms.json')
const distReady = existsSync(path.join(distDir, 'index.html'))
const requiredEnv = ['DB_HOST', 'DB_PORT', 'DB_USER', 'DB_PASSWORD', 'DB_DATABASE']
@@ -35,6 +37,9 @@ const pool =
})
: null
const rooms = loadPersistedRooms()
const roomListClients = new Set()
app.use(express.json())
app.get('/api/health', (_request, response) => {
@@ -65,6 +70,198 @@ app.get('/api/version', (_request, response) => {
})
})
app.get('/api/rooms', (_request, response) => {
response.json({
ok: true,
data: getLiveRoomSummaries(),
})
})
app.get('/api/rooms/stream', (request, response) => {
setupSse(response)
roomListClients.add(response)
sendSse(response, 'rooms', getLiveRoomSummaries())
request.on('close', () => {
roomListClients.delete(response)
})
})
app.post('/api/rooms', (request, response) => {
const payload = normalizeRoomPayload(request.body)
if (!payload.ok) {
response.status(400).json({
ok: false,
message: payload.message,
})
return
}
const roomId = createRoomId()
const hostToken = createHostToken()
const now = new Date().toISOString()
const room = {
...payload.value,
clients: new Set(),
createdAt: now,
hostToken,
roomId,
status: 'live',
updatedAt: now,
winnerTeamName: null,
}
rooms.set(roomId, room)
persistRooms()
broadcastRoom(room)
broadcastRoomList()
response.json({
ok: true,
data: {
hostToken,
roomId,
status: room.status,
},
})
})
app.post('/api/rooms/:roomId/release', (request, response) => {
const room = rooms.get(request.params.roomId)
if (!room) {
response.json({
ok: true,
})
return
}
const { hostToken } = request.body ?? {}
if (typeof hostToken !== 'string' || hostToken !== room.hostToken) {
response.status(403).json({
ok: false,
message: '沒有權限清除此房間。',
})
return
}
if (room.status === 'finished') {
response.json({
ok: true,
})
return
}
room.clients.forEach((client) => {
sendSse(client, 'room-closed', {
roomId: room.roomId,
status: 'released',
})
client.end()
})
rooms.delete(room.roomId)
persistRooms()
broadcastRoomList()
response.json({
ok: true,
})
})
app.get('/api/rooms/:roomId', (request, response) => {
const room = rooms.get(request.params.roomId)
if (!room) {
response.status(404).json({
ok: false,
message: '找不到這個房間。',
})
return
}
response.json({
ok: true,
data: serializeRoom(room),
})
})
app.get('/api/rooms/:roomId/stream', (request, response) => {
const room = rooms.get(request.params.roomId)
if (!room) {
response.status(404).json({
ok: false,
message: '找不到這個房間。',
})
return
}
setupSse(response)
room.clients.add(response)
sendSse(response, 'room', serializeRoom(room))
request.on('close', () => {
room.clients.delete(response)
})
})
app.put('/api/rooms/:roomId', (request, response) => {
const room = rooms.get(request.params.roomId)
if (!room) {
response.status(404).json({
ok: false,
message: '找不到這個房間。',
})
return
}
const { hostToken, status, winnerTeamName } = request.body ?? {}
if (typeof hostToken !== 'string' || hostToken !== room.hostToken) {
response.status(403).json({
ok: false,
message: '沒有更新這個房間的權限。',
})
return
}
const payload = normalizeRoomPayload(request.body)
if (!payload.ok) {
response.status(400).json({
ok: false,
message: payload.message,
})
return
}
room.groupId = payload.value.groupId
room.leftTeamName = payload.value.leftTeamName
room.matchupLabel = payload.value.matchupLabel
room.pointLog = payload.value.pointLog
room.rightTeamName = payload.value.rightTeamName
room.scoreState = payload.value.scoreState
room.targetDate = payload.value.targetDate
room.status = status === 'finished' ? 'finished' : 'live'
room.updatedAt = new Date().toISOString()
room.winnerTeamName = room.status === 'finished' && typeof winnerTeamName === 'string'
? winnerTeamName
: null
persistRooms()
broadcastRoom(room)
broadcastRoomList()
response.json({
ok: true,
data: serializeRoom(room),
})
})
app.get('/api/match-results/:time', async (request, response) => {
if (!pool) {
response.status(500).json({
@@ -282,6 +479,189 @@ app.listen(port, () => {
}
})
function createHostToken() {
return `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 10)}`
}
function createRoomId() {
let roomId = ''
do {
roomId = String(Math.floor(100000 + Math.random() * 900000))
} while (rooms.has(roomId))
return roomId
}
function loadPersistedRooms() {
const nextRooms = new Map()
try {
if (!existsSync(roomsFilePath)) {
return nextRooms
}
const raw = readFileSync(roomsFilePath, 'utf8')
if (!raw.trim()) {
return nextRooms
}
const savedRooms = JSON.parse(raw)
if (!Array.isArray(savedRooms)) {
return nextRooms
}
savedRooms.forEach((savedRoom) => {
if (!savedRoom || typeof savedRoom !== 'object' || typeof savedRoom.roomId !== 'string') {
return
}
nextRooms.set(savedRoom.roomId, {
...savedRoom,
clients: new Set(),
})
})
} catch (error) {
console.error('load persisted rooms error:', error)
}
return nextRooms
}
function persistRooms() {
try {
mkdirSync(roomDataDir, { recursive: true })
writeFileSync(
roomsFilePath,
JSON.stringify(
Array.from(rooms.values()).map((room) => ({
createdAt: room.createdAt,
groupId: room.groupId,
hostToken: room.hostToken,
leftTeamName: room.leftTeamName,
matchupLabel: room.matchupLabel,
pointLog: room.pointLog,
rightTeamName: room.rightTeamName,
roomId: room.roomId,
scoreState: room.scoreState,
status: room.status,
targetDate: room.targetDate,
updatedAt: room.updatedAt,
winnerTeamName: room.winnerTeamName,
})),
null,
2,
),
'utf8',
)
} catch (error) {
console.error('persist rooms error:', error)
}
}
function normalizeRoomPayload(value) {
const {
groupId,
leftTeamName,
matchupLabel,
pointLog,
rightTeamName,
scoreState,
targetDate,
} = value ?? {}
if (
typeof leftTeamName !== 'string' ||
typeof rightTeamName !== 'string' ||
typeof matchupLabel !== 'string' ||
typeof targetDate !== 'string' ||
!Array.isArray(pointLog) ||
!scoreState ||
typeof scoreState !== 'object'
) {
return {
ok: false,
message: '房間資料格式不正確。',
}
}
return {
ok: true,
value: {
groupId: typeof groupId === 'number' ? groupId : null,
leftTeamName,
matchupLabel,
pointLog,
rightTeamName,
scoreState,
targetDate,
},
}
}
function serializeRoom(room) {
return {
roomId: room.roomId,
groupId: room.groupId,
leftTeamName: room.leftTeamName,
matchupLabel: room.matchupLabel,
pointLog: room.pointLog,
rightTeamName: room.rightTeamName,
scoreState: room.scoreState,
status: room.status,
targetDate: room.targetDate,
createdAt: room.createdAt,
updatedAt: room.updatedAt,
winnerTeamName: room.winnerTeamName,
}
}
function getLiveRoomSummaries() {
return Array.from(rooms.values())
.filter((room) => room.status === 'live')
.sort((left, right) => right.updatedAt.localeCompare(left.updatedAt))
.map((room) => ({
roomId: room.roomId,
createdAt: room.createdAt,
leftTeamName: room.leftTeamName,
rightTeamName: room.rightTeamName,
scoreLeft: room.scoreState.scoreLeft,
scoreRight: room.scoreState.scoreRight,
status: room.status,
targetScore: room.scoreState.targetScore,
updatedAt: room.updatedAt,
}))
}
function setupSse(response) {
response.writeHead(200, {
'Cache-Control': 'no-cache, no-transform',
Connection: 'keep-alive',
'Content-Type': 'text/event-stream',
})
}
function sendSse(response, eventName, payload) {
response.write(`event: ${eventName}\n`)
response.write(`data: ${JSON.stringify(payload)}\n\n`)
}
function broadcastRoom(room) {
const payload = serializeRoom(room)
room.clients.forEach((client) => {
sendSse(client, 'room', payload)
})
}
function broadcastRoomList() {
const payload = getLiveRoomSummaries()
roomListClients.forEach((client) => {
sendSse(client, 'rooms', payload)
})
}
async function ensureMatchTable(poolInstance, currentTableName) {
await poolInstance.execute(`
CREATE TABLE IF NOT EXISTS \`${currentTableName}\` (