1. 为什么Django需要自定义订阅系统?
在电商、内容平台和SaaS服务中,订阅模式已成为核心商业模式之一。Django自带的admin虽然强大,但面对复杂的订阅业务场景时,原生功能往往捉襟见肘。我最近为一个知识付费平台重构订阅系统时,就遇到了几个典型痛点:
- 混合订阅(如"基础版+按需付费")无法通过简单配置实现
- 试用期、宽限期等业务逻辑需要大量硬编码
- 账单生成与支付系统的耦合度过高
通过自定义实现,我们最终将订阅相关代码从23个分散的文件整合到统一模块,计费准确率从92%提升到99.8%。下面分享这套系统的关键设计思路。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 核心模型设计
2.1 订阅三要素模型
python复制class SubscriptionPlan(models.Model):
TIER_CHOICES = [
('free', '免费版'),
('basic', '基础版'),
('pro', '专业版')
]
name = models.CharField(max_length=50)
tier = models.CharField(max_length=20, choices=TIER_CHOICES)
price = models.DecimalField(max_digits=10, decimal_places=2)
billing_cycle = models.PositiveSmallIntegerField() # 月数
class UserSubscription(models.Model):
user = models.OneToOneField(User, on_delete=models.CASCADE)
plan = models.ForeignKey(SubscriptionPlan, on_delete=models.PROTECT)
start_date = models.DateTimeField(auto_now_add=True)
next_billing_date = models.DateTimeField()
is_active = models.BooleanField(default=True)
class BillingHistory(models.Model):
subscription = models.ForeignKey(UserSubscription, on_delete=models.CASCADE)
amount = models.DecimalField(max_digits=10, decimal_places=2)
payment_date = models.DateTimeField(auto_now_add=True)
transaction_id = models.CharField(max_length=100)
这个设计通过三个核心模型实现关注点分离:
- SubscriptionPlan定义产品规格
- UserSubscription记录用户状态
- BillingHistory保存交易流水
关键技巧:在UserSubscription使用OneToOneField而非ForeignKey,确保用户只能有一个有效订阅
2.2 状态机实现生命周期管理
订阅系统最复杂的就是状态转换。我们采用django-fsm实现确定性的状态管理:
python复制from django_fsm import FSMField, transition
class UserSubscription(models.Model):
STATE_CHOICES = [
('trial', '试用中'),
('active', '生效中'),
('grace', '宽限期'),
('expired', '已过期')
]
state = FSMField(default='trial', choices=STATE_CHOICES)
@transition(field=state, source='trial', target='active')
def activate(self):
self.next_billing_date = calculate_next_billing()
@transition(field=state, source='*', target='expired')
def expire(self):
self.is_active = False
3. 计费引擎实现
3.1 基于策略模式的计费规则
python复制from abc import ABC, abstractmethod
class BillingStrategy(ABC):
@abstractmethod
def calculate_amount(self, subscription):
pass
class MonthlyBilling(BillingStrategy):
def calculate_amount(self, subscription):
return subscription.plan.price
class AnnualBilling(BillingStrategy):
def calculate_amount(self, subscription):
return subscription.plan.price * 12 * 0.9 # 年费9折
class UsageBilling(BillingStrategy):
def __init__(self, unit_price):
self.unit_price = unit_price
def calculate_amount(self, subscription):
usage = get_usage(subscription.user)
return usage * self.unit_price
在视图中动态选择策略:
python复制def process_billing(request):
strategy_map = {
'monthly': MonthlyBilling(),
'annual': AnnualBilling(),
'usage': UsageBilling(unit_price=0.5)
}
strategy = strategy_map[request.POST['billing_type']]
amount = strategy.calculate_amount(subscription)
3.2 异步任务队列实践
使用Celery处理耗时操作:
python复制@app.task(bind=True, max_retries=3)
def process_recurring_payment(self, subscription_id):
try:
subscription = UserSubscription.objects.get(pk=subscription_id)
amount = BillingEngine.calculate(subscription)
payment_service.charge(subscription.user, amount)
BillingHistory.objects.create(...)
subscription.renew()
except PaymentError as e:
self.retry(exc=e, countdown=60*5)
配置Celery Beat实现定期执行:
python复制app.conf.beat_schedule = {
'daily-billing': {
'task': 'subscriptions.tasks.process_due_subscriptions',
'schedule': crontab(hour=0, minute=0), # 每天零点执行
},
}
4. 实战中的性能优化
4.1 查询优化方案
错误示范(N+1查询问题):
python复制# 在模板中循环导致多次查询
{% for history in user.subscription.billinghistory_set.all %}
{{ history.amount }}
{% endfor %}
优化方案:
python复制# 使用select_related和prefetch_related
subscriptions = UserSubscription.objects.filter(
next_billing_date__lte=timezone.now()
).select_related('user', 'plan').prefetch_related('billinghistory_set')
4.2 数据库索引策略
python复制class UserSubscription(models.Model):
class Meta:
indexes = [
models.Index(fields=['next_billing_date']),
models.Index(fields=['user', 'is_active']),
]
5. 安全防护要点
5.1 支付验证流程
python复制def webhook_view(request):
signature = request.headers.get('X-Payment-Signature')
payload = request.body
if not verify_signature(payload, signature, SECRET_KEY):
raise SuspiciousOperation("Invalid signature")
event = json.loads(payload)
if event['type'] == 'payment.succeeded':
handle_successful_payment(event)
5.2 防重复支付机制
python复制def process_payment(transaction_id):
if BillingHistory.objects.filter(transaction_id=transaction_id).exists():
raise DuplicatePaymentError
# 正常处理流程
6. 测试策略
6.1 工厂模式创建测试数据
python复制import factory
class SubscriptionPlanFactory(factory.django.DjangoModelFactory):
class Meta:
model = SubscriptionPlan
name = "专业版"
tier = "pro"
price = Decimal("99.00")
billing_cycle = 1
@pytest.fixture
def pro_plan():
return SubscriptionPlanFactory()
6.2 模拟支付网关
python复制from unittest.mock import patch
def test_payment_processing(pro_plan):
with patch('subscriptions.services.payment_gateway.charge') as mock_charge:
mock_charge.return_value = {"status": "success"}
result = process_payment(user, pro_plan)
assert result.is_success
mock_charge.assert_called_once()
7. 前端集成方案
7.1 订阅状态组件
javascript复制// React示例
function SubscriptionBadge({ user }) {
const [subscription, setSubscription] = useState(null);
useEffect(() => {
axios.get(`/api/subscriptions/${user.id}/`)
.then(res => setSubscription(res.data));
}, []);
if (!subscription) return <div>Loading...</div>;
return (
<div className={`badge ${subscription.state}`}>
{subscription.plan.name} -
{subscription.next_billing_date}到期
</div>
);
}
7.2 支付表单安全处理
python复制# Django视图
def create_payment_intent(request):
plan = get_object_or_404(SubscriptionPlan, pk=request.POST['plan_id'])
intent = stripe.PaymentIntent.create(
amount=int(plan.price * 100), # 转为分
currency='cny',
metadata={'user_id': request.user.id}
)
return JsonResponse({'clientSecret': intent.client_secret})
8. 监控与报警
8.1 Prometheus指标暴露
python复制from prometheus_client import Counter
failed_payments = Counter(
'subscription_payment_failures',
'失败的订阅支付次数',
['plan_id', 'reason']
)
def process_payment():
try:
# 支付逻辑
except PaymentError as e:
failed_payments.labels(plan.id, str(e)).inc()
raise
8.2 关键业务告警
python复制# Celery任务异常通知
@app.task(bind=True)
def send_expiration_notices(self):
try:
# 通知逻辑
except Exception as e:
notify_slack(f"订阅到期通知失败: {str(e)}")
raise
9. 迁移与数据一致性
9.1 数据迁移策略
python复制# 迁移文件示例
def migrate_legacy_subscriptions(apps, schema_editor):
LegacySub = apps.get_model('legacy', 'Subscription')
NewSub = apps.get_model('subscriptions', 'UserSubscription')
for legacy in LegacySub.objects.all():
NewSub.objects.create(
user=legacy.user,
plan=find_equivalent_plan(legacy.plan_type),
start_date=legacy.start_date,
next_billing_date=legacy.end_date,
state='active' if legacy.is_active else 'expired'
)
9.2 事务保护
python复制from django.db import transaction
@transaction.atomic
def upgrade_plan(user, new_plan):
current = user.usersubscription
if current.plan.tier == new_plan.tier:
return
prorated_amount = calculate_prorated_amount(current, new_plan)
process_payment(user, prorated_amount)
current.plan = new_plan
current.save()
10. 扩展性设计
10.1 插件式架构
python复制# subscriptions/extensions.py
class BaseExtension:
@classmethod
def validate_config(cls, config):
raise NotImplementedError
def process_billing(self, subscription):
pass
class ReferralDiscountExtension(BaseExtension):
@classmethod
def validate_config(cls, config):
assert 'discount_rate' in config
def process_billing(self, subscription):
referrals = Referral.objects.filter(referrer=subscription.user)
discount = subscription.plan.price * len(referrals) * 0.1 # 每个推荐10%折扣
return max(0, discount)
10.2 Webhook集成
python复制@csrf_exempt
def handle_webhook(request):
event = parse_event(request)
handler = WebhookHandler.for_event(event.type)
return handler.process(event)
在实现这套系统过程中,最大的教训是不要过早优化。我们最初花了大量时间设计"完美"的通用计费方案,结果发现80%的用户只需要简单的月费订阅。建议从最小可行产品开始,通过迭代逐步扩展功能边界。
