fix: allow expired members to buy new plans

This commit is contained in:
QiuSW
2026-07-23 17:42:08 +08:00
parent e12659faa0
commit 976b55c374
6 changed files with 207 additions and 21 deletions
+38 -11
View File
@@ -783,16 +783,40 @@ def _generate_software_order_no() -> str:
raise SoftwareOrderError("order_number_failed", "无法生成软件订单号")
def _active_entitlement_for_software_order(*, user, product_code):
return (
def _usable_entitlement_for_software_order(
*,
user,
product_code,
now=None,
for_update=False,
):
now = now or timezone.now()
queryset = SoftwareEntitlement.objects.filter(
user=user,
product_code=product_code,
status=SoftwareEntitlement.Status.ACTIVE,
grace_expires_at__gt=now,
)
if for_update:
queryset = queryset.select_for_update()
return queryset.order_by("-expires_at", "-id").first()
def _expire_elapsed_entitlements_for_software_order(*, user, product_code, now):
entitlement_ids = list(
SoftwareEntitlement.objects.select_for_update()
.filter(
user=user,
product_code=product_code,
status=SoftwareEntitlement.Status.ACTIVE,
grace_expires_at__lte=now,
)
.order_by("-expires_at", "-id")
.first()
.values_list("id", flat=True)
)
if entitlement_ids:
SoftwareEntitlement.objects.filter(id__in=entitlement_ids).update(
status=SoftwareEntitlement.Status.EXPIRED,
updated_at=now,
)
@@ -801,14 +825,9 @@ def create_software_order(*, user, plan: SoftwarePlan, pay_method: str, payment_
raise SoftwareOrderError("plan_inactive", "套餐已停用,无法购买")
if pay_method != SoftwareOrder.PayMethod.WEIXIN:
raise SoftwareOrderError("bad_request", "当前软件订阅仅支持微信支付")
existing_entitlement = (
SoftwareEntitlement.objects.filter(
existing_entitlement = _usable_entitlement_for_software_order(
user=user,
product_code=plan.product_code,
status=SoftwareEntitlement.Status.ACTIVE,
)
.order_by("-expires_at", "-id")
.first()
)
if existing_entitlement is not None and existing_entitlement.source_plan_id != plan.id:
raise SoftwareOrderError("plan_change_not_supported", "当前套餐变更请联系运营处理")
@@ -917,9 +936,17 @@ def apply_software_payment(payment) -> SoftwarePaymentResult:
).exclude(pk=order.pk).exists():
raise SoftwareOrderTransactionMismatchError()
entitlement = _active_entitlement_for_software_order(
fulfillment_now = timezone.now()
_expire_elapsed_entitlements_for_software_order(
user=order.user,
product_code=order.product_code,
now=fulfillment_now,
)
entitlement = _usable_entitlement_for_software_order(
user=order.user,
product_code=order.product_code,
now=fulfillment_now,
for_update=True,
)
if entitlement is None:
entitlement = _grant_software_order_entitlement(order=order, now=paid_at)
+112
View File
@@ -32,6 +32,7 @@ from apps.licensing.models import (
from apps.licensing.services import (
LicensingError,
SoftwareOrderAmountMismatchError,
SoftwareOrderError,
SoftwareOrderTransactionMismatchError,
SubscriptionAuthorizationDecision,
apply_software_payment,
@@ -675,6 +676,16 @@ class SoftwareOrderServiceTests(TestCase):
),
)
def create_alternate_plan(self):
return SoftwarePlan.objects.create(
product_code=ClientDevice.ProductCode.CMSHOPEE,
name="其他月度订阅",
duration_days=30,
price=Decimal("29.90"),
device_limit=1,
grace_days=2,
)
@staticmethod
def payment_for(order, *, amount=None, transaction_id="wx-software-001"):
return PaymentReceipt(
@@ -736,6 +747,107 @@ class SoftwareOrderServiceTests(TestCase):
1,
)
def test_expired_other_plan_allows_order_and_payment_grants_new_entitlement(self):
old_plan = self.create_alternate_plan()
old_entitlement = grant_software_entitlement(
user=self.user,
plan=old_plan,
reason="准备过期套餐测试",
starts_at=timezone.now() - timedelta(days=40),
)
order = self.create_order()
old_entitlement.refresh_from_db()
self.assertEqual(old_entitlement.status, SoftwareEntitlement.Status.ACTIVE)
result = apply_software_payment(
self.payment_for(order, transaction_id="wx-expired-plan-change")
)
old_entitlement.refresh_from_db()
self.assertEqual(old_entitlement.status, SoftwareEntitlement.Status.EXPIRED)
self.assertNotEqual(result.entitlement.pk, old_entitlement.pk)
self.assertEqual(result.entitlement.source_plan_id, self.plan.id)
self.assertEqual(result.entitlement.status, SoftwareEntitlement.Status.ACTIVE)
def test_expired_same_plan_creates_new_entitlement_instead_of_renewing_old_one(self):
old_entitlement = grant_software_entitlement(
user=self.user,
plan=self.plan,
reason="准备同套餐过期测试",
starts_at=timezone.now() - timedelta(days=40),
)
order = self.create_order()
result = apply_software_payment(
self.payment_for(order, transaction_id="wx-expired-same-plan")
)
old_entitlement.refresh_from_db()
self.assertEqual(old_entitlement.status, SoftwareEntitlement.Status.EXPIRED)
self.assertNotEqual(result.entitlement.pk, old_entitlement.pk)
self.assertEqual(
SoftwareEntitlement.objects.filter(user=self.user).count(),
2,
)
def test_other_plan_within_grace_period_still_blocks_order_creation(self):
old_plan = self.create_alternate_plan()
grant_software_entitlement(
user=self.user,
plan=old_plan,
reason="准备宽限期套餐测试",
starts_at=timezone.now() - timedelta(days=31),
)
with self.assertRaises(SoftwareOrderError) as context:
self.create_order()
self.assertEqual(context.exception.code, "plan_change_not_supported")
self.assertEqual(SoftwareOrder.objects.filter(user=self.user).count(), 0)
def test_other_active_plan_created_after_order_still_blocks_payment(self):
order = self.create_order()
old_plan = self.create_alternate_plan()
active_entitlement = grant_software_entitlement(
user=self.user,
plan=old_plan,
reason="模拟下单后套餐变化",
)
with self.assertRaises(SoftwareOrderError) as context:
apply_software_payment(
self.payment_for(order, transaction_id="wx-late-plan-change")
)
self.assertEqual(context.exception.code, "plan_change_not_supported")
order.refresh_from_db()
active_entitlement.refresh_from_db()
self.assertEqual(order.status, SoftwareOrder.Status.PENDING)
self.assertEqual(active_entitlement.status, SoftwareEntitlement.Status.ACTIVE)
def test_invalid_payment_does_not_expire_elapsed_entitlement(self):
old_plan = self.create_alternate_plan()
old_entitlement = grant_software_entitlement(
user=self.user,
plan=old_plan,
reason="准备支付校验失败测试",
starts_at=timezone.now() - timedelta(days=40),
)
order = self.create_order()
with self.assertRaises(SoftwareOrderAmountMismatchError):
apply_software_payment(
self.payment_for(
order,
amount=order.amount_money - Decimal("0.01"),
transaction_id="wx-invalid-expired-plan",
)
)
old_entitlement.refresh_from_db()
self.assertEqual(old_entitlement.status, SoftwareEntitlement.Status.ACTIVE)
class SoftwareOrderConcurrencyTests(TransactionTestCase):
def setUp(self):
+1 -1
View File
@@ -112,7 +112,7 @@
| 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 | **已完成。** 新增默认只预演的 `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` 判断当前套餐的口径差异:下单与支付入账都按 `status=active + grace_expires_at > now` 识别当前可用权益;支付发放时把同产品已超过宽限期但仍为 `active` 的历史权益收敛为 `expired`,并为目标套餐创建新权益。有效/宽限期内的跨套餐仍拒绝;同套餐续订、支付幂等、金额校验和点数账本隔离保持不变。详见 [`tasks/T-634.md`](tasks/T-634.md)。 | TODO |
| 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 |
## 里程碑
File diff suppressed because one or more lines are too long
+30 -1
View File
@@ -63,4 +63,33 @@
## 状态
TODO。
DONE。
## 实施结果
- 新增统一的当前可用权益查询,软件下单和支付发放都只把
`status=active` 且尚未超过宽限截止时间的权益视为当前套餐。
- 创建支付订单时不提前修改历史权益;用户未付款或支付下单失败不会触发
权益状态变化。
- 支付回调通过订单状态、通道、金额和交易号校验后,在原事务内锁定并把
同产品自然到期的 `active` 权益更新为 `expired`,再决定新建或续订。
- 已过期的同套餐和不同套餐都创建新权益;仍可用的同套餐继续续订,仍可用
的不同套餐继续返回 `plan_change_not_supported`。
- 未修改模型、迁移、接口契约、支付验签、点数账本或订阅模式。
## 验证结果
- `py -3.12 -m py_compile apps\licensing\services.py apps\licensing\tests.py`:
通过。
- SQLite 目标 `SoftwareOrderServiceTests`:8 tests OK,其中新增 5 条覆盖
过期跨套餐、过期同套餐、宽限期跨套餐、下单后套餐变化和支付校验失败。
- SQLite 非并发 licensing / 软件支付回调扩大回归:40 tests OK。
- SQLite 既有软件订单并发测试仍因 `database table is locked` 失败;该测试
依赖真实行锁,不能把 SQLite 结果作为 MySQL 并发结论。
- 已尝试在生产服务器 MySQL 8.4 上创建唯一命名的隔离测试库,连接成功但
项目账号没有建库权限,测试在迁移和断言前退出;生产业务库未被修改。
- `manage.py check`:通过,0 issues。
- `manage.py makemigrations --check --dry-run`:No changes detected;检查
迁移历史时旧本地 MySQL 地址拒绝连接并给出 warning,本任务无迁移。
- `./init.ps1`:通过,Python 3.12.3,Django system check 0 issues。
- `git diff --check`:通过,仅有 Windows 工作区 LF/CRLF 转换提示。
+16
View File
@@ -2172,3 +2172,19 @@
- 方案:下单和支付都只把仍在有效期或宽限期内的权益视为当前权益;支付发放时在同一事务内把同产品自然到期但仍标记为 `active` 的历史权益收敛为 `expired`,再创建目标套餐新权益。
- 兼容边界:有效/宽限期内的跨套餐仍拒绝;同套餐有效权益继续续订;不实现升级、降级、差价或退款,不改支付验签、点数账本、API 契约或数据库结构。
- 基线:执行 `./init.ps1` 通过,Python 3.12.3,依赖已满足,`manage.py check` 0 issues。本阶段只登记任务,尚未修改代码。
## 2026-07-23 开工:T-634 过期会员允许购买其他套餐
- 状态:DOING。
- 实施范围:统一软件下单、支付入账与订阅授权的“当前可用权益”口径;支付事务内收敛自然到期状态,并补齐过期跨套餐、过期同套餐、有效跨套餐和支付失败不改历史状态测试。
- 边界:不改接口、数据库结构、支付验签、支付金额与交易号校验、点数账本或订阅强制模式。
## 2026-07-23 完成:T-634 过期会员允许购买其他套餐
- 实现:`create_software_order()` 和支付发放统一只查询 `status=active` 且 `grace_expires_at > now` 的当前可用权益。支付订单创建阶段保持只读,不会因用户尚未付款而修改历史权益。
- 支付事务:订单状态、通道、金额和交易号校验通过后,锁定同账号同产品权益,把 `grace_expires_at <= now` 但仍为 `active` 的历史权益更新为 `expired`,再按当前可用权益决定创建新权益、续订同套餐或拒绝有效跨套餐。
- 兼容:已过期同套餐和不同套餐都创建新权益;有效/宽限期内的同套餐仍续订、不同套餐仍返回 `plan_change_not_supported`。支付幂等、金额校验、微信网关、软件订单快照和点数账本未改;无迁移、无 API 契约变化。
- 测试:SQLite `SoftwareOrderServiceTests` 8 条通过,其中新增 5 条缺陷回归;非并发 licensing 与软件支付回调 API 扩大回归 40 条通过。既有 SQLite 并发回归因表级锁报 `database table is locked`,符合该测试必须在 MySQL 行锁环境运行的既有事实。
- MySQL 验证尝试:在 `185.216.248.75` 的 `/tmp` 临时代码副本请求创建唯一测试库 `test_cmhub_t634_base`,MySQL 连接成功但项目账号无建库权限,测试未进入迁移或断言;临时代码目录已删除,生产 `cmhub` 库未修改。
- 静态验证:`py_compile`、`manage.py check`、`./init.ps1` 和 `git diff --check` 通过;`makemigrations --check --dry-run` 为 No changes detected,但检查旧本地 MySQL 迁移历史时收到连接拒绝 warning;本任务不包含迁移。
- 发布状态:代码与文档已完成,尚未部署到线上服务。