feat: grant transition plan to existing users
This commit is contained in:
@@ -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))
|
||||
@@ -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(
|
||||
*,
|
||||
|
||||
@@ -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="",
|
||||
|
||||
+1
-1
@@ -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 |
|
||||
|
||||
## 里程碑
|
||||
|
||||
|
||||
@@ -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` 前仍须完成桌面端回归,并用真实微信商户完成充值和订阅两条回调闭环验收。
|
||||
|
||||
## 维护规则
|
||||
|
||||
|
||||
@@ -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 和预演结果确认,不能照抄。
|
||||
|
||||
+22
-1
@@ -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
@@ -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 授权命令写账。
|
||||
|
||||
Reference in New Issue
Block a user