Back to Blog
PAPA開發實錄 [Day 3/7] - Django 後端架構與智能分帳算法
📋 Case Study

PAPA開發實錄 [Day 3/7] - Django 後端架構與智能分帳算法

B
Blake
Aug 28, 2025 By Blake 20 min read
簡單勝過複雜:扁平化權限比複雜權限樹更易維護 業務邏輯內聚:Model 層封裝提升程式碼品質 查詢優化至關重要:正確預載策略可提升 10 倍效能 文化理解不可替代:AI 能生成 60% 程式碼,但核心算法需人工設計

作為Blake Lab的文化科技融合實驗,第三天我們深入 Django 後端的技術核心,展示如何用優雅的架構設計解決阿美族社群複雜的財務分攤需求。

🎯 今日核心:從部落財務痛點到技術解決方案

昨天完成了前端 UX 設計,今天要處理真正的技術難題:如何設計一個既能處理複雜分帳邏輯,又能維持高效能的後端架構?

這不只是寫 CRUD 的問題,而是要處理:

  • 時間敏感的分攤(後加入者不分攤之前的費用)

  • 選擇性分攤(只分攤特定項目)

  • 權限繼承(群組→活動→支出的權限傳遞)

  • 即時同步(多人同時編輯不衝突)

🏗️ Django 權限系統:扁平化設計的智慧

為何選擇扁平化角色設計?

經過多次迭代,我發現複雜的權限樹反而讓使用者困惑。最終採用扁平化 + 關聯式的設計:

# 簡化但靈活的權限模型
class User(AbstractUser):
    class UserRole(models.TextChoices):
        ADMIN = 'ADMIN', '系統管理員'
        USER = 'USER', '一般用戶'
    
    role = models.CharField(
        "角色", max_length=20, 
        choices=UserRole.choices,
        default=UserRole.USER
    )
    
    @property
    def managed_groups(self):
        """透過關聯獲取管理的群組"""
        return self.managing_groups.all()
    
    @property
    def managed_events(self):
        """透過關聯獲取管理的活動"""
        return Event.objects.filter(managers=self)

設計哲學:權限不是靜態的角色,而是動態的關係

業務邏輯封裝的最佳實踐

class Event(models.Model):
    def can_user_add_expense(self, user) -> bool:
        """
        分層權限檢查邏輯
        展現了阿美族 kapot(年齡階層)的權限概念
        """
        if not user or not user.is_authenticated:
            return False
        
        # 層級 1:系統管理員 - 最高權限
        if user.role == 'ADMIN':
            return True
        
        # 層級 2:活動已鎖定 - 只有管理者可操作
        if self.is_locked:
            return self.can_user_manage(user)
        
        # 層級 3:活動進行中 - 參與者可新增
        if self.participants.filter(
            user=user, 
            is_active=True
        ).exists():
            return True
        
        # 層級 4:活動管理者 - 即使非參與者也可操作
        if self.managers.filter(id=user.id).exists():
            return True
        
        return False

關鍵洞察:將權限邏輯直接嵌入模型,而不是分散在 View 或 Serializer 中,確保了業務規則的一致性。

🧮 智能分帳算法:處理現實世界的複雜性

四種分攤模式的統一架構

class ExpenseSplit(models.Model):
    """支出分攤記錄 - 核心算法載體"""
    
    class SplitType(models.TextChoices):
        AVERAGE = 'AVERAGE', '平均分攤'    # 家族聚餐常用
        RATIO = 'RATIO', '比例分攤'        # 按收入比例
        FIXED = 'FIXED', '固定金額'        # 事先約定
        SELECTIVE = 'SELECTIVE', '選擇性'   # 靈活選擇
    
    expense = models.ForeignKey(
        Expense, on_delete=models.CASCADE, 
        related_name='splits'
    )
    participant = models.ForeignKey(
        ActivityParticipant, on_delete=models.CASCADE
    )
    split_type = models.CharField(
        max_length=20, choices=SplitType.choices
    )
    split_value = models.DecimalField(
        max_digits=10, decimal_places=4,
        help_text="比例時為小數,固定金額時為金額"
    )
    calculated_amount = models.DecimalField(
        max_digits=10, decimal_places=2
    )
    
    # 審計欄位
    is_adjusted = models.BooleanField(default=False)
    adjusted_by = models.ForeignKey(
        User, null=True, on_delete=models.SET_NULL
    )
    adjusted_at = models.DateTimeField(null=True)

時間敏感分攤的創新實現

這是 PAPA 最具特色的功能之一:

def calculate_expense_splits(expense: Expense) -> List[ExpenseSplit]:
    """
    智能分攤計算引擎
    處理時間敏感和選擇性分攤的複雜邏輯
    """
    splits = []
    participants = expense.event.participants.filter(is_active=True)
    
    for participant in participants:
        # 時間敏感檢查:晚加入者的特殊處理
        if participant.split_option == 'NO_SPLIT':
            # 只分攤加入後產生的支出
            if expense.date < participant.joined_at:
                continue
        
        # 選擇性分攤:白名單機制
        elif participant.split_option == 'PARTIAL_SPLIT':
            # 檢查是否在分攤白名單中
            if expense.id not in participant.partial_split_expenses:
                continue
        
        # 計算個人應付金額
        amount = calculate_participant_amount(
            expense.amount,
            participant,
            len(eligible_participants)
        )
        
        splits.append(ExpenseSplit(
            expense=expense,
            participant=participant,
            split_type=participant.default_split_type,
            calculated_amount=amount
        ))
    
    return splits

創新點:將時間維度納入分攤邏輯,更符合真實世界的使用場景。

🗄️ PostgreSQL 查詢優化:從 N+1 到 1

問題:初版的 N+1 查詢地獄

# ❌ 錯誤示範:會產生大量資料庫查詢
expenses = Expense.objects.all()
for expense in expenses:
    print(expense.user.name)  # N 次查詢
    print(expense.category.name)  # N 次查詢
    for split in expense.splits.all():  # N 次查詢
        print(split.participant.user.name)  # N*M 次查詢

解決方案:智能預載策略

# ✅ 優化後:只需要 3 次查詢
class ExpenseViewSet(viewsets.ModelViewSet):
    def get_queryset(self):
        return Expense.objects.select_related(
            'user',           # JOIN 使用者表
            'category',       # JOIN 分類表
            'event',          # JOIN 活動表
            'event__group'    # JOIN 群組表
        ).prefetch_related(
            'splits__participant__user',  # 預載分攤資料
            Prefetch(
                'event__participants',
                queryset=ActivityParticipant.objects.select_related('user')
            )
        ).annotate(
            # 聚合計算,避免額外查詢
            total_splits=Count('splits'),
            split_sum=Sum('splits__calculated_amount')
        )

效能提升:從平均 200ms 降至 15ms(資料量 1000 筆)。

複合索引的精準設計

class Meta:
    indexes = [
        # 常用查詢路徑的複合索引
        models.Index(
            fields=['event', 'is_active', '-date'],
            name='event_active_date_idx'
        ),
        # 分攤查詢優化
        models.Index(
            fields=['expense', 'participant'],
            name='split_lookup_idx'
        ),
        # 時間範圍查詢
        models.Index(
            fields=['date', 'event'],
            condition=Q(is_active=True),  # 部分索引
            name='active_date_event_idx'
        ),
    ]

🔄 原子性操作:確保資料一致性

使用資料庫事務處理複雜操作

from django.db import transaction

class ExpenseViewSet(viewsets.ModelViewSet):
    @action(detail=True, methods=['post'])
    def adjust_splits(self, request, pk=None):
        """
        調整分攤方式 - 需要原子性保證
        """
        expense = self.get_object()
        
        with transaction.atomic():
            # 建立保存點
            sid = transaction.savepoint()
            
            try:
                # 刪除舊的分攤記錄
                expense.splits.all().delete()
                
                # 創建新的分攤記錄
                total_amount = Decimal('0')
                for split_data in request.data['splits']:
                    split = ExpenseSplit.objects.create(
                        expense=expense,
                        **split_data
                    )
                    total_amount += split.calculated_amount
                
                # 驗證總金額
                if abs(total_amount - expense.amount) > Decimal('0.01'):
                    transaction.savepoint_rollback(sid)
                    return Response(
                        {'error': '分攤總金額與支出金額不符'},
                        status=400
                    )
                
                # 記錄審計日誌
                ActivityLog.objects.create(
                    activity=expense.event,
                    action_type='SPLIT_ADJUST',
                    description=f'調整支出分攤:{expense.description}',
                    operator=request.user,
                    metadata={
                        'expense_id': expense.id,
                        'old_count': old_count,
                        'new_count': len(request.data['splits'])
                    }
                )
                
                transaction.savepoint_commit(sid)
                
            except Exception as e:
                transaction.savepoint_rollback(sid)
                raise e
        
        return Response({'status': 'success'})

關鍵技術

  • 使用 savepoint 實現細粒度的事務控制

  • 金額驗證防止分帳錯誤

  • 完整的審計日誌追蹤

🚀 Railway 部署:從本地到雲端的無縫遷移

智能環境配置

# settings/railway.py
import dj_database_url
from decouple import config, Csv

# 環境感知配置
ENV = config('ENV', default='development')
DEBUG = config('DEBUG', default=False, cast=bool)

# 資料庫 URL 的智能解析
DATABASE_URL = config('DATABASE_URL', default='')
if DATABASE_URL:
    # Railway 特殊字符處理
    DATABASE_URL = DATABASE_URL.replace('{{', '').replace('}}', '')
    DATABASES = {
        'default': dj_database_url.parse(
            DATABASE_URL,
            conn_max_age=600,  # 連接池優化
            conn_health_checks=True,  # 健康檢查
        )
    }

# Redis 配置與回退機制
REDIS_URL = config('REDIS_URL', default=None)
if REDIS_URL:
    # 生產環境:使用 Redis
    CHANNEL_LAYERS = {
        'default': {
            'BACKEND': 'channels_redis.core.RedisChannelLayer',
            'CONFIG': {
                "hosts": [REDIS_URL],
                "capacity": 1500,  # 訊息容量
                "expiry": 10,      # 訊息過期時間
            },
        },
    }
else:
    # 開發環境:使用記憶體
    CHANNEL_LAYERS = {
        'default': {
            'BACKEND': 'channels.layers.InMemoryChannelLayer',
        },
    }

部署腳本的最佳實踐

#!/bin/bash
# railway-predeploy.sh - 部署前置作業

echo "🚀 Starting Railway deployment..."

# 資料庫遷移
python manage.py migrate --no-input

# 靜態檔案收集
python manage.py collectstatic --no-input

# 創建預設管理員(如果不存在)
python manage.py shell -c "
from django.contrib.auth import get_user_model
User = get_user_model()
if not User.objects.filter(username='admin').exists():
    User.objects.create_superuser('admin', '[email protected]', 'admin')
    print('✅ Default admin created')
"

# 初始化測試資料(僅開發環境)
if [ "$ENV" = "development" ]; then
    python create_test_scenarios.py
    echo "✅ Test data initialized"
fi

echo "✅ Railway deployment ready!"

🎯 今日成果與技術收穫

完成的技術亮點

  • ✅ 扁平化權限系統的優雅實現

  • ✅ 時間敏感分攤算法的創新設計

  • ✅ PostgreSQL 查詢從 200ms 優化到 15ms

  • ✅ 原子性操作確保資料一致性

  • ✅ Railway 無縫部署配置

關鍵技術洞察

  1. 簡單勝過複雜:扁平化權限設計比複雜權限樹更易維護

  2. 業務邏輯內聚:將權限檢查放在 Model 層提升了程式碼品質

  3. 查詢優化至關重要:正確的預載策略可以提升 10 倍效能

  4. 原子性保證資料完整:金融相關功能必須使用事務

AI 輔助開發的實際效果

  • GitHub Copilot 協助生成了 60% 的序列化器程式碼

  • Claude 3.5 幫助設計了複雜的查詢優化策略

  • 但分帳算法的核心邏輯仍需要人工設計(文化理解無法被 AI 取代)

🔮 明日預告

Day 4 將分享前後端整合的實戰經驗,包括:

  • React Query 與 Django REST 的完美配合

  • WebSocket 即時同步的實現細節

  • 錯誤處理與用戶體驗優化

  • 端到端測試策略

技術的價值在於解決真實問題,而不是炫技。PAPA 的後端架構證明了:用對的技術,解決對的問題,就是最好的設計。


🚀 Blake Lab - AI時代文化科技雙領先者
💡 20年政府專案 | 🤖 AI原住民應用 | 🎯 族語NLP開發 | 📈 文化敏感設計
🌐 wchung.tw | 📧 [email protected]

關注 Blake Lab,一起探索技術與文化的無限可能!

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