feat: add account subscription authorization
This commit is contained in:
+110
-1
@@ -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)
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -86,6 +86,18 @@ class AuthorizationDecision:
|
||||
return not self.allowed
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SubscriptionAuthorizationDecision:
|
||||
product_code: str
|
||||
allowed: bool
|
||||
code: str
|
||||
entitlement: SoftwareEntitlement | None = None
|
||||
|
||||
@property
|
||||
def would_reject(self) -> bool:
|
||||
return not self.allowed
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DeviceRegistrationResult:
|
||||
device: ClientDevice
|
||||
@@ -911,3 +923,74 @@ def evaluate_device_authorization(*, user, product_code: str, device=None, raw_c
|
||||
if not credential.is_active_at(now) or not credential.entitlement.is_usable_at(now):
|
||||
return AuthorizationDecision(product_code, False, "license_expired", credential.id)
|
||||
return AuthorizationDecision(product_code, True, "", credential.id)
|
||||
|
||||
|
||||
def evaluate_account_authorization(*, user, product_code: str, now=None):
|
||||
"""Authorize a product by account entitlement, without device or credential checks."""
|
||||
now = now or timezone.now()
|
||||
usable_entitlement = (
|
||||
SoftwareEntitlement.objects.filter(
|
||||
user=user,
|
||||
product_code=product_code,
|
||||
status=SoftwareEntitlement.Status.ACTIVE,
|
||||
starts_at__lte=now,
|
||||
grace_expires_at__gt=now,
|
||||
)
|
||||
.order_by("-expires_at", "-id")
|
||||
.first()
|
||||
)
|
||||
if usable_entitlement is not None:
|
||||
return SubscriptionAuthorizationDecision(
|
||||
product_code=product_code,
|
||||
allowed=True,
|
||||
code="",
|
||||
entitlement=usable_entitlement,
|
||||
)
|
||||
|
||||
historical_entitlement = (
|
||||
SoftwareEntitlement.objects.filter(
|
||||
user=user,
|
||||
product_code=product_code,
|
||||
)
|
||||
.exclude(status=SoftwareEntitlement.Status.REVOKED)
|
||||
.order_by("-grace_expires_at", "-id")
|
||||
.first()
|
||||
)
|
||||
if historical_entitlement is not None:
|
||||
return SubscriptionAuthorizationDecision(
|
||||
product_code=product_code,
|
||||
allowed=False,
|
||||
code="subscription_expired",
|
||||
entitlement=historical_entitlement,
|
||||
)
|
||||
return SubscriptionAuthorizationDecision(
|
||||
product_code=product_code,
|
||||
allowed=False,
|
||||
code="subscription_required",
|
||||
)
|
||||
|
||||
|
||||
def software_subscription_status(*, user, product_code: str, now=None) -> dict:
|
||||
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": {
|
||||
"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,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user