論壇系統的基礎建設模組,雖不直接面向使用者,卻是整個系統穩定運行的基石。本文整理了早期 BBS 專案中分散在各處的工具函式,現代化重構為可複用的服務類,涵蓋:時間格式化、回文計數、分頁導航、展開狀態 Cookie、郵件驗證、瀏覽數遞增、訂閱管理、郵件通知發送、推薦給好友等核心功能。
function formatDateTime(string $datetime, string $format = 'm/d H:i'): string
{
try {
return (new DateTime($datetime))->format($format);
} catch (Exception $e) {
error_log("Invalid datetime: {$datetime}");
return 'Invalid Date';
}
}
function getReplyCountMap(PDO $pdo, int $aid): array
{
$stmt = $pdo->prepare("SELECT sid, COUNT(*) AS cnt FROM bbs_docs WHERE aid = :aid GROUP BY sid");
$stmt->execute([':aid' => $aid]);
return $stmt->fetchAll(PDO::FETCH_KEY_PAIR); // [sid => count]
}
// 單一查詢版 (若無批次需求)
function getReplyCount(PDO $pdo, int $sid): int
{
$stmt = $pdo->prepare("SELECT COUNT(*) FROM bbs_docs WHERE sid = :sid");
$stmt->execute([':sid' => $sid]);
return (int)$stmt->fetchColumn();
}
class Paginator
{
public function __construct(
private int $totalItems,
private int $perPage = 20,
private int $currentPage = 1,
private string $baseUrl = '',
private array $queryParams = []
) {
$this->totalPages = (int)ceil($this->totalItems / $this->perPage);
$this->currentPage = max(1, min($this->currentPage, max(1, $this->totalPages)));
}
public function render(): string
{
if ($this->totalPages <= 1) return '';
$html = '<nav class="pagination" aria-label="分頁導航"><ul>';
// 上一頁
if ($this->currentPage > 1) {
$html .= $this->link($this->currentPage - 1, '« 上一頁');
}
// 頁碼 (可加入省略號邏輯)
for ($i = 1; $i <= $this->totalPages; $i++) {
$html .= ($i === $this->currentPage)
? '<li class="active" aria-current="page"><span>' . $i . '</span></li>'
: $this->link($i, (string)$i);
}
// 下一頁
if ($this->currentPage < $this->totalPages) {
$html .= $this->link($this->currentPage + 1, '下一頁 »');
}
$html .= '</ul></nav>';
return $html;
}
private function link(int $page, string $text): string
{
$params = array_merge($this->queryParams, ['pgno' => $page]);
$url = $this->baseUrl . '?' . http_build_query($params);
return '<li><a href="' . htmlspecialchars($url) . '">' . $text . '</a></li>';
}
}
class ThreadExpandState
{
private const COOKIE_NAME = 'BBS_OPEN_LIST';
private const EXPIRE = 31536000; // 1 年
public static function get(): bool
{
return isset($_COOKIE[self::COOKIE_NAME]) && $_COOKIE[self::COOKIE_NAME] === '1';
}
public static function set(bool $open, string $path = '/'): void
{
$value = $open ? '1' : '0';
// Secure=true 僅限 HTTPS 環境;SameSite=Lax 平衡安全與體驗
setcookie(self::COOKIE_NAME, $value, [
'expires' => time() + self::EXPIRE,
'path' => $path,
'domain' => '', // 當前域名
'secure' => isset($_SERVER['HTTPS']),
'httponly' => true, // 防 XSS 竊取
'samesite' => 'Lax',
]);
}
public static function toggle(string $redirectUrl): void
{
self::set(!self::get());
header("Location: {$redirectUrl}");
exit;
}
}
function isValidEmail(string $email): bool
{
return (bool)filter_var($email, FILTER_VALIDATE_EMAIL);
}
function incrementViewCount(PDO $pdo, int $id, bool $isSubject): void
{
$table = $isSubject ? 'bbs_subj' : 'bbs_docs';
// 單一 SQL 原子遞增,避免競態條件
$pdo->prepare("UPDATE {$table} SET views = views + 1 WHERE id = :id")
->execute([':id' => $id]);
}
function toggleSubscription(PDO $pdo, int $sid, string $email, bool $subscribe): array
{
if (!isValidEmail($email)) {
return ['success' => false, 'msg' => 'Email 格式錯誤'];
}
$checkStmt = $pdo->prepare("SELECT 1 FROM bbs_sml WHERE sid = :sid AND email = :email");
$checkStmt->execute([':sid' => $sid, ':email' => $email]);
$exists = (bool)$checkStmt->fetchColumn();
if ($subscribe) {
if ($exists) return ['success' => false, 'msg' => '已訂閱過'];
$pdo->prepare("INSERT INTO bbs_sml (sid, email) VALUES (:sid, :email)")
->execute([':sid' => $sid, ':email' => $email]);
return ['success' => true, 'msg' => '訂閱成功'];
} else {
if (!$exists) return ['success' => false, 'msg' => '未訂閱過'];
$pdo->prepare("DELETE FROM bbs_sml WHERE sid = :sid AND email = :email")
->execute([':sid' => $sid, ':email' => $email]);
return ['success' => true, 'msg' => '退訂成功'];
}
}
function sendNewPostNotifications(PDO $pdo, int $aid, int $sid, int $rid): void
{
// 1. 取得內容摘要
$table = ($sid === $rid) ? 'bbs_subj' : 'bbs_docs';
$stmt = $pdo->prepare("SELECT poster, banner, body FROM {$table} WHERE id = :rid");
$stmt->execute([':rid' => $rid]);
$post = $stmt->fetch();
if (!$post) return;
$subject = "{$post['poster']} 發表新回覆";
$message = "發文者: {$post['poster']}\n標題: {$post['banner']}\n內容: {$post['body']}\n\n";
$message .= "查看完整內容: https://{$_SERVER['HTTP_HOST']}/view.php?aid={$aid}&sid={$sid}&rid={$rid}\n";
// 2. 通知主題訂閱者
$subStmt = $pdo->prepare("SELECT email FROM bbs_sml WHERE sid = :sid");
$subStmt->execute([':sid' => $sid]);
$emails = $subStmt->fetchAll(PDO::FETCH_COLUMN);
// 3. 通知版主 (從 bbs_area 取得)
$areaStmt = $pdo->prepare("SELECT email FROM bbs_area WHERE id = :aid");
$areaStmt->execute([':aid' => $aid]);
$masterEmail = $areaStmt->fetchColumn();
if ($masterEmail) $emails[] = $masterEmail;
// 4. 發送 (生產環境請推入佇列,如 Redis/RabbitMQ/Database Queue)
foreach (array_unique($emails) as $email) {
// 非同步範例:寫入 mail_queue 表
$pdo->prepare("INSERT INTO mail_queue (to_email, subject, body, created_at) VALUES (?, ?, ?, NOW())")
->execute([$email, $subject, $message]);
// 同步發送 (僅示意,會阻塞請求)
// mail($email, $subject, $message, "From: BBS System <noreply@example.com>\r\nContent-Type: text/plain; charset=UTF-8");
}
}
郵件發送最佳實踐:
- **絕不**在 HTTP 請求中同步呼叫
mail()或 SMTP,會嚴重拖慢回應且易超時。- 寫入
mail_queue資料表,由獨立 Worker (cron / systemd / Supervisor 執行 PHP CLI) 處理發送、重試、退信記錄。- 使用成熟庫:
symfony/mailer,phpmailer/phpmailer,支援 TLS、DKIM、SPF、模板引擎。- 內容採用 HTML + 純文字 multipart/alternative 格式。
function shareToFriend(PDO $pdo, int $aid, int $sid, int $rid, string $fromEmail, string $toEmail): array
{
if (!isValidEmail($toEmail)) return ['success' => false, 'msg' => '收件人 Email 格式錯誤'];
$table = ($sid === $rid) ? 'bbs_subj' : 'bbs_docs';
$stmt = $pdo->prepare("SELECT banner, body FROM {$table} WHERE id = :rid");
$stmt->execute([':rid' => $rid]);
$post = $stmt->fetch();
if (!$post) return ['success' => false, 'msg' => '內容不存在'];
$subject = "{$fromEmail} 推薦您閱讀: {$post['banner']}";
$message = "您的好友 {$fromEmail} 推薦這篇文章:\n\n";
$message .= "標題: {$post['banner']}\n內容: {$post['body']}\n\n";
$message .= "連結: https://{$_SERVER['HTTP_HOST']}/view.php?aid={$aid}&sid={$sid}&rid={$rid}\n";
// 寫入分享日誌 (供統計/防濫用)
$pdo->prepare("INSERT INTO share_log (from_email, to_email, aid, sid, rid, created_at) VALUES (?, ?, ?, ?, ?, NOW())")
->execute([$fromEmail, $toEmail, $aid, $sid, $rid]);
// 推入郵件佇列
$pdo->prepare("INSERT INTO mail_queue (to_email, subject, body, created_at) VALUES (?, ?, ?, NOW())")
->execute([$toEmail, $subject, $message]);
return ['success' => true, 'msg' => '推薦郵件已排程發送'];
}
本文將散落在程式碼各處的基礎功能,重構為單一職責、可單元測試、採用現代 PHP 語法 (型別宣告、嚴格模式、DateTime、filter_var、PDO 參數綁定)的服務類與函式。關鍵架構決策:
filter_var、(int) 強制轉型。這些基礎建設的現代化,能大幅降低維護成本、提升系統穩定度與安全性。