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 路由的標準寫法。
留言
張貼留言