feat: add software entitlement foundations
This commit is contained in:
+248
-2
@@ -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")
|
||||
|
||||
+149
@@ -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'),
|
||||
),
|
||||
]
|
||||
@@ -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
@@ -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
@@ -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})
|
||||
|
||||
@@ -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 | 授权服务 | 授予、续期、撤销、绑定、解绑等不可变事件;保存权益、可选席位/设备、操作人、必填原因和非敏感元数据 |
|
||||
|
||||
关键事实:
|
||||
|
||||
|
||||
+1
-1
@@ -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 |
|
||||
|
||||
@@ -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 前须保留并验证真实支付回调到账闭环。
|
||||
|
||||
## 维护规则
|
||||
|
||||
@@ -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` 后续价格、时长或设备数变化而变化;席位数量由授予时快照固定。
|
||||
- 不允许在后台修改或删除流水与调用记录(账目不可篡改)。
|
||||
|
||||
### 列表与检索
|
||||
|
||||
@@ -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 兼容。
|
||||
|
||||
Reference in New Issue
Block a user