feat: add account subscription authorization
This commit is contained in:
@@ -84,6 +84,7 @@ WECHAT_PAY_CERT_SERIAL_NO=your-cert-serial-no
|
|||||||
WECHAT_PAY_PRIVATE_KEY_PATH=/secure/wechat/apiclient_key.pem
|
WECHAT_PAY_PRIVATE_KEY_PATH=/secure/wechat/apiclient_key.pem
|
||||||
WECHAT_PAY_NOTIFY_URL=https://cmhub.example.com/api/v1/recharge/callback/wechat
|
WECHAT_PAY_NOTIFY_URL=https://cmhub.example.com/api/v1/recharge/callback/wechat
|
||||||
SOFTWARE_WECHAT_PAY_NOTIFY_URL=https://cmhub.example.com/api/v1/software-orders/callback/wechat
|
SOFTWARE_WECHAT_PAY_NOTIFY_URL=https://cmhub.example.com/api/v1/software-orders/callback/wechat
|
||||||
|
CMSHOPEE_SUBSCRIPTION_ENFORCEMENT=false
|
||||||
|
|
||||||
# Alipay face-to-face payment
|
# Alipay face-to-face payment
|
||||||
ALIPAY_APPID=your-alipay-appid
|
ALIPAY_APPID=your-alipay-appid
|
||||||
|
|||||||
+110
-1
@@ -64,7 +64,11 @@ from apps.moderation.models import SensitiveWord
|
|||||||
from apps.moderation.providers.keyword import reset_keyword_matcher_cache
|
from apps.moderation.providers.keyword import reset_keyword_matcher_cache
|
||||||
from apps.portal.models import DownloadRelease
|
from apps.portal.models import DownloadRelease
|
||||||
from apps.licensing.models import ClientDevice, SoftwareOrder, SoftwarePlan
|
from apps.licensing.models import ClientDevice, 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 ApiKey
|
||||||
from apps.users.models import UserWallet
|
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):
|
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)
|
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):
|
def telemetry_event_from_logs(self, captured):
|
||||||
events = [
|
events = [
|
||||||
getattr(record, "generation_route_usage", None)
|
getattr(record, "generation_route_usage", None)
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ from .views import (
|
|||||||
CmshopeeGenerateImageTaskDetailView,
|
CmshopeeGenerateImageTaskDetailView,
|
||||||
CmshopeeGenerateImageTaskSubmitView,
|
CmshopeeGenerateImageTaskSubmitView,
|
||||||
CmshopeeGenerateTitleView,
|
CmshopeeGenerateTitleView,
|
||||||
|
CmshopeeSubscriptionStatusView,
|
||||||
DeviceHeartbeatView,
|
DeviceHeartbeatView,
|
||||||
DeviceRegistrationView,
|
DeviceRegistrationView,
|
||||||
GenerateImageTaskDetailView,
|
GenerateImageTaskDetailView,
|
||||||
@@ -59,6 +60,11 @@ urlpatterns = [
|
|||||||
CmshopeeGenerateTitleView.as_view(),
|
CmshopeeGenerateTitleView.as_view(),
|
||||||
name="api-cmshopee-generate-title",
|
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/analyze/images", AnalyzeImagesView.as_view(), name="api-analyze-images"),
|
||||||
path(
|
path(
|
||||||
"v1/cmshopee/analyze/images",
|
"v1/cmshopee/analyze/images",
|
||||||
|
|||||||
+31
-8
@@ -77,9 +77,10 @@ from apps.licensing.services import (
|
|||||||
SoftwareOrderPayMethodMismatchError,
|
SoftwareOrderPayMethodMismatchError,
|
||||||
apply_software_payment,
|
apply_software_payment,
|
||||||
create_migration_request,
|
create_migration_request,
|
||||||
evaluate_device_authorization,
|
|
||||||
expire_software_order,
|
expire_software_order,
|
||||||
query_and_apply_software_payment,
|
query_and_apply_software_payment,
|
||||||
|
evaluate_account_authorization,
|
||||||
|
software_subscription_status,
|
||||||
record_device_heartbeat,
|
record_device_heartbeat,
|
||||||
register_device,
|
register_device,
|
||||||
resolve_optional_device_session,
|
resolve_optional_device_session,
|
||||||
@@ -394,22 +395,26 @@ class CmshopeeShadowAuthorizationMixin:
|
|||||||
product_code = "cmshopee"
|
product_code = "cmshopee"
|
||||||
|
|
||||||
def optional_client_device(self, request):
|
def optional_client_device(self, request):
|
||||||
device = super().optional_client_device(request)
|
try:
|
||||||
decision = evaluate_device_authorization(
|
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,
|
user=request.user,
|
||||||
product_code=self.product_code,
|
product_code=self.product_code,
|
||||||
device=device,
|
|
||||||
raw_credential_token=request.headers.get("X-Device-Credential", ""),
|
|
||||||
)
|
)
|
||||||
event = {
|
event = {
|
||||||
"event": "cmshopee_authorization_shadow",
|
"event": "cmshopee_subscription_authorization",
|
||||||
"product_code": self.product_code,
|
"product_code": self.product_code,
|
||||||
"user_id": request.user.id,
|
"user_id": request.user.id,
|
||||||
"client_device_id": getattr(device, "id", None),
|
"client_device_id": getattr(device, "id", None),
|
||||||
"credential_id": decision.credential_id,
|
|
||||||
"would_reject": decision.would_reject,
|
"would_reject": decision.would_reject,
|
||||||
"would_reject_code": decision.code,
|
"would_reject_code": decision.code,
|
||||||
"shadow_mode": settings.CMSHOPEE_AUTHORIZATION_SHADOW_MODE,
|
"enforcement": settings.CMSHOPEE_SUBSCRIPTION_ENFORCEMENT,
|
||||||
}
|
}
|
||||||
authorization_logger.info(
|
authorization_logger.info(
|
||||||
"%s %s",
|
"%s %s",
|
||||||
@@ -417,6 +422,13 @@ class CmshopeeShadowAuthorizationMixin:
|
|||||||
event,
|
event,
|
||||||
extra={"cmshopee_authorization": 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
|
request.cmshopee_authorization = decision
|
||||||
return device
|
return device
|
||||||
|
|
||||||
@@ -440,6 +452,17 @@ class CmshopeeGenerateImageTaskDetailView(GenerateImageTaskDetailView):
|
|||||||
pass
|
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):
|
class BalanceView(ExternalApiView):
|
||||||
def get(self, request):
|
def get(self, request):
|
||||||
balance = get_balance_snapshot(request.user)
|
balance = get_balance_snapshot(request.user)
|
||||||
|
|||||||
@@ -86,6 +86,18 @@ class AuthorizationDecision:
|
|||||||
return not self.allowed
|
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)
|
@dataclass(frozen=True)
|
||||||
class DeviceRegistrationResult:
|
class DeviceRegistrationResult:
|
||||||
device: ClientDevice
|
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):
|
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, False, "license_expired", credential.id)
|
||||||
return AuthorizationDecision(product_code, True, "", 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,
|
||||||
|
}
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ DEVICE_SESSION_TTL_SECONDS = env_int("DEVICE_SESSION_TTL_SECONDS", 3600)
|
|||||||
DEVICE_ACTIVITY_UPDATE_SECONDS = env_int("DEVICE_ACTIVITY_UPDATE_SECONDS", 86400)
|
DEVICE_ACTIVITY_UPDATE_SECONDS = env_int("DEVICE_ACTIVITY_UPDATE_SECONDS", 86400)
|
||||||
MIGRATION_REQUEST_TTL_SECONDS = env_int("MIGRATION_REQUEST_TTL_SECONDS", 900)
|
MIGRATION_REQUEST_TTL_SECONDS = env_int("MIGRATION_REQUEST_TTL_SECONDS", 900)
|
||||||
CMSHOPEE_AUTHORIZATION_SHADOW_MODE = env_bool("CMSHOPEE_AUTHORIZATION_SHADOW_MODE", True)
|
CMSHOPEE_AUTHORIZATION_SHADOW_MODE = env_bool("CMSHOPEE_AUTHORIZATION_SHADOW_MODE", True)
|
||||||
|
CMSHOPEE_SUBSCRIPTION_ENFORCEMENT = env_bool("CMSHOPEE_SUBSCRIPTION_ENFORCEMENT", False)
|
||||||
|
|
||||||
# SECURITY WARNING: don't run with debug turned on in production!
|
# SECURITY WARNING: don't run with debug turned on in production!
|
||||||
DEBUG = env_bool("DJANGO_DEBUG", True)
|
DEBUG = env_bool("DJANGO_DEBUG", True)
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ T-626 已在同一 app 建立授权基础:`SoftwarePlan` 可由运营维护产
|
|||||||
|
|
||||||
T-629 已完成:`SoftwareOrder` 属于 `apps.licensing`,与 `RechargeOrder`、`PointsLedger` 完全隔离。订单在创建时锁定产品、套餐、金额、时长、设备数和宽限期快照;支付成功后锁订单,校验金额/通道/交易号,首次购买创建权益,续订延长同套餐权益,并记录一项可反查订单的 `LicenseEvent(order_fulfilled)`。`(pay_method, payment_txn_no)` 唯一且已支付订单只接受原交易号,防止跨订单复用交易号;任何路径不得调用充值入账服务或修改钱包。微信下单、验签与查单只复用通用网关适配,订阅使用独立 notify URL、回调与状态查询路由。第一版不做自动代扣、退款发起或自动回收权益;已支付退款及权益撤销由运营按订单人工处理。
|
T-629 已完成:`SoftwareOrder` 属于 `apps.licensing`,与 `RechargeOrder`、`PointsLedger` 完全隔离。订单在创建时锁定产品、套餐、金额、时长、设备数和宽限期快照;支付成功后锁订单,校验金额/通道/交易号,首次购买创建权益,续订延长同套餐权益,并记录一项可反查订单的 `LicenseEvent(order_fulfilled)`。`(pay_method, payment_txn_no)` 唯一且已支付订单只接受原交易号,防止跨订单复用交易号;任何路径不得调用充值入账服务或修改钱包。微信下单、验签与查单只复用通用网关适配,订阅使用独立 notify URL、回调与状态查询路由。第一版不做自动代扣、退款发起或自动回收权益;已支付退款及权益撤销由运营按订单人工处理。
|
||||||
|
|
||||||
T-630 规划:蝦皮圈产品授权从设备凭证改为账号订阅。`evaluate_account_authorization(user, product_code)` 只查询用户有效 `SoftwareEntitlement`,不读取 `DeviceCredential`、`LicenseSeat` 或 `DeviceSession`;同一用户可从多台设备调用。`ClientDevice` / `DeviceSession` 仍可作为可选观测关联到调用记录,但不参与授权结果。产品专属入口默认只记录 `would_reject` 账号订阅日志,配置 `CMSHOPEE_SUBSCRIPTION_ENFORCEMENT=true` 后才将无订阅/过期结果映射为 403;通用生成入口继续保持原 API Key + 点数语义。
|
T-630 已完成:蝦皮圈产品授权从设备凭证改为账号订阅。`evaluate_account_authorization(user, product_code)` 只查询用户当前有效的 `SoftwareEntitlement`,不读取 `DeviceCredential`、`LicenseSeat` 或 `DeviceSession`;同一用户可从多台设备调用。`ClientDevice` / `DeviceSession` 仍可作为可选观测关联到调用记录,但不参与授权结果。产品专属入口默认只记录账号订阅影子结果,配置 `CMSHOPEE_SUBSCRIPTION_ENFORCEMENT=true` 后才将无订阅/过期结果映射为 403;过期或无效设备会话不会阻断有效账号订阅。通用生成入口继续保持原 API Key + 点数语义。
|
||||||
|
|
||||||
T-627 已建立存量迁移闭环:运营在 admin 显式创建 `LegacyMigrationGrant`,它关联一项套餐快照权益及资格快照,新注册用户不会自动创建。客户端必须以旧 API Key 加服务端验证的当前设备会话申请 `MigrationRequest`;请求只短时有效,生成的设备凭证明文只在该 API 响应出现一次。portal 仅允许请求所属账号确认,确认事务内分配席位、按请求中已存 token hash 签发 `DeviceCredential`,重复确认只返回既有结果。用户自助解绑会吊销凭证、写授权事件并释放对应席位。原始凭证、机器标识、公钥和 API Key 都不写入迁移记录 / 事件;T-628 才会用凭证参与专属接口影子校验。
|
T-627 已建立存量迁移闭环:运营在 admin 显式创建 `LegacyMigrationGrant`,它关联一项套餐快照权益及资格快照,新注册用户不会自动创建。客户端必须以旧 API Key 加服务端验证的当前设备会话申请 `MigrationRequest`;请求只短时有效,生成的设备凭证明文只在该 API 响应出现一次。portal 仅允许请求所属账号确认,确认事务内分配席位、按请求中已存 token hash 签发 `DeviceCredential`,重复确认只返回既有结果。用户自助解绑会吊销凭证、写授权事件并释放对应席位。原始凭证、机器标识、公钥和 API Key 都不写入迁移记录 / 事件;T-628 才会用凭证参与专属接口影子校验。
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -108,7 +108,7 @@
|
|||||||
| T-627 | 存量用户迁移权益、网页确认与设备凭证 | T-624, T-626, T-501 | 已完成存量迁移闭环:新增 `LegacyMigrationGrant`、短时 `MigrationRequest` 和 `DeviceCredential`,均只保存 hash/摘要;admin 通过显式用户+套餐授予迁移资格和快照,新注册用户无自动路径。客户端用旧 API Key + 当前设备会话申请迁移并一次性取得凭证明文;同账号 portal 确认后事务内绑定席位、按 hash 签发凭证,重复确认/轮询不重复占位或签发。用户可自助解绑,凭证吊销并释放席位且写审计。新增迁移 API、portal 确认/设备页;旧生成 API 完全不变。已覆盖资格、缺会话、跨账号、过期、重复确认、凭证 hash、状态轮询与解绑;迁移 / `check` / 迁移一致性通过。 | DONE |
|
| T-627 | 存量用户迁移权益、网页确认与设备凭证 | T-624, T-626, T-501 | 已完成存量迁移闭环:新增 `LegacyMigrationGrant`、短时 `MigrationRequest` 和 `DeviceCredential`,均只保存 hash/摘要;admin 通过显式用户+套餐授予迁移资格和快照,新注册用户无自动路径。客户端用旧 API Key + 当前设备会话申请迁移并一次性取得凭证明文;同账号 portal 确认后事务内绑定席位、按 hash 签发凭证,重复确认/轮询不重复占位或签发。用户可自助解绑,凭证吊销并释放席位且写审计。新增迁移 API、portal 确认/设备页;旧生成 API 完全不变。已覆盖资格、缺会话、跨账号、过期、重复确认、凭证 hash、状态轮询与解绑;迁移 / `check` / 迁移一致性通过。 | DONE |
|
||||||
| T-628 | 蝦皮圈专属授权入口与影子校验 | T-625, T-626, T-627, T-613, T-614 | 已完成:新增 `/api/v1/cmshopee/` 的 title、vision、异步图片提交和已接受任务读取,均复用原 API / 计费 core。统一授权判定检查设备会话、凭证、用户、产品、席位和权益,返回 `device_not_bound` / `device_mismatch` / `license_required` / `license_expired`;当前仅写脱敏 `would_reject` 日志,不阻断专属或通用调用。通用 `/api/v1/generate/*` 未改,任务读取不追加订阅校验;`CMSHOPEE_AUTHORIZATION_SHADOW_MODE=true` 为部署口径。已验证正确 / 缺失 / 过期凭证判定、迁移流程回归、`check` 和迁移一致性。**评审说明(2026-07-21)**:`CMSHOPEE_AUTHORIZATION_SHADOW_MODE` 当前仅被写入日志事件,没有任何分支依据它决定放行或拒绝,属阶段 2 预期行为;须留档提醒——切换强制拦截不是改环境变量即可,需要在专属入口显式加入拒绝分支并同步部署说明,避免运维误判为纯配置开关。 | DONE |
|
| T-628 | 蝦皮圈专属授权入口与影子校验 | T-625, T-626, T-627, T-613, T-614 | 已完成:新增 `/api/v1/cmshopee/` 的 title、vision、异步图片提交和已接受任务读取,均复用原 API / 计费 core。统一授权判定检查设备会话、凭证、用户、产品、席位和权益,返回 `device_not_bound` / `device_mismatch` / `license_required` / `license_expired`;当前仅写脱敏 `would_reject` 日志,不阻断专属或通用调用。通用 `/api/v1/generate/*` 未改,任务读取不追加订阅校验;`CMSHOPEE_AUTHORIZATION_SHADOW_MODE=true` 为部署口径。已验证正确 / 缺失 / 过期凭证判定、迁移流程回归、`check` 和迁移一致性。**评审说明(2026-07-21)**:`CMSHOPEE_AUTHORIZATION_SHADOW_MODE` 当前仅被写入日志事件,没有任何分支依据它决定放行或拒绝,属阶段 2 预期行为;须留档提醒——切换强制拦截不是改环境变量即可,需要在专属入口显式加入拒绝分支并同步部署说明,避免运维误判为纯配置开关。 | DONE |
|
||||||
| T-629 | 软件套餐购买、续订订单与权益入账 | T-626, T-627, T-628, T-304, T-305 | **已完成。** 新增与 `RechargeOrder`、`PointsLedger` 完全分离的 `SoftwareOrder` 和 `licensing.0004_alter_licenseevent_action_softwareorder`。订单锁定产品、套餐、金额、币种、时长、设备数、宽限期和通道快照,保存关联权益、唯一 `order_fulfilled` 事件和发放时间;`order_no` 与 `(pay_method, payment_txn_no)` 受数据库唯一约束,已支付订单收到不同交易号一律拒绝。微信支付仅复用通用验签、下单、查单适配,新增独立 `SOFTWARE_WECHAT_PAY_NOTIFY_URL`、`POST /api/v1/software-orders/callback/wechat` 与 `GET /api/v1/software-orders/status`,未调用 `apply_recharge_payment()`、未写 `UserWallet` / `PointsLedger`。portal 新增 `/subscription` 套餐购买/续订、状态和最近订单,admin 新增只读软件套餐订单检索。成功回调/查单锁订单后首次创建权益或续订同套餐权益;同订单重复/并发回调最多发放一次,金额/通道/验签不一致不发权益。支付票据到期转 `expired`,下单失败转 `failed`。第一版仅微信主动月度续订,不做自动代扣、退款发起或自动退款回收;已支付退款与权益撤销由运营按订单人工处理。**同时修复 T-626 遗留**:续期同步刷新未吊销凭证到期时间,撤销权益逐项吊销凭证、释放席位并写审计。已通过 12 条 licensing/API 目标测试(含双连接并发回调)和 31 条既有充值回归;`check`、`makemigrations --check --dry-run`、编译检查通过。全量测试在远端 MySQL 测试库运行 10 分钟未返回且无失败输出,未将其记为通过。 | DONE |
|
| T-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/*`、充值、点数和历史迁移接口不变。保留旧设备/卡密数据只读,不删除历史记录,不再签发新设备凭证。测试覆盖多设备同账号、无订阅、过期、有效订阅、开关强制拦截和通用接口回归。 | TODO |
|
| 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 |
|
||||||
|
|
||||||
## 里程碑
|
## 里程碑
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -41,7 +41,7 @@ T-305 已实现扫码充值下单与轮询基线:`POST /api/v1/recharge/create
|
|||||||
|
|
||||||
T-629 已完成:软件套餐订单使用独立 `SoftwareOrder`、`POST /api/v1/software-orders/callback/wechat` 与 `GET /api/v1/software-orders/status`,只发放/续订软件权益,绝不兑换点数或触碰 `RechargeOrder` / `PointsLedger`。订阅微信下单必须配置独立 `SOFTWARE_WECHAT_PAY_NOTIFY_URL`,不得复用充值回调地址。
|
T-629 已完成:软件套餐订单使用独立 `SoftwareOrder`、`POST /api/v1/software-orders/callback/wechat` 与 `GET /api/v1/software-orders/status`,只发放/续订软件权益,绝不兑换点数或触碰 `RechargeOrder` / `PointsLedger`。订阅微信下单必须配置独立 `SOFTWARE_WECHAT_PAY_NOTIFY_URL`,不得复用充值回调地址。
|
||||||
|
|
||||||
T-630 规划:蝦皮圈授权改为账号订阅模式。产品专属接口按 API Key 对应用户查询 `SoftwareEntitlement`,默认允许同一账号在多台电脑使用;设备登记、设备会话和历史凭证不再作为授权前置条件。
|
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-306 已实现对外 API 安全加固:`image_url` 下载只允许 `http` / `https` 公网地址,拒绝私有/回环/链路本地/保留等地址,重定向后重新校验并限制响应大小;全局 DRF 默认认证为空且默认权限为 `IsAuthenticated`,外部 API 与用户端 API 必须显式 opt-in 认证类;生成接口按 API Key / 用户限流,认证失败按 IP 限流;充值创建有 `RECHARGE_MAX_AMOUNT_CNY` 单笔上限。
|
T-306 已实现对外 API 安全加固:`image_url` 下载只允许 `http` / `https` 公网地址,拒绝私有/回环/链路本地/保留等地址,重定向后重新校验并限制响应大小;全局 DRF 默认认证为空且默认权限为 `IsAuthenticated`,外部 API 与用户端 API 必须显式 opt-in 认证类;生成接口按 API Key / 用户限流,认证失败按 IP 限流;充值创建有 `RECHARGE_MAX_AMOUNT_CNY` 单笔上限。
|
||||||
|
|
||||||
|
|||||||
@@ -75,11 +75,11 @@
|
|||||||
任务状态以 [`06-tasks.md`](06-tasks.md) 为准,历史执行记录见 [`../progress.md`](../progress.md)。
|
任务状态以 [`06-tasks.md`](06-tasks.md) 为准,历史执行记录见 [`../progress.md`](../progress.md)。
|
||||||
|
|
||||||
- 已完成:T-001 初始化 Django + DRF 项目骨架;T-002 建立 apps 目录、自定义 User 与配置;T-003 接通 django-admin 与最小测试;T-004 Phase 0 骨架审核修补;T-101 Provider 适配器层 + 移植 cmbot 调用;T-102 AiModel + ModelAlias 模型 + 别名解析;T-103 配置变更审计;T-104 跑通一次录制标题生成;T-105 Phase 1 AI 层审核修补;T-201 User / UserWallet / ApiKey / PointsLedger / CallRecord 模型;T-202 PricingRule / ExchangeRate 模型 + 计费计算;T-203 并发安全扣点 / 退点;T-204 Phase 2 计费核心审核加固;T-301 API Key 鉴权;T-302 生成标题 / 图片接口;T-303 余额查询接口;T-304 充值回调;T-305 扫码充值下单 + 轮询;T-306 Phase 3 对外 API 安全加固;T-501 注册 / 登录(allauth);T-502 API Key 自助管理页;T-503 个人中心 / 记录页;T-504 充值页(扫码 + 轮询到账);T-505 Phase 4 用户端审核优化;T-401 运营后台完善;T-402 完整验收 MVP;T-403 部署 / 运行文档;T-601 可用别名发现;T-602 django-admin 中文化(第 1-3 层);T-603 django-admin 中文化(第 4 层·字段级);T-604 中文敏感词本地过滤;T-605 免邮箱验证策略落地;T-606 公开首页 + 客户端下载入口;T-607 桌面端最新版本检查接口;T-608 新用户注册赠送试用点数(当前 10 点);T-609 桌面端版本检查接口增加强制更新标记;T-610 首页导入模板下载入口;T-611 用户端品牌名统一为虾皮圈;T-612 生图同步接口止血(上游硬截止 + 长请求池校准);T-613 抽生成核心 service(计费+审核+上游共享 core);T-614 生图异步任务化接口(提交+轮询,新增不动旧接口);T-615 旧同步生图接口用量遥测 + 弃用口径;T-616 生图失败自动重试 2 次;T-617 桌面端版本检查接口增加文件大小字段;T-618 客户端发布版本后台必填文件校验元数据。
|
- 已完成:T-001 初始化 Django + DRF 项目骨架;T-002 建立 apps 目录、自定义 User 与配置;T-003 接通 django-admin 与最小测试;T-004 Phase 0 骨架审核修补;T-101 Provider 适配器层 + 移植 cmbot 调用;T-102 AiModel + ModelAlias 模型 + 别名解析;T-103 配置变更审计;T-104 跑通一次录制标题生成;T-105 Phase 1 AI 层审核修补;T-201 User / UserWallet / ApiKey / PointsLedger / CallRecord 模型;T-202 PricingRule / ExchangeRate 模型 + 计费计算;T-203 并发安全扣点 / 退点;T-204 Phase 2 计费核心审核加固;T-301 API Key 鉴权;T-302 生成标题 / 图片接口;T-303 余额查询接口;T-304 充值回调;T-305 扫码充值下单 + 轮询;T-306 Phase 3 对外 API 安全加固;T-501 注册 / 登录(allauth);T-502 API Key 自助管理页;T-503 个人中心 / 记录页;T-504 充值页(扫码 + 轮询到账);T-505 Phase 4 用户端审核优化;T-401 运营后台完善;T-402 完整验收 MVP;T-403 部署 / 运行文档;T-601 可用别名发现;T-602 django-admin 中文化(第 1-3 层);T-603 django-admin 中文化(第 4 层·字段级);T-604 中文敏感词本地过滤;T-605 免邮箱验证策略落地;T-606 公开首页 + 客户端下载入口;T-607 桌面端最新版本检查接口;T-608 新用户注册赠送试用点数(当前 10 点);T-609 桌面端版本检查接口增加强制更新标记;T-610 首页导入模板下载入口;T-611 用户端品牌名统一为虾皮圈;T-612 生图同步接口止血(上游硬截止 + 长请求池校准);T-613 抽生成核心 service(计费+审核+上游共享 core);T-614 生图异步任务化接口(提交+轮询,新增不动旧接口);T-615 旧同步生图接口用量遥测 + 弃用口径;T-616 生图失败自动重试 2 次;T-617 桌面端版本检查接口增加文件大小字段;T-618 客户端发布版本后台必填文件校验元数据。
|
||||||
- 已完成补充:T-619 多张图片理解并返回文字;T-620 图生图支持单图 / 多图主图与参考图;T-622 图片生成任务后台图片缩略预览;T-623 图片生成任务单图 / 多图筛选;T-624 蝦皮圈设备登记与会话观测;T-625 蝦皮圈设备使用关联与迁移观测;T-626 软件套餐、权益与设备席位基础模型及凭证续期/撤销清理;T-627 存量用户迁移权益、网页确认与设备凭证;T-628 蝦皮圈专属授权入口与影子校验;T-629 软件套餐购买、续订订单与权益入账。
|
- 已完成补充:T-619 多张图片理解并返回文字;T-620 图生图支持单图 / 多图主图与参考图;T-622 图片生成任务后台图片缩略预览;T-623 图片生成任务单图 / 多图筛选;T-624 蝦皮圈设备登记与会话观测;T-625 蝦皮圈设备使用关联与迁移观测;T-626 软件套餐、权益与设备席位基础模型及凭证续期/撤销清理;T-627 存量用户迁移权益、网页确认与设备凭证;T-628 蝦皮圈专属授权入口与影子校验;T-629 软件套餐购买、续订订单与权益入账;T-630 账号订阅授权与默认多设备模式。
|
||||||
- 正在进行:无。
|
- 正在进行:无。
|
||||||
- 待开始:T-630 账号订阅授权与默认多设备模式;T-621 注册赠点运营后台配置继续留在 Backlog。真实支付回调到账闭环、客户端发布、生产多图理解模型配置和线上旧同步接口用量观察仍可继续拆任务。
|
- T-621 注册赠点运营后台配置继续留在 Backlog。真实支付回调到账闭环、客户端发布、生产多图理解模型配置和线上旧同步接口用量观察仍可继续拆任务。
|
||||||
- 当前 blocker:支付商户真实密钥/证书与生产 SDK 依赖仍待提供;微信回调到账闭环仍需真实支付验收;真实 AI 标题生成已在线上跑通,图片生成慢 / 504 / 客户端超时风险已拆为 T-612~T-616 并完成工程侧处理。
|
- 当前 blocker:支付商户真实密钥/证书与生产 SDK 依赖仍待提供;微信回调到账闭环仍需真实支付验收;真实 AI 标题生成已在线上跑通,图片生成慢 / 504 / 客户端超时风险已拆为 T-612~T-616 并完成工程侧处理。
|
||||||
- 下一个可领取任务:T-630 账号订阅授权与默认多设备模式。T-621 保留在 Backlog,真实微信商户支付验收仍是线上 blocker;软件订阅订单已完成代码与 mock/SDK 契约验证,但未进行真实付款验收。具体范围见 [`06-tasks.md`](06-tasks.md)。
|
- 下一个可领取任务:按业务优先级从 Backlog 拆分后续任务。T-621 保留在 Backlog,真实微信商户支付验收仍是线上 blocker;软件订阅订单已完成代码与 mock/SDK 契约验证,但未进行真实付款验收。具体范围见 [`06-tasks.md`](06-tasks.md)。
|
||||||
|
|
||||||
## 当前可运行内容
|
## 当前可运行内容
|
||||||
|
|
||||||
|
|||||||
@@ -2117,3 +2117,10 @@
|
|||||||
- 需求变更:保留软件会员订阅,取消卡密和固定电脑绑定。同一账号默认允许多台设备使用。
|
- 需求变更:保留软件会员订阅,取消卡密和固定电脑绑定。同一账号默认允许多台设备使用。
|
||||||
- 任务边界:授权只检查 API Key 对应用户的 `SoftwareEntitlement`;设备登记/心跳/调用关联仅作观测和风控,历史设备凭证与迁移记录保留只读,不删除、不再作为新授权依据。
|
- 任务边界:授权只检查 API Key 对应用户的 `SoftwareEntitlement`;设备登记/心跳/调用关联仅作观测和风控,历史设备凭证与迁移记录保留只读,不删除、不再作为新授权依据。
|
||||||
- 发布策略:新增 `CMSHOPEE_SUBSCRIPTION_ENFORCEMENT=false`,先记录账号订阅影子结果;完成多设备与存量用户回归后,才在产品专属入口开启实际拒绝。
|
- 发布策略:新增 `CMSHOPEE_SUBSCRIPTION_ENFORCEMENT=false`,先记录账号订阅影子结果;完成多设备与存量用户回归后,才在产品专属入口开启实际拒绝。
|
||||||
|
|
||||||
|
## 2026-07-21 完成:T-630 账号订阅授权与默认多设备模式
|
||||||
|
|
||||||
|
- 交付:蝦皮圈产品专属入口改为按 API Key 对应账号查询有效 `SoftwareEntitlement`;新增订阅状态接口;默认关闭强制校验并记录影子结果,开启 `CMSHOPEE_SUBSCRIPTION_ENFORCEMENT=true` 后无订阅/过期账号返回 403。
|
||||||
|
- 多设备:设备登记、设备会话、旧凭证和席位不再参与账号订阅授权;设备数据继续保留为可选观测,过期设备会话不会阻断有效订阅账号。
|
||||||
|
- 验证:目标 API 测试通过(无订阅状态、有效订阅状态、强制拒绝、多设备上下文),`manage.py check`、`compileall` 通过。测试环境仍提示 MySQL 不支持条件唯一约束的既有 warning。
|
||||||
|
- 下一步:T-621 继续留在 Backlog;按业务优先级拆分真实支付回调验收、客户端发布和生产观察任务。
|
||||||
|
|||||||
Reference in New Issue
Block a user