在 iOS App 中购买医生服务应选择哪种支付网关?
明确结论
如果 App 销售的是医生与患者之间的实时一对一问诊服务,可以在印度接入第三方支付网关,不需要将医疗服务做成 Apple In-App Purchase。选型时可以优先考虑 Razorpay、Cashfree Payments 或 PayU,再结合结算费率、支付方式、退款能力、服务稳定性和合规要求作出决定。
Razorpay 在印度 App 支付场景中较为常见,支持银行卡、UPI 和网银等支付方式,可以优先评估,但没有哪一家网关适合所有项目。
需要区分以下几类服务:
- 实时一对一视频、语音或线下问诊,通常属于人与人之间的服务,可以使用第三方支付。
- 录播课程、数字报告、会员内容、AI 问诊功能,以及可在 App 内消费的数字权益,可能需要使用 Apple In-App Purchase。
- 一对多或一对少数人的实时服务,也可能不符合一对一服务例外。
- App Review 是否通过,要看实际业务流程、商品描述以及当时有效的 App Store Review Guidelines,不能只凭”医生服务”这个名称判断。
如何选择印度支付网关
至少需要比较以下项目:
| 比较项 | 需要确认的内容 |
|---|---|
| 支付方式 | UPI、信用卡、借记卡、网银等 |
| 结算能力 | 结算周期、最低结算金额、银行账户要求 |
| 退款 | 全额退款、部分退款、退款状态查询 |
| Webhook | 支付成功、失败、退款、拒付等事件 |
| iOS SDK | 是否持续维护,是否支持 Swift |
| 服务端 API | 创建订单、查询支付、验签和退款 |
| 合规要求 | 商户 KYC、医疗业务准入、隐私和数据保存要求 |
| 费用 | 交易手续费、税费、国际卡费用及退款费用 |
| Marketplace | 如果平台需要向医生分账,应确认是否支持合规的分账或关联账户 |
如果平台只代表一家诊所收款,普通商户账户可能已经够用。如果款项先由平台收取,再结算给多名独立医生,就不能只使用普通收款接口,还需要评估分账、税务、KYC 和平台型支付产品。
推荐的系统架构
iOS App 不应直接创建支付订单,也不能保存支付网关的密钥。较为安全的支付流程如下:
- 用户在 App 中选择医生和预约时间。
- App 请求业务服务器创建预约。
- 服务器读取数据库中的价格,并据此创建支付网关订单。
- 服务器将订单 ID、金额和公开配置返回给 App。
- App 调起支付网关 SDK。
- 支付完成后,App 将支付结果提交给服务器。
- 服务器验证支付签名,并通过支付网关 API 查询实际状态。
- 服务器获得可信的支付成功结果后,再确认预约。
- Webhook 用于处理 App 被关闭、网络中断等异常情况。
预约状态可以分开设计,例如:
PENDING_PAYMENT
PAYMENT_PROCESSING
CONFIRMED
PAYMENT_FAILED
CANCELLED
REFUNDED
不能只根据 iOS SDK 的”支付成功”回调,将预约标记为已付款。客户端回调可能被伪造,也可能因网络问题与网关的最终状态不一致。
服务端创建订单示例
下面用 Node.js 展示通用流程。具体请求地址、字段和签名算法必须以所选网关的当前文档为准。
import express from "express";
const app = express();
app.use(express.json());
app.post("/api/appointments/:id/payment-order", async (req, res) => {
const appointment = await loadAppointment(req.params.id, req.user.id);
if (!appointment || appointment.status !== "PENDING_PAYMENT") {
return res.status(400).json({ error: "Invalid appointment" });
}
// 金额必须从服务端数据库读取,不能信任客户端提交的价格。
const amountInPaise = appointment.priceInPaise;
const gatewayOrder = await paymentGateway.createOrder({
amount: amountInPaise,
currency: "INR",
receipt: appointment.id
});
await saveGatewayOrder({
appointmentId: appointment.id,
gatewayOrderId: gatewayOrder.id,
amount: amountInPaise
});
res.json({
appointmentId: appointment.id,
orderId: gatewayOrder.id,
amount: amountInPaise,
currency: "INR",
publicKey: process.env.PAYMENT_GATEWAY_PUBLIC_KEY
});
});
印度卢比通常以 paise 为最小货币单位。如果服务价格是 ₹500,传给网关的金额通常是:
500 × 100 = 50000 paise
是否使用最小货币单位,以及字段应采用什么类型,需要以对应网关的 API 定义为准。
iOS 端调用示例
不同网关的 SDK 接口并不相同。下面通过常见的订单式 SDK 流程展示整体结构,类名和参数需要按照实际 SDK 调整:
import UIKit
struct PaymentOrder: Decodable {
let appointmentId: String
let orderId: String
let amount: Int
let currency: String
let publicKey: String
}
final class CheckoutViewController: UIViewController {
private var paymentOrder: PaymentOrder?
func startPayment(for appointmentId: String) {
createPaymentOrder(appointmentId: appointmentId) { [weak self] result in
DispatchQueue.main.async {
switch result {
case .success(let order):
self?.paymentOrder = order
self?.openGatewayCheckout(order: order)
case .failure(let error):
self?.showError(error.localizedDescription)
}
}
}
}
private func openGatewayCheckout(order: PaymentOrder) {
let options: [String: Any] = [
"key": order.publicKey,
"amount": order.amount,
"currency": order.currency,
"order_id": order.orderId,
"name": "Clinic Name",
"description": "Doctor consultation",
"prefill": [
"contact": "+91XXXXXXXXXX",
"email": "patient@example.com"
]
]
// 在这里调用所选支付网关的官方 iOS SDK。
// 不要把 secret key 写入 App。
presentGatewayCheckout(options: options)
}
private func createPaymentOrder(
appointmentId: String,
completion: @escaping (Result<PaymentOrder, Error>) -> Void
) {
var request = URLRequest(
url: URL(
string: "https://api.example.com/api/appointments/\(appointmentId)/payment-order"
)!
)
request.httpMethod = "POST"
request.setValue(
"Bearer \(currentAccessToken)",
forHTTPHeaderField: "Authorization"
)
URLSession.shared.dataTask(with: request) { data, response, error in
if let error {
completion(.failure(error))
return
}
guard
let httpResponse = response as? HTTPURLResponse,
(200...299).contains(httpResponse.statusCode),
let data
else {
completion(.failure(PaymentError.invalidResponse))
return
}
do {
completion(.success(try JSONDecoder().decode(PaymentOrder.self, from: data)))
} catch {
completion(.failure(error))
}
}.resume()
}
}
支付 SDK 回调成功后,只需将支付标识提交给服务器验证:
func paymentDidSucceed(
paymentId: String,
orderId: String,
signature: String
) {
let body: [String: String] = [
"paymentId": paymentId,
"orderId": orderId,
"signature": signature
]
verifyPaymentOnServer(body)
}
signature 是否存在、字段名称是什么,以及具体的回调协议,都取决于所用网关。不要为了套用示例,自行构造实际并不存在的字段。
服务端验签
以采用 HMAC-SHA256 的订单签名方案为例,服务器可以按照网关规定拼接订单 ID 和支付 ID,再使用密钥计算摘要:
import crypto from "node:crypto";
function verifySignature({ orderId, paymentId, signature }) {
const message = `${orderId}|${paymentId}`;
const expectedSignature = crypto
.createHmac("sha256", process.env.PAYMENT_GATEWAY_SECRET)
.update(message)
.digest("hex");
const expected = Buffer.from(expectedSignature, "utf8");
const received = Buffer.from(signature, "utf8");
return expected.length === received.length &&
crypto.timingSafeEqual(expected, received);
}
这只是常见签名方式的示意。消息拼接顺序、编码、摘要格式和验签字段,都必须严格遵守所选网关的当前规范。
即使签名验证通过,服务器仍应调用网关的支付查询接口,核对以下信息:
- 订单 ID 是否属于当前预约;
- 实付金额是否正确;
- 币种是否为
INR; - 支付是否处于可确认状态;
- 同一支付是否已经处理;
- 商户账户是否正确。
确认接口必须具备幂等性,避免 SDK 回调、客户端重试和 Webhook 同时到达时重复确认预约。
Webhook 处理
Webhook 是支付流程中必不可少的一环。用户付款后可能立即关闭 App,客户端也可能没有收到成功回调,但网关仍会向服务器发送事件。
app.post(
"/webhooks/payment-gateway",
express.raw({ type: "application/json" }),
async (req, res) => {
const signature = req.headers["x-gateway-signature"];
if (!verifyWebhookSignature(req.body, signature)) {
return res.sendStatus(400);
}
const event = JSON.parse(req.body.toString("utf8"));
if (event.type === "payment.succeeded") {
await confirmAppointmentIdempotently(event.data);
}
if (event.type === "refund.processed") {
await markAppointmentRefunded(event.data);
}
res.sendStatus(200);
}
);
Webhook 验签通常必须使用原始请求体。如果 JSON 中间件先解析请求体,再将其重新序列化,可能会造成签名不匹配。具体事件名称和请求头名称应以网关文档为准。
医疗场景的额外注意事项
支付网关只应接收完成交易所必需的信息。不要将病历、症状、诊断结果或处方写入订单描述、收据编号或支付网关的自定义字段。
还需要处理以下问题:
- 支付前应明确展示医生、服务内容、价格、时间和取消政策。
- 明确医生取消、患者取消、迟到和未出席时的退款规则。
- 如果支付成功后预约时段已被占用,应自动退款或提供重新预约流程。
- 日志中应隐藏银行卡信息、访问令牌、签名和患者隐私数据。
- 不要在自己的服务器上保存 CVV 或完整银行卡号。
- 使用支付网关提供的 SDK、托管支付页或令牌化方案,缩小 PCI DSS 的适用范围。
- 对创建订单、确认支付和退款接口进行身份验证与频率限制。
- Webhook 必须验签,并保存事件 ID,避免重复处理同一事件。
- 上线前应测试支付成功、失败、取消、超时、重复回调、退款和网络中断等情况。
比较稳妥的实施方式是先确认商品确实属于实时一对一医生服务,再选择支持印度本地支付方式的网关。订单创建、验签和 Webhook 接收均由服务端完成,iOS App 只负责展示预约信息并调起官方支付 SDK。
备注:内容仅供参考。
