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

12 KiB
Raw Blame History

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

状态

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

背景

一期已具备:主题槽(@themeslot / theme.*)、轻量 Hook、插件启停与骨架(larablog/paymentmembership、双商城)。
缺口:后台表单/表格无插件注入;无插件依赖声明;无订单/权益模型;文章仅有 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.jsonrequiresoptionaldocs(相对 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/migrationsProvider loadMigrationsFromplugins:sync --enable / 启用时确保可 migrate;文档说明需 php artisan migrate)。
    • 表:ordersorder_itemsentitlementspayment_transactions
    • StubGateway:登录用户建单 → 确认页「模拟支付成功」→ paid → 写 entitlement → order.paid
    • Filament:订单列表/详情;详情「标记已支付」运维按钮;插件设置说明页。
  7. paid-content
    • 表:article_productscomposer.json PSR-4 + sync。
    • 注入文章表单付费 Sectionafter_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 核心表。

方案要点

分层

Core: ArticleAccess(决策)+ Hook 增强 + Filament 扩展点挂载 + 全文出口收口
  ↑
larablog/payment(订单 / Stub 网关 / entitlements / order.*
  ↑
larablog/paid-contentarticle_products + 表单注入 + access 收紧 + 试读)

ArticleAccess 契约(已定)

  • 纯决策,不含 HTTP 副作用。密码 POST 解锁仍由 BlogController(或细小 Action)写入 session 后再次调用 resolve()
  • Decisionstatusallow | need_password | need_purchase | need_login;可选 teaser_htmlcheckout_urlmessage
  • 解析顺序:
    1. 若 Web 上下文且用户为文章作者,或具备 spatie 角色 adminallow仅 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_passwordarticle_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 注入与保存(已定)

  • ArticleFormcomponents 合并 Hook::collect('filament.article.form')
  • CreateArticle / EditArticlemutateFormDataBeforeSave → filter filament.article.mutate_before_saveafterSave → dispatch filament.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=charstrial_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 阻塞项 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 单时按服务端当前价重新定价