Files
cmhub/docs/tasks/T-634.md
T

96 lines
5.1 KiB
Markdown
Raw Normal View History

# T-634 过期会员允许购买其他套餐
## 背景
线上 `dev` 用户的“测试”权益已经超过宽限截止时间,但
`SoftwareEntitlement.status` 仍为 `active`。用户购买“99元/月”套餐时,
软件订单服务只按数据库状态查找已有权益,没有同时判断
`grace_expires_at`,因此把时间上已经失效的权益误判为当前套餐,并返回
`plan_change_not_supported`(“当前套餐变更请联系运营处理”)。
订阅授权服务已经按 `status + grace_expires_at` 判断真实有效性,下单与支付
入账使用不同口径,导致状态接口显示已过期,但用户仍无法购买新套餐。
## 目标
1. 已超过宽限截止时间的权益不得阻止用户购买其他套餐。
2. 尚在有效期或宽限期内的不同套餐仍拒绝自助切换,继续由运营处理。
3. 支付回调首次发放新套餐时,把同产品已超过宽限期但仍标记为 `active`
的历史权益收敛为 `expired`。
4. 同套餐有效权益的正常续订、支付幂等、金额校验和点数账本隔离保持不变。
## 范围
### 下单前校验
- `create_software_order()` 查询当前套餐时必须同时满足:
`status=active` 且 `grace_expires_at > now`。
- 只有当前仍可用的权益与目标套餐不同,才返回
`plan_change_not_supported`。
- 已超过宽限期的 `active` 历史权益不阻止创建其他套餐的待支付订单。
- 创建支付订单阶段不提前改写历史权益;只有支付确认并发放权益时才完成
状态收敛,避免用户未付款就改变历史数据。
### 支付入账
- `apply_software_payment()` 继续在数据库事务内锁软件订单和同产品权益。
- 发放前把 `status=active` 且 `grace_expires_at <= now` 的同产品历史权益
更新为 `expired`,再查询当前仍可用权益。
- 当前没有可用权益时,按订单快照创建一条新权益;不得续期已过期的旧权益。
- 当前存在同套餐可用权益时沿用现有续订逻辑。
- 当前存在其他套餐可用权益时继续拒绝入账,不实现升级、降级、差价、退款
或剩余天数折算。
## 安全与兼容
- 不修改 API 路由、请求或响应结构,不新增数据库字段或迁移。
- 不修改 `RechargeOrder`、`UserWallet`、`PointsLedger` 或点数余额。
- 不修改微信验签、金额比对、交易号唯一约束和同订单幂等逻辑。
- 历史权益只做自然到期状态收敛,不删除权益、席位、事件或订单。
- 通用生成接口、产品专属生成接口和订阅强制模式保持不变。
## 验收条件
1. 已超过宽限期但状态仍为 `active` 的旧套餐不阻止创建其他套餐订单。
2. 上述订单支付成功后创建目标套餐新权益,旧权益状态变为 `expired`。
3. 已超过宽限期的同套餐权益也创建新权益,不在旧权益上续期。
4. 仍在有效期或宽限期内的其他套餐在下单和支付入账两个阶段均继续返回
`plan_change_not_supported`。
5. 仍可用的同套餐权益继续正常续订,重复回调最多发放一次。
6. 支付失败或校验失败不提前修改旧权益状态。
7. `manage.py check`、目标测试、licensing 回归、迁移一致性、编译检查和
`git diff --check` 通过。
## 状态
2026-07-23 17:42:08 +08:00
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 转换提示。