From 21719687809dbfe68a021e862d8608f1b82473e4 Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Tue, 28 Jul 2026 15:42:23 +0800 Subject: [PATCH] feat: add admin client policy control --- .env.example | 5 +- apps/api/tests.py | 24 +++ apps/api/views.py | 5 +- apps/licensing/admin.py | 93 +++++++++ .../0006_client_subscription_policy.py | 57 ++++++ apps/licensing/models.py | 78 +++++++ apps/licensing/services.py | 57 ++++++ apps/licensing/tests.py | 190 +++++++++++++++++- docs/04-architecture.md | 8 +- docs/06-tasks.md | 2 +- docs/api.md | 2 +- docs/current-state.md | 8 +- docs/deployment.md | 2 +- docs/env.md | 6 +- docs/routes.md | 4 +- docs/tasks/T-636.md | 16 +- progress.md | 22 ++ 17 files changed, 558 insertions(+), 21 deletions(-) create mode 100644 apps/licensing/migrations/0006_client_subscription_policy.py diff --git a/.env.example b/.env.example index 8ac5338..3a21fe9 100644 --- a/.env.example +++ b/.env.example @@ -42,8 +42,9 @@ CMSHOPEE_AUTHORIZATION_SHADOW_MODE=true CMSHOPEE_SUBSCRIPTION_MODE=open # Legacy fallback only when CMSHOPEE_SUBSCRIPTION_MODE is unset: # CMSHOPEE_SUBSCRIPTION_ENFORCEMENT=false -# Client-only startup policy: off -> false/false, observe -> true/false, -# enforce -> true/true. Update the timestamp whenever this policy changes. +# Fallback only while django-admin has no client policy record. The admin singleton +# takes priority and publishes changes immediately without a service restart. +# off -> false/false, observe -> true/false, enforce -> true/true. CMSHOPEE_CLIENT_SUBSCRIPTION_POLICY=off CMSHOPEE_CLIENT_SUBSCRIPTION_POLICY_UPDATED_AT=2026-07-28T00:00:00+08:00 AI_IMAGE_UPSTREAM_DEADLINE_SECONDS=180 diff --git a/apps/api/tests.py b/apps/api/tests.py index f45639f..ef4110f 100644 --- a/apps/api/tests.py +++ b/apps/api/tests.py @@ -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", diff --git a/apps/api/views.py b/apps/api/views.py index 11f1354..7f43011 100644 --- a/apps/api/views.py +++ b/apps/api/views.py @@ -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 diff --git a/apps/licensing/admin.py b/apps/licensing/admin.py index fe7b585..1730b12 100644 --- a/apps/licensing/admin.py +++ b/apps/licensing/admin.py @@ -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 "" diff --git a/apps/licensing/migrations/0006_client_subscription_policy.py b/apps/licensing/migrations/0006_client_subscription_policy.py new file mode 100644 index 0000000..b1ad104 --- /dev/null +++ b/apps/licensing/migrations/0006_client_subscription_policy.py @@ -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'), + ), + ] diff --git a/apps/licensing/models.py b/apps/licensing/models.py index 81f9a2d..ebcb8a4 100644 --- a/apps/licensing/models.py +++ b/apps/licensing/models.py @@ -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", "虾皮圈优化助手" diff --git a/apps/licensing/services.py b/apps/licensing/services.py index 443c555..0035901 100644 --- a/apps/licensing/services.py +++ b/apps/licensing/services.py @@ -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, diff --git a/apps/licensing/tests.py b/apps/licensing/tests.py index 02c3c9b..f539822 100644 --- a/apps/licensing/tests.py +++ b/apps/licensing/tests.py @@ -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): diff --git a/docs/04-architecture.md b/docs/04-architecture.md index 8dfed0b..c4ee929 100644 --- a/docs/04-architecture.md +++ b/docs/04-architecture.md @@ -20,7 +20,7 @@ - **用户端层(Django 模板 SSR)**:公开首页、注册/登录(Django auth / allauth)、个人中心(余额/充值总额/充值记录/点数记录)、API Key 自助管理、发起扫码充值、模型目录与客户端下载入口。入口 `apps/portal/`,公开首页匿名可访问,其他自助页面用 session 鉴权。T-501/T-608 已落地 `/signup`、`/login`、`/logout` 与 `/dashboard`;注册成功后经计费层一次性发放 10 点试用点数,并写 `signup_bonus` 点数流水。T-502 已落地 `/apikeys`,用户可自助生成和删除(吊销)自己的 API Key,明文只显示一次,列表只显示 prefix。T-503/T-608 已扩展 `/dashboard` 并新增 `/records/recharge`、`/records/usage`,只读展示当前用户余额、充值订单、注册赠点与消费/退款流水。T-504 已落地 `/recharge`,用户可创建 pending 充值订单、查看二维码票据,并轮询订单状态;到账仍以服务端回调或主动查单入账后的本地订单状态为准。T-505 已把 Bootstrap/qrcode.js 改成本地 static 自托管,并把充值/点数记录页从固定切片改为分页。T-606 已把 `/` 改为公开首页,并新增 `DownloadRelease` 下载版本配置用于展示 Windows 客户端版本、下载地址、SHA256 与发布说明;T-607/T-609/T-617 已新增公开 JSON 版本检查接口给桌面端自动更新使用,并返回强制更新标记和安装包文件大小字节数。T-610 已在公开首页下载区增加导入模板下载入口,由后台 `ImportTemplate` 配置当前模板。 - **用户与账号层**:注册用户 `User`、点数钱包 `UserWallet`、`ApiKey`(一用户多把、哈希存储)。入口 `apps/users/`。 -- **软件授权层**:蝦皮圈客户端设备、短期设备会话、套餐、权益、设备席位和授权审计。入口 `apps/licensing/`;T-624/T-625 建立设备观测及调用关联,T-626 建立套餐/权益/席位基础,T-630/T-632 已将产品专属授权切换为账号订阅三阶段模式;通用 API Key 或点数授权不变。 +- **软件授权层**:蝦皮圈客户端设备、短期设备会话、套餐、权益、设备席位、授权审计与客户端启动策略。入口 `apps/licensing/`;T-624/T-625 建立设备观测及调用关联,T-626 建立套餐/权益/席位基础,T-630/T-632 已将产品专属授权切换为账号订阅三阶段模式,T-636 增加全局客户端策略单例与审计;通用 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/`。 @@ -48,7 +48,7 @@ T-629 已完成:`SoftwareOrder` 属于 `apps.licensing`,与 `RechargeOrder` T-630/T-632 已完成:蝦皮圈产品授权从设备凭证改为账号订阅。`evaluate_account_authorization(user, product_code)` 只计算数据库真实 `SoftwareEntitlement` 状态,不读取 `DeviceCredential`、`LicenseSeat` 或 `DeviceSession`;`evaluate_subscription_access()` 再按 `CMSHOPEE_SUBSCRIPTION_MODE=open|shadow|enforce` 计算最终访问结果。`open` 和 `shadow` 不写虚假权益,前者提供开发测试访问,后者保留访问并记录真实权益覆盖;`enforce` 才将无订阅/过期结果映射为 403。同一用户可从多台设备调用,过期设备会话不会阻断账号订阅;通用生成入口继续保持原 API Key + 点数语义。 -T-635 在公开版本检查接口顶层增加无账号上下文的 `client_policy`,由 `CMSHOPEE_CLIENT_SUBSCRIPTION_POLICY=off|observe|enforce` 和其带时区更新时间生成,成功及错误响应均 `Cache-Control: no-store`。这只控制新版客户端是否检查 / 展示会员门禁,不覆盖 `CMSHOPEE_SUBSCRIPTION_MODE` 的服务端授权;订阅状态接口新增 `real_entitlement_allowed`,仅真实 `active` / `grace` 权益为真,客户端强制门禁必须使用它而非 `open` / `shadow` 的兼容 `allowed`。 +T-635/T-636 在公开版本检查接口顶层增加无账号上下文的 `client_policy`,成功及错误响应均 `Cache-Control: no-store`。`get_published_client_subscription_policy()` 每次请求优先读取 `ClientSubscriptionPolicy(singleton_id=1)` 的 `mode + updated_at`,未创建该记录时才回退 `CMSHOPEE_CLIENT_SUBSCRIPTION_POLICY=off|observe|enforce` 及其带时区更新时间;因此后台保存后无进程缓存、无需重启。数据库单例有固定主键和 CHECK 约束,服务层事务内同时写 `ClientSubscriptionPolicyAudit`,admin 仅超级管理员可操作、审计只读。该策略只控制新版客户端是否检查 / 展示会员门禁,不覆盖 `CMSHOPEE_SUBSCRIPTION_MODE` 的服务端授权;订阅状态接口新增 `real_entitlement_allowed`,仅真实 `active` / `grace` 权益为真,客户端强制门禁必须使用它而非 `open` / `shadow` 的兼容 `allowed`。 T-632 同时简化软件授权 admin:应用索引只展示“会员套餐” (`SoftwarePlan`) 与“用户会员” (`SoftwareEntitlement`)。软件订单、授权事件、设备观测和历史设备授权模型仍注册并保留直接管理地址、权限及历史数据,只是不出现在日常导航;该显示策略不改变数据库结构和授权判定。 @@ -125,6 +125,8 @@ T-619 多图理解继续复用上述 URL 下载器和逐跳 SSRF 校验,并额 | SensitiveWord | 运营配置 | 本地敏感词快筛词库;word、normalized_word、category、action、is_active;T-604 MVP 仅支持 action=block | | DownloadRelease | 运营配置 | 客户端下载版本;platform、version、file、external_url、size_bytes、sha256、is_current、force_update、release_notes、created_at、updated_at;每平台当前版本由应用层事务保存时互斥 | | ImportTemplate | 运营配置 | 导入商品数据模板;name、file、external_url、sha256、is_current、notes、created_at、updated_at;当前模板由应用层事务保存时互斥 | +| ClientSubscriptionPolicy | 全局运营配置 | T-636 客户端启动策略单例,固定 `singleton_id=1`,`mode=off|observe|enforce`、最后操作人和时间;仅在存在记录时覆盖 `.env` 兜底 | +| ClientSubscriptionPolicyAudit | 配置审计 | 每次有效策略变更的前后模式、必填原因、操作人和时间;仅在策略详情页只读展示 | | ClientDevice | 客户端登记 | 用户、产品、版本化设备摘要、安装公钥摘要、平台、客户端版本、状态、首次/最后活跃时间;不保存原始机器标识或私钥;`(user, product_code, device_fingerprint)` 唯一 | | DeviceSession | 设备登记服务 | 设备、会话 token hash、过期/最后使用/吊销时间;明文只在登记响应出现一次 | | DeviceBindingAudit | 设备登记服务 | 用户、设备、来源 API Key、登记/会话/心跳等动作、原因、客户端版本、时间;只读审计 | @@ -255,7 +257,7 @@ CREATE TABLE import_template ( ); ``` -T-102 已实现 `AiModel` / `ModelAlias` 的 Django models、admin、迁移与别名解析。T-103 已补 `AiConfigAuditLog`,admin 里保存/删除模型配置或能力别名时自动写审计日志。T-202 已实现 `PricingRule` / `ExchangeRate` 与 `apps.billing.pricing` 计算函数:定价按 `operation_type + alias + resolution` 查 active 规则,优先 exact resolution,再回退到空 resolution 默认价;缺规则抛 `NoPricingRuleError(code="no_pricing_rule")`。T-203 已实现 `apps.billing.services`:`precharge_call()` 锁 `UserWallet` 行预扣并写 pending 调用与 consume 流水;`mark_call_success()` 确认成功不再改余额;`refund_call_points()` 锁调用记录并幂等退点,写 refund 流水。T-401 已在同一计费层新增 `adjust_wallet_points()`,供 admin 手工调点使用。T-606/T-609/T-617/T-618 已实现 `DownloadRelease`,admin 可上传安装包或填写 `external_url`、标记当前版本、配置是否强制更新和文件大小字节数;T-618 起 admin 新增 / 编辑发布版本时 `sha256` 与 `size_bytes` 必填,但数据库仍保留空值兼容历史记录;`external_url` 优先于 `file.url`,每平台仅一个当前版本由模型 `save()` 在事务内把同平台旧 current 置为 false。T-610 已新增 `ImportTemplate`,admin 可上传导入模板或填写 `external_url` 并标记当前模板;`external_url` 优先于 `file.url`,当前模板互斥同样由应用层事务处理,不使用 MySQL 不支持的条件唯一约束。T-607/T-609/T-617/T-635 版本检查接口只读取 `DownloadRelease(platform, is_current=True)` 与无状态环境配置;响应 URL 是外部可访问 URL,不暴露本地 `MEDIA_ROOT`,并把 `force_update`、`size_bytes` 和 `client_policy` 作为公开元数据返回。`client_policy` 有独立的配置更新时间且在 `release:null` 时仍返回,不读取用户、订阅、套餐或点数。导入模板下载不新增对外 JSON API,首页直接渲染当前模板下载链接。默认别名唯一性由 model validation、admin 与导入器保证;MySQL 不支持通用 partial unique index,若后续要强制数据库层默认别名唯一,可另评估触发器或约束表。 +T-102 已实现 `AiModel` / `ModelAlias` 的 Django models、admin、迁移与别名解析。T-103 已补 `AiConfigAuditLog`,admin 里保存/删除模型配置或能力别名时自动写审计日志。T-202 已实现 `PricingRule` / `ExchangeRate` 与 `apps.billing.pricing` 计算函数:定价按 `operation_type + alias + resolution` 查 active 规则,优先 exact resolution,再回退到空 resolution 默认价;缺规则抛 `NoPricingRuleError(code="no_pricing_rule")`。T-203 已实现 `apps.billing.services`:`precharge_call()` 锁 `UserWallet` 行预扣并写 pending 调用与 consume 流水;`mark_call_success()` 确认成功不再改余额;`refund_call_points()` 锁调用记录并幂等退点,写 refund 流水。T-401 已在同一计费层新增 `adjust_wallet_points()`,供 admin 手工调点使用。T-606/T-609/T-617/T-618 已实现 `DownloadRelease`,admin 可上传安装包或填写 `external_url`、标记当前版本、配置是否强制更新和文件大小字节数;T-618 起 admin 新增 / 编辑发布版本时 `sha256` 与 `size_bytes` 必填,但数据库仍保留空值兼容历史记录;`external_url` 优先于 `file.url`,每平台仅一个当前版本由模型 `save()` 在事务内把同平台旧 current 置为 false。T-610 已新增 `ImportTemplate`,admin 可上传导入模板或填写 `external_url` 并标记当前模板;`external_url` 优先于 `file.url`,当前模板互斥同样由应用层事务处理,不使用 MySQL 不支持的条件唯一约束。T-607/T-609/T-617/T-635/T-636 版本检查接口只读取 `DownloadRelease(platform, is_current=True)` 与全局 `ClientSubscriptionPolicy`(无记录时读取环境兜底);响应 URL 是外部可访问 URL,不暴露本地 `MEDIA_ROOT`,并把 `force_update`、`size_bytes` 和 `client_policy` 作为公开元数据返回。`client_policy` 有独立的配置更新时间且在 `release:null` 时仍返回,不读取用户、真实订阅权益、套餐、订单或点数。导入模板下载不新增对外 JSON API,首页直接渲染当前模板下载链接。默认别名唯一性由 model validation、admin 与导入器保证;MySQL 不支持通用 partial unique index,若后续要强制数据库层默认别名唯一,可另评估触发器或约束表。 > 可选增强(接口预留、MVP 不实现):`account_alias_permission`(按账号授权可用别名,防止调用方点用未授权/昂贵模型);别名按比例分流到多个模型(灰度/AB/故障转移)。适配器接口需为此留口子。 diff --git a/docs/06-tasks.md b/docs/06-tasks.md index e991c78..ae281d0 100644 --- a/docs/06-tasks.md +++ b/docs/06-tasks.md @@ -114,7 +114,7 @@ | T-633 | 存量用户批量授予过渡测试套餐 | T-632, T-626 | **已完成。** 新增默认只预演的 `grant_existing_users_plan`,通过明确 `plan_id`、非空原因、`--execute` 与预期人数双重确认,为启用的非后台账号批量授予套餐;已有同产品有效/宽限期权益会跳过。执行在单事务内复用 `grant_software_entitlement()` 生成权益快照、席位和授权事件,任一失败整批回滚。线上已备份 MySQL 后为 30 个存量用户授予“测试”套餐,反向预演待授予为 0;后台账号、API Key、点数、充值、软件订单和生成记录未由命令修改。详见 [`tasks/T-633.md`](tasks/T-633.md)。 | DONE | | T-634 | 过期会员允许购买其他套餐 | T-629, T-632 | **已完成。** 软件下单与支付入账统一按 `status=active + grace_expires_at > now` 识别当前可用权益;支付校验通过后在同一事务内把同产品自然到期但仍为 `active` 的历史权益收敛为 `expired`,并为目标套餐创建新权益。有效/宽限期内的跨套餐仍拒绝;同套餐有效权益续订、支付幂等、金额校验和点数账本隔离保持不变。新增 5 条缺陷回归,扩大非并发 licensing / 软件支付 API 回归 40 条通过;无迁移。详见 [`tasks/T-634.md`](tasks/T-634.md)。 | DONE | | T-635 | cmshopee 客户端启动订阅策略接口 | T-607, T-632 | **已完成。** 公开版本检查接口顶层新增 `client_policy`,有发布和 `release:null` 均返回,所有响应 `Cache-Control: no-store`;单一 `off|observe|enforce` 配置源输出双布尔字段,非 `off` 强制带时区更新时间。订阅状态新增不受运行模式影响的 `real_entitlement_allowed`,客户端不再把 `open` / `shadow` 的兼容 `allowed=true` 误判为真实会员。无迁移,不读用户、套餐或点数,不改生文、生图、支付、扣点和订阅订单。详见 [`tasks/T-635.md`](tasks/T-635.md)。 | DONE | -| T-636 | 客户端订阅策略 django-admin 运营开关 | T-635, T-632 | 将新版客户端启动策略从“改 `.env` + 重启”扩展为 django-admin 可运营的单例配置。**数据源**:新增全局单例 `ClientSubscriptionPolicy`,取值仅 `off|observe|enforce`;公开版本检查接口优先读取该记录,记录尚不存在时才回退 T-635 环境变量,确保本次上线和回滚不改变既有 `off` 行为。**后台**:日常导航只增加一个“客户端订阅策略”入口;仅超级管理员可新增 / 修改,不允许删除,首次创建后只能编辑该唯一记录。保存必须填写变更原因,自动记录操作者、前后模式、时间和原因;审计只在详情页只读展示,不另增菜单入口。`CMSHOPEE_SUBSCRIPTION_MODE` 只读展示为服务端授权模式,不能被该页修改。**即时生效**:每次公开版本检查实时读取该全局配置,不做进程内缓存,admin 保存后无需重启 Web / Generate;不创建策略记录仍沿用 `.env`,紧急回退可在后台改回 `off`。**安全与兼容**:公开响应仍无账号上下文,不读取用户权益、套餐、点数、订单或 API Key;不改通用 / 专属生成、预扣 / 退点、支付、软件订单及服务端授权模式。**迁移与部署**:迁移只建空表,不预置记录;生产先备份并执行 migrate,验收公开接口仍为当前 `client_policy=off`,再由超级管理员按 `off -> observe -> enforce` 灰度。**测试**:覆盖 `.env` 回退、后台策略优先、三种映射、保存后下一次公开请求立即生效、唯一性、超级管理员限制、原因和审计留痕、审计不出现在 admin 导航、`no-store` 与既有发布字段回归;`makemigrations` / `migrate` / `check` / 目标测试 / `init` / `git diff --check` 通过,并同步 API、架构、环境、部署、当前状态、进度和 Obsidian 客户端契约。详见 [`tasks/T-636.md`](tasks/T-636.md)。 | TODO | +| T-636 | 客户端订阅策略 django-admin 运营开关 | T-635, T-632 | **实施中。** 已新增 `ClientSubscriptionPolicy(singleton_id=1)` 与只读 `ClientSubscriptionPolicyAudit`,公开版本接口优先读后台单例、缺记录回退 `.env`;superuser-only admin 保存必须带原因并即时发布。迁移、目标 API / service / admin 回归已通过,待完成本地标准验证、提交和生产 migrate 部署。详见 [`tasks/T-636.md`](tasks/T-636.md)。 | DOING | ## 里程碑 diff --git a/docs/api.md b/docs/api.md index ca9e63a..53320f6 100644 --- a/docs/api.md +++ b/docs/api.md @@ -90,7 +90,7 @@ T-606 已实现公开首页与客户端下载入口:`GET /` 匿名返回 200 T-610 已在公开首页下载区新增“下载导入模板”链接:模板由 django-admin 维护 `ImportTemplate` 当前记录,首页直接渲染公开下载链接;该能力不新增对外 JSON API,不需要 API Key、不读取用户、不扣点。生产本地模板文件复用 Nginx 直接服务 `MEDIA_ROOT/import_templates/`,避免文件下载占用 Gunicorn worker;`external_url` 优先于本地文件 URL。 -T-607/T-609/T-617/T-635 已实现 `GET /api/v1/client/releases/latest?platform=windows`,给桌面端自动检查更新和读取启动订阅策略使用。该接口公开匿名可访问,不需要 API Key,不读取用户、套餐或点数,不扣点、不占用生成接口限流;只返回 `DownloadRelease` 的公开发布元数据和无账号上下文的 `client_policy`。`release.force_update` 表示该版本是否必须升级,来源于后台 `DownloadRelease.force_update`,默认 `false`;`release.size_bytes` 表示安装包文件大小字节数,来源于后台 `DownloadRelease.size_bytes`,用于桌面端下载后校验文件大小。 +T-607/T-609/T-617/T-635/T-636 已实现 `GET /api/v1/client/releases/latest?platform=windows`,给桌面端自动检查更新和读取启动订阅策略使用。该接口公开匿名可访问,不需要 API Key,不读取用户、套餐、权益、订单或点数,不扣点、不占用生成接口限流;只返回 `DownloadRelease` 的公开发布元数据和无账号上下文的 `client_policy`。策略优先读取 django-admin 的全局 `ClientSubscriptionPolicy` 单例,记录不存在才回退 `.env`;后台保存后下一次请求立即生效,结构仍为 T-635 的 `policy_version=1` 与两个布尔字段。`release.force_update` 表示该版本是否必须升级,来源于后台 `DownloadRelease.force_update`,默认 `false`;`release.size_bytes` 表示安装包文件大小字节数,来源于后台 `DownloadRelease.size_bytes`,用于桌面端下载后校验文件大小。 通用错误响应: diff --git a/docs/current-state.md b/docs/current-state.md index 1bffa4d..3c82e57 100644 --- a/docs/current-state.md +++ b/docs/current-state.md @@ -11,7 +11,7 @@ ## 当前快照 -- 日期:2026-07-23 +- 日期:2026-07-28 - 阶段:Phase 6 增强(MVP 后);Phase 3 对外 API 与充值已完成到 T-306,Phase 4 用户端 T-501 注册 / 登录(allauth)、T-502 API Key 自助管理页、T-503 个人中心 / 记录页、T-504 充值页与 T-505 用户端审核优化已完成,Phase 5 T-401 运营后台完善、T-402 MVP 完整验收与 T-403 部署 / 运行文档已完成,Phase 6 T-601 可用别名发现、T-602 django-admin 中文化第 1-3 层、T-603 django-admin 字段级中文化、T-604 中文敏感词本地过滤、T-605 免邮箱验证策略落地、T-606 公开首页 + 客户端下载入口、T-607 桌面端最新版本检查接口、T-608 新用户注册赠送试用点数(当前 10 点)、T-609 桌面端版本检查接口增加强制更新标记、T-610 首页导入模板下载入口、T-611 用户端品牌名统一为虾皮圈、T-612 生图同步接口止血、T-613 抽生成核心 service、T-614 生图异步任务化接口、T-615 旧同步生图接口遥测 / 弃用口径、T-616 生图失败自动重试 2 次、T-617 桌面端版本文件大小字段与 T-618 客户端发布版本后台必填校验元数据已完成;后续仍需处理真实支付回调到账闭环、客户端下载包发布和生产侧旧同步接口用量观察 - 技术栈:系统 Python 3.12.3 + Django 5.2.15 + DRF 3.16.1 + django-allauth 65.18.0 + PyMySQL 1.1.3 + cryptography 49.0.0 + requests 2.34.2 + ahocorapy 1.6.2 + wechatpayv3 2.0.2 + python-alipay-sdk 3.4.0 + django-admin;MySQL 8.4 已接入 settings,并支持 `MYSQL_CONNECT_TIMEOUT` / `MYSQL_READ_TIMEOUT` / `MYSQL_WRITE_TIMEOUT`;用户端已用 Django 模板 SSR + Bootstrap + allauth 落地注册登录;生产部署口径为 VPS / 宝塔 + Nginx + Gunicorn(gthread) + systemd;详见 `03-tech-stack.md` 与 `deployment.md` - 生产代码:已有最小 Django 工程骨架:`manage.py`、`config/`;T-002 已创建 `apps/users|portal|billing|ai|api`;T-003 已把自定义 `User` 注册进 django-admin;T-004 已完成 email 唯一性、init 版本断言、app 顺序、`.env.example` 与 `pyproject.toml`;T-101 已新增 `apps/ai/providers/`(Provider 接口、注册表、chat/gemini/images/images_edits 适配器);T-102 已新增 `AiModel` / `ModelAlias`、Fernet 加密密钥存储、别名解析、admin 配置页、`import_ai_models` 导入命令;T-103 已新增 `AiConfigAuditLog` 审计表、admin 只读页面和后台保存/删除审计 hook;T-104/T-105 已完成录制 title/image smoke 与审核修补;T-201 已新增 `UserWallet` / `ApiKey`、`PointsLedger` / `CallRecord`、对应 admin 与迁移;T-202 已新增 `PricingRule` / `ExchangeRate`、`apps.billing.pricing` 计费计算函数、admin 配置页与迁移;T-203 已新增 `apps.billing.services`,实现并发安全预扣、成功确认与幂等失败退点;T-204 已新增 `billing.0003_pointsledger_unique_ledger_change_type_per_call`,用 MySQL 可落地的 `ref_call + change_type` 复合唯一约束兜底防重复 refund;T-301 已新增 `apps.api.authentication.ApiKeyAuthentication` 与 `ExternalApiView`;T-302 已新增生成接口编排、序列化器、图片本地存储和 `/api/v1/generate/title|image` 路由;T-303 已新增 `apps.billing.services.get_balance_snapshot()` 与 `/api/v1/balance` 余额查询接口;T-304 已新增 `RechargeOrder`、充值回调验签适配器、幂等入账服务、微信/支付宝回调路由与迁移 `billing.0004_rechargeorder_and_more`;T-305 已新增 `create_recharge_order()`、微信/支付宝扫码下单 mock/SDK 入口、`/api/v1/recharge/create` 与 `/api/v1/recharge/status`;T-306 已新增 `apps.api.throttles`、`apps.api.exceptions`、`REST_FRAMEWORK` 安全默认认证、生成/认证失败限流、`image_url` SSRF 防护与响应大小上限、充值单笔金额上限;T-501/T-608 已接入 allauth 注册登录路径,注册成功后 adapter 调用 `grant_signup_bonus()` 经 billing 一次性发放 10 点并写 `signup_bonus` 流水,新增 `SignupBonusGrant(user UNIQUE)` 幂等标记、admin 只读检索和 `billing.0007` 迁移;T-502 已新增 `/apikeys`、API Key 创建表单、列表页和删除(吊销)动作,生成后明文只显示一次,列表只显示 prefix;T-503/T-608 已扩展 `/dashboard` 为个人中心汇总,并新增 `/records/recharge` 充值记录与 `/records/usage` 点数记录,只读展示当前用户数据和注册赠点 / 消费 / 退款流水;T-504 已新增 `/recharge` 页面、`RechargeCreateForm`、充值导航入口和轮询脚本,页面创建 pending 订单、展示二维码票据、轮询 `/api/v1/recharge/status`,订单 paid 后刷新余额;T-505 已把 Bootstrap 5 CSS 与 qrcode.js vendoring 到 `apps/portal/static/portal/vendor/`,页面不再依赖 jsdelivr,并把充值记录 / 点数记录改为 Django `Paginator` 分页;T-401 已新增 `adjust_wallet_points()` 手工调点服务、钱包 admin 专用调点表单与模板,后台可管理/检索用户、钱包、API Key(脱敏)、计费规则、汇率、充值订单、点数流水、注册赠点记录和调用记录,流水/订单/调用记录保持只读;T-402 已新增 `docs/mvp-acceptance.md`,按 P0 验收矩阵记录 MVP 完整验收结论、测试证据和已知限制;T-403 已新增 `docs/deployment.md` 与 `requirements-production.txt`,并在 settings 中补齐 `STATIC_ROOT`、`CSRF_TRUSTED_ORIGINS`、共享 `CACHES`、HTTPS cookie、proxy SSL、HSTS 环境变量与 `ACCOUNT_SIGNUP_RATE_LIMIT` 注册限流配置;T-601 已新增 `apps.ai.catalog.get_public_model_catalog()`、`GET /api/v1/models` 与 portal `/models` 只读页面,只展示 active 可调用别名、能力、是否需要原图和点数单价,不解密 provider key,不暴露底层 SKU / URL / key / `extra_body`;T-604 已新增 `apps.moderation`、`SensitiveWord` 模型/admin/迁移、keyword provider、归一化管线和共享 cache 版本失效,生成接口已改为 prompt 先审再读取图片/计费/扣点/调上游;T-606 已新增公开首页 `/`、`DownloadRelease` 模型/admin/迁移、首页 SSR 模板、共享 `portal/brand.css`,并把现有 portal 页面套入同一套品牌 token;T-607/T-609 已新增 `ClientLatestReleaseView` 与 `/api/v1/client/releases/latest`,公开匿名返回当前客户端版本 JSON,`release.force_update` 表示该版本是否强制升级;`portal.0002_downloadrelease_force_update` 已给 `DownloadRelease` 增加 `force_update` 字段,admin 可编辑和筛选;T-610 已新增 `ImportTemplate` 模型/admin/迁移 `portal.0003_importtemplate`,首页读取当前模板并在“下载客户端”旁展示“下载导入模板”,本地文件 URL 转为当前站点绝对 URL,`external_url` 优先;T-611 已把用户端 portal 可见品牌名统一为“虾皮圈”,包括页面标题、顶部导航、首页 H1、用户端“虾皮圈 API Key”文案和 allauth 邮件模板;T-612 已新增 `AI_IMAGE_UPSTREAM_DEADLINE_SECONDS`,生图 Provider 上游请求和上游返回图片 URL 下载会按 `min(AiModel.timeout_seconds 或分辨率默认值, 硬截止)` 控制读取超时,超时返回 `upstream_timeout` 并走既有失败退点路径;T-613 已把旧同步生成链路抽成 `GenerationInput`、`prepare_generation()`、`precharge_generation()`、`execute_precharged_generation()` 与 `GenerationResult`,旧 view 只负责 serializer 和异常转 HTTP,后续异步 worker 可复用已预扣执行 / 确认 / 退点阶段。 @@ -76,10 +76,10 @@ - 已完成: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-626 软件套餐、权益与设备席位基础模型及凭证续期/撤销清理;T-627 存量用户迁移权益、网页确认与设备凭证;T-628 蝦皮圈专属授权入口与影子校验;T-629 软件套餐购买、续订订单与权益入账;T-630 账号订阅授权与默认多设备模式;T-631 隐藏旧设备授权模型的 admin 菜单入口;T-632 会员订阅三阶段启用、状态接口补全与 admin 简化;T-633 存量用户批量授予过渡测试套餐;T-634 过期会员允许购买其他套餐;T-635 cmshopee 客户端启动订阅策略接口。 -- 正在进行:无。 +- 正在进行:T-636 客户端订阅策略 django-admin 运营开关,代码、迁移与目标回归已完成,待标准验证、提交和生产部署。 - T-621 注册赠点运营后台配置继续留在 Backlog。真实支付回调到账闭环、客户端发布、生产多图理解模型配置和线上旧同步接口用量观察仍可继续拆任务。 - 当前 blocker:支付商户真实密钥/证书与生产 SDK 依赖仍待提供;微信回调到账闭环仍需真实支付验收;真实 AI 标题生成已在线上跑通,图片生成慢 / 504 / 客户端超时风险已拆为 T-612~T-616 并完成工程侧处理。 -- 下一个可领取任务:按业务优先级从 Backlog 拆分后续任务。T-621 保留在 Backlog;真实微信商户支付验收仍是线上 blocker。T-635 已部署生产 revision `1c100c5`,客户端策略当前为 `off`,待新版客户端灰度后再切 `observe` / `enforce`。具体范围见 [`06-tasks.md`](06-tasks.md)。 +- 下一个可领取任务:完成 T-636 的提交和生产部署。T-621 保留在 Backlog;真实微信商户支付验收仍是线上 blocker。线上 T-635 revision 为 `1c100c5`,客户端策略当前由 `.env` 兜底为 `off`;T-636 发布后先保持该兜底,待超级管理员创建后台记录后再按 `observe` / `enforce` 灰度。具体范围见 [`06-tasks.md`](06-tasks.md)。 ## 当前可运行内容 @@ -155,7 +155,7 @@ T-633 已新增默认只预演的 `grant_existing_users_plan`:按套餐 ID、 T-634 已统一软件订单与订阅授权的当前权益口径:只有 `active` 且未超过宽限截止时间的权益会阻止跨套餐下单或参与续订。支付校验通过后,服务在同一事务内把自然到期但仍为 `active` 的同产品历史权益收敛为 `expired`;已过期同套餐或不同套餐都会创建新权益。该实现不改接口、迁移、支付验签或点数账本,已部署生产 revision `976b55c`。 -T-635 已在公开 `GET /api/v1/client/releases/latest` 顶层发布 `client_policy`,有发布和 `release:null` 均返回并带 `Cache-Control: no-store`;策略以 `CMSHOPEE_CLIENT_SUBSCRIPTION_POLICY=off|observe|enforce` 配置,和服务端 `CMSHOPEE_SUBSCRIPTION_MODE` 独立。订阅状态接口新增 `real_entitlement_allowed`,只代表真实 `active` / `grace` 权益,客户端门禁不得以 `open` / `shadow` 的兼容 `allowed` 字段替代。无迁移,不读取用户、套餐或点数,不影响生成、扣点、退款、充值或软件订单。2026-07-28 已部署到生产 revision `1c100c5`,客户端策略保持 `off`,Web / Generate 服务和 66 个图片 worker 健康。 +T-635 已在公开 `GET /api/v1/client/releases/latest` 顶层发布 `client_policy`,有发布和 `release:null` 均返回并带 `Cache-Control: no-store`;策略与服务端 `CMSHOPEE_SUBSCRIPTION_MODE` 独立。订阅状态接口新增 `real_entitlement_allowed`,只代表真实 `active` / `grace` 权益,客户端门禁不得以 `open` / `shadow` 的兼容 `allowed` 字段替代。T-636 正在把策略来源扩展为 admin 单例优先、`.env` 缺记录兜底;不读取用户、真实权益、套餐、订单或点数,不影响生成、扣点、退款、充值或软件订单。T-635 已于 2026-07-28 部署到生产 revision `1c100c5`,客户端策略当前由 `.env` 保持 `off`,Web / Generate 服务和 66 个图片 worker 健康。 ## 开始编码前检查 diff --git a/docs/deployment.md b/docs/deployment.md index 6c11fec..145361b 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -155,7 +155,7 @@ python3.12 -c "from cryptography.fernet import Fernet; print(Fernet.generate_key - `PAYMENT_CALLBACK_MODE=sdk` 必须配齐微信 / 支付宝商户配置;未配齐时先保持 `mock`。 - 宝塔 / Nginx 已强制 HTTPS 时,`DJANGO_SECURE_SSL_REDIRECT=false` 即可;全站 HTTPS 稳定后再把 `DJANGO_SECURE_HSTS_SECONDS` 调大,避免 HSTS 误锁域名。 - T-614 异步生图 worker 没有 request 对象,生产必须配置 `MEDIA_PUBLIC_BASE_URL` 或 `PUBLIC_BASE_URL` 为公开 HTTPS 域名,否则异步轮询成功时可能返回相对 `/media/...` URL。 -- T-635 客户端启动订阅策略与服务端订阅模式独立:先部署并保持 `CMSHOPEE_CLIENT_SUBSCRIPTION_POLICY=off`,新版客户端灰度后改为 `observe`,确认权益覆盖和客户端分类提示后才改为 `enforce`;每次改策略必须同步更新带时区的 `CMSHOPEE_CLIENT_SUBSCRIPTION_POLICY_UPDATED_AT`,然后重启 `cmhub-web` 与 `cmhub-generate`。不得把客户端 `off` 误当作服务端取消授权。 +- T-635/T-636 客户端启动订阅策略与服务端订阅模式独立:先部署迁移但不要预置 `ClientSubscriptionPolicy`,此时保留 `.env` 兜底 `CMSHOPEE_CLIENT_SUBSCRIPTION_POLICY=off`。日常由超级管理员在 `/admin/licensing/clientsubscriptionpolicy/` 创建 / 编辑唯一策略记录,填写原因后按 `off -> observe -> enforce` 灰度;保存后下一次版本检查立即生效,无需重启。仅当该记录不存在时,才修改带时区的 `.env` 更新时间并重启 `cmhub-web` 与 `cmhub-generate`。不得把客户端 `off` 误当作服务端取消授权。 ## 五、初始化数据库与静态文件 diff --git a/docs/env.md b/docs/env.md index 17a4134..847bedc 100644 --- a/docs/env.md +++ b/docs/env.md @@ -57,8 +57,8 @@ | `MIGRATION_REQUEST_TTL_SECONDS` | 否 | `900` | T-627 存量迁移网页确认请求有效秒数,默认 15 分钟;过期后客户端必须重新申请,凭证明文不在服务端恢复 | | `CMSHOPEE_SUBSCRIPTION_MODE` | 否 | `open` | T-632 账号订阅运行模式:`open` 开发测试开放、`shadow` 放行但记录真实权益、`enforce` 按真实权益强制拦截。非法值阻止 Django 启动 | | `CMSHOPEE_SUBSCRIPTION_ENFORCEMENT` | 否 | `false` | T-630 旧兼容开关;仅当 `CMSHOPEE_SUBSCRIPTION_MODE` 未设置时生效,`true` 映射为 `enforce`,否则映射为 `open`。新部署不要再用它控制模式 | -| `CMSHOPEE_CLIENT_SUBSCRIPTION_POLICY` | 否 | `off` | T-635 新版客户端启动策略:`off` 不检测不门禁、`observe` 检测但不门禁、`enforce` 检测并门禁;只控制客户端体验,不改变服务端专属接口授权。非法值阻止 Django 启动 | -| `CMSHOPEE_CLIENT_SUBSCRIPTION_POLICY_UPDATED_AT` | `observe` / `enforce` 时是 | `2026-07-28T10:00:00+08:00` | T-635 客户端策略最后变更时间,必须是带时区 ISO 8601。每次改策略同步更新;默认 `off` 未设置时使用仓库基线时间,其他模式缺失或非法值阻止 Django 启动 | +| `CMSHOPEE_CLIENT_SUBSCRIPTION_POLICY` | 否 | `off` | T-636 django-admin 尚未创建 `ClientSubscriptionPolicy` 单例时的客户端策略兜底:`off` 不检测不门禁、`observe` 检测但不门禁、`enforce` 检测并门禁;只控制客户端体验,不改变服务端专属接口授权。非法值阻止 Django 启动 | +| `CMSHOPEE_CLIENT_SUBSCRIPTION_POLICY_UPDATED_AT` | `.env` 兜底为 `observe` / `enforce` 时是 | `2026-07-28T10:00:00+08:00` | `.env` 兜底策略的最后变更时间,必须是带时区 ISO 8601。默认 `off` 未设置时使用仓库基线时间;其他模式缺失或非法值阻止 Django 启动。后台单例存在时接口改用其 `updated_at` | | `AI_IMAGE_UPSTREAM_DEADLINE_SECONDS` | 否 | `180` | T-612 生图上游读取硬截止秒数;只作用于 `generate_image` 的上游请求和上游返回图片 URL 下载,Provider 实际读取超时取 `min(AiModel.timeout_seconds 或分辨率默认值, 本值)`;生产按真实图片 smoke 耗时校准,外层 Gunicorn / Nginx / 客户端超时必须大于该值 | | `PUBLIC_BASE_URL` | 否 | `https://cm.833729.com` | 站点公开基础 URL;异步 worker 没有 request 时可用它生成绝对媒体 URL | | `MEDIA_PUBLIC_BASE_URL` | 否 | `https://cm.833729.com` | 媒体文件公开基础 URL;优先于 `PUBLIC_BASE_URL`,用于 T-614 异步生图 worker 返回 `result.image_url` | @@ -111,7 +111,7 @@ T-614 起新增异步生图任务接口:提交任务仍同步审核 prompt 和 T-604 只做 prompt 本地敏感词快筛。命中时返回 `content_blocked`,不扣点、不写调用记录、不调上游;并且必须在下载 `image_url` 前执行 prompt 审核。详细规则见 [`moderation.md`](moderation.md)。 -T-635 的 `CMSHOPEE_CLIENT_SUBSCRIPTION_POLICY` 与 `CMSHOPEE_SUBSCRIPTION_MODE` 是两套独立开关:前者只通过公开版本检查接口通知**新版客户端**是否检查 / 展示会员门禁,后者才决定产品专属提交接口是否在服务端拦截。变更客户端策略后重启 `cmhub-web` 和 `cmhub-generate`;推荐先 `off -> observe -> enforce` 灰度,且在每一步更新 `CMSHOPEE_CLIENT_SUBSCRIPTION_POLICY_UPDATED_AT`。不支持使用两个独立布尔环境变量,避免发布矛盾的 `false/true` 配置。 +T-635/T-636 的客户端策略与 `CMSHOPEE_SUBSCRIPTION_MODE` 是两套独立开关:前者只通过公开版本检查接口通知**新版客户端**是否检查 / 展示会员门禁,后者才决定产品专属提交接口是否在服务端拦截。日常运营应由超级管理员在 `/admin/licensing/clientsubscriptionpolicy/` 保存单一 `off -> observe -> enforce` 枚举和必填原因,保存后下一次公开请求立即生效,无需重启;该单例不存在时才使用本节两个 `.env` 变量,修改 `.env` 仍需重启 `cmhub-web` 与 `cmhub-generate`。不支持使用两个独立布尔环境变量,避免发布矛盾的 `false/true` 配置。 生产多 Gunicorn worker 下,敏感词 matcher 刷新依赖共享 cache 的版本号;若仍使用 `LocMemCache`,只能刷新当前进程,不能保证所有 worker 及时更新。 diff --git a/docs/routes.md b/docs/routes.md index d3f935d..38b4d24 100644 --- a/docs/routes.md +++ b/docs/routes.md @@ -74,6 +74,7 @@ T-627 已新增迁移申请 / 轮询和网页登录确认:客户端必须同 | 配置审计 | AiConfigAuditLog | 只读查看 AiModel / ModelAlias / 密钥变更:谁、何时、改了什么 | | 客户端下载版本 | DownloadRelease | 上传安装包或填写外部下载地址,标记每个平台当前版本、是否强制更新和文件大小字节数;新增 / 编辑时必须填写 SHA256 与文件大小字节数;展示版本、SHA256、强制更新标记与发布说明 | | 导入模板 | ImportTemplate | 上传导入模板或填写外部下载地址,标记当前模板;展示模板名称、SHA256 与说明 | +| 客户端订阅策略 | ClientSubscriptionPolicy | 仅超级管理员维护单一 `off` / `observe` / `enforce` 客户端启动策略;保存必须填写原因,详情页只读显示审计和服务端 `CMSHOPEE_SUBSCRIPTION_MODE`,保存后版本检查接口立即发布 | | 会员套餐 | SoftwarePlan | 软件授权模块默认可见;维护蝦皮圈套餐的时长、价格、设备数、宽限期与启停状态,修改不回写已有权益快照 | | 用户会员 | SoftwareEntitlement | 软件授权模块默认可见;只读查看用户权益和套餐快照,通过专用后台页面人工授予、续期或撤销,三种操作均必须填写原因并写授权事件 | | 客户端设备(隐藏导航) | ClientDevice | 只读检索蝦皮圈设备平台、版本、状态和最近活跃时间;设备摘要仅脱敏显示 | @@ -103,10 +104,11 @@ T-627 已新增迁移申请 / 轮询和网页登录确认:客户端必须同 ### 软件授权 -- T-632 后 django-admin 应用索引只展示“会员套餐”和“用户会员”。其他授权、订单、设备与审计模型仍注册且可通过直接管理地址访问,隐藏导航不等于删除数据或放宽权限。 +- T-636 后 django-admin 应用索引展示“客户端订阅策略”、“会员套餐”和“用户会员”。客户端策略是全局单例,只有超级管理员可新增 / 编辑、不能删除;策略审计不单独出现在导航。其他授权、订单、设备与审计模型仍注册且可通过直接管理地址访问,隐藏导航不等于删除数据或放宽权限。 - 开发测试阶段设置 `CMSHOPEE_SUBSCRIPTION_MODE=open`,运营无需给所有存量用户逐个创建长期权益;正式启用前按 `open` → `shadow` → `enforce` 顺序切换。 - T-626 的套餐、权益和席位只提供运营基础数据与人工操作,不新增公开接口、不接支付订单,也不改变通用生成 API 的授权结果。 - 授予 / 续期 / 撤销一律调用 `apps.licensing.services`;原因必填并产生 `LicenseEvent`。不得在 admin 表单中直接修改权益状态、到期时间、席位绑定或历史事件。 +- 客户端策略一律调用 `set_client_subscription_policy()`;模式不变会被拒绝,避免写入伪审计。它只影响新版客户端体验,不能修改 `CMSHOPEE_SUBSCRIPTION_MODE`,也不能替代服务端授权。 - 已授予权益读取自身套餐快照,不随 `SoftwarePlan` 后续价格、时长或设备数变化而变化;席位数量由授予时快照固定。 - 不允许在后台修改或删除流水与调用记录(账目不可篡改)。 diff --git a/docs/tasks/T-636.md b/docs/tasks/T-636.md index a10e0c0..db5c2f1 100644 --- a/docs/tasks/T-636.md +++ b/docs/tasks/T-636.md @@ -88,4 +88,18 @@ T-635 已经通过公开版本检查接口向新版 cmshopee 客户端发布启 ## 状态 -TODO。 +DOING。代码、迁移和目标回归已完成,待标准验证、提交和生产部署。 + +## 实施结果(待部署) + +- 新增 `ClientSubscriptionPolicy` 与 `ClientSubscriptionPolicyAudit`,迁移 + `licensing.0006_client_subscription_policy` 只建空表。策略固定主键为 `1`,并由 + 数据库 CHECK 约束拒绝第二个主键;没有预置记录,保留既有 `.env` 兜底。 +- `get_published_client_subscription_policy()` 每次公开版本检查优先读取后台单例, + 用其 `updated_at` 发布原有 `client_policy` 结构;未创建记录时继续调用 T-635 + 的环境配置校验。没有进程内缓存,后台保存后下一次请求立即生效。 +- django-admin 新增“客户端订阅策略”入口:仅超级管理员可新增 / 编辑,不能删除; + 表单强制填写原因,服务层在事务内写策略和只读审计。策略详情显示当前服务端订阅 + 模式,但不允许修改它;审计只作为 inline 展示,不增加导航噪音。 +- 未改通用 / 专属生成、服务端订阅授权、用户权益、套餐、点数、支付、软件订单或 + 异步图片任务。 diff --git a/progress.md b/progress.md index 28032da..dc1ff83 100644 --- a/progress.md +++ b/progress.md @@ -2237,3 +2237,25 @@ 66 个 `cmhub-image-worker` 未重启且均 active,failed units 为 0。公网版本接口返回 HTTP 200、`Cache-Control: no-store` 和 `client_policy` 的两个 false 布尔值;线上 `.deploy_revision=1c100c5`。 + +## 2026-07-28 实施中:T-636 客户端订阅策略 django-admin 运营开关 + +- 数据与来源:新增 `ClientSubscriptionPolicy(singleton_id=1)` 和只读 + `ClientSubscriptionPolicyAudit`,迁移 `licensing.0006_client_subscription_policy` + 只创建空表;固定主键与数据库 CHECK 约束防止第二个策略记录。公开版本检查接口每次 + 优先读取该全局策略,未创建记录才回退 T-635 的 `.env` 配置,因此上线后保持现有 + `off` 行为,后台保存后无需重启即在下一次请求生效。 +- 后台:新增仅超级管理员可见的“客户端订阅策略”入口。首次创建和后续变更都必须填写 + 原因;服务层事务内写策略和前后模式审计,拒绝相同模式伪变更。审计只在详情页只读 + inline 展示;`CMSHOPEE_SUBSCRIPTION_MODE` 仅展示为服务端授权模式,不能由此页修改。 +- 边界:未改用户权益、套餐、点数、支付、软件订单、通用 / 专属生成、图片 worker 或 + 服务端订阅授权。版本检查接口仍匿名、`Cache-Control: no-store`,不读用户、真实权益、 + 套餐、订单、API Key 或点数。 +- 验证:隔离 SQLite 已应用全量迁移(含 `licensing.0006`); + `ClientLatestReleaseApiTests`、`ClientSubscriptionPolicyServiceTests`、 + `SoftwareEntitlementAdminTests` 共 25 tests OK,覆盖 `.env` 兜底、后台优先、即时 + 发布、三种映射、单例约束、原因 / 审计、策略导航和 superuser 限制。`py_compile`、 + `manage.py check`、`./init.ps1` 与 `git diff --check` 通过。 +- 迁移一致性:`makemigrations --check --dry-run` 显示 No changes detected;旧本机 + MySQL `43.128.3.240` 拒绝连接,仅产生迁移历史检查 warning。待提交后按部署文档备份、 + `migrate` 并保持线上 `.env` 兜底为 `off`。