feat: add account subscription authorization

This commit is contained in:
QiuSW
2026-07-21 16:38:52 +08:00
parent 64a3dfec1d
commit d9230b9688
11 changed files with 245 additions and 15 deletions
+110 -1
View File
@@ -64,7 +64,11 @@ 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.services import create_software_order, register_device
from apps.licensing.services import (
create_software_order,
grant_software_entitlement,
register_device,
)
from apps.users.models import ApiKey
from apps.users.models import UserWallet
@@ -1417,6 +1421,111 @@ class GenerateApiTests(TestCase):
with patch("apps.api.generation.get_provider", return_value=provider or self.provider):
return self.client.post(path, payload, format="json", **self.auth_header(), **extra)
def create_cmshopee_entitlement(self, *, starts_at=None):
plan = SoftwarePlan.objects.create(
product_code=ClientDevice.ProductCode.CMSHOPEE,
name="虾皮圈月度订阅",
duration_days=30,
price=Decimal("19.90"),
device_limit=1,
grace_days=3,
)
return grant_software_entitlement(
user=self.user,
plan=plan,
reason="API 订阅授权测试",
starts_at=starts_at,
)
def test_cmshopee_subscription_status_is_required_without_entitlement(self):
response = self.client.get(
"/api/v1/cmshopee/subscription/status",
**self.auth_header(),
)
self.assertEqual(response.status_code, 200)
self.assertEqual(
response.json(),
{
"product_code": "cmshopee",
"status": "required",
"allowed": False,
"code": "subscription_required",
"plan": None,
},
)
def test_cmshopee_subscription_status_is_active_without_device_session(self):
entitlement = self.create_cmshopee_entitlement()
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.assertIsNone(data["code"])
self.assertEqual(data["plan"]["name"], entitlement.plan_name)
def test_cmshopee_subscription_status_is_expired_after_grace_period(self):
self.create_cmshopee_entitlement(
starts_at=timezone.now() - timedelta(days=40),
)
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"], "expired")
self.assertFalse(data["allowed"])
self.assertEqual(data["code"], "subscription_expired")
@override_settings(CMSHOPEE_SUBSCRIPTION_ENFORCEMENT=True)
def test_cmshopee_enforcement_rejects_without_subscription(self):
response = self.post_with_provider(
"/api/v1/cmshopee/generate/title",
{"prompt": "生成一个商品标题", "model": self.title_alias},
)
self.assertEqual(response.status_code, 403)
self.assertEqual(response.json()["error"]["code"], "subscription_required")
self.assertEqual(self.provider.text_calls, [])
self.assert_generation_not_charged()
@override_settings(CMSHOPEE_SUBSCRIPTION_ENFORCEMENT=True)
def test_cmshopee_account_subscription_allows_multiple_device_contexts(self):
self.create_cmshopee_entitlement()
first_response = self.post_with_provider(
"/api/v1/cmshopee/generate/title",
{"prompt": "生成一个商品标题", "model": self.title_alias},
)
second_response = self.post_with_provider(
"/api/v1/cmshopee/generate/title",
{"prompt": "再生成一个商品标题", "model": self.title_alias},
HTTP_X_DEVICE_SESSION="stale-device-session",
)
self.assertEqual(first_response.status_code, 200)
self.assertEqual(second_response.status_code, 200)
self.assertEqual(len(self.provider.text_calls), 2)
@override_settings(CMSHOPEE_SUBSCRIPTION_ENFORCEMENT=True)
def test_generic_generation_remains_available_without_subscription(self):
response = self.post_with_provider(
"/api/v1/generate/title",
{"prompt": "通用接口标题", "model": self.title_alias},
)
self.assertEqual(response.status_code, 200)
self.assertEqual(len(self.provider.text_calls), 1)
def telemetry_event_from_logs(self, captured):
events = [
getattr(record, "generation_route_usage", None)
+6
View File
@@ -9,6 +9,7 @@ from .views import (
CmshopeeGenerateImageTaskDetailView,
CmshopeeGenerateImageTaskSubmitView,
CmshopeeGenerateTitleView,
CmshopeeSubscriptionStatusView,
DeviceHeartbeatView,
DeviceRegistrationView,
GenerateImageTaskDetailView,
@@ -59,6 +60,11 @@ urlpatterns = [
CmshopeeGenerateTitleView.as_view(),
name="api-cmshopee-generate-title",
),
path(
"v1/cmshopee/subscription/status",
CmshopeeSubscriptionStatusView.as_view(),
name="api-cmshopee-subscription-status",
),
path("v1/analyze/images", AnalyzeImagesView.as_view(), name="api-analyze-images"),
path(
"v1/cmshopee/analyze/images",
+31 -8
View File
@@ -77,9 +77,10 @@ from apps.licensing.services import (
SoftwareOrderPayMethodMismatchError,
apply_software_payment,
create_migration_request,
evaluate_device_authorization,
expire_software_order,
query_and_apply_software_payment,
evaluate_account_authorization,
software_subscription_status,
record_device_heartbeat,
register_device,
resolve_optional_device_session,
@@ -394,22 +395,26 @@ class CmshopeeShadowAuthorizationMixin:
product_code = "cmshopee"
def optional_client_device(self, request):
device = super().optional_client_device(request)
decision = evaluate_device_authorization(
try:
device = super().optional_client_device(request)
except ApiRequestError:
# A stale device session must not block an account-based subscription.
request.device_session_present = bool(
request.headers.get("X-Device-Session", "").strip()
)
device = None
decision = evaluate_account_authorization(
user=request.user,
product_code=self.product_code,
device=device,
raw_credential_token=request.headers.get("X-Device-Credential", ""),
)
event = {
"event": "cmshopee_authorization_shadow",
"event": "cmshopee_subscription_authorization",
"product_code": self.product_code,
"user_id": request.user.id,
"client_device_id": getattr(device, "id", None),
"credential_id": decision.credential_id,
"would_reject": decision.would_reject,
"would_reject_code": decision.code,
"shadow_mode": settings.CMSHOPEE_AUTHORIZATION_SHADOW_MODE,
"enforcement": settings.CMSHOPEE_SUBSCRIPTION_ENFORCEMENT,
}
authorization_logger.info(
"%s %s",
@@ -417,6 +422,13 @@ class CmshopeeShadowAuthorizationMixin:
event,
extra={"cmshopee_authorization": event},
)
if settings.CMSHOPEE_SUBSCRIPTION_ENFORCEMENT and decision.would_reject:
message = (
"当前账号没有有效的软件订阅"
if decision.code == "subscription_required"
else "软件订阅已到期,请先续订"
)
raise ApiRequestError(decision.code, message, status.HTTP_403_FORBIDDEN)
request.cmshopee_authorization = decision
return device
@@ -440,6 +452,17 @@ class CmshopeeGenerateImageTaskDetailView(GenerateImageTaskDetailView):
pass
class CmshopeeSubscriptionStatusView(ExternalApiView):
def get(self, request):
return Response(
software_subscription_status(
user=request.user,
product_code="cmshopee",
),
status=status.HTTP_200_OK,
)
class BalanceView(ExternalApiView):
def get(self, request):
balance = get_balance_snapshot(request.user)