feat: add client subscription startup policy

This commit is contained in:
QiuSW
2026-07-28 15:14:48 +08:00
parent f0f34156da
commit 1c100c5d7e
15 changed files with 290 additions and 21 deletions
+4
View File
@@ -42,6 +42,10 @@ CMSHOPEE_AUTHORIZATION_SHADOW_MODE=true
CMSHOPEE_SUBSCRIPTION_MODE=open
# Legacy fallback only when CMSHOPEE_SUBSCRIPTION_MODE is unset:
# CMSHOPEE_SUBSCRIPTION_ENFORCEMENT=false
# Client-only startup policy: off -> false/false, observe -> true/false,
# enforce -> true/true. Update the timestamp whenever this policy changes.
CMSHOPEE_CLIENT_SUBSCRIPTION_POLICY=off
CMSHOPEE_CLIENT_SUBSCRIPTION_POLICY_UPDATED_AT=2026-07-28T00:00:00+08:00
AI_IMAGE_UPSTREAM_DEADLINE_SECONDS=180
PUBLIC_BASE_URL=
MEDIA_PUBLIC_BASE_URL=
+101 -2
View File
@@ -14,6 +14,7 @@ from django.conf import settings
from django.contrib import admin
from django.contrib.auth import get_user_model
from django.core.cache import cache
from django.core.exceptions import ImproperlyConfigured
from django.core.files.base import ContentFile
from django.core.management import call_command
from django.test import TestCase, override_settings
@@ -463,6 +464,12 @@ class ClientLatestReleaseApiTests(TestCase):
def setUp(self):
cache.clear()
self.client = APIClient()
self.client_policy_override = override_settings(
CMSHOPEE_CLIENT_SUBSCRIPTION_POLICY="off",
CMSHOPEE_CLIENT_SUBSCRIPTION_POLICY_UPDATED_AT="2026-07-28T00:00:00+08:00",
)
self.client_policy_override.enable()
self.addCleanup(self.client_policy_override.disable)
def create_release(
self,
@@ -502,7 +509,17 @@ class ClientLatestReleaseApiTests(TestCase):
self.assertEqual(response.status_code, 200)
self.assertNotIn(GenerateRateThrottle, ClientLatestReleaseView.throttle_classes)
self.assertEqual(response["Cache-Control"], "no-store")
self.assertEqual(response.data["platform"], "windows")
self.assertEqual(
response.data["client_policy"],
{
"policy_version": 1,
"subscription_check_enabled": False,
"subscription_enforcement_enabled": False,
"updated_at": "2026-07-28T00:00:00+08:00",
},
)
self.assertEqual(
set(response.data["release"]),
{
@@ -624,9 +641,79 @@ class ClientLatestReleaseApiTests(TestCase):
"platform": "windows",
"release": None,
"message": "暂未发布",
"client_policy": {
"policy_version": 1,
"subscription_check_enabled": False,
"subscription_enforcement_enabled": False,
"updated_at": "2026-07-28T00:00:00+08:00",
},
},
)
@override_settings(
CMSHOPEE_CLIENT_SUBSCRIPTION_POLICY="observe",
CMSHOPEE_CLIENT_SUBSCRIPTION_POLICY_UPDATED_AT="2026-07-28T09:00:00+08:00",
)
def test_latest_release_returns_observe_client_policy_without_release(self):
response = self.client.get(self.url)
self.assertEqual(response.status_code, 200)
self.assertEqual(response["Cache-Control"], "no-store")
self.assertIsNone(response.data["release"])
self.assertEqual(
response.data["client_policy"],
{
"policy_version": 1,
"subscription_check_enabled": True,
"subscription_enforcement_enabled": False,
"updated_at": "2026-07-28T09:00:00+08:00",
},
)
@override_settings(
CMSHOPEE_CLIENT_SUBSCRIPTION_POLICY="enforce",
CMSHOPEE_CLIENT_SUBSCRIPTION_POLICY_UPDATED_AT="2026-07-28T10:00:00+08:00",
)
def test_latest_release_returns_enforce_client_policy_with_release(self):
self.create_release()
response = self.client.get(self.url)
self.assertEqual(response.status_code, 200)
self.assertEqual(
response.data["client_policy"],
{
"policy_version": 1,
"subscription_check_enabled": True,
"subscription_enforcement_enabled": True,
"updated_at": "2026-07-28T10:00:00+08:00",
},
)
@override_settings(
CMSHOPEE_CLIENT_SUBSCRIPTION_POLICY="invalid",
CMSHOPEE_CLIENT_SUBSCRIPTION_POLICY_UPDATED_AT="2026-07-28T10:00:00+08:00",
)
def test_latest_release_rejects_invalid_client_policy_configuration(self):
with self.assertRaises(ImproperlyConfigured):
self.client.get(self.url)
@override_settings(
CMSHOPEE_CLIENT_SUBSCRIPTION_POLICY="enforce",
CMSHOPEE_CLIENT_SUBSCRIPTION_POLICY_UPDATED_AT="",
)
def test_latest_release_rejects_missing_enforced_policy_timestamp(self):
with self.assertRaises(ImproperlyConfigured):
self.client.get(self.url)
@override_settings(
CMSHOPEE_CLIENT_SUBSCRIPTION_POLICY="observe",
CMSHOPEE_CLIENT_SUBSCRIPTION_POLICY_UPDATED_AT="2026-07-28 10:00:00",
)
def test_latest_release_rejects_naive_policy_timestamp(self):
with self.assertRaises(ImproperlyConfigured):
self.client.get(self.url)
def test_latest_release_returns_unpublished_when_current_release_has_no_download_url(self):
self.create_release(external_url="", file_name="")
@@ -668,7 +755,16 @@ class ClientLatestReleaseApiTests(TestCase):
response = self.client.get(self.url, secure=True)
self.assertEqual(response.status_code, 200)
self.assertEqual(set(response.data), {"platform", "release"})
self.assertEqual(set(response.data), {"platform", "release", "client_policy"})
self.assertEqual(
set(response.data["client_policy"]),
{
"policy_version",
"subscription_check_enabled",
"subscription_enforcement_enabled",
"updated_at",
},
)
self.assertEqual(
set(response.data["release"]),
{
@@ -686,7 +782,6 @@ class ClientLatestReleaseApiTests(TestCase):
"id",
"is_current",
"created_at",
"updated_at",
"MEDIA_ROOT",
str(settings.MEDIA_ROOT),
"user",
@@ -1466,6 +1561,7 @@ class GenerateApiTests(TestCase):
self.assertEqual(data["manage_url"], "https://cm.example.test/subscription")
self.assertEqual(data["access_source"], "open_mode")
self.assertEqual(data["entitlement_status"], "required")
self.assertFalse(data["real_entitlement_allowed"])
self.assertTrue(data["notice_id"])
self.assertFalse(SoftwareEntitlement.objects.filter(user=self.user).exists())
@@ -1482,6 +1578,7 @@ class GenerateApiTests(TestCase):
self.assertTrue(data["allowed"])
self.assertEqual(data["access_source"], "shadow_fallback")
self.assertEqual(data["entitlement_status"], "required")
self.assertFalse(data["real_entitlement_allowed"])
self.assertEqual(data["plan"]["code"], "shadow-fallback")
@override_settings(CMSHOPEE_SUBSCRIPTION_MODE="open")
@@ -1571,6 +1668,7 @@ class GenerateApiTests(TestCase):
)
self.assertEqual(data["access_source"], "entitlement")
self.assertEqual(data["entitlement_status"], "active")
self.assertTrue(data["real_entitlement_allowed"])
@override_settings(CMSHOPEE_SUBSCRIPTION_MODE="enforce")
def test_cmshopee_subscription_status_is_expired_after_grace_period(self):
@@ -1589,6 +1687,7 @@ class GenerateApiTests(TestCase):
self.assertFalse(data["allowed"])
self.assertEqual(data["code"], "subscription_expired")
self.assertEqual(data["entitlement_status"], "expired")
self.assertFalse(data["real_entitlement_allowed"])
@override_settings(CMSHOPEE_SUBSCRIPTION_MODE="enforce")
def test_cmshopee_enforcement_rejects_without_subscription(self):
+19 -7
View File
@@ -1,5 +1,6 @@
import logging
from config.client_subscription_policy import get_client_subscription_policy
from django.http import HttpResponse
from django.utils import timezone
from django.utils.decorators import method_decorator
@@ -651,11 +652,12 @@ class MigrationRequestDetailView(ExternalApiView):
)
def _release_unpublished_response(platform: str) -> dict:
def _release_unpublished_response(platform: str, client_policy: dict) -> dict:
return {
"platform": platform,
"release": None,
"message": "暂未发布",
"client_policy": client_policy,
}
@@ -673,15 +675,18 @@ class ClientLatestReleaseView(APIView):
permission_classes = (AllowAny,)
def get(self, request):
client_policy = get_client_subscription_policy()
platform = (
request.query_params.get("platform")
or DownloadRelease.Platform.WINDOWS
).strip().lower()
if platform not in DownloadRelease.Platform.values:
return Response(
response = Response(
api_error("bad_request", "参数错误"),
status=status.HTTP_400_BAD_REQUEST,
)
response["Cache-Control"] = "no-store"
return response
release = (
DownloadRelease.objects.filter(platform=platform, is_current=True)
@@ -689,19 +694,23 @@ class ClientLatestReleaseView(APIView):
.first()
)
if release is None:
return Response(
_release_unpublished_response(platform),
response = Response(
_release_unpublished_response(platform, client_policy),
status=status.HTTP_200_OK,
)
response["Cache-Control"] = "no-store"
return response
download_url = _absolute_download_url(request, release)
if not download_url:
return Response(
_release_unpublished_response(platform),
response = Response(
_release_unpublished_response(platform, client_policy),
status=status.HTTP_200_OK,
)
response["Cache-Control"] = "no-store"
return response
return Response(
response = Response(
{
"platform": platform,
"release": {
@@ -713,9 +722,12 @@ class ClientLatestReleaseView(APIView):
"size_bytes": release.size_bytes,
"published_at": timezone.localtime(release.updated_at).isoformat(),
},
"client_policy": client_policy,
},
status=status.HTTP_200_OK,
)
response["Cache-Control"] = "no-store"
return response
class PortalSessionApiView(APIView):
+1
View File
@@ -1221,4 +1221,5 @@ def software_subscription_status(*, user, product_code: str, now=None, mode=None
),
"access_source": access.access_source,
"entitlement_status": access.entitlement_status,
"real_entitlement_allowed": access.entitlement_status in {"active", "grace"},
}
+2
View File
@@ -273,6 +273,7 @@ class SubscriptionModeUnitTests(SimpleTestCase):
self.assertEqual(data["expires_at"], data["plan"]["expires_at"])
self.assertEqual(data["access_source"], "open_mode")
self.assertEqual(data["entitlement_status"], "required")
self.assertFalse(data["real_entitlement_allowed"])
def test_admin_index_only_exposes_membership_models_without_deleting_registration(self):
hidden_models = (
@@ -333,6 +334,7 @@ class SubscriptionModeUnitTests(SimpleTestCase):
self.assertEqual(data["status"], "grace")
self.assertEqual(data["entitlement_status"], "grace")
self.assertTrue(data["real_entitlement_allowed"])
self.assertEqual(data["plan"]["code"], "plan-3")
self.assertEqual(data["expires_at"], entitlement.expires_at.isoformat())
self.assertEqual(
+64
View File
@@ -0,0 +1,64 @@
"""Configuration-only policy published to supported cmshopee clients."""
from datetime import datetime
from django.conf import settings
from django.core.exceptions import ImproperlyConfigured
from django.utils.dateparse import parse_datetime
from django.utils.timezone import is_aware
CLIENT_SUBSCRIPTION_POLICY_VERSION = 1
CLIENT_SUBSCRIPTION_POLICY_MODES = frozenset({"off", "observe", "enforce"})
DEFAULT_CLIENT_SUBSCRIPTION_POLICY_UPDATED_AT = "2026-07-28T00:00:00+08:00"
def build_client_subscription_policy(*, mode, updated_at) -> dict:
normalized_mode = str(mode or "").strip().lower()
if normalized_mode not in CLIENT_SUBSCRIPTION_POLICY_MODES:
raise ImproperlyConfigured(
"CMSHOPEE_CLIENT_SUBSCRIPTION_POLICY must be one of: "
"off, observe, enforce"
)
if isinstance(updated_at, datetime):
parsed_updated_at = updated_at
else:
raw_updated_at = str(updated_at or "").strip()
if not raw_updated_at:
if normalized_mode != "off":
raise ImproperlyConfigured(
"CMSHOPEE_CLIENT_SUBSCRIPTION_POLICY_UPDATED_AT is required "
"when CMSHOPEE_CLIENT_SUBSCRIPTION_POLICY is observe or enforce"
)
raw_updated_at = DEFAULT_CLIENT_SUBSCRIPTION_POLICY_UPDATED_AT
if "T" not in raw_updated_at:
raise ImproperlyConfigured(
"CMSHOPEE_CLIENT_SUBSCRIPTION_POLICY_UPDATED_AT must be an "
"ISO 8601 datetime with timezone"
)
parsed_updated_at = parse_datetime(raw_updated_at)
if parsed_updated_at is None or not is_aware(parsed_updated_at):
raise ImproperlyConfigured(
"CMSHOPEE_CLIENT_SUBSCRIPTION_POLICY_UPDATED_AT must be an "
"ISO 8601 datetime with timezone"
)
return {
"policy_version": CLIENT_SUBSCRIPTION_POLICY_VERSION,
"subscription_check_enabled": normalized_mode != "off",
"subscription_enforcement_enabled": normalized_mode == "enforce",
"updated_at": parsed_updated_at.isoformat(),
}
def get_client_subscription_policy() -> dict:
return build_client_subscription_policy(
mode=getattr(settings, "CMSHOPEE_CLIENT_SUBSCRIPTION_POLICY", "off"),
updated_at=getattr(
settings,
"CMSHOPEE_CLIENT_SUBSCRIPTION_POLICY_UPDATED_AT",
"",
),
)
+13
View File
@@ -14,6 +14,7 @@ import os
from decimal import Decimal
from pathlib import Path
from config.client_subscription_policy import build_client_subscription_policy
from django.core.exceptions import ImproperlyConfigured
# Build paths inside the project like this: BASE_DIR / 'subdir'.
@@ -80,6 +81,18 @@ if CMSHOPEE_SUBSCRIPTION_MODE not in {"open", "shadow", "enforce"}:
)
# Kept as an effective compatibility setting for integrations that still inspect it.
CMSHOPEE_SUBSCRIPTION_ENFORCEMENT = CMSHOPEE_SUBSCRIPTION_MODE == "enforce"
CMSHOPEE_CLIENT_SUBSCRIPTION_POLICY = os.environ.get(
"CMSHOPEE_CLIENT_SUBSCRIPTION_POLICY",
"off",
).strip().lower()
CMSHOPEE_CLIENT_SUBSCRIPTION_POLICY_UPDATED_AT = os.environ.get(
"CMSHOPEE_CLIENT_SUBSCRIPTION_POLICY_UPDATED_AT",
"",
).strip()
build_client_subscription_policy(
mode=CMSHOPEE_CLIENT_SUBSCRIPTION_POLICY,
updated_at=CMSHOPEE_CLIENT_SUBSCRIPTION_POLICY_UPDATED_AT,
)
# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = env_bool("DJANGO_DEBUG", True)
+3 -1
View File
@@ -48,6 +48,8 @@ T-629 已完成:`SoftwareOrder` 属于 `apps.licensing`,与 `RechargeOrder`
T-630/T-632 已完成:蝦皮圈产品授权从设备凭证改为账号订阅。`evaluate_account_authorization(user, product_code)` 只计算数据库真实 `SoftwareEntitlement` 状态,不读取 `DeviceCredential`、`LicenseSeat` 或 `DeviceSession`;`evaluate_subscription_access()` 再按 `CMSHOPEE_SUBSCRIPTION_MODE=open|shadow|enforce` 计算最终访问结果。`open` 和 `shadow` 不写虚假权益,前者提供开发测试访问,后者保留访问并记录真实权益覆盖;`enforce` 才将无订阅/过期结果映射为 403。同一用户可从多台设备调用,过期设备会话不会阻断账号订阅;通用生成入口继续保持原 API Key + 点数语义。
T-635 在公开版本检查接口顶层增加无账号上下文的 `client_policy`,由 `CMSHOPEE_CLIENT_SUBSCRIPTION_POLICY=off|observe|enforce` 和其带时区更新时间生成,成功及错误响应均 `Cache-Control: no-store`。这只控制新版客户端是否检查 / 展示会员门禁,不覆盖 `CMSHOPEE_SUBSCRIPTION_MODE` 的服务端授权;订阅状态接口新增 `real_entitlement_allowed`,仅真实 `active` / `grace` 权益为真,客户端强制门禁必须使用它而非 `open` / `shadow` 的兼容 `allowed`。
T-632 同时简化软件授权 admin:应用索引只展示“会员套餐” (`SoftwarePlan`) 与“用户会员” (`SoftwareEntitlement`)。软件订单、授权事件、设备观测和历史设备授权模型仍注册并保留直接管理地址、权限及历史数据,只是不出现在日常导航;该显示策略不改变数据库结构和授权判定。
T-627 已建立存量迁移闭环:运营在 admin 显式创建 `LegacyMigrationGrant`,它关联一项套餐快照权益及资格快照,新注册用户不会自动创建。客户端必须以旧 API Key 加服务端验证的当前设备会话申请 `MigrationRequest`;请求只短时有效,生成的设备凭证明文只在该 API 响应出现一次。portal 仅允许请求所属账号确认,确认事务内分配席位、按请求中已存 token hash 签发 `DeviceCredential`,重复确认只返回既有结果。用户自助解绑会吊销凭证、写授权事件并释放对应席位。原始凭证、机器标识、公钥和 API Key 都不写入迁移记录 / 事件;T-628 才会用凭证参与专属接口影子校验。
@@ -253,7 +255,7 @@ CREATE TABLE import_template (
);
```
T-102 已实现 `AiModel` / `ModelAlias` 的 Django models、admin、迁移与别名解析。T-103 已补 `AiConfigAuditLog`,admin 里保存/删除模型配置或能力别名时自动写审计日志。T-202 已实现 `PricingRule` / `ExchangeRate` 与 `apps.billing.pricing` 计算函数:定价按 `operation_type + alias + resolution` 查 active 规则,优先 exact resolution,再回退到空 resolution 默认价;缺规则抛 `NoPricingRuleError(code="no_pricing_rule")`。T-203 已实现 `apps.billing.services`:`precharge_call()` 锁 `UserWallet` 行预扣并写 pending 调用与 consume 流水;`mark_call_success()` 确认成功不再改余额;`refund_call_points()` 锁调用记录并幂等退点,写 refund 流水。T-401 已在同一计费层新增 `adjust_wallet_points()`,供 admin 手工调点使用。T-606/T-609/T-617/T-618 已实现 `DownloadRelease`,admin 可上传安装包或填写 `external_url`、标记当前版本、配置是否强制更新和文件大小字节数;T-618 起 admin 新增 / 编辑发布版本时 `sha256` 与 `size_bytes` 必填,但数据库仍保留空值兼容历史记录;`external_url` 优先于 `file.url`,每平台仅一个当前版本由模型 `save()` 在事务内把同平台旧 current 置为 false。T-610 已新增 `ImportTemplate`,admin 可上传导入模板或填写 `external_url` 并标记当前模板;`external_url` 优先于 `file.url`,当前模板互斥同样由应用层事务处理,不使用 MySQL 不支持的条件唯一约束。T-607/T-609/T-617 版本检查接口只读取 `DownloadRelease(platform, is_current=True)`;响应 URL 是外部可访问 URL,不暴露本地 `MEDIA_ROOT`,并把 `force_update` 和 `size_bytes` 作为公开发布元数据返回。导入模板下载不新增对外 JSON API,首页直接渲染当前模板下载链接。默认别名唯一性由 model validation、admin 与导入器保证;MySQL 不支持通用 partial unique index,若后续要强制数据库层默认别名唯一,可另评估触发器或约束表。
T-102 已实现 `AiModel` / `ModelAlias` 的 Django models、admin、迁移与别名解析。T-103 已补 `AiConfigAuditLog`,admin 里保存/删除模型配置或能力别名时自动写审计日志。T-202 已实现 `PricingRule` / `ExchangeRate` 与 `apps.billing.pricing` 计算函数:定价按 `operation_type + alias + resolution` 查 active 规则,优先 exact resolution,再回退到空 resolution 默认价;缺规则抛 `NoPricingRuleError(code="no_pricing_rule")`。T-203 已实现 `apps.billing.services`:`precharge_call()` 锁 `UserWallet` 行预扣并写 pending 调用与 consume 流水;`mark_call_success()` 确认成功不再改余额;`refund_call_points()` 锁调用记录并幂等退点,写 refund 流水。T-401 已在同一计费层新增 `adjust_wallet_points()`,供 admin 手工调点使用。T-606/T-609/T-617/T-618 已实现 `DownloadRelease`,admin 可上传安装包或填写 `external_url`、标记当前版本、配置是否强制更新和文件大小字节数;T-618 起 admin 新增 / 编辑发布版本时 `sha256` 与 `size_bytes` 必填,但数据库仍保留空值兼容历史记录;`external_url` 优先于 `file.url`,每平台仅一个当前版本由模型 `save()` 在事务内把同平台旧 current 置为 false。T-610 已新增 `ImportTemplate`,admin 可上传导入模板或填写 `external_url` 并标记当前模板;`external_url` 优先于 `file.url`,当前模板互斥同样由应用层事务处理,不使用 MySQL 不支持的条件唯一约束。T-607/T-609/T-617/T-635 版本检查接口只读取 `DownloadRelease(platform, is_current=True)` 与无状态环境配置;响应 URL 是外部可访问 URL,不暴露本地 `MEDIA_ROOT`,并把 `force_update`、`size_bytes` 和 `client_policy` 作为公开元数据返回。`client_policy` 有独立的配置更新时间且在 `release:null` 时仍返回,不读取用户、订阅、套餐或点数。导入模板下载不新增对外 JSON API,首页直接渲染当前模板下载链接。默认别名唯一性由 model validation、admin 与导入器保证;MySQL 不支持通用 partial unique index,若后续要强制数据库层默认别名唯一,可另评估触发器或约束表。
> 可选增强(接口预留、MVP 不实现):`account_alias_permission`(按账号授权可用别名,防止调用方点用未授权/昂贵模型);别名按比例分流到多个模型(灰度/AB/故障转移)。适配器接口需为此留口子。
+1 -1
View File
@@ -113,7 +113,7 @@
| T-632 | 会员订阅三阶段启用与简化运营 | T-630, T-631 | **已完成。** 新增 `CMSHOPEE_SUBSCRIPTION_MODE=open|shadow|enforce`,将真实权益状态与最终访问结果分离;`open` / `shadow` 不写虚假权益,`enforce` 才拦截产品专属 submit。状态接口补齐桌面端账号、套餐、顶层有效期、会员中心、通知及观测字段,并保留 T-630 旧字段。admin 首页仅保留“会员套餐”和“用户会员”,其他模型只隐藏不删除。5 条无数据库单元测试、`check`、迁移一致性和编译通过;数据库集成测试因旧远程 MySQL 拒绝连接未取得结果。详见 [`tasks/T-632.md`](tasks/T-632.md)。 | DONE |
| T-633 | 存量用户批量授予过渡测试套餐 | T-632, T-626 | **已完成。** 新增默认只预演的 `grant_existing_users_plan`,通过明确 `plan_id`、非空原因、`--execute` 与预期人数双重确认,为启用的非后台账号批量授予套餐;已有同产品有效/宽限期权益会跳过。执行在单事务内复用 `grant_software_entitlement()` 生成权益快照、席位和授权事件,任一失败整批回滚。线上已备份 MySQL 后为 30 个存量用户授予“测试”套餐,反向预演待授予为 0;后台账号、API Key、点数、充值、软件订单和生成记录未由命令修改。详见 [`tasks/T-633.md`](tasks/T-633.md)。 | DONE |
| T-634 | 过期会员允许购买其他套餐 | T-629, T-632 | **已完成。** 软件下单与支付入账统一按 `status=active + grace_expires_at > now` 识别当前可用权益;支付校验通过后在同一事务内把同产品自然到期但仍为 `active` 的历史权益收敛为 `expired`,并为目标套餐创建新权益。有效/宽限期内的跨套餐仍拒绝;同套餐有效权益续订、支付幂等、金额校验和点数账本隔离保持不变。新增 5 条缺陷回归,扩大非并发 licensing / 软件支付 API 回归 40 条通过;无迁移。详见 [`tasks/T-634.md`](tasks/T-634.md)。 | DONE |
| T-635 | cmshopee 客户端启动订阅策略接口 | T-607, T-632 | 在公开 `GET /api/v1/client/releases/latest` 顶层新增只读 `client_policy`,用稳定配置发布客户端订阅检测 / 门禁策略;有发布和 `release:null` 均返回,响应 `Cache-Control: no-store`。客户端策略与 `CMSHOPEE_SUBSCRIPTION_MODE` 服务端授权解耦;订阅状态接口新增真实权益是否有效的明确字段,避免 `open` / `shadow` 的兼容 `allowed=true` 被客户端误判。不得读取用户、套餐、点数或变更账本;不改生文、生图、支付和订阅订单。详见 [`tasks/T-635.md`](tasks/T-635.md)。 | TODO |
| T-635 | cmshopee 客户端启动订阅策略接口 | T-607, T-632 | **已完成。** 公开版本检查接口顶层新增 `client_policy`,有发布和 `release:null` 均返回,所有响应 `Cache-Control: no-store`;单一 `off|observe|enforce` 配置源输出双布尔字段,非 `off` 强制带时区更新时间。订阅状态新增不受运行模式影响的 `real_entitlement_allowed`,客户端不再把 `open` / `shadow` 的兼容 `allowed=true` 误判为真实会员。无迁移,不读用户、套餐或点数,不改生文、生图、支付、扣点和订阅订单。详见 [`tasks/T-635.md`](tasks/T-635.md)。 | DONE |
## 里程碑
+20 -7
View File
@@ -43,7 +43,7 @@ T-629 已完成:软件套餐订单使用独立 `SoftwareOrder`、`POST /api/v1
T-630/T-632 已完成:蝦皮圈产品专属接口按 API Key 对应用户查询 `SoftwareEntitlement`,默认允许同一账号在多台电脑使用;设备登记、设备会话和历史凭证不再作为授权前置条件。`CMSHOPEE_SUBSCRIPTION_MODE` 支持 `open`(开发测试统一放行)、`shadow`(放行并记录真实权益)和 `enforce`(按真实权益拦截);通用生成接口不受影响。旧 `CMSHOPEE_SUBSCRIPTION_ENFORCEMENT` 仅在新配置缺失时作为兼容回退。
`GET /api/v1/cmshopee/subscription/status` 使用 API Key 鉴权。响应中的 `status` / `allowed` 表示当前模式下的最终访问结果,`entitlement_status` 表示数据库真实权益状态;二者在 `open` / `shadow` 下可能不同。示例:
`GET /api/v1/cmshopee/subscription/status` 使用 API Key 鉴权。响应中的 `status` / `allowed` 表示当前模式下的最终访问结果,`entitlement_status` 表示数据库真实权益状态;二者在 `open` / `shadow` 下可能不同。T-635 新增的 `real_entitlement_allowed` 只由真实权益决定:仅 `active` / `grace` 为 `true`,不受当前运行模式影响。客户端强制门禁必须使用该字段,不得以兼容性 `allowed=true` 代替。示例:
```json
{
@@ -67,11 +67,12 @@ T-630/T-632 已完成:蝦皮圈产品专属接口按 API Key 对应用户查
"manage_url": "https://cmhub.example.com/subscription",
"notice_id": "cmshopee-subscription-open-required-v1",
"access_source": "open_mode",
"entitlement_status": "required"
"entitlement_status": "required",
"real_entitlement_allowed": false
}
```
`plan.name`、`plan.expires_at`、`plan.grace_expires_at` 是 T-630 旧客户端兼容字段;新客户端使用 `plan.code` / `plan.display_name` 和顶层有效期。`access_source` 取 `open_mode`、`shadow_fallback` 或 `entitlement`。真实权益已进入宽限期时 `status` / `entitlement_status` 为 `grace`;无权益和过期在 `enforce` 模式分别返回 `required` / `expired`,对应产品 submit 的 `subscription_required` / `subscription_expired` 403。
`plan.name`、`plan.expires_at`、`plan.grace_expires_at` 是 T-630 旧客户端兼容字段;新客户端使用 `plan.code` / `plan.display_name` 和顶层有效期。`access_source` 取 `open_mode`、`shadow_fallback` 或 `entitlement`。真实权益状态当前只承诺 `active`、`grace`、`required`、`expired`:宽限期为 `grace`,无权益为 `required`,超过宽限期为 `expired`。禁用账号或 API Key 不返回此成功 JSON,而是在认证层返回 HTTP 403 `account_disabled`。产品 submit 在 `enforce` 模式对无权益 / 过期分别返回 `subscription_required` / `subscription_expired` 403。
T-306 已实现对外 API 安全加固:`image_url` 下载只允许 `http` / `https` 公网地址,拒绝私有/回环/链路本地/保留等地址,重定向后重新校验并限制响应大小;全局 DRF 默认认证为空且默认权限为 `IsAuthenticated`,外部 API 与用户端 API 必须显式 opt-in 认证类;生成接口按 API Key / 用户限流,认证失败按 IP 限流;充值创建有 `RECHARGE_MAX_AMOUNT_CNY` 单笔上限。
@@ -89,7 +90,7 @@ T-606 已实现公开首页与客户端下载入口:`GET /` 匿名返回 200
T-610 已在公开首页下载区新增“下载导入模板”链接:模板由 django-admin 维护 `ImportTemplate` 当前记录,首页直接渲染公开下载链接;该能力不新增对外 JSON API,不需要 API Key、不读取用户、不扣点。生产本地模板文件复用 Nginx 直接服务 `MEDIA_ROOT/import_templates/`,避免文件下载占用 Gunicorn worker;`external_url` 优先于本地文件 URL。
T-607/T-609/T-617 已实现 `GET /api/v1/client/releases/latest?platform=windows`,给桌面端自动检查更新使用。该接口公开匿名可访问,不需要 API Key,不读取用户、不扣点、不占用生成接口限流;只返回 `DownloadRelease` 的公开发布元数据。`release.force_update` 表示该版本是否必须升级,来源于后台 `DownloadRelease.force_update`,默认 `false`;`release.size_bytes` 表示安装包文件大小字节数,来源于后台 `DownloadRelease.size_bytes`,用于桌面端下载后校验文件大小。
T-607/T-609/T-617/T-635 已实现 `GET /api/v1/client/releases/latest?platform=windows`,给桌面端自动检查更新和读取启动订阅策略使用。该接口公开匿名可访问,不需要 API Key,不读取用户、套餐或点数,不扣点、不占用生成接口限流;只返回 `DownloadRelease` 的公开发布元数据和无账号上下文的 `client_policy`。`release.force_update` 表示该版本是否必须升级,来源于后台 `DownloadRelease.force_update`,默认 `false`;`release.size_bytes` 表示安装包文件大小字节数,来源于后台 `DownloadRelease.size_bytes`,用于桌面端下载后校验文件大小。
通用错误响应:
@@ -174,6 +175,12 @@ GET /api/v1/client/releases/latest?platform=windows
"force_update": true,
"size_bytes": 45678901,
"published_at": "2026-07-06T18:00:00+08:00"
},
"client_policy": {
"policy_version": 1,
"subscription_check_enabled": true,
"subscription_enforcement_enabled": false,
"updated_at": "2026-07-28T10:00:00+08:00"
}
}
```
@@ -184,13 +191,19 @@ GET /api/v1/client/releases/latest?platform=windows
{
"platform": "windows",
"release": null,
"message": "暂未发布"
"message": "暂未发布",
"client_policy": {
"policy_version": 1,
"subscription_check_enabled": false,
"subscription_enforcement_enabled": false,
"updated_at": "2026-07-28T00:00:00+08:00"
}
}
```
字段说明:`force_update` 为布尔值,表示桌面端是否必须升级到该版本;未勾选时返回 `false`。`size_bytes` 为整数或 `null`,单位字节;T-618 起 django-admin 新增 / 编辑客户端发布版本时要求填写 `sha256` 和 `size_bytes`,但历史记录或非 admin 导入数据仍可能为空,客户端应只在该值为正整数时校验下载后的本地文件大小。客户端可先做版本号比较,再按 `force_update=true` 阻止继续使用旧版本或进入强制升级流程,下载完成后同时校验 `size_bytes` 和 `sha256`。
字段说明:`force_update` 为布尔值,表示桌面端是否必须升级到该版本;未勾选时返回 `false`。`size_bytes` 为整数或 `null`,单位字节;T-618 起 django-admin 新增 / 编辑客户端发布版本时要求填写 `sha256` 和 `size_bytes`,但历史记录或非 admin 导入数据仍可能为空,客户端应只在该值为正整数时校验下载后的本地文件大小。客户端可先做版本号比较,再按 `force_update=true` 阻止继续使用旧版本或进入强制升级流程,下载完成后同时校验 `size_bytes` 和 `sha256`。`client_policy.policy_version` 当前固定为整数 `1`;两个 `subscription_*_enabled` 均为 JSON boolean;`updated_at` 是带时区 ISO 8601 的策略最后变更时间,不是请求时间。`off` 映射为 `false/false`,`observe` 映射为 `true/false`,`enforce` 映射为 `true/true`。
要点:接口只查 `DownloadRelease(platform, is_current=True)`;`download_url` 优先使用 `external_url`,否则用 `file.url` 生成绝对 HTTPS URL;`published_at` MVP 可使用 `DownloadRelease.updated_at`;`force_update` 使用后台发布版本上的布尔配置,默认 `false`;`size_bytes` 使用后台填写的安装包字节数,可为空以兼容历史记录。响应不得包含本地 `MEDIA_ROOT`、文件系统路径、后台 ID、`is_current`、用户信息、API Key、模型配置或任何密钥字段。成功和“暂未发布”均返回 HTTP 200,方便桌面端静默检查;非法平台返回 `400 bad_request`。无当前版本或当前版本没有下载地址时返回 `release:null`,不返回独立的 `force_update` / `size_bytes` 顶层字段。
要点:接口只查 `DownloadRelease(platform, is_current=True)` 和纯环境配置;`download_url` 优先使用 `external_url`,否则用 `file.url` 生成绝对 HTTPS URL;`published_at` MVP 可使用 `DownloadRelease.updated_at`;`force_update` 使用后台发布版本上的布尔配置,默认 `false`;`size_bytes` 使用后台填写的安装包字节数,可为空以兼容历史记录。成功和“暂未发布”均返回 `client_policy`,发布记录和策略互不依赖;成功及错误响应带 `Cache-Control: no-store`。响应不得包含本地 `MEDIA_ROOT`、文件系统路径、后台 ID、`is_current`、用户信息、API Key、套餐、点数、模型配置或任何密钥字段。成功和“暂未发布”均返回 HTTP 200,方便桌面端静默检查;非法平台返回 `400 bad_request`。无当前版本或当前版本没有下载地址时返回 `release:null`,不返回独立的 `force_update` / `size_bytes` 顶层字段。客户端仅在识别 `policy_version=1` 且字段类型合法时采用策略;策略请求失败或字段异常时应回退到“检测开启、强制关闭”,服务端授权仍独立生效。
### `POST /api/v1/client/devices/register`
+4 -2
View File
@@ -75,11 +75,11 @@
任务状态以 [`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-619 多张图片理解并返回文字;T-620 图生图支持单图 / 多图主图与参考图;T-622 图片生成任务后台图片缩略预览;T-623 图片生成任务单图 / 多图筛选;T-624 蝦皮圈设备登记与会话观测;T-625 蝦皮圈设备使用关联与迁移观测;T-626 软件套餐、权益与设备席位基础模型及凭证续期/撤销清理;T-627 存量用户迁移权益、网页确认与设备凭证;T-628 蝦皮圈专属授权入口与影子校验;T-629 软件套餐购买、续订订单与权益入账;T-630 账号订阅授权与默认多设备模式;T-631 隐藏旧设备授权模型的 admin 菜单入口;T-632 会员订阅三阶段启用、状态接口补全与 admin 简化;T-633 存量用户批量授予过渡测试套餐;T-634 过期会员允许购买其他套餐。
- 已完成补充:T-619 多张图片理解并返回文字;T-620 图生图支持单图 / 多图主图与参考图;T-622 图片生成任务后台图片缩略预览;T-623 图片生成任务单图 / 多图筛选;T-624 蝦皮圈设备登记与会话观测;T-625 蝦皮圈设备使用关联与迁移观测;T-626 软件套餐、权益与设备席位基础模型及凭证续期/撤销清理;T-627 存量用户迁移权益、网页确认与设备凭证;T-628 蝦皮圈专属授权入口与影子校验;T-629 软件套餐购买、续订订单与权益入账;T-630 账号订阅授权与默认多设备模式;T-631 隐藏旧设备授权模型的 admin 菜单入口;T-632 会员订阅三阶段启用、状态接口补全与 admin 简化;T-633 存量用户批量授予过渡测试套餐;T-634 过期会员允许购买其他套餐;T-635 cmshopee 客户端启动订阅策略接口。
- 正在进行:无。
- T-621 注册赠点运营后台配置继续留在 Backlog。真实支付回调到账闭环、客户端发布、生产多图理解模型配置和线上旧同步接口用量观察仍可继续拆任务。
- 当前 blocker:支付商户真实密钥/证书与生产 SDK 依赖仍待提供;微信回调到账闭环仍需真实支付验收;真实 AI 标题生成已在线上跑通,图片生成慢 / 504 / 客户端超时风险已拆为 T-612~T-616 并完成工程侧处理。
- 下一个可领取任务:按业务优先级从 Backlog 拆分后续任务。T-621 保留在 Backlog,真实微信商户支付验收仍是线上 blocker;T-634 本地代码已完成但尚未部署生产。具体范围见 [`06-tasks.md`](06-tasks.md)。
- 下一个可领取任务:按业务优先级从 Backlog 拆分后续任务。T-621 保留在 Backlog;真实微信商户支付验收仍是线上 blocker。T-635 已在本地完成,部署前生产应先保持客户端策略 `off`,待新版客户端灰度后再切 `observe` / `enforce`。具体范围见 [`06-tasks.md`](06-tasks.md)。
## 当前可运行内容
@@ -155,6 +155,8 @@ T-633 已新增默认只预演的 `grant_existing_users_plan`:按套餐 ID、
T-634 已统一软件订单与订阅授权的当前权益口径:只有 `active` 且未超过宽限截止时间的权益会阻止跨套餐下单或参与续订。支付校验通过后,服务在同一事务内把自然到期但仍为 `active` 的同产品历史权益收敛为 `expired`;已过期同套餐或不同套餐都会创建新权益。该实现不改接口、迁移、支付验签或点数账本,已部署生产 revision `976b55c`。
T-635 已在公开 `GET /api/v1/client/releases/latest` 顶层发布 `client_policy`,有发布和 `release:null` 均返回并带 `Cache-Control: no-store`;策略以 `CMSHOPEE_CLIENT_SUBSCRIPTION_POLICY=off|observe|enforce` 配置,和服务端 `CMSHOPEE_SUBSCRIPTION_MODE` 独立。订阅状态接口新增 `real_entitlement_allowed`,只代表真实 `active` / `grace` 权益,客户端门禁不得以 `open` / `shadow` 的兼容 `allowed` 字段替代。无迁移,不读取用户、套餐或点数,不影响生成、扣点、退款、充值或软件订单。
## 开始编码前检查
1. 读仓库级 `AGENTS.md` / `CLAUDE.md`。
+4
View File
@@ -124,6 +124,9 @@ IMAGE_TASK_REAPER_INTERVAL_SECONDS=60
IMAGE_TASK_LEASE_SECONDS=600
IMAGE_TASK_MAX_RETRIES=2
IMAGE_TASK_RETRY_BACKOFF_SECONDS=10,30
CMSHOPEE_SUBSCRIPTION_MODE=shadow
CMSHOPEE_CLIENT_SUBSCRIPTION_POLICY=off
CMSHOPEE_CLIENT_SUBSCRIPTION_POLICY_UPDATED_AT=2026-07-28T00:00:00+08:00
IMAGE_MAX_INPUT_IMAGES=8
IMAGE_MAX_INPUT_IMAGE_BYTES=10485760
IMAGE_MAX_INPUT_TOTAL_BYTES=33554432
@@ -152,6 +155,7 @@ python3.12 -c "from cryptography.fernet import Fernet; print(Fernet.generate_key
- `PAYMENT_CALLBACK_MODE=sdk` 必须配齐微信 / 支付宝商户配置;未配齐时先保持 `mock`。
- 宝塔 / Nginx 已强制 HTTPS 时,`DJANGO_SECURE_SSL_REDIRECT=false` 即可;全站 HTTPS 稳定后再把 `DJANGO_SECURE_HSTS_SECONDS` 调大,避免 HSTS 误锁域名。
- T-614 异步生图 worker 没有 request 对象,生产必须配置 `MEDIA_PUBLIC_BASE_URL` 或 `PUBLIC_BASE_URL` 为公开 HTTPS 域名,否则异步轮询成功时可能返回相对 `/media/...` URL。
- T-635 客户端启动订阅策略与服务端订阅模式独立:先部署并保持 `CMSHOPEE_CLIENT_SUBSCRIPTION_POLICY=off`,新版客户端灰度后改为 `observe`,确认权益覆盖和客户端分类提示后才改为 `enforce`;每次改策略必须同步更新带时区的 `CMSHOPEE_CLIENT_SUBSCRIPTION_POLICY_UPDATED_AT`,然后重启 `cmhub-web` 与 `cmhub-generate`。不得把客户端 `off` 误当作服务端取消授权。
## 五、初始化数据库与静态文件
+4
View File
@@ -57,6 +57,8 @@
| `MIGRATION_REQUEST_TTL_SECONDS` | 否 | `900` | T-627 存量迁移网页确认请求有效秒数,默认 15 分钟;过期后客户端必须重新申请,凭证明文不在服务端恢复 |
| `CMSHOPEE_SUBSCRIPTION_MODE` | 否 | `open` | T-632 账号订阅运行模式:`open` 开发测试开放、`shadow` 放行但记录真实权益、`enforce` 按真实权益强制拦截。非法值阻止 Django 启动 |
| `CMSHOPEE_SUBSCRIPTION_ENFORCEMENT` | 否 | `false` | T-630 旧兼容开关;仅当 `CMSHOPEE_SUBSCRIPTION_MODE` 未设置时生效,`true` 映射为 `enforce`,否则映射为 `open`。新部署不要再用它控制模式 |
| `CMSHOPEE_CLIENT_SUBSCRIPTION_POLICY` | 否 | `off` | T-635 新版客户端启动策略:`off` 不检测不门禁、`observe` 检测但不门禁、`enforce` 检测并门禁;只控制客户端体验,不改变服务端专属接口授权。非法值阻止 Django 启动 |
| `CMSHOPEE_CLIENT_SUBSCRIPTION_POLICY_UPDATED_AT` | `observe` / `enforce` 时是 | `2026-07-28T10:00:00+08:00` | T-635 客户端策略最后变更时间,必须是带时区 ISO 8601。每次改策略同步更新;默认 `off` 未设置时使用仓库基线时间,其他模式缺失或非法值阻止 Django 启动 |
| `AI_IMAGE_UPSTREAM_DEADLINE_SECONDS` | 否 | `180` | T-612 生图上游读取硬截止秒数;只作用于 `generate_image` 的上游请求和上游返回图片 URL 下载,Provider 实际读取超时取 `min(AiModel.timeout_seconds 或分辨率默认值, 本值)`;生产按真实图片 smoke 耗时校准,外层 Gunicorn / Nginx / 客户端超时必须大于该值 |
| `PUBLIC_BASE_URL` | 否 | `https://cm.833729.com` | 站点公开基础 URL;异步 worker 没有 request 时可用它生成绝对媒体 URL |
| `MEDIA_PUBLIC_BASE_URL` | 否 | `https://cm.833729.com` | 媒体文件公开基础 URL;优先于 `PUBLIC_BASE_URL`,用于 T-614 异步生图 worker 返回 `result.image_url` |
@@ -109,6 +111,8 @@ T-614 起新增异步生图任务接口:提交任务仍同步审核 prompt 和
T-604 只做 prompt 本地敏感词快筛。命中时返回 `content_blocked`,不扣点、不写调用记录、不调上游;并且必须在下载 `image_url` 前执行 prompt 审核。详细规则见 [`moderation.md`](moderation.md)。
T-635 的 `CMSHOPEE_CLIENT_SUBSCRIPTION_POLICY` 与 `CMSHOPEE_SUBSCRIPTION_MODE` 是两套独立开关:前者只通过公开版本检查接口通知**新版客户端**是否检查 / 展示会员门禁,后者才决定产品专属提交接口是否在服务端拦截。变更客户端策略后重启 `cmhub-web` 和 `cmhub-generate`;推荐先 `off -> observe -> enforce` 灰度,且在每一步更新 `CMSHOPEE_CLIENT_SUBSCRIPTION_POLICY_UPDATED_AT`。不支持使用两个独立布尔环境变量,避免发布矛盾的 `false/true` 配置。
生产多 Gunicorn worker 下,敏感词 matcher 刷新依赖共享 cache 的版本号;若仍使用 `LocMemCache`,只能刷新当前进程,不能保证所有 worker 及时更新。
## 七、静态文件与限流缓存
+27 -1
View File
@@ -108,4 +108,30 @@
## 状态
TODO。
DONE。
## 实施结果
- 新增 `config/client_subscription_policy.py`,以单一
`CMSHOPEE_CLIENT_SUBSCRIPTION_POLICY=off|observe|enforce` 解析并发布策略,
固定输出 `policy_version=1` 与两个 JSON 布尔字段;非法模式、非 `off` 缺更新时间、
无时区或非法时间均抛出 `ImproperlyConfigured`。
- `GET /api/v1/client/releases/latest` 的当前发布、无发布和无下载地址路径均返回
顶层 `client_policy`,所有响应设置 `Cache-Control: no-store`;接口仍只查询
`DownloadRelease` 和环境配置。
- `GET /api/v1/cmshopee/subscription/status` 新增
`real_entitlement_allowed`,仅在真实权益为 `active` 或 `grace` 时为 `true`,
保留既有最终访问 `allowed` 兼容字段。
- 未新增模型、迁移、admin 配置或运行时写入,不改通用 / 专属生成、点数账本、
充值支付、软件订单或服务端订阅强制模式。
## 验证结果
- `py -3.12 -m py_compile config\client_subscription_policy.py config\settings.py apps\api\views.py apps\api\tests.py apps\licensing\services.py apps\licensing\tests.py`:通过。
- `py -3.12 manage.py check`:通过,0 issues。
- 隔离 SQLite 目标回归:`ClientLatestReleaseApiTests`、订阅状态核心场景和
`SubscriptionModeUnitTests` 共 27 tests OK,覆盖三种策略、无发布策略、
非法配置、`no-store`、真实权益 active / grace / required / expired 语义。
- `py -3.12 manage.py makemigrations --check --dry-run`:No changes detected;
旧本地 MySQL 地址拒绝连接,仅产生迁移历史检查 warning,本任务无迁移。
- `./init.ps1` 和 `git diff --check`:通过。
+23
View File
@@ -2197,3 +2197,26 @@
- 发布检查:线上 `manage.py check` 通过;`migrate --noinput` 显示 `No migrations to apply`;因历史 `staticfiles` 目录由 root 创建,先修正为 `cmhub:cmhub` 后 `collectstatic` 成功。
- 重启:`cmhub-web`、`cmhub-generate` 和 66 个 `cmhub-image-worker` 已重启并 active;Nginx、MySQL 8.4 active,systemd failed units 为 0。
- 公网验收:`https://cm.833729.com/` 返回 HTTP 200;`/api/v1/client/releases/latest?platform=windows` 返回 HTTP 200;线上 `.deploy_revision=976b55c`,T-634 代码已存在。
## 2026-07-28 完成:T-635 cmshopee 客户端启动订阅策略接口
- 实现:新增无状态 `config.client_subscription_policy`。环境变量
`CMSHOPEE_CLIENT_SUBSCRIPTION_POLICY=off|observe|enforce` 是客户端策略的唯一
配置源,输出兼容客户端的 `subscription_check_enabled` /
`subscription_enforcement_enabled`;非 `off` 必须提供带时区的
`CMSHOPEE_CLIENT_SUBSCRIPTION_POLICY_UPDATED_AT`,非法值在 Django 启动校验和
运行时配置读取时均明确拒绝。
- 接口:公开版本检查接口在当前发布、无发布和无下载地址三条成功路径均返回
顶层 `client_policy`,并给所有响应设置 `Cache-Control: no-store`。接口仍只读
`DownloadRelease` 和环境配置,不读取用户、权益、套餐、点数或 API Key,不扣点。
- 语义:订阅状态新增 `real_entitlement_allowed`,只在真实权益为 `active` /
`grace` 时为真。既有 `allowed` 保持“当前服务端模式下最终访问结果”,因此
`open` / `shadow` 无权益账号仍可能 `allowed=true`,但不会被新客户端误判为真实会员。
- 兼容:未新增迁移或后台配置页;未改通用 / 专属生成、预扣 / 退款、充值支付、
软件订单或 `CMSHOPEE_SUBSCRIPTION_MODE` 的服务端授权。旧客户端忽略新增字段
保持原行为;新版客户端策略失败时应进入检测开启、强制关闭的观察回退。
- 验证:`py_compile`、`manage.py check`、`init.ps1` 和 `git diff --check` 均通过。
隔离 SQLite 目标回归 27 tests OK,覆盖 off / observe / enforce、无发布策略、
非法配置、`no-store`、release 字段白名单及 active / grace / required / expired
的真实权益语义。`makemigrations --check --dry-run` 为 No changes detected;
旧本地 MySQL 地址 `43.128.3.240` 拒绝连接,只产生迁移历史检查 warning,本任务无迁移。