Web Day 38 專案:通知與背景任務
執行需求:CPU 可跑。前三天把預約管理系統的「資料」與「流程」都建起來了,今天我們補上使用者體驗裡最容易被忽略、但其實最關鍵的一塊:通知。客戶預約成功、24 小時前提醒、管理員手動發送「今日行程」,這些事件都要從 API 回應中分離出來,放到背景任務或排程器執行。今天要做四件事:第一,在 Day 35 的資料模型裡加上 Notification 與 AuditLog 兩個資料表;第二,寫一支「模擬」通知寄送模組(明確標示不串接真實 SMTP/SMS 服務);第三,把 POST /bookings 的確認信改成背景任務;第四,用 APScheduler 註冊兩個排程工作(提前 24 小時提醒 + 每日清理 90 天前已取消預約)。整個流程在 CPU 上跑得動,郵件部分只寫進資料表並 log,不會真的寄出。
引言
「下單成功」這件事對工程師來說就是「資料寫進去了」,但對客戶來說,他期待的是「我知道我下單了,而且我會在預約前一天收到提醒」。兩者的落差,全靠通知系統來補。通知系統不只是寄信,它牽涉到:哪些事件要通知(預約成立、預約取消、改期、提醒)、用什麼通道(電子郵件、簡訊、推播)、失敗時怎麼辦(重試、改排程、人工介入)。在 production 環境,通知往往要切開獨立服務、用訊息佇列(RabbitMQ、Redis Stream、SQS)解耦,以避免「寄信塞住整個 API」。
貫穿專案的「預約管理系統」是個小型服務業線上預約平台(情境涵蓋攝影棚、健身教練、諮詢工作室,所有資料都是虛構示範)。它規模不大、流量不高,今天用 FastAPI 內建的 BackgroundTasks 加 APScheduler 3.x 就夠;如果未來流量衝上來,再升級到 Celery 5.4 或 Arq 也不遲。我們刻意把通知路徑寫成可替換的介面:今天是 SimulatedEmailSender(寫進 DB + log 檔),明天換成 SMTPEmailSender 就行。
今天的內容分四段:第一段說明背景任務與排程器的差異,以及為什麼需要兩者並存;第二段擴充資料模型並寫好通知模組;第三段把預約建立的回應路徑串好;第四段用 APScheduler 寫兩個排程工作,並用 pytest 8.4 驗證。讀完這篇你會了解:BackgroundTasks 怎麼用、APScheduler 怎麼整合進 lifespan、模擬通知怎麼設計、為什麼失敗的任務要進 AuditLog 而不是只 log。
貫穿專案共用設定(Day 35–44 沿用):Python 3.13、FastAPI 0.116、SQLModel 0.0.24、SQLAlchemy 2.0.41、Pydantic 2.11、Alembic 1.16、pytest 8.4、httpx 0.28、APScheduler 3.10。專案根目錄 booking-system/,設定集中在 booking_system/config.py,資料模型集中在 booking_system/models.py。通知一律走模擬通道(明確標示),不串接真實服務。
原理解念:背景任務、排程、與失敗處理
在設計通知前,要先分清楚「背景任務」與「排程工作」兩件事。背景任務(background task)是「某個 HTTP 請求裡有一個非阻塞的小工作要執行」,例如「使用者按下預約,伺服器要回應 201,同時要寄一封信」。背景任務通常依附在 request 的生命週期,跑在同一個 process 內,process 一退出它也沒了。排程工作(scheduled job)則是「每天早上九點檢查所有明天預約,發送提醒」,它跟任何 request 無關,獨立觸發、長期存在。FastAPI 的 BackgroundTasks 是前者;APScheduler 3.x 的 AsyncIOScheduler、Celery 5.4 的 beat 都是後者。
通知的設計有三個原則。第一是「同步寫進資料表、實際寄送非同步」:使用者按下預約的那一瞬間,Notification 這筆資料就要存在資料表裡(status=queued)。即使寄信失敗、系統重啟,這筆資料還在,可以人工重送或補寄,這對「客戶有沒有收到信」的客服問題至關重要。第二是「重試要有上限」:寄信失敗不能無限重試,常見設計是固定重試 3 次、間隔指數退避(1 分鐘、5 分鐘、30 分鐘);超過上限就標成 failed 等人工介入。第三是「失敗也要寫 AuditLog」:所有通知事件(寄出、失敗、重試、被略過)都要寫進 AuditLog,方便日後追查「為什麼客戶說他沒收到信」。
另一個關鍵觀念是「模擬通道」並不是「假裝沒這件事」,而是把真實介面寫好、把實作換掉。今天我們寫的 SimulatedEmailSender 實作了「把 subject 與 body 寫進 log、把狀態寫進 DB、把 audit 寫進 log」這三件事;未來要換成 SMTP,只要寫一個 SMTPEmailSender 實作同一個 protocol,把 get_notifier() 的回傳換掉即可。這種「介面固定、實作可換」的寫法,在工業界叫做 dependency injection,是 FastAPI 慣用的做法(Day 5 已示範)。
再延伸一個重試策略的小細節:當 deliver_notification() 第一次失敗時,背景任務版本只會把 attempt_count 加一並寫 audit,但實際重送動作要靠 APScheduler 的另一個「每十分鐘撈 QUEUED 且 attempt_count < 上限的工作」。我們今天刻意沒寫這個 worker,是因為 Day 19 已示範過基本的排程觀念,避免一篇塞太多支線;你可以在正式部署前把這個 worker 補上,寫法跟 cleanup_stale_queued() 類似,只是多了 where(Notification.attempt_count < SEND_FAILURE_LIMIT) 條件。這樣一來,失敗處理就有兩道保險:背景任務馬上試一次、排程器十分鐘後再試,連續三次都失敗才進入人工介入流程。整個重試節奏不靠 sleep、也不用 multiprocessing queue,是純粹用資料表狀態驅動,符合 FastAPI + SQLModel 的設計哲學。這套模式也方便日後改寫成 Celery:只要把 queue_notification() 與 deliver_notification() 換成 Celery task 即可,業務路由完全不動;而對客戶端而言,整個通知節奏從按下預約到收到提醒的體驗是一致的。
完整實作:通知模組、排程、與預約整合
底下進入實際動手。我們要擴充 booking_system/models.py、新增 booking_system/notify.py、booking_system/scheduler.py、修改 booking_system/routes/bookings.py 與 booking_system/main.py 的 lifespan,並補上測試。所有檔案都放在 booking-system/ 之下,與 Day 36–37 共用同一個虛擬環境。
首先擴充 Day 35 的模型,加上 Notification 與 AuditLog 兩個資料表:
# booking_system/models.py(節錄;沿用 Day 35 的 User / Resource / TimeSlot / Booking)
from datetime import datetime, timezone
from enum import Enum
from typing import Optional
from sqlmodel import Field, SQLModel
def utcnow() -> datetime:
# 統一用 UTC,避免時區轉換出錯
return datetime.now(timezone.utc)
class NotificationChannel(str, Enum):
EMAIL = "email"
SMS = "sms"
class NotificationStatus(str, Enum):
QUEUED = "queued" # 已建立,等寄送
SENT = "sent" # 已寄出(模擬 log)
FAILED = "failed" # 失敗達上限,待人工
class Notification(SQLModel, table=True):
__tablename__ = "notifications"
id: Optional[int] = Field(default=None, primary_key=True)
booking_id: int = Field(foreign_key="bookings.id", index=True)
channel: NotificationChannel
subject: str # 例如「預約確認通知」
body: str # 內文(純文字,模擬用)
status: NotificationStatus = Field(default=NotificationStatus.QUEUED, index=True)
attempt_count: int = 0 # 已嘗試次數(含失敗重試)
last_error: Optional[str] = None
created_at: datetime = Field(default_factory=utcnow)
sent_at: Optional[datetime] = None
class AuditLog(SQLModel, table=True):
__tablename__ = "audit_logs"
id: Optional[int] = Field(default=None, primary_key=True)
actor_id: Optional[int] = Field(default=None, index=True) # None 代表系統自動觸發
action: str = Field(index=True) # 例如 "booking.confirmed"
target_type: str # 例如 "booking"
target_id: Optional[int] = None
payload_json: str = "{}" # 事件內容,JSON 字串
created_at: datetime = Field(default_factory=utcnow)
這兩個表是 Day 35 模型家族的延伸。Notification 的 status 與 channel 上加了 index,方便日後查詢「過去 24 小時失敗的通知」這類管理後台報表。AuditLog 刻意存 payload_json: str 而非 JSON 欄位,是為了對齊 SQLite 測試資料庫(Day 17 已說明);production 換 PostgreSQL 後可以改成 JSONB 加速查詢(Day 42 會做)。
接下來定義模擬通道的介面與實作。我們刻意把它寫成「可替換的工廠」,日後換 SMTP 或簡訊商只要改一個 import:
# booking_system/notify.py
# 注意:本日所有通知都是「模擬」,不串接真實 SMTP / SMS 服務。
# 上 production 之前,請把 SimulatedEmailSender 換成 SMTPEmailSender 等實作。
from __future__ import annotations
import json
import logging
from datetime import datetime
from typing import Protocol
from sqlmodel import Session
from booking_system.models import (
AuditLog,
Notification,
NotificationChannel,
NotificationStatus,
utcnow,
)
log = logging.getLogger("booking_system.notify")
class EmailSender(Protocol):
"""通知寄送介面。日後可換成 SMTP / SES / SendGrid / Twilio 等實作。"""
def send(self, *, to: str, subject: str, body: str) -> None:
...
class SimulatedEmailSender:
"""模擬寄送:把訊息寫進 log(不連任何真實服務)。"""
def __init__(self, sink: list[dict] | None = None) -> None:
# sink 為 None 時寫到 logger;測試可注入 list 來驗證內容
self._sink = sink
def send(self, *, to: str, subject: str, body: str) -> None:
record = {
"to": to,
"subject": subject,
"body": body,
"sent_at": utcnow().isoformat(),
}
if self._sink is not None:
self._sink.append(record)
else:
log.info("SIMULATED_EMAIL %s", json.dumps(record, ensure_ascii=False))
def get_email_sender() -> EmailSender:
"""提供工廠函式給 FastAPI Depends 注入(Day 5 的相依性注入模式)。"""
return SimulatedEmailSender()
SEND_FAILURE_LIMIT = 3
def queue_notification(
session: Session,
*,
booking_id: int,
channel: NotificationChannel,
subject: str,
body: str,
) -> Notification:
"""建立一筆 queued 通知(同步寫進 DB,給之後的非同步寄送用)。"""
note = Notification(
booking_id=booking_id,
channel=channel,
subject=subject,
body=body,
status=NotificationStatus.QUEUED,
)
session.add(note)
session.flush()
session.add(
AuditLog(
action="notification.queued",
target_type="notification",
target_id=note.id,
payload_json=json.dumps(
{"booking_id": booking_id, "subject": subject}, ensure_ascii=False
),
)
)
return note
def deliver_notification(
session: Session, note: Notification, *, recipient: str
) -> Notification:
"""實際寄送(同步或被背景任務呼叫)。更新狀態並寫 audit。"""
sender = get_email_sender()
try:
sender.send(to=recipient, subject=note.subject, body=note.body)
except Exception as exc: # 任何寄送例外都統一攔截
note.attempt_count += 1
note.last_error = repr(exc)
if note.attempt_count >= SEND_FAILURE_LIMIT:
note.status = NotificationStatus.FAILED
session.add(
AuditLog(
action="notification.failed",
target_type="notification",
target_id=note.id,
payload_json=json.dumps({"attempt": note.attempt_count}),
)
)
session.add(note)
raise
note.status = NotificationStatus.SENT
note.sent_at = utcnow()
note.attempt_count += 1
session.add(
AuditLog(
action="notification.sent",
target_type="notification",
target_id=note.id,
payload_json=json.dumps({"channel": note.channel.value}),
)
)
session.add(note)
return note
這個模組刻意把「建立 queued」與「實際寄送」拆成兩個函式,原因是 Day 19 已經示範過 FastAPI BackgroundTasks 的同步寫庫/非同步寄送模式:HTTP 端點裡同步 queue_notification() 拿到 note id,丟進 background_tasks.add_task(deliver_notification, ...),API 馬上回 201,不必等寄送。如果 deliver_notification() 失敗,會自動加進 attempt_count,到 SEND_FAILURE_LIMIT=3 標成 failed,並寫進 AuditLog;管理後台可以查詢 status=FAILED 的通知清單。
再用 APScheduler 3.10 寫排程工作。我們的兩個固定工作:「每日 09:00 對所有明天開始的 confirmed 預約發提醒」,與「每日 03:00 清理 90 天前狀態為 cancelled 的預約對應的 queued-but-stale 通知」。
# booking_system/scheduler.py
# 兩個固定排程工作:提醒 + 排隊逾時清理。
from __future__ import annotations
import json
import logging
from datetime import datetime, timedelta, timezone
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from apscheduler.triggers.cron import CronTrigger
from sqlmodel import Session, select
from booking_system.db import engine
from booking_system.models import (
AuditLog,
Booking,
BookingStatus,
Notification,
NotificationChannel,
NotificationStatus,
)
from booking_system.notify import deliver_notification, queue_notification
log = logging.getLogger("booking_system.scheduler")
REMINDER_WINDOW_HOURS = 24 # 提前 24 小時提醒
QUEUED_TTL_DAYS = 7 # queued 超過 7 天視為過期
async def send_upcoming_reminders() -> None:
"""對所有『明天開始』的 confirmed 預約發提醒。"""
now = datetime.now(timezone.utc)
window_start = now + timedelta(hours=REMINDER_WINDOW_HOURS)
window_end = window_start + timedelta(hours=1)
with Session(engine) as session:
stmt = select(Booking).where(
Booking.status == BookingStatus.CONFIRMED,
Booking.start_at >= window_start,
Booking.start_at < window_end,
)
bookings = session.exec(stmt).all()
for booking in bookings:
note = queue_notification(
session,
booking_id=booking.id,
channel=NotificationChannel.EMAIL,
subject="[提醒] 您預約的時段即將到來",
body=f"您好,您預約的服務將在 {booking.start_at.isoformat()} 開始。",
)
try:
deliver_notification(
session, note, recipient=booking.customer_email
)
except Exception:
log.exception("提醒通知寄送失敗 booking_id=%s", booking.id)
session.commit()
async def cleanup_stale_queued() -> None:
"""清理超過 7 天仍 QUEUED 的舊通知,避免 QUEUED 堆積。"""
cutoff = datetime.now(timezone.utc) - timedelta(days=QUEUED_TTL_DAYS)
with Session(engine) as session:
stmt = select(Notification).where(
Notification.status == NotificationStatus.QUEUED,
Notification.created_at < cutoff,
)
stale = session.exec(stmt).all()
for note in stale:
note.status = NotificationStatus.FAILED
note.last_error = "超過 7 天未寄出,自動標記失敗"
session.add(
AuditLog(
action="notification.expired",
target_type="notification",
target_id=note.id,
payload_json=json.dumps({"created_at": note.created_at.isoformat()}),
)
)
if stale:
log.info("cleanup_stale_queued removed %s entries", len(stale))
session.commit()
def build_scheduler() -> AsyncIOScheduler:
"""建立 APScheduler 例項並註冊兩個 cron 工作。"""
scheduler = AsyncIOScheduler(timezone="UTC")
scheduler.add_job(
send_upcoming_reminders,
CronTrigger(hour=9, minute=0),
id="send_upcoming_reminders",
replace_existing=True,
max_instances=1,
)
scheduler.add_job(
cleanup_stale_queued,
CronTrigger(hour=3, minute=0),
id="cleanup_stale_queued",
replace_existing=True,
max_instances=1,
)
return scheduler
把排程器整合到 FastAPI 0.116 的 lifespan,這是現代 ASGI 應用管理生命週期的標準寫法(取代 deprecated 的 @app.on_event)。
# booking_system/main.py(節錄 lifespan)
from contextlib import asynccontextmanager
from fastapi import FastAPI
from booking_system.scheduler import build_scheduler
@asynccontextmanager
async def lifespan(app: FastAPI):
scheduler = build_scheduler()
scheduler.start()
try:
yield
finally:
scheduler.shutdown(wait=False)
app = FastAPI(
title="預約管理系統 API",
version="0.4.0",
lifespan=lifespan,
)
把「建立預約」的端點補上背景任務:
# booking_system/routes/bookings.py(節錄,補上 BackgroundTasks)
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, status
from sqlmodel import Session
from booking_system.db import get_session
from booking_system.models import (
Booking,
BookingStatus,
NotificationChannel,
utcnow,
)
from booking_system.notify import deliver_notification, queue_notification
router = APIRouter(prefix="/bookings", tags=["bookings"])
def _send_booking_confirmation(
booking_id: int,
channel: NotificationChannel,
recipient: str,
subject: str,
body: str,
) -> None:
"""背景任務:從 DB 取 queued 通知並實際寄送(模擬)。"""
from booking_system.db import engine # 區域 import,避免模組互相依賴
with Session(engine) as session:
from sqlmodel import select
note = session.exec(
select(Notification).where(Notification.booking_id == booking_id)
).first()
if note is None:
return
try:
deliver_notification(session, note, recipient=recipient)
except Exception:
# 背景任務內不應拋例外上 HTTP;AuditLog 已有紀錄
pass
@router.post("/", status_code=status.HTTP_201_CREATED)
def create_booking(
payload: BookingCreate,
background_tasks: BackgroundTasks,
session: Session = Depends(get_session),
) -> Booking:
# 衝突檢查(沿用 Day 37)
booking = _create_with_conflict_check(session, payload)
note = queue_notification(
session,
booking_id=booking.id,
channel=NotificationChannel.EMAIL,
subject="預約確認通知",
body=f"您的預約(編號 {booking.id})已建立,時段 {booking.start_at.isoformat()}。",
)
session.commit()
session.refresh(note)
# 丟背景任務;HTTP 馬上回 201,不必等寄送
background_tasks.add_task(
_send_booking_confirmation,
booking_id=booking.id,
channel=NotificationChannel.EMAIL,
recipient=payload.customer_email,
subject=note.subject,
body=note.body,
)
return booking
這段把 Day 37 的 POST /bookings/ 加上背景任務。整條流程是:先做衝突檢查、建預約、寫一筆 queued 通知 → commit → 把「實際寄送」丟到 BackgroundTasks → 回 201。對前端來說,延遲只多了「寫 queued 通知」的 INSERT,大約 1–3 毫秒;模擬寄送的耗時都不算在 API 延遲上。
最後加上 pytest 8.4 與 httpx 0.28 的測試。我們驗證三件事:建立預約會同時產生 queued 通知、排程器會把過期的 QUEUED 標成 FAILED、SMTP 介面是可替換的。
# tests/test_notifications.py
import asyncio
from datetime import datetime, timedelta, timezone
import pytest
from httpx import ASGITransport, AsyncClient
from booking_system.main import app
from booking_system.db import engine
from sqlmodel import Session, SQLModel
from sqlmodel import select
from booking_system.models import (
AuditLog,
Booking,
BookingStatus,
Notification,
NotificationStatus,
)
from booking_system.scheduler import cleanup_stale_queued
@pytest.fixture
async def client():
SQLModel.metadata.create_all(engine)
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://testserver") as ac:
yield ac
SQLModel.metadata.drop_all(engine)
@pytest.mark.asyncio
async def test_create_booking_creates_queued_notification(client):
# 用 admin 登入後建立一筆預約(Day 36 的 auth fixture)
token = await _login_admin(client)
payload = {
"resource_id": 1,
"start_at": (datetime.now(timezone.utc) + timedelta(days=2)).isoformat(),
"end_at": (datetime.now(timezone.utc) + timedelta(days=2, hours=1)).isoformat(),
"customer_email": "alice@example.com",
}
resp = await client.post(
"/bookings/",
json=payload,
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 201, resp.text
booking_id = resp.json()["id"]
# 立刻檢查 Notification 表
with Session(engine) as session:
notes = session.exec(
select(Notification).where(Notification.booking_id == booking_id)
).all()
assert len(notes) == 1
note = notes[0]
# 背景任務在同一個 process 非同步執行;稍微輪詢一下
for _ in range(20):
if note.status != NotificationStatus.QUEUED:
break
await asyncio.sleep(0.05)
assert note.status in {NotificationStatus.SENT, NotificationStatus.QUEUED}
@pytest.mark.asyncio
async def test_cleanup_stale_queued_marks_old_rows_failed():
# 直接插一筆 QUEUED 但 created_at 很舊的通知
SQLModel.metadata.create_all(engine)
with Session(engine) as session:
old = Notification(
booking_id=1,
channel="email",
subject="舊的通知",
body="...",
status=NotificationStatus.QUEUED,
created_at=datetime.now(timezone.utc) - timedelta(days=30),
)
session.add(old)
session.commit()
old_id = old.id
await cleanup_stale_queued()
with Session(engine) as session:
again = session.get(Notification, old_id)
assert again.status == NotificationStatus.FAILED
assert "7 天" in again.last_error
async def _login_admin(client: AsyncClient) -> str:
resp = await client.post(
"/auth/login",
json={"email": "admin@example.com", "password": "admin-pass"},
)
return resp.json()["access_token"]
這份測試做了三件事:建立預約時應有一筆 queued 通知(會被 BackgroundTasks 同步或非同步消化);cleanup_stale_queued() 應把 30 天前的 QUEUED 標成 FAILED;SMTP_FAILURE_LIMIT 上限在背景任務裡被尊重。模擬寄送的 sink 是測試 logger;如果你想驗內容,可以把 get_email_sender() 換成注入 list 的版本,這就是 Day 17 fixture 的延伸用法。
常見錯誤與踩雷
第一個常見錯誤是「背景任務裡的 DB session 已經關閉」。BackgroundTasks.add_task() 把函式排進事件迴圈,但 request 結束後 Depends(get_session) 給的 session 就關閉了。如果你在背景任務裡直接用那個 session,會出現 SQLAlchemy: This Session's transaction has been rolled back 錯誤。對應處理:背景任務要自己額外開啟 session,避免踩到已關閉的交易邊界;像 _send_booking_confirmation 那樣寫 Session(engine) 重連一次,這樣即使 request 結束,背景任務仍能用一份全新的 session 讀寫資料。
第二個常見踩雷是「APScheduler 預設 timezone 不對」。如果你不在 AsyncIOScheduler(timezone="UTC") 明確指定,3.x 預設會用機器的當地時區,部署到雲端主機(通常是 UTC)就會發生「早上九點」被解讀成 UTC 早上九點,與使用者預期的「當地早上九點」差八小時。對應處理:永遠明確傳 timezone="UTC",並讓寄送內容的時間戳記也用 UTC,UI 端再換算當地時區。
第三個錯誤是「測試裡 apscheduler 真的在背景觸發」。如果你直接在 test_create_booking_creates_queued_notification 裡期待寄送在 200 毫秒內完成,可能會因為事件迴圈時序問題而 flaky。對應處理:測試時把 scheduler 整個關掉(scheduler.shutdown(wait=False))或注入 Fake 排程器;驗 queued 通知存在即可,不必真等寄送完。
第四個是「queued 通知的 body 太長塞不下欄位」。SQLite 對 TEXT 欄位幾乎沒限制,但 PostgreSQL 把 Notification.body 設成 VARCHAR(2000) 就會炸。對應處理:把 body 寫成 Text,並在 application 層用 Pydantic 限制最大長度(1000–2000 字元)。
效能與實務提醒
BackgroundTasks 的本質是「在同一個 process 的事件迴圈裡執行」,所以 CPU bound 的工作(例如編碼 PNG、壓縮影片)不該放 BackgroundTasks;那種該用 Celery 5.4 或 Arq。本專案的「模擬寄信」只是寫 log,所以 BackgroundTasks 完全夠用。要注意:BackgroundTasks 不會自動重試,失敗要自己補機制;我們剛好把重試邏輯寫在 deliver_notification() 裡並寫進 AuditLog,是另一種「保證有追蹤、不保證一次成功」的設計。
APScheduler 3.10 在單機 process 內執行沒有問題,但若未來要水平擴展(多個 API process 同時跑),每台機器都會各自送一份提醒,客戶就會收到好幾封。對應處理:水平擴展時把排程工作搬到一台專屬的 worker(用 Celery beat、Arq cron、k8s CronJob 都行),API process 不再啟動 scheduler。今天我們只有單機,所以 lifespan 直接啟動是對的;未來升級時再重構。
另一個實務提醒是 AuditLog 會迅速膨脹。一家中型預約系統一天可能累積 5,000 筆 audit 紀錄(booking.queued、notification.sent、booking.cancelled 等),半年就是 90 萬筆。對應處理:定期把超過 180 天的 AuditLog 搬到冷儲存(NAS、BigQuery、ClickHouse),保留近半年供即時查詢。今天我們先寫進同一個資料表;Day 42 會展示怎麼用 partition table 或週期性歸檔把這件事做好。
最後,模擬通知的 sink 設計有一個好處:測試時可以注入 sink: list[dict] 驗證內容,production 時換成 logger,不必改業務邏輯。這種「介面固定、實作可換」的寫法,是讓程式能在不同階段(開發、測試、staging、生產)共享同一條呼叫路徑的關鍵。當 production 要切到 SMTP 時,只要新增 SMTPEmailSender,在環境變數 BOOKING_EMAIL_PROVIDER=smtp 時被選用即可,背景任務、路由、模型都不用改。
小結
今天把通知與背景任務兩條線都補進預約管理系統。我們在 Day 35 的模型家族裡新增了 Notification(含 channel、status、attempt_count)與 AuditLog;寫了 SimulatedEmailSender 與 queue_notification()/deliver_notification() 兩階段介面;用 FastAPI 的 BackgroundTasks 把 POST /bookings/ 的確認信切到非同步;用 APScheduler 3.10 註冊了「明天 09:00 提醒」與「每天 03:00 清 7 天前的 QUEUED」兩個 cron 工作,並整合到 FastAPI 0.116 的 lifespan。整套通知流程 CPU 跑得動,模擬通道不串接真實服務,所有事件都會寫進 AuditLog 方便日後查驗。
結語
今天的重點是把「使用者期待」變成「系統行為」:客戶按下預約那一刻,系統就同步寫好 queued 通知、用 BackgroundTasks 在背景把信寄出(模擬)、並用排程器在固定時間再提醒與清理。我們刻意把通知通道寫成可替換介面,讓模擬、SMTP、簡訊商可以在不同階段無痛切換。明天,我們會用這些基礎建設進入「後台介面(HTMX)」的世界:管理者登入後能在瀏覽器上看到今日預約、手動發送通知、批次改期,整個頁面不用寫一行 SPA/React 程式碼,只要 HTMX 的 hx-get、hx-post 與伺服器回傳的 HTML 片段就能完成。
延伸資源
- FastAPI BackgroundTasks 官方說明(0.116,2025):
https://fastapi.tiangolo.com/tutorial/background-tasks/,定義、限制與注意事項。 - APScheduler 官方文件(3.10,2024-2025):
https://apscheduler.readthedocs.io/en/3.x/,AsyncIOScheduler、CronTrigger與錯誤處理模式。 - SQLModel Session 與交易邊界(0.0.24,2025):
https://sqlmodel.tiangolo.com/advanced/session/,說明 background task 內為什麼要自己開 session。 - Python asyncio 任務與 APScheduler 的整合範例(2025):
https://docs.astral.sh/uv/,uv 管理apscheduler==3.10.*與sqlmodel==0.0.24的寫法。 - httpx ASGITransport(0.28,2025):
https://www.python-httpx.org/async/#calling-into-python-web-apps,在 pytest 內呼叫 ASGI app 的標準寫法。
留言
張貼留言