如何在 Android Kotlin 中实现支付后解锁联系方式?
结论
绝大部分开发和测试都可以在不产生真实扣款的情况下完成。正规的支付服务通常会提供 Sandbox/Test Mode、测试银行卡,以及模拟支付成功、失败、取消等场景的工具。
不过,“支付成功后解锁联系方式”不能只看 Android 客户端返回的支付结果。更可靠的流程是:
- Android 向业务后端申请创建订单。
- 后端调用支付网关创建支付会话。
- 用户通过支付网关提供的安全页面或 SDK 付款。
- 支付网关通过 Webhook 通知后端。
- 后端验证通知、金额、币种、订单状态和签名。
- 后端向该用户授予对应房源的查看权限。
- Android 从后端获取联系电话和详细地址。
不要提前把广告发布者的敏感信息下载到手机上,再通过界面隐藏。这样做无法真正保护数据,用户仍可能通过抓包、日志、缓存或反编译拿到这些信息。
推荐的系统结构
系统至少需要以下部分:
- Android Kotlin 客户端
- 业务后端
- 数据库
- 支付网关的服务端 API
- 支付网关的 Webhook 接口
数据关系可以按下面的方式设计:
users
- id
- name
listings
- id
- publisher_id
- public_description
- private_phone
- private_address
payment_orders
- id
- user_id
- listing_id
- amount
- currency
- status
- provider_payment_id
contact_entitlements
- user_id
- listing_id
- payment_order_id
- granted_at
payment_orders.status 可以使用类似的状态:
CREATED
PENDING
PAID
FAILED
CANCELED
REFUNDED
contact_entitlements 用来记录某个用户是否已经购买某个房源的联系方式。权限判断不能以客户端传来的 isPaid=true 为准。
完整支付流程
1. 客户端请求创建订单
Android 只提交房源 ID,最终价格由后端决定:
POST /api/payment-orders
Authorization: Bearer <access_token>
Content-Type: application/json
{
"listingId": "listing_123"
}
后端根据 listingId 查询数据库中的价格,创建本地订单,然后调用支付网关。
后端返回什么内容取决于所用的支付服务。例如:
{
"orderId": "order_456",
"paymentUrl": "https://payment-provider.example/checkout/...",
"status": "PENDING"
}
有些支付服务则会返回支付 SDK 所需的 clientSecret、session ID 或 payment token。
不要让 Android 将价格、商户账户或支付成功状态直接写入数据库。
2. 用户完成支付
Android 可以打开支付网关的托管收银台,也可以调用该网关的官方 Android SDK。
支付页面返回”成功”后,客户端可以显示”正在确认支付”,但不能立即解锁联系方式。客户端回调可能被伪造,而且部分支付方式需要异步确认。
3. Webhook 确认交易
支付网关会向后端发送事件。后端需要使用网关提供的签名验证方法,确认请求真实有效。
下面是简化后的服务端逻辑。具体 API 名称需要替换为所选支付网关的官方实现:
@PostMapping("/webhooks/payment")
fun handlePaymentWebhook(
@RequestBody rawBody: ByteArray,
@RequestHeader("Payment-Signature") signature: String
): ResponseEntity<Void> {
val event = paymentProvider.verifyAndParseWebhook(
rawBody = rawBody,
signature = signature
)
if (event.type == "payment.succeeded") {
val providerPayment = paymentProvider.getPayment(event.paymentId)
val order = orderRepository.findByProviderPaymentId(event.paymentId)
?: return ResponseEntity.notFound().build()
require(providerPayment.amount == order.amount)
require(providerPayment.currency == order.currency)
require(providerPayment.status == "succeeded")
paymentService.markPaidAndGrantAccess(
orderId = order.id,
providerEventId = event.id
)
}
return ResponseEntity.ok().build()
}
授予权限和更新订单最好放在同一个数据库事务中完成:
@Transactional
fun markPaidAndGrantAccess(
orderId: String,
providerEventId: String
) {
if (webhookEventRepository.existsByProviderEventId(providerEventId)) {
return
}
val order = orderRepository.findByIdForUpdate(orderId)
?: error("Order not found")
if (order.status != PaymentStatus.PAID) {
order.status = PaymentStatus.PAID
orderRepository.save(order)
entitlementRepository.insertIfAbsent(
userId = order.userId,
listingId = order.listingId,
paymentOrderId = order.id
)
}
webhookEventRepository.save(
WebhookEvent(providerEventId = providerEventId)
)
}
支付网关可能多次发送同一个 Webhook,因此处理过程必须具备幂等性。重复通知不能造成重复授权、重复记账或重复发放权益。
4. Android 查询订单状态
支付界面结束后,Android 可以轮询业务后端:
interface PaymentApi {
@GET("api/payment-orders/{orderId}")
suspend fun getOrder(
@Path("orderId") orderId: String
): PaymentOrderResponse
}
data class PaymentOrderResponse(
val orderId: String,
val status: String
)
ViewModel 示例:
class PaymentViewModel(
private val paymentApi: PaymentApi,
private val listingApi: ListingApi
) : ViewModel() {
private val _uiState = MutableStateFlow<PaymentUiState>(
PaymentUiState.Idle
)
val uiState: StateFlow<PaymentUiState> = _uiState
fun confirmPayment(orderId: String, listingId: String) {
viewModelScope.launch {
_uiState.value = PaymentUiState.Confirming
repeat(10) {
val order = paymentApi.getOrder(orderId)
when (order.status) {
"PAID" -> {
val contact = listingApi.getPrivateContact(listingId)
_uiState.value = PaymentUiState.Unlocked(contact)
return@launch
}
"FAILED", "CANCELED" -> {
_uiState.value =
PaymentUiState.PaymentFailed(order.status)
return@launch
}
}
delay(2_000)
}
_uiState.value = PaymentUiState.StillProcessing
}
}
}
sealed interface PaymentUiState {
data object Idle : PaymentUiState
data object Confirming : PaymentUiState
data object StillProcessing : PaymentUiState
data class Unlocked(
val contact: PrivateContact
) : PaymentUiState
data class PaymentFailed(
val reason: String
) : PaymentUiState
}
实际项目还应允许用户稍后重新打开房源。只要后端确认该用户已经拥有权限,就应直接返回联系方式,不能要求用户再次付款。
联系方式接口应由后端鉴权
公共房源接口不要返回手机号和详细地址:
{
"id": "listing_123",
"title": "Single room",
"city": "London",
"contactUnlocked": false
}
敏感数据应通过独立接口获取:
GET /api/listings/listing_123/private-contact
Authorization: Bearer <access_token>
后端先检查当前登录用户是否拥有对应权益:
@GetMapping("/api/listings/{listingId}/private-contact")
fun getPrivateContact(
@PathVariable listingId: String,
principal: UserPrincipal
): PrivateContactResponse {
val allowed = entitlementRepository.exists(
userId = principal.userId,
listingId = listingId
)
if (!allowed) {
throw ResponseStatusException(HttpStatus.FORBIDDEN)
}
val listing = listingRepository.findById(listingId)
?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
return PrivateContactResponse(
phone = listing.privatePhone,
address = listing.privateAddress
)
}
Android 根据接口返回结果显示内容:
when (val state = uiState) {
is PaymentUiState.Unlocked -> {
phoneTextView.text = state.contact.phone
addressTextView.text = state.contact.address
contactContainer.isVisible = true
payButton.isVisible = false
}
else -> {
contactContainer.isVisible = false
}
}
即使有人修改或跳过界面,也无法从服务器获取敏感数据,因为真正的权限检查在后端执行。
如何测试
使用支付网关的测试环境
开发阶段通常会用到:
- 测试 API Key
- 测试商户账户
- 测试银行卡或测试支付凭证
- 测试 Webhook
- 模拟成功、失败、取消、超时和退款的工具
测试环境中的交易不会产生真实资金结算。测试卡号和预期结果必须从所选支付网关的官方文档中获取。不要使用真实银行卡信息,也不要随意编造卡号。
核心测试场景包括:
| 场景 | 预期结果 |
|---|---|
| 支付成功 | 订单变为 PAID,联系方式可查看 |
| 支付失败 | 不授予权限 |
| 用户取消 | 不授予权限,并允许重新支付 |
| Webhook 延迟 | 页面显示处理中,收到通知后解锁 |
| Webhook 重复 | 只授权一次 |
| 客户端伪造成功 | 后端仍拒绝提供联系方式 |
| 金额或币种不符 | 不授权并记录异常 |
| 应用关闭后支付成功 | 重新登录后仍可查看 |
| 同一用户重复购买 | 根据业务规则阻止重复收费 |
| 退款或拒付 | 按业务规则撤销或保留权限 |
本地后端通常无法直接接收公网 Webhook。开发时可以使用支付网关提供的本地转发工具,也可以使用临时 HTTPS 隧道。具体方式取决于所选网关。
是否完全不需要真实支付
集成开发和自动化测试一般不需要真实付款。不过,正式上线前可以使用客户自己的正式商户账户进行一次小额端到端验证,确认:
- 正式 API Key 和 Webhook 配置正确
- 交易确实进入预期商户账户
- 手续费、税务和结算币种符合预期
- 退款流程可用
- Android 正式包的跳转和回调正常
这是上线验收的一部分,不应由开发者使用个人商户账户代收。测试金额、退款安排和费用承担方式,应由客户提前确认。
如何让钱直接进入客户账户
支付网关的正式商户账户应由客户创建,并由客户完成实名认证、银行账户绑定、税务资料提交和结算设置。后端使用客户商户账户生成的服务端凭证创建支付。
建议按以下方式分工:
- 客户拥有支付账户、银行账户和后台管理员权限。
- 客户通过安全方式配置生产环境凭证。
- 开发者只获得完成集成所需的最低权限。
- Android 包中只保存网关允许公开的客户端标识。
- Secret Key、Webhook Secret 等凭证只保存在后端的密钥管理系统或环境配置中。
使用客户的商户账户创建交易后,支付网关会按照该账户的结算规则,将款项汇入客户绑定的银行账户。款项通常不会在付款完成的瞬间直接进入银行账户,而是先形成商户余额,再按网关的结算周期支付。
不要把客户的 Secret Key 写进 Android:
// 错误:APK 可以被反编译
const val PAYMENT_SECRET_KEY = "secret_key"
客户端只调用自己的后端:
interface CheckoutApi {
@POST("api/payment-orders")
suspend fun createOrder(
@Body request: CreateOrderRequest
): CreateOrderResponse
}
如果平台需要将同一笔款项拆分给多个房东或广告发布者,这就不再是普通的商户收款,而属于 marketplace、connected accounts、split payment 或类似模式。这类模式通常涉及收款主体验证、手续费分配、退款、拒付和当地合规要求,因此需要选择明确支持平台型支付的服务。不能把不同收款人的银行资料交给 Android 客户端处理。
Android 发布政策也需要提前确认
这里出售的是”查看联系方式的权限”。应用商店可能将其认定为数字内容或应用内功能,也可能因为它与线下房屋租赁服务有关而适用不同规则。最终结论取决于应用的销售方式、目标国家或地区、发布渠道,以及当时有效的平台政策。
确定使用 Stripe、PayPal、Razorpay 或其他第三方网关之前,应先核对当前的 Google Play 支付政策。如果该场景必须使用 Google Play Billing,就不能只用第三方支付网关代替。应用不通过 Google Play 分发时,则需要根据实际分发渠道重新判断。
安全和业务注意事项
- 全程使用 HTTPS。
- Webhook 必须验证签名,不能只相信请求中的
status字段。 - 金额和币种必须从后端订单中读取并再次核对。
- 不要在日志中记录完整手机号、地址、银行卡数据或支付密钥。
- 不要自行采集或保存银行卡号、CVV 等敏感信息,应使用网关提供的 SDK 或托管收银台。
- 支付订单必须绑定当前登录用户和具体房源。
- 客户端返回 URL 只能用来改善用户体验,不能作为付款凭证。
- 数据库操作要支持幂等和事务,防止重复授权。
- 记录订单 ID、网关交易 ID 和事件 ID,便于对账和排查。
- 明确退款、拒付、房源删除及联系方式变更后的权限规则。
- 向用户展示价格、币种、购买内容和退款条件。
- 正式环境和测试环境的 API Key、订单及 Webhook 地址必须严格分开。
- 联系方式属于个人数据,还需要满足业务所在地的隐私和数据保护要求。
判断支付是否完成,不能看付款页面是否显示成功,而要看业务后端是否已经通过支付网关验证交易,并向当前用户授予该房源联系方式的查看权限。围绕这个原则设计后,测试环境和正式环境就能使用同一套业务流程。
备注:内容仅供参考。
