微信小程序虚拟支付2.0实战:用Java搞定余额查询,避开offer_id和sessionKey的坑

微信小程序虚拟支付2.0实战:用Java搞定余额查询,避开offer_id和sessionKey的坑 微信小程序虚拟支付2.0深度实践Java开发者的避坑指南与最佳实现在移动互联网时代微信小程序的虚拟支付功能已经成为游戏、知识付费等场景的核心基础设施。作为开发者我们经常需要与微信的米大师虚拟支付系统打交道。本文将聚焦于虚拟支付2.0版本特别是余额查询功能的实现分享在实际开发中遇到的坑点与解决方案。1. 环境准备与基础配置在开始编码之前我们需要确保开发环境已经正确配置。对于使用Spring Boot的Java开发者来说以下几个步骤必不可少依赖引入确保项目中已经添加了必要的依赖包括HTTP客户端如OkHttp或Apache HttpClient、JSON处理库如Jackson或Fastjson以及加密相关库。// Maven依赖示例 dependency groupIdcom.squareup.okhttp3/groupId artifactIdokhttp/artifactId version4.9.3/version /dependency dependency groupIdcom.alibaba/groupId artifactIdfastjson/artifactId version1.2.83/version /dependency配置参数将微信支付相关的配置参数放在application.properties或application.yml中# application.yml示例 wx: midas: offer-id: your_offer_id secret: your_midas_secret env: 0 # 0表示正式环境1表示沙箱环境Redis配置由于sessionKey需要缓存确保Redis连接已经配置好Configuration public class RedisConfig { Bean public RedisTemplateString, Object redisTemplate(RedisConnectionFactory factory) { RedisTemplateString, Object template new RedisTemplate(); template.setConnectionFactory(factory); template.setKeySerializer(new StringRedisSerializer()); template.setValueSerializer(new GenericJackson2JsonRedisSerializer()); return template; } }2. 签名算法实现与关键细节签名是微信支付安全机制的核心也是开发者最容易出错的地方。虚拟支付2.0版本使用了两种签名pay_sig和signature。2.1 签名算法实现以下是两种签名的Java实现代码public class SignatureUtil { /** * 计算pay_sig签名 * param uri 请求URI不包含域名 * param postBody 请求体JSON字符串 * param appKey 米大师密钥 * return 签名结果 */ public static String calcPaySig(String uri, String postBody, String appKey) { String needSignMsg uri postBody; return hmacSha256(needSignMsg, appKey); } /** * 计算signature签名 * param postBody 请求体JSON字符串 * param sessionKey 用户session_key * return 签名结果 */ public static String calcSignature(String postBody, String sessionKey) { return hmacSha256(postBody, sessionKey); } private static String hmacSha256(String message, String secret) { try { Mac sha256Hmac Mac.getInstance(HmacSHA256); SecretKeySpec secretKey new SecretKeySpec( secret.getBytes(StandardCharsets.UTF_8), HmacSHA256 ); sha256Hmac.init(secretKey); byte[] bytes sha256Hmac.doFinal(message.getBytes(StandardCharsets.UTF_8)); return bytesToHex(bytes); } catch (Exception e) { throw new RuntimeException(计算HMAC-SHA256失败, e); } } private static String bytesToHex(byte[] bytes) { StringBuilder hexString new StringBuilder(); for (byte b : bytes) { String hex Integer.toHexString(0xff b); if (hex.length() 1) { hexString.append(0); } hexString.append(hex); } return hexString.toString(); } }2.2 签名常见问题在实际开发中我们遇到了以下几个典型问题字段命名问题微信API要求字段名使用下划线命名法如offer_id而Java通常使用驼峰命名法如offerId。解决方案是使用JsonProperty注解public class GetBalanceParamV2 { JsonProperty(offer_id) private String offerId; JsonProperty(openid) private String openId; // 其他字段... }签名内容格式确保签名的原始字符串严格按照文档要求拼接特别是URI和postBody之间的符号。编码问题所有字符串必须使用UTF-8编码否则可能导致签名验证失败。3. SessionKey管理与缓存策略SessionKey是微信小程序用户身份验证的重要凭证具有以下特点一次性使用通过wx.login获取的code只能使用一次有效期短通常为30分钟安全性要求高泄露可能导致用户信息被盗用3.1 SessionKey获取流程sequenceDiagram 小程序-微信服务器: wx.login()获取code 小程序-开发者服务器: 发送code 开发者服务器-微信服务器: code2Session接口 微信服务器--开发者服务器: 返回session_key和openid 开发者服务器-Redis: 缓存session_key 开发者服务器--小程序: 返回自定义登录态3.2 Redis缓存实现以下是基于Redis的SessionKey缓存实现Service public class SessionKeyService { Autowired private RedisTemplateString, Object redisTemplate; private static final String SESSION_KEY_PREFIX wx:session:; private static final long EXPIRATION 1800; // 30分钟 /** * 缓存sessionKey * param openid 用户openid * param sessionKey session_key */ public void cacheSessionKey(String openid, String sessionKey) { String key SESSION_KEY_PREFIX openid; redisTemplate.opsForValue().set(key, sessionKey, EXPIRATION, TimeUnit.SECONDS); } /** * 获取缓存的sessionKey * param openid 用户openid * return session_key */ public String getSessionKey(String openid) { String key SESSION_KEY_PREFIX openid; return (String) redisTemplate.opsForValue().get(key); } }注意在实际生产环境中应考虑使用更安全的方式存储session_key如加密存储或使用专门的密钥管理服务。3.3 常见问题与解决方案问题现象可能原因解决方案报错code been usedcode被重复使用确保每个code只调用一次code2Session接口报错invalid session_keysession_key过期或错误重新获取code并更新session_key缓存用户频繁需要重新登录session_key缓存时间设置过短适当延长缓存时间但不超过微信规定的有效期4. 余额查询完整实现与异常处理现在我们可以将前面准备的各个组件组合起来实现完整的余额查询功能。4.1 DTO与VO定义首先定义数据传输对象和视图对象Data public class MidasBalanceV2DTO { private String orderNumber; private String openid; private String ts; // 时间戳 private String zoneId; // 游戏区服ID private String appNumber; // 应用编号 } Data public class MidasBalanceV2VO { private String balance; // 余额 private String presentBalance; // 赠送余额 private String currencyType; // 货币类型 private String openid; }4.2 余额查询服务实现Service Slf4j public class MidasPayService { Autowired private WxConfigService wxConfigService; Autowired private SessionKeyService sessionKeyService; Autowired private WxApiService wxApiService; private static final String BALANCE_URI /wxa/game/getbalance; public MidasBalanceV2VO queryBalanceV2(MidasBalanceV2DTO dto) { // 1. 参数校验 validateParams(dto); // 2. 获取配置信息 WxConfig config wxConfigService.getConfig(dto.getAppNumber()); // 3. 获取sessionKey String sessionKey sessionKeyService.getSessionKey(dto.getOpenid()); if (StringUtils.isBlank(sessionKey)) { throw new BusinessException(session_key不存在或已过期); } // 4. 构建请求参数 GetBalanceParamV2 param buildBalanceParam(dto, config); String postBody JSON.toJSONString(param); // 5. 计算签名 String signature SignatureUtil.calcSignature(postBody, sessionKey); String paySig SignatureUtil.calcPaySig(BALANCE_URI, postBody, config.getMidasSecret()); // 6. 获取access_token String accessToken wxApiService.getAccessToken(config.getAppId(), config.getAppSecret()); // 7. 调用微信接口 GetBalanceResultV2 result callMidasApi(signature, paySig, accessToken, postBody); // 8. 处理结果 return buildBalanceVO(dto.getOpenid(), result); } private void validateParams(MidasBalanceV2DTO dto) { if (StringUtils.isAnyBlank(dto.getOrderNumber(), dto.getOpenid(), dto.getTs(), dto.getZoneId(), dto.getAppNumber())) { throw new BusinessException(参数不能为空); } } private GetBalanceParamV2 buildBalanceParam(MidasBalanceV2DTO dto, WxConfig config) { GetBalanceParamV2 param new GetBalanceParamV2(); param.setOfferId(config.getMidasOfferId()); param.setOpenid(dto.getOpenid()); param.setTs(dto.getTs()); param.setZoneId(dto.getZoneId()); param.setEnv(config.getMidasEnv()); return param; } private GetBalanceResultV2 callMidasApi(String signature, String paySig, String accessToken, String postBody) { String url String.format(%s?access_token%ssignature%spay_sig%s, config.getMidasApiBaseUrl() BALANCE_URI, accessToken, signature, paySig); try { String response HttpUtil.post(url, postBody); GetBalanceResultV2 result JSON.parseObject(response, GetBalanceResultV2.class); if (result.getErrcode() ! 0) { log.error(微信接口调用失败: {}, result.getErrmsg()); throw new BusinessException(微信接口调用失败: result.getErrmsg()); } return result; } catch (Exception e) { log.error(调用微信接口异常, e); throw new BusinessException(调用微信接口异常, e); } } private MidasBalanceV2VO buildBalanceVO(String openid, GetBalanceResultV2 result) { MidasBalanceV2VO vo new MidasBalanceV2VO(); vo.setOpenid(openid); vo.setBalance(result.getBalance()); vo.setPresentBalance(result.getPresentBalance()); vo.setCurrencyType(result.getCurrencyType()); return vo; } }4.3 异常处理与日志记录良好的异常处理和日志记录对于支付系统至关重要。我们需要注意以下几点错误码映射将微信返回的错误码映射为业务错误码敏感信息脱敏在日志中避免记录敏感信息如session_key等重试机制对于网络超时等临时性错误实现合理的重试机制RestControllerAdvice public class GlobalExceptionHandler { ExceptionHandler(BusinessException.class) public ResponseEntityErrorResponse handleBusinessException(BusinessException ex) { ErrorResponse response new ErrorResponse( ex.getCode(), ex.getMessage() ); return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(response); } ExceptionHandler(Exception.class) public ResponseEntityErrorResponse handleException(Exception ex) { log.error(系统异常, ex); ErrorResponse response new ErrorResponse( 500, 系统繁忙请稍后再试 ); return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(response); } }5. 测试与调试技巧在实际开发中有效的测试方法可以大大减少上线后的问题。以下是几个实用的测试技巧5.1 沙箱环境使用微信提供了沙箱环境用于测试将env参数设置为1使用沙箱环境沙箱环境不需要真实的支付行为可以使用测试专用的offer_id和app_key5.2 常见问题排查表问题现象排查步骤解决方案签名错误1. 检查签名算法实现2. 检查参与签名的字符串3. 检查密钥是否正确使用微信提供的签名验证工具比对session_key无效1. 检查缓存是否过期2. 检查是否重复使用code3. 检查网络请求是否超时重新获取session_key并更新缓存接口返回系统繁忙1. 检查网络连接2. 检查微信接口状态3. 检查参数格式等待后重试或联系微信客服5.3 单元测试示例编写单元测试可以确保核心逻辑的正确性SpringBootTest public class MidasPayServiceTest { Autowired private MidasPayService midasPayService; MockBean private SessionKeyService sessionKeyService; MockBean private WxConfigService wxConfigService; Test public void testQueryBalanceSuccess() { // 准备测试数据 MidasBalanceV2DTO dto new MidasBalanceV2DTO(); dto.setOpenid(test_openid); dto.setTs(String.valueOf(System.currentTimeMillis() / 1000)); dto.setZoneId(1); dto.setAppNumber(test_app); // Mock依赖服务 when(sessionKeyService.getSessionKey(anyString())).thenReturn(test_session_key); WxConfig config new WxConfig(); config.setMidasOfferId(test_offer_id); config.setMidasSecret(test_secret); config.setMidasEnv(0); when(wxConfigService.getConfig(anyString())).thenReturn(config); // 调用测试方法 MidasBalanceV2VO result midasPayService.queryBalanceV2(dto); // 验证结果 assertNotNull(result); // 更多断言... } }6. 性能优化与安全建议在系统上线后我们还需要考虑性能和安全性方面的优化。6.1 性能优化缓存策略缓存access_token避免频繁获取对余额查询结果实现短期缓存连接池配置配置HTTP连接池避免频繁创建连接设置合理的超时时间Configuration public class HttpClientConfig { Bean public CloseableHttpClient httpClient() { return HttpClients.custom() .setMaxConnTotal(100) // 最大连接数 .setMaxConnPerRoute(20) // 每个路由最大连接数 .setConnectionTimeToLive(30, TimeUnit.SECONDS) // 连接存活时间 .build(); } }6.2 安全建议敏感信息保护不要在日志中记录敏感信息使用Vault或KMS管理密钥接口防护实现频率限制防止暴力破解对用户身份进行二次验证数据校验对所有输入参数进行严格校验使用白名单验证zoneId等参数public class ZoneIdValidator { private static final SetString VALID_ZONE_IDS Set.of(1, 2, 3); public static boolean isValid(String zoneId) { return VALID_ZONE_IDS.contains(zoneId); } }在实际项目中我们遇到了session_key管理不当导致的安全问题。后来我们实现了自动刷新机制当检测到session_key即将过期时主动通知客户端重新登录获取新的session_key既保证了用户体验又确保了安全性。