2、调整:解绑接口和旧版电子健康卡部分接口进行数据加密 3、调整:电子健康卡新版和旧版共用配置逻辑调整。 4、调整:医共体数据同步逻辑调整,新增的用户数据需进行本院绑定获取本院id。debug
parent
3f253c75b9
commit
8d8b77d460
27 changed files with 2094 additions and 324 deletions
@ -1,12 +1,26 @@ |
|||||||
package com.ynxbd.common.action; |
package com.ynxbd.common.action; |
||||||
|
|
||||||
|
import com.alibaba.fastjson.JSONObject; |
||||||
import com.ynxbd.common.action.base.BaseAction; |
import com.ynxbd.common.action.base.BaseAction; |
||||||
|
import com.ynxbd.common.result.Result; |
||||||
|
import com.ynxbd.common.service.HCodeService; |
||||||
|
import com.ynxbd.wx.config.WeChatConfig; |
||||||
import lombok.extern.slf4j.Slf4j; |
import lombok.extern.slf4j.Slf4j; |
||||||
|
import org.apache.struts2.convention.annotation.Action; |
||||||
import org.apache.struts2.convention.annotation.Namespace; |
import org.apache.struts2.convention.annotation.Namespace; |
||||||
|
|
||||||
@Slf4j |
@Slf4j |
||||||
@Namespace("/hc") |
@Namespace("/hc") |
||||||
public class HealthCardAction extends BaseAction { |
public class HealthCardAction extends BaseAction { |
||||||
|
|
||||||
|
/** |
||||||
|
* [电子健康卡]绑卡验证授权 |
||||||
|
*/ |
||||||
|
@Action("registerHealthCardPreAuth") |
||||||
|
public Result registerHealthCardPreAuth(Boolean isMiniApp, String wechatCode, String healthCode, String openId) { |
||||||
|
log.info("[电子健康卡]绑卡验证授权 isMiniApp={}, wechatCode={}, healthCode={}, openId={}", isMiniApp, wechatCode, healthCode, openId); |
||||||
|
JSONObject result = HCodeService.registerHealthCardPreAuth(isMiniApp, false, wechatCode, healthCode, openId); |
||||||
|
return Result.success(result); |
||||||
|
} |
||||||
|
|
||||||
} |
} |
||||||
|
|||||||
@ -0,0 +1,188 @@ |
|||||||
|
package com.ynxbd.common.config; |
||||||
|
|
||||||
|
import com.alibaba.fastjson.JSONObject; |
||||||
|
import com.tencent.healthcard.impl.HealthCardServerImpl; |
||||||
|
import com.tencent.healthcard.model.CommonIn; |
||||||
|
import com.ynxbd.common.helper.ProperHelper; |
||||||
|
import com.ynxbd.common.helper.common.ErrorHelper; |
||||||
|
import com.ynxbd.wx.config.WeChatConfig; |
||||||
|
import lombok.Getter; |
||||||
|
import lombok.NoArgsConstructor; |
||||||
|
import lombok.Setter; |
||||||
|
import lombok.ToString; |
||||||
|
import lombok.extern.slf4j.Slf4j; |
||||||
|
import org.apache.commons.lang3.ObjectUtils; |
||||||
|
import org.ehcache.Cache; |
||||||
|
|
||||||
|
import java.util.UUID; |
||||||
|
|
||||||
|
// 电子健康卡配置
|
||||||
|
|
||||||
|
@Slf4j |
||||||
|
public class HealthCardConfig { |
||||||
|
public static final String MINI_CACHE_KEY = "mini_app_token_cache"; |
||||||
|
public static final String APP_CACHE_KEY = "app_token_cache"; |
||||||
|
|
||||||
|
public static synchronized void initCache() { |
||||||
|
if (CACHE == null) { // 创建一个缓存实例(7000s最大存活时间)
|
||||||
|
CACHE = EhCacheConfig.createCache(String.class, String.class, "health_card_cache", 1L, 3L, 10L, false, 7000L, null); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
private static final boolean IS_ENABLE; // 是否启用电子健康卡(true:启用, false:禁用)
|
||||||
|
public static final boolean IS_UPLOAD_DATA; // 是否允许上传数据(true:启用, false:禁用)
|
||||||
|
|
||||||
|
// 健康码
|
||||||
|
public static final String H_APP_ID; |
||||||
|
public static final String H_APP_SECRET; |
||||||
|
public static final String H_HOSPITAL_ID; // 医院ID(每家医院不一样)
|
||||||
|
public static final String H_MINI_HOSPITAL_ID; // 小程序-医院ID(每家医院不一样)
|
||||||
|
public static final String H_MINI_APP_ID; // 小程序-appid
|
||||||
|
// 万达
|
||||||
|
public static final String CARD_URL; |
||||||
|
public static final String CARD_APP_ID; |
||||||
|
public static final String CARD_PUBLIC_KEY; |
||||||
|
public static final String CARD_PRIVATE_KEY; |
||||||
|
|
||||||
|
// 缓存
|
||||||
|
private static Cache<String, String> CACHE; |
||||||
|
|
||||||
|
// 离线WeChartCode 批量领取健康卡用
|
||||||
|
public static final String WE_CHART_CODE = "73EFA6796D3869FF82FAE7E81E9814B7"; |
||||||
|
|
||||||
|
// 公众号不用传,小程序内嵌必传,具体传参请联系平台对接人员分配
|
||||||
|
public static final int domainChannel = 0; |
||||||
|
|
||||||
|
static { |
||||||
|
ProperHelper config = new ProperHelper().read("hcode.properties"); |
||||||
|
IS_ENABLE = config.getBoolean("is_enable", false); |
||||||
|
IS_UPLOAD_DATA = config.getBoolean("h.is_upload_data", true); |
||||||
|
// 不用对config设置开关,不开启时也需要用到参数
|
||||||
|
H_APP_ID = config.getString("h.app_id"); |
||||||
|
H_APP_SECRET = config.getString("h.app_secret"); |
||||||
|
CARD_APP_ID = config.getString("h.card_app_id"); |
||||||
|
CARD_PUBLIC_KEY = config.getString("h.card_public_key"); |
||||||
|
CARD_PRIVATE_KEY = config.getString("h.card_private_key"); |
||||||
|
CARD_URL = config.getString("h.card_url"); |
||||||
|
|
||||||
|
H_HOSPITAL_ID = config.getString("h.hospital_id"); |
||||||
|
if (ObjectUtils.isEmpty(H_HOSPITAL_ID)) { |
||||||
|
log.info("[电子健康卡]医院id缺失"); |
||||||
|
} |
||||||
|
H_MINI_APP_ID = config.getString("h.mini_app_id"); |
||||||
|
H_MINI_HOSPITAL_ID = config.getString("h.mini_hospital_id"); |
||||||
|
|
||||||
|
initCache(); |
||||||
|
} |
||||||
|
|
||||||
|
|
||||||
|
// 判断是否开启电子健康卡
|
||||||
|
public static boolean isEnable() { |
||||||
|
if (!IS_ENABLE) { |
||||||
|
log.info("[配置][hcode.properties]未开启电子健康卡IS_ENABLE"); |
||||||
|
} |
||||||
|
return IS_ENABLE; |
||||||
|
} |
||||||
|
|
||||||
|
|
||||||
|
public static HealthCardServerImpl createHealthCardService() { |
||||||
|
return new HealthCardServerImpl(HealthCardConfig.H_APP_SECRET); |
||||||
|
} |
||||||
|
|
||||||
|
public static CommonIn createCommonIn(Boolean isMiniApp) { |
||||||
|
return createCommonIn(isMiniApp, getAppToken(isMiniApp, true)); |
||||||
|
} |
||||||
|
|
||||||
|
public static CommonIn createCommonIn(Boolean isMiniApp, String appToken) { |
||||||
|
if (appToken == null) { // 此处不判断空字符串
|
||||||
|
log.info("[电子健康卡]appToken为空"); |
||||||
|
return null; |
||||||
|
} |
||||||
|
if (isMiniApp == null) { |
||||||
|
isMiniApp = false; |
||||||
|
} |
||||||
|
int channelNum = (isMiniApp ? 1 : 0); // 填入代码,0为微信服务号渠道,1为微信小程序渠道,3为刷脸终端,4为扫码终端
|
||||||
|
String hospId = (isMiniApp ? H_MINI_HOSPITAL_ID : H_HOSPITAL_ID); |
||||||
|
String requestId = UUID.randomUUID().toString().replaceAll("-", "").toUpperCase(); |
||||||
|
String appId = (isMiniApp ? H_MINI_APP_ID : WeChatConfig.APP_ID); |
||||||
|
return new CommonIn(appToken, requestId, hospId, channelNum, appId, null); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* 获取token |
||||||
|
* |
||||||
|
* @param isEnTokenSwitch [true:必须开启功能才能获取token] |
||||||
|
* @return token |
||||||
|
*/ |
||||||
|
public static String getAppToken(Boolean isMiniApp, boolean isEnTokenSwitch) { |
||||||
|
if (isEnTokenSwitch && !isEnable()) { |
||||||
|
return null; |
||||||
|
} |
||||||
|
|
||||||
|
if (isMiniApp == null) { |
||||||
|
isMiniApp = false; |
||||||
|
} |
||||||
|
|
||||||
|
String appToken = null; |
||||||
|
if (CACHE == null) { |
||||||
|
initCache(); |
||||||
|
} |
||||||
|
if (CACHE != null) { |
||||||
|
appToken = CACHE.get(isMiniApp ? MINI_CACHE_KEY : APP_CACHE_KEY); |
||||||
|
if (appToken != null) { |
||||||
|
return appToken; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
try { |
||||||
|
CommonIn commonIn = createCommonIn(isMiniApp, ""); |
||||||
|
if (commonIn == null) { |
||||||
|
return null; |
||||||
|
} |
||||||
|
JSONObject resultJson = new HealthCardServerImpl(H_APP_SECRET, 5, 10).getAppToken(commonIn, H_APP_ID); |
||||||
|
HCardResult result = new HCardResult(resultJson); |
||||||
|
if (!result.isOk) { |
||||||
|
log.info("[电子健康卡]获取appToken失败: {}", resultJson); |
||||||
|
return null; |
||||||
|
} |
||||||
|
JSONObject respJson = result.getRsp(); |
||||||
|
appToken = respJson.getString("appToken"); |
||||||
|
|
||||||
|
if (CACHE != null && !ObjectUtils.isEmpty(appToken)) { |
||||||
|
CACHE.put("appToken", appToken); |
||||||
|
} |
||||||
|
} catch (Exception e) { |
||||||
|
ErrorHelper.println(e); |
||||||
|
} |
||||||
|
|
||||||
|
if (ObjectUtils.isEmpty(appToken)) { |
||||||
|
log.info("[电子健康卡]获取appToken为空"); |
||||||
|
} |
||||||
|
return appToken; |
||||||
|
} |
||||||
|
|
||||||
|
|
||||||
|
@Setter |
||||||
|
@Getter |
||||||
|
@ToString |
||||||
|
@NoArgsConstructor |
||||||
|
public static class HCardResult { // 数据解析
|
||||||
|
private JSONObject commonOut; |
||||||
|
private JSONObject rsp; |
||||||
|
private String resultCode; |
||||||
|
public boolean isOk; |
||||||
|
|
||||||
|
public HCardResult(JSONObject resultJsonObj) { |
||||||
|
if (resultJsonObj == null) { |
||||||
|
return; |
||||||
|
} |
||||||
|
this.rsp = resultJsonObj.getJSONObject("rsp"); |
||||||
|
this.commonOut = resultJsonObj.getJSONObject("commonOut"); |
||||||
|
if (this.commonOut == null) { |
||||||
|
return; |
||||||
|
} |
||||||
|
this.resultCode = commonOut.getString("resultCode"); |
||||||
|
this.isOk = "0".equals(this.resultCode); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,78 @@ |
|||||||
|
package com.ynxbd.common.service; |
||||||
|
|
||||||
|
import com.alibaba.fastjson.JSONObject; |
||||||
|
import com.tencent.healthcard.model.CommonIn; |
||||||
|
import com.ynxbd.common.config.HealthCardConfig; |
||||||
|
import com.ynxbd.common.helper.common.ErrorHelper; |
||||||
|
import com.ynxbd.common.helper.common.JsonHelper; |
||||||
|
import com.ynxbd.common.helper.common.URLHelper; |
||||||
|
import lombok.extern.slf4j.Slf4j; |
||||||
|
|
||||||
|
@Slf4j |
||||||
|
public class HealthCardService { |
||||||
|
|
||||||
|
/** |
||||||
|
* 绑卡验证授权 |
||||||
|
* |
||||||
|
* @param wechatCode 微信身份码 |
||||||
|
* @param openId openId |
||||||
|
*/ |
||||||
|
public static JSONObject registerHealthCardPreAuth(Boolean isMiniApp, String domain, String wechatCode, String healthCode, String openId) { |
||||||
|
try { |
||||||
|
CommonIn commonIn = HealthCardConfig.createCommonIn(isMiniApp); |
||||||
|
if (commonIn == null) { |
||||||
|
return null; |
||||||
|
} |
||||||
|
|
||||||
|
// 成功回调页
|
||||||
|
String successRedirectUrl = (isMiniApp ? "mini:/path/to/isvpage?healthCode=" : (domain + "/path/to/isvpage?healthCode=")) + wechatCode; |
||||||
|
// 失败回调页
|
||||||
|
String failRedirectUrl = (isMiniApp ? "/path/to/isvpage?regInfoCode=" : (domain + "/path/to/isvpage?regInfoCode=")) + wechatCode; |
||||||
|
/* |
||||||
|
* 用户手动填写信息建卡可选两种方式(二选一): |
||||||
|
* 一、服务商自行提供建卡页(可以是H5或小程序),需增加authCode占位符,示例如下: |
||||||
|
* H5: https://xxx.xxx.xx/path/to/isvpage?authCode=${authCode} |小程序: mini:/path/to/isvpage?authCode=${authCode}
|
||||||
|
* 二、使用开放平台的H5绑卡组件页,需增加authCode占位符,示例如下: |
||||||
|
* https://h5-health.tengmed.com/h5/tencent/open/card/regist?hospitalId=${hospitalId}&redirect_uri=${redirect_uri}&fail_redirect_uri=${fail_redirect_uri}&authCode=${authCode}小程序需修改该域名地址为已配置的业务域名
|
||||||
|
* ${hospitalId},必传,为开放平台分配的医院ID(hospitalId); |
||||||
|
* ${redirect_uri},必传,为建卡成功后的回跳服务商页面,必须要对该URL进行UrlEncode编码。 |
||||||
|
* ${fail_redirect_uri},必传,为建卡失败后的回跳服务商页面,必须要对该URL进行UrlEncode编码。 |
||||||
|
* 小程序内嵌,仍以 mini 协议开头,且需要UrlEncode编码。 |
||||||
|
*/ |
||||||
|
|
||||||
|
String userFormPageUrl = String.format("%s/h5/tencent/open/card/regist?hospitalId=%s&redirect_uri=%s&fail_redirect_uri=%s&authCode=%s", |
||||||
|
(isMiniApp ? domain : " https://h5-health.tengmed.com"), |
||||||
|
HealthCardConfig.H_HOSPITAL_ID, |
||||||
|
URLHelper.encodeURL(successRedirectUrl), |
||||||
|
URLHelper.encodeURL(failRedirectUrl), |
||||||
|
wechatCode); |
||||||
|
|
||||||
|
// 小程序内嵌必传(固定为小程序路径,不需要加“mini:”前缀)示例: /path/to/facePage
|
||||||
|
String faceUrl = isMiniApp ? "/path/to/facePage" : null; |
||||||
|
// 放弃验证回调页
|
||||||
|
String verifyFailRedirectUrl = isMiniApp ? "mini:/path/to/isvpage" : (domain + "/path/to/isvpage"); |
||||||
|
|
||||||
|
int patientType = 0; // 0-新患者 1-老患者(针对就诊卡升级健康卡);不传默认为0
|
||||||
|
|
||||||
|
JSONObject resultJson = HealthCardConfig.createHealthCardService().registerHealthCardPreAuth(commonIn, |
||||||
|
wechatCode, |
||||||
|
patientType, |
||||||
|
successRedirectUrl, |
||||||
|
failRedirectUrl, |
||||||
|
userFormPageUrl, |
||||||
|
faceUrl, |
||||||
|
verifyFailRedirectUrl, |
||||||
|
HealthCardConfig.domainChannel); |
||||||
|
log.info("[电子健康卡]绑卡验证授权 resp={}", JsonHelper.toJsonString(resultJson)); |
||||||
|
HealthCardConfig.HCardResult result = new HealthCardConfig.HCardResult(resultJson); |
||||||
|
if (!result.isOk) { |
||||||
|
log.info("[电子健康卡]绑卡验证授权-失败: {}", resultJson); |
||||||
|
return result.getCommonOut(); |
||||||
|
} |
||||||
|
return result.getRsp(); |
||||||
|
} catch (Exception e) { |
||||||
|
ErrorHelper.println(e); |
||||||
|
} |
||||||
|
return null; |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,8 @@ |
|||||||
|
package com.ynxbd.wx.wxfactory; |
||||||
|
|
||||||
|
import lombok.extern.slf4j.Slf4j; |
||||||
|
|
||||||
|
@Slf4j |
||||||
|
public class WxMedInsHelper { |
||||||
|
|
||||||
|
} |
||||||
@ -0,0 +1,107 @@ |
|||||||
|
package com.ynxbd.wx.wxfactory.base.auth.models; |
||||||
|
|
||||||
|
|
||||||
|
import lombok.*; |
||||||
|
import org.apache.commons.lang3.ObjectUtils; |
||||||
|
|
||||||
|
@Getter |
||||||
|
@Setter |
||||||
|
@ToString |
||||||
|
@NoArgsConstructor |
||||||
|
public class WxVerifyId { |
||||||
|
private Integer errCode; |
||||||
|
private String errMsg; |
||||||
|
private String verifyId; |
||||||
|
private Integer expiresIn; |
||||||
|
private String outSeqNo; |
||||||
|
private Integer verifyRet; |
||||||
|
private String certHash; |
||||||
|
|
||||||
|
// 接口请求状态
|
||||||
|
public boolean isOk() { |
||||||
|
return errCode != null && errCode == 0; |
||||||
|
} |
||||||
|
|
||||||
|
public String filterErrMsg() { |
||||||
|
if (ObjectUtils.isEmpty(errMsg)) { |
||||||
|
return "接口调用失败"; |
||||||
|
} |
||||||
|
return errMsg; |
||||||
|
} |
||||||
|
|
||||||
|
// 是否校验通过
|
||||||
|
public boolean isVerifyOk() { |
||||||
|
return verifyRet != null && verifyRet.equals(FaceVerifyCodeEnum.SUCCESS.code); |
||||||
|
} |
||||||
|
|
||||||
|
|
||||||
|
@AllArgsConstructor |
||||||
|
public enum FaceVerifyCodeEnum { |
||||||
|
// 成功
|
||||||
|
SUCCESS(10000, "识别成功"), |
||||||
|
|
||||||
|
_90001(90001, "设备不支持人脸检测"), |
||||||
|
_90002(90002, "用户取消"), |
||||||
|
_90003(90003, "用户取消"), |
||||||
|
_90004(90004, "用户取消"), |
||||||
|
_90005(90005, "用户取消"), |
||||||
|
_90006(90006, "用户取消"), |
||||||
|
_90007(90007, "网络错误"), |
||||||
|
_90008(90008, "相机权限未授权"), |
||||||
|
_90009(90009, "麦克风权限未授权"), |
||||||
|
_90010(90010, "相机和麦克风权限都未授权"), |
||||||
|
_90011(90011, "人脸数据采集无效"), |
||||||
|
_90012(90012, "网络错误上传失败"), |
||||||
|
_90013(90013, "人脸数据采集无效"), |
||||||
|
_90014(90014, "人脸数据采集无效"), |
||||||
|
_90017(90017, "识别过程超时"), |
||||||
|
_90018(90018, "系统错误"), |
||||||
|
_90104(90104, "获取人脸配置失败"), |
||||||
|
_90105(90105, "获取确认数据失败"), |
||||||
|
_90106(90106, "相机失败"), |
||||||
|
_90107(90107, "用户检测超时"), |
||||||
|
_90109(90109, "设备不支持人脸检测"), |
||||||
|
_90110(90110, "获取协议信息失败"), |
||||||
|
_90199(90199, "用户系统错误"), |
||||||
|
|
||||||
|
_10001(10001, "参数错误"), |
||||||
|
_10002(10002, "人脸特征检测失败"), |
||||||
|
_10003(10003, "身份证号不匹配"), |
||||||
|
_10004(10004, "比对人脸信息不匹配"), |
||||||
|
_10005(10005, "正在检测中"), |
||||||
|
_10006(10006, "appid 没有权限"), |
||||||
|
_10007(10007, "后台获取图片失败"), |
||||||
|
_10008(10008, "系统失败"), |
||||||
|
_10010(10010, "照片质量较低"), |
||||||
|
_10012(10012, "比对验证失败"), |
||||||
|
_10013(10013, "系统错误"), |
||||||
|
_10014(10014, "系统失败"), |
||||||
|
_10015(10015, "系统失败"), |
||||||
|
_10016(10016, "存储用户图片失败"), |
||||||
|
_10017(10017, "非法 Id"), |
||||||
|
_10018(10018, "用户信息不存在"), |
||||||
|
_10020(10020, "认证超时"), |
||||||
|
_10021(10021, "重复的请求,返回上一次的结果"), |
||||||
|
_10026(10026, "用户身份数据不在权威源比对库中"), |
||||||
|
_10029(10029, "请求超时"), |
||||||
|
_10040(10040, "请求数据编码不对,必须是 UTF8 编码"), |
||||||
|
_10041(10041, "非法用户"), |
||||||
|
_10042(10042, "请求过于频繁,稍后再重试"), |
||||||
|
_10045(10045, "系统失败"), |
||||||
|
_10052(10052, "请求超时"), |
||||||
|
_10300(10300, "未完成核身"); |
||||||
|
|
||||||
|
public final Integer code; |
||||||
|
public final String msg; |
||||||
|
|
||||||
|
|
||||||
|
public static String findEnumMsg(WxVerifyId info) { |
||||||
|
for (FaceVerifyCodeEnum e : values()) { |
||||||
|
if (e.code.equals(info.getVerifyRet())) { |
||||||
|
return e.msg; |
||||||
|
} |
||||||
|
} |
||||||
|
return "未知异常"; |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,374 @@ |
|||||||
|
package com.ynxbd.wx.wxfactory.medins; |
||||||
|
|
||||||
|
import com.alibaba.fastjson.JSON; |
||||||
|
import com.alibaba.fastjson.JSONObject; |
||||||
|
import com.ynxbd.common.helper.common.ErrorHelper; |
||||||
|
import com.ynxbd.common.helper.common.JsonHelper; |
||||||
|
import com.ynxbd.common.helper.http.OkHttpHelper; |
||||||
|
import com.ynxbd.common.result.JsonResult; |
||||||
|
import com.ynxbd.wx.wxfactory.medical.WxMedConfig; |
||||||
|
import com.ynxbd.wx.wxfactory.medical.enums.MdRefundTypeEnum; |
||||||
|
import com.ynxbd.wx.wxfactory.medical.models.*; |
||||||
|
import com.ynxbd.wx.wxfactory.medins.models.CreateOrder; |
||||||
|
import com.ynxbd.wx.wxfactory.payment.OrderMIEnum; |
||||||
|
import com.ynxbd.wx.wxfactory.utils.WxRequestHelper; |
||||||
|
import com.ynxbd.wx.wxfactory.utils.WxSignHelper; |
||||||
|
import lombok.NoArgsConstructor; |
||||||
|
import lombok.extern.slf4j.Slf4j; |
||||||
|
import org.apache.commons.lang3.ObjectUtils; |
||||||
|
|
||||||
|
import java.math.BigDecimal; |
||||||
|
import java.time.LocalDateTime; |
||||||
|
import java.util.*; |
||||||
|
|
||||||
|
@Slf4j |
||||||
|
@NoArgsConstructor |
||||||
|
public class Client { |
||||||
|
|
||||||
|
/** |
||||||
|
* 免密授权地址 |
||||||
|
* |
||||||
|
* @param url url |
||||||
|
*/ |
||||||
|
public String getAuthUrl(String url) { |
||||||
|
String configUrl = WxMedConfig.getConfigUrl(url); |
||||||
|
log.info("[医保]免密授权地址-{}", configUrl); |
||||||
|
return configUrl; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* 微信医保下单 |
||||||
|
* |
||||||
|
* @param OrderMIEnum 订单类型 |
||||||
|
* @param appId appId |
||||||
|
* @param cityCode 城市编码 |
||||||
|
* @param hospitalName 医院名称 |
||||||
|
* @param orgNo 医疗机构编码(医保局分配给机构) |
||||||
|
* @param channel channel |
||||||
|
* @param openId openid |
||||||
|
* @param payAuthNo 医保授权码 |
||||||
|
* @param payOrdId 医保订单id |
||||||
|
* @param outTradeNo 商户订单号 |
||||||
|
* @param serialNo serialNo |
||||||
|
* @param totalFee 【使用该接口下单的总金额】 单位分,使用该接口下单的总金额。 |
||||||
|
* @param insuranceFee 医保支付金额 |
||||||
|
* @param cashFee 自费现金部分 |
||||||
|
* @param govFee 统筹支付 |
||||||
|
* @param selfFee 个账支付 |
||||||
|
* @param cardNo 证件号码 |
||||||
|
* @param realName 真实姓名 |
||||||
|
* @param callbackUrl 回调通知地址 |
||||||
|
* @return MedicalPayOrder |
||||||
|
*/ |
||||||
|
public MedicalPayOrder createOrder(OrderMIEnum OrderMIEnum, |
||||||
|
String appId, String openId, String cityCode, String hospitalName, |
||||||
|
String orgNo, String channel, |
||||||
|
String payAuthNo, String payOrdId, String outTradeNo, String serialNo, |
||||||
|
BigDecimal totalFee, BigDecimal insuranceFee, BigDecimal cashFee, BigDecimal govFee, BigDecimal selfFee, |
||||||
|
String cardNo, String realName, |
||||||
|
String callbackUrl, String attach, boolean isRelative, String relativeName, String relativeCardNo) { |
||||||
|
try { |
||||||
|
if (cashFee == null) cashFee = new BigDecimal(0); |
||||||
|
|
||||||
|
boolean isPureSelfPaid = totalFee.compareTo(cashFee) == 0 ; // 为纯自费支付
|
||||||
|
|
||||||
|
// TODO: 请准备商户开发必要参数,参考:https://pay.weixin.qq.com/doc/v3/merchant/4013070756
|
||||||
|
CreateOrder client = new CreateOrder(); |
||||||
|
|
||||||
|
CreateOrder.CreateOrderRequest request = new CreateOrder.CreateOrderRequest(); |
||||||
|
request.mixPayType = CreateOrder.MixPayType.CASH_AND_INSURANCE; // 混合支付类型
|
||||||
|
request.orderType = CreateOrder.OrderType.REG_PAY; // 订单类型
|
||||||
|
|
||||||
|
request.appid = appId; |
||||||
|
request.openid = openId; |
||||||
|
request.payer = new CreateOrder.PersonIdentification(); |
||||||
|
request.payer.name = client.encrypt(realName); |
||||||
|
request.payer.idDigest = client.encrypt(cardNo); |
||||||
|
request.payer.cardType = CreateOrder.UserCardType.ID_CARD; |
||||||
|
request.payForRelatives = isRelative; // 【是否代亲属支付】 不传默认替本人支付【代亲属支付:true 本人支付:false】
|
||||||
|
|
||||||
|
if (isRelative) { // 亲属身份信息
|
||||||
|
request.relative = new CreateOrder.PersonIdentification(); |
||||||
|
request.relative.name = client.encrypt(relativeName); |
||||||
|
request.relative.idDigest = client.encrypt(relativeCardNo); |
||||||
|
request.relative.cardType = CreateOrder.UserCardType.ID_CARD; |
||||||
|
} |
||||||
|
|
||||||
|
request.outTradeNo = outTradeNo; // 商户订单号
|
||||||
|
request.serialNo = serialNo; // 医疗机构订单号
|
||||||
|
request.payOrderId = payOrdId; // 医保局返回的支付单ID
|
||||||
|
request.payAuthNo = payAuthNo; // 医保局返回的支付授权码
|
||||||
|
request.geoLocation = "0,0"; // 经纬度未获取到用户定位时,传“0,0”。| 纯医保支付或医保自费混合支付时该字段必填,纯自费支付时禁止填写。
|
||||||
|
request.cityId = cityCode; // 城市ID
|
||||||
|
|
||||||
|
request.medInstName = hospitalName; // 医疗机构名称
|
||||||
|
request.medInstNo = orgNo; |
||||||
|
request.medInsOrderCreateTime = LocalDateTime.now().toString(); |
||||||
|
|
||||||
|
request.totalFee = totalFee.movePointRight(2).longValue(); |
||||||
|
if (!isPureSelfPaid) { |
||||||
|
request.medInsGovFee = govFee.movePointRight(2).longValue(); // 【医保统筹支付金额】纯医保支付或医保自费混合支付时该字段必填,纯自费支付时禁止填写。
|
||||||
|
request.medInsSelfFee = selfFee.movePointRight(2).longValue(); // 【医保个账支付金额】纯医保支付或医保自费混合支付时该字段必填,纯自费支付时禁止填写。
|
||||||
|
request.medInsOtherFee = 0L; // 【医保其他支付金额】纯医保支付或医保自费混合支付时该字段必填,纯自费支付时禁止填写。
|
||||||
|
|
||||||
|
long medInsCashFee = cashFee.movePointRight(2).longValue(); |
||||||
|
request.medInsCashFee = medInsCashFee; // 【需要自费的金额】纯医保支付或医保自费混合支付时该字段必填,纯自费支付时禁止填写。
|
||||||
|
request.wechatPayCashFee = medInsCashFee; // 【实际需要用户微信支付的金额】纯自费支付或医保自费混合支付时必填,纯医保支付时禁止填写。| 实际需要用户微信支付的金额(wechat_pay_cash_fee)= 医保结算后需要自费的金额(med_ins_cash_fee)+ 现金补充金额(cash_add_detail) - 现金减免金额(cash_reduce_detail)
|
||||||
|
} |
||||||
|
|
||||||
|
request.callbackUrl = callbackUrl; |
||||||
|
request.prepayId = "wx201410272009395522657a690389285100"; // 【自费预下单ID】 微信支付预支付交易会话标识。用于后续接口调用中使用,该值有效期为2小时。纯自费支付或医保自费混合支付时必填,纯医保支付时禁止填写。
|
||||||
|
// request.passthroughRequestContent = "{\"payAuthNo\":\"AUTH****\",\"payOrdId\":\"ORD****\",\"setlLatlnt\":\"118.096435,24.485407\"}";
|
||||||
|
request.attach = attach; |
||||||
|
request.channelNo = channel; |
||||||
|
request.medInsTestEnv = false; |
||||||
|
|
||||||
|
CreateOrder.OrderEntity response = client.run("/v3/med-ins/orders", request); |
||||||
|
|
||||||
|
log.info("【微信-医保】[下单] resp={}", JsonHelper.toJsonString(response)); |
||||||
|
// if (!response.success()) {
|
||||||
|
// return new MedicalPayOrder().createResult(jsonResult);
|
||||||
|
// }
|
||||||
|
// return jsonResult.dataMapToBean(MedicalPayOrder.class);
|
||||||
|
|
||||||
|
return null; |
||||||
|
} catch (Exception e) { |
||||||
|
ErrorHelper.println(e); |
||||||
|
} |
||||||
|
return null; |
||||||
|
} |
||||||
|
|
||||||
|
|
||||||
|
public WxMedOrder queryOrder(String accessToken, String appId, String mchId, String mdPayKey, String outTradeNo, String medTransId) { |
||||||
|
JsonResult jsonResult = WxRequestHelper.postMdXml(("https://api.weixin.qq.com/payinsurance/queryorder?access_token=" + accessToken), params -> { |
||||||
|
params.put("appid", appId); |
||||||
|
params.put("mch_id", mchId); |
||||||
|
// 二选一
|
||||||
|
params.put("hosp_out_trade_no", outTradeNo); // 第三方服务商订单号 outTradeNo
|
||||||
|
// params.put("med_trans_id", medTransId); // 微信生成的医疗订单号
|
||||||
|
params.put("nonce_str", UUID.randomUUID().toString().replaceAll("-", "")); |
||||||
|
|
||||||
|
params.put("sign", WxSignHelper.generateSign(params, WxSignHelper.SIGN_TYPE_MD5, mdPayKey)); |
||||||
|
log.info("[医保][查询订单] req={}", JsonHelper.toJsonString(params)); |
||||||
|
}); |
||||||
|
if (!jsonResult.success()) { |
||||||
|
return new WxMedOrder().createResult(jsonResult); |
||||||
|
} |
||||||
|
log.info("[医保][查询订单] req={}", JsonHelper.toJsonString(jsonResult)); |
||||||
|
WxMedOrder order = jsonResult.dataMapToBean(WxMedOrder.class); |
||||||
|
|
||||||
|
order.setCashFee(order.getCashFee().movePointLeft(2)); |
||||||
|
order.setTotalFee(order.getTotalFee().movePointLeft(2)); |
||||||
|
|
||||||
|
order.setInsuranceFee(order.getInsuranceFee().movePointLeft(2)); |
||||||
|
order.setInsuranceSelfFee(order.getInsuranceSelfFee().movePointLeft(2)); |
||||||
|
|
||||||
|
order.setInsuranceFundFee(order.getInsuranceFundFee().movePointLeft(2)); |
||||||
|
order.setInsuranceOtherFee(order.getInsuranceOtherFee().movePointLeft(2)); |
||||||
|
return order; |
||||||
|
} |
||||||
|
|
||||||
|
|
||||||
|
/** |
||||||
|
* [医保]退费(注意:需要先退医保部分,才能退现金部分) |
||||||
|
* |
||||||
|
* @param accessToken accessToken |
||||||
|
* @param appId appId |
||||||
|
* @param mchId mchId |
||||||
|
* @param mdPayKey mdPayKey |
||||||
|
* @param outTradeNo 订单号 |
||||||
|
* @param outRefundNo 退费订单号 |
||||||
|
* @param payOrdId 医保订单号|对应处方上传的出参单号 |
||||||
|
* @param cashFee 退费现金 |
||||||
|
* @param reason 退费原因 |
||||||
|
*/ |
||||||
|
public WxMedOrder refund(String accessToken, String appId, String mchId, String mdPayKey, String outTradeNo, String outRefundNo, String payOrdId, BigDecimal cashFee, MdRefundTypeEnum mdRefundTypeEnum, String reason) { |
||||||
|
if (reason == null) { |
||||||
|
reason = "申请退款"; |
||||||
|
} |
||||||
|
|
||||||
|
if (cashFee == null || cashFee.compareTo(BigDecimal.ZERO) == 0) { |
||||||
|
return new MedicalRefund().createResult("退费金额错误或为0禁止退费"); |
||||||
|
} |
||||||
|
String finalReason = reason; |
||||||
|
JsonResult jsonResult = WxRequestHelper.postMdXml(("https://api.weixin.qq.com/payinsurance/refund?access_token=" + accessToken), params -> { |
||||||
|
params.put("appid", appId); |
||||||
|
params.put("mch_id", mchId); |
||||||
|
// 二选一
|
||||||
|
params.put("hosp_out_trade_no", outTradeNo); // 第三方服务商订单号 outTradeNo
|
||||||
|
// params.put("med_trans_id", medTransId); // 微信生成的医疗订单号
|
||||||
|
|
||||||
|
params.put("hosp_out_refund_no", outRefundNo); // 退费订单号
|
||||||
|
params.put("nonce_str", UUID.randomUUID().toString().replaceAll("-", "")); |
||||||
|
|
||||||
|
JSONObject requestContent = new JSONObject(); |
||||||
|
requestContent.put("ref_reason", finalReason); |
||||||
|
requestContent.put("payOrdId", payOrdId); |
||||||
|
params.put("request_content", JSON.toJSONString(requestContent)); |
||||||
|
|
||||||
|
|
||||||
|
// /**
|
||||||
|
// * 不填默认全退,填写金额时则指定金额不能为0;该参数只在part_refund_type为INS_INDIVIDUAL_PART生效
|
||||||
|
// */
|
||||||
|
// params.put("ins_refund_fee", new BigDecimal(refundFee).movePointRight(2)); // 个账部分退款
|
||||||
|
|
||||||
|
if (mdRefundTypeEnum != null) { |
||||||
|
params.put("part_refund_type", mdRefundTypeEnum.CODE); // 只退现金部分
|
||||||
|
} |
||||||
|
|
||||||
|
params.put("cash_refund_fee", cashFee.movePointRight(2).intValue()); // 现金退款
|
||||||
|
|
||||||
|
// 医保退款必须------------------------------------------------
|
||||||
|
params.put("cancel_bill_no", outRefundNo); // 撤销单据号
|
||||||
|
params.put("cancel_serial_no", outRefundNo); // 撤销流水号
|
||||||
|
|
||||||
|
params.put("sign", WxSignHelper.generateSign(params, WxSignHelper.SIGN_TYPE_MD5, mdPayKey)); |
||||||
|
log.info("[医保][退费] req={}", JsonHelper.toJsonString(params)); |
||||||
|
}); |
||||||
|
|
||||||
|
log.info("[医保][退费] resp={}", JsonHelper.toJsonString(jsonResult)); |
||||||
|
if (!jsonResult.success()) { |
||||||
|
return new MedicalRefund().createResult(jsonResult); |
||||||
|
} |
||||||
|
return jsonResult.dataMapToBean(WxMedOrder.class); |
||||||
|
} |
||||||
|
|
||||||
|
|
||||||
|
/** |
||||||
|
* [医保]查询退费 |
||||||
|
* |
||||||
|
* @param accessToken accessToken |
||||||
|
* @param appId appId |
||||||
|
* @param mchId mchId |
||||||
|
* @param mdPayKey mdPayKey |
||||||
|
*/ |
||||||
|
public MedicalRefundInfo queryRefund(String accessToken, String appId, String mchId, String mdPayKey, String outRefundNo, String outTradeNo, String medTransId) { |
||||||
|
JsonResult jsonResult = WxRequestHelper.postMdXml(("https://api.weixin.qq.com/payinsurance/queryrefund?access_token=" + accessToken), params -> { |
||||||
|
params.put("appid", appId); |
||||||
|
params.put("mch_id", mchId); |
||||||
|
// 四选一 优先级(med_refund_id > hosp_out_refund_no > hosp_out_trade_no > med_trans_id)
|
||||||
|
if (!ObjectUtils.isEmpty(outRefundNo)) { |
||||||
|
params.put("hosp_out_refund_no", outRefundNo); // 医院退款订单号
|
||||||
|
} |
||||||
|
if (!ObjectUtils.isEmpty(outTradeNo)) { |
||||||
|
params.put("hosp_out_trade_no", outTradeNo); // 第三方服务商订单号 outTradeNo
|
||||||
|
} |
||||||
|
if (!ObjectUtils.isEmpty(medTransId)) { |
||||||
|
params.put("med_trans_id", medTransId); // 微信生成的医疗订单号
|
||||||
|
} |
||||||
|
params.put("nonce_str", UUID.randomUUID().toString().replaceAll("-", "")); |
||||||
|
|
||||||
|
params.put("sign", WxSignHelper.generateSign(params, WxSignHelper.SIGN_TYPE_MD5, mdPayKey)); |
||||||
|
|
||||||
|
log.info("[医保][查询退费] req={}", JsonHelper.toJsonString(params)); |
||||||
|
}); |
||||||
|
if (!jsonResult.success()) { |
||||||
|
return new MedicalRefundInfo().createResult(jsonResult); |
||||||
|
} |
||||||
|
return jsonResult.dataMapToBean(MedicalRefundInfo.class); |
||||||
|
} |
||||||
|
|
||||||
|
|
||||||
|
/** |
||||||
|
* [医保]下载对账账单 |
||||||
|
* |
||||||
|
* @param accessToken accessToken |
||||||
|
* @param appId appId |
||||||
|
* @param mchId mchId |
||||||
|
* @param mdPayKey mdPayKey |
||||||
|
* @param billDate 对账单日期(yyyyMMdd) |
||||||
|
* @param billType 帐单类型 (ALL,返回当日所有订单信息;默认值 SUCCESS,返回当日成功支付的订单;REFUND,返回当日退款订单) |
||||||
|
*/ |
||||||
|
public MedicalBill downBill(String accessToken, String appId, String mchId, String mdPayKey, String billDate, String billType) { |
||||||
|
|
||||||
|
JsonResult jsonResult = WxRequestHelper.postMdXml(("https://api.weixin.qq.com/payinsurance/billdownload?access_token=" + accessToken), params -> { |
||||||
|
params.put("appid", appId); |
||||||
|
params.put("mch_id", mchId); |
||||||
|
//
|
||||||
|
params.put("bill_date", billDate); |
||||||
|
params.put("bill_type", billType); |
||||||
|
|
||||||
|
params.put("nonce_str", UUID.randomUUID().toString().replaceAll("-", "")); |
||||||
|
params.put("sign", WxSignHelper.generateSign(params, WxSignHelper.SIGN_TYPE_MD5, mdPayKey)); |
||||||
|
log.info("[医保][账单下载] req={}", JsonHelper.toJsonString(params)); |
||||||
|
}); |
||||||
|
|
||||||
|
if (!jsonResult.success()) { |
||||||
|
return new MedicalRefund().createResult(jsonResult); |
||||||
|
} |
||||||
|
log.info("[医保][账单下载] resp={}", JsonHelper.toJsonString(jsonResult)); |
||||||
|
return jsonResult.dataMapToBean(MedicalBill.class); |
||||||
|
} |
||||||
|
|
||||||
|
|
||||||
|
/** |
||||||
|
* 获取用户授权码等信息(注意:联系腾讯修改白名单后,调用接口会报授权码错误的提示,需切换请求路径) |
||||||
|
* |
||||||
|
* @param reqUrl 请求地址 |
||||||
|
* @param openid openid |
||||||
|
* @param qrcode 用户授权码 |
||||||
|
* @return 用户信息 |
||||||
|
*/ |
||||||
|
public MedicalUserInfo getUserInfo(String reqUrl, String openid, String qrcode) { |
||||||
|
log.info("【微信-医保】[授权]请求地址-{}", reqUrl); |
||||||
|
MedicalUserInfo info = new MedicalUserInfo(); |
||||||
|
String timestamp = Long.toString(System.currentTimeMillis()); |
||||||
|
|
||||||
|
String signature = WxMedConfig.createSignature(timestamp); |
||||||
|
if (signature == null) { |
||||||
|
info.setMessage("signature签名错误"); |
||||||
|
return info; |
||||||
|
} |
||||||
|
JSONObject respJson = OkHttpHelper.postForm(reqUrl, params -> { |
||||||
|
params.put("qrcode", qrcode); |
||||||
|
params.put("openid", openid); |
||||||
|
// 【微信-医保】测试报告内容[1]
|
||||||
|
log.info("【微信-医保】[授权] req={}", JsonHelper.toJsonString(params)); |
||||||
|
}, headers -> { |
||||||
|
headers.set("Accept", "application/json"); |
||||||
|
headers.set("Content-Type", "application/json"); |
||||||
|
//
|
||||||
|
headers.set("god-portal-timestamp", timestamp); |
||||||
|
headers.set("god-portal-signature", signature); |
||||||
|
headers.set("god-portal-request-id", UUID.randomUUID().toString().replaceAll("-", "")); |
||||||
|
}); |
||||||
|
if (respJson == null) { |
||||||
|
info.setMessage("请求用户信息失败"); |
||||||
|
return info; |
||||||
|
} |
||||||
|
String code = respJson.getString("code"); |
||||||
|
String message = respJson.getString("message"); |
||||||
|
log.info("respJson={}", JsonHelper.toJsonString(respJson)); |
||||||
|
if (!"0".equals(code)) { |
||||||
|
log.error("【微信-医保】[授权]失败 resp={}", respJson); |
||||||
|
|
||||||
|
info.setMessage(String.format("[%s] %s", code, message)); |
||||||
|
return info; |
||||||
|
} |
||||||
|
|
||||||
|
|
||||||
|
JSONObject longitudeLatitude = respJson.getJSONObject("user_longitude_latitude"); |
||||||
|
if (longitudeLatitude == null) { |
||||||
|
info.setMessage(String.format("[%s] %s, tip=%s", code, message, "经纬度异常")); |
||||||
|
return info; |
||||||
|
} |
||||||
|
String longitude = longitudeLatitude.getString("longitude"); |
||||||
|
String latitude = longitudeLatitude.getString("latitude"); |
||||||
|
|
||||||
|
String userName = respJson.getString("user_name"); |
||||||
|
String payAuthNo = respJson.getString("pay_auth_no"); |
||||||
|
String cardNo = respJson.getString("user_card_no"); |
||||||
|
if (cardNo != null && !"".equals(cardNo)) { |
||||||
|
info.setCardNo(cardNo); |
||||||
|
respJson.remove("user_card_no"); |
||||||
|
} |
||||||
|
log.error("【微信-医保】[授权]返回 resp={}", respJson); |
||||||
|
|
||||||
|
info.setUserName(userName); |
||||||
|
info.setPayAuthNo(payAuthNo); |
||||||
|
info.setLongitude(longitude); |
||||||
|
info.setLatitude(latitude); |
||||||
|
info.setSuccess(true); |
||||||
|
return info; |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,908 @@ |
|||||||
|
package com.ynxbd.wx.wxfactory.medins.util; |
||||||
|
|
||||||
|
import com.google.gson.ExclusionStrategy; |
||||||
|
import com.google.gson.FieldAttributes; |
||||||
|
import com.google.gson.Gson; |
||||||
|
import com.google.gson.GsonBuilder; |
||||||
|
import com.google.gson.JsonElement; |
||||||
|
import com.google.gson.JsonObject; |
||||||
|
import com.google.gson.JsonSyntaxException; |
||||||
|
import com.google.gson.annotations.Expose; |
||||||
|
import com.google.gson.annotations.SerializedName; |
||||||
|
|
||||||
|
import java.util.List; |
||||||
|
import java.util.Map.Entry; |
||||||
|
|
||||||
|
import okhttp3.Headers; |
||||||
|
import okhttp3.Response; |
||||||
|
import okio.BufferedSource; |
||||||
|
|
||||||
|
import javax.crypto.BadPaddingException; |
||||||
|
import javax.crypto.Cipher; |
||||||
|
import javax.crypto.IllegalBlockSizeException; |
||||||
|
import javax.crypto.NoSuchPaddingException; |
||||||
|
import javax.crypto.spec.GCMParameterSpec; |
||||||
|
import javax.crypto.spec.SecretKeySpec; |
||||||
|
import java.io.IOException; |
||||||
|
import java.io.UncheckedIOException; |
||||||
|
import java.io.UnsupportedEncodingException; |
||||||
|
import java.net.URLEncoder; |
||||||
|
import java.nio.charset.StandardCharsets; |
||||||
|
import java.nio.file.Files; |
||||||
|
import java.nio.file.Paths; |
||||||
|
import java.security.InvalidAlgorithmParameterException; |
||||||
|
import java.security.InvalidKeyException; |
||||||
|
import java.security.KeyFactory; |
||||||
|
import java.security.NoSuchAlgorithmException; |
||||||
|
import java.security.PrivateKey; |
||||||
|
import java.security.PublicKey; |
||||||
|
import java.security.SecureRandom; |
||||||
|
import java.security.Signature; |
||||||
|
import java.security.SignatureException; |
||||||
|
import java.security.spec.InvalidKeySpecException; |
||||||
|
import java.security.spec.PKCS8EncodedKeySpec; |
||||||
|
import java.security.spec.X509EncodedKeySpec; |
||||||
|
import java.time.DateTimeException; |
||||||
|
import java.time.Duration; |
||||||
|
import java.time.Instant; |
||||||
|
import java.util.Base64; |
||||||
|
import java.util.HashMap; |
||||||
|
import java.util.Map; |
||||||
|
import java.util.Objects; |
||||||
|
import java.security.MessageDigest; |
||||||
|
import java.io.InputStream; |
||||||
|
|
||||||
|
import org.bouncycastle.crypto.digests.SM3Digest; |
||||||
|
import org.bouncycastle.jce.provider.BouncyCastleProvider; |
||||||
|
|
||||||
|
import java.security.Security; |
||||||
|
|
||||||
|
public class WXPayUtility { |
||||||
|
private static final Gson gson = new GsonBuilder() |
||||||
|
.disableHtmlEscaping() |
||||||
|
.addSerializationExclusionStrategy(new ExclusionStrategy() { |
||||||
|
@Override |
||||||
|
public boolean shouldSkipField(FieldAttributes fieldAttributes) { |
||||||
|
final Expose expose = fieldAttributes.getAnnotation(Expose.class); |
||||||
|
return expose != null && !expose.serialize(); |
||||||
|
} |
||||||
|
|
||||||
|
@Override |
||||||
|
public boolean shouldSkipClass(Class<?> aClass) { |
||||||
|
return false; |
||||||
|
} |
||||||
|
}) |
||||||
|
.addDeserializationExclusionStrategy(new ExclusionStrategy() { |
||||||
|
@Override |
||||||
|
public boolean shouldSkipField(FieldAttributes fieldAttributes) { |
||||||
|
final Expose expose = fieldAttributes.getAnnotation(Expose.class); |
||||||
|
return expose != null && !expose.deserialize(); |
||||||
|
} |
||||||
|
|
||||||
|
@Override |
||||||
|
public boolean shouldSkipClass(Class<?> aClass) { |
||||||
|
return false; |
||||||
|
} |
||||||
|
}) |
||||||
|
.create(); |
||||||
|
private static final char[] SYMBOLS = |
||||||
|
"0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ".toCharArray(); |
||||||
|
private static final SecureRandom random = new SecureRandom(); |
||||||
|
|
||||||
|
/** |
||||||
|
* 将 Object 转换为 JSON 字符串 |
||||||
|
*/ |
||||||
|
public static String toJson(Object object) { |
||||||
|
return gson.toJson(object); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* 将 JSON 字符串解析为特定类型的实例 |
||||||
|
*/ |
||||||
|
public static <T> T fromJson(String json, Class<T> classOfT) throws JsonSyntaxException { |
||||||
|
return gson.fromJson(json, classOfT); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* 从公私钥文件路径中读取文件内容 |
||||||
|
* |
||||||
|
* @param keyPath 文件路径 |
||||||
|
* @return 文件内容 |
||||||
|
*/ |
||||||
|
private static String readKeyStringFromPath(String keyPath) { |
||||||
|
try { |
||||||
|
return new String(Files.readAllBytes(Paths.get(keyPath)), StandardCharsets.UTF_8); |
||||||
|
} catch (IOException e) { |
||||||
|
throw new UncheckedIOException(e); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* 读取 PKCS#8 格式的私钥字符串并加载为私钥对象 |
||||||
|
* |
||||||
|
* @param keyString 私钥文件内容,以 -----BEGIN PRIVATE KEY----- 开头 |
||||||
|
* @return PrivateKey 对象 |
||||||
|
*/ |
||||||
|
public static PrivateKey loadPrivateKeyFromString(String keyString) { |
||||||
|
try { |
||||||
|
keyString = keyString.replace("-----BEGIN PRIVATE KEY-----", "") |
||||||
|
.replace("-----END PRIVATE KEY-----", "") |
||||||
|
.replaceAll("\\s+", ""); |
||||||
|
return KeyFactory.getInstance("RSA").generatePrivate( |
||||||
|
new PKCS8EncodedKeySpec(Base64.getDecoder().decode(keyString))); |
||||||
|
} catch (NoSuchAlgorithmException e) { |
||||||
|
throw new UnsupportedOperationException(e); |
||||||
|
} catch (InvalidKeySpecException e) { |
||||||
|
throw new IllegalArgumentException(e); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* 从 PKCS#8 格式的私钥文件中加载私钥 |
||||||
|
* |
||||||
|
* @param keyPath 私钥文件路径 |
||||||
|
* @return PrivateKey 对象 |
||||||
|
*/ |
||||||
|
public static PrivateKey loadPrivateKeyFromPath(String keyPath) { |
||||||
|
return loadPrivateKeyFromString(readKeyStringFromPath(keyPath)); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* 读取 PKCS#8 格式的公钥字符串并加载为公钥对象 |
||||||
|
* |
||||||
|
* @param keyString 公钥文件内容,以 -----BEGIN PUBLIC KEY----- 开头 |
||||||
|
* @return PublicKey 对象 |
||||||
|
*/ |
||||||
|
public static PublicKey loadPublicKeyFromString(String keyString) { |
||||||
|
try { |
||||||
|
keyString = keyString.replace("-----BEGIN PUBLIC KEY-----", "") |
||||||
|
.replace("-----END PUBLIC KEY-----", "") |
||||||
|
.replaceAll("\\s+", ""); |
||||||
|
return KeyFactory.getInstance("RSA").generatePublic( |
||||||
|
new X509EncodedKeySpec(Base64.getDecoder().decode(keyString))); |
||||||
|
} catch (NoSuchAlgorithmException e) { |
||||||
|
throw new UnsupportedOperationException(e); |
||||||
|
} catch (InvalidKeySpecException e) { |
||||||
|
throw new IllegalArgumentException(e); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* 从 PKCS#8 格式的公钥文件中加载公钥 |
||||||
|
* |
||||||
|
* @param keyPath 公钥文件路径 |
||||||
|
* @return PublicKey 对象 |
||||||
|
*/ |
||||||
|
public static PublicKey loadPublicKeyFromPath(String keyPath) { |
||||||
|
return loadPublicKeyFromString(readKeyStringFromPath(keyPath)); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* 创建指定长度的随机字符串,字符集为[0-9a-zA-Z],可用于安全相关用途 |
||||||
|
*/ |
||||||
|
public static String createNonce(int length) { |
||||||
|
char[] buf = new char[length]; |
||||||
|
for (int i = 0; i < length; ++i) { |
||||||
|
buf[i] = SYMBOLS[random.nextInt(SYMBOLS.length)]; |
||||||
|
} |
||||||
|
return new String(buf); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* 使用公钥按照 RSA_PKCS1_OAEP_PADDING 算法进行加密 |
||||||
|
* |
||||||
|
* @param publicKey 加密用公钥对象 |
||||||
|
* @param plaintext 待加密明文 |
||||||
|
* @return 加密后密文 |
||||||
|
*/ |
||||||
|
public static String encrypt(PublicKey publicKey, String plaintext) { |
||||||
|
final String transformation = "RSA/ECB/OAEPWithSHA-1AndMGF1Padding"; |
||||||
|
|
||||||
|
try { |
||||||
|
Cipher cipher = Cipher.getInstance(transformation); |
||||||
|
cipher.init(Cipher.ENCRYPT_MODE, publicKey); |
||||||
|
return Base64.getEncoder().encodeToString(cipher.doFinal(plaintext.getBytes(StandardCharsets.UTF_8))); |
||||||
|
} catch (NoSuchAlgorithmException | NoSuchPaddingException e) { |
||||||
|
throw new IllegalArgumentException("The current Java environment does not support " + transformation, e); |
||||||
|
} catch (InvalidKeyException e) { |
||||||
|
throw new IllegalArgumentException("RSA encryption using an illegal publicKey", e); |
||||||
|
} catch (BadPaddingException | IllegalBlockSizeException e) { |
||||||
|
throw new IllegalArgumentException("Plaintext is too long", e); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* 使用私钥按照 RSA_PKCS1_OAEP_PADDING 算法进行解密 |
||||||
|
* |
||||||
|
* @param privateKey 解密用私钥对象 |
||||||
|
* @param ciphertext 待解密密文(Base64编码的字符串) |
||||||
|
* @return 解密后明文 |
||||||
|
*/ |
||||||
|
public static String rsaOaepDecrypt(PrivateKey privateKey, String ciphertext) { |
||||||
|
final String transformation = "RSA/ECB/OAEPWithSHA-1AndMGF1Padding"; |
||||||
|
|
||||||
|
try { |
||||||
|
Cipher cipher = Cipher.getInstance(transformation); |
||||||
|
cipher.init(Cipher.DECRYPT_MODE, privateKey); |
||||||
|
byte[] decryptedBytes = cipher.doFinal(Base64.getDecoder().decode(ciphertext)); |
||||||
|
return new String(decryptedBytes, StandardCharsets.UTF_8); |
||||||
|
} catch (NoSuchAlgorithmException | NoSuchPaddingException e) { |
||||||
|
throw new IllegalArgumentException("The current Java environment does not support " + transformation, e); |
||||||
|
} catch (InvalidKeyException e) { |
||||||
|
throw new IllegalArgumentException("RSA decryption using an illegal privateKey", e); |
||||||
|
} catch (BadPaddingException | IllegalBlockSizeException e) { |
||||||
|
throw new IllegalArgumentException("Ciphertext decryption failed", e); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
public static String aesAeadDecrypt(byte[] key, byte[] associatedData, byte[] nonce, |
||||||
|
byte[] ciphertext) { |
||||||
|
final String transformation = "AES/GCM/NoPadding"; |
||||||
|
final String algorithm = "AES"; |
||||||
|
final int tagLengthBit = 128; |
||||||
|
|
||||||
|
try { |
||||||
|
Cipher cipher = Cipher.getInstance(transformation); |
||||||
|
cipher.init( |
||||||
|
Cipher.DECRYPT_MODE, |
||||||
|
new SecretKeySpec(key, algorithm), |
||||||
|
new GCMParameterSpec(tagLengthBit, nonce)); |
||||||
|
if (associatedData != null) { |
||||||
|
cipher.updateAAD(associatedData); |
||||||
|
} |
||||||
|
return new String(cipher.doFinal(ciphertext), StandardCharsets.UTF_8); |
||||||
|
} catch (InvalidKeyException |
||||||
|
| InvalidAlgorithmParameterException |
||||||
|
| BadPaddingException |
||||||
|
| IllegalBlockSizeException |
||||||
|
| NoSuchAlgorithmException |
||||||
|
| NoSuchPaddingException e) { |
||||||
|
throw new IllegalArgumentException(String.format("AesAeadDecrypt with %s Failed", |
||||||
|
transformation), e); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* 使用私钥按照指定算法进行签名 |
||||||
|
* |
||||||
|
* @param message 待签名串 |
||||||
|
* @param algorithm 签名算法,如 SHA256withRSA |
||||||
|
* @param privateKey 签名用私钥对象 |
||||||
|
* @return 签名结果 |
||||||
|
*/ |
||||||
|
public static String sign(String message, String algorithm, PrivateKey privateKey) { |
||||||
|
byte[] sign; |
||||||
|
try { |
||||||
|
Signature signature = Signature.getInstance(algorithm); |
||||||
|
signature.initSign(privateKey); |
||||||
|
signature.update(message.getBytes(StandardCharsets.UTF_8)); |
||||||
|
sign = signature.sign(); |
||||||
|
} catch (NoSuchAlgorithmException e) { |
||||||
|
throw new UnsupportedOperationException("The current Java environment does not support " + algorithm, e); |
||||||
|
} catch (InvalidKeyException e) { |
||||||
|
throw new IllegalArgumentException(algorithm + " signature uses an illegal privateKey.", e); |
||||||
|
} catch (SignatureException e) { |
||||||
|
throw new RuntimeException("An error occurred during the sign process.", e); |
||||||
|
} |
||||||
|
return Base64.getEncoder().encodeToString(sign); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* 使用公钥按照特定算法验证签名 |
||||||
|
* |
||||||
|
* @param message 待签名串 |
||||||
|
* @param signature 待验证的签名内容 |
||||||
|
* @param algorithm 签名算法,如:SHA256withRSA |
||||||
|
* @param publicKey 验签用公钥对象 |
||||||
|
* @return 签名验证是否通过 |
||||||
|
*/ |
||||||
|
public static boolean verify(String message, String signature, String algorithm, |
||||||
|
PublicKey publicKey) { |
||||||
|
try { |
||||||
|
Signature sign = Signature.getInstance(algorithm); |
||||||
|
sign.initVerify(publicKey); |
||||||
|
sign.update(message.getBytes(StandardCharsets.UTF_8)); |
||||||
|
return sign.verify(Base64.getDecoder().decode(signature)); |
||||||
|
} catch (SignatureException e) { |
||||||
|
return false; |
||||||
|
} catch (InvalidKeyException e) { |
||||||
|
throw new IllegalArgumentException("verify uses an illegal publickey.", e); |
||||||
|
} catch (NoSuchAlgorithmException e) { |
||||||
|
throw new UnsupportedOperationException("The current Java environment does not support" + algorithm, e); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* 根据微信支付APIv3请求签名规则构造 Authorization 签名 |
||||||
|
* |
||||||
|
* @param mchid 商户号 |
||||||
|
* @param certificateSerialNo 商户API证书序列号 |
||||||
|
* @param privateKey 商户API证书私钥 |
||||||
|
* @param method 请求接口的HTTP方法,请使用全大写表述,如 GET、POST、PUT、DELETE |
||||||
|
* @param uri 请求接口的URL |
||||||
|
* @param body 请求接口的Body |
||||||
|
* @return 构造好的微信支付APIv3 Authorization 头 |
||||||
|
*/ |
||||||
|
public static String buildAuthorization(String mchid, String certificateSerialNo, |
||||||
|
PrivateKey privateKey, |
||||||
|
String method, String uri, String body) { |
||||||
|
String nonce = createNonce(32); |
||||||
|
long timestamp = Instant.now().getEpochSecond(); |
||||||
|
|
||||||
|
String message = String.format("%s\n%s\n%d\n%s\n%s\n", method, uri, timestamp, nonce, |
||||||
|
body == null ? "" : body); |
||||||
|
|
||||||
|
String signature = sign(message, "SHA256withRSA", privateKey); |
||||||
|
|
||||||
|
return String.format( |
||||||
|
"WECHATPAY2-SHA256-RSA2048 mchid=\"%s\",nonce_str=\"%s\",signature=\"%s\"," + |
||||||
|
"timestamp=\"%d\",serial_no=\"%s\"", |
||||||
|
mchid, nonce, signature, timestamp, certificateSerialNo); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* 计算输入流的哈希值 |
||||||
|
* |
||||||
|
* @param inputStream 输入流 |
||||||
|
* @param algorithm 哈希算法名称,如 "SHA-256", "SHA-1" |
||||||
|
* @return 哈希值的十六进制字符串 |
||||||
|
*/ |
||||||
|
private static String calculateHash(InputStream inputStream, String algorithm) { |
||||||
|
try { |
||||||
|
MessageDigest digest = MessageDigest.getInstance(algorithm); |
||||||
|
byte[] buffer = new byte[8192]; |
||||||
|
int bytesRead; |
||||||
|
while ((bytesRead = inputStream.read(buffer)) != -1) { |
||||||
|
digest.update(buffer, 0, bytesRead); |
||||||
|
} |
||||||
|
byte[] hashBytes = digest.digest(); |
||||||
|
StringBuilder hexString = new StringBuilder(); |
||||||
|
for (byte b : hashBytes) { |
||||||
|
String hex = Integer.toHexString(0xff & b); |
||||||
|
if (hex.length() == 1) { |
||||||
|
hexString.append('0'); |
||||||
|
} |
||||||
|
hexString.append(hex); |
||||||
|
} |
||||||
|
return hexString.toString(); |
||||||
|
} catch (NoSuchAlgorithmException e) { |
||||||
|
throw new UnsupportedOperationException(algorithm + " algorithm not available", e); |
||||||
|
} catch (IOException e) { |
||||||
|
throw new RuntimeException("Error reading from input stream", e); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* 计算输入流的 SHA256 哈希值 |
||||||
|
* |
||||||
|
* @param inputStream 输入流 |
||||||
|
* @return SHA256 哈希值的十六进制字符串 |
||||||
|
*/ |
||||||
|
public static String sha256(InputStream inputStream) { |
||||||
|
return calculateHash(inputStream, "SHA-256"); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* 计算输入流的 SHA1 哈希值 |
||||||
|
* |
||||||
|
* @param inputStream 输入流 |
||||||
|
* @return SHA1 哈希值的十六进制字符串 |
||||||
|
*/ |
||||||
|
public static String sha1(InputStream inputStream) { |
||||||
|
return calculateHash(inputStream, "SHA-1"); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* 计算输入流的 SM3 哈希值 |
||||||
|
* |
||||||
|
* @param inputStream 输入流 |
||||||
|
* @return SM3 哈希值的十六进制字符串 |
||||||
|
*/ |
||||||
|
public static String sm3(InputStream inputStream) { |
||||||
|
// 确保Bouncy Castle Provider已注册
|
||||||
|
if (Security.getProvider(BouncyCastleProvider.PROVIDER_NAME) == null) { |
||||||
|
Security.addProvider(new BouncyCastleProvider()); |
||||||
|
} |
||||||
|
|
||||||
|
try { |
||||||
|
SM3Digest digest = new SM3Digest(); |
||||||
|
byte[] buffer = new byte[8192]; |
||||||
|
int bytesRead; |
||||||
|
while ((bytesRead = inputStream.read(buffer)) != -1) { |
||||||
|
digest.update(buffer, 0, bytesRead); |
||||||
|
} |
||||||
|
byte[] hashBytes = new byte[digest.getDigestSize()]; |
||||||
|
digest.doFinal(hashBytes, 0); |
||||||
|
|
||||||
|
StringBuilder hexString = new StringBuilder(); |
||||||
|
for (byte b : hashBytes) { |
||||||
|
String hex = Integer.toHexString(0xff & b); |
||||||
|
if (hex.length() == 1) { |
||||||
|
hexString.append('0'); |
||||||
|
} |
||||||
|
hexString.append(hex); |
||||||
|
} |
||||||
|
return hexString.toString(); |
||||||
|
} catch (IOException e) { |
||||||
|
throw new RuntimeException("Error reading from input stream", e); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* 对参数进行 URL 编码 |
||||||
|
* |
||||||
|
* @param content 参数内容 |
||||||
|
* @return 编码后的内容 |
||||||
|
*/ |
||||||
|
public static String urlEncode(String content) { |
||||||
|
try { |
||||||
|
return URLEncoder.encode(content, StandardCharsets.UTF_8.name()); |
||||||
|
} catch (UnsupportedEncodingException e) { |
||||||
|
throw new RuntimeException(e); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* 对参数Map进行 URL 编码,生成 QueryString |
||||||
|
* |
||||||
|
* @param params Query参数Map |
||||||
|
* @return QueryString |
||||||
|
*/ |
||||||
|
public static String urlEncode(Map<String, Object> params) { |
||||||
|
if (params == null || params.isEmpty()) { |
||||||
|
return ""; |
||||||
|
} |
||||||
|
|
||||||
|
StringBuilder result = new StringBuilder(); |
||||||
|
for (Entry<String, Object> entry : params.entrySet()) { |
||||||
|
if (entry.getValue() == null) { |
||||||
|
continue; |
||||||
|
} |
||||||
|
|
||||||
|
String key = entry.getKey(); |
||||||
|
Object value = entry.getValue(); |
||||||
|
if (value instanceof List) { |
||||||
|
List<?> list = (List<?>) entry.getValue(); |
||||||
|
for (Object temp : list) { |
||||||
|
appendParam(result, key, temp); |
||||||
|
} |
||||||
|
} else { |
||||||
|
appendParam(result, key, value); |
||||||
|
} |
||||||
|
} |
||||||
|
return result.toString(); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* 将键值对 放入返回结果 |
||||||
|
* |
||||||
|
* @param result 返回的query string |
||||||
|
* @param key 属性 |
||||||
|
* @param value 属性值 |
||||||
|
*/ |
||||||
|
private static void appendParam(StringBuilder result, String key, Object value) { |
||||||
|
if (result.length() > 0) { |
||||||
|
result.append("&"); |
||||||
|
} |
||||||
|
|
||||||
|
String valueString; |
||||||
|
// 如果是基本类型、字符串或枚举,直接转换;如果是对象,序列化为JSON
|
||||||
|
if (value instanceof String || value instanceof Number || |
||||||
|
value instanceof Boolean || value instanceof Enum) { |
||||||
|
valueString = value.toString(); |
||||||
|
} else { |
||||||
|
valueString = toJson(value); |
||||||
|
} |
||||||
|
|
||||||
|
result.append(key) |
||||||
|
.append("=") |
||||||
|
.append(urlEncode(valueString)); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* 从应答中提取 Body |
||||||
|
* |
||||||
|
* @param response HTTP 请求应答对象 |
||||||
|
* @return 应答中的Body内容,Body为空时返回空字符串 |
||||||
|
*/ |
||||||
|
public static String extractBody(Response response) { |
||||||
|
if (response.body() == null) { |
||||||
|
return ""; |
||||||
|
} |
||||||
|
|
||||||
|
try { |
||||||
|
BufferedSource source = response.body().source(); |
||||||
|
return source.readUtf8(); |
||||||
|
} catch (IOException e) { |
||||||
|
throw new RuntimeException(String.format("An error occurred during reading response body. " + |
||||||
|
"Status: %d", response.code()), e); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* 根据微信支付APIv3应答验签规则对应答签名进行验证,验证不通过时抛出异常 |
||||||
|
* |
||||||
|
* @param wechatpayPublicKeyId 微信支付公钥ID |
||||||
|
* @param wechatpayPublicKey 微信支付公钥对象 |
||||||
|
* @param headers 微信支付应答 Header 列表 |
||||||
|
* @param body 微信支付应答 Body |
||||||
|
*/ |
||||||
|
public static void validateResponse(String wechatpayPublicKeyId, PublicKey wechatpayPublicKey, |
||||||
|
Headers headers, |
||||||
|
String body) { |
||||||
|
String timestamp = headers.get("Wechatpay-Timestamp"); |
||||||
|
String requestId = headers.get("Request-ID"); |
||||||
|
try { |
||||||
|
Instant responseTime = Instant.ofEpochSecond(Long.parseLong(timestamp)); |
||||||
|
// 拒绝过期请求
|
||||||
|
if (Duration.between(responseTime, Instant.now()).abs().toMinutes() >= 5) { |
||||||
|
throw new IllegalArgumentException( |
||||||
|
String.format("Validate response failed, timestamp[%s] is expired, request-id[%s]", |
||||||
|
timestamp, requestId)); |
||||||
|
} |
||||||
|
} catch (DateTimeException | NumberFormatException e) { |
||||||
|
throw new IllegalArgumentException( |
||||||
|
String.format("Validate response failed, timestamp[%s] is invalid, request-id[%s]", |
||||||
|
timestamp, requestId)); |
||||||
|
} |
||||||
|
String serialNumber = headers.get("Wechatpay-Serial"); |
||||||
|
if (!Objects.equals(serialNumber, wechatpayPublicKeyId)) { |
||||||
|
throw new IllegalArgumentException( |
||||||
|
String.format("Validate response failed, Invalid Wechatpay-Serial, Local: %s, Remote: " + |
||||||
|
"%s", wechatpayPublicKeyId, serialNumber)); |
||||||
|
} |
||||||
|
|
||||||
|
String signature = headers.get("Wechatpay-Signature"); |
||||||
|
String message = String.format("%s\n%s\n%s\n", timestamp, headers.get("Wechatpay-Nonce"), |
||||||
|
body == null ? "" : body); |
||||||
|
|
||||||
|
boolean success = verify(message, signature, "SHA256withRSA", wechatpayPublicKey); |
||||||
|
if (!success) { |
||||||
|
throw new IllegalArgumentException( |
||||||
|
String.format("Validate response failed,the WechatPay signature is incorrect.%n" |
||||||
|
+ "Request-ID[%s]\tresponseHeader[%s]\tresponseBody[%.1024s]", |
||||||
|
headers.get("Request-ID"), headers, body)); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* 根据微信支付APIv3通知验签规则对通知签名进行验证,验证不通过时抛出异常 |
||||||
|
* |
||||||
|
* @param wechatpayPublicKeyId 微信支付公钥ID |
||||||
|
* @param wechatpayPublicKey 微信支付公钥对象 |
||||||
|
* @param headers 微信支付通知 Header 列表 |
||||||
|
* @param body 微信支付通知 Body |
||||||
|
*/ |
||||||
|
public static void validateNotification(String wechatpayPublicKeyId, |
||||||
|
PublicKey wechatpayPublicKey, Headers headers, |
||||||
|
String body) { |
||||||
|
String timestamp = headers.get("Wechatpay-Timestamp"); |
||||||
|
try { |
||||||
|
Instant responseTime = Instant.ofEpochSecond(Long.parseLong(timestamp)); |
||||||
|
// 拒绝过期请求
|
||||||
|
if (Duration.between(responseTime, Instant.now()).abs().toMinutes() >= 5) { |
||||||
|
throw new IllegalArgumentException( |
||||||
|
String.format("Validate notification failed, timestamp[%s] is expired", timestamp)); |
||||||
|
} |
||||||
|
} catch (DateTimeException | NumberFormatException e) { |
||||||
|
throw new IllegalArgumentException( |
||||||
|
String.format("Validate notification failed, timestamp[%s] is invalid", timestamp)); |
||||||
|
} |
||||||
|
String serialNumber = headers.get("Wechatpay-Serial"); |
||||||
|
if (!Objects.equals(serialNumber, wechatpayPublicKeyId)) { |
||||||
|
throw new IllegalArgumentException( |
||||||
|
String.format("Validate notification failed, Invalid Wechatpay-Serial, Local: %s, " + |
||||||
|
"Remote: %s", |
||||||
|
wechatpayPublicKeyId, |
||||||
|
serialNumber)); |
||||||
|
} |
||||||
|
|
||||||
|
String signature = headers.get("Wechatpay-Signature"); |
||||||
|
String message = String.format("%s\n%s\n%s\n", timestamp, headers.get("Wechatpay-Nonce"), |
||||||
|
body == null ? "" : body); |
||||||
|
|
||||||
|
boolean success = verify(message, signature, "SHA256withRSA", wechatpayPublicKey); |
||||||
|
if (!success) { |
||||||
|
throw new IllegalArgumentException( |
||||||
|
String.format("Validate notification failed, WechatPay signature is incorrect.\n" |
||||||
|
+ "responseHeader[%s]\tresponseBody[%.1024s]", |
||||||
|
headers, body)); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* 对微信支付通知进行签名验证、解析,同时将业务数据解密。验签名失败、解析失败、解密失败时抛出异常 |
||||||
|
* |
||||||
|
* @param apiv3Key 商户的 APIv3 Key |
||||||
|
* @param wechatpayPublicKeyId 微信支付公钥ID |
||||||
|
* @param wechatpayPublicKey 微信支付公钥对象 |
||||||
|
* @param headers 微信支付请求 Header 列表 |
||||||
|
* @param body 微信支付请求 Body |
||||||
|
* @return 解析后的通知内容,解密后的业务数据可以使用 Notification.getPlaintext() 访问 |
||||||
|
*/ |
||||||
|
public static Notification parseNotification(String apiv3Key, String wechatpayPublicKeyId, |
||||||
|
PublicKey wechatpayPublicKey, Headers headers, |
||||||
|
String body) { |
||||||
|
validateNotification(wechatpayPublicKeyId, wechatpayPublicKey, headers, body); |
||||||
|
Notification notification = gson.fromJson(body, Notification.class); |
||||||
|
notification.decrypt(apiv3Key); |
||||||
|
return notification; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* 微信支付API错误异常,发送HTTP请求成功,但返回状态码不是 2XX 时抛出本异常 |
||||||
|
*/ |
||||||
|
public static class ApiException extends RuntimeException { |
||||||
|
private static final long serialVersionUID = 2261086748874802175L; |
||||||
|
|
||||||
|
private final int statusCode; |
||||||
|
private final String body; |
||||||
|
private final Headers headers; |
||||||
|
private final String errorCode; |
||||||
|
private final String errorMessage; |
||||||
|
|
||||||
|
public ApiException(int statusCode, String body, Headers headers) { |
||||||
|
super(String.format("微信支付API访问失败,StatusCode: [%s], Body: [%s], Headers: [%s]", statusCode, |
||||||
|
body, headers)); |
||||||
|
this.statusCode = statusCode; |
||||||
|
this.body = body; |
||||||
|
this.headers = headers; |
||||||
|
|
||||||
|
if (body != null && !body.isEmpty()) { |
||||||
|
JsonElement code; |
||||||
|
JsonElement message; |
||||||
|
|
||||||
|
try { |
||||||
|
JsonObject jsonObject = gson.fromJson(body, JsonObject.class); |
||||||
|
code = jsonObject.get("code"); |
||||||
|
message = jsonObject.get("message"); |
||||||
|
} catch (JsonSyntaxException ignored) { |
||||||
|
code = null; |
||||||
|
message = null; |
||||||
|
} |
||||||
|
this.errorCode = code == null ? null : code.getAsString(); |
||||||
|
this.errorMessage = message == null ? null : message.getAsString(); |
||||||
|
} else { |
||||||
|
this.errorCode = null; |
||||||
|
this.errorMessage = null; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* 获取 HTTP 应答状态码 |
||||||
|
*/ |
||||||
|
public int getStatusCode() { |
||||||
|
return statusCode; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* 获取 HTTP 应答包体内容 |
||||||
|
*/ |
||||||
|
public String getBody() { |
||||||
|
return body; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* 获取 HTTP 应答 Header |
||||||
|
*/ |
||||||
|
public Headers getHeaders() { |
||||||
|
return headers; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* 获取 错误码 (错误应答中的 code 字段) |
||||||
|
*/ |
||||||
|
public String getErrorCode() { |
||||||
|
return errorCode; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* 获取 错误消息 (错误应答中的 message 字段) |
||||||
|
*/ |
||||||
|
public String getErrorMessage() { |
||||||
|
return errorMessage; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
public static class Notification { |
||||||
|
@SerializedName("id") |
||||||
|
private String id; |
||||||
|
@SerializedName("create_time") |
||||||
|
private String createTime; |
||||||
|
@SerializedName("event_type") |
||||||
|
private String eventType; |
||||||
|
@SerializedName("resource_type") |
||||||
|
private String resourceType; |
||||||
|
@SerializedName("summary") |
||||||
|
private String summary; |
||||||
|
@SerializedName("resource") |
||||||
|
private Resource resource; |
||||||
|
private String plaintext; |
||||||
|
|
||||||
|
public String getId() { |
||||||
|
return id; |
||||||
|
} |
||||||
|
|
||||||
|
public String getCreateTime() { |
||||||
|
return createTime; |
||||||
|
} |
||||||
|
|
||||||
|
public String getEventType() { |
||||||
|
return eventType; |
||||||
|
} |
||||||
|
|
||||||
|
public String getResourceType() { |
||||||
|
return resourceType; |
||||||
|
} |
||||||
|
|
||||||
|
public String getSummary() { |
||||||
|
return summary; |
||||||
|
} |
||||||
|
|
||||||
|
public Resource getResource() { |
||||||
|
return resource; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* 获取解密后的业务数据(JSON字符串,需要自行解析) |
||||||
|
*/ |
||||||
|
public String getPlaintext() { |
||||||
|
return plaintext; |
||||||
|
} |
||||||
|
|
||||||
|
private void validate() { |
||||||
|
if (resource == null) { |
||||||
|
throw new IllegalArgumentException("Missing required field `resource` in notification"); |
||||||
|
} |
||||||
|
resource.validate(); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* 使用 APIv3Key 对通知中的业务数据解密,解密结果可以通过 getPlainText 访问。 |
||||||
|
* 外部拿到的 Notification 一定是解密过的,因此本方法没有设置为 public |
||||||
|
* |
||||||
|
* @param apiv3Key 商户APIv3 Key |
||||||
|
*/ |
||||||
|
private void decrypt(String apiv3Key) { |
||||||
|
validate(); |
||||||
|
|
||||||
|
plaintext = aesAeadDecrypt( |
||||||
|
apiv3Key.getBytes(StandardCharsets.UTF_8), |
||||||
|
resource.associatedData.getBytes(StandardCharsets.UTF_8), |
||||||
|
resource.nonce.getBytes(StandardCharsets.UTF_8), |
||||||
|
Base64.getDecoder().decode(resource.ciphertext) |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
public static class Resource { |
||||||
|
@SerializedName("algorithm") |
||||||
|
private String algorithm; |
||||||
|
|
||||||
|
@SerializedName("ciphertext") |
||||||
|
private String ciphertext; |
||||||
|
|
||||||
|
@SerializedName("associated_data") |
||||||
|
private String associatedData; |
||||||
|
|
||||||
|
@SerializedName("nonce") |
||||||
|
private String nonce; |
||||||
|
|
||||||
|
@SerializedName("original_type") |
||||||
|
private String originalType; |
||||||
|
|
||||||
|
public String getAlgorithm() { |
||||||
|
return algorithm; |
||||||
|
} |
||||||
|
|
||||||
|
public String getCiphertext() { |
||||||
|
return ciphertext; |
||||||
|
} |
||||||
|
|
||||||
|
public String getAssociatedData() { |
||||||
|
return associatedData; |
||||||
|
} |
||||||
|
|
||||||
|
public String getNonce() { |
||||||
|
return nonce; |
||||||
|
} |
||||||
|
|
||||||
|
public String getOriginalType() { |
||||||
|
return originalType; |
||||||
|
} |
||||||
|
|
||||||
|
private void validate() { |
||||||
|
if (algorithm == null || algorithm.isEmpty()) { |
||||||
|
throw new IllegalArgumentException("Missing required field `algorithm` in Notification" + |
||||||
|
".Resource"); |
||||||
|
} |
||||||
|
if (!Objects.equals(algorithm, "AEAD_AES_256_GCM")) { |
||||||
|
throw new IllegalArgumentException(String.format("Unsupported `algorithm`[%s] in " + |
||||||
|
"Notification.Resource", algorithm)); |
||||||
|
} |
||||||
|
|
||||||
|
if (ciphertext == null || ciphertext.isEmpty()) { |
||||||
|
throw new IllegalArgumentException("Missing required field `ciphertext` in Notification" + |
||||||
|
".Resource"); |
||||||
|
} |
||||||
|
|
||||||
|
if (associatedData == null || associatedData.isEmpty()) { |
||||||
|
throw new IllegalArgumentException("Missing required field `associatedData` in " + |
||||||
|
"Notification.Resource"); |
||||||
|
} |
||||||
|
|
||||||
|
if (nonce == null || nonce.isEmpty()) { |
||||||
|
throw new IllegalArgumentException("Missing required field `nonce` in Notification" + |
||||||
|
".Resource"); |
||||||
|
} |
||||||
|
|
||||||
|
if (originalType == null || originalType.isEmpty()) { |
||||||
|
throw new IllegalArgumentException("Missing required field `originalType` in " + |
||||||
|
"Notification.Resource"); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* 根据文件名获取对应的Content-Type |
||||||
|
* |
||||||
|
* @param fileName 文件名 |
||||||
|
* @return Content-Type字符串 |
||||||
|
*/ |
||||||
|
public static String getContentTypeByFileName(String fileName) { |
||||||
|
if (fileName == null || fileName.isEmpty()) { |
||||||
|
return "application/octet-stream"; |
||||||
|
} |
||||||
|
|
||||||
|
// 获取文件扩展名
|
||||||
|
String extension = ""; |
||||||
|
int lastDotIndex = fileName.lastIndexOf('.'); |
||||||
|
if (lastDotIndex > 0 && lastDotIndex < fileName.length() - 1) { |
||||||
|
extension = fileName.substring(lastDotIndex + 1).toLowerCase(); |
||||||
|
} |
||||||
|
|
||||||
|
// 常见文件类型映射
|
||||||
|
Map<String, String> contentTypeMap = new HashMap<>(); |
||||||
|
// 图片类型
|
||||||
|
contentTypeMap.put("png", "image/png"); |
||||||
|
contentTypeMap.put("jpg", "image/jpeg"); |
||||||
|
contentTypeMap.put("jpeg", "image/jpeg"); |
||||||
|
contentTypeMap.put("gif", "image/gif"); |
||||||
|
contentTypeMap.put("bmp", "image/bmp"); |
||||||
|
contentTypeMap.put("webp", "image/webp"); |
||||||
|
contentTypeMap.put("svg", "image/svg+xml"); |
||||||
|
contentTypeMap.put("ico", "image/x-icon"); |
||||||
|
|
||||||
|
// 文档类型
|
||||||
|
contentTypeMap.put("pdf", "application/pdf"); |
||||||
|
contentTypeMap.put("doc", "application/msword"); |
||||||
|
contentTypeMap.put("docx", "application/vnd.openxmlformats-officedocument.wordprocessingml.document"); |
||||||
|
contentTypeMap.put("xls", "application/vnd.ms-excel"); |
||||||
|
contentTypeMap.put("xlsx", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"); |
||||||
|
contentTypeMap.put("ppt", "application/vnd.ms-powerpoint"); |
||||||
|
contentTypeMap.put("pptx", "application/vnd.openxmlformats-officedocument.presentationml.presentation"); |
||||||
|
|
||||||
|
// 文本类型
|
||||||
|
contentTypeMap.put("txt", "text/plain"); |
||||||
|
contentTypeMap.put("html", "text/html"); |
||||||
|
contentTypeMap.put("css", "text/css"); |
||||||
|
contentTypeMap.put("js", "application/javascript"); |
||||||
|
contentTypeMap.put("json", "application/json"); |
||||||
|
contentTypeMap.put("xml", "application/xml"); |
||||||
|
contentTypeMap.put("csv", "text/csv"); |
||||||
|
|
||||||
|
// 音视频类型
|
||||||
|
contentTypeMap.put("mp3", "audio/mpeg"); |
||||||
|
contentTypeMap.put("wav", "audio/wav"); |
||||||
|
contentTypeMap.put("mp4", "video/mp4"); |
||||||
|
contentTypeMap.put("avi", "video/x-msvideo"); |
||||||
|
contentTypeMap.put("mov", "video/quicktime"); |
||||||
|
|
||||||
|
// 压缩文件类型
|
||||||
|
contentTypeMap.put("zip", "application/zip"); |
||||||
|
contentTypeMap.put("rar", "application/x-rar-compressed"); |
||||||
|
contentTypeMap.put("7z", "application/x-7z-compressed"); |
||||||
|
|
||||||
|
return contentTypeMap.getOrDefault(extension, "application/octet-stream"); |
||||||
|
} |
||||||
|
} |
||||||
Loading…
Reference in new issue