From 7b80ae3a7aebd69171d3ee76d94e47687471863c Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Tue, 21 Jul 2026 09:51:29 +0800 Subject: [PATCH] feat: add software entitlement foundations --- apps/licensing/admin.py | 250 ++++++++++++++- ...ement_licenseseat_licenseevent_and_more.py | 149 +++++++++ apps/licensing/models.py | 240 ++++++++++++++ apps/licensing/services.py | 213 ++++++++++++- .../licensing/entitlement_operation.html | 14 + .../softwareentitlement/change_form.html | 9 + apps/licensing/tests.py | 292 +++++++++++++++++- docs/04-architecture.md | 8 +- docs/06-tasks.md | 2 +- docs/current-state.md | 10 +- docs/routes.md | 9 + progress.md | 8 + 12 files changed, 1191 insertions(+), 13 deletions(-) create mode 100644 apps/licensing/migrations/0002_softwareentitlement_licenseseat_licenseevent_and_more.py create mode 100644 apps/licensing/templates/admin/licensing/entitlement_operation.html create mode 100644 apps/licensing/templates/admin/licensing/softwareentitlement/change_form.html diff --git a/apps/licensing/admin.py b/apps/licensing/admin.py index 68be3b0..1590d1a 100644 --- a/apps/licensing/admin.py +++ b/apps/licensing/admin.py @@ -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( + "/renew/", + self.admin_site.admin_view(self.renew_view), + name="licensing_softwareentitlement_renew", + ), + path( + "/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") diff --git a/apps/licensing/migrations/0002_softwareentitlement_licenseseat_licenseevent_and_more.py b/apps/licensing/migrations/0002_softwareentitlement_licenseseat_licenseevent_and_more.py new file mode 100644 index 0000000..544dce6 --- /dev/null +++ b/apps/licensing/migrations/0002_softwareentitlement_licenseseat_licenseevent_and_more.py @@ -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'), + ), + ] diff --git a/apps/licensing/models.py b/apps/licensing/models.py index fe886b6..4082874 100644 --- a/apps/licensing/models.py +++ b/apps/licensing/models.py @@ -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 diff --git a/apps/licensing/services.py b/apps/licensing/services.py index f2fc30a..d4f8de1 100644 --- a/apps/licensing/services.py +++ b/apps/licensing/services.py @@ -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 diff --git a/apps/licensing/templates/admin/licensing/entitlement_operation.html b/apps/licensing/templates/admin/licensing/entitlement_operation.html new file mode 100644 index 0000000..29506ee --- /dev/null +++ b/apps/licensing/templates/admin/licensing/entitlement_operation.html @@ -0,0 +1,14 @@ +{% extends "admin/base_site.html" %} + +{% block content %} + {% if entitlement %} +

{{ entitlement }}

+ {% endif %} +
+ {% csrf_token %} + {{ form.as_p }} +
+ +
+
+{% endblock %} diff --git a/apps/licensing/templates/admin/licensing/softwareentitlement/change_form.html b/apps/licensing/templates/admin/licensing/softwareentitlement/change_form.html new file mode 100644 index 0000000..48d67ff --- /dev/null +++ b/apps/licensing/templates/admin/licensing/softwareentitlement/change_form.html @@ -0,0 +1,9 @@ +{% extends "admin/change_form.html" %} + +{% block object-tools-items %} + {{ block.super }} + {% if original %} +
  • 续期
  • +
  • 撤销
  • + {% endif %} +{% endblock %} diff --git a/apps/licensing/tests.py b/apps/licensing/tests.py index f5c7c49..3b50ff5 100644 --- a/apps/licensing/tests.py +++ b/apps/licensing/tests.py @@ -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}) diff --git a/docs/04-architecture.md b/docs/04-architecture.md index 1a2066c..12c4d8d 100644 --- a/docs/04-architecture.md +++ b/docs/04-architecture.md @@ -20,7 +20,7 @@ - **用户端层(Django 模板 SSR)**:公开首页、注册/登录(Django auth / allauth)、个人中心(余额/充值总额/充值记录/点数记录)、API Key 自助管理、发起扫码充值、模型目录与客户端下载入口。入口 `apps/portal/`,公开首页匿名可访问,其他自助页面用 session 鉴权。T-501/T-608 已落地 `/signup`、`/login`、`/logout` 与 `/dashboard`;注册成功后经计费层一次性发放 10 点试用点数,并写 `signup_bonus` 点数流水。T-502 已落地 `/apikeys`,用户可自助生成和删除(吊销)自己的 API Key,明文只显示一次,列表只显示 prefix。T-503/T-608 已扩展 `/dashboard` 并新增 `/records/recharge`、`/records/usage`,只读展示当前用户余额、充值订单、注册赠点与消费/退款流水。T-504 已落地 `/recharge`,用户可创建 pending 充值订单、查看二维码票据,并轮询订单状态;到账仍以服务端回调或主动查单入账后的本地订单状态为准。T-505 已把 Bootstrap/qrcode.js 改成本地 static 自托管,并把充值/点数记录页从固定切片改为分页。T-606 已把 `/` 改为公开首页,并新增 `DownloadRelease` 下载版本配置用于展示 Windows 客户端版本、下载地址、SHA256 与发布说明;T-607/T-609/T-617 已新增公开 JSON 版本检查接口给桌面端自动更新使用,并返回强制更新标记和安装包文件大小字节数。T-610 已在公开首页下载区增加导入模板下载入口,由后台 `ImportTemplate` 配置当前模板。 - **用户与账号层**:注册用户 `User`、点数钱包 `UserWallet`、`ApiKey`(一用户多把、哈希存储)。入口 `apps/users/`。 -- **软件授权层**:蝦皮圈客户端设备、短期设备会话和设备审计。入口 `apps/licensing/`;T-624/T-625 建立设备观测及调用关联,不改变通用 API Key 或点数授权,套餐/权益/设备席位由后续任务实现。 +- **软件授权层**:蝦皮圈客户端设备、短期设备会话、套餐、权益、设备席位和授权审计。入口 `apps/licensing/`;T-624/T-625 建立设备观测及调用关联,T-626 建立套餐/权益/席位基础,均不改变通用 API Key 或点数授权;专属订阅校验由后续任务实现。 - **API 层(DRF)**:对外生成接口、异步图片任务接口、余额查询、支付回调接收、扫码下单、公开版本检查接口。入口 `apps/api/`。生成/余额/模型目录这类对外业务 API **只认 API Key,不接受 Web session**;充值下单/状态查询属于用户端流程,走 Web session + CSRF;支付回调走平台验签;T-607/T-609/T-617 的客户端下载版本检查接口为公开只读例外,不需要 API Key,不读取用户、不扣点,`release.force_update` 仅表示当前发布版本是否强制升级,`release.size_bytes` 仅表示下载文件大小字节数。 - **计费层**:点数计算、原子扣减(锁 `UserWallet` 行)、退点、充值入账、流水记账。入口 `apps/billing/`。 - **AI 调用层(Provider Adapter 架构)**:对外只暴露稳定能力,内部用「能力别名 → 具体供应商适配器」解耦。入口 `apps/ai/`,适配器在 `apps/ai/providers/`。 @@ -42,6 +42,8 @@ T-301 已实现 `ApiKeyAuthentication` 与 `ExternalApiView`:外部 API 使用 T-624/T-625 已新增 `apps.licensing`:`POST /api/v1/client/devices/register` 用既有 API Key 建立或复用设备记录,并签发数据库仅存 hash 的短期设备会话;`POST /api/v1/client/devices/heartbeat` 用 `X-Device-Session` 更新活跃观测。设备 ID 使用服务端 pepper 后的摘要,安装公钥只存摘要,默认按设备每日节流 `last_seen_at` 写入。四个生成提交入口可选解析同账号有效会话,将服务端解析的 `ClientDevice` 写入可空 `CallRecord.client_device`;无会话仍走原 API Key、计费与任务路径,异步轮询不读取设备会话。伪造/过期会话在预扣前拒绝,跨账号会话拒绝;`generation_route_usage` 仅记录产品代码、内部设备 ID 与是否带会话等白名单字段。T-628 才进入专属授权影子校验。 +T-626 已在同一 app 建立授权基础:`SoftwarePlan` 可由运营维护产品、时长、价格、设备数、宽限期和状态;`SoftwareEntitlement` 在人工授予时复制套餐名称、价格、时长、设备数和宽限期快照,之后套餐改动不回写历史权益。服务层在事务内预创建固定数量 `LicenseSeat`,绑定时先锁权益和全部席位,避免并发超售;人工授予、续期、撤销、绑定和解绑都必须给出原因并写只读 `LicenseEvent`。续期从 `max(now, expires_at)` 起算,且全程不读取或修改点数钱包。django-admin 通过专用操作页处理授予 / 续期 / 撤销,权益、席位和事件不允许直接篡改。此阶段没有购买入口、支付订单、迁移凭证或生成授权拦截。 + T-302 已实现 `/api/v1/generate/title` 与 `/api/v1/generate/image`:API 层只做鉴权、参数校验和编排;别名解析、Provider 选择、计费计算、预扣、成功确认、失败退点分别调用 `apps.ai` / `apps.billing` 既有模块。T-613 已把生成链路抽为 `apps.api.generation` 的核心阶段:`prepare_generation()` 负责审核、图片输入、别名、Provider 与计费准备;`precharge_generation()` 只调用 billing 预扣;`execute_precharged_generation()` 复用已预扣 `CallRecord` 调上游并成功确认或失败退点,供旧同步接口和后续异步 worker 共用。T-620 将图生图输入扩展为有序集合:兼容旧单图字段,新 `images[0]` 为主商品图、`images[1:]` 为参考图;服务端在审核用户 prompt 后固定追加角色规则,再将完整顺序交给 Provider。图片结果 MVP 先用本地 `default_storage` 保存到 `MEDIA_ROOT/generated/images/...` 并返回 `image_url`;核心阶段通过 URL 构建器生成外部 URL,不依赖 DRF `Request`;`CallRecord` 只写 URL / 摘要,不保存 provider `raw` 或 base64。 T-619 已在同一生成核心增加 `vision` 分支和 `/api/v1/analyze/images`:API 接收有序的 `images[]`,每项二选一提供 `image_url` / `image_base64`;prompt 审核通过后才下载或解码图片,随后校验同时具备 `text + vision` 的模型与 Provider、按 `vision + alias` 默认价格预扣一次、同步调用 Chat Completions / Gemini 多模态接口并返回完整文字。输入图片只在请求内存中使用,不落库;`CallRecord` 只保存最多 500 字结果摘要。 @@ -117,6 +119,10 @@ T-619 多图理解继续复用上述 URL 下载器和逐跳 SSRF 校验,并额 | DeviceSession | 设备登记服务 | 设备、会话 token hash、过期/最后使用/吊销时间;明文只在登记响应出现一次 | | DeviceBindingAudit | 设备登记服务 | 用户、设备、来源 API Key、登记/会话/心跳等动作、原因、客户端版本、时间;只读审计 | | CallRecord.client_device | 生成调用 | 可空关联到服务端验证后的 `ClientDevice`;仅有效设备会话的标题、图片、异步提交和 vision 写入,历史/无头调用保持为空 | +| SoftwarePlan | 运营配置 | 产品、名称、有效天数、价格、设备数、宽限天数和启停状态;修改仅影响后续人工授予 / 购买 | +| SoftwareEntitlement | 授权服务 | 用户、产品、来源套餐(可空)及购买/授予时套餐快照、状态、开始/到期/宽限截止/撤销时间;不从套餐反向同步 | +| LicenseSeat | 授权服务 | 每项权益按序号预创建的固定席位,可空关联已绑定设备及绑定 / 解绑时间;`(entitlement, seat_number)` 唯一 | +| LicenseEvent | 授权服务 | 授予、续期、撤销、绑定、解绑等不可变事件;保存权益、可选席位/设备、操作人、必填原因和非敏感元数据 | 关键事实: diff --git a/docs/06-tasks.md b/docs/06-tasks.md index 031bbb3..439523a 100644 --- a/docs/06-tasks.md +++ b/docs/06-tasks.md @@ -104,7 +104,7 @@ | T-623 | 图片生成任务单图 / 多图筛选 | T-614, T-620 | 已完成:`ImageGenerationTaskAdmin` 的列表右侧新增“输入图片类型”筛选,提供“单图生图”和“多图生图”。数据库聚合计数识别 1 条子输入的单图、2 条及以上的多图;无子输入且历史 `input_image` 非空的任务兼容归单图;无输入图任务不归类。筛选可与既有状态 / 日期过滤叠加,结果不重复;未改模型、迁移、任务状态、计费、API、worker、媒体文件或详情页缩略图。已通过 6 条目标 admin 测试、`check`、迁移一致性和 `init.ps1`。 | DONE | | T-624 | 蝦皮圈设备登记与会话观测 | T-301, T-401 | 已完成:新增 `apps.licensing`、`ClientDevice` / `DeviceSession` / `DeviceBindingAudit` 与 `licensing.0001_initial`。设备登记用 API Key 鉴权、心跳用短期 `X-Device-Session`;重复登记不重复建设备并轮换旧会话,设备/公钥/会话只存摘要或 hash,活跃时间默认每日节流。admin 只读展示设备、会话和审计摘要,不回显敏感值。现有生成、余额、模型目录、异步任务和通用 API Key 语义未改变。已通过本地迁移、21 条目标回归测试、`check`、迁移一致性和 `init.ps1`。 | DONE | | T-625 | 蝦皮圈设备使用关联与迁移观测 | T-624, T-613, T-614, T-615 | 已完成阶段 1 关联观测:有效 `X-Device-Session` 仅在标题、同步图片、异步图片提交、vision 的预扣 `CallRecord` 写入服务端解析的可空 `client_device`;无头旧调用保持原返回、扣点、提交和轮询。新增 `billing.0010_callrecord_client_device_and_more` 与 `(client_device, created_at)` 索引;admin 支持展示 / 按设备筛选。`generation_route_usage` 新增 `product_code`、内部 `client_device_id`、`device_session_present` 白名单字段,不记录设备摘要、公钥、令牌、Key、prompt 或图片。伪造/过期会话在预扣前返回 401,吊销/跨账号会话返回 403;仍不做订阅授权拦截。已通过 4 条新增入口/兼容/安全/遥测测试及 14 条 licensing/生成回归、迁移、`check` 和一致性验证。 | DONE | -| T-626 | 软件套餐、权益与设备席位基础模型 | T-624, T-401 | **阶段 2 的数据与后台基础,不接购买页、不改生成授权。** 新增 `SoftwarePlan`、`SoftwareEntitlement`、`LicenseSeat`、`LicenseEvent` 模型及 django-admin;套餐保存 `product_code`、名称、时长、价格、设备数、状态,权益保存用户、产品、购买时套餐快照、状态、开始/到期/宽限时间;席位按权益和序号唯一,事件记录人工授予、续期、撤销、解绑等原因。建立单一 licensing service,提供手工授予、续期、撤销、分配/释放席位的事务方法;续期从 `max(now, expires_at)` 计算,禁止直接改钱包或 `points_balance`。后台人工操作必须填原因并生成审计事件,历史权益不可被套餐后来编辑反向篡改。测试覆盖套餐快照、同一权益并发占用席位不超限、续期计算、撤销、审计和 admin 权限;同步数据模型、运营流程与迁移说明。 | TODO | +| T-626 | 软件套餐、权益与设备席位基础模型 | T-624, T-401 | 已完成阶段 2 基础模型:新增 `SoftwarePlan`、`SoftwareEntitlement`、`LicenseSeat`、`LicenseEvent` 和 `licensing.0002_softwareentitlement_licenseseat_licenseevent_and_more`。授予时复制套餐名称/价格/时长/设备数/宽限期快照并预创建固定席位;`(entitlement, seat_number)` 唯一。服务层以事务锁权益和席位执行授予、续期、撤销、绑定与解绑,所有人工操作原因必填并写事件;续期从 `max(now, expires_at)` 起算,不触碰钱包。admin 提供套餐维护及权益授予/续期/撤销专用入口,权益/席位/事件只读。未接购买、支付、迁移凭证或生成授权。已通过套餐快照、续期、撤销、审计、后台权限及双连接单席位并发测试、迁移和 `check`。 | DONE | | T-627 | 存量用户迁移权益、网页确认与设备凭证 | T-624, T-626, T-501 | 实施“自动迁移权益 + 网页确认当前设备”而非旧 API Key 自动抢占设备。新增 `LegacyMigrationGrant` / 一次性 `MigrationRequest` 和产品专用 `DeviceCredential`(均只存 hash/摘要);提供后台按资格快照批量或受控创建迁移权益,默认设备数和迁移到期日由明确配置/管理动作决定,新注册用户不得自动领取。客户端以旧 API Key 仅可为当前设备创建短时迁移请求;网页登录的 portal 页面确认同账号、同设备和未占用席位后,在事务内绑定席位并签发仅本次显示的设备凭证。重复确认、客户端轮询重试、浏览器刷新和响应丢失均幂等;复制旧 API Key 不能绕过网页登录确认。支持受审计的自助/客服解绑,吊销旧凭证;不改变旧生成接口,迁移窗口内旧客户端仍可用。测试覆盖资格边界、跨账号拒绝、过期/重复请求、并发确认只占一席、凭证 hash 与吊销、旧接口兼容;同步 portal 路由、API 契约、迁移与隐私文档。 | TODO | | T-628 | 蝦皮圈专属授权入口与影子校验 | T-625, T-626, T-627, T-613, T-614 | 新增蝦皮圈**专属**产品调用入口或明确的产品认证分流,使用 `DeviceCredential + X-Device-Session` 解析产品、用户和设备;不得在通用 `/api/v1/generate/*` 上给全部 API Key 强加订阅校验。实现统一 `licensing` 授权服务/permission,区分“可新提交生成”和“可读取本人已接受任务”,返回稳定结构化错误码(如 `device_not_bound`、`device_mismatch`、`license_required`、`license_expired`),但本任务先以影子模式记录 `would_reject`,不实际阻断已迁移或旧客户端。蝦皮圈专属 title、vision、异步 image submit 接入该服务;已接受任务的查询/下载始终按任务归属可读。测试覆盖通用 API 完全不回归、专属凭证认证、跨用户拒绝、影子结果日志脱敏、过期权益的 would-reject、已接受任务读取和点数账本不被授权检查重复修改;同步 API、架构、部署开关和桌面端对接文档。 | TODO | | T-629 | 软件套餐购买、续订订单与权益入账 | T-626, T-627, T-628, T-304, T-305 | 新增与 `RechargeOrder` **分离**的 `SoftwareOrder`,实现蝦皮圈套餐选择、创建待支付订单、支付回调/主动查单后的幂等权益发放与续期;不得把软件订阅金额兑换为点数,也不得改动充值订单或既有点数回调契约。订单须锁定套餐名称、价格、时长、设备数等快照,回调必须验签、校验订单金额/通道、按订单号幂等;同一订单重复回调最多延长一次。portal 提供订阅状态、套餐购买/续订入口和订单只读记录,支付失败/取消保持 pending/failed 语义并保留审计。复用现有支付网关的微信路径但不假设自动续费协议;第一版“月订阅”是用户每月主动续订,自动代扣仅登记后续任务。测试覆盖订单快照、重复回调、金额不匹配、并发回调、续期起算、退款/撤销后的权益状态、充值链路回归;真实生产支付验收依赖既有微信回调闭环修复,未提供商户条件时仅按 mock/SDK 契约测试。 | TODO | diff --git a/docs/current-state.md b/docs/current-state.md index 3f7e8be..643fd71 100644 --- a/docs/current-state.md +++ b/docs/current-state.md @@ -18,7 +18,7 @@ - T-614/T-616 生产代码补充:已新增 `ImageGenerationTask` 与 `api.0001_initial`,新增 `POST /api/v1/generate/image/tasks`、`GET /api/v1/generate/image/tasks/{task_id}`、`apps.api.image_tasks` 任务服务、`run_image_tasks` management command 和只读 admin;异步提交支持 `Idempotency-Key` 去重 / 冲突检测,worker 使用 DB 任务表、租约、心跳与 reaper,成功返回 cmhub 托管 URL;T-616 已通过 `api.0002_imagegenerationtask_next_attempt_at_and_more` 增加 `next_attempt_at` 与 `(status, next_attempt_at)` 索引,临时性 `upstream_timeout` / `upstream_error` 默认最多重试 2 次,重试期间不退点,最终失败 / 僵任务才走计费层幂等退款。 - T-617 生产代码补充:`DownloadRelease` 已新增 `size_bytes` 可空正整数字段和迁移 `portal.0004_downloadrelease_size_bytes`,django-admin 可填写并在列表展示;`GET /api/v1/client/releases/latest` 的 `release` 对象新增 `size_bytes`,有值返回整数,未配置返回 `null`,未发布时仍只返回 `release:null`。 - T-618 生产代码补充:`DownloadReleaseAdmin` 已挂专用 `ModelForm`,后台新增 / 编辑客户端发布版本时 `sha256` 与 `size_bytes` 必填;数据库字段仍兼容历史空值,API 仍可读取历史 `size_bytes=null` 记录。 -- T-624/T-625 本地代码补充:已新增 `apps.licensing` 和迁移 `licensing.0001_initial`,包含 `ClientDevice`、`DeviceSession`、`DeviceBindingAudit`;新增 API Key 鉴权的设备登记与设备会话鉴权的心跳接口。`billing.0010_callrecord_client_device_and_more` 为调用记录增加可空设备关联和索引;四个生成提交入口可选解析同账号设备会话,关联调用记录和白名单遥测。无会话旧客户端及异步轮询保持不变,伪造/过期/跨账号会话在预扣前拒绝;尚未部署生产。 +- T-624/T-625/T-626 本地代码补充:`apps.licensing` 已包含设备、会话、设备审计、套餐、权益、席位和授权事件;`licensing.0002_softwareentitlement_licenseseat_licenseevent_and_more` 建立套餐快照和固定席位基础。`billing.0010_callrecord_client_device_and_more` 为调用记录增加可空设备关联和索引;四个生成提交入口可选解析同账号设备会话,关联调用记录和白名单遥测。套餐/权益服务不触碰钱包;无会话旧客户端及异步轮询保持不变,尚未部署生产。 - T-619 生产代码补充:`ModelAlias` / `CallRecord` 已新增 `vision` 操作并生成 `ai.0005` / `billing.0008` 迁移;`POST /api/v1/analyze/images` 已接入 API Key 鉴权、生成限流、prompt 前置审核、有序多图 URL/Base64 读取、SSRF 与大小保护、Chat/Gemini 多模态 Provider、固定单次预扣 / 成功确认 / 幂等退款及安全调用摘要。模型目录与 portal 可用模型页会展示能力匹配的 active `vision` 别名并标记 `requires_image=true`。2026-07-16 已在 `185.216.248.75` 部署 `707941a` 并应用迁移,复用 active `GPT-5.5 文本`(`text + vision`)创建默认 active `vision-standard`,默认价格为 1 点/次;Nginx 已把该同步接口分流到 `cmhub-generate` 长请求池。 - T-619 生产验收:真实提交两张图片成功返回中文文字,HTTP 200,`model_used=gpt-5.5`,`call_id=6096`;余额 234→233,调用记录为 `vision/success`,对应 consume 1 条、refund 0 条。验收临时 API Key 已吊销;`cmhub-web`、`cmhub-generate`、Nginx、MySQL 8.4 均为 active,66 个异步生图 worker 未因本次部署重启。 - 最新验证:T-619 已完成 `ai.0005_alter_modelalias_operation_type` 与 `billing.0008_alter_callrecord_operation_type_and_more` 迁移并应用到当前开发库;`manage.py check` 0 issues,`makemigrations --check --dry-run` 无变化;Provider 专项 10 tests OK,T-619 API / 别名 / 目录专项 13 tests OK,包含旧标题 / 生图的扩展回归 84 tests OK,users / billing / moderation 分组 46 tests OK。完整单命令回归在 604 秒达到执行器超时,之后 API 全量重试在测试库初始化阶段遇到远程 MySQL `43.128.3.240` 连接超时,均未产生断言失败;测试期仍有 allauth 在 MySQL 条件唯一约束上的既有 `models.W036` 警告。 @@ -75,11 +75,11 @@ 任务状态以 [`06-tasks.md`](06-tasks.md) 为准,历史执行记录见 [`../progress.md`](../progress.md)。 - 已完成:T-001 初始化 Django + DRF 项目骨架;T-002 建立 apps 目录、自定义 User 与配置;T-003 接通 django-admin 与最小测试;T-004 Phase 0 骨架审核修补;T-101 Provider 适配器层 + 移植 cmbot 调用;T-102 AiModel + ModelAlias 模型 + 别名解析;T-103 配置变更审计;T-104 跑通一次录制标题生成;T-105 Phase 1 AI 层审核修补;T-201 User / UserWallet / ApiKey / PointsLedger / CallRecord 模型;T-202 PricingRule / ExchangeRate 模型 + 计费计算;T-203 并发安全扣点 / 退点;T-204 Phase 2 计费核心审核加固;T-301 API Key 鉴权;T-302 生成标题 / 图片接口;T-303 余额查询接口;T-304 充值回调;T-305 扫码充值下单 + 轮询;T-306 Phase 3 对外 API 安全加固;T-501 注册 / 登录(allauth);T-502 API Key 自助管理页;T-503 个人中心 / 记录页;T-504 充值页(扫码 + 轮询到账);T-505 Phase 4 用户端审核优化;T-401 运营后台完善;T-402 完整验收 MVP;T-403 部署 / 运行文档;T-601 可用别名发现;T-602 django-admin 中文化(第 1-3 层);T-603 django-admin 中文化(第 4 层·字段级);T-604 中文敏感词本地过滤;T-605 免邮箱验证策略落地;T-606 公开首页 + 客户端下载入口;T-607 桌面端最新版本检查接口;T-608 新用户注册赠送试用点数(当前 10 点);T-609 桌面端版本检查接口增加强制更新标记;T-610 首页导入模板下载入口;T-611 用户端品牌名统一为虾皮圈;T-612 生图同步接口止血(上游硬截止 + 长请求池校准);T-613 抽生成核心 service(计费+审核+上游共享 core);T-614 生图异步任务化接口(提交+轮询,新增不动旧接口);T-615 旧同步生图接口用量遥测 + 弃用口径;T-616 生图失败自动重试 2 次;T-617 桌面端版本检查接口增加文件大小字段;T-618 客户端发布版本后台必填文件校验元数据。 -- 已完成补充:T-619 多张图片理解并返回文字;T-620 图生图支持单图 / 多图主图与参考图;T-622 图片生成任务后台图片缩略预览;T-623 图片生成任务单图 / 多图筛选;T-624 蝦皮圈设备登记与会话观测;T-625 蝦皮圈设备使用关联与迁移观测。 +- 已完成补充:T-619 多张图片理解并返回文字;T-620 图生图支持单图 / 多图主图与参考图;T-622 图片生成任务后台图片缩略预览;T-623 图片生成任务单图 / 多图筛选;T-624 蝦皮圈设备登记与会话观测;T-625 蝦皮圈设备使用关联与迁移观测;T-626 软件套餐、权益与设备席位基础模型。 - 正在进行:无。 -- 待开始:T-626 软件套餐/权益/席位基础;其后依赖链为 T-627 存量迁移与设备凭证、T-628 蝦皮圈专属授权影子校验、T-629 套餐购买/续订订单与权益入账。T-621 注册赠点运营后台配置继续留在 Backlog。真实支付回调到账闭环、客户端发布、生产多图理解模型配置和线上旧同步接口用量观察仍可继续拆任务。 +- 待开始:T-627 存量迁移与设备凭证;其后依赖链为 T-628 蝦皮圈专属授权影子校验、T-629 套餐购买/续订订单与权益入账。T-621 注册赠点运营后台配置继续留在 Backlog。真实支付回调到账闭环、客户端发布、生产多图理解模型配置和线上旧同步接口用量观察仍可继续拆任务。 - 当前 blocker:支付商户真实密钥/证书与生产 SDK 依赖仍待提供;微信回调到账闭环仍需真实支付验收;真实 AI 标题生成已在线上跑通,图片生成慢 / 504 / 客户端超时风险已拆为 T-612~T-616 并完成工程侧处理。 -- 下一个可领取任务:T-626 软件套餐、权益与设备席位基础。T-621 保留在 Backlog,具体范围见 [`06-tasks.md`](06-tasks.md)。 +- 下一个可领取任务:T-627 存量用户迁移权益、网页确认与设备凭证。T-621 保留在 Backlog,具体范围见 [`06-tasks.md`](06-tasks.md)。 ## 当前可运行内容 @@ -153,7 +153,7 @@ T-619 已落地同步多图理解:调用方提交有序 `images` 列表,服 1. 读仓库级 `AGENTS.md` / `CLAUDE.md`。 2. 读 `docs/00-ai-start-here.md`。 3. 读 `docs/05-coding-rules.md`(尤其第 8 节资金安全)。 -4. 在 `docs/06-tasks.md` 领取第一个 `TODO` 且依赖均 `DONE` 的任务;当前应先做 T-626。 +4. 在 `docs/06-tasks.md` 领取第一个 `TODO` 且依赖均 `DONE` 的任务;当前应先做 T-627。 5. T-624~T-629 按设备观测 → 套餐权益 → 存量迁移 → 专属授权影子校验 → 订阅订单顺序执行;T-629 前须保留并验证真实支付回调到账闭环。 ## 维护规则 diff --git a/docs/routes.md b/docs/routes.md index 5431912..14c13d4 100644 --- a/docs/routes.md +++ b/docs/routes.md @@ -70,6 +70,9 @@ T-624/T-625 已新增设备登记 / 心跳及调用关联:登记接口继续 | 导入模板 | ImportTemplate | 上传导入模板或填写外部下载地址,标记当前模板;展示模板名称、SHA256 与说明 | | 客户端设备 | ClientDevice | 只读检索蝦皮圈设备平台、版本、状态和最近活跃时间;设备摘要仅脱敏显示 | | 设备会话 / 审计 | DeviceSession / DeviceBindingAudit | 只读查看会话有效期、吊销状态及登记/心跳审计;不展示会话令牌或安装公钥 | +| 软件套餐 | SoftwarePlan | 维护蝦皮圈套餐的时长、价格、设备数、宽限期与启停状态;修改不回写已有权益快照 | +| 软件权益 | SoftwareEntitlement | 只读查看用户权益和套餐快照;通过专用后台页面人工授予、续期或撤销,三种操作均必须填写原因并写授权事件 | +| 授权席位 / 授权事件 | LicenseSeat / LicenseEvent | 只读检索设备绑定、解绑时间和全部授权审计;不支持后台直接编辑席位或事件 | ## 后台职责约定 @@ -88,6 +91,12 @@ T-624/T-625 已新增设备登记 / 心跳及调用关联:登记接口继续 ### 流水 / 调用记录 - 只读列表,支持按账号、API Key、设备、时间范围、类型/状态筛选与搜索;设备关联为空表示存量无头调用,不代表调用失败。 + +### 软件授权 + +- T-626 的套餐、权益和席位只提供运营基础数据与人工操作,不新增公开接口、不接支付订单,也不改变通用生成 API 的授权结果。 +- 授予 / 续期 / 撤销一律调用 `apps.licensing.services`;原因必填并产生 `LicenseEvent`。不得在 admin 表单中直接修改权益状态、到期时间、席位绑定或历史事件。 +- 已授予权益读取自身套餐快照,不随 `SoftwarePlan` 后续价格、时长或设备数变化而变化;席位数量由授予时快照固定。 - 不允许在后台修改或删除流水与调用记录(账目不可篡改)。 ### 列表与检索 diff --git a/progress.md b/progress.md index 3719de9..b475660 100644 --- a/progress.md +++ b/progress.md @@ -2066,3 +2066,11 @@ - 后台与文档:`CallRecordAdmin` 增加只读设备展示与筛选;同步 `api.md`、`routes.md`、`04-architecture.md`、`current-state.md` 和任务看板,明确本期只做关联观测,不实施订阅授权拦截。 - 验证:本地已应用 billing 迁移;新增 4 条测试覆盖四个生成入口关联、无头兼容、伪造/跨账号会话拒绝、遥测脱敏和异步轮询。`py -3.12 manage.py test` 定向新增 4 条通过;随后 licensing 加关键标题/vision/同步图片/异步提交/遥测回归共 14 条通过;`manage.py check` 和 `makemigrations --check --dry-run` 通过。测试仅保留既有 allauth MySQL 条件唯一约束 `models.W036` 警告。 - 下一步:领取 T-626,建立套餐、权益、席位和审计的基础模型与后台,不接入生成授权或购买流程。 + +## 2026-07-21 完成:T-626 软件套餐、权益与设备席位基础模型 + +- 实现:`apps.licensing` 新增 `SoftwarePlan`、`SoftwareEntitlement`、`LicenseSeat`、`LicenseEvent`,迁移为 `licensing.0002_softwareentitlement_licenseseat_licenseevent_and_more`。授予权益时复制套餐名称、价格、有效天数、设备数和宽限期,并预创建固定数量的席位;套餐后续编辑不会反向修改历史权益。 +- 服务与后台:新增事务服务 `grant_software_entitlement()`、`renew_software_entitlement()`、`revoke_software_entitlement()`、`assign_license_seat()`、`release_license_seat()`。每个人工操作均要求非空原因并写不可变授权事件;续期按 `max(now, expires_at)` 延长,席位分配锁权益和席位行。django-admin 支持套餐维护和权益专用授予/续期/撤销页,权益、席位、事件不允许直接编辑。 +- 边界:本任务不创建购买页、软件订单、支付回调、存量迁移请求/设备凭证,也不改变通用生成 API 或点数账本。 +- 验证:本地已应用 licensing 迁移。`py -3.12 manage.py test apps.licensing --keepdb --noinput --verbosity 2` 通过 14 条测试,包含套餐快照、续期、撤销、审计、后台权限和两个独立数据库连接并发抢占单席位;`manage.py check` 与 `makemigrations --check --dry-run` 通过。测试仅保留既有 allauth MySQL 条件唯一约束 `models.W036` 警告。 +- 下一步:领取 T-627,实施存量迁移权益、网页登录确认和产品专用设备凭证,继续保持通用生成 API 兼容。