如何为 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 |
|---|---|---|
| Basic | monthly | price_basic_monthly |
| Basic | annual | price_basic_annual |
| Pro | monthly | price_pro_monthly |
| Pro | annual | price_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.completedinvoice.paidinvoice.payment_failedcustomer.subscription.updatedcustomer.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,把支付平台相关代码放在独立模块中。这样更便于后续升级,也更容易更换支付供应商。
备注:内容仅供参考。
