fyz 1 тиждень тому
батько
коміт
3380e9d615

+ 189 - 0
mjava-sdzl/src/main/java/com/malk/sdzl/common/EqbYidaFormConfig.java

@@ -0,0 +1,189 @@
+package com.malk.sdzl.common;
+
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+/**
+ * 宜搭自动落印表单字段配置。
+ *
+ * <p>配置来源为《深度智联自动落印数据来源.xlsx》。新增表单时,只需在静态配置块中
+ * 增加一条 formUuid 与字段 ID 的对应关系。</p>
+ */
+public final class EqbYidaFormConfig {
+
+    /** e签宝签署流程与宜搭业务表单的跟踪表。 */
+    public static final String SIGN_FLOW_TRACKING_FORM_UUID =
+            "FORM-32DF48EE189F433AB1E3E963F2D473FE23JP";
+    /** e签宝签署流程ID。 */
+    public static final String TRACKING_SIGN_FLOW_ID_FIELD = "textField_mte3s2rf";
+    /** 发起签署的业务表单formUuid。 */
+    public static final String TRACKING_FORM_UUID_FIELD = "textField_mte3s2rh";
+    /** 发起签署的业务流程实例ID。 */
+    public static final String TRACKING_FORM_INSTANCE_ID_FIELD = "textField_mte6b6fs";
+    public static final String TRACKING_HANDLED_FIELD = "radioField_mte6b6fw";
+
+    private static final String BUSINESS_REFERENCE_SEPARATOR = "|";
+    private static final Map<String, EqbYidaFormConfig> CONFIGS;
+
+    static {
+        Map<String, EqbYidaFormConfig> configs = new LinkedHashMap<>();
+        add(configs, new EqbYidaFormConfig(
+                "FORM-DB6F25B40F874A77A87BEA12019253B887GX",
+                "textField_mq625xjb",
+                "textField_mpfb5ond",
+                "textField_mq625xkd",
+                "radioField_mq625xj9",
+                "attachmentField_mq625xl3",
+                "attachmentField_mq625xl5",
+                "textField_mq625xl4"));
+        add(configs, new EqbYidaFormConfig(
+                "FORM-76FFD768A0FC47128ED4706DB7E9A638JZGF",
+                "textField_2uyzeim",
+                "textField_r1um3e1",
+                "textField_klsewbm",
+                "radioField_ms2ouakx",
+                "attachmentField_mq68f9vv",
+                "attachmentField_msh62jim",
+                "textField_mq625xl4"));
+        CONFIGS = Collections.unmodifiableMap(configs);
+    }
+
+    private final String formUuid;
+    private final String counterpartyRecipientFieldId;
+    private final String counterpartyPhoneFieldId;
+    private final String ourCompanyFieldId;
+    private final String electronicSignFieldId;
+    private final String contractAttachmentFieldId;
+    private final String writeBackAttachmentFieldId;
+    private final String sealTypeFieldId;
+
+    private EqbYidaFormConfig(String formUuid,
+                              String counterpartyRecipientFieldId,
+                              String counterpartyPhoneFieldId,
+                              String ourCompanyFieldId,
+                              String electronicSignFieldId,
+                              String contractAttachmentFieldId,
+                              String writeBackAttachmentFieldId,
+                              String sealTypeFieldId) {
+        this.formUuid = formUuid;
+        this.counterpartyRecipientFieldId = counterpartyRecipientFieldId;
+        this.counterpartyPhoneFieldId = counterpartyPhoneFieldId;
+        this.ourCompanyFieldId = ourCompanyFieldId;
+        this.electronicSignFieldId = electronicSignFieldId;
+        this.contractAttachmentFieldId = contractAttachmentFieldId;
+        this.writeBackAttachmentFieldId = writeBackAttachmentFieldId;
+        this.sealTypeFieldId = sealTypeFieldId;
+    }
+
+    private static void add(Map<String, EqbYidaFormConfig> configs,
+                            EqbYidaFormConfig config) {
+        configs.put(config.formUuid, config);
+    }
+
+    public static EqbYidaFormConfig require(String formUuid) {
+        String normalized = formUuid == null ? null : formUuid.trim();
+        EqbYidaFormConfig config = CONFIGS.get(normalized);
+        if (config == null) {
+            throw new IllegalArgumentException("未配置自动落印表单formUuid:" + formUuid);
+        }
+        return config;
+    }
+
+    /** 将 formUuid 与实例 ID 一并写入跟踪表,供异步签署回调准确选择回写字段。 */
+    public static String encodeBusinessReference(String formUuid, String formInstanceId) {
+        EqbYidaFormConfig config = require(formUuid);
+        if (formInstanceId == null || formInstanceId.trim().isEmpty()) {
+            throw new IllegalArgumentException("formInstanceId不能为空");
+        }
+        return config.formUuid + BUSINESS_REFERENCE_SEPARATOR + formInstanceId.trim();
+    }
+
+    /**
+     * 从跟踪表的独立字段构建业务表单引用;formUuid为空时兼容历史合并格式。
+     */
+    public static BusinessReference resolveBusinessReference(
+            String formUuid, String formInstanceId) {
+        if (formUuid == null || formUuid.trim().isEmpty()) {
+            return parseBusinessReference(formInstanceId);
+        }
+        EqbYidaFormConfig config = require(formUuid);
+        if (formInstanceId == null || formInstanceId.trim().isEmpty()) {
+            throw new IllegalArgumentException("业务流程实例ID不能为空");
+        }
+        return new BusinessReference(config.formUuid, formInstanceId.trim());
+    }
+
+    /** 兼容历史跟踪记录:没有分隔符时按仅包含实例 ID 的旧格式解析。 */
+    public static BusinessReference parseBusinessReference(String value) {
+        if (value == null || value.trim().isEmpty()) {
+            throw new IllegalArgumentException("业务表单引用不能为空");
+        }
+        String normalized = value.trim();
+        int separator = normalized.indexOf(BUSINESS_REFERENCE_SEPARATOR);
+        if (separator < 0) {
+            return new BusinessReference(null, normalized);
+        }
+        String formUuid = normalized.substring(0, separator).trim();
+        String formInstanceId = normalized.substring(separator + 1).trim();
+        require(formUuid);
+        if (formInstanceId.isEmpty()) {
+            throw new IllegalArgumentException("业务表单引用缺少formInstanceId");
+        }
+        return new BusinessReference(formUuid, formInstanceId);
+    }
+
+    public String getFormUuid() {
+        return formUuid;
+    }
+
+    public String getCounterpartyRecipientFieldId() {
+        return counterpartyRecipientFieldId;
+    }
+
+    public String getCounterpartyPhoneFieldId() {
+        return counterpartyPhoneFieldId;
+    }
+
+    public String getOurCompanyFieldId() {
+        return ourCompanyFieldId;
+    }
+
+    public String getElectronicSignFieldId() {
+        return electronicSignFieldId;
+    }
+
+    public String getContractAttachmentFieldId() {
+        return contractAttachmentFieldId;
+    }
+
+    public String getWriteBackAttachmentFieldId() {
+        return writeBackAttachmentFieldId;
+    }
+
+    public String getSealTypeFieldId() {
+        return sealTypeFieldId;
+    }
+
+    public static final class BusinessReference {
+        private final String formUuid;
+        private final String formInstanceId;
+
+        private BusinessReference(String formUuid, String formInstanceId) {
+            this.formUuid = formUuid;
+            this.formInstanceId = formInstanceId;
+        }
+
+        public boolean isConfiguredForm() {
+            return formUuid != null;
+        }
+
+        public String getFormUuid() {
+            return formUuid;
+        }
+
+        public String getFormInstanceId() {
+            return formInstanceId;
+        }
+    }
+}

+ 139 - 0
mjava-sdzl/src/main/java/com/malk/sdzl/config/EqbAccountProperties.java

@@ -0,0 +1,139 @@
+package com.malk.sdzl.config;
+
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.stereotype.Component;
+import org.springframework.util.StringUtils;
+
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+/**
+ * e签宝多主体应用凭证配置。
+ *
+ * <p>业务发起时按我方签约公司选择账号;回调时按请求头中的appId反查同一账号。</p>
+ */
+@Component
+@ConfigurationProperties(prefix = "eqb")
+public class EqbAccountProperties {
+
+    private Map<String, Account> accounts = new LinkedHashMap<>();
+
+    public Map<String, Account> getAccounts() {
+        return accounts;
+    }
+
+    public void setAccounts(Map<String, Account> accounts) {
+        this.accounts = accounts == null ? new LinkedHashMap<>() : accounts;
+    }
+
+    /** 根据宜搭表单中的我方签约公司取得对应e签宝应用凭证。 */
+    public Account requireByCompany(String companyName) {
+        if (!StringUtils.hasText(companyName)) {
+            throw new IllegalArgumentException("我方签约公司不能为空");
+        }
+        Account matched = null;
+        for (Account account : accounts.values()) {
+            validate(account);
+            if (companyName.trim().equals(account.getCompanyName().trim())) {
+                if (matched != null) {
+                    throw new IllegalStateException("e签宝账号配置中的公司名称重复:" + companyName);
+                }
+                matched = account;
+            }
+        }
+        if (matched == null) {
+            throw new IllegalArgumentException("未配置我方签约公司的e签宝账号:" + companyName);
+        }
+        return matched;
+    }
+
+    /** 根据e签宝回调请求头中的appId反查应用凭证。 */
+    public Account requireByAppId(String appId) {
+        if (!StringUtils.hasText(appId)) {
+            throw new SecurityException("e签宝回调缺少appId");
+        }
+        Account matched = null;
+        for (Account account : accounts.values()) {
+            validate(account);
+            if (appId.trim().equals(account.getAppId().trim())) {
+                if (matched != null) {
+                    throw new IllegalStateException("e签宝账号配置中的appId重复:" + appId);
+                }
+                matched = account;
+            }
+        }
+        if (matched == null) {
+            throw new SecurityException("未配置回调appId对应的e签宝账号");
+        }
+        return matched;
+    }
+
+    /**
+     * 根据我方签约公司和宜搭表单中的印章类型取得当前环境的印章ID。
+     * “合同专用章”与配置中的“合同章”按同一种印章处理。
+     */
+    public String requireSealId(String companyName, String sealType) {
+        if (!StringUtils.hasText(sealType)) {
+            throw new IllegalArgumentException("印章类型不能为空");
+        }
+        Account account = requireByCompany(companyName);
+        String normalizedSealType = sealType.trim();
+        String sealId = account.getSealIds().get(normalizedSealType);
+        if (!StringUtils.hasText(sealId) && "合同专用章".equals(normalizedSealType)) {
+            sealId = account.getSealIds().get("合同章");
+        }
+        if (!StringUtils.hasText(sealId)) {
+            throw new IllegalArgumentException("未配置我方签约公司的印章ID:"
+                    + companyName.trim() + ",印章类型:" + normalizedSealType);
+        }
+        return sealId.trim();
+    }
+
+    private void validate(Account account) {
+        if (account == null || !StringUtils.hasText(account.getCompanyName())
+                || !StringUtils.hasText(account.getAppId())
+                || !StringUtils.hasText(account.getAppKey())) {
+            throw new IllegalStateException("eqb.accounts中的companyName、appId和appKey均不能为空");
+        }
+    }
+
+    public static class Account {
+        private String companyName;
+        private String appId;
+        private String appKey;
+        /** 当前运行环境下,该公司各印章类型对应的e签宝印章ID。 */
+        private Map<String, String> sealIds = new LinkedHashMap<>();
+
+        public String getCompanyName() {
+            return companyName;
+        }
+
+        public void setCompanyName(String companyName) {
+            this.companyName = companyName;
+        }
+
+        public String getAppId() {
+            return appId;
+        }
+
+        public void setAppId(String appId) {
+            this.appId = appId;
+        }
+
+        public String getAppKey() {
+            return appKey;
+        }
+
+        public void setAppKey(String appKey) {
+            this.appKey = appKey;
+        }
+
+        public Map<String, String> getSealIds() {
+            return sealIds;
+        }
+
+        public void setSealIds(Map<String, String> sealIds) {
+            this.sealIds = sealIds == null ? new LinkedHashMap<>() : sealIds;
+        }
+    }
+}

+ 2 - 4
mjava-sdzl/src/main/java/com/malk/sdzl/controller/ECController.java

@@ -48,10 +48,8 @@ public class ECController {
     }
     /**
      * 上传合同并发起签署,完整路径需加上server.servlet.context-path。
-     * 表单模式通过JSON传入formUuid和formInstanceId,前者匹配参与方字段配置,后者查询宜搭实例。
-     * 根据各方编号查询相对方登记信息生成signers;签署配置沿用服务层默认值。
-     * 下载并上传宜搭附件,全部就绪后合并发起一次签署。
-     * 也支持原有fileName方式,直接读取服务器配置目录内的文件。
+     * 表单模式通过JSON传入formUuid和formInstanceId,按formUuid读取自动落印字段配置。
+     * 仅当表单的“是否发起电子签”为“是”时,下载合同附件并发起签署;我方固定为乙方。
      *
      * @param params 上传及签署参数,字段约定见EqbService.uploadFile
      * @return McR业务结果;调用方应检查success及code,成功后读取data.signFlowId

+ 72 - 0
mjava-sdzl/src/main/java/com/malk/sdzl/controller/EqbCallbackController.java

@@ -0,0 +1,72 @@
+package com.malk.sdzl.controller;
+
+import com.malk.sdzl.service.Impl.EqbCallbackService;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.MediaType;
+import org.springframework.http.ResponseEntity;
+import org.springframework.util.MultiValueMap;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestHeader;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+/** 接收e签宝签署流程完结通知。 */
+@RestController
+@RequestMapping("/eqb/callback")
+public class EqbCallbackController {
+    private static final Logger LOG = LoggerFactory.getLogger(EqbCallbackController.class);
+
+    private final EqbCallbackService eqbCallbackService;
+
+    @Autowired
+    public EqbCallbackController(EqbCallbackService eqbCallbackService) {
+        this.eqbCallbackService = eqbCallbackService;
+    }
+
+    /**
+     * 按e签宝官方协议保留原始请求体进行验签,并处理SIGN_FLOW_COMPLETE事件。
+     * 只有验签及后续业务处理全部成功才返回2xx,失败时由e签宝继续重试通知。
+     */
+    @PostMapping(value = "/sign-flow-complete",
+            consumes = MediaType.APPLICATION_JSON_VALUE,
+            produces = MediaType.APPLICATION_JSON_VALUE)
+    public ResponseEntity<Map<String, String>> signFlowComplete(
+            @RequestBody byte[] body,
+            @RequestHeader HttpHeaders headers,
+            @RequestParam MultiValueMap<String, String> query) {
+        try {
+            eqbCallbackService.receive(body, headers, query);
+            return ResponseEntity.ok(callbackResult("200", "success"));
+        } catch (SecurityException e) {
+            LOG.warn("e签宝签署回调验签失败,appId={}",
+                    headers.getFirst("X-Tsign-Open-App-Id"), e);
+            return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
+                    .body(callbackResult("401", "signature verification failed"));
+        } catch (IllegalArgumentException e) {
+            LOG.warn("e签宝签署回调参数错误:{}", e.getMessage());
+            return ResponseEntity.status(HttpStatus.BAD_REQUEST)
+                    .body(callbackResult("400", "invalid callback request"));
+        } catch (Exception e) {
+            LOG.error("e签宝签署回调处理失败,appId={}",
+                    headers.getFirst("X-Tsign-Open-App-Id"), e);
+            return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
+                    .body(callbackResult("500", "callback processing failed"));
+        }
+    }
+
+    private static Map<String, String> callbackResult(String code, String msg) {
+        Map<String, String> result = new LinkedHashMap<>();
+        result.put("code", code);
+        result.put("msg", msg);
+        return result;
+    }
+}

+ 9 - 9
mjava-sdzl/src/main/java/com/malk/sdzl/service/EqbService.java

@@ -15,18 +15,15 @@ public interface EqbService {
      * 每个文件的状态轮询最长90秒,此时间不包含上传文件和创建签署流程的耗时。
      *
      * @param params 请求参数,约定如下:
-     *               表单模式需传formUuid和formInstanceId,使用请求中的formUuid匹配json/party-fields.json;
-     *               formInstanceId用于查询宜搭实例,formData中无需包含表单UUID;
-     *               甲乙方编号有值时,分别按serialNumberField_mrlipwdo查询相对方登记表并生成signers;
-     *               空编号跳过,登记信息缺失或不唯一时停止;此模式忽略调用方传入的signers;
-     *               读取两个约定的附件组件,先下载到eqb.downloadFilePath,再逐文件上传;
+     *               表单模式需传formUuid和formInstanceId,formUuid匹配自动落印字段配置;
+     *               查询表单后仅当“是否发起电子签”为“是”时继续,否则成功返回SKIPPED;
+     *               我方固定为乙方并自动落印,对方为甲方且姓名、电话从对应表单字段读取;
+     *               合同附件字段按formUuid选择,先下载到eqb.downloadFilePath,再逐文件上传;
      *               全部附件确认就绪后合并发起一次签署,每次调用只为该表单创建一个流程;
      *               不传formUuid和formInstanceId时,fileName必填,为该目录内的文件名(包含真实扩展名);
      *               本地文件模式的signers必填,为1至10个签署方,个人传psnSignerInfo,企业手动签署传orgSignerInfo及经办人;
-     *               表单模式只生成甲方、乙方签署人;textField_kcmzci1必须填写“甲方”或“乙方”,用于标识我方;
-     *               我方设置autoSign=true、freeMode=false并指定我方印章,企业自动签署不传经办人;
-     *               另一方使用手动签署,企业签署时使用宜搭中的对应联系人作为经办人;
-     *               若我方登记为自然人,会因e签宝不支持个人自动签署而在上传前报错;
+     *               我方设置autoSign=true、freeMode=false,并按我方签约公司及印章类型选择assignedSealId;
+     *               签署流程完成后,回调按formUuid将已签文件写入配置的回写附件字段;
      *               signFlowConfig可选,主题默认使用文件名(表单模式取第一个附件名),自动开启流程,autoFinish默认true;
      *               convertToPDF、convertToHTML、convertToOFD可选且互斥,默认false,非PDF合同需指定转换格式;
      *               未传签署区时默认自由签章,每位签署方的签署区配置会应用到每个附件,fileId由服务端绑定;
@@ -48,4 +45,7 @@ public interface EqbService {
      * 获取签署流程中已签署文件的临时下载地址。
      */
     Map getSignedFileDownloadUrls(String signFlowId);
+
+    /** 按我方签约公司选择e签宝应用凭证,获取已签署文件下载地址。 */
+    Map getSignedFileDownloadUrls(String signFlowId, String ourCompany);
 }

+ 62 - 34
mjava-sdzl/src/main/java/com/malk/sdzl/service/Impl/EqbCallbackService.java

@@ -3,6 +3,8 @@ 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.common.EqbYidaFormConfig;
+import com.malk.sdzl.config.EqbAccountProperties;
 import com.malk.sdzl.entity.dto.EqbSignResultCallback;
 import com.malk.sdzl.entity.dto.EqbSignedFile;
 import com.malk.sdzl.entity.dto.YidaAttachment;
@@ -42,20 +44,12 @@ public class EqbCallbackService {
     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;
+    @Autowired
+    private EqbAccountProperties eqbAccountProperties;
 
     public void receive(byte[] body, HttpHeaders headers, MultiValueMap<String, String> query) {
-        verifySignature(body, headers, query);
+        EqbAccountProperties.Account callbackAccount = verifySignature(body, headers, query);
         JsonNode payload;
         try {
             payload = MAPPER.reader().with(com.fasterxml.jackson.databind.DeserializationFeature.FAIL_ON_TRAILING_TOKENS)
@@ -97,20 +91,18 @@ public class EqbCallbackService {
             throw new IllegalArgumentException("Invalid sign flow result fields");
         }
         // 业务处理异常继续向外抛出,由控制器返回失败状态,供平台重试。
-        handleSignFlowComplete(resultCallback);
+        handleSignFlowComplete(resultCallback, callbackAccount.getCompanyName());
     }
 
-    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");
-        }
+    private EqbAccountProperties.Account verifySignature(
+            byte[] body, HttpHeaders headers, MultiValueMap<String, String> query) {
+        String requestAppId = headers.getFirst("X-Tsign-Open-App-Id");
+        EqbAccountProperties.Account callbackAccount =
+                eqbAccountProperties.requireByAppId(requestAppId);
         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}")
+        if (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");
@@ -127,7 +119,8 @@ public class EqbCallbackService {
         }
         try {
             Mac mac = Mac.getInstance("HmacSHA256");
-            mac.init(new SecretKeySpec(appKey.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
+            mac.init(new SecretKeySpec(
+                    callbackAccount.getAppKey().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");
@@ -135,6 +128,7 @@ public class EqbCallbackService {
         } catch (GeneralSecurityException | DecoderException e) {
             throw new IllegalStateException("Cannot verify callback signature", e);
         }
+        return callbackAccount;
     }
     @Value("${aliwork.appType}")
     private String appType;
@@ -151,6 +145,10 @@ public class EqbCallbackService {
      * 签署成功后将 e签宝返回的文件地址封装为宜搭附件,并回写到业务表。
      */
     public void handleSignFlowComplete(EqbSignResultCallback callback) {
+        handleSignFlowComplete(callback, null);
+    }
+
+    private void handleSignFlowComplete(EqbSignResultCallback callback, String ourCompany) {
         LOG.info("e签宝签署流程结束: signFlowId={}, signFlowStatus={}, completed={}, signFlowFinishTime={}, timestamp={}",
                 callback.getSignFlowId(), callback.getSignFlowStatus(), callback.isCompleted(),
                 callback.getSignFlowFinishTime(), callback.getTimestamp());
@@ -165,19 +163,24 @@ public class EqbCallbackService {
                 return;
             }
 
-            List<EqbSignedFile> signedFiles = getSignedFileDownloadUrls(callback.getSignFlowId());
+            List<EqbSignedFile> signedFiles = getSignedFileDownloadUrls(
+                    callback.getSignFlowId(), ourCompany);
             List<YidaAttachment> attachments = toYidaAttachments(signedFiles);
 
             for (Map trackingRow : trackingRows) {
-                String businessInstanceId = UtilMap.getString(trackingRow, "textField_mte6b6fs");
-                if (!StringUtils.hasText(businessInstanceId)) {
+                String trackingFormUuid = UtilMap.getString(trackingRow,
+                        EqbYidaFormConfig.TRACKING_FORM_UUID_FIELD);
+                String trackingFormInstanceId = UtilMap.getString(trackingRow,
+                        EqbYidaFormConfig.TRACKING_FORM_INSTANCE_ID_FIELD);
+                if (!StringUtils.hasText(trackingFormInstanceId)) {
                     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);
+                EqbYidaFormConfig.BusinessReference businessReference =
+                        EqbYidaFormConfig.resolveBusinessReference(
+                                trackingFormUuid, trackingFormInstanceId);
+                Map<String, Object> businessUpdate = buildBusinessUpdate(
+                        businessReference, attachments, callback.getSignFlowFinishTime());
+                updateYidaForm(businessReference.getFormInstanceId(), businessUpdate);
             }
 
             // 业务表和附件都更新成功后再置为已处理;失败时保留“否”,让平台回调可以重试。
@@ -187,15 +190,40 @@ public class EqbCallbackService {
                     throw new IllegalStateException("签署跟踪记录缺少formInstanceId");
                 }
                 Map<String, Object> trackingUpdate = new LinkedHashMap<>();
-                trackingUpdate.put("radioField_mte6b6fw", "是");
+                trackingUpdate.put(EqbYidaFormConfig.TRACKING_HANDLED_FIELD, "是");
                 trackingUpdate.put("radioField_mu9983xg", "已完成");
                 updateYidaForm(trackingInstanceId, trackingUpdate);
             }
         }
     }
 
+    /** 根据跟踪记录中的 formUuid 选择签署完成后的附件回写字段。 */
+    static Map<String, Object> buildBusinessUpdate(
+            EqbYidaFormConfig.BusinessReference businessReference,
+            List<YidaAttachment> attachments, Long signFlowFinishTime) {
+        Map<String, Object> businessUpdate = new LinkedHashMap<>();
+        if (businessReference.isConfiguredForm()) {
+            EqbYidaFormConfig formConfig = EqbYidaFormConfig.require(
+                    businessReference.getFormUuid());
+            businessUpdate.put(formConfig.getWriteBackAttachmentFieldId(), attachments);
+        } else {
+            // 兼容改造前已创建的跟踪记录。
+            businessUpdate.put("attachmentField_msu4s4kw", attachments);
+            businessUpdate.put("radioField_mu9983xg", "已完成");
+            businessUpdate.put("dateField_mse1k70h", signFlowFinishTime);
+        }
+        return businessUpdate;
+    }
+
     protected List<EqbSignedFile> getSignedFileDownloadUrls(String signFlowId) {
-        Map response = eqbService.getSignedFileDownloadUrls(signFlowId);
+        return getSignedFileDownloadUrls(signFlowId, null);
+    }
+
+    private List<EqbSignedFile> getSignedFileDownloadUrls(
+            String signFlowId, String ourCompany) {
+        Map response = ourCompany == null
+                ? eqbService.getSignedFileDownloadUrls(signFlowId)
+                : eqbService.getSignedFileDownloadUrls(signFlowId, ourCompany);
         Object filesValue = response == null ? null : response.get("files");
         List<EqbSignedFile> files = JSON.parseArray(JSON.toJSONString(filesValue), EqbSignedFile.class);
         if (files == null || files.isEmpty()) {
@@ -240,12 +268,12 @@ public class EqbCallbackService {
 
     private List<Map> queryPendingTrackingRows(String signFlowId) {
         Map<String, Object> searchFields = new LinkedHashMap<>();
-        searchFields.put("textField_mte3s2rf", signFlowId);
-        searchFields.put("radioField_mte6b6fw", "否");
+        searchFields.put(EqbYidaFormConfig.TRACKING_SIGN_FLOW_ID_FIELD, signFlowId);
+        searchFields.put(EqbYidaFormConfig.TRACKING_HANDLED_FIELD, "否");
         return ydService.queryFormData_all(YDParam.builder()
                 .appType(appType)
                 .systemToken(systemToken)
-                .formUuid(SIGN_FLOW_TRACKING_FORM)
+                .formUuid(EqbYidaFormConfig.SIGN_FLOW_TRACKING_FORM_UUID)
                 .searchFieldJson(JSON.toJSONString(searchFields))
                 .build());
     }

+ 333 - 190
mjava-sdzl/src/main/java/com/malk/sdzl/service/Impl/EqbServiceImpl.java

@@ -4,9 +4,10 @@ import cn.hutool.core.util.ObjectUtil;
 import com.alibaba.fastjson.JSON;
 import com.alibaba.fastjson.JSONArray;
 import com.alibaba.fastjson.JSONObject;
+import com.malk.sdzl.common.EqbYidaFormConfig;
+import com.malk.sdzl.config.EqbAccountProperties;
 import com.malk.sdzl.service.EqbService;
 import com.malk.sdzl.util.HTTPHelper;
-import com.malk.sdzl.service.EqbService;
 import com.malk.server.aliwork.YDConf;
 import com.malk.server.aliwork.YDParam;
 import com.malk.server.common.McR;
@@ -53,25 +54,11 @@ public class EqbServiceImpl implements EqbService {
     // 按接口文档每3秒轮询一次,文件状态查询及等待时间合计最多90秒。
     private static final long FILE_STATUS_POLL_INTERVAL_MILLIS = 3000;
     private static final long FILE_STATUS_POLL_TIMEOUT_MILLIS = 90000;
-    private static final String TEMPORARY_URL_ATTACHMENT_FIELD = "attachmentField_mrkcwgh7";
-    private static final String DIRECT_URL_ATTACHMENT_FIELD = "attachmentField_mr1tqpgz";
     private static final List<String> FORM_SIGNER_PARTIES =
             Collections.unmodifiableList(Arrays.asList("甲方", "乙方"));
     private static final String OUR_PARTY = "乙方";
     private static final String PARTY_A_SIGN_KEYWORD = "甲方/甲方授权代表电子签名";
     private static final String PARTY_B_SIGN_KEYWORD = "乙方电子签名";
-    /** 未配置主体或印章类型时使用的默认印章。 */
-    private static final String DEFAULT_OUR_PARTY_SEAL_ID = "25b69e0c-4d74-4c8d-9164-eb10891a284d";
-    /** 宜搭表单中的考核主体字段。 */
-    private static final String ASSESSMENT_SUBJECT_FIELD_ID = "textField_asr5cxt";
-    /** 宜搭表单中的核算印章类型字段。 */
-    private static final String ACCOUNTING_SEAL_TYPE_FIELD_ID = "textField_3mf3de6";
-    /**
-     * 考核主体与印章类型对应的 e 签宝印章 ID。
-     * 后续新增主体时,在该映射中增加一个主体及其印章类型配置即可。
-     */
-    private static final Map<String, Map<String, String>> ASSESSMENT_SUBJECT_SEAL_IDS =
-            createAssessmentSubjectSealIds();
     private static final String INTERNAL_SIGN_KEYWORD = "_signKeyword";
     private static final Set<String> UPLOAD_FILE_EXTENSIONS = new HashSet<>(Arrays.asList(
             "pdf", "docx", "doc", "rtf", "xlsx", "xls", "pptx", "ppt", "wps", "et", "dps",
@@ -116,6 +103,10 @@ public class EqbServiceImpl implements EqbService {
     @Value("${eqb.apiHost}")
     private String eqbApiHost;
 
+    // e签宝签署流程完结通知的公网地址;配置后自动写入发起签署请求。
+    @Value("${eqb.callbackUrl:}")
+    private String eqbCallbackUrl;
+
 //    @Value(value = "${server.host}")
     private String serverHost;
 
@@ -128,19 +119,10 @@ public class EqbServiceImpl implements EqbService {
     @Autowired
     private YDService ydService;
 
-    private volatile List<Map> MATE;
-
-    private static Map<String, Map<String, String>> createAssessmentSubjectSealIds() {
-        Map<String, String> tianJiSealIds = new LinkedHashMap<>();
-        tianJiSealIds.put("公章", "4b8cd76c-d0e4-4874-badb-0df0a974abf1");
-        tianJiSealIds.put("合同专用章", "7a51d006-bb92-4b5b-86c6-4f639ee94a40");
-        tianJiSealIds.put("合同章", "7a51d006-bb92-4b5b-86c6-4f639ee94a40");
+    @Autowired
+    private EqbAccountProperties eqbAccountProperties;
 
-        Map<String, Map<String, String>> subjectSealIds = new LinkedHashMap<>();
-        subjectSealIds.put("上海添玑好房网络服务有限公司",
-                Collections.unmodifiableMap(tianJiSealIds));
-        return Collections.unmodifiableMap(subjectSealIds);
-    }
+    private volatile List<Map> MATE;
 
     /**
      * 加载json
@@ -184,13 +166,14 @@ public class EqbServiceImpl implements EqbService {
                 throw new IllegalArgumentException("formUuid不能为空,必须通过请求参数传入");
             }
             String formUuid = ((String) rawUuid).trim();
+            EqbYidaFormConfig formConfig = EqbYidaFormConfig.require(formUuid);
             resultData.put("formInstance", formInstance);
+            resultData.put("formUuid", formUuid);
+            resultData.put("writeBackAttachmentFieldId",
+                    formConfig.getWriteBackAttachmentFieldId());
             if (!hasText(appType) || !hasText(systemToken)) {
                 throw new IllegalStateException("未配置aliwork.appType或aliwork.systemToken");
             }
-            if (!hasText(downloadFilePath)) {
-                throw new IllegalStateException("未配置eqb.downloadFilePath");
-            }
             DDR_New form = ydClient.queryData(YDParam.builder()
                     .appType(appType)
                     .systemToken(systemToken)
@@ -200,17 +183,33 @@ public class EqbServiceImpl implements EqbService {
             if (form == null || form.getFormData() == null) {
                 throw new IllegalStateException("查询宜搭表单失败或未返回表单数据");
             }
+            Map formData = form.getFormData();
+            String electronicSign = trimToEmpty(
+                    UtilMap.getString(formData, formConfig.getElectronicSignFieldId()));
+            resultData.put("electronicSign", electronicSign);
+            if (!shouldStartElectronicSign(formUuid, formData)) {
+                resultData.put("status", "SKIPPED");
+                resultData.put("reason", "是否发起电子签不是“是”,未发起签署");
+                return McR.success(resultData);
+            }
+            String ourCompany = requireFormText(formData,
+                    formConfig.getOurCompanyFieldId(), "我方签约公司");
+            EqbAccountProperties.Account eqbAccount =
+                    eqbAccountProperties.requireByCompany(ourCompany);
+            resultData.put("eqbAppId", eqbAccount.getAppId());
+            if (!hasText(downloadFilePath)) {
+                throw new IllegalStateException("未配置eqb.downloadFilePath");
+            }
             List<FormAttachment> attachments = new ArrayList<>();
-//            collectAttachments(form.getFormData(), TEMPORARY_URL_ATTACHMENT_FIELD, attachments);
-            collectAttachments(form.getFormData(), DIRECT_URL_ATTACHMENT_FIELD, attachments);
+            collectAttachments(formData, formConfig.getContractAttachmentFieldId(), attachments);
             if (attachments.isEmpty()) {
-                throw new IllegalArgumentException("附件组件中均没有可上传的文件");
+                throw new IllegalArgumentException("合同附件中没有可上传的文件");
             }
-            stage = "查询相对方登记信息";
-            // 表单模式不采用请求中的signers,所有参与方均由表单编号及相对方登记信息生成。
+            stage = "构建签署方信息";
+            // 表单模式不采用请求中的signers;我方固定为乙方,对方固定为甲方。
             Map<String, Object> signParams = new HashMap<>(params);
             signParams.remove("autoSignKeywords");
-            signParams.put("signers", buildFormSigners(formUuid, form.getFormData()));
+            signParams.put("signers", buildFormSigners(formUuid, formData));
             // 整个表单共用签署配置;未指定主题时使用第一个附件名,并校验展开后的签署区总数。
             stage = "校验签署配置";
             JSONObject signRequest = prepareSignRequest(signParams, attachments.get(0).fileName, attachments.size());
@@ -244,7 +243,7 @@ public class EqbServiceImpl implements EqbService {
                 stage = "下载附件:" + attachment.fileName;
                 String downloadUrl = attachment.downloadUrl;
 
-                if (!DIRECT_URL_ATTACHMENT_FIELD.equals(attachment.fieldId)) {
+                if (!"attachmentField_mq625xl3".equals(attachment.fieldId)) {
                     // 宜搭原生附件需先换取临时免登地址,第二个组件直接使用其下载地址。
                     downloadUrl = ydClient.convertTemporaryUrl(downloadUrl, appType, systemToken);
                     if (!hasText(downloadUrl)) {
@@ -263,7 +262,7 @@ public class EqbServiceImpl implements EqbService {
             for (int i = 0; i < attachments.size(); i++) {
                 FormAttachment attachment = attachments.get(i);
                 stage = "上传并确认附件状态:" + attachment.fileName;
-                McR result = uploadLocalFile(uploadParams.get(i), attachment.localFile);
+                McR result = uploadLocalFile(uploadParams.get(i), attachment.localFile, eqbAccount);
                 Map<String, Object> fileResult = fileResults.get(i);
                 fileResult.put("success", result.isSuccess());
                 fileResult.put("code", result.getCode());
@@ -280,21 +279,15 @@ public class EqbServiceImpl implements EqbService {
             }
             // 所有附件均已达到可签署状态后,仅调用一次创建接口,共享同一个signFlowId。
             stage = "创建表单附件签署流程";
-            String signFlowId = createUploadedFilesSignFlow(signRequest, fileResults);
+            String signFlowId = createUploadedFilesSignFlow(signRequest, fileResults, eqbAccount);
             resultData.put("signFlowId", signFlowId);
             for (Map<String, Object> fileResult : fileResults) {
                 fileResult.put("signFlowId", signFlowId);
                 fileResult.put("status", "SIGN_FLOW_CREATED");
             }
-//            ydClient.operateData(YDParam.builder()
-//                    .appType(appType)
-//                    .systemToken(systemToken)
-////                    .formInstanceId(request.getInstanceId())
-//                    .formUuid("FORM-3C73F8BB960244DE841E2DFD6B0A2818V7H5")
-////                    .useLatestVersion(true)
-//                    .formDataJson(JSON.toJSONString(UtilMap.map("textField_mte3s2rf, textField_mte6b6fs, radioField_mte6b6fw",
-//                            signFlowId, formInstance, "否")))
-//                    .build(), YDConf.FORM_OPERATION.create);
+            stage = "创建签署跟踪记录";
+            createSignFlowTracking(formUuid, formInstance, signFlowId);
+            resultData.put("status", "SIGN_FLOW_CREATED");
             return McR.success(resultData);
         } catch (Exception e) {
             LOG.error("宜搭附件处理失败,阶段:{}", stage, e);
@@ -305,6 +298,39 @@ public class EqbServiceImpl implements EqbService {
         }
     }
 
+    /** 保存签署流程与原业务表单的关系,供异步回调选择正确的附件回写字段。 */
+    private void createSignFlowTracking(String formUuid, String formInstanceId,
+                                        String signFlowId) {
+        Map<String, Object> trackingData = buildSignFlowTrackingData(
+                formUuid, formInstanceId, signFlowId);
+        ydClient.operateData(YDParam.builder()
+                .appType(appType)
+                .systemToken(systemToken)
+                .formUuid(EqbYidaFormConfig.SIGN_FLOW_TRACKING_FORM_UUID)
+                .formDataJson(JSON.toJSONString(trackingData))
+                .build(), YDConf.FORM_OPERATION.create);
+    }
+
+    /** 构建签署跟踪表数据,formUuid和流程实例ID分别写入独立字段。 */
+    static Map<String, Object> buildSignFlowTrackingData(
+            String formUuid, String formInstanceId, String signFlowId) {
+        EqbYidaFormConfig formConfig = EqbYidaFormConfig.require(formUuid);
+        if (formInstanceId == null || formInstanceId.trim().isEmpty()) {
+            throw new IllegalArgumentException("流程实例ID不能为空");
+        }
+        if (signFlowId == null || signFlowId.trim().isEmpty()) {
+            throw new IllegalArgumentException("e签宝签署流程ID不能为空");
+        }
+        Map<String, Object> trackingData = new LinkedHashMap<>();
+        trackingData.put(EqbYidaFormConfig.TRACKING_SIGN_FLOW_ID_FIELD, signFlowId.trim());
+        trackingData.put(EqbYidaFormConfig.TRACKING_FORM_UUID_FIELD,
+                formConfig.getFormUuid());
+        trackingData.put(EqbYidaFormConfig.TRACKING_FORM_INSTANCE_ID_FIELD,
+                formInstanceId.trim());
+        trackingData.put(EqbYidaFormConfig.TRACKING_HANDLED_FIELD, "否");
+        return trackingData;
+    }
+
     /**
      * 使用请求中的formUuid匹配party-fields.json,再按甲、乙顺序提取编号。
      * 未配置或未填写的参与方跳过;formData仅提供字段值,不读取其中的表单UUID。
@@ -386,94 +412,95 @@ public class EqbServiceImpl implements EqbService {
         return partyNumbers;
     }
 
-    /** 每个有值的参与方独立查询一次登记表,并转换为e签宝签署方信息。 */
+    /**
+     * 根据当前 formUuid 的字段配置构建签署方。我方固定为乙方并自动落印,
+     * 对方固定为甲方并使用表单中的接收人姓名、电话完成手动签署。
+     */
     protected JSONArray buildFormSigners(String formUuid, Map formData) {
-
-        JSONArray signers = new JSONArray();
-        String assignedSealId = resolveAssignedSealId(formData);
-        String jName = UtilMap.getString(formData,"textField_e6og3c9");
-        String jPhone = UtilMap.getString(formData,"textField_ismg45u");
-        String yName = UtilMap.getString(formData,"textField_mtwp487i");
-        String yPhone = UtilMap.getString(formData,"textField_mtwp487h");
-        for (Map<String, String> party : extractFormPartyNumbers(formUuid, formData)) {
-            String partyName = party.get("party");
-            boolean isOurParty = OUR_PARTY.equals(partyName);
-            String signerName = "甲方".equals(partyName) ? jName : yName;
-            String signerPhone = "甲方".equals(partyName) ? jPhone : yPhone;
-//            List<Map> dataList = ydService.queryFormData_all(YDParam.builder()
-//                    .appType(appType)
-//                    .systemToken(systemToken)
-//                    .formUuid("FORM-B7DBC8CA2A284FDC9F38E814CC043B17DIMN")
-//                    .searchFieldJson(JSON.toJSONString(UtilMap.map("serialNumberField_mrlipwdo", party.get("partyNumber"))))
-//                    .build());
-//            // 同一编号不能任取一条,避免登记信息缺失或重复时绑定错误签署主体。
-//            if (dataList == null) {
-//                throw new IllegalStateException(partyName + "相对方登记查询未返回数据");
-//            }
-//            if (dataList.isEmpty()) {
-//                throw new IllegalArgumentException(partyName + "编号未查到相对方登记记录");
-//            }
-//            if (dataList.size() != 1 || dataList.get(0) == null) {
-//                throw new IllegalArgumentException(partyName + "编号必须对应唯一一条相对方登记记录");
-//            }
-//            Map data = dataList.get(0);
-//            String type = requirePartyText(data, "radioField_ms4kbtdz", partyName, "相对方类型");
-//            String name = requirePartyText(data, "textField_mrlipwdn", partyName, "名称");
-//            String idCardNum = requirePartyText(data, "textField_mrlipwdt", partyName, "证件号码");
-//            String legalPerson = requirePartyText(data, "textField_ms4js5qb", partyName, "法人代表");
-            JSONObject signer = new JSONObject();
-            if ("甲方".equals(partyName)) {
-                signer.put("signerType", 0);
-                JSONObject psnInfo = new JSONObject();
-//                psnInfo.put("psnName", name);
-//                psnInfo.put("psnIDCardNum", idCardNum);
-//                psnInfo.put("psnIDCardType", "护照".equals(data.get("radioField_ms4js5q6"))
-//                        ? "CRED_PSN_PASSPORT" : "CRED_PSN_CH_IDCARD");
-                JSONObject psnSignerInfo = new JSONObject();
-                psnSignerInfo.put("psnInfo", psnInfo);
-                psnInfo.put("psnName", signerName);
-                psnSignerInfo.put("psnAccount", signerPhone);
-                signer.put("psnSignerInfo", psnSignerInfo);
-            } else {
-                signer.put("signerType", 1);
-                JSONObject orgInfo = new JSONObject();
-                orgInfo.put("orgIDCardType", "CRED_ORG_USCC");
-                orgInfo.put("orgIDCardNum", "91310106MAEC5CTQ0A");
-                JSONObject orgSignerInfo = new JSONObject();
-                orgSignerInfo.put("orgName", "上海深度智联科技有限公司");
-                orgSignerInfo.put("orgInfo", orgInfo);
-                if (!isOurParty) {
-                    JSONObject psnInfo = new JSONObject();
-                    psnInfo.put("psnName", signerName);
-                    JSONObject transactorInfo = new JSONObject();
-                    transactorInfo.put("psnAccount", signerPhone);
-                    transactorInfo.put("psnInfo", psnInfo);
-                    orgSignerInfo.put("transactorInfo", transactorInfo);
-                    signer.put("orgSignerInfo", orgSignerInfo);
-                }
-            }
-            signer.put("signFields", Collections.singletonList(
-                    createSignField(partyName, assignedSealId)));
-            signers.add(signer);
+        if (formData == null) {
+            throw new IllegalArgumentException("宜搭表单数据不能为空");
         }
+        EqbYidaFormConfig formConfig = EqbYidaFormConfig.require(formUuid);
+        String counterpartyName = requireFormText(formData,
+                formConfig.getCounterpartyRecipientFieldId(), "对方接收人");
+        String counterpartyPhone = requireFormText(formData,
+                formConfig.getCounterpartyPhoneFieldId(), "对方接收电话");
+        String ourCompany = requireFormText(formData,
+                formConfig.getOurCompanyFieldId(), "我方签约公司");
+        String sealType = trimToEmpty(
+                UtilMap.getString(formData, formConfig.getSealTypeFieldId()));
+        String assignedSealId = resolveAssignedSealId(ourCompany, sealType);
+        JSONArray signers = new JSONArray();
+
+        JSONObject counterpartySigner = new JSONObject();
+        counterpartySigner.put("signerType", 0);
+        JSONObject counterpartyPsnInfo = new JSONObject();
+        counterpartyPsnInfo.put("psnName", counterpartyName);
+        JSONObject counterpartySignerInfo = new JSONObject();
+        counterpartySignerInfo.put("psnInfo", counterpartyPsnInfo);
+        counterpartySignerInfo.put("psnAccount", counterpartyPhone);
+        counterpartySigner.put("psnSignerInfo", counterpartySignerInfo);
+        counterpartySigner.put("signFields", Collections.singletonList(
+                createSignField("甲方", assignedSealId)));
+        signers.add(counterpartySigner);
+
+        JSONObject ourSigner = new JSONObject();
+        ourSigner.put("signerType", 1);
+        // 机构自动签署时不传orgSignerInfo,由当前appId对应的机构主体完成自动落印。
+        ourSigner.put("signFields", Collections.singletonList(
+                createSignField(OUR_PARTY, assignedSealId)));
+        signers.add(ourSigner);
         return signers;
     }
 
-    /** 根据考核主体和核算印章类型选择印章;未配置的组合统一使用默认印章。 */
-    private String resolveAssignedSealId(Map formData) {
-        if (formData == null) {
-            return DEFAULT_OUR_PARTY_SEAL_ID;
+    /**
+     * 按我方签约公司名称查询e签宝机构认证信息,并返回自动签署要求的机构orgId。
+     * 查询和后续签署使用同一个主体账号,防止多主体场景下混用appId、appKey。
+     *
+     * @see <a href="https://open.esign.cn/doc/opendoc/auth3/xxz4tc">查询机构认证信息</a>
+     */
+    protected String queryOrganizationId(String orgName,
+                                         EqbAccountProperties.Account eqbAccount) {
+        if (!hasText(orgName)) {
+            throw new IllegalArgumentException("我方签约公司不能为空");
         }
-        String assessmentSubject = trimToEmpty(
-                UtilMap.getString(formData, ASSESSMENT_SUBJECT_FIELD_ID));
-        String sealType = trimToEmpty(
-                UtilMap.getString(formData, ACCOUNTING_SEAL_TYPE_FIELD_ID));
-        Map<String, String> sealIdsByType = ASSESSMENT_SUBJECT_SEAL_IDS.get(assessmentSubject);
-        if (sealIdsByType == null) {
-            return DEFAULT_OUR_PARTY_SEAL_ID;
+        if (eqbAccount == null) {
+            throw new IllegalArgumentException("e签宝主体账号不能为空");
+        }
+        try {
+            String normalizedOrgName = orgName.trim();
+            String encodedOrgName = URLEncoder.encode(normalizedOrgName, "UTF-8")
+                    .replace("+", "%20");
+            String apiPath = "/v3/organizations/identity-info?orgName=";
+            // 鉴权文档要求Query原值参与签名;仅在发送含中文的HTTP URL时进行UrlEncode。
+            String signaturePath = apiPath + normalizedOrgName;
+            String requestPath = apiPath + encodedOrgName;
+            Map organization = requestEqbApi(
+                    "GET", requestPath, signaturePath, null, 60000, eqbAccount);
+            Object rawOrgId = organization == null ? null : organization.get("orgId");
+            if (!(rawOrgId instanceof String) || !hasText((String) rawOrgId)) {
+                throw new IllegalStateException("查询机构认证信息未返回orgId");
+            }
+            return ((String) rawOrgId).trim();
+        } catch (UnsupportedEncodingException e) {
+            throw new IllegalStateException("UTF-8编码不可用", e);
+        }
+    }
+
+    /** 是否发起电子签字段只有明确为“是”时才继续创建签署流程。 */
+    protected boolean shouldStartElectronicSign(String formUuid, Map formData) {
+        if (formData == null) {
+            return false;
         }
-        String assignedSealId = sealIdsByType.get(sealType);
-        return hasText(assignedSealId) ? assignedSealId : DEFAULT_OUR_PARTY_SEAL_ID;
+        EqbYidaFormConfig formConfig = EqbYidaFormConfig.require(formUuid);
+        String electronicSign = trimToEmpty(
+                UtilMap.getString(formData, formConfig.getElectronicSignFieldId()));
+        return "是".equals(electronicSign);
+    }
+
+    /** 根据我方签约公司和印章类型,从当前环境配置中选择自动落印的印章ID。 */
+    private String resolveAssignedSealId(String ourCompany, String sealType) {
+        return eqbAccountProperties.requireSealId(ourCompany, sealType);
     }
 
     private String trimToEmpty(String value) {
@@ -487,8 +514,10 @@ public class EqbServiceImpl implements EqbService {
         fieldConfig.put("freeMode", false);
         fieldConfig.put("signFieldStyle", 1);
         if (isOurParty) {
-            fieldConfig.put("assignedSealId", hasText(assignedSealId)
-                    ? assignedSealId : DEFAULT_OUR_PARTY_SEAL_ID);
+            if (!hasText(assignedSealId)) {
+                throw new IllegalArgumentException("自动落印的印章ID不能为空");
+            }
+            fieldConfig.put("assignedSealId", assignedSealId.trim());
         }
         JSONObject field = new JSONObject();
         field.put("normalSignFieldConfig", fieldConfig);
@@ -506,10 +535,10 @@ public class EqbServiceImpl implements EqbService {
         throw new IllegalArgumentException("不支持的签署方:" + partyName);
     }
 
-    private String requirePartyText(Map data, String fieldId, String partyName, String label) {
+    private String requireFormText(Map data, String fieldId, String label) {
         Object value = data.get(fieldId);
         if (!(value instanceof String) || !hasText((String) value)) {
-            throw new IllegalArgumentException(partyName + "相对方登记缺少有效的" + label + ",字段:" + fieldId);
+            throw new IllegalArgumentException(label + "不能为空,字段:" + fieldId);
         }
         return ((String) value).trim();
     }
@@ -651,7 +680,8 @@ public class EqbServiceImpl implements EqbService {
     }
 
     /** 只上传并等待文件就绪,不创建签署流程;downloadedFile仅供表单下载流程内部传入。 */
-    private McR uploadLocalFile(Map<String, Object> params, File downloadedFile) {
+    private McR uploadLocalFile(Map<String, Object> params, File downloadedFile,
+                                EqbAccountProperties.Account eqbAccount) {
         try {
             // 签署参数由调用方统一校验,此处校验本地文件与转换选项。
             Object name = params == null ? null : params.get("fileName");
@@ -692,7 +722,7 @@ public class EqbServiceImpl implements EqbService {
             request.put("convertToOFD", convertToOFD);
 
             // 获取fileId仅代表预分配文件记录,此时文件尚未上传,不能直接用于签署。
-            Map uploadInfo = getFileUploadUrl(request);
+            Map uploadInfo = getFileUploadUrl(request, eqbAccount);
             String fileId = uploadInfo == null ? null : UtilMap.getString(uploadInfo, "fileId");
             String fileUploadUrl = uploadInfo == null ? null : UtilMap.getString(uploadInfo, "fileUploadUrl");
             if (fileId == null || fileId.trim().isEmpty()
@@ -701,7 +731,7 @@ public class EqbServiceImpl implements EqbService {
             }
             // PUT内部同时校验HTTP状态和errCode;上传接口成功后仍需等待服务端异步处理。
             uploadFileStream(file, fileSize, fileUploadUrl, contentMd5, contentType);
-            int fileStatus = waitForUploadedFile(fileId);
+            int fileStatus = waitForUploadedFile(fileId, eqbAccount);
 
             Map<String, Object> result = new HashMap<>();
             result.put("fileId", fileId);
@@ -725,6 +755,12 @@ public class EqbServiceImpl implements EqbService {
 
     /** 将全部已上传文件绑定到同一个流程,每位签署方的签署区配置应用于每个附件。 */
     private String createUploadedFilesSignFlow(JSONObject signRequest, List<Map<String, Object>> files) {
+        return createUploadedFilesSignFlow(signRequest, files, null);
+    }
+
+    private String createUploadedFilesSignFlow(JSONObject signRequest,
+                                               List<Map<String, Object>> files,
+                                               EqbAccountProperties.Account eqbAccount) {
         List<Map<String, Object>> docs = new ArrayList<>();
         for (Map<String, Object> file : files) {
             // 文件转换后可能改变扩展名,省略docs.fileName以保留服务端文件名。
@@ -748,8 +784,8 @@ public class EqbServiceImpl implements EqbService {
             signer.put("signFields", fields);
         }
         // fileId 只有在所有附件上传完成后才可确定,因此自动落印的关键字定位必须在此处执行。
-        applySignKeywordPositions(signRequest);
-        Map signResult = createSignFlow(signRequest);
+        applySignKeywordPositions(signRequest, eqbAccount);
+        Map signResult = createSignFlow(signRequest, eqbAccount);
         Object signFlowId = signResult == null ? null : signResult.get("signFlowId");
         if (!(signFlowId instanceof String) || !hasText((String) signFlowId)) {
             throw new IllegalStateException("发起签署未返回signFlowId");
@@ -791,8 +827,12 @@ public class EqbServiceImpl implements EqbService {
         }
     }
 
-    /** 根据已绑定的关键字定位每个展开后的签署区,并将坐标写回signFieldPosition。 */
-    private void applySignKeywordPositions(JSONObject signRequest) {
+    /**
+     * 根据已绑定的关键字定位签署区;同一关键字出现多次时,每个坐标都生成独立签署区。
+     * Excel多Sheet转换为多页后,可确保每个Sheet中的相同关键字都参与签署。
+     */
+    private void applySignKeywordPositions(JSONObject signRequest,
+                                           EqbAccountProperties.Account eqbAccount) {
         Map<String, Set<String>> keywordsByFile = new LinkedHashMap<>();
         for (Object signerValue : signRequest.getJSONArray("signers")) {
             JSONObject signer = (JSONObject) signerValue;
@@ -814,22 +854,26 @@ public class EqbServiceImpl implements EqbService {
         Map<String, List<KeywordPosition>> positionsByFile = new HashMap<>();
         for (Map.Entry<String, Set<String>> entry : keywordsByFile.entrySet()) {
             positionsByFile.put(entry.getKey(),
-                    queryKeywordPositions(entry.getKey(), new ArrayList<>(entry.getValue())));
+                    queryKeywordPositions(entry.getKey(), new ArrayList<>(entry.getValue()), eqbAccount));
         }
 
+        int expandedFieldCount = 0;
         for (Object signerValue : signRequest.getJSONArray("signers")) {
             JSONObject signer = (JSONObject) signerValue;
+            JSONArray expandedFields = new JSONArray();
             for (Object fieldValue : signer.getJSONArray("signFields")) {
                 JSONObject signField = (JSONObject) fieldValue;
                 JSONObject fieldConfig = signField.getJSONObject("normalSignFieldConfig");
                 String keyword = signField.getString(INTERNAL_SIGN_KEYWORD);
                 if (!hasText(keyword)) {
+                    expandedFields.add(signField);
+                    expandedFieldCount++;
                     continue;
                 }
-                signField.remove(INTERNAL_SIGN_KEYWORD);
                 String fileId = signField.getString("fileId");
-                KeywordPosition position = findFirstKeywordPosition(positionsByFile.get(fileId), keyword);
-                if (position == null) {
+                List<KeywordPosition> positions = findKeywordPositions(
+                        positionsByFile.get(fileId), keyword);
+                if (positions.isEmpty()) {
                     throw new IllegalArgumentException("签署关键字未定位到坐标,fileId=" + fileId + ",keyword=" + keyword);
                 }
                 if (Boolean.TRUE.equals(fieldConfig.getBoolean("freeMode"))) {
@@ -839,18 +883,28 @@ public class EqbServiceImpl implements EqbService {
                 if (signFieldStyle != null && signFieldStyle != 1) {
                     throw new IllegalArgumentException("使用关键字定位时,signFieldStyle必须为1(单页签章)");
                 }
-                fieldConfig.put("signFieldStyle", 1);
-                JSONObject signFieldPosition = fieldConfig.getJSONObject("signFieldPosition");
-                if (signFieldPosition == null) {
-                    signFieldPosition = new JSONObject();
-                    fieldConfig.put("signFieldPosition", signFieldPosition);
+                for (KeywordPosition position : positions) {
+                    JSONObject positionedField = JSONObject.parseObject(
+                            JSONObject.toJSONString(signField));
+                    positionedField.remove(INTERNAL_SIGN_KEYWORD);
+                    JSONObject positionedConfig = positionedField.getJSONObject(
+                            "normalSignFieldConfig");
+                    positionedConfig.put("signFieldStyle", 1);
+                    JSONObject signFieldPosition = new JSONObject();
+                    signFieldPosition.put("positionPage", String.valueOf(position.pageNum));
+                    signFieldPosition.put("positionX", position.positionX);
+                    signFieldPosition.put("positionY", position.positionY);
+                    positionedConfig.put("signFieldPosition", signFieldPosition);
+                    expandedFields.add(positionedField);
+                    expandedFieldCount++;
+                    LOG.info("e签宝签署区定位成功,fileId={},keyword={},page={},x={},y={}",
+                            fileId, keyword, position.pageNum, position.positionX, position.positionY);
                 }
-                signFieldPosition.put("positionPage", String.valueOf(position.pageNum));
-                signFieldPosition.put("positionX", position.positionX);
-                signFieldPosition.put("positionY", position.positionY);
-                LOG.info("e签宝签署区定位成功,fileId={},keyword={},page={},x={},y={}",
-                        fileId, keyword, position.pageNum, position.positionX, position.positionY);
             }
+            signer.put("signFields", expandedFields);
+        }
+        if (expandedFieldCount > 300) {
+            throw new IllegalArgumentException("关键字坐标展开后的签署区总数不能超过300个");
         }
     }
 
@@ -888,12 +942,17 @@ public class EqbServiceImpl implements EqbService {
      * @see <a href="https://open.esign.cn/doc/opendoc/pdf-sign3/ze0ahv">检索文件关键字坐标</a>
      */
     protected List<KeywordPosition> queryKeywordPositions(String fileId, List<String> keywords) {
+        return queryKeywordPositions(fileId, keywords, null);
+    }
+
+    private List<KeywordPosition> queryKeywordPositions(
+            String fileId, List<String> keywords, EqbAccountProperties.Account eqbAccount) {
         try {
             JSONObject request = new JSONObject();
             request.put("keywords", new ArrayList<>(new LinkedHashSet<>(keywords)));
             String path = "/v3/files/" + URLEncoder.encode(fileId, "UTF-8").replace("+", "%20")
                     + "/keyword-positions";
-            Map data = requestEqbApi("POST", path, request, 60000);
+            Map data = requestEqbApi("POST", path, request, 60000, eqbAccount);
             Object rawKeywordPositions = data.get("keywordPositions");
             if (!(rawKeywordPositions instanceof List)) {
                 throw new IllegalStateException("关键字定位接口未返回keywordPositions数组,fileId=" + fileId);
@@ -942,13 +1001,21 @@ public class EqbServiceImpl implements EqbService {
         }
     }
 
-    private KeywordPosition findFirstKeywordPosition(List<KeywordPosition> positions, String keyword) {
-        for (KeywordPosition position : positions) {
-            if (keyword.equals(position.keyword)) {
-                return position;
+    private List<KeywordPosition> findKeywordPositions(
+            List<KeywordPosition> positions, String keyword) {
+        List<KeywordPosition> matched = new ArrayList<>();
+        if (positions != null) {
+            for (KeywordPosition position : positions) {
+                if (keyword.equals(position.keyword)) {
+                    matched.add(position);
+                }
             }
         }
-        return null;
+        // 固定页码及页内坐标顺序,确保同一文件每次生成的签署区顺序一致。
+        matched.sort(Comparator.comparingInt((KeywordPosition position) -> position.pageNum)
+                .thenComparingDouble(position -> position.positionY)
+                .thenComparingDouble(position -> position.positionX));
+        return matched;
     }
 
     private static class KeywordPosition {
@@ -973,6 +1040,10 @@ public class EqbServiceImpl implements EqbService {
         return requestEqbApi("POST", "/v3/files/file-upload-url", request, 60000);
     }
 
+    private Map getFileUploadUrl(JSONObject request, EqbAccountProperties.Account eqbAccount) {
+        return requestEqbApi("POST", "/v3/files/file-upload-url", request, 60000, eqbAccount);
+    }
+
     /**
      * 使用已确认可用的fileId创建并开启签署流程,业务成功时返回signFlowId。
      * @see <a href="https://open.esign.cn/doc/opendoc/pdf-sign3/nxhgcl3bfgqz8qlz">基于文件发起签署</a>
@@ -982,8 +1053,26 @@ public class EqbServiceImpl implements EqbService {
         return requestEqbApi("POST", "/v3/sign-flow/create-by-file", request, 60000);
     }
 
+    private Map createSignFlow(JSONObject request, EqbAccountProperties.Account eqbAccount) {
+        // 创建流程不自动重试,避免网络响应丢失时重复发起合同。
+        return requestEqbApi("POST", "/v3/sign-flow/create-by-file", request, 60000, eqbAccount);
+    }
+
     @Override
     public Map getSignedFileDownloadUrls(String signFlowId) {
+        return getSignedFileDownloadUrls(
+                signFlowId, (EqbAccountProperties.Account) null);
+    }
+
+    @Override
+    public Map getSignedFileDownloadUrls(String signFlowId, String ourCompany) {
+        EqbAccountProperties.Account eqbAccount = ourCompany == null
+                ? null : eqbAccountProperties.requireByCompany(ourCompany);
+        return getSignedFileDownloadUrls(signFlowId, eqbAccount);
+    }
+
+    private Map getSignedFileDownloadUrls(String signFlowId,
+                                          EqbAccountProperties.Account eqbAccount) {
         if (!hasText(signFlowId)) {
             throw new IllegalArgumentException("signFlowId不能为空");
         }
@@ -992,7 +1081,7 @@ public class EqbServiceImpl implements EqbService {
         try {
             String path = "/v3/sign-flow/" + URLEncoder.encode(signFlowId, "UTF-8").replace("+", "%20")
                     + "/file-download-url";
-            return requestEqbApi("POST", path, request, 60000);
+            return requestEqbApi("POST", path, request, 60000, eqbAccount);
         } catch (UnsupportedEncodingException e) {
             throw new IllegalStateException("UTF-8编码不可用", e);
         }
@@ -1009,11 +1098,19 @@ public class EqbServiceImpl implements EqbService {
         return requestEqbApi("GET", path, null, timeoutMillis);
     }
 
+    private Map getFileStatus(String fileId, int timeoutMillis,
+                              EqbAccountProperties.Account eqbAccount)
+            throws UnsupportedEncodingException {
+        String path = "/v3/files/" + URLEncoder.encode(fileId, "UTF-8").replace("+", "%20");
+        return requestEqbApi("GET", path, null, timeoutMillis, eqbAccount);
+    }
+
     /**
      * 等待文件达到可签署状态;上传或转换失败立即终止,超时或中断时不发起签署。
      * @return 2(上传完成或HTML转换完成)或5(PDF转换完成)
      */
-    private int waitForUploadedFile(String fileId) throws Exception {
+    private int waitForUploadedFile(String fileId, EqbAccountProperties.Account eqbAccount)
+            throws Exception {
         // 使用单调时钟计算实际耗时,避免系统时间调整影响90秒截止时间。
         long deadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(FILE_STATUS_POLL_TIMEOUT_MILLIS);
         int maxAttempts = (int) (FILE_STATUS_POLL_TIMEOUT_MILLIS / FILE_STATUS_POLL_INTERVAL_MILLIS);
@@ -1029,7 +1126,7 @@ public class EqbServiceImpl implements EqbService {
             // 单次查询最多15秒,临近截止时间时缩短请求预算,查询耗时也计入轮询时长。
             Map data;
             try {
-                data = getFileStatus(fileId, (int) Math.min(15000, remaining));
+                data = getFileStatus(fileId, (int) Math.min(15000, remaining), eqbAccount);
             } catch (IllegalStateException e) {
                 if (System.nanoTime() >= deadline) {
                     break;
@@ -1103,6 +1200,9 @@ public class EqbServiceImpl implements EqbService {
         JSONObject noticeConfig = new JSONObject();
         noticeConfig.put("noticeTypes","1");
         config.put("noticeConfig",noticeConfig);
+        if (hasText(eqbCallbackUrl)) {
+            config.put("notifyUrl", eqbCallbackUrl.trim());
+        }
         JSONArray signers = JSONArray.parseArray(JSONObject.toJSONString(rawSigners));
         int fieldCount = 0;
         for (Object value : signers) {
@@ -1146,12 +1246,9 @@ public class EqbServiceImpl implements EqbService {
             }
             if (type == 0) {
                 validateSignerAccount(signer.getJSONObject("psnSignerInfo"));
-            } else if (!allAutoSign) {
-                JSONObject org = signer.getJSONObject("orgSignerInfo");
-                if (org == null || (!hasText(org.getString("orgId")) && !hasText(org.getString("orgName")))) {
-                    throw new IllegalArgumentException("企业签署方必须提供orgSignerInfo.orgName或orgId");
-                }
-                validateSignerAccount(org.getJSONObject("transactorInfo"));
+            } else if (type == 1 && allAutoSign) {
+                // 机构自动签署由当前appId确定签署主体,不能再传orgSignerInfo。
+                signer.remove("orgSignerInfo");
             }
         }
         if ((long) fieldCount * fileCount > 300) {
@@ -1191,26 +1288,59 @@ public class EqbServiceImpl implements EqbService {
      * @return e签宝响应中的data对象,不包含外层code、message
      */
     protected Map requestEqbApi(String method, String path, JSONObject body, int timeoutMillis) {
+        return executeEqbApi(method, path, path, body, timeoutMillis, appId, appKey);
+    }
+
+    protected Map requestEqbApi(String method, String path, JSONObject body, int timeoutMillis,
+                                EqbAccountProperties.Account eqbAccount) {
+        // null仅供历史入口和单元测试使用;宜搭自动落印始终显式传入公司账号。
+        if (eqbAccount == null) {
+            return requestEqbApi(method, path, body, timeoutMillis);
+        }
+        return executeEqbApi(method, path, path, body, timeoutMillis,
+                eqbAccount.getAppId(), eqbAccount.getAppKey());
+    }
+
+    /** 中文Query参数使用原值签名、编码值发送时调用,其余请求的两个路径保持一致。 */
+    protected Map requestEqbApi(String method, String requestPath, String signaturePath,
+                                JSONObject body, int timeoutMillis,
+                                EqbAccountProperties.Account eqbAccount) {
+        if (eqbAccount == null) {
+            return executeEqbApi(method, requestPath, signaturePath, body, timeoutMillis,
+                    appId, appKey);
+        }
+        return executeEqbApi(method, requestPath, signaturePath, body, timeoutMillis,
+                eqbAccount.getAppId(), eqbAccount.getAppKey());
+    }
+
+    private Map executeEqbApi(String method, String requestPath, String signaturePath,
+                              JSONObject body, int timeoutMillis,
+                              String requestAppId, String requestAppKey) {
         HttpURLConnection connection = null;
         try {
+            if (!hasText(requestAppId) || !hasText(requestAppKey)) {
+                throw new IllegalStateException("e签宝appId或appKey未配置");
+            }
             long deadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(timeoutMillis);
             String bodyText = body == null ? "" : body.toJSONString();
             // API鉴权的MD5针对JSON请求体,与PUT文件流使用的文件MD5含义不同。
             String contentMd5 = body == null ? "" : doContentMD5(bodyText);
             String contentType = "application/json; charset=UTF-8";
             // 按协议顺序拼接:方法、Accept、Content-MD5、Content-Type、空Date、请求路径。
-            String plaintext = method + "\n*/*\n" + contentMd5 + "\n" + contentType + "\n\n" + path;
-            connection = (HttpURLConnection) new URL(eqbApiHost + path).openConnection();
+            String plaintext = method + "\n*/*\n" + contentMd5 + "\n" + contentType
+                    + "\n\n" + signaturePath;
+            connection = (HttpURLConnection) new URL(eqbApiHost + requestPath).openConnection();
             connection.setRequestMethod(method);
             connection.setConnectTimeout(Math.min(15000, timeoutMillis));
             connection.setReadTimeout(timeoutMillis);
             connection.setInstanceFollowRedirects(false);
             connection.setRequestProperty("Accept", "*/*");
             connection.setRequestProperty("Content-Type", contentType);
-            connection.setRequestProperty("X-Tsign-Open-App-Id", appId);
+            connection.setRequestProperty("X-Tsign-Open-App-Id", requestAppId);
             connection.setRequestProperty("X-Tsign-Open-Auth-Mode", "Signature");
             connection.setRequestProperty("X-Tsign-Open-Ca-Timestamp", String.valueOf(timeStamp()));
-            connection.setRequestProperty("X-Tsign-Open-Ca-Signature", doSignatureBase64(plaintext, appKey));
+            connection.setRequestProperty("X-Tsign-Open-Ca-Signature",
+                    doSignatureBase64(plaintext, requestAppKey));
             if (body != null) {
                 byte[] bytes = bodyText.getBytes(StandardCharsets.UTF_8);
                 connection.setRequestProperty("Content-MD5", contentMd5);
@@ -1227,23 +1357,13 @@ public class EqbServiceImpl implements EqbService {
              String responseMessage = connection.getResponseMessage();
             int status = connection.getResponseCode();
             if (status < 200 || status >= 300) {
-                throw new IOException("HTTP状态码:" + status);
-            }
-            StringBuilder responseText = new StringBuilder();
-            try (BufferedReader reader = new BufferedReader(
-                    new InputStreamReader(connection.getInputStream(), StandardCharsets.UTF_8))) {
-                String line;
-                while (true) {
-                    connection.setReadTimeout(remainingRequestMillis(deadline));
-                    line = reader.readLine();
-                    if (line == null) {
-                        break;
-                    }
-                    responseText.append(line);
-                }
+                String errorBody = readEqbResponse(connection.getErrorStream());
+                throw new IOException("HTTP状态码:" + status
+                        + (hasText(errorBody) ? ",响应:" + errorBody : ""));
             }
+            String responseText = readEqbResponse(connection.getInputStream());
             // 普通API成功码是code=0;文件流PUT接口使用errCode=0,二者不能混用。
-            JSONObject response = JSONObject.parseObject(responseText.toString());
+            JSONObject response = JSONObject.parseObject(responseText);
             if (response == null || !Integer.valueOf(0).equals(response.getInteger("code"))) {
                 throw new IllegalStateException(response == null ? "返回空响应"
                         : "code=" + response.getString("code") + ",message=" + response.getString("message"));
@@ -1253,7 +1373,8 @@ public class EqbServiceImpl implements EqbService {
             }
             return response.getJSONObject("data");
         } catch (Exception e) {
-            throw new IllegalStateException("e签宝接口" + path + "调用失败:" + e.getMessage(), e);
+            throw new IllegalStateException(
+                    "e签宝接口" + requestPath + "调用失败:" + e.getMessage(), e);
         } finally {
             if (connection != null) {
                 connection.disconnect();
@@ -1261,6 +1382,26 @@ public class EqbServiceImpl implements EqbService {
         }
     }
 
+    /** 读取成功或失败响应体;失败响应保留业务错误码,便于区分签名错误与接口权限不足。 */
+    private String readEqbResponse(InputStream input) throws IOException {
+        if (input == null) {
+            return "";
+        }
+        StringBuilder responseText = new StringBuilder();
+        try (BufferedReader reader = new BufferedReader(
+                new InputStreamReader(input, StandardCharsets.UTF_8))) {
+            String line;
+            while (true) {
+                line = reader.readLine();
+                if (line == null) {
+                    break;
+                }
+                responseText.append(line);
+            }
+        }
+        return responseText.toString();
+    }
+
     /** 计算请求剩余时间;耗尽时抛出异常,避免向连接传入0而变成无限等待。 */
     private int remainingRequestMillis(long deadline) throws SocketTimeoutException {
         long remaining = TimeUnit.NANOSECONDS.toMillis(deadline - System.nanoTime());
@@ -1481,7 +1622,9 @@ public class EqbServiceImpl implements EqbService {
         noticeConfig.put("noticeTypes","2");
         signFlowConfig.put("noticeConfig",noticeConfig);
         signFlowConfig.put("autoFinish",true);
-        signFlowConfig.put("notifyUrl",serverHost + "/huagao/eqb/callback");//回调地址
+        if (hasText(eqbCallbackUrl)) {
+            signFlowConfig.put("notifyUrl", eqbCallbackUrl.trim());//签署流程完结回调地址
+        }
         reqBodyObj2.put("signFlowConfig",signFlowConfig);
 
         //签署方信息

+ 4 - 2
mjava-sdzl/src/main/java/com/malk/sdzl/service/Impl/PayServiceImpl.java

@@ -1727,6 +1727,7 @@ public class PayServiceImpl implements PayService {
                     request.getColId(), response.getStatus(), response.getError());
         }
         return response;
+//        return null;
     }
 
     /**
@@ -1762,7 +1763,8 @@ public class PayServiceImpl implements PayService {
                 .map(obj -> (JSONObject) obj)
                 .filter(jsonObj -> {
                     String dateStr = jsonObj.getString("operateTimeGMT");
-                    return dateStr != null && !dateStr.trim().isEmpty();
+                    String taskId = jsonObj.getString("taskId");
+                    return dateStr != null && !dateStr.trim().isEmpty() && !taskId.equals("null");
                 })
                 .sorted(Comparator.comparing(
                         jsonObj -> OffsetDateTime.parse(jsonObj.getString("operateTimeGMT"),DateTimeFormatter.ISO_OFFSET_DATE_TIME),
@@ -1892,7 +1894,7 @@ public class PayServiceImpl implements PayService {
         return records.stream()
                 .map(item -> item instanceof JSONObject
                         ? (JSONObject) item : JSON.parseObject(JSON.toJSONString(item)))
-                .filter(item -> item.getString("operateTimeGMT") != null)
+                .filter(item -> item.getString("operateTimeGMT") != null && !Objects.equals(item.getString("taskId"), "null"))
                 .max(Comparator.comparing(item -> {
                     OffsetDateTime time = safeParseIsoTime(item.getString("operateTimeGMT"));
                     return time == null ? OffsetDateTime.MIN : time;

+ 23 - 5
mjava-sdzl/src/main/resources/application-dev.yml

@@ -1,6 +1,6 @@
 # 环境配置
 server:
-  port: 8108
+  port: 8109
   servlet:
     context-path: /api/sdzl
 
@@ -69,15 +69,33 @@ aliwork:
   systemToken: 14G668D1DCV5TU1EG98ZX6XRO47H33RN9DCPMKC1
 
 eqb:
+  # 外网可访问的完整地址,例如:https://域名/api/sdzl/eqb/callback/sign-flow-complete
+  callbackUrl: ${EQB_CALLBACK_URL:}
   #  downloadFilePath: C:\\Users\\EDY\\Desktop\\项目\\华高\\电子签\\files\\
   downloadFilePath: C:\Users\86137\Desktop\
   #  signatoryPsnId: c3fb35cb7e574baf97ed9f8917c6327b #签署人 陈伟东
   signatoryPsnId: 95945af8f0864d15bf186e7808693176 #签署人 俞静妙
-  apiHost: https://openapi.esign.cn
-  psnName: 方煜浙
-  psnIDCardNum: 332624199910200013
-  psnIDCardType: CRED_PSN_CH_IDCARD
+  apiHost: https://smlopenapi.esign.cn
+#  psnName: 方煜浙
+#  psnIDCardNum: 332624199910200013
+#  psnIDCardType: CRED_PSN_CH_IDCARD
   appId: 5112101376
   appKey: 2fc3aacccffb60bda557e945d22f92a5
+  # 自动落印按宜搭表单中的“我方签约公司”和“印章类型”选择当前环境的账号及印章。
+  accounts:
+    shanghai-sdzl:
+      companyName: 上海深度智联科技有限公司
+      appId: 5112101376
+      appKey: 2fc3aacccffb60bda557e945d22f92a5
+      sealIds:
+        "[公章]": 25b69e0c-4d74-4c8d-9164-eb10891a284d
+        "[合同章]": 25b69e0c-4d74-4c8d-9164-eb10891a284d
+    shanghai-tianji:
+      companyName: 上海添玑好房网络服务有限公司
+      appId: 7438842564
+      appKey: 04ddf5b30ece7f35643517ef8a5c7783
+      sealIds:
+        "[公章]": 3eea122a-df4a-4b38-8023-1c6db8329411
+        "[合同章]": 7e531af4-a6f4-45c4-93b7-51f002315fd7
 #  appId: 5112105688
 #  appKey: d41e3ebbc5ba045927bdd9d10862d39

+ 18 - 0
mjava-sdzl/src/main/resources/application-prod.yml

@@ -69,6 +69,8 @@ aliwork:
   systemToken: "14G668D1DCV5TU1EG98ZX6XRO47H33RN9DCPMKC1"
 
 eqb:
+  # 外网可访问的完整地址,例如:https://域名/api/sdzl/eqb/callback/sign-flow-complete
+  callbackUrl: ${EQB_CALLBACK_URL:}
   #  downloadFilePath: C:\\Users\\EDY\\Desktop\\项目\\华高\\电子签\\files\\
   downloadFilePath: /home/server/sdzl/file/sign/
   #  signatoryPsnId: c3fb35cb7e574baf97ed9f8917c6327b #签署人 陈伟东
@@ -79,3 +81,19 @@ eqb:
   psnIDCardType: CRED_PSN_CH_IDCARD
   appId: 5112101376
   appKey: 2fc3aacccffb60bda557e945d22f92a5
+  # 自动落印按宜搭表单中的“我方签约公司”和“印章类型”选择当前环境的账号及印章。
+  accounts:
+    shanghai-sdzl:
+      companyName: 上海深度智联科技有限公司
+      appId: 5112101376
+      appKey: 2fc3aacccffb60bda557e945d22f92a5
+      sealIds:
+        "[公章]": 25b69e0c-4d74-4c8d-9164-eb10891a284d
+        "[合同章]": 38b560ea-737d-4fa9-9615-1bde7e4fe3a2
+    shanghai-tianji:
+      companyName: 上海添玑好房网络服务有限公司
+      appId: 5111948746
+      appKey: a0a5616c166ed74c5650db866ccf8d51
+      sealIds:
+        "[公章]": 4b8cd76c-d0e4-4874-badb-0df0a974abf1
+        "[合同章]": 7a51d006-bb92-4b5b-86c6-4f639ee94a40

+ 2 - 2
mjava-sdzl/src/main/resources/json/party-fields.json

@@ -26,8 +26,8 @@
     "allFieldCount": 119
   },
   {
-    "formName": "装饰装修及展柜定制年度框架合同",
-    "formUuid": "FORM-7B78FEDB2F384D488F6F86FD20F3BF100G1T",
+    "formName": "共享经济结算书申请单",
+    "formUuid": "FORM-DB6F25B40F874A77A87BEA12019253B887GX",
     "formType": "process",
     "parties": {
       "甲方": [