# 插件扩展面 + 支付 / 内容付费

## 状态
- 状态：已定稿
- 创建：2026-08-12 00:08
- 最近更新：2026-08-12 01:12

## 背景
一期已具备：主题槽（`@themeslot` / `theme.*`）、轻量 `Hook`、插件启停与骨架（`larablog/payment`、`membership`、双商城）。  
缺口：后台表单/表格无插件注入；无插件依赖声明；无订单/权益模型；文章仅有 `visible` + `read_password`，无法做付费阅读。  
全文出口今天不止 Web：`/api/v1/articles/{id}` 与 `/rss.xml` 会输出全文（密码文亦然），付费能力必须一并收口。

## 目标
1. **插件扩展平台**：前台 + 后台均支持约定式注入（UI / 功能 / 数据关联）。
2. **支付基建** `larablog/payment`：订单、Stub 网关、回调日志、权益记账；后台订单可查。
3. **内容付费** `larablog/paid-content`：依赖 payment；文章价格/试读（表单注入）；Web 访问闸 + 全出口防泄露。
4. **插件元数据**：`requires`、README 使用说明；启用时校验依赖。

## 范围

### In scope
1. 增强 `Hook`：在现有 `listen` / `dispatch` / `gather` 之上增加：
   - `collect(string $event, array $initial = [], mixed ...$payload): array`（数组合并，供 Filament 组件）
   - `filter(string $event, mixed $value, mixed ...$payload): mixed`（值管道）
2. **首批约定扩展点**：
   | 点 | 类型 | 用途 |
   |---|---|---|
   | `theme.*` | gather | 已有前台槽 |
   | `article.access` | filter | 收紧访问决策（只许变严） |
   | `filament.article.form` | collect | 文章表单追加 Schema 组件 |
   | `filament.article.table.columns` | collect | 文章表追加列 |
   | `filament.article.actions` | collect | 文章表/页动作 |
   | `filament.article.mutate_before_save` | filter | `(array $data, ?Article $record)` |
   | `filament.article.after_save` | dispatch | `(Article $record, array $data)` |
   | `order.paid` / `order.refunded` | dispatch | payment 发出 |
3. `plugin.json`：`requires`、`optional`、`docs`（相对 README，默认 `README.md`）。本期**不**引入无消费方的 `provides`。
4. `PluginManager::enable`：硬依赖未启用则拒绝；后台卡片展示依赖与「查看说明」。
5. **插件 Filament 注册机制（已定）**：订单 Resource / 插件设置页放在**插件命名空间**内；通过 `Filament\Panel::configureUsing`（或 `Filament::serving`）在 panel 配置阶段按「插件已启用」注册 `pages`/`resources`。不再把商务 UI 永久写死在核心 `app/Filament`（现有 skeleton 页可改为薄代理或迁入插件后删除）。
6. **payment（最小可跑闭环）**：
   - 迁移：插件目录 `database/migrations`，Provider `loadMigrationsFrom`（`plugins:sync --enable` / 启用时确保可 migrate；文档说明需 `php artisan migrate`）。
   - 表：`orders`、`order_items`、`entitlements`、`payment_transactions`。
   - StubGateway：登录用户建单 → 确认页「模拟支付成功」→ paid → 写 entitlement → `order.paid`。
   - Filament：订单列表/详情；详情「标记已支付」运维按钮；插件设置说明页。
7. **paid-content**：
   - 表：`article_products`；`composer.json` PSR-4 + sync。
   - 注入文章表单付费 Section；`after_save` 落库。
   - 实现 `article.access` 收紧；试读在 **HTML 渲染之后**按纯文本长度截断（避免截断 Markdown 语法）。
8. **ArticleAccess（核心，纯决策）** + 全部全文出口收口：Web show、API show、RSS。
9. 文档：`docs/plugins.md`、两插件 README；OpenAPI / `ApiV1Test` 同步 API 破坏性变更。
10. PHPUnit：依赖校验、access 矩阵、Stub 入账、RSS/API 不泄露全文。

### Out of scope
- 真实微信/支付宝/Stripe 商户对接（Stub + Gateway 接口预留）。
- API / 小程序登录鉴权与「已购用户经 API 读全文」（见下方 API 策略）。
- `membership` 套餐续费、主题市场真实售卖（仅预留 `product_type` 枚举值）。
- 优惠券、购物车、游客购买、多币种汇率。
- 任意后台 DOM 注入；远程安装插件。
- 价格字段写入 `articles` 核心表。

## 方案要点

### 分层
```text
Core: ArticleAccess（决策）+ Hook 增强 + Filament 扩展点挂载 + 全文出口收口
  ↑
larablog/payment（订单 / Stub 网关 / entitlements / order.*）
  ↑
larablog/paid-content（article_products + 表单注入 + access 收紧 + 试读）
```

### ArticleAccess 契约（已定）
- **纯决策**，不含 HTTP 副作用。密码 POST 解锁仍由 `BlogController`（或细小 Action）写入 session 后**再次**调用 `resolve()`。
- Decision：`status` ∈ `allow | need_password | need_purchase | need_login`；可选 `teaser_html`、`checkout_url`、`message`。
- 解析顺序：
  1. 若 Web 上下文且用户为文章作者，或具备 spatie 角色 `admin` → `allow`（**仅 Web/session 用户**；API 本期无用户）。
  2. 若 `read_password` 非空且 session 未解锁 → `need_password`（**到此结束，插件不参与**）。
  3. 否则 `status=allow`，再跑 `Hook::filter('article.access', $decision, $ctx)`。
- **`need_login` vs `need_purchase`（已定）**：本期 `paid-content` 对未购读者统一返回 `need_purchase`（未登录同样给购买 CTA，点购买时再要求登录）。`need_login` 预留给后续 membership 等插件，本期核心/paid-content 不主动产生。
- **filter 合并规则（只许变严）**：严重度 `allow < need_login < need_purchase < need_password`；监听器返回的 status 仅当不低于当前严重度时才接受；`allow` 不能覆盖已收紧结果。元数据字段（teaser/checkout_url）可由插件填充。
- **密码与付费互斥（已定）**：后台保存时校验——`read_password` 与 `article_products.enabled` 不可同时有效；冲突则校验失败提示。私密分享用密码，售卖用付费。

### API 策略（已定 · 对应原 B1）
- 本期 API **保持匿名**（不加 Sanctum）。
- API / RSS 调用 ArticleAccess 时 **user=null**：
  - 密码文 → 不返回全文（可返回标题/摘要/access 状态；与「今日 API 泄露密码文」相比为**明确破坏性收紧**）。
  - 付费文未购 → 试读 + `access`，无全文。
  - **不**在 API 识别管理员/已购（已购全文仅 Web 登录会话）。
- OpenAPI 与测试同步；兼容说明写入 CHANGELOG/文档：「受限文不再经匿名 API/RSS 输出全文」。

### RSS（已定 · 对应原 B5）
- 密码文、付费文：RSS `description`/`content` 仅输出试读或站点摘要字段，**禁止** `renderedHtml()` 全文。
- 免费可见文保持现行为。

### 后台 UI 注入与保存（已定）
- `ArticleForm`：`components` 合并 `Hook::collect('filament.article.form')`。
- `CreateArticle` / `EditArticle`：`mutateFormDataBeforeSave` → filter `filament.article.mutate_before_save`；`afterSave` → dispatch `filament.article.after_save`。
- 插件 state 前缀：`paid_content.*`；不得进入 Article `$fillable`；由 after_save 写 `article_products`。

### 插件页面注册（已定 · 对应原 B2）
```php
// 插件 ServiceProvider::boot
Filament\Panel::configureUsing(function (Panel $panel): void {
    if (! app(PluginManager::class)->isEnabled('larablog/payment')) {
        return;
    }
    $panel->resources([OrderResource::class])->pages([PaymentSettingsPage::class]);
});
```
（若 `configureUsing` 时机与 discover 冲突，实现时以「启用才注册、禁用不可见」为准，允许微调为 `Filament::serving`；SPEC 验收看行为不绑死 API 名。）

### 数据模型

#### payment
| 表 | 关键字段 |
|---|---|
| `orders` | id, user_id(not null 本期), status(`pending`\|`paid`\|`cancelled`\|`refunded`), amount `decimal(10,2)`（= items 之和）, currency, gateway, paid_at, meta json |
| `order_items` | order_id, product_type, product_id, title, amount `decimal(10,2)` |
| `entitlements` | user_id, product_type, product_id, source_order_id, granted_at, revoked_at nullable；**唯一索引** `unique(user_id, product_type, product_id)`（一行表示当前权益；撤销写 `revoked_at`，再次授予清除 `revoked_at` 并更新 source_order_id——**可移植，不用 partial unique**） |
| `payment_transactions` | order_id, gateway, external_id nullable, payload json, status, timestamps |

`product_type` 枚举预留：`article` \| `theme` \| `membership`（后两者本期不实现业务）。

#### paid-content
| 表 | 关键字段 |
|---|---|
| `article_products` | article_id unique, FK cascade delete → articles, enabled bool, price decimal(10,2), currency default `CNY`, trial_mode `chars`\|`none`, trial_value int default 200, timestamps |

### Stub 支付 UX（已定）
- 独立简页（不依赖主题卡片）：展示订单金额 →「模拟支付成功」。
- Filament 订单详情额外提供「标记已支付」。
- 必须登录下单；已有未撤销 entitlement → 拒绝重复建单并提示。

### 试读（已定）
- 默认 `trial_mode=chars`，`trial_value=200`。
- 流程：先 `ContentRenderer` 出 HTML → 再按可见文本截断并净化 → 作 teaser。
- **teaser 恒为纯文本摘录，且必须保留部分正文**：当 `trial_value` ≥ 正文可见字数时，仍只输出一半（防止短文/大试读值导致全文泄露）。
- 主题：default 提供试读+CTA 模板即可（example fallback 到 default）。

### 安全
- 全文出口：Web show、API show、RSS、**列表摘要**均经 ArticleAccess（或等价 helper）。
- 金额以后端 `article_products.price` 为准。
- 订单状态机 + entitlement 唯一防重复开通；建单与 `markPaid` 均在事务内复核权益，pending 单按 user+商品复用而非叠加，且复用时按当前价格重新定价。
- 只有 `visible` + `published` 的文章可被下单（草稿/隐藏文不可购买）。
- 插件 manifest `docs` 只允许指向插件目录内的文件（拒绝 `..`、绝对路径、软链越界）。

## 验收标准
- [ ] 未启用 payment/paid-content 时：Web 免费文/密码文行为与现网一致；匿名 API/RSS **不再**输出密码文全文（文档标明的破坏性收紧）。
- [ ] 仅启用 payment：Stub 可完成一笔测试单并产生 entitlement；后台订单可见；「标记已支付」可用；对已有有效 entitlement 的商品重复下单被拒绝并提示。
- [ ] 启用 paid-content 前未启用 payment → 拒绝启用并提示。
- [ ] 启用后：文章表单有付费 Section；保存写入 `article_products`；与 `read_password` 互斥校验生效。
- [ ] 禁用 paid-content / payment 后：其 Filament Resource/设置页不可见；文章表单不再出现付费 Section。
- [ ] Web：未购付费文见试读+购买（含未登录，status=`need_purchase`）；登录 Stub 支付后见全文；作者与 `admin` 角色见全文。
- [ ] 匿名 API：付费/密码文无全文，响应含 `access`；免费文仍有全文。
- [ ] RSS：付费/密码文无全文。
- [ ] 插件 README 可从后台说明入口查看；`docs/plugins.md` 含扩展点与 requires。
- [ ] PHPUnit 覆盖：依赖校验、access 矩阵、Stub 入账、重复下单、API/RSS 防泄露。

## 开放问题
（实现前已拍板，保留备查）
- [x] Stub UX：独立简页 + 后台「标记已支付」
- [x] 游客购买：否，必须登录
- [x] membership/主题市场：仅 `product_type` 预留
- [x] 试读默认：chars=200，渲染后截断
- [x] API：本期匿名；已购/管理员全文仅 Web
- [x] 密码与付费：互斥
- [x] 插件 Filament：插件内 Resource + Panel::configureUsing

## 变更记录
| 时间 | 原因 | 变更 |
|------|------|------|
| 2026-08-12 00:08 | 初稿 | 创建 SPEC |
| 2026-08-12 00:12 | qodercli review 阻塞项 B1–B5 | 定 API 匿名策略与破坏性说明；插件 Filament 注册机制；ArticleAccess 纯决策/互斥/只许变严；entitlement 可移植唯一键；RSS 纳入闸门；采纳保存钩子与迁移/试读等建议 |
| 2026-08-12 00:14 | 二轮 review 无阻塞；采纳建议 | 明确 need_login 预留；补禁用可见性/重复下单验收；状态改为待用户确认 |
| 2026-08-12 00:17 | 用户确认定稿 | 状态改为已定稿；配套 CHECKLIST / TESTPLAN |
| 2026-08-12 01:01 | Bugbot review 发现 5 项（3 high） | 明确 teaser 必须保留部分正文；列表摘要纳入闸门；补充事务内复核/pending 复用、仅可售已发布文、docs 路径限制 |
| 2026-08-12 01:12 | qodercli 复测提出金额陈旧，用户选「刷新为当前价」 | 复用 pending 单时按服务端当前价重新定价 |
