baseline: 批次A-D 成果 + membership 半成品(测试红)
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class BillingConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "apps.billing"
|
||||
verbose_name = "套餐与计费"
|
||||
@@ -0,0 +1,63 @@
|
||||
# Generated by Django 5.2.12 on 2026-09-10 13:51
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('core', '0001_initial'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Plan',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('code', models.CharField(max_length=32, unique=True)),
|
||||
('name', models.CharField(max_length=64)),
|
||||
('price_monthly', models.DecimalField(decimal_places=2, default=0, help_text='月费(元);0 = 免费', max_digits=10)),
|
||||
('limits', models.JSONField(default=dict, help_text='配额与功能开关(唯一事实来源)')),
|
||||
('description', models.CharField(blank=True, default='', max_length=255)),
|
||||
('sort_order', models.IntegerField(default=0)),
|
||||
('is_active', models.BooleanField(default=True)),
|
||||
],
|
||||
options={
|
||||
'verbose_name_plural': '套餐',
|
||||
'db_table': 'billing_plan',
|
||||
'ordering': ['sort_order', 'code'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Subscription',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('is_deleted', models.BooleanField(default=False)),
|
||||
('deleted_at', models.DateTimeField(blank=True, null=True)),
|
||||
('ext_data', models.JSONField(blank=True, default=dict)),
|
||||
('source_channel', models.CharField(blank=True, default='manual', max_length=32)),
|
||||
('status', models.CharField(choices=[('trial', '试用中'), ('active', '已订阅'), ('expired', '已过期')], default='trial', max_length=16)),
|
||||
('trial_ends_at', models.DateTimeField(blank=True, null=True)),
|
||||
('period_start', models.DateField(blank=True, null=True)),
|
||||
('period_end', models.DateField(blank=True, null=True)),
|
||||
('remark', models.TextField(blank=True, default='')),
|
||||
('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)),
|
||||
('org', models.ForeignKey(blank=True, help_text='所属组织(多机构预留)', null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='core.org')),
|
||||
('plan', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='subscriptions', to='billing.plan')),
|
||||
('tenant', models.ForeignKey(help_text='所属租户', on_delete=django.db.models.deletion.PROTECT, related_name='+', to='core.tenant')),
|
||||
('updated_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'verbose_name_plural': '订阅',
|
||||
'db_table': 'billing_subscription',
|
||||
'ordering': ['-created_at'],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,214 @@
|
||||
"""套餐与订阅模型(批次 C1)。
|
||||
|
||||
设计要点:
|
||||
- `Plan.limits` 是**唯一事实来源**:官网价格表、配额校验、升级引导弹窗都读它,
|
||||
一处定义两处使用(计划要求)。
|
||||
- `Subscription` 每租户一条:free/basic/pro,含试用期与账期。
|
||||
- 试用到期由 Dramatiq cron 每日扫描 → 降级 free(见 tasks.py)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import timedelta
|
||||
|
||||
from django.db import models
|
||||
from django.utils import timezone
|
||||
|
||||
from apps.core.base_models import TenantScopedModel
|
||||
|
||||
|
||||
# 预置套餐(定价依据:卡在金蝶 698 与用友 1500 之间偏上,凭"带总账 + AI 风控"打差异)
|
||||
DEFAULT_PLANS = [
|
||||
{
|
||||
"code": "free",
|
||||
"name": "免费版",
|
||||
"price_monthly": 0,
|
||||
"sort_order": 0,
|
||||
"description": "30 天全功能试用,适合先跑通流程",
|
||||
"limits": {
|
||||
"users": 1,
|
||||
"products": 100,
|
||||
"bills_monthly": 50,
|
||||
"ai_parse_order": 10,
|
||||
"ai_ask": 20,
|
||||
"batch_managed": True,
|
||||
"finance_ledger": True,
|
||||
"print_templates": 2,
|
||||
"storefront": False,
|
||||
},
|
||||
},
|
||||
{
|
||||
"code": "basic",
|
||||
"name": "基础版",
|
||||
"price_monthly": 998,
|
||||
"sort_order": 1,
|
||||
"description": "单店经销商:进销存 + 应收应付 + 打印",
|
||||
"limits": {
|
||||
"users": 5,
|
||||
"products": 3000,
|
||||
"bills_monthly": 2000,
|
||||
"ai_parse_order": 200,
|
||||
"ai_ask": 500,
|
||||
"batch_managed": True,
|
||||
"finance_ledger": True,
|
||||
"print_templates": 20,
|
||||
"storefront": False,
|
||||
},
|
||||
},
|
||||
{
|
||||
"code": "pro",
|
||||
"name": "专业版",
|
||||
"price_monthly": 4800,
|
||||
"sort_order": 2,
|
||||
"description": "多仓多业务员:总账 + 批次效期 + AI 风控 + 订货商城",
|
||||
"limits": {
|
||||
"users": 50,
|
||||
"products": 100000,
|
||||
"bills_monthly": 0, # 0 = 不限
|
||||
"ai_parse_order": 2000,
|
||||
"ai_ask": 5000,
|
||||
"batch_managed": True,
|
||||
"finance_ledger": True,
|
||||
"print_templates": 0, # 0 = 不限
|
||||
"storefront": True,
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
class Plan(models.Model):
|
||||
"""套餐定义(全局,非租户级)。"""
|
||||
|
||||
code = models.CharField(max_length=32, unique=True)
|
||||
name = models.CharField(max_length=64)
|
||||
price_monthly = models.DecimalField(max_digits=10, decimal_places=2, default=0,
|
||||
help_text="月费(元);0 = 免费")
|
||||
limits = models.JSONField(default=dict, help_text="配额与功能开关(唯一事实来源)")
|
||||
description = models.CharField(max_length=255, blank=True, default="")
|
||||
sort_order = models.IntegerField(default=0)
|
||||
is_active = models.BooleanField(default=True)
|
||||
|
||||
class Meta:
|
||||
db_table = "billing_plan"
|
||||
verbose_name_plural = "套餐"
|
||||
ordering = ["sort_order", "code"]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.name}(¥{self.price_monthly}/月)"
|
||||
|
||||
|
||||
class Subscription(TenantScopedModel):
|
||||
"""租户订阅(每租户一条)。"""
|
||||
|
||||
STATUS_TRIAL = "trial"
|
||||
STATUS_ACTIVE = "active"
|
||||
STATUS_EXPIRED = "expired"
|
||||
STATUS_CHOICES = [
|
||||
(STATUS_TRIAL, "试用中"),
|
||||
(STATUS_ACTIVE, "已订阅"),
|
||||
(STATUS_EXPIRED, "已过期"),
|
||||
]
|
||||
|
||||
plan = models.ForeignKey(Plan, on_delete=models.PROTECT, related_name="subscriptions")
|
||||
status = models.CharField(max_length=16, choices=STATUS_CHOICES, default=STATUS_TRIAL)
|
||||
trial_ends_at = models.DateTimeField(null=True, blank=True)
|
||||
period_start = models.DateField(null=True, blank=True)
|
||||
period_end = models.DateField(null=True, blank=True)
|
||||
remark = models.TextField(blank=True, default="")
|
||||
|
||||
class Meta:
|
||||
db_table = "billing_subscription"
|
||||
verbose_name_plural = "订阅"
|
||||
ordering = ["-created_at"]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.tenant_id}:{self.plan.code}/{self.status}"
|
||||
|
||||
@property
|
||||
def is_trial_expired(self) -> bool:
|
||||
return bool(
|
||||
self.status == self.STATUS_TRIAL
|
||||
and self.trial_ends_at
|
||||
and timezone.now() > self.trial_ends_at
|
||||
)
|
||||
|
||||
def effective_limits(self) -> dict:
|
||||
"""当前生效配额:试用过期或已过期 → 回落 free 套餐限制。"""
|
||||
if self.status == self.STATUS_EXPIRED or self.is_trial_expired:
|
||||
free = Plan.objects.filter(code="free", is_active=True).first()
|
||||
return dict(free.limits) if free else {}
|
||||
return dict(self.plan.limits or {})
|
||||
|
||||
|
||||
def seed_plans() -> int:
|
||||
"""幂等写入预置套餐。返回新建数量。"""
|
||||
created = 0
|
||||
for spec in DEFAULT_PLANS:
|
||||
_, was_created = Plan.objects.update_or_create(
|
||||
code=spec["code"],
|
||||
defaults={
|
||||
"name": spec["name"],
|
||||
"price_monthly": spec["price_monthly"],
|
||||
"limits": spec["limits"],
|
||||
"description": spec["description"],
|
||||
"sort_order": spec["sort_order"],
|
||||
"is_active": True,
|
||||
},
|
||||
)
|
||||
created += 1 if was_created else 0
|
||||
return created
|
||||
|
||||
|
||||
def subscribe(
|
||||
tenant,
|
||||
*,
|
||||
plan_code: str = "free",
|
||||
status: str = Subscription.STATUS_TRIAL,
|
||||
trial_days: int = 30,
|
||||
) -> Subscription:
|
||||
"""为新租户开通订阅(幂等:已存在则原样返回)。"""
|
||||
existing = Subscription.objects.filter(tenant=tenant).first()
|
||||
if existing is not None:
|
||||
return existing
|
||||
|
||||
plan = Plan.objects.filter(code=plan_code, is_active=True).first()
|
||||
if plan is None:
|
||||
seed_plans()
|
||||
plan = Plan.objects.filter(code=plan_code, is_active=True).first()
|
||||
|
||||
now = timezone.now()
|
||||
return Subscription.objects.create(
|
||||
tenant=tenant,
|
||||
plan=plan,
|
||||
status=status,
|
||||
trial_ends_at=(now + timedelta(days=trial_days)) if status == Subscription.STATUS_TRIAL else None,
|
||||
period_start=now.date(),
|
||||
period_end=(now + timedelta(days=trial_days)).date(),
|
||||
)
|
||||
|
||||
|
||||
def get_subscription(tenant) -> Subscription:
|
||||
"""取订阅,没有则自动开通 free 试用(保证任何租户都有配额可查)。"""
|
||||
sub = Subscription.objects.filter(tenant=tenant).select_related("plan").first()
|
||||
if sub is None:
|
||||
sub = subscribe(tenant)
|
||||
return sub
|
||||
|
||||
|
||||
def upgrade(tenant, plan_code: str, *, months: int = 1) -> Subscription:
|
||||
"""升级/续订套餐(真实支付接入后,由支付回调调用)。"""
|
||||
sub = get_subscription(tenant)
|
||||
plan = Plan.objects.filter(code=plan_code, is_active=True).first()
|
||||
if plan is None:
|
||||
raise ValueError(f"plan {plan_code} not found")
|
||||
|
||||
today = timezone.now().date()
|
||||
base = sub.period_end if (sub.period_end and sub.period_end > today) else today
|
||||
sub.plan = plan
|
||||
sub.status = Subscription.STATUS_ACTIVE
|
||||
sub.period_start = today
|
||||
sub.period_end = base + timedelta(days=30 * months)
|
||||
sub.trial_ends_at = None
|
||||
sub.save(update_fields=["plan", "status", "period_start", "period_end",
|
||||
"trial_ends_at", "updated_at"])
|
||||
return sub
|
||||
@@ -0,0 +1,209 @@
|
||||
"""配额校验(批次 C1):`check_and_count(tenant, kind)` 统一入口。
|
||||
|
||||
kind 取值与 `Plan.limits` 的键对应:
|
||||
- users / products / bills_monthly → 数量型(达到即拒)
|
||||
- ai_parse_order / ai_ask → 复用给 apps.ai.usage.quota_for()
|
||||
- batch_managed / finance_ledger → 功能开关(False 即拒)
|
||||
- storefront → 功能开关
|
||||
|
||||
调用方拿到 QuotaExceeded 后翻译成 403 + 升级引导(见 views / 业务拦截点)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from django.db.models import Count, Sum
|
||||
|
||||
from .models import Plan, Subscription, get_subscription
|
||||
|
||||
|
||||
class QuotaExceeded(Exception):
|
||||
"""超出套餐限制。payload 供前端弹"升级引导"(列差距 + 跳价格页)。"""
|
||||
|
||||
def __init__(self, kind: str, used, limit, plan_code: str, plan_name: str,
|
||||
upgrade_hint: str = ""):
|
||||
self.kind = kind
|
||||
self.used = used
|
||||
self.limit = limit
|
||||
self.plan_code = plan_code
|
||||
self.plan_name = plan_name
|
||||
self.upgrade_hint = upgrade_hint or "升级套餐即可解除限制"
|
||||
super().__init__(f"quota exceeded for {kind}: {used}/{limit} @ {plan_code}")
|
||||
|
||||
def as_dict(self) -> dict:
|
||||
return {
|
||||
"code": "quota_exceeded",
|
||||
"kind": self.kind,
|
||||
"detail": f"{self.kind} 已达 {self.used}/{self.limit} 上限(当前套餐:{self.plan_name}),{self.upgrade_hint}",
|
||||
"used": float(self.used) if isinstance(self.used, (int, float)) else self.used,
|
||||
"limit": float(self.limit) if isinstance(self.limit, (int, float)) else self.limit,
|
||||
"plan_code": self.plan_code,
|
||||
"plan_name": self.plan_name,
|
||||
"upgrade_url": "/#/pricing",
|
||||
}
|
||||
|
||||
|
||||
def limits_for(tenant) -> dict:
|
||||
return get_subscription(tenant).effective_limits()
|
||||
|
||||
|
||||
def plan_of(tenant) -> Plan:
|
||||
return get_subscription(tenant).plan
|
||||
|
||||
|
||||
def _count_users(tenant) -> int:
|
||||
"""该租户下的活跃用户数(经审计日志里出现过的操作者去重)。
|
||||
|
||||
注:当前用户表尚未挂 tenant 外键(阶段 1 只做了 Org/租户字段预留),
|
||||
故用"在该租户留下过审计记录的用户数"作为保守口径;
|
||||
接入成员表后替换此函数即可,调用方无需改动。
|
||||
"""
|
||||
from apps.core.models import AuditLog
|
||||
|
||||
return (
|
||||
AuditLog.objects.filter(tenant=tenant, user__isnull=False)
|
||||
.values("user_id")
|
||||
.distinct()
|
||||
.count()
|
||||
)
|
||||
|
||||
|
||||
def _count_products(tenant) -> int:
|
||||
from apps.catalog.models import Product
|
||||
|
||||
return Product.objects.filter(tenant=tenant, is_deleted=False).count()
|
||||
|
||||
|
||||
def _count_bills_monthly(tenant) -> int:
|
||||
"""本月单据量 = 销售单 + 进货单(草稿与已过账都算,is_deleted 排除)。"""
|
||||
from datetime import date
|
||||
|
||||
from apps.purchase.models import PurchaseBill
|
||||
from apps.sales.models import SalesBill
|
||||
|
||||
today = date.today()
|
||||
month_start = today.replace(day=1)
|
||||
sales = SalesBill.objects.filter(
|
||||
tenant=tenant, is_deleted=False, bill_date__gte=month_start
|
||||
).count()
|
||||
purchase = PurchaseBill.objects.filter(
|
||||
tenant=tenant, is_deleted=False, bill_date__gte=month_start
|
||||
).count()
|
||||
return sales + purchase
|
||||
|
||||
|
||||
COUNTERS = {
|
||||
"users": _count_users,
|
||||
"products": _count_products,
|
||||
"bills_monthly": _count_bills_monthly,
|
||||
}
|
||||
|
||||
FEATURE_FLAGS = {"batch_managed", "finance_ledger", "storefront"}
|
||||
|
||||
|
||||
def usage_snapshot(tenant) -> dict:
|
||||
"""当前用量 vs 限额(前端"套餐用量"页 + 升级引导弹窗的数据源)。"""
|
||||
sub = get_subscription(tenant)
|
||||
limits = sub.effective_limits()
|
||||
snap = {
|
||||
"plan": {
|
||||
"code": sub.plan.code, "name": sub.plan.name,
|
||||
"price_monthly": float(sub.plan.price_monthly),
|
||||
},
|
||||
"status": sub.status,
|
||||
"trial_ends_at": sub.trial_ends_at.isoformat() if sub.trial_ends_at else None,
|
||||
"trial_expired": sub.is_trial_expired,
|
||||
"period_end": sub.period_end.isoformat() if sub.period_end else None,
|
||||
"items": [],
|
||||
}
|
||||
for kind, fn in COUNTERS.items():
|
||||
limit = int(limits.get(kind) or 0)
|
||||
used = fn(tenant)
|
||||
snap["items"].append({
|
||||
"kind": kind,
|
||||
"used": used,
|
||||
"limit": limit,
|
||||
"unlimited": limit == 0,
|
||||
"percent": None if limit == 0 else round(used / limit * 100, 1),
|
||||
})
|
||||
# AI 次数(复用 ai.usage)
|
||||
from apps.ai import usage as ai_usage
|
||||
|
||||
for kind in (ai_usage.KIND_PARSE_ORDER, ai_usage.KIND_ASK):
|
||||
limit = int(limits.get(f"ai_{kind}") or 0)
|
||||
used = ai_usage.monthly_count(tenant, kind)
|
||||
snap["items"].append({
|
||||
"kind": f"ai_{kind}",
|
||||
"used": used,
|
||||
"limit": limit,
|
||||
"unlimited": limit == 0,
|
||||
"percent": None if limit == 0 else round(used / limit * 100, 1),
|
||||
})
|
||||
snap["features"] = {k: bool(limits.get(k)) for k in FEATURE_FLAGS}
|
||||
return snap
|
||||
|
||||
|
||||
def check_and_count(tenant, kind: str, *, delta: int = 1) -> dict:
|
||||
"""配额校验统一入口。
|
||||
|
||||
数量型:used + delta > limit → 拒(limit=0 视为不限)
|
||||
功能开关:limits[kind] 为假 → 拒
|
||||
AI 次数:交给 apps.ai.usage.check_quota(保持单一实现)
|
||||
|
||||
返回 {used, limit, remaining} 或抛 QuotaExceeded。
|
||||
"""
|
||||
sub = get_subscription(tenant)
|
||||
limits = sub.effective_limits()
|
||||
plan = sub.plan
|
||||
|
||||
if kind in COUNTERS:
|
||||
limit = int(limits.get(kind) or 0)
|
||||
used = COUNTERS[kind](tenant)
|
||||
if limit > 0 and used + delta > limit:
|
||||
raise QuotaExceeded(kind, used, limit, plan.code, plan.name,
|
||||
upgrade_hint=_hint_for(kind))
|
||||
return {
|
||||
"kind": kind, "used": used, "limit": limit,
|
||||
"remaining": (limit - used) if limit > 0 else None,
|
||||
}
|
||||
if kind in FEATURE_FLAGS:
|
||||
if not limits.get(kind):
|
||||
raise QuotaExceeded(kind, 0, 0, plan.code, plan.name,
|
||||
upgrade_hint=f"{_label(kind)}需专业版")
|
||||
return {"kind": kind, "enabled": True}
|
||||
|
||||
if kind.startswith("ai_"):
|
||||
from apps.ai import usage as ai_usage
|
||||
|
||||
out = ai_usage.check_quota(tenant, kind[3:]) # ai_usage 内部已抛自己的 QuotaExceeded
|
||||
return out
|
||||
|
||||
raise ValueError(f"unknown quota kind: {kind}")
|
||||
|
||||
|
||||
def _label(kind: str) -> str:
|
||||
return {
|
||||
"users": "用户数", "products": "商品数", "bills_monthly": "月度单据量",
|
||||
"ai_parse_order": "AI 录单次数", "ai_ask": "AI 问答次数",
|
||||
"batch_managed": "批次效期管理", "finance_ledger": "总账核算",
|
||||
"storefront": "订货商城",
|
||||
}.get(kind, kind)
|
||||
|
||||
|
||||
def _hint_for(kind: str) -> str:
|
||||
return {
|
||||
"users": "升级到基础版可加到 5 个用户,专业版 50 个",
|
||||
"products": "升级到基础版可管 3000 个商品",
|
||||
"bills_monthly": "升级到专业版不限制单据量",
|
||||
"ai_parse_order": "升级套餐可获得更多 AI 录单次数",
|
||||
"ai_ask": "升级套餐可获得更多 AI 问答次数",
|
||||
}.get(kind, "升级套餐即可解除限制")
|
||||
|
||||
|
||||
# ------------------------------------------------------------
|
||||
# 供 apps.ai 反向调用(billing 未安装时 ai 会回落到 settings)
|
||||
# ------------------------------------------------------------
|
||||
|
||||
def quota_for(tenant, kind: str) -> int:
|
||||
"""返回某配额项的月度/总量上限(0 = 不限)。apps.ai.usage 优先调用本函数。"""
|
||||
limits = limits_for(tenant)
|
||||
return int(limits.get(kind) or 0)
|
||||
@@ -0,0 +1,81 @@
|
||||
"""订阅到期处理(Dramatiq actor + cron 每日调用)。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, timedelta
|
||||
|
||||
import dramatiq
|
||||
from django.utils import timezone
|
||||
|
||||
from .models import Plan, Subscription
|
||||
|
||||
|
||||
def expire_trials() -> dict:
|
||||
"""扫描到期试用 → 降级 free 并把状态置为 expired,同时发通知。"""
|
||||
from django.db.models import Q
|
||||
|
||||
from apps.notify.services import send_notification
|
||||
|
||||
now = timezone.now()
|
||||
qs = Subscription.objects.filter(
|
||||
status=Subscription.STATUS_TRIAL,
|
||||
trial_ends_at__isnull=False,
|
||||
trial_ends_at__lt=now,
|
||||
).select_related("tenant", "plan")
|
||||
|
||||
free = Plan.objects.filter(code="free", is_active=True).first()
|
||||
downgraded = 0
|
||||
for sub in qs:
|
||||
sub.status = Subscription.STATUS_EXPIRED
|
||||
if free is not None:
|
||||
sub.plan = free
|
||||
sub.save(update_fields=["status", "plan", "updated_at"])
|
||||
try:
|
||||
send_notification(
|
||||
tenant=sub.tenant,
|
||||
title="【套餐】免费试用已到期",
|
||||
content=(
|
||||
"30 天全功能试用已结束,当前已回落至免费版配额"
|
||||
"(1 用户 / 100 商品 / 50 单据每月)。"
|
||||
"升级基础版 ¥998/月 可解除限制,历史数据全部保留。"
|
||||
),
|
||||
category="warning",
|
||||
link="/#/pricing",
|
||||
extra_data={"kind": "trial_expired"},
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
downgraded += 1
|
||||
|
||||
# 付费到期(未续费)→ 同样降级
|
||||
today = date.today()
|
||||
paid_expired = Subscription.objects.filter(
|
||||
status=Subscription.STATUS_ACTIVE,
|
||||
period_end__isnull=False,
|
||||
period_end__lt=today,
|
||||
).select_related("tenant", "plan")
|
||||
for sub in paid_expired:
|
||||
sub.status = Subscription.STATUS_EXPIRED
|
||||
if free is not None:
|
||||
sub.plan = free
|
||||
sub.save(update_fields=["status", "plan", "updated_at"])
|
||||
try:
|
||||
send_notification(
|
||||
tenant=sub.tenant,
|
||||
title="【套餐】订阅已到期",
|
||||
content=f"套餐已于 {sub.period_end} 到期,当前回落至免费版配额,请及时续费。",
|
||||
category="warning",
|
||||
link="/#/pricing",
|
||||
extra_data={"kind": "subscription_expired"},
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
downgraded += 1
|
||||
|
||||
return {"expired_count": downgraded, "checked_at": now.isoformat()}
|
||||
|
||||
|
||||
@dramatiq.actor(max_retries=2, time_limit=300_000)
|
||||
def expire_trials_and_notify():
|
||||
"""Dramatiq actor 包装(供 config/dramatiq.py 的 cron 调用/手动 send)。"""
|
||||
return expire_trials()
|
||||
@@ -0,0 +1,13 @@
|
||||
"""计费路由。"""
|
||||
|
||||
from django.urls import path
|
||||
|
||||
from .views import plan_list, my_subscription, change_plan, quota_check
|
||||
|
||||
|
||||
urlpatterns = [
|
||||
path("plans/", plan_list, name="billing-plans"),
|
||||
path("subscription/", my_subscription, name="billing-subscription"),
|
||||
path("subscribe/", change_plan, name="billing-subscribe"),
|
||||
path("quota/", quota_check, name="billing-quota"),
|
||||
]
|
||||
@@ -0,0 +1,110 @@
|
||||
"""套餐与计费 API(批次 C1)。
|
||||
|
||||
- GET /api/v1/billing/plans/ 套餐列表(官网价格页数据源)
|
||||
- GET /api/v1/billing/subscription/ 我的套餐 + 用量
|
||||
- POST /api/v1/billing/subscribe/ 变更套餐(真实支付接入后由回调驱动)
|
||||
"""
|
||||
|
||||
from asgiref.sync import sync_to_async
|
||||
from adrf.decorators import api_view
|
||||
from rest_framework import status
|
||||
from rest_framework.decorators import authentication_classes, permission_classes
|
||||
from rest_framework.permissions import AllowAny
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.exceptions import ValidationError
|
||||
|
||||
from apps.core.viewset import resolve_tenant
|
||||
from .models import Plan, Subscription, upgrade, get_subscription, seed_plans
|
||||
from . import quota as billing_quota
|
||||
|
||||
|
||||
async def _tenant(request):
|
||||
code = request.META.get("HTTP_X_TENANT_ID", "")
|
||||
tenant = await sync_to_async(resolve_tenant)(code)
|
||||
if tenant is None:
|
||||
raise ValidationError({"tenant": "无法识别租户"})
|
||||
return tenant
|
||||
|
||||
|
||||
@api_view(["GET"])
|
||||
@authentication_classes([])
|
||||
@permission_classes([AllowAny])
|
||||
async def plan_list(request):
|
||||
"""套餐列表(价格 + 功能对比)。公开:官网价格页/登录页都需要。"""
|
||||
|
||||
def _load():
|
||||
seed_plans()
|
||||
return list(Plan.objects.filter(is_active=True).order_by("sort_order", "code"))
|
||||
|
||||
plans = await sync_to_async(_load)()
|
||||
return Response([
|
||||
{
|
||||
"code": p.code,
|
||||
"name": p.name,
|
||||
"price_monthly": str(p.price_monthly),
|
||||
"description": p.description,
|
||||
"limits": p.limits,
|
||||
}
|
||||
for p in plans
|
||||
])
|
||||
|
||||
|
||||
@api_view(["GET"])
|
||||
async def my_subscription(request):
|
||||
"""我的套餐状态 + 各项用量(升级引导弹窗数据源)。"""
|
||||
tenant = await _tenant(request)
|
||||
snap = await sync_to_async(billing_quota.usage_snapshot)(tenant)
|
||||
return Response(snap)
|
||||
|
||||
|
||||
@api_view(["POST"])
|
||||
async def change_plan(request):
|
||||
"""变更套餐:POST {plan_code, months?}。
|
||||
|
||||
真实支付接入前:直接切换(演示/内部开通用);接入支付后应改为
|
||||
"创建订单 → 支付回调 → upgrade",此接口仅保留给管理员。
|
||||
"""
|
||||
tenant = await _tenant(request)
|
||||
payload = request.data or {}
|
||||
plan_code = (payload.get("plan_code") or "").strip()
|
||||
if not plan_code:
|
||||
raise ValidationError({"detail": "plan_code 必填"})
|
||||
months = int(payload.get("months") or 1)
|
||||
|
||||
def _do():
|
||||
sub = upgrade(tenant, plan_code, months=months)
|
||||
return sub
|
||||
|
||||
try:
|
||||
sub = await sync_to_async(_do)()
|
||||
except ValueError as exc:
|
||||
return Response({"detail": str(exc)}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
return Response({
|
||||
"ok": True,
|
||||
"plan_code": sub.plan.code,
|
||||
"plan_name": sub.plan.name,
|
||||
"status": sub.status,
|
||||
"period_end": sub.period_end.isoformat() if sub.period_end else None,
|
||||
})
|
||||
|
||||
|
||||
@api_view(["GET"])
|
||||
async def quota_check(request):
|
||||
"""预检某配额项:?kind=products(前端按钮置灰/拦截提示用)。
|
||||
|
||||
语义是"还能不能再加一个":delta=1,因此 used 已达上限即返回 403。
|
||||
"""
|
||||
tenant = await _tenant(request)
|
||||
kind = request.query_params.get("kind") or ""
|
||||
if not kind:
|
||||
raise ValidationError({"detail": "kind 必填(products/bills_monthly/ai_parse_order/...)"})
|
||||
|
||||
def _check():
|
||||
return billing_quota.check_and_count(tenant, kind, delta=1)
|
||||
|
||||
try:
|
||||
out = await sync_to_async(_check)()
|
||||
except billing_quota.QuotaExceeded as exc:
|
||||
return Response(exc.as_dict(), status=status.HTTP_403_FORBIDDEN)
|
||||
return Response(out)
|
||||
Reference in New Issue
Block a user