@polar-sh/nuxt 适配器全解析:在 Nuxt 应用中集成 Polar 支付、Checkout 与 Webhook
【免费下载链接】polarPolar — A billing platform for the intelligence era项目地址: https://gitcode.com/GitHub_Trending/po/polar
@polar-sh/nuxt 是 Polar 官方为 Nuxt 3 提供的服务端适配器模块,将 Polar 的托管结账(Checkout)、客户门户(Customer Portal)与 Webhook 验签能力以三个即插即用的 Server Handler 形式封装进 Nuxt 应用。本文以仓库内 CHANGELOG.md 的版本演进为主线,结合 README.md 与模块源码、测试用例,完整讲解安装配置、Checkout Query 参数、Webhook 细粒度事件处理器与权益(Entitlement)机制,读完即可在 Nuxt 3 应用中落地一套完整的 Polar 收款闭环。
模块定位与整体结构
@polar-sh/nuxt 当前版本为 0.5.8,定位是"Polar 的 Nuxt 集成适配器"(见 package.json 的 description),它不是一个业务组件库,而是一组运行在 Nuxt 服务端(Nitro/h3)的路由处理器工厂。模块源代码结构如下:
clients/adapters/nuxt/ ├── src/ │ ├── module.ts # Nuxt 模块定义(configKey: polar) │ └── runtime/server/ │ ├── checkoutHandler.ts # Checkout 处理器 │ ├── customerPortalHandler.ts # Customer Portal 处理器 │ ├── webhookHandler.ts # Webhook 验签与分发 │ └── index.ts # 统一导出 ├── playground/ # 本地开发调试用 Nuxt 应用 ├── test/ # vitest 单元测试 ├── CHANGELOG.md # 版本演进记录 └── README.md # 使用文档从 module.ts 可以看出,模块通过defineNuxtModule注册,configKey为polar(即nuxt.config.ts中的polar: {}配置块),核心动作是调用addServerImportsDir将runtime/server目录下的Checkout、CustomerPortal、Webhooks三个工厂函数注册为 Nuxt 服务端自动导入,因此业务代码中无需手动 import。
在底层,三个处理器都基于 Polar TypeScript SDK(源码中引入的是@polar-sh/sdk/2026-04这个按 API 版本划分的命名空间,通过createPolarCore({ accessToken, environment })创建 SDK 客户端)与 h3 的事件对象(H3Event)协作。
安装与模块注册
按 README.md 与 package.json 的记录,安装要求 Node.js >= 22,使用任意包管理器安装即可:
pnpm add @polar-sh/nuxt然后在nuxt.config.ts中注册模块:
export default defineNuxtConfig({ modules: ['@polar-sh/nuxt'], })值得注意的一点是:仓库内的 playground/nuxt.config.ts 展示了推荐的运行时配置方式——把 Polar 的敏感凭据放进runtimeConfig.private,从而避免在业务代码里硬编码密钥:
export default defineNuxtConfig({ modules: ['../src/module'], polar: {}, compatibilityDate: '2025-02-25', runtimeConfig: { private: { polarAccessToken: '', polarServer: '', polarCheckoutSuccessUrl: '', polarWebhookSecret: '', }, }, })这些私有运行时配置键(polarAccessToken、polarServer、polarCheckoutSuccessUrl、polarWebhookSecret)会在后续三个 Handler 的示例代码中通过useRuntimeConfig()读取,建议在部署时用环境变量注入。
Checkout:一行代码接入 Polar 托管结账页
Polar 的 Checkout 是托管式的:你的服务端只需把客户重定向到 Polar 生成的结账 URL。@polar-sh/nuxt 的Checkout工厂负责创建结账会话并完成重定向(sendRedirect),参考 README 的最小实现:
// server/routes/api/checkout.post.ts export default defineEventHandler((event) => { const { private: { polarAccessToken, polarCheckoutSuccessUrl, polarServer }, } = useRuntimeConfig() const checkoutHandler = Checkout({ accessToken: polarAccessToken, successUrl: polarCheckoutSuccessUrl, returnUrl: 'https://myapp.com', // 可选:结账页显示"返回"按钮 environment: polarServer as 'sandbox' | 'production', theme: 'dark', // 强制深色主题;不传则跟随系统主题 }) return checkoutHandler(event) })CheckoutConfig 配置项
对照 checkoutHandler.ts 中导出的CheckoutConfig类型,可用的配置如下:
| 配置项 | 类型 | 说明 |
|---|---|---|
accessToken | string | Polar 组织访问令牌,必填,用于创建结账会话 |
successUrl | string | 支付成功后的跳转地址;默认会追加checkout_id参数 |
returnUrl | string | 可选,在结账页渲染"返回"按钮的回跳地址(0.3.12 版本加入) |
includeCheckoutId | boolean | 是否在successUrl中追加{CHECKOUT_ID}占位符,默认true |
environment | 'sandbox' \| 'production' | Polar 环境,对应 SDK 的Environment |
theme | 'light' \| 'dark' | 强制结账页主题(0.3.3 版本加入),省略则跟随系统偏好 |
底层行为细节
从源码实现(checkoutHandler.ts)可以看到几个关键行为:
successUrl占位符:当includeCheckoutId(默认 true)时,处理器会把{CHECKOUT_ID}占位符写入successUrl的查询参数,SDK 创建会话后会用真实的结账 ID 替换,方便你在成功页读取checkout_id完成后续订单同步。discount_id与discount_code的优先级:discount_id在创建会话时直接传给 API;而discount_code是在会话创建成功之后,通过clientUpdateCheckouts(result.client_secret, { discount_code })二次调用更新进去的。源码中的if (discountCode && !discountId)保证了"两者都传时以discount_id为准"(README 明确标注了这一优先级规则)。- 主题注入:
theme通过重定向 URL 的theme查询参数传给 Polar 结账页,因此测试用例中能看到最终重定向地址形如https://polar.sh/checkout/123?theme=dark。 - 错误处理:任何创建失败都会被包装成 h3 的 500 错误(
createError({ statusCode: 500, statusMessage: error.message })),避免把 Polar 内部错误直接暴露给前端。
Checkout Query 参数全解析
Polar 结账会话的所有输入都通过请求当前路由的 Query 参数传递(README 称之为 Query Params)。README 只列举了 7 个常用参数,但源码中的 zod 校验模式(checkoutHandler.ts)实际支持完整参数集,下面给出全表:
| 参数 | 必填 | 示例 | 说明 |
|---|---|---|---|
products | 是 | ?products=123 | 商品 ID,可用逗号分隔传多个商品(0.3.0 起替代productId/productPriceId) |
customer_id | 否 | ?customer_id=xxx | 关联已有 Polar 客户 |
external_customer_id | 否 | ?external_customer_id=xxx | 你自己的系统中的客户 ID |
customer_email | 否 | ?customer_email=janedoe@gmail.com | 预填客户邮箱 |
customer_name | 否 | ?customer_name=Jane | 预填客户姓名 |
customer_billing_address | 否 | URL 编码的 JSON | 账单地址对象(源码中用JSON.parse解析) |
customer_tax_id | 否 | ?customer_tax_id=TAX1 | 客户税号 |
customer_ip_address | 否 | ?customer_ip_address=10.0.0.1 | 客户 IP |
customer_metadata | 否 | URL 编码的 JSON | 客户自定义元数据(JSON.parse解析) |
allow_discount_codes | 否 | ?allow_discount_codes=true | 是否允许该结账会话使用折扣码(字符串"true"才会被解析为布尔真) |
discount_id | 否 | ?discount_id=disc_1 | 预选折扣,优先于discount_code |
discount_code | 否 | ?discount_code=SAVE20 | 预填折扣码,需在 Polar 后台开启折扣码功能 |
metadata | 否 | URL 编码的 JSON | 结账会话元数据(JSON.parse解析) |
seats | 否 | ?seats=5 | 按席位计费的席位数量(parseInt解析) |
几点使用提醒:
products是唯一必填参数,且支持逗号分隔多商品——这正是 0.3.0 版本的破坏性变更(见下文版本演进小节),旧的productId、productPriceId已被移除。- 所有 JSON 类型参数(
customer_billing_address、customer_metadata、metadata)必须经过 URL 编码,处理器内部会调用JSON.parse还原为对象。 allow_discount_codes只接受字面量"true"(转小写后比较),其余值一律视为false。
这些行为在 checkoutHandler.test.ts 中有对应的回归测试覆盖,例如"?seats=5透传为seats: 5"、"URL 转义保留"、"JSON 参数解析"、"discount_code触发二次更新接口"等。
Customer Portal:让客户自助管理订单与订阅
客户门户让已购客户自行查看订单和订阅状态。@polar-sh/nuxt 的CustomerPortal工厂接收一个getCustomerId回调,由你在服务端解析出当前登录客户,然后由它创建 Polar 客户会话并重定向:
// server/routes/api/portal.get.ts export default defineEventHandler((event) => { const { private: { polarAccessToken, polarServer }, } = useRuntimeConfig() const customerPortalHandler = CustomerPortal({ accessToken: polarAccessToken, environment: polarServer as 'sandbox' | 'production', getCustomerId: (event) => { return Promise.resolve('9d89909b-216d-475e-8005-053dba7cff07') }, returnUrl: 'https://myapp.com', // 可选:门户内"返回"按钮的地址 }) return customerPortalHandler(event) })对照 customerPortalHandler.ts,CustomerPortalConfig包含四个字段:
| 配置项 | 类型 | 说明 |
|---|---|---|
accessToken | string | Polar 组织访问令牌 |
environment | 'sandbox' \| 'production' | Polar 环境 |
getCustomerId | (event: H3Event) => Promise<string> | 从请求中解析当前客户 ID 的异步回调 |
returnUrl | string | 可选,门户页面"返回"按钮回跳地址 |
源码中的关键逻辑(customerPortalHandler.ts):
- 先
await getCustomerId(event)拿到客户 ID;如果回调返回空值,直接抛 400 错误(customerId not defined),并在服务端打印错误日志。 - 拿到 ID 后调用 SDK 的
createCustomerSessions创建客户会话,returnUrl会经过decodeURI处理后再传给 Polar,最后sendRedirect到result.customer_portal_url。 - 会话创建失败同样包装为 h3 500 错误。
Webhooks:签名验证 + 细粒度事件分发
Polar 通过 Webhook 把订单、订阅、权益等事件推送到你的服务器。@polar-sh/nuxt 的Webhooks处理器封装了完整的验签流程——它从请求头读取webhook-id、webhook-timestamp、webhook-signature,读取原始请求体(readRawBody),然后交给 SDK 的webhooks.validateEvent验证签名:
// server/routes/webhook/polar.post.ts export default defineEventHandler((event) => { const { private: { polarWebhookSecret }, } = useRuntimeConfig() const webhooksHandler = Webhooks({ webhookSecret: polarWebhookSecret, onPayload: async (payload: any) => { // 处理所有事件 // 无需返回确认响应 }, }) return webhooksHandler(event) })验签与错误响应语义
对照 webhookHandler.ts,验签失败时的响应有明确的 HTTP 语义:
| 异常类型 | 响应 | 说明 |
|---|---|---|
PolarWebhookVerificationError | 403{ received: false } | 签名验证失败,拒绝该请求 |
PolarWebhookUnknownTypeError | 事件类型为 null 时 400,否则 200 | 未知事件类型按"已接收"或"非法"处理 |
其他PolarWebhookError | 400{ received: false } | 通用 Webhook 错误 |
| 验签通过 | 200{ received: true } | 正常处理并确认 |
这里特别值得注意 0.5.4 版本的修复:"Fix webhook signature verification failing on parsed + re-serialized webhook bodies"。这解释了为什么处理器必须用readRawBody读取原始请求体再交给validateEvent——如果先用 JSON.parse 再序列化,字节内容会与 Polar 签名时使用的原始字节不一致,导致验签失败。这一点对自行实现验签的读者同样是重要提示。
细粒度 Payload Handlers
Webhooks配置除了通用onPayload外,还支持按事件类型注册独立处理器(README 与 webhooks.ts 中定义的完整列表):
- Checkout:
onCheckoutCreated、onCheckoutUpdated、onCheckoutExpired - Order:
onOrderCreated、onOrderUpdated、onOrderPaid、onOrderRefunded - Refund:
onRefundCreated、onRefundUpdated(0.3.8 / 0.3.9 版本加入) - Subscription:
onSubscriptionCreated、onSubscriptionUpdated、onSubscriptionActive、onSubscriptionCanceled、onSubscriptionCycled、onSubscriptionPastDue、onSubscriptionPaused、onSubscriptionResumed、onSubscriptionRevoked、onSubscriptionUncanceled - Product:
onProductCreated、onProductUpdated - Organization:
onOrganizationUpdated - Benefit:
onBenefitCreated、onBenefitUpdated - Benefit Grant:
onBenefitGrantCreated、onBenefitGrantUpdated、onBenefitGrantRevoked、onBenefitGrantCycled - Customer:
onCustomerCreated、onCustomerUpdated、onCustomerDeleted、onCustomerStateChanged(0.2.2 版本加入客户状态支持) - Customer Seat:
onCustomerSeatAssigned、onCustomerSeatClaimed、onCustomerSeatRevoked - Discount:
onDiscountCreated、onDiscountUpdated、onDiscountDeleted - Member:
onMemberCreated、onMemberUpdated、onMemberDeleted
这些处理器定义在 @polar-sh/nuxt 的底层依赖 @polar-sh/adapter-utils 中,handleWebhookPayload用switch (payload.type)把事件精确分发到对应回调,并用Promise.all并发执行所有命中的处理器(通用onPayload总会执行,事件专属处理器按需执行),最后统一await全部完成——这正是 0.1.2 版本"Await webhook handlers"变更带来的语义:处理器函数返回的 Promise 会被完整等待,确保异步副作用(如写库)完成后才返回响应。
从 Webhook 到权益(Entitlements)
@polar-sh/nuxt 的Webhooks配置还支持传入entitlements(权益分发器),用于在benefit_grant.created/benefit_grant.revoked事件发生时自动执行授权/撤销逻辑。机制位于 adapter-utils 的 entitlement 实现:
import { Entitlements, EntitlementStrategy } from '@polar-sh/adapter-utils' const proStrategy = new EntitlementStrategy() .grant(async ({ customer, properties }) => { // 授予权益:按 properties 为 customer 开通功能 }) .revoke(async ({ customer }) => { // 撤销权益 }) Entitlements.use('pro-plan', proStrategy)其设计要点:
EntitlementStrategy以链式方式注册grant/revoke回调,通过handler(slug)生成一个EntitlementHandler。Entitlements.use(slug, strategy)把策略挂载到全局静态handlers数组,最终传入Webhooks的entitlements配置。- 匹配规则:回调中拿
payload.data.benefit.description === slug来判定该事件是否属于该权益策略,事件携带的customer与properties会注入到回调上下文EntitlementContext中。
权益相关能力从 0.1.11(导出 Entitlement 类)到 0.1.13(导出权益工具)逐步完善,是适配器"收款闭环"之外的重要一环——订单支付后自动授权益、退款或订阅取消后自动撤销权益。
版本演进:从 CHANGELOG 看模块能力的时间线
CHANGELOG 完整记录了 @polar-sh/nuxt 从 0.1.x 到 0.5.8 的演进,按功能域归纳如下:
0.1.x:能力奠基期
- 0.1.1 加入细粒度 Webhook 处理器(granular webhook handlers);
- 0.1.2 修复 Webhook 处理器未被 await的问题;
- 0.1.9 增加
productPriceId参数能力;0.1.10 保证结账时必须传 price 或 product; - 0.1.11 导出 Entitlement 类;0.1.12 落地 entitlements 实现并初始化 Nuxt 接入;0.1.13 导出权益工具;
- 0.1.14 导出类型;0.1.15 修复 URI 解码(decode the URI properly)。
0.2.x:模块化与状态支持
- 0.2.1 正式初始化 Nuxt 模块包结构;0.2.2 加入客户状态(customer state)支持;0.2.4 改善错误信息;0.2.5 加入新订单 Webhook 支持。
0.3.x:Checkout 能力大版本
- 0.3.0(破坏性变更):Checkout 端点不再支持
productId/productPriceId传商品,统一改用可重复的products参数,一次结账可传多个商品; - 0.3.1 修复
products参数传递问题;0.3.3 为 Checkout 配置加入主题支持(theme);0.3.4 修复 SDK 误解析 Zod v4 的问题;0.3.5 修复导入路径; - 0.3.8 / 0.3.9 两连发加入退款 Webhook(refund webhooks);
- 0.3.12 加入returnUrl支持(结账页/门户"返回"按钮)。
0.4.x:SDK 升级
- 0.4.0 将 SDK 更新到 0.40.2,adapter-utils 同步升至 0.3.0。
0.5.x:稳定性与 SDK 同步
- 0.5.0 升级 Polar SDK;0.5.1 统一升级依赖;0.5.2 / 0.5.3 持续跟随 SDK 发布;0.5.4 修复"解析后重新序列化的 Webhook 请求体导致验签失败"——即前文强调的
readRawBody原始字节验签;0.5.5 ~ 0.5.7 密集跟随 SDK 更新(0.46.0 → 0.47.0 区间,其间 adapter-utils 同步升级);0.5.8 将 adapter-utils 更新到 0.4.7。
当前仓库中 package.json 记录的依赖为@polar-sh/sdk@1.0.0-alpha.20、@polar-sh/adapter-utils(workspace 版本,即仓库内 adapter-utils),CHANGELOG 中 0.3.x ~ 0.5.x 的 SDK 版本号属于历史快照,二者并不矛盾——这反映的是该适配器长期保持"紧跟 SDK 发布节奏"的维护策略。
测试与验证
适配器配有 vitest 单元测试(见 test/checkoutHandler.test.ts),通过 mock SDK 与sendRedirect对Checkout处理器做行为级断言,覆盖了:
- URL 转义保留:
%2541、%2500、%7Bx%7D等在successUrl/returnUrl中不被二次破坏,{CHECKOUT_ID}占位符可正常替换; - seats 透传:
?seats=5/1/0的透传,以及非数字输入按NaN透传(与 nextjs 适配器保持一致性); - 多参数组合:
products逗号分隔、客户字段、allow_discount_codes、discount_id的组合透传; - JSON 参数解析:
customer_billing_address、customer_metadata、metadata的JSON.parse行为; - 折扣流程:
discount_code触发clientUpdateCheckouts二次调用、discount_id存在时不再调用、折扣应用失败时不重定向; - 主题注入:
theme=dark追加到重定向 URL; - 错误处理:创建结账失败时抛 h3 500 错误。
运行测试使用:
pnpm test # 在 clients/adapters/nuxt 目录下执行小结
@polar-sh/nuxt 把 Polar 的结账、客户门户与 Webhook 三个核心能力压缩为三个配置型工厂函数,开发者只需在 Nuxt 服务端路由中调用Checkout、CustomerPortal、Webhooks即可完成接入。从 CHANGELOG 的演进脉络看,模块的能力增长点集中在:多商品结账(0.3.0products)、结账主题与返回链接(0.3.3 / 0.3.12)、退款事件与客户状态(0.3.8 / 0.2.2)、原始字节验签修复(0.5.4)以及贯穿始终的 SDK 同步升级——这为理解"如何正确使用该适配器"以及"自行接入 Polar 时应关注哪些细节"提供了清晰的路线图。
【免费下载链接】polarPolar — A billing platform for the intelligence era项目地址: https://gitcode.com/GitHub_Trending/po/polar
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考