系統背景與設計挑戰
在開發阿美族家族記帳系統(Pangcah Accounting)時,我們面臨一個核心挑戰:如何設計一套能夠支援多人協作、靈活分帳、且具備完整審計追蹤的資料模型。
系統的核心模型包括: - Event(活動):家族聚會、旅遊等活動 - ActivityParticipant(參與者):活動中的成員 - Expense(支出):活動產生的費用 - ExpenseSplit(分攤記錄):每筆費用如何分攤給各參與者 - Group(群組):家族成員分組管理
這些模型之間存在複雜的關聯關係,設計決策將直接影響後續的開發效率與系統性能。
一、ForeignKey vs ManyToManyField 的抉擇
關聯類型的選擇是 Model 設計中最關鍵的決策之一。錯誤的選擇可能導致資料冗餘、查詢複雜化,甚至業務邏輯混亂。
使用 ForeignKey 的場景
當關係具有明確的「歸屬」性質時,應使用 ForeignKey:
# apps/events/models.py
class ActivityParticipant(models.Model):
"""活動參與者模型"""
activity = models.ForeignKey(
Event,
on_delete=models.CASCADE,
related_name='participants',
verbose_name="關聯活動"
)
user = models.ForeignKey(
get_user_model(),
on_delete=models.CASCADE,
related_name='activity_participations',
verbose_name="參與用戶"
)
joined_at = models.DateTimeField(
"加入時間",
auto_now_add=True
)
split_option = models.CharField(
"分攤選項",
max_length=20,
choices=SplitOption.choices,
default=SplitOption.FULL_SPLIT
)這裡使用 ForeignKey 的原因: 1. 一對多關係:一個活動有多個參與者,但每個參與記錄只屬於一個活動 2. 需要額外欄位:參與者記錄需要儲存加入時間、分攤選項等資訊 3. 中間表需要業務邏輯:這不是單純的多對多關係,而是帶有業務屬性的關聯
同樣的邏輯也適用於支出分攤記錄:
# apps/expenses/models.py
class ExpenseSplit(models.Model):
"""費用分攤模型"""
expense = models.ForeignKey(
Expense,
on_delete=models.CASCADE,
related_name='splits',
verbose_name="關聯支出"
)
participant = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name='expense_splits',
verbose_name="分攤參與者"
)
split_type = models.CharField(
"分攤類型",
max_length=20,
choices=SplitType.choices,
default=SplitType.AVERAGE
)
calculated_amount = models.DecimalField(
"計算後金額",
max_digits=10,
decimal_places=2
)使用 ManyToManyField 的場景
當關係是對等的多對多,且不需要在關聯本身儲存額外資訊時,使用 ManyToManyField:
# apps/events/models.py
class Event(models.Model):
"""活動模型"""
name = models.CharField("活動名稱", max_length=200)
# 活動管理者 - 多對多關係
managers = models.ManyToManyField(
get_user_model(),
related_name='managed_events',
verbose_name="活動管理者",
blank=True
)
# 創建者 - 外鍵關係
created_by = models.ForeignKey(
get_user_model(),
on_delete=models.SET_NULL,
null=True,
related_name='created_events',
verbose_name="創建者"
)這裡的設計邏輯: - managers 使用 M2M:多個管理員可管理同一活動,一個管理員可管理多個活動,且不需要額外屬性 - created_by 使用 FK:活動只有一個創建者,這是明確的所有權關係
群組管理的類似設計
# apps/groups/models.py
class Group(models.Model):
"""群組模型"""
name = models.CharField("群組名稱", max_length=100)
# 創建者 - 外鍵
created_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name='created_groups',
verbose_name="創建者"
)
# 群組管理者 - 多對多
managers = models.ManyToManyField(
settings.AUTH_USER_MODEL,
related_name='managed_groups',
blank=True,
verbose_name="群組管理者"
)二、related_name 命名策略與反向查詢設計
related_name 定義了反向查詢的接口,良好的命名策略能大幅提升代碼可讀性。
命名原則
使用複數形式描述關聯集合
從反向查詢的角度思考命名
保持一致性
# 支出模型的關聯設計
class Expense(models.Model):
# 從 Category 反查:category.expenses.all()
category = models.ForeignKey(
'categories.Category',
on_delete=models.PROTECT,
related_name='expenses',
verbose_name="分類"
)
# 從 User 反查:user.expenses.all()
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name='expenses',
verbose_name="記錄者"
)
# 從 Event 反查:event.expenses.all()
event = models.ForeignKey(
'events.Event',
on_delete=models.SET_NULL,
null=True,
related_name='expenses',
verbose_name="關聯活動"
)
# 從 Group 反查:group.expenses.all()
group = models.ForeignKey(
'groups.Group',
on_delete=models.SET_NULL,
null=True,
related_name='expenses',
verbose_name="關聯群組"
)反向查詢的實際應用
# 獲取某用戶參與的所有活動
events = Event.objects.filter(participants__user=user)
# 統計某活動的總支出
from django.db.models import Sum
total = event.expenses.aggregate(total=Sum('amount'))['total']
# 獲取某群組的所有活動
group_events = group.events.all()
# 獲取某用戶管理的所有群組
managed_groups = user.managed_groups.all()三、TextChoices 列舉類型定義狀態
相比字符串常量或整數選項,TextChoices 提供類型安全和自文檔化的優勢。
活動狀態定義
# apps/events/models.py
class EventStatus(models.TextChoices):
"""活動狀態選擇"""
ACTIVE = 'ACTIVE', '進行中'
COMPLETED = 'COMPLETED', '已完成'
CANCELLED = 'CANCELLED', '已取消'
class Event(models.Model):
status = models.CharField(
"活動狀態",
max_length=10,
choices=EventStatus.choices,
default=EventStatus.ACTIVE
)分攤類型定義
# apps/expenses/models.py
class SplitType(models.TextChoices):
"""費用分攤類型"""
AVERAGE = 'AVERAGE', '平均分攤'
RATIO = 'RATIO', '比例分攤'
FIXED = 'FIXED', '固定金額'
SELECTIVE = 'SELECTIVE', '選擇性分攤'參與者分攤選項
# apps/events/models.py
class SplitOption(models.TextChoices):
"""參與者分攤選項"""
NO_SPLIT = 'NO_SPLIT', '不分攤先前費用'
PARTIAL_SPLIT = 'PARTIAL_SPLIT', '部分分攤費用'
FULL_SPLIT = 'FULL_SPLIT', '分攤所有費用'TextChoices 的優勢: 1. IDE 支援:可以使用 SplitType.AVERAGE 進行類型檢查 2. 自動表單生成:DRF Serializer 自動生成選項 3. 可讀性高:代碼中使用枚舉值而非魔法字串
四、@property 裝飾器設計計算屬性
業務邏輯涉及基於模型數據的計算,@property 將這些計算封裝在 Model 中,保持關注點分離。
活動狀態的計算屬性
# apps/events/models.py
class Event(models.Model):
@property
def is_active(self) -> bool:
"""檢查活動是否為進行中"""
return self.status == EventStatus.ACTIVE and self.enabled
@property
def is_completed(self) -> bool:
"""檢查活動是否已完成"""
return self.status == EventStatus.COMPLETED
@property
def is_in_progress(self) -> bool:
"""檢查活動是否已開始進行"""
from django.utils import timezone
return self.start_date <= timezone.now()
@property
def is_before_start(self) -> bool:
"""檢查活動是否尚未開始"""
from django.utils import timezone
return self.start_date > timezone.now()分類的計算屬性
# apps/categories/models.py
class Category(models.Model):
@property
def is_expense_category(self) -> bool:
"""檢查是否為支出分類"""
return self.type == CategoryType.EXPENSE
@property
def is_income_category(self) -> bool:
"""檢查是否為收入分類"""
return self.type == CategoryType.INCOME用戶角色的計算屬性
# apps/users/models.py
class User(AbstractUser):
@property
def is_admin(self) -> bool:
"""檢查是否為管理員"""
return self.role == UserRole.ADMIN
@property
def is_regular_user(self) -> bool:
"""檢查是否為一般用戶"""
return self.role == UserRole.USER
@property
def can_manage_payments(self) -> bool:
"""檢查是否可以管理付款狀態"""
return self.role == UserRole.ADMIN群組的計算屬性
# apps/groups/models.py
class Group(models.Model):
@property
def member_count(self) -> int:
"""取得群組成員數量"""
return self.members.count()
class GroupMember(models.Model):
@property
def is_system_user(self) -> bool:
"""檢查是否為系統用戶"""
return self.user is not None性能注意事項:@property 每次訪問都會執行計算,大量查詢時可能產生 N+1 問題。此時應考慮使用 @cached_property 或在 QuerySet 中使用 annotate() 和 F() 表達式。
五、JSONField 儲存彈性資料
分帳系統中存在多種分帳方式和元數據需求,JSONField 提供了結構化但靈活的存儲方案。
參與者的部分分攤支出列表
# apps/events/models.py
class ActivityParticipant(models.Model):
# 部分分攤時的特定支出ID列表
partial_split_expenses = models.JSONField(
"部分分攤支出列表",
default=list,
blank=True,
help_text="當選擇部分分攤時,需要分攤的支出ID列表"
)審計日誌的元數據
# apps/events/models.py
class ActivityLog(models.Model):
# 額外的元數據資訊
metadata = models.JSONField(
"操作元數據",
default=dict,
blank=True,
help_text="操作的額外資訊,如變更前後的值"
)JSONField 的實際應用
# 記錄支出新增的詳細資訊
ActivityLog.objects.create(
activity=expense.event,
action_type=ActionType.EXPENSE_ADD,
description=f"新增支出「{expense.description}」NT${expense.amount}",
operator=request.user,
metadata={
'expense_id': expense.id,
'amount': str(expense.amount),
'category': expense.category.name
}
)
# 記錄分攤調整
ActivityLog.objects.create(
activity=expense.event,
action_type=ActionType.SPLIT_ADJUST,
description=f"調整支出「{expense.description}」的分攤方式",
operator=user,
metadata={
'expense_id': expense.id,
'splits_count': len(new_splits),
'previous_type': old_split_type,
'new_type': new_split_type
}
)JSONField 的優勢在於避免過度規範化,同時保持數據的可查詢性(Django 3.1+ 支持 JSONField 查詢)。
六、Meta 類別配置:索引與查詢優化
Meta 類別配置直接影響數據庫性能和業務邏輯。
支出模型的 Meta 配置
# apps/expenses/models.py
class Expense(models.Model):
class Meta:
verbose_name = "支出記錄"
verbose_name_plural = "支出記錄"
db_table = "expenses"
ordering = ['-date', '-created_at'] # 按日期和創建時間倒序
indexes = [
models.Index(fields=['type']),
models.Index(fields=['date']),
]活動模型的 Meta 配置
# apps/events/models.py
class Event(models.Model):
class Meta:
verbose_name = "活動"
verbose_name_plural = "活動"
db_table = "events"
ordering = ['-start_date', '-created_at']
indexes = [
models.Index(fields=['status']),
models.Index(fields=['enabled']),
models.Index(fields=['start_date']),
]分攤記錄的 Meta 配置
# apps/expenses/models.py
class ExpenseSplit(models.Model):
class Meta:
verbose_name = "費用分攤"
verbose_name_plural = "費用分攤"
db_table = "expense_splits"
unique_together = ['expense', 'participant'] # 確保不重複分攤
indexes = [
models.Index(fields=['expense', 'participant']),
models.Index(fields=['split_type']),
models.Index(fields=['is_adjusted']),
]活動參與者的 Meta 配置
# apps/events/models.py
class ActivityParticipant(models.Model):
class Meta:
verbose_name = "活動參與者"
verbose_name_plural = "活動參與者"
db_table = "activity_participants"
unique_together = ['activity', 'user'] # 同一用戶不能重複參與
indexes = [
models.Index(fields=['activity', 'user']),
models.Index(fields=['is_active']),
]索引設計考量
查詢頻繁的字段:status、date、type 等過濾條件
複合索引:支持多條件查詢如
['activity', 'user']避免過度索引:索引會增加寫入成本,應根據實際查詢模式決定
七、模型方法設計原則
業務邏輯應當封裝在 Model 中,使代碼更加內聚和可測試。
權限檢查方法
# apps/events/models.py
class Event(models.Model):
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_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()支出的權限與業務方法
# apps/expenses/models.py
class Expense(models.Model):
def can_user_edit(self, user) -> bool:
"""檢查用戶是否可以編輯此支出"""
if not user or not user.is_authenticated:
return False
# 系統管理員可以編輯所有支出
if user.role == 'ADMIN':
return True
# 支出記錄者可以編輯自己的記錄
if self.user == user:
return True
# 如果有關聯活動,檢查活動管理權限
if self.event:
return self.event.can_user_manage(user)
return False
def get_participants_for_split(self):
"""獲取參與分攤的用戶列表"""
if not self.event:
return []
participants = []
for participant in self.event.participants.filter(is_active=True):
# 檢查分攤選項
if participant.split_option == 'NO_SPLIT':
# 不分攤先前費用,只分攤加入後的支出
if self.date >= participant.joined_at:
participants.append(participant.user)
elif participant.split_option == 'PARTIAL_SPLIT':
# 部分分攤,檢查是否包含在分攤列表中
if self.id in participant.partial_split_expenses:
participants.append(participant.user)
elif participant.split_option == 'FULL_SPLIT':
# 分攤所有費用
participants.append(participant.user)
return participants群組管理方法
# apps/groups/models.py
class Group(models.Model):
def is_manager(self, user) -> bool:
"""檢查指定用戶是否為此群組的管理者"""
if user.role == 'ADMIN': # 系統管理員預設為所有群組的管理者
return True
return self.managers.filter(id=user.id).exists()
def add_manager(self, user):
"""添加群組管理者"""
self.managers.add(user)
def remove_manager(self, user):
"""移除群組管理者"""
self.managers.remove(user)八、設計決策的實踐建議
1. 優先考慮查詢模式
在定義 related_name 時,想象實際的查詢語句,確保反向查詢直觀:
# 設計前先思考:我會怎麼查詢這個關聯?
user.expenses.all() # 用戶的所有支出
event.participants.all() # 活動的所有參與者
group.members.all() # 群組的所有成員2. 計算屬性的平衡
簡單計算使用 @property,複雜聚合在視圖層用 annotate() 實現:
# 簡單判斷 → @property
@property
def is_active(self):
return self.status == 'ACTIVE'
# 複雜聚合 → QuerySet annotate
expenses = Expense.objects.annotate(
total_splits=Count('splits')
).filter(total_splits__gt=1)3. 元數據的結構化
雖然 JSONField 靈活,但仍應為常見結構定義清晰文檔:
metadata = models.JSONField(
default=dict,
help_text="""
操作的額外資訊,結構如下:
- expense_id: 相關支出ID
- amount: 金額(字串格式)
- previous_value: 變更前的值
- new_value: 變更後的值
"""
)4. 索引的精準設計
不是所有字段都需要索引,測量查詢瓶頸後再優化:
# 只為頻繁查詢的欄位建立索引
indexes = [
models.Index(fields=['status']), # 經常按狀態過濾
models.Index(fields=['date']), # 經常按日期範圍查詢
# models.Index(fields=['description']), # 很少單獨查詢,不需要索引
]總結
合理的 Django Model 設計是構建高效、可維護系統的基石。通過本文的分析,我們可以歸納出以下核心原則:
關聯類型選擇:ForeignKey 用於明確的歸屬關係,ManyToManyField 用於對等的多方關係
命名策略:
related_name應從反向查詢的角度思考,保持一致性狀態定義:使用 TextChoices 獲得類型安全和自文檔化
計算屬性:封裝業務邏輯,但注意性能影響
彈性儲存:JSONField 適合元數據,但應保持結構文檔化
Meta 配置:索引設計應基於實際查詢模式
方法封裝:業務邏輯封裝在 Model 中,提高內聚性
好的模型設計應當讓業務邏輯清晰可讀,讓數據查詢高效可靠。這些原則在我們的家族記帳系統中得到了充分驗證,也希望能為你的專案提供參考。