feat: add staged subscription rollout mode
This commit is contained in:
+3
-1
@@ -39,6 +39,9 @@ DEVICE_SESSION_TTL_SECONDS=3600
|
||||
DEVICE_ACTIVITY_UPDATE_SECONDS=86400
|
||||
MIGRATION_REQUEST_TTL_SECONDS=900
|
||||
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
|
||||
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_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
|
||||
CMSHOPEE_SUBSCRIPTION_ENFORCEMENT=false
|
||||
|
||||
# Alipay face-to-face payment
|
||||
ALIPAY_APPID=your-alipay-appid
|
||||
|
||||
+117
-13
@@ -63,7 +63,12 @@ from apps.billing.services import RechargePayment
|
||||
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 ClientDevice, SoftwareOrder, SoftwarePlan
|
||||
from apps.licensing.models import (
|
||||
ClientDevice,
|
||||
SoftwareEntitlement,
|
||||
SoftwareOrder,
|
||||
SoftwarePlan,
|
||||
)
|
||||
from apps.licensing.services import (
|
||||
create_software_order,
|
||||
grant_software_entitlement,
|
||||
@@ -1437,6 +1442,82 @@ class GenerateApiTests(TestCase):
|
||||
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):
|
||||
response = self.client.get(
|
||||
"/api/v1/cmshopee/subscription/status",
|
||||
@@ -1444,17 +1525,29 @@ class GenerateApiTests(TestCase):
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(
|
||||
response.json(),
|
||||
{
|
||||
"product_code": "cmshopee",
|
||||
"status": "required",
|
||||
"allowed": False,
|
||||
"code": "subscription_required",
|
||||
"plan": None,
|
||||
},
|
||||
data = response.json()
|
||||
self.assertEqual(data["status"], "required")
|
||||
self.assertFalse(data["allowed"])
|
||||
self.assertEqual(data["code"], "subscription_required")
|
||||
self.assertIsNone(data["plan"])
|
||||
self.assertEqual(data["entitlement_status"], "required")
|
||||
self.assertEqual(data["access_source"], "entitlement")
|
||||
|
||||
@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):
|
||||
entitlement = self.create_cmshopee_entitlement()
|
||||
|
||||
@@ -1468,8 +1561,18 @@ class GenerateApiTests(TestCase):
|
||||
self.assertEqual(data["status"], "active")
|
||||
self.assertTrue(data["allowed"])
|
||||
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"]["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):
|
||||
self.create_cmshopee_entitlement(
|
||||
starts_at=timezone.now() - timedelta(days=40),
|
||||
@@ -1485,8 +1588,9 @@ class GenerateApiTests(TestCase):
|
||||
self.assertEqual(data["status"], "expired")
|
||||
self.assertFalse(data["allowed"])
|
||||
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):
|
||||
response = self.post_with_provider(
|
||||
"/api/v1/cmshopee/generate/title",
|
||||
@@ -1498,7 +1602,7 @@ class GenerateApiTests(TestCase):
|
||||
self.assertEqual(self.provider.text_calls, [])
|
||||
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):
|
||||
self.create_cmshopee_entitlement()
|
||||
|
||||
@@ -1516,7 +1620,7 @@ class GenerateApiTests(TestCase):
|
||||
self.assertEqual(second_response.status_code, 200)
|
||||
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):
|
||||
response = self.post_with_provider(
|
||||
"/api/v1/generate/title",
|
||||
|
||||
+15
-11
@@ -1,7 +1,6 @@
|
||||
import logging
|
||||
|
||||
from django.http import HttpResponse
|
||||
from django.conf import settings
|
||||
from django.utils import timezone
|
||||
from django.utils.decorators import method_decorator
|
||||
from django.views.decorators.csrf import csrf_exempt
|
||||
@@ -79,7 +78,7 @@ from apps.licensing.services import (
|
||||
create_migration_request,
|
||||
expire_software_order,
|
||||
query_and_apply_software_payment,
|
||||
evaluate_account_authorization,
|
||||
evaluate_subscription_access,
|
||||
software_subscription_status,
|
||||
record_device_heartbeat,
|
||||
register_device,
|
||||
@@ -391,7 +390,7 @@ class GenerateImageTaskDetailView(ExternalApiView):
|
||||
return Response(task_detail_response(task), status=status.HTTP_200_OK)
|
||||
|
||||
|
||||
class CmshopeeShadowAuthorizationMixin:
|
||||
class CmshopeeSubscriptionAuthorizationMixin:
|
||||
product_code = "cmshopee"
|
||||
|
||||
def optional_client_device(self, request):
|
||||
@@ -403,7 +402,7 @@ class CmshopeeShadowAuthorizationMixin:
|
||||
request.headers.get("X-Device-Session", "").strip()
|
||||
)
|
||||
device = None
|
||||
decision = evaluate_account_authorization(
|
||||
decision = evaluate_subscription_access(
|
||||
user=request.user,
|
||||
product_code=self.product_code,
|
||||
)
|
||||
@@ -412,9 +411,14 @@ class CmshopeeShadowAuthorizationMixin:
|
||||
"product_code": self.product_code,
|
||||
"user_id": request.user.id,
|
||||
"client_device_id": getattr(device, "id", None),
|
||||
"would_reject": decision.would_reject,
|
||||
"would_reject_code": decision.code,
|
||||
"enforcement": settings.CMSHOPEE_SUBSCRIPTION_ENFORCEMENT,
|
||||
"would_reject": bool(decision.entitlement_code),
|
||||
"would_reject_code": decision.entitlement_code,
|
||||
"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(
|
||||
"%s %s",
|
||||
@@ -422,7 +426,7 @@ class CmshopeeShadowAuthorizationMixin:
|
||||
event,
|
||||
extra={"cmshopee_authorization": event},
|
||||
)
|
||||
if settings.CMSHOPEE_SUBSCRIPTION_ENFORCEMENT and decision.would_reject:
|
||||
if decision.would_reject:
|
||||
message = (
|
||||
"当前账号没有有效的软件订阅"
|
||||
if decision.code == "subscription_required"
|
||||
@@ -433,16 +437,16 @@ class CmshopeeShadowAuthorizationMixin:
|
||||
return device
|
||||
|
||||
|
||||
class CmshopeeGenerateTitleView(CmshopeeShadowAuthorizationMixin, GenerateTitleView):
|
||||
class CmshopeeGenerateTitleView(CmshopeeSubscriptionAuthorizationMixin, GenerateTitleView):
|
||||
pass
|
||||
|
||||
|
||||
class CmshopeeAnalyzeImagesView(CmshopeeShadowAuthorizationMixin, AnalyzeImagesView):
|
||||
class CmshopeeAnalyzeImagesView(CmshopeeSubscriptionAuthorizationMixin, AnalyzeImagesView):
|
||||
pass
|
||||
|
||||
|
||||
class CmshopeeGenerateImageTaskSubmitView(
|
||||
CmshopeeShadowAuthorizationMixin,
|
||||
CmshopeeSubscriptionAuthorizationMixin,
|
||||
GenerateImageTaskSubmitView,
|
||||
):
|
||||
pass
|
||||
|
||||
+18
-14
@@ -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:
|
||||
if not value:
|
||||
return ""
|
||||
@@ -35,7 +42,7 @@ def _masked_fingerprint(value: str) -> str:
|
||||
|
||||
|
||||
@admin.register(ClientDevice)
|
||||
class ClientDeviceAdmin(admin.ModelAdmin):
|
||||
class ClientDeviceAdmin(HiddenFromAdminIndexMixin, admin.ModelAdmin):
|
||||
list_display = (
|
||||
"user",
|
||||
"product_code",
|
||||
@@ -81,7 +88,7 @@ class ClientDeviceAdmin(admin.ModelAdmin):
|
||||
|
||||
|
||||
@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_filter = ("revoked_at", "expires_at")
|
||||
search_fields = ("device__user__username", "device__user__email")
|
||||
@@ -106,7 +113,7 @@ class DeviceSessionAdmin(admin.ModelAdmin):
|
||||
|
||||
|
||||
@admin.register(DeviceBindingAudit)
|
||||
class DeviceBindingAuditAdmin(admin.ModelAdmin):
|
||||
class DeviceBindingAuditAdmin(HiddenFromAdminIndexMixin, admin.ModelAdmin):
|
||||
list_display = ("user", "device", "api_key", "action", "reason", "created_at")
|
||||
list_filter = ("action", "created_at")
|
||||
search_fields = ("user__username", "user__email", "api_key__key_prefix")
|
||||
@@ -334,15 +341,12 @@ class ReadOnlyLicenseAdmin(admin.ModelAdmin):
|
||||
return False
|
||||
|
||||
|
||||
class HiddenLegacyLicenseAdmin(ReadOnlyLicenseAdmin):
|
||||
"""Keep legacy records addressable without advertising them in admin."""
|
||||
|
||||
def get_model_perms(self, request):
|
||||
return {}
|
||||
class HiddenLicenseAdmin(HiddenFromAdminIndexMixin, ReadOnlyLicenseAdmin):
|
||||
pass
|
||||
|
||||
|
||||
@admin.register(LicenseSeat)
|
||||
class LicenseSeatAdmin(HiddenLegacyLicenseAdmin):
|
||||
class LicenseSeatAdmin(HiddenLicenseAdmin):
|
||||
list_display = ("entitlement", "seat_number", "device", "bound_at", "released_at")
|
||||
list_filter = ("entitlement__product_code", "bound_at", "released_at")
|
||||
search_fields = (
|
||||
@@ -354,7 +358,7 @@ class LicenseSeatAdmin(HiddenLegacyLicenseAdmin):
|
||||
|
||||
|
||||
@admin.register(LicenseEvent)
|
||||
class LicenseEventAdmin(ReadOnlyLicenseAdmin):
|
||||
class LicenseEventAdmin(HiddenLicenseAdmin):
|
||||
list_display = ("entitlement", "action", "seat", "device", "actor", "reason", "created_at")
|
||||
list_filter = ("action", "entitlement__product_code", "created_at")
|
||||
search_fields = (
|
||||
@@ -370,7 +374,7 @@ class LegacyMigrationGrantForm(EntitlementGrantForm):
|
||||
|
||||
|
||||
@admin.register(LegacyMigrationGrant)
|
||||
class LegacyMigrationGrantAdmin(HiddenLegacyLicenseAdmin):
|
||||
class LegacyMigrationGrantAdmin(HiddenLicenseAdmin):
|
||||
list_display = ("user", "product_code", "entitlement", "status", "actor", "created_at")
|
||||
list_filter = ("product_code", "status", "created_at")
|
||||
search_fields = ("user__username", "user__email", "reason")
|
||||
@@ -417,7 +421,7 @@ class LegacyMigrationGrantAdmin(HiddenLegacyLicenseAdmin):
|
||||
|
||||
|
||||
@admin.register(MigrationRequest)
|
||||
class MigrationRequestAdmin(HiddenLegacyLicenseAdmin):
|
||||
class MigrationRequestAdmin(HiddenLicenseAdmin):
|
||||
list_display = ("request_id", "user", "device", "migration_grant", "status", "expires_at", "confirmed_at")
|
||||
list_filter = ("status", "device__product_code", "expires_at")
|
||||
search_fields = ("=request_id", "user__username", "user__email")
|
||||
@@ -425,7 +429,7 @@ class MigrationRequestAdmin(HiddenLegacyLicenseAdmin):
|
||||
|
||||
|
||||
@admin.register(DeviceCredential)
|
||||
class DeviceCredentialAdmin(HiddenLegacyLicenseAdmin):
|
||||
class DeviceCredentialAdmin(HiddenLicenseAdmin):
|
||||
list_display = ("token_prefix", "user", "product_code", "device", "entitlement", "expires_at", "revoked_at")
|
||||
list_filter = ("product_code", "revoked_at", "expires_at")
|
||||
search_fields = ("token_prefix", "user__username", "user__email")
|
||||
@@ -433,7 +437,7 @@ class DeviceCredentialAdmin(HiddenLegacyLicenseAdmin):
|
||||
|
||||
|
||||
@admin.register(SoftwareOrder)
|
||||
class SoftwareOrderAdmin(ReadOnlyLicenseAdmin):
|
||||
class SoftwareOrderAdmin(HiddenLicenseAdmin):
|
||||
list_display = (
|
||||
"created_at",
|
||||
"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': '会员套餐'},
|
||||
),
|
||||
]
|
||||
@@ -108,8 +108,8 @@ class SoftwarePlan(models.Model):
|
||||
|
||||
class Meta:
|
||||
db_table = "software_plan"
|
||||
verbose_name = "软件套餐"
|
||||
verbose_name_plural = "软件套餐"
|
||||
verbose_name = "会员套餐"
|
||||
verbose_name_plural = "会员套餐"
|
||||
ordering = ("product_code", "name", "id")
|
||||
constraints = [
|
||||
models.CheckConstraint(
|
||||
@@ -178,8 +178,8 @@ class SoftwareEntitlement(models.Model):
|
||||
|
||||
class Meta:
|
||||
db_table = "software_entitlement"
|
||||
verbose_name = "软件权益"
|
||||
verbose_name_plural = "软件权益"
|
||||
verbose_name = "用户会员"
|
||||
verbose_name_plural = "用户会员"
|
||||
ordering = ("-expires_at", "-id")
|
||||
constraints = [
|
||||
models.CheckConstraint(
|
||||
|
||||
+138
-16
@@ -2,7 +2,7 @@ from __future__ import annotations
|
||||
|
||||
import secrets
|
||||
from dataclasses import dataclass
|
||||
from datetime import timedelta
|
||||
from datetime import datetime, timedelta, timezone as datetime_timezone
|
||||
from decimal import Decimal
|
||||
from decimal import InvalidOperation
|
||||
|
||||
@@ -98,6 +98,22 @@ class SubscriptionAuthorizationDecision:
|
||||
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)
|
||||
class DeviceRegistrationResult:
|
||||
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:
|
||||
decision = evaluate_account_authorization(
|
||||
def _real_entitlement_status(decision, now) -> str:
|
||||
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,
|
||||
product_code=product_code,
|
||||
now=now,
|
||||
)
|
||||
entitlement = decision.entitlement
|
||||
entitlement_status = _real_entitlement_status(real_decision, now)
|
||||
|
||||
if mode == "open":
|
||||
return SubscriptionAccessDecision(
|
||||
product_code=product_code,
|
||||
mode=mode,
|
||||
allowed=True,
|
||||
code="",
|
||||
access_source="open_mode",
|
||||
entitlement_status=entitlement_status,
|
||||
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 {
|
||||
"product_code": product_code,
|
||||
"status": (
|
||||
"active"
|
||||
if decision.allowed
|
||||
else ("expired" if decision.code == "subscription_expired" else "required")
|
||||
),
|
||||
"allowed": decision.allowed,
|
||||
"code": decision.code or None,
|
||||
"plan": {
|
||||
"code": plan_code,
|
||||
"display_name": entitlement.plan_name,
|
||||
# T-630 compatibility fields; new clients use the top-level dates.
|
||||
"name": entitlement.plan_name,
|
||||
"expires_at": entitlement.expires_at.isoformat(),
|
||||
"grace_expires_at": entitlement.grace_expires_at.isoformat(),
|
||||
}
|
||||
if entitlement is not None
|
||||
else None,
|
||||
}, entitlement.expires_at, entitlement.grace_expires_at
|
||||
if access.access_source in {"open_mode", "shadow_fallback"}:
|
||||
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
@@ -2,10 +2,11 @@ from datetime import timedelta
|
||||
from decimal import Decimal
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
from django.contrib import admin
|
||||
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.utils import timezone
|
||||
from rest_framework.test import APIClient
|
||||
@@ -29,6 +30,7 @@ from apps.licensing.services import (
|
||||
LicensingError,
|
||||
SoftwareOrderAmountMismatchError,
|
||||
SoftwareOrderTransactionMismatchError,
|
||||
SubscriptionAuthorizationDecision,
|
||||
apply_software_payment,
|
||||
assign_license_seat,
|
||||
confirm_migration_request,
|
||||
@@ -36,16 +38,139 @@ from apps.licensing.services import (
|
||||
create_migration_request,
|
||||
create_software_order,
|
||||
evaluate_device_authorization,
|
||||
evaluate_subscription_access,
|
||||
get_subscription_mode,
|
||||
grant_software_entitlement,
|
||||
record_device_heartbeat,
|
||||
release_license_seat,
|
||||
renew_software_entitlement,
|
||||
revoke_software_entitlement,
|
||||
register_device,
|
||||
software_subscription_status,
|
||||
)
|
||||
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):
|
||||
def setUp(self):
|
||||
self.user = User.objects.create_user(
|
||||
@@ -550,22 +675,22 @@ class SoftwareEntitlementAdminTests(TestCase):
|
||||
).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 = (
|
||||
ClientDevice,
|
||||
DeviceSession,
|
||||
DeviceBindingAudit,
|
||||
LicenseSeat,
|
||||
LegacyMigrationGrant,
|
||||
MigrationRequest,
|
||||
DeviceCredential,
|
||||
)
|
||||
visible_models = (
|
||||
ClientDevice,
|
||||
DeviceSession,
|
||||
DeviceBindingAudit,
|
||||
SoftwarePlan,
|
||||
SoftwareEntitlement,
|
||||
SoftwareOrder,
|
||||
LicenseEvent,
|
||||
)
|
||||
visible_models = (
|
||||
SoftwarePlan,
|
||||
SoftwareEntitlement,
|
||||
)
|
||||
request = SimpleNamespace(user=self.operator)
|
||||
|
||||
for model in hidden_models:
|
||||
@@ -576,6 +701,14 @@ class SoftwareEntitlementAdminTests(TestCase):
|
||||
self.assertTrue(LicenseSeat.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):
|
||||
def setUp(self):
|
||||
|
||||
+12
-1
@@ -14,6 +14,8 @@ import os
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
|
||||
# Build paths inside the project like this: BASE_DIR / 'subdir'.
|
||||
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)
|
||||
MIGRATION_REQUEST_TTL_SECONDS = env_int("MIGRATION_REQUEST_TTL_SECONDS", 900)
|
||||
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!
|
||||
DEBUG = env_bool("DJANGO_DEBUG", True)
|
||||
|
||||
@@ -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 建立套餐/权益/席位基础,均不改变通用 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` 仅表示下载文件大小字节数。
|
||||
- **计费层**:点数计算、原子扣减(锁 `UserWallet` 行)、退点、充值入账、流水记账。入口 `apps/billing/`。
|
||||
- **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-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 才会用凭证参与专属接口影子校验。
|
||||
|
||||
|
||||
+1
-1
@@ -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-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-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
@@ -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-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` 单笔上限。
|
||||
|
||||
|
||||
@@ -75,7 +75,7 @@
|
||||
任务状态以 [`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-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。真实支付回调到账闭环、客户端发布、生产多图理解模型配置和线上旧同步接口用量观察仍可继续拆任务。
|
||||
- 当前 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/models`
|
||||
- `GET /api/v1/client/releases/latest`
|
||||
- `GET /api/v1/cmshopee/subscription/status`
|
||||
- `POST /api/v1/recharge/create`(用户端 session + CSRF)
|
||||
- `GET /api/v1/recharge/status`(用户端 session)
|
||||
- `POST /api/v1/recharge/callback/wechat`
|
||||
@@ -148,13 +149,15 @@ T-614 已落地异步生图任务化:提交接口预扣后返回 `task_id`,
|
||||
|
||||
T-619 已落地同步多图理解:调用方提交有序 `images` 列表,服务按 `vision + alias` 默认价固定扣点,成功返回文字,失败幂等退款;输入图片和 provider raw 不写入调用记录。代码部署并迁移后,需由运营配置视觉模型、默认别名与默认价格,才会成为可调用能力。
|
||||
|
||||
T-632 已落地会员订阅三阶段模式:`CMSHOPEE_SUBSCRIPTION_MODE=open` 为当前开发测试默认值,无需给所有用户逐个绑定长期权益;`shadow` 继续放行并记录真实权益覆盖,`enforce` 才按真实权益拦截产品专属 submit。订阅状态接口已补齐桌面端账号、套餐、顶层有效期、会员中心与通知字段;admin 软件授权首页只显示“会员套餐”和“用户会员”,隐藏模型及历史数据未删除。
|
||||
|
||||
## 开始编码前检查
|
||||
|
||||
1. 读仓库级 `AGENTS.md` / `CLAUDE.md`。
|
||||
2. 读 `docs/00-ai-start-here.md`。
|
||||
3. 读 `docs/05-coding-rules.md`(尤其第 8 节资金安全)。
|
||||
4. 在 `docs/06-tasks.md` 领取第一个 `TODO` 且依赖均 `DONE` 的任务;当前可领取 T-632。
|
||||
5. T-632 先以 `open` 保持开发测试兼容,再通过 `shadow` 观察真实权益覆盖率,最后才切 `enforce`;生产启用前仍须用真实微信商户完成充值和订阅两条回调闭环验收。
|
||||
4. 在 `docs/06-tasks.md` 领取第一个 `TODO` 且依赖均 `DONE` 的任务;当前没有可直接领取的任务。
|
||||
5. 当前开发测试环境应保持 `CMSHOPEE_SUBSCRIPTION_MODE=open`;切到 `shadow` / `enforce` 前须补跑数据库集成测试,并用真实微信商户完成充值和订阅两条回调闭环验收。
|
||||
|
||||
## 维护规则
|
||||
|
||||
|
||||
+2
-1
@@ -55,7 +55,8 @@
|
||||
| `DEVICE_SESSION_TTL_SECONDS` | 否 | `3600` | T-624 设备会话令牌有效秒数,默认 1 小时;过期后客户端重新登记刷新令牌 |
|
||||
| `DEVICE_ACTIVITY_UPDATE_SECONDS` | 否 | `86400` | T-624 同一设备更新最后活跃时间的最小间隔秒数,默认每日一次,避免每次生成写库 |
|
||||
| `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 / 客户端超时必须大于该值 |
|
||||
| `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` |
|
||||
|
||||
+10
-8
@@ -74,14 +74,14 @@ T-627 已新增迁移申请 / 轮询和网页登录确认:客户端必须同
|
||||
| 配置审计 | AiConfigAuditLog | 只读查看 AiModel / ModelAlias / 密钥变更:谁、何时、改了什么 |
|
||||
| 客户端下载版本 | DownloadRelease | 上传安装包或填写外部下载地址,标记每个平台当前版本、是否强制更新和文件大小字节数;新增 / 编辑时必须填写 SHA256 与文件大小字节数;展示版本、SHA256、强制更新标记与发布说明 |
|
||||
| 导入模板 | ImportTemplate | 上传导入模板或填写外部下载地址,标记当前模板;展示模板名称、SHA256 与说明 |
|
||||
| 客户端设备 | ClientDevice | 只读检索蝦皮圈设备平台、版本、状态和最近活跃时间;设备摘要仅脱敏显示 |
|
||||
| 设备会话 / 审计 | DeviceSession / DeviceBindingAudit | 只读查看会话有效期、吊销状态及登记/心跳审计;不展示会话令牌或安装公钥 |
|
||||
| 软件套餐 | SoftwarePlan | 维护蝦皮圈套餐的时长、价格、设备数、宽限期与启停状态;修改不回写已有权益快照 |
|
||||
| 软件权益 | SoftwareEntitlement | 只读查看用户权益和套餐快照;通过专用后台页面人工授予、续期或撤销,三种操作均必须填写原因并写授权事件 |
|
||||
| 软件套餐订单 | SoftwareOrder | 只读检索套餐快照、金额、支付通道、交易号、状态、关联权益与发放时间;不允许后台直接修改订单、交易号或权益关联 |
|
||||
| 授权席位 / 授权事件 | LicenseSeat / LicenseEvent | 只读检索设备绑定、解绑时间和全部授权审计;不支持后台直接编辑席位或事件 |
|
||||
| 存量迁移资格 | LegacyMigrationGrant | 通过专用后台入口按用户与套餐显式授予;资格快照、关联权益和原因只读,新注册用户不会自动创建 |
|
||||
| 迁移请求 / 设备凭证 | MigrationRequest / DeviceCredential | 只读排查短时确认状态和凭证前缀 / 吊销时间;不展示令牌明文或 hash |
|
||||
| 会员套餐 | SoftwarePlan | 软件授权模块默认可见;维护蝦皮圈套餐的时长、价格、设备数、宽限期与启停状态,修改不回写已有权益快照 |
|
||||
| 用户会员 | SoftwareEntitlement | 软件授权模块默认可见;只读查看用户权益和套餐快照,通过专用后台页面人工授予、续期或撤销,三种操作均必须填写原因并写授权事件 |
|
||||
| 客户端设备(隐藏导航) | ClientDevice | 只读检索蝦皮圈设备平台、版本、状态和最近活跃时间;设备摘要仅脱敏显示 |
|
||||
| 设备会话 / 审计(隐藏导航) | DeviceSession / DeviceBindingAudit | 只读查看会话有效期、吊销状态及登记/心跳审计;不展示会话令牌或安装公钥 |
|
||||
| 软件套餐订单(隐藏导航) | SoftwareOrder | 只读检索套餐快照、金额、支付通道、交易号、状态、关联权益与发放时间;不允许后台直接修改订单、交易号或权益关联 |
|
||||
| 授权席位 / 授权事件(隐藏导航) | LicenseSeat / LicenseEvent | 只读检索设备绑定、解绑时间和全部授权审计;不支持后台直接编辑席位或事件 |
|
||||
| 存量迁移资格(隐藏导航) | LegacyMigrationGrant | 历史资格快照、关联权益和原因只读,新注册用户不会自动创建 |
|
||||
| 迁移请求 / 设备凭证(隐藏导航) | MigrationRequest / DeviceCredential | 只读排查短时确认状态和凭证前缀 / 吊销时间;不展示令牌明文或 hash |
|
||||
|
||||
## 后台职责约定
|
||||
|
||||
@@ -103,6 +103,8 @@ T-627 已新增迁移申请 / 轮询和网页登录确认:客户端必须同
|
||||
|
||||
### 软件授权
|
||||
|
||||
- T-632 后 django-admin 应用索引只展示“会员套餐”和“用户会员”。其他授权、订单、设备与审计模型仍注册且可通过直接管理地址访问,隐藏导航不等于删除数据或放宽权限。
|
||||
- 开发测试阶段设置 `CMSHOPEE_SUBSCRIPTION_MODE=open`,运营无需给所有存量用户逐个创建长期权益;正式启用前按 `open` → `shadow` → `enforce` 顺序切换。
|
||||
- T-626 的套餐、权益和席位只提供运营基础数据与人工操作,不新增公开接口、不接支付订单,也不改变通用生成 API 的授权结果。
|
||||
- 授予 / 续期 / 撤销一律调用 `apps.licensing.services`;原因必填并产生 `LicenseEvent`。不得在 admin 表单中直接修改权益状态、到期时间、席位绑定或历史事件。
|
||||
- 已授予权益读取自身套餐快照,不随 `SoftwarePlan` 后续价格、时长或设备数变化而变化;席位数量由授予时快照固定。
|
||||
|
||||
+18
-1
@@ -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 时项目账号无本机权限,因此本轮未取得数据库集成测试通过结果,未将其误记为通过。
|
||||
|
||||
@@ -2138,3 +2138,11 @@
|
||||
- 方案:新增 `CMSHOPEE_SUBSCRIPTION_MODE=open|shadow|enforce`。开发测试使用 `open`,不批量创建虚假权益;正式前使用 `shadow` 统计真实权益覆盖率;最终使用 `enforce` 强制账号订阅。状态接口补齐桌面端账号、套餐、顶层有效期、会员中心、通知版本和真实权益状态字段。
|
||||
- 运营:admin 首页只保留“会员套餐”和“用户会员”,其余模型继续注册并保留直接管理地址、权限和历史数据,不删除表。
|
||||
- 边界:不改 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 数据库集成回归,并核对桌面端状态页。
|
||||
|
||||
Reference in New Issue
Block a user