1. 银联后台 HTTPS 联调为什么总卡在证书这一步
Android 端要访问银联后台,绕不开 HTTPS。很多人第一次对接时,用浏览器或 Postman 能通,一放到 App 里就报SSLHandshakeException、Trust anchor for certification path not found,或者干脆Connection reset。问题往往不在 Retrofit 或 OkHttp 本身,而在证书信任链没配对。
银联后台这类金融接口,通常不会用公共 CA 签发的证书,而是走自签或内部中级 CA。Android 系统默认只信任预置的根证书,你拿到的.pem、.cer、.crt如果不手动加载进TrustManager,OkHttp 就会拒绝握手。再加上报文头是定制的、Body 是十六进制二进制流,普通 JSON 请求那套写法直接套上去必然失败。
这篇面向的场景很具体:Android 用 Retrofit + OkHttp 访问银联 HTTPS 后台,需要导入本地公钥证书、自定义SSLSocketFactory和X509TrustManager、设置请求头与超时,最后用 curl 或 Postman 对照验证,一次联调通过。适合谁?正在做支付、金融类 App 对接,手上有银联或类似机构给的证书文件,但被 HTTPS 握手和二进制报文卡住的 Android 开发者。
核心检索词先明确:Android HTTPS 证书配置、Retrofit OkHttp 自签证书信任、银联后台联调。下面按“问题定位 → 证书准备 → 代码配置 → 验证 → 排错”的顺序走,每一步都能直接复制。
先说一个容易忽略的点:证书文件放哪里。放res/raw还是assets,读取方式不同。放assets用getAssets().open("xxx.pem"),放res/raw用getResources().openRawResource(R.raw.xxx)。银联给的通常是 PEM 格式,文本开头是-----BEGIN CERTIFICATE-----。如果你拿到的是 DER 二进制,需要先转成 PEM,否则CertificateFactory解析会抛CertificateException。
另外,证书可能不止一张。银联后台常见的是“中级 CA + 服务器证书”两段链,只导入服务器证书,握手时找不到签发它的中级 CA,一样失败。所以setCertificates方法设计成接收可变参数InputStream...,就是为了一次加载多张证书。
还有HostnameVerifier。银联后台的域名或 IP 往往和证书里的 CN/SAN 对不上,OkHttp 默认会校验主机名,对不上就断。联调阶段可以临时放行,但上线前要和对方确认证书里的 SAN 是否包含你实际访问的地址。放行写法是hostnameVerifier返回true,但这属于降低安全等级的操作,生产环境要谨慎。
超时参数也得调。银联后台走的是专线或前置,响应可能偏慢,默认 10 秒读超时不够。实测下来,连接超时 30 秒、读超时 30 秒、写超时 3 分钟比较稳,尤其是上传十六进制报文时。retryOnConnectionFailure(true)建议打开,网络抖动时能自动重试一次。
把这些点串起来,整个链路就是:读取证书流 → 生成KeyStore→ 初始化TrustManagerFactory→ 拿到X509TrustManager→ 初始化SSLContext→ 得到SSLSocketFactory→ 塞进OkHttpClient.Builder。任何一环出错,握手就断。下面进入具体配置。
2. TaoToken 前置准备:证书、Key 与 Base URL 三件套
在写 OkHttp 代码之前,先把“三件套”备齐:证书文件、访问凭证(Key)、目标 Base URL。银联后台的 Base URL 通常是https://ip:port/这种形式,端口不是 443,证书里可能只绑了 IP。这三样缺一不可,而且要和对方给的接入规范逐字核对。
证书文件处理。把银联给的.pem或.cer放到app/src/main/assets/目录下,命名比如unionpay.pem。如果是多张证书,可以合并成一个 PEM 文件,也可以分开放,代码里依次传入。合并的方式是把多个-----BEGIN CERTIFICATE-----块直接拼接,CertificateFactory.generateCertificates能一次解析多个,但用generateCertificate逐个读也行。
访问凭证这块,银联接口一般不是简单的 Bearer Token,而是通过报文头里的特定字段或签名来鉴权。你需要在请求头里带上对方要求的字段,比如User-Agent: Donjin Http 0.1、Content-Type: x-ISO-TPDU/x-auth。这些值必须和规范一致,大小写、空格都不能错。我试过把Content-Type写成application/json,服务端直接返回 415。
Base URL 的写法要注意结尾斜杠。Retrofit 的baseUrl("https://1xx.xx.xx.xx:xxxx/")必须以/结尾,否则拼接路径时会丢段。接口路径用相对路径,比如@POST("mjc/webtrans/VPB_lb"),不要以/开头,否则会覆盖 baseUrl 的路径部分。
如果你在本地调试阶段需要一个稳定的模型对话或编码辅助环境来生成、校验报文,可以用 TaoToken 的模型对话能力做对照。它的 API 地址是https://taotoken.net/api,模型对话入口在https://taotoken.net/models?utm_source=taotoken_aicg_blog_end&utm_content=model_chat&utm_campaign=rewrite。注意,这只是辅助你理解报文结构和排查逻辑,不替代银联后台本身。
对于长期做金融类 App 编码、需要反复调试证书和报文的场景,可以考虑 Coding Plan,入口是https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_content=coding_plan&utm_campaign=rewrite。它能帮你把证书配置、Retrofit 构建、报文解析这些重复劳动模板化。
Key 的管理别硬编码在代码里。联调阶段可以放local.properties或BuildConfig,上线前换成服务端下发或加密存储。银联这类接口的凭证泄露风险很高,git提交前务必检查。
三件套备齐后,先别急着写 Retrofit 接口。用 curl 在电脑上验证一遍证书和报文头,能通再写 Android 代码,否则你会分不清是证书问题还是代码问题。curl 命令示例:
curl -v --cacert unionpay.pem \ -H "User-Agent: Donjin Http 0.1" \ -H "Cache-Control: no-cache" \ -H "Content-Type: x-ISO-TPDU/x-auth" \ -H "Accept: */*" \ --data-binary @payload.bin \ https://1xx.xx.xx.xx:xxxx/mjc/webtrans/VPB_lb--cacert指定证书,--data-binary发送二进制报文。如果 curl 返回 200,说明证书和报文头没问题,接下来才是 Android 端的事。如果 curl 也失败,先解决证书链或网络可达性,别往下走。
3. 可复制配置:OkHttpClient 与 Retrofit 构建全流程
这一节是核心,直接给可复制的代码。先看证书加载工具类,它负责把InputStream变成SSLSocketFactory和X509TrustManager。
public class OkHttp3Utils { public static void setCertificates(OkHttpClient.Builder clientBuilder, InputStream... certificates) { try { CertificateFactory certificateFactory = CertificateFactory.getInstance("X.509"); KeyStore keyStore = KeyStore.getInstance(KeyStore.getDefaultType()); keyStore.load(null); int index = 0; for (InputStream certificate : certificates) { String alias = Integer.toString(index++); keyStore.setCertificateEntry(alias, certificateFactory.generateCertificate(certificate)); try { if (certificate != null) certificate.close(); } catch (IOException e) { // 关闭失败不影响主流程 } } TrustManagerFactory tmf = TrustManagerFactory.getInstance( TrustManagerFactory.getDefaultAlgorithm()); tmf.init(keyStore); TrustManager[] trustManagers = tmf.getTrustManagers(); if (trustManagers.length != 1 || !(trustManagers[0] instanceof X509TrustManager)) { throw new IllegalStateException("Unexpected default trust managers:" + Arrays.toString(trustManagers)); } X509TrustManager trustManager = (X509TrustManager) trustManagers[0]; SSLContext sslContext = SSLContext.getInstance("TLS"); sslContext.init(null, tmf.getTrustManagers(), new SecureRandom()); clientBuilder.sslSocketFactory(sslContext.getSocketFactory(), trustManager); } catch (Exception e) { e.printStackTrace(); } } }注意sslSocketFactory的第二个参数必须传X509TrustManager,OkHttp 3.4 之后强制要求,只传SSLSocketFactory会编译不过或运行时报错。
接着是OkHttpClient单例构建,包含超时、拦截器、证书加载和HostnameVerifier。
public class HttpClientProvider { private static volatile OkHttpClient okHttpClient; private static final long CONNECT_TIMEOUT = 30L; public static OkHttpClient getInstance(Context context) { if (okHttpClient == null) { synchronized (HttpClientProvider.class) { if (okHttpClient == null) { OkHttpClient.Builder builder = new OkHttpClient.Builder() .connectTimeout(CONNECT_TIMEOUT, TimeUnit.SECONDS) .readTimeout(30, TimeUnit.SECONDS) .writeTimeout(3, TimeUnit.MINUTES) .retryOnConnectionFailure(true) .addNetworkInterceptor(new HttpLoggingInterceptor() .setLevel(HttpLoggingInterceptor.Level.HEADERS)) .hostnameVerifier(new HostnameVerifier() { @Override public boolean verify(String hostname, SSLSession session) { return true; } }); try { InputStream is = context.getAssets().open("unionpay.pem"); OkHttp3Utils.setCertificates(builder, is); } catch (IOException e) { e.printStackTrace(); } okHttpClient = builder.build(); } } } return okHttpClient; } }HttpLoggingInterceptor只开HEADERS级别,别开BODY,否则十六进制报文会刷屏,还可能泄露敏感数据。
然后是 Retrofit 构建,注意ConverterFactory要自定义,因为报文是十六进制二进制,不是 JSON。
public class RetrofitClient { private static Retrofit retrofit; public static Retrofit getRetrofit(Context context) { if (retrofit == null) { retrofit = new Retrofit.Builder() .baseUrl("https://1xx.xx.xx.xx:xxxx/") .client(HttpClientProvider.getInstance(context)) .addConverterFactory(HexConverterFactory.create()) .build(); } return retrofit; } }接口定义用@Body RequestBody传二进制,请求头用@Headers固定。
public interface UnionPayService { @Headers({ "User-Agent: Donjin Http 0.1", "Cache-Control: no-cache", "Content-Type: x-ISO-TPDU/x-auth", "Accept: */*" }) @POST("mjc/webtrans/VPB_lb") Call<ResponseBody> postHex(@Body RequestBody body); }Content-Length不要写死在@Headers里,OkHttp 会根据RequestBody.contentLength()自动计算。如果你手动写了固定值,报文长度对不上,服务端会截断或报错。
自定义RequestBody是关键,必须重写contentType()、writeTo()和contentLength()。
public class HexRequestBody extends RequestBody { private final byte[] data; private final MediaType mediaType; public HexRequestBody(byte[] data, MediaType mediaType) { this.data = data; this.mediaType = mediaType; } @Override public MediaType contentType() { return mediaType; } @Override public void writeTo(BufferedSink sink) throws IOException { sink.write(data); } @Override public long contentLength() { return data.length; } }十六进制字符串转字节数组的工具方法:
public static byte[] hexStringToBytes(String hex) { int len = hex.length(); byte[] out = new byte[len / 2]; for (int i = 0; i < len; i += 2) { out[i / 2] = (byte) ((Character.digit(hex.charAt(i), 16) << 4) + Character.digit(hex.charAt(i + 1), 16)); } return out; }调用时:
String hex = "00000000000000c0006300011000000010030002953657175656e6365204343131390003303120"; byte[] payload = hexStringToBytes(hex); RequestBody body = new HexRequestBody(payload, MediaType.parse("x-ISO-TPDU/x-auth")); UnionPayService service = RetrofitClient.getRetrofit(context).create(UnionPayService.class); Call<ResponseBody> call = service.postHex(body); call.enqueue(new Callback<ResponseBody>() { @Override public void onResponse(Call<ResponseBody> call, Response<ResponseBody> response) { if (response.isSuccessful()) { // 解析 response.body() } } @Override public void onFailure(Call<ResponseBody> call, Throwable t) { t.printStackTrace(); } });这套配置里,证书、超时、请求头、二进制 Body 都覆盖了。ConverterFactory如果暂时不需要解析响应,可以先不加,直接用ResponseBody拿原始字节。
4. 验证请求与成功结果:curl 对照 + Logcat 报文
配置写完,先别跑 App,用 curl 对照验证一遍。准备一个payload.bin,内容是十六进制报文对应的二进制。可以用xxd -r -p把十六进制字符串转成二进制文件:
echo "00000000000000c0006300011000000010030002953657175656e6365204343131390003303120" | xxd -r -p > payload.bin然后执行 curl:
curl -v --cacert unionpay.pem \ -H "User-Agent: Donjin Http 0.1" \ -H "Cache-Control: no-cache" \ -H "Content-Type: x-ISO-TPDU/x-auth" \ -H "Accept: */*" \ --data-binary @payload.bin \ https://1xx.xx.xx.xx:xxxx/mjc/webtrans/VPB_lb成功时你会看到类似:
< HTTP/1.1 200 OK < Allow: POST, PUT < Content-Type: x-ISO-TPDU/x-auth < Content-Length: 112 < Server: Access-Guard-1000-Software/1.0 < Connection: close响应体是十六进制,用xxd -p转成字符串看:
curl -s --cacert unionpay.pem ... | xxd -pcurl 通了,再跑 Android。Logcat 里过滤OkHttp标签,成功时能看到:
--> POST https://1xx.xx.xx.xx:xxxx/mjc/webtrans/VPB_lb http/1.1 Content-Type: x-ISO-TPDU/x-auth Content-Length: 89 User-Agent: Donjin Http 0.1 Cache-Control: no-cache Accept-Encoding: * Host: 1xx.xx.xx.xx:xxxx Connection: Keep-Alive --> END POST <-- 200 OK https://1xx.xx.xx.xx:xxxx/mjc/webtrans/VPB_lb (421ms) Allow: POST, PUT Content-Type: x-ISO-TPDU/x-auth Content-Length: 123 Server: Access-Guard-1000-Software/1.0 Connection: close <-- END HTTP注意Content-Length是 OkHttp 自动算的,和你传的byte[]长度一致。如果这里显示的长度和你预期不符,检查hexStringToBytes有没有把奇数长度字符串处理错。
响应报文解析。银联返回的也是十六进制,先转成字节数组,再按规范里的字段偏移量解析。比如响应里可能包含PIK(PIN Key)之类的字段,解析出来是十六进制字符串。解析时注意大小端,银联规范里通常是大端。
如果响应里出现PIK错误或类似提示,先别怀疑证书,多半是报文内容或字段偏移算错了。把请求报文和响应报文都打印成十六进制,逐字节对照规范。
验证阶段建议开一个Interceptor把请求和响应的十六进制都打到 Logcat,但上线前记得关掉。可以用BuildConfig.DEBUG控制。
.addInterceptor(chain -> { Request request = chain.request(); Response response = chain.proceed(request); if (BuildConfig.DEBUG) { Log.d("UnionPay", "url=" + request.url()); } return response; })到这里,一次完整的联调链路就通了:证书加载 → OkHttp 构建 → Retrofit 接口 → 二进制 Body → 200 响应 → 报文解析。
5. 常见报错排查清单:401、握手失败、choices 解析异常
联调过程中最常见的几类报错,逐个对照。
javax.net.ssl.SSLHandshakeException: Trust anchor for certification path not found。这是证书没加载或加载不全。检查assets里的 PEM 是否包含完整链,setCertificates是否被调用,sslSocketFactory是否真的塞进了 builder。如果用了hostnameVerifier返回true还是报这个,说明是信任链问题,不是主机名问题。
java.security.cert.CertPathValidatorException: Trust anchor for certification path not found。同上,多半是缺中级 CA。把银联给的所有证书都导入,别只导服务器证书。
javax.net.ssl.SSLPeerUnverifiedException: Hostname 1xx.xx.xx.xx not verified。主机名校验失败。联调阶段用hostnameVerifier放行,生产环境确认证书 SAN 是否包含实际访问的 IP 或域名。
java.net.SocketTimeoutException: timeout。超时太短。把connectTimeout、readTimeout调到 30 秒,writeTimeout调到 3 分钟。银联前置响应慢是常态。
okhttp3.internal.http2.StreamResetException: stream was reset: PROTOCOL_ERROR。可能是 HTTP/2 协商问题。银联后台有些只支持 HTTP/1.1,可以在OkHttpClient.Builder里加.protocols(Arrays.asList(Protocol.HTTP_1_1))强制降级。
401 Unauthorized或403 Forbidden。请求头字段不对。逐字对照规范,特别是User-Agent、Content-Type、Accept。有些接口还要求Authorization或自定义签名头,漏了就 401。
Response{protocol=http/1.1, code=200, message=OK}但解析报reading choices或字段错位。这是响应报文解析问题,不是网络问题。检查ConverterFactory是否按十六进制解析,字段偏移量是否和规范一致。银联报文里常有长度字段,先读长度再读内容,别按固定偏移硬读。
local proxy failed或Failed to connect to /127.0.0.1:xxxx。这是本地代理配置问题。检查 Android Studio 的 Proxy 设置,或者代码里有没有误设Proxy。真机调试时,确认手机和后台网络可达,别被公司内网策略挡了。
OAuth相关报错。如果银联接口走 OAuth 鉴权,检查 token 是否过期、Authorization头格式是否为Bearer xxx。token 获取接口本身也可能需要证书,别漏了。
Content-Length不匹配。如果你在@Headers里手写了Content-Length,删掉,让 OkHttp 自动算。手写值和服务端实际收到的不一致,会报ProtocolException或服务端截断。
java.lang.IllegalStateException: Unexpected default trust managers。TrustManagerFactory返回的数组长度不是 1,或者不是X509TrustManager。检查KeyStore是否加载成功,证书是否有效。
排查顺序建议:先 curl 验证证书和报文头 → 再 Logcat 看 OkHttp 请求日志 → 对比请求头和规范 → 检查证书链 → 最后看报文解析。别一上来就改代码,先定位是网络层、TLS 层还是应用层的问题。
6. 长期联调与编码辅助:把证书配置模板化
银联这类对接不是一次性的,后续还有对账、退款、查询等接口,每个都可能要带证书和定制头。与其每次复制粘贴,不如把证书配置和 OkHttp 构建抽成独立模块,用BuildConfig区分环境和证书。
证书文件按环境分目录:assets/unionpay/test/、assets/unionpay/prod/,构建时按buildType读取。这样测试和生产的证书不会混。
请求头也可以抽成常量类,避免散落在各个接口定义里。Content-Type、User-Agent这些值一旦规范更新,改一处就行。
对于需要反复调试报文结构、生成十六进制测试数据的场景,可以用 TaoToken 的模型对话做辅助,入口https://taotoken.net/models?utm_source=taotoken_aicg_blog_end&utm_content=model_chat&utm_campaign=rewrite。把规范里的字段说明贴进去,让它帮你生成测试用的十六进制串,比手算偏移量快。
如果你在做一个长期的金融类 App,涉及多个后台、多套证书、多种报文格式,Coding Plan 能把证书加载、Retrofit 构建、ConverterFactory 这些模板代码管理起来,入口https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_content=coding_plan&utm_campaign=rewrite。它适合需要持续迭代、反复联调的编码场景。
API Key 的管理入口在https://taotoken.net/api-keys?utm_source=taotoken_aicg_blog_end&utm_content=api_keys&utm_campaign=rewrite,接入文档在https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_content=doc&utm_campaign=rewrite。这些是辅助你开发和排查的工具,银联后台的证书和报文规范仍以对方提供的为准。
最后提醒几个实操细节。证书文件别提交到公开仓库,用.gitignore排除。hostnameVerifier放行只用于联调,上线前必须改回严格校验或确认证书 SAN。HttpLoggingInterceptor的BODY级别会打印十六进制报文,生产环境关掉。超时参数根据实际网络调整,别照搬。
把上面这套配置跑通后,银联后台的 HTTPS 访问基本就稳了。后续新增接口,复用OkHttpClient单例和Retrofit实例,只改接口定义和报文内容即可。