baseline: 批次A-D 成果 + membership 半成品(测试红)

This commit is contained in:
agent
2026-09-11 23:11:35 +08:00
commit b3f3095d53
311 changed files with 40540 additions and 0 deletions
View File
+7
View File
@@ -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'],
},
),
]
+214
View File
@@ -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
+209
View File
@@ -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)
+81
View File
@@ -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()
+13
View File
@@ -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"),
]
+110
View File
@@ -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)