feat: add staged subscription rollout mode

This commit is contained in:
QiuSW
2026-07-22 15:13:28 +08:00
parent e3b90ccea5
commit 2604a59d67
17 changed files with 551 additions and 87 deletions
+3 -1
View File
@@ -39,6 +39,9 @@ DEVICE_SESSION_TTL_SECONDS=3600
DEVICE_ACTIVITY_UPDATE_SECONDS=86400 DEVICE_ACTIVITY_UPDATE_SECONDS=86400
MIGRATION_REQUEST_TTL_SECONDS=900 MIGRATION_REQUEST_TTL_SECONDS=900
CMSHOPEE_AUTHORIZATION_SHADOW_MODE=true CMSHOPEE_AUTHORIZATION_SHADOW_MODE=true
CMSHOPEE_SUBSCRIPTION_MODE=open
# Legacy fallback only when CMSHOPEE_SUBSCRIPTION_MODE is unset:
# CMSHOPEE_SUBSCRIPTION_ENFORCEMENT=false
AI_IMAGE_UPSTREAM_DEADLINE_SECONDS=180 AI_IMAGE_UPSTREAM_DEADLINE_SECONDS=180
PUBLIC_BASE_URL= PUBLIC_BASE_URL=
MEDIA_PUBLIC_BASE_URL= MEDIA_PUBLIC_BASE_URL=
@@ -84,7 +87,6 @@ WECHAT_PAY_CERT_SERIAL_NO=your-cert-serial-no
WECHAT_PAY_PRIVATE_KEY_PATH=/secure/wechat/apiclient_key.pem WECHAT_PAY_PRIVATE_KEY_PATH=/secure/wechat/apiclient_key.pem
WECHAT_PAY_NOTIFY_URL=https://cmhub.example.com/api/v1/recharge/callback/wechat WECHAT_PAY_NOTIFY_URL=https://cmhub.example.com/api/v1/recharge/callback/wechat
SOFTWARE_WECHAT_PAY_NOTIFY_URL=https://cmhub.example.com/api/v1/software-orders/callback/wechat SOFTWARE_WECHAT_PAY_NOTIFY_URL=https://cmhub.example.com/api/v1/software-orders/callback/wechat
CMSHOPEE_SUBSCRIPTION_ENFORCEMENT=false
# Alipay face-to-face payment # Alipay face-to-face payment
ALIPAY_APPID=your-alipay-appid ALIPAY_APPID=your-alipay-appid
+117 -13
View File
@@ -63,7 +63,12 @@ from apps.billing.services import RechargePayment
from apps.moderation.models import SensitiveWord from apps.moderation.models import SensitiveWord
from apps.moderation.providers.keyword import reset_keyword_matcher_cache from apps.moderation.providers.keyword import reset_keyword_matcher_cache
from apps.portal.models import DownloadRelease from apps.portal.models import DownloadRelease
from apps.licensing.models import ClientDevice, SoftwareOrder, SoftwarePlan from apps.licensing.models import (
ClientDevice,
SoftwareEntitlement,
SoftwareOrder,
SoftwarePlan,
)
from apps.licensing.services import ( from apps.licensing.services import (
create_software_order, create_software_order,
grant_software_entitlement, grant_software_entitlement,
@@ -1437,6 +1442,82 @@ class GenerateApiTests(TestCase):
starts_at=starts_at, starts_at=starts_at,
) )
@override_settings(
CMSHOPEE_SUBSCRIPTION_MODE="open",
PUBLIC_BASE_URL="https://cm.example.test",
)
def test_cmshopee_open_mode_returns_compatible_access_without_writing_entitlement(self):
response = self.client.get(
"/api/v1/cmshopee/subscription/status",
**self.auth_header(),
)
self.assertEqual(response.status_code, 200)
data = response.json()
self.assertEqual(data["product_code"], "cmshopee")
self.assertEqual(data["status"], "active")
self.assertTrue(data["allowed"])
self.assertIsNone(data["code"])
self.assertEqual(data["account"]["username"], self.user.username)
self.assertEqual(data["plan"]["code"], "development-open")
self.assertEqual(data["plan"]["display_name"], "开发测试长期会员")
self.assertEqual(data["expires_at"], data["plan"]["expires_at"])
self.assertIsNone(data["grace_expires_at"])
self.assertEqual(data["manage_url"], "https://cm.example.test/subscription")
self.assertEqual(data["access_source"], "open_mode")
self.assertEqual(data["entitlement_status"], "required")
self.assertTrue(data["notice_id"])
self.assertFalse(SoftwareEntitlement.objects.filter(user=self.user).exists())
@override_settings(CMSHOPEE_SUBSCRIPTION_MODE="shadow")
def test_cmshopee_shadow_mode_allows_missing_entitlement_and_reports_real_status(self):
response = self.client.get(
"/api/v1/cmshopee/subscription/status",
**self.auth_header(),
)
self.assertEqual(response.status_code, 200)
data = response.json()
self.assertEqual(data["status"], "active")
self.assertTrue(data["allowed"])
self.assertEqual(data["access_source"], "shadow_fallback")
self.assertEqual(data["entitlement_status"], "required")
self.assertEqual(data["plan"]["code"], "shadow-fallback")
@override_settings(CMSHOPEE_SUBSCRIPTION_MODE="open")
def test_cmshopee_open_mode_submit_does_not_create_entitlement(self):
response = self.post_with_provider(
"/api/v1/cmshopee/generate/title",
{"prompt": "开发测试标题", "model": self.title_alias},
)
self.assertEqual(response.status_code, 200)
self.assertEqual(len(self.provider.text_calls), 1)
self.assertFalse(SoftwareEntitlement.objects.filter(user=self.user).exists())
@override_settings(CMSHOPEE_SUBSCRIPTION_MODE="shadow")
def test_cmshopee_shadow_submit_logs_real_entitlement_status(self):
with self.assertLogs("cmhub.licensing.authorization", level="INFO") as captured:
response = self.post_with_provider(
"/api/v1/cmshopee/generate/title",
{"prompt": "影子模式标题", "model": self.title_alias},
)
self.assertEqual(response.status_code, 200)
events = [
getattr(record, "cmshopee_authorization", None)
for record in captured.records
if getattr(record, "cmshopee_authorization", None)
]
self.assertEqual(len(events), 1)
self.assertEqual(events[0]["subscription_mode"], "shadow")
self.assertEqual(events[0]["access_source"], "shadow_fallback")
self.assertEqual(events[0]["entitlement_status"], "required")
self.assertTrue(events[0]["would_reject"])
self.assertEqual(events[0]["would_reject_code"], "subscription_required")
self.assertTrue(events[0]["final_allowed"])
@override_settings(CMSHOPEE_SUBSCRIPTION_MODE="enforce")
def test_cmshopee_subscription_status_is_required_without_entitlement(self): def test_cmshopee_subscription_status_is_required_without_entitlement(self):
response = self.client.get( response = self.client.get(
"/api/v1/cmshopee/subscription/status", "/api/v1/cmshopee/subscription/status",
@@ -1444,17 +1525,29 @@ class GenerateApiTests(TestCase):
) )
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertEqual( data = response.json()
response.json(), self.assertEqual(data["status"], "required")
{ self.assertFalse(data["allowed"])
"product_code": "cmshopee", self.assertEqual(data["code"], "subscription_required")
"status": "required", self.assertIsNone(data["plan"])
"allowed": False, self.assertEqual(data["entitlement_status"], "required")
"code": "subscription_required", self.assertEqual(data["access_source"], "entitlement")
"plan": None,
}, @override_settings(
CMSHOPEE_SUBSCRIPTION_MODE="",
CMSHOPEE_SUBSCRIPTION_ENFORCEMENT=True,
)
def test_cmshopee_legacy_enforcement_setting_remains_supported(self):
response = self.client.get(
"/api/v1/cmshopee/subscription/status",
**self.auth_header(),
) )
self.assertEqual(response.status_code, 200)
self.assertFalse(response.json()["allowed"])
self.assertEqual(response.json()["code"], "subscription_required")
@override_settings(CMSHOPEE_SUBSCRIPTION_MODE="enforce")
def test_cmshopee_subscription_status_is_active_without_device_session(self): def test_cmshopee_subscription_status_is_active_without_device_session(self):
entitlement = self.create_cmshopee_entitlement() entitlement = self.create_cmshopee_entitlement()
@@ -1468,8 +1561,18 @@ class GenerateApiTests(TestCase):
self.assertEqual(data["status"], "active") self.assertEqual(data["status"], "active")
self.assertTrue(data["allowed"]) self.assertTrue(data["allowed"])
self.assertIsNone(data["code"]) self.assertIsNone(data["code"])
self.assertEqual(data["plan"]["display_name"], entitlement.plan_name)
self.assertEqual(data["plan"]["name"], entitlement.plan_name) self.assertEqual(data["plan"]["name"], entitlement.plan_name)
self.assertEqual(data["plan"]["code"], f"plan-{entitlement.source_plan_id}")
self.assertEqual(data["expires_at"], entitlement.expires_at.isoformat())
self.assertEqual(
data["grace_expires_at"],
entitlement.grace_expires_at.isoformat(),
)
self.assertEqual(data["access_source"], "entitlement")
self.assertEqual(data["entitlement_status"], "active")
@override_settings(CMSHOPEE_SUBSCRIPTION_MODE="enforce")
def test_cmshopee_subscription_status_is_expired_after_grace_period(self): def test_cmshopee_subscription_status_is_expired_after_grace_period(self):
self.create_cmshopee_entitlement( self.create_cmshopee_entitlement(
starts_at=timezone.now() - timedelta(days=40), starts_at=timezone.now() - timedelta(days=40),
@@ -1485,8 +1588,9 @@ class GenerateApiTests(TestCase):
self.assertEqual(data["status"], "expired") self.assertEqual(data["status"], "expired")
self.assertFalse(data["allowed"]) self.assertFalse(data["allowed"])
self.assertEqual(data["code"], "subscription_expired") self.assertEqual(data["code"], "subscription_expired")
self.assertEqual(data["entitlement_status"], "expired")
@override_settings(CMSHOPEE_SUBSCRIPTION_ENFORCEMENT=True) @override_settings(CMSHOPEE_SUBSCRIPTION_MODE="enforce")
def test_cmshopee_enforcement_rejects_without_subscription(self): def test_cmshopee_enforcement_rejects_without_subscription(self):
response = self.post_with_provider( response = self.post_with_provider(
"/api/v1/cmshopee/generate/title", "/api/v1/cmshopee/generate/title",
@@ -1498,7 +1602,7 @@ class GenerateApiTests(TestCase):
self.assertEqual(self.provider.text_calls, []) self.assertEqual(self.provider.text_calls, [])
self.assert_generation_not_charged() self.assert_generation_not_charged()
@override_settings(CMSHOPEE_SUBSCRIPTION_ENFORCEMENT=True) @override_settings(CMSHOPEE_SUBSCRIPTION_MODE="enforce")
def test_cmshopee_account_subscription_allows_multiple_device_contexts(self): def test_cmshopee_account_subscription_allows_multiple_device_contexts(self):
self.create_cmshopee_entitlement() self.create_cmshopee_entitlement()
@@ -1516,7 +1620,7 @@ class GenerateApiTests(TestCase):
self.assertEqual(second_response.status_code, 200) self.assertEqual(second_response.status_code, 200)
self.assertEqual(len(self.provider.text_calls), 2) self.assertEqual(len(self.provider.text_calls), 2)
@override_settings(CMSHOPEE_SUBSCRIPTION_ENFORCEMENT=True) @override_settings(CMSHOPEE_SUBSCRIPTION_MODE="enforce")
def test_generic_generation_remains_available_without_subscription(self): def test_generic_generation_remains_available_without_subscription(self):
response = self.post_with_provider( response = self.post_with_provider(
"/api/v1/generate/title", "/api/v1/generate/title",
+15 -11
View File
@@ -1,7 +1,6 @@
import logging import logging
from django.http import HttpResponse from django.http import HttpResponse
from django.conf import settings
from django.utils import timezone from django.utils import timezone
from django.utils.decorators import method_decorator from django.utils.decorators import method_decorator
from django.views.decorators.csrf import csrf_exempt from django.views.decorators.csrf import csrf_exempt
@@ -79,7 +78,7 @@ from apps.licensing.services import (
create_migration_request, create_migration_request,
expire_software_order, expire_software_order,
query_and_apply_software_payment, query_and_apply_software_payment,
evaluate_account_authorization, evaluate_subscription_access,
software_subscription_status, software_subscription_status,
record_device_heartbeat, record_device_heartbeat,
register_device, register_device,
@@ -391,7 +390,7 @@ class GenerateImageTaskDetailView(ExternalApiView):
return Response(task_detail_response(task), status=status.HTTP_200_OK) return Response(task_detail_response(task), status=status.HTTP_200_OK)
class CmshopeeShadowAuthorizationMixin: class CmshopeeSubscriptionAuthorizationMixin:
product_code = "cmshopee" product_code = "cmshopee"
def optional_client_device(self, request): def optional_client_device(self, request):
@@ -403,7 +402,7 @@ class CmshopeeShadowAuthorizationMixin:
request.headers.get("X-Device-Session", "").strip() request.headers.get("X-Device-Session", "").strip()
) )
device = None device = None
decision = evaluate_account_authorization( decision = evaluate_subscription_access(
user=request.user, user=request.user,
product_code=self.product_code, product_code=self.product_code,
) )
@@ -412,9 +411,14 @@ class CmshopeeShadowAuthorizationMixin:
"product_code": self.product_code, "product_code": self.product_code,
"user_id": request.user.id, "user_id": request.user.id,
"client_device_id": getattr(device, "id", None), "client_device_id": getattr(device, "id", None),
"would_reject": decision.would_reject, "would_reject": bool(decision.entitlement_code),
"would_reject_code": decision.code, "would_reject_code": decision.entitlement_code,
"enforcement": settings.CMSHOPEE_SUBSCRIPTION_ENFORCEMENT, "final_allowed": decision.allowed,
"final_code": decision.code,
"subscription_mode": decision.mode,
"access_source": decision.access_source,
"entitlement_status": decision.entitlement_status,
"enforcement": decision.mode == "enforce",
} }
authorization_logger.info( authorization_logger.info(
"%s %s", "%s %s",
@@ -422,7 +426,7 @@ class CmshopeeShadowAuthorizationMixin:
event, event,
extra={"cmshopee_authorization": event}, extra={"cmshopee_authorization": event},
) )
if settings.CMSHOPEE_SUBSCRIPTION_ENFORCEMENT and decision.would_reject: if decision.would_reject:
message = ( message = (
"当前账号没有有效的软件订阅" "当前账号没有有效的软件订阅"
if decision.code == "subscription_required" if decision.code == "subscription_required"
@@ -433,16 +437,16 @@ class CmshopeeShadowAuthorizationMixin:
return device return device
class CmshopeeGenerateTitleView(CmshopeeShadowAuthorizationMixin, GenerateTitleView): class CmshopeeGenerateTitleView(CmshopeeSubscriptionAuthorizationMixin, GenerateTitleView):
pass pass
class CmshopeeAnalyzeImagesView(CmshopeeShadowAuthorizationMixin, AnalyzeImagesView): class CmshopeeAnalyzeImagesView(CmshopeeSubscriptionAuthorizationMixin, AnalyzeImagesView):
pass pass
class CmshopeeGenerateImageTaskSubmitView( class CmshopeeGenerateImageTaskSubmitView(
CmshopeeShadowAuthorizationMixin, CmshopeeSubscriptionAuthorizationMixin,
GenerateImageTaskSubmitView, GenerateImageTaskSubmitView,
): ):
pass pass
+18 -14
View File
@@ -28,6 +28,13 @@ from .services import (
) )
class HiddenFromAdminIndexMixin:
"""Keep registered models addressable without adding menu noise."""
def get_model_perms(self, request):
return {}
def _masked_fingerprint(value: str) -> str: def _masked_fingerprint(value: str) -> str:
if not value: if not value:
return "" return ""
@@ -35,7 +42,7 @@ def _masked_fingerprint(value: str) -> str:
@admin.register(ClientDevice) @admin.register(ClientDevice)
class ClientDeviceAdmin(admin.ModelAdmin): class ClientDeviceAdmin(HiddenFromAdminIndexMixin, admin.ModelAdmin):
list_display = ( list_display = (
"user", "user",
"product_code", "product_code",
@@ -81,7 +88,7 @@ class ClientDeviceAdmin(admin.ModelAdmin):
@admin.register(DeviceSession) @admin.register(DeviceSession)
class DeviceSessionAdmin(admin.ModelAdmin): class DeviceSessionAdmin(HiddenFromAdminIndexMixin, admin.ModelAdmin):
list_display = ("device", "expires_at", "last_used_at", "revoked_at", "created_at") list_display = ("device", "expires_at", "last_used_at", "revoked_at", "created_at")
list_filter = ("revoked_at", "expires_at") list_filter = ("revoked_at", "expires_at")
search_fields = ("device__user__username", "device__user__email") search_fields = ("device__user__username", "device__user__email")
@@ -106,7 +113,7 @@ class DeviceSessionAdmin(admin.ModelAdmin):
@admin.register(DeviceBindingAudit) @admin.register(DeviceBindingAudit)
class DeviceBindingAuditAdmin(admin.ModelAdmin): class DeviceBindingAuditAdmin(HiddenFromAdminIndexMixin, admin.ModelAdmin):
list_display = ("user", "device", "api_key", "action", "reason", "created_at") list_display = ("user", "device", "api_key", "action", "reason", "created_at")
list_filter = ("action", "created_at") list_filter = ("action", "created_at")
search_fields = ("user__username", "user__email", "api_key__key_prefix") search_fields = ("user__username", "user__email", "api_key__key_prefix")
@@ -334,15 +341,12 @@ class ReadOnlyLicenseAdmin(admin.ModelAdmin):
return False return False
class HiddenLegacyLicenseAdmin(ReadOnlyLicenseAdmin): class HiddenLicenseAdmin(HiddenFromAdminIndexMixin, ReadOnlyLicenseAdmin):
"""Keep legacy records addressable without advertising them in admin.""" pass
def get_model_perms(self, request):
return {}
@admin.register(LicenseSeat) @admin.register(LicenseSeat)
class LicenseSeatAdmin(HiddenLegacyLicenseAdmin): class LicenseSeatAdmin(HiddenLicenseAdmin):
list_display = ("entitlement", "seat_number", "device", "bound_at", "released_at") list_display = ("entitlement", "seat_number", "device", "bound_at", "released_at")
list_filter = ("entitlement__product_code", "bound_at", "released_at") list_filter = ("entitlement__product_code", "bound_at", "released_at")
search_fields = ( search_fields = (
@@ -354,7 +358,7 @@ class LicenseSeatAdmin(HiddenLegacyLicenseAdmin):
@admin.register(LicenseEvent) @admin.register(LicenseEvent)
class LicenseEventAdmin(ReadOnlyLicenseAdmin): class LicenseEventAdmin(HiddenLicenseAdmin):
list_display = ("entitlement", "action", "seat", "device", "actor", "reason", "created_at") list_display = ("entitlement", "action", "seat", "device", "actor", "reason", "created_at")
list_filter = ("action", "entitlement__product_code", "created_at") list_filter = ("action", "entitlement__product_code", "created_at")
search_fields = ( search_fields = (
@@ -370,7 +374,7 @@ class LegacyMigrationGrantForm(EntitlementGrantForm):
@admin.register(LegacyMigrationGrant) @admin.register(LegacyMigrationGrant)
class LegacyMigrationGrantAdmin(HiddenLegacyLicenseAdmin): class LegacyMigrationGrantAdmin(HiddenLicenseAdmin):
list_display = ("user", "product_code", "entitlement", "status", "actor", "created_at") list_display = ("user", "product_code", "entitlement", "status", "actor", "created_at")
list_filter = ("product_code", "status", "created_at") list_filter = ("product_code", "status", "created_at")
search_fields = ("user__username", "user__email", "reason") search_fields = ("user__username", "user__email", "reason")
@@ -417,7 +421,7 @@ class LegacyMigrationGrantAdmin(HiddenLegacyLicenseAdmin):
@admin.register(MigrationRequest) @admin.register(MigrationRequest)
class MigrationRequestAdmin(HiddenLegacyLicenseAdmin): class MigrationRequestAdmin(HiddenLicenseAdmin):
list_display = ("request_id", "user", "device", "migration_grant", "status", "expires_at", "confirmed_at") list_display = ("request_id", "user", "device", "migration_grant", "status", "expires_at", "confirmed_at")
list_filter = ("status", "device__product_code", "expires_at") list_filter = ("status", "device__product_code", "expires_at")
search_fields = ("=request_id", "user__username", "user__email") search_fields = ("=request_id", "user__username", "user__email")
@@ -425,7 +429,7 @@ class MigrationRequestAdmin(HiddenLegacyLicenseAdmin):
@admin.register(DeviceCredential) @admin.register(DeviceCredential)
class DeviceCredentialAdmin(HiddenLegacyLicenseAdmin): class DeviceCredentialAdmin(HiddenLicenseAdmin):
list_display = ("token_prefix", "user", "product_code", "device", "entitlement", "expires_at", "revoked_at") list_display = ("token_prefix", "user", "product_code", "device", "entitlement", "expires_at", "revoked_at")
list_filter = ("product_code", "revoked_at", "expires_at") list_filter = ("product_code", "revoked_at", "expires_at")
search_fields = ("token_prefix", "user__username", "user__email") search_fields = ("token_prefix", "user__username", "user__email")
@@ -433,7 +437,7 @@ class DeviceCredentialAdmin(HiddenLegacyLicenseAdmin):
@admin.register(SoftwareOrder) @admin.register(SoftwareOrder)
class SoftwareOrderAdmin(ReadOnlyLicenseAdmin): class SoftwareOrderAdmin(HiddenLicenseAdmin):
list_display = ( list_display = (
"created_at", "created_at",
"order_no", "order_no",
@@ -0,0 +1,21 @@
# Generated by Django 5.2.15 on 2026-07-22 07:04
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('licensing', '0004_alter_licenseevent_action_softwareorder'),
]
operations = [
migrations.AlterModelOptions(
name='softwareentitlement',
options={'ordering': ('-expires_at', '-id'), 'verbose_name': '用户会员', 'verbose_name_plural': '用户会员'},
),
migrations.AlterModelOptions(
name='softwareplan',
options={'ordering': ('product_code', 'name', 'id'), 'verbose_name': '会员套餐', 'verbose_name_plural': '会员套餐'},
),
]
+4 -4
View File
@@ -108,8 +108,8 @@ class SoftwarePlan(models.Model):
class Meta: class Meta:
db_table = "software_plan" db_table = "software_plan"
verbose_name = "软件套餐" verbose_name = "会员套餐"
verbose_name_plural = "软件套餐" verbose_name_plural = "会员套餐"
ordering = ("product_code", "name", "id") ordering = ("product_code", "name", "id")
constraints = [ constraints = [
models.CheckConstraint( models.CheckConstraint(
@@ -178,8 +178,8 @@ class SoftwareEntitlement(models.Model):
class Meta: class Meta:
db_table = "software_entitlement" db_table = "software_entitlement"
verbose_name = "软件权益" verbose_name = "用户会员"
verbose_name_plural = "软件权益" verbose_name_plural = "用户会员"
ordering = ("-expires_at", "-id") ordering = ("-expires_at", "-id")
constraints = [ constraints = [
models.CheckConstraint( models.CheckConstraint(
+139 -17
View File
@@ -2,7 +2,7 @@ from __future__ import annotations
import secrets import secrets
from dataclasses import dataclass from dataclasses import dataclass
from datetime import timedelta from datetime import datetime, timedelta, timezone as datetime_timezone
from decimal import Decimal from decimal import Decimal
from decimal import InvalidOperation from decimal import InvalidOperation
@@ -98,6 +98,22 @@ class SubscriptionAuthorizationDecision:
return not self.allowed return not self.allowed
@dataclass(frozen=True)
class SubscriptionAccessDecision:
product_code: str
mode: str
allowed: bool
code: str
access_source: str
entitlement_status: str
entitlement_code: str
entitlement: SoftwareEntitlement | None = None
@property
def would_reject(self) -> bool:
return not self.allowed
@dataclass(frozen=True) @dataclass(frozen=True)
class DeviceRegistrationResult: class DeviceRegistrationResult:
device: ClientDevice device: ClientDevice
@@ -970,27 +986,133 @@ def evaluate_account_authorization(*, user, product_code: str, now=None):
) )
def software_subscription_status(*, user, product_code: str, now=None) -> dict: def _real_entitlement_status(decision, now) -> str:
decision = evaluate_account_authorization( if decision.allowed:
if decision.entitlement and decision.entitlement.expires_at <= now:
return "grace"
return "active"
if decision.code == "subscription_expired":
return "expired"
return "required"
def get_subscription_mode() -> str:
mode = str(getattr(settings, "CMSHOPEE_SUBSCRIPTION_MODE", "") or "").strip().lower()
if mode in {"open", "shadow", "enforce"}:
return mode
return (
"enforce"
if getattr(settings, "CMSHOPEE_SUBSCRIPTION_ENFORCEMENT", False)
else "open"
)
def evaluate_subscription_access(*, user, product_code: str, now=None, mode=None):
now = now or timezone.now()
mode = mode or get_subscription_mode()
real_decision = evaluate_account_authorization(
user=user, user=user,
product_code=product_code, product_code=product_code,
now=now, now=now,
) )
entitlement = decision.entitlement entitlement_status = _real_entitlement_status(real_decision, now)
return {
"product_code": product_code, if mode == "open":
"status": ( return SubscriptionAccessDecision(
"active" product_code=product_code,
if decision.allowed mode=mode,
else ("expired" if decision.code == "subscription_expired" else "required") allowed=True,
), code="",
"allowed": decision.allowed, access_source="open_mode",
"code": decision.code or None, entitlement_status=entitlement_status,
"plan": { entitlement_code=real_decision.code,
)
if mode == "shadow" and not real_decision.allowed:
return SubscriptionAccessDecision(
product_code=product_code,
mode=mode,
allowed=True,
code="",
access_source="shadow_fallback",
entitlement_status=entitlement_status,
entitlement_code=real_decision.code,
)
return SubscriptionAccessDecision(
product_code=product_code,
mode=mode,
allowed=real_decision.allowed,
code=real_decision.code,
access_source="entitlement",
entitlement_status=entitlement_status,
entitlement_code=real_decision.code,
entitlement=real_decision.entitlement,
)
def _subscription_manage_url() -> str:
base_url = str(getattr(settings, "PUBLIC_BASE_URL", "") or "").rstrip("/")
return f"{base_url}/subscription" if base_url else "/subscription"
def _effective_plan(access):
entitlement = access.entitlement
if entitlement is not None:
plan_code = (
f"plan-{entitlement.source_plan_id}"
if entitlement.source_plan_id
else f"entitlement-{entitlement.id}"
)
return {
"code": plan_code,
"display_name": entitlement.plan_name,
# T-630 compatibility fields; new clients use the top-level dates.
"name": entitlement.plan_name, "name": entitlement.plan_name,
"expires_at": entitlement.expires_at.isoformat(), "expires_at": entitlement.expires_at.isoformat(),
"grace_expires_at": entitlement.grace_expires_at.isoformat(), "grace_expires_at": entitlement.grace_expires_at.isoformat(),
} }, entitlement.expires_at, entitlement.grace_expires_at
if entitlement is not None if access.access_source in {"open_mode", "shadow_fallback"}:
else None, expires_at = datetime(2099, 12, 31, 23, 59, 59, tzinfo=datetime_timezone.utc)
plan_code = "development-open" if access.access_source == "open_mode" else "shadow-fallback"
display_name = "开发测试长期会员" if access.access_source == "open_mode" else "订阅过渡会员"
return {
"code": plan_code,
"display_name": display_name,
"name": display_name,
"expires_at": expires_at.isoformat(),
"grace_expires_at": None,
}, expires_at, None
return None, None, None
def software_subscription_status(*, user, product_code: str, now=None, mode=None) -> dict:
access = evaluate_subscription_access(
user=user,
product_code=product_code,
now=now,
mode=mode,
)
plan, expires_at, grace_expires_at = _effective_plan(access)
username = user.get_username()
return {
"product_code": product_code,
"status": (
access.entitlement_status
if access.access_source == "entitlement"
else "active"
),
"allowed": access.allowed,
"code": access.code or None,
"account": {
"display_name": user.get_full_name() or username,
"username": username,
},
"plan": plan,
"expires_at": expires_at.isoformat() if expires_at else None,
"grace_expires_at": grace_expires_at.isoformat() if grace_expires_at else None,
"manage_url": _subscription_manage_url(),
"notice_id": (
f"{product_code}-subscription-{access.mode}-{access.entitlement_status}-v1"
),
"access_source": access.access_source,
"entitlement_status": access.entitlement_status,
} }
+142 -9
View File
@@ -2,10 +2,11 @@ from datetime import timedelta
from decimal import Decimal from decimal import Decimal
from concurrent.futures import ThreadPoolExecutor from concurrent.futures import ThreadPoolExecutor
from types import SimpleNamespace from types import SimpleNamespace
from unittest.mock import patch
from django.contrib import admin from django.contrib import admin
from django.db import close_old_connections from django.db import close_old_connections
from django.test import TestCase, TransactionTestCase, override_settings from django.test import SimpleTestCase, TestCase, TransactionTestCase, override_settings
from django.urls import reverse from django.urls import reverse
from django.utils import timezone from django.utils import timezone
from rest_framework.test import APIClient from rest_framework.test import APIClient
@@ -29,6 +30,7 @@ from apps.licensing.services import (
LicensingError, LicensingError,
SoftwareOrderAmountMismatchError, SoftwareOrderAmountMismatchError,
SoftwareOrderTransactionMismatchError, SoftwareOrderTransactionMismatchError,
SubscriptionAuthorizationDecision,
apply_software_payment, apply_software_payment,
assign_license_seat, assign_license_seat,
confirm_migration_request, confirm_migration_request,
@@ -36,16 +38,139 @@ from apps.licensing.services import (
create_migration_request, create_migration_request,
create_software_order, create_software_order,
evaluate_device_authorization, evaluate_device_authorization,
evaluate_subscription_access,
get_subscription_mode,
grant_software_entitlement, grant_software_entitlement,
record_device_heartbeat, record_device_heartbeat,
release_license_seat, release_license_seat,
renew_software_entitlement, renew_software_entitlement,
revoke_software_entitlement, revoke_software_entitlement,
register_device, register_device,
software_subscription_status,
) )
from apps.users.models import ApiKey, User, UserWallet from apps.users.models import ApiKey, User, UserWallet
class SubscriptionModeUnitTests(SimpleTestCase):
@override_settings(
CMSHOPEE_SUBSCRIPTION_MODE="",
CMSHOPEE_SUBSCRIPTION_ENFORCEMENT=True,
)
def test_legacy_enforcement_setting_maps_to_enforce(self):
self.assertEqual(get_subscription_mode(), "enforce")
@override_settings(CMSHOPEE_SUBSCRIPTION_MODE="shadow")
@patch("apps.licensing.services.evaluate_account_authorization")
def test_shadow_access_keeps_real_rejection_for_observation(self, evaluate_mock):
evaluate_mock.return_value = SubscriptionAuthorizationDecision(
product_code="cmshopee",
allowed=False,
code="subscription_required",
)
access = evaluate_subscription_access(
user=SimpleNamespace(),
product_code="cmshopee",
)
self.assertTrue(access.allowed)
self.assertEqual(access.access_source, "shadow_fallback")
self.assertEqual(access.entitlement_status, "required")
self.assertEqual(access.entitlement_code, "subscription_required")
@patch("apps.licensing.services.evaluate_account_authorization")
def test_open_status_has_client_contract_without_database_entitlement(self, evaluate_mock):
evaluate_mock.return_value = SubscriptionAuthorizationDecision(
product_code="cmshopee",
allowed=False,
code="subscription_required",
)
user = SimpleNamespace(
get_username=lambda: "desktop-user",
get_full_name=lambda: "",
)
data = software_subscription_status(
user=user,
product_code="cmshopee",
mode="open",
)
self.assertEqual(data["status"], "active")
self.assertTrue(data["allowed"])
self.assertEqual(data["account"]["display_name"], "desktop-user")
self.assertEqual(data["plan"]["code"], "development-open")
self.assertEqual(data["expires_at"], data["plan"]["expires_at"])
self.assertEqual(data["access_source"], "open_mode")
self.assertEqual(data["entitlement_status"], "required")
def test_admin_index_only_exposes_membership_models_without_deleting_registration(self):
hidden_models = (
ClientDevice,
DeviceSession,
DeviceBindingAudit,
LicenseSeat,
LegacyMigrationGrant,
MigrationRequest,
DeviceCredential,
SoftwareOrder,
LicenseEvent,
)
user = SimpleNamespace(
is_active=True,
is_staff=True,
is_superuser=True,
has_perm=lambda *_args, **_kwargs: True,
has_module_perms=lambda *_args, **_kwargs: True,
)
request = SimpleNamespace(user=user)
for model in hidden_models:
self.assertIn(model, admin.site._registry)
self.assertEqual(admin.site._registry[model].get_model_perms(request), {})
self.assertTrue(admin.site._registry[SoftwarePlan].get_model_perms(request))
self.assertTrue(admin.site._registry[SoftwareEntitlement].get_model_perms(request))
self.assertEqual(SoftwarePlan._meta.verbose_name, "会员套餐")
self.assertEqual(SoftwareEntitlement._meta.verbose_name, "用户会员")
@patch("apps.licensing.services.evaluate_account_authorization")
def test_enforce_status_reports_real_grace_period_at_top_level(self, evaluate_mock):
now = timezone.now()
entitlement = SimpleNamespace(
id=7,
source_plan_id=3,
plan_name="专业版",
expires_at=now - timedelta(days=1),
grace_expires_at=now + timedelta(days=2),
)
evaluate_mock.return_value = SubscriptionAuthorizationDecision(
product_code="cmshopee",
allowed=True,
code="",
entitlement=entitlement,
)
user = SimpleNamespace(
get_username=lambda: "member-user",
get_full_name=lambda: "会员用户",
)
data = software_subscription_status(
user=user,
product_code="cmshopee",
now=now,
mode="enforce",
)
self.assertEqual(data["status"], "grace")
self.assertEqual(data["entitlement_status"], "grace")
self.assertEqual(data["plan"]["code"], "plan-3")
self.assertEqual(data["expires_at"], entitlement.expires_at.isoformat())
self.assertEqual(
data["grace_expires_at"],
entitlement.grace_expires_at.isoformat(),
)
class DeviceRegistrationApiTests(TestCase): class DeviceRegistrationApiTests(TestCase):
def setUp(self): def setUp(self):
self.user = User.objects.create_user( self.user = User.objects.create_user(
@@ -550,22 +675,22 @@ class SoftwareEntitlementAdminTests(TestCase):
).exists() ).exists()
) )
def test_admin_hides_legacy_license_models_but_keeps_current_models_visible(self): def test_admin_index_only_exposes_membership_models(self):
hidden_models = ( hidden_models = (
ClientDevice,
DeviceSession,
DeviceBindingAudit,
LicenseSeat, LicenseSeat,
LegacyMigrationGrant, LegacyMigrationGrant,
MigrationRequest, MigrationRequest,
DeviceCredential, DeviceCredential,
)
visible_models = (
ClientDevice,
DeviceSession,
DeviceBindingAudit,
SoftwarePlan,
SoftwareEntitlement,
SoftwareOrder, SoftwareOrder,
LicenseEvent, LicenseEvent,
) )
visible_models = (
SoftwarePlan,
SoftwareEntitlement,
)
request = SimpleNamespace(user=self.operator) request = SimpleNamespace(user=self.operator)
for model in hidden_models: for model in hidden_models:
@@ -576,6 +701,14 @@ class SoftwareEntitlementAdminTests(TestCase):
self.assertTrue(LicenseSeat.objects.model._meta.db_table) self.assertTrue(LicenseSeat.objects.model._meta.db_table)
self.assertTrue(DeviceCredential.objects.model._meta.db_table) self.assertTrue(DeviceCredential.objects.model._meta.db_table)
self.client.force_login(self.operator)
self.assertEqual(
self.client.get(reverse("admin:licensing_softwareorder_changelist")).status_code,
200,
)
self.assertEqual(SoftwarePlan._meta.verbose_name, "会员套餐")
self.assertEqual(SoftwareEntitlement._meta.verbose_name, "用户会员")
class LicenseSeatConcurrencyTests(TransactionTestCase): class LicenseSeatConcurrencyTests(TransactionTestCase):
def setUp(self): def setUp(self):
+12 -1
View File
@@ -14,6 +14,8 @@ import os
from decimal import Decimal from decimal import Decimal
from pathlib import Path from pathlib import Path
from django.core.exceptions import ImproperlyConfigured
# Build paths inside the project like this: BASE_DIR / 'subdir'. # Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = Path(__file__).resolve().parent.parent BASE_DIR = Path(__file__).resolve().parent.parent
@@ -68,7 +70,16 @@ DEVICE_SESSION_TTL_SECONDS = env_int("DEVICE_SESSION_TTL_SECONDS", 3600)
DEVICE_ACTIVITY_UPDATE_SECONDS = env_int("DEVICE_ACTIVITY_UPDATE_SECONDS", 86400) DEVICE_ACTIVITY_UPDATE_SECONDS = env_int("DEVICE_ACTIVITY_UPDATE_SECONDS", 86400)
MIGRATION_REQUEST_TTL_SECONDS = env_int("MIGRATION_REQUEST_TTL_SECONDS", 900) MIGRATION_REQUEST_TTL_SECONDS = env_int("MIGRATION_REQUEST_TTL_SECONDS", 900)
CMSHOPEE_AUTHORIZATION_SHADOW_MODE = env_bool("CMSHOPEE_AUTHORIZATION_SHADOW_MODE", True) CMSHOPEE_AUTHORIZATION_SHADOW_MODE = env_bool("CMSHOPEE_AUTHORIZATION_SHADOW_MODE", True)
CMSHOPEE_SUBSCRIPTION_ENFORCEMENT = env_bool("CMSHOPEE_SUBSCRIPTION_ENFORCEMENT", False) _legacy_subscription_enforcement = env_bool("CMSHOPEE_SUBSCRIPTION_ENFORCEMENT", False)
CMSHOPEE_SUBSCRIPTION_MODE = os.environ.get("CMSHOPEE_SUBSCRIPTION_MODE", "").strip().lower()
if not CMSHOPEE_SUBSCRIPTION_MODE:
CMSHOPEE_SUBSCRIPTION_MODE = "enforce" if _legacy_subscription_enforcement else "open"
if CMSHOPEE_SUBSCRIPTION_MODE not in {"open", "shadow", "enforce"}:
raise ImproperlyConfigured(
"CMSHOPEE_SUBSCRIPTION_MODE must be one of: open, shadow, enforce"
)
# Kept as an effective compatibility setting for integrations that still inspect it.
CMSHOPEE_SUBSCRIPTION_ENFORCEMENT = CMSHOPEE_SUBSCRIPTION_MODE == "enforce"
# SECURITY WARNING: don't run with debug turned on in production! # SECURITY WARNING: don't run with debug turned on in production!
DEBUG = env_bool("DJANGO_DEBUG", True) DEBUG = env_bool("DJANGO_DEBUG", True)
+4 -2
View File
@@ -20,7 +20,7 @@
- **用户端层(Django 模板 SSR)**:公开首页、注册/登录(Django auth / allauth)、个人中心(余额/充值总额/充值记录/点数记录)、API Key 自助管理、发起扫码充值、模型目录与客户端下载入口。入口 `apps/portal/`,公开首页匿名可访问,其他自助页面用 session 鉴权。T-501/T-608 已落地 `/signup`、`/login`、`/logout` 与 `/dashboard`;注册成功后经计费层一次性发放 10 点试用点数,并写 `signup_bonus` 点数流水。T-502 已落地 `/apikeys`,用户可自助生成和删除(吊销)自己的 API Key,明文只显示一次,列表只显示 prefix。T-503/T-608 已扩展 `/dashboard` 并新增 `/records/recharge`、`/records/usage`,只读展示当前用户余额、充值订单、注册赠点与消费/退款流水。T-504 已落地 `/recharge`,用户可创建 pending 充值订单、查看二维码票据,并轮询订单状态;到账仍以服务端回调或主动查单入账后的本地订单状态为准。T-505 已把 Bootstrap/qrcode.js 改成本地 static 自托管,并把充值/点数记录页从固定切片改为分页。T-606 已把 `/` 改为公开首页,并新增 `DownloadRelease` 下载版本配置用于展示 Windows 客户端版本、下载地址、SHA256 与发布说明;T-607/T-609/T-617 已新增公开 JSON 版本检查接口给桌面端自动更新使用,并返回强制更新标记和安装包文件大小字节数。T-610 已在公开首页下载区增加导入模板下载入口,由后台 `ImportTemplate` 配置当前模板。 - **用户端层(Django 模板 SSR)**:公开首页、注册/登录(Django auth / allauth)、个人中心(余额/充值总额/充值记录/点数记录)、API Key 自助管理、发起扫码充值、模型目录与客户端下载入口。入口 `apps/portal/`,公开首页匿名可访问,其他自助页面用 session 鉴权。T-501/T-608 已落地 `/signup`、`/login`、`/logout` 与 `/dashboard`;注册成功后经计费层一次性发放 10 点试用点数,并写 `signup_bonus` 点数流水。T-502 已落地 `/apikeys`,用户可自助生成和删除(吊销)自己的 API Key,明文只显示一次,列表只显示 prefix。T-503/T-608 已扩展 `/dashboard` 并新增 `/records/recharge`、`/records/usage`,只读展示当前用户余额、充值订单、注册赠点与消费/退款流水。T-504 已落地 `/recharge`,用户可创建 pending 充值订单、查看二维码票据,并轮询订单状态;到账仍以服务端回调或主动查单入账后的本地订单状态为准。T-505 已把 Bootstrap/qrcode.js 改成本地 static 自托管,并把充值/点数记录页从固定切片改为分页。T-606 已把 `/` 改为公开首页,并新增 `DownloadRelease` 下载版本配置用于展示 Windows 客户端版本、下载地址、SHA256 与发布说明;T-607/T-609/T-617 已新增公开 JSON 版本检查接口给桌面端自动更新使用,并返回强制更新标记和安装包文件大小字节数。T-610 已在公开首页下载区增加导入模板下载入口,由后台 `ImportTemplate` 配置当前模板。
- **用户与账号层**:注册用户 `User`、点数钱包 `UserWallet`、`ApiKey`(一用户多把、哈希存储)。入口 `apps/users/`。 - **用户与账号层**:注册用户 `User`、点数钱包 `UserWallet`、`ApiKey`(一用户多把、哈希存储)。入口 `apps/users/`。
- **软件授权层**:蝦皮圈客户端设备、短期设备会话、套餐、权益、设备席位和授权审计。入口 `apps/licensing/`;T-624/T-625 建立设备观测及调用关联,T-626 建立套餐/权益/席位基础,均不改变通用 API Key 或点数授权;专属订阅校验由后续任务实现。 - **软件授权层**:蝦皮圈客户端设备、短期设备会话、套餐、权益、设备席位和授权审计。入口 `apps/licensing/`;T-624/T-625 建立设备观测及调用关联,T-626 建立套餐/权益/席位基础,T-630/T-632 已将产品专属授权切换为账号订阅三阶段模式;通用 API Key 或点数授权不变。
- **API 层(DRF)**:对外生成接口、异步图片任务接口、余额查询、支付回调接收、扫码下单、公开版本检查接口。入口 `apps/api/`。生成/余额/模型目录这类对外业务 API **只认 API Key,不接受 Web session**;充值下单/状态查询属于用户端流程,走 Web session + CSRF;支付回调走平台验签;T-607/T-609/T-617 的客户端下载版本检查接口为公开只读例外,不需要 API Key,不读取用户、不扣点,`release.force_update` 仅表示当前发布版本是否强制升级,`release.size_bytes` 仅表示下载文件大小字节数。 - **API 层(DRF)**:对外生成接口、异步图片任务接口、余额查询、支付回调接收、扫码下单、公开版本检查接口。入口 `apps/api/`。生成/余额/模型目录这类对外业务 API **只认 API Key,不接受 Web session**;充值下单/状态查询属于用户端流程,走 Web session + CSRF;支付回调走平台验签;T-607/T-609/T-617 的客户端下载版本检查接口为公开只读例外,不需要 API Key,不读取用户、不扣点,`release.force_update` 仅表示当前发布版本是否强制升级,`release.size_bytes` 仅表示下载文件大小字节数。
- **计费层**:点数计算、原子扣减(锁 `UserWallet` 行)、退点、充值入账、流水记账。入口 `apps/billing/`。 - **计费层**:点数计算、原子扣减(锁 `UserWallet` 行)、退点、充值入账、流水记账。入口 `apps/billing/`。
- **AI 调用层(Provider Adapter 架构)**:对外只暴露稳定能力,内部用「能力别名 → 具体供应商适配器」解耦。入口 `apps/ai/`,适配器在 `apps/ai/providers/`。 - **AI 调用层(Provider Adapter 架构)**:对外只暴露稳定能力,内部用「能力别名 → 具体供应商适配器」解耦。入口 `apps/ai/`,适配器在 `apps/ai/providers/`。
@@ -46,7 +46,9 @@ T-626 已在同一 app 建立授权基础:`SoftwarePlan` 可由运营维护产
T-629 已完成:`SoftwareOrder` 属于 `apps.licensing`,与 `RechargeOrder`、`PointsLedger` 完全隔离。订单在创建时锁定产品、套餐、金额、时长、设备数和宽限期快照;支付成功后锁订单,校验金额/通道/交易号,首次购买创建权益,续订延长同套餐权益,并记录一项可反查订单的 `LicenseEvent(order_fulfilled)`。`(pay_method, payment_txn_no)` 唯一且已支付订单只接受原交易号,防止跨订单复用交易号;任何路径不得调用充值入账服务或修改钱包。微信下单、验签与查单只复用通用网关适配,订阅使用独立 notify URL、回调与状态查询路由。第一版不做自动代扣、退款发起或自动回收权益;已支付退款及权益撤销由运营按订单人工处理。 T-629 已完成:`SoftwareOrder` 属于 `apps.licensing`,与 `RechargeOrder`、`PointsLedger` 完全隔离。订单在创建时锁定产品、套餐、金额、时长、设备数和宽限期快照;支付成功后锁订单,校验金额/通道/交易号,首次购买创建权益,续订延长同套餐权益,并记录一项可反查订单的 `LicenseEvent(order_fulfilled)`。`(pay_method, payment_txn_no)` 唯一且已支付订单只接受原交易号,防止跨订单复用交易号;任何路径不得调用充值入账服务或修改钱包。微信下单、验签与查单只复用通用网关适配,订阅使用独立 notify URL、回调与状态查询路由。第一版不做自动代扣、退款发起或自动回收权益;已支付退款及权益撤销由运营按订单人工处理。
T-630 已完成:蝦皮圈产品授权从设备凭证改为账号订阅。`evaluate_account_authorization(user, product_code)` 只查询用户当前有效的 `SoftwareEntitlement`,不读取 `DeviceCredential`、`LicenseSeat` 或 `DeviceSession`;同一用户可从多台设备调用。`ClientDevice` / `DeviceSession` 仍可作为可选观测关联到调用记录,但不参与授权结果。产品专属入口默认只记录账号订阅影子结果,配置 `CMSHOPEE_SUBSCRIPTION_ENFORCEMENT=true` 后才将无订阅/过期结果映射为 403;过期或无效设备会话不会阻断有效账号订阅。通用生成入口继续保持原 API Key + 点数语义。 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-632 同时简化软件授权 admin:应用索引只展示“会员套餐” (`SoftwarePlan`) 与“用户会员” (`SoftwareEntitlement`)。软件订单、授权事件、设备观测和历史设备授权模型仍注册并保留直接管理地址、权限及历史数据,只是不出现在日常导航;该显示策略不改变数据库结构和授权判定。
T-627 已建立存量迁移闭环:运营在 admin 显式创建 `LegacyMigrationGrant`,它关联一项套餐快照权益及资格快照,新注册用户不会自动创建。客户端必须以旧 API Key 加服务端验证的当前设备会话申请 `MigrationRequest`;请求只短时有效,生成的设备凭证明文只在该 API 响应出现一次。portal 仅允许请求所属账号确认,确认事务内分配席位、按请求中已存 token hash 签发 `DeviceCredential`,重复确认只返回既有结果。用户自助解绑会吊销凭证、写授权事件并释放对应席位。原始凭证、机器标识、公钥和 API Key 都不写入迁移记录 / 事件;T-628 才会用凭证参与专属接口影子校验。 T-627 已建立存量迁移闭环:运营在 admin 显式创建 `LegacyMigrationGrant`,它关联一项套餐快照权益及资格快照,新注册用户不会自动创建。客户端必须以旧 API Key 加服务端验证的当前设备会话申请 `MigrationRequest`;请求只短时有效,生成的设备凭证明文只在该 API 响应出现一次。portal 仅允许请求所属账号确认,确认事务内分配席位、按请求中已存 token hash 签发 `DeviceCredential`,重复确认只返回既有结果。用户自助解绑会吊销凭证、写授权事件并释放对应席位。原始凭证、机器标识、公钥和 API Key 都不写入迁移记录 / 事件;T-628 才会用凭证参与专属接口影子校验。
+1 -1
View File
@@ -110,7 +110,7 @@
| T-629 | 软件套餐购买、续订订单与权益入账 | T-626, T-627, T-628, T-304, T-305 | **已完成。** 新增与 `RechargeOrder`、`PointsLedger` 完全分离的 `SoftwareOrder` 和 `licensing.0004_alter_licenseevent_action_softwareorder`。订单锁定产品、套餐、金额、币种、时长、设备数、宽限期和通道快照,保存关联权益、唯一 `order_fulfilled` 事件和发放时间;`order_no` 与 `(pay_method, payment_txn_no)` 受数据库唯一约束,已支付订单收到不同交易号一律拒绝。微信支付仅复用通用验签、下单、查单适配,新增独立 `SOFTWARE_WECHAT_PAY_NOTIFY_URL`、`POST /api/v1/software-orders/callback/wechat` 与 `GET /api/v1/software-orders/status`,未调用 `apply_recharge_payment()`、未写 `UserWallet` / `PointsLedger`。portal 新增 `/subscription` 套餐购买/续订、状态和最近订单,admin 新增只读软件套餐订单检索。成功回调/查单锁订单后首次创建权益或续订同套餐权益;同订单重复/并发回调最多发放一次,金额/通道/验签不一致不发权益。支付票据到期转 `expired`,下单失败转 `failed`。第一版仅微信主动月度续订,不做自动代扣、退款发起或自动退款回收;已支付退款与权益撤销由运营按订单人工处理。**同时修复 T-626 遗留**:续期同步刷新未吊销凭证到期时间,撤销权益逐项吊销凭证、释放席位并写审计。已通过 12 条 licensing/API 目标测试(含双连接并发回调)和 31 条既有充值回归;`check`、`makemigrations --check --dry-run`、编译检查通过。全量测试在远端 MySQL 测试库运行 10 分钟未返回且无失败输出,未将其记为通过。 | DONE | | T-629 | 软件套餐购买、续订订单与权益入账 | T-626, T-627, T-628, T-304, T-305 | **已完成。** 新增与 `RechargeOrder`、`PointsLedger` 完全分离的 `SoftwareOrder` 和 `licensing.0004_alter_licenseevent_action_softwareorder`。订单锁定产品、套餐、金额、币种、时长、设备数、宽限期和通道快照,保存关联权益、唯一 `order_fulfilled` 事件和发放时间;`order_no` 与 `(pay_method, payment_txn_no)` 受数据库唯一约束,已支付订单收到不同交易号一律拒绝。微信支付仅复用通用验签、下单、查单适配,新增独立 `SOFTWARE_WECHAT_PAY_NOTIFY_URL`、`POST /api/v1/software-orders/callback/wechat` 与 `GET /api/v1/software-orders/status`,未调用 `apply_recharge_payment()`、未写 `UserWallet` / `PointsLedger`。portal 新增 `/subscription` 套餐购买/续订、状态和最近订单,admin 新增只读软件套餐订单检索。成功回调/查单锁订单后首次创建权益或续订同套餐权益;同订单重复/并发回调最多发放一次,金额/通道/验签不一致不发权益。支付票据到期转 `expired`,下单失败转 `failed`。第一版仅微信主动月度续订,不做自动代扣、退款发起或自动退款回收;已支付退款与权益撤销由运营按订单人工处理。**同时修复 T-626 遗留**:续期同步刷新未吊销凭证到期时间,撤销权益逐项吊销凭证、释放席位并写审计。已通过 12 条 licensing/API 目标测试(含双连接并发回调)和 31 条既有充值回归;`check`、`makemigrations --check --dry-run`、编译检查通过。全量测试在远端 MySQL 测试库运行 10 分钟未返回且无失败输出,未将其记为通过。 | DONE |
| T-630 | 账号订阅授权与默认多设备模式 | T-628, T-629 | **已完成。** 将蝦皮圈产品专属授权从“设备会话 + 设备凭证”调整为“API Key 对应账号 + 软件订阅权益”。同一账号默认允许多台电脑使用,不设置 `LicenseSeat` 设备数量硬限制;`ClientDevice` / `DeviceSession` 仅保留为可选观测和风控数据。新增 `GET /api/v1/cmshopee/subscription/status`,返回账号对应产品的订阅状态、套餐和到期时间;新增 `subscription_required` / `subscription_expired` 错误码。授权服务只查询用户 `SoftwareEntitlement`,不再读取 `DeviceCredential`。`CMSHOPEE_SUBSCRIPTION_ENFORCEMENT=false` 时只写账号订阅影子日志并保持兼容,开启后产品专属生成入口才拒绝无订阅用户;通用 `/api/v1/generate/*`、充值、点数和历史迁移接口不变。保留旧设备/卡密数据只读,不删除历史记录,不再签发新设备凭证;过期设备会话也不会阻断账号订阅授权。已补账号无订阅/有效订阅状态、强制拦截和多设备上下文回归测试;`check`、编译检查和目标 API 测试通过。 | DONE | | T-630 | 账号订阅授权与默认多设备模式 | T-628, T-629 | **已完成。** 将蝦皮圈产品专属授权从“设备会话 + 设备凭证”调整为“API Key 对应账号 + 软件订阅权益”。同一账号默认允许多台电脑使用,不设置 `LicenseSeat` 设备数量硬限制;`ClientDevice` / `DeviceSession` 仅保留为可选观测和风控数据。新增 `GET /api/v1/cmshopee/subscription/status`,返回账号对应产品的订阅状态、套餐和到期时间;新增 `subscription_required` / `subscription_expired` 错误码。授权服务只查询用户 `SoftwareEntitlement`,不再读取 `DeviceCredential`。`CMSHOPEE_SUBSCRIPTION_ENFORCEMENT=false` 时只写账号订阅影子日志并保持兼容,开启后产品专属生成入口才拒绝无订阅用户;通用 `/api/v1/generate/*`、充值、点数和历史迁移接口不变。保留旧设备/卡密数据只读,不删除历史记录,不再签发新设备凭证;过期设备会话也不会阻断账号订阅授权。已补账号无订阅/有效订阅状态、强制拦截和多设备上下文回归测试;`check`、编译检查和目标 API 测试通过。 | DONE |
| T-631 | 隐藏旧设备授权模型的 admin 菜单入口 | T-630 | 已完成:从 django-admin 应用索引隐藏 `LicenseSeat`、`LegacyMigrationGrant`、`MigrationRequest`、`DeviceCredential` 四个旧方案模型;保留数据库表、历史数据和只读模型。当前订阅、订单、授权事件、设备和会话观测模型继续可见,不改变 API、授权和账务行为。定向 admin 测试、`check`、迁移一致性和编译检查通过。详见 [`tasks/T-631.md`](tasks/T-631.md)。 | DONE | | T-631 | 隐藏旧设备授权模型的 admin 菜单入口 | T-630 | 已完成:从 django-admin 应用索引隐藏 `LicenseSeat`、`LegacyMigrationGrant`、`MigrationRequest`、`DeviceCredential` 四个旧方案模型;保留数据库表、历史数据和只读模型。当前订阅、订单、授权事件、设备和会话观测模型继续可见,不改变 API、授权和账务行为。定向 admin 测试、`check`、迁移一致性和编译检查通过。详见 [`tasks/T-631.md`](tasks/T-631.md)。 | DONE |
| T-632 | 会员订阅三阶段启用与简化运营 | T-630, T-631 | 新增 `open` / `shadow` / `enforce` 三阶段订阅模式;补齐桌面端订阅状态接口合约;admin 首页仅保留“会员套餐”和“用户会员”,其他模型只隐藏不删除。模式切换不批量写权益,不影响通用生成、点数、充值与已接受任务。详见 [`tasks/T-632.md`](tasks/T-632.md)。 | TODO | | T-632 | 会员订阅三阶段启用与简化运营 | T-630, T-631 | **已完成。** 新增 `CMSHOPEE_SUBSCRIPTION_MODE=open|shadow|enforce`,将真实权益状态与最终访问结果分离;`open` / `shadow` 不写虚假权益,`enforce` 才拦截产品专属 submit。状态接口补齐桌面端账号、套餐、顶层有效期、会员中心、通知及观测字段,并保留 T-630 旧字段。admin 首页仅保留“会员套餐”和“用户会员”,其他模型只隐藏不删除。5 条无数据库单元测试、`check`、迁移一致性和编译通过;数据库集成测试因旧远程 MySQL 拒绝连接未取得结果。详见 [`tasks/T-632.md`](tasks/T-632.md)。 | DONE |
## 里程碑 ## 里程碑
+31 -1
View File
@@ -41,7 +41,37 @@ T-305 已实现扫码充值下单与轮询基线:`POST /api/v1/recharge/create
T-629 已完成:软件套餐订单使用独立 `SoftwareOrder`、`POST /api/v1/software-orders/callback/wechat` 与 `GET /api/v1/software-orders/status`,只发放/续订软件权益,绝不兑换点数或触碰 `RechargeOrder` / `PointsLedger`。订阅微信下单必须配置独立 `SOFTWARE_WECHAT_PAY_NOTIFY_URL`,不得复用充值回调地址。 T-629 已完成:软件套餐订单使用独立 `SoftwareOrder`、`POST /api/v1/software-orders/callback/wechat` 与 `GET /api/v1/software-orders/status`,只发放/续订软件权益,绝不兑换点数或触碰 `RechargeOrder` / `PointsLedger`。订阅微信下单必须配置独立 `SOFTWARE_WECHAT_PAY_NOTIFY_URL`,不得复用充值回调地址。
T-630 已完成:蝦皮圈产品专属接口按 API Key 对应用户查询 `SoftwareEntitlement`,默认允许同一账号在多台电脑使用;设备登记、设备会话和历史凭证不再作为授权前置条件。`GET /api/v1/cmshopee/subscription/status` 返回 `product_code`、`status`(`active` / `expired` / `required`)、`allowed`、`code` 和套餐到期信息。`CMSHOPEE_SUBSCRIPTION_ENFORCEMENT=false` 时保持兼容并只记录影子授权结果;开启后产品专属生成入口对无订阅或已过期账号返回 403,通用生成接口不受影响。 T-630/T-632 已完成:蝦皮圈产品专属接口按 API Key 对应用户查询 `SoftwareEntitlement`,默认允许同一账号在多台电脑使用;设备登记、设备会话和历史凭证不再作为授权前置条件。`CMSHOPEE_SUBSCRIPTION_MODE` 支持 `open`(开发测试统一放行)、`shadow`(放行并记录真实权益)和 `enforce`(按真实权益拦截);通用生成接口不受影响。旧 `CMSHOPEE_SUBSCRIPTION_ENFORCEMENT` 仅在新配置缺失时作为兼容回退。
`GET /api/v1/cmshopee/subscription/status` 使用 API Key 鉴权。响应中的 `status` / `allowed` 表示当前模式下的最终访问结果,`entitlement_status` 表示数据库真实权益状态;二者在 `open` / `shadow` 下可能不同。示例:
```json
{
"product_code": "cmshopee",
"status": "active",
"allowed": true,
"code": null,
"account": {
"display_name": "cmhub_user",
"username": "cmhub_user"
},
"plan": {
"code": "development-open",
"display_name": "开发测试长期会员",
"name": "开发测试长期会员",
"expires_at": "2099-12-31T23:59:59+00:00",
"grace_expires_at": null
},
"expires_at": "2099-12-31T23:59:59+00:00",
"grace_expires_at": null,
"manage_url": "https://cmhub.example.com/subscription",
"notice_id": "cmshopee-subscription-open-required-v1",
"access_source": "open_mode",
"entitlement_status": "required"
}
```
`plan.name`、`plan.expires_at`、`plan.grace_expires_at` 是 T-630 旧客户端兼容字段;新客户端使用 `plan.code` / `plan.display_name` 和顶层有效期。`access_source` 取 `open_mode`、`shadow_fallback` 或 `entitlement`。真实权益已进入宽限期时 `status` / `entitlement_status` 为 `grace`;无权益和过期在 `enforce` 模式分别返回 `required` / `expired`,对应产品 submit 的 `subscription_required` / `subscription_expired` 403。
T-306 已实现对外 API 安全加固:`image_url` 下载只允许 `http` / `https` 公网地址,拒绝私有/回环/链路本地/保留等地址,重定向后重新校验并限制响应大小;全局 DRF 默认认证为空且默认权限为 `IsAuthenticated`,外部 API 与用户端 API 必须显式 opt-in 认证类;生成接口按 API Key / 用户限流,认证失败按 IP 限流;充值创建有 `RECHARGE_MAX_AMOUNT_CNY` 单笔上限。 T-306 已实现对外 API 安全加固:`image_url` 下载只允许 `http` / `https` 公网地址,拒绝私有/回环/链路本地/保留等地址,重定向后重新校验并限制响应大小;全局 DRF 默认认证为空且默认权限为 `IsAuthenticated`,外部 API 与用户端 API 必须显式 opt-in 认证类;生成接口按 API Key / 用户限流,认证失败按 IP 限流;充值创建有 `RECHARGE_MAX_AMOUNT_CNY` 单笔上限。
+6 -3
View File
@@ -75,7 +75,7 @@
任务状态以 [`06-tasks.md`](06-tasks.md) 为准,历史执行记录见 [`../progress.md`](../progress.md)。 任务状态以 [`06-tasks.md`](06-tasks.md) 为准,历史执行记录见 [`../progress.md`](../progress.md)。
- 已完成:T-001 初始化 Django + DRF 项目骨架;T-002 建立 apps 目录、自定义 User 与配置;T-003 接通 django-admin 与最小测试;T-004 Phase 0 骨架审核修补;T-101 Provider 适配器层 + 移植 cmbot 调用;T-102 AiModel + ModelAlias 模型 + 别名解析;T-103 配置变更审计;T-104 跑通一次录制标题生成;T-105 Phase 1 AI 层审核修补;T-201 User / UserWallet / ApiKey / PointsLedger / CallRecord 模型;T-202 PricingRule / ExchangeRate 模型 + 计费计算;T-203 并发安全扣点 / 退点;T-204 Phase 2 计费核心审核加固;T-301 API Key 鉴权;T-302 生成标题 / 图片接口;T-303 余额查询接口;T-304 充值回调;T-305 扫码充值下单 + 轮询;T-306 Phase 3 对外 API 安全加固;T-501 注册 / 登录(allauth);T-502 API Key 自助管理页;T-503 个人中心 / 记录页;T-504 充值页(扫码 + 轮询到账);T-505 Phase 4 用户端审核优化;T-401 运营后台完善;T-402 完整验收 MVP;T-403 部署 / 运行文档;T-601 可用别名发现;T-602 django-admin 中文化(第 1-3 层);T-603 django-admin 中文化(第 4 层·字段级);T-604 中文敏感词本地过滤;T-605 免邮箱验证策略落地;T-606 公开首页 + 客户端下载入口;T-607 桌面端最新版本检查接口;T-608 新用户注册赠送试用点数(当前 10 点);T-609 桌面端版本检查接口增加强制更新标记;T-610 首页导入模板下载入口;T-611 用户端品牌名统一为虾皮圈;T-612 生图同步接口止血(上游硬截止 + 长请求池校准);T-613 抽生成核心 service(计费+审核+上游共享 core);T-614 生图异步任务化接口(提交+轮询,新增不动旧接口);T-615 旧同步生图接口用量遥测 + 弃用口径;T-616 生图失败自动重试 2 次;T-617 桌面端版本检查接口增加文件大小字段;T-618 客户端发布版本后台必填文件校验元数据。 - 已完成:T-001 初始化 Django + DRF 项目骨架;T-002 建立 apps 目录、自定义 User 与配置;T-003 接通 django-admin 与最小测试;T-004 Phase 0 骨架审核修补;T-101 Provider 适配器层 + 移植 cmbot 调用;T-102 AiModel + ModelAlias 模型 + 别名解析;T-103 配置变更审计;T-104 跑通一次录制标题生成;T-105 Phase 1 AI 层审核修补;T-201 User / UserWallet / ApiKey / PointsLedger / CallRecord 模型;T-202 PricingRule / ExchangeRate 模型 + 计费计算;T-203 并发安全扣点 / 退点;T-204 Phase 2 计费核心审核加固;T-301 API Key 鉴权;T-302 生成标题 / 图片接口;T-303 余额查询接口;T-304 充值回调;T-305 扫码充值下单 + 轮询;T-306 Phase 3 对外 API 安全加固;T-501 注册 / 登录(allauth);T-502 API Key 自助管理页;T-503 个人中心 / 记录页;T-504 充值页(扫码 + 轮询到账);T-505 Phase 4 用户端审核优化;T-401 运营后台完善;T-402 完整验收 MVP;T-403 部署 / 运行文档;T-601 可用别名发现;T-602 django-admin 中文化(第 1-3 层);T-603 django-admin 中文化(第 4 层·字段级);T-604 中文敏感词本地过滤;T-605 免邮箱验证策略落地;T-606 公开首页 + 客户端下载入口;T-607 桌面端最新版本检查接口;T-608 新用户注册赠送试用点数(当前 10 点);T-609 桌面端版本检查接口增加强制更新标记;T-610 首页导入模板下载入口;T-611 用户端品牌名统一为虾皮圈;T-612 生图同步接口止血(上游硬截止 + 长请求池校准);T-613 抽生成核心 service(计费+审核+上游共享 core);T-614 生图异步任务化接口(提交+轮询,新增不动旧接口);T-615 旧同步生图接口用量遥测 + 弃用口径;T-616 生图失败自动重试 2 次;T-617 桌面端版本检查接口增加文件大小字段;T-618 客户端发布版本后台必填文件校验元数据。
- 已完成补充:T-619 多张图片理解并返回文字;T-620 图生图支持单图 / 多图主图与参考图;T-622 图片生成任务后台图片缩略预览;T-623 图片生成任务单图 / 多图筛选;T-624 蝦皮圈设备登记与会话观测;T-625 蝦皮圈设备使用关联与迁移观测;T-626 软件套餐、权益与设备席位基础模型及凭证续期/撤销清理;T-627 存量用户迁移权益、网页确认与设备凭证;T-628 蝦皮圈专属授权入口与影子校验;T-629 软件套餐购买、续订订单与权益入账;T-630 账号订阅授权与默认多设备模式;T-631 隐藏旧设备授权模型的 admin 菜单入口。 - 已完成补充: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-621 注册赠点运营后台配置继续留在 Backlog。真实支付回调到账闭环、客户端发布、生产多图理解模型配置和线上旧同步接口用量观察仍可继续拆任务。 - T-621 注册赠点运营后台配置继续留在 Backlog。真实支付回调到账闭环、客户端发布、生产多图理解模型配置和线上旧同步接口用量观察仍可继续拆任务。
- 当前 blocker:支付商户真实密钥/证书与生产 SDK 依赖仍待提供;微信回调到账闭环仍需真实支付验收;真实 AI 标题生成已在线上跑通,图片生成慢 / 504 / 客户端超时风险已拆为 T-612~T-616 并完成工程侧处理。 - 当前 blocker:支付商户真实密钥/证书与生产 SDK 依赖仍待提供;微信回调到账闭环仍需真实支付验收;真实 AI 标题生成已在线上跑通,图片生成慢 / 504 / 客户端超时风险已拆为 T-612~T-616 并完成工程侧处理。
@@ -124,6 +124,7 @@ python3.12 manage.py run_image_tasks --worker-id cmhub-image-worker-1 --sleep-se
- `GET /api/v1/balance` - `GET /api/v1/balance`
- `GET /api/v1/models` - `GET /api/v1/models`
- `GET /api/v1/client/releases/latest` - `GET /api/v1/client/releases/latest`
- `GET /api/v1/cmshopee/subscription/status`
- `POST /api/v1/recharge/create`(用户端 session + CSRF) - `POST /api/v1/recharge/create`(用户端 session + CSRF)
- `GET /api/v1/recharge/status`(用户端 session) - `GET /api/v1/recharge/status`(用户端 session)
- `POST /api/v1/recharge/callback/wechat` - `POST /api/v1/recharge/callback/wechat`
@@ -148,13 +149,15 @@ T-614 已落地异步生图任务化:提交接口预扣后返回 `task_id`,
T-619 已落地同步多图理解:调用方提交有序 `images` 列表,服务按 `vision + alias` 默认价固定扣点,成功返回文字,失败幂等退款;输入图片和 provider raw 不写入调用记录。代码部署并迁移后,需由运营配置视觉模型、默认别名与默认价格,才会成为可调用能力。 T-619 已落地同步多图理解:调用方提交有序 `images` 列表,服务按 `vision + alias` 默认价固定扣点,成功返回文字,失败幂等退款;输入图片和 provider raw 不写入调用记录。代码部署并迁移后,需由运营配置视觉模型、默认别名与默认价格,才会成为可调用能力。
T-632 已落地会员订阅三阶段模式:`CMSHOPEE_SUBSCRIPTION_MODE=open` 为当前开发测试默认值,无需给所有用户逐个绑定长期权益;`shadow` 继续放行并记录真实权益覆盖,`enforce` 才按真实权益拦截产品专属 submit。订阅状态接口已补齐桌面端账号、套餐、顶层有效期、会员中心与通知字段;admin 软件授权首页只显示“会员套餐”和“用户会员”,隐藏模型及历史数据未删除。
## 开始编码前检查 ## 开始编码前检查
1. 读仓库级 `AGENTS.md` / `CLAUDE.md`。 1. 读仓库级 `AGENTS.md` / `CLAUDE.md`。
2. 读 `docs/00-ai-start-here.md`。 2. 读 `docs/00-ai-start-here.md`。
3. 读 `docs/05-coding-rules.md`(尤其第 8 节资金安全)。 3. 读 `docs/05-coding-rules.md`(尤其第 8 节资金安全)。
4. 在 `docs/06-tasks.md` 领取第一个 `TODO` 且依赖均 `DONE` 的任务;当前可领取 T-632。 4. 在 `docs/06-tasks.md` 领取第一个 `TODO` 且依赖均 `DONE` 的任务;当前没有可直接领取的任务。
5. T-632 先以 `open` 保持开发测试兼容,再通过 `shadow` 观察真实权益覆盖率,最后才切 `enforce`;生产启用前仍须用真实微信商户完成充值和订阅两条回调闭环验收。 5. 当前开发测试环境应保持 `CMSHOPEE_SUBSCRIPTION_MODE=open`;切到 `shadow` / `enforce` 前须补跑数据库集成测试,并用真实微信商户完成充值和订阅两条回调闭环验收。
## 维护规则 ## 维护规则
+2 -1
View File
@@ -55,7 +55,8 @@
| `DEVICE_SESSION_TTL_SECONDS` | 否 | `3600` | T-624 设备会话令牌有效秒数,默认 1 小时;过期后客户端重新登记刷新令牌 | | `DEVICE_SESSION_TTL_SECONDS` | 否 | `3600` | T-624 设备会话令牌有效秒数,默认 1 小时;过期后客户端重新登记刷新令牌 |
| `DEVICE_ACTIVITY_UPDATE_SECONDS` | 否 | `86400` | T-624 同一设备更新最后活跃时间的最小间隔秒数,默认每日一次,避免每次生成写库 | | `DEVICE_ACTIVITY_UPDATE_SECONDS` | 否 | `86400` | T-624 同一设备更新最后活跃时间的最小间隔秒数,默认每日一次,避免每次生成写库 |
| `MIGRATION_REQUEST_TTL_SECONDS` | 否 | `900` | T-627 存量迁移网页确认请求有效秒数,默认 15 分钟;过期后客户端必须重新申请,凭证明文不在服务端恢复 | | `MIGRATION_REQUEST_TTL_SECONDS` | 否 | `900` | T-627 存量迁移网页确认请求有效秒数,默认 15 分钟;过期后客户端必须重新申请,凭证明文不在服务端恢复 |
| `CMSHOPEE_SUBSCRIPTION_ENFORCEMENT` | 否 | `false` | T-630 账号订阅强制开关;`false` 只记录影子结果,`true` 才拦截蝦皮圈产品专属接口的无订阅/过期账号 | | `CMSHOPEE_SUBSCRIPTION_MODE` | 否 | `open` | T-632 账号订阅运行模式:`open` 开发测试开放、`shadow` 放行但记录真实权益、`enforce` 按真实权益强制拦截。非法值阻止 Django 启动 |
| `CMSHOPEE_SUBSCRIPTION_ENFORCEMENT` | 否 | `false` | T-630 旧兼容开关;仅当 `CMSHOPEE_SUBSCRIPTION_MODE` 未设置时生效,`true` 映射为 `enforce`,否则映射为 `open`。新部署不要再用它控制模式 |
| `AI_IMAGE_UPSTREAM_DEADLINE_SECONDS` | 否 | `180` | T-612 生图上游读取硬截止秒数;只作用于 `generate_image` 的上游请求和上游返回图片 URL 下载,Provider 实际读取超时取 `min(AiModel.timeout_seconds 或分辨率默认值, 本值)`;生产按真实图片 smoke 耗时校准,外层 Gunicorn / Nginx / 客户端超时必须大于该值 | | `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 | | `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` | | `MEDIA_PUBLIC_BASE_URL` | 否 | `https://cm.833729.com` | 媒体文件公开基础 URL;优先于 `PUBLIC_BASE_URL`,用于 T-614 异步生图 worker 返回 `result.image_url` |
+10 -8
View File
@@ -74,14 +74,14 @@ T-627 已新增迁移申请 / 轮询和网页登录确认:客户端必须同
| 配置审计 | AiConfigAuditLog | 只读查看 AiModel / ModelAlias / 密钥变更:谁、何时、改了什么 | | 配置审计 | AiConfigAuditLog | 只读查看 AiModel / ModelAlias / 密钥变更:谁、何时、改了什么 |
| 客户端下载版本 | DownloadRelease | 上传安装包或填写外部下载地址,标记每个平台当前版本、是否强制更新和文件大小字节数;新增 / 编辑时必须填写 SHA256 与文件大小字节数;展示版本、SHA256、强制更新标记与发布说明 | | 客户端下载版本 | DownloadRelease | 上传安装包或填写外部下载地址,标记每个平台当前版本、是否强制更新和文件大小字节数;新增 / 编辑时必须填写 SHA256 与文件大小字节数;展示版本、SHA256、强制更新标记与发布说明 |
| 导入模板 | ImportTemplate | 上传导入模板或填写外部下载地址,标记当前模板;展示模板名称、SHA256 与说明 | | 导入模板 | ImportTemplate | 上传导入模板或填写外部下载地址,标记当前模板;展示模板名称、SHA256 与说明 |
| 客户端设备 | ClientDevice | 只读检索蝦皮圈设备平台、版本、状态和最近活跃时间;设备摘要仅脱敏显示 | | 会员套餐 | SoftwarePlan | 软件授权模块默认可见;维护蝦皮圈套餐的时长、价格、设备数、宽限期与启停状态,修改不回写已有权益快照 |
| 设备会话 / 审计 | DeviceSession / DeviceBindingAudit | 只读查看会话有效期、吊销状态及登记/心跳审计;不展示会话令牌或安装公钥 | | 用户会员 | SoftwareEntitlement | 软件授权模块默认可见;只读查看用户权益和套餐快照,通过专用后台页面人工授予、续期或撤销,三种操作均必须填写原因并写授权事件 |
| 软件套餐 | SoftwarePlan | 维护蝦皮圈套餐的时长、价格、设备数、宽限期与启停状态;修改不回写已有权益快照 | | 客户端设备(隐藏导航) | ClientDevice | 只读检索蝦皮圈设备平台、版本、状态和最近活跃时间;设备摘要仅脱敏显示 |
| 软件权益 | SoftwareEntitlement | 只读查看用户权益和套餐快照;通过专用后台页面人工授予、续期或撤销,三种操作均必须填写原因并写授权事件 | | 设备会话 / 审计(隐藏导航) | DeviceSession / DeviceBindingAudit | 只读查看会话有效期、吊销状态及登记/心跳审计;不展示会话令牌或安装公钥 |
| 软件套餐订单 | SoftwareOrder | 只读检索套餐快照、金额、支付通道、交易号、状态、关联权益与发放时间;不允许后台直接修改订单、交易号或权益关联 | | 软件套餐订单(隐藏导航) | SoftwareOrder | 只读检索套餐快照、金额、支付通道、交易号、状态、关联权益与发放时间;不允许后台直接修改订单、交易号或权益关联 |
| 授权席位 / 授权事件 | LicenseSeat / LicenseEvent | 只读检索设备绑定、解绑时间和全部授权审计;不支持后台直接编辑席位或事件 | | 授权席位 / 授权事件(隐藏导航) | LicenseSeat / LicenseEvent | 只读检索设备绑定、解绑时间和全部授权审计;不支持后台直接编辑席位或事件 |
| 存量迁移资格 | LegacyMigrationGrant | 通过专用后台入口按用户与套餐显式授予;资格快照、关联权益和原因只读,新注册用户不会自动创建 | | 存量迁移资格(隐藏导航) | LegacyMigrationGrant | 历史资格快照、关联权益和原因只读,新注册用户不会自动创建 |
| 迁移请求 / 设备凭证 | MigrationRequest / DeviceCredential | 只读排查短时确认状态和凭证前缀 / 吊销时间;不展示令牌明文或 hash | | 迁移请求 / 设备凭证(隐藏导航) | MigrationRequest / DeviceCredential | 只读排查短时确认状态和凭证前缀 / 吊销时间;不展示令牌明文或 hash |
## 后台职责约定 ## 后台职责约定
@@ -103,6 +103,8 @@ T-627 已新增迁移申请 / 轮询和网页登录确认:客户端必须同
### 软件授权 ### 软件授权
- T-632 后 django-admin 应用索引只展示“会员套餐”和“用户会员”。其他授权、订单、设备与审计模型仍注册且可通过直接管理地址访问,隐藏导航不等于删除数据或放宽权限。
- 开发测试阶段设置 `CMSHOPEE_SUBSCRIPTION_MODE=open`,运营无需给所有存量用户逐个创建长期权益;正式启用前按 `open` → `shadow` → `enforce` 顺序切换。
- T-626 的套餐、权益和席位只提供运营基础数据与人工操作,不新增公开接口、不接支付订单,也不改变通用生成 API 的授权结果。 - T-626 的套餐、权益和席位只提供运营基础数据与人工操作,不新增公开接口、不接支付订单,也不改变通用生成 API 的授权结果。
- 授予 / 续期 / 撤销一律调用 `apps.licensing.services`;原因必填并产生 `LicenseEvent`。不得在 admin 表单中直接修改权益状态、到期时间、席位绑定或历史事件。 - 授予 / 续期 / 撤销一律调用 `apps.licensing.services`;原因必填并产生 `LicenseEvent`。不得在 admin 表单中直接修改权益状态、到期时间、席位绑定或历史事件。
- 已授予权益读取自身套餐快照,不随 `SoftwarePlan` 后续价格、时长或设备数变化而变化;席位数量由授予时快照固定。 - 已授予权益读取自身套餐快照,不随 `SoftwarePlan` 后续价格、时长或设备数变化而变化;席位数量由授予时快照固定。
+18 -1
View File
@@ -73,4 +73,21 @@ cmhub 已具备软件套餐、用户权益、软件订单、支付回调、授
## 状态 ## 状态
TODO。 DONE。
## 实施结果
- 配置层新增 `CMSHOPEE_SUBSCRIPTION_MODE` 严格枚举校验;新配置缺失时按旧 `CMSHOPEE_SUBSCRIPTION_ENFORCEMENT` 映射,运行时代码统一读取有效模式。
- 授权服务将数据库真实权益判定与最终访问判定分离;`open` / `shadow` 不写 `SoftwareEntitlement`,`enforce` 才在产品专属 submit 预扣前拒绝。
- 订阅状态接口已补齐账号、套餐代码/显示名、顶层有效期、会员中心、通知 ID、访问来源和真实权益状态;保留 T-630 的 `plan.name` 与套餐内有效期字段。
- 授权日志同时记录真实 `would_reject`、最终 `final_allowed`、模式、来源和真实权益状态,不记录 API Key、prompt 或图片。
- admin 应用索引只保留“会员套餐”和“用户会员”;其他模型仍注册并可通过直接管理地址访问。新增 `licensing.0005_alter_softwareentitlement_options_and_more`,仅修改模型显示名称。
## 验证结果
- `manage.py check`:通过,0 issues。
- `manage.py test apps.licensing.tests.SubscriptionModeUnitTests --noinput --verbosity 2`:5 tests OK,覆盖旧开关映射、open 合约、shadow 观测、真实宽限期和 admin 导航。
- `manage.py makemigrations --check --dry-run`:No changes detected;检查迁移历史时旧远程 MySQL `43.128.3.240` 拒绝连接并给出 RuntimeWarning,不影响迁移文件一致性结果。
- `compileall config apps/licensing apps/api`:通过。
- 非法 `CMSHOPEE_SUBSCRIPTION_MODE` 启动检查:按预期抛 `ImproperlyConfigured`。
- 依赖数据库的 70 条 API/admin 定向测试已尝试,但本地 `.env` 指向的 `43.128.3.240` 拒绝连接;改用本机 MySQL 时项目账号无本机权限,因此本轮未取得数据库集成测试通过结果,未将其误记为通过。
+8
View File
@@ -2138,3 +2138,11 @@
- 方案:新增 `CMSHOPEE_SUBSCRIPTION_MODE=open|shadow|enforce`。开发测试使用 `open`,不批量创建虚假权益;正式前使用 `shadow` 统计真实权益覆盖率;最终使用 `enforce` 强制账号订阅。状态接口补齐桌面端账号、套餐、顶层有效期、会员中心、通知版本和真实权益状态字段。 - 方案:新增 `CMSHOPEE_SUBSCRIPTION_MODE=open|shadow|enforce`。开发测试使用 `open`,不批量创建虚假权益;正式前使用 `shadow` 统计真实权益覆盖率;最终使用 `enforce` 强制账号订阅。状态接口补齐桌面端账号、套餐、顶层有效期、会员中心、通知版本和真实权益状态字段。
- 运营:admin 首页只保留“会员套餐”和“用户会员”,其余模型继续注册并保留直接管理地址、权限和历史数据,不删除表。 - 运营:admin 首页只保留“会员套餐”和“用户会员”,其余模型继续注册并保留直接管理地址、权限和历史数据,不删除表。
- 边界:不改 API Key、点数、充值、软件订单、通用生成接口或已接受异步任务;任务详见 `docs/tasks/T-632.md`。 - 边界:不改 API Key、点数、充值、软件订单、通用生成接口或已接受异步任务;任务详见 `docs/tasks/T-632.md`。
## 2026-07-22 完成:T-632 会员订阅三阶段启用与简化运营
- 配置与授权:新增严格校验的 `CMSHOPEE_SUBSCRIPTION_MODE=open|shadow|enforce`,新配置缺失时兼容旧布尔开关。授权服务分别计算真实权益状态和最终访问结果;`open` / `shadow` 不创建或修改权益,`enforce` 才在产品专属 submit 预扣前拒绝。
- API:订阅状态接口新增 `account`、稳定套餐代码/显示名、顶层有效期、`manage_url`、`notice_id`、`access_source`、`entitlement_status`,并保留 T-630 的 `plan.name` 及套餐内有效期字段。授权日志同时保留真实拒绝指标和最终放行结果。
- admin:软件授权应用索引只显示“会员套餐”和“用户会员”;软件订单、事件、设备观测及历史授权模型继续注册、保留直接管理地址和历史数据。`licensing.0005` 只修改两个模型的显示名称。
- 验证:5 条不依赖数据库的模式/合约/宽限期/admin 单元测试通过;`manage.py check`、迁移一致性、编译与 `git diff --check` 通过;非法模式按预期阻止启动。数据库 API/admin 定向测试已尝试,但旧远程 MySQL `43.128.3.240` 拒绝连接,本机 MySQL 又不接受项目账号,因此未取得数据库集成测试结果。
- 发布约束:当前部署保持 `open`;切 `shadow` / `enforce` 前必须在可用 MySQL 测试库补跑 T-632 数据库集成回归,并核对桌面端状态页。