From 976b55c374b53ddee298f31b16f208716b8a2c85 Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Thu, 23 Jul 2026 17:42:08 +0800 Subject: [PATCH] fix: allow expired members to buy new plans --- apps/licensing/services.py | 53 +++++++++++++----- apps/licensing/tests.py | 112 +++++++++++++++++++++++++++++++++++++ docs/06-tasks.md | 2 +- docs/current-state.md | 14 +++-- docs/tasks/T-634.md | 31 +++++++++- progress.md | 16 ++++++ 6 files changed, 207 insertions(+), 21 deletions(-) diff --git a/apps/licensing/services.py b/apps/licensing/services.py index 01abbc0..af5173b 100644 --- a/apps/licensing/services.py +++ b/apps/licensing/services.py @@ -783,17 +783,41 @@ 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, + ) def create_software_order(*, user, plan: SoftwarePlan, pay_method: str, payment_order_func=None): @@ -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( - user=user, - product_code=plan.product_code, - status=SoftwareEntitlement.Status.ACTIVE, - ) - .order_by("-expires_at", "-id") - .first() + existing_entitlement = _usable_entitlement_for_software_order( + user=user, + product_code=plan.product_code, ) 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) diff --git a/apps/licensing/tests.py b/apps/licensing/tests.py index 0abc71d..b23484f 100644 --- a/apps/licensing/tests.py +++ b/apps/licensing/tests.py @@ -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): diff --git a/docs/06-tasks.md b/docs/06-tasks.md index 9a29af5..c835c0b 100644 --- a/docs/06-tasks.md +++ b/docs/06-tasks.md @@ -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 | ## 里程碑 diff --git a/docs/current-state.md b/docs/current-state.md index c3f700d..64ad09b 100644 --- a/docs/current-state.md +++ b/docs/current-state.md @@ -11,7 +11,7 @@ ## 当前快照 -- 日期:2026-07-21 +- 日期:2026-07-23 - 阶段:Phase 6 增强(MVP 后);Phase 3 对外 API 与充值已完成到 T-306,Phase 4 用户端 T-501 注册 / 登录(allauth)、T-502 API Key 自助管理页、T-503 个人中心 / 记录页、T-504 充值页与 T-505 用户端审核优化已完成,Phase 5 T-401 运营后台完善、T-402 MVP 完整验收与 T-403 部署 / 运行文档已完成,Phase 6 T-601 可用别名发现、T-602 django-admin 中文化第 1-3 层、T-603 django-admin 字段级中文化、T-604 中文敏感词本地过滤、T-605 免邮箱验证策略落地、T-606 公开首页 + 客户端下载入口、T-607 桌面端最新版本检查接口、T-608 新用户注册赠送试用点数(当前 10 点)、T-609 桌面端版本检查接口增加强制更新标记、T-610 首页导入模板下载入口、T-611 用户端品牌名统一为虾皮圈、T-612 生图同步接口止血、T-613 抽生成核心 service、T-614 生图异步任务化接口、T-615 旧同步生图接口遥测 / 弃用口径、T-616 生图失败自动重试 2 次、T-617 桌面端版本文件大小字段与 T-618 客户端发布版本后台必填校验元数据已完成;后续仍需处理真实支付回调到账闭环、客户端下载包发布和生产侧旧同步接口用量观察 - 技术栈:系统 Python 3.12.3 + Django 5.2.15 + DRF 3.16.1 + django-allauth 65.18.0 + PyMySQL 1.1.3 + cryptography 49.0.0 + requests 2.34.2 + ahocorapy 1.6.2 + wechatpayv3 2.0.2 + python-alipay-sdk 3.4.0 + django-admin;MySQL 8.4 已接入 settings,并支持 `MYSQL_CONNECT_TIMEOUT` / `MYSQL_READ_TIMEOUT` / `MYSQL_WRITE_TIMEOUT`;用户端已用 Django 模板 SSR + Bootstrap + allauth 落地注册登录;生产部署口径为 VPS / 宝塔 + Nginx + Gunicorn(gthread) + systemd;详见 `03-tech-stack.md` 与 `deployment.md` - 生产代码:已有最小 Django 工程骨架:`manage.py`、`config/`;T-002 已创建 `apps/users|portal|billing|ai|api`;T-003 已把自定义 `User` 注册进 django-admin;T-004 已完成 email 唯一性、init 版本断言、app 顺序、`.env.example` 与 `pyproject.toml`;T-101 已新增 `apps/ai/providers/`(Provider 接口、注册表、chat/gemini/images/images_edits 适配器);T-102 已新增 `AiModel` / `ModelAlias`、Fernet 加密密钥存储、别名解析、admin 配置页、`import_ai_models` 导入命令;T-103 已新增 `AiConfigAuditLog` 审计表、admin 只读页面和后台保存/删除审计 hook;T-104/T-105 已完成录制 title/image smoke 与审核修补;T-201 已新增 `UserWallet` / `ApiKey`、`PointsLedger` / `CallRecord`、对应 admin 与迁移;T-202 已新增 `PricingRule` / `ExchangeRate`、`apps.billing.pricing` 计费计算函数、admin 配置页与迁移;T-203 已新增 `apps.billing.services`,实现并发安全预扣、成功确认与幂等失败退点;T-204 已新增 `billing.0003_pointsledger_unique_ledger_change_type_per_call`,用 MySQL 可落地的 `ref_call + change_type` 复合唯一约束兜底防重复 refund;T-301 已新增 `apps.api.authentication.ApiKeyAuthentication` 与 `ExternalApiView`;T-302 已新增生成接口编排、序列化器、图片本地存储和 `/api/v1/generate/title|image` 路由;T-303 已新增 `apps.billing.services.get_balance_snapshot()` 与 `/api/v1/balance` 余额查询接口;T-304 已新增 `RechargeOrder`、充值回调验签适配器、幂等入账服务、微信/支付宝回调路由与迁移 `billing.0004_rechargeorder_and_more`;T-305 已新增 `create_recharge_order()`、微信/支付宝扫码下单 mock/SDK 入口、`/api/v1/recharge/create` 与 `/api/v1/recharge/status`;T-306 已新增 `apps.api.throttles`、`apps.api.exceptions`、`REST_FRAMEWORK` 安全默认认证、生成/认证失败限流、`image_url` SSRF 防护与响应大小上限、充值单笔金额上限;T-501/T-608 已接入 allauth 注册登录路径,注册成功后 adapter 调用 `grant_signup_bonus()` 经 billing 一次性发放 10 点并写 `signup_bonus` 流水,新增 `SignupBonusGrant(user UNIQUE)` 幂等标记、admin 只读检索和 `billing.0007` 迁移;T-502 已新增 `/apikeys`、API Key 创建表单、列表页和删除(吊销)动作,生成后明文只显示一次,列表只显示 prefix;T-503/T-608 已扩展 `/dashboard` 为个人中心汇总,并新增 `/records/recharge` 充值记录与 `/records/usage` 点数记录,只读展示当前用户数据和注册赠点 / 消费 / 退款流水;T-504 已新增 `/recharge` 页面、`RechargeCreateForm`、充值导航入口和轮询脚本,页面创建 pending 订单、展示二维码票据、轮询 `/api/v1/recharge/status`,订单 paid 后刷新余额;T-505 已把 Bootstrap 5 CSS 与 qrcode.js vendoring 到 `apps/portal/static/portal/vendor/`,页面不再依赖 jsdelivr,并把充值记录 / 点数记录改为 Django `Paginator` 分页;T-401 已新增 `adjust_wallet_points()` 手工调点服务、钱包 admin 专用调点表单与模板,后台可管理/检索用户、钱包、API Key(脱敏)、计费规则、汇率、充值订单、点数流水、注册赠点记录和调用记录,流水/订单/调用记录保持只读;T-402 已新增 `docs/mvp-acceptance.md`,按 P0 验收矩阵记录 MVP 完整验收结论、测试证据和已知限制;T-403 已新增 `docs/deployment.md` 与 `requirements-production.txt`,并在 settings 中补齐 `STATIC_ROOT`、`CSRF_TRUSTED_ORIGINS`、共享 `CACHES`、HTTPS cookie、proxy SSL、HSTS 环境变量与 `ACCOUNT_SIGNUP_RATE_LIMIT` 注册限流配置;T-601 已新增 `apps.ai.catalog.get_public_model_catalog()`、`GET /api/v1/models` 与 portal `/models` 只读页面,只展示 active 可调用别名、能力、是否需要原图和点数单价,不解密 provider key,不暴露底层 SKU / URL / key / `extra_body`;T-604 已新增 `apps.moderation`、`SensitiveWord` 模型/admin/迁移、keyword provider、归一化管线和共享 cache 版本失效,生成接口已改为 prompt 先审再读取图片/计费/扣点/调上游;T-606 已新增公开首页 `/`、`DownloadRelease` 模型/admin/迁移、首页 SSR 模板、共享 `portal/brand.css`,并把现有 portal 页面套入同一套品牌 token;T-607/T-609 已新增 `ClientLatestReleaseView` 与 `/api/v1/client/releases/latest`,公开匿名返回当前客户端版本 JSON,`release.force_update` 表示该版本是否强制升级;`portal.0002_downloadrelease_force_update` 已给 `DownloadRelease` 增加 `force_update` 字段,admin 可编辑和筛选;T-610 已新增 `ImportTemplate` 模型/admin/迁移 `portal.0003_importtemplate`,首页读取当前模板并在“下载客户端”旁展示“下载导入模板”,本地文件 URL 转为当前站点绝对 URL,`external_url` 优先;T-611 已把用户端 portal 可见品牌名统一为“虾皮圈”,包括页面标题、顶部导航、首页 H1、用户端“虾皮圈 API Key”文案和 allauth 邮件模板;T-612 已新增 `AI_IMAGE_UPSTREAM_DEADLINE_SECONDS`,生图 Provider 上游请求和上游返回图片 URL 下载会按 `min(AiModel.timeout_seconds 或分辨率默认值, 硬截止)` 控制读取超时,超时返回 `upstream_timeout` 并走既有失败退点路径;T-613 已把旧同步生成链路抽成 `GenerationInput`、`prepare_generation()`、`precharge_generation()`、`execute_precharged_generation()` 与 `GenerationResult`,旧 view 只负责 serializer 和异常转 HTTP,后续异步 worker 可复用已预扣执行 / 确认 / 退点阶段。 @@ -75,11 +75,11 @@ 任务状态以 [`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-633 存量用户批量授予过渡测试套餐。 -- 正在进行:无;T-634 已完成任务登记,尚未开始代码实现。 +- 已完成补充: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-634 过期会员允许购买其他套餐。 +- 正在进行:无。 - T-621 注册赠点运营后台配置继续留在 Backlog。真实支付回调到账闭环、客户端发布、生产多图理解模型配置和线上旧同步接口用量观察仍可继续拆任务。 - 当前 blocker:支付商户真实密钥/证书与生产 SDK 依赖仍待提供;微信回调到账闭环仍需真实支付验收;真实 AI 标题生成已在线上跑通,图片生成慢 / 504 / 客户端超时风险已拆为 T-612~T-616 并完成工程侧处理。 -- 下一个可领取任务:T-634 过期会员允许购买其他套餐。T-621 保留在 Backlog,真实微信商户支付验收仍是线上 blocker;软件订阅订单已完成代码与 mock/SDK 契约验证,但未进行真实付款验收。具体范围见 [`06-tasks.md`](06-tasks.md) 与 [`tasks/T-634.md`](tasks/T-634.md)。 +- 下一个可领取任务:按业务优先级从 Backlog 拆分后续任务。T-621 保留在 Backlog,真实微信商户支付验收仍是线上 blocker;T-634 本地代码已完成但尚未部署生产。具体范围见 [`06-tasks.md`](06-tasks.md)。 ## 当前可运行内容 @@ -151,14 +151,16 @@ T-619 已落地同步多图理解:调用方提交有序 `images` 列表,服 T-632 已落地会员订阅三阶段模式:`CMSHOPEE_SUBSCRIPTION_MODE=open` 为当前开发测试默认值,无需给所有用户逐个绑定长期权益;`shadow` 继续放行并记录真实权益覆盖,`enforce` 才按真实权益拦截产品专属 submit。订阅状态接口已补齐桌面端账号、套餐、顶层有效期、会员中心与通知字段;admin 软件授权首页只显示“会员套餐”和“用户会员”,隐藏模型及历史数据未删除。 -T-633 已新增默认只预演的 `grant_existing_users_plan`:按套餐 ID、非空原因和预期授予人数安全批量授予启用的非后台账号,跳过同产品已有有效/宽限期权益,并在单事务内复用授权服务生成快照、席位和事件。线上当前保持 `shadow`,已给 30 个存量普通用户授予“测试”套餐;后台账号未授予,重复预演待授予为 0。 +T-633 已新增默认只预演的 `grant_existing_users_plan`:按套餐 ID、非空原因和预期授予人数安全批量授予启用的非后台账号,跳过同产品已有有效/宽限期权益,并在单事务内复用授权服务生成快照、席位和事件。批量授予执行时线上保持 `shadow`,已给 30 个存量普通用户授予“测试”套餐;后台账号未授予,重复预演待授予为 0。生产订阅模式后续已切换为 `enforce`。 + +T-634 已统一软件订单与订阅授权的当前权益口径:只有 `active` 且未超过宽限截止时间的权益会阻止跨套餐下单或参与续订。支付校验通过后,服务在同一事务内把自然到期但仍为 `active` 的同产品历史权益收敛为 `expired`;已过期同套餐或不同套餐都会创建新权益。该实现不改接口、迁移、支付验签或点数账本,当前尚未部署生产。 ## 开始编码前检查 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` 的任务;当前可领取 T-634。 +4. 在 `docs/06-tasks.md` 领取第一个 `TODO` 且依赖均 `DONE` 的任务;当前没有可直接领取的任务。 5. 代码默认开发测试模式为 `open`;线上当前为 `shadow` 且存量普通用户已有“测试”套餐。切到 `enforce` 前仍须完成桌面端回归,并用真实微信商户完成充值和订阅两条回调闭环验收。 ## 维护规则 diff --git a/docs/tasks/T-634.md b/docs/tasks/T-634.md index ad26753..993b3e6 100644 --- a/docs/tasks/T-634.md +++ b/docs/tasks/T-634.md @@ -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 转换提示。 diff --git a/progress.md b/progress.md index b45e994..51e6676 100644 --- a/progress.md +++ b/progress.md @@ -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;本任务不包含迁移。 +- 发布状态:代码与文档已完成,尚未部署到线上服务。