如何在 iOS 中实现网站票务 checkout 流程?
结论
不要让 iOS App 模拟网页表单,直接发送”最终购买请求”,也不要把支付密钥、票价计算或下单逻辑放进客户端。
建议采用以下流程:
- App 将场次、票种、数量等信息提交到自己的后端。
- 后端重新校验库存和价格,创建订单或支付会话。
- App 使用支付服务商的原生 SDK,或者通过
WKWebView或系统浏览器打开适配移动端的托管结账页。 - 后端收到支付回调并确认付款结果后,再完成出票。
如果网站和 App 都由你方维护,应直接检查后端路由、表单提交逻辑和支付服务商配置,不要尝试在客户端”逆向”一个不可控的最终请求。只有暂时无法修改旧系统时,才适合通过浏览器开发者工具抓取自有网站的请求,以便理解现有流程并重构接口。
为什么不能只复刻最终 HTTP 请求
结账通常不是一个独立的 POST 请求,而是一段有状态的流程,可能依赖:
- 登录会话和 Cookie
- CSRF token
- 购物车或订单 ID
- 实时库存锁定
- 服务端计算的价格、税费和手续费
- 支付 token
- 3-D Secure 等额外验证
- 防重放字段、签名或一次性 nonce
- 幂等键
- 风控信息
- 支付服务商的异步 webhook
即使抓到了某次成功请求,该请求也可能只对当前会话有效。将字段和 Cookie 硬编码到 App 中,网站稍作修改就可能失效,还容易引发重复扣款、超卖和支付数据泄露。
除非整个系统已经按照适用的 PCI DSS 范围进行设计和审计,否则不要将信用卡号提交到自行拼装的接口。通常应该使用支付服务商提供的 SDK 或托管页面收集支付信息,避免敏感卡数据经过你的服务器和日志系统。
推荐的流程设计
1. App 只提交购票意图
客户端只提交可以验证的业务参数,不提交最终金额:
{
"eventId": "event_123",
"ticketTypeId": "adult",
"quantity": 2,
"performanceDate": "2026-09-12T19:30:00+08:00"
}
金额、优惠、税费和库存必须由服务端重新计算。即使客户端传入了 totalPrice,也只能用于显示,不能作为实际收费依据。
2. 后端创建订单或 checkout session
可以为移动端设计一个稳定的接口:
POST /api/v1/checkout/sessions
Authorization: Bearer <access-token>
Content-Type: application/json
Idempotency-Key: 6E175A46-3C69-4C66-8BC8-45BD71EEDB36
后端返回托管结账页地址:
{
"checkoutSessionId": "cs_123",
"checkoutURL": "https://checkout.example.com/session/cs_123",
"expiresAt": "2026-08-29T16:30:00+08:00"
}
后端也可以返回支付 SDK 所需的临时凭据。具体字段由所使用的支付服务商决定,不能直接照搬这个示例。
3. 使用移动端结账页或支付 SDK
如果支付服务商提供官方 iOS SDK,通常优先使用 SDK。它更适合处理 Apple Pay、银行卡输入、3-D Secure 和支付状态恢复。
如果现有结账流程以网页为主,可以将页面改为响应式设计,再通过 WKWebView 或系统浏览器打开。新项目不应再使用 UIWebView。
下面是使用系统浏览器会话的简化示例:
import AuthenticationServices
final class CheckoutCoordinator: NSObject,
ASWebAuthenticationPresentationContextProviding {
private var session: ASWebAuthenticationSession?
func startCheckout(url: URL) {
session = ASWebAuthenticationSession(
url: url,
callbackURLScheme: "ticketapp"
) { callbackURL, error in
guard error == nil, let callbackURL else {
// 用户取消或浏览器流程失败
return
}
// 回调只代表浏览器已返回 App。
// 仍需向后端查询订单的最终支付状态。
self.verifyOrderStatus(from: callbackURL)
}
session?.presentationContextProvider = self
session?.prefersEphemeralWebBrowserSession = false
session?.start()
}
func presentationAnchor(
for session: ASWebAuthenticationSession
) -> ASPresentationAnchor {
guard let scene = UIApplication.shared.connectedScenes
.compactMap({ $0 as? UIWindowScene })
.first,
let window = scene.windows.first(where: { $0.isKeyWindow }) else {
return ASPresentationAnchor()
}
return window
}
private func verifyOrderStatus(from callbackURL: URL) {
// 从回调中取得不敏感的订单引用,
// 然后调用自己的后端查询状态。
}
}
是否适合使用 ASWebAuthenticationSession,取决于结账流程是否需要跨应用验证和回跳。普通的站内结账也可以使用 SFSafariViewController。如果需要嵌入页面并控制更多导航行为,再考虑 WKWebView。
4. 以后端状态作为付款依据
不要只根据返回 URL 中的 success=true 判断支付成功。这个参数可能被伪造,用户也可能在支付成功后没有返回 App。
App 应向后端查询订单状态:
GET /api/v1/orders/order_123
Authorization: Bearer <access-token>
例如:
{
"orderId": "order_123",
"status": "paid",
"tickets": [
{
"ticketId": "ticket_456"
}
]
}
后端应根据支付服务商的签名回调或可信的服务端查询结果更新订单状态。
原生请求示例
下面的代码只负责向自有后端创建结账会话,不直接处理银行卡数据:
import Foundation
struct CheckoutRequest: Encodable {
let eventId: String
let ticketTypeId: String
let quantity: Int
let performanceDate: Date
}
struct CheckoutSession: Decodable {
let checkoutSessionId: String
let checkoutURL: URL
let expiresAt: Date
}
func createCheckoutSession(
request checkout: CheckoutRequest,
accessToken: String
) async throws -> CheckoutSession {
let url = URL(string: "https://api.example.com/api/v1/checkout/sessions")!
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue(
"Bearer \(accessToken)",
forHTTPHeaderField: "Authorization"
)
request.setValue(
"application/json",
forHTTPHeaderField: "Content-Type"
)
request.setValue(
UUID().uuidString,
forHTTPHeaderField: "Idempotency-Key"
)
let encoder = JSONEncoder()
encoder.dateEncodingStrategy = .iso8601
request.httpBody = try encoder.encode(checkout)
let (data, response) = try await URLSession.shared.data(for: request)
guard let httpResponse = response as? HTTPURLResponse,
(200..<300).contains(httpResponse.statusCode) else {
throw CheckoutError.requestFailed
}
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
return try decoder.decode(CheckoutSession.self, from: data)
}
enum CheckoutError: Error {
case requestFailed
}
后端必须真正实现 Idempotency-Key:同一用户使用同一键值重复发起请求时,不能创建多笔收费。仅由客户端添加请求头,并不会自动获得幂等能力。
如果必须分析现有请求
只分析你有权维护或测试的网站。可以在测试环境中使用浏览器开发者工具的 Network 面板:
- 清空网络记录,从选择票种开始,完整操作一次购票流程。
- 找出创建购物车、锁定库存、创建订单和初始化支付的请求。
- 检查请求方法、URL、JSON 或表单字段、Cookie、重定向和响应内容。
- 使用不同票数、日期和登录状态重复测试,区分业务字段和一次性字段。
- 确认 CSRF token、订单 ID 和支付 token 分别由哪个响应产生。
- 在后端代码或网关日志中确认对应路由,不要只凭抓包结果猜测。
- 将需要复用的功能整理成正式、带版本的 API,并为移动端建立明确的认证机制。
浏览器的”Copy as cURL”可以辅助调试,但复制的请求可能包含登录 Cookie、CSRF token 或个人数据。不要将其提交到代码仓库,也不要直接打包进 App。
适配 iPhone 屏幕
如果现有结账页功能完整,通常不需要重写整套支付流程。可以先将页面改为响应式布局:
<meta
name="viewport"
content="width=device-width, initial-scale=1, viewport-fit=cover"
>
.checkout {
width: min(100% - 32px, 640px);
margin-inline: auto;
}
.checkout input,
.checkout select,
.checkout button {
width: 100%;
min-height: 44px;
font-size: 16px;
}
.checkout-actions {
position: sticky;
bottom: env(safe-area-inset-bottom);
}
移动端页面还需要注意:
- 避免横向滚动和固定的桌面端宽度。
- 为表单控件提供足够大的点击区域。
- 正确设置
autocomplete、inputmode和字段类型。 - 保留订单摘要、总金额和退款规则。
- 支持用户中断后恢复订单。
- 不要在 URL 中携带支付凭据或个人敏感信息。
- 对失败、取消、库存失效和重复提交显示明确状态。
App Store 与支付方式注意事项
实体活动、现场演出或线下服务的门票,通常与纯数字内容适用的应用内购买规则不同。不过,具体要求可能随销售内容、地区和审核规则变化。发布前应核对当时适用的 App Store Review Guidelines,并确认支付服务商是否允许在嵌入式 WebView 中完成支付。
推荐的整体架构是”App 选择票务信息 → 后端创建订单 → SDK 或移动结账页支付 → webhook 确认 → App 查询订单并出票”。这种方式比复刻网页的最后一个请求更稳定,也更容易满足安全、库存一致性和支付合规要求。
备注:内容仅供参考。
