|
|
@@ -0,0 +1,287 @@
|
|
|
+package com.malk.sdzl.service.Impl;
|
|
|
+
|
|
|
+import com.alibaba.fastjson.JSON;
|
|
|
+import com.fasterxml.jackson.databind.JsonNode;
|
|
|
+import com.fasterxml.jackson.databind.ObjectMapper;
|
|
|
+import com.malk.sdzl.entity.dto.EqbSignResultCallback;
|
|
|
+import com.malk.sdzl.entity.dto.EqbSignedFile;
|
|
|
+import com.malk.sdzl.entity.dto.YidaAttachment;
|
|
|
+import com.malk.sdzl.service.EqbService;
|
|
|
+import com.malk.server.aliwork.YDConf;
|
|
|
+import com.malk.server.aliwork.YDParam;
|
|
|
+import com.malk.service.aliwork.YDClient;
|
|
|
+import com.malk.service.aliwork.YDService;
|
|
|
+import com.malk.utils.UtilMap;
|
|
|
+import org.apache.commons.codec.DecoderException;
|
|
|
+import org.apache.commons.codec.binary.Hex;
|
|
|
+import org.slf4j.Logger;
|
|
|
+import org.slf4j.LoggerFactory;
|
|
|
+import org.springframework.beans.factory.annotation.Autowired;
|
|
|
+import org.springframework.beans.factory.annotation.Value;
|
|
|
+import org.springframework.http.HttpHeaders;
|
|
|
+import org.springframework.stereotype.Service;
|
|
|
+import org.springframework.util.MultiValueMap;
|
|
|
+import org.springframework.util.StringUtils;
|
|
|
+
|
|
|
+import javax.crypto.Mac;
|
|
|
+import javax.crypto.spec.SecretKeySpec;
|
|
|
+import java.io.IOException;
|
|
|
+import java.nio.charset.StandardCharsets;
|
|
|
+import java.security.GeneralSecurityException;
|
|
|
+import java.security.MessageDigest;
|
|
|
+import java.util.ArrayList;
|
|
|
+import java.util.LinkedHashMap;
|
|
|
+import java.util.List;
|
|
|
+import java.util.Locale;
|
|
|
+import java.util.Map;
|
|
|
+import java.util.TreeMap;
|
|
|
+
|
|
|
+@Service
|
|
|
+public class EqbCallbackService {
|
|
|
+ private static final Logger LOG = LoggerFactory.getLogger(EqbCallbackService.class);
|
|
|
+ private static final ObjectMapper MAPPER = new ObjectMapper();
|
|
|
+ private static final int SIGN_FLOW_LOCK_COUNT = 64;
|
|
|
+ private static final Object[] SIGN_FLOW_LOCKS = createSignFlowLocks();
|
|
|
+ private static final String SIGN_FLOW_TRACKING_FORM = "FORM-3C73F8BB960244DE841E2DFD6B0A2818V7H5";
|
|
|
+
|
|
|
+ // 未单独配置回调凭据时,沿用 eqb.appId 和 eqb.appKey。
|
|
|
+ @Value("${eqb.callback.appId:}")
|
|
|
+ private String callbackAppId;
|
|
|
+ @Value("${eqb.callback.appKey:}")
|
|
|
+ private String callbackAppKey;
|
|
|
+ @Value("${eqb.appId}")
|
|
|
+ private String defaultAppId;
|
|
|
+ @Value("${eqb.appKey}")
|
|
|
+ private String defaultAppKey;
|
|
|
+
|
|
|
+ public void receive(byte[] body, HttpHeaders headers, MultiValueMap<String, String> query) {
|
|
|
+ verifySignature(body, headers, query);
|
|
|
+ JsonNode payload;
|
|
|
+ try {
|
|
|
+ payload = MAPPER.reader().with(com.fasterxml.jackson.databind.DeserializationFeature.FAIL_ON_TRAILING_TOKENS)
|
|
|
+ .readTree(body);
|
|
|
+ } catch (IOException e) {
|
|
|
+ throw new IllegalArgumentException("Body must be a JSON object");
|
|
|
+ }
|
|
|
+ if (payload == null || !payload.isObject() || !payload.path("action").isTextual()
|
|
|
+ || !StringUtils.hasText(payload.path("action").asText())) {
|
|
|
+ throw new IllegalArgumentException("action is required");
|
|
|
+ }
|
|
|
+ if (!EqbSignResultCallback.ACTION.equals(payload.path("action").asText())) {
|
|
|
+ // 兼容平台新增事件:当前无需处理的事件直接确认接收成功。
|
|
|
+ LOG.debug("Ignored eSign callback action={}", payload.path("action").asText());
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ requireText(payload, "signFlowId");
|
|
|
+ requireText(payload, "signFlowTitle");
|
|
|
+ requireLong(payload, "timestamp");
|
|
|
+ requireLong(payload, "signFlowCreateTime");
|
|
|
+ requireLong(payload, "signFlowStartTime");
|
|
|
+ requireLong(payload, "signFlowFinishTime");
|
|
|
+ requireText(payload, "signFlowStatus");
|
|
|
+ String status = payload.path("signFlowStatus").textValue();
|
|
|
+ if (!EqbSignResultCallback.STATUS_COMPLETED.equals(status)
|
|
|
+ && !EqbSignResultCallback.STATUS_REVOKED.equals(status)
|
|
|
+ && !EqbSignResultCallback.STATUS_EXPIRED.equals(status)
|
|
|
+ && !EqbSignResultCallback.STATUS_REJECTED.equals(status)) {
|
|
|
+ throw new IllegalArgumentException("signFlowStatus must be 2, 3, 5 or 7");
|
|
|
+ }
|
|
|
+ // 描述字段允许为空字符串,流程是否成功只根据 signFlowStatus 判断。
|
|
|
+ if (!payload.path("statusDescription").isTextual()) {
|
|
|
+ throw new IllegalArgumentException("statusDescription must be a string");
|
|
|
+ }
|
|
|
+ EqbSignResultCallback resultCallback;
|
|
|
+ try {
|
|
|
+ resultCallback = MAPPER.treeToValue(payload, EqbSignResultCallback.class);
|
|
|
+ } catch (IOException e) {
|
|
|
+ throw new IllegalArgumentException("Invalid sign flow result fields");
|
|
|
+ }
|
|
|
+ // 业务处理异常继续向外抛出,由控制器返回失败状态,供平台重试。
|
|
|
+ handleSignFlowComplete(resultCallback);
|
|
|
+ }
|
|
|
+
|
|
|
+ private void verifySignature(byte[] body, HttpHeaders headers, MultiValueMap<String, String> query) {
|
|
|
+ String appId = StringUtils.hasText(callbackAppId) ? callbackAppId : defaultAppId;
|
|
|
+ String appKey = StringUtils.hasText(callbackAppKey) ? callbackAppKey : defaultAppKey;
|
|
|
+ if (StringUtils.hasText(callbackAppId) != StringUtils.hasText(callbackAppKey)) {
|
|
|
+ throw new IllegalStateException("Configure both eqb.callback.appId and eqb.callback.appKey");
|
|
|
+ }
|
|
|
+ String timestamp = headers.getFirst("X-Tsign-Open-TIMESTAMP");
|
|
|
+ String signature = headers.getFirst("X-Tsign-Open-SIGNATURE");
|
|
|
+ String algorithm = headers.getFirst("X-Tsign-Open-SIGNATURE-ALGORITHM");
|
|
|
+ if (!appId.equals(headers.getFirst("X-Tsign-Open-App-Id"))
|
|
|
+ || timestamp == null || !timestamp.matches("[0-9]{1,19}")
|
|
|
+ || signature == null || !signature.matches("[0-9a-fA-F]{64}")
|
|
|
+ || (algorithm != null && !"hmac-sha256".equalsIgnoreCase(algorithm))) {
|
|
|
+ throw new SecurityException("Invalid callback headers");
|
|
|
+ }
|
|
|
+ if (body == null || body.length == 0) {
|
|
|
+ throw new IllegalArgumentException("Body is required");
|
|
|
+ }
|
|
|
+ StringBuilder prefix = new StringBuilder(timestamp);
|
|
|
+ for (Map.Entry<String, List<String>> entry : new TreeMap<>(query).entrySet()) {
|
|
|
+ if (entry.getValue() == null || entry.getValue().size() != 1 || entry.getValue().get(0) == null) {
|
|
|
+ throw new IllegalArgumentException("Repeated query parameters are not supported");
|
|
|
+ }
|
|
|
+ prefix.append(entry.getValue().get(0));
|
|
|
+ }
|
|
|
+ try {
|
|
|
+ Mac mac = Mac.getInstance("HmacSHA256");
|
|
|
+ mac.init(new SecretKeySpec(appKey.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
|
|
|
+ mac.update(prefix.toString().getBytes(StandardCharsets.UTF_8));
|
|
|
+ if (!MessageDigest.isEqual(mac.doFinal(body), Hex.decodeHex(signature.toCharArray()))) {
|
|
|
+ throw new SecurityException("Signature mismatch");
|
|
|
+ }
|
|
|
+ } catch (GeneralSecurityException | DecoderException e) {
|
|
|
+ throw new IllegalStateException("Cannot verify callback signature", e);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ @Value("${aliwork.appType}")
|
|
|
+ private String appType;
|
|
|
+ @Value("${aliwork.systemToken}")
|
|
|
+ private String systemToken;
|
|
|
+ @Autowired
|
|
|
+ private YDService ydService;
|
|
|
+ @Autowired
|
|
|
+ private YDClient ydClient;
|
|
|
+ @Autowired
|
|
|
+ private EqbService eqbService;
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 签署成功后将 e签宝返回的文件地址封装为宜搭附件,并回写到业务表。
|
|
|
+ */
|
|
|
+ public void handleSignFlowComplete(EqbSignResultCallback callback) {
|
|
|
+ LOG.info("e签宝签署流程结束: signFlowId={}, signFlowStatus={}, completed={}, signFlowFinishTime={}, timestamp={}",
|
|
|
+ callback.getSignFlowId(), callback.getSignFlowStatus(), callback.isCompleted(),
|
|
|
+ callback.getSignFlowFinishTime(), callback.getTimestamp());
|
|
|
+ if (!callback.isCompleted()) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ synchronized (signFlowLock(callback.getSignFlowId())) {
|
|
|
+ List<Map> trackingRows = queryPendingTrackingRows(callback.getSignFlowId());
|
|
|
+ if (trackingRows == null || trackingRows.isEmpty()) {
|
|
|
+ LOG.info("e签宝签署流程已处理,忽略重复通知: signFlowId={}", callback.getSignFlowId());
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ List<EqbSignedFile> signedFiles = getSignedFileDownloadUrls(callback.getSignFlowId());
|
|
|
+ List<YidaAttachment> attachments = toYidaAttachments(signedFiles);
|
|
|
+
|
|
|
+ for (Map trackingRow : trackingRows) {
|
|
|
+ String businessInstanceId = UtilMap.getString(trackingRow, "textField_mte6b6fs");
|
|
|
+ if (!StringUtils.hasText(businessInstanceId)) {
|
|
|
+ throw new IllegalStateException("签署跟踪记录缺少业务表单实例ID");
|
|
|
+ }
|
|
|
+ Map<String, Object> businessUpdate = new LinkedHashMap<>();
|
|
|
+ businessUpdate.put("attachmentField_msu4s4kw", attachments);
|
|
|
+ businessUpdate.put("radioField_mu9983xg", "已完成");
|
|
|
+ businessUpdate.put("dateField_mse1k70h", callback.getSignFlowFinishTime());
|
|
|
+ updateYidaForm(businessInstanceId, businessUpdate);
|
|
|
+ }
|
|
|
+
|
|
|
+ // 业务表和附件都更新成功后再置为已处理;失败时保留“否”,让平台回调可以重试。
|
|
|
+ for (Map trackingRow : trackingRows) {
|
|
|
+ String trackingInstanceId = UtilMap.getString(trackingRow, "formInstanceId");
|
|
|
+ if (!StringUtils.hasText(trackingInstanceId)) {
|
|
|
+ throw new IllegalStateException("签署跟踪记录缺少formInstanceId");
|
|
|
+ }
|
|
|
+ Map<String, Object> trackingUpdate = new LinkedHashMap<>();
|
|
|
+ trackingUpdate.put("radioField_mte6b6fw", "是");
|
|
|
+ trackingUpdate.put("radioField_mu9983xg", "已完成");
|
|
|
+ updateYidaForm(trackingInstanceId, trackingUpdate);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ protected List<EqbSignedFile> getSignedFileDownloadUrls(String signFlowId) {
|
|
|
+ Map response = eqbService.getSignedFileDownloadUrls(signFlowId);
|
|
|
+ Object filesValue = response == null ? null : response.get("files");
|
|
|
+ List<EqbSignedFile> files = JSON.parseArray(JSON.toJSONString(filesValue), EqbSignedFile.class);
|
|
|
+ if (files == null || files.isEmpty()) {
|
|
|
+ throw new IllegalStateException("e签宝未返回已签署文件下载地址");
|
|
|
+ }
|
|
|
+ for (EqbSignedFile file : files) {
|
|
|
+ if (file == null || !StringUtils.hasText(file.getFileId())
|
|
|
+ || !StringUtils.hasText(file.getFileName())
|
|
|
+ || !StringUtils.hasText(file.getDownloadUrl())) {
|
|
|
+ throw new IllegalStateException("e签宝返回的已签署文件信息不完整");
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return files;
|
|
|
+ }
|
|
|
+
|
|
|
+ static List<YidaAttachment> toYidaAttachments(List<EqbSignedFile> signedFiles) {
|
|
|
+ if (signedFiles == null || signedFiles.isEmpty()) {
|
|
|
+ throw new IllegalArgumentException("已签署文件不能为空");
|
|
|
+ }
|
|
|
+ List<YidaAttachment> attachments = new ArrayList<>(signedFiles.size());
|
|
|
+ for (EqbSignedFile signedFile : signedFiles) {
|
|
|
+ if (signedFile == null || !StringUtils.hasText(signedFile.getFileName())
|
|
|
+ || !StringUtils.hasText(signedFile.getDownloadUrl())) {
|
|
|
+ throw new IllegalArgumentException("已签署文件信息不完整");
|
|
|
+ }
|
|
|
+ String fileName = signedFile.getFileName().trim();
|
|
|
+ int extensionSeparator = fileName.lastIndexOf('.');
|
|
|
+ if (extensionSeparator < 0 || extensionSeparator == fileName.length() - 1) {
|
|
|
+ throw new IllegalArgumentException("已签署文件缺少扩展名: " + fileName);
|
|
|
+ }
|
|
|
+ String downloadUrl = signedFile.getDownloadUrl().trim();
|
|
|
+ YidaAttachment attachment = new YidaAttachment();
|
|
|
+ attachment.setDownloadUrl(downloadUrl);
|
|
|
+ attachment.setPreviewUrl(downloadUrl);
|
|
|
+ attachment.setUrl(downloadUrl);
|
|
|
+ attachment.setName(fileName);
|
|
|
+ attachment.setExt(fileName.substring(extensionSeparator + 1).toLowerCase(Locale.ROOT));
|
|
|
+ attachments.add(attachment);
|
|
|
+ }
|
|
|
+ return attachments;
|
|
|
+ }
|
|
|
+
|
|
|
+ private List<Map> queryPendingTrackingRows(String signFlowId) {
|
|
|
+ Map<String, Object> searchFields = new LinkedHashMap<>();
|
|
|
+ searchFields.put("textField_mte3s2rf", signFlowId);
|
|
|
+ searchFields.put("radioField_mte6b6fw", "否");
|
|
|
+ return ydService.queryFormData_all(YDParam.builder()
|
|
|
+ .appType(appType)
|
|
|
+ .systemToken(systemToken)
|
|
|
+ .formUuid(SIGN_FLOW_TRACKING_FORM)
|
|
|
+ .searchFieldJson(JSON.toJSONString(searchFields))
|
|
|
+ .build());
|
|
|
+ }
|
|
|
+
|
|
|
+ private void updateYidaForm(String formInstanceId, Map<String, Object> updateFields) {
|
|
|
+ ydClient.operateData(YDParam.builder()
|
|
|
+ .appType(appType)
|
|
|
+ .systemToken(systemToken)
|
|
|
+ .formInstanceId(formInstanceId)
|
|
|
+ .useLatestVersion(true)
|
|
|
+ .updateFormDataJson(JSON.toJSONString(updateFields))
|
|
|
+ .build(), YDConf.FORM_OPERATION.update);
|
|
|
+ }
|
|
|
+
|
|
|
+ private static Object signFlowLock(String signFlowId) {
|
|
|
+ return SIGN_FLOW_LOCKS[Math.floorMod(signFlowId.hashCode(), SIGN_FLOW_LOCK_COUNT)];
|
|
|
+ }
|
|
|
+
|
|
|
+ private static Object[] createSignFlowLocks() {
|
|
|
+ Object[] locks = new Object[SIGN_FLOW_LOCK_COUNT];
|
|
|
+ for (int index = 0; index < locks.length; index++) {
|
|
|
+ locks[index] = new Object();
|
|
|
+ }
|
|
|
+ return locks;
|
|
|
+ }
|
|
|
+
|
|
|
+ private void requireText(JsonNode payload, String field) {
|
|
|
+ if (!payload.path(field).isTextual() || !StringUtils.hasText(payload.path(field).asText())) {
|
|
|
+ throw new IllegalArgumentException(field + " is required");
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private void requireLong(JsonNode payload, String field) {
|
|
|
+ if (!payload.path(field).isIntegralNumber() || !payload.path(field).canConvertToLong()) {
|
|
|
+ throw new IllegalArgumentException(field + " must be an int64");
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+}
|