PAYATHON 2026

在 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 不应直接创建支付订单,也不能保存支付网关的密钥。较为安全的支付流程如下:

  1. 用户在 App 中选择医生和预约时间。
  2. App 请求业务服务器创建预约。
  3. 服务器读取数据库中的价格,并据此创建支付网关订单。
  4. 服务器将订单 ID、金额和公开配置返回给 App。
  5. App 调起支付网关 SDK。
  6. 支付完成后,App 将支付结果提交给服务器。
  7. 服务器验证支付签名,并通过支付网关 API 查询实际状态。
  8. 服务器获得可信的支付成功结果后,再确认预约。
  9. 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。

备注:内容仅供参考。