Files
larablog/docs/specs/membership/SPEC.md
T
gouki 3cec4c5e18
CI / PHPUnit (PHP 8.3) (push) Failing after 4s
CI / PHPUnit (PHP 8.2) (push) Failing after 1m9s
CI / Deploy (manual gate) (push) Skipped
wip: article AI polish, category SEO fields, cover generator, membership plan seeder
2026-09-07 18:48:37 +00:00

148 lines
7.6 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 会员插件 `larablog/membership`
## 状态
- 状态:已定稿
- 创建:2026-08-12 03:25
- 最近更新:2026-08-12 03:45
## 背景
- 支付基建 `larablog/payment` 与内容付费 `larablog/paid-content` 已闭环(Stub 下单 → 权益 → `article.access`)。
- `ProductType::MEMBERSHIP` 已预留;membership 插件仍是侧栏 + `/plugins/membership/status` 骨架。
- Spatie 角色 `member` = 注册用户,**不能**当作付费会员。
## 目标
1. 可配置会员套餐(价格 / 时长)。
2. 登录用户经 Stub 支付订阅,写入 `entitlements``product_type=membership`)。
3. 文章可标「会员可见」;未开通则试读 + 订阅 CTA(复用 paywall 体验)。
4. 资料页 / status API 展示当前会员状态。
## 范围
### In scopev1
1. 插件表 `membership_plans`slug、name、description、price、currency、duration_daysnullable=终身)、enabled、sort_order。
2. 插件内幂等 Seeder:月度 / 终身各一档(`updateOrCreate` by slug);不写死进核心 `DatabaseSeeder`
3. `requires: ["larablog/payment"]`;依赖未满足不可启用。
4. Filament:套餐 CRUD(插件内 Resource);核心 `MembershipPluginPage::shouldRegisterNavigation` **恒 false**(v1 起骨架页永久隐藏,导航只由插件 Resource 提供;区别于「enabled 才显示」的旧骨架行为)。
5. 支付结账(见「结账安全」):membership **仅**服务端按 plan 取价;query 兜底路径**拒绝** `membership`
6. 权益与过期(见「过期语义」):`expires_at` 加在 payment 的 `entitlements`;活跃判定三处统一。
7. 文章闸:`article_membership`;表单注入;`article.access``need_purchase`(未登录也给订阅 CTA)。
8. 三向互斥:会员闸 / 密码 / 单篇付费(见「互斥机制」)。
9. 前台:`/plugins/membership` 套餐列表;资料页会员状态;status JSON:`active``plan_id``plan_slug``plan_name``expires_at`
10. `OrderService::hasActiveAny($userId, $productType)`:任意有效 membership 权益。
11. 文档 + PHPUnit(含 PaidContentCommerceTest 回归)。
### Out of scope
- 真实微信/支付宝续费扣款、自动续费、退款流程。
- 多套餐叠加的复杂权益栈(v1:指定 plan 闸只认该 plan;「任意会员」闸认任一有效 membership)。
- 用 Spatie role 表示付费会员。
- 主题市场 / 会员专属主题。
- 优惠券、试用期、邀请码、外部文生图。
## 方案要点
### 分层
```text
Core ArticleAccess + Hook(已有)
larablog/payment(订单 / Stub / entitlements[+expires_at]
larablog/membershipplans + 文章会员闸 + 订阅页)
```
### 过期语义(固化 · 原 B1)
payment 核心修改(影响面声明:article 权益 `expires_at` 恒 null,语义不变;须跑 PaidContentCommerceTest 回归):
1. 迁移:`entitlements.expires_at` nullable timestamp。
2. **活跃**定义统一为:`revoked_at IS NULL AND (expires_at IS NULL OR expires_at > now())`,用于:
- `OrderService::hasEntitlement`
- `OrderService::markPaid``ownedElsewhere` 守卫
- `Entitlement::scopeActive` / `isActive()`
3. 过期后可重新 `createOrder` + `markPaid`(续订);`updateOrCreate` 同键刷新 `granted_at` / `expires_at` / `source_order_id`
4. membership 监听 `order.paid`:对 membership item,读 plan
- `duration_days` 有值 → `expires_at = now()->addDays(duration_days)`
- 终身 → `expires_at = null`
- plan 已删:**fail-closed**——不授予/撤销本次写入并打日志(避免月度变终身);Stub 支付页提示失败。(实现可用:markPaid 前后校验 plan 存在;或 paid 后若 plan 缺失则 `revoked_at=now()` 并通知。)
### 结账安全(固化 · 原 B3)
`resolveCheckoutProduct`
1. `article`:保持现逻辑(visible+published + enabled product)。
2. `membership``class_exists` plan model + 表存在 + plan `enabled` → 返回 `[name, price, currency]`;否则 `invalid_checkout`
3. **其它/未知类型**:直接 `invalid_checkout`**删除**信任 query `title/amount` 的兜底,堵住伪造 membership)。theme 售卖以后再加专用分支。
### 互斥机制(固化 · 原 B2)
新增核心约定扩展点(一次 filter,多方可见):
| 点 | 类型 | 用途 |
|---|---|---|
| `filament.article.validate_access_restrictions` | filter | `(array $data, ?Article $record): array`;在 strip 插件私有键**之前**调用;抛 `ValidationException` |
调用顺序(Create/Edit):
1. `mutate_before_save`(可填充/规范插件字段,**不得**在此 unset 限制字段)
2. `validate_access_restrictions`paid-content + membership 均在此检查互斥)
3. 各插件在 `after_save` 落库;`mutate_before_save` 末尾或独立 strip 阶段再去掉 `paid_content` / `membership` 私有键(或 after_save 只读 form state
互斥规则:下列至多一个为真——
- `filled(read_password)`
- `paid_content.enabled`
- `membership.enabled`
paid-content 现有「在 mutate_before_save unset」改为:校验点之后再 strip(改动 paid-content provider)。
### Plan 删除(固化 · 原 B4
- `article_membership.required_plan_id`**`restrictOnDelete`**(有文章仍引用则不可删 plan)。
- 后台删除 plan:若仍有未过期 entitlement,拒绝删除并提示(或仅允许 `enabled=false`);v1 实现:**有任何 entitlement 行则禁止硬删,引导禁用**。
### 数据模型
#### `membership_plans`
| 字段 | 说明 |
|---|---|
| id | PK |
| slug | unique |
| name | 展示名 |
| description | nullable |
| price | decimal(10,2) |
| currency | default CNY |
| duration_days | unsignedInt nullablenull=终身 |
| enabled | bool |
| sort_order | int default 0 |
| timestamps | |
#### `article_membership`
| 字段 | 说明 |
|---|---|
| article_id | unique FK → articles cascadeOnDelete |
| enabled | bool |
| required_plan_id | nullable FK → membership_plans **restrictOnDelete**null=任意有效会员 |
| timestamps | |
### 文章闸 CTA
- checkout 指向:`required_plan_id` 对应 enabled plan;若 plan 禁用/缺失 → 降级为「最低价 enabled 套餐」;若无任何套餐 → 无购买按钮,仅提示联系管理员。
- 试读:复用 `HtmlTeaser` / description(与 paid-content 同量级默认 chars)。
### 插件生命周期
- 启用前须 payment 已启用;迁移:`php artisan migrate`AppServiceProvider 已 load 全部插件 migrations)。
- 禁用后:钩子不注册 → 会员文变公开;DB 行保留。
## 验收标准
- [ ] 未启用 membership:行为与现网一致;骨架导航不出现。
- [ ] 无 payment 时启用 membership → 拒绝。
- [ ] Stub 订阅后 status/资料页显示有效会员;有期限套餐可过期后续订。
- [ ] 会员文:未购试读+订阅;订阅后全文;指定 plan 闸不接受其它 plan。
- [ ] 伪造 `product_type=membership&amount=0.01` → 422。
- [ ] 三向互斥保存失败。
- [ ] 被文章引用或仍有权益的 plan 不可硬删。
- [ ] PaidContentCommerceTest + 新 Membership 测试全绿。
## 已拍板(原开放问题)
- [x]`expires_at`(支持月度)
- [x] 未购统一 `need_purchase`(与 paid-content 一致)
- [x] 禁用插件后会员文变公开
## 变更记录
| 时间 | 原因 | 变更 |
|------|------|------|
| 2026-08-12 03:25 | 启动会员功能 | 初稿 |
| 2026-08-12 03:30 | qodercli SPEC review B1B4 | 固化过期三处一致、互斥校验点、结账拒伪造、plan restrictOnDelete;补 hasActiveAny / 骨架导航 / fail-closed |
| 2026-08-12 03:45 | 用户确认「定」 | 状态改为已定稿;配套 CHECKLIST / TESTPLAN |