feat: add software entitlement foundations

This commit is contained in:
QiuSW
2026-07-21 09:51:29 +08:00
parent 11c0d63ac8
commit 7b80ae3a7a
12 changed files with 1191 additions and 13 deletions
+248 -2
View File
@@ -1,6 +1,26 @@
from django.contrib import admin from django import forms
from django.contrib import admin, messages
from django.contrib.auth import get_user_model
from django.http import HttpResponseRedirect
from django.shortcuts import get_object_or_404
from django.template.response import TemplateResponse
from django.urls import path, reverse
from .models import ClientDevice, DeviceBindingAudit, DeviceSession from .models import (
ClientDevice,
DeviceBindingAudit,
DeviceSession,
LicenseEvent,
LicenseSeat,
SoftwareEntitlement,
SoftwarePlan,
)
from .services import (
LicensingError,
grant_software_entitlement,
renew_software_entitlement,
revoke_software_entitlement,
)
def _masked_fingerprint(value: str) -> str: def _masked_fingerprint(value: str) -> str:
@@ -105,3 +125,229 @@ class DeviceBindingAuditAdmin(admin.ModelAdmin):
def has_delete_permission(self, request, obj=None): def has_delete_permission(self, request, obj=None):
return False return False
@admin.register(SoftwarePlan)
class SoftwarePlanAdmin(admin.ModelAdmin):
list_display = (
"name",
"product_code",
"duration_days",
"price",
"device_limit",
"grace_days",
"status",
"updated_at",
)
list_filter = ("product_code", "status")
search_fields = ("name",)
list_editable = ("status",)
readonly_fields = ("created_at", "updated_at")
class EntitlementGrantForm(forms.Form):
user = forms.ModelChoiceField(queryset=get_user_model().objects.all(), label="用户")
plan = forms.ModelChoiceField(
queryset=SoftwarePlan.objects.filter(status=SoftwarePlan.Status.ACTIVE),
label="套餐",
)
reason = forms.CharField(label="授予原因", widget=forms.Textarea(attrs={"rows": 4}))
def clean_reason(self):
reason = self.cleaned_data["reason"].strip()
if not reason:
raise forms.ValidationError("必须填写操作原因。")
return reason
class EntitlementReasonForm(forms.Form):
reason = forms.CharField(label="操作原因", widget=forms.Textarea(attrs={"rows": 4}))
def clean_reason(self):
reason = self.cleaned_data["reason"].strip()
if not reason:
raise forms.ValidationError("必须填写操作原因。")
return reason
@admin.register(SoftwareEntitlement)
class SoftwareEntitlementAdmin(admin.ModelAdmin):
change_form_template = "admin/licensing/softwareentitlement/change_form.html"
list_display = (
"user",
"product_code",
"plan_name",
"plan_device_limit",
"status",
"starts_at",
"expires_at",
"grace_expires_at",
)
list_filter = ("product_code", "status", "expires_at")
search_fields = ("user__username", "user__email", "plan_name")
list_select_related = ("user", "source_plan")
readonly_fields = (
"user",
"product_code",
"source_plan",
"plan_name",
"plan_duration_days",
"plan_price",
"plan_device_limit",
"plan_grace_days",
"status",
"starts_at",
"expires_at",
"grace_expires_at",
"revoked_at",
"created_at",
"updated_at",
)
fields = readonly_fields
def has_add_permission(self, request):
return False
def has_delete_permission(self, request, obj=None):
return False
def get_urls(self):
urls = super().get_urls()
custom_urls = [
path(
"grant/",
self.admin_site.admin_view(self.grant_view),
name="licensing_softwareentitlement_grant",
),
path(
"<path:object_id>/renew/",
self.admin_site.admin_view(self.renew_view),
name="licensing_softwareentitlement_renew",
),
path(
"<path:object_id>/revoke/",
self.admin_site.admin_view(self.revoke_view),
name="licensing_softwareentitlement_revoke",
),
]
return custom_urls + urls
def grant_view(self, request):
form = EntitlementGrantForm(request.POST or None)
if request.method == "POST" and form.is_valid():
try:
entitlement = grant_software_entitlement(
user=form.cleaned_data["user"],
plan=form.cleaned_data["plan"],
reason=form.cleaned_data["reason"],
actor=request.user,
)
except LicensingError as exc:
form.add_error(None, exc.message)
else:
self.message_user(request, "软件权益已授予。", messages.SUCCESS)
return HttpResponseRedirect(self._change_url(entitlement))
return self._operation_response(
request,
title="手工授予软件权益",
form=form,
)
def renew_view(self, request, object_id):
entitlement = get_object_or_404(SoftwareEntitlement, pk=object_id)
return self._entitlement_operation_view(
request,
entitlement=entitlement,
action="renew",
title="续期软件权益",
)
def revoke_view(self, request, object_id):
entitlement = get_object_or_404(SoftwareEntitlement, pk=object_id)
return self._entitlement_operation_view(
request,
entitlement=entitlement,
action="revoke",
title="撤销软件权益",
)
def _entitlement_operation_view(self, request, *, entitlement, action, title):
form = EntitlementReasonForm(request.POST or None)
if request.method == "POST" and form.is_valid():
try:
operation = (
renew_software_entitlement
if action == "renew"
else revoke_software_entitlement
)
operation(
entitlement=entitlement,
reason=form.cleaned_data["reason"],
actor=request.user,
)
except LicensingError as exc:
form.add_error(None, exc.message)
else:
self.message_user(request, f"软件权益已{'续期' if action == 'renew' else '撤销'}。", messages.SUCCESS)
return HttpResponseRedirect(self._change_url(entitlement))
return self._operation_response(
request,
title=title,
form=form,
entitlement=entitlement,
)
def _operation_response(self, request, *, title, form, entitlement=None):
context = {
**self.admin_site.each_context(request),
"title": title,
"opts": self.model._meta,
"form": form,
"entitlement": entitlement,
}
return TemplateResponse(
request,
"admin/licensing/entitlement_operation.html",
context,
)
def _change_url(self, entitlement):
return reverse("admin:licensing_softwareentitlement_change", args=(entitlement.pk,))
class ReadOnlyLicenseAdmin(admin.ModelAdmin):
def get_readonly_fields(self, request, obj=None):
return tuple(field.name for field in self.model._meta.fields)
def has_add_permission(self, request):
return False
def has_change_permission(self, request, obj=None):
return False
def has_delete_permission(self, request, obj=None):
return False
@admin.register(LicenseSeat)
class LicenseSeatAdmin(ReadOnlyLicenseAdmin):
list_display = ("entitlement", "seat_number", "device", "bound_at", "released_at")
list_filter = ("entitlement__product_code", "bound_at", "released_at")
search_fields = (
"entitlement__user__username",
"entitlement__user__email",
"device__user__username",
)
list_select_related = ("entitlement", "entitlement__user", "device")
@admin.register(LicenseEvent)
class LicenseEventAdmin(ReadOnlyLicenseAdmin):
list_display = ("entitlement", "action", "seat", "device", "actor", "reason", "created_at")
list_filter = ("action", "entitlement__product_code", "created_at")
search_fields = (
"entitlement__user__username",
"entitlement__user__email",
"reason",
)
list_select_related = ("entitlement", "seat", "device", "actor")
@@ -0,0 +1,149 @@
# Generated by Django 5.2.15 on 2026-07-21 01:45
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('licensing', '0001_initial'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='SoftwareEntitlement',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('product_code', models.CharField(choices=[('cmshopee', '虾皮圈优化助手')], max_length=32, verbose_name='产品代码')),
('plan_name', models.CharField(max_length=120, verbose_name='套餐名称快照')),
('plan_duration_days', models.PositiveIntegerField(verbose_name='套餐有效天数快照')),
('plan_price', models.DecimalField(decimal_places=2, max_digits=12, verbose_name='套餐价格快照')),
('plan_device_limit', models.PositiveSmallIntegerField(verbose_name='设备数量快照')),
('plan_grace_days', models.PositiveSmallIntegerField(default=0, verbose_name='宽限天数快照')),
('status', models.CharField(choices=[('active', '有效'), ('revoked', '已撤销'), ('expired', '已过期')], default='active', max_length=20, verbose_name='状态')),
('starts_at', models.DateTimeField(verbose_name='开始时间')),
('expires_at', models.DateTimeField(verbose_name='到期时间')),
('grace_expires_at', models.DateTimeField(verbose_name='宽限截止时间')),
('revoked_at', models.DateTimeField(blank=True, null=True, verbose_name='撤销时间')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='software_entitlements', to=settings.AUTH_USER_MODEL, verbose_name='用户')),
],
options={
'verbose_name': '软件权益',
'verbose_name_plural': '软件权益',
'db_table': 'software_entitlement',
'ordering': ('-expires_at', '-id'),
},
),
migrations.CreateModel(
name='LicenseSeat',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('seat_number', models.PositiveSmallIntegerField(verbose_name='席位序号')),
('bound_at', models.DateTimeField(blank=True, null=True, verbose_name='绑定时间')),
('released_at', models.DateTimeField(blank=True, null=True, verbose_name='解绑时间')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')),
('device', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='license_seats', to='licensing.clientdevice', verbose_name='绑定设备')),
('entitlement', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='seats', to='licensing.softwareentitlement', verbose_name='软件权益')),
],
options={
'verbose_name': '授权席位',
'verbose_name_plural': '授权席位',
'db_table': 'license_seat',
'ordering': ('entitlement_id', 'seat_number'),
},
),
migrations.CreateModel(
name='LicenseEvent',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('action', models.CharField(choices=[('granted', '人工授予'), ('renewed', '续期'), ('revoked', '撤销'), ('seat_assigned', '绑定席位'), ('seat_released', '解绑席位')], max_length=32, verbose_name='动作')),
('reason', models.CharField(max_length=255, verbose_name='原因')),
('metadata', models.JSONField(blank=True, default=dict, verbose_name='附加信息')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')),
('actor', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='license_events_performed', to=settings.AUTH_USER_MODEL, verbose_name='操作人')),
('device', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='license_events', to='licensing.clientdevice', verbose_name='关联设备')),
('seat', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='events', to='licensing.licenseseat', verbose_name='授权席位')),
('entitlement', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='events', to='licensing.softwareentitlement', verbose_name='软件权益')),
],
options={
'verbose_name': '授权事件',
'verbose_name_plural': '授权事件',
'db_table': 'license_event',
'ordering': ('-created_at', '-id'),
},
),
migrations.CreateModel(
name='SoftwarePlan',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('product_code', models.CharField(choices=[('cmshopee', '虾皮圈优化助手')], max_length=32, verbose_name='产品代码')),
('name', models.CharField(max_length=120, verbose_name='套餐名称')),
('duration_days', models.PositiveIntegerField(verbose_name='有效天数')),
('price', models.DecimalField(decimal_places=2, max_digits=12, verbose_name='套餐价格')),
('device_limit', models.PositiveSmallIntegerField(verbose_name='设备数量')),
('grace_days', models.PositiveSmallIntegerField(default=0, verbose_name='宽限天数')),
('status', models.CharField(choices=[('active', '启用'), ('inactive', '停用')], default='active', max_length=20, verbose_name='状态')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')),
],
options={
'verbose_name': '软件套餐',
'verbose_name_plural': '软件套餐',
'db_table': 'software_plan',
'ordering': ('product_code', 'name', 'id'),
'indexes': [models.Index(fields=['product_code', 'status'], name='software_pl_product_3580ff_idx')],
'constraints': [models.CheckConstraint(condition=models.Q(('duration_days__gt', 0)), name='software_plan_duration_days_positive'), models.CheckConstraint(condition=models.Q(('price__gt', 0)), name='software_plan_price_positive'), models.CheckConstraint(condition=models.Q(('device_limit__gt', 0)), name='software_plan_device_limit_positive')],
},
),
migrations.AddField(
model_name='softwareentitlement',
name='source_plan',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='entitlements', to='licensing.softwareplan', verbose_name='来源套餐'),
),
migrations.AddIndex(
model_name='licenseseat',
index=models.Index(fields=['device', 'updated_at'], name='license_sea_device__85c03f_idx'),
),
migrations.AddConstraint(
model_name='licenseseat',
constraint=models.UniqueConstraint(fields=('entitlement', 'seat_number'), name='license_seat_entitlement_number_unique'),
),
migrations.AddConstraint(
model_name='licenseseat',
constraint=models.CheckConstraint(condition=models.Q(('seat_number__gt', 0)), name='license_seat_number_positive'),
),
migrations.AddIndex(
model_name='licenseevent',
index=models.Index(fields=['entitlement', 'created_at'], name='license_eve_entitle_a66e88_idx'),
),
migrations.AddIndex(
model_name='licenseevent',
index=models.Index(fields=['device', 'created_at'], name='license_eve_device__e70841_idx'),
),
migrations.AddIndex(
model_name='softwareentitlement',
index=models.Index(fields=['user', 'product_code', 'status'], name='software_en_user_id_cd6e26_idx'),
),
migrations.AddIndex(
model_name='softwareentitlement',
index=models.Index(fields=['product_code', 'expires_at'], name='software_en_product_c6ffbf_idx'),
),
migrations.AddConstraint(
model_name='softwareentitlement',
constraint=models.CheckConstraint(condition=models.Q(('plan_duration_days__gt', 0)), name='software_entitlement_duration_positive'),
),
migrations.AddConstraint(
model_name='softwareentitlement',
constraint=models.CheckConstraint(condition=models.Q(('plan_price__gt', 0)), name='software_entitlement_price_positive'),
),
migrations.AddConstraint(
model_name='softwareentitlement',
constraint=models.CheckConstraint(condition=models.Q(('plan_device_limit__gt', 0)), name='software_entitlement_device_limit_positive'),
),
]
+240
View File
@@ -5,7 +5,9 @@ import hmac
import secrets import secrets
from django.conf import settings from django.conf import settings
from django.core.exceptions import ValidationError
from django.db import models from django.db import models
from django.db.models import Q
from django.utils import timezone from django.utils import timezone
@@ -79,6 +81,244 @@ class ClientDevice(models.Model):
return hashlib.sha256(public_key.encode("utf-8")).hexdigest() 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): class DeviceSession(models.Model):
TOKEN_PREFIX_LENGTH = 12 TOKEN_PREFIX_LENGTH = 12
+212 -1
View File
@@ -7,7 +7,15 @@ from django.conf import settings
from django.db import IntegrityError, transaction from django.db import IntegrityError, transaction
from django.utils import timezone 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): class DeviceRegistrationError(Exception):
@@ -21,6 +29,13 @@ class DeviceSessionValidationError(DeviceRegistrationError):
pass pass
class LicensingError(Exception):
def __init__(self, code: str, message: str):
self.code = code
self.message = message
super().__init__(message)
@dataclass(frozen=True) @dataclass(frozen=True)
class DeviceRegistrationResult: class DeviceRegistrationResult:
device: ClientDevice device: ClientDevice
@@ -178,3 +193,199 @@ def resolve_optional_device_session(*, user, raw_token: str) -> DeviceSession |
if session.device.user_id != user.id: if session.device.user_id != user.id:
raise DeviceSessionValidationError("device_mismatch", "设备会话不属于当前账号") raise DeviceSessionValidationError("device_mismatch", "设备会话不属于当前账号")
return session return session
def _required_reason(reason: str) -> str:
normalized_reason = str(reason or "").strip()
if not normalized_reason:
raise LicensingError("reason_required", "必须填写操作原因")
return normalized_reason
def _create_license_event(
*,
entitlement: SoftwareEntitlement,
action: str,
reason: str,
actor=None,
seat: LicenseSeat | None = None,
device: ClientDevice | None = None,
metadata: dict | None = None,
) -> LicenseEvent:
return LicenseEvent.objects.create(
entitlement=entitlement,
seat=seat,
device=device,
actor=actor,
action=action,
reason=_required_reason(reason),
metadata=metadata or {},
)
@transaction.atomic
def grant_software_entitlement(*, user, plan: SoftwarePlan, reason: str, actor=None, starts_at=None):
reason = _required_reason(reason)
if plan.status != SoftwarePlan.Status.ACTIVE:
raise LicensingError("plan_inactive", "套餐已停用,不能授予权益")
now = timezone.now()
starts_at = starts_at or now
expires_at = starts_at + timedelta(days=plan.duration_days)
grace_expires_at = expires_at + timedelta(days=plan.grace_days)
entitlement = SoftwareEntitlement.objects.create(
user=user,
product_code=plan.product_code,
source_plan=plan,
plan_name=plan.name,
plan_duration_days=plan.duration_days,
plan_price=plan.price,
plan_device_limit=plan.device_limit,
plan_grace_days=plan.grace_days,
starts_at=starts_at,
expires_at=expires_at,
grace_expires_at=grace_expires_at,
)
LicenseSeat.objects.bulk_create(
[
LicenseSeat(entitlement=entitlement, seat_number=seat_number)
for seat_number in range(1, plan.device_limit + 1)
]
)
_create_license_event(
entitlement=entitlement,
action=LicenseEvent.Action.GRANTED,
reason=reason,
actor=actor,
metadata={
"source_plan_id": plan.id,
"expires_at": expires_at.isoformat(),
"device_limit": plan.device_limit,
},
)
return entitlement
@transaction.atomic
def renew_software_entitlement(*, entitlement: SoftwareEntitlement, reason: str, actor=None, now=None):
reason = _required_reason(reason)
now = now or timezone.now()
locked_entitlement = SoftwareEntitlement.objects.select_for_update().get(pk=entitlement.pk)
if locked_entitlement.status == SoftwareEntitlement.Status.REVOKED:
raise LicensingError("entitlement_revoked", "已撤销权益不能续期")
extension_start = max(now, locked_entitlement.expires_at)
locked_entitlement.expires_at = extension_start + timedelta(
days=locked_entitlement.plan_duration_days
)
locked_entitlement.grace_expires_at = locked_entitlement.expires_at + timedelta(
days=locked_entitlement.plan_grace_days
)
locked_entitlement.status = SoftwareEntitlement.Status.ACTIVE
locked_entitlement.revoked_at = None
locked_entitlement.save(
update_fields=(
"expires_at",
"grace_expires_at",
"status",
"revoked_at",
"updated_at",
)
)
_create_license_event(
entitlement=locked_entitlement,
action=LicenseEvent.Action.RENEWED,
reason=reason,
actor=actor,
metadata={
"extension_start": extension_start.isoformat(),
"expires_at": locked_entitlement.expires_at.isoformat(),
},
)
return locked_entitlement
@transaction.atomic
def revoke_software_entitlement(*, entitlement: SoftwareEntitlement, reason: str, actor=None, now=None):
reason = _required_reason(reason)
now = now or timezone.now()
locked_entitlement = SoftwareEntitlement.objects.select_for_update().get(pk=entitlement.pk)
if locked_entitlement.status == SoftwareEntitlement.Status.REVOKED:
return locked_entitlement
locked_entitlement.status = SoftwareEntitlement.Status.REVOKED
locked_entitlement.revoked_at = now
locked_entitlement.save(update_fields=("status", "revoked_at", "updated_at"))
_create_license_event(
entitlement=locked_entitlement,
action=LicenseEvent.Action.REVOKED,
reason=reason,
actor=actor,
)
return locked_entitlement
@transaction.atomic
def assign_license_seat(*, entitlement: SoftwareEntitlement, device: ClientDevice, reason: str, actor=None, now=None):
reason = _required_reason(reason)
now = now or timezone.now()
locked_entitlement = SoftwareEntitlement.objects.select_for_update().get(pk=entitlement.pk)
if not locked_entitlement.is_usable_at(now):
raise LicensingError("entitlement_unavailable", "软件权益当前不可用")
if device.user_id != locked_entitlement.user_id:
raise LicensingError("device_user_mismatch", "设备不属于权益用户")
if device.product_code != locked_entitlement.product_code:
raise LicensingError("device_product_mismatch", "设备产品与权益不匹配")
if device.status != ClientDevice.Status.ACTIVE:
raise LicensingError("device_revoked", "设备已被吊销")
seats = list(
LicenseSeat.objects.select_for_update()
.filter(entitlement=locked_entitlement)
.order_by("seat_number")
)
existing_seat = next((seat for seat in seats if seat.device_id == device.id), None)
if existing_seat is not None:
return existing_seat
available_seat = next((seat for seat in seats if seat.device_id is None), None)
if available_seat is None:
raise LicensingError("seat_limit_reached", "设备席位已用完")
available_seat.device = device
available_seat.bound_at = now
available_seat.released_at = None
available_seat.save(update_fields=("device", "bound_at", "released_at", "updated_at"))
_create_license_event(
entitlement=locked_entitlement,
seat=available_seat,
device=device,
action=LicenseEvent.Action.SEAT_ASSIGNED,
reason=reason,
actor=actor,
)
return available_seat
@transaction.atomic
def release_license_seat(*, seat: LicenseSeat, reason: str, actor=None, now=None):
reason = _required_reason(reason)
now = now or timezone.now()
locked_seat = LicenseSeat.objects.select_for_update().select_related("entitlement", "device").get(
pk=seat.pk
)
if locked_seat.device_id is None:
return locked_seat
previous_device = locked_seat.device
locked_seat.device = None
locked_seat.released_at = now
locked_seat.save(update_fields=("device", "released_at", "updated_at"))
_create_license_event(
entitlement=locked_seat.entitlement,
seat=locked_seat,
device=previous_device,
action=LicenseEvent.Action.SEAT_RELEASED,
reason=reason,
actor=actor,
)
return locked_seat
@@ -0,0 +1,14 @@
{% extends "admin/base_site.html" %}
{% block content %}
{% if entitlement %}
<p>{{ entitlement }}</p>
{% endif %}
<form method="post">
{% csrf_token %}
{{ form.as_p }}
<div class="submit-row">
<input type="submit" value="确认" class="default">
</div>
</form>
{% endblock %}
@@ -0,0 +1,9 @@
{% extends "admin/change_form.html" %}
{% block object-tools-items %}
{{ block.super }}
{% if original %}
<li><a href="renew/">续期</a></li>
<li><a href="revoke/">撤销</a></li>
{% endif %}
{% endblock %}
+289 -3
View File
@@ -1,12 +1,31 @@
from datetime import timedelta from 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.urls import reverse
from django.utils import timezone from django.utils import timezone
from rest_framework.test import APIClient from rest_framework.test import APIClient
from apps.licensing.models import ClientDevice, DeviceBindingAudit, DeviceSession from apps.licensing.models import (
from apps.licensing.services import record_device_heartbeat 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 from apps.users.models import ApiKey, User, UserWallet
@@ -160,3 +179,270 @@ class DeviceRegistrationApiTests(TestCase):
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
device.refresh_from_db() device.refresh_from_db()
self.assertGreater(device.last_seen_at, stale_time) 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})
+7 -1
View File
@@ -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` 配置当前模板。 - **用户端层(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/`。 - **用户与账号层**:注册用户 `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` 仅表示下载文件大小字节数。 - **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/`。 - **计费层**:点数计算、原子扣减(锁 `UserWallet` 行)、退点、充值入账、流水记账。入口 `apps/billing/`。
- **AI 调用层(Provider Adapter 架构)**:对外只暴露稳定能力,内部用「能力别名 → 具体供应商适配器」解耦。入口 `apps/ai/`,适配器在 `apps/ai/providers/`。 - **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-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-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 字结果摘要。 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、过期/最后使用/吊销时间;明文只在登记响应出现一次 | | DeviceSession | 设备登记服务 | 设备、会话 token hash、过期/最后使用/吊销时间;明文只在登记响应出现一次 |
| DeviceBindingAudit | 设备登记服务 | 用户、设备、来源 API Key、登记/会话/心跳等动作、原因、客户端版本、时间;只读审计 | | DeviceBindingAudit | 设备登记服务 | 用户、设备、来源 API Key、登记/会话/心跳等动作、原因、客户端版本、时间;只读审计 |
| CallRecord.client_device | 生成调用 | 可空关联到服务端验证后的 `ClientDevice`;仅有效设备会话的标题、图片、异步提交和 vision 写入,历史/无头调用保持为空 | | CallRecord.client_device | 生成调用 | 可空关联到服务端验证后的 `ClientDevice`;仅有效设备会话的标题、图片、异步提交和 vision 写入,历史/无头调用保持为空 |
| SoftwarePlan | 运营配置 | 产品、名称、有效天数、价格、设备数、宽限天数和启停状态;修改仅影响后续人工授予 / 购买 |
| SoftwareEntitlement | 授权服务 | 用户、产品、来源套餐(可空)及购买/授予时套餐快照、状态、开始/到期/宽限截止/撤销时间;不从套餐反向同步 |
| LicenseSeat | 授权服务 | 每项权益按序号预创建的固定席位,可空关联已绑定设备及绑定 / 解绑时间;`(entitlement, seat_number)` 唯一 |
| LicenseEvent | 授权服务 | 授予、续期、撤销、绑定、解绑等不可变事件;保存权益、可选席位/设备、操作人、必填原因和非敏感元数据 |
关键事实: 关键事实:
+1 -1
View File
@@ -104,7 +104,7 @@
| T-623 | 图片生成任务单图 / 多图筛选 | T-614, T-620 | 已完成:`ImageGenerationTaskAdmin` 的列表右侧新增“输入图片类型”筛选,提供“单图生图”和“多图生图”。数据库聚合计数识别 1 条子输入的单图、2 条及以上的多图;无子输入且历史 `input_image` 非空的任务兼容归单图;无输入图任务不归类。筛选可与既有状态 / 日期过滤叠加,结果不重复;未改模型、迁移、任务状态、计费、API、worker、媒体文件或详情页缩略图。已通过 6 条目标 admin 测试、`check`、迁移一致性和 `init.ps1`。 | DONE | | 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-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-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-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-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 | | T-629 | 软件套餐购买、续订订单与权益入账 | T-626, T-627, T-628, T-304, T-305 | 新增与 `RechargeOrder` **分离**的 `SoftwareOrder`,实现蝦皮圈套餐选择、创建待支付订单、支付回调/主动查单后的幂等权益发放与续期;不得把软件订阅金额兑换为点数,也不得改动充值订单或既有点数回调契约。订单须锁定套餐名称、价格、时长、设备数等快照,回调必须验签、校验订单金额/通道、按订单号幂等;同一订单重复回调最多延长一次。portal 提供订阅状态、套餐购买/续订入口和订单只读记录,支付失败/取消保持 pending/failed 语义并保留审计。复用现有支付网关的微信路径但不假设自动续费协议;第一版“月订阅”是用户每月主动续订,自动代扣仅登记后续任务。测试覆盖订单快照、重复回调、金额不匹配、并发回调、续期起算、退款/撤销后的权益状态、充值链路回归;真实生产支付验收依赖既有微信回调闭环修复,未提供商户条件时仅按 mock/SDK 契约测试。 | TODO |
+5 -5
View File
@@ -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-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-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-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 生产代码补充:`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 生产验收:真实提交两张图片成功返回中文文字,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` 警告。 - 最新验证: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)。 任务状态以 [`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-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 并完成工程侧处理。 - 当前 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`。 1. 读仓库级 `AGENTS.md` / `CLAUDE.md`。
2. 读 `docs/00-ai-start-here.md`。 2. 读 `docs/00-ai-start-here.md`。
3. 读 `docs/05-coding-rules.md`(尤其第 8 节资金安全)。 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 前须保留并验证真实支付回调到账闭环。 5. T-624~T-629 按设备观测 → 套餐权益 → 存量迁移 → 专属授权影子校验 → 订阅订单顺序执行;T-629 前须保留并验证真实支付回调到账闭环。
## 维护规则 ## 维护规则
+9
View File
@@ -70,6 +70,9 @@ T-624/T-625 已新增设备登记 / 心跳及调用关联:登记接口继续
| 导入模板 | ImportTemplate | 上传导入模板或填写外部下载地址,标记当前模板;展示模板名称、SHA256 与说明 | | 导入模板 | ImportTemplate | 上传导入模板或填写外部下载地址,标记当前模板;展示模板名称、SHA256 与说明 |
| 客户端设备 | ClientDevice | 只读检索蝦皮圈设备平台、版本、状态和最近活跃时间;设备摘要仅脱敏显示 | | 客户端设备 | ClientDevice | 只读检索蝦皮圈设备平台、版本、状态和最近活跃时间;设备摘要仅脱敏显示 |
| 设备会话 / 审计 | DeviceSession / DeviceBindingAudit | 只读查看会话有效期、吊销状态及登记/心跳审计;不展示会话令牌或安装公钥 | | 设备会话 / 审计 | DeviceSession / DeviceBindingAudit | 只读查看会话有效期、吊销状态及登记/心跳审计;不展示会话令牌或安装公钥 |
| 软件套餐 | SoftwarePlan | 维护蝦皮圈套餐的时长、价格、设备数、宽限期与启停状态;修改不回写已有权益快照 |
| 软件权益 | SoftwareEntitlement | 只读查看用户权益和套餐快照;通过专用后台页面人工授予、续期或撤销,三种操作均必须填写原因并写授权事件 |
| 授权席位 / 授权事件 | LicenseSeat / LicenseEvent | 只读检索设备绑定、解绑时间和全部授权审计;不支持后台直接编辑席位或事件 |
## 后台职责约定 ## 后台职责约定
@@ -88,6 +91,12 @@ T-624/T-625 已新增设备登记 / 心跳及调用关联:登记接口继续
### 流水 / 调用记录 ### 流水 / 调用记录
- 只读列表,支持按账号、API Key、设备、时间范围、类型/状态筛选与搜索;设备关联为空表示存量无头调用,不代表调用失败。 - 只读列表,支持按账号、API Key、设备、时间范围、类型/状态筛选与搜索;设备关联为空表示存量无头调用,不代表调用失败。
### 软件授权
- T-626 的套餐、权益和席位只提供运营基础数据与人工操作,不新增公开接口、不接支付订单,也不改变通用生成 API 的授权结果。
- 授予 / 续期 / 撤销一律调用 `apps.licensing.services`;原因必填并产生 `LicenseEvent`。不得在 admin 表单中直接修改权益状态、到期时间、席位绑定或历史事件。
- 已授予权益读取自身套餐快照,不随 `SoftwarePlan` 后续价格、时长或设备数变化而变化;席位数量由授予时快照固定。
- 不允许在后台修改或删除流水与调用记录(账目不可篡改)。 - 不允许在后台修改或删除流水与调用记录(账目不可篡改)。
### 列表与检索 ### 列表与检索
+8
View File
@@ -2066,3 +2066,11 @@
- 后台与文档:`CallRecordAdmin` 增加只读设备展示与筛选;同步 `api.md`、`routes.md`、`04-architecture.md`、`current-state.md` 和任务看板,明确本期只做关联观测,不实施订阅授权拦截。 - 后台与文档:`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` 警告。 - 验证:本地已应用 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,建立套餐、权益、席位和审计的基础模型与后台,不接入生成授权或购买流程。 - 下一步:领取 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 兼容。