跳转至

第 4 周 参考答案(默认折叠)

做完再看

先让自己的测试全部通过,再展开对照。看完合上,凭记忆把自己的版本重写一遍——只看不写等于没学(见 ai-guide.md 规则 5)。

点击展开:w4_01_fastapi_basics/app.py
exercises/solutions/week4/w4_01_fastapi_basics/app.py
"""第 4 周 · 练习 1 参考答案:FastAPI 入门的 5 个端点。

用法:uv run pytest exercises/week4/w4_01_fastapi_basics -q(先把答案复制成练习目录的
app.py)
      uv run fastapi dev exercises/solutions/week4/w4_01_fastapi_basics/app.py
说明:写法不必和这里一致,行为对就行。几个值得注意的地方:
- `response_model=NoteOut` 让响应只包含模型字段,多传的字段不会泄漏;
- 404 用 `HTTPException`,detail 里带上 id 方便排查;
- 204 的函数返回 `None`,FastAPI 不会写响应体;
- 查询参数的约束写在 `Query(...)` 里,越界由框架返回 422,端点里不用手写 if。
"""

from typing import Annotated

from fastapi import FastAPI, HTTPException, Query
from pydantic import BaseModel, Field

app = FastAPI(title="Notes API(第 4 周练习 1)", version="0.1.0")


class NoteIn(BaseModel):
    """客户端发来的笔记:标题必填且非空,正文与标签可省略。

    例:NoteIn(title="读书笔记", tags=["python"]).body -> ""
    """

    title: Annotated[str, Field(min_length=1, max_length=100)]
    body: str = ""
    tags: list[str] = Field(default_factory=list)


class NoteOut(BaseModel):
    """返回给客户端的笔记:比 NoteIn 多一个 id。

    例:NoteOut(id=1, title="t", body="", tags=[]).id -> 1
    """

    id: int
    title: str
    body: str
    tags: list[str]


NOTES: dict[int, NoteOut] = {}
_next_id = 1


def _new_id() -> int:
    """返回下一个笔记 id(从 1 开始自增)。

    例:空存储时第一次调用 -> 1,第二次 -> 2
    """
    global _next_id
    note_id = _next_id
    _next_id += 1
    return note_id


def reset_storage() -> None:
    """清空内存存储并把自增 id 归位(测试的 fixture 会调用它)。

    例:reset_storage() 之后 NOTES == {} 且下一个 id 是 1
    """
    global _next_id
    NOTES.clear()
    _next_id = 1


@app.get("/health")
def health() -> dict[str, str]:
    """健康检查:固定返回 {"status": "ok"}。

    例:GET /health -> 200 {"status": "ok"}
    """
    return {"status": "ok"}


@app.get("/notes", response_model=list[NoteOut])
def list_notes(
    tag: str | None = None,
    limit: Annotated[int, Query(ge=1, le=50)] = 10,
) -> list[NoteOut]:
    """按 id 升序列出笔记;给了 tag 就只留标签里含它的;最多返回 limit 条。

    例:GET /notes?tag=python&limit=2 -> 200 [最多 2 条带 python 标签的笔记]
        GET /notes?limit=0 -> 422(Query(ge=1) 帮你挡掉)
    """
    notes = [NOTES[note_id] for note_id in sorted(NOTES)]
    if tag is not None:
        notes = [note for note in notes if tag in note.tags]
    return notes[:limit]


@app.get("/notes/{note_id}", response_model=NoteOut)
def get_note(note_id: int) -> NoteOut:
    """按 id 取一条笔记;不存在时 raise HTTPException(404, ...)。

    例:GET /notes/1 -> 200 {"id": 1, ...};GET /notes/999 -> 404
    """
    note = NOTES.get(note_id)
    if note is None:
        raise HTTPException(status_code=404, detail=f"note {note_id} not found")
    return note


@app.post("/notes", response_model=NoteOut, status_code=201)
def create_note(payload: NoteIn) -> NoteOut:
    """新建笔记:分配 id、存进 NOTES、返回 201 与新笔记。

    例:POST /notes {"title": "t"} -> 201 {"id": 1, "title": "t", "body": "", "tags":
    []}
    """
    note = NoteOut(id=_new_id(), **payload.model_dump())
    NOTES[note.id] = note
    return note


@app.delete("/notes/{note_id}", status_code=204)
def delete_note(note_id: int) -> None:
    """删除一条笔记:成功返回 204 空响应;不存在时 404。

    例:DELETE /notes/1 -> 204 且响应体为空;再 DELETE /notes/1 -> 404
    """
    if NOTES.pop(note_id, None) is None:
        raise HTTPException(status_code=404, detail=f"note {note_id} not found")
点击展开:w4_02_fastapi_structure/notes_app/init.py
exercises/solutions/week4/w4_02_fastapi_structure/notes_app/__init__.py
1
2
3
4
5
6
"""第 4 周 · 练习 2 · 分层后的 notes 应用包。

分层:routers(协议层,只做 HTTP ↔ 模型转换)→ repository(存储,Protocol + 内存实现),
      schemas(Pydantic 传输模型)、errors(领域异常)、deps(依赖注入)、main(组装)。
用法:uv run pytest exercises/week4/w4_02_fastapi_structure -q
"""
点击展开:w4_02_fastapi_structure/notes_app/deps.py
exercises/solutions/week4/w4_02_fastapi_structure/notes_app/deps.py
"""依赖注入(已给出,不用改):设置与仓储从这里取。

要点:`Depends` 只是"构造函数传依赖"的框架版;测试里用
      `app.dependency_overrides[get_repo] = lambda: InMemoryNoteRepository()`
      换掉真实现,
      用完记得 `app.dependency_overrides.clear()`(忘了会污染下一个测试)。
用法:uv run pytest exercises/week4/w4_02_fastapi_structure -q
"""

from functools import lru_cache
from typing import Annotated

from fastapi import Depends, Request
from pydantic_settings import BaseSettings, SettingsConfigDict

from notes_app.repository import NoteRepository


class Settings(BaseSettings):
    """应用设置:环境变量 NOTES_APP_NAME / NOTES_MAX_PAGE_SIZE 可覆盖默认值。

    例:Settings(max_page_size=2).max_page_size -> 2
    """

    model_config = SettingsConfigDict(env_prefix="NOTES_", extra="ignore")

    app_name: str = "notes-app"
    max_page_size: int = 50


@lru_cache
def get_settings() -> Settings:
    """返回全局唯一的设置对象(lru_cache = 模块级单例的正经写法)。

    例:get_settings() is get_settings() -> True
    """
    return Settings()


def get_repo(request: Request) -> NoteRepository:
    """返回 lifespan 在启动时挂到 app.state 上的仓储。

    例:测试里用 dependency_overrides 把它换成 InMemoryNoteRepository 实例
    """
    repo: NoteRepository = request.app.state.repo
    return repo


SettingsDep = Annotated[Settings, Depends(get_settings)]
RepoDep = Annotated[NoteRepository, Depends(get_repo)]
点击展开:w4_02_fastapi_structure/notes_app/errors.py
exercises/solutions/week4/w4_02_fastapi_structure/notes_app/errors.py
"""领域异常层次(已给出,不用改):这个文件绝不 import fastapi。

为什么:存储层/服务层只知道"没找到""重名了",把它翻译成哪个 HTTP 状态码是协议层的事。
用法:uv run pytest exercises/week4/w4_02_fastapi_structure -q
"""


class NoteError(Exception):
    """笔记领域所有异常的基类。

    例:isinstance(NoteNotFoundError(1), NoteError) -> True
    """


class NoteNotFoundError(NoteError):
    """按 id 找不到笔记(协议层映射成 404)。

    例:str(NoteNotFoundError(3)) -> 'note 3 not found'
    """

    def __init__(self, note_id: int) -> None:
        """记下找不到的 id,方便日志与响应体里带上它。"""
        super().__init__(f"note {note_id} not found")
        self.note_id = note_id


class NoteConflictError(NoteError):
    """标题重复(协议层映射成 409)。

    例:str(NoteConflictError("读书笔记")) -> "note titled '读书笔记' already exists"
    """

    def __init__(self, title: str) -> None:
        """记下冲突的标题。"""
        super().__init__(f"note titled {title!r} already exists")
        self.title = title
点击展开:w4_02_fastapi_structure/notes_app/main.py
exercises/solutions/week4/w4_02_fastapi_structure/notes_app/main.py
"""组装层参考答案:create_app(工厂)、lifespan、异常处理器、/health。

用法:uv run pytest exercises/week4/w4_02_fastapi_structure -q(先把答案复制进练习目录)
      uv run uvicorn notes_app.main:app --reload(在 w4_02 目录下运行)
说明:为什么用工厂函数而不是模块级 `app = FastAPI()`:每个测试可以拿到一个全新的 app,
      互不干扰;`dependency_overrides` 也不会串味。领域异常在这里、且只在这里变成 HTTP。
"""

from collections.abc import AsyncIterator, Awaitable, Callable
from contextlib import asynccontextmanager

from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse

from notes_app.deps import RepoDep, SettingsDep
from notes_app.errors import NoteConflictError, NoteNotFoundError
from notes_app.repository import InMemoryNoteRepository, NoteRepository
from notes_app.routers import notes

TITLE = "Notes API(第 4 周练习 2)"
ROUTERS = [notes.router]


def build_repo() -> NoteRepository:
    """建一个仓储实例(已给出):真实项目里这里换成 SQLite 实现,其他代码不用动。

    例:build_repo().count() -> 0
    """
    return InMemoryNoteRepository()


def error_response(status_code: int, exc: Exception) -> JSONResponse:
    """把领域异常包成 {"detail": 消息} 的 JSON 响应(已给出)。

    例:error_response(404, NoteNotFoundError(3)).status_code -> 404
    """
    return JSONResponse(status_code=status_code, content={"detail": str(exc)})


@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
    """启动时把仓储挂到 app.state.repo,关闭时清掉(替代已弃用的 on_event)。

    例:`with TestClient(create_app()) as c:` 进入时建仓储,退出时清理
    """
    app.state.repo = build_repo()
    yield
    del app.state.repo


def health(settings: SettingsDep, repo: RepoDep) -> dict[str, object]:
    """健康检查:返回 {"status": "ok", "app": 应用名, "notes": 当前笔记数}。

    例:GET /health -> 200 {"status": "ok", "app": "notes-app", "notes": 0}
    """
    return {"status": "ok", "app": settings.app_name, "notes": repo.count()}


async def note_not_found_handler(request: Request, exc: Exception) -> JSONResponse:
    """把领域异常 NoteNotFoundError 映射成 404 {"detail": 异常消息}。

    例:NoteNotFoundError(3) -> 404 {"detail": "note 3 not found"}
    """
    return error_response(404, exc)


async def note_conflict_handler(request: Request, exc: Exception) -> JSONResponse:
    """把领域异常 NoteConflictError 映射成 409 {"detail": 异常消息}。

    例:NoteConflictError("t") -> 409 {"detail": "note titled 't' already exists"}
    """
    return error_response(409, exc)


# 领域异常 → 处理器的映射表(已给出):注册表模式就是"装饰器/字典 + 循环"
EXCEPTION_HANDLERS: dict[
    type[Exception], Callable[[Request, Exception], Awaitable[JSONResponse]]
] = {
    NoteNotFoundError: note_not_found_handler,
    NoteConflictError: note_conflict_handler,
}


def create_app() -> FastAPI:
    """应用工厂:建 FastAPI 实例、注册异常处理器、挂 /health 与 notes 路由。

    例:create_app().title == TITLE -> True
    """
    app = FastAPI(title=TITLE, version="0.1.0", lifespan=lifespan)
    for exc_type, handler in EXCEPTION_HANDLERS.items():
        app.add_exception_handler(exc_type, handler)
    app.add_api_route("/health", health, methods=["GET"], tags=["ops"])
    for router in ROUTERS:
        app.include_router(router)
    return app


app = create_app()
点击展开:w4_02_fastapi_structure/notes_app/repository.py
exercises/solutions/week4/w4_02_fastapi_structure/notes_app/repository.py
"""领域对象 + 仓储接口 + 内存实现(已给出,不用改):这个文件也不 import fastapi。

要点:`NoteRepository` 是 typing.Protocol——"谁长这样谁就能用",不需要继承。
      路由只依赖这个协议,测试时塞一个内存实现,线上塞 SQLite 实现,代码不变。
用法:uv run pytest exercises/week4/w4_02_fastapi_structure -q
"""

from dataclasses import dataclass, field
from typing import Protocol

from notes_app.errors import NoteConflictError, NoteNotFoundError


@dataclass(slots=True)
class Note:
    """领域模型:一条笔记(普通 dataclass,不带任何 Web 概念)。

    例:Note(1, "读书笔记", "", ["python"]).title -> '读书笔记'
    """

    id: int
    title: str
    body: str
    tags: list[str] = field(default_factory=list)


class NoteRepository(Protocol):
    """笔记存储的接口:找不到抛 NoteNotFoundError,重名抛 NoteConflictError。

    例:InMemoryNoteRepository() 就是它的一个实现(无需继承)
    """

    def add(self, title: str, body: str, tags: list[str]) -> Note:
        """新增一条笔记并返回它(带上分配好的 id)。"""
        ...

    def get(self, note_id: int) -> Note:
        """按 id 取笔记;不存在时抛 NoteNotFoundError。"""
        ...

    def list(self, tag: str | None = None, limit: int = 10) -> list[Note]:
        """按 id 升序列出笔记,可按标签过滤,最多 limit 条。"""
        ...

    def update(
        self,
        note_id: int,
        title: str | None = None,
        body: str | None = None,
        tags: list[str] | None = None,
    ) -> Note:
        """局部更新:只改传进来的字段,返回更新后的笔记。"""
        ...

    def delete(self, note_id: int) -> None:
        """按 id 删除;不存在时抛 NoteNotFoundError。"""
        ...

    def count(self) -> int:
        """当前笔记条数。"""
        ...


class InMemoryNoteRepository:
    """内存实现:一个 dict 加一个自增 id,测试用它就够了。

    例:repo = InMemoryNoteRepository();repo.add("t", "", []).id -> 1
    """

    def __init__(self) -> None:
        """建一个空存储。"""
        self._notes: dict[int, Note] = {}
        self._next_id = 1

    def add(self, title: str, body: str, tags: list[str]) -> Note:
        """新增一条笔记;标题已存在时抛 NoteConflictError。

        例:repo.add("读书笔记", "", []) 两次 -> 第二次抛 NoteConflictError
        """
        if any(note.title == title for note in self._notes.values()):
            raise NoteConflictError(title)
        note = Note(id=self._next_id, title=title, body=body, tags=list(tags))
        self._notes[note.id] = note
        self._next_id += 1
        return note

    def get(self, note_id: int) -> Note:
        """按 id 取笔记;不存在时抛 NoteNotFoundError。

        例:空仓储 repo.get(7) -> raise NoteNotFoundError
        """
        try:
            return self._notes[note_id]
        except KeyError:
            raise NoteNotFoundError(note_id) from None

    def list(self, tag: str | None = None, limit: int = 10) -> list[Note]:
        """按 id 升序列出笔记,可按标签过滤,最多 limit 条。

        例:repo.list(tag="python", limit=2) -> 最多 2 条带 python 标签的笔记
        """
        notes = [self._notes[note_id] for note_id in sorted(self._notes)]
        if tag is not None:
            notes = [note for note in notes if tag in note.tags]
        return notes[:limit]

    def update(
        self,
        note_id: int,
        title: str | None = None,
        body: str | None = None,
        tags: list[str] | None = None,
    ) -> Note:
        """局部更新;改标题撞上别人的标题时抛 NoteConflictError。

        例:repo.update(1, body="新正文").body -> '新正文'
        """
        note = self.get(note_id)
        if title is not None and title != note.title:
            if any(
                other.title == title
                for other in self._notes.values()
                if other.id != note_id
            ):
                raise NoteConflictError(title)
            note.title = title
        if body is not None:
            note.body = body
        if tags is not None:
            note.tags = list(tags)
        return note

    def delete(self, note_id: int) -> None:
        """按 id 删除;不存在时抛 NoteNotFoundError。

        例:repo.delete(1) 之后 repo.count() 少 1
        """
        if self._notes.pop(note_id, None) is None:
            raise NoteNotFoundError(note_id)

    def count(self) -> int:
        """当前笔记条数。

        例:空仓储 repo.count() -> 0
        """
        return len(self._notes)
点击展开:w4_02_fastapi_structure/notes_app/routers/init.py
exercises/solutions/week4/w4_02_fastapi_structure/notes_app/routers/__init__.py
"""路由包:每个资源一个模块,由 main.create_app() 用 include_router 挂上去。"""
点击展开:w4_02_fastapi_structure/notes_app/routers/notes.py
exercises/solutions/week4/w4_02_fastapi_structure/notes_app/routers/notes.py
"""notes 路由(协议层):只做 HTTP ↔ 模型转换,业务规则在仓储/服务层。

规则:这里**不写** try/except 把领域异常翻译成 HTTPException——那是 main.py 里
      异常处理器的活。路由让 NoteNotFoundError / NoteConflictError 直接往上抛。
用法:uv run pytest exercises/week4/w4_02_fastapi_structure -q(先把答案复制进练习目录)
说明:注意每个函数都短到一眼能看完——协议层没有 if/else 的业务分支,也没有 try/except。
"""

from typing import Annotated

from fastapi import APIRouter, Query

from notes_app.deps import RepoDep, SettingsDep
from notes_app.repository import Note
from notes_app.schemas import NoteIn, NoteOut, NotePatch

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


@router.get("", response_model=list[NoteOut])
def list_notes(
    repo: RepoDep,
    settings: SettingsDep,
    tag: str | None = None,
    limit: Annotated[int, Query(ge=1)] = 10,
) -> list[Note]:
    """列出笔记:可按 tag 过滤;limit 再大也不能超过 settings.max_page_size。

    例:GET /notes?tag=python&limit=100 且 max_page_size=50 -> 最多 50 条
    """
    return repo.list(tag=tag, limit=min(limit, settings.max_page_size))


@router.post("", response_model=NoteOut, status_code=201)
def create_note(payload: NoteIn, repo: RepoDep) -> Note:
    """新建笔记:201 + 新笔记;标题重复时让 NoteConflictError 抛出去(→ 409)。

    例:POST /notes {"title": "t"} -> 201 {"id": 1, ...}
    """
    return repo.add(payload.title, payload.body, payload.tags)


@router.get("/{note_id}", response_model=NoteOut)
def get_note(note_id: int, repo: RepoDep) -> Note:
    """取一条笔记;不存在时让 NoteNotFoundError 抛出去(→ 404)。

    例:GET /notes/999 -> 404 {"detail": "note 999 not found"}
    """
    return repo.get(note_id)


@router.patch("/{note_id}", response_model=NoteOut)
def patch_note(note_id: int, payload: NotePatch, repo: RepoDep) -> Note:
    """局部更新:只改请求里给了的字段,返回更新后的笔记。

    例:PATCH /notes/1 {"body": "新正文"} -> 200,title 不变
    """
    return repo.update(
        note_id, title=payload.title, body=payload.body, tags=payload.tags
    )


@router.delete("/{note_id}", status_code=204)
def delete_note(note_id: int, repo: RepoDep) -> None:
    """删除笔记:204 空响应;不存在时 NoteNotFoundError(→ 404)。

    例:DELETE /notes/1 -> 204;再删一次 -> 404
    """
    repo.delete(note_id)
点击展开:w4_02_fastapi_structure/notes_app/schemas.py
exercises/solutions/week4/w4_02_fastapi_structure/notes_app/schemas.py
"""传输模型(已给出,不用改):只描述"线上传什么",和存储里的领域对象分开。

要点:NoteOut 开了 from_attributes=True,所以路由可以直接 return 领域对象 Note,
      由 response_model 负责按字段名取属性并裁剪;这就是"边界处映射"。
用法:uv run pytest exercises/week4/w4_02_fastapi_structure -q
"""

from typing import Annotated

from pydantic import BaseModel, ConfigDict, Field


class NoteIn(BaseModel):
    """新建笔记的请求体:标题必填非空。

    例:NoteIn(title="读书笔记").tags -> []
    """

    title: Annotated[str, Field(min_length=1, max_length=100)]
    body: str = ""
    tags: list[str] = Field(default_factory=list)


class NotePatch(BaseModel):
    """局部更新的请求体:三个字段都可选,没传的字段保持原样。

    例:NotePatch(body="新正文").title is None -> True
    """

    title: Annotated[str | None, Field(default=None, min_length=1, max_length=100)]
    body: str | None = None
    tags: list[str] | None = None


class NoteOut(BaseModel):
    """响应体:从领域对象 Note 的属性读取。

    例:NoteOut.model_validate(Note(1, "t", "", [])).id -> 1
    """

    model_config = ConfigDict(from_attributes=True)

    id: int
    title: str
    body: str
    tags: list[str]
点击展开:w4_03_fastapi_async_sse/app.py
exercises/solutions/week4/w4_03_fastapi_async_sse/app.py
"""第 4 周 · 练习 3 参考答案:异步端点、后台任务、SSE、中间件、API key。

用法:uv run pytest exercises/week4/w4_03_fastapi_async_sse -q(先把答案复制进练习目录)
      uv run fastapi dev exercises/solutions/week4/w4_03_fastapi_async_sse/app.py
说明:几个要点
- `/slow-sync` 是 `def`,Starlette 把它丢线程池,所以阻塞的 time.sleep
不会卡住事件循环;
  `/slow-async` 是 `async def`,里面只能 `await asyncio.sleep`,写 time.sleep
  会卡住整个服务;
- SSE 生成器每条之间都有 `await`,客户端断开时才能被取消;
- 中间件在响应对象上加头,日志写进内存列表(真实项目写 logging);
- API key 缺失是 401(没认证),错误是 403(认证了但没权限)。
"""

import asyncio
import time
import uuid
from collections.abc import AsyncIterator, Awaitable, Callable
from functools import lru_cache
from typing import Annotated

from fastapi import (
    BackgroundTasks,
    Depends,
    FastAPI,
    Header,
    HTTPException,
    Query,
    Request,
    Response,
)
from fastapi.sse import EventSourceResponse, ServerSentEvent
from pydantic import BaseModel, Field
from pydantic_settings import BaseSettings, SettingsConfigDict

app = FastAPI(title="Async & SSE Notes(第 4 周练习 3)", version="0.1.0")


class Settings(BaseSettings):
    """设置:环境变量 W4_API_KEY / W4_TICK_SECONDS / W4_SLOW_SECONDS 可覆盖。

    例:Settings().api_key -> 'dev-key'
    """

    model_config = SettingsConfigDict(env_prefix="W4_", extra="ignore")

    api_key: str = "dev-key"
    tick_seconds: float = 0.05
    slow_seconds: float = 0.2


@lru_cache
def get_settings() -> Settings:
    """返回全局唯一的设置对象。

    例:get_settings() is get_settings() -> True
    """
    return Settings()


SettingsDep = Annotated[Settings, Depends(get_settings)]

REQUEST_LOG: list[dict[str, object]] = []
JOBS: dict[str, dict[str, object]] = {}


class ImportRequest(BaseModel):
    """导入请求:假装要导入 files 个文件。

    例:ImportRequest(files=3).files -> 3
    """

    files: Annotated[int, Field(ge=1, le=100)]


def reset_state() -> None:
    """清空请求日志与任务表(测试的 fixture 会调用它)。

    例:reset_state() 之后 JOBS == {} 且 REQUEST_LOG == []
    """
    REQUEST_LOG.clear()
    JOBS.clear()


def new_id() -> str:
    """生成 8 位随机 id:请求 id 与 job id 都用它。

    例:len(new_id()) -> 8
    """
    return uuid.uuid4().hex[:8]


def verify_api_key(
    settings: SettingsDep,
    x_api_key: Annotated[str | None, Header()] = None,
) -> str:
    """校验 X-API-Key 头:没带 401,带错 403,正确则返回它。

    例:headers={"X-API-Key": "dev-key"} -> 'dev-key'
    """
    if x_api_key is None:
        raise HTTPException(status_code=401, detail="missing X-API-Key header")
    if x_api_key != settings.api_key:
        raise HTTPException(status_code=403, detail="invalid API key")
    return x_api_key


@app.middleware("http")
async def log_requests(
    request: Request, call_next: Callable[[Request], Awaitable[Response]]
) -> Response:
    """请求日志中间件:给每个请求一个 id,记录耗时与状态码,并写进响应头。

    例:任何响应都带 X-Request-ID 与 X-Process-Time 两个头
    """
    request_id = new_id()
    started = time.perf_counter()
    response = await call_next(request)
    elapsed = time.perf_counter() - started
    REQUEST_LOG.append(
        {
            "request_id": request_id,
            "method": request.method,
            "path": request.url.path,
            "status": response.status_code,
            "elapsed": round(elapsed, 4),
        }
    )
    response.headers["X-Request-ID"] = request_id
    response.headers["X-Process-Time"] = f"{elapsed:.4f}"
    return response


@app.get("/health")
async def health() -> dict[str, str]:
    """健康检查。

    例:GET /health -> 200 {"status": "ok"}
    """
    return {"status": "ok"}


@app.get("/events/countdown", response_class=EventSourceResponse)
async def countdown(
    settings: SettingsDep,
    n: Annotated[int, Query(ge=1, le=20)] = 5,
) -> AsyncIterator[ServerSentEvent]:
    """SSE 端点(0.135+ 写法):端点函数本身是异步生成器,逐条 yield 事件。

    例:GET /events/countdown?n=3 -> 3 条 event=tick + 1 条 event=done
        线上格式:'event: tick' / 'data: {"remaining": 3}' / 空行
    """
    for remaining in range(n, 0, -1):
        await asyncio.sleep(settings.tick_seconds)
        yield ServerSentEvent(data={"remaining": remaining}, event="tick")
    yield ServerSentEvent(data={"total": n}, event="done")


async def run_import(job_id: str, files: int) -> None:
    """后台任务:逐个"导入"文件并更新 JOBS 里的进度。

    例:run_import("abc", 2) 结束后 JOBS["abc"]["status"] == 'done'
    """
    JOBS[job_id] = {"status": "running", "processed": 0, "total": files}
    for index in range(1, files + 1):
        await asyncio.sleep(0)
        JOBS[job_id]["processed"] = index
    JOBS[job_id]["status"] = "done"


@app.post("/import", status_code=202)
async def start_import(
    payload: ImportRequest, background_tasks: BackgroundTasks
) -> dict[str, str]:
    """收下导入请求,登记任务并立刻返回 202;真正的导入在响应之后跑。

    例:POST /import {"files": 3} -> 202 {"job_id": "...", "status": "queued"}
    """
    job_id = new_id()
    JOBS[job_id] = {"status": "queued", "processed": 0, "total": payload.files}
    background_tasks.add_task(run_import, job_id, payload.files)
    return {"job_id": job_id, "status": "queued"}


@app.get("/jobs/{job_id}")
async def get_job(job_id: str) -> dict[str, object]:
    """查任务进度;没有这个任务时 404。

    例:GET /jobs/abc -> {"status": "done", "processed": 3, "total": 3}
    """
    job = JOBS.get(job_id)
    if job is None:
        raise HTTPException(status_code=404, detail=f"job {job_id} not found")
    return job


@app.get("/slow-sync")
def slow_sync(settings: SettingsDep) -> dict[str, str]:
    """同步端点:阻塞 sleep 后返回。Starlette 会把它放线程池里跑。

    例:GET /slow-sync -> 200 {"kind": "sync"}
    """
    time.sleep(settings.slow_seconds)
    return {"kind": "sync"}


@app.get("/slow-async")
async def slow_async(settings: SettingsDep) -> dict[str, str]:
    """异步端点:await asyncio.sleep 后返回,5 个并发请求总耗时约等于一次。

    例:GET /slow-async -> 200 {"kind": "async"}
    """
    await asyncio.sleep(settings.slow_seconds)
    return {"kind": "async"}


@app.get("/admin/stats", dependencies=[Depends(verify_api_key)])
async def admin_stats() -> dict[str, int]:
    """受 X-API-Key 保护的统计端点。

    例:GET /admin/stats + 正确 key -> 200 {"jobs": 0, "requests": 1}
    """
    return {"jobs": len(JOBS), "requests": len(REQUEST_LOG)}
点击展开:w4_04_refactor_kata/cli.py
exercises/solutions/week4/w4_04_refactor_kata/cli.py
"""练习 4 参考答案 · 命令式外壳:解析参数、组装依赖、打印结果。

用法:uv run python cli.py add "读书笔记" "正文" python,web
      uv run python cli.py list [tag]
      uv run python cli.py search 笔记
      uv run python cli.py delete 1
      uv run python cli.py stats
      (数据库路径:--db 参数 > 环境变量 NOTES_DB > 默认 notes.db)
说明:这是全程序**唯一**允许 print 与 sys.exit 的地方,也是唯一知道"存储是
SQLite"的地方。
      领域异常在这里被翻译成人话 + 退出码;业务规则一行都没有。
"""

import argparse
import os
import sys
from pathlib import Path

from domain import NoteError, format_note
from repository import SqliteNoteRepository
from service import NoteService

DEFAULT_DB = "notes.db"


def build_parser() -> argparse.ArgumentParser:
    """定义命令行接口(五个子命令 + 全局 --db)。

    例:build_parser().parse_args(["list"]).command -> 'list'
    """
    parser = argparse.ArgumentParser(prog="notes", description="笔记小工具")
    parser.add_argument("--db", default=os.getenv("NOTES_DB", DEFAULT_DB))
    subparsers = parser.add_subparsers(dest="command", required=True)

    add_parser = subparsers.add_parser("add", help="添加一条笔记")
    add_parser.add_argument("title")
    add_parser.add_argument("body", nargs="?", default="")
    add_parser.add_argument("tags", nargs="?", default="")

    list_parser = subparsers.add_parser("list", help="列出笔记")
    list_parser.add_argument("tag", nargs="?", default=None)

    search_parser = subparsers.add_parser("search", help="搜索笔记")
    search_parser.add_argument("keyword")

    delete_parser = subparsers.add_parser("delete", help="删除一条笔记")
    delete_parser.add_argument("note_id", type=int)

    subparsers.add_parser("stats", help="看统计")
    return parser


def run_command(service: NoteService, args: argparse.Namespace) -> int:
    """执行一条命令并打印结果,返回退出码。

    例:run_command(svc, parser.parse_args(["stats"])) -> 0
    """
    if args.command == "add":
        note = service.add_note(args.title, args.body, args.tags)
        print(f"已添加 {format_note(note)}")
    elif args.command == "list":
        notes = service.list_notes(tag=args.tag)
        if not notes:
            print("(还没有笔记)")
        for note in notes:
            print(format_note(note))
    elif args.command == "search":
        notes = service.search_notes(args.keyword)
        print(f"命中 {len(notes)} 条")
        for note in notes:
            print(format_note(note))
    elif args.command == "delete":
        service.delete_note(args.note_id)
        print(f"已删除 #{args.note_id}")
    else:
        stats = service.stats()
        print(f"共 {stats.total} 条笔记")
        for tag in sorted(stats.tags):
            print(f"  {tag}: {stats.tags[tag]}")
        if stats.longest_title is not None:
            print(f"最长标题:{stats.longest_title}")
    return 0


def main(argv: list[str] | None = None) -> int:
    """入口:组装 SQLite 仓储 + 服务,跑命令,把领域异常翻译成退出码。

    例:main(["stats"]) -> 0;main(["delete", "999"]) -> 1(并打印一行错误)
    """
    args = build_parser().parse_args(argv)
    repo = SqliteNoteRepository(Path(args.db))
    service = NoteService(repo)
    try:
        return run_command(service, args)
    except NoteError as exc:
        print(f"错误:{exc}", file=sys.stderr)
        return 1
    finally:
        repo.close()


if __name__ == "__main__":
    sys.exit(main())
点击展开:w4_04_refactor_kata/domain.py
exercises/solutions/week4/w4_04_refactor_kata/domain.py
"""练习 4 参考答案 · 领域层:不可变模型、领域异常、纯函数。

用法:uv run pytest exercises/week4/w4_04_refactor_kata
-q(先把四个答案文件复制进练习目录)
说明:这个文件里没有任何 I/O——没有 sqlite3、没有 print、没有 datetime.now()。
      纯核心可以随便测:给一个输入就有一个确定的输出。
"""

from collections.abc import Iterable, Sequence
from dataclasses import dataclass

MAX_TITLE_LENGTH = 100


class NoteError(Exception):
    """笔记领域所有异常的基类。

    例:isinstance(NoteNotFoundError(1), NoteError) -> True
    """


class NoteNotFoundError(NoteError):
    """按 id 找不到笔记。

    例:str(NoteNotFoundError(7)) -> 'note 7 not found'
    """

    def __init__(self, note_id: int) -> None:
        """记下找不到的 id。"""
        super().__init__(f"note {note_id} not found")
        self.note_id = note_id


class NoteValidationError(NoteError):
    """输入不合法(标题为空、关键词为空……)。

    例:raise NoteValidationError("标题不能为空")
    """


class DuplicateNoteError(NoteError):
    """已经有同名笔记了。

    例:str(DuplicateNoteError("读书笔记")) -> "note titled '读书笔记' already exists"
    """

    def __init__(self, title: str) -> None:
        """记下冲突的标题。"""
        super().__init__(f"note titled {title!r} already exists")
        self.title = title


@dataclass(frozen=True, slots=True)
class Note:
    """一条笔记(不可变:要改就用 dataclasses.replace 生成新对象)。

    例:Note(1, "读书笔记", "", ("python",), "2026-01-01T00:00:00+00:00").id -> 1
    """

    id: int
    title: str
    body: str
    tags: tuple[str, ...]
    created_at: str


@dataclass(frozen=True, slots=True)
class NoteStats:
    """统计结果:总数、标签分布、最长标题。

    例:NoteStats(0, {}, None).total -> 0
    """

    total: int
    tags: dict[str, int]
    longest_title: str | None


def validate_title(raw: str) -> str:
    """去掉首尾空白后返回标题;空标题或超过 100 字时抛 NoteValidationError。

    例:validate_title("  读书笔记 ") -> '读书笔记';validate_title("  ") -> raise
    """
    title = raw.strip()
    if not title:
        raise NoteValidationError("标题不能为空")
    if len(title) > MAX_TITLE_LENGTH:
        raise NoteValidationError(f"标题不能超过 {MAX_TITLE_LENGTH} 个字")
    return title


def normalize_tags(raw: str | Iterable[str]) -> tuple[str, ...]:
    """把标签规范化成"小写、去空白、去重、排序"的元组。

    例:normalize_tags("Python, web , python,") -> ('python', 'web')
        normalize_tags(["Web", " python "]) -> ('python', 'web')
    """
    parts = raw.split(",") if isinstance(raw, str) else raw
    cleaned = {part.strip().lower() for part in parts if part.strip()}
    return tuple(sorted(cleaned))


def format_note(note: Note) -> str:
    """把一条笔记格式化成一行(纯函数:不 print,只返回字符串)。

    例:format_note(Note(3, "读书笔记", "", ("python", "web"), "")) -> '#3 读书笔记
    [python,web]'
    """
    return f"#{note.id} {note.title} [{','.join(note.tags)}]"


def summarize(notes: Sequence[Note]) -> NoteStats:
    """统计总数、每个标签出现的次数、最长的标题。

    例:summarize([]) -> NoteStats(total=0, tags={}, longest_title=None)
    """
    tags: dict[str, int] = {}
    for note in notes:
        for tag in note.tags:
            tags[tag] = tags.get(tag, 0) + 1
    longest = max((note.title for note in notes), key=len, default=None)
    return NoteStats(total=len(notes), tags=tags, longest_title=longest)
点击展开:w4_04_refactor_kata/repository.py
exercises/solutions/week4/w4_04_refactor_kata/repository.py
"""练习 4 参考答案 · 存储层:Protocol 接口 + 内存实现 + SQLite 实现。

用法:uv run pytest exercises/week4/w4_04_refactor_kata
-q(先把四个答案文件复制进练习目录)
说明:两个实现的方法名、参数、返回值完全一样,所以同一套测试能跑两遍;
      SQL 全部用 `?` 参数化,写操作放在 `with self._conn:` 里(异常时自动回滚)。
"""

import sqlite3
from dataclasses import replace
from pathlib import Path
from typing import Protocol

from domain import Note

_SCHEMA = """
CREATE TABLE IF NOT EXISTS notes (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    title TEXT NOT NULL,
    body TEXT NOT NULL,
    tags TEXT NOT NULL,
    created_at TEXT NOT NULL
)
"""
_COLUMNS = "id, title, body, tags, created_at"


class NoteRepository(Protocol):
    """笔记存储接口:谁实现了这五个方法,谁就能被 NoteService 使用。

    例:InMemoryNoteRepository() 与 SqliteNoteRepository(path) 都满足它
    """

    def add(self, note: Note) -> Note:
        """存一条笔记(忽略传入的 id,分配新 id 并返回新对象)。"""
        ...

    def get(self, note_id: int) -> Note | None:
        """按 id 取笔记;没有就返回 None("找不到"是不是错误由上层决定)。"""
        ...

    def list_all(self) -> list[Note]:
        """按 id 升序返回全部笔记。"""
        ...

    def search(self, keyword: str) -> list[Note]:
        """标题或正文包含 keyword 的笔记(忽略大小写)。"""
        ...

    def delete(self, note_id: int) -> bool:
        """删除笔记;真的删掉了返回 True,本来就没有返回 False。"""
        ...


class InMemoryNoteRepository:
    """内存实现:一个 dict 加一个自增 id,测试首选。

    例:InMemoryNoteRepository().add(note).id -> 1
    """

    def __init__(self) -> None:
        """建一个空存储。"""
        self._notes: dict[int, Note] = {}
        self._next_id = 1

    def add(self, note: Note) -> Note:
        """存一条笔记并返回带新 id 的副本。

        例:add(Note(0, "t", "", (), "")).id -> 1
        """
        saved = replace(note, id=self._next_id)
        self._notes[saved.id] = saved
        self._next_id += 1
        return saved

    def get(self, note_id: int) -> Note | None:
        """按 id 取笔记;没有返回 None。

        例:空仓储 get(1) -> None
        """
        return self._notes.get(note_id)

    def list_all(self) -> list[Note]:
        """按 id 升序返回全部笔记。

        例:add 三条后 [n.id for n in list_all()] -> [1, 2, 3]
        """
        return [self._notes[note_id] for note_id in sorted(self._notes)]

    def search(self, keyword: str) -> list[Note]:
        """标题或正文包含 keyword 的笔记(忽略大小写)。

        例:search("python") 能命中标题 'Python 笔记'
        """
        needle = keyword.lower()
        return [
            note
            for note in self.list_all()
            if needle in note.title.lower() or needle in note.body.lower()
        ]

    def delete(self, note_id: int) -> bool:
        """删除笔记;删掉了返回 True,本来没有返回 False。

        例:delete(1) 两次 -> True, False
        """
        return self._notes.pop(note_id, None) is not None


class SqliteNoteRepository:
    """SQLite 实现:同样的五个方法,换的只是存储介质。

    例:repo = SqliteNoteRepository(Path("notes.db"));用完 repo.close()
    """

    def __init__(self, db_path: Path | str) -> None:
        """连上数据库并建表(表不存在时才建)。"""
        self._conn = sqlite3.connect(db_path)
        with self._conn:
            self._conn.execute(_SCHEMA)

    def close(self) -> None:
        """关连接(谁打开谁负责关;真实项目里交给 lifespan/上下文管理器)。

        例:repo.close() 之后不能再用它
        """
        self._conn.close()

    def add(self, note: Note) -> Note:
        """插入一条笔记并返回带自增 id 的副本(参数化查询,标题里有引号也没事)。

        例:add(Note(0, "it's fine", "", (), "...")).title -> "it's fine"
        """
        with self._conn:
            cursor = self._conn.execute(
                "INSERT INTO notes (title, body, tags, created_at) VALUES (?, ?, ?, ?)",
                (note.title, note.body, ",".join(note.tags), note.created_at),
            )
        return replace(note, id=int(cursor.lastrowid or 0))

    def get(self, note_id: int) -> Note | None:
        """按 id 查一条;没有返回 None。

        例:get(999) -> None
        """
        row = self._conn.execute(
            f"SELECT {_COLUMNS} FROM notes WHERE id = ?", (note_id,)
        ).fetchone()
        return _row_to_note(row) if row is not None else None

    def list_all(self) -> list[Note]:
        """按 id 升序查全部。

        例:[n.title for n in list_all()] -> ['A', 'B']
        """
        rows = self._conn.execute(
            f"SELECT {_COLUMNS} FROM notes ORDER BY id"
        ).fetchall()
        return [_row_to_note(row) for row in rows]

    def search(self, keyword: str) -> list[Note]:
        """标题或正文 LIKE 关键词(忽略大小写);`?` 参数化,不拼字符串。

        例:search("python") -> [Note(...)]
        """
        pattern = f"%{keyword.lower()}%"
        rows = self._conn.execute(
            f"SELECT {_COLUMNS} FROM notes "
            "WHERE lower(title) LIKE ? OR lower(body) LIKE ? ORDER BY id",
            (pattern, pattern),
        ).fetchall()
        return [_row_to_note(row) for row in rows]

    def delete(self, note_id: int) -> bool:
        """删除一条;受影响行数为 0 说明本来就没有。

        例:delete(1) -> True
        """
        with self._conn:
            cursor = self._conn.execute("DELETE FROM notes WHERE id = ?", (note_id,))
        return cursor.rowcount > 0


def _row_to_note(row: tuple[int, str, str, str, str]) -> Note:
    """把一行 SQL 结果映射成领域对象(边界处映射就写在这里)。

    例:_row_to_note((1, "t", "", "python,web", "...")).tags -> ('python', 'web')
    """
    note_id, title, body, tags, created_at = row
    return Note(
        id=note_id,
        title=title,
        body=body,
        tags=tuple(tag for tag in tags.split(",") if tag),
        created_at=created_at,
    )
点击展开:w4_04_refactor_kata/service.py
exercises/solutions/week4/w4_04_refactor_kata/service.py
"""练习 4 参考答案 · 用例层:业务规则住在这里,依赖靠构造函数注入。

用法:uv run pytest exercises/week4/w4_04_refactor_kata
-q(先把四个答案文件复制进练习目录)
说明:这个文件只 import domain 与 repository 的 Protocol——没有 sqlite3、没有 print、
      没有 datetime.now() 的硬调用(时间由 clock 注入,所以测试里输出可预测)。
"""

from collections.abc import Callable, Iterable
from datetime import UTC, datetime

from domain import (
    DuplicateNoteError,
    Note,
    NoteNotFoundError,
    NoteStats,
    NoteValidationError,
    normalize_tags,
    summarize,
    validate_title,
)
from repository import NoteRepository


def utc_now_iso() -> str:
    """默认时钟:带时区的当前时间(ISO 8601 字符串)。

    例:utc_now_iso() -> '2026-01-01T00:00:00.123456+00:00'
    """
    return datetime.now(UTC).isoformat()


class NoteService:
    """笔记用例:校验、查重、过滤、统计;存储怎么实现它不关心。

    例:NoteService(InMemoryNoteRepository()).add_note("读书笔记", tags="python")
    """

    def __init__(
        self,
        repo: NoteRepository,
        clock: Callable[[], str] = utc_now_iso,
    ) -> None:
        """把仓储与时钟注入进来(测试时传内存仓储 + 固定时钟)。"""
        self._repo = repo
        self._clock = clock

    def add_note(
        self,
        title: str,
        body: str = "",
        tags: str | Iterable[str] = (),
    ) -> Note:
        """校验标题、规范化标签、查重后存下来。

        例:add_note(" 读书笔记 ", tags="Python, python") -> Note(title='读书笔记',
            tags=('python',))
        """
        clean_title = validate_title(title)
        if any(note.title == clean_title for note in self._repo.list_all()):
            raise DuplicateNoteError(clean_title)
        draft = Note(
            id=0,
            title=clean_title,
            body=body,
            tags=normalize_tags(tags),
            created_at=self._clock(),
        )
        return self._repo.add(draft)

    def get_note(self, note_id: int) -> Note:
        """取一条笔记;不存在时抛 NoteNotFoundError(仓储返回 None,语义由这里决定)。

        例:空仓储 get_note(1) -> raise NoteNotFoundError
        """
        note = self._repo.get(note_id)
        if note is None:
            raise NoteNotFoundError(note_id)
        return note

    def list_notes(self, tag: str | None = None) -> list[Note]:
        """列出全部笔记,或只列出带某个标签的(标签先规范化再比)。

        例:list_notes(tag="Python") 能匹配存成 'python' 的标签
        """
        notes = self._repo.list_all()
        if tag is None:
            return notes
        wanted = normalize_tags(tag)
        return [note for note in notes if set(wanted) <= set(note.tags)]

    def search_notes(self, keyword: str) -> list[Note]:
        """按关键词搜索;空关键词是输入错误,不是"没搜到"。

        例:search_notes("  ") -> raise NoteValidationError
        """
        if not keyword.strip():
            raise NoteValidationError("关键词不能为空")
        return self._repo.search(keyword.strip())

    def delete_note(self, note_id: int) -> None:
        """删除一条笔记;本来就没有时抛 NoteNotFoundError。

        例:delete_note(999) -> raise NoteNotFoundError
        """
        if not self._repo.delete(note_id):
            raise NoteNotFoundError(note_id)

    def stats(self) -> NoteStats:
        """统计:把数据取出来交给领域层的纯函数算。

        例:stats().total -> 2
        """
        return summarize(self._repo.list_all())