feat: add admin client policy control

This commit is contained in:
QiuSW
2026-07-28 15:42:23 +08:00
parent 9544aaf4cc
commit 2171968780
17 changed files with 558 additions and 21 deletions
+24
View File
@@ -65,6 +65,7 @@ from apps.moderation.models import SensitiveWord
from apps.moderation.providers.keyword import reset_keyword_matcher_cache
from apps.portal.models import DownloadRelease
from apps.licensing.models import (
ClientSubscriptionPolicy,
ClientDevice,
SoftwareEntitlement,
SoftwareOrder,
@@ -690,6 +691,29 @@ class ClientLatestReleaseApiTests(TestCase):
},
)
@override_settings(
CMSHOPEE_CLIENT_SUBSCRIPTION_POLICY="off",
CMSHOPEE_CLIENT_SUBSCRIPTION_POLICY_UPDATED_AT="2026-07-28T00:00:00+08:00",
)
def test_latest_release_prefers_admin_policy_without_restart(self):
policy = ClientSubscriptionPolicy.objects.create(
mode=ClientSubscriptionPolicy.Mode.OBSERVE,
)
response = self.client.get(self.url)
self.assertEqual(response.status_code, 200)
self.assertEqual(response["Cache-Control"], "no-store")
self.assertEqual(
response.data["client_policy"],
{
"policy_version": 1,
"subscription_check_enabled": True,
"subscription_enforcement_enabled": False,
"updated_at": policy.updated_at.isoformat(),
},
)
@override_settings(
CMSHOPEE_CLIENT_SUBSCRIPTION_POLICY="invalid",
CMSHOPEE_CLIENT_SUBSCRIPTION_POLICY_UPDATED_AT="2026-07-28T10:00:00+08:00",
+2 -3
View File
@@ -1,6 +1,4 @@
import logging
from config.client_subscription_policy import get_client_subscription_policy
from django.http import HttpResponse
from django.utils import timezone
from django.utils.decorators import method_decorator
@@ -81,6 +79,7 @@ from apps.licensing.services import (
query_and_apply_software_payment,
evaluate_subscription_access,
software_subscription_status,
get_published_client_subscription_policy,
record_device_heartbeat,
register_device,
resolve_optional_device_session,
@@ -675,7 +674,7 @@ class ClientLatestReleaseView(APIView):
permission_classes = (AllowAny,)
def get(self, request):
client_policy = get_client_subscription_policy()
client_policy = get_published_client_subscription_policy()
platform = (
request.query_params.get("platform")
or DownloadRelease.Platform.WINDOWS
+93
View File
@@ -7,6 +7,8 @@ from django.template.response import TemplateResponse
from django.urls import path, reverse
from .models import (
ClientSubscriptionPolicy,
ClientSubscriptionPolicyAudit,
ClientDevice,
DeviceCredential,
DeviceBindingAudit,
@@ -23,8 +25,10 @@ from .services import (
LicensingError,
grant_software_entitlement,
create_legacy_migration_grant,
get_subscription_mode,
renew_software_entitlement,
revoke_software_entitlement,
set_client_subscription_policy,
)
@@ -35,6 +39,95 @@ class HiddenFromAdminIndexMixin:
return {}
class ClientSubscriptionPolicyAdminForm(forms.ModelForm):
reason = forms.CharField(label="变更原因", widget=forms.Textarea(attrs={"rows": 3}))
class Meta:
model = ClientSubscriptionPolicy
fields = ("mode",)
def clean_mode(self):
mode = self.cleaned_data["mode"]
if not self.instance._state.adding and self.instance.mode == mode:
raise forms.ValidationError("请选择不同的客户端订阅策略。")
return mode
def clean_reason(self):
reason = self.cleaned_data["reason"].strip()
if not reason:
raise forms.ValidationError("必须填写操作原因。")
return reason
class ClientSubscriptionPolicyAuditInline(admin.TabularInline):
model = ClientSubscriptionPolicyAudit
extra = 0
can_delete = False
max_num = 0
ordering = ("-changed_at", "-id")
fields = ("previous_mode", "mode", "reason", "changed_by", "changed_at")
readonly_fields = fields
verbose_name = "策略变更审计"
verbose_name_plural = "策略变更审计"
def has_add_permission(self, request, obj=None):
return False
def has_change_permission(self, request, obj=None):
return False
@admin.register(ClientSubscriptionPolicy)
class ClientSubscriptionPolicyAdmin(admin.ModelAdmin):
form = ClientSubscriptionPolicyAdminForm
inlines = (ClientSubscriptionPolicyAuditInline,)
fields = (
"mode",
"reason",
"server_subscription_mode",
"updated_by",
"created_at",
"updated_at",
)
readonly_fields = (
"server_subscription_mode",
"updated_by",
"created_at",
"updated_at",
)
def has_module_permission(self, request):
return request.user.is_superuser
def has_view_permission(self, request, obj=None):
return request.user.is_superuser
def has_add_permission(self, request):
return request.user.is_superuser and not ClientSubscriptionPolicy.objects.exists()
def has_change_permission(self, request, obj=None):
return request.user.is_superuser
def has_delete_permission(self, request, obj=None):
return False
@admin.display(description="服务端订阅授权模式")
def server_subscription_mode(self, obj):
return get_subscription_mode()
def save_model(self, request, obj, form, change):
policy = set_client_subscription_policy(
mode=form.cleaned_data["mode"],
reason=form.cleaned_data["reason"],
actor=request.user,
)
obj.singleton_id = policy.singleton_id
obj.mode = policy.mode
obj.updated_by = policy.updated_by
obj.created_at = policy.created_at
obj.updated_at = policy.updated_at
def _masked_fingerprint(value: str) -> str:
if not value:
return ""
@@ -0,0 +1,57 @@
# Generated by Django 5.2.15 on 2026-07-28 07:30
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('licensing', '0005_alter_softwareentitlement_options_and_more'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='ClientSubscriptionPolicy',
fields=[
('singleton_id', models.PositiveSmallIntegerField(default=1, editable=False, primary_key=True, serialize=False, verbose_name='固定配置标识')),
('mode', models.CharField(choices=[('off', '关闭'), ('observe', '仅检测'), ('enforce', '检测并门禁')], max_length=16, verbose_name='客户端策略')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')),
('updated_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='client_subscription_policy_updates', to=settings.AUTH_USER_MODEL, verbose_name='最后操作人')),
],
options={
'verbose_name': '客户端订阅策略',
'verbose_name_plural': '客户端订阅策略',
'db_table': 'client_subscription_policy',
},
),
migrations.CreateModel(
name='ClientSubscriptionPolicyAudit',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('previous_mode', models.CharField(blank=True, max_length=16, verbose_name='变更前策略')),
('mode', models.CharField(choices=[('off', '关闭'), ('observe', '仅检测'), ('enforce', '检测并门禁')], max_length=16, verbose_name='变更后策略')),
('reason', models.CharField(max_length=255, verbose_name='变更原因')),
('changed_at', models.DateTimeField(auto_now_add=True, verbose_name='变更时间')),
('changed_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='client_subscription_policy_audits', to=settings.AUTH_USER_MODEL, verbose_name='操作人')),
('policy', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='audits', to='licensing.clientsubscriptionpolicy', verbose_name='客户端订阅策略')),
],
options={
'verbose_name': '客户端订阅策略审计',
'verbose_name_plural': '客户端订阅策略审计',
'db_table': 'client_subscription_policy_audit',
'ordering': ('-changed_at', '-id'),
},
),
migrations.AddConstraint(
model_name='clientsubscriptionpolicy',
constraint=models.CheckConstraint(condition=models.Q(('singleton_id', 1)), name='client_subscription_policy_singleton_only'),
),
migrations.AddIndex(
model_name='clientsubscriptionpolicyaudit',
index=models.Index(fields=['policy', 'changed_at'], name='client_subs_policy__05d186_idx'),
),
]
+78
View File
@@ -12,6 +12,84 @@ from django.db.models import Q
from django.utils import timezone
class ClientSubscriptionPolicy(models.Model):
"""The one global policy published to supported cmshopee clients."""
class Mode(models.TextChoices):
OFF = "off", "关闭"
OBSERVE = "observe", "仅检测"
ENFORCE = "enforce", "检测并门禁"
singleton_id = models.PositiveSmallIntegerField(
"固定配置标识",
primary_key=True,
default=1,
editable=False,
)
mode = models.CharField("客户端策略", max_length=16, choices=Mode.choices)
updated_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
verbose_name="最后操作人",
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="client_subscription_policy_updates",
)
created_at = models.DateTimeField("创建时间", auto_now_add=True)
updated_at = models.DateTimeField("更新时间", auto_now=True)
class Meta:
db_table = "client_subscription_policy"
verbose_name = "客户端订阅策略"
verbose_name_plural = "客户端订阅策略"
constraints = [
models.CheckConstraint(
condition=Q(singleton_id=1),
name="client_subscription_policy_singleton_only",
),
]
def __str__(self) -> str:
return f"客户端订阅策略:{self.get_mode_display()}"
def save(self, *args, **kwargs):
self.singleton_id = 1
super().save(*args, **kwargs)
class ClientSubscriptionPolicyAudit(models.Model):
policy = models.ForeignKey(
ClientSubscriptionPolicy,
verbose_name="客户端订阅策略",
on_delete=models.PROTECT,
related_name="audits",
)
previous_mode = models.CharField("变更前策略", max_length=16, blank=True)
mode = models.CharField("变更后策略", max_length=16, choices=ClientSubscriptionPolicy.Mode.choices)
reason = models.CharField("变更原因", max_length=255)
changed_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
verbose_name="操作人",
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="client_subscription_policy_audits",
)
changed_at = models.DateTimeField("变更时间", auto_now_add=True)
class Meta:
db_table = "client_subscription_policy_audit"
verbose_name = "客户端订阅策略审计"
verbose_name_plural = "客户端订阅策略审计"
ordering = ("-changed_at", "-id")
indexes = [
models.Index(fields=("policy", "changed_at")),
]
def __str__(self) -> str:
return f"{self.policy} {self.previous_mode or '未配置'} -> {self.mode}"
class ClientDevice(models.Model):
class ProductCode(models.TextChoices):
CMSHOPEE = "cmshopee", "虾皮圈优化助手"
+57
View File
@@ -11,7 +11,13 @@ from django.contrib.auth import get_user_model
from django.db import IntegrityError, transaction
from django.utils import timezone
from config.client_subscription_policy import (
build_client_subscription_policy,
get_client_subscription_policy,
)
from apps.licensing.models import (
ClientSubscriptionPolicy,
ClientSubscriptionPolicyAudit,
ClientDevice,
DeviceCredential,
DeviceBindingAudit,
@@ -289,6 +295,57 @@ def _required_reason(reason: str) -> str:
return normalized_reason
def get_published_client_subscription_policy() -> dict:
"""Return the admin override when present, otherwise the T-635 env fallback."""
policy = ClientSubscriptionPolicy.objects.filter(pk=1).only("mode", "updated_at").first()
if policy is None:
return get_client_subscription_policy()
return build_client_subscription_policy(mode=policy.mode, updated_at=policy.updated_at)
@transaction.atomic
def set_client_subscription_policy(*, mode: str, reason: str, actor) -> ClientSubscriptionPolicy:
"""Persist one audited policy transition without allowing a second singleton."""
normalized_mode = str(mode or "").strip().lower()
if normalized_mode not in ClientSubscriptionPolicy.Mode.values:
raise LicensingError("bad_request", "客户端订阅策略不合法")
normalized_reason = _required_reason(reason)
policy = ClientSubscriptionPolicy.objects.select_for_update().filter(pk=1).first()
created = policy is None
if policy is None:
try:
with transaction.atomic():
policy = ClientSubscriptionPolicy.objects.create(
singleton_id=1,
mode=normalized_mode,
updated_by=actor,
)
except IntegrityError:
policy = ClientSubscriptionPolicy.objects.select_for_update().get(pk=1)
created = False
if not created and policy.mode == normalized_mode:
raise LicensingError("policy_unchanged", "请选择不同的客户端订阅策略")
previous_mode = "" if created else policy.mode
if not created:
policy.mode = normalized_mode
policy.updated_by = actor
policy.save(update_fields=("mode", "updated_by", "updated_at"))
ClientSubscriptionPolicyAudit.objects.create(
policy=policy,
previous_mode=previous_mode,
mode=normalized_mode,
reason=normalized_reason,
changed_by=actor,
)
return policy
def _create_license_event(
*,
entitlement: SoftwareEntitlement,
+189 -1
View File
@@ -8,7 +8,7 @@ from unittest.mock import patch
from django.contrib import admin
from django.core.management import call_command
from django.core.management.base import CommandError
from django.db import close_old_connections
from django.db import IntegrityError, close_old_connections
from django.test import SimpleTestCase, TestCase, TransactionTestCase, override_settings
from django.urls import reverse
from django.utils import timezone
@@ -17,6 +17,8 @@ from rest_framework.test import APIClient
from apps.billing.models import PointsLedger
from apps.billing.payment_gateways import PaymentOrderCode, PaymentReceipt
from apps.licensing.models import (
ClientSubscriptionPolicy,
ClientSubscriptionPolicyAudit,
ClientDevice,
DeviceBindingAudit,
DeviceCredential,
@@ -44,6 +46,7 @@ from apps.licensing.services import (
evaluate_device_authorization,
evaluate_subscription_access,
get_subscription_mode,
get_published_client_subscription_policy,
grant_plan_to_existing_users,
grant_software_entitlement,
record_device_heartbeat,
@@ -52,6 +55,7 @@ from apps.licensing.services import (
revoke_software_entitlement,
register_device,
software_subscription_status,
set_client_subscription_policy,
)
from apps.users.models import ApiKey, User, UserWallet
@@ -495,6 +499,114 @@ class DeviceRegistrationApiTests(TestCase):
self.assertGreater(device.last_seen_at, stale_time)
class ClientSubscriptionPolicyServiceTests(TestCase):
def setUp(self):
self.operator = User.objects.create_user(
username="client-policy-operator",
email="client-policy-operator@example.com",
password="test-password",
is_staff=True,
is_superuser=True,
)
@override_settings(
CMSHOPEE_CLIENT_SUBSCRIPTION_POLICY="enforce",
CMSHOPEE_CLIENT_SUBSCRIPTION_POLICY_UPDATED_AT="2026-07-28T10:00:00+08:00",
)
def test_missing_admin_policy_falls_back_to_environment_configuration(self):
self.assertEqual(
get_published_client_subscription_policy(),
{
"policy_version": 1,
"subscription_check_enabled": True,
"subscription_enforcement_enabled": True,
"updated_at": "2026-07-28T10:00:00+08:00",
},
)
@override_settings(
CMSHOPEE_CLIENT_SUBSCRIPTION_POLICY="off",
CMSHOPEE_CLIENT_SUBSCRIPTION_POLICY_UPDATED_AT="2026-07-28T00:00:00+08:00",
)
def test_policy_transition_writes_audit_and_immediately_overrides_environment(self):
policy = set_client_subscription_policy(
mode=ClientSubscriptionPolicy.Mode.OBSERVE,
reason="新版客户端灰度检测",
actor=self.operator,
)
self.assertEqual(policy.pk, 1)
self.assertEqual(policy.mode, ClientSubscriptionPolicy.Mode.OBSERVE)
self.assertEqual(
get_published_client_subscription_policy(),
{
"policy_version": 1,
"subscription_check_enabled": True,
"subscription_enforcement_enabled": False,
"updated_at": policy.updated_at.isoformat(),
},
)
first_audit = ClientSubscriptionPolicyAudit.objects.get(policy=policy)
self.assertEqual(first_audit.previous_mode, "")
self.assertEqual(first_audit.mode, ClientSubscriptionPolicy.Mode.OBSERVE)
self.assertEqual(first_audit.reason, "新版客户端灰度检测")
self.assertEqual(first_audit.changed_by, self.operator)
updated = set_client_subscription_policy(
mode=ClientSubscriptionPolicy.Mode.ENFORCE,
reason="灰度验收完成",
actor=self.operator,
)
self.assertEqual(updated.pk, policy.pk)
self.assertEqual(updated.mode, ClientSubscriptionPolicy.Mode.ENFORCE)
self.assertEqual(ClientSubscriptionPolicy.objects.count(), 1)
self.assertEqual(
ClientSubscriptionPolicyAudit.objects.filter(policy=policy).count(),
2,
)
latest_audit = ClientSubscriptionPolicyAudit.objects.latest("id")
self.assertEqual(latest_audit.previous_mode, ClientSubscriptionPolicy.Mode.OBSERVE)
self.assertEqual(latest_audit.mode, ClientSubscriptionPolicy.Mode.ENFORCE)
def test_policy_transition_requires_reason_and_rejects_same_mode(self):
with self.assertRaises(LicensingError) as missing_reason:
set_client_subscription_policy(
mode=ClientSubscriptionPolicy.Mode.OBSERVE,
reason="",
actor=self.operator,
)
self.assertEqual(missing_reason.exception.code, "reason_required")
self.assertFalse(ClientSubscriptionPolicy.objects.exists())
set_client_subscription_policy(
mode=ClientSubscriptionPolicy.Mode.OFF,
reason="创建默认策略",
actor=self.operator,
)
with self.assertRaises(LicensingError) as unchanged:
set_client_subscription_policy(
mode=ClientSubscriptionPolicy.Mode.OFF,
reason="不应产生伪审计",
actor=self.operator,
)
self.assertEqual(unchanged.exception.code, "policy_unchanged")
self.assertEqual(ClientSubscriptionPolicyAudit.objects.count(), 1)
def test_database_singleton_rejects_a_second_primary_key(self):
ClientSubscriptionPolicy.objects.create(mode=ClientSubscriptionPolicy.Mode.OFF)
with self.assertRaises(IntegrityError):
ClientSubscriptionPolicy.objects.bulk_create(
[
ClientSubscriptionPolicy(
singleton_id=2,
mode=ClientSubscriptionPolicy.Mode.OBSERVE,
)
]
)
class SoftwareEntitlementServiceTests(TestCase):
def setUp(self):
self.user = User.objects.create_user(
@@ -973,6 +1085,7 @@ class SoftwareEntitlementAdminTests(TestCase):
visible_models = (
SoftwarePlan,
SoftwareEntitlement,
ClientSubscriptionPolicy,
)
request = SimpleNamespace(user=self.operator)
@@ -991,6 +1104,81 @@ class SoftwareEntitlementAdminTests(TestCase):
)
self.assertEqual(SoftwarePlan._meta.verbose_name, "会员套餐")
self.assertEqual(SoftwareEntitlement._meta.verbose_name, "用户会员")
self.assertNotIn(ClientSubscriptionPolicyAudit, admin.site._registry)
def test_client_subscription_policy_admin_requires_superuser_and_writes_audit(self):
staff = User.objects.create_user(
username="licensing-policy-staff",
email="licensing-policy-staff@example.com",
password="test-password",
is_staff=True,
)
add_url = reverse("admin:licensing_clientsubscriptionpolicy_add")
self.client.force_login(staff)
self.assertEqual(self.client.get(add_url).status_code, 403)
self.client.force_login(self.operator)
audit_management = {
"audits-TOTAL_FORMS": "0",
"audits-INITIAL_FORMS": "0",
"audits-MIN_NUM_FORMS": "0",
"audits-MAX_NUM_FORMS": "0",
}
missing_reason = self.client.post(
add_url,
{
"mode": ClientSubscriptionPolicy.Mode.OBSERVE,
"reason": "",
**audit_management,
},
)
self.assertEqual(missing_reason.status_code, 200)
self.assertFalse(ClientSubscriptionPolicy.objects.exists())
created = self.client.post(
add_url,
{
"mode": ClientSubscriptionPolicy.Mode.OBSERVE,
"reason": "客户端灰度",
**audit_management,
},
)
self.assertEqual(created.status_code, 302)
policy = ClientSubscriptionPolicy.objects.get(pk=1)
self.assertEqual(policy.mode, ClientSubscriptionPolicy.Mode.OBSERVE)
self.assertTrue(
ClientSubscriptionPolicyAudit.objects.filter(
policy=policy,
reason="客户端灰度",
changed_by=self.operator,
).exists()
)
self.client.force_login(staff)
change_url = reverse(
"admin:licensing_clientsubscriptionpolicy_change",
args=(policy.pk,),
)
self.assertEqual(self.client.get(change_url).status_code, 403)
self.client.force_login(self.operator)
same_mode = self.client.post(
change_url,
{
"mode": ClientSubscriptionPolicy.Mode.OBSERVE,
"reason": "不应写入",
"audits-TOTAL_FORMS": "1",
"audits-INITIAL_FORMS": "1",
"audits-MIN_NUM_FORMS": "0",
"audits-MAX_NUM_FORMS": "0",
"audits-0-id": ClientSubscriptionPolicyAudit.objects.get(policy=policy).pk,
"audits-0-policy": policy.pk,
},
)
self.assertEqual(same_mode.status_code, 200)
self.assertEqual(ClientSubscriptionPolicyAudit.objects.count(), 1)
self.assertEqual(self.client.get(add_url).status_code, 403)
class LicenseSeatConcurrencyTests(TransactionTestCase):