功能:結算上傳戰績時自動幫忘記報名的上場球員補報名

摘要:
- 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>
This commit is contained in:
2026-08-20 13:51:55 +08:00
co-authored by Claude Fable 5
parent 6f38c155f0
commit cb01c14421
4 changed files with 114 additions and 1 deletions
+87 -1
View File
@@ -1,7 +1,7 @@
import 'dotenv/config'
import express from 'express'
import mysql from 'mysql2/promise'
import { X509Certificate } from 'node:crypto'
import { createHash, X509Certificate } from 'node:crypto'
import path from 'node:path'
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
@@ -505,10 +505,20 @@ app.post('/api/history', async (request, response) => {
],
)
// 上場的人若忘了報名(不在當天 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。',
})
@@ -934,6 +944,82 @@ function broadcastRoomList() {
})
}
// 把秒級 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)