()
+ .in(PartCodeRelation::getPartId, partIds)
+ .eq(modelId != null, PartCodeRelation::getModelId, modelId));
}
private String safeString(Object value) {
diff --git a/src/main/java/com/bim/api/util/SignUtil.java b/src/main/java/com/bim/api/util/SignUtil.java
new file mode 100644
index 0000000..2cf5764
--- /dev/null
+++ b/src/main/java/com/bim/api/util/SignUtil.java
@@ -0,0 +1,327 @@
+package com.bim.api.util;
+
+import javax.crypto.Mac;
+import javax.crypto.spec.SecretKeySpec;
+import java.lang.reflect.Array;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.TreeMap;
+
+/**
+ * 转换服务签名工具
+ *
+ * 按转换服务接口规范生成 HMAC-SHA256 签名:
+ * 1. 排除 sign 字段
+ * 2. 字段名按 ASCII 升序排序
+ * 3. 按 key=value 格式拼接,& 连接
+ * 4. 对象/数组使用稳定 JSON(无多余空格,字段 ASCII 升序排序)
+ * 5. 签名算法:Hex(HMAC-SHA256(signString, secretKey))
+ *
+ * @author bim-engine
+ */
+public class SignUtil {
+
+ private SignUtil() {
+ }
+
+ /**
+ * 生成签名
+ *
+ * @param params 请求参数(必须包含 sign 占位字段,值为空字符串或 null)
+ * @param secretKey 共享密钥
+ * @return 十六进制小写签名
+ */
+ public static String generateSign(Map params, String secretKey) {
+ try {
+ String signString = buildSignString(params);
+ return hmacSha256Hex(signString, secretKey);
+ } catch (Exception e) {
+ throw new RuntimeException("生成签名失败: " + e.getMessage(), e);
+ }
+ }
+
+ /**
+ * 验证签名
+ *
+ * @param params 请求参数(包含 sign 字段)
+ * @param secretKey 共享密钥
+ * @param sign 待验证的签名
+ * @return true 表示验证通过
+ */
+ public static boolean verifySign(Map params, String secretKey, String sign) {
+ if (sign == null || sign.isEmpty()) {
+ return false;
+ }
+ try {
+ String expectedSign = generateSign(params, secretKey);
+ return expectedSign.equals(sign);
+ } catch (Exception e) {
+ return false;
+ }
+ }
+
+ /**
+ * 验证签名,并允许指定部分字符串字段按原始 JSON 转义值参与签名。
+ *
+ * 适用于回调消息中存在 JSON 字符串字段的场景,例如 extraData 在原始消息中为
+ * {\"id\":1},解析后会变成 {"id":1},直接用解析值会改变签名串。
+ *
+ * @param params 请求参数(包含 sign 字段)
+ * @param rawJson 原始 JSON 消息
+ * @param rawStringFields 需要保留原始转义内容的顶层字符串字段名
+ * @param secretKey 共享密钥
+ * @param sign 待验证的签名
+ * @return true 表示验证通过
+ */
+ public static boolean verifySignWithRawStringFields(Map params, String rawJson,
+ Set rawStringFields, String secretKey, String sign) {
+ if (sign == null || sign.isEmpty()) {
+ return false;
+ }
+ try {
+ String signString = buildSignString(params, rawJson, rawStringFields, false);
+ String expectedSign = hmacSha256Hex(signString, secretKey);
+ return expectedSign.equals(sign);
+ } catch (Exception e) {
+ return false;
+ }
+ }
+
+ /**
+ * 构建待签名字符串
+ */
+ public static String buildSignString(Map params) {
+ TreeMap sortedParams = new TreeMap<>(params);
+ sortedParams.remove("sign");
+
+ StringBuilder builder = new StringBuilder();
+ for (Map.Entry entry : sortedParams.entrySet()) {
+ if (builder.length() > 0) {
+ builder.append("&");
+ }
+ builder.append(entry.getKey())
+ .append("=")
+ .append(toSignValue(entry.getValue()));
+ }
+ return builder.toString();
+ }
+
+ /**
+ * 构建待签名字符串,并对指定顶层字符串字段使用原始 JSON 中的转义内容。
+ */
+ public static String buildSignString(Map params, String rawJson, Set rawStringFields) {
+ return buildSignString(params, rawJson, rawStringFields, false);
+ }
+
+ /**
+ * 构建待签名字符串
+ */
+ public static String buildSignString(Map params, String rawJson, Set rawStringFields, boolean nullAsEmpty) {
+ TreeMap sortedParams = new TreeMap<>(params);
+ sortedParams.remove("sign");
+
+ StringBuilder builder = new StringBuilder();
+ for (Map.Entry entry : sortedParams.entrySet()) {
+ if (builder.length() > 0) {
+ builder.append("&");
+ }
+ builder.append(entry.getKey()).append("=");
+
+ String rawStringValue = null;
+ if (rawStringFields != null && rawStringFields.contains(entry.getKey()) && entry.getValue() instanceof String) {
+ rawStringValue = extractRawJsonStringValue(rawJson, entry.getKey());
+ }
+ builder.append(rawStringValue == null ? toSignValue(entry.getValue(), nullAsEmpty) : rawStringValue);
+ }
+ return builder.toString();
+ }
+
+ /**
+ * 提取顶层 JSON 字符串字段的原始内容,不反转义,保留 \"、\\ 等字符。
+ */
+ private static String extractRawJsonStringValue(String json, String fieldName) {
+ if (json == null || fieldName == null) {
+ return null;
+ }
+ String fieldToken = "\"" + fieldName + "\"";
+ int fieldIndex = json.indexOf(fieldToken);
+ if (fieldIndex < 0) {
+ return null;
+ }
+
+ int colonIndex = json.indexOf(':', fieldIndex + fieldToken.length());
+ if (colonIndex < 0) {
+ return null;
+ }
+
+ int valueStart = colonIndex + 1;
+ while (valueStart < json.length() && Character.isWhitespace(json.charAt(valueStart))) {
+ valueStart++;
+ }
+ if (valueStart >= json.length() || json.charAt(valueStart) != '"') {
+ return null;
+ }
+
+ StringBuilder rawValue = new StringBuilder();
+ for (int i = valueStart + 1; i < json.length(); i++) {
+ char current = json.charAt(i);
+ if (current == '\\') {
+ if (i + 1 < json.length()) {
+ rawValue.append(current).append(json.charAt(i + 1));
+ i++;
+ } else {
+ rawValue.append(current);
+ }
+ continue;
+ }
+ if (current == '"') {
+ return rawValue.toString();
+ }
+ rawValue.append(current);
+ }
+ return null;
+ }
+
+ /**
+ * 将值转换为签名字符串中的表示形式
+ */
+ private static String toSignValue(Object value) {
+ return toSignValue(value, false);
+ }
+
+ /**
+ * 将值转换为签名字符串中的表示形式。
+ */
+ private static String toSignValue(Object value, boolean nullAsEmpty) {
+ if (value == null) {
+ return nullAsEmpty ? "" : "null";
+ }
+ if (value instanceof Map || value instanceof Iterable || value.getClass().isArray()) {
+ return toStableJson(value);
+ }
+ if (value instanceof Boolean) {
+ return Boolean.TRUE.equals(value) ? "true" : "false";
+ }
+ return String.valueOf(value);
+ }
+
+ /**
+ * 生成稳定 JSON 字符串
+ *
+ * 规则:
+ * - 无多余空格
+ * - 对象字段按 ASCII 升序排序
+ * - 嵌套对象也按 ASCII 升序排序
+ * - 数组顺序保持不变
+ */
+ private static String toStableJson(Object value) {
+ if (value == null) {
+ return "null";
+ }
+ if (value instanceof String || value instanceof Character) {
+ return "\"" + escapeJson(String.valueOf(value)) + "\"";
+ }
+ if (value instanceof Number || value instanceof Boolean) {
+ return String.valueOf(value);
+ }
+ if (value instanceof Map) {
+ TreeMap sortedMap = new TreeMap<>();
+ for (Map.Entry, ?> entry : ((Map, ?>) value).entrySet()) {
+ sortedMap.put(String.valueOf(entry.getKey()), entry.getValue());
+ }
+
+ StringBuilder builder = new StringBuilder("{");
+ Iterator> iterator = sortedMap.entrySet().iterator();
+ while (iterator.hasNext()) {
+ Map.Entry entry = iterator.next();
+ builder.append("\"")
+ .append(escapeJson(entry.getKey()))
+ .append("\":")
+ .append(toStableJson(entry.getValue()));
+ if (iterator.hasNext()) {
+ builder.append(",");
+ }
+ }
+ return builder.append("}").toString();
+ }
+ if (value instanceof Iterable) {
+ StringBuilder builder = new StringBuilder("[");
+ Iterator> iterator = ((Iterable>) value).iterator();
+ while (iterator.hasNext()) {
+ builder.append(toStableJson(iterator.next()));
+ if (iterator.hasNext()) {
+ builder.append(",");
+ }
+ }
+ return builder.append("]").toString();
+ }
+ if (value.getClass().isArray()) {
+ List