PAYATHON 2026

如何为 Grails application 集成持续维护的 payment plugin?

支付小周

结论

不要把支付系统建立在某个 Grails 专用 payment plugin 上。更稳妥的方案是选择仍在维护的支付平台,例如 Stripe、Braintree 或 Adyen,再通过官方 Java SDK 或 REST API 接入 Grails。

如果订阅分为 monthly、annual 等多个级别,可以提前在支付平台中配置相应的 Product 和 Price,并在 Grails 中保存对应的 Price ID。支付状态通过 webhook 同步。payment details 只应包含交易编号、订阅状态、金额、币种、卡品牌、卡号末四位等非敏感信息。应用不应获取或存储完整卡号和 CVC。

下面以 Stripe 为例说明实现方式。SDK 升级后,具体类名和接口可能发生变化,接入时应以当前官方文档为准。

为什么不建议依赖 Grails 专用插件

支付接口、安全规范和订阅模型一直在变化。一个过去可用的 Grails 插件,后来可能遇到这些问题:

  • 底层支付 SDK 已停止升级;
  • 不支持 Payment Intents、Strong Customer Authentication 等较新的支付流程;
  • webhook 验签或事件模型已经过时;
  • Grails 或 Groovy 升级后出现依赖冲突;
  • 插件隐藏了支付平台的原始对象,反而增加了排查问题的难度。

Grails 运行在 JVM 上,通常可以直接使用官方 Java SDK。这样既能继续使用 Grails 的 Service、Controller 和事务管理,也便于及时升级支付 SDK。

订阅模型设计

订阅价格最好预先在支付平台中创建,不要在代码里临时计算。例如:

业务级别周期Stripe Price ID
Basicmonthlyprice_basic_monthly
Basicannualprice_basic_annual
Promonthlyprice_pro_monthly
Proannualprice_pro_annual

数据库不能只保存字符串形式的套餐名称,还要保存支付平台返回的标识:

class BillingSubscription {
    Long userId
    String providerCustomerId
    String providerSubscriptionId
    String providerPriceId
    String status
    Date currentPeriodEnd

    Date dateCreated
    Date lastUpdated

    static constraints = {
        providerCustomerId nullable: false
        providerSubscriptionId nullable: false, unique: true
        providerPriceId nullable: false
        status nullable: false
        currentPeriodEnd nullable: true
    }
}

Price ID 必须由服务端根据套餐和周期选择,不能直接采用浏览器提交的任意 Price ID。

添加官方 SDK

在 build.gradle 中添加 Stripe Java SDK。具体版本需要结合当前 Grails、Java 版本和 Stripe 的官方发布信息确定:

dependencies {
    implementation "com.stripe:stripe-java:<current-compatible-version>"
}

密钥从环境变量或密钥管理服务读取:

stripe:
  secretKey: ${STRIPE_SECRET_KEY}
  webhookSecret: ${STRIPE_WEBHOOK_SECRET}

不要把 secret key 写入源码或提交到 Git。

创建订阅结账会话

对大多数应用而言,使用托管的 Checkout 页面比自行收集银行卡信息简单,也能大幅缩小 PCI 合规范围。

package example.billing

import com.stripe.Stripe
import com.stripe.model.checkout.Session
import com.stripe.param.checkout.SessionCreateParams
import grails.core.GrailsApplication
import grails.gorm.transactions.Transactional

@Transactional
class PaymentService {

    GrailsApplication grailsApplication

    Session createSubscriptionCheckout(
        String customerEmail,
        String priceId,
        String successUrl,
        String cancelUrl
    ) {
        Stripe.apiKey = grailsApplication.config.stripe.secretKey

        SessionCreateParams params =
            SessionCreateParams.builder()
                .setMode(SessionCreateParams.Mode.SUBSCRIPTION)
                .setCustomerEmail(customerEmail)
                .setSuccessUrl(successUrl)
                .setCancelUrl(cancelUrl)
                .addLineItem(
                    SessionCreateParams.LineItem.builder()
                        .setPrice(priceId)
                        .setQuantity(1L)
                        .build()
                )
                .build()

        Session.create(params)
    }
}

Controller 只接收套餐和周期等业务参数,再由服务端把它们映射到允许使用的 Price ID:

package example.billing

import grails.converters.JSON

class BillingController {

    PaymentService paymentService

    def checkout() {
        String key = "${params.plan}:${params.interval}"

        Map<String, String> allowedPrices = [
            "basic:monthly": "price_basic_monthly",
            "basic:annual" : "price_basic_annual",
            "pro:monthly"  : "price_pro_monthly",
            "pro:annual"   : "price_pro_annual"
        ]

        String priceId = allowedPrices[key]
        if (!priceId) {
            render status: 400, text: "Unsupported subscription plan"
            return
        }

        def session = paymentService.createSubscriptionCheckout(
            currentUser.email,
            priceId,
            "https://example.com/billing/success?session_id={CHECKOUT_SESSION_ID}",
            "https://example.com/billing/cancel"
        )

        render([checkoutUrl: session.url] as JSON)
    }
}

示例中的 currentUser、域名和 Price ID 都要替换为应用自己的实现。生产环境还需要验证用户身份,并避免重复创建订阅。

使用 webhook 同步支付状态

用户访问 success URL,并不代表付款已经成功。用户可能关闭页面、重复跳转,也可能伪造请求。支付结果必须以通过签名验证的 webhook 事件为准。

通常需要处理以下事件:

  • checkout.session.completed
  • invoice.paid
  • invoice.payment_failed
  • customer.subscription.updated
  • customer.subscription.deleted

Webhook Controller 可以采用下面的结构:

package example.billing

import com.stripe.exception.SignatureVerificationException
import com.stripe.model.Event
import com.stripe.net.Webhook
import grails.core.GrailsApplication

class StripeWebhookController {

    GrailsApplication grailsApplication
    SubscriptionSyncService subscriptionSyncService

    static allowedMethods = [receive: "POST"]

    def receive() {
        String payload = request.reader.text
        String signature = request.getHeader("Stripe-Signature")
        String webhookSecret =
            grailsApplication.config.stripe.webhookSecret

        Event event

        try {
            event = Webhook.constructEvent(
                payload,
                signature,
                webhookSecret
            )
        } catch (SignatureVerificationException ignored) {
            render status: 400, text: "Invalid signature"
            return
        } catch (Exception ignored) {
            render status: 400, text: "Invalid payload"
            return
        }

        switch (event.type) {
            case "checkout.session.completed":
                subscriptionSyncService.handleCheckoutCompleted(event)
                break

            case "invoice.paid":
                subscriptionSyncService.handleInvoicePaid(event)
                break

            case "invoice.payment_failed":
                subscriptionSyncService.handleInvoicePaymentFailed(event)
                break

            case "customer.subscription.updated":
            case "customer.subscription.deleted":
                subscriptionSyncService.handleSubscriptionChanged(event)
                break
        }

        render status: 200, text: "ok"
    }
}

Stripe Java SDK 的版本会影响事件对象的反序列化方式,因此同步服务应按照当前所用版本的官方示例实现。事件未经验签时,不能解析并据此修改订阅状态。

Webhook 还要满足两个要求:

  • 同一个事件可能被重复发送,需要使用 event.id 做幂等处理;
  • 如果处理失败,应返回非 2xx 状态,让支付平台稍后重试。

可以建立事件记录表,并为事件 ID 添加唯一约束:

class ProcessedPaymentEvent {
    String providerEventId
    Date dateCreated

    static constraints = {
        providerEventId unique: true, blank: false
    }
}

获取 payment details

如果 payment details 指支付记录,可以从 Invoice 或 PaymentIntent 中获取以下信息:

  • 支付金额;
  • 币种;
  • 支付状态;
  • 支付时间;
  • Invoice ID;
  • PaymentIntent ID;
  • PaymentMethod ID;
  • 卡品牌和末四位;
  • 失败原因。

取得 PaymentMethod 后,可以读取非敏感的卡片信息:

import com.stripe.model.PaymentMethod

PaymentMethod paymentMethod =
    PaymentMethod.retrieve(paymentMethodId)

String brand = paymentMethod.card?.brand
String last4 = paymentMethod.card?.last4
Long expMonth = paymentMethod.card?.expMonth
Long expYear = paymentMethod.card?.expYear

这里无法取得完整卡号和 CVC,这是支付平台主动设置的安全限制。应用数据库通常只需保存:

providerPaymentId
providerInvoiceId
amount
currency
status
cardBrand
cardLast4
paidAt
failureCode

如果需要展示完整的账单明细,可以实时查询支付平台,也可以只保存筛选后的业务字段。不要把未经处理的完整支付对象直接写入数据库。

订阅状态不要只分“有效”和“无效”

订阅系统至少需要考虑以下状态:

  • 正常订阅;
  • 试用期;
  • 支付失败,但仍处于宽限期;
  • 已安排在当前周期结束时取消;
  • 已取消;
  • 首次付款尚未完成;
  • 暂停或欠款。

用户能否继续使用服务,应由明确的业务规则判断。例如,收到 invoice.payment_failed 并不一定要立即停用服务,因为支付平台可能仍会自动重试。

年度订阅也不应直接按“月价乘以 12”创建。更合适的方式是配置独立的 annual Price,让支付平台统一处理折扣、税费、退款和账单记录。

上线前的检查项

  • 使用官方 SDK 或 REST API,并确认它仍支持当前 Java 版本;
  • secret key 只保存在服务端;
  • 浏览器不能决定实际支付金额,也不能提交任意 Price ID;
  • 不存储完整卡号、CVC 或未经筛选的支付载荷;
  • 所有 webhook 都必须验签;
  • webhook 处理必须支持幂等和重试;
  • 金额使用最小货币单位或精确小数类型,不能使用 float、double;
  • 在支付平台的测试环境中验证成功付款、失败付款、续费、取消和 webhook 重放;
  • 明确退款、套餐升级、降级、按比例计费和宽限期规则;
  • 日志中不能记录密钥、完整 webhook 请求或敏感支付数据。

如果现有 Grails 插件只是在官方 SDK 外包了一层很薄的接口,实际价值通常不大。可以直接封装自己的 PaymentService,把支付平台相关代码放在独立模块中。这样更便于后续升级,也更容易更换支付供应商。

备注:内容仅供参考。