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 attendanceTableName = 'attendance' const membersTableName = 'tg_members' 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, courts, target } = request.body ?? {} const isV2Payload = Array.isArray(courts) if (typeof time !== 'string' || (!Array.isArray(teams) && !isV2Payload)) { response.status(400).json({ ok: false, message: '送出的 LINE 訊息資料格式不正確。', }) return } const resolvedTargetId = target === 'local' ? process.env.LINE_TARGET_ID_LOCAL ?? '' : lineTargetId if (!lineAccessToken || !resolvedTargetId) { response.status(500).json({ ok: false, message: 'LINE 推播環境變數缺少,請檢查 LINE_CHANNEL_ACCESS_TOKEN 與 LINE_TARGET_ID_LOCAL / LINE_TARGET_ID_PROD。', }) return } try { const message = isV2Payload ? buildLineFlexMessageV2(time, courts) : 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: resolvedTargetId, 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 推播失敗。', }) } }) 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.get('/api/attendance/: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 { const [rows] = await pool.execute( ` SELECT a.nickname AS nickname, COALESCE( m.skill, (SELECT MAX(m2.skill) FROM \`${membersTableName}\` m2 WHERE m2.nickname = a.nickname), 1 ) AS skill FROM \`${attendanceTableName}\` a LEFT JOIN \`${membersTableName}\` m ON m.user_id = a.user_id WHERE a.poll_date = ? ORDER BY a.joined_at ASC, a.user_id ASC `, [Number(time)], ) const members = rows .map((row) => ({ name: String(row.nickname ?? '').trim(), skill: clampSkill(row.skill), })) .filter((member) => member.name) if (members.length === 0) { response.status(404).json({ ok: false, message: '指定日期沒有出席資料。', }) return } response.json({ ok: true, data: { time: Number(time), names: members.map((member) => member.name), members, }, }) } catch (error) { console.error('attendance load error:', error) response.status(500).json({ ok: false, message: error instanceof Error ? error.message : '出席資料讀取失敗。', }) } }) app.get('/api/skills', async (_request, response) => { if (!pool) { response.status(500).json({ ok: false, message: `資料庫環境變數缺少:${missingEnv.join(', ')}`, }) return } try { const [rows] = await pool.execute( `SELECT nickname, skill FROM \`${membersTableName}\` WHERE nickname IS NOT NULL`, ) const skills = {} for (const row of rows) { 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.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(', ')}`) } }) function clampSkill(value) { const skill = Number(value) if (!Number.isFinite(skill)) { return 1 } return Math.min(10, Math.max(1, Math.round(skill))) } let tableReady = false async function ensureTable(poolInstance, currentTableName) { if (tableReady) { return } 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 `) await widenLegacyColumns(poolInstance, currentTableName) tableReady = true } async function widenLegacyColumns(poolInstance, currentTableName) { const [columns] = await poolInstance.execute( ` SELECT COLUMN_NAME AS name, DATA_TYPE AS type FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME IN ('personnel', 'battlecombination') `, [currentTableName], ) for (const column of columns) { if (['text', 'mediumtext', 'longtext'].includes(String(column.type).toLowerCase())) { continue } const nullability = column.name === 'personnel' ? 'NOT NULL' : 'DEFAULT NULL' await poolInstance.execute( `ALTER TABLE \`${currentTableName}\` MODIFY \`${column.name}\` TEXT ${nullability}`, ) console.log(`widened column ${column.name} to TEXT on \`${currentTableName}\``) } } function buildLineFlexMessageV2(time, courts) { const dateText = `${time.slice(0, 4)}-${time.slice(4, 6)}-${time.slice(6, 8)}` return { type: 'flex', altText: `${dateText} 羽球場地賽程`, contents: { type: 'carousel', contents: courts.map((court) => ({ type: 'bubble', size: 'mega', header: { type: 'box', layout: 'vertical', backgroundColor: '#1D7B4D', paddingAll: '16px', contents: [ { type: 'text', text: '勝皇羽球團', color: '#CDEBDA', size: 'xs', weight: 'bold', }, { type: 'text', text: `${court.court} 號場地`, color: '#FFFFFF', size: 'xxl', weight: 'bold', margin: 'xs', }, { type: 'text', text: dateText, color: '#CDEBDA', size: 'sm', margin: 'xs', }, ], }, body: { type: 'box', layout: 'vertical', spacing: 'xs', paddingAll: '12px', contents: court.games.map((game, gameIndex) => ({ type: 'box', layout: 'horizontal', alignItems: 'center', backgroundColor: gameIndex % 2 === 0 ? '#F2F9F4' : '#FFFFFF', cornerRadius: '8px', paddingAll: '8px', contents: [ { type: 'text', text: String(game.game).padStart(2, '0'), color: '#1DB446', weight: 'bold', size: 'sm', flex: 1, }, { type: 'text', text: game.a.join('、'), size: 'sm', weight: 'bold', color: '#8B5A0D', align: 'end', flex: 5, wrap: true, }, { type: 'text', text: 'vs', size: 'xs', color: '#AAAAAA', align: 'center', flex: 1, }, { type: 'text', text: game.b.join('、'), size: 'sm', weight: 'bold', color: '#1D6C46', flex: 5, wrap: true, }, ], })), }, footer: { type: 'box', layout: 'vertical', paddingAll: '10px', contents: [ { type: 'text', text: '搭檔不重複・實力平衡排點', size: 'xxs', color: '#9AA89E', align: 'center', }, ], }, })), }, } } 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, }, }, })), }, } }