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
+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