Files
larablog/docs/specs/plugin-extension-commerce/SPEC.md
T
ak 263b98b218 Initial baseline: LaraBlog core with plugin commerce surface.
Captures the current working tree after theme slots, ArticleAccess, and the payment / paid-content plugins so subsequent work has a reviewable git history.
2026-08-12 01:15:38 +08:00

177 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 插件扩展面 + 支付 / 内容付费
## 状态
- 状态:已定稿
- 创建: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`、两插件 READMEOpenAPI / `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-contentarticle_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 阻塞项 B1B5 | 定 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 单时按服务端当前价重新定价 |