2025-2026 學藝QR系統
1. 伺服器與 PHP 環境
必備條件:
- Web Server:例如 Apache 或 Nginx + PHP-FPM
- PHP 版本:
- 程式開頭
declare(strict_types=1);,加上用到Throwable、PDO 等,一般建議 PHP 7.4+ 或 8.x
- 程式開頭
- 已安裝 PDO MySQL extension,並啟用:
pdo_mysql模組要可用,否則new PDO(...)會失敗
- 此 PHP 檔案所在目錄要允許寫入
log.txt- 若目前沒有
log.txt,write_log()會直接建立,所以目錄要有寫入權限
- 若目前沒有
2. MySQL 資料庫與帳號
你必須:
建立
graduation資料庫(或你改成自己的名稱,記得上面$DB_NAME一起改):
-- 建立資料庫(若已存在可略過)
CREATE DATABASE IF NOT EXISTS graduation
CHARACTER SET utf8mb4
COLLATE utf8mb4_unicode_ci;
-- 建議:建立一個專用使用者,而不要用 root
CREATE USER IF NOT EXISTS 'grad_user'@'localhost'
IDENTIFIED BY '請改成你的密碼';
GRANT SELECT, INSERT, UPDATE
ON graduation.*
TO 'grad_user'@'localhost';
FLUSH PRIVILEGES;
CREATE TABLE IF NOT EXISTS ticket_checkins (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
ticket_id INT UNSIGNED NOT NULL,
ticket_code VARCHAR(255) NOT NULL,
checkin_no TINYINT UNSIGNED NOT NULL, -- 第幾次 1 / 2
checked_in_at DATETIME NOT NULL,
ip VARCHAR(45) NOT NULL,
user_agent VARCHAR(255) NOT NULL,
CONSTRAINT fk_ticket_checkins_ticket
FOREIGN KEY (ticket_id) REFERENCES tickets(id)
ON DELETE CASCADE,
INDEX idx_ticket (ticket_id),
INDEX idx_ticket_code (ticket_code)
) ENGINE=InnoDB
DEFAULT CHARSET=utf8mb4
COLLATE=utf8mb4_unicode_ci;
「單檔」的票務入場系統(掃 QR code 做入場點名),功能包括:
- 每張票最多可「Check-in 2 次」
- 使用 MySQL + PDO,對同一張票的更新用交易 +
SELECT ... FOR UPDATE確保「併發安全」 - 後端有防抖機制(1 秒內重複掃同一張票不會多算次數)
- 前端也有掃描節流 + 去重(Android 手機連續回呼的問題)
- 前端用 html5-qrcode 做攝像頭掃描,也支援手動輸入票碼
- 有成功 / 失敗聲音提示
- UI 顯示票資料與歷史 2 次入場記錄
false,
'message' => '',
'ticket_code' => '',
'name' => '',
'engname' => '',
'class' => '',
'class_number' => '',
'count' => 0,
'left' => 0,
'checked_in_at' => '',
'checkins' => [],
];
}
function client_ip(): string {
// For LAN direct access, REMOTE_ADDR is usually correct.
// If behind a trusted reverse proxy, you can extend this safely.
return $_SERVER['REMOTE_ADDR'] ?? '';
}
// -------------------- DB CONNECT --------------------
$dsn = "mysql:host={$DB_HOST};dbname={$DB_NAME};charset={$DB_CHARSET}";
$options = [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
];
try {
$pdo = new PDO($dsn, $DB_USER, $DB_PASS, $options);
} catch (PDOException $e) {
http_response_code(500);
echo 'Database connection error';
exit;
}
// -------------------- API --------------------
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['qr_data'])) {
if ($API_KEY !== '') {
$hdr = $_SERVER['HTTP_X_API_KEY'] ?? '';
if (!hash_equals($API_KEY, $hdr)) {
respond_json(['success' => false, 'message' => 'Unauthorized'], 401);
}
}
$scan_time = date('Y-m-d H:i:s');
$ua = $_SERVER['HTTP_USER_AGENT'] ?? 'Unknown';
$ip = client_ip();
$qr_data = trim((string)($_POST['qr_data'] ?? ''));
$p = base_payload();
$p['ticket_code'] = $qr_data;
if ($qr_data === '') {
write_log("[FAIL ] {$scan_time} | empty qr_data | IP: {$ip} | UA: {$ua}");
$p['message'] = 'Empty code.';
respond_json($p, 400);
}
// Allow any ticket_code format; only enforce a max length to protect server.
if (strlen($qr_data) > 255) {
write_log("[FAIL ] {$scan_time} | too long: " . substr($qr_data, 0, 80) . "... | IP: {$ip} | UA: {$ua}");
$p['message'] = 'Code too long.';
respond_json($p, 400);
}
try {
$pdo->beginTransaction();
// Lock row
$stmt = $pdo->prepare("
SELECT id, class, class_number, engname, name, ticket_code,
checkin_count, last_checked_in_at, last_scan_at
FROM tickets
WHERE ticket_code = ?
FOR UPDATE
");
$stmt->execute([$qr_data]);
$ticket = $stmt->fetch();
if (!$ticket) {
$pdo->rollBack();
write_log("[FAIL ] {$scan_time} | not found: {$qr_data} | IP: {$ip} | UA: {$ua}");
$p['message'] = 'No record found for this ticket code.';
respond_json($p, 404);
}
$ticket_id = (int)$ticket['id'];
$count = (int)$ticket['checkin_count'];
// Fill ticket info
$p['name'] = (string)$ticket['name'];
$p['engname'] = (string)$ticket['engname'];
$p['class'] = (string)$ticket['class'];
$p['class_number'] = (string)$ticket['class_number'];
$p['count'] = $count;
$p['left'] = max(0, $MAX_CHECKINS - $count);
$p['checked_in_at'] = $ticket['last_checked_in_at'] ? (string)$ticket['last_checked_in_at'] : '';
// Load existing history (show even when failed/limit)
$logsStmt = $pdo->prepare("
SELECT checkin_no, checked_in_at
FROM ticket_checkins
WHERE ticket_id = ?
ORDER BY checkin_no ASC
");
$logsStmt->execute([$ticket_id]);
$logs = $logsStmt->fetchAll();
$p['checkins'] = array_map(static fn($row) => ['no' => (int)$row['checkin_no'], 'at' => (string)$row['checked_in_at']], $logs);
// -------- Server-side debounce (hard protection) --------
// Prevent rapid repeated scans from consuming both entries.
$last_scan_at = $ticket['last_scan_at'] ? strtotime((string)$ticket['last_scan_at']) : 0;
$now_ts = time();
if ($last_scan_at && ($now_ts - $last_scan_at) < $DEBOUNCE_SECONDS) {
// Update last_scan_at optionally (not required). We'll keep it as is.
$pdo->commit();
write_log("[FAST ] {$scan_time} | {$p['name']} | {$qr_data} | debounced | IP: {$ip} | UA: {$ua}");
$p['message'] = 'Too fast. Please wait 1 second and scan again.';
$p['success'] = false;
respond_json($p, 200);
}
// If already at limit, still update last_scan_at (so spamming doesn't hammer DB)
if ($count >= $MAX_CHECKINS) {
$updScan = $pdo->prepare("UPDATE tickets SET last_scan_at = NOW() WHERE id = ?");
$updScan->execute([$ticket_id]);
$pdo->commit();
write_log("[LIMIT] {$scan_time} | {$p['name']} | {$qr_data} | limit reached ({$count}) | IP: {$ip} | UA: {$ua}");
$p['message'] = 'Limit reached: already checked in 2 times.';
$p['success'] = false;
$p['left'] = 0;
respond_json($p, 200);
}
// Next check-in number
$checkin_no = $count + 1; // 1 or 2
// Update ticket + set last_scan_at NOW to block immediate repeats
$upd = $pdo->prepare("
UPDATE tickets
SET checkin_count = checkin_count + 1,
last_checked_in_at = NOW(),
last_scan_at = NOW()
WHERE id = ?
");
$upd->execute([$ticket_id]);
// Insert log row
$ins = $pdo->prepare("
INSERT INTO ticket_checkins (ticket_id, ticket_code, checkin_no, checked_in_at, ip, user_agent)
VALUES (?, ?, ?, NOW(), ?, ?)
");
$ins->execute([$ticket_id, $qr_data, $checkin_no, $ip, $ua]);
// Reload for accurate display
$after = $pdo->prepare("SELECT checkin_count, last_checked_in_at FROM tickets WHERE id = ?");
$after->execute([$ticket_id]);
$afterRow = $after->fetch();
$logsStmt->execute([$ticket_id]);
$logs2 = $logsStmt->fetchAll();
$pdo->commit();
$newCount = (int)$afterRow['checkin_count'];
$p['success'] = true;
$p['count'] = $newCount;
$p['left'] = max(0, $MAX_CHECKINS - $newCount);
$p['checked_in_at'] = (string)$afterRow['last_checked_in_at'];
$p['checkins'] = array_map(static fn($row) => ['no' => (int)$row['checkin_no'], 'at' => (string)$row['checked_in_at']], $logs2);
$p['message'] = $newCount === 1
? 'Check-in OK. (1/2)'
: 'Check-in OK. (2/2) Limit reached.';
write_log("[PASS ] {$scan_time} | {$p['name']} | {$qr_data} | checkin #{$newCount} | IP: {$ip} | UA: {$ua}");
respond_json($p, 200);
} catch (Throwable $e) {
if ($pdo->inTransaction()) $pdo->rollBack();
write_log("[ERROR] {$scan_time} | {$qr_data} | {$e->getMessage()} | IP: {$ip} | UA: {$ua}");
$p['message'] = 'Server error. Please try again.';
respond_json($p, 500);
}
}
?>
2025-2026 Talent Quest & Prize-giving Ceremony — Ticket Check-in
2025-2026 Talent Quest & Prize-giving Ceremony — Ticket Check-in
Scan QR code or use manual entry. Fixed limit: 2 check-ins per ticket.
Ready
Scanner
Tip: on mobile, sound requires a user tap first (Resume / Manual Entry).
Result
Idle
Waiting
0/2
Ready to scan.
Place a QR code inside the frame.
Chinese Name-
English Name-
Class-
Class No.-
Ticket Code-
Last Check-in-
Check-in History (max 2)
- 與 DB 連線相關的變數
$MAX_CHECKINS:每張票最多幾次 check-in(目前是 2)$DEBOUNCE_SECONDS:伺服器端防抖(同一票 1 秒內重複掃不會再次扣額度)$API_KEY:如果你要讓只有帶特定 HTTP Header 的人才能呼叫 API,就在這裡設;目前是空字串 = 沒啟用$TICKET_REGEX:原本用來驗證票碼格式,現在註解說明改為「只限制長度」而不檢查格式,實際 code 也沒用到這個 regex
INSERT INTO tickets (ticket_code, name, engname, class, class_number)
VALUES
('rcpsTcVeC', '張三', 'ZHANG SAN', '6A', '01'),
('AbCdEfGh', '李四', 'LEE SI', '6B', '02');