From 2604a59d675d5eefc2caa46744899dea8f19bd4d Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Wed, 22 Jul 2026 15:13:28 +0800 Subject: [PATCH] feat: add staged subscription rollout mode --- .env.example | 4 +- apps/api/tests.py | 130 +++++++++++++-- apps/api/views.py | 26 +-- apps/licensing/admin.py | 32 ++-- ...er_softwareentitlement_options_and_more.py | 21 +++ apps/licensing/models.py | 8 +- apps/licensing/services.py | 156 ++++++++++++++++-- apps/licensing/tests.py | 151 ++++++++++++++++- config/settings.py | 13 +- docs/04-architecture.md | 6 +- docs/06-tasks.md | 2 +- docs/api.md | 32 +++- docs/current-state.md | 9 +- docs/env.md | 3 +- docs/routes.md | 18 +- docs/tasks/T-632.md | 19 ++- progress.md | 8 + 17 files changed, 551 insertions(+), 87 deletions(-) create mode 100644 apps/licensing/migrations/0005_alter_softwareentitlement_options_and_more.py diff --git a/.env.example b/.env.example index 8de5dcb..c1b7c54 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/apps/api/tests.py b/apps/api/tests.py index 4814217..8c2be16 100644 --- a/apps/api/tests.py +++ b/apps/api/tests.py @@ -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", diff --git a/apps/api/views.py b/apps/api/views.py index 28470ae..9d24f47 100644 --- a/apps/api/views.py +++ b/apps/api/views.py @@ -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 diff --git a/apps/licensing/admin.py b/apps/licensing/admin.py index 5f38572..fe7b585 100644 --- a/apps/licensing/admin.py +++ b/apps/licensing/admin.py @@ -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", diff --git a/apps/licensing/migrations/0005_alter_softwareentitlement_options_and_more.py b/apps/licensing/migrations/0005_alter_softwareentitlement_options_and_more.py new file mode 100644 index 0000000..042d2ae --- /dev/null +++ b/apps/licensing/migrations/0005_alter_softwareentitlement_options_and_more.py @@ -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': '会员套餐'}, + ), + ] diff --git a/apps/licensing/models.py b/apps/licensing/models.py index bfda92b..81f9a2d 100644 --- a/apps/licensing/models.py +++ b/apps/licensing/models.py @@ -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( diff --git a/apps/licensing/services.py b/apps/licensing/services.py index 520b478..bd421c9 100644 --- a/apps/licensing/services.py +++ b/apps/licensing/services.py @@ -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 - 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": { + 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 { + "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, } diff --git a/apps/licensing/tests.py b/apps/licensing/tests.py index 9142092..b9c3a37 100644 --- a/apps/licensing/tests.py +++ b/apps/licensing/tests.py @@ -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): diff --git a/config/settings.py b/config/settings.py index f67df2c..bb91825 100644 --- a/config/settings.py +++ b/config/settings.py @@ -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) diff --git a/docs/04-architecture.md b/docs/04-architecture.md index 98cd7e7..97fba89 100644 --- a/docs/04-architecture.md +++ b/docs/04-architecture.md @@ -20,7 +20,7 @@ - **用户端层(Django 模板 SSR)**:公开首页、注册/登录(Django auth / allauth)、个人中心(余额/充值总额/充值记录/点数记录)、API Key 自助管理、发起扫码充值、模型目录与客户端下载入口。入口 `apps/portal/`,公开首页匿名可访问,其他自助页面用 session 鉴权。T-501/T-608 已落地 `/signup`、`/login`、`/logout` 与 `/dashboard`;注册成功后经计费层一次性发放 10 点试用点数,并写 `signup_bonus` 点数流水。T-502 已落地 `/apikeys`,用户可自助生成和删除(吊销)自己的 API Key,明文只显示一次,列表只显示 prefix。T-503/T-608 已扩展 `/dashboard` 并新增 `/records/recharge`、`/records/usage`,只读展示当前用户余额、充值订单、注册赠点与消费/退款流水。T-504 已落地 `/recharge`,用户可创建 pending 充值订单、查看二维码票据,并轮询订单状态;到账仍以服务端回调或主动查单入账后的本地订单状态为准。T-505 已把 Bootstrap/qrcode.js 改成本地 static 自托管,并把充值/点数记录页从固定切片改为分页。T-606 已把 `/` 改为公开首页,并新增 `DownloadRelease` 下载版本配置用于展示 Windows 客户端版本、下载地址、SHA256 与发布说明;T-607/T-609/T-617 已新增公开 JSON 版本检查接口给桌面端自动更新使用,并返回强制更新标记和安装包文件大小字节数。T-610 已在公开首页下载区增加导入模板下载入口,由后台 `ImportTemplate` 配置当前模板。 - **用户与账号层**:注册用户 `User`、点数钱包 `UserWallet`、`ApiKey`(一用户多把、哈希存储)。入口 `apps/users/`。 -- **软件授权层**:蝦皮圈客户端设备、短期设备会话、套餐、权益、设备席位和授权审计。入口 `apps/licensing/`;T-624/T-625 建立设备观测及调用关联,T-626 建立套餐/权益/席位基础,均不改变通用 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 才会用凭证参与专属接口影子校验。 diff --git a/docs/06-tasks.md b/docs/06-tasks.md index 0065bb3..064e6f7 100644 --- a/docs/06-tasks.md +++ b/docs/06-tasks.md @@ -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 | ## 里程碑 diff --git a/docs/api.md b/docs/api.md index b4f46ab..2ddc13b 100644 --- a/docs/api.md +++ b/docs/api.md @@ -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` 单笔上限。 diff --git a/docs/current-state.md b/docs/current-state.md index 2b88874..e6ee179 100644 --- a/docs/current-state.md +++ b/docs/current-state.md @@ -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` 前须补跑数据库集成测试,并用真实微信商户完成充值和订阅两条回调闭环验收。 ## 维护规则 diff --git a/docs/env.md b/docs/env.md index f1c0f7f..a7680f8 100644 --- a/docs/env.md +++ b/docs/env.md @@ -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` | diff --git a/docs/routes.md b/docs/routes.md index 1bdb46b..d3f935d 100644 --- a/docs/routes.md +++ b/docs/routes.md @@ -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` 后续价格、时长或设备数变化而变化;席位数量由授予时快照固定。 diff --git a/docs/tasks/T-632.md b/docs/tasks/T-632.md index 42cc020..b5949c3 100644 --- a/docs/tasks/T-632.md +++ b/docs/tasks/T-632.md @@ -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 时项目账号无本机权限,因此本轮未取得数据库集成测试通过结果,未将其误记为通过。 diff --git a/progress.md b/progress.md index 6a6de12..f7c74e6 100644 --- a/progress.md +++ b/progress.md @@ -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 数据库集成回归,并核对桌面端状态页。