功能:SSL 憑證過期偵測與提醒、歷史戰績改為分頁載入

根本原因:
1. 憑證過期時瀏覽器只回 Failed to fetch,前端無法辨識原因,使用者誤以為是 DB 連不上(本次 7/1 憑證過期即是如此)。
2. 歷史戰績一次撈出全部資料(400+ 筆、逾 100KB),筆數持續成長會越來越慢。

影響:所有 API 的連線錯誤訊息、歷史戰績頁載入速度、Docker 憑證掛載路徑與檔名。

修法:
- 後端讀取 cert.pem 到期日(60 秒快取),由 /api/version 與 /api/health 回傳 certNotAfter
- 前端把到期日存進 localStorage;fetch 失敗時依離線、憑證已過期、其他連線問題分別顯示明確訊息
- 憑證到期前 7 天於頁面跳出「SSL 憑證提醒」通知,可手動關閉
- /api/history 支援 page/pageSize(預設 20 筆、上限 100),回傳 total 與 totalPages
- 歷史戰績頁加入分頁控制列(上一頁/頁碼含省略號/下一頁/總筆數),刪除後自動重新載入並修正超出範圍的頁碼
- 憑證掛載目錄改為 /volume1/docker/certs,檔名改為 cert.pem / chain.pem / privkey.pem,app 容器同步掛載以讀取到期日

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-13 15:44:44 +08:00
co-authored by Claude Fable 5
parent 13f3bf5198
commit 1bc42f5bf6
9 changed files with 356 additions and 31 deletions
+55 -1
View File
@@ -1,6 +1,7 @@
import 'dotenv/config'
import express from 'express'
import mysql from 'mysql2/promise'
import { X509Certificate } from 'node:crypto'
import path from 'node:path'
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
@@ -21,6 +22,14 @@ 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])
@@ -47,6 +56,7 @@ app.get('/api/health', (_request, response) => {
response.json({
appStartedAt,
appVersion,
certNotAfter: getCertNotAfter(),
ok: true,
dbReady: Boolean(pool),
distReady,
@@ -65,6 +75,7 @@ app.get('/api/version', (_request, response) => {
})
response.json({
certNotAfter: getCertNotAfter(),
ok: true,
startedAt: appStartedAt,
version: appVersion,
@@ -427,7 +438,7 @@ app.post('/api/history', async (request, response) => {
}
})
app.get('/api/history', async (_request, response) => {
app.get('/api/history', async (request, response) => {
if (!pool) {
response.status(500).json({
ok: false,
@@ -436,19 +447,33 @@ app.get('/api/history', async (_request, response) => {
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)
@@ -521,6 +546,35 @@ app.listen(port, () => {
}
})
// 憑證檔可能隨時被換新,帶 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)}`
}