前言
在開發家族記帳系統的過程中,權限管理是我花費最多時間思考的架構決策之一。傳統 Django 的權限系統採用簡單的二進制模型(有權限/無權限),但實際業務場景往往需要更細緻的控制粒度。
本文將分享我在 Pangcah-Accounting 專案中設計的多層權限架構,這套系統需要處理五種不同角色的差異化權限,且權限狀態會隨著活動生命週期動態變化。
一、業務需求分析
1.1 權限層級定義
在設計權限系統前,首先需要釐清各角色的職責邊界:
角色 | 說明 | 核心權限 |
|---|---|---|
系統管理員 (ADMIN) | 擁有全局控制權 | 管理所有資源、用戶、系統配置 |
活動管理者 | 特定活動的負責人 | 管理活動設定、審核支出、執行結算 |
群組管理者 | 特定群組的管理員 | 管理群組成員、查看群組財務報表 |
活動參與者 | 活動的一般成員 | 新增個人支出、查看分攤結果 |
一般用戶 | 系統註冊用戶 | 僅能查看公開資訊 |
1.2 動態權限的關鍵挑戰
權限系統的核心挑戰在於動態性:
角色情境化:同一用戶在不同活動中可能扮演不同角色
狀態依賴:權限隨活動狀態(進行中/已完成/已鎖定)變化
繼承關係:群組管理者對該群組下所有活動具有查看權限
二、架構設計:三層權限驗證模型
經過分析,我採用了分層設計的架構,將權限邏輯分散在 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 設計考量
防禦性檢查:每個方法開頭都驗證
user是否存在且已認證階層式判斷:ADMIN 優先判斷,避免後續不必要的資料庫查詢
狀態感知:
can_user_add_expense會根據is_locked狀態調整邏輯關聯查詢優化:使用
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 queryset4.3 效能優化要點
select_related:處理 ForeignKey 關聯(一對一、多對一)
prefetch_related:處理 ManyToMany 和反向關聯
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 效能優化
使用 selectrelated / prefetchrelated:避免 N+1 查詢問題
快取策略:對於頻繁讀取的權限檢查,考慮使用 Redis 快取
索引設計:在
status、is_locked等常用過濾欄位建立索引
7.2 安全性考量
防禦性程式設計:所有權限方法都應處理
None和未認證的情況最小權限原則:預設拒絕,只在明確符合條件時授予權限
審計日誌:記錄所有權限相關操作,便於事後追蹤
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 任務存取,權限規則都保持一致。