Files
badminton-scoreboard/server/server.mjs
T
JianMiauandClaude Fable 5 cb01c14421 功能:結算上傳戰績時自動幫忘記報名的上場球員補報名
摘要:
- 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>
2026-08-20 13:51:55 +08:00

1251 lines
32 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.
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_dateYYYYMMDD 整數)。台北沒有日光節約,直接 +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
`)
}