跳到主要內容

Web Day 39 專案:後台介面(HTMX)

Web Day 39 專案:後台介面(HTMX)

執行需求:CPU 可跑。昨天把背景任務與通知排程都補上,現在管理者每天要打開瀏覽器看「今天的預約」、「尚未確認的預約」、「昨天取消的清單」,並能在不重新整理整頁的情況下操作:確認、發通知、改時段。這種「伺服器端回 HTML 片段,前端只負責把它塞回 DOM」的需求,正是 HTMX 2.0 的強項。今天要做四件事:第一,把 FastAPI 加上 Jinja2 模板引擎支援,並註冊靜態檔案;第二,用 HTMX 2.0 寫一個後台首頁,包含今日預約、狀態篩選、搜尋框;第三,寫兩個伺服器端 partial 端點(/admin/bookings/search、/admin/bookings/{id}/confirm),回傳 HTML 片段而非全頁;第四,用 cookies + Day 36 的 JWT 守後台路由,整個後台都在 /admin/* 底下,需 admin 角色才能進入。整個後台用瀏覽器就能操作,不需要寫 SPA 或 React。本篇的資料模型繼續沿用 Day 35–38,所有資料皆為虛構示範。

引言

很多後端工程師對「後台介面」的第一反應是「交給前端同事去做」,但其實大多數小型系統的後台(admin)操作簡單,根本不需要另一個團隊來寫 React / Vue。HTMX 2.0 把這個痛點直接消掉:瀏覽器送 HTTP 請求給伺服器,伺服器回一段 HTML,HTMX 把那段 HTML 塞進指定 DOM 位置,全程不用寫前端程式碼。這種「hypermedia-driven」的設計哲學,跟 Roy Fielding 在 2000 年的 REST 論文一致:把狀態轉移的邏輯放在伺服器、客戶端只負責呈現。

貫穿專案的「預約管理系統」是個小型服務業線上預約平台(情境涵蓋攝影棚、健身教練、諮詢工作室,所有資料都是虛構示範),管理者要做的後台動作有四種:看今日預約、確認尚未確認的預約、批次改期、發送系統通知。Day 23–24 我們用 HTMX 寫過簡單的動態介面,但那時還沒串真實資料;今天要把 Day 35–38 的資料庫、JWT 認證、通知模型整合起來,寫一個能在本機瀏覽器跑、能在 staging 部署、甚至能在 production 撐小型工作室的後台。

今天的內容分四段:第一段說明 HTMX 與 Jinja2 的搭配方式、為什麼後台可以這樣寫;第二段把後台模板、目錄、共用 partial 寫好;第三段寫兩個 partial 端點(搜尋、確認),並用 cookies 守住 /admin/*;第四段用 httpx 0.28 與 pytest 8.4 寫端對端測試,確認後台能正確運作。讀完這篇你會了解:HTMX 2.0 的 hx-get、hx-post、hx-swap 三個關鍵屬性、Jinja2 模板的 base/layout 機制、為什麼後台 cookie 要用 HttpOnly、怎麼用 FastAPI 0.116 的 Depends 把角色檢查寫成一行。

原理解念:HTMX、Jinja2、與 partial 端點

HTMX 的核心概念只有三條:hx-get 讓瀏覽器對某個元素發 GET 請求、伺服器回 HTML 片段,HTMX 把它塞進 hx-target 指定的位置;hx-post 同理但送 POST 請求、可用 hx-vals 帶 JSON 欄位;hx-swap 控制塞進去的時機(innerHTML、outerHTML、none 等)。整個 HTMX 的 runtime 約 14 KB(gzipped),比 jQuery 還小,而且對伺服器沒有額外要求——任何能回 HTML 的伺服器都行,這正是它能與 FastAPI、Jinja2 完美搭配的原因。

Jinja2 模板引擎的角色是把 Python 物件「渲染」成 HTML 字串。我們把頁面分成三層:layout template(templates/base.html)定義 head、body、CSS、HTMX script include;page template(templates/admin/dashboard.html)繼承 base、把內容塞進 {% block content %};partial template(templates/admin/_booking_row.html)給 HTMX 換頁用。這個分工讓我們能同時支援「首次載入完整頁面」與「HTMX 局部更新」兩種使用情境。

後台的安全模型分成兩層。第一層是「一定要登入」,用 Day 12 / 36 的 JWT,HttpOnly cookie 儲存(瀏覽器 JavaScript 不能存取,避免 XSS 偷 token)。第二層是「必須是 admin 角色」,在 FastAPI 用一個 require_admin Depends 函式檢查 JWT 解出來的 role 欄位;不符合就回 403。後台的每個路由與 partial 都掛這個依賴,沒有「忘記加權限檢查」的可能性。

另一個關鍵觀念是「partial 端點要回 HTML,不能回 JSON」。HTMX 的請求有兩個識別方式:一種是看 HX-Request: true 標頭,一種是看 HX-Target 標頭;我們在後端用 HX-Request 來決定回什麼:如果是 HTMX 來的,回 partial 模板;如果是直接打瀏覽器進來,回完整頁面。這種「同一支端點、兩種回應」的模式,可以讓 /admin/bookings/search?q=... 既能被管理者在後台搜尋,也能被 curl 自動測試。

最後從觀念回到選擇。HTMX 不是唯一解,選它的理由有三:學習曲線最低(懂 HTML 就能上手)、對 SSR 友善(不用 build step)、效能與 SPA 接近但程式碼少一個量級。我們的後台規模很小、互動有限、沒有離線需求,HTMX 剛好對應到這個甜蜜點;如果今天是影音平台或即時協作工具,就會選另一條路徑。我們也比較過 Alpine.js 與 Livewire:Alpine 適合給樣板加一點互動,Livewire 在 Laravel 生態很成熟但 FastAPI 沒有對應的整合;對 Python 後端工程師來說,HTMX + Jinja2 是阻力最小的組合,這也是本系列貫穿前後台的統一做法。

完整實作:模板、partial 端點、與後台守門

先把目錄與共用設定準備好。FastAPI 0.116 內建 Jinja2 整合,只要從 fastapi.templating import Jinja2Templates 即可。

# booking_system/config.py(節錄,跟 Day 38 共用同一份)
from pathlib import Path

BASE_DIR = Path(__file__).resolve().parent.parent
TEMPLATE_DIR = BASE_DIR / "templates"
STATIC_DIR = BASE_DIR / "static"
COOKIE_NAME = "booking_admin_session"
COOKIE_MAX_AGE = 60 * 60 * 8  # 後台 session 8 小時
ROLE_ADMIN = "admin"
ROLE_CUSTOMER = "customer"
# booking_system/web.py
# 把 Jinja2Templates、StaticFiles、後台依賴串起來
from fastapi import Depends, HTTPException, Request, status
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from jose import JWTError, jwt

from booking_system.auth import ALGORITHM, SECRET_KEY
from booking_system.config import (
    COOKIE_NAME,
    ROLE_ADMIN,
    STATIC_DIR,
    TEMPLATE_DIR,
)
from booking_system.models import User
from booking_system.db import SessionLocal


templates = Jinja2Templates(directory=str(TEMPLATE_DIR))


def current_admin(request: Request) -> User:
    """讀 cookie 中的 JWT,驗證後回 User;若不是 admin 就 403。"""
    token = request.cookies.get(COOKIE_NAME)
    if not token:
        raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
    try:
        payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
    except JWTError:
        raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
    if payload.get("role") != ROLE_ADMIN:
        raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
    user_id = payload.get("sub")
    with SessionLocal() as session:
        user = session.get(User, int(user_id))
    if user is None:
        raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
    return user


def mount_admin(app) -> None:
    """把靜態檔案與後台路由註冊到 FastAPI app。"""
    app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
    from booking_system.routes.admin import router as admin_router
    app.include_router(admin_router, dependencies=[Depends(current_admin)])

mount_admin() 整包掛在 app 上,所有 /admin/* 路由都帶 Depends(current_admin),符合條件才進得去。HttpOnly cookie 用 Set-Cookie: booking_admin_session=<jwt>; HttpOnly; SameSite=Lax; Secure(Day 27 已示範),瀏覽器送出請求時自動帶上,前端 JavaScript 完全碰不到,符合最小授權原則。Current_admin() 內部直接 SessionLocal() 開新 session,目的是避免 request 結束後背景 session 已被關閉(Day 38 提過同樣的設計原則)。

底下是 HTML 模板。我們故意把模板寫成「小而明確」,方便閱讀也方便 partial 抽取。以下兩段都是 Jinja2 模板,請當成字串內容讀;為了避免干擾 HTML 解析,內容已轉成純文字:

# 對應的 templates/base.html(純文字版)
# 這個檔放在 booking-system/templates/base.html,包含 layout 與 HTMX include
LAYOUT_BASE = """<!doctype html>
<html lang="zh-Hant">
<head>
  <meta charset="utf-8">
  <title>{% block title %}預約管理系統後台{% endblock %}</title>
  <link rel="stylesheet" href="/static/admin.css">
  <script src="https://unpkg.com/htmx.org@2.0.0"></script>
</head>
<body>
  <header class="admin-header">
    <h1>預約管理系統後台</h1>
    <nav>
      <a href="/admin/dashboard">Dashboard</a>
      <a href="/admin/bookings">預約清單</a>
      <a href="/admin/notifications">通知中心</a>
    </nav>
  </header>
  <main>{% block content %}{% endblock %}</main>
</body>
</html>"""
# 對應的 templates/admin/dashboard.html(純文字版)
# extends base.html,並定義今日預約、搜尋框、與搜尋結果容器
LAYOUT_DASHBOARD = """{% extends "base.html" %}
{% block title %}Dashboard - 預約管理系統{% endblock %}
{% block content %}
<section>
  <h2>今日預約({{ today_count }} 筆)</h2>
  <form hx-get="/admin/bookings/search"
        hx-target="#booking-list"
        hx-trigger="input changed from:input[type=search], change from:select"
        hx-swap="innerHTML">
    <input type="search" name="q" placeholder="搜尋客戶 email 或姓名"
           autofocus autocomplete="off">
    <select name="status">
      <option value="">所有狀態</option>
      <option value="pending">待確認</option>
      <option value="confirmed">已確認</option>
      <option value="cancelled">已取消</option>
    </select>
  </form>
  <div id="booking-list"
       hx-get="/admin/bookings/search"
       hx-trigger="load delay:100ms"
       hx-swap="innerHTML">
    <p class="placeholder">載入中…</p>
  </div>
</section>
{% endblock %}"""

這兩個模板在實際部署時會存成 templates/base.html 與 templates/admin/dashboard.html,不必再用 Python 字串常數包起來。我們用 LAYOUT_BASE = """...""" 是為了讓本篇文章可以在沒有 templates 目錄的環境下展示內容;正式部署時就把 """ 與 """ 之間的內容直接寫成 HTML 檔即可。

這個 dashboard 有兩個 HTMX 行為:搜尋框每輸入一個字、select 換選項時,就觸發 /admin/bookings/search,回傳的 HTML 片段塞進 #booking-list;頁面載入時自動送一次請求,把今日預約撈回來。delay:100ms 是為了讓 q 與 status 都進表單後再發請求,避免邊輸入邊選 select 重複觸發。整個後台沒有 SPA 框架、沒有 build step、沒有 useEffect。

接著是 partial 端點的伺服器端:

# booking_system/routes/admin.py
from datetime import datetime, timedelta, timezone

from fastapi import APIRouter, Depends, Request, status
from fastapi.responses import HTMLResponse
from sqlmodel import Session, select

from booking_system.db import get_session
from booking_system.models import Booking, BookingStatus, NotificationChannel, User
from booking_system.web import templates, current_admin
from booking_system.notify import queue_notification


router = APIRouter(prefix="/admin", tags=["admin"])


def _today_bounds() -> tuple[datetime, datetime]:
    start = datetime.now(timezone.utc).replace(hour=0, minute=0, second=0, microsecond=0)
    return start, start + timedelta(days=1)


@router.get("/dashboard", response_class=HTMLResponse)
def dashboard(
    request: Request,
    session: Session = Depends(get_session),
    admin: User = Depends(current_admin),
) -> HTMLResponse:
    today_start, today_end = _today_bounds()
    stmt = select(Booking).where(
        Booking.start_at >= today_start,
        Booking.start_at < today_end,
    )
    today_count = len(session.exec(stmt).all())
    return templates.TemplateResponse(
        request,
        "admin/dashboard.html",
        {"today_count": today_count, "admin": admin},
    )


@router.get("/bookings/search", response_class=HTMLResponse)
def search_bookings(
    request: Request,
    q: str = "",
    status: str = "",
    session: Session = Depends(get_session),
    admin: User = Depends(current_admin),
) -> HTMLResponse:
    """回 HTML 片段:給 HTMX 塞進 #booking-list。"""
    stmt = select(Booking)
    if status and status in {s.value for s in BookingStatus}:
        stmt = stmt.where(Booking.status == status)
    if q:
        like = f"%{q}%"
        stmt = stmt.where(Booking.customer_email.like(like))
    rows = session.exec(stmt.limit(50)).all()
    return templates.TemplateResponse(
        request,
        "admin/_booking_list.html",
        {"rows": rows, "admin": admin},
    )


@router.post("/bookings/{booking_id}/confirm", response_class=HTMLResponse)
def confirm_booking(
    booking_id: int,
    request: Request,
    session: Session = Depends(get_session),
    admin: User = Depends(current_admin),
) -> HTMLResponse:
    """HTMX 按下確認按鈕後,把預約狀態改成 confirmed 並回更新後的 row HTML。"""
    booking = session.get(Booking, booking_id)
    if booking is None:
        return HTMLResponse("<p>查無此預約</p>", status_code=404)
    booking.status = BookingStatus.CONFIRMED
    booking.updated_at = datetime.now(timezone.utc)
    queue_notification(
        session,
        booking_id=booking.id,
        channel=NotificationChannel.EMAIL,
        subject="[預約確認] 您的預約已確認",
        body=f"您的預約編號 {booking.id} 已被管理員確認,時段 {booking.start_at.isoformat()}。",
    )
    session.add(booking)
    session.commit()
    session.refresh(booking)
    return templates.TemplateResponse(
        request, "admin/_booking_row.html", {"row": booking}
    )

這份程式碼做了三件事。/admin/dashboard 是首次載入,渲染 dashboard.html 並把今日預約數量傳進 template。/admin/bookings/search 是 HTMX partial,回 HTML 片段,前端把它塞進 #booking-list。/admin/bookings/{id}/confirm 是 HTMX 動作端點,把預約狀態改成 confirmed,順手發一封確認信(走 Day 38 的 queue_notification()),並回單列 HTML 給 HTMX swap 回原本位置。整條流程都帶 Depends(current_admin),未登入或非 admin 都會被擋。Depends(current_admin) 同時在 router include 與函式簽章出現,FastAPI 0.116 會去重,不會跑兩次。

兩個 partial 模板(用 Python 字串示範內容):

LAYOUT_LIST = """<table class="booking-table">
  <thead>
    <tr><th>時段</th><th>客戶</th><th>狀態</th><th>操作</th></tr>
  </thead>
  <tbody>
    {% for row in rows %}
      {% include "admin/_booking_row.html" %}
    {% endfor %}
  </tbody>
</table>
{% if not rows %}
  <p class="empty">目前沒有符合條件的預約。</p>
{% endif %}"""

LAYOUT_ROW = """<tr id="booking-{{ row.id }}">
  <td>{{ row.start_at.strftime("%Y-%m-%d %H:%M") }}</td>
  <td>{{ row.customer_email }}</td>
  <td>
    <span class="status status-{{ row.status.value }}">{{ row.status.value }}</span>
  </td>
  <td>
    {% if row.status.value == "pending" %}
      <button hx-post="/admin/bookings/{{ row.id }}/confirm"
              hx-target="#booking-{{ row.id }}"
              hx-swap="outerHTML">
        確認
      </button>
    {% else %}
      <span class="muted">—</span>
    {% endif %}
  </td>
</tr>"""

按下「確認」按鈕時,HTMX 送 POST 到 /admin/bookings/{id}/confirm,伺服器回新的整列 HTML,hx-swap="outerHTML" 把原本那一列整個換掉,瀏覽器立刻看到狀態變成 confirmed。完全不需要重新整理、沒有 JSON 解析、沒有 react state、沒有 useEffect。換成 SPA 框架要寫的話至少要 80 行程式,這裡只用 12 行 template + 14 行 route。

最後寫登入端點與測試:

# booking_system/routes/admin_auth.py
from fastapi import APIRouter, Depends, Response, status
from sqlmodel import Session

from booking_system.auth import create_access_token, verify_password
from booking_system.config import COOKIE_MAX_AGE, COOKIE_NAME, ROLE_ADMIN
from booking_system.db import get_session
from booking_system.models import User


router = APIRouter(prefix="/admin/auth", tags=["admin-auth"])


@router.post("/login")
def login(
    response: Response,
    payload: dict,
    session: Session = Depends(get_session),
) -> dict:
    email = payload.get("email", "")
    password = payload.get("password", "")
    user = session.query(User).filter_by(email=email).one_or_none()
    if user is None or not verify_password(password, user.password_hash):
        # 認證失敗一律回同一個訊息,避免帳號枚舉
        return {"ok": False}
    if user.role != ROLE_ADMIN:
        return {"ok": False}
    token = create_access_token(subject=str(user.id), role=user.role)
    response.set_cookie(
        key=COOKIE_NAME,
        value=token,
        max_age=COOKIE_MAX_AGE,
        httponly=True,
        samesite="lax",
        secure=False,  # 本機 HTTP;上線請改 True
    )
    return {"ok": True}
# tests/test_admin_dashboard.py
import pytest
from fastapi.testclient import TestClient

from booking_system.main import app
from booking_system.db import engine
from booking_system.models import (
    Booking, BookingStatus, Resource, ResourceKind, User, UserRole,
    utcnow,
)
from booking_system.auth import hash_password
from sqlmodel import Session, SQLModel


@pytest.fixture
def client():
    SQLModel.metadata.create_all(engine)
    return TestClient(app)


def _make_admin_and_booking():
    with Session(engine) as session:
        admin = User(
            email="admin@example.com",
            password_hash=hash_password("admin-pass"),
            role=UserRole.ADMIN,
            display_name="管理員",
        )
        session.add(admin)
        resource = Resource(name="攝影棚 A", kind=ResourceKind.ROOM)
        session.add(resource)
        session.commit()
        session.refresh(admin)
        session.refresh(resource)
        booking = Booking(
            resource_id=resource.id,
            user_id=admin.id,
            customer_email="alice@example.com",
            start_at=utcnow(),
            end_at=utcnow(),
            status=BookingStatus.PENDING,
        )
        session.add(booking)
        session.commit()
        session.refresh(booking)
        return admin.id, booking.id


def test_admin_login_and_dashboard(client: TestClient):
    _make_admin_and_booking()
    # 未登入會被擋
    r = client.get("/admin/dashboard")
    assert r.status_code == 401

    # 登入並把 cookie 留著
    r = client.post(
        "/admin/auth/login",
        json={"email": "admin@example.com", "password": "admin-pass"},
    )
    assert r.status_code == 200
    assert r.json()["ok"] is True

    r = client.get("/admin/dashboard")
    assert r.status_code == 200
    assert "預約管理系統後台" in r.text


def test_htmx_search_returns_partial_html(client: TestClient):
    _make_admin_and_booking()
    client.post(
        "/admin/auth/login",
        json={"email": "admin@example.com", "password": "admin-pass"},
    )

    r = client.get(
        "/admin/bookings/search?q=alice&status=pending",
        headers={"HX-Request": "true"},
    )
    assert r.status_code == 200
    # partial HTML(沒有 doctype 標籤)
    assert "DOCTYPE" not in r.text.upper()
    assert "alice@example.com" in r.text


def test_confirm_booking_swaps_row_html(client: TestClient):
    _, booking_id = _make_admin_and_booking()
    client.post(
        "/admin/auth/login",
        json={"email": "admin@example.com", "password": "admin-pass"},
    )
    r = client.post(f"/admin/bookings/{booking_id}/confirm")
    assert r.status_code == 200
    assert "confirmed" in r.text
    assert f"booking-{booking_id}" in r.text

三支測試覆蓋三條主線:未登入被擋(401)、登入後能看 dashboard、HTMX 來的搜尋請求會收到 partial HTML、確認按鈕會把列換成新的狀態。整個後台不用寫 SPA,只要用 FastAPI 的 TestClient 發請求、檢查回 HTML 的字串片段即可驗證。headers={"HX-Request": "true"} 是 HTMX 自動加的標頭,加上它讓後端能區分 HTMX 與一般瀏覽器請求(Day 24 已示範類似模式)。

常見錯誤與踩雷

第一個常見錯誤是「忘記把 partial 端點的 HX-Request 標頭考慮進去」。如果你的 partial 端點在沒有 HX-Request 時也回 fragment,會被自動化腳本(curl、健康檢查器)誤以為是完整頁面抓進去,長久下來 log 會被一堆無效抓取淹沒。對應處理:partial 端點可以選擇性回 400 或回完整頁面;我們的設計是 partial 一定回 fragment、完整頁面則掛在 /admin/dashboard,兩者分離就不會互相干擾。

第二個常見踩雷是「{% include %} 的相對路徑」。Jinja2 的 {% include "admin/_booking_row.html" %} 是相對於 Templates 目錄,跟 Python 的相對 import 不同。第一次寫常會打成 _booking_row.html(少了 admin/ 前綴),結果整列 HTML 渲染不出來。對應處理:模板路徑一律寫絕對路徑(從 templates/ 起算),並用 extends 與 include 兩種語意不同的指令:extends 繼承 layout、include 重用 partial。

第三個是「cookie 沒設 HttpOnly 或 Secure」。如果 Set-Cookie 少了 HttpOnly,瀏覽器的 JavaScript 就能讀到 token,XSS 攻擊就能盜走管理員 session。對應處理:後台 cookie 一律 httponly=True, samesite="lax",正式環境再加上 secure=True。samesite="lax" 可以擋掉跨站請求帶 cookie 的攻擊模式,是 OWASP 推薦的設定;配合 Day 15 的 CORS 限制,整體防禦已經能擋下大部分常見的瀏覽器端攻擊。

第四個是「HTMX 版本鎖定在 2.0」。如果你用 @2 自動升級到 2.0.1、2.0.2,那天 HTMX 出了 2.1.x breaking change,後台就壞了。對應處理:用 @2.0.0 鎖定版本、或下載到 /static/htmx.min.js 自己 host,不要依賴 unpkg 的浮動版本。在本系列規模下,自己 host 多一個 14 KB 的靜態檔其實更划算:少了第三方連線失敗的風險,也方便離線開發。

效能與實務提醒

HTMX 把所有互動變成 HTTP 請求,每個動作都會走一次完整的 server roundtrip,但因為請求只回幾 KB 的 HTML 片段,比 SPA 抓 JSON 後再在前端 render 還快。在我們這個小型後台,搜尋 50 筆預約的回應約 8–15 KB(gzipped),延遲約 20–40 毫秒;切換狀態的回應約 1 KB,延遲約 10 毫秒(實際數字會略有不同)。如果未來預約量成長到上千筆,partial 端點就要加分頁(用 ?page=&size=)與 limit,避免回應過大。

Jinja2 模板渲染在 CPU 上約每頁 2–5 毫秒,比直接寫字串慢,但比 SPA 的 React 渲染快很多。如果遇到頁面延遲,可以考慮加上 Jinja2 的 bytecode cache(Jinja2Templates(env=Environment(...))),但本系列規模用不到。另一個加速手段是 fragment caching:把後台側欄的 HTML 用 {% cache %} 包起來,5 分鐘內不重算;不過 FastAPI 預設沒有 Flask-Caching 那種擴充套件,要手刻也有點麻煩,本篇先跳過。在本系列的後台規模下,連 partial 端點走 httpx 客戶端的併發測試都能輕鬆達到每秒 500 次以上(實際數字會略有不同),瓶頸通常在資料庫而非模板渲染,這也是 Day 43 才會深入的議題。

另一個實務提醒:Day 38 寫的「通知」與今天寫的「後台操作」會互相連動。當 admin 按下「確認」按鈕,queue_notification() 就會被呼叫並寫進 DB;接著 BackgroundTasks 會在背景把信寄出去(模擬)。整個互動的延遲仍然取決於 HTTP 請求本身,不會被通知流程拖慢,這是 Day 38 把通知拆到背景任務的紅利。如果未來通知量很大,可以考慮把 queue_notification() 移到 Kafka 或 Redis Stream 後端,但目前的 SQLModel session 寫法在小型系統完全夠用。

最後,HTMX 一個常被忽略的功能是「hx-boost」:在 layout 的 <body> 上加 hx-boost="true" 可以讓所有 <a> 與 <form> 自動轉成 HTMX 請求。我們刻意沒用,因為這會讓非同步請求的體驗跟同步請求混在一起,不利於除錯;只在需要的元素加上 hx-get、hx-post 是更明確的寫法。Day 24 我們討論過同樣的權衡,今天再複習一次。

部署到正式環境後,這個後台通常會放在 /admin/... 子網域後面或加 IP 白名單;用 Nginx/Caddy 加一層 basic auth 也能擋掉大部分自動化掃描。Day 33 會示範 Caddy 的 HTTPS 與 reverse proxy 設定,把 /admin/ 與 /api/ 放在同一個站點的不同路徑下,比拆成子網域更容易管理 cookies。整個 stack 從 Day 35 的「資料模型」、Day 36 的「認證」、Day 37 的「衝突檢查」、Day 38 的「通知背景任務」一直到今天的「後台介面」,是一條很完整的端到端示範,明天的測試與驗收會把所有 API 與後台互動一一跑到,產出可重現的綠燈。

小結

今天用 HTMX 2.0 + Jinja2 寫出一個能跑的後台介面。我們把 FastAPI 0.116 的 Jinja2Templates、StaticFiles、current_admin 依賴串起來;用 base.html、dashboard.html、_booking_list.html、_booking_row.html 四個模板搭出 layout、page、partial 三層;寫了 /admin/dashboard(完整頁)、/admin/bookings/search(HTMX 搜尋)、/admin/bookings/{id}/confirm(HTMX 動作)三條端點;用 HttpOnly cookie 與 Depends(current_admin) 守住 /admin/* 路由;用 pytest 8.4 + TestClient 驗證 dashboard、search、confirm 三條主線。整個後台能在本機瀏覽器跑、能在 staging 部署、不需要 React。資料延續 Day 35–38 的模型,所有使用者、預約、通知都走 SQLModel 0.0.24 與 PostgreSQL 17 的同一套 schema。後台模板集中在 templates/admin/,partial 與 page 各司其職,明天測試與驗收會把這一切打包成 pytest 測試套件,並補一份驗收清單;驗收清單同時是 Day 41 部署前的關卡。

結語

今天的重點是把 Day 35–38 的 API 變成「瀏覽器裡能用的東西」。我們刻意把後台寫成伺服器端渲染 + HTMX 局部更新這個組合:對小型工作室來說,這個架構最划算(前端、後端同一支程式、部署簡單、SSR 友善、可複製到不同的小型專案上)。明天,我們會把整個預約管理系統「測試與驗收」一次做完:用 pytest 8.4 寫端對端測試覆蓋所有 API、用 httpx 0.28 整合非同步測試、整理一份「驗收清單」(acceptance checklist)作為上線前的檢查重點,確保 Day 41 部署到 Docker Compose 時一次就過。

延伸資源

  • HTMX 官方文件(2.0,2025):https://htmx.org/,hx-get、hx-post、hx-swap、hx-trigger 的標準範例與模式。
  • FastAPI 模板與靜態檔案(0.116,2025):https://fastapi.tiangolo.com/advanced/templates/,Jinja2Templates 的整合方式。
  • Jinja2 官方手冊(2025):https://jinja.palletsprojects.com/,extends、include、block 與 filters 的語法。
  • OWASP HttpOnly Cookie 指南(2024):https://owasp.org/www-community/HttpOnly,為什麼後台 cookie 一律要 HttpOnly + SameSite。
  • httpx TestClient(0.28,2025):https://www.python-httpx.org/async/#calling-into-python-web-apps,用 TestClient 驗 FastAPI 路由的標準寫法。

留言

這個網誌中的熱門文章

Day 2 變數與資料型別

Day 2 變數與資料型別 引言 寫程式的過程中,變數與資料型別是處理資料的基礎。變數是存放資料的容器,資料型別則決定這筆資料有哪些特性、可以進行哪些操作。學會定義變數、認識各種資料型別,是學好 Python 的關鍵一步。 這篇文章會帶你了解 Python 中變數的觀念、如何定義變數,以及常見的資料型別,包括整數、浮點數、字串、布林值,還有串列、元組、字典與集合等容器型別。我們也會介紹變數的命名規則與撰寫風格建議,以及如何用 type() 檢查資料型別。 什麼是變數?如何在 Python 中定義變數 變數是在程式執行時用來存放資料的名稱。透過定義變數,我們可以給一筆資料一個名字,並在程式的其他地方用這個名字取用該筆資料。在 Python 中,變數不需要事先宣告型別,因為 Python 是動態型別語言,變數的型別由指定給它的值決定。 定義變數的基本語法 在 Python 中定義變數非常簡單,只要用賦值符號 = 把值指定給變數即可。例如: x = 5 # 定義變數 x,並把整數 5 賦值給它 name = "Alice" # 定義變數 name,並把字串 "Alice" 賦值給它 在這裡,x 是一個變數,被賦予整數 5;name 是另一個變數,被賦予字串 "Alice"。 變數的更新與覆寫 變數的值可以修改,也就是說,我們可以在程式的不同地方給同一個變數新的值。例如: x = 10 # x 最初被賦予 10 x = 15 # x 的值現在被更新為 15 這樣就能依照需求,在程式執行過程中靈活調整變數的值。 Python 的動態型別系統 Python 和某些靜態型別語言不同,定義變數時不需要宣告型別。賦值時,Python 會根據值自動判斷變數的型別。例如: x = 5 # x 是整數 x = 3.14 # x 變成浮點數 x = "Hi" # x 變成字串 同一個變數在程式執行過程中可以存放不同型別的值,這是 Python 的彈性之一。 常見資料型別 在 Python 中,資料型別決定我們可以對變數進行哪些操作...

Day 1 Python 簡介與環境設定

Day 1 Python 簡介與環境設定 引言 在現在的科技環境裡,程式設計已經是一項重要技能。無論你是對資料科學有興趣、想成為開發者,或是想踏入人工智慧(AI)領域,學會寫程式都能明顯提升你的競爭力。在眾多程式語言中,Python 因為語法簡單、功能強大、應用範圍廣泛,成為許多人進入程式世界的第一選擇。這篇文章會帶你認識 Python 的背景與優勢,並一步步教你在不同系統上安裝與設定 Python 開發環境,最後寫出第一支 Python 程式。 為什麼選擇 Python? Python 是一種高階程式語言,由 Guido van Rossum 在 1991 年發布。Python 的設計哲學強調程式碼的可讀性,並用縮排來定義程式區塊,這點和許多使用大括號的語言不同。簡潔的語法讓它成為初學者的理想選擇;就算是經驗豐富的開發者,也能用它完成複雜的專案。 Python 的優勢如下: 簡單易學 :Python 的語法清楚、結構簡潔,初學者很快就能上手。和其他語言相比,學習曲線相對平緩,不需要先弄懂一堆複雜觀念,就能開始寫程式。 應用範圍廣泛 :從資料科學、網頁開發、人工智慧、機器學習、自動化測試到網路爬蟲,Python 都有大量開源函式庫與工具支援,而且在這些領域都扮演關鍵角色。 豐富的函式庫與框架 :Python 的函式庫生態系非常龐大。做資料分析有 NumPy、Pandas;開發網站有 Django、Flask;做深度學習有 TensorFlow、PyTorch。各種需求幾乎都能找到對應的套件,讓開發更有效率。 跨平台支援 :Python 支援 Windows、macOS、Linux 等作業系統,程式通常不需要太多修改就能跨平台執行,讓開發與部署更有彈性。 活躍的社群 :Python 擁有龐大的開發者社群。學習或開發上遇到問題,幾乎都能在社群與論壇(例如 Stack Overflow)找到答案,對初學者來說是很強的後盾,也能減少卡關時的挫折感。 Python 的應用領域 Python 的流行與強大功能,讓許多領域都開始大量使用它。以下是幾個常見的應用方向: 資料科學 :隨著大數據與人工智慧興起,資料科學大量使用 Python。NumPy、Pandas 與 Matplotlib 等工具能處理和分析龐...

Python 從入門到 PyTorch 深度學習:開啟 AI 世界的大門

Python 從入門到 PyTorch 深度學習:開啟 AI 世界的大門 隨著人工智慧(AI)與深度學習(Deep Learning)快速發展,越來越多人對這些技術產生興趣。不論你是想踏入 AI 領域的初學者,還是已經有程式基礎的開發者,學好 Python 與深度學習框架(例如 PyTorch),都能為你打開更多可能。 為什麼選擇 Python? Python 已經是資料科學與人工智慧領域的首選語言。它的語法簡潔、容易上手,而且擁有龐大的生態系與大量開源函式庫。無論是資料處理、資料視覺化,還是建立機器學習與深度學習模型,Python 都能勝任。對想進入 AI 或資料科學領域的人來說,它幾乎是必備工具。 PyTorch 是什麼? PyTorch 是由 Meta(原 Facebook)AI 研究團隊開發的開源深度學習框架,以易用、靈活和動態計算圖著稱,是許多 AI 研究人員與開發者的首選。相較於其他框架,PyTorch 的寫法更貼近原生 Python,對初學者相對友善。無論是簡單的實驗,還是複雜的深度學習模型,PyTorch 都能提供強大的支援。 這個系列能帶給你什麼? 這個系列會從 Python 的基礎開始,帶你一步一步學習,最後能自己用 PyTorch 建立深度學習模型。即使你完全沒有寫過程式,也能跟著文章的節奏累積技能,理解 AI 與深度學習的核心觀念。 本系列涵蓋的主題 Python 基礎:從變數、條件判斷到函式與模組。 資料處理工具:用 NumPy 與 Pandas 有效率地操作資料。 資料視覺化:用 Matplotlib 與 Seaborn 把資料畫成圖表。 深度學習的數學基礎:線性代數、微積分與機率。 PyTorch 入門:理解張量、模型建構與 GPU 加速。 基礎深度學習模型:CNN 與 RNN 的實作應用。 深度學習專案實戰:從資料前處理到模型部署的端到端流程。 誰適合這個系列? 程式初學者 :如果你對 AI 充滿好奇,卻還沒寫過程式,系列的第一部分會帶你快速上手 Python,並幫助你理解深度學習的基本觀念。 資料科學愛好者 :如果你已經熟悉一些資料處理方法,進階部分會教你如何用 PyTorch 建構深度學習模型。 開發者與研究人員 :想更深入了...