新增即時觀戰房間並整理 README
This commit is contained in:
1
server/data/live-rooms.json
Normal file
1
server/data/live-rooms.json
Normal file
@@ -0,0 +1 @@
|
||||
[]
|
||||
@@ -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}\` (
|
||||
|
||||
Reference in New Issue
Block a user