未載入 Skill2728 tokens · 85647 ms
根據題述,主要契約問題有三項:
1. `ProjectUpdate` 繼承 `ProjectCreate`
- PATCH 被迫提供 `workspace_id`,失去部分更新語意。
- 建立欄位與可更新欄位被錯誤耦合。
- 若 `workspace_id` 不應由一般更新移動,還會形成 mass-assignment/越權風險。
- 更新時必須使用 `model_dump(exclude_unset=True)`,才能區分「未提供」與「明確設為 `null`」。
2. `ProjectResponse` 直接加入 `doc_type`
- 將搜尋索引或多型文件的 discriminator 洩漏到一般 Project API。
- 若型別只是 `str`,OpenAPI 無法保證其固定值。
- `doc_type` 應只存在於需要 discriminated union 的投影模型,並使用 `Literal["project"]`。
3. 所有 API 都回傳 `ProjectInDB`
- 儲存層欄位成為公開契約,可能洩漏 `deleted_at`、內部狀態、版本號或其他未來欄位。
- 資料庫 schema 的變更會意外破壞 API。
- FastAPI 若沒有明確的 `response_model`,可能直接序列化內部欄位。
- 建立、讀取、列表、刪除等端點也不應被迫共享同一回應形狀。
較安全的 Pydantic v2 結構如下;實際業務欄位可依現有 schema 補入:
```python
from datetime import datetime
from typing import Annotated, Literal
from uuid import UUID
from pydantic import (
BaseModel,
ConfigDict,
Field,
field_validator,
model_validator,
)
class ProjectCreate(BaseModel):
"""POST /projects 的輸入契約。"""
model_config = ConfigDict(
extra="forbid",
str_strip_whitespace=True,
)
workspace_id: UUID
name: str = Field(min_length=1, max_length=200)
description: str | None = Field(default=None, max_length=2_000)
class ProjectUpdate(BaseModel):
"""PATCH /projects/{id} 的輸入契約;不得繼承 ProjectCreate。"""
model_config = ConfigDict(
extra="forbid",
str_strip_whitespace=True,
)
name: str | None = Field(default=None, min_length=1, max_length=200)
description: str | None = Field(default=None, max_length=2_000)
@field_validator("name")
@classmethod
def name_must_not_be_null(cls, value: str | None) -> str:
if value is None:
raise ValueError("name cannot be null")
return value
@model_validator(mode="after")
def require_at_least_one_change(self) -> "ProjectUpdate":
if not self.model_fields_set:
raise ValueError("at least one field must be provided")
return self
class ProjectMove(BaseModel):
"""只有產品明確允許移動專案時才提供獨立操作。"""
model_config = ConfigDict(extra="forbid")
target_workspace_id: UUID
class ProjectResponse(BaseModel):
"""一般公開 API 的輸出契約。"""
model_config = ConfigDict(from_attributes=True)
id: UUID
workspace_id: UUID
name: str
description: str | None
created_at: datetime
updated_at: datetime
class ProjectInDB(BaseModel):
"""僅限 repository/service 內部使用,不作為 API response_model。"""
model_config = ConfigDict(from_attributes=True)
id: UUID
workspace_id: UUID
name: str
description: str | None
created_at: datetime
updated_at: datetime
deleted_at: datetime | None = None
version: int
class ProjectDocumentResponse(ProjectResponse):
"""只用於搜尋結果或跨文件型別的多型回應。"""
doc_type: Literal["project"] = "project"
class UserDocumentResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
doc_type: Literal["user"] = "user"
id: UUID
display_name: str
SearchDocument = Annotated[
ProjectDocumentResponse | UserDocumentResponse,
Field(discriminator="doc_type"),
]
```
PATCH 的更新資料應如此產生:
```python
changes = payload.model_dump(exclude_unset=True)
updated_record = repository.update(project_id, changes)
return ProjectResponse.model_validate(updated_record)
```
FastAPI 端點應明確指定各自契約:
```python
@router.post(
"/projects",
response_model=ProjectResponse,
status_code=201,
)
def create_project(payload: ProjectCreate) -> ProjectResponse:
record = service.create_project(payload)
return ProjectResponse.model_validate(record)
@router.patch(
"/projects/{project_id}",
response_model=ProjectResponse,
)
def update_project(
project_id: UUID,
payload: ProjectUpdate,
) -> ProjectResponse:
record = service.update_project(
project_id,
payload.model_dump(exclude_unset=True),
)
return ProjectResponse.model_validate(record)
@router.get(
"/projects/{project_id}",
response_model=ProjectResponse,
)
def get_project(project_id: UUID) -> ProjectResponse:
return ProjectResponse.model_validate(service.get_project(project_id))
@router.delete(
"/projects/{project_id}",
status_code=204,
)
def delete_project(project_id: UUID) -> None:
service.delete_project(project_id)
```
核心邊界是:`ProjectCreate`、`ProjectUpdate` 屬於命令契約;`ProjectResponse` 屬於公開讀取契約;`ProjectInDB` 僅屬於持久化層;帶 `doc_type` 的模型則是特定搜尋/多型投影,不應成為所有 Project API 的預設回應。
已載入 Skill +2567 tokens · 83998 ms
## 契約問題
1. `ProjectUpdate` 繼承 `ProjectCreate`
- `workspace_id` 被意外變成更新時的必填欄位,無法正確表達 PATCH。
- 若 API 接受該值,還可能允許專案跨 workspace 移動,造成越權風險。
- 建立專用欄位與可更新欄位被錯誤綁定。
2. `ProjectResponse` 包含 `doc_type`
- 將資料庫 discriminator 洩漏到公開 API 與 OpenAPI 契約。
- 用戶端會依賴儲存實作,未來更換資料庫或欄位名稱就可能成為 breaking change。
3. API 回傳 `ProjectInDB`
- 持久層模型與公開契約耦合。
- 未來加入內部欄位時,可能在沒有察覺的情況下外洩。
- API 應明確回傳 `ProjectResponse`;`ProjectInDB` 僅限 repository/database 層使用。
## 建議結構
```python
"""Project API and persistence models."""
from datetime import datetime
from typing import Literal
from pydantic import BaseModel, ConfigDict, Field, model_validator
class ProjectBase(BaseModel):
"""Public fields shared by create and response models."""
model_config = ConfigDict(
populate_by_name=True,
extra="forbid",
)
name: str = Field(min_length=1, max_length=200)
description: str | None = Field(default=None, max_length=2000)
class ProjectCreate(ProjectBase):
"""POST request body."""
workspace_id: str = Field(
min_length=1,
alias="workspaceId",
)
class ProjectUpdate(BaseModel):
"""PATCH request body; omitted fields remain unchanged."""
model_config = ConfigDict(
populate_by_name=True,
extra="forbid",
)
name: str | None = Field(default=None, min_length=1, max_length=200)
description: str | None = Field(default=None, max_length=2000)
@model_validator(mode="after")
def reject_null_name(self) -> "ProjectUpdate":
# name may be omitted, but explicitly setting it to null is invalid.
if "name" in self.model_fields_set and self.name is None:
raise ValueError("name cannot be null")
return self
class ProjectResponse(ProjectBase):
"""Stable public API response."""
model_config = ConfigDict(
populate_by_name=True,
from_attributes=True,
extra="forbid",
)
id: str = Field(min_length=1)
workspace_id: str = Field(min_length=1, alias="workspaceId")
author_id: str = Field(min_length=1, alias="authorId")
created_at: datetime = Field(alias="createdAt")
updated_at: datetime | None = Field(default=None, alias="updatedAt")
class ProjectInDB(ProjectResponse):
"""Internal database document; never used as an API response model."""
doc_type: Literal["project"] = "project"
```
更新時必須使用 `exclude_unset=True`,才能區分「未提供」和「明確設為 `null`」:
```python
changes = update.model_dump(
exclude_unset=True,
by_alias=False,
)
```
API 與持久層契約應分開:
```python
@router.post("/projects", response_model=ProjectResponse)
async def create_project(payload: ProjectCreate) -> ProjectResponse:
document: ProjectInDB = await repository.create(payload)
return ProjectResponse.model_validate(
document.model_dump(exclude={"doc_type"})
)
@router.patch("/projects/{project_id}", response_model=ProjectResponse)
async def update_project(
project_id: str,
payload: ProjectUpdate,
) -> ProjectResponse:
document: ProjectInDB = await repository.update(
project_id,
payload.model_dump(exclude_unset=True),
)
return ProjectResponse.model_validate(
document.model_dump(exclude={"doc_type"})
)
```
最終邊界應為:
- `POST` 請求:`ProjectCreate`
- `PATCH` 請求:`ProjectUpdate`
- 所有公開回應:`ProjectResponse`
- repository/database 文件:`ProjectInDB`
- `workspace_id`、`author_id`、時間戳與 `doc_type` 不得由一般更新請求修改