Files
badminton-scoreboard/server/server.mjs

312 lines
7.8 KiB
JavaScript
Raw Normal View History

import 'dotenv/config'
import express from 'express'
import mysql from 'mysql2/promise'
import path from 'node:path'
import { existsSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
const app = express()
const port = Number(process.env.PORT ?? process.env.SERVER_PORT ?? 8788)
const matchTableName = process.env.DB_TABLE ?? 'badminton'
const historyTableName = process.env.DB_HISTORY_TABLE ?? 'history'
const appVersion = process.env.APP_VERSION ?? `${Date.now()}`
const appStartedAt = new Date().toISOString()
const currentFilePath = fileURLToPath(import.meta.url)
const currentDir = path.dirname(currentFilePath)
const projectRoot = path.resolve(currentDir, '..')
const distDir = path.join(projectRoot, 'dist')
const distReady = existsSync(path.join(distDir, 'index.html'))
const requiredEnv = ['DB_HOST', 'DB_PORT', 'DB_USER', 'DB_PASSWORD', 'DB_DATABASE']
const missingEnv = requiredEnv.filter((key) => !process.env[key])
const pool =
missingEnv.length === 0
? mysql.createPool({
host: process.env.DB_HOST,
port: Number(process.env.DB_PORT),
user: process.env.DB_USER,
password: process.env.DB_PASSWORD,
database: process.env.DB_DATABASE,
charset: 'utf8mb4',
waitForConnections: true,
connectionLimit: 10,
})
: null
app.use(express.json())
app.get('/api/health', (_request, response) => {
response.json({
appStartedAt,
appVersion,
ok: true,
dbReady: Boolean(pool),
distReady,
historyTableName,
matchTableName,
missingEnv,
})
})
app.get('/api/version', (_request, response) => {
response.set({
'Cache-Control': 'no-store, no-cache, must-revalidate, proxy-revalidate',
Expires: '0',
Pragma: 'no-cache',
'Surrogate-Control': 'no-store',
})
response.json({
ok: true,
startedAt: appStartedAt,
version: appVersion,
})
})
app.get('/api/match-results/:time', async (request, response) => {
if (!pool) {
response.status(500).json({
ok: false,
message: `DB 尚未設定完成,缺少 ${missingEnv.join(', ')}`,
})
return
}
const time = String(request.params.time ?? '')
if (!/^\d{8}$/.test(time)) {
response.status(400).json({
ok: false,
message: '日期格式必須是 YYYYMMDD。',
})
return
}
try {
await ensureMatchTable(pool, matchTableName)
const [rows] = await pool.execute(
`SELECT time, personnel, battlecombination FROM \`${matchTableName}\` WHERE time = ? LIMIT 1`,
[Number(time)],
)
const record = rows[0]
if (!record) {
response.status(404).json({
ok: false,
message: '指定日期沒有資料。',
})
return
}
response.json({
ok: true,
data: record,
})
} catch (error) {
console.error('match-results load error:', error)
response.status(500).json({
ok: false,
message: error instanceof Error ? error.message : '讀取對戰資料失敗。',
})
}
})
app.post('/api/history', async (request, response) => {
if (!pool) {
response.status(500).json({
ok: false,
message: `DB 尚未設定完成,缺少 ${missingEnv.join(', ')}`,
})
return
}
const {
dayOfWeek,
players,
score,
scoreList,
team,
time,
type,
winScore,
} = request.body ?? {}
if (
typeof time !== 'number' ||
typeof dayOfWeek !== 'number' ||
typeof winScore !== 'number' ||
typeof type !== 'number' ||
!Array.isArray(score) ||
score.length !== 2 ||
!Array.isArray(players) ||
!Array.isArray(team) ||
!Array.isArray(scoreList)
) {
response.status(400).json({
ok: false,
message: '戰績資料格式不正確。',
})
return
}
try {
await ensureHistoryTable(pool, historyTableName)
const [result] = await pool.execute(
`
INSERT INTO \`${historyTableName}\`
(time, dayOfWeek, score, winScore, type, players, team, scoreList)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`,
[
time,
dayOfWeek,
JSON.stringify(score),
winScore,
type,
JSON.stringify(players),
JSON.stringify(team),
JSON.stringify(scoreList),
],
)
response.json({
ok: true,
data: {
id: result.insertId,
},
message: '戰績已寫入 DB。',
})
} catch (error) {
console.error('history save error:', error)
response.status(500).json({
ok: false,
message: error instanceof Error ? error.message : '寫入戰績失敗。',
})
}
})
app.get('/api/history', async (_request, response) => {
if (!pool) {
response.status(500).json({
ok: false,
message: `DB 尚未設定完成,缺少 ${missingEnv.join(', ')}`,
})
return
}
try {
await ensureHistoryTable(pool, historyTableName)
const [rows] = await pool.execute(
`
SELECT id, time, dayOfWeek, score, winScore, type, players, team, scoreList
FROM \`${historyTableName}\`
ORDER BY id DESC
`,
)
response.json({
ok: true,
data: rows,
})
} catch (error) {
console.error('history load error:', error)
response.status(500).json({
ok: false,
message: error instanceof Error ? error.message : '讀取歷史戰績失敗。',
})
}
})
app.delete('/api/history/:id', async (request, response) => {
if (!pool) {
response.status(500).json({
ok: false,
message: `DB 設定不完整,缺少:${missingEnv.join(', ')}`,
})
return
}
const id = Number(request.params.id)
if (!Number.isInteger(id) || id <= 0) {
response.status(400).json({
ok: false,
message: '戰績編號格式不正確。',
})
return
}
try {
await ensureHistoryTable(pool, historyTableName)
const [result] = await pool.execute(
`DELETE FROM \`${historyTableName}\` WHERE id = ? LIMIT 1`,
[id],
)
if (result.affectedRows === 0) {
response.status(404).json({
ok: false,
message: '找不到要刪除的戰績。',
})
return
}
response.json({
ok: true,
message: '戰績已刪除。',
})
} catch (error) {
console.error('history delete error:', error)
response.status(500).json({
ok: false,
message: error instanceof Error ? error.message : '刪除戰績失敗。',
})
}
})
if (distReady) {
app.use(express.static(distDir))
app.get(/^(?!\/api).*/, (_request, response) => {
response.sendFile(path.join(distDir, 'index.html'))
})
}
app.listen(port, () => {
console.log(`Server ready on http://localhost:${port}`)
if (missingEnv.length > 0) {
console.log(`Missing env: ${missingEnv.join(', ')}`)
}
})
async function ensureMatchTable(poolInstance, currentTableName) {
await poolInstance.execute(`
CREATE TABLE IF NOT EXISTS \`${currentTableName}\` (
time INT(11) NOT NULL,
personnel TEXT NOT NULL,
battlecombination TEXT DEFAULT NULL,
PRIMARY KEY (time)
) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci
`)
}
async function ensureHistoryTable(poolInstance, currentTableName) {
await poolInstance.execute(`
CREATE TABLE IF NOT EXISTS \`${currentTableName}\` (
id INT(11) NOT NULL AUTO_INCREMENT,
time INT(11) NOT NULL COMMENT '記錄時間',
dayOfWeek INT(1) NOT NULL COMMENT '星期',
score VARCHAR(255) NOT NULL COMMENT '隊伍分數 [ [隊伍1分數], [隊伍2分數] ]',
winScore INT(2) NOT NULL COMMENT '幾分獲勝',
type INT(1) NOT NULL COMMENT '遊戲類型(0:雙打,1:單打)',
players VARCHAR(255) NOT NULL COMMENT '玩家',
team VARCHAR(255) NOT NULL COMMENT '玩家隊伍 [ [隊伍1成員], [隊伍2成員] ]',
scoreList TEXT DEFAULT NULL COMMENT '得分過程[round, starter, winCount, winner]',
PRIMARY KEY (id)
) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci
`)
}