摘要: - POST /api/history 寫入戰績後,檢查上場 4 人是否在當天 attendance 名單, 沒報名的自動補:有 TG 綁定用 tg_members 的真實 user_id 與名稱, 沒綁定用名字 md5 前 6 bytes 取負的固定 id(與歷史匯入規則一致,已驗證) - 名字比對沿用出席統計規則(別名表+大小寫不分),輪空等佔位字不補 - 前端上傳成功後浮出「已幫 ○○ 補報名」提示,6 秒自動消失 - 補報名失敗只記 log,不影響戰績上傳 根本原因: - 報名靠 TG bot,臨時上場的人常忘了報名,出席統計就少算; 記分板結算時已經知道誰上場,是補報名最準的時機 影響: - 跨裝置記分也適用,出席統計(attendance 表)不再漏掉臨時上場的人 - 同一場重複上傳不會重複補(已在名單就跳過) 修法: - server.mjs 新增 ensureAttendanceSignups / getTaipeiPollDate / syntheticUserId - types.ts 的 HistoryUploadResponse 增加 addedSignups - App.tsx 新增 signupNotice 狀態與浮動提示 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1251 lines
32 KiB
JavaScript
1251 lines
32 KiB
JavaScript
import 'dotenv/config'
|
||
import express from 'express'
|
||
import mysql from 'mysql2/promise'
|
||
import { createHash, X509Certificate } from 'node:crypto'
|
||
import path from 'node:path'
|
||
import { existsSync, mkdirSync, readFileSync, writeFileSync } 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'
|
||
// 出席統計的來源:TG 報名 bot 寫入的 attendance 表(每天每人一列)。
|
||
const attendanceTableName = process.env.DB_ATTENDANCE_TABLE ?? 'attendance'
|
||
// 成員實力表(match-hub 維護的 tg_members.skill)。
|
||
const membersTableName = process.env.DB_MEMBERS_TABLE ?? 'tg_members'
|
||
const appVersion = process.env.APP_VERSION ?? `${Date.now()}`
|
||
const appStartedAt = new Date().toISOString()
|
||
const LIVE_ROOM_STALE_MS = 30_000
|
||
|
||
// 出席統計的名字正規化設定。
|
||
// 英文名只是大小寫不同(RURU / RuRu / Ruru)會自動視為同一人,
|
||
// 中文打錯字沒辦法自動判斷,統一在這張別名表對照。
|
||
const ATTENDANCE_ALIASES = {
|
||
阿翔: '昱翔',
|
||
卡森: '景涵',
|
||
景函: '景涵',
|
||
振華: '振驊',
|
||
}
|
||
// 分組名單裡的佔位字,不是真的有人到場,不列入統計。
|
||
const ATTENDANCE_IGNORED_NAMES = new Set(['輪空', '那個', '隨機'])
|
||
// 「最近 N 場」用來看誰還在打球。
|
||
const ATTENDANCE_RECENT_DAYS = 12
|
||
|
||
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'))
|
||
|
||
// SSL 憑證由 nginx 處理,這裡只讀取到期日回報給前端;
|
||
// 前端存下來後,遇到 Failed to fetch 就能判斷是不是憑證過期造成的。
|
||
const sslCertDir = process.env.SSL_CERT_DIR ?? ''
|
||
const sslCertFileName = process.env.SSL_CERT_FILE_NAME ?? 'cert.pem'
|
||
const sslCertPath = sslCertDir ? path.join(sslCertDir, sslCertFileName) : ''
|
||
const CERT_INFO_TTL_MS = 60_000
|
||
let certInfoCache = { checkedAt: 0, notAfter: null }
|
||
|
||
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
|
||
|
||
const rooms = loadPersistedRooms()
|
||
const roomListClients = new Set()
|
||
|
||
app.use(express.json())
|
||
|
||
app.get('/api/health', (_request, response) => {
|
||
response.json({
|
||
appStartedAt,
|
||
appVersion,
|
||
certNotAfter: getCertNotAfter(),
|
||
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({
|
||
certNotAfter: getCertNotAfter(),
|
||
ok: true,
|
||
startedAt: appStartedAt,
|
||
version: appVersion,
|
||
})
|
||
})
|
||
|
||
app.get('/api/rooms', (_request, response) => {
|
||
response.json({
|
||
ok: true,
|
||
data: getLiveRoomSummaries(),
|
||
})
|
||
})
|
||
|
||
app.post('/api/rooms/reconcile', (_request, response) => {
|
||
const removedRoomIds = pruneStaleRooms()
|
||
|
||
response.json({
|
||
ok: true,
|
||
data: {
|
||
removedRoomIds,
|
||
},
|
||
})
|
||
})
|
||
|
||
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,
|
||
hostSeenAt: now,
|
||
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.post('/api/rooms/:roomId/heartbeat', (request, response) => {
|
||
const room = rooms.get(request.params.roomId)
|
||
|
||
if (!room) {
|
||
response.status(404).json({
|
||
ok: false,
|
||
message: '找不到這個房間。',
|
||
})
|
||
return
|
||
}
|
||
|
||
const { hostToken } = request.body ?? {}
|
||
|
||
if (typeof hostToken !== 'string' || hostToken !== room.hostToken) {
|
||
response.status(403).json({
|
||
ok: false,
|
||
message: '沒有權限更新房間心跳。',
|
||
})
|
||
return
|
||
}
|
||
|
||
room.hostSeenAt = new Date().toISOString()
|
||
persistRooms()
|
||
|
||
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({
|
||
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 : '讀取對戰資料失敗。',
|
||
})
|
||
}
|
||
})
|
||
|
||
// 全體成員實力表(1~10,預設 1),給首頁手動排賽程時做實力配對用。
|
||
app.get('/api/skills', async (_request, response) => {
|
||
if (!pool) {
|
||
response.status(500).json({
|
||
ok: false,
|
||
message: `DB 尚未設定完成,缺少 ${missingEnv.join(', ')}`,
|
||
})
|
||
return
|
||
}
|
||
|
||
try {
|
||
const [rows] = await pool.execute(
|
||
`SELECT nickname, skill FROM \`${membersTableName}\` WHERE nickname IS NOT NULL`,
|
||
)
|
||
const skills = {}
|
||
|
||
rows.forEach((row) => {
|
||
const name = String(row.nickname ?? '').trim()
|
||
|
||
if (name) {
|
||
skills[name] = clampSkill(row.skill)
|
||
}
|
||
})
|
||
|
||
response.json({
|
||
ok: true,
|
||
data: { skills },
|
||
})
|
||
} catch (error) {
|
||
console.error('skills load error:', error)
|
||
response.status(500).json({
|
||
ok: false,
|
||
message: error instanceof Error ? error.message : '實力資料讀取失敗。',
|
||
})
|
||
}
|
||
})
|
||
|
||
app.get('/api/attendance', async (_request, response) => {
|
||
if (!pool) {
|
||
response.status(500).json({
|
||
ok: false,
|
||
message: `DB 尚未設定完成,缺少 ${missingEnv.join(', ')}`,
|
||
})
|
||
return
|
||
}
|
||
|
||
try {
|
||
// 出席統計以 attendance 表(TG 報名名單)為主:比每天的分組名單完整,
|
||
// 當天名單還沒存進 badminton 表之前就有資料,名字寫法也已經統一。
|
||
// 沒有這張表的環境(例如舊的 DB)退回用 badminton 表的分組名單。
|
||
const days = await loadAttendanceDays(pool)
|
||
|
||
response.json({
|
||
ok: true,
|
||
data: buildAttendanceStats(days),
|
||
})
|
||
} catch (error) {
|
||
console.error('attendance 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),
|
||
],
|
||
)
|
||
|
||
// 上場的人若忘了報名(不在當天 attendance 名單),順便幫他補報名。
|
||
// 補報名失敗不影響戰績上傳,只記 log。
|
||
let addedSignups = []
|
||
try {
|
||
addedSignups = await ensureAttendanceSignups(pool, players, time)
|
||
} catch (signupError) {
|
||
console.error('attendance signup error:', signupError)
|
||
}
|
||
|
||
response.json({
|
||
ok: true,
|
||
data: {
|
||
id: result.insertId,
|
||
addedSignups,
|
||
},
|
||
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
|
||
}
|
||
|
||
// 分頁參數已驗證為整數並限制範圍,直接內插避免 mysql2 execute 對 LIMIT 佔位符的相容問題。
|
||
const page = Math.max(1, Number.parseInt(request.query.page, 10) || 1)
|
||
const pageSize = Math.min(100, Math.max(1, Number.parseInt(request.query.pageSize, 10) || 20))
|
||
const offset = (page - 1) * pageSize
|
||
|
||
try {
|
||
await ensureHistoryTable(pool, historyTableName)
|
||
const [[countRow]] = await pool.execute(
|
||
`SELECT COUNT(*) AS total FROM \`${historyTableName}\``,
|
||
)
|
||
const total = Number(countRow?.total ?? 0)
|
||
const [rows] = await pool.execute(
|
||
`
|
||
SELECT id, time, dayOfWeek, score, winScore, type, players, team, scoreList
|
||
FROM \`${historyTableName}\`
|
||
ORDER BY id DESC
|
||
LIMIT ${pageSize} OFFSET ${offset}
|
||
`,
|
||
)
|
||
|
||
response.json({
|
||
ok: true,
|
||
data: rows,
|
||
page,
|
||
pageSize,
|
||
total,
|
||
totalPages: Math.max(1, Math.ceil(total / pageSize)),
|
||
})
|
||
} catch (error) {
|
||
console.error('history load error:', error)
|
||
response.status(500).json({
|
||
ok: false,
|
||
message: error instanceof Error ? error.message : '讀取歷史戰績失敗。',
|
||
})
|
||
}
|
||
})
|
||
|
||
// 指定時間區間內(通常是「今天」)每個人在 history 表打了幾場,
|
||
// 讓不同裝置記分後可以把本機的今日場次同步成 DB 的數字。
|
||
app.get('/api/history/play-counts', async (request, response) => {
|
||
if (!pool) {
|
||
response.status(500).json({
|
||
ok: false,
|
||
message: `DB 尚未設定完成,缺少 ${missingEnv.join(', ')}`,
|
||
})
|
||
return
|
||
}
|
||
|
||
const from = Number.parseInt(request.query.from, 10)
|
||
const to = Number.parseInt(request.query.to, 10)
|
||
|
||
if (!Number.isFinite(from) || !Number.isFinite(to) || from >= to) {
|
||
response.status(400).json({
|
||
ok: false,
|
||
message: '時間區間格式不正確。',
|
||
})
|
||
return
|
||
}
|
||
|
||
try {
|
||
await ensureHistoryTable(pool, historyTableName)
|
||
const [rows] = await pool.execute(
|
||
`SELECT players FROM \`${historyTableName}\` WHERE time >= ? AND time < ?`,
|
||
[from, to],
|
||
)
|
||
const counts = {}
|
||
|
||
rows.forEach((row) => {
|
||
parseAttendanceNames(row.players).forEach((name) => {
|
||
// 輪空等佔位字不是真的有人上場。
|
||
if (ATTENDANCE_IGNORED_NAMES.has(name)) {
|
||
return
|
||
}
|
||
|
||
counts[name] = (counts[name] ?? 0) + 1
|
||
})
|
||
})
|
||
|
||
response.json({
|
||
ok: true,
|
||
data: {
|
||
counts,
|
||
matches: rows.length,
|
||
},
|
||
})
|
||
} catch (error) {
|
||
console.error('history play-counts 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(', ')}`)
|
||
}
|
||
})
|
||
|
||
// 憑證檔可能隨時被換新,帶 60 秒快取避免每次請求都讀檔。
|
||
function getCertNotAfter() {
|
||
if (!sslCertPath) {
|
||
return null
|
||
}
|
||
|
||
const now = Date.now()
|
||
|
||
if (now - certInfoCache.checkedAt < CERT_INFO_TTL_MS) {
|
||
return certInfoCache.notAfter
|
||
}
|
||
|
||
let notAfter = null
|
||
|
||
try {
|
||
const certificate = new X509Certificate(readFileSync(sslCertPath))
|
||
const notAfterTime = Date.parse(certificate.validTo)
|
||
|
||
if (Number.isFinite(notAfterTime)) {
|
||
notAfter = new Date(notAfterTime).toISOString()
|
||
}
|
||
} catch (error) {
|
||
console.error('read ssl cert error:', error instanceof Error ? error.message : error)
|
||
}
|
||
|
||
certInfoCache = { checkedAt: now, notAfter }
|
||
return notAfter
|
||
}
|
||
|
||
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,
|
||
hostSeenAt:
|
||
typeof savedRoom.hostSeenAt === 'string' ? savedRoom.hostSeenAt : savedRoom.updatedAt,
|
||
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,
|
||
hostSeenAt: room.hostSeenAt,
|
||
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 pruneStaleRooms() {
|
||
const now = Date.now()
|
||
const removedRoomIds = []
|
||
|
||
rooms.forEach((room, roomId) => {
|
||
if (room.status !== 'live') {
|
||
return
|
||
}
|
||
|
||
const hostSeenAtTime = Date.parse(room.hostSeenAt ?? '')
|
||
|
||
if (!Number.isFinite(hostSeenAtTime) || now - hostSeenAtTime > LIVE_ROOM_STALE_MS) {
|
||
room.clients.forEach((client) => {
|
||
sendSse(client, 'room-closed', {
|
||
roomId: room.roomId,
|
||
status: 'stale',
|
||
})
|
||
client.end()
|
||
})
|
||
|
||
rooms.delete(roomId)
|
||
removedRoomIds.push(roomId)
|
||
}
|
||
})
|
||
|
||
if (removedRoomIds.length > 0) {
|
||
persistRooms()
|
||
broadcastRoomList()
|
||
}
|
||
|
||
return removedRoomIds
|
||
}
|
||
|
||
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)
|
||
})
|
||
}
|
||
|
||
// 把秒級 timestamp 轉成台北時區的 poll_date(YYYYMMDD 整數)。台北沒有日光節約,直接 +8 小時。
|
||
function getTaipeiPollDate(timeSeconds) {
|
||
const shifted = new Date((timeSeconds + 8 * 3600) * 1000)
|
||
const year = shifted.getUTCFullYear()
|
||
const month = String(shifted.getUTCMonth() + 1).padStart(2, '0')
|
||
const day = String(shifted.getUTCDate()).padStart(2, '0')
|
||
return Number(`${year}${month}${day}`)
|
||
}
|
||
|
||
// 沒綁 TG 的人用名字產生固定的負數 user_id(md5 前 6 bytes 取負),
|
||
// 規則與 attendance 表的歷史匯入資料一致,同一個名字永遠拿到同一個 id。
|
||
function syntheticUserId(name) {
|
||
const hash = createHash('md5').update(name).digest()
|
||
return -hash.readUIntBE(0, 6)
|
||
}
|
||
|
||
// 結算上傳時的補報名:上場的人若不在當天 attendance 名單就自動加入,
|
||
// 名字比對沿用出席統計的別名與大小寫規則;有 TG 綁定就用 tg_members 的資料。
|
||
// 回傳實際補進去的名字。
|
||
async function ensureAttendanceSignups(pool, players, timeSeconds) {
|
||
const pollDate = getTaipeiPollDate(timeSeconds)
|
||
const names = []
|
||
const seenKeys = new Set()
|
||
|
||
players.forEach((rawName) => {
|
||
const name = String(rawName ?? '').trim()
|
||
const key = getAttendanceKey(name)
|
||
|
||
if (!name || seenKeys.has(key) || ATTENDANCE_IGNORED_NAMES.has(getAttendanceName(name))) {
|
||
return
|
||
}
|
||
|
||
seenKeys.add(key)
|
||
names.push(name)
|
||
})
|
||
|
||
if (names.length === 0) {
|
||
return []
|
||
}
|
||
|
||
const [signedRows] = await pool.execute(
|
||
`SELECT nickname FROM \`${attendanceTableName}\` WHERE poll_date = ?`,
|
||
[pollDate],
|
||
)
|
||
const signedKeys = new Set(signedRows.map((row) => getAttendanceKey(row.nickname)))
|
||
const missing = names.filter((name) => !signedKeys.has(getAttendanceKey(name)))
|
||
|
||
if (missing.length === 0) {
|
||
return []
|
||
}
|
||
|
||
const [memberRows] = await pool.execute(
|
||
`SELECT user_id, nickname, tg_name, username FROM \`${membersTableName}\` WHERE nickname IS NOT NULL`,
|
||
)
|
||
const membersByKey = new Map(
|
||
memberRows.map((member) => [getAttendanceKey(member.nickname), member]),
|
||
)
|
||
|
||
const added = []
|
||
|
||
for (const name of missing) {
|
||
const member = membersByKey.get(getAttendanceKey(name))
|
||
const nickname = member?.nickname ?? getAttendanceName(name)
|
||
|
||
await pool.execute(
|
||
`INSERT INTO \`${attendanceTableName}\` (poll_date, user_id, nickname, tg_name, username)
|
||
VALUES (?, ?, ?, ?, ?)
|
||
ON DUPLICATE KEY UPDATE nickname = VALUES(nickname)`,
|
||
[pollDate, member?.user_id ?? syntheticUserId(nickname), nickname, member?.tg_name ?? null, member?.username ?? null],
|
||
)
|
||
added.push(nickname)
|
||
}
|
||
|
||
return added
|
||
}
|
||
|
||
// 實力限制在 1~10 的整數,缺值或壞值一律當 1。
|
||
function clampSkill(value) {
|
||
const skill = Number(value)
|
||
|
||
if (!Number.isFinite(skill)) {
|
||
return 1
|
||
}
|
||
|
||
return Math.min(10, Math.max(1, Math.round(skill)))
|
||
}
|
||
|
||
function getAttendanceName(rawName) {
|
||
const name = String(rawName ?? '').trim()
|
||
return ATTENDANCE_ALIASES[name] ?? name
|
||
}
|
||
|
||
// 大小寫不同視為同一人,所以統計用的 key 一律轉小寫。
|
||
function getAttendanceKey(rawName) {
|
||
return getAttendanceName(rawName).toLowerCase()
|
||
}
|
||
|
||
// 這裡保留名單上的原始寫法,合併留到統計時再做,才能回報是哪幾種寫法被併在一起。
|
||
function parseAttendanceNames(personnel) {
|
||
try {
|
||
const parsed = JSON.parse(personnel ?? '[]')
|
||
|
||
if (!Array.isArray(parsed)) {
|
||
return []
|
||
}
|
||
|
||
return parsed
|
||
.map((entry) => String((Array.isArray(entry) ? entry[1] : entry) ?? '').trim())
|
||
.filter(Boolean)
|
||
} catch {
|
||
return []
|
||
}
|
||
}
|
||
|
||
// 讀出每天的出席名單,回傳 [{ time, names }],時間由舊到新。
|
||
async function loadAttendanceDays(pool) {
|
||
try {
|
||
const [rows] = await pool.execute(
|
||
`SELECT poll_date, nickname FROM \`${attendanceTableName}\` ORDER BY poll_date ASC, joined_at ASC`,
|
||
)
|
||
const daysByTime = new Map()
|
||
|
||
rows.forEach((row) => {
|
||
const time = Number(row.poll_date)
|
||
const name = String(row.nickname ?? '').trim()
|
||
|
||
if (!Number.isFinite(time) || !name) {
|
||
return
|
||
}
|
||
|
||
const names = daysByTime.get(time) ?? []
|
||
names.push(name)
|
||
daysByTime.set(time, names)
|
||
})
|
||
|
||
return Array.from(daysByTime.entries()).map(([time, names]) => ({ time, names }))
|
||
} catch (error) {
|
||
if (error?.code !== 'ER_NO_SUCH_TABLE') {
|
||
throw error
|
||
}
|
||
|
||
console.warn(`attendance table \`${attendanceTableName}\` 不存在,改用 \`${matchTableName}\` 的分組名單統計。`)
|
||
}
|
||
|
||
await ensureMatchTable(pool, matchTableName)
|
||
const [rows] = await pool.execute(
|
||
`SELECT time, personnel FROM \`${matchTableName}\` ORDER BY time ASC`,
|
||
)
|
||
|
||
return rows
|
||
.map((row) => ({
|
||
time: Number(row.time),
|
||
names: parseAttendanceNames(row.personnel),
|
||
}))
|
||
.filter((day) => Number.isFinite(day.time) && day.names.length > 0)
|
||
}
|
||
|
||
function buildAttendanceStats(days) {
|
||
const totalDays = days.length
|
||
|
||
if (totalDays === 0) {
|
||
return {
|
||
averagePerDay: 0,
|
||
dailyCounts: [],
|
||
firstDate: null,
|
||
lastDate: null,
|
||
players: [],
|
||
recentDays: 0,
|
||
totalDays: 0,
|
||
yearTotals: [],
|
||
}
|
||
}
|
||
|
||
const recentDays = Math.min(ATTENDANCE_RECENT_DAYS, totalDays)
|
||
const recentStartIndex = totalDays - recentDays
|
||
const yearTotals = new Map()
|
||
const players = new Map()
|
||
const dailyCounts = []
|
||
let attendanceSum = 0
|
||
|
||
days.forEach((day, dayIndex) => {
|
||
const year = String(day.time).slice(0, 4)
|
||
const yearTotal = yearTotals.get(year) ?? { days: 0, people: 0 }
|
||
yearTotal.days += 1
|
||
yearTotals.set(year, yearTotal)
|
||
|
||
// 同一天同一個人只算一次。
|
||
const seenKeys = new Set()
|
||
// 佔位字(輪空等)不是真的有人,不列入每天人數。
|
||
let dayCount = 0
|
||
|
||
day.names.forEach((rawName) => {
|
||
const key = getAttendanceKey(rawName)
|
||
const name = getAttendanceName(rawName)
|
||
|
||
if (seenKeys.has(key)) {
|
||
return
|
||
}
|
||
|
||
seenKeys.add(key)
|
||
|
||
if (ATTENDANCE_IGNORED_NAMES.has(name)) {
|
||
return
|
||
}
|
||
|
||
dayCount += 1
|
||
|
||
const player = players.get(key) ?? {
|
||
count: 0,
|
||
firstDate: day.time,
|
||
firstIndex: dayIndex,
|
||
forms: new Set(),
|
||
lastDate: day.time,
|
||
name,
|
||
recentCount: 0,
|
||
years: new Map(),
|
||
}
|
||
|
||
// 顯示名採用最近一次出現的寫法,貼近現在慣用的名字;別名表對照過的錯字則用正規名。
|
||
player.name = name
|
||
player.count += 1
|
||
player.lastDate = day.time
|
||
player.forms.add(rawName)
|
||
player.years.set(year, (player.years.get(year) ?? 0) + 1)
|
||
|
||
if (dayIndex >= recentStartIndex) {
|
||
player.recentCount += 1
|
||
}
|
||
|
||
players.set(key, player)
|
||
})
|
||
|
||
yearTotal.people += dayCount
|
||
dailyCounts.push({ date: day.time, count: dayCount })
|
||
attendanceSum += dayCount
|
||
})
|
||
|
||
const playerList = Array.from(players.values())
|
||
.filter((player) => !ATTENDANCE_IGNORED_NAMES.has(player.name))
|
||
.map((player) => {
|
||
// 加入後場次:從這個人第一次出席那天算到最新一場,新加入的人才不會被稀釋。
|
||
const sinceDays = totalDays - player.firstIndex
|
||
|
||
return {
|
||
count: player.count,
|
||
firstDate: player.firstDate,
|
||
lastDate: player.lastDate,
|
||
// 同一人有多種寫法時列出來,方便對照是不是打錯字。
|
||
mergedForms: player.forms.size > 1 ? Array.from(player.forms) : [],
|
||
name: player.name,
|
||
rate: player.count / totalDays,
|
||
recentCount: player.recentCount,
|
||
sinceDays,
|
||
sinceRate: player.count / sinceDays,
|
||
years: Array.from(player.years.entries()).map(([year, count]) => ({ year, count })),
|
||
}
|
||
})
|
||
.sort((left, right) => right.count - left.count || left.firstDate - right.firstDate)
|
||
|
||
return {
|
||
averagePerDay: attendanceSum / totalDays,
|
||
dailyCounts,
|
||
firstDate: days[0].time,
|
||
lastDate: days[totalDays - 1].time,
|
||
players: playerList,
|
||
recentDays,
|
||
totalDays,
|
||
yearTotals: Array.from(yearTotals.entries())
|
||
.map(([year, total]) => ({
|
||
year,
|
||
days: total.days,
|
||
averagePerDay: total.days > 0 ? total.people / total.days : 0,
|
||
}))
|
||
.sort((left, right) => left.year.localeCompare(right.year)),
|
||
}
|
||
}
|
||
|
||
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
|
||
`)
|
||
}
|