diff --git a/doc/中铜技术文档/api 平台使用说明.pdf b/doc/中铜技术文档/api 平台使用说明.pdf new file mode 100644 index 0000000..92c98af Binary files /dev/null and b/doc/中铜技术文档/api 平台使用说明.pdf differ diff --git a/doc/外部对接/iwork/iwork电子用印后回调.md b/doc/外部对接/iwork/iwork电子用印后回调.md new file mode 100644 index 0000000..d8896e3 --- /dev/null +++ b/doc/外部对接/iwork/iwork电子用印后回调.md @@ -0,0 +1,290 @@ +# 检验检测模块 IWork电子用印后回调 + +**简介**:检测报告在用印环节会调用iwork接口发起用印流程。iwork在用印完毕后,调用接口返回结果。 + +**基础地址** + +测试系统地址:http://172.16.46.63:30081/admin-api/databus/api/portal + +生产系统地址:待定 + +**版本**:V1 + +## 1.总体说明 + +先调用【上传附件】接口,上传盖章后的文件。然后调用【用印回调】接口,返回用印结果。接口调用时需要进行签名和加密,详见【接口签名】章节。 + +## 2.上传附件接口 +**接口地址**:`/qms_uploadAtt/v1` + +**请求方式**:`POST` + +**请求数据类型**:`application/json` + +**请求参数**: + +| 参数名称 | 参数说明 | 数据类型 | 是否必须 | +|---------------|---------------|--------|------| +| base64Content | 文件内容 | string | 是 | +| fileName | 文件名称 | string | 是 | +| directory | 文件目录 | long | 否 | +| encrypt | 是否加密(默认false) | bool | 否 | + + +**响应参数**: + +| 参数名称 | 参数说明 | 类型 | +|----------------------|---------|----------------| +| code | 返回代码 | integer(int32) | +| msg | 返回处理消息 | string | +| data | 返回数据对象 | object | +| $\qquad$ id | id | string | +| $\qquad$ path | 文件存储路径 | string | +| $\qquad$ name | 文件名 | string | +| $\qquad$ url | 文件访问url | string | +| $\qquad$ previewUrl | 文件预览url | string | +| $\qquad$ isEncrypted | 是否加密 | bool | +| $\qquad$ type | 类型 | string | +| $\qquad$ size | 大小 | long | +| $\qquad$ createTime | 创建时间 | 时间戳 | + + + +## 3.用印回调接口 +**接口地址**:`/qms_sealReply/v1` + +**请求方式**:`POST` + +**请求数据类型**:`application/json` + +**请求参数**: + +| 参数名称 | 参数说明 | 请求类型 | 是否必须 | +|------------|----------------|--------|------| +| mainId | 报告id | string | 是 | +| fileIds | 附件id,多值用半角逗号分隔 | string | 是 | + +**响应参数**: + +| 参数名称 | 参数说明 | 类型 | +|----------------------|-----------------|----------------| +| code | 错误码:0-成功,其他值-失败 | integer(int32) | +| msg | 返回处理消息 | string | + + + +## 4.接口签名 +接口签名和加密参考以下代码: + +APP_ID和APP_SECRET请联系相关人员获取 + + +```java +public class ApiForIworkExample { + public static final String TIMESTAMP = Long.toString(System.currentTimeMillis()); + private static final String APP_ID = ""; + private static final String APP_SECRET = ""; + private static final String ENCRYPTION_TYPE = CryptoSignatureUtils.ENCRYPT_TYPE_AES; + private static final String TARGET_API = "http://172.16.46.63:30081/admin-api/databus/api/portal/"; + private static final HttpClient HTTP_CLIENT = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(5)).build(); + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + private static final PrintStream OUT = buildConsolePrintStream(); + public static final String ZT_APP_ID = "ZT-App-Id"; + public static final String ZT_TIMESTAMP = "ZT-Timestamp"; + public static final String ZT_NONCE = "ZT-Nonce"; + public static final String ZT_SIGNATURE = "ZT-Signature"; + public static final String ZT_AUTH_TOKEN = "ZT-Auth-Token"; + public static final String CONTENT_TYPE = "Content-Type"; + + private ApiForIworkExample() {} + + public static void main(String[] args) throws Exception { + executePostExample(); + } + + private static void executePostExample() throws Exception { + Map queryParams = new LinkedHashMap<>(); + JSONObject jsonObject = new JSONObject(); + jsonObject.put("mainId", "1983446576685900000"); + jsonObject.put("fileIds", "1983446576685900001,1983446576685900002"); + String bodyJson = jsonObject.toJSONString(); + Map bodyParams = parseBodyJson(bodyJson); + String signature = generateSignature(queryParams, bodyParams); + String url = TARGET_API + "qms_sealReply/v1"; + URI requestUri = buildUri(url, queryParams); + String nonce = randomNonce(); + String cipherBody = encryptPayload(bodyJson); + OUT.println("原始 Request Body: " + bodyJson); + OUT.println("加密 Request Body: " + cipherBody); + HttpRequest request = HttpRequest.newBuilder(requestUri) + .timeout(Duration.ofSeconds(10)) + .header(ZT_APP_ID, APP_ID) + .header(ZT_TIMESTAMP, TIMESTAMP) + .header(ZT_NONCE, nonce) + .header(ZT_SIGNATURE, signature) +// .header(ZT_AUTH_TOKEN, "82e5c281ddfa4386988fa4074e8794d7") + .header(CONTENT_TYPE, "application/json") + .POST(HttpRequest.BodyPublishers.ofString(cipherBody, StandardCharsets.UTF_8)) + .build(); + HttpResponse response = HTTP_CLIENT.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); + printResponse(response); + } + + private static String encryptPayload(String plaintext) { + try { + return CryptoSignatureUtils.encrypt(plaintext, APP_SECRET, ENCRYPTION_TYPE); + } catch (Exception ex) { + throw new IllegalStateException("Failed to encrypt request body", ex); + } + } + + private static void printResponse(HttpResponse response) { + OUT.println("HTTP Status: " + response.statusCode()); + String cipherText = response.body(); + OUT.println("加密 Response: " + cipherText); + String plain = tryDecrypt(cipherText); + OUT.println("原始 Response: " + normalizePotentialMojibake(plain)); + } + + private static String randomNonce() { + return UUID.randomUUID().toString().replace("-", ""); + } + + private static URI buildUri(String baseUrl, Map queryParams) { + if (queryParams == null || queryParams.isEmpty()) { + return URI.create(baseUrl); + } + StringBuilder builder = new StringBuilder(baseUrl); + builder.append(baseUrl.contains("?") ? '&' : '?'); + boolean first = true; + for (Map.Entry entry : queryParams.entrySet()) { + if (!first) { + builder.append('&'); + } + first = false; + builder.append(URLEncoder.encode(entry.getKey(), StandardCharsets.UTF_8)); + builder.append('='); + builder.append(URLEncoder.encode(String.valueOf(entry.getValue()), StandardCharsets.UTF_8)); + } + return URI.create(builder.toString()); + } + + private static String generateSignature(Map queryParams, Map bodyParams) { + TreeMap sorted = new TreeMap<>(); + if (queryParams != null) { + queryParams.forEach((key, value) -> sorted.put(key, normalizeValue(value))); + } + if (bodyParams != null) { + bodyParams.forEach((key, value) -> sorted.put(key, normalizeValue(value))); + } + sorted.put(ZT_APP_ID, APP_ID); + sorted.put(ZT_TIMESTAMP, TIMESTAMP); + StringBuilder canonical = new StringBuilder(); + sorted.forEach((key, value) -> { + if (value == null) { + return; + } + if (canonical.length() > 0) { + canonical.append('&'); + } + canonical.append(key).append('=').append(value); + }); + OUT.println("原始 签名串: " + canonical); + String md5Hex = md5Hex(canonical.toString()); + OUT.println("原始签名: " + md5Hex); + return md5Hex; + } + + private static Object normalizeValue(Object value) { + if (value == null) { + return null; + } + if (value instanceof Map || value instanceof Iterable) { + try { + return OBJECT_MAPPER.writeValueAsString(value); + } catch (JsonProcessingException ignored) { + return value.toString(); + } + } + return value; + } + + private static Map parseBodyJson(String bodyJson) { + if (bodyJson == null || bodyJson.isBlank()) { + return Map.of(); + } + try { + return OBJECT_MAPPER.readValue(bodyJson, new TypeReference>() { }); + } catch (IOException ex) { + throw new IllegalArgumentException("Failed to parse request body JSON", ex); + } + } + + private static String md5Hex(String input) { + try { + MessageDigest digest = MessageDigest.getInstance("MD5"); + byte[] bytes = digest.digest(input.getBytes(StandardCharsets.UTF_8)); + StringBuilder hex = new StringBuilder(bytes.length * 2); + for (byte b : bytes) { + String segment = Integer.toHexString(b & 0xFF); + if (segment.length() == 1) { + hex.append('0'); + } + hex.append(segment); + } + return hex.toString(); + } catch (NoSuchAlgorithmException ex) { + throw new IllegalStateException("MD5 algorithm not available", ex); + } + } + + private static String tryDecrypt(String cipherText) { + if (cipherText == null || cipherText.isBlank()) { + return cipherText; + } + try { + // Databus 会在凭证开启加密时返回密文,这里做一次解密展示真实响应。 + return CryptoSignatureUtils.decrypt(cipherText, APP_SECRET, ENCRYPTION_TYPE); + } catch (Exception ex) { + return " " + ex.getMessage(); + } + } + + // 解决控制台打印 乱码问题 + private static String normalizePotentialMojibake(String value) { + if (value == null || value.isEmpty()) { + return value; + } + long suspectCount = value.chars().filter(ch -> ch >= 0x80 && ch <= 0xFF).count(); + long highCount = value.chars().filter(ch -> ch > 0xFF).count(); + if (suspectCount > 0 && highCount == 0) { + try { + byte[] decoded = value.getBytes(StandardCharsets.ISO_8859_1); + String converted = new String(decoded, StandardCharsets.UTF_8); + if (converted.chars().anyMatch(ch -> ch > 0xFF)) { + return converted; + } + } catch (Exception ignored) { + return value; + } + } + return value; + } + + /** + * 输出流编码与当前控制台保持一致,避免中文字符再次出现编码差异。 + */ + private static PrintStream buildConsolePrintStream() { + try { + String consoleEncoding = System.getProperty("sun.stdout.encoding"); + if (consoleEncoding != null && !consoleEncoding.isBlank()) { + return new PrintStream(System.out, true, Charset.forName(consoleEncoding)); + } + return new PrintStream(System.out, true, Charset.defaultCharset()); + } catch (Exception ignored) { + return System.out; + } + } +} + +``` diff --git a/doc/外部对接/iwork/iwork电子用印后回调.pdf b/doc/外部对接/iwork/iwork电子用印后回调.pdf new file mode 100644 index 0000000..72c06bd Binary files /dev/null and b/doc/外部对接/iwork/iwork电子用印后回调.pdf differ diff --git a/zt-module-qms/zt-module-qms-server/src/test/java/com/zt/plat/module/qms/ApiForIworkExample.java b/zt-module-qms/zt-module-qms-server/src/test/java/com/zt/plat/module/qms/ApiForIworkExample.java new file mode 100644 index 0000000..0e27246 --- /dev/null +++ b/zt-module-qms/zt-module-qms-server/src/test/java/com/zt/plat/module/qms/ApiForIworkExample.java @@ -0,0 +1,232 @@ +package com.zt.plat.module.qms; + +import com.alibaba.fastjson.JSONObject; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.zt.plat.framework.common.util.security.CryptoSignatureUtils; + +import java.io.IOException; +import java.io.PrintStream; +import java.net.URI; +import java.net.URLEncoder; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.Duration; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.TreeMap; +import java.util.UUID; + +public class ApiForIworkExample { + public static final String TIMESTAMP = Long.toString(System.currentTimeMillis()); + private static final String APP_ID = "iwork"; + private static final String APP_SECRET = "lpGXiNe/GMLk0vsbYGLa8eYxXq8tGhTbuu3/D4MJzIk="; + private static final String ENCRYPTION_TYPE = CryptoSignatureUtils.ENCRYPT_TYPE_AES; + private static final String TARGET_API = "http://172.16.46.63:30081/admin-api/databus/api/portal/"; + private static final HttpClient HTTP_CLIENT = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(5)).build(); + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + private static final PrintStream OUT = buildConsolePrintStream(); + public static final String ZT_APP_ID = "ZT-App-Id"; + public static final String ZT_TIMESTAMP = "ZT-Timestamp"; + public static final String ZT_NONCE = "ZT-Nonce"; + public static final String ZT_SIGNATURE = "ZT-Signature"; + public static final String ZT_AUTH_TOKEN = "ZT-Auth-Token"; + public static final String CONTENT_TYPE = "Content-Type"; + + private ApiForIworkExample() { + } + + public static void main(String[] args) throws Exception { + executePostExample(); + } + + private static void executePostExample() throws Exception { + Map queryParams = new LinkedHashMap<>(); + JSONObject jsonObject = new JSONObject(); + jsonObject.put("id", "1983446576685985794"); + jsonObject.put("remark", ""); + String bodyJson = jsonObject.toJSONString(); + Map bodyParams = parseBodyJson(bodyJson); + String signature = generateSignature(queryParams, bodyParams); + String url = TARGET_API + "qms_sealReply/v1"; + URI requestUri = buildUri(url, queryParams); + String nonce = randomNonce(); + String cipherBody = encryptPayload(bodyJson); + OUT.println("原始 Request Body: " + bodyJson); + OUT.println("加密 Request Body: " + cipherBody); + HttpRequest request = HttpRequest.newBuilder(requestUri) + .timeout(Duration.ofSeconds(10)) + .header(ZT_APP_ID, APP_ID) + .header(ZT_TIMESTAMP, TIMESTAMP) + .header(ZT_NONCE, nonce) + .header(ZT_SIGNATURE, signature) + .header(ZT_AUTH_TOKEN, "82e5c281ddfa4386988fa4074e8794d7") + .header(CONTENT_TYPE, "application/json") + .POST(HttpRequest.BodyPublishers.ofString(cipherBody, StandardCharsets.UTF_8)) + .build(); + HttpResponse response = HTTP_CLIENT.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); + printResponse(response); + } + + private static String encryptPayload(String plaintext) { + try { + return CryptoSignatureUtils.encrypt(plaintext, APP_SECRET, ENCRYPTION_TYPE); + } catch (Exception ex) { + throw new IllegalStateException("Failed to encrypt request body", ex); + } + } + + private static void printResponse(HttpResponse response) { + OUT.println("HTTP Status: " + response.statusCode()); + String cipherText = response.body(); + OUT.println("加密 Response: " + cipherText); + String plain = tryDecrypt(cipherText); + OUT.println("原始 Response: " + normalizePotentialMojibake(plain)); + } + + private static String randomNonce() { + return UUID.randomUUID().toString().replace("-", ""); + } + + private static URI buildUri(String baseUrl, Map queryParams) { + if (queryParams == null || queryParams.isEmpty()) { + return URI.create(baseUrl); + } + StringBuilder builder = new StringBuilder(baseUrl); + builder.append(baseUrl.contains("?") ? '&' : '?'); + boolean first = true; + for (Map.Entry entry : queryParams.entrySet()) { + if (!first) { + builder.append('&'); + } + first = false; + builder.append(URLEncoder.encode(entry.getKey(), StandardCharsets.UTF_8)); + builder.append('='); + builder.append(URLEncoder.encode(String.valueOf(entry.getValue()), StandardCharsets.UTF_8)); + } + return URI.create(builder.toString()); + } + + private static String generateSignature(Map queryParams, Map bodyParams) { + TreeMap sorted = new TreeMap<>(); + if (queryParams != null) { + queryParams.forEach((key, value) -> sorted.put(key, normalizeValue(value))); + } + if (bodyParams != null) { + bodyParams.forEach((key, value) -> sorted.put(key, normalizeValue(value))); + } + sorted.put(ZT_APP_ID, APP_ID); + sorted.put(ZT_TIMESTAMP, TIMESTAMP); + StringBuilder canonical = new StringBuilder(); + sorted.forEach((key, value) -> { + if (value == null) { + return; + } + if (canonical.length() > 0) { + canonical.append('&'); + } + canonical.append(key).append('=').append(value); + }); + OUT.println("原始 签名串: " + canonical); + String md5Hex = md5Hex(canonical.toString()); + OUT.println("原始签名: " + md5Hex); + return md5Hex; + } + + private static Object normalizeValue(Object value) { + if (value == null) { + return null; + } + if (value instanceof Map || value instanceof Iterable) { + try { + return OBJECT_MAPPER.writeValueAsString(value); + } catch (JsonProcessingException ignored) { + return value.toString(); + } + } + return value; + } + + private static Map parseBodyJson(String bodyJson) { + if (bodyJson == null || bodyJson.isBlank()) { + return Map.of(); + } + try { + return OBJECT_MAPPER.readValue(bodyJson, new TypeReference>() { }); + } catch (IOException ex) { + throw new IllegalArgumentException("Failed to parse request body JSON", ex); + } + } + + private static String md5Hex(String input) { + try { + MessageDigest digest = MessageDigest.getInstance("MD5"); + byte[] bytes = digest.digest(input.getBytes(StandardCharsets.UTF_8)); + StringBuilder hex = new StringBuilder(bytes.length * 2); + for (byte b : bytes) { + String segment = Integer.toHexString(b & 0xFF); + if (segment.length() == 1) { + hex.append('0'); + } + hex.append(segment); + } + return hex.toString(); + } catch (NoSuchAlgorithmException ex) { + throw new IllegalStateException("MD5 algorithm not available", ex); + } + } + + private static String tryDecrypt(String cipherText) { + if (cipherText == null || cipherText.isBlank()) { + return cipherText; + } + try { + // Databus 会在凭证开启加密时返回密文,这里做一次解密展示真实响应。 + return CryptoSignatureUtils.decrypt(cipherText, APP_SECRET, ENCRYPTION_TYPE); + } catch (Exception ex) { + return " " + ex.getMessage(); + } + } + + // 解决控制台打印 乱码问题 + private static String normalizePotentialMojibake(String value) { + if (value == null || value.isEmpty()) { + return value; + } + long suspectCount = value.chars().filter(ch -> ch >= 0x80 && ch <= 0xFF).count(); + long highCount = value.chars().filter(ch -> ch > 0xFF).count(); + if (suspectCount > 0 && highCount == 0) { + try { + byte[] decoded = value.getBytes(StandardCharsets.ISO_8859_1); + String converted = new String(decoded, StandardCharsets.UTF_8); + if (converted.chars().anyMatch(ch -> ch > 0xFF)) { + return converted; + } + } catch (Exception ignored) { + return value; + } + } + return value; + } + + /** + * 输出流编码与当前控制台保持一致,避免中文字符再次出现编码差异。 + */ + private static PrintStream buildConsolePrintStream() { + try { + String consoleEncoding = System.getProperty("sun.stdout.encoding"); + if (consoleEncoding != null && !consoleEncoding.isBlank()) { + return new PrintStream(System.out, true, Charset.forName(consoleEncoding)); + } + return new PrintStream(System.out, true, Charset.defaultCharset()); + } catch (Exception ignored) { + return System.out; + } + } +} diff --git a/zt-module-qms/zt-module-qms-server/src/test/java/com/zt/plat/module/qms/DatabusApiInvocationExample.java b/zt-module-qms/zt-module-qms-server/src/test/java/com/zt/plat/module/qms/DatabusApiInvocationExample.java index c062459..703ff6e 100644 --- a/zt-module-qms/zt-module-qms-server/src/test/java/com/zt/plat/module/qms/DatabusApiInvocationExample.java +++ b/zt-module-qms/zt-module-qms-server/src/test/java/com/zt/plat/module/qms/DatabusApiInvocationExample.java @@ -5,7 +5,9 @@ import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; import com.zt.plat.framework.common.util.security.CryptoSignatureUtils; +import org.springframework.util.StringUtils; +import java.io.ByteArrayOutputStream; import java.io.IOException; import java.io.PrintStream; import java.net.URI; @@ -15,13 +17,13 @@ import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.nio.charset.Charset; import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; import java.time.Duration; -import java.util.LinkedHashMap; -import java.util.Map; -import java.util.TreeMap; -import java.util.UUID; +import java.util.*; public class DatabusApiInvocationExample { public static final String TIMESTAMP = Long.toString(System.currentTimeMillis()); @@ -85,14 +87,12 @@ public class DatabusApiInvocationExample { JSONObject jsonObject = new JSONObject(); - jsonObject.put("id", "1968843568287817730"); - jsonObject.put("sampleName", "545(委检样)"); - jsonObject.put("materialName", "ff"); - jsonObject.put("materialId", "123"); - jsonObject.put("dictionaryBusinessId", "1965289399255388162"); + jsonObject.put("id", "1983446576685985794"); + jsonObject.put("remark", ""); - long extraTimestamp = 1761556157185L; - String bodyJson = String.format(jsonObject.toJSONString(), extraTimestamp); +// long extraTimestamp = 1761556157185L; +// String bodyJson = String.format(jsonObject.toJSONString(), extraTimestamp); + String bodyJson = jsonObject.toJSONString(); Map bodyParams = parseBodyJson(bodyJson); String signature = generateSignature(queryParams, bodyParams); @@ -109,10 +109,8 @@ public class DatabusApiInvocationExample { .header(ZT_TIMESTAMP, TIMESTAMP) .header(ZT_NONCE, nonce) .header(ZT_SIGNATURE, signature) - .header(ZT_AUTH_TOKEN, "843304444f65423f901a3efe2f252a75") + .header(ZT_AUTH_TOKEN, "82e5c281ddfa4386988fa4074e8794d7") .header(CONTENT_TYPE, "application/json") - .header("visit-company-id", "101") - .header("visit-dept-id", "103") .POST(HttpRequest.BodyPublishers.ofString(cipherBody, StandardCharsets.UTF_8)) .build();