Captures the current working tree after theme slots, ArticleAccess, and the payment / paid-content plugins so subsequent work has a reviewable git history.
12 KiB
12 KiB
插件扩展面 + 支付 / 内容付费
状态
- 状态:已定稿
- 创建: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 会输出全文(密码文亦然),付费能力必须一并收口。
目标
- 插件扩展平台:前台 + 后台均支持约定式注入(UI / 功能 / 数据关联)。
- 支付基建
larablog/payment:订单、Stub 网关、回调日志、权益记账;后台订单可查。 - 内容付费
larablog/paid-content:依赖 payment;文章价格/试读(表单注入);Web 访问闸 + 全出口防泄露。 - 插件元数据:
requires、README 使用说明;启用时校验依赖。
范围
In scope
- 增强
Hook:在现有listen/dispatch/gather之上增加:collect(string $event, array $initial = [], mixed ...$payload): array(数组合并,供 Filament 组件)filter(string $event, mixed $value, mixed ...$payload): mixed(值管道)
- 首批约定扩展点:
点 类型 用途 theme.*gather 已有前台槽 article.accessfilter 收紧访问决策(只许变严) filament.article.formcollect 文章表单追加 Schema 组件 filament.article.table.columnscollect 文章表追加列 filament.article.actionscollect 文章表/页动作 filament.article.mutate_before_savefilter (array $data, ?Article $record)filament.article.after_savedispatch (Article $record, array $data)order.paid/order.refundeddispatch payment 发出 plugin.json:requires、optional、docs(相对 README,默认README.md)。本期不引入无消费方的provides。PluginManager::enable:硬依赖未启用则拒绝;后台卡片展示依赖与「查看说明」。- 插件 Filament 注册机制(已定):订单 Resource / 插件设置页放在插件命名空间内;通过
Filament\Panel::configureUsing(或Filament::serving)在 panel 配置阶段按「插件已启用」注册pages/resources。不再把商务 UI 永久写死在核心app/Filament(现有 skeleton 页可改为薄代理或迁入插件后删除)。 - payment(最小可跑闭环):
- 迁移:插件目录
database/migrations,ProviderloadMigrationsFrom(plugins:sync --enable/ 启用时确保可 migrate;文档说明需php artisan migrate)。 - 表:
orders、order_items、entitlements、payment_transactions。 - StubGateway:登录用户建单 → 确认页「模拟支付成功」→ paid → 写 entitlement →
order.paid。 - Filament:订单列表/详情;详情「标记已支付」运维按钮;插件设置说明页。
- 迁移:插件目录
- paid-content:
- 表:
article_products;composer.jsonPSR-4 + sync。 - 注入文章表单付费 Section;
after_save落库。 - 实现
article.access收紧;试读在 HTML 渲染之后按纯文本长度截断(避免截断 Markdown 语法)。
- 表:
- ArticleAccess(核心,纯决策) + 全部全文出口收口:Web show、API show、RSS。
- 文档:
docs/plugins.md、两插件 README;OpenAPI /ApiV1Test同步 API 破坏性变更。 - PHPUnit:依赖校验、access 矩阵、Stub 入账、RSS/API 不泄露全文。
Out of scope
- 真实微信/支付宝/Stripe 商户对接(Stub + Gateway 接口预留)。
- API / 小程序登录鉴权与「已购用户经 API 读全文」(见下方 API 策略)。
membership套餐续费、主题市场真实售卖(仅预留product_type枚举值)。- 优惠券、购物车、游客购买、多币种汇率。
- 任意后台 DOM 注入;远程安装插件。
- 价格字段写入
articles核心表。
方案要点
分层
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。 - 解析顺序:
- 若 Web 上下文且用户为文章作者,或具备 spatie 角色
admin→allow(仅 Web/session 用户;API 本期无用户)。 - 若
read_password非空且 session 未解锁 →need_password(到此结束,插件不参与)。 - 否则
status=allow,再跑Hook::filter('article.access', $decision, $ctx)。
- 若 Web 上下文且用户为文章作者,或具备 spatie 角色
need_loginvsneed_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→ filterfilament.article.mutate_before_save;afterSave→ dispatchfilament.article.after_save。- 插件 state 前缀:
paid_content.*;不得进入 Article$fillable;由 after_save 写article_products。
插件页面注册(已定 · 对应原 B2)
// 插件 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 防泄露。
开放问题
(实现前已拍板,保留备查)
- Stub UX:独立简页 + 后台「标记已支付」
- 游客购买:否,必须登录
- membership/主题市场:仅
product_type预留 - 试读默认:chars=200,渲染后截断
- API:本期匿名;已购/管理员全文仅 Web
- 密码与付费:互斥
- 插件 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 单时按服务端当前价重新定价 |