完成留言板最後核心功能:管理員刪除、前台 AJAX 即時互動,並提供生產環境部署完整檢查清單。
admin/delete.php<?php
// admin/delete.php
require_once __DIR__ . '/../includes/common.php';
require_once __DIR__ . '/../includes/admin_auth.php';
$pdo = getPDO();
$id = (int)($_POST['id'] ?? $_GET['id'] ?? 0);
if ($id <= 0) {
// AJAX 請求回傳 JSON
if (!empty($_SERVER['HTTP_X_REQUESTED_WITH']) && $_SERVER['HTTP_X_REQUESTED_WITH'] === 'XMLHttpRequest') {
header('Content-Type: application/json');
echo json_encode(['success' => false, 'message' => '無效 ID']);
exit;
}
flash('無效 ID', 'danger');
redirect('index.php');
}
// CSRF 驗證(支援 Header 與表單)
$csrf_token = $_POST['csrf'] ?? $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
if (!csrf_check($csrf_token)) {
if (!empty($_SERVER['HTTP_X_REQUESTED_WITH'])) {
header('Content-Type: application/json');
http_response_code(403);
echo json_encode(['success' => false, 'message' => 'CSRF 驗證失敗']);
exit;
}
flash('CSRF 驗證失敗', 'danger');
redirect('index.php');
}
try {
$stmt = $pdo->prepare('DELETE FROM `messages` WHERE `id` = ?');
$stmt->execute([$id]);
if ($stmt->rowCount() > 0) {
$msg = '留言已刪除';
$success = true;
} else {
$msg = '找不到該留言';
$success = false;
}
} catch (PDOException $e) {
$msg = '資料庫錯誤:' . $e->getMessage();
$success = false;
}
// AJAX 回應
if (!empty($_SERVER['HTTP_X_REQUESTED_WITH']) && $_SERVER['HTTP_X_REQUESTED_WITH'] === 'XMLHttpRequest') {
header('Content-Type: application/json');
echo json_encode(['success' => $success, 'message' => $msg]);
exit;
}
// 一般表單提交
flash($msg, $success ? 'success' : 'danger');
redirect('index.php');
public/assets/js/app.js// public/assets/js/app.js
const API_BASE = '';
/** 通用 AJAX 請求 */
async function apiRequest(url, options = {}) {
const defaults = {
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.content || '',
'X-Requested-With': 'XMLHttpRequest',
},
credentials: 'same-origin',
};
const res = await fetch(url, { ...defaults, ...options });
const contentType = res.headers.get('content-type') || '';
if (contentType.includes('application/json')) {
return res.json();
}
return res.text();
}
/** 刪除留言(管理員) */
async function deleteMessage(id, buttonEl) {
if (!confirm('確定刪除此留言?')) return;
buttonEl.disabled = true;
buttonEl.textContent = '刪除中...';
try {
const result = await apiRequest('admin/delete.php', {
method: 'POST',
body: new URLSearchParams({ id, csrf: getCsrfToken() }),
});
if (result.success) {
// 移除 DOM 元素
const card = buttonEl.closest('.message-card') || buttonEl.closest('tr');
card?.remove();
showToast('刪除成功', 'success');
} else {
showToast(result.message || '刪除失敗', 'danger');
}
} catch (e) {
showToast('網路錯誤:' + e.message, 'danger');
} finally {
buttonEl.disabled = false;
buttonEl.textContent = '刪除';
}
}
/** 取得 CSRF Token */
function getCsrfToken() {
return document.querySelector('meta[name="csrf-token"]')?.content
|| document.querySelector('input[name="csrf"]')?.value
|| '';
}
/** Toast 通知 */
function showToast(message, type = 'info') {
const container = getOrCreateToastContainer();
const toast = document.createElement('div');
toast.className = `toast align-items-center text-white bg-${type} border-0`;
toast.setAttribute('role', 'alert');
toast.innerHTML = `
${message}
`;
container.appendChild(toast);
const bsToast = new bootstrap.Toast(toast, { delay: 3000 });
bsToast.show();
toast.addEventListener('hidden.bs.toast', () => toast.remove());
}
function getOrCreateToastContainer() {
let container = document.getElementById('toast-container');
if (!container) {
container = document.createElement('div');
container.id = 'toast-container';
container.className = 'toast-container position-fixed bottom-0 end-0 p-3';
document.body.appendChild(container);
}
return container;
}
// 綁定刪除按鈕(委派事件)
document.addEventListener('click', (e) => {
const btn = e.target.closest('[data-action="delete"]');
if (btn) {
e.preventDefault();
const id = btn.dataset.id;
if (id) deleteMessage(id, btn);
}
});
// 表單 AJAX 提交(新增留言、回覆)
document.querySelectorAll('form[data-ajax="true"]').forEach(form => {
form.addEventListener('submit', async (e) => {
e.preventDefault();
const submitBtn = form.querySelector('button[type="submit"]');
const originalText = submitBtn.textContent;
submitBtn.disabled = true;
submitBtn.textContent = '送出中...';
try {
const formData = new FormData(form);
formData.append('csrf', getCsrfToken());
const result = await apiRequest(form.action, {
method: 'POST',
body: formData,
headers: { 'Content-Type': 'multipart/form-data' }, // 讓瀏覽器自動設定 boundary
});
if (result.success) {
showToast(result.message || '操作成功', 'success');
form.reset();
// 留言列表頁:重新載入或插入新留言
if (form.dataset.reload === 'true') {
setTimeout(() => location.reload(), 500);
}
} else {
showToast(result.message || '操作失敗', 'danger');
}
} catch (err) {
showToast('錯誤:' + err.message, 'danger');
} finally {
submitBtn.disabled = false;
submitBtn.textContent = originalText;
}
});
});
<!-- 在所有頁面 head 結束前加入 -->
<meta name="csrf-token" content="<?= csrf_token() ?>">
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
<script src="/assets/js/app.js" defer></script>
<!-- 表單加上 data-ajax -->
<form method="post" data-ajax="true" data-reload="true">...</form>
<!-- 刪除按鈕加上 data 屬性 -->
<a href="#" class="btn btn-sm btn-danger" data-action="delete" data-id="<?= $msg['id'] ?>">刪除</a>
| 類別 | 檢查項目 | 狀態 |
|---|---|---|
| 安全性 | PHP 版本 ≥ 8.1(安全支援中) | ☐ |
| 關閉 display_errors、開啟 log_errors | ☐ | |
| Session Cookie:Secure、HttpOnly、SameSite=Lax | ☐ | |
| 資料庫帳號最小權限、強密碼、非 root | ☐ | |
| 上傳目錄禁用 PHP 執行(.htaccess / nginx location) | ☐ | |
| 啟用 HTTPS、HSTS、CSP 標頭 | ☐ | |
| 效能 | OPcache 啟用、JIT 開啟(PHP 8+) | ☐ |
Composer 優化自動載入 composer dump-autoload -o | ☐ | |
| 靜態資源 CDN、瀏覽器快取標頭 | ☐ | |
| 資料庫索引優化、慢查詢日誌分析 | ☐ | |
| 考慮 Redis 快取熱門留言、Session | ☐ | |
| 維運 | 錯誤日誌集中收集(Loki/ELK/Sentry) | ☐ |
| 資料庫備份策略(每日全備 + Binlog 增備) | ☐ | |
健康檢查端點 /healthz | ☐ | |
| 部署腳本自動化(GitHub Actions / GitLab CI / Deployer) | ☐ | |
| 程式碼品質 | PHPStan Level 5+ 靜態分析通過 | ☐ |
| 單元/功能測試覆蓋率 ≥ 80% | ☐ | |
| PSR-12 程式碼風格統一 | ☐ | |
相依套件無已知漏洞 composer audit | ☐ | |
| 法規遵循 | 隱私權政策、Cookie 同意條款 | ☐ |
| 個資加密儲存、存取紀錄 | ☐ | |
| 定期滲透測試、漏洞掃描 | ☐ |
# /etc/nginx/sites-available/guestbook
server {
listen 80;
server_name guestbook.example.com;
return 301 https://$server_name$request_uri;
}
server {
listen 443 ssl http2;
server_name guestbook.example.com;
root /var/www/guestbook/public;
index index.php;
ssl_certificate /etc/letsencrypt/live/guestbook.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/guestbook.example.com/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
# 安全標頭
add_header X-Frame-Options "SAMEORIGIN";
add_header X-Content-Type-Options "nosniff";
add_header Referrer-Policy "strict-origin-when-cross-origin";
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' cdn.jsdelivr.net; style-src 'self' 'unsafe-inline' cdn.jsdelivr.net; img-src 'self' data:; font-src 'self' cdn.jsdelivr.net;";
# 禁止上傳目錄執行 PHP
location /uploads/ {
location ~ \.php$ { deny all; }
}
location / {
try_files $uri $uri/ /index.php?$query_string;
}
location ~ \.php$ {
fastcgi_pass unix:/run/php/php8.2-fpm.sock;
fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
include fastcgi_params;
fastcgi_hide_header X-Powered-By;
}
location ~ /\.(?!well-known) {
deny all;
}
}
guestbook/
├── public/
│ ├── index.php ✅ 留言列表、分頁
│ ├── add_message.php ✅ 新增留言表單
│ ├── assets/
│ │ └── js/app.js ✅ AJAX 模組、Toast、刪除/表單增強
│ └── .htaccess # Apache 重寫規則
├── admin/
│ ├── login.php ✅ 登入
│ ├── logout.php ✅ 登出
│ ├── index.php ✅ 管理列表、分頁
│ ├── reply.php ✅ 回覆編輯
│ └── delete.php ✅ 刪除(支援 AJAX/一般)
├── includes/
│ ├── common.php ✅ 共用函數、DB、Session、CSRF
│ ├── db.php ✅ PDO Singleton
│ └── admin_auth.php ✅ 管理員中介層
├── config.php ✅ 設定檔
├── composer.json
└── README.md
本系列實作涵蓋:
後續可擴展方向:
從簡易留言板出發,掌握 PHP Web 開發核心技能,為進階框架學習打下堅實基礎。