根本原因: V2 原本把推送目標寫死在測試對話(target: local),部署到 NAS 後也只會 私訊測試對象,無法推到正式羽球群;另外卡片底部的「搭檔不重複・實力平衡 排點」說明文字對群組成員沒有意義。 影響: - NAS 正式環境(LINE_TARGET_MODE=prod)推送到勝皇羽球團群組 - 本機開發(local)維持推到測試對話,按鈕會標示「推送到 LINE(測試)」 - LINE 卡片不再顯示底部說明文字 修法: 前端移除寫死的 target 參數,交由 server 依 LINE_TARGET_MODE 解析目標; 按鈕文字與推送成功訊息改為依環境顯示;buildLineFlexMessageV2 移除 footer。 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
605 lines
16 KiB
JavaScript
605 lines
16 KiB
JavaScript
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,
|
|
},
|
|
],
|
|
})),
|
|
},
|
|
})),
|
|
},
|
|
}
|
|
}
|
|
|
|
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,
|
|
},
|
|
},
|
|
})),
|
|
},
|
|
}
|
|
}
|