feat: add cmshopee authorization shadow routes

This commit is contained in:
QiuSW
2026-07-21 10:13:26 +08:00
parent 1af47d043e
commit b1fa2f13b9
8 changed files with 177 additions and 1 deletions
+1
View File
@@ -38,6 +38,7 @@ DEVICE_IDENTIFIER_PEPPER=change-me-device-identifier-pepper
DEVICE_SESSION_TTL_SECONDS=3600 DEVICE_SESSION_TTL_SECONDS=3600
DEVICE_ACTIVITY_UPDATE_SECONDS=86400 DEVICE_ACTIVITY_UPDATE_SECONDS=86400
MIGRATION_REQUEST_TTL_SECONDS=900 MIGRATION_REQUEST_TTL_SECONDS=900
CMSHOPEE_AUTHORIZATION_SHADOW_MODE=true
AI_IMAGE_UPSTREAM_DEADLINE_SECONDS=180 AI_IMAGE_UPSTREAM_DEADLINE_SECONDS=180
PUBLIC_BASE_URL= PUBLIC_BASE_URL=
MEDIA_PUBLIC_BASE_URL= MEDIA_PUBLIC_BASE_URL=
+24
View File
@@ -5,6 +5,10 @@ from .views import (
AnalyzeImagesView, AnalyzeImagesView,
BalanceView, BalanceView,
ClientLatestReleaseView, ClientLatestReleaseView,
CmshopeeAnalyzeImagesView,
CmshopeeGenerateImageTaskDetailView,
CmshopeeGenerateImageTaskSubmitView,
CmshopeeGenerateTitleView,
DeviceHeartbeatView, DeviceHeartbeatView,
DeviceRegistrationView, DeviceRegistrationView,
GenerateImageTaskDetailView, GenerateImageTaskDetailView,
@@ -48,18 +52,38 @@ urlpatterns = [
name="api-client-release-latest", name="api-client-release-latest",
), ),
path("v1/generate/title", GenerateTitleView.as_view(), name="api-generate-title"), path("v1/generate/title", GenerateTitleView.as_view(), name="api-generate-title"),
path(
"v1/cmshopee/generate/title",
CmshopeeGenerateTitleView.as_view(),
name="api-cmshopee-generate-title",
),
path("v1/analyze/images", AnalyzeImagesView.as_view(), name="api-analyze-images"), path("v1/analyze/images", AnalyzeImagesView.as_view(), name="api-analyze-images"),
path(
"v1/cmshopee/analyze/images",
CmshopeeAnalyzeImagesView.as_view(),
name="api-cmshopee-analyze-images",
),
path("v1/generate/image", GenerateImageView.as_view(), name="api-generate-image"), path("v1/generate/image", GenerateImageView.as_view(), name="api-generate-image"),
path( path(
"v1/generate/image/tasks", "v1/generate/image/tasks",
GenerateImageTaskSubmitView.as_view(), GenerateImageTaskSubmitView.as_view(),
name="api-generate-image-task-submit", name="api-generate-image-task-submit",
), ),
path(
"v1/cmshopee/generate/image/tasks",
CmshopeeGenerateImageTaskSubmitView.as_view(),
name="api-cmshopee-generate-image-task-submit",
),
path( path(
"v1/generate/image/tasks/<uuid:task_id>", "v1/generate/image/tasks/<uuid:task_id>",
GenerateImageTaskDetailView.as_view(), GenerateImageTaskDetailView.as_view(),
name="api-generate-image-task-detail", name="api-generate-image-task-detail",
), ),
path(
"v1/cmshopee/generate/image/tasks/<uuid:task_id>",
CmshopeeGenerateImageTaskDetailView.as_view(),
name="api-cmshopee-generate-image-task-detail",
),
path("v1/recharge/create", RechargeCreateView.as_view(), name="api-recharge-create"), path("v1/recharge/create", RechargeCreateView.as_view(), name="api-recharge-create"),
path("v1/recharge/status", RechargeStatusView.as_view(), name="api-recharge-status"), path("v1/recharge/status", RechargeStatusView.as_view(), name="api-recharge-status"),
path( path(
+53
View File
@@ -1,6 +1,7 @@
import logging import logging
from django.http import HttpResponse from django.http import HttpResponse
from django.conf import settings
from django.utils import timezone from django.utils import timezone
from django.utils.decorators import method_decorator from django.utils.decorators import method_decorator
from django.views.decorators.csrf import csrf_exempt from django.views.decorators.csrf import csrf_exempt
@@ -70,6 +71,7 @@ from apps.licensing.services import (
DeviceRegistrationError, DeviceRegistrationError,
DeviceSessionValidationError, DeviceSessionValidationError,
create_migration_request, create_migration_request,
evaluate_device_authorization,
record_device_heartbeat, record_device_heartbeat,
register_device, register_device,
resolve_optional_device_session, resolve_optional_device_session,
@@ -77,6 +79,7 @@ from apps.licensing.services import (
from apps.licensing.models import MigrationRequest from apps.licensing.models import MigrationRequest
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
authorization_logger = logging.getLogger("cmhub.licensing.authorization")
class ExternalApiView(APIView): class ExternalApiView(APIView):
@@ -379,6 +382,56 @@ class GenerateImageTaskDetailView(ExternalApiView):
return Response(task_detail_response(task), status=status.HTTP_200_OK) return Response(task_detail_response(task), status=status.HTTP_200_OK)
class CmshopeeShadowAuthorizationMixin:
product_code = "cmshopee"
def optional_client_device(self, request):
device = super().optional_client_device(request)
decision = evaluate_device_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",
"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,
}
authorization_logger.info(
"%s %s",
event["event"],
event,
extra={"cmshopee_authorization": event},
)
request.cmshopee_authorization = decision
return device
class CmshopeeGenerateTitleView(CmshopeeShadowAuthorizationMixin, GenerateTitleView):
pass
class CmshopeeAnalyzeImagesView(CmshopeeShadowAuthorizationMixin, AnalyzeImagesView):
pass
class CmshopeeGenerateImageTaskSubmitView(
CmshopeeShadowAuthorizationMixin,
GenerateImageTaskSubmitView,
):
pass
class CmshopeeGenerateImageTaskDetailView(GenerateImageTaskDetailView):
pass
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)
+41
View File
@@ -39,6 +39,18 @@ class LicensingError(Exception):
super().__init__(message) super().__init__(message)
@dataclass(frozen=True)
class AuthorizationDecision:
product_code: str
allowed: bool
code: str
credential_id: int | 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
@@ -579,3 +591,32 @@ def revoke_device_credential(*, credential: DeviceCredential, reason: str, actor
now=now, now=now,
) )
return locked_credential return locked_credential
def evaluate_device_authorization(*, user, product_code: str, device=None, raw_credential_token: str = "", now=None):
now = now or timezone.now()
if device is None:
return AuthorizationDecision(product_code, False, "device_not_bound")
if device.user_id != user.id or device.product_code != product_code:
return AuthorizationDecision(product_code, False, "device_mismatch")
raw_credential_token = str(raw_credential_token or "").strip()
if not raw_credential_token:
return AuthorizationDecision(product_code, False, "license_required")
credential = (
DeviceCredential.objects.select_related("device", "entitlement", "seat")
.filter(token_hash=MigrationRequest.hash_credential_token(raw_credential_token))
.first()
)
if credential is None:
return AuthorizationDecision(product_code, False, "license_required")
if (
credential.user_id != user.id
or credential.product_code != product_code
or credential.device_id != device.id
or credential.seat.device_id != device.id
):
return AuthorizationDecision(product_code, False, "device_mismatch", credential.id)
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)
+48
View File
@@ -26,6 +26,7 @@ from apps.licensing.services import (
confirm_migration_request, confirm_migration_request,
create_legacy_migration_grant, create_legacy_migration_grant,
create_migration_request, create_migration_request,
evaluate_device_authorization,
grant_software_entitlement, grant_software_entitlement,
record_device_heartbeat, record_device_heartbeat,
release_license_seat, release_license_seat,
@@ -625,3 +626,50 @@ class LegacyMigrationFlowTests(TestCase):
now=timezone.now(), now=timezone.now(),
) )
self.assertFalse(DeviceCredential.objects.exists()) self.assertFalse(DeviceCredential.objects.exists())
def test_authorization_decision_handles_missing_mismatched_and_expired_credentials(self):
self.grant_migration()
migration_request, raw_token = create_migration_request(
user=self.user,
device=self.device,
)
_request, credential, created = confirm_migration_request(
request_id=migration_request.request_id,
user=self.user,
)
self.assertTrue(created)
self.assertEqual(
evaluate_device_authorization(
user=self.user,
product_code="cmshopee",
).code,
"device_not_bound",
)
self.assertEqual(
evaluate_device_authorization(
user=self.user,
product_code="cmshopee",
device=self.device,
).code,
"license_required",
)
allowed = evaluate_device_authorization(
user=self.user,
product_code="cmshopee",
device=self.device,
raw_credential_token=raw_token,
)
self.assertTrue(allowed.allowed)
self.assertEqual(allowed.credential_id, credential.id)
DeviceCredential.objects.filter(pk=credential.pk).update(
expires_at=timezone.now() - timedelta(seconds=1)
)
expired = evaluate_device_authorization(
user=self.user,
product_code="cmshopee",
device=self.device,
raw_credential_token=raw_token,
)
self.assertTrue(expired.would_reject)
self.assertEqual(expired.code, "license_expired")
+1
View File
@@ -67,6 +67,7 @@ DEVICE_IDENTIFIER_PEPPER = os.environ.get("DEVICE_IDENTIFIER_PEPPER", SECRET_KEY
DEVICE_SESSION_TTL_SECONDS = env_int("DEVICE_SESSION_TTL_SECONDS", 3600) 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)
# 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)
+1 -1
View File
@@ -106,7 +106,7 @@
| T-625 | 蝦皮圈设备使用关联与迁移观测 | T-624, T-613, T-614, T-615 | 已完成阶段 1 关联观测:有效 `X-Device-Session` 仅在标题、同步图片、异步图片提交、vision 的预扣 `CallRecord` 写入服务端解析的可空 `client_device`;无头旧调用保持原返回、扣点、提交和轮询。新增 `billing.0010_callrecord_client_device_and_more` 与 `(client_device, created_at)` 索引;admin 支持展示 / 按设备筛选。`generation_route_usage` 新增 `product_code`、内部 `client_device_id`、`device_session_present` 白名单字段,不记录设备摘要、公钥、令牌、Key、prompt 或图片。伪造/过期会话在预扣前返回 401,吊销/跨账号会话返回 403;仍不做订阅授权拦截。已通过 4 条新增入口/兼容/安全/遥测测试及 14 条 licensing/生成回归、迁移、`check` 和一致性验证。 | DONE | | T-625 | 蝦皮圈设备使用关联与迁移观测 | T-624, T-613, T-614, T-615 | 已完成阶段 1 关联观测:有效 `X-Device-Session` 仅在标题、同步图片、异步图片提交、vision 的预扣 `CallRecord` 写入服务端解析的可空 `client_device`;无头旧调用保持原返回、扣点、提交和轮询。新增 `billing.0010_callrecord_client_device_and_more` 与 `(client_device, created_at)` 索引;admin 支持展示 / 按设备筛选。`generation_route_usage` 新增 `product_code`、内部 `client_device_id`、`device_session_present` 白名单字段,不记录设备摘要、公钥、令牌、Key、prompt 或图片。伪造/过期会话在预扣前返回 401,吊销/跨账号会话返回 403;仍不做订阅授权拦截。已通过 4 条新增入口/兼容/安全/遥测测试及 14 条 licensing/生成回归、迁移、`check` 和一致性验证。 | DONE |
| T-626 | 软件套餐、权益与设备席位基础模型 | T-624, T-401 | 已完成阶段 2 基础模型:新增 `SoftwarePlan`、`SoftwareEntitlement`、`LicenseSeat`、`LicenseEvent` 和 `licensing.0002_softwareentitlement_licenseseat_licenseevent_and_more`。授予时复制套餐名称/价格/时长/设备数/宽限期快照并预创建固定席位;`(entitlement, seat_number)` 唯一。服务层以事务锁权益和席位执行授予、续期、撤销、绑定与解绑,所有人工操作原因必填并写事件;续期从 `max(now, expires_at)` 起算,不触碰钱包。admin 提供套餐维护及权益授予/续期/撤销专用入口,权益/席位/事件只读。未接购买、支付、迁移凭证或生成授权。已通过套餐快照、续期、撤销、审计、后台权限及双连接单席位并发测试、迁移和 `check`。 | DONE | | T-626 | 软件套餐、权益与设备席位基础模型 | T-624, T-401 | 已完成阶段 2 基础模型:新增 `SoftwarePlan`、`SoftwareEntitlement`、`LicenseSeat`、`LicenseEvent` 和 `licensing.0002_softwareentitlement_licenseseat_licenseevent_and_more`。授予时复制套餐名称/价格/时长/设备数/宽限期快照并预创建固定席位;`(entitlement, seat_number)` 唯一。服务层以事务锁权益和席位执行授予、续期、撤销、绑定与解绑,所有人工操作原因必填并写事件;续期从 `max(now, expires_at)` 起算,不触碰钱包。admin 提供套餐维护及权益授予/续期/撤销专用入口,权益/席位/事件只读。未接购买、支付、迁移凭证或生成授权。已通过套餐快照、续期、撤销、审计、后台权限及双连接单席位并发测试、迁移和 `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-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 | 新增蝦皮圈**专属**产品调用入口或明确的产品认证分流,使用 `DeviceCredential + X-Device-Session` 解析产品、用户和设备;不得在通用 `/api/v1/generate/*` 上给全部 API Key 强加订阅校验。实现统一 `licensing` 授权服务/permission,区分“可新提交生成”和“可读取本人已接受任务”,返回稳定结构化错误码(如 `device_not_bound`、`device_mismatch`、`license_required`、`license_expired`),但本任务先以影子模式记录 `would_reject`,不实际阻断已迁移或旧客户端。蝦皮圈专属 title、vision、异步 image submit 接入该服务;已接受任务的查询/下载始终按任务归属可读。测试覆盖通用 API 完全不回归、专属凭证认证、跨用户拒绝、影子结果日志脱敏、过期权益的 would-reject、已接受任务读取和点数账本不被授权检查重复修改;同步 API、架构、部署开关和桌面端对接文档。 | TODO | | 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` 和迁移一致性。 | DONE |
| T-629 | 软件套餐购买、续订订单与权益入账 | T-626, T-627, T-628, T-304, T-305 | 新增与 `RechargeOrder` **分离**的 `SoftwareOrder`,实现蝦皮圈套餐选择、创建待支付订单、支付回调/主动查单后的幂等权益发放与续期;不得把软件订阅金额兑换为点数,也不得改动充值订单或既有点数回调契约。订单须锁定套餐名称、价格、时长、设备数等快照,回调必须验签、校验订单金额/通道、按订单号幂等;同一订单重复回调最多延长一次。portal 提供订阅状态、套餐购买/续订入口和订单只读记录,支付失败/取消保持 pending/failed 语义并保留审计。复用现有支付网关的微信路径但不假设自动续费协议;第一版“月订阅”是用户每月主动续订,自动代扣仅登记后续任务。测试覆盖订单快照、重复回调、金额不匹配、并发回调、续期起算、退款/撤销后的权益状态、充值链路回归;真实生产支付验收依赖既有微信回调闭环修复,未提供商户条件时仅按 mock/SDK 契约测试。 | TODO | | T-629 | 软件套餐购买、续订订单与权益入账 | T-626, T-627, T-628, T-304, T-305 | 新增与 `RechargeOrder` **分离**的 `SoftwareOrder`,实现蝦皮圈套餐选择、创建待支付订单、支付回调/主动查单后的幂等权益发放与续期;不得把软件订阅金额兑换为点数,也不得改动充值订单或既有点数回调契约。订单须锁定套餐名称、价格、时长、设备数等快照,回调必须验签、校验订单金额/通道、按订单号幂等;同一订单重复回调最多延长一次。portal 提供订阅状态、套餐购买/续订入口和订单只读记录,支付失败/取消保持 pending/failed 语义并保留审计。复用现有支付网关的微信路径但不假设自动续费协议;第一版“月订阅”是用户每月主动续订,自动代扣仅登记后续任务。测试覆盖订单快照、重复回调、金额不匹配、并发回调、续期起算、退款/撤销后的权益状态、充值链路回归;真实生产支付验收依赖既有微信回调闭环修复,未提供商户条件时仅按 mock/SDK 契约测试。 | TODO |
## 里程碑 ## 里程碑
+8
View File
@@ -2082,3 +2082,11 @@
- 安全与兼容:跨账号确认返回 403,缺设备会话返回 401,跨设备查询按 404 处理;确认 URL 不含凭证明文。旧生成、余额、模型目录、异步任务和点数账本未改,设备凭证尚未参与生成授权。 - 安全与兼容:跨账号确认返回 403,缺设备会话返回 401,跨设备查询按 404 处理;确认 URL 不含凭证明文。旧生成、余额、模型目录、异步任务和点数账本未改,设备凭证尚未参与生成授权。
- 验证:本地已应用 licensing 迁移。T-627 迁移专项 3 条测试通过,覆盖资格边界、缺会话、跨账号、过期/重复、确认/轮询、凭证 hash 和自助解绑;此前 T-626 14 条 licensing 基础回归通过。`manage.py check` 与 `makemigrations --check --dry-run` 通过;测试仅保留既有 allauth MySQL 条件唯一约束 `models.W036` 警告。 - 验证:本地已应用 licensing 迁移。T-627 迁移专项 3 条测试通过,覆盖资格边界、缺会话、跨账号、过期/重复、确认/轮询、凭证 hash 和自助解绑;此前 T-626 14 条 licensing 基础回归通过。`manage.py check` 与 `makemigrations --check --dry-run` 通过;测试仅保留既有 allauth MySQL 条件唯一约束 `models.W036` 警告。
- 下一步:领取 T-628,建立蝦皮圈专属调用入口与影子授权校验,不影响通用 `/api/v1/generate/*`。 - 下一步:领取 T-628,建立蝦皮圈专属调用入口与影子授权校验,不影响通用 `/api/v1/generate/*`。
## 2026-07-21 完成:T-628 蝦皮圈专属授权入口与影子校验
- 实现:新增蝦皮圈专属 title、vision、异步图片提交和任务读取路由。提交路径复用已有视图、生成 core 与点数账本;通用 `/api/v1/generate/*` 未修改,已接受任务读取仍只按 API Key 所属用户检查。
- 授权:新增统一设备授权判定,检查当前设备会话、产品凭证、用户、设备、席位和权益有效期。专属入口只写 `cmhub.licensing.authorization` 脱敏事件,记录内部 ID、`would_reject` 和结果码,不记录凭证、API Key、prompt 或图片;本期不阻断请求。
- 配置:增加 `CMSHOPEE_AUTHORIZATION_SHADOW_MODE=true`,当前仅作为影子部署口径与日志字段;正式执行阻断需单独任务、灰度和回滚验收。
- 验证:凭证正确、无设备、无凭证和过期的判定测试已覆盖;T-627 迁移专项 4 条回归、`manage.py check` 与 `makemigrations --check --dry-run` 通过。
- 下一步:领取 T-629,建立与点数充值订单隔离的软件套餐购买、续订订单和幂等权益入账。