# 会员插件 `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 scope(v1) 1. 插件表 `membership_plans`:slug、name、description、price、currency、duration_days(nullable=终身)、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/membership(plans + 文章会员闸 + 订阅页) ``` ### 过期语义(固化 · 原 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 nullable;null=终身 | | 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 B1–B4 | 固化过期三处一致、互斥校验点、结账拒伪造、plan restrictOnDelete;补 hasActiveAny / 骨架导航 / fail-closed | | 2026-08-12 03:45 | 用户确认「定」 | 状态改为已定稿;配套 CHECKLIST / TESTPLAN |