7.6 KiB
7.6 KiB
会员插件 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= 注册用户,不能当作付费会员。
目标
- 可配置会员套餐(价格 / 时长)。
- 登录用户经 Stub 支付订阅,写入
entitlements(product_type=membership)。 - 文章可标「会员可见」;未开通则试读 + 订阅 CTA(复用 paywall 体验)。
- 资料页 / status API 展示当前会员状态。
范围
In scope(v1)
- 插件表
membership_plans:slug、name、description、price、currency、duration_days(nullable=终身)、enabled、sort_order。 - 插件内幂等 Seeder:月度 / 终身各一档(
updateOrCreateby slug);不写死进核心DatabaseSeeder。 requires: ["larablog/payment"];依赖未满足不可启用。- Filament:套餐 CRUD(插件内 Resource);核心
MembershipPluginPage::shouldRegisterNavigation恒 false(v1 起骨架页永久隐藏,导航只由插件 Resource 提供;区别于「enabled 才显示」的旧骨架行为)。 - 支付结账(见「结账安全」):membership 仅服务端按 plan 取价;query 兜底路径拒绝
membership。 - 权益与过期(见「过期语义」):
expires_at加在 payment 的entitlements;活跃判定三处统一。 - 文章闸:
article_membership;表单注入;article.access→need_purchase(未登录也给订阅 CTA)。 - 三向互斥:会员闸 / 密码 / 单篇付费(见「互斥机制」)。
- 前台:
/plugins/membership套餐列表;资料页会员状态;status JSON:active、plan_id、plan_slug、plan_name、expires_at。 OrderService::hasActiveAny($userId, $productType):任意有效 membership 权益。- 文档 + PHPUnit(含 PaidContentCommerceTest 回归)。
Out of scope
- 真实微信/支付宝续费扣款、自动续费、退款流程。
- 多套餐叠加的复杂权益栈(v1:指定 plan 闸只认该 plan;「任意会员」闸认任一有效 membership)。
- 用 Spatie role 表示付费会员。
- 主题市场 / 会员专属主题。
- 优惠券、试用期、邀请码、外部文生图。
方案要点
分层
Core ArticleAccess + Hook(已有)
↑
larablog/payment(订单 / Stub / entitlements[+expires_at])
↑
larablog/membership(plans + 文章会员闸 + 订阅页)
过期语义(固化 · 原 B1)
payment 核心修改(影响面声明:article 权益 expires_at 恒 null,语义不变;须跑 PaidContentCommerceTest 回归):
- 迁移:
entitlements.expires_atnullable timestamp。 - 活跃定义统一为:
revoked_at IS NULL AND (expires_at IS NULL OR expires_at > now()),用于:OrderService::hasEntitlementOrderService::markPaid的ownedElsewhere守卫Entitlement::scopeActive/isActive()
- 过期后可重新
createOrder+markPaid(续订);updateOrCreate同键刷新granted_at/expires_at/source_order_id。 - 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:
article:保持现逻辑(visible+published + enabled product)。membership:class_existsplan model + 表存在 + planenabled→ 返回[name, price, currency];否则invalid_checkout。- 其它/未知类型:直接
invalid_checkout(删除信任 querytitle/amount的兜底,堵住伪造 membership)。theme 售卖以后再加专用分支。
互斥机制(固化 · 原 B2)
新增核心约定扩展点(一次 filter,多方可见):
| 点 | 类型 | 用途 |
|---|---|---|
filament.article.validate_access_restrictions |
filter | (array $data, ?Article $record): array;在 strip 插件私有键之前调用;抛 ValidationException |
调用顺序(Create/Edit):
mutate_before_save(可填充/规范插件字段,不得在此 unset 限制字段)validate_access_restrictions(paid-content + membership 均在此检查互斥)- 各插件在
after_save落库;mutate_before_save末尾或独立 strip 阶段再去掉paid_content/membership私有键(或 after_save 只读 form state)
互斥规则:下列至多一个为真——
filled(read_password)paid_content.enabledmembership.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 测试全绿。
已拍板(原开放问题)
- 做
expires_at(支持月度) - 未购统一
need_purchase(与 paid-content 一致) - 禁用插件后会员文变公开
变更记录
| 时间 | 原因 | 变更 |
|---|---|---|
| 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 |