# 

# 02-3. 自訂驗證器

&gt; ⏱️ **閱讀時間：** 15 分鐘
&gt; 🎯 **難度：** ⭐⭐⭐ (中級)

---

## 🤔 一句話解釋

**當內建驗證不夠用時，使用自訂驗證器實現任何你想要的驗證邏輯。**

---

## 🎯 Pydantic v2 驗證器類型

```
┌─────────────────────────────────────────────┐
│            驗證器執行順序                    │
├─────────────────────────────────────────────┤
│  1. before validator (原始輸入)              │
│         ↓                                   │
│  2. 型別轉換/核心驗證                        │
│         ↓                                   │
│  3. after validator (轉換後的值)             │
│         ↓                                   │
│  4. model_validator (整個模型)               │
└─────────────────────────────────────────────┘
```

---

## 🔧 field_validator - 欄位驗證器

### 基本用法

```python
from pydantic import BaseModel, field_validator

class User(BaseModel):
    name: str
    age: int

    @field_validator(&#39;name&#39;)
    @classmethod
    def name_must_not_be_empty(cls, v: str) -&gt; str:
        if not v.strip():
            raise ValueError(&#39;名稱不能為空&#39;)
        return v.strip()

    @field_validator(&#39;age&#39;)
    @classmethod
    def age_must_be_positive(cls, v: int) -&gt; int:
        if v &lt; 0:
            raise ValueError(&#39;年齡必須大於 0&#39;)
        return v

# 測試
user = User(name=&#34;  John  &#34;, age=25)
print(user.name)  # &#34;John&#34; (已去除空白)
```

### 驗證多個欄位

```python
from pydantic import BaseModel, field_validator

class User(BaseModel):
    first_name: str
    last_name: str
    email: str

    # 同時驗證多個欄位
    @field_validator(&#39;first_name&#39;, &#39;last_name&#39;)
    @classmethod
    def names_not_empty(cls, v: str) -&gt; str:
        if not v.strip():
            raise ValueError(&#39;名稱不能為空&#39;)
        return v.strip().title()  # 首字大寫

    @field_validator(&#39;email&#39;)
    @classmethod
    def email_lowercase(cls, v: str) -&gt; str:
        return v.lower().strip()

# 測試
user = User(
    first_name=&#34;john&#34;,
    last_name=&#34;DOE&#34;,
    email=&#34;JOHN@EXAMPLE.COM&#34;
)
print(user.first_name)  # John
print(user.last_name)   # Doe
print(user.email)       # john@example.com
```

### 使用驗證模式

```python
from pydantic import BaseModel, field_validator, ValidationInfo

class Product(BaseModel):
    name: str
    price: float
    discount_price: float = None

    # mode=&#39;before&#39;: 在型別轉換之前執行
    @field_validator(&#39;price&#39;, mode=&#39;before&#39;)
    @classmethod
    def parse_price(cls, v):
        if isinstance(v, str):
            # 移除貨幣符號
            v = v.replace(&#39;$&#39;, &#39;&#39;).replace(&#39;,&#39;, &#39;&#39;)
        return float(v)

    # mode=&#39;after&#39;: 在型別轉換之後執行（預設）
    @field_validator(&#39;price&#39;, mode=&#39;after&#39;)
    @classmethod
    def price_must_be_positive(cls, v: float) -&gt; float:
        if v &lt;= 0:
            raise ValueError(&#39;價格必須大於 0&#39;)
        return round(v, 2)

# 測試
product = Product(name=&#34;iPhone&#34;, price=&#34;$1,299.99&#34;)
print(product.price)  # 1299.99
```

### 存取其他欄位的值

```python
from pydantic import BaseModel, field_validator, ValidationInfo

class DateRange(BaseModel):
    start_date: str
    end_date: str

    @field_validator(&#39;end_date&#39;)
    @classmethod
    def end_after_start(cls, v: str, info: ValidationInfo) -&gt; str:
        # info.data 包含已驗證的欄位
        if &#39;start_date&#39; in info.data:
            if v &lt; info.data[&#39;start_date&#39;]:
                raise ValueError(&#39;結束日期必須在開始日期之後&#39;)
        return v

class PasswordConfirm(BaseModel):
    password: str
    confirm_password: str

    @field_validator(&#39;confirm_password&#39;)
    @classmethod
    def passwords_match(cls, v: str, info: ValidationInfo) -&gt; str:
        if &#39;password&#39; in info.data and v != info.data[&#39;password&#39;]:
            raise ValueError(&#39;密碼不一致&#39;)
        return v
```

---

## 🏗️ model_validator - 模型驗證器

### 驗證整個模型

```python
from pydantic import BaseModel, model_validator

class Rectangle(BaseModel):
    width: float
    height: float
    area: float = None

    @model_validator(mode=&#39;after&#39;)
    def calculate_area(self):
        if self.area is None:
            self.area = self.width * self.height
        elif self.area != self.width * self.height:
            raise ValueError(&#39;面積與寬高不符&#39;)
        return self

# 測試
rect = Rectangle(width=10, height=5)
print(rect.area)  # 50.0

rect2 = Rectangle(width=10, height=5, area=50)  # ✅ OK
rect3 = Rectangle(width=10, height=5, area=100)  # ❌ ValidationError
```

### before 模式 - 處理原始輸入

```python
from pydantic import BaseModel, model_validator
from typing import Any

class FlexibleUser(BaseModel):
    name: str
    email: str

    @model_validator(mode=&#39;before&#39;)
    @classmethod
    def handle_string_input(cls, data: Any) -&gt; Any:
        # 如果輸入是字串，嘗試解析
        if isinstance(data, str):
            # 假設格式是 &#34;name:email&#34;
            parts = data.split(&#39;:&#39;)
            if len(parts) == 2:
                return {&#39;name&#39;: parts[0], &#39;email&#39;: parts[1]}
        return data

# 測試
user1 = FlexibleUser(name=&#34;John&#34;, email=&#34;john@example.com&#34;)
user2 = FlexibleUser.model_validate(&#34;John:john@example.com&#34;)
```

### wrap 模式 - 包裝核心驗證

```python
from pydantic import BaseModel, model_validator, ValidationError

class SafeModel(BaseModel):
    value: int

    @model_validator(mode=&#39;wrap&#39;)
    @classmethod
    def wrap_validation(cls, values, handler):
        try:
            return handler(values)
        except ValidationError as e:
            # 可以在這裡處理錯誤
            print(f&#34;Validation failed: {e}&#34;)
            raise
```

---

## 🔄 Annotated Validators

### 使用 Annotated 定義可重用驗證器

```python
from pydantic import BaseModel, AfterValidator, BeforeValidator
from typing import Annotated

# 定義驗證函數
def to_lowercase(v: str) -&gt; str:
    return v.lower()

def strip_whitespace(v: str) -&gt; str:
    return v.strip()

def must_not_be_empty(v: str) -&gt; str:
    if not v:
        raise ValueError(&#39;不能為空&#39;)
    return v

# 組合成可重用的型別
CleanString = Annotated[
    str,
    BeforeValidator(strip_whitespace),
    AfterValidator(to_lowercase),
    AfterValidator(must_not_be_empty),
]

class User(BaseModel):
    username: CleanString
    email: CleanString

# 測試
user = User(username=&#34;  JOHN  &#34;, email=&#34;  JOHN@EXAMPLE.COM  &#34;)
print(user.username)  # john
print(user.email)     # john@example.com
```

### 常用可重用驗證器

```python
from pydantic import BaseModel, AfterValidator, BeforeValidator, Field
from typing import Annotated
import re

# 去除空白
def strip(v: str) -&gt; str:
    return v.strip() if isinstance(v, str) else v

# 轉小寫
def lower(v: str) -&gt; str:
    return v.lower() if isinstance(v, str) else v

# 轉大寫
def upper(v: str) -&gt; str:
    return v.upper() if isinstance(v, str) else v

# 首字大寫
def title(v: str) -&gt; str:
    return v.title() if isinstance(v, str) else v

# 非空驗證
def not_empty(v: str) -&gt; str:
    if not v or not v.strip():
        raise ValueError(&#39;不能為空&#39;)
    return v

# 正規表達式驗證
def matches_pattern(pattern: str):
    def validator(v: str) -&gt; str:
        if not re.match(pattern, v):
            raise ValueError(f&#39;格式不符合: {pattern}&#39;)
        return v
    return validator

# 組合成常用型別
TrimmedString = Annotated[str, BeforeValidator(strip)]
LowerString = Annotated[str, BeforeValidator(strip), AfterValidator(lower)]
UpperString = Annotated[str, BeforeValidator(strip), AfterValidator(upper)]
TitleString = Annotated[str, BeforeValidator(strip), AfterValidator(title)]
NonEmptyString = Annotated[str, BeforeValidator(strip), AfterValidator(not_empty)]

# 使用
class Person(BaseModel):
    first_name: TitleString
    last_name: TitleString
    nickname: TrimmedString = &#34;&#34;

person = Person(first_name=&#34;  john  &#34;, last_name=&#34;  DOE  &#34;)
print(person.first_name)  # John
print(person.last_name)   # Doe
```

---

## 🎨 實戰範例

### 信用卡驗證

```python
from pydantic import BaseModel, field_validator, model_validator
from datetime import date
import re

class CreditCard(BaseModel):
    card_number: str
    expiry_month: int
    expiry_year: int
    cvv: str
    holder_name: str

    @field_validator(&#39;card_number&#39;)
    @classmethod
    def validate_card_number(cls, v: str) -&gt; str:
        # 移除空格和破折號
        v = re.sub(r&#39;[\s-]&#39;, &#39;&#39;, v)

        # 檢查是否全為數字
        if not v.isdigit():
            raise ValueError(&#39;卡號只能包含數字&#39;)

        # 檢查長度（13-19 位）
        if not 13 &lt;= len(v) &lt;= 19:
            raise ValueError(&#39;卡號長度不正確&#39;)

        # Luhn 演算法驗證
        def luhn_check(card_num):
            digits = [int(d) for d in card_num]
            odd_digits = digits[-1::-2]
            even_digits = digits[-2::-2]
            checksum = sum(odd_digits)
            for d in even_digits:
                checksum &#43;= sum(divmod(d * 2, 10))
            return checksum % 10 == 0

        if not luhn_check(v):
            raise ValueError(&#39;無效的卡號&#39;)

        return v

    @field_validator(&#39;expiry_month&#39;)
    @classmethod
    def validate_month(cls, v: int) -&gt; int:
        if not 1 &lt;= v &lt;= 12:
            raise ValueError(&#39;月份必須在 1-12 之間&#39;)
        return v

    @field_validator(&#39;expiry_year&#39;)
    @classmethod
    def validate_year(cls, v: int) -&gt; int:
        current_year = date.today().year
        if v &lt; current_year:
            raise ValueError(&#39;卡片已過期&#39;)
        if v &gt; current_year &#43; 20:
            raise ValueError(&#39;年份不合理&#39;)
        return v

    @field_validator(&#39;cvv&#39;)
    @classmethod
    def validate_cvv(cls, v: str) -&gt; str:
        if not v.isdigit() or not 3 &lt;= len(v) &lt;= 4:
            raise ValueError(&#39;CVV 必須是 3-4 位數字&#39;)
        return v

    @field_validator(&#39;holder_name&#39;)
    @classmethod
    def validate_holder_name(cls, v: str) -&gt; str:
        v = v.strip().upper()
        if not re.match(r&#39;^[A-Z\s]&#43;$&#39;, v):
            raise ValueError(&#39;持卡人姓名只能包含英文字母&#39;)
        return v

    @model_validator(mode=&#39;after&#39;)
    def check_expiry(self):
        today = date.today()
        if self.expiry_year == today.year and self.expiry_month &lt; today.month:
            raise ValueError(&#39;卡片已過期&#39;)
        return self
```

### 訂單驗證

```python
from pydantic import BaseModel, Field, field_validator, model_validator
from typing import List, Optional
from decimal import Decimal
from datetime import datetime
from enum import Enum

class OrderStatus(str, Enum):
    PENDING = &#34;pending&#34;
    CONFIRMED = &#34;confirmed&#34;
    SHIPPED = &#34;shipped&#34;
    DELIVERED = &#34;delivered&#34;
    CANCELLED = &#34;cancelled&#34;

class OrderItem(BaseModel):
    product_id: int = Field(gt=0)
    product_name: str = Field(min_length=1)
    quantity: int = Field(ge=1, le=999)
    unit_price: Decimal = Field(ge=0, decimal_places=2)

    @property
    def subtotal(self) -&gt; Decimal:
        return self.unit_price * self.quantity

class ShippingAddress(BaseModel):
    recipient: str = Field(min_length=1, max_length=100)
    phone: str = Field(pattern=r&#39;^09\d{8}$&#39;)
    city: str
    district: str
    address: str = Field(min_length=5, max_length=200)
    zip_code: str = Field(pattern=r&#39;^\d{3,5}$&#39;)

class CreateOrderRequest(BaseModel):
    items: List[OrderItem] = Field(min_length=1, max_length=50)
    shipping_address: ShippingAddress
    coupon_code: Optional[str] = Field(None, pattern=r&#39;^[A-Z0-9]{6,12}$&#39;)
    note: Optional[str] = Field(None, max_length=500)

    @field_validator(&#39;items&#39;)
    @classmethod
    def validate_items(cls, v: List[OrderItem]) -&gt; List[OrderItem]:
        # 檢查是否有重複的商品
        product_ids = [item.product_id for item in v]
        if len(product_ids) != len(set(product_ids)):
            raise ValueError(&#39;訂單中有重複的商品&#39;)
        return v

    @model_validator(mode=&#39;after&#39;)
    def validate_order_total(self):
        # 計算總金額
        total = sum(item.subtotal for item in self.items)

        # 檢查最低訂單金額
        if total &lt; Decimal(&#39;100&#39;):
            raise ValueError(&#39;訂單金額必須至少 100 元&#39;)

        # 檢查最高訂單金額
        if total &gt; Decimal(&#39;1000000&#39;):
            raise ValueError(&#39;訂單金額超過上限&#39;)

        return self

    @property
    def total_amount(self) -&gt; Decimal:
        return sum(item.subtotal for item in self.items)

    @property
    def total_items(self) -&gt; int:
        return sum(item.quantity for item in self.items)
```

---

## ⚠️ 常見錯誤

### 1. 忘記 @classmethod

```python
# ❌ 錯誤
class User(BaseModel):
    name: str

    @field_validator(&#39;name&#39;)
    def validate_name(cls, v):  # 缺少 @classmethod
        return v

# ✅ 正確
class User(BaseModel):
    name: str

    @field_validator(&#39;name&#39;)
    @classmethod
    def validate_name(cls, v):
        return v
```

### 2. 驗證器順序

```python
# 注意：欄位驗證器按照欄位定義順序執行
class User(BaseModel):
    password: str
    confirm: str  # 這個驗證器執行時，password 已經驗證過了

    @field_validator(&#39;confirm&#39;)
    @classmethod
    def check_confirm(cls, v, info):
        if &#39;password&#39; in info.data:  # password 已可用
            ...
        return v
```

### 3. 修改 self 在 model_validator

```python
# ❌ mode=&#39;before&#39; 不能存取 self
@model_validator(mode=&#39;before&#39;)
@classmethod
def validate(cls, values):  # values 是 dict
    return values

# ✅ mode=&#39;after&#39; 可以存取 self
@model_validator(mode=&#39;after&#39;)
def validate(self):  # 是實例方法
    self.field = self.field.upper()
    return self
```

---

## ✅ 重點總結

### 驗證器類型

| 驗證器 | 用途 | mode |
|--------|------|------|
| `field_validator` | 單一欄位 | before/after |
| `model_validator` | 整個模型 | before/after/wrap |
| `Annotated` | 可重用驗證 | BeforeValidator/AfterValidator |

### 執行順序

1. `model_validator(mode=&#39;before&#39;)` - 原始輸入
2. `field_validator(mode=&#39;before&#39;)` - 型別轉換前
3. 核心型別驗證和轉換
4. `field_validator(mode=&#39;after&#39;)` - 型別轉換後
5. `model_validator(mode=&#39;after&#39;)` - 所有欄位驗證後

---

## 🎤 面試這樣答

### Q: Pydantic 中 field_validator 和 model_validator 有什麼區別？

**答案：**

&gt; `field_validator` 用於驗證單一欄位，只能存取該欄位的值和已經驗證過的其他欄位。
&gt;
&gt; `model_validator` 用於驗證整個模型，可以存取所有欄位，適合做跨欄位驗證，例如確認密碼是否一致。
&gt;
&gt; ```python
&gt; @field_validator(&#39;email&#39;)
&gt; @classmethod
&gt; def validate_email(cls, v):
&gt;     return v.lower()
&gt;
&gt; @model_validator(mode=&#39;after&#39;)
&gt; def check_passwords_match(self):
&gt;     if self.password != self.confirm_password:
&gt;         raise ValueError(&#39;密碼不一致&#39;)
&gt;     return self
&gt; ```

---

## 🤓 小測驗

1. `@field_validator` 需要搭配什麼裝飾器？
   &lt;details&gt;
   &lt;summary&gt;點擊看答案&lt;/summary&gt;
   @classmethod
   &lt;/details&gt;

2. 如何在 field_validator 中存取其他欄位的值？
   &lt;details&gt;
   &lt;summary&gt;點擊看答案&lt;/summary&gt;
   使用 ValidationInfo 的 data 屬性：info.data[&#39;field_name&#39;]
   &lt;/details&gt;

3. mode=&#39;before&#39; 和 mode=&#39;after&#39; 的差別？
   &lt;details&gt;
   &lt;summary&gt;點擊看答案&lt;/summary&gt;
   before 在型別轉換前執行（收到原始輸入），after 在型別轉換後執行（收到正確型別的值）
   &lt;/details&gt;

---

**上一篇：** [02-2. 欄位驗證與約束](./02-2)
**下一篇：** [02-4. 巢狀模型與繼承](./02-4)

---

最後更新：2025-12-17


---

> 作者: luk  
> URL: https://yoru-karu-blog-lalaluk-52581ac5e0cef170a3c8922c19182ecb6f7bd604.gitlab.io/posts/tutorial/fastapi/02-3/  

