feat: grant transition plan to existing users

This commit is contained in:
QiuSW
2026-07-22 16:56:13 +08:00
parent 09d13ff64a
commit 82c338cbe7
10 changed files with 374 additions and 4 deletions
@@ -0,0 +1,59 @@
from django.core.management.base import BaseCommand, CommandError
from apps.licensing.models import SoftwarePlan
from apps.licensing.services import LicensingError, grant_plan_to_existing_users
class Command(BaseCommand):
help = "Preview or grant one active software plan to existing active non-staff users."
def add_arguments(self, parser):
parser.add_argument("--plan-id", type=int, required=True, help="Exact SoftwarePlan ID.")
parser.add_argument(
"--reason",
required=True,
help="Non-empty audit reason written to every grant event.",
)
parser.add_argument(
"--execute",
action="store_true",
help="Apply grants. Without this flag the command is read-only.",
)
parser.add_argument(
"--expected-grant-count",
type=int,
help="Required with --execute and must match the current preview count.",
)
def handle(self, *args, **options):
execute = options["execute"]
expected_grant_count = options["expected_grant_count"]
if execute and expected_grant_count is None:
raise CommandError("--execute 必须同时提供 --expected-grant-count")
if not execute and expected_grant_count is not None:
raise CommandError("预演模式不能提供 --expected-grant-count")
try:
plan = SoftwarePlan.objects.get(pk=options["plan_id"])
except SoftwarePlan.DoesNotExist as exc:
raise CommandError("指定套餐不存在") from exc
try:
result = grant_plan_to_existing_users(
plan=plan,
reason=options["reason"],
execute=execute,
expected_grant_count=expected_grant_count,
)
except LicensingError as exc:
raise CommandError(exc.message) from exc
mode = "EXECUTED" if result.executed else "DRY-RUN"
summary = (
f"mode={mode} plan_id={plan.pk} product={plan.product_code} "
f"eligible={result.eligible_count} "
f"skipped_existing={result.skipped_existing_count} "
f"grant_count={result.grant_count}"
)
style = self.style.SUCCESS if result.executed else self.style.WARNING
self.stdout.write(style(summary))
+79
View File
@@ -7,6 +7,7 @@ from decimal import Decimal
from decimal import InvalidOperation from decimal import InvalidOperation
from django.conf import settings from django.conf import settings
from django.contrib.auth import get_user_model
from django.db import IntegrityError, transaction from django.db import IntegrityError, transaction
from django.utils import timezone from django.utils import timezone
@@ -74,6 +75,14 @@ class SoftwarePaymentResult:
applied: bool applied: bool
@dataclass(frozen=True)
class BulkEntitlementGrantResult:
eligible_count: int
skipped_existing_count: int
grant_count: int
executed: bool
@dataclass(frozen=True) @dataclass(frozen=True)
class AuthorizationDecision: class AuthorizationDecision:
product_code: str product_code: str
@@ -344,6 +353,76 @@ def grant_software_entitlement(*, user, plan: SoftwarePlan, reason: str, actor=N
return entitlement return entitlement
@transaction.atomic
def grant_plan_to_existing_users(
*,
plan: SoftwarePlan,
reason: str,
execute: bool = False,
expected_grant_count: int | None = None,
actor=None,
now=None,
) -> BulkEntitlementGrantResult:
reason = _required_reason(reason)
if execute and expected_grant_count is None:
raise LicensingError("expected_grant_count_required", "实际执行必须提供预期授予人数")
if expected_grant_count is not None and expected_grant_count < 0:
raise LicensingError("invalid_expected_grant_count", "预期授予人数不能为负数")
plan_query = SoftwarePlan.objects
if execute:
plan_query = plan_query.select_for_update()
locked_plan = plan_query.get(pk=plan.pk)
if locked_plan.status != SoftwarePlan.Status.ACTIVE:
raise LicensingError("plan_inactive", "套餐已停用,不能批量授予权益")
users_query = get_user_model().objects.filter(
is_active=True,
is_staff=False,
is_superuser=False,
).order_by("pk")
if execute:
users_query = users_query.select_for_update()
eligible_users = list(users_query)
eligible_user_ids = [user.pk for user in eligible_users]
effective_now = now or timezone.now()
existing_user_ids = set(
SoftwareEntitlement.objects.filter(
user_id__in=eligible_user_ids,
product_code=locked_plan.product_code,
status=SoftwareEntitlement.Status.ACTIVE,
grace_expires_at__gt=effective_now,
).values_list("user_id", flat=True)
)
users_to_grant = [
user for user in eligible_users if user.pk not in existing_user_ids
]
grant_count = len(users_to_grant)
if execute and expected_grant_count != grant_count:
raise LicensingError(
"grant_count_mismatch",
f"预期授予 {expected_grant_count} 人,当前实际应授予 {grant_count} 人",
)
if execute:
for user in users_to_grant:
grant_software_entitlement(
user=user,
plan=locked_plan,
reason=reason,
actor=actor,
starts_at=effective_now,
)
return BulkEntitlementGrantResult(
eligible_count=len(eligible_users),
skipped_existing_count=len(existing_user_ids),
grant_count=grant_count,
executed=execute,
)
@transaction.atomic @transaction.atomic
def renew_software_entitlement( def renew_software_entitlement(
*, *,
+169
View File
@@ -1,10 +1,13 @@
from datetime import timedelta from datetime import timedelta
from decimal import Decimal from decimal import Decimal
from concurrent.futures import ThreadPoolExecutor from concurrent.futures import ThreadPoolExecutor
from io import StringIO
from types import SimpleNamespace from types import SimpleNamespace
from unittest.mock import patch from unittest.mock import patch
from django.contrib import admin from django.contrib import admin
from django.core.management import call_command
from django.core.management.base import CommandError
from django.db import close_old_connections from django.db import close_old_connections
from django.test import SimpleTestCase, TestCase, TransactionTestCase, override_settings from django.test import SimpleTestCase, TestCase, TransactionTestCase, override_settings
from django.urls import reverse from django.urls import reverse
@@ -40,6 +43,7 @@ from apps.licensing.services import (
evaluate_device_authorization, evaluate_device_authorization,
evaluate_subscription_access, evaluate_subscription_access,
get_subscription_mode, get_subscription_mode,
grant_plan_to_existing_users,
grant_software_entitlement, grant_software_entitlement,
record_device_heartbeat, record_device_heartbeat,
release_license_seat, release_license_seat,
@@ -51,6 +55,171 @@ from apps.licensing.services import (
from apps.users.models import ApiKey, User, UserWallet from apps.users.models import ApiKey, User, UserWallet
class ExistingUserPlanGrantCommandTests(TestCase):
def setUp(self):
self.plan = SoftwarePlan.objects.create(
product_code=ClientDevice.ProductCode.CMSHOPEE,
name="测试",
duration_days=30,
price=Decimal("99.00"),
device_limit=1,
grace_days=2,
)
self.user_one = User.objects.create_user(
username="existing-one",
email="existing-one@example.com",
password="password",
)
self.user_two = User.objects.create_user(
username="existing-two",
email="existing-two@example.com",
password="password",
)
self.staff_user = User.objects.create_user(
username="staff-user",
email="staff-user@example.com",
password="password",
is_staff=True,
)
self.inactive_user = User.objects.create_user(
username="inactive-user",
email="inactive-user@example.com",
password="password",
is_active=False,
)
def command_options(self, **overrides):
options = {
"plan_id": self.plan.pk,
"reason": "T-633 存量用户测试套餐过渡",
}
options.update(overrides)
return options
def test_dry_run_is_read_only_and_reports_scope(self):
stdout = StringIO()
call_command("grant_existing_users_plan", stdout=stdout, **self.command_options())
self.assertIn("mode=DRY-RUN", stdout.getvalue())
self.assertIn("eligible=2", stdout.getvalue())
self.assertIn("skipped_existing=0", stdout.getvalue())
self.assertIn("grant_count=2", stdout.getvalue())
self.assertFalse(SoftwareEntitlement.objects.exists())
self.assertFalse(LicenseSeat.objects.exists())
self.assertFalse(LicenseEvent.objects.exists())
def test_execute_grants_only_active_nonstaff_users_with_audit_events(self):
before = timezone.now()
call_command(
"grant_existing_users_plan",
execute=True,
expected_grant_count=2,
**self.command_options(),
)
entitlements = SoftwareEntitlement.objects.order_by("user_id")
self.assertEqual(entitlements.count(), 2)
self.assertSetEqual(
set(entitlements.values_list("user_id", flat=True)),
{self.user_one.pk, self.user_two.pk},
)
entitlement = entitlements.first()
self.assertEqual(entitlement.source_plan, self.plan)
self.assertEqual(entitlement.plan_name, "测试")
self.assertEqual(entitlement.plan_duration_days, 30)
self.assertEqual(entitlement.plan_grace_days, 2)
self.assertGreaterEqual(entitlement.starts_at, before)
self.assertEqual(
entitlement.expires_at,
entitlement.starts_at + timedelta(days=30),
)
self.assertEqual(
entitlement.grace_expires_at,
entitlement.expires_at + timedelta(days=2),
)
self.assertEqual(LicenseSeat.objects.count(), 2)
self.assertEqual(
LicenseEvent.objects.filter(
action=LicenseEvent.Action.GRANTED,
reason="T-633 存量用户测试套餐过渡",
).count(),
2,
)
def test_existing_usable_product_entitlement_is_skipped_and_rerun_is_idempotent(self):
formal_plan = SoftwarePlan.objects.create(
product_code=ClientDevice.ProductCode.CMSHOPEE,
name="正式会员",
duration_days=365,
price=Decimal("999.00"),
device_limit=1,
grace_days=7,
)
grant_software_entitlement(
user=self.user_one,
plan=formal_plan,
reason="已有正式权益",
)
call_command(
"grant_existing_users_plan",
execute=True,
expected_grant_count=1,
**self.command_options(),
)
result = grant_plan_to_existing_users(
plan=self.plan,
reason="重复预演",
)
self.assertEqual(SoftwareEntitlement.objects.count(), 2)
self.assertEqual(result.eligible_count, 2)
self.assertEqual(result.skipped_existing_count, 2)
self.assertEqual(result.grant_count, 0)
self.assertFalse(result.executed)
def test_execute_requires_matching_preview_count(self):
with self.assertRaisesRegex(CommandError, "当前实际应授予 2 人"):
call_command(
"grant_existing_users_plan",
execute=True,
expected_grant_count=1,
**self.command_options(),
)
self.assertFalse(SoftwareEntitlement.objects.exists())
self.assertFalse(LicenseEvent.objects.exists())
def test_failure_rolls_back_entire_batch(self):
original_grant = grant_software_entitlement
call_count = 0
def fail_second_grant(**kwargs):
nonlocal call_count
call_count += 1
if call_count == 2:
raise RuntimeError("simulated grant failure")
return original_grant(**kwargs)
with patch(
"apps.licensing.services.grant_software_entitlement",
side_effect=fail_second_grant,
):
with self.assertRaisesRegex(RuntimeError, "simulated grant failure"):
call_command(
"grant_existing_users_plan",
execute=True,
expected_grant_count=2,
**self.command_options(),
)
self.assertFalse(SoftwareEntitlement.objects.exists())
self.assertFalse(LicenseSeat.objects.exists())
self.assertFalse(LicenseEvent.objects.exists())
class SubscriptionModeUnitTests(SimpleTestCase): class SubscriptionModeUnitTests(SimpleTestCase):
@override_settings( @override_settings(
CMSHOPEE_SUBSCRIPTION_MODE="", CMSHOPEE_SUBSCRIPTION_MODE="",
+1 -1
View File
@@ -111,7 +111,7 @@
| 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 | | 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 |
| T-631 | 隐藏旧设备授权模型的 admin 菜单入口 | T-630 | 已完成:从 django-admin 应用索引隐藏 `LicenseSeat`、`LegacyMigrationGrant`、`MigrationRequest`、`DeviceCredential` 四个旧方案模型;保留数据库表、历史数据和只读模型。当前订阅、订单、授权事件、设备和会话观测模型继续可见,不改变 API、授权和账务行为。定向 admin 测试、`check`、迁移一致性和编译检查通过。详见 [`tasks/T-631.md`](tasks/T-631.md)。 | DONE | | T-631 | 隐藏旧设备授权模型的 admin 菜单入口 | T-630 | 已完成:从 django-admin 应用索引隐藏 `LicenseSeat`、`LegacyMigrationGrant`、`MigrationRequest`、`DeviceCredential` 四个旧方案模型;保留数据库表、历史数据和只读模型。当前订阅、订单、授权事件、设备和会话观测模型继续可见,不改变 API、授权和账务行为。定向 admin 测试、`check`、迁移一致性和编译检查通过。详见 [`tasks/T-631.md`](tasks/T-631.md)。 | DONE |
| 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-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 | 新增默认只预演的批量授权命令,通过明确 `plan_id`、非空原因、`--execute` 与预期人数双重确认,为执行时已有的启用非后台账号授予指定套餐。已有同产品有效/宽限期权益用户必须跳过;实际授予复用 `grant_software_entitlement()`,在单事务内生成权益快照、席位和授权事件,任一失败整批回滚。不得修改订阅模式、API Key、点数、充值、软件订单或生成记录。详见 [`tasks/T-633.md`](tasks/T-633.md)。 | DOING | | 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 |
## 里程碑 ## 里程碑
+4 -2
View File
@@ -75,7 +75,7 @@
任务状态以 [`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-630 账号订阅授权与默认多设备模式;T-631 隐藏旧设备授权模型的 admin 菜单入口;T-632 会员订阅三阶段启用、状态接口补全与 admin 简化。 - 已完成补充: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-621 注册赠点运营后台配置继续留在 Backlog。真实支付回调到账闭环、客户端发布、生产多图理解模型配置和线上旧同步接口用量观察仍可继续拆任务。 - T-621 注册赠点运营后台配置继续留在 Backlog。真实支付回调到账闭环、客户端发布、生产多图理解模型配置和线上旧同步接口用量观察仍可继续拆任务。
- 当前 blocker:支付商户真实密钥/证书与生产 SDK 依赖仍待提供;微信回调到账闭环仍需真实支付验收;真实 AI 标题生成已在线上跑通,图片生成慢 / 504 / 客户端超时风险已拆为 T-612~T-616 并完成工程侧处理。 - 当前 blocker:支付商户真实密钥/证书与生产 SDK 依赖仍待提供;微信回调到账闭环仍需真实支付验收;真实 AI 标题生成已在线上跑通,图片生成慢 / 504 / 客户端超时风险已拆为 T-612~T-616 并完成工程侧处理。
@@ -151,13 +151,15 @@ T-619 已落地同步多图理解:调用方提交有序 `images` 列表,服
T-632 已落地会员订阅三阶段模式:`CMSHOPEE_SUBSCRIPTION_MODE=open` 为当前开发测试默认值,无需给所有用户逐个绑定长期权益;`shadow` 继续放行并记录真实权益覆盖,`enforce` 才按真实权益拦截产品专属 submit。订阅状态接口已补齐桌面端账号、套餐、顶层有效期、会员中心与通知字段;admin 软件授权首页只显示“会员套餐”和“用户会员”,隐藏模型及历史数据未删除。 T-632 已落地会员订阅三阶段模式:`CMSHOPEE_SUBSCRIPTION_MODE=open` 为当前开发测试默认值,无需给所有用户逐个绑定长期权益;`shadow` 继续放行并记录真实权益覆盖,`enforce` 才按真实权益拦截产品专属 submit。订阅状态接口已补齐桌面端账号、套餐、顶层有效期、会员中心与通知字段;admin 软件授权首页只显示“会员套餐”和“用户会员”,隐藏模型及历史数据未删除。
T-633 已新增默认只预演的 `grant_existing_users_plan`:按套餐 ID、非空原因和预期授予人数安全批量授予启用的非后台账号,跳过同产品已有有效/宽限期权益,并在单事务内复用授权服务生成快照、席位和事件。线上当前保持 `shadow`,已给 30 个存量普通用户授予“测试”套餐;后台账号未授予,重复预演待授予为 0。
## 开始编码前检查 ## 开始编码前检查
1. 读仓库级 `AGENTS.md` / `CLAUDE.md`。 1. 读仓库级 `AGENTS.md` / `CLAUDE.md`。
2. 读 `docs/00-ai-start-here.md`。 2. 读 `docs/00-ai-start-here.md`。
3. 读 `docs/05-coding-rules.md`(尤其第 8 节资金安全)。 3. 读 `docs/05-coding-rules.md`(尤其第 8 节资金安全)。
4. 在 `docs/06-tasks.md` 领取第一个 `TODO` 且依赖均 `DONE` 的任务;当前没有可直接领取的任务。 4. 在 `docs/06-tasks.md` 领取第一个 `TODO` 且依赖均 `DONE` 的任务;当前没有可直接领取的任务。
5. 当前开发测试环境应保持 `CMSHOPEE_SUBSCRIPTION_MODE=open`;切到 `shadow` / `enforce` 前须补跑数据库集成测试,并用真实微信商户完成充值和订阅两条回调闭环验收。 5. 代码默认开发测试模式为 `open`;线上当前为 `shadow` 且存量普通用户已有“测试”套餐。切到 `enforce` 前仍须完成桌面端回归,并用真实微信商户完成充值和订阅两条回调闭环验收。
## 维护规则 ## 维护规则
+29
View File
@@ -516,3 +516,32 @@ python3.12 manage.py smoke_ai_generation image
- 真实支付 SDK 与商户配置齐全后,`PAYMENT_CALLBACK_MODE=sdk`。 - 真实支付 SDK 与商户配置齐全后,`PAYMENT_CALLBACK_MODE=sdk`。
- 真实图片生成耗时已记录并用于设置超时链路;若未记录,发布说明必须标注风险。 - 真实图片生成耗时已记录并用于设置超时链路;若未记录,发布说明必须标注风险。
- `manage.py check`、迁移、静态文件、关键测试和 smoke 均有记录。 - `manage.py check`、迁移、静态文件、关键测试和 smoke 均有记录。
## 十一、存量用户批量授予过渡套餐
仅在运营已创建并确认目标套餐后使用。先用套餐数据库 ID 预演,默认不会写数据:
```bash
python3.12 manage.py grant_existing_users_plan \
--plan-id 2 \
--reason "存量用户测试套餐过渡"
```
确认输出中的 `grant_count`、套餐 ID 和产品代码,并完成数据库备份后,再把预演人数原样传给执行命令:
```bash
python3.12 manage.py grant_existing_users_plan \
--plan-id 2 \
--reason "存量用户测试套餐过渡" \
--execute \
--expected-grant-count 30
```
安全规则:
- 默认只处理启用的非后台、非超级用户账号。
- 同产品已有有效或宽限期权益的用户会跳过,不用测试套餐覆盖正式会员。
- `--expected-grant-count` 与执行时重新计算的人数不一致时整批拒绝。
- 实际授予在一个事务中执行,任一失败整批回滚,并为每个新权益写带原因的授权事件。
- 执行后再次运行预演,正常应得到 `grant_count=0`;再核对权益、授权事件、后台账号和订阅状态接口。
- 示例中的套餐 ID 和人数只表示一次部署记录,每次操作都必须重新从 admin 和预演结果确认,不能照抄。
+22 -1
View File
@@ -58,4 +58,25 @@
## 状态 ## 状态
DOING。 DONE。
## 实施结果
- 新增 `grant_plan_to_existing_users()` 批量服务:执行时锁定套餐和启用的非后台用户,按同产品仍有效或处于宽限期的权益跳过已有会员,并在单事务中复用 `grant_software_entitlement()`。
- 新增 `grant_existing_users_plan` management command。默认只预演;实际执行必须同时提供 `--execute` 和与当前范围一致的 `--expected-grant-count`。
- 命令按套餐 ID 精确选择启用套餐,统一要求非空原因;输出只包含套餐、产品和数量统计。
- 新增 5 条专项测试,覆盖只读预演、普通用户范围、正式权益保护、重复执行、人数不匹配拒绝和异常整批回滚。
## 验证结果
- `manage.py check`:通过,0 issues。
- `manage.py makemigrations --check --dry-run`:No changes detected;本任务无迁移。
- `manage.py test apps.licensing.tests.ExistingUserPlanGrantCommandTests`:5 tests OK。
- SQLite 完整 `apps.licensing` 回归共 35 条,其中 33 条通过;2 条既有多连接并发测试因 SQLite 表级锁返回 `database table is locked`,与本任务断言无关,未误记为全量通过。
- `compileall apps/licensing` 与 `git diff --check`:通过。
- 生产 MySQL 8.4 执行前备份:`/root/cmhub-t633-backup-20260722_164956/cmhub-before-t633.sql.gz`,SHA256 为 `ae53d3edd329a3ed16e55958f2a93453f420b5026bbb3294edb6b4e91ad17162`,本地副本校验一致。
- 线上预演:`eligible=30`、`skipped_existing=0`、`grant_count=30`;按预期人数执行成功。
- 执行后:测试套餐权益 30 条、不同用户 30 个、`granted` 事件 30 条、席位 3030 条;后台账号权益 0,缺少有效权益的目标用户 0,重复有效权益用户 0。
- 反向预演:`eligible=30`、`skipped_existing=30`、`grant_count=0`,证明重复执行不会重复授予。
- 真实 API Key 抽样返回 `access_source=entitlement`、`entitlement_status=active`、套餐“测试”,点数余额保持 148。
- 执行期间线上有 4 个生图任务运行,聚合点数同期减少 2 点并新增一条关联调用的 `consume -2` 流水;这是并发生成业务变化,不是授权命令写账。
+11
View File
@@ -2153,3 +2153,14 @@
- 方案:新增默认只预演的 `grant_existing_users_plan` 命令。实际执行必须指定套餐 ID、非空原因、`--execute` 和与预演一致的预期授予人数;默认排除后台/超级用户,并跳过已有同产品有效或宽限期权益的账号。 - 方案:新增默认只预演的 `grant_existing_users_plan` 命令。实际执行必须指定套餐 ID、非空原因、`--execute` 和与预演一致的预期授予人数;默认排除后台/超级用户,并跳过已有同产品有效或宽限期权益的账号。
- 安全边界:批量授予必须在单事务中锁定套餐和目标用户,逐项复用 `grant_software_entitlement()` 生成套餐快照、席位和授权事件;任一失败整批回滚。不得直接写权益表,不修改订阅模式、点数、充值、软件订单或生成任务。 - 安全边界:批量授予必须在单事务中锁定套餐和目标用户,逐项复用 `grant_software_entitlement()` 生成套餐快照、席位和授权事件;任一失败整批回滚。不得直接写权益表,不修改订阅模式、点数、充值、软件订单或生成任务。
- 发布顺序:任务文档提交后实现命令和测试;线上部署后先备份数据库,再预演、按预演人数执行并抽样验证订阅状态。测试结束后的正式套餐切换另行立项。 - 发布顺序:任务文档提交后实现命令和测试;线上部署后先备份数据库,再预演、按预演人数执行并抽样验证订阅状态。测试结束后的正式套餐切换另行立项。
## 2026-07-22 完成:T-633 存量用户批量授予过渡测试套餐
- 实现:新增 `grant_plan_to_existing_users()` 与 `grant_existing_users_plan`。默认只预演;执行要求套餐 ID、非空原因、`--execute` 和预期授予人数。范围为启用的非后台/非超级用户;同产品已有有效或宽限期权益时跳过。
- 安全:执行阶段锁套餐和目标用户,在单事务中逐项复用 `grant_software_entitlement()`,所以套餐快照、席位和 `LicenseEvent(granted)` 保持既有规则,任一异常整批回滚。未新增迁移,也未调用点数、充值或软件订单服务。
- 本地验证:专项 5 条测试通过;SQLite 完整 licensing 回归 35 条中 33 条通过,2 条既有并发测试因 SQLite 表级锁失败;`check`、迁移一致性、编译和 `git diff --check` 通过。本地旧 MySQL `43.128.3.240` 拒绝连接,因此未声称取得本地 MySQL 全量测试结果。
- 生产备份:执行前生成 `/root/cmhub-t633-backup-20260722_164956/cmhub-before-t633.sql.gz`,并复制到本地 `deploy/185.216.248.75/20260722_164956_t633/`;两端 SHA256 均为 `ae53d3edd329a3ed16e55958f2a93453f420b5026bbb3294edb6b4e91ad17162`。
- 线上预演:套餐 ID 2、产品 `cmshopee`,`eligible=30`、`skipped_existing=0`、`grant_count=30`;使用 `--expected-grant-count 30` 执行成功。
- 线上结果:30 个不同普通用户获得“测试”权益,产生 30 条授予事件和 3030 条套餐席位快照;后台账号为 0,目标用户缺失为 0,重复有效权益为 0。反向预演为 `skipped_existing=30`、`grant_count=0`。
- API 抽样:订阅状态返回 `active`、`access_source=entitlement`、`entitlement_status=active` 和套餐“测试”,抽样账号点数保持 148。
- 并发说明:执行前后有 4 个生图任务运行,点数聚合从 22221 变为 22219、流水从 10180 变为 10181;最新流水为关联调用的 `consume -2`,属于并发生图业务,不是 T-633 授权命令写账。