diff --git a/apps/licensing/management/__init__.py b/apps/licensing/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/licensing/management/commands/__init__.py b/apps/licensing/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/licensing/management/commands/grant_existing_users_plan.py b/apps/licensing/management/commands/grant_existing_users_plan.py new file mode 100644 index 0000000..7cb3044 --- /dev/null +++ b/apps/licensing/management/commands/grant_existing_users_plan.py @@ -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)) diff --git a/apps/licensing/services.py b/apps/licensing/services.py index bd421c9..01abbc0 100644 --- a/apps/licensing/services.py +++ b/apps/licensing/services.py @@ -7,6 +7,7 @@ from decimal import Decimal from decimal import InvalidOperation from django.conf import settings +from django.contrib.auth import get_user_model from django.db import IntegrityError, transaction from django.utils import timezone @@ -74,6 +75,14 @@ class SoftwarePaymentResult: applied: bool +@dataclass(frozen=True) +class BulkEntitlementGrantResult: + eligible_count: int + skipped_existing_count: int + grant_count: int + executed: bool + + @dataclass(frozen=True) class AuthorizationDecision: product_code: str @@ -344,6 +353,76 @@ def grant_software_entitlement(*, user, plan: SoftwarePlan, reason: str, actor=N 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 def renew_software_entitlement( *, diff --git a/apps/licensing/tests.py b/apps/licensing/tests.py index b9c3a37..0abc71d 100644 --- a/apps/licensing/tests.py +++ b/apps/licensing/tests.py @@ -1,10 +1,13 @@ from datetime import timedelta from decimal import Decimal from concurrent.futures import ThreadPoolExecutor +from io import StringIO from types import SimpleNamespace from unittest.mock import patch 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.test import SimpleTestCase, TestCase, TransactionTestCase, override_settings from django.urls import reverse @@ -40,6 +43,7 @@ from apps.licensing.services import ( evaluate_device_authorization, evaluate_subscription_access, get_subscription_mode, + grant_plan_to_existing_users, grant_software_entitlement, record_device_heartbeat, release_license_seat, @@ -51,6 +55,171 @@ from apps.licensing.services import ( 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): @override_settings( CMSHOPEE_SUBSCRIPTION_MODE="", diff --git a/docs/06-tasks.md b/docs/06-tasks.md index b31cf6b..a6c2300 100644 --- a/docs/06-tasks.md +++ b/docs/06-tasks.md @@ -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-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-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 | ## 里程碑 diff --git a/docs/current-state.md b/docs/current-state.md index e6ee179..3de407d 100644 --- a/docs/current-state.md +++ b/docs/current-state.md @@ -75,7 +75,7 @@ 任务状态以 [`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-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。真实支付回调到账闭环、客户端发布、生产多图理解模型配置和线上旧同步接口用量观察仍可继续拆任务。 - 当前 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-633 已新增默认只预演的 `grant_existing_users_plan`:按套餐 ID、非空原因和预期授予人数安全批量授予启用的非后台账号,跳过同产品已有有效/宽限期权益,并在单事务内复用授权服务生成快照、席位和事件。线上当前保持 `shadow`,已给 30 个存量普通用户授予“测试”套餐;后台账号未授予,重复预演待授予为 0。 + ## 开始编码前检查 1. 读仓库级 `AGENTS.md` / `CLAUDE.md`。 2. 读 `docs/00-ai-start-here.md`。 3. 读 `docs/05-coding-rules.md`(尤其第 8 节资金安全)。 4. 在 `docs/06-tasks.md` 领取第一个 `TODO` 且依赖均 `DONE` 的任务;当前没有可直接领取的任务。 -5. 当前开发测试环境应保持 `CMSHOPEE_SUBSCRIPTION_MODE=open`;切到 `shadow` / `enforce` 前须补跑数据库集成测试,并用真实微信商户完成充值和订阅两条回调闭环验收。 +5. 代码默认开发测试模式为 `open`;线上当前为 `shadow` 且存量普通用户已有“测试”套餐。切到 `enforce` 前仍须完成桌面端回归,并用真实微信商户完成充值和订阅两条回调闭环验收。 ## 维护规则 diff --git a/docs/deployment.md b/docs/deployment.md index cc4b85e..91fcbbc 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -516,3 +516,32 @@ python3.12 manage.py smoke_ai_generation image - 真实支付 SDK 与商户配置齐全后,`PAYMENT_CALLBACK_MODE=sdk`。 - 真实图片生成耗时已记录并用于设置超时链路;若未记录,发布说明必须标注风险。 - `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 和预演结果确认,不能照抄。 diff --git a/docs/tasks/T-633.md b/docs/tasks/T-633.md index 45f32fd..7bcb9af 100644 --- a/docs/tasks/T-633.md +++ b/docs/tasks/T-633.md @@ -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` 流水;这是并发生成业务变化,不是授权命令写账。 diff --git a/progress.md b/progress.md index 9bd00d3..94c50d2 100644 --- a/progress.md +++ b/progress.md @@ -2153,3 +2153,14 @@ - 方案:新增默认只预演的 `grant_existing_users_plan` 命令。实际执行必须指定套餐 ID、非空原因、`--execute` 和与预演一致的预期授予人数;默认排除后台/超级用户,并跳过已有同产品有效或宽限期权益的账号。 - 安全边界:批量授予必须在单事务中锁定套餐和目标用户,逐项复用 `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 授权命令写账。