feat: add software entitlement foundations

This commit is contained in:
QiuSW
2026-07-21 09:51:29 +08:00
parent 11c0d63ac8
commit 7b80ae3a7a
12 changed files with 1191 additions and 13 deletions
+248 -2
View File
@@ -1,6 +1,26 @@
from django.contrib import admin
from django import forms
from django.contrib import admin, messages
from django.contrib.auth import get_user_model
from django.http import HttpResponseRedirect
from django.shortcuts import get_object_or_404
from django.template.response import TemplateResponse
from django.urls import path, reverse
from .models import ClientDevice, DeviceBindingAudit, DeviceSession
from .models import (
ClientDevice,
DeviceBindingAudit,
DeviceSession,
LicenseEvent,
LicenseSeat,
SoftwareEntitlement,
SoftwarePlan,
)
from .services import (
LicensingError,
grant_software_entitlement,
renew_software_entitlement,
revoke_software_entitlement,
)
def _masked_fingerprint(value: str) -> str:
@@ -105,3 +125,229 @@ class DeviceBindingAuditAdmin(admin.ModelAdmin):
def has_delete_permission(self, request, obj=None):
return False
@admin.register(SoftwarePlan)
class SoftwarePlanAdmin(admin.ModelAdmin):
list_display = (
"name",
"product_code",
"duration_days",
"price",
"device_limit",
"grace_days",
"status",
"updated_at",
)
list_filter = ("product_code", "status")
search_fields = ("name",)
list_editable = ("status",)
readonly_fields = ("created_at", "updated_at")
class EntitlementGrantForm(forms.Form):
user = forms.ModelChoiceField(queryset=get_user_model().objects.all(), label="用户")
plan = forms.ModelChoiceField(
queryset=SoftwarePlan.objects.filter(status=SoftwarePlan.Status.ACTIVE),
label="套餐",
)
reason = forms.CharField(label="授予原因", widget=forms.Textarea(attrs={"rows": 4}))
def clean_reason(self):
reason = self.cleaned_data["reason"].strip()
if not reason:
raise forms.ValidationError("必须填写操作原因。")
return reason
class EntitlementReasonForm(forms.Form):
reason = forms.CharField(label="操作原因", widget=forms.Textarea(attrs={"rows": 4}))
def clean_reason(self):
reason = self.cleaned_data["reason"].strip()
if not reason:
raise forms.ValidationError("必须填写操作原因。")
return reason
@admin.register(SoftwareEntitlement)
class SoftwareEntitlementAdmin(admin.ModelAdmin):
change_form_template = "admin/licensing/softwareentitlement/change_form.html"
list_display = (
"user",
"product_code",
"plan_name",
"plan_device_limit",
"status",
"starts_at",
"expires_at",
"grace_expires_at",
)
list_filter = ("product_code", "status", "expires_at")
search_fields = ("user__username", "user__email", "plan_name")
list_select_related = ("user", "source_plan")
readonly_fields = (
"user",
"product_code",
"source_plan",
"plan_name",
"plan_duration_days",
"plan_price",
"plan_device_limit",
"plan_grace_days",
"status",
"starts_at",
"expires_at",
"grace_expires_at",
"revoked_at",
"created_at",
"updated_at",
)
fields = readonly_fields
def has_add_permission(self, request):
return False
def has_delete_permission(self, request, obj=None):
return False
def get_urls(self):
urls = super().get_urls()
custom_urls = [
path(
"grant/",
self.admin_site.admin_view(self.grant_view),
name="licensing_softwareentitlement_grant",
),
path(
"<path:object_id>/renew/",
self.admin_site.admin_view(self.renew_view),
name="licensing_softwareentitlement_renew",
),
path(
"<path:object_id>/revoke/",
self.admin_site.admin_view(self.revoke_view),
name="licensing_softwareentitlement_revoke",
),
]
return custom_urls + urls
def grant_view(self, request):
form = EntitlementGrantForm(request.POST or None)
if request.method == "POST" and form.is_valid():
try:
entitlement = grant_software_entitlement(
user=form.cleaned_data["user"],
plan=form.cleaned_data["plan"],
reason=form.cleaned_data["reason"],
actor=request.user,
)
except LicensingError as exc:
form.add_error(None, exc.message)
else:
self.message_user(request, "软件权益已授予。", messages.SUCCESS)
return HttpResponseRedirect(self._change_url(entitlement))
return self._operation_response(
request,
title="手工授予软件权益",
form=form,
)
def renew_view(self, request, object_id):
entitlement = get_object_or_404(SoftwareEntitlement, pk=object_id)
return self._entitlement_operation_view(
request,
entitlement=entitlement,
action="renew",
title="续期软件权益",
)
def revoke_view(self, request, object_id):
entitlement = get_object_or_404(SoftwareEntitlement, pk=object_id)
return self._entitlement_operation_view(
request,
entitlement=entitlement,
action="revoke",
title="撤销软件权益",
)
def _entitlement_operation_view(self, request, *, entitlement, action, title):
form = EntitlementReasonForm(request.POST or None)
if request.method == "POST" and form.is_valid():
try:
operation = (
renew_software_entitlement
if action == "renew"
else revoke_software_entitlement
)
operation(
entitlement=entitlement,
reason=form.cleaned_data["reason"],
actor=request.user,
)
except LicensingError as exc:
form.add_error(None, exc.message)
else:
self.message_user(request, f"软件权益已{'续期' if action == 'renew' else '撤销'}。", messages.SUCCESS)
return HttpResponseRedirect(self._change_url(entitlement))
return self._operation_response(
request,
title=title,
form=form,
entitlement=entitlement,
)
def _operation_response(self, request, *, title, form, entitlement=None):
context = {
**self.admin_site.each_context(request),
"title": title,
"opts": self.model._meta,
"form": form,
"entitlement": entitlement,
}
return TemplateResponse(
request,
"admin/licensing/entitlement_operation.html",
context,
)
def _change_url(self, entitlement):
return reverse("admin:licensing_softwareentitlement_change", args=(entitlement.pk,))
class ReadOnlyLicenseAdmin(admin.ModelAdmin):
def get_readonly_fields(self, request, obj=None):
return tuple(field.name for field in self.model._meta.fields)
def has_add_permission(self, request):
return False
def has_change_permission(self, request, obj=None):
return False
def has_delete_permission(self, request, obj=None):
return False
@admin.register(LicenseSeat)
class LicenseSeatAdmin(ReadOnlyLicenseAdmin):
list_display = ("entitlement", "seat_number", "device", "bound_at", "released_at")
list_filter = ("entitlement__product_code", "bound_at", "released_at")
search_fields = (
"entitlement__user__username",
"entitlement__user__email",
"device__user__username",
)
list_select_related = ("entitlement", "entitlement__user", "device")
@admin.register(LicenseEvent)
class LicenseEventAdmin(ReadOnlyLicenseAdmin):
list_display = ("entitlement", "action", "seat", "device", "actor", "reason", "created_at")
list_filter = ("action", "entitlement__product_code", "created_at")
search_fields = (
"entitlement__user__username",
"entitlement__user__email",
"reason",
)
list_select_related = ("entitlement", "seat", "device", "actor")
@@ -0,0 +1,149 @@
# Generated by Django 5.2.15 on 2026-07-21 01:45
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('licensing', '0001_initial'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='SoftwareEntitlement',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('product_code', models.CharField(choices=[('cmshopee', '虾皮圈优化助手')], max_length=32, verbose_name='产品代码')),
('plan_name', models.CharField(max_length=120, verbose_name='套餐名称快照')),
('plan_duration_days', models.PositiveIntegerField(verbose_name='套餐有效天数快照')),
('plan_price', models.DecimalField(decimal_places=2, max_digits=12, verbose_name='套餐价格快照')),
('plan_device_limit', models.PositiveSmallIntegerField(verbose_name='设备数量快照')),
('plan_grace_days', models.PositiveSmallIntegerField(default=0, verbose_name='宽限天数快照')),
('status', models.CharField(choices=[('active', '有效'), ('revoked', '已撤销'), ('expired', '已过期')], default='active', max_length=20, verbose_name='状态')),
('starts_at', models.DateTimeField(verbose_name='开始时间')),
('expires_at', models.DateTimeField(verbose_name='到期时间')),
('grace_expires_at', models.DateTimeField(verbose_name='宽限截止时间')),
('revoked_at', models.DateTimeField(blank=True, null=True, verbose_name='撤销时间')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='software_entitlements', to=settings.AUTH_USER_MODEL, verbose_name='用户')),
],
options={
'verbose_name': '软件权益',
'verbose_name_plural': '软件权益',
'db_table': 'software_entitlement',
'ordering': ('-expires_at', '-id'),
},
),
migrations.CreateModel(
name='LicenseSeat',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('seat_number', models.PositiveSmallIntegerField(verbose_name='席位序号')),
('bound_at', models.DateTimeField(blank=True, null=True, verbose_name='绑定时间')),
('released_at', models.DateTimeField(blank=True, null=True, verbose_name='解绑时间')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')),
('device', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='license_seats', to='licensing.clientdevice', verbose_name='绑定设备')),
('entitlement', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='seats', to='licensing.softwareentitlement', verbose_name='软件权益')),
],
options={
'verbose_name': '授权席位',
'verbose_name_plural': '授权席位',
'db_table': 'license_seat',
'ordering': ('entitlement_id', 'seat_number'),
},
),
migrations.CreateModel(
name='LicenseEvent',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('action', models.CharField(choices=[('granted', '人工授予'), ('renewed', '续期'), ('revoked', '撤销'), ('seat_assigned', '绑定席位'), ('seat_released', '解绑席位')], max_length=32, verbose_name='动作')),
('reason', models.CharField(max_length=255, verbose_name='原因')),
('metadata', models.JSONField(blank=True, default=dict, verbose_name='附加信息')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')),
('actor', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='license_events_performed', to=settings.AUTH_USER_MODEL, verbose_name='操作人')),
('device', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='license_events', to='licensing.clientdevice', verbose_name='关联设备')),
('seat', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='events', to='licensing.licenseseat', verbose_name='授权席位')),
('entitlement', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='events', to='licensing.softwareentitlement', verbose_name='软件权益')),
],
options={
'verbose_name': '授权事件',
'verbose_name_plural': '授权事件',
'db_table': 'license_event',
'ordering': ('-created_at', '-id'),
},
),
migrations.CreateModel(
name='SoftwarePlan',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('product_code', models.CharField(choices=[('cmshopee', '虾皮圈优化助手')], max_length=32, verbose_name='产品代码')),
('name', models.CharField(max_length=120, verbose_name='套餐名称')),
('duration_days', models.PositiveIntegerField(verbose_name='有效天数')),
('price', models.DecimalField(decimal_places=2, max_digits=12, verbose_name='套餐价格')),
('device_limit', models.PositiveSmallIntegerField(verbose_name='设备数量')),
('grace_days', models.PositiveSmallIntegerField(default=0, verbose_name='宽限天数')),
('status', models.CharField(choices=[('active', '启用'), ('inactive', '停用')], default='active', max_length=20, verbose_name='状态')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')),
],
options={
'verbose_name': '软件套餐',
'verbose_name_plural': '软件套餐',
'db_table': 'software_plan',
'ordering': ('product_code', 'name', 'id'),
'indexes': [models.Index(fields=['product_code', 'status'], name='software_pl_product_3580ff_idx')],
'constraints': [models.CheckConstraint(condition=models.Q(('duration_days__gt', 0)), name='software_plan_duration_days_positive'), models.CheckConstraint(condition=models.Q(('price__gt', 0)), name='software_plan_price_positive'), models.CheckConstraint(condition=models.Q(('device_limit__gt', 0)), name='software_plan_device_limit_positive')],
},
),
migrations.AddField(
model_name='softwareentitlement',
name='source_plan',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='entitlements', to='licensing.softwareplan', verbose_name='来源套餐'),
),
migrations.AddIndex(
model_name='licenseseat',
index=models.Index(fields=['device', 'updated_at'], name='license_sea_device__85c03f_idx'),
),
migrations.AddConstraint(
model_name='licenseseat',
constraint=models.UniqueConstraint(fields=('entitlement', 'seat_number'), name='license_seat_entitlement_number_unique'),
),
migrations.AddConstraint(
model_name='licenseseat',
constraint=models.CheckConstraint(condition=models.Q(('seat_number__gt', 0)), name='license_seat_number_positive'),
),
migrations.AddIndex(
model_name='licenseevent',
index=models.Index(fields=['entitlement', 'created_at'], name='license_eve_entitle_a66e88_idx'),
),
migrations.AddIndex(
model_name='licenseevent',
index=models.Index(fields=['device', 'created_at'], name='license_eve_device__e70841_idx'),
),
migrations.AddIndex(
model_name='softwareentitlement',
index=models.Index(fields=['user', 'product_code', 'status'], name='software_en_user_id_cd6e26_idx'),
),
migrations.AddIndex(
model_name='softwareentitlement',
index=models.Index(fields=['product_code', 'expires_at'], name='software_en_product_c6ffbf_idx'),
),
migrations.AddConstraint(
model_name='softwareentitlement',
constraint=models.CheckConstraint(condition=models.Q(('plan_duration_days__gt', 0)), name='software_entitlement_duration_positive'),
),
migrations.AddConstraint(
model_name='softwareentitlement',
constraint=models.CheckConstraint(condition=models.Q(('plan_price__gt', 0)), name='software_entitlement_price_positive'),
),
migrations.AddConstraint(
model_name='softwareentitlement',
constraint=models.CheckConstraint(condition=models.Q(('plan_device_limit__gt', 0)), name='software_entitlement_device_limit_positive'),
),
]
+240
View File
@@ -5,7 +5,9 @@ import hmac
import secrets
from django.conf import settings
from django.core.exceptions import ValidationError
from django.db import models
from django.db.models import Q
from django.utils import timezone
@@ -79,6 +81,244 @@ class ClientDevice(models.Model):
return hashlib.sha256(public_key.encode("utf-8")).hexdigest()
class SoftwarePlan(models.Model):
class Status(models.TextChoices):
ACTIVE = "active", "启用"
INACTIVE = "inactive", "停用"
product_code = models.CharField(
"产品代码",
max_length=32,
choices=ClientDevice.ProductCode.choices,
)
name = models.CharField("套餐名称", max_length=120)
duration_days = models.PositiveIntegerField("有效天数")
price = models.DecimalField("套餐价格", max_digits=12, decimal_places=2)
device_limit = models.PositiveSmallIntegerField("设备数量")
grace_days = models.PositiveSmallIntegerField("宽限天数", default=0)
status = models.CharField(
"状态",
max_length=20,
choices=Status.choices,
default=Status.ACTIVE,
)
created_at = models.DateTimeField("创建时间", auto_now_add=True)
updated_at = models.DateTimeField("更新时间", auto_now=True)
class Meta:
db_table = "software_plan"
verbose_name = "软件套餐"
verbose_name_plural = "软件套餐"
ordering = ("product_code", "name", "id")
constraints = [
models.CheckConstraint(
condition=Q(duration_days__gt=0),
name="software_plan_duration_days_positive",
),
models.CheckConstraint(
condition=Q(price__gt=0),
name="software_plan_price_positive",
),
models.CheckConstraint(
condition=Q(device_limit__gt=0),
name="software_plan_device_limit_positive",
),
]
indexes = [
models.Index(fields=("product_code", "status")),
]
def __str__(self) -> str:
return f"{self.product_code} {self.name}"
class SoftwareEntitlement(models.Model):
class Status(models.TextChoices):
ACTIVE = "active", "有效"
REVOKED = "revoked", "已撤销"
EXPIRED = "expired", "已过期"
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
verbose_name="用户",
on_delete=models.PROTECT,
related_name="software_entitlements",
)
product_code = models.CharField(
"产品代码",
max_length=32,
choices=ClientDevice.ProductCode.choices,
)
source_plan = models.ForeignKey(
SoftwarePlan,
verbose_name="来源套餐",
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="entitlements",
)
plan_name = models.CharField("套餐名称快照", max_length=120)
plan_duration_days = models.PositiveIntegerField("套餐有效天数快照")
plan_price = models.DecimalField("套餐价格快照", max_digits=12, decimal_places=2)
plan_device_limit = models.PositiveSmallIntegerField("设备数量快照")
plan_grace_days = models.PositiveSmallIntegerField("宽限天数快照", default=0)
status = models.CharField(
"状态",
max_length=20,
choices=Status.choices,
default=Status.ACTIVE,
)
starts_at = models.DateTimeField("开始时间")
expires_at = models.DateTimeField("到期时间")
grace_expires_at = models.DateTimeField("宽限截止时间")
revoked_at = models.DateTimeField("撤销时间", null=True, blank=True)
created_at = models.DateTimeField("创建时间", auto_now_add=True)
updated_at = models.DateTimeField("更新时间", auto_now=True)
class Meta:
db_table = "software_entitlement"
verbose_name = "软件权益"
verbose_name_plural = "软件权益"
ordering = ("-expires_at", "-id")
constraints = [
models.CheckConstraint(
condition=Q(plan_duration_days__gt=0),
name="software_entitlement_duration_positive",
),
models.CheckConstraint(
condition=Q(plan_price__gt=0),
name="software_entitlement_price_positive",
),
models.CheckConstraint(
condition=Q(plan_device_limit__gt=0),
name="software_entitlement_device_limit_positive",
),
]
indexes = [
models.Index(fields=("user", "product_code", "status")),
models.Index(fields=("product_code", "expires_at")),
]
def __str__(self) -> str:
return f"{self.user} {self.product_code} {self.plan_name}"
def clean(self) -> None:
super().clean()
if self.expires_at and self.starts_at and self.expires_at <= self.starts_at:
raise ValidationError({"expires_at": "到期时间必须晚于开始时间。"})
if (
self.grace_expires_at
and self.expires_at
and self.grace_expires_at < self.expires_at
):
raise ValidationError({"grace_expires_at": "宽限截止时间不能早于到期时间。"})
def is_usable_at(self, now=None) -> bool:
now = now or timezone.now()
return self.status == self.Status.ACTIVE and self.grace_expires_at > now
class LicenseSeat(models.Model):
entitlement = models.ForeignKey(
SoftwareEntitlement,
verbose_name="软件权益",
on_delete=models.PROTECT,
related_name="seats",
)
seat_number = models.PositiveSmallIntegerField("席位序号")
device = models.ForeignKey(
ClientDevice,
verbose_name="绑定设备",
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="license_seats",
)
bound_at = models.DateTimeField("绑定时间", null=True, blank=True)
released_at = models.DateTimeField("解绑时间", null=True, blank=True)
created_at = models.DateTimeField("创建时间", auto_now_add=True)
updated_at = models.DateTimeField("更新时间", auto_now=True)
class Meta:
db_table = "license_seat"
verbose_name = "授权席位"
verbose_name_plural = "授权席位"
ordering = ("entitlement_id", "seat_number")
constraints = [
models.UniqueConstraint(
fields=("entitlement", "seat_number"),
name="license_seat_entitlement_number_unique",
),
models.CheckConstraint(
condition=Q(seat_number__gt=0),
name="license_seat_number_positive",
),
]
indexes = [
models.Index(fields=("device", "updated_at")),
]
def __str__(self) -> str:
return f"{self.entitlement} #{self.seat_number}"
class LicenseEvent(models.Model):
class Action(models.TextChoices):
GRANTED = "granted", "人工授予"
RENEWED = "renewed", "续期"
REVOKED = "revoked", "撤销"
SEAT_ASSIGNED = "seat_assigned", "绑定席位"
SEAT_RELEASED = "seat_released", "解绑席位"
entitlement = models.ForeignKey(
SoftwareEntitlement,
verbose_name="软件权益",
on_delete=models.PROTECT,
related_name="events",
)
seat = models.ForeignKey(
LicenseSeat,
verbose_name="授权席位",
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="events",
)
device = models.ForeignKey(
ClientDevice,
verbose_name="关联设备",
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="license_events",
)
actor = models.ForeignKey(
settings.AUTH_USER_MODEL,
verbose_name="操作人",
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="license_events_performed",
)
action = models.CharField("动作", max_length=32, choices=Action.choices)
reason = models.CharField("原因", max_length=255)
metadata = models.JSONField("附加信息", default=dict, blank=True)
created_at = models.DateTimeField("创建时间", auto_now_add=True)
class Meta:
db_table = "license_event"
verbose_name = "授权事件"
verbose_name_plural = "授权事件"
ordering = ("-created_at", "-id")
indexes = [
models.Index(fields=("entitlement", "created_at")),
models.Index(fields=("device", "created_at")),
]
def __str__(self) -> str:
return f"{self.entitlement} {self.action}"
class DeviceSession(models.Model):
TOKEN_PREFIX_LENGTH = 12
+212 -1
View File
@@ -7,7 +7,15 @@ from django.conf import settings
from django.db import IntegrityError, transaction
from django.utils import timezone
from apps.licensing.models import ClientDevice, DeviceBindingAudit, DeviceSession
from apps.licensing.models import (
ClientDevice,
DeviceBindingAudit,
DeviceSession,
LicenseEvent,
LicenseSeat,
SoftwareEntitlement,
SoftwarePlan,
)
class DeviceRegistrationError(Exception):
@@ -21,6 +29,13 @@ class DeviceSessionValidationError(DeviceRegistrationError):
pass
class LicensingError(Exception):
def __init__(self, code: str, message: str):
self.code = code
self.message = message
super().__init__(message)
@dataclass(frozen=True)
class DeviceRegistrationResult:
device: ClientDevice
@@ -178,3 +193,199 @@ def resolve_optional_device_session(*, user, raw_token: str) -> DeviceSession |
if session.device.user_id != user.id:
raise DeviceSessionValidationError("device_mismatch", "设备会话不属于当前账号")
return session
def _required_reason(reason: str) -> str:
normalized_reason = str(reason or "").strip()
if not normalized_reason:
raise LicensingError("reason_required", "必须填写操作原因")
return normalized_reason
def _create_license_event(
*,
entitlement: SoftwareEntitlement,
action: str,
reason: str,
actor=None,
seat: LicenseSeat | None = None,
device: ClientDevice | None = None,
metadata: dict | None = None,
) -> LicenseEvent:
return LicenseEvent.objects.create(
entitlement=entitlement,
seat=seat,
device=device,
actor=actor,
action=action,
reason=_required_reason(reason),
metadata=metadata or {},
)
@transaction.atomic
def grant_software_entitlement(*, user, plan: SoftwarePlan, reason: str, actor=None, starts_at=None):
reason = _required_reason(reason)
if plan.status != SoftwarePlan.Status.ACTIVE:
raise LicensingError("plan_inactive", "套餐已停用,不能授予权益")
now = timezone.now()
starts_at = starts_at or now
expires_at = starts_at + timedelta(days=plan.duration_days)
grace_expires_at = expires_at + timedelta(days=plan.grace_days)
entitlement = SoftwareEntitlement.objects.create(
user=user,
product_code=plan.product_code,
source_plan=plan,
plan_name=plan.name,
plan_duration_days=plan.duration_days,
plan_price=plan.price,
plan_device_limit=plan.device_limit,
plan_grace_days=plan.grace_days,
starts_at=starts_at,
expires_at=expires_at,
grace_expires_at=grace_expires_at,
)
LicenseSeat.objects.bulk_create(
[
LicenseSeat(entitlement=entitlement, seat_number=seat_number)
for seat_number in range(1, plan.device_limit + 1)
]
)
_create_license_event(
entitlement=entitlement,
action=LicenseEvent.Action.GRANTED,
reason=reason,
actor=actor,
metadata={
"source_plan_id": plan.id,
"expires_at": expires_at.isoformat(),
"device_limit": plan.device_limit,
},
)
return entitlement
@transaction.atomic
def renew_software_entitlement(*, entitlement: SoftwareEntitlement, reason: str, actor=None, now=None):
reason = _required_reason(reason)
now = now or timezone.now()
locked_entitlement = SoftwareEntitlement.objects.select_for_update().get(pk=entitlement.pk)
if locked_entitlement.status == SoftwareEntitlement.Status.REVOKED:
raise LicensingError("entitlement_revoked", "已撤销权益不能续期")
extension_start = max(now, locked_entitlement.expires_at)
locked_entitlement.expires_at = extension_start + timedelta(
days=locked_entitlement.plan_duration_days
)
locked_entitlement.grace_expires_at = locked_entitlement.expires_at + timedelta(
days=locked_entitlement.plan_grace_days
)
locked_entitlement.status = SoftwareEntitlement.Status.ACTIVE
locked_entitlement.revoked_at = None
locked_entitlement.save(
update_fields=(
"expires_at",
"grace_expires_at",
"status",
"revoked_at",
"updated_at",
)
)
_create_license_event(
entitlement=locked_entitlement,
action=LicenseEvent.Action.RENEWED,
reason=reason,
actor=actor,
metadata={
"extension_start": extension_start.isoformat(),
"expires_at": locked_entitlement.expires_at.isoformat(),
},
)
return locked_entitlement
@transaction.atomic
def revoke_software_entitlement(*, entitlement: SoftwareEntitlement, reason: str, actor=None, now=None):
reason = _required_reason(reason)
now = now or timezone.now()
locked_entitlement = SoftwareEntitlement.objects.select_for_update().get(pk=entitlement.pk)
if locked_entitlement.status == SoftwareEntitlement.Status.REVOKED:
return locked_entitlement
locked_entitlement.status = SoftwareEntitlement.Status.REVOKED
locked_entitlement.revoked_at = now
locked_entitlement.save(update_fields=("status", "revoked_at", "updated_at"))
_create_license_event(
entitlement=locked_entitlement,
action=LicenseEvent.Action.REVOKED,
reason=reason,
actor=actor,
)
return locked_entitlement
@transaction.atomic
def assign_license_seat(*, entitlement: SoftwareEntitlement, device: ClientDevice, reason: str, actor=None, now=None):
reason = _required_reason(reason)
now = now or timezone.now()
locked_entitlement = SoftwareEntitlement.objects.select_for_update().get(pk=entitlement.pk)
if not locked_entitlement.is_usable_at(now):
raise LicensingError("entitlement_unavailable", "软件权益当前不可用")
if device.user_id != locked_entitlement.user_id:
raise LicensingError("device_user_mismatch", "设备不属于权益用户")
if device.product_code != locked_entitlement.product_code:
raise LicensingError("device_product_mismatch", "设备产品与权益不匹配")
if device.status != ClientDevice.Status.ACTIVE:
raise LicensingError("device_revoked", "设备已被吊销")
seats = list(
LicenseSeat.objects.select_for_update()
.filter(entitlement=locked_entitlement)
.order_by("seat_number")
)
existing_seat = next((seat for seat in seats if seat.device_id == device.id), None)
if existing_seat is not None:
return existing_seat
available_seat = next((seat for seat in seats if seat.device_id is None), None)
if available_seat is None:
raise LicensingError("seat_limit_reached", "设备席位已用完")
available_seat.device = device
available_seat.bound_at = now
available_seat.released_at = None
available_seat.save(update_fields=("device", "bound_at", "released_at", "updated_at"))
_create_license_event(
entitlement=locked_entitlement,
seat=available_seat,
device=device,
action=LicenseEvent.Action.SEAT_ASSIGNED,
reason=reason,
actor=actor,
)
return available_seat
@transaction.atomic
def release_license_seat(*, seat: LicenseSeat, reason: str, actor=None, now=None):
reason = _required_reason(reason)
now = now or timezone.now()
locked_seat = LicenseSeat.objects.select_for_update().select_related("entitlement", "device").get(
pk=seat.pk
)
if locked_seat.device_id is None:
return locked_seat
previous_device = locked_seat.device
locked_seat.device = None
locked_seat.released_at = now
locked_seat.save(update_fields=("device", "released_at", "updated_at"))
_create_license_event(
entitlement=locked_seat.entitlement,
seat=locked_seat,
device=previous_device,
action=LicenseEvent.Action.SEAT_RELEASED,
reason=reason,
actor=actor,
)
return locked_seat
@@ -0,0 +1,14 @@
{% extends "admin/base_site.html" %}
{% block content %}
{% if entitlement %}
<p>{{ entitlement }}</p>
{% endif %}
<form method="post">
{% csrf_token %}
{{ form.as_p }}
<div class="submit-row">
<input type="submit" value="确认" class="default">
</div>
</form>
{% endblock %}
@@ -0,0 +1,9 @@
{% extends "admin/change_form.html" %}
{% block object-tools-items %}
{{ block.super }}
{% if original %}
<li><a href="renew/">续期</a></li>
<li><a href="revoke/">撤销</a></li>
{% endif %}
{% endblock %}
+289 -3
View File
@@ -1,12 +1,31 @@
from datetime import timedelta
from decimal import Decimal
from concurrent.futures import ThreadPoolExecutor
from django.test import TestCase, override_settings
from django.db import close_old_connections
from django.test import TestCase, TransactionTestCase, override_settings
from django.urls import reverse
from django.utils import timezone
from rest_framework.test import APIClient
from apps.licensing.models import ClientDevice, DeviceBindingAudit, DeviceSession
from apps.licensing.services import record_device_heartbeat
from apps.licensing.models import (
ClientDevice,
DeviceBindingAudit,
DeviceSession,
LicenseEvent,
LicenseSeat,
SoftwareEntitlement,
SoftwarePlan,
)
from apps.licensing.services import (
LicensingError,
assign_license_seat,
grant_software_entitlement,
record_device_heartbeat,
release_license_seat,
renew_software_entitlement,
revoke_software_entitlement,
)
from apps.users.models import ApiKey, User, UserWallet
@@ -160,3 +179,270 @@ class DeviceRegistrationApiTests(TestCase):
self.assertEqual(response.status_code, 200)
device.refresh_from_db()
self.assertGreater(device.last_seen_at, stale_time)
class SoftwareEntitlementServiceTests(TestCase):
def setUp(self):
self.user = User.objects.create_user(
username="entitlement-user",
email="entitlement@example.com",
password="test-password",
)
self.operator = User.objects.create_user(
username="entitlement-operator",
email="operator@example.com",
password="test-password",
is_staff=True,
)
self.plan = SoftwarePlan.objects.create(
product_code=ClientDevice.ProductCode.CMSHOPEE,
name="月度套餐",
duration_days=30,
price=Decimal("19.90"),
device_limit=1,
grace_days=3,
)
def create_device(self, suffix):
device_id = f"v1:entitlement-device-{suffix}"
public_key = f"entitlement-public-key-{suffix}"
return ClientDevice.objects.create(
user=self.user,
product_code=ClientDevice.ProductCode.CMSHOPEE,
device_id_version="v1",
device_fingerprint=ClientDevice.fingerprint_device_id("v1", device_id),
public_key_fingerprint=ClientDevice.fingerprint_public_key(public_key),
platform=ClientDevice.Platform.WINDOWS,
client_version="0.1.0",
)
def grant_entitlement(self, **kwargs):
return grant_software_entitlement(
user=self.user,
plan=self.plan,
reason="客服人工授予",
actor=self.operator,
**kwargs,
)
def test_grant_snapshots_plan_creates_fixed_seats_and_audit_event(self):
entitlement = self.grant_entitlement()
self.plan.name = "已调整套餐"
self.plan.price = Decimal("29.90")
self.plan.device_limit = 3
self.plan.save()
entitlement.refresh_from_db()
self.assertEqual(entitlement.plan_name, "月度套餐")
self.assertEqual(entitlement.plan_price, Decimal("19.90"))
self.assertEqual(entitlement.plan_device_limit, 1)
self.assertEqual(LicenseSeat.objects.filter(entitlement=entitlement).count(), 1)
event = LicenseEvent.objects.get(entitlement=entitlement)
self.assertEqual(event.action, LicenseEvent.Action.GRANTED)
self.assertEqual(event.reason, "客服人工授予")
self.assertEqual(event.actor, self.operator)
def test_renew_extends_from_later_of_now_or_existing_expiry(self):
now = timezone.now()
entitlement = self.grant_entitlement(starts_at=now - timedelta(days=10))
previous_expiry = entitlement.expires_at
renewed = renew_software_entitlement(
entitlement=entitlement,
reason="用户续订",
actor=self.operator,
now=now,
)
self.assertEqual(renewed.expires_at, previous_expiry + timedelta(days=30))
self.assertEqual(renewed.grace_expires_at, renewed.expires_at + timedelta(days=3))
self.assertEqual(renewed.status, SoftwareEntitlement.Status.ACTIVE)
self.assertTrue(
LicenseEvent.objects.filter(
entitlement=entitlement,
action=LicenseEvent.Action.RENEWED,
reason="用户续订",
).exists()
)
def test_assign_release_and_revoke_are_audited_and_never_exceed_seat_limit(self):
entitlement = self.grant_entitlement()
first_device = self.create_device("one")
second_device = self.create_device("two")
seat = assign_license_seat(
entitlement=entitlement,
device=first_device,
reason="首次绑定",
actor=self.operator,
)
repeated = assign_license_seat(
entitlement=entitlement,
device=first_device,
reason="重复绑定",
actor=self.operator,
)
self.assertEqual(seat.pk, repeated.pk)
with self.assertRaisesRegex(LicensingError, "设备席位已用完"):
assign_license_seat(
entitlement=entitlement,
device=second_device,
reason="超额绑定",
actor=self.operator,
)
released = release_license_seat(
seat=seat,
reason="客服解绑",
actor=self.operator,
)
assigned_again = assign_license_seat(
entitlement=entitlement,
device=second_device,
reason="重新绑定",
actor=self.operator,
)
self.assertIsNone(released.device_id)
self.assertEqual(assigned_again.device_id, second_device.id)
self.assertEqual(
LicenseEvent.objects.filter(
entitlement=entitlement,
action=LicenseEvent.Action.SEAT_ASSIGNED,
).count(),
2,
)
self.assertTrue(
LicenseEvent.objects.filter(
entitlement=entitlement,
action=LicenseEvent.Action.SEAT_RELEASED,
).exists()
)
revoked = revoke_software_entitlement(
entitlement=entitlement,
reason="退款撤销",
actor=self.operator,
)
self.assertEqual(revoked.status, SoftwareEntitlement.Status.REVOKED)
with self.assertRaisesRegex(LicensingError, "软件权益当前不可用"):
assign_license_seat(
entitlement=entitlement,
device=first_device,
reason="撤销后绑定",
actor=self.operator,
)
def test_manual_operations_require_reason(self):
with self.assertRaisesRegex(LicensingError, "必须填写操作原因"):
grant_software_entitlement(user=self.user, plan=self.plan, reason="")
class SoftwareEntitlementAdminTests(TestCase):
def setUp(self):
self.operator = User.objects.create_user(
username="licensing-admin",
email="licensing-admin@example.com",
password="test-password",
is_staff=True,
)
self.user = User.objects.create_user(
username="licensing-target",
email="licensing-target@example.com",
password="test-password",
)
self.plan = SoftwarePlan.objects.create(
product_code=ClientDevice.ProductCode.CMSHOPEE,
name="后台套餐",
duration_days=30,
price=Decimal("9.90"),
device_limit=2,
)
self.grant_url = reverse("admin:licensing_softwareentitlement_grant")
def test_admin_grant_requires_staff_and_reason_then_writes_event(self):
anonymous = self.client.get(self.grant_url)
self.assertEqual(anonymous.status_code, 302)
self.client.force_login(self.operator)
missing_reason = self.client.post(
self.grant_url,
{"user": self.user.pk, "plan": self.plan.pk, "reason": ""},
)
self.assertEqual(missing_reason.status_code, 200)
self.assertFalse(SoftwareEntitlement.objects.exists())
response = self.client.post(
self.grant_url,
{"user": self.user.pk, "plan": self.plan.pk, "reason": "后台补偿"},
)
self.assertEqual(response.status_code, 302)
entitlement = SoftwareEntitlement.objects.get(user=self.user)
self.assertTrue(
LicenseEvent.objects.filter(
entitlement=entitlement,
action=LicenseEvent.Action.GRANTED,
reason="后台补偿",
actor=self.operator,
).exists()
)
class LicenseSeatConcurrencyTests(TransactionTestCase):
def setUp(self):
self.user = User.objects.create_user(
username="seat-concurrency-user",
email="seat-concurrency@example.com",
password="test-password",
)
self.plan = SoftwarePlan.objects.create(
product_code=ClientDevice.ProductCode.CMSHOPEE,
name="单席位套餐",
duration_days=30,
price=Decimal("9.90"),
device_limit=1,
)
self.entitlement = grant_software_entitlement(
user=self.user,
plan=self.plan,
reason="并发测试授予",
)
self.first_device = self.create_device("first")
self.second_device = self.create_device("second")
def create_device(self, suffix):
return ClientDevice.objects.create(
user=self.user,
product_code=ClientDevice.ProductCode.CMSHOPEE,
device_id_version="v1",
device_fingerprint=ClientDevice.fingerprint_device_id("v1", f"concurrent-{suffix}"),
public_key_fingerprint=ClientDevice.fingerprint_public_key(f"key-{suffix}"),
platform=ClientDevice.Platform.WINDOWS,
client_version="0.1.0",
)
def test_concurrent_assignments_do_not_exceed_fixed_seat_limit(self):
def assign(device_id):
close_old_connections()
try:
entitlement = SoftwareEntitlement.objects.get(pk=self.entitlement.pk)
device = ClientDevice.objects.get(pk=device_id)
seat = assign_license_seat(
entitlement=entitlement,
device=device,
reason="并发绑定",
)
return ("assigned", seat.device_id)
except LicensingError as exc:
return (exc.code, None)
finally:
close_old_connections()
with ThreadPoolExecutor(max_workers=2) as executor:
outcomes = list(
executor.map(assign, (self.first_device.pk, self.second_device.pk))
)
self.assertEqual(sum(outcome[0] == "assigned" for outcome in outcomes), 1)
self.assertEqual(sum(outcome[0] == "seat_limit_reached" for outcome in outcomes), 1)
seat = LicenseSeat.objects.get(entitlement=self.entitlement)
self.assertIn(seat.device_id, {self.first_device.pk, self.second_device.pk})