微信小程序虚拟支付2.0实战:用Java搞定余额查询,避开offer_id和sessionKey的坑
微信小程序虚拟支付2.0深度实践:Java开发者的避坑指南与最佳实现
在移动互联网时代,微信小程序的虚拟支付功能已经成为游戏、知识付费等场景的核心基础设施。作为开发者,我们经常需要与微信的米大师虚拟支付系统打交道。本文将聚焦于虚拟支付2.0版本,特别是余额查询功能的实现,分享在实际开发中遇到的坑点与解决方案。
1. 环境准备与基础配置
在开始编码之前,我们需要确保开发环境已经正确配置。对于使用Spring Boot的Java开发者来说,以下几个步骤必不可少:
- 依赖引入:确保项目中已经添加了必要的依赖,包括HTTP客户端(如OkHttp或Apache HttpClient)、JSON处理库(如Jackson或Fastjson)以及加密相关库。
// Maven依赖示例 <dependency> <groupId>com.squareup.okhttp3</groupId> <artifactId>okhttp</artifactId> <version>4.9.3</version> </dependency> <dependency> <groupId>com.alibaba</groupId> <artifactId>fastjson</artifactId> <version>1.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 RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) { RedisTemplate<String, 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 RedisTemplate<String, 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 used" | code被重复使用 | 确保每个code只调用一次code2Session接口 |
| 报错"invalid session_key" | session_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=%s&signature=%s&pay_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 ResponseEntity<ErrorResponse> handleBusinessException(BusinessException ex) { ErrorResponse response = new ErrorResponse( ex.getCode(), ex.getMessage() ); return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(response); } @ExceptionHandler(Exception.class) public ResponseEntity<ErrorResponse> 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_key
5.2 常见问题排查表
| 问题现象 | 排查步骤 | 解决方案 |
|---|---|---|
| 签名错误 | 1. 检查签名算法实现 2. 检查参与签名的字符串 3. 检查密钥是否正确 | 使用微信提供的签名验证工具比对 |
| session_key无效 | 1. 检查缓存是否过期 2. 检查是否重复使用code 3. 检查网络请求是否超时 | 重新获取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 Set<String> 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,既保证了用户体验,又确保了安全性。
