ARTICLE DETAIL

资讯详情

深耕商务建站与企业官网运营的一线实战洞察。

Golang 内购服务端校验:Apple IAP 与 Google Play

Golang 内购服务端校验:Apple IAP 与 Google Play 做内购这块我前后在三个项目里踩过坑一次是 Apple 收据校验在灰度环境全挂一次是 Google Play 商品因为忘记调确认接口被系统在第三天自动退款还有一次是订阅续订的通知没接用户续费了但权益没续上。这几次事故之后我把整套链路重新捋了一遍用 Golang 写了统一的服务端校验层把Golang 内购接入流程和解析Google Play、Apple IAP这件事拆成可复用的模块。这篇内容讲的就是这套东西移动端应用内购买的服务端校验包含 Apple IAP 的收据校验与 StoreKit 2 的签名验证也包含 Google Play 的购买令牌校验、确认与订阅状态查询。它解决的问题很直白——客户端传来的我付过钱了这句话一个字都不能信必须由服务端去平台侧核对。适合已经有 Go 后端基础、正在接内购或者被内购退款问题折磨过的同学看也适合刚接手支付模块、需要快速搞清楚链路全貌的人。下面按设计思路、核心机制、实操落地、问题排查四块来讲代码都可以直接拿去改。1. 内购链路的整体设计与方案选型1.1 客户端在支付链路里能做的事比想象中少很多新手一开始会把内购理解成客户端调个支付弹窗成功了告诉我一声。这个理解如果只做 Demo 没问题一旦上线就是灾难。平台侧的支付结果在客户端手里只是一个凭据Apple 给的是收据receipt或 JWS 签名串Google 给的是一个 purchaseToken 字符串。它们本身没有任何防伪能力用户越狱或者抓包之后把这些字符串换成别人的、伪造的、重放的你的服务端如果照单全收就等于开了个免费领取的口子。所以链路必须这样切客户端只负责三件事——拉起支付、拿到凭据、把凭据扔给自家服务端。服务端负责拿凭据去平台服务器核对核对通过之后才落单、发货、写权益。这样一来客户端能造假的环节就只剩把别人的凭据发给我而这种重放会被订单表的唯一索引挡住。我在第一版设计里吃过一个亏为了省一次请求让客户端在支付成功回调里直接把商品 ID 和金额带上服务端核对收据之后用客户端传的金额入账。结果测试同学随手改了个金额参数虚拟币到账数直接翻倍。教训很直接——价格、商品 ID、币种这些信息必须从平台返回的报文或者你自己的商品配置表里取客户端传过来的只当作参考日志。1.2 服务端校验架构的两种取舍架构上实际只有两种做法。第一种是即时校验客户端拿到凭据立刻请求服务端服务端同步调平台接口校验通过返回发货结果。第二种是落库异步校验服务端先收下凭据写进待校验表立刻返回已受理后台 worker 慢慢去调平台接口。两种我都在生产用过选择看场景。即时校验的优点是用户体验好支付完立刻看到权益到账代码链路也短。缺点是平台接口抖一下用户就卡在支付成功但不到账的页面还容易重复点击造成并发。异步校验的优点是抗抖动平台接口超时了可以重试客户端也不用等。缺点是用户要等而且需要一个补偿机制告诉客户端你的货到了。我的做法是混合先落库用唯一索引保证幂等然后在请求内同步尝试一次校验成功就直接返回成功失败就把订单标记成待重试丢给后台客户端拿到处理中状态开始轮询。这样既保住了快路径的体验又不会被平台接口的抖动拖死。后台 worker 用的是固定间隔加指数退避间隔从 5 秒起步最多重试 8 次累计大概覆盖半小时超过就进人工队列。1.3 数据模型与状态机怎么定表结构这块我建议一开始就想清楚后面改动成本很高。核心是两张表订单表和权益表。订单表记录每一次支付尝试权益表记录用户最终拥有的东西。中间用订单 ID 关联不要把两者合成一张表否则退款、订阅续期这些场景会让你非常难受。订单表的关键字段我列一下直接用 SQL 表达更清楚CREATE TABLE iap_order ( id BIGSERIAL PRIMARY KEY, user_id BIGINT NOT NULL, platform SMALLINT NOT NULL, -- 1apple 2google product_id VARCHAR(128) NOT NULL, order_no VARCHAR(64) NOT NULL, -- 自家订单号 outer_txn_id VARCHAR(128) NOT NULL, -- 平台交易号 / purchaseToken 哈希 original_txn_id VARCHAR(128) DEFAULT , -- Apple 原始交易号订阅续期用 receipt TEXT DEFAULT , -- 原始凭据留档排查 amount BIGINT DEFAULT 0, -- 单位分 currency VARCHAR(8) DEFAULT , status SMALLINT NOT NULL DEFAULT 0, env SMALLINT NOT NULL DEFAULT 0, -- 0生产 1沙盒 expire_at TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE UNIQUE INDEX uk_iap_order_outer ON iap_order(platform, outer_txn_id); CREATE UNIQUE INDEX uk_iap_order_no ON iap_order(order_no);那个唯一索引是整张表的命门。并发请求、客户端重试、消息重复投递全部靠它在数据库层拦住比在代码里加锁可靠得多。Go 侧配合INSERT ... ON CONFLICT DO NOTHING加RETURNING id插入成功就继续走校验插入失败说明是重复请求直接查老订单返回当前状态就行一次数据库往返解决问题。状态机我定成五个状态用整数表示0 待校验、1 已支付待发货、2 已完成、3 已退款、4 校验失败需人工。流转规则很简单只允许 0→1→2 和 1→3其他跳转一律拒绝并打告警日志。状态机的作用不是好看是让你在出了退款、掉单、重复回调这些事的时候能一眼看出订单卡在哪一步。2. 两条通道的核心机制与关键细节2.1 Apple 收据校验verifyReceipt 的老路与 StoreKit 2 的新路Apple 这边历史包袱比较重现在同时存在两套东西。老的一套是verifyReceipt接口客户端从SKPaymentQueue拿到一个 base64 编码的收据串服务端 POST 到https://buy.itunes.apple.com/verifyReceipt请求体里除了收据还要带共享密钥订阅类商品必需。返回的 JSON 里status为 0 表示校验通过非 0 就是各类错误码。这里有个所有接入方都会遇到的坑生产地址返回 21007 的时候说明你拿到的是沙盒收据必须改打https://sandbox.itunes.apple.com/verifyReceipt重新校验一次。反过来沙盒地址返回 21008 说明是生产收据。标准做法是先生产后沙盒无条件打生产收到 21007 再降级打沙盒。我见过有同学反过来写先打沙盒再打生产结果沙盒接口在生产环境经常超时白白多一次 RTT。新的 StoreKit 2 换了一套思路不再给整包收据而是给一个 JWS 签名的Transaction对象。这个串本身带签名服务端用 Apple 的公钥链验签就能确认内容没被篡改。JWS 的 header 里有x5c字段是个证书数组第一张是签名证书最后一张是根证书 Apple Root CA - G3。验签流程是拿x5c拼出证书链验证链上每一级的签名关系取第一张证书的公钥验 JWS 的签名最后检查 payload 里的bundleId、environment、transactionId跟你预期的一致。这两套要选哪个我的建议是新项目直接上 StoreKit 2 加服务端 JWS 验签再配一套 App Store Server Notifications V2 接收续订、退款、撤销事件。老项目如果还在用SKPaymentQueue别急着大改先把降级逻辑和错误码处理好等有排期了再迁移。因为两套的收据格式、有效期字段、订阅信息结构全不一样混着用很容易在解析层出 bug。JWS 验签的 Go 实现通常用github.com/go-jose/go-jose/v3把x5c里的证书逐个x509.ParseCertificate然后手动验证上下级签名最后jose.ParseSigned验 payload。这一步我建议写单元测试固定住因为证书链校验写错往往不会报错只会一直返回 false。2.2 Google Play购买令牌、服务端校验与确认机制Google 这边链路相对干净但有几个机制如果不了解会导致很隐蔽的资损。客户端用 Play Billing 完成支付后会拿到三样东西purchaseToken、productId、orderId。服务端拿这三个去调 Android Publisher API一次性商品走purchases.products.get订阅走purchases.subscriptionsv2.get。鉴权用服务账号Service Account在 Google Cloud 控制台创建下载 JSON 密钥放到服务器上授予 Play Console 里的财务权限和订单查询权限。Go 侧用golang.org/x/oauth2/google加google.golang.org/api/androidpublisher/v3就够了SDK 会自动处理 token 缓存和刷新。要注意的是权限授予有延迟刚加完服务账号就去调接口经常会拿到 401 或 403等五分钟再试一般就好。真正容易出事的是确认机制acknowledge。Google 规定一笔购买必须在三天内被确认否则会被自动退款。这个确认不是校验很多同学把products.get返回purchaseState 1当成万事大吉然后就没下文了三天后用户收到退款通知货还在用户手里。正确流程是校验通过并且发货成功之后立刻调purchases.products.acknowledge。消耗型商品比如金币用purchases.products.consume替代consume 本身也带确认效果。订阅也有类似的东西subscriptionsv2.get返回的subscriptionState是SUBSCRIPTION_STATE_ACTIVE才算有效还有acknowledgementState字段0 表示还没确认。订阅的确认同样是三天期限。另外订阅还有个账号绑定选项开启之后 purchaseToken 会跟用户账号绑定换账号无法使用是否开启取决于你的产品设计我个人倾向开启能有效减少共享行为。还有一个容易被忽略的点purchaseState 2表示挂起pending通常是用户选了现金支付等延迟付款方式。这种订单在校验时会返回 pending你不应该发货也不能拒绝而是要等状态变化。实现上要么靠实时开发者通知RTDN推送要么靠客户端下次启动时重查。我的做法是两条都上RTDN 走 Pub/Sub 推到一个 HTTP 端点收到消息后不信任消息内容拿里面的 purchaseToken 再去查一次真实状态这样最稳。2.3 幂等、补单与退款回收幂等这件事说穿了就三层防护第一层是客户端侧的按钮防抖防止用户连点第二层是服务端的分布式锁按 purchaseToken 加锁锁的粒度要细第三层是数据库唯一索引这是最后一道保险。三层里只有第三层是绝对可靠的前两层都可能因为网络分区失效。所以我写代码时永远假设前两层不存在唯一索引冲突就老老实实返回已有订单。补单是个必需品而不是可选项。用户支付成功但网络断了的场景每天都在发生客户端本地要缓存未完成的交易下次启动时按顺序重新上报。我建议给客户端一个未完成订单队列用本地数据库存上报成功一条删一条。Apple 的SKPaymentQueue本身会保留未完成的交易并在下次启动时重放Google 的queryPurchasesAsync也能拿到所有有效购买但主动补单在跨设备、重装应用之后仍然必要。退款回收是很多人上线后才想起来做的。Apple 的退款通过 App Store Server Notifications V2 的REFUND或REVOKE通知下发Google 的通过voidedPurchases.list接口或者 RTDN 的 voided 通知下发。收到之后要做的是把对应订单状态置为已退款把权益收回或者标记成负数而不是直接删记录。删记录会导致对账时对不上而且用户申诉时你没有依据。3. 从零跑通的实操过程3.1 账号与凭证准备Apple 侧需要的东西App Store Connect 里的应用、一个内购商品配置好商品 ID 和价格档位、一个用于收据校验的共享密钥在应用信息的App 专用共享密钥里生成。如果做订阅还要配置订阅群组。订阅群组的层级设计会直接影响用户升级降级时的行为建议先把档位排好再上线后改很麻烦。Google 侧需要Play Console 里创建应用、上传一个带 billing 权限的签名包到内部测试轨道、配置商品、创建服务账号并授权。注意商品必须在有有效 APK 的轨道上才能生效光在草稿状态创建商品客户端是拉不到商品列表的。这一点卡了我半天一直以为是代码问题其实是包没传。配置统一放配置文件或者密钥管理服务里不要硬编码。我用的是一个结构体从环境变量加载方便在不同环境切换。共享密钥这种敏感信息单独存权限收紧到只有支付服务能读。# 环境变量示例 export IAP_APPLE_SHARED_SECRETxxxxxxxx export IAP_APPLE_BUNDLE_IDcom.example.app export IAP_GOOGLE_SA_FILE/etc/iap/service-account.json export IAP_GOOGLE_PACKAGEcom.example.app3.2 Apple 校验服务的 Go 实现先看数据结构定义。verifyReceipt的响应体字段特别多只定义你需要的字段就够了Go 的 json 解析会忽略多余字段。type appleVerifyRequest struct { ReceiptData string json:receipt-data Password string json:password,omitempty ExcludeOld bool json:exclude-old-transactions } type appleVerifyResponse struct { Status int json:status Env string json:environment Receipt struct { BundleID string json:bundle_id InApp []struct { ProductID string json:product_id TransactionID string json:transaction_id OriginalTxID string json:original_transaction_id PurchaseDateMs string json:purchase_date_ms ExpiresDateMs string json:expires_date_ms IsTrialPeriod string json:is_trial_period InAppOwnershipType string json:in_app_ownership_type } json:in_app } json:receipt LatestReceiptInfo []struct { ProductID string json:product_id TransactionID string json:transaction_id ExpiresDateMs string json:expires_date_ms } json:latest_receipt_info }校验函数的核心是生产降级沙盒和状态码分流两件事写在一起const ( appleProdURL https://buy.itunes.apple.com/verifyReceipt appleSandboxURL https://sandbox.itunes.apple.com/verifyReceipt ) func verifyApple(ctx context.Context, receipt string, sharedSecret string) (*appleVerifyResponse, bool, error) { body, _ : json.Marshal(appleVerifyRequest{ ReceiptData: receipt, Password: sharedSecret, ExcludeOld: true, }) resp, err : postJSON(ctx, appleProdURL, body) if err ! nil { return nil, false, err } var out appleVerifyResponse if err : json.Unmarshal(resp, out); err ! nil { return nil, false, err } if out.Status 0 { return out, false, nil } if out.Status 21007 { // 沙盒收据打到了生产地址降级重试 resp2, err : postJSON(ctx, appleSandboxURL, body) if err ! nil { return nil, true, err } var out2 appleVerifyResponse if err : json.Unmarshal(resp2, out2); err ! nil { return nil, true, err } if out2.Status 0 { return out2, true, nil } return nil, true, fmt.Errorf(apple sandbox verify failed: %d, out2.Status) } return nil, false, fmt.Errorf(apple verify failed: %d, out.Status) }postJSON里我加了 15 秒超时和一次重试重试只针对网络错误和 5xx业务错误码不重试因为重试也不会变。超时时间不能设太短Apple 的校验接口在促销期间偶尔会到 8 秒以上设 5 秒会误杀。拿到响应之后要做三件事核对receipt.bundle_id是不是自己的应用核对返回的 product_id 是不是订单里的产品核对in_app里确实存在这条transaction_id。这三步都是防止串号和伪造的必要检查。金额不要从响应里取Apple 的收据里没有可靠的金额字段价格得查你自己的商品配置表。3.3 Google Play 校验与确认的 Go 实现Google 侧的结构比 Apple 干净先初始化 servicefunc newPublisher(ctx context.Context, saFile string) (*androidpublisher.Service, error) { data, err : os.ReadFile(saFile) if err ! nil { return nil, err } creds, err : google.CredentialsFromJSON(ctx, data, androidpublisher.AndroidpublisherScope) if err ! nil { return nil, err } return androidpublisher.NewService(ctx, option.WithCredentials(creds)) }一次性商品的校验与确认func verifyConsumable(ctx context.Context, svc *androidpublisher.Service, pkg, productID, token string) error { purchase, err : svc.Purchases.Products.Get(pkg, productID, token).Context(ctx).Do() if err ! nil { return fmt.Errorf(get product purchase: %w, err) } // purchaseState: 0 未指定 1 已购买 2 挂起 if purchase.PurchaseState ! 1 { return fmt.Errorf(purchase state not completed: %d, purchase.PurchaseState) } if purchase.OrderId { return errors.New(empty order id, suspicious) } // 发货逻辑放在这里且必须幂等 if err : grantGoods(ctx, purchase.OrderId, productID); err ! nil { return err } // 消耗型商品用 consume它同时完成确认 if err : svc.Purchases.Products.Consume(pkg, productID, token).Context(ctx).Do(); err ! nil { return fmt.Errorf(consume: %w, err) } return nil }订阅的状态查询用的是 v2 接口返回结构比 v1 清晰很多func verifySubscription(ctx context.Context, svc *androidpublisher.Service, pkg, token string) (*androidpublisher.SubscriptionPurchaseV2, error) { sub, err : svc.Purchases.Subscriptionsv2.Get(pkg, token).Context(ctx).Do() if err ! nil { return nil, err } switch sub.SubscriptionState { case SUBSCRIPTION_STATE_ACTIVE, SUBSCRIPTION_STATE_IN_GRACE_PERIOD, SUBSCRIPTION_STATE_CANCELED: // 三种状态都要给权益宽限期和已取消未到期都要算有效 default: return nil, fmt.Errorf(subscription inactive: %s, sub.SubscriptionState) } if sub.AcknowledgementState ACKNOWLEDGEMENT_STATE_PENDING { if err : svc.Purchases.Subscriptions.Acknowledge(pkg, token, androidpublisher.SubscriptionPurchasesAcknowledgeRequest{}). Context(ctx).Do(); err ! nil { return nil, fmt.Errorf(ack: %w, err) } } return sub, nil }这里有个细节值得单独说SUBSCRIPTION_STATE_CANCELED状态的订阅用户虽然取消了自动续订但在当前付费周期结束前仍然有权益。很多实现只认 ACTIVE导致用户一取消就掉权益被投诉的概率极高。判断权益是否有效应该看lineItems[].expiryTime那个时间没到就继续给。3.4 联调与沙盒验证Apple 的沙盒环境需要在设备上登录沙盒测试账号这个账号在 App Store Connect 的用户与访问里创建跟真实 Apple ID 完全隔离。沙盒环境下订阅周期会被大幅压缩周订阅 3 分钟就走完一个周期方便测续订逻辑。测试时建议把续订通知的接收方指向一台能看日志的机器观察整个周期的事件流。Google 的测试账号在 Play Console 的许可测试里配置配置之后用这个账号下单不实际扣款。这里有个限制测试购买只能走测试轨道或者内部测试轨道正式轨道上的包不会用测试账号。联调我一般跑五个用例跑通了才敢上灰度正常购买到账、重复上报同一笔凭据、平台校验接口超时后补单、退款通知回收到权益、订阅跨周期续订后权益续期。这五个用例覆盖了内购 90% 的故障场景。第一次接入的时候我在沙盒里反复跑这五个跑了大概两天后来真上线的时候确实没出大问题。4. 高频问题与排查实录4.1 错误码速查表Apple 和 Google 的错误码如果只看文档会很难对应到实际问题我整理了一张按真实遇到过的场景归纳的表排查时对着看比翻文档快。平台返回常见原因处理方式Apple21000请求体不是合法的 JSON 或 HTTP 方法不对检查 Content-Type 和 POST 方法Apple21002收据数据格式异常确认 base64 没被二次编码或截断Apple21003收据无法通过认证收据被篡改或来自不明的应用Apple21004共享密钥不匹配检查密钥是否对应当前应用Apple21005服务器暂不可用隔 30 秒重试别密集打Apple21006订阅已过期正常情况不发货即可Apple21007沙盒收据打到了生产地址降级到沙盒地址重试Apple21008生产收据打到了沙盒地址说明环境配置错切回生产Apple21010账号不存在或已过期检查订阅是否被 Apple 客服撤销Google401服务账号 token 无效检查密钥 JSON 和系统时间Google403未授权或权限不足Play Console 里补授订单查询权限Google404商品或购买记录不存在productId 拼错或包名不对Google410purchaseToken 已作废已被退款或已消耗不发货Google429调用频率超限加退避别把校验放在循环里GooglepurchaseState2支付挂起不发货物等 RTDN 或客户端重查GoogleacknowledgementState0未确认立即调 acknowledge超过三天会退款表里最后两行是我认为最值得单独记住的。挂起状态如果当成失败直接拒绝用户付了钱拿不到东西会来投诉未确认如果忘了调三天后平台自动退款用户白拿资损是静默发生的不对账根本发现不了。4.2 那些只有踩过才知道的坑收据必须原样入库。一开始我觉得收据太长没必要存只存交易号。结果有一次用户申诉说没收到货平台侧显示已扣款我这边查不到记录因为没有原始收据连手动补验都没法做。现在我在订单表里留了receipt TEXT字段压缩后存储成本很低救命的次数不少。时间一律用 UTC金额一律用整数存最小单位。这两个规范不是洁癖是血泪。我们踩过一次时区问题Apple 返回的expires_date_ms是毫秒时间戳转换时用了本地时区在跨境用户那里算出了负数剩余时间订阅直接判为过期。金额用浮点数存的问题更明显0.1 0.2不等于0.3对账时差几分钱查到最后是浮点精度。日志要留请求和响应原文但要脱敏。排查内购问题 90% 的时间在比对报文没有原始报文基本靠猜。收据和 purchaseToken 属于凭据日志里只打前 8 位加后 4 位共享密钥绝对不打。我习惯在日志里带一个 traceID从客户端请求一路串到平台接口调用出问题直接 grep。不要在校验链路里做耗时操作。最开始我把发货逻辑写权益、发消息、更新用户等级全塞在校验请求里同步做平台接口一慢整个请求就超时用户重试又触发一次。后来拆成两步校验只负责写订单状态发货走内部消息队列消费端做真正的权益变更。这样校验的耗时稳定在 200ms 以内用户体验好很多。订阅续订的通知要做去重。App Store Server Notifications 是至少一次投递同一条通知可能来两次甚至更多。我第一次接的时候没做去重用户续费一次权益加了两次续了两个月。去重键用notificationUUID配合唯一索引简单有效。别忽略家庭共享和跨区差异。Apple 的家庭共享会让一笔购买被多个账号使用收据里的in_app_ownership_type字段会标成FAMILY_SHARED这时候要不要给权益得看你的产品规则。Google 那边则要考虑不同国家地区的税率和定价同一商品在不同地区的实际金额差异很大做对账时不能用固定金额比对。灰度阶段先把校验接口的监控拉满。我给校验接口配了四个指标成功率、P95 耗时、各错误码计数、沙盒流量占比。沙盒流量占比这个指标特别有用正常生产环境的沙盒流量应该是 0一旦冒出来说明有测试包泄到了正式环境或者有用户在伪造沙盒收据——两种情况都得立刻看。最后分享一个我在架构上的取舍。这套东西现在的形态是一个独立的支付服务对外只暴露两个 RPCVerify和Query。所有平台差异都收敛在服务内部业务方不需要知道是 Apple 还是 Google。商品配置也放在这个服务里一个 product_id 对应一份权益描述业务方只按 product_id 领取。这么做的好处是以后接第三个平台业务侧一行代码都不用动。代价是支付服务成了关键路径所以它的可用性要求比其他服务高一个等级我给它配了独立的数据库实例和独立的告警通道宁可多花点资源也不想再经历一次支付不可用导致的全局故障。
返回列表
PREV
查看更多资讯
NEXT
返回资讯列表