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

7.6 KiB
Raw Blame History

会员插件 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 支付订阅,写入 entitlementsproduct_type=membership)。
  3. 文章可标「会员可见」;未开通则试读 + 订阅 CTA(复用 paywall 体验)。
  4. 资料页 / status API 展示当前会员状态。

范围

In scopev1

  1. 插件表 membership_plansslug、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.accessneed_purchase(未登录也给订阅 CTA)。
  8. 三向互斥:会员闸 / 密码 / 单篇付费(见「互斥机制」)。
  9. 前台:/plugins/membership 套餐列表;资料页会员状态;status JSON:activeplan_idplan_slugplan_nameexpires_at
  10. OrderService::hasActiveAny($userId, $productType):任意有效 membership 权益。
  11. 文档 + PHPUnit(含 PaidContentCommerceTest 回归)。

Out of scope

  • 真实微信/支付宝续费扣款、自动续费、退款流程。
  • 多套餐叠加的复杂权益栈(v1:指定 plan 闸只认该 plan;「任意会员」闸认任一有效 membership)。
  • 用 Spatie role 表示付费会员。
  • 主题市场 / 会员专属主题。
  • 优惠券、试用期、邀请码、外部文生图。

方案要点

分层

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::markPaidownedElsewhere 守卫
    • 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. membershipclass_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_restrictionspaid-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_idrestrictOnDelete(有文章仍引用则不可删 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 restrictOnDeletenull=任意有效会员
timestamps

文章闸 CTA

  • checkout 指向:required_plan_id 对应 enabled plan;若 plan 禁用/缺失 → 降级为「最低价 enabled 套餐」;若无任何套餐 → 无购买按钮,仅提示联系管理员。
  • 试读:复用 HtmlTeaser / description(与 paid-content 同量级默认 chars)。

插件生命周期

  • 启用前须 payment 已启用;迁移:php artisan migrateAppServiceProvider 已 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 B1B4 固化过期三处一致、互斥校验点、结账拒伪造、plan restrictOnDelete;补 hasActiveAny / 骨架导航 / fail-closed
2026-08-12 03:45 用户确认「定」 状态改为已定稿;配套 CHECKLIST / TESTPLAN