From b1fa2f13b9b9f6dcdc618a11ec16e19d581fdea7 Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Tue, 21 Jul 2026 10:13:26 +0800 Subject: [PATCH] feat: add cmshopee authorization shadow routes --- .env.example | 1 + apps/api/urls.py | 24 +++++++++++++++++ apps/api/views.py | 53 ++++++++++++++++++++++++++++++++++++++ apps/licensing/services.py | 41 +++++++++++++++++++++++++++++ apps/licensing/tests.py | 48 ++++++++++++++++++++++++++++++++++ config/settings.py | 1 + docs/06-tasks.md | 2 +- progress.md | 8 ++++++ 8 files changed, 177 insertions(+), 1 deletion(-) diff --git a/.env.example b/.env.example index 0ca97f9..7b084c5 100644 --- a/.env.example +++ b/.env.example @@ -38,6 +38,7 @@ DEVICE_IDENTIFIER_PEPPER=change-me-device-identifier-pepper DEVICE_SESSION_TTL_SECONDS=3600 DEVICE_ACTIVITY_UPDATE_SECONDS=86400 MIGRATION_REQUEST_TTL_SECONDS=900 +CMSHOPEE_AUTHORIZATION_SHADOW_MODE=true AI_IMAGE_UPSTREAM_DEADLINE_SECONDS=180 PUBLIC_BASE_URL= MEDIA_PUBLIC_BASE_URL= diff --git a/apps/api/urls.py b/apps/api/urls.py index e3e8724..3989177 100644 --- a/apps/api/urls.py +++ b/apps/api/urls.py @@ -5,6 +5,10 @@ from .views import ( AnalyzeImagesView, BalanceView, ClientLatestReleaseView, + CmshopeeAnalyzeImagesView, + CmshopeeGenerateImageTaskDetailView, + CmshopeeGenerateImageTaskSubmitView, + CmshopeeGenerateTitleView, DeviceHeartbeatView, DeviceRegistrationView, GenerateImageTaskDetailView, @@ -48,18 +52,38 @@ urlpatterns = [ name="api-client-release-latest", ), 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/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/tasks", GenerateImageTaskSubmitView.as_view(), name="api-generate-image-task-submit", ), + path( + "v1/cmshopee/generate/image/tasks", + CmshopeeGenerateImageTaskSubmitView.as_view(), + name="api-cmshopee-generate-image-task-submit", + ), path( "v1/generate/image/tasks/", GenerateImageTaskDetailView.as_view(), name="api-generate-image-task-detail", ), + path( + "v1/cmshopee/generate/image/tasks/", + CmshopeeGenerateImageTaskDetailView.as_view(), + name="api-cmshopee-generate-image-task-detail", + ), path("v1/recharge/create", RechargeCreateView.as_view(), name="api-recharge-create"), path("v1/recharge/status", RechargeStatusView.as_view(), name="api-recharge-status"), path( diff --git a/apps/api/views.py b/apps/api/views.py index ab21d2c..996b27c 100644 --- a/apps/api/views.py +++ b/apps/api/views.py @@ -1,6 +1,7 @@ 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 @@ -70,6 +71,7 @@ from apps.licensing.services import ( DeviceRegistrationError, DeviceSessionValidationError, create_migration_request, + evaluate_device_authorization, record_device_heartbeat, register_device, resolve_optional_device_session, @@ -77,6 +79,7 @@ from apps.licensing.services import ( from apps.licensing.models import MigrationRequest logger = logging.getLogger(__name__) +authorization_logger = logging.getLogger("cmhub.licensing.authorization") class ExternalApiView(APIView): @@ -379,6 +382,56 @@ class GenerateImageTaskDetailView(ExternalApiView): 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): def get(self, request): balance = get_balance_snapshot(request.user) diff --git a/apps/licensing/services.py b/apps/licensing/services.py index 186fc64..65f41b0 100644 --- a/apps/licensing/services.py +++ b/apps/licensing/services.py @@ -39,6 +39,18 @@ class LicensingError(Exception): 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) class DeviceRegistrationResult: device: ClientDevice @@ -579,3 +591,32 @@ def revoke_device_credential(*, credential: DeviceCredential, reason: str, actor now=now, ) 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) diff --git a/apps/licensing/tests.py b/apps/licensing/tests.py index b314adf..e859f8c 100644 --- a/apps/licensing/tests.py +++ b/apps/licensing/tests.py @@ -26,6 +26,7 @@ from apps.licensing.services import ( confirm_migration_request, create_legacy_migration_grant, create_migration_request, + evaluate_device_authorization, grant_software_entitlement, record_device_heartbeat, release_license_seat, @@ -625,3 +626,50 @@ class LegacyMigrationFlowTests(TestCase): now=timezone.now(), ) 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") diff --git a/config/settings.py b/config/settings.py index 2a24c9e..0d910c3 100644 --- a/config/settings.py +++ b/config/settings.py @@ -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_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) # SECURITY WARNING: don't run with debug turned on in production! DEBUG = env_bool("DJANGO_DEBUG", True) diff --git a/docs/06-tasks.md b/docs/06-tasks.md index 982b866..aa4346b 100644 --- a/docs/06-tasks.md +++ b/docs/06-tasks.md @@ -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-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-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 | ## 里程碑 diff --git a/progress.md b/progress.md index 7924c73..d8338c1 100644 --- a/progress.md +++ b/progress.md @@ -2082,3 +2082,11 @@ - 安全与兼容:跨账号确认返回 403,缺设备会话返回 401,跨设备查询按 404 处理;确认 URL 不含凭证明文。旧生成、余额、模型目录、异步任务和点数账本未改,设备凭证尚未参与生成授权。 - 验证:本地已应用 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/*`。 + +## 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,建立与点数充值订单隔离的软件套餐购买、续订订单和幂等权益入账。