Back to Blog
Django Model 設計:活動分帳系統的關聯模型架構
📝 Dev Notes

Django Model 設計:活動分帳系統的關聯模型架構

B
Blake
Dec 5, 2025 By Blake 29 min read
在構建多人分帳系統時,合理的 Django Model 設計直接影響系統的可維護性、查詢性能和業務邏輯的清晰度。本文將以實際的家族活動分帳系統為例,深入探討關鍵的 Model 設計決策與最佳實踐。

系統背景與設計挑戰

在開發阿美族家族記帳系統(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 定義了反向查詢的接口,良好的命名策略能大幅提升代碼可讀性。

命名原則

  1. 使用複數形式描述關聯集合

  2. 從反向查詢的角度思考命名

  3. 保持一致性

# 支出模型的關聯設計
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']),
        ]

索引設計考量

  1. 查詢頻繁的字段:status、date、type 等過濾條件

  2. 複合索引:支持多條件查詢如 ['activity', 'user']

  3. 避免過度索引:索引會增加寫入成本,應根據實際查詢模式決定


七、模型方法設計原則

業務邏輯應當封裝在 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 設計是構建高效、可維護系統的基石。通過本文的分析,我們可以歸納出以下核心原則:

  1. 關聯類型選擇:ForeignKey 用於明確的歸屬關係,ManyToManyField 用於對等的多方關係

  2. 命名策略related_name 應從反向查詢的角度思考,保持一致性

  3. 狀態定義:使用 TextChoices 獲得類型安全和自文檔化

  4. 計算屬性:封裝業務邏輯,但注意性能影響

  5. 彈性儲存:JSONField 適合元數據,但應保持結構文檔化

  6. Meta 配置:索引設計應基於實際查詢模式

  7. 方法封裝:業務邏輯封裝在 Model 中,提高內聚性

好的模型設計應當讓業務邏輯清晰可讀,讓數據查詢高效可靠。這些原則在我們的家族記帳系統中得到了充分驗證,也希望能為你的專案提供參考。

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