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
+3 -2
View File
@@ -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
+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):
+5 -3
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` 配置当前模板。
- **用户与账号层**:注册用户 `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/故障转移)。适配器接口需为此留口子。
+1 -1
View File
@@ -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 |
## 里程碑
+1 -1
View File
@@ -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`,用于桌面端下载后校验文件大小。
通用错误响应:
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -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` 误当作服务端取消授权。
## 五、初始化数据库与静态文件
+3 -3
View File
@@ -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 及时更新。
+3 -1
View File
@@ -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` 后续价格、时长或设备数变化而变化;席位数量由授予时快照固定。
- 不允许在后台修改或删除流水与调用记录(账目不可篡改)。
+15 -1
View File
@@ -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 展示,不增加导航噪音。
- 未改通用 / 专属生成、服务端订阅授权、用户权益、套餐、点数、支付、软件订单或
异步图片任务。
+22
View File
@@ -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`。