# 会员插件 `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 |
