支付宝沙箱支付实战记录从生成支付链接到异步回调验签这篇博客记录一下我在「医云问」项目里接入支付宝沙箱支付的过程。项目里的支付场景比较典型患者预约挂号后点击「去支付」后端生成一个支付宝沙箱支付链接前端打开这个链接患者在支付宝沙箱页面完成支付。支付成功后支付宝再通知我方后端我方后端完成验签并把预约状态从pending更新为confirmed。听起来像是“点一下、跳一下、付一下、改一下状态”但真正接的时候会发现支付链路最麻烦的地方不是发起支付而是签名、验签、回调地址和状态一致性。换句话说支付系统的核心不是“让钱飞一会儿”而是确认这笔钱真的从正确的地方飞过来了。一、整体链路先放一张我整理的流程图这条链路里有三个角色患者浏览器负责点击支付、跳转支付宝页面、支付完成后回到前端页面。我方后端负责生成支付链接、签名、接收支付宝异步通知、验签、更新预约状态。支付宝服务器负责展示收银台、处理支付、向我方后端发起回调通知。核心流程可以拆成四步患者点击「去支付」前端请求后端接口POST /api/v1/pay/create。后端构造支付宝支付参数使用应用私钥进行 RSA2 签名拼接出pay_url返回给前端。前端通过window.open(pay_url)跳转到支付宝沙箱收银台。用户完成支付后支付宝触发异步回调notify_url后端验签成功后更新预约状态。这里要特别注意return_url和notify_url不是一回事。return_url是浏览器同步跳转地址主要用于让用户支付后回到前端页面它更偏“用户体验”。notify_url是支付宝服务器主动请求我方后端的异步通知地址主要用于确认支付结果它才是后端更新订单状态的依据。所以不能因为用户浏览器跳回了前端页面就直接认为支付成功。真正可靠的支付结果应该以后端收到并验签通过的notify_url为准。二、后端接口设计我在 FastAPI 里做了两个支付相关接口POST /api/v1/pay/create创建支付宝支付链接。POST /api/v1/pay/notify接收支付宝异步回调。创建支付链接的接口代码如下classPayCreateRequest(BaseModel):appointment_id:intamount:float9.99router.post(/create)asyncdefcreate_pay_order(body:PayCreateRequest,db:AsyncSessionDepends(get_db),):创建支付宝订单返回跳转 URLapptawaitdb.get(Appointment,body.appointment_id)ifnotappt:raiseHTTPException(status_code404,detail预约不存在)pay_urlbuild_pay_url(body.appointment_id,body.amount)return{pay_url:pay_url,order_no:fappt-{body.appointment_id}}这个接口的职责很纯粹先检查预约是否存在然后生成支付宝支付链接。这里我用了appointment_id来生成商户订单号out_trade_nofappt-{appointment_id}这样做的好处是后面收到支付宝回调时可以从out_trade_no里反推出对应的预约记录。不过在真实生产项目里不太建议直接用业务表 ID 当支付订单号。更稳的做法是单独建一张支付订单表比如payment_order里面保存支付订单号业务 ID支付金额支付状态支付宝交易号创建时间回调时间这样支付系统和预约系统之间的边界会更清晰后续扩展退款、重复支付检测、对账也更舒服。三、构造支付宝支付链接支付宝电脑网站支付使用的方法是alipay.trade.page.pay项目里拼接支付链接的核心代码是defbuild_pay_url(appointment_id:int,amount:float)-str: 拼接支付宝电脑网页支付跳转 URL biz_contentjson.dumps({out_trade_no:fappt-{appointment_id},product_code:FAST_INSTANT_TRADE_PAY,total_amount:f{amount:.2f},subject:f医云问预约挂号#{appointment_id},},ensure_asciiFalse,separators(,,:))params{app_id:settings.alipay_app_id,method:alipay.trade.page.pay,charset:utf-8,sign_type:RSA2,timestamp:time.strftime(%Y-%m-%d %H:%M:%S),version:1.0,notify_url:NOTIFY_URL,return_url:RETURN_URL,biz_content:biz_content,}params[sign]_sign(params)query_stringurllib.parse.urlencode(params)returnf{ALIPAY_GATEWAY}?{query_string}这里最关键的是biz_content。它不是普通的 URL 参数而是一个 JSON 字符串里面放的是业务订单信息out_trade_no商户订单号必须保证唯一。product_code电脑网站支付固定使用FAST_INSTANT_TRADE_PAY。total_amount支付金额建议统一保留两位小数。subject订单标题会展示在支付宝收银台里。拼好这些参数后不能直接发给支付宝还要先签名。四、RSA2 签名支付宝的 RSA2 签名大致可以理解成把请求参数按 key 的字母顺序排序。拼成keyvaluekeyvalue格式的字符串。使用应用私钥做SHA256withRSA签名。把签名结果进行 Base64 编码。再把签名一起放进 URL 参数里。项目里的签名函数是这样的def_sign(params:dict)-str: RSA2 签名 1. 参数按 key 字母序排列拼成 keyvaluekeyvalue 字符串 2. 用应用私钥对字符串做 SHA256withRSA 签名 3. Base64 编码后 URL encode sorted_paramssorted(params.items())sign_str.join(f{k}{v}fork,vinsorted_params)private_key_str(-----BEGIN PRIVATE KEY-----\nsettings.alipay_app_private_key\n-----END PRIVATE KEY-----)keyRSA.import_key(private_key_str)hSHA256.new(sign_str.encode(utf-8))signaturepkcs1_15.new(key).sign(h)returnbase64.b64encode(signature).decode(utf-8)我这里踩过一个很典型的坑私钥头部格式。支付宝工具生成的应用私钥通常是 PKCS8 格式对应的头尾应该是-----BEGIN PRIVATE KEY----- -----END PRIVATE KEY-----如果误写成下面这种-----BEGIN RSA PRIVATE KEY----- -----END RSA PRIVATE KEY-----就可能因为 PKCS1 和 PKCS8 格式不匹配导致签名失败。这个错误看起来很像“库坏了”或者“密钥坏了”但实际只是外壳格式不对。程序员排查这类问题的时候血压一般会先替 CPU 超频。五、前端跳转支付后端返回的数据大概是{pay_url:https://openapi-sandbox.dl.alipaydev.com/gateway.do?...,order_no:appt-123}前端拿到pay_url后直接打开新窗口或者当前页面跳转即可window.open(payUrl)支付完成后支付宝会根据return_url把浏览器跳回前端页面RETURN_URLhttp://localhost:5173/patient/appointment?paysuccess再次强调一下这个跳转只能说明“用户浏览器走完了这个流程”不能作为后端确认支付成功的依据。如果只靠return_url改状态就会留下安全隐患。因为用户可以自己伪造一个类似这样的地址http://localhost:5173/patient/appointment?paysuccess这不代表他真的付款了。六、支付宝异步回调与验签真正的支付确认发生在notify_urlSERVER_BASEhttp://localhost:8001NOTIFY_URLf{SERVER_BASE}/api/v1/pay/notify支付宝支付成功后会向这个地址发送一个POST请求里面包含订单号、交易状态、支付宝交易号、签名等字段。后端收到之后第一件事不是更新数据库而是验签。router.post(/notify)asyncdefalipay_notify(request:Request,db:AsyncSessionDepends(get_db)):支付宝异步回调验签后更新预约状态form_dataawaitrequest.form()datadict(form_data)signaturedata.pop(sign,)data.pop(sign_type,None)ifnot_verify(data,signature):returnJSONResponse(contentfail,status_code400)trade_statusdata.get(trade_status,)out_trade_nodata.get(out_trade_no,)iftrade_statusin(TRADE_SUCCESS,TRADE_FINISHED):try:appointment_idint(out_trade_no.replace(appt-,))exceptValueError:returnJSONResponse(contentfail,status_code400)apptawaitdb.get(Appointment,appointment_id)ifapptandappt.statuspending:appt.statusconfirmedawaitdb.commit()returnJSONResponse(contentsuccess)验签时要把sign字段拿出来因为它是签名结果本身不能参与待验签字符串的拼接。sign_type也不参与验签所以这里一起移除了。然后用支付宝公钥进行验签def_verify(params:dict,signature:str)-bool: 验证支付宝回调签名 try:public_key_str(-----BEGIN PUBLIC KEY-----\nsettings.alipay_public_key\n-----END PUBLIC KEY-----)keyRSA.import_key(public_key_str)sorted_paramssorted(params.items())sign_str.join(f{k}{v}fork,vinsorted_params)hSHA256.new(sign_str.encode(utf-8))sig_bytesbase64.b64decode(signature)pkcs1_15.new(key).verify(h,sig_bytes)returnTrueexceptException:returnFalse验签通过之后才说明这个回调确实来自支付宝而且参数没有被中途篡改。这一步是支付系统里的安全闸门。没有验签就更新订单状态相当于医院收费窗口写了个牌子“你说你交了那就算你交了。”这显然不太适合出现在严肃项目里。七、状态更新从 pending 到 confirmed支付成功后我把预约状态从pending更新为confirmed代码里还有一个很重要的小判断ifapptandappt.statuspending:appt.statusconfirmedawaitdb.commit()这个判断是在做最基础的幂等控制。支付宝异步通知并不保证只发送一次。如果我方后端没有按要求返回成功或者网络中间出了问题支付宝可能会重复通知。所以回调接口必须能够接受重复请求。第一次请求把状态从pending改成confirmed后面的重复请求发现状态已经不是pending就不再重复更新。真实项目里还可以进一步增强记录支付宝交易号trade_no。校验回调金额是否等于订单金额。校验app_id是否是自己的应用。校验seller_id是否是自己的收款账号。单独记录支付回调日志方便排查问题。这些都属于支付系统里很值得加分的稳定性设计。八、最容易踩的几个坑1. notify_url 不能是 localhost这是接支付宝沙箱时最常见的坑。我本地代码里暂时写的是SERVER_BASEhttp://localhost:8001NOTIFY_URLf{SERVER_BASE}/api/v1/pay/notify但是支付宝服务器在公网它访问不到我电脑上的localhost:8001。所以在本地调试异步回调时需要使用内网穿透工具比如 cpolar、ngrok、natapp或者把后端服务部署到一台公网服务器上。否则就会出现一种很迷惑的情况用户在沙箱页面支付成功了浏览器也跳回前端了但是后端状态一直没有变。原因不是支付失败而是支付宝根本通知不到你的本地后端。2. return_url 不是支付成功凭证return_url是浏览器跳转用户可见也容易被伪造。后端不能因为前端 URL 上带了paysuccess就直接把订单改成已支付。正确姿势是前端可以根据return_url展示“支付处理中”或者刷新订单状态但最终状态以后端异步回调为准。3. 验签时不要把 sign 放进待签名字符串支付宝回调里的sign是签名结果不能参与验签字符串拼接。如果把它也拼进去验签一定失败。项目里对应的处理是signaturedata.pop(sign,)data.pop(sign_type,None)4. 回调成功要返回 success支付宝要求异步通知处理成功后返回success。如果没有正确返回支付宝会认为通知失败然后继续重试。在 FastAPI 里有个细节需要注意如果写成returnJSONResponse(contentsuccess)响应体可能是 JSON 字符串形式的success而不是纯文本success。更稳的写法是fromfastapi.responsesimportPlainTextResponsereturnPlainTextResponse(success)失败时也可以返回returnPlainTextResponse(fail,status_code400)这个细节很小但支付平台一般都很较真。它们不是在为难你它们只是在用一种非常机器的方式提醒你“请严格按协议来。”九、这次接入的收获这次支付宝沙箱支付接入下来我最大的感受是第三方支付并不是简单调用一个接口而是在处理一套跨系统的可信通信流程。前端跳转只是表层体验真正重要的是后端这几件事如何生成可信的支付请求。如何证明回调确实来自支付宝。如何避免重复通知导致状态异常。如何区分同步跳转和异步通知。如何让业务状态和支付状态最终一致。如果把它和全栈项目联系起来看支付模块其实非常适合写进简历因为它能体现的不只是“我会调接口”还包括安全意识、异步流程理解、状态机设计和工程排错能力。对于「医云问」这个项目来说支付宝沙箱支付让预约挂号流程更完整了用户不只是能预约还能模拟真实线上挂号里的支付闭环。后续如果继续完善我会优先补这几个点增加支付订单表拆开预约状态和支付状态。接入公网回调地址完整跑通支付宝异步通知。回调时校验金额、应用 ID、商户号等关键字段。保存回调原始报文方便问题追踪和对账。增加支付超时关闭订单的定时任务。这样一来支付模块就不只是“能跑”而是更接近真实业务系统里的支付闭环。十、总结这次支付宝沙箱支付的核心链路可以总结成一句话后端生成带 RSA2 签名的支付链接前端跳转支付宝沙箱收银台用户支付后支付宝异步通知后端后端验签通过后更新业务状态并返回success结束通知重试。整条链路里最值得记住的是两个判断用户浏览器跳回来了不等于支付一定成功。支付宝异步回调验签通过了才是后端更新订单状态的依据。支付系统看起来是在处理钱其实更像是在处理“信任”。谁发来的请求可信哪些字段不能被篡改重复通知怎么处理状态什么时候才能落库这些才是它真正有技术含量的地方。也就是说接支付最重要的不是“让用户扫码”而是别让系统被一条假回调骗得团团转。钱包可以羞涩验签必须硬气。