Back to Blog
Django REST Framework 多層權限系統實作:從業務需求到架構設計的完整解析
📝 Dev Notes

Django REST Framework 多層權限系統實作:從業務需求到架構設計的完整解析

B
Blake
Dec 3, 2025 By Blake 19 min read
本文深入探討在 Django REST Framework 中設計多層權限系統的實務經驗。以家族記帳系統為例,說明如何處理五種角色(系統管理員、活動管理者、群組管理者、參與者、一般用戶)的差異化權限需求。文章涵蓋三層架構設計(Model、ViewSet、Serializer)、動態權限實作、QuerySet 效能優化,以及審計日誌整合等核心技術點,並提供完整的程式碼範例與最佳實踐建議。

前言

在開發家族記帳系統的過程中,權限管理是我花費最多時間思考的架構決策之一。傳統 Django 的權限系統採用簡單的二進制模型(有權限/無權限),但實際業務場景往往需要更細緻的控制粒度。

本文將分享我在 Pangcah-Accounting 專案中設計的多層權限架構,這套系統需要處理五種不同角色的差異化權限,且權限狀態會隨著活動生命週期動態變化。


一、業務需求分析

1.1 權限層級定義

在設計權限系統前,首先需要釐清各角色的職責邊界:

角色

說明

核心權限

系統管理員 (ADMIN)

擁有全局控制權

管理所有資源、用戶、系統配置

活動管理者

特定活動的負責人

管理活動設定、審核支出、執行結算

群組管理者

特定群組的管理員

管理群組成員、查看群組財務報表

活動參與者

活動的一般成員

新增個人支出、查看分攤結果

一般用戶

系統註冊用戶

僅能查看公開資訊

1.2 動態權限的關鍵挑戰

權限系統的核心挑戰在於動態性

  1. 角色情境化:同一用戶在不同活動中可能扮演不同角色

  2. 狀態依賴:權限隨活動狀態(進行中/已完成/已鎖定)變化

  3. 繼承關係:群組管理者對該群組下所有活動具有查看權限


二、架構設計:三層權限驗證模型

經過分析,我採用了分層設計的架構,將權限邏輯分散在 Model、ViewSet、Serializer 三層,各司其職:

┌─────────────────────────────────────────────────────────┐
│                    Serializer 層                        │
│          動態權限欄位,提供前端 UI 判斷依據              │
├─────────────────────────────────────────────────────────┤
│                     ViewSet 層                          │
│          請求入口把關,覆寫 perform_create 等方法        │
├─────────────────────────────────────────────────────────┤
│                      Model 層                           │
│          業務規則的真實來源,封裝權限檢查方法            │
└─────────────────────────────────────────────────────────┘

這種設計的優勢在於: - 單一職責:每層只處理特定類型的權限邏輯 - 可測試性:Model 方法可獨立進行單元測試 - 可維護性:權限規則變更時,修改範圍明確


三、第一層:Model 層權限檢查方法

3.1 設計原則

Model 層是權限邏輯的真實來源 (Single Source of Truth)。將權限檢查封裝為 Model 方法,確保業務規則的一致性。

3.2 實際程式碼

以下是 Event 模型中的權限檢查方法實作:

# apps/events/models.py

class EventStatus(models.TextChoices):
    """活動狀態選擇"""
    ACTIVE = 'ACTIVE', '進行中'
    COMPLETED = 'COMPLETED', '已完成'
    CANCELLED = 'CANCELLED', '已取消'


class Event(models.Model):
    """活動模型"""

    name = models.CharField("活動名稱", max_length=200)
    status = models.CharField(
        "活動狀態",
        max_length=10,
        choices=EventStatus.choices,
        default=EventStatus.ACTIVE
    )

    # 活動管理者 - 多對多關係
    managers = models.ManyToManyField(
        get_user_model(),
        related_name='managed_events',
        verbose_name="活動管理者",
        blank=True
    )

    # 結算控制
    is_locked = models.BooleanField(
        "結算鎖定狀態",
        default=False,
        help_text="活動是否已結算鎖定,鎖定後一般用戶無法新增記錄"
    )

    group = models.ForeignKey(
        'groups.Group',
        on_delete=models.CASCADE,
        related_name='events',
        verbose_name="所屬群組"
    )

    def can_user_manage(self, user) -> bool:
        """檢查用戶是否可以管理此活動"""
        if not user or not user.is_authenticated:
            return False

        # 系統管理員可以管理所有活動
        if user.role == 'ADMIN':
            return True

        # 活動管理者可以管理
        if self.managers.filter(id=user.id).exists():
            return True

        return False

    def can_user_view_finances(self, user) -> bool:
        """檢查用戶是否可以查看此活動的財務狀況"""
        if not user or not user.is_authenticated:
            return False

        # 系統管理員可以查看所有活動
        if user.role == 'ADMIN':
            return True

        # 活動管理者可以查看
        if self.managers.filter(id=user.id).exists():
            return True

        # 群組管理者可以查看群組內活動的財務狀況
        if self.group and self.group.managers.filter(id=user.id).exists():
            return True

        return False

    def can_user_add_expense(self, user) -> bool:
        """檢查用戶是否可以新增支出記錄"""
        if not user or not user.is_authenticated:
            return False

        # 如果活動已鎖定,只有管理員和活動管理者可以新增
        if self.is_locked:
            return self.can_user_manage(user)

        # 檢查用戶是否為活動參與者
        return self.participants.filter(user=user, is_active=True).exists()

3.3 設計考量

  1. 防禦性檢查:每個方法開頭都驗證 user 是否存在且已認證

  2. 階層式判斷:ADMIN 優先判斷,避免後續不必要的資料庫查詢

  3. 狀態感知can_user_add_expense 會根據 is_locked 狀態調整邏輯

  4. 關聯查詢優化:使用 filter().exists() 而非 get() 避免例外處理


四、第二層:ViewSet 層權限驗證

4.1 覆寫 perform_create 方法

ViewSet 層負責在請求入口處進行權限把關。透過覆寫 perform_create 方法,可以在資料建立前進行完整的權限驗證:

# apps/expenses/views.py

class ExpenseViewSet(viewsets.ModelViewSet):
    """支出管理視圖集"""
    queryset = Expense.objects.all()
    serializer_class = ExpenseSerializer
    permission_classes = [permissions.IsAuthenticated]

    def perform_create(self, serializer):
        """創建支出時設置用戶並進行權限驗證"""
        # 取得關聯的活動
        event_id = serializer.validated_data.get('event_id')
        event = None

        if event_id:
            from apps.events.models import Event
            try:
                event = Event.objects.get(id=event_id)
            except Event.DoesNotExist:
                from rest_framework.exceptions import ValidationError
                raise ValidationError({'event_id': '指定的活動不存在'})

        if event:
            # 情境一:活動已完成或取消
            if event.status in ['COMPLETED', 'CANCELLED']:
                if not event.can_user_manage(self.request.user):
                    from rest_framework.exceptions import PermissionDenied
                    raise PermissionDenied(
                        '只有活動管理者可以在已結束的活動中新增支出'
                    )
            else:
                # 情境二:活動進行中,檢查參與者身份
                is_participant = event.participants.filter(
                    user=self.request.user,
                    is_active=True
                ).exists()

                if not is_participant:
                    if not event.can_user_manage(self.request.user):
                        from rest_framework.exceptions import PermissionDenied
                        raise PermissionDenied(
                            '您不是此活動的參與者,無法新增支出'
                        )

        # 驗證通過,儲存支出記錄
        expense = serializer.save(user=self.request.user)

        # 記錄活動日誌(審計追蹤)
        if expense.event:
            ActivityLog.objects.create(
                activity=expense.event,
                action_type=ActionType.EXPENSE_ADD,
                description=f"新增支出「{expense.description}」NT${expense.amount}",
                operator=self.request.user,
                metadata={
                    'expense_id': expense.id,
                    'amount': str(expense.amount)
                }
            )

4.2 複雜權限過濾的 QuerySet 設計

在 get_queryset 方法中實作權限過濾,確保用戶只能看到有權限存取的資料:

def get_queryset(self):
    """根據用戶權限和查詢參數過濾查詢集"""
    queryset = Expense.objects.select_related(
        'user', 'category', 'event', 'group'
    ).prefetch_related('splits__participant')

    # 如果不是系統管理員,只顯示相關的支出
    if self.request.user.role != 'ADMIN':
        queryset = queryset.filter(
            models.Q(user=self.request.user) |                    # 自己的記錄
            models.Q(event__participants__user=self.request.user) |  # 參與的活動
            models.Q(event__managers=self.request.user) |            # 管理的活動
            models.Q(group__managers=self.request.user) |            # 管理的群組
            models.Q(group__members__user=self.request.user)         # 所屬群組
        ).distinct()  # 避免多重 JOIN 造成的重複記錄

    return queryset

4.3 效能優化要點

  1. select_related:處理 ForeignKey 關聯(一對一、多對一)

  2. prefetch_related:處理 ManyToMany 和反向關聯

  3. distinct():解決複雜 Q 物件組合造成的重複記錄問題


五、動態權限:活動狀態與權限矩陣

5.1 權限矩陣

根據活動狀態和用戶角色,權限會動態調整:

操作

系統管理員

活動管理者

群組管理者

參與者

一般用戶

查看活動

編輯活動

查看財務

新增支出(進行中)

新增支出(已完成)

新增支出(已鎖定)

執行結算

5.2 狀態轉換與權限變化

# apps/events/models.py

def perform_settlement(self, user):
    """執行活動結算"""
    if not self.can_user_manage(user):
        raise PermissionError("只有活動管理者可以執行結算")

    from django.utils import timezone
    self.status = EventStatus.COMPLETED
    self.is_locked = True
    self.settlement_date = timezone.now()
    self.save()

六、審計日誌整合

6.1 操作類型定義

使用 TextChoices 定義標準化的操作類型:

class ActionType(models.TextChoices):
    """活動操作類型"""
    EXPENSE_ADD = 'EXPENSE_ADD', '新增支出'
    EXPENSE_EDIT = 'EXPENSE_EDIT', '編輯支出'
    EXPENSE_DELETE = 'EXPENSE_DELETE', '刪除支出'
    SPLIT_ADJUST = 'SPLIT_ADJUST', '調整分攤'
    STATUS_CHANGE = 'STATUS_CHANGE', '狀態變更'
    SETTLEMENT = 'SETTLEMENT', '執行結算'
    MANAGER_ADDED = 'MANAGER_ADDED', '新增管理者'
    MANAGER_REMOVED = 'MANAGER_REMOVED', '移除管理者'

6.2 日誌模型設計

class ActivityLog(models.Model):
    """活動記錄模型 - 提供完整的審計追蹤"""

    activity = models.ForeignKey(
        Event,
        on_delete=models.CASCADE,
        related_name='logs'
    )
    action_type = models.CharField(
        max_length=30,
        choices=ActionType.choices
    )
    description = models.TextField()
    operator = models.ForeignKey(
        get_user_model(),
        on_delete=models.SET_NULL,
        null=True
    )
    timestamp = models.DateTimeField(auto_now_add=True)
    metadata = models.JSONField(default=dict, blank=True)

    class Meta:
        indexes = [
            models.Index(fields=['activity', 'timestamp']),
            models.Index(fields=['action_type']),
        ]
        ordering = ['-timestamp']

七、最佳實踐建議

7.1 效能優化

  1. 使用 selectrelated / prefetchrelated:避免 N+1 查詢問題

  2. 快取策略:對於頻繁讀取的權限檢查,考慮使用 Redis 快取

  3. 索引設計:在 statusis_locked 等常用過濾欄位建立索引

7.2 安全性考量

  1. 防禦性程式設計:所有權限方法都應處理 None 和未認證的情況

  2. 最小權限原則:預設拒絕,只在明確符合條件時授予權限

  3. 審計日誌:記錄所有權限相關操作,便於事後追蹤

7.3 測試策略

# 建議的測試案例結構
class TestEventPermissions(TestCase):
    def test_admin_can_manage_any_event(self):
        """系統管理員可以管理任何活動"""
        pass

    def test_manager_can_manage_own_event(self):
        """活動管理者可以管理自己的活動"""
        pass

    def test_participant_cannot_add_expense_after_settlement(self):
        """參與者無法在結算後新增支出"""
        pass

結論

多層權限系統的成功在於分層設計

  • Model 層:定義業務規則的本質,作為權限邏輯的真實來源

  • ViewSet 層:在請求入口處把關,處理複雜的情境判斷

  • 審計系統:提供完整的操作追蹤,滿足合規性需求

這種架構既保證了系統的健壯性,也為未來的功能擴展預留了空間。透過將權限邏輯封裝在 Model 方法中,我們可以確保無論從 API、管理後台還是 Celery 任務存取,權限規則都保持一致。


相關資源

Enjoyed this article? Show some love!

0
Clap

Enjoyed this article?

Subscribe for engineering notes and AI development insights

We respect your privacy. No spam, unsubscribe anytime.

Share this article

Comments