Files
badminton-match-hub/server/server.mjs

352 lines
9.1 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 ?? 8787)
const tableName = process.env.DB_TABLE ?? 'badminton'
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 lineAccessToken =
process.env.LINE_CHANNEL_ACCESS_TOKEN ?? process.env.channelAccessToken ?? ''
const lineTargetMode = (process.env.LINE_TARGET_MODE ?? 'local').toLowerCase()
const lineTargetId =
process.env.LINE_TARGET_ID ??
(lineTargetMode === 'prod'
? process.env.LINE_TARGET_ID_PROD ?? ''
: process.env.LINE_TARGET_ID_LOCAL ?? '')
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({
ok: true,
dbReady: Boolean(pool),
distReady,
lineReady: Boolean(lineAccessToken && lineTargetId),
lineTargetMode,
missingEnv,
})
})
app.post('/api/line/push-match-results', async (request, response) => {
const { time, teams } = request.body ?? {}
if (typeof time !== 'string' || !Array.isArray(teams)) {
response.status(400).json({
ok: false,
message: '送出的 LINE 訊息資料格式不正確。',
})
return
}
if (!lineAccessToken || !lineTargetId) {
response.status(500).json({
ok: false,
message:
'LINE 推播環境變數缺少,請檢查 LINE_CHANNEL_ACCESS_TOKEN 與 LINE_TARGET_ID_LOCAL / LINE_TARGET_ID_PROD。',
})
return
}
try {
const message = buildLineFlexMessage(time, teams)
const lineResponse = await fetch('https://api.line.me/v2/bot/message/push', {
method: 'POST',
headers: {
Authorization: `Bearer ${lineAccessToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
to: lineTargetId,
messages: [message],
}),
})
if (!lineResponse.ok) {
const errorText = await lineResponse.text()
throw new Error(`LINE 推播失敗:${errorText}`)
}
response.json({
ok: true,
message: '已推送到 LINE。',
})
} catch (error) {
console.error('line push error:', error)
response.status(500).json({
ok: false,
message: error instanceof Error ? error.message : 'LINE 推播失敗。',
})
}
})
2026-04-15 10:11:53 +08:00
app.get('/api/match-results/:time', async (request, response) => {
if (!pool) {
response.status(500).json({
ok: false,
message: `資料庫環境變數缺少:${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 ensureTable(pool, tableName)
const [rows] = await pool.execute(
`SELECT time, personnel, battlecombination FROM \`${tableName}\` 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/match-results', async (request, response) => {
if (!pool) {
response.status(500).json({
ok: false,
message: `資料庫環境變數缺少:${missingEnv.join(', ')}`,
})
return
}
const { time, areaA, areaB, teams } = request.body ?? {}
if (
typeof time !== 'string' ||
!Array.isArray(areaA) ||
!Array.isArray(areaB) ||
!Array.isArray(teams)
) {
response.status(400).json({
ok: false,
message: '送出的資料格式不正確。',
})
return
}
try {
await ensureTable(pool, tableName)
const personnel = JSON.stringify([
...areaA.map((name) => [1, name]),
...areaB.map((name) => [0, name]),
])
const battlecombination = JSON.stringify(
Object.fromEntries(
teams.map((round, index) => [
String(index),
round.teams.map((team) => [team.a, team.b]),
]),
),
)
await pool.execute(
`
INSERT INTO \`${tableName}\` (time, personnel, battlecombination)
VALUES (?, ?, ?)
ON DUPLICATE KEY UPDATE
personnel = VALUES(personnel),
battlecombination = VALUES(battlecombination)
`,
[Number(time), personnel, battlecombination],
)
response.json({
ok: true,
message: '已寫入資料庫。',
})
} catch (error) {
console.error('match-results save 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'))
})
} else {
app.get('/', (_request, response) => {
response
.status(503)
.send('前端尚未建置,請先執行 npm run build 或使用 Docker 映像部署。')
})
}
app.listen(port, () => {
console.log(`Server ready on http://localhost:${port}`)
console.log(`Static files: ${distReady ? 'loaded' : 'missing'}`)
console.log(`LINE target mode: ${lineTargetMode}`)
if (missingEnv.length > 0) {
console.log(`Missing env: ${missingEnv.join(', ')}`)
}
})
async function ensureTable(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
`)
}
function buildLineFlexMessage(time, rounds) {
const dateText = `${time.slice(0, 4)}-${time.slice(4, 6)}-${time.slice(6, 8)}`
return {
type: 'flex',
altText: `${dateText} 羽球隊伍配對`,
contents: {
type: 'carousel',
contents: rounds.map((round, roundIndex) => ({
type: 'bubble',
size: 'micro',
body: {
type: 'box',
layout: 'vertical',
contents: [
{
type: 'text',
text: '勝皇羽球團',
weight: 'bold',
color: '#1DB446',
size: 'sm',
},
{
type: 'text',
text: dateText,
weight: 'bold',
size: 'xl',
margin: 'sm',
},
{
type: 'text',
text: `${roundIndex + 1}`,
weight: 'bold',
size: 'xxl',
margin: 'sm',
},
{
type: 'separator',
margin: 'md',
},
{
type: 'box',
layout: 'horizontal',
contents: [
{
type: 'text',
text: '一號隊友',
size: 'sm',
flex: 0,
},
{
type: 'text',
text: '二號隊友',
size: 'sm',
align: 'center',
},
],
margin: 'md',
},
{
type: 'separator',
margin: 'sm',
},
{
type: 'box',
layout: 'vertical',
spacing: 'sm',
margin: 'md',
contents: round.teams.map((team) => ({
type: 'box',
layout: 'horizontal',
contents: [
{
type: 'text',
text: team.a,
size: 'sm',
flex: 0,
},
{
type: 'text',
text: team.b,
size: 'sm',
align: 'center',
},
],
})),
},
],
},
styles: {
footer: {
separator: true,
},
},
})),
},
}
}