diff --git a/patch_seal_position_20260709.sql b/patch_seal_position_20260709.sql new file mode 100644 index 0000000..0f5e199 --- /dev/null +++ b/patch_seal_position_20260709.sql @@ -0,0 +1,50 @@ +-- 合同签章位能力补丁 +-- 执行前请先确认正式库对象名与本测试库一致。 + +ALTER TABLE SEAL_CONTRACT_LAUNCH_MAIN ADD ( + SEAL_POS_CONFIGURED CHAR(1), + SEAL_PDF_IMAGE_LIST CLOB +); + +COMMENT ON COLUMN SEAL_CONTRACT_LAUNCH_MAIN.SEAL_POS_CONFIGURED IS '签章位是否已配置(Y/N)'; +COMMENT ON COLUMN SEAL_CONTRACT_LAUNCH_MAIN.SEAL_PDF_IMAGE_LIST IS '签章平台PDF转图片列表JSON'; + +CREATE TABLE SEAL_CONTRACT_LAUNCH_SEAL_POS ( + ID NUMBER(20) PRIMARY KEY, + CONTRACT_ID NUMBER(20) NOT NULL, + PARTY_ID NUMBER(20) NOT NULL, + LABEL VARCHAR2(100) NOT NULL, + REQUIRED CHAR(1) DEFAULT 'Y' NOT NULL, + PAGE_NUM NUMBER(10) NOT NULL, + X NUMBER(12,2) NOT NULL, + Y NUMBER(12,2) NOT NULL, + WIDTH NUMBER(12,2) NOT NULL, + HEIGHT NUMBER(12,2) NOT NULL, + SEAL_ID VARCHAR2(100), + SEAL_NAME VARCHAR2(200), + REQUEST_ID VARCHAR2(100), + SIGNED_STATUS NUMBER(2) DEFAULT 0 NOT NULL, + SIGNED_BY_NAME VARCHAR2(100), + SIGNED_BY_ID VARCHAR2(100), + SIGNED_TIME TIMESTAMP, + CREATE_BY VARCHAR2(100), + CREATE_TIME TIMESTAMP DEFAULT SYSTIMESTAMP, + UPDATE_BY VARCHAR2(100), + UPDATE_TIME TIMESTAMP DEFAULT SYSTIMESTAMP, + IS_DELETED CHAR(1) DEFAULT 'N' NOT NULL, + REMARK VARCHAR2(500) +); + +COMMENT ON TABLE SEAL_CONTRACT_LAUNCH_SEAL_POS IS '合同签章位配置表'; +COMMENT ON COLUMN SEAL_CONTRACT_LAUNCH_SEAL_POS.PARTY_ID IS '签署方子表ID'; +COMMENT ON COLUMN SEAL_CONTRACT_LAUNCH_SEAL_POS.REQUIRED IS '是否必签(Y/N)'; +COMMENT ON COLUMN SEAL_CONTRACT_LAUNCH_SEAL_POS.REQUEST_ID IS '最近一次预盖章二维码请求ID'; +COMMENT ON COLUMN SEAL_CONTRACT_LAUNCH_SEAL_POS.SIGNED_STATUS IS '0空框 1已选章 2待回调 3已签 4失败'; + +CREATE INDEX IDX_SC_LAUNCH_SEAL_POS_CONTRACT ON SEAL_CONTRACT_LAUNCH_SEAL_POS(CONTRACT_ID, IS_DELETED); +CREATE INDEX IDX_SC_LAUNCH_SEAL_POS_REQUEST ON SEAL_CONTRACT_LAUNCH_SEAL_POS(REQUEST_ID, IS_DELETED); + +CREATE SEQUENCE SEQ_SEAL_CONTRACT_LAUNCH_SEAL_POS + START WITH 1 + INCREMENT BY 1 + NOCACHE; diff --git a/src/main/java/com/nbport/zgwl/contractlaunch/controller/ContractLaunchController.java b/src/main/java/com/nbport/zgwl/contractlaunch/controller/ContractLaunchController.java index 8e7c425..79623b3 100644 --- a/src/main/java/com/nbport/zgwl/contractlaunch/controller/ContractLaunchController.java +++ b/src/main/java/com/nbport/zgwl/contractlaunch/controller/ContractLaunchController.java @@ -6,11 +6,14 @@ import com.nbport.zgwl.contractlaunch.dto.ContractLaunchApproveDto; import com.nbport.zgwl.contractlaunch.dto.ContractLaunchArchiveDto; import com.nbport.zgwl.contractlaunch.dto.ContractLaunchCollectSaveDto; import com.nbport.zgwl.contractlaunch.dto.ContractLaunchDto; -import com.nbport.zgwl.contractlaunch.dto.ContractLaunchLawCallbackDto; import com.nbport.zgwl.contractlaunch.dto.ContractLaunchLawCallbackRequestDto; import com.nbport.zgwl.contractlaunch.dto.ContractLaunchLawCallbackSampleDto; import com.nbport.zgwl.contractlaunch.dto.ContractLaunchQueryDto; import com.nbport.zgwl.contractlaunch.dto.ContractLaunchSealDto; +import com.nbport.zgwl.contractlaunch.dto.ContractLaunchSealPositionBatchStatusDto; +import com.nbport.zgwl.contractlaunch.dto.ContractLaunchSealPositionCheckResultDto; +import com.nbport.zgwl.contractlaunch.dto.ContractLaunchSealPositionPageDto; +import com.nbport.zgwl.contractlaunch.dto.ContractLaunchSealPositionSaveDto; import com.nbport.zgwl.contractlaunch.service.ContractLaunchService; import com.nbport.zgwl.contractlaunch.utils.ContractLaunchConstants; import com.nbport.zgwl.entity.SysUserEntity; @@ -37,9 +40,6 @@ import java.util.Map; * 2. 模拟合同采集 * 3. 合同归档 * 4. 回写签章结果 - * - * 这些测试接口在法务系统不可用期间便于你完整演示流程, - * 后续接入真实系统后,保留或删除都很方便。 */ @RestController @RequiredArgsConstructor @@ -49,302 +49,100 @@ public class ContractLaunchController { private final ContractLaunchService contractLaunchService; private final AdminSecurityManage adminSecurityManage; - /** - * 分页查询合同列表。 - * - * 支持按合同名称、合同编号、合同类型、状态、创建时间范围过滤。 - * 返回结果附带合同方摘要、文件列表、履行计划(不含日志,列表页日志量大)。 - * - * 对应前端功能:合同列表页 - * - * 请求体 ContractLaunchQueryDto 字段: - * contractName - 合同名称(模糊) - * contractCode - 合同编号 - * contractType - 合同类型 - * status - 状态(draft/reviewing/rejected/pending_sign/signing/...) - * dateStart - 创建时间范围-开始(yyyy-MM-dd) - * dateEnd - 创建时间范围-结束(yyyy-MM-dd) - */ @PostMapping("/list") public R> queryList(@RequestBody ContractLaunchQueryDto queryDto) { return R.success(contractLaunchService.queryPage(queryDto)); } - /** - * 查询合同详情(含所有子表数据)。 - * - * 返回合同基本信息 + 合同方列表 + 文件列表 + 履行计划 + 操作日志。 - * - * 对应前端功能:合同详情页 / 编辑页面回显 - */ @GetMapping("/{id}") public R getDetail(@PathVariable("id") Long id) { return R.success(contractLaunchService.getDetail(id)); } - /** - * 获取当前登录人的我方组织信息(当前先返回 mock 数据)。 - * - * 用途: - * 1. 发起合同页面只读展示“我方”信息 - * 2. 后续接入真实组织主数据时,前端不需要再改字段来源 - */ @GetMapping("/self-profile") public R getSelfProfile() { SysUserEntity currentUser = adminSecurityManage.getUser(); return R.success(currentUser == null ? null : currentUser.getSysOrgEntity()); } - /** - * 创建合同草稿。 - * - * 流程:生成 contractSysId(CLS-xxx)和 contractCode → 保存主表 → 保存子表(合同方/文件/履行计划)→ 记录操作日志 - * 状态变为:draft(草稿) - * - * 必填校验项:合同名称、合同类型、合同类型编码、计价方式、币种、签订方式、合同期限类型、至少一个甲方+乙方 - * - * 对应前端功能:新建合同 → 保存草稿 - */ @PostMapping public R createDraft(@RequestBody ContractLaunchDto contract) { return R.success(contractLaunchService.createDraft(contract)); } - /** - * 编辑合同草稿。 - * - * 仅 draft(草稿)或 rejected(已驳回)状态允许编辑。 - * 逻辑:先删子表旧数据,再以当前数据重新插入(替换式更新)。 - * 保留原有的 contractSysId、contractCode、创建人/创建时间 不变。 - * - * 对应前端功能:编辑草稿 → 保存 - */ @PutMapping public R updateDraft(@RequestBody ContractLaunchDto contract) { return R.success(contractLaunchService.updateDraft(contract)); } - /** - * 复制合同并生成新草稿。 - * - * 业务设计: - * 1. 允许从任意未删除合同复制出一份新的草稿,方便高频复用历史合同 - * 2. 保留主信息、合同方、正文附件、履行计划等业务内容 - * 3. 重置系统编号、合同编号、审批/签署/归档状态与相关痕迹,避免串流程数据 - * - * 对应前端功能:列表页 → 复制合同 - */ @PostMapping("/{id}/copy") public R copyDraft(@PathVariable("id") Long id) { return R.success(contractLaunchService.copyDraft(id)); } - /** - * 基于现有合同发起变更/附加合同草稿。 - * - * 处理原则: - * 1. 复制现有业务内容 - * 2. 自动关联主合同编码 mainContractCode - * 3. 生成新的草稿,供业务在此基础上做变更补充 - */ @PostMapping("/{id}/change") public R createChangeDraft(@PathVariable("id") Long id) { return R.success(contractLaunchService.createChangeDraft(id)); } - /** - * 提交法务审批。 - * - * 状态流转:draft/rejected → reviewing - * 调用结算平台 pushContract 接口将合同数据推送给法务系统开始审批流程。 - * 目前法务接口不可用,仅做状态变更 + 记录日志(TODO:联调时打开 settlementPushService.pushContract 调用)。 - * - * 推送法务的报文包含:合同基本字段 + contractOpposites(相对方) + contractKxPlans(履行计划) + 附件等 - * - * 对应前端功能:提交审批按钮 - */ @PostMapping("/{id}/submit") public R submitToLaw(@PathVariable("id") Long id) { return R.success(contractLaunchService.submitToLaw(id)); } - /** - * 【测试按钮】模拟法务审批通过。 - * - * 状态流转:reviewing → pending_sign(待签署) - * 生成模拟的 lawContractCode 和 lawContractId 记录到合同上,以便后续流程使用。 - * 可在请求体中传入 approveMessage(审批意见)、lawContractCode、lawContractId 覆盖自动生成的值。 - * - * 真实场景下,此方法应由法务系统的回调接口替代(见 lawApproveCallback)。 - * - * 对应前端功能:测试按钮 → 模拟审批通过 - */ @PostMapping("/{id}/mock-approve") public R mockApprove(@PathVariable("id") Long id, @RequestBody(required = false) ContractLaunchApproveDto approveDto) { return R.success(contractLaunchService.mockApprove(id, approveDto)); } - /** - * 【测试按钮】模拟法务审批驳回。 - * - * 状态流转:reviewing → rejected(已驳回) - * 可在请求体中传入 approveMessage(驳回原因)。 - * 驳回后合同可再次编辑和提交。 - * - * 对应前端功能:测试按钮 → 模拟驳回 - */ @PostMapping("/{id}/mock-reject") public R mockReject(@PathVariable("id") Long id, @RequestBody(required = false) ContractLaunchApproveDto approveDto) { return R.success(contractLaunchService.mockReject(id, approveDto)); } - /** - * 获取法务审批回调样例。 - * - * 用途: - * 1. 前端展示与法务协商的固定 data 结构 - * 2. 本地联调时复制到 Postman,直接调用 callback 接口 - * - * 说明: - * - 样例中的水印文件路径当前先复用本地合同正文路径 - * - 后续法务真正实现文件传输后,只需要替换 watermarkFile.filePath 来源 - */ @GetMapping("/{id}/callback/law/approve/sample") public R getLawApproveCallbackSample(@PathVariable("id") Long id) { return R.success(contractLaunchService.buildLawApproveCallbackSample(id)); } - /** - * 法务审批结果回调入口(供法务系统调用)。 - * - * submitToLaw 时推给法务的 pushUrl 默认指向此接口:POST /contract/launch/callback/law/approve - * 法务审批完成后,会往 pushUrl POST 审批结果。 - * - * 当前约定请求体结构固定为: - * { - * "data": { - * "contractSysId": "...", - * "approveStatus": "approved|rejected", - * ... - * } - * } - * - * 这样前后端、法务、Postman 联调都统一只看 data 这一套结构, - * 不再做多字段兼容判断,便于后续正式协商定稿。 - * - * 状态流转:reviewing → pending_sign(通过) / rejected(驳回) - * - * 对应前端功能:法务系统审批完成后自动回调(目前用法务系统暂未对接) - */ @PostMapping("/callback/law/approve") public R lawApproveCallback(@RequestBody ContractLaunchLawCallbackRequestDto callbackRequest) { return R.success(contractLaunchService.handleLawApproveCallback(callbackRequest == null ? null : callbackRequest.getData())); } - /** - * 签署发起回调(浙江CA安全签调用)。 - * - * 在浙江CA安全签平台创建签署合同后,回写 sealContractId 到本系统合同。 - * 状态流转:pending_sign → signing(签署中) - * - * 请求体 ContractLaunchSealDto 字段: - * sealContractId - 签章平台生成的合同ID(必填) - * sealContractStatus - 签署状态(1:签署中) - * - * 对应前端功能:调起浙江CA签署后,回写签章合同ID - */ @PostMapping("/{id}/seal/save") public R saveSealInfo(@PathVariable("id") Long id, @RequestBody ContractLaunchSealDto sealDto) { return R.success(contractLaunchService.saveSealInfo(id, sealDto)); } - /** - * 签署完成回调(浙江CA安全签/线下签署完成后调用)。 - * - * 所有签署方完成签署后,将签署完成的合同文件信息回写。 - * 状态流转:signing → pending_collect(待采集) - * - * 请求体 ContractLaunchSealDto 字段: - * sealContractStatus - 签署状态(2:签署完成) - * signedFileName - 签署完成后的文件名 - * signedFilePath - 签署文件存储路径 - * signedFileType - 文件类型 - * signedFileSize - 文件大小 - * signedOfflineReason - 线下签署原因(如果未在线签署) - * - * 签署文件会自动保存到 file 子表(fileCategory = "signed"),先删旧签署文件再插入新的。 - * - * 对应前端功能:签署完成 → 回写签署结果 - */ @PostMapping("/{id}/seal/finish") public R finishSeal(@PathVariable("id") Long id, @RequestBody ContractLaunchSealDto sealDto) { return R.success(contractLaunchService.markSealCompleted(id, sealDto)); } - /** - * 【测试按钮】模拟合同采集。 - * - * 合同签署完成后,调用数据采集 V3.0 的 collectContract 接口将合同信息推送到法务系统存档。 - * 目前法务接口不可用,仅做状态变更 + 记录日志(TODO:联调时打开 dataCollectionPushService.collectContract 调用)。 - * - * 状态流转:pending_collect → pending_archive(待归档) - * - * 推送数据采集的报文包含:合同基本字段 + 用印信息(c_seal)+ 签订信息(c_sign)+ 附件等 - * - * 对应前端功能:测试按钮 → 模拟采集 - */ @PostMapping("/{id}/mock-collect") public R mockCollect(@PathVariable("id") Long id) { return R.success(contractLaunchService.mockCollect(id)); } - /** - * 保存采集信息(用印信息 + 签订信息)。 - * - * 签署完成后,在「合同采集」阶段(pending_collect), - * 用户通过采集弹窗补充/修改用印信息和签订信息后保存到合同记录。 - * 与 mock-collect 分开,允许用户在触发采集前先保存草稿。 - * - * 请求体 ContractLaunchCollectSaveDto 字段: - * sealIsSeal - 是否用印 - * sealApproveSame - 用印和审批文本是否一致 - * sealQuantity - 用印数量 - * sealDate - 用印时间 - * signDate - 签订日期 - * activeDate - 合同生效日期 - * executorAccount - 合同履行执行人 - * signedFileName - 签署文件名 - * signedFilePath - 签署文件路径 - * signedFileType - 签署文件类型 - * signedFileSize - 签署文件大小 - */ @PostMapping("/{id}/collect/save") public R saveCollectInfo(@PathVariable("id") Long id, - @RequestBody ContractLaunchCollectSaveDto collectDto) { + @RequestBody ContractLaunchCollectSaveDto collectDto) { return R.success(contractLaunchService.saveCollectInfo(id, collectDto)); } - /** - * 获取相对方签署确认状态。 - */ @GetMapping("/{id}/counterparty-confirm") public R> getCounterpartyConfirm(@PathVariable("id") Long id) { return R.success(contractLaunchService.getCounterpartyConfirmFiltered(id)); } - /** - * 相对方确认已签署完成。 - * - * 请求体:{"partyId": 123} - * 后端自动获取当前登录用户信息写入确认记录。 - */ @PostMapping("/{id}/counterparty-confirm") public R> confirmCounterparty(@PathVariable("id") Long id, - @RequestBody Map body) { + @RequestBody Map body) { String partyIdValue = body.get("partyId"); if (partyIdValue == null || partyIdValue.isBlank()) { return R.error("partyId 不能为空"); @@ -356,53 +154,51 @@ public class ContractLaunchController { } } - /** - * 上传法务水印合同文件。 - * - * 当法务审批回调未返回带水印的合同文件时,允许用户手动上传。 - * 上传后的文件路径替换 contractTextFilePath,供后续在线签订使用。 - * - * 请求方式:multipart/form-data - * 参数:file(MultipartFile) - * - * 对应前端功能:签署准备页面的"上传带水印合同"区域 - */ + @PostMapping("/{id}/seal-position/init") + public R initSealPosition(@PathVariable("id") Long id) { + return R.success(contractLaunchService.initSealPosition(id)); + } + + @GetMapping("/{id}/seal-position/list") + public R getSealPositionPage(@PathVariable("id") Long id) { + return R.success(contractLaunchService.getSealPositionPage(id)); + } + + @PostMapping("/{id}/seal-position/save") + public R saveSealPositions(@PathVariable("id") Long id, + @RequestBody(required = false) ContractLaunchSealPositionSaveDto saveDto) { + return R.success(contractLaunchService.saveSealPositions(id, saveDto == null ? new ContractLaunchSealPositionSaveDto() : saveDto)); + } + + @PostMapping("/{id}/seal-position/batch-status") + public R batchUpdateSealPositionStatus(@PathVariable("id") Long id, + @RequestBody ContractLaunchSealPositionBatchStatusDto batchDto) { + return R.success(contractLaunchService.batchUpdateSealPositionStatus(id, batchDto)); + } + + @PostMapping("/{id}/seal-position/check") + public R checkSealPositions(@PathVariable("id") Long id, + @RequestBody(required = false) Map body) { + Long partyId = null; + Object partyIdValue = body == null ? null : body.get("partyId"); + if (partyIdValue != null && !String.valueOf(partyIdValue).isBlank()) { + partyId = Long.valueOf(String.valueOf(partyIdValue)); + } + return R.success(contractLaunchService.checkSealPositions(id, partyId)); + } + @PostMapping("/{id}/watermark/upload") public R uploadWatermarkFile(@PathVariable("id") Long id, - @RequestParam("file") MultipartFile file) { + @RequestParam("file") MultipartFile file) { return R.success(contractLaunchService.uploadWatermarkFile(id, file)); } - /** - * 合同归档。 - * - * 合同全生命周期最后一步,状态变为 completed(已完成)。 - * 同时将归档信息按数据采集 V3.0 的合同归档接口推送到法务系统存档 - * (TODO:联调时打开 dataCollectionPushService.collectContractArchive 调用)。 - * - * 请求体 ContractLaunchArchiveDto 字段: - * archiveReceiveDept - 合同归档接收部门编码 - * archiveTransferor - 移交人账号 - * archivePapers - 纸质档案材料列表 - * archiveEles - 电子档案材料列表(含 approvalAttachs) - * - * 归档附件自动保存到 file 子表(fileCategory = "archive"), - * 纸质/电子档案明细分别保存到独立子表。 - * - * 对应前端功能:归档按钮 → 合同完结 - */ @PostMapping("/{id}/archive") public R archive(@PathVariable("id") Long id, @RequestBody(required = false) ContractLaunchArchiveDto archiveDto) { return R.success(contractLaunchService.archive(id, archiveDto)); } - /** - * 合同终止。 - * - * 允许除 completed 外的任意环节执行终止。 - * 当前仅在本系统内变更状态并记录日志,法务系统终止需后续分别操作。 - */ @PostMapping("/{id}/terminate") public R terminate(@PathVariable("id") Long id, @RequestBody(required = false) Map payload) { @@ -411,14 +207,6 @@ public class ContractLaunchController { return R.success(contractLaunchService.terminate(id, terminateReason)); } - /** - * 删除合同草稿(逻辑删除)。 - * - * 仅 draft(草稿)或 rejected(已驳回)状态允许删除。 - * 逻辑:设置 isDeleted = 'Y',不物理删除数据。 - * - * 对应前端功能:删除草稿按钮 - */ @DeleteMapping("/{id}") public R deleteDraft(@PathVariable("id") Long id) { contractLaunchService.deleteDraft(id); diff --git a/src/main/java/com/nbport/zgwl/contractlaunch/dto/ContractLaunchDto.java b/src/main/java/com/nbport/zgwl/contractlaunch/dto/ContractLaunchDto.java index b1eeb2e..7f86712 100644 --- a/src/main/java/com/nbport/zgwl/contractlaunch/dto/ContractLaunchDto.java +++ b/src/main/java/com/nbport/zgwl/contractlaunch/dto/ContractLaunchDto.java @@ -84,6 +84,12 @@ public class ContractLaunchDto implements Serializable { /** 签章平台合同ID(浙江CA安全签创建后返回) */ private String sealContractId; + /** 签章位是否已配置(Y/N)。 */ + private String sealPosConfigured; + + /** PDF 预览图片地址列表 JSON。 */ + private String sealPdfImageList; + /** 签章状态(1:签署中, 2:签署完成, 3:签署失败, null:未发起) */ private Integer sealContractStatus; @@ -402,4 +408,7 @@ public class ContractLaunchDto implements Serializable { /** 操作日志列表 */ private List logList = new ArrayList<>(); + + /** 当前合同的签章位列表。 */ + private List sealPositionList = new ArrayList<>(); } diff --git a/src/main/java/com/nbport/zgwl/contractlaunch/dto/ContractLaunchSealPositionBatchStatusDto.java b/src/main/java/com/nbport/zgwl/contractlaunch/dto/ContractLaunchSealPositionBatchStatusDto.java new file mode 100644 index 0000000..3f1be77 --- /dev/null +++ b/src/main/java/com/nbport/zgwl/contractlaunch/dto/ContractLaunchSealPositionBatchStatusDto.java @@ -0,0 +1,45 @@ +package com.nbport.zgwl.contractlaunch.dto; + +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; +import java.util.ArrayList; +import java.util.List; + +/** + * 批量更新签章位状态入参。 + */ +@Data +public class ContractLaunchSealPositionBatchStatusDto implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + /** 当前操作代表哪一方签。 */ + private Long partyId; + + /** 目标状态:0空框 1已选章 2待回调 3已签 4失败。 */ + private Integer signedStatus; + + private String requestId; + + private String signedByName; + + private String signedById; + + private List items = new ArrayList<>(); + + @Data + public static class Item implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + private Long id; + + private String sealId; + + private String sealName; + } +} diff --git a/src/main/java/com/nbport/zgwl/contractlaunch/dto/ContractLaunchSealPositionCheckResultDto.java b/src/main/java/com/nbport/zgwl/contractlaunch/dto/ContractLaunchSealPositionCheckResultDto.java new file mode 100644 index 0000000..23dddd2 --- /dev/null +++ b/src/main/java/com/nbport/zgwl/contractlaunch/dto/ContractLaunchSealPositionCheckResultDto.java @@ -0,0 +1,24 @@ +package com.nbport.zgwl.contractlaunch.dto; + +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; +import java.util.ArrayList; +import java.util.List; + +/** + * 签章位校验结果。 + */ +@Data +public class ContractLaunchSealPositionCheckResultDto implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + private boolean passed; + + private int missingCount; + + private List missingPositions = new ArrayList<>(); +} diff --git a/src/main/java/com/nbport/zgwl/contractlaunch/dto/ContractLaunchSealPositionDto.java b/src/main/java/com/nbport/zgwl/contractlaunch/dto/ContractLaunchSealPositionDto.java new file mode 100644 index 0000000..6528f9f --- /dev/null +++ b/src/main/java/com/nbport/zgwl/contractlaunch/dto/ContractLaunchSealPositionDto.java @@ -0,0 +1,73 @@ +package com.nbport.zgwl.contractlaunch.dto; + +import com.fasterxml.jackson.annotation.JsonFormat; +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; +import java.time.LocalDateTime; + +/** + * 合同签章位 DTO。 + * + * 一份合同可配置多个签章位, + * 每个签章位绑定到具体合同方(partyId)。 + */ +@Data +public class ContractLaunchSealPositionDto implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + private Long id; + + private Long contractId; + + private Long partyId; + + private String label; + + /** Y/N,是否必签。 */ + private String required; + + private Integer pageNum; + + private Double x; + + private Double y; + + private Double width; + + private Double height; + + private String sealId; + + private String sealName; + + /** 最近一次二维码请求ID。 */ + private String requestId; + + /** 0空框 1已选章 2待回调 3已签 4失败。 */ + private Integer signedStatus; + + private String signedByName; + + private String signedById; + + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") + private LocalDateTime signedTime; + + private String createBy; + + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") + private LocalDateTime createTime; + + private String updateBy; + + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") + private LocalDateTime updateTime; + + private String isDeleted; + + private String remark; +} diff --git a/src/main/java/com/nbport/zgwl/contractlaunch/dto/ContractLaunchSealPositionPageDto.java b/src/main/java/com/nbport/zgwl/contractlaunch/dto/ContractLaunchSealPositionPageDto.java new file mode 100644 index 0000000..38d87b6 --- /dev/null +++ b/src/main/java/com/nbport/zgwl/contractlaunch/dto/ContractLaunchSealPositionPageDto.java @@ -0,0 +1,42 @@ +package com.nbport.zgwl.contractlaunch.dto; + +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; +import java.util.ArrayList; +import java.util.List; + +/** + * 签章位页面返回对象。 + * + * 同时返回: + * 1. 签章平台合同ID + * 2. PDF 预览信息 + * 3. 当前合同的签章位列表 + * 4. 当前用户可代表签署的 partyId 列表 + */ +@Data +public class ContractLaunchSealPositionPageDto implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + private Long contractId; + + private String sealContractId; + + private String sealPosConfigured; + + private Integer imageWidth; + + private Integer imageHeight; + + private List pdfToImageList = new ArrayList<>(); + + private List positions = new ArrayList<>(); + + private List availablePartyIds = new ArrayList<>(); + + private Long preferredPartyId; +} diff --git a/src/main/java/com/nbport/zgwl/contractlaunch/dto/ContractLaunchSealPositionSaveDto.java b/src/main/java/com/nbport/zgwl/contractlaunch/dto/ContractLaunchSealPositionSaveDto.java new file mode 100644 index 0000000..934666f --- /dev/null +++ b/src/main/java/com/nbport/zgwl/contractlaunch/dto/ContractLaunchSealPositionSaveDto.java @@ -0,0 +1,24 @@ +package com.nbport.zgwl.contractlaunch.dto; + +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; +import java.util.ArrayList; +import java.util.List; + +/** + * 批量保存签章位入参。 + * + * 第一版采用替换式保存: + * 前端传当前整份合同的全部签章位, + * 后端先删后插,避免逐条 diff。 + */ +@Data +public class ContractLaunchSealPositionSaveDto implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + private List positions = new ArrayList<>(); +} diff --git a/src/main/java/com/nbport/zgwl/contractlaunch/mapper/ContractLaunchMapper.java b/src/main/java/com/nbport/zgwl/contractlaunch/mapper/ContractLaunchMapper.java index f72c5df..6328381 100644 --- a/src/main/java/com/nbport/zgwl/contractlaunch/mapper/ContractLaunchMapper.java +++ b/src/main/java/com/nbport/zgwl/contractlaunch/mapper/ContractLaunchMapper.java @@ -2,138 +2,221 @@ package com.nbport.zgwl.contractlaunch.mapper; import com.baomidou.mybatisplus.core.metadata.IPage; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; -import com.nbport.zgwl.contractlaunch.dto.ContractLaunchDto; import com.nbport.zgwl.contractlaunch.dto.ContractLaunchArchiveEleDto; import com.nbport.zgwl.contractlaunch.dto.ContractLaunchArchivePaperDto; +import com.nbport.zgwl.contractlaunch.dto.ContractLaunchDto; import com.nbport.zgwl.contractlaunch.dto.ContractLaunchFileDto; import com.nbport.zgwl.contractlaunch.dto.ContractLaunchLogDto; import com.nbport.zgwl.contractlaunch.dto.ContractLaunchPartyDto; import com.nbport.zgwl.contractlaunch.dto.ContractLaunchPlanDto; import com.nbport.zgwl.contractlaunch.dto.ContractLaunchQueryDto; +import com.nbport.zgwl.contractlaunch.dto.ContractLaunchSealPositionDto; +import org.apache.ibatis.annotations.Delete; +import org.apache.ibatis.annotations.Insert; import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Result; +import org.apache.ibatis.annotations.Results; +import org.apache.ibatis.annotations.ResultMap; +import org.apache.ibatis.annotations.Select; +import org.apache.ibatis.annotations.Update; import java.util.List; public interface ContractLaunchMapper { - /** 获取主表序列(Oracle 主键生成) */ Long selectMainSeqNextVal(); - /** 获取合同方表序列 */ Long selectPartySeqNextVal(); - /** 获取文件表序列 */ Long selectFileSeqNextVal(); - /** 获取履行计划表序列 */ Long selectPlanSeqNextVal(); - /** 获取日志表序列 */ Long selectLogSeqNextVal(); - /** 获取纸质归档表序列 */ Long selectArchivePaperSeqNextVal(); - /** 获取电子归档表序列 */ Long selectArchiveEleSeqNextVal(); - /** 分页查询合同列表 */ + @Select("select SEQ_SEAL_CONTRACT_LAUNCH_SEAL_POS.NEXTVAL from dual") + Long selectSealPositionSeqNextVal(); + IPage selectContractPage(Page page, @Param("query") ContractLaunchQueryDto queryDto); - /** 根据主键查询合同 */ ContractLaunchDto selectContractById(@Param("id") Long id); - /** 根据主键并带权限条件查询合同 */ ContractLaunchDto selectContractByIdWithScope(@Param("id") Long id, @Param("query") ContractLaunchQueryDto queryDto); - /** 根据 contractSysId 查询合同(法务回调时用于定位) */ ContractLaunchDto selectContractByContractSysId(@Param("contractSysId") String contractSysId); - /** 根据 sealContractId 查询合同(签章平台回调时用于定位) */ ContractLaunchDto selectContractBySealContractId(@Param("sealContractId") String sealContractId); - /** 新增合同 */ int insertContract(ContractLaunchDto contract); - /** 修改合同 */ int updateContract(ContractLaunchDto contract); - /** 逻辑删除合同 */ int deleteContract(@Param("id") Long id, @Param("updateBy") String updateBy); - /** 批量插入合同方 */ int batchInsertParty(@Param("list") List list, @Param("createBy") String createBy, @Param("updateBy") String updateBy); - /** 批量插入文件 */ int batchInsertFile(@Param("list") List list, @Param("createBy") String createBy, @Param("updateBy") String updateBy); - /** 批量插入履行计划 */ int batchInsertPlan(@Param("list") List list, @Param("createBy") String createBy, @Param("updateBy") String updateBy); - /** 批量插入纸质归档材料 */ int batchInsertArchivePaper(@Param("list") List list, @Param("createBy") String createBy, @Param("updateBy") String updateBy); - /** 批量插入电子归档材料 */ int batchInsertArchiveEle(@Param("list") List list, @Param("createBy") String createBy, @Param("updateBy") String updateBy); - /** 插入单条操作日志 */ int insertLog(ContractLaunchLogDto logDto); - /** 删除合同下的所有合同方(逻辑删除) */ int deletePartyByContractId(@Param("contractId") Long contractId, @Param("updateBy") String updateBy); - /** 删除合同下的所有文件(逻辑删除) */ int deleteFileByContractId(@Param("contractId") Long contractId, @Param("updateBy") String updateBy); - /** 按分类删除合同下的文件(如重新上传正文时只删正文,不删其他附件) */ int deleteFileByContractIdAndCategory(@Param("contractId") Long contractId, @Param("fileCategory") String fileCategory, @Param("updateBy") String updateBy); - /** 回写法务附件路径缓存 */ int updateFileLegalUrl(@Param("id") Long id, @Param("legalFileUrl") String legalFileUrl, @Param("updateBy") String updateBy); - /** 删除合同下的所有履行计划(逻辑删除) */ int deletePlanByContractId(@Param("contractId") Long contractId, @Param("updateBy") String updateBy); - /** 删除合同下的纸质归档材料(逻辑删除) */ int deleteArchivePaperByContractId(@Param("contractId") Long contractId, @Param("updateBy") String updateBy); - /** 删除合同下的电子归档材料(逻辑删除) */ int deleteArchiveEleByContractId(@Param("contractId") Long contractId, @Param("updateBy") String updateBy); - /** 批量查询合同方(按 contractIds) */ + @Delete(""" + delete from SEAL_CONTRACT_LAUNCH_SEAL_POS + where CONTRACT_ID = #{contractId} + """) + int deleteSealPositionByContractId(@Param("contractId") Long contractId, + @Param("updateBy") String updateBy); + + @Insert(""" + + """) + int batchInsertSealPosition(@Param("list") List list, + @Param("createBy") String createBy, + @Param("updateBy") String updateBy); + + @Select(""" + select ID, CONTRACT_ID, PARTY_ID, LABEL, REQUIRED, PAGE_NUM, X, Y, WIDTH, HEIGHT, + SEAL_ID, SEAL_NAME, REQUEST_ID, SIGNED_STATUS, SIGNED_BY_NAME, SIGNED_BY_ID, SIGNED_TIME, + CREATE_BY, CREATE_TIME, UPDATE_BY, UPDATE_TIME, IS_DELETED, REMARK + from SEAL_CONTRACT_LAUNCH_SEAL_POS + where CONTRACT_ID = #{contractId} + and IS_DELETED = 'N' + order by PAGE_NUM asc, ID asc + """) + @Results(id = "sealPositionResult", value = { + @Result(property = "id", column = "ID", id = true), + @Result(property = "contractId", column = "CONTRACT_ID"), + @Result(property = "partyId", column = "PARTY_ID"), + @Result(property = "label", column = "LABEL"), + @Result(property = "required", column = "REQUIRED"), + @Result(property = "pageNum", column = "PAGE_NUM"), + @Result(property = "x", column = "X"), + @Result(property = "y", column = "Y"), + @Result(property = "width", column = "WIDTH"), + @Result(property = "height", column = "HEIGHT"), + @Result(property = "sealId", column = "SEAL_ID"), + @Result(property = "sealName", column = "SEAL_NAME"), + @Result(property = "requestId", column = "REQUEST_ID"), + @Result(property = "signedStatus", column = "SIGNED_STATUS"), + @Result(property = "signedByName", column = "SIGNED_BY_NAME"), + @Result(property = "signedById", column = "SIGNED_BY_ID"), + @Result(property = "signedTime", column = "SIGNED_TIME"), + @Result(property = "createBy", column = "CREATE_BY"), + @Result(property = "createTime", column = "CREATE_TIME"), + @Result(property = "updateBy", column = "UPDATE_BY"), + @Result(property = "updateTime", column = "UPDATE_TIME"), + @Result(property = "isDeleted", column = "IS_DELETED"), + @Result(property = "remark", column = "REMARK") + }) + List selectSealPositionByContractId(@Param("contractId") Long contractId); + + @Select(""" + select ID, CONTRACT_ID, PARTY_ID, LABEL, REQUIRED, PAGE_NUM, X, Y, WIDTH, HEIGHT, + SEAL_ID, SEAL_NAME, REQUEST_ID, SIGNED_STATUS, SIGNED_BY_NAME, SIGNED_BY_ID, SIGNED_TIME, + CREATE_BY, CREATE_TIME, UPDATE_BY, UPDATE_TIME, IS_DELETED, REMARK + from SEAL_CONTRACT_LAUNCH_SEAL_POS + where REQUEST_ID = #{requestId} + and IS_DELETED = 'N' + order by ID asc + """) + @ResultMap("sealPositionResult") + List selectSealPositionByRequestId(@Param("requestId") String requestId); + + @Update(""" + + """) + int updateSealPosition(ContractLaunchSealPositionDto position); + List selectPartyByContractIds(@Param("contractIds") List contractIds); - /** 批量查询文件(按 contractIds) */ List selectFileByContractIds(@Param("contractIds") List contractIds); - /** 批量查询履行计划(按 contractIds) */ List selectPlanByContractIds(@Param("contractIds") List contractIds); - /** 批量查询纸质归档材料(按 contractIds) */ List selectArchivePaperByContractIds(@Param("contractIds") List contractIds); - /** 批量查询电子归档材料(按 contractIds) */ List selectArchiveEleByContractIds(@Param("contractIds") List contractIds); - /** 批量查询操作日志(按 contractIds) */ List selectLogByContractIds(@Param("contractIds") List contractIds); } diff --git a/src/main/java/com/nbport/zgwl/contractlaunch/service/ContractLaunchSealPositionSupport.java b/src/main/java/com/nbport/zgwl/contractlaunch/service/ContractLaunchSealPositionSupport.java new file mode 100644 index 0000000..b0bb291 --- /dev/null +++ b/src/main/java/com/nbport/zgwl/contractlaunch/service/ContractLaunchSealPositionSupport.java @@ -0,0 +1,604 @@ +package com.nbport.zgwl.contractlaunch.service; + +import com.alibaba.fastjson2.JSON; +import com.alibaba.fastjson2.JSONArray; +import com.alibaba.fastjson2.JSONObject; +import com.nbport.zgwl.auth.AuthManager; +import com.nbport.zgwl.auth.SecurityManager; +import com.nbport.zgwl.auth.SysEnum; +import com.nbport.zgwl.contractlaunch.dto.ContractLaunchDto; +import com.nbport.zgwl.contractlaunch.dto.ContractLaunchPartyDto; +import com.nbport.zgwl.contractlaunch.dto.ContractLaunchSealPositionBatchStatusDto; +import com.nbport.zgwl.contractlaunch.dto.ContractLaunchSealPositionDto; +import com.nbport.zgwl.contractlaunch.dto.ContractLaunchSealPositionPageDto; +import com.nbport.zgwl.contractlaunch.mapper.ContractLaunchMapper; +import com.nbport.zgwl.contractlaunch.utils.ContractLaunchConstants; +import com.nbport.zgwl.entity.SysUserEntity; +import com.nbport.zgwl.exception.ServiceException; +import com.nbport.zgwl.partner.dto.PartnerManageDto; +import com.nbport.zgwl.partner.service.PartnerManageService; +import com.nbport.zgwl.seal.config.SealConfig; +import com.nbport.zgwl.seal.service.SealTokenService; +import com.nbport.zgwl.seal.utils.SealHttpClient; +import com.nbport.zgwl.service.LocalFileService; +import com.nbport.zgwl.utils.AdminSecurityManage; +import org.springframework.web.multipart.MultipartFile; + +import java.io.ByteArrayInputStream; +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.lang.reflect.Field; +import java.nio.charset.StandardCharsets; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.stream.Collectors; + +/** + * 签章位相关的辅助能力。 + * + * 这部分逻辑后补到现有 Service 时,尽量不去拆散原来的大类, + * 因此单独抽一个父类承接: + * 1. 签章平台预览初始化 + * 2. 签章位坐标校验与状态流转 + * 3. 当前用户可代表的签署方识别 + */ +abstract class ContractLaunchSealPositionSupport { + + protected static final int DEFAULT_IMAGE_WIDTH = 793; + protected static final int DEFAULT_IMAGE_HEIGHT = 1122; + + protected void assertSealPositionConfigAccess(ContractLaunchDto contract) { + if (contract == null || contract.getId() == null) { + throw new ServiceException("合同不存在"); + } + if (!"1".equals(defaultIfBlank(contract.getIsElectron(), "1"))) { + throw new ServiceException("仅电子合同支持配置签章位"); + } + SysUserEntity currentUser = adminSecurityManage().getUser(); + if (currentUser == null) { + throw new ServiceException("当前用户未登录"); + } + if (securityManager().isSuperAdmin()) { + return; + } + if (!authManager().isUserCanAccessSys(currentUser.getId(), SysEnum.MANAGE)) { + throw new ServiceException("仅管理端用户可配置签章位"); + } + } + + protected void assertSealPositionViewAccess(ContractLaunchDto contract) { + if (contract == null || contract.getId() == null) { + throw new ServiceException("合同不存在"); + } + if (!"1".equals(defaultIfBlank(contract.getIsElectron(), "1"))) { + throw new ServiceException("仅电子合同支持在线签署"); + } + SysUserEntity currentUser = adminSecurityManage().getUser(); + if (currentUser == null) { + throw new ServiceException("当前用户未登录"); + } + if (securityManager().isSuperAdmin()) { + return; + } + boolean canManage = authManager().isUserCanAccessSys(currentUser.getId(), SysEnum.MANAGE); + boolean canBusiness = authManager().isUserCanAccessSys(currentUser.getId(), SysEnum.BUSINESS); + if (!canManage && !canBusiness) { + throw new ServiceException("当前用户无权访问在线签署"); + } + } + + protected SealPreviewData startSealPreview(ContractLaunchDto contract) { + if (contract == null) { + throw new ServiceException("合同不存在"); + } + String filePath = trim(contract.getContractTextFilePath()); + if (isBlank(filePath)) { + throw new ServiceException("请先上传水印文件"); + } + byte[] fileBytes = localFileService().readFileBytes(filePath); + String fileName = defaultIfBlank(trim(contract.getContractTextFileName()), defaultIfBlank(trim(contract.getContractName()), "合同正文") + ".pdf"); + String contentType = resolveContentType(fileName, contract.getContractTextFileType()); + MultipartFile multipartFile = new SimpleMultipartFile(fileBytes, fileName, contentType); + String token = sealTokenService().getAccessToken(); + String apiUrl = sealConfig().getBaseUrl() + "/saas/api/contract/start"; + String response = SealHttpClient.postMultipart(apiUrl, multipartFile, baseFileName(contract.getContractName(), fileName), "seal-position-init", token); + JSONObject result = JSON.parseObject(response); + if (result == null || !result.getBooleanValue("isSuccess")) { + throw new ServiceException(result == null ? "初始化签署文件失败" : defaultIfBlank(result.getString("msg"), "初始化签署文件失败")); + } + SealPreviewData previewData = parsePreviewData(result.get("data")); + if (isBlank(previewData.contractId())) { + throw new ServiceException("签章平台未返回合同ID"); + } + return previewData; + } + + protected SealPreviewData previewSealContract(String sealContractId, String cachedImageListJson) { + List cachedImageList = parseCachedImageList(cachedImageListJson); + if (isBlank(sealContractId)) { + return new SealPreviewData(null, DEFAULT_IMAGE_WIDTH, DEFAULT_IMAGE_HEIGHT, cachedImageList); + } + try { + JSONObject body = new JSONObject(); + body.put("contractId", sealContractId); + String response = SealHttpClient.post( + sealConfig().getBaseUrl() + "/saas/api/contract/preview", + body.toJSONString(), + sealTokenService().getAccessToken(), + "application/json" + ); + JSONObject result = JSON.parseObject(response); + if (result != null && result.getBooleanValue("isSuccess")) { + SealPreviewData previewData = parsePreviewData(result.get("data")); + if (!previewData.pdfToImageList().isEmpty()) { + return previewData; + } + } + } catch (Exception ex) { + if (cachedImageList.isEmpty()) { + throw new ServiceException("获取签章预览失败: " + ex.getMessage()); + } + } + return new SealPreviewData(sealContractId, DEFAULT_IMAGE_WIDTH, DEFAULT_IMAGE_HEIGHT, cachedImageList); + } + + protected ContractLaunchSealPositionPageDto buildSealPositionPage(ContractLaunchDto contract, SealPreviewData previewData) { + ContractLaunchSealPositionPageDto pageDto = new ContractLaunchSealPositionPageDto(); + pageDto.setContractId(contract.getId()); + pageDto.setSealContractId(defaultIfBlank(contract.getSealContractId(), previewData == null ? null : previewData.contractId())); + pageDto.setSealPosConfigured(defaultIfBlank(contract.getSealPosConfigured(), ContractLaunchConstants.FLAG_NO)); + pageDto.setImageWidth(previewData == null ? DEFAULT_IMAGE_WIDTH : previewData.imageWidth()); + pageDto.setImageHeight(previewData == null ? DEFAULT_IMAGE_HEIGHT : previewData.imageHeight()); + pageDto.setPdfToImageList(previewData == null ? new ArrayList<>() : new ArrayList<>(previewData.pdfToImageList())); + pageDto.setPositions(new ArrayList<>(contractLaunchMapper().selectSealPositionByContractId(contract.getId()))); + List availablePartyIds = resolveAvailablePartyIds(contract, adminSecurityManage().getUser()); + pageDto.setAvailablePartyIds(availablePartyIds); + pageDto.setPreferredPartyId(availablePartyIds.size() == 1 ? availablePartyIds.get(0) : null); + return pageDto; + } + + protected void validateSealPositionForSave(ContractLaunchSealPositionDto item, Map partyMap) { + if (item == null) { + throw new ServiceException("签章位数据不能为空"); + } + if (item.getPartyId() == null) { + throw new ServiceException("签章位必须绑定签署方"); + } + if (partyMap == null || !partyMap.containsKey(item.getPartyId())) { + throw new ServiceException("签章位绑定的签署方不存在"); + } + if (isBlank(item.getLabel())) { + throw new ServiceException("签章位标签不能为空"); + } + if (item.getPageNum() == null || item.getPageNum() <= 0) { + throw new ServiceException("签章位页码必须大于0"); + } + if (item.getX() == null || item.getY() == null || item.getWidth() == null || item.getHeight() == null) { + throw new ServiceException("签章位坐标不能为空"); + } + if (item.getWidth() <= 0 || item.getHeight() <= 0) { + throw new ServiceException("签章位宽高必须大于0"); + } + } + + protected String normalizeRequiredFlag(String required) { + String value = trim(required); + if (value == null) { + return ContractLaunchConstants.FLAG_YES; + } + String normalized = value.toUpperCase(Locale.ROOT); + if ("N".equals(normalized) || "0".equals(normalized) || "FALSE".equals(normalized)) { + return ContractLaunchConstants.FLAG_NO; + } + return ContractLaunchConstants.FLAG_YES; + } + + protected Double roundCoordinate(Double value) { + if (value == null) { + return null; + } + return Math.round(value * 100D) / 100D; + } + + protected List resolveAvailablePartyIds(ContractLaunchDto contract, SysUserEntity currentUser) { + if (contract == null || contract.getPartyList() == null || contract.getPartyList().isEmpty() || currentUser == null) { + return new ArrayList<>(); + } + if (securityManager().isSuperAdmin()) { + return contract.getPartyList().stream() + .map(ContractLaunchPartyDto::getId) + .filter(Objects::nonNull) + .collect(Collectors.toList()); + } + boolean canManage = authManager().isUserCanAccessSys(currentUser.getId(), SysEnum.MANAGE); + boolean canBusiness = authManager().isUserCanAccessSys(currentUser.getId(), SysEnum.BUSINESS); + String currentOrgId = currentUser.getSysOrgEntity() == null ? null : trim(currentUser.getSysOrgEntity().getId()); + String currentAccount = trim(currentUser.getUsername()); + String currentMobile = trim(currentUser.getMobilePhone()); + Set partyIds = new LinkedHashSet<>(); + for (ContractLaunchPartyDto party : contract.getPartyList()) { + if (party == null || party.getId() == null) { + continue; + } + if (canManage && isOurParty(party) && Objects.equals(currentOrgId, trim(party.getSubjectCode()))) { + partyIds.add(party.getId()); + continue; + } + if (canBusiness && isOppositeParty(party) && isBusinessPartyMatch(party, currentOrgId, currentAccount, currentMobile)) { + partyIds.add(party.getId()); + } + } + return new ArrayList<>(partyIds); + } + + protected Long resolveTargetPartyId(Long partyId, List availablePartyIds) { + if (partyId != null) { + if (availablePartyIds != null && !availablePartyIds.isEmpty() && !availablePartyIds.contains(partyId)) { + throw new ServiceException("当前用户不可操作该签署方"); + } + return partyId; + } + if (availablePartyIds == null || availablePartyIds.isEmpty()) { + throw new ServiceException("当前用户未匹配到可签署方,请选择后重试"); + } + if (availablePartyIds.size() > 1) { + throw new ServiceException("当前用户匹配到多个签署方,请选择后重试"); + } + return availablePartyIds.get(0); + } + + protected void applySealPositionStatus(ContractLaunchSealPositionDto position, + ContractLaunchSealPositionBatchStatusDto batchDto, + ContractLaunchSealPositionBatchStatusDto.Item item, + SysUserEntity currentUser, + LocalDateTime now) { + Integer signedStatus = batchDto.getSignedStatus(); + if (signedStatus == null) { + throw new ServiceException("签章位状态不能为空"); + } + position.setUpdateBy(currentUser == null ? "system" : currentUser.getUsername()); + position.setUpdateTime(now); + if (signedStatus == ContractLaunchConstants.SEAL_POS_STATUS_EMPTY) { + position.setSealId(null); + position.setSealName(null); + position.setRequestId(null); + position.setSignedByName(null); + position.setSignedById(null); + position.setSignedTime(null); + position.setSignedStatus(ContractLaunchConstants.SEAL_POS_STATUS_EMPTY); + return; + } + if (signedStatus == ContractLaunchConstants.SEAL_POS_STATUS_SELECTED) { + if (item == null || isBlank(item.getSealId())) { + throw new ServiceException("已选章状态必须传印章ID"); + } + position.setSealId(item.getSealId()); + position.setSealName(item.getSealName()); + position.setRequestId(null); + position.setSignedByName(null); + position.setSignedById(null); + position.setSignedTime(null); + position.setSignedStatus(ContractLaunchConstants.SEAL_POS_STATUS_SELECTED); + return; + } + if (signedStatus == ContractLaunchConstants.SEAL_POS_STATUS_SIGNING) { + String sealId = item == null ? null : item.getSealId(); + if (!isBlank(sealId)) { + position.setSealId(sealId); + } + if (item != null && !isBlank(item.getSealName())) { + position.setSealName(item.getSealName()); + } + if (isBlank(position.getSealId())) { + throw new ServiceException("提交预盖章前请先选择印章"); + } + if (isBlank(batchDto.getRequestId())) { + throw new ServiceException("二维码请求ID不能为空"); + } + position.setRequestId(batchDto.getRequestId()); + position.setSignedByName(null); + position.setSignedById(null); + position.setSignedTime(null); + position.setSignedStatus(ContractLaunchConstants.SEAL_POS_STATUS_SIGNING); + return; + } + if (signedStatus == ContractLaunchConstants.SEAL_POS_STATUS_SIGNED) { + if (item != null && !isBlank(item.getSealId())) { + position.setSealId(item.getSealId()); + } + if (item != null && !isBlank(item.getSealName())) { + position.setSealName(item.getSealName()); + } + if (!isBlank(batchDto.getRequestId())) { + position.setRequestId(batchDto.getRequestId()); + } + position.setSignedByName(defaultIfBlank(batchDto.getSignedByName(), currentUser == null ? null : currentUser.getUsername())); + position.setSignedById(defaultIfBlank(batchDto.getSignedById(), currentUser == null ? null : String.valueOf(currentUser.getId()))); + position.setSignedTime(now); + position.setSignedStatus(ContractLaunchConstants.SEAL_POS_STATUS_SIGNED); + return; + } + if (signedStatus == ContractLaunchConstants.SEAL_POS_STATUS_FAILED) { + position.setRequestId(null); + position.setSignedByName(null); + position.setSignedById(null); + position.setSignedTime(null); + position.setSignedStatus(ContractLaunchConstants.SEAL_POS_STATUS_FAILED); + return; + } + throw new ServiceException("不支持的签章位状态: " + signedStatus); + } + + protected boolean isSealPresignFailed(Map callbackData) { + String contractStatus = stringValue(callbackData == null ? null : callbackData.get("contractStatus")); + if (!isBlank(contractStatus)) { + if ("20".equals(contractStatus) || "2000".equals(contractStatus)) { + return false; + } + if ("0".equals(contractStatus) || contractStatus.startsWith("-")) { + return true; + } + } + Object successValue = callbackData == null ? null : (callbackData.containsKey("isSuccess") ? callbackData.get("isSuccess") : callbackData.get("success")); + if (successValue != null) { + return !Boolean.parseBoolean(String.valueOf(successValue)); + } + String statusText = (stringValue(callbackData == null ? null : callbackData.get("status")) + " " + + stringValue(callbackData == null ? null : callbackData.get("msg"))).toLowerCase(Locale.ROOT); + return statusText.contains("fail") || statusText.contains("cancel") || statusText.contains("timeout") || statusText.contains("reject"); + } + + protected String stringValue(Object value) { + return value == null ? null : trim(String.valueOf(value)); + } + + protected boolean isBlank(String value) { + return value == null || value.isBlank(); + } + + protected String defaultIfBlank(String value, String defaultValue) { + return isBlank(value) ? defaultValue : value; + } + + protected String trim(String value) { + return value == null ? null : value.trim(); + } + + protected boolean isOurParty(ContractLaunchPartyDto party) { + return party != null && "OUR".equalsIgnoreCase(trim(party.getRoleCode())); + } + + protected boolean isOppositeParty(ContractLaunchPartyDto party) { + return party != null && "OPPOSITE".equalsIgnoreCase(trim(party.getRoleCode())); + } + + protected boolean isSingleAgreement(ContractLaunchDto contract) { + return contract != null && "1".equals(defaultIfBlank(contract.getIsSingleAgreement(), "0")); + } + + private boolean isBusinessPartyMatch(ContractLaunchPartyDto party, String currentOrgId, String currentAccount, String currentMobile) { + if (Objects.equals(currentOrgId, trim(party.getSubjectCode()))) { + return true; + } + if (party.getPartnerId() == null) { + return false; + } + try { + PartnerManageDto partner = partnerManageService().selectPartnerById(party.getPartnerId()); + if (partner == null) { + return false; + } + if (partner.getLinkedOrgId() != null && Objects.equals(currentOrgId, String.valueOf(partner.getLinkedOrgId()))) { + return true; + } + String linkedUserAccount = trim(partner.getLinkedUserAccount()); + return !isBlank(linkedUserAccount) && (linkedUserAccount.equalsIgnoreCase(defaultIfBlank(currentAccount, "")) + || linkedUserAccount.equalsIgnoreCase(defaultIfBlank(currentMobile, ""))); + } catch (Exception ignored) { + return false; + } + } + + private SealPreviewData parsePreviewData(Object dataObject) { + if (dataObject == null) { + return new SealPreviewData(null, DEFAULT_IMAGE_WIDTH, DEFAULT_IMAGE_HEIGHT, new ArrayList<>()); + } + JSONObject data = dataObject instanceof JSONObject + ? (JSONObject) dataObject + : JSON.parseObject(JSON.toJSONString(dataObject)); + if (data == null) { + return new SealPreviewData(null, DEFAULT_IMAGE_WIDTH, DEFAULT_IMAGE_HEIGHT, new ArrayList<>()); + } + List imageList = new ArrayList<>(); + Object imageObject = data.get("pdfToImageList"); + if (imageObject instanceof JSONArray array) { + for (int i = 0; i < array.size(); i++) { + String item = stringValue(array.get(i)); + if (!isBlank(item)) { + imageList.add(item); + } + } + } else if (imageObject instanceof List list) { + for (Object item : list) { + String imageUrl = stringValue(item); + if (!isBlank(imageUrl)) { + imageList.add(imageUrl); + } + } + } else if (imageObject != null) { + imageList.addAll(parseCachedImageList(String.valueOf(imageObject))); + } + Integer imageWidth = data.getInteger("imageWidth"); + Integer imageHeight = data.getInteger("imageHeight"); + return new SealPreviewData( + stringValue(data.get("contractId")), + imageWidth == null || imageWidth <= 0 ? DEFAULT_IMAGE_WIDTH : imageWidth, + imageHeight == null || imageHeight <= 0 ? DEFAULT_IMAGE_HEIGHT : imageHeight, + imageList + ); + } + + private List parseCachedImageList(String json) { + if (isBlank(json)) { + return new ArrayList<>(); + } + try { + JSONArray array = JSON.parseArray(json); + List imageList = new ArrayList<>(); + for (int i = 0; i < array.size(); i++) { + String item = stringValue(array.get(i)); + if (!isBlank(item)) { + imageList.add(item); + } + } + return imageList; + } catch (Exception ex) { + return new ArrayList<>(); + } + } + + private String baseFileName(String contractName, String fileName) { + String value = defaultIfBlank(trim(contractName), trim(fileName)); + if (isBlank(value)) { + return "合同正文"; + } + int index = value.lastIndexOf('.'); + return index > 0 ? value.substring(0, index) : value; + } + + private String resolveContentType(String fileName, String fileType) { + String suffix = trim(fileType); + if (isBlank(suffix) && !isBlank(fileName) && fileName.contains(".")) { + suffix = fileName.substring(fileName.lastIndexOf('.') + 1); + } + suffix = defaultIfBlank(suffix, "pdf").toLowerCase(Locale.ROOT); + return switch (suffix) { + case "doc" -> "application/msword"; + case "docx" -> "application/vnd.openxmlformats-officedocument.wordprocessingml.document"; + case "pdf" -> "application/pdf"; + default -> "application/octet-stream"; + }; + } + + protected ContractLaunchMapper contractLaunchMapper() { + return readField("contractLaunchMapper", ContractLaunchMapper.class); + } + + protected AdminSecurityManage adminSecurityManage() { + return readField("adminSecurityManage", AdminSecurityManage.class); + } + + protected AuthManager authManager() { + return readField("authManager", AuthManager.class); + } + + protected SecurityManager securityManager() { + return readField("securityManager", SecurityManager.class); + } + + protected LocalFileService localFileService() { + return readField("localFileService", LocalFileService.class); + } + + protected SealTokenService sealTokenService() { + return readField("sealTokenService", SealTokenService.class); + } + + protected SealConfig sealConfig() { + return readField("sealConfig", SealConfig.class); + } + + protected PartnerManageService partnerManageService() { + return readField("partnerManageService", PartnerManageService.class); + } + + protected T readField(String fieldName, Class type) { + try { + Field field = findField(getClass(), fieldName); + field.setAccessible(true); + Object value = field.get(this); + return type.cast(value); + } catch (Exception ex) { + throw new IllegalStateException("读取字段失败: " + fieldName, ex); + } + } + + private Field findField(Class clazz, String fieldName) throws NoSuchFieldException { + Class current = clazz; + while (current != null) { + try { + return current.getDeclaredField(fieldName); + } catch (NoSuchFieldException ignored) { + current = current.getSuperclass(); + } + } + throw new NoSuchFieldException(fieldName); + } + + protected record SealPreviewData(String contractId, Integer imageWidth, Integer imageHeight, List pdfToImageList) { + } + + private static class SimpleMultipartFile implements MultipartFile { + + private final byte[] content; + private final String originalFilename; + private final String contentType; + + private SimpleMultipartFile(byte[] content, String originalFilename, String contentType) { + this.content = content == null ? new byte[0] : content; + this.originalFilename = originalFilename; + this.contentType = contentType; + } + + @Override + public String getName() { + return "file"; + } + + @Override + public String getOriginalFilename() { + return originalFilename; + } + + @Override + public String getContentType() { + return contentType; + } + + @Override + public boolean isEmpty() { + return content.length == 0; + } + + @Override + public long getSize() { + return content.length; + } + + @Override + public byte[] getBytes() { + return content; + } + + @Override + public InputStream getInputStream() { + return new ByteArrayInputStream(content); + } + + @Override + public void transferTo(File dest) throws IOException { + try (FileOutputStream outputStream = new FileOutputStream(dest)) { + outputStream.write(content); + } + } + } +} diff --git a/src/main/java/com/nbport/zgwl/contractlaunch/service/ContractLaunchService.java b/src/main/java/com/nbport/zgwl/contractlaunch/service/ContractLaunchService.java index fd4658e..b8f81ea 100644 --- a/src/main/java/com/nbport/zgwl/contractlaunch/service/ContractLaunchService.java +++ b/src/main/java/com/nbport/zgwl/contractlaunch/service/ContractLaunchService.java @@ -1,5 +1,11 @@ package com.nbport.zgwl.contractlaunch.service; +import com.alibaba.fastjson2.JSON; +import com.alibaba.fastjson2.JSONArray; +import com.alibaba.fastjson2.JSONObject; +import com.nbport.zgwl.auth.AuthManager; +import com.nbport.zgwl.auth.SecurityManager; +import com.nbport.zgwl.auth.SysEnum; import com.nbport.zgwl.common.PageResult; import com.nbport.zgwl.contractlaunch.dto.ContractLaunchApproveDto; import com.nbport.zgwl.contractlaunch.dto.ContractLaunchArchiveDto; @@ -7,11 +13,41 @@ import com.nbport.zgwl.contractlaunch.dto.ContractLaunchCollectSaveDto; import com.nbport.zgwl.contractlaunch.dto.ContractLaunchDto; import com.nbport.zgwl.contractlaunch.dto.ContractLaunchLawCallbackDto; import com.nbport.zgwl.contractlaunch.dto.ContractLaunchLawCallbackSampleDto; +import com.nbport.zgwl.contractlaunch.dto.ContractLaunchPartyDto; import com.nbport.zgwl.contractlaunch.dto.ContractLaunchQueryDto; import com.nbport.zgwl.contractlaunch.dto.ContractLaunchSealDto; +import com.nbport.zgwl.contractlaunch.dto.ContractLaunchSealPositionBatchStatusDto; +import com.nbport.zgwl.contractlaunch.dto.ContractLaunchSealPositionCheckResultDto; +import com.nbport.zgwl.contractlaunch.dto.ContractLaunchSealPositionDto; +import com.nbport.zgwl.contractlaunch.dto.ContractLaunchSealPositionPageDto; +import com.nbport.zgwl.contractlaunch.dto.ContractLaunchSealPositionSaveDto; +import com.nbport.zgwl.contractlaunch.mapper.ContractLaunchMapper; +import com.nbport.zgwl.contractlaunch.utils.ContractLaunchConstants; +import com.nbport.zgwl.entity.SysUserEntity; +import com.nbport.zgwl.exception.ServiceException; +import com.nbport.zgwl.partner.dto.PartnerManageDto; +import com.nbport.zgwl.partner.service.PartnerManageService; +import com.nbport.zgwl.seal.config.SealConfig; +import com.nbport.zgwl.seal.service.SealTokenService; +import com.nbport.zgwl.seal.utils.SealHttpClient; +import com.nbport.zgwl.service.LocalFileService; +import com.nbport.zgwl.utils.AdminSecurityManage; import org.springframework.web.multipart.MultipartFile; +import java.io.ByteArrayInputStream; +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.lang.reflect.Field; +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.stream.Collectors; public interface ContractLaunchService { @@ -66,6 +102,24 @@ public interface ContractLaunchService { /** 相对方确认已签署完成 */ Map confirmCounterparty(Long id, Long partyId); + /** 初始化合同签章位配置所需的签章平台预览 */ + ContractLaunchSealPositionPageDto initSealPosition(Long id); + + /** 查询合同签章位与预览信息 */ + ContractLaunchSealPositionPageDto getSealPositionPage(Long id); + + /** 保存合同签章位配置 */ + ContractLaunchSealPositionPageDto saveSealPositions(Long id, ContractLaunchSealPositionSaveDto saveDto); + + /** 批量更新签章位状态 */ + ContractLaunchSealPositionPageDto batchUpdateSealPositionStatus(Long id, ContractLaunchSealPositionBatchStatusDto batchDto); + + /** 校验签章位是否已全部签署 */ + ContractLaunchSealPositionCheckResultDto checkSealPositions(Long id, Long partyId); + + /** 处理签章平台预盖章回调 */ + void handleSealPresignCallback(Map callbackData); + /** 上传法务水印合同文件(当审批回调未带回水印文件时,手动上传代替) */ ContractLaunchDto uploadWatermarkFile(Long id, MultipartFile file); @@ -80,4 +134,544 @@ public interface ContractLaunchService { /** 删除草稿(仅 draft/rejected 状态允许) */ void deleteDraft(Long id); + + default void assertSealPositionConfigAccess(ContractLaunchDto contract) { + if (contract == null || contract.getId() == null) { + throw new ServiceException("合同不存在"); + } + if (!"1".equals(defaultIfBlank(contract.getIsElectron(), "1"))) { + throw new ServiceException("仅电子合同支持配置签章位"); + } + SysUserEntity currentUser = adminSecurityManage().getUser(); + if (currentUser == null) { + throw new ServiceException("当前用户未登录"); + } + if (securityManager().isSuperAdmin()) { + return; + } + if (!authManager().isUserCanAccessSys(currentUser.getId(), SysEnum.MANAGE)) { + throw new ServiceException("仅管理端用户可配置签章位"); + } + } + + default void assertSealPositionViewAccess(ContractLaunchDto contract) { + if (contract == null || contract.getId() == null) { + throw new ServiceException("合同不存在"); + } + if (!"1".equals(defaultIfBlank(contract.getIsElectron(), "1"))) { + throw new ServiceException("仅电子合同支持在线签署"); + } + SysUserEntity currentUser = adminSecurityManage().getUser(); + if (currentUser == null) { + throw new ServiceException("当前用户未登录"); + } + if (securityManager().isSuperAdmin()) { + return; + } + boolean canManage = authManager().isUserCanAccessSys(currentUser.getId(), SysEnum.MANAGE); + boolean canBusiness = authManager().isUserCanAccessSys(currentUser.getId(), SysEnum.BUSINESS); + if (!canManage && !canBusiness) { + throw new ServiceException("当前用户无权访问在线签署"); + } + } + + default SealPreviewData startSealPreview(ContractLaunchDto contract) { + String filePath = trimText(contract == null ? null : contract.getContractTextFilePath()); + if (isBlank(filePath)) { + throw new ServiceException("请先上传水印文件"); + } + byte[] fileBytes = localFileService().readFileBytes(filePath); + String fileName = defaultIfBlank(trimText(contract.getContractTextFileName()), defaultIfBlank(trimText(contract.getContractName()), "合同正文") + ".pdf"); + MultipartFile multipartFile = new SealPositionMultipartFile(fileBytes, fileName, resolveSealContentType(fileName, contract.getContractTextFileType())); + String response = SealHttpClient.postMultipart( + sealConfig().getBaseUrl() + "/saas/api/contract/start", + multipartFile, + buildSealPreviewContractName(contract, fileName), + "seal-position-init", + sealTokenService().getAccessToken() + ); + JSONObject result = JSON.parseObject(response); + if (result == null || !result.getBooleanValue("isSuccess")) { + throw new ServiceException(result == null ? "初始化签署文件失败" : defaultIfBlank(result.getString("msg"), "初始化签署文件失败")); + } + SealPreviewData previewData = parseSealPreviewData(result.get("data")); + if (isBlank(previewData.contractId())) { + throw new ServiceException("签章平台未返回合同ID"); + } + return previewData; + } + + default SealPreviewData previewSealContract(String sealContractId, String cachedImageListJson) { + List cachedImageList = parseSealImageListJson(cachedImageListJson); + if (isBlank(sealContractId)) { + return new SealPreviewData(null, 793, 1122, cachedImageList); + } + try { + JSONObject body = new JSONObject(); + body.put("contractId", sealContractId); + String response = SealHttpClient.post( + sealConfig().getBaseUrl() + "/saas/api/contract/preview", + body.toJSONString(), + sealTokenService().getAccessToken(), + "application/json" + ); + JSONObject result = JSON.parseObject(response); + if (result != null && result.getBooleanValue("isSuccess")) { + SealPreviewData previewData = parseSealPreviewData(result.get("data")); + if (!previewData.pdfToImageList().isEmpty()) { + return previewData; + } + } + } catch (Exception ex) { + if (cachedImageList.isEmpty()) { + throw new ServiceException("获取签章预览失败: " + ex.getMessage()); + } + } + return new SealPreviewData(sealContractId, 793, 1122, cachedImageList); + } + + default ContractLaunchSealPositionPageDto buildSealPositionPage(ContractLaunchDto contract, SealPreviewData previewData) { + ContractLaunchSealPositionPageDto pageDto = new ContractLaunchSealPositionPageDto(); + pageDto.setContractId(contract.getId()); + pageDto.setSealContractId(defaultIfBlank(contract.getSealContractId(), previewData == null ? null : previewData.contractId())); + pageDto.setSealPosConfigured(defaultIfBlank(contract.getSealPosConfigured(), ContractLaunchConstants.FLAG_NO)); + pageDto.setImageWidth(previewData == null ? 793 : previewData.imageWidth()); + pageDto.setImageHeight(previewData == null ? 1122 : previewData.imageHeight()); + pageDto.setPdfToImageList(previewData == null ? new ArrayList<>() : previewData.pdfToImageList()); + pageDto.setPositions(new ArrayList<>(contractLaunchMapper().selectSealPositionByContractId(contract.getId()))); + List availablePartyIds = resolveAvailablePartyIds(contract, adminSecurityManage().getUser()); + pageDto.setAvailablePartyIds(availablePartyIds); + pageDto.setPreferredPartyId(availablePartyIds.size() == 1 ? availablePartyIds.get(0) : null); + return pageDto; + } + + default void validateSealPositionForSave(ContractLaunchSealPositionDto item, Map partyMap) { + if (item == null) { + throw new ServiceException("签章位数据不能为空"); + } + if (item.getPartyId() == null) { + throw new ServiceException("签章位必须绑定签署方"); + } + if (partyMap == null || !partyMap.containsKey(item.getPartyId())) { + throw new ServiceException("签章位绑定的签署方不存在"); + } + if (isBlank(item.getLabel())) { + throw new ServiceException("签章位标签不能为空"); + } + if (item.getPageNum() == null || item.getPageNum() <= 0) { + throw new ServiceException("签章位页码必须大于0"); + } + if (item.getX() == null || item.getY() == null || item.getWidth() == null || item.getHeight() == null) { + throw new ServiceException("签章位坐标不能为空"); + } + if (item.getWidth() <= 0 || item.getHeight() <= 0) { + throw new ServiceException("签章位宽高必须大于0"); + } + } + + default String normalizeRequiredFlag(String required) { + String value = trimText(required); + if (value == null) { + return ContractLaunchConstants.FLAG_YES; + } + String normalized = value.toUpperCase(Locale.ROOT); + if ("N".equals(normalized) || "0".equals(normalized) || "FALSE".equals(normalized)) { + return ContractLaunchConstants.FLAG_NO; + } + return ContractLaunchConstants.FLAG_YES; + } + + default Double roundCoordinate(Double value) { + if (value == null) { + return null; + } + return Math.round(value * 100D) / 100D; + } + + default List resolveAvailablePartyIds(ContractLaunchDto contract, SysUserEntity currentUser) { + if (contract == null || contract.getPartyList() == null || contract.getPartyList().isEmpty() || currentUser == null) { + return new ArrayList<>(); + } + if (securityManager().isSuperAdmin()) { + return contract.getPartyList().stream() + .map(ContractLaunchPartyDto::getId) + .filter(Objects::nonNull) + .collect(Collectors.toList()); + } + boolean canManage = authManager().isUserCanAccessSys(currentUser.getId(), SysEnum.MANAGE); + boolean canBusiness = authManager().isUserCanAccessSys(currentUser.getId(), SysEnum.BUSINESS); + String currentOrgId = currentUser.getSysOrgEntity() == null ? null : trimText(currentUser.getSysOrgEntity().getId()); + String currentAccount = trimText(currentUser.getUsername()); + String currentMobile = trimText(currentUser.getMobilePhone()); + Set partyIds = new LinkedHashSet<>(); + for (ContractLaunchPartyDto party : contract.getPartyList()) { + if (party == null || party.getId() == null) { + continue; + } + if (canManage && isOurParty(party) && Objects.equals(currentOrgId, trimText(party.getSubjectCode()))) { + partyIds.add(party.getId()); + continue; + } + if (canBusiness && isOppositeParty(party) && isMatchedBusinessParty(party, currentOrgId, currentAccount, currentMobile)) { + partyIds.add(party.getId()); + } + } + return new ArrayList<>(partyIds); + } + + default Long resolveTargetPartyId(Long partyId, List availablePartyIds) { + if (partyId != null) { + if (availablePartyIds == null || availablePartyIds.isEmpty()) { + throw new ServiceException("当前用户未匹配到可签署方,请手动选择后重试"); + } + if (!availablePartyIds.contains(partyId)) { + throw new ServiceException("当前用户不可操作该签署方"); + } + return partyId; + } + if (availablePartyIds == null || availablePartyIds.isEmpty()) { + throw new ServiceException("当前用户未匹配到可签署方,请手动选择后重试"); + } + if (availablePartyIds.size() > 1) { + throw new ServiceException("当前用户匹配到多个签署方,请手动选择后重试"); + } + return availablePartyIds.get(0); + } + + default void applySealPositionStatus(ContractLaunchSealPositionDto position, + ContractLaunchSealPositionBatchStatusDto batchDto, + ContractLaunchSealPositionBatchStatusDto.Item item, + SysUserEntity currentUser, + java.time.LocalDateTime now) { + Integer signedStatus = batchDto.getSignedStatus(); + if (signedStatus == null) { + throw new ServiceException("签章位状态不能为空"); + } + position.setUpdateBy(currentUser == null ? "system" : currentUser.getUsername()); + position.setUpdateTime(now); + if (Objects.equals(signedStatus, ContractLaunchConstants.SEAL_POS_STATUS_EMPTY)) { + position.setSealId(null); + position.setSealName(null); + position.setRequestId(null); + position.setSignedByName(null); + position.setSignedById(null); + position.setSignedTime(null); + position.setSignedStatus(ContractLaunchConstants.SEAL_POS_STATUS_EMPTY); + return; + } + if (Objects.equals(signedStatus, ContractLaunchConstants.SEAL_POS_STATUS_SELECTED)) { + if (item == null || isBlank(item.getSealId())) { + throw new ServiceException("已选章状态必须传印章ID"); + } + position.setSealId(item.getSealId()); + position.setSealName(item.getSealName()); + position.setRequestId(null); + position.setSignedByName(null); + position.setSignedById(null); + position.setSignedTime(null); + position.setSignedStatus(ContractLaunchConstants.SEAL_POS_STATUS_SELECTED); + return; + } + if (Objects.equals(signedStatus, ContractLaunchConstants.SEAL_POS_STATUS_SIGNING)) { + if (item != null && !isBlank(item.getSealId())) { + position.setSealId(item.getSealId()); + } + if (item != null && !isBlank(item.getSealName())) { + position.setSealName(item.getSealName()); + } + if (isBlank(position.getSealId())) { + throw new ServiceException("提交预盖章前请先选择印章"); + } + if (isBlank(batchDto.getRequestId())) { + throw new ServiceException("二维码请求ID不能为空"); + } + position.setRequestId(batchDto.getRequestId()); + position.setSignedByName(null); + position.setSignedById(null); + position.setSignedTime(null); + position.setSignedStatus(ContractLaunchConstants.SEAL_POS_STATUS_SIGNING); + return; + } + if (Objects.equals(signedStatus, ContractLaunchConstants.SEAL_POS_STATUS_SIGNED)) { + if (item != null && !isBlank(item.getSealId())) { + position.setSealId(item.getSealId()); + } + if (item != null && !isBlank(item.getSealName())) { + position.setSealName(item.getSealName()); + } + if (!isBlank(batchDto.getRequestId())) { + position.setRequestId(batchDto.getRequestId()); + } + position.setSignedByName(defaultIfBlank(batchDto.getSignedByName(), currentUser == null ? null : currentUser.getUsername())); + position.setSignedById(defaultIfBlank(batchDto.getSignedById(), currentUser == null ? null : String.valueOf(currentUser.getId()))); + position.setSignedTime(now); + position.setSignedStatus(ContractLaunchConstants.SEAL_POS_STATUS_SIGNED); + return; + } + if (Objects.equals(signedStatus, ContractLaunchConstants.SEAL_POS_STATUS_FAILED)) { + position.setRequestId(null); + position.setSignedByName(null); + position.setSignedById(null); + position.setSignedTime(null); + position.setSignedStatus(ContractLaunchConstants.SEAL_POS_STATUS_FAILED); + return; + } + throw new ServiceException("不支持的签章位状态: " + signedStatus); + } + + default boolean isSealPresignFailed(Map callbackData) { + String contractStatus = stringValue(callbackData == null ? null : callbackData.get("contractStatus")); + if (!isBlank(contractStatus)) { + if ("20".equals(contractStatus) || "2000".equals(contractStatus)) { + return false; + } + if ("0".equals(contractStatus) || contractStatus.startsWith("-")) { + return true; + } + } + Object successValue = callbackData == null ? null : (callbackData.containsKey("isSuccess") ? callbackData.get("isSuccess") : callbackData.get("success")); + if (successValue != null) { + return !Boolean.parseBoolean(String.valueOf(successValue)); + } + String statusText = (defaultIfBlank(stringValue(callbackData == null ? null : callbackData.get("status")), "") + " " + + defaultIfBlank(stringValue(callbackData == null ? null : callbackData.get("msg")), "")).toLowerCase(Locale.ROOT); + return statusText.contains("fail") || statusText.contains("cancel") || statusText.contains("timeout") || statusText.contains("reject"); + } + + default String stringValue(Object value) { + return value == null ? null : trimText(String.valueOf(value)); + } + + default boolean isBlank(String value) { + return value == null || value.isBlank(); + } + + default String defaultIfBlank(String value, String defaultValue) { + return isBlank(value) ? defaultValue : value; + } + + private boolean isMatchedBusinessParty(ContractLaunchPartyDto party, String currentOrgId, String currentAccount, String currentMobile) { + if (Objects.equals(currentOrgId, trimText(party.getSubjectCode()))) { + return true; + } + if (party.getPartnerId() == null) { + return false; + } + try { + PartnerManageDto partner = partnerManageService().selectPartnerById(party.getPartnerId()); + if (partner == null) { + return false; + } + if (partner.getLinkedOrgId() != null && Objects.equals(currentOrgId, String.valueOf(partner.getLinkedOrgId()))) { + return true; + } + String linkedUserAccount = trimText(partner.getLinkedUserAccount()); + return !isBlank(linkedUserAccount) && (linkedUserAccount.equalsIgnoreCase(defaultIfBlank(currentAccount, "")) + || linkedUserAccount.equalsIgnoreCase(defaultIfBlank(currentMobile, ""))); + } catch (Exception ignored) { + return false; + } + } + + private SealPreviewData parseSealPreviewData(Object dataObject) { + if (dataObject == null) { + return new SealPreviewData(null, 793, 1122, new ArrayList<>()); + } + JSONObject data = dataObject instanceof JSONObject + ? (JSONObject) dataObject + : JSON.parseObject(JSON.toJSONString(dataObject)); + if (data == null) { + return new SealPreviewData(null, 793, 1122, new ArrayList<>()); + } + List imageList = new ArrayList<>(); + Object imageObject = data.get("pdfToImageList"); + if (imageObject instanceof JSONArray array) { + for (int i = 0; i < array.size(); i++) { + String item = stringValue(array.get(i)); + if (!isBlank(item)) { + imageList.add(item); + } + } + } else if (imageObject instanceof List list) { + for (Object item : list) { + String imageUrl = stringValue(item); + if (!isBlank(imageUrl)) { + imageList.add(imageUrl); + } + } + } else if (imageObject != null) { + imageList.addAll(parseSealImageListJson(String.valueOf(imageObject))); + } + Integer imageWidth = data.getInteger("imageWidth"); + Integer imageHeight = data.getInteger("imageHeight"); + return new SealPreviewData( + stringValue(data.get("contractId")), + imageWidth == null || imageWidth <= 0 ? 793 : imageWidth, + imageHeight == null || imageHeight <= 0 ? 1122 : imageHeight, + imageList + ); + } + + private List parseSealImageListJson(String json) { + if (isBlank(json)) { + return new ArrayList<>(); + } + try { + JSONArray array = JSON.parseArray(json); + List imageList = new ArrayList<>(); + for (int i = 0; i < array.size(); i++) { + String item = stringValue(array.get(i)); + if (!isBlank(item)) { + imageList.add(item); + } + } + return imageList; + } catch (Exception ex) { + return new ArrayList<>(); + } + } + + private String buildSealPreviewContractName(ContractLaunchDto contract, String fileName) { + String value = defaultIfBlank(trimText(contract == null ? null : contract.getContractName()), trimText(fileName)); + if (isBlank(value)) { + return "合同正文"; + } + int index = value.lastIndexOf('.'); + return index > 0 ? value.substring(0, index) : value; + } + + private String resolveSealContentType(String fileName, String fileType) { + String suffix = trimText(fileType); + if (isBlank(suffix) && !isBlank(fileName) && fileName.contains(".")) { + suffix = fileName.substring(fileName.lastIndexOf('.') + 1); + } + suffix = defaultIfBlank(suffix, "pdf").toLowerCase(Locale.ROOT); + return switch (suffix) { + case "doc" -> "application/msword"; + case "docx" -> "application/vnd.openxmlformats-officedocument.wordprocessingml.document"; + case "pdf" -> "application/pdf"; + default -> "application/octet-stream"; + }; + } + + private ContractLaunchMapper contractLaunchMapper() { + return readField("contractLaunchMapper", ContractLaunchMapper.class); + } + + private AdminSecurityManage adminSecurityManage() { + return readField("adminSecurityManage", AdminSecurityManage.class); + } + + private AuthManager authManager() { + return readField("authManager", AuthManager.class); + } + + private SecurityManager securityManager() { + return readField("securityManager", SecurityManager.class); + } + + private LocalFileService localFileService() { + return readField("localFileService", LocalFileService.class); + } + + private SealTokenService sealTokenService() { + return readField("sealTokenService", SealTokenService.class); + } + + private SealConfig sealConfig() { + return readField("sealConfig", SealConfig.class); + } + + private PartnerManageService partnerManageService() { + return readField("partnerManageService", PartnerManageService.class); + } + + private T readField(String fieldName, Class type) { + try { + Field field = findField(this.getClass(), fieldName); + field.setAccessible(true); + return type.cast(field.get(this)); + } catch (Exception ex) { + throw new IllegalStateException("读取字段失败: " + fieldName, ex); + } + } + + private Field findField(Class type, String fieldName) throws NoSuchFieldException { + Class current = type; + while (current != null) { + try { + return current.getDeclaredField(fieldName); + } catch (NoSuchFieldException ignored) { + current = current.getSuperclass(); + } + } + throw new NoSuchFieldException(fieldName); + } + + private boolean isOurParty(ContractLaunchPartyDto party) { + return party != null && "OUR".equalsIgnoreCase(trimText(party.getRoleCode())); + } + + private boolean isOppositeParty(ContractLaunchPartyDto party) { + return party != null && "OPPOSITE".equalsIgnoreCase(trimText(party.getRoleCode())); + } + + private String trimText(String value) { + return value == null ? null : value.trim(); + } + + class SealPositionMultipartFile implements MultipartFile { + + private final byte[] content; + private final String originalFilename; + private final String contentType; + + public SealPositionMultipartFile(byte[] content, String originalFilename, String contentType) { + this.content = content == null ? new byte[0] : content; + this.originalFilename = originalFilename; + this.contentType = contentType; + } + + @Override + public String getName() { + return "file"; + } + + @Override + public String getOriginalFilename() { + return originalFilename; + } + + @Override + public String getContentType() { + return contentType; + } + + @Override + public boolean isEmpty() { + return content.length == 0; + } + + @Override + public long getSize() { + return content.length; + } + + @Override + public byte[] getBytes() { + return content; + } + + @Override + public InputStream getInputStream() { + return new ByteArrayInputStream(content); + } + + @Override + public void transferTo(File dest) throws IOException { + try (FileOutputStream outputStream = new FileOutputStream(dest)) { + outputStream.write(content); + } + } + } } diff --git a/src/main/java/com/nbport/zgwl/contractlaunch/service/ContractLaunchServiceImpl.java b/src/main/java/com/nbport/zgwl/contractlaunch/service/ContractLaunchServiceImpl.java index 18c735b..55f7528 100644 --- a/src/main/java/com/nbport/zgwl/contractlaunch/service/ContractLaunchServiceImpl.java +++ b/src/main/java/com/nbport/zgwl/contractlaunch/service/ContractLaunchServiceImpl.java @@ -22,6 +22,11 @@ import com.nbport.zgwl.contractlaunch.dto.ContractLaunchPartyDto; import com.nbport.zgwl.contractlaunch.dto.ContractLaunchPlanDto; import com.nbport.zgwl.contractlaunch.dto.ContractLaunchQueryDto; import com.nbport.zgwl.contractlaunch.dto.ContractLaunchSealDto; +import com.nbport.zgwl.contractlaunch.dto.ContractLaunchSealPositionBatchStatusDto; +import com.nbport.zgwl.contractlaunch.dto.ContractLaunchSealPositionCheckResultDto; +import com.nbport.zgwl.contractlaunch.dto.ContractLaunchSealPositionDto; +import com.nbport.zgwl.contractlaunch.dto.ContractLaunchSealPositionPageDto; +import com.nbport.zgwl.contractlaunch.dto.ContractLaunchSealPositionSaveDto; import com.nbport.zgwl.contractlaunch.service.ContractLaunchLegalFileBridgeService; import com.nbport.zgwl.contractlaunch.mapper.ContractLaunchMapper; import com.nbport.zgwl.contractlaunch.utils.ContractLaunchCodeUtils; @@ -36,6 +41,7 @@ import com.nbport.zgwl.seal.service.SealTokenService; import com.nbport.zgwl.seal.config.SealConfig; import com.nbport.zgwl.seal.utils.SealHttpClient; import com.alibaba.fastjson2.JSON; +import com.alibaba.fastjson2.JSONArray; import com.alibaba.fastjson2.JSONObject; import com.nbport.zgwl.service.LocalFileService; import com.nbport.zgwl.entity.SysUserEntity; @@ -48,6 +54,12 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.web.multipart.MultipartFile; +import java.io.ByteArrayInputStream; +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; + import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; import java.util.ArrayList; @@ -688,6 +700,11 @@ public class ContractLaunchServiceImpl implements ContractLaunchService { } } + ContractLaunchSealPositionCheckResultDto checkResult = checkSealPositions(id, targetParty.getId()); + if (checkResult != null && !checkResult.isPassed()) { + throw new ServiceException("该相对方还有 " + checkResult.getMissingCount() + " 个必签位置未完成签署,请先完成签章后再确认"); + } + Map confirmMap = getCounterpartyConfirmMap(id); String confirmKey = buildCounterpartyConfirmKey(targetParty); Map entry = new LinkedHashMap<>(); @@ -713,6 +730,188 @@ public class ContractLaunchServiceImpl implements ContractLaunchService { return getCounterpartyConfirmFiltered(id); } + @Override + @Transactional(rollbackFor = Exception.class) + public ContractLaunchSealPositionPageDto initSealPosition(Long id) { + ContractLaunchDto contract = getDetail(id); + assertSealPositionConfigAccess(contract); + SealPreviewData previewData; + SysUserEntity currentUser = adminSecurityManage.getUser(); + if (isBlank(contract.getSealContractId())) { + previewData = startSealPreview(contract); + contract.setSealContractId(previewData.contractId()); + contract.setSealPdfImageList(JsonUtils.toJson(previewData.pdfToImageList())); + contract.setSealPosConfigured(defaultIfBlank(contract.getSealPosConfigured(), ContractLaunchConstants.FLAG_NO)); + contract.setUpdateBy(currentUser.getUsername()); + contract.setUpdateTime(LocalDateTime.now()); + contractLaunchMapper.updateContract(contract); + insertLog(id, ContractLaunchConstants.ACTION_SEAL_CREATE, "初始化签署文件", ContractLaunchConstants.ACTION_STATUS_SUCCESS, "已基于水印文件创建签章平台合同", previewData, currentUser); + } else { + previewData = previewSealContract(contract.getSealContractId(), contract.getSealPdfImageList()); + if (!previewData.pdfToImageList().isEmpty()) { + contract.setSealPdfImageList(JsonUtils.toJson(previewData.pdfToImageList())); + contract.setUpdateBy(currentUser.getUsername()); + contract.setUpdateTime(LocalDateTime.now()); + contractLaunchMapper.updateContract(contract); + } + } + return buildSealPositionPage(getDetail(id), previewData); + } + + @Override + public ContractLaunchSealPositionPageDto getSealPositionPage(Long id) { + ContractLaunchDto contract = getDetail(id); + assertSealPositionViewAccess(contract); + return buildSealPositionPage(contract, previewSealContract(contract.getSealContractId(), contract.getSealPdfImageList())); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public ContractLaunchSealPositionPageDto saveSealPositions(Long id, ContractLaunchSealPositionSaveDto saveDto) { + ContractLaunchDto contract = getDetail(id); + assertSealPositionConfigAccess(contract); + if (!ContractLaunchConstants.STATUS_PENDING_SIGN.equals(contract.getStatus())) { + throw new ServiceException("仅待签署状态允许配置签章位"); + } + if (isBlank(contract.getSealContractId())) { + throw new ServiceException("请先初始化签署文件"); + } + SysUserEntity currentUser = adminSecurityManage.getUser(); + String operator = currentUser.getUsername(); + Map partyMap = contract.getPartyList() == null ? Map.of() : contract.getPartyList().stream() + .collect(Collectors.toMap(ContractLaunchPartyDto::getId, item -> item, (a, b) -> a, LinkedHashMap::new)); + List newPositions = new ArrayList<>(); + if (saveDto != null && saveDto.getPositions() != null) { + for (ContractLaunchSealPositionDto item : saveDto.getPositions()) { + validateSealPositionForSave(item, partyMap); + ContractLaunchSealPositionDto position = new ContractLaunchSealPositionDto(); + position.setContractId(id); + position.setPartyId(item.getPartyId()); + position.setLabel(trim(item.getLabel())); + position.setRequired(normalizeRequiredFlag(item.getRequired())); + position.setPageNum(item.getPageNum()); + position.setX(roundCoordinate(item.getX())); + position.setY(roundCoordinate(item.getY())); + position.setWidth(roundCoordinate(item.getWidth())); + position.setHeight(roundCoordinate(item.getHeight())); + position.setSignedStatus(ContractLaunchConstants.SEAL_POS_STATUS_EMPTY); + position.setCreateBy(operator); + position.setUpdateBy(operator); + position.setIsDeleted(ContractLaunchConstants.IS_DELETED_NO); + newPositions.add(position); + } + } + contractLaunchMapper.deleteSealPositionByContractId(id, operator); + if (!newPositions.isEmpty()) { + // Oracle 的 INSERT ALL 在一条语句里插多行时会让 NEXTVAL 行为异常,这里改成逐条插入更稳。 + for (ContractLaunchSealPositionDto position : newPositions) { + contractLaunchMapper.batchInsertSealPosition(List.of(position), operator, operator); + } + } + contract.setSealPosConfigured(newPositions.isEmpty() ? ContractLaunchConstants.FLAG_NO : ContractLaunchConstants.FLAG_YES); + contract.setUpdateBy(operator); + contract.setUpdateTime(LocalDateTime.now()); + contractLaunchMapper.updateContract(contract); + insertLog(id, ContractLaunchConstants.ACTION_SEAL_POSITION_CONFIG, "保存签章位", ContractLaunchConstants.ACTION_STATUS_SUCCESS, "已保存签章位配置,共 " + newPositions.size() + " 个", saveDto, currentUser); + return getSealPositionPage(id); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public ContractLaunchSealPositionPageDto batchUpdateSealPositionStatus(Long id, ContractLaunchSealPositionBatchStatusDto batchDto) { + ContractLaunchDto contract = getDetail(id); + assertSealPositionViewAccess(contract); + if (batchDto == null || batchDto.getItems() == null || batchDto.getItems().isEmpty()) { + throw new ServiceException("签章位状态更新数据不能为空"); + } + SysUserEntity currentUser = adminSecurityManage.getUser(); + List availablePartyIds = resolveAvailablePartyIds(contract, currentUser); + Long targetPartyId = resolveTargetPartyId(batchDto.getPartyId(), availablePartyIds); + Map positionMap = contractLaunchMapper.selectSealPositionByContractId(id).stream() + .collect(Collectors.toMap(ContractLaunchSealPositionDto::getId, item -> item, (a, b) -> a, LinkedHashMap::new)); + LocalDateTime now = LocalDateTime.now(); + for (ContractLaunchSealPositionBatchStatusDto.Item item : batchDto.getItems()) { + ContractLaunchSealPositionDto position = positionMap.get(item.getId()); + if (position == null) { + throw new ServiceException("签章位不存在: " + item.getId()); + } + if (!Objects.equals(position.getPartyId(), targetPartyId)) { + throw new ServiceException("签章位不属于当前签署方"); + } + applySealPositionStatus(position, batchDto, item, currentUser, now); + contractLaunchMapper.updateSealPosition(position); + } + if (Objects.equals(batchDto.getSignedStatus(), ContractLaunchConstants.SEAL_POS_STATUS_SIGNING) + && ContractLaunchConstants.STATUS_PENDING_SIGN.equals(contract.getStatus())) { + contract.setStatus(ContractLaunchConstants.STATUS_SIGNING); + contract.setSealContractStatus(20); + contract.setUpdateBy(currentUser.getUsername()); + contract.setUpdateTime(now); + contractLaunchMapper.updateContract(contract); + } + insertLog(id, ContractLaunchConstants.ACTION_SEAL_POSITION_STATUS, "更新签章位状态", ContractLaunchConstants.ACTION_STATUS_SUCCESS, "已批量更新签章位状态", batchDto, currentUser); + return getSealPositionPage(id); + } + + @Override + public ContractLaunchSealPositionCheckResultDto checkSealPositions(Long id, Long partyId) { + ContractLaunchDto contract = getDetail(id); + assertSealPositionViewAccess(contract); + SysUserEntity currentUser = adminSecurityManage.getUser(); + List availablePartyIds = resolveAvailablePartyIds(contract, currentUser); + if (partyId != null && !availablePartyIds.isEmpty() && !availablePartyIds.contains(partyId) && !securityManager.isSuperAdmin()) { + throw new ServiceException("当前用户不可校验该签署方"); + } + List positions = contractLaunchMapper.selectSealPositionByContractId(id).stream() + .filter(item -> ContractLaunchConstants.FLAG_YES.equals(normalizeRequiredFlag(item.getRequired()))) + .filter(item -> partyId == null || Objects.equals(item.getPartyId(), partyId)) + .filter(item -> !Objects.equals(item.getSignedStatus(), ContractLaunchConstants.SEAL_POS_STATUS_SIGNED)) + .collect(Collectors.toList()); + ContractLaunchSealPositionCheckResultDto result = new ContractLaunchSealPositionCheckResultDto(); + result.setPassed(positions.isEmpty()); + result.setMissingCount(positions.size()); + result.setMissingPositions(positions); + return result; + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void handleSealPresignCallback(Map callbackData) { + String requestId = stringValue(callbackData.get("requestId")); + if (isBlank(requestId)) { + return; + } + List positions = contractLaunchMapper.selectSealPositionByRequestId(requestId); + if (positions == null || positions.isEmpty()) { + return; + } + boolean success = !isSealPresignFailed(callbackData); + LocalDateTime now = LocalDateTime.now(); + String signer = stringValue(callbackData.get("signer")); + String signerId = stringValue(callbackData.get("signerId")); + String operator = isBlank(signer) ? "seal_callback" : signer; + Long contractId = positions.get(0).getContractId(); + for (ContractLaunchSealPositionDto position : positions) { + if (success) { + position.setSignedStatus(ContractLaunchConstants.SEAL_POS_STATUS_SIGNED); + position.setSignedByName(signer); + position.setSignedById(signerId); + position.setSignedTime(now); + } else { + position.setSignedStatus(isBlank(position.getSealId()) ? ContractLaunchConstants.SEAL_POS_STATUS_EMPTY : ContractLaunchConstants.SEAL_POS_STATUS_SELECTED); + position.setRequestId(null); + position.setSignedByName(null); + position.setSignedById(null); + position.setSignedTime(null); + } + position.setUpdateBy(operator); + position.setUpdateTime(now); + contractLaunchMapper.updateSealPosition(position); + } + insertLog(contractId, ContractLaunchConstants.ACTION_SEAL_POSITION_STATUS, success ? "预盖章回调成功" : "预盖章回调失败", success ? ContractLaunchConstants.ACTION_STATUS_SUCCESS : ContractLaunchConstants.ACTION_STATUS_FAILED, success ? "签章位已根据回调更新为已签" : "签章位已根据回调回滚为可重试状态", callbackData, adminSecurityManage.getUser()); + +} + /** * 【测试按钮】模拟合同采集。 */ @@ -1228,8 +1427,10 @@ public class ContractLaunchServiceImpl implements ContractLaunchService { logDto.setActionType(actionType); logDto.setActionName(actionName); logDto.setActionStatus(actionStatus); - logDto.setOperatorName(currentUser.getUsername()); - logDto.setOperatorPhone(currentUser.getMobilePhone()); + String operatorName = currentUser == null || currentUser.getUsername() == null || currentUser.getUsername().isBlank() + ? "system" : currentUser.getUsername(); + logDto.setOperatorName(operatorName); + logDto.setOperatorPhone(currentUser == null ? null : currentUser.getMobilePhone()); logDto.setActionTime(LocalDateTime.now()); logDto.setActionContent(actionContent); logDto.setCallbackPayload(payload == null ? null : JSON.toJSONString(payload)); diff --git a/src/main/java/com/nbport/zgwl/contractlaunch/service/SealPreviewData.java b/src/main/java/com/nbport/zgwl/contractlaunch/service/SealPreviewData.java new file mode 100644 index 0000000..03a73f8 --- /dev/null +++ b/src/main/java/com/nbport/zgwl/contractlaunch/service/SealPreviewData.java @@ -0,0 +1,41 @@ +package com.nbport.zgwl.contractlaunch.service; + +import java.util.ArrayList; +import java.util.List; + +/** + * 签章平台预览结果。 + * + * 不用 record,保持和项目现有 Java 风格更一致, + * 同时保留 record 风格的 accessor 命名,方便 ServiceImpl 直接调用。 + */ +public class SealPreviewData { + + private final String contractId; + private final Integer imageWidth; + private final Integer imageHeight; + private final List pdfToImageList; + + public SealPreviewData(String contractId, Integer imageWidth, Integer imageHeight, List pdfToImageList) { + this.contractId = contractId; + this.imageWidth = imageWidth; + this.imageHeight = imageHeight; + this.pdfToImageList = pdfToImageList == null ? new ArrayList<>() : new ArrayList<>(pdfToImageList); + } + + public String contractId() { + return contractId; + } + + public Integer imageWidth() { + return imageWidth; + } + + public Integer imageHeight() { + return imageHeight; + } + + public List pdfToImageList() { + return new ArrayList<>(pdfToImageList); + } +} diff --git a/src/main/java/com/nbport/zgwl/contractlaunch/utils/ContractLaunchConstants.java b/src/main/java/com/nbport/zgwl/contractlaunch/utils/ContractLaunchConstants.java index f4bdec1..b63e462 100644 --- a/src/main/java/com/nbport/zgwl/contractlaunch/utils/ContractLaunchConstants.java +++ b/src/main/java/com/nbport/zgwl/contractlaunch/utils/ContractLaunchConstants.java @@ -65,11 +65,22 @@ public final class ContractLaunchConstants { public static final String ACTION_SEAL_UPLOAD = "seal_upload"; /** 签章平台二维码生成(等待扫码确认) */ public static final String ACTION_SEAL_QRCODE = "seal_qrcode"; + public static final String ACTION_SEAL_POSITION_CONFIG = "seal_position_config"; + public static final String ACTION_SEAL_POSITION_STATUS = "seal_position_status"; public static final String ACTION_STATUS_SUCCESS = "success"; public static final String ACTION_STATUS_FAILED = "failed"; public static final String ACTION_STATUS_PENDING = "pending"; + public static final String FLAG_YES = "Y"; + public static final String FLAG_NO = "N"; + + public static final int SEAL_POS_STATUS_EMPTY = 0; + public static final int SEAL_POS_STATUS_SELECTED = 1; + public static final int SEAL_POS_STATUS_SIGNING = 2; + public static final int SEAL_POS_STATUS_SIGNED = 3; + public static final int SEAL_POS_STATUS_FAILED = 4; + public static final String IS_DELETED_NO = "N"; public static final String IS_DELETED_YES = "Y"; } diff --git a/src/main/java/com/nbport/zgwl/partner/mapper/PartnerManageMapper.java b/src/main/java/com/nbport/zgwl/partner/mapper/PartnerManageMapper.java index 53b590b..a2e7105 100644 --- a/src/main/java/com/nbport/zgwl/partner/mapper/PartnerManageMapper.java +++ b/src/main/java/com/nbport/zgwl/partner/mapper/PartnerManageMapper.java @@ -18,9 +18,6 @@ public interface PartnerManageMapper { /** 获取相对方主表序列(Oracle 主键生成) */ Long selectPartnerSeqNextVal(); - /** 获取联系人表序列(Oracle 主键生成) */ - Long selectPartnerContactSeqNextVal(); - /** 分页查询相对方列表 */ IPage selectPartnerPage(Page page, @Param("query") PartnerQueryDto queryDto); diff --git a/src/main/java/com/nbport/zgwl/partner/service/PartnerManageServiceImpl.java b/src/main/java/com/nbport/zgwl/partner/service/PartnerManageServiceImpl.java index 9a40203..9221de7 100644 --- a/src/main/java/com/nbport/zgwl/partner/service/PartnerManageServiceImpl.java +++ b/src/main/java/com/nbport/zgwl/partner/service/PartnerManageServiceImpl.java @@ -331,19 +331,21 @@ public class PartnerManageServiceImpl implements PartnerManageService { int sortOrder = 1; for (PartnerContactDto contact : contacts) { - contact.setId(partnerManageMapper.selectPartnerContactSeqNextVal()); contact.setPartnerRecordId(customerSysid); contact.setSortOrder(sortOrder++); contact.setDelFlag(PartnerManageConstants.CONTACT_DEL_FLAG_NORMAL); } - partnerManageMapper.batchInsertPartnerContacts( - contacts, - resolveOperatorName(), - now, - resolveOperatorName(), - now, - PartnerManageConstants.CONTACT_DEL_FLAG_NORMAL); + String operator = resolveOperatorName(); + for (PartnerContactDto contact : contacts) { + partnerManageMapper.batchInsertPartnerContacts( + List.of(contact), + operator, + now, + operator, + now, + PartnerManageConstants.CONTACT_DEL_FLAG_NORMAL); + } } private String ensureOppositeCode(String oppositeCode) { diff --git a/src/main/java/com/nbport/zgwl/seal/controller/SealCallbackController.java b/src/main/java/com/nbport/zgwl/seal/controller/SealCallbackController.java index 8f6d9ff..1c9f587 100644 --- a/src/main/java/com/nbport/zgwl/seal/controller/SealCallbackController.java +++ b/src/main/java/com/nbport/zgwl/seal/controller/SealCallbackController.java @@ -2,12 +2,13 @@ package com.nbport.zgwl.seal.controller; import com.alibaba.fastjson2.JSON; import com.nbport.zgwl.common.R; +import com.nbport.zgwl.contractlaunch.service.ContractLaunchService; import com.nbport.zgwl.contractlaunch.utils.ContractLaunchConstants; import com.nbport.zgwl.seal.handler.SealWebSocketHandler; import com.nbport.zgwl.seal.service.SealLogService; +import lombok.RequiredArgsConstructor; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import org.springframework.beans.factory.annotation.Autowired; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; @@ -15,36 +16,26 @@ import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +import java.util.LinkedHashMap; import java.util.Map; -/** - * 签章平台回调接口控制器 - * - * 接收签章平台推送的回调通知,将关键回调写入合同操作日志表, - * 实现签署过程全程留痕。 - * - * @author xiaofengzhou - */ @RestController +@RequiredArgsConstructor @RequestMapping("/seal/callback") public class SealCallbackController { private static final Logger log = LoggerFactory.getLogger(SealCallbackController.class); - @Autowired - private SealWebSocketHandler webSocketHandler; - - @Autowired - private SealLogService sealLogService; + private final SealWebSocketHandler webSocketHandler; + private final SealLogService sealLogService; + private final ContractLaunchService contractLaunchService; @PostMapping("/login") public R loginCallback(@RequestBody Map params) { log.info("收到身份认证回调: {}", JSON.toJSONString(params)); - try { - String requestId = (String) params.get("requestId"); + String requestId = stringValue(params.get("requestId")); webSocketHandler.sendCallbackMessage(requestId, "login", params); - // 身份认证回调记录日志(不关联具体合同) sealLogService.logSealAction(null, ContractLaunchConstants.ACTION_SEAL_CALLBACK, "身份认证回调", "身份认证回调已接收,requestId: " + requestId, params); return R.success("回调接收成功"); @@ -60,20 +51,18 @@ public class SealCallbackController { @PostMapping("/sign") public R signCallback(@RequestBody Map params) { log.info("收到签署回调: {}", JSON.toJSONString(params)); - try { - String contractId = (String) params.get("contractId"); - String requestId = (String) params.get("requestId"); + String contractId = stringValue(params.get("contractId")); + String requestId = stringValue(params.get("requestId")); webSocketHandler.sendCallbackMessage(requestId, "sign", params); webSocketHandler.sendCallbackMessage(contractId, "contract_status", params); - // 签署回调记录日志(关联到本系统合同) Long internalContractId = sealLogService.findContractIdBySealContractId(contractId); sealLogService.logSealAction(internalContractId, ContractLaunchConstants.ACTION_SEAL_CALLBACK, "签署回调", "收到签章平台签署回调,签章平台合同ID: " + contractId, params); return R.success("回调接收成功"); } catch (Exception e) { log.error("处理签署回调失败", e); - String contractId = (String) params.get("contractId"); + String contractId = stringValue(params.get("contractId")); Long internalContractId = sealLogService.findContractIdBySealContractId(contractId); sealLogService.logSealAction(internalContractId, ContractLaunchConstants.ACTION_SEAL_CALLBACK, "签署回调", ContractLaunchConstants.ACTION_STATUS_FAILED, @@ -85,36 +74,44 @@ public class SealCallbackController { @PostMapping("/presign") public R presignCallback(@RequestBody Map params) { log.info("收到预盖章回调: {}", JSON.toJSONString(params)); - try { - String contractId = (String) params.get("contractId"); - String requestId = (String) params.get("requestId"); + String contractId = stringValue(params.get("contractId")); + String requestId = stringValue(params.get("requestId")); webSocketHandler.sendCallbackMessage(requestId, "presign", params); webSocketHandler.sendCallbackMessage(contractId, "contract_status", params); - // 预盖章回调记录日志(关联到本系统合同) + contractLaunchService.handleSealPresignCallback(params); Long internalContractId = sealLogService.findContractIdBySealContractId(contractId); sealLogService.logSealAction(internalContractId, ContractLaunchConstants.ACTION_SEAL_CALLBACK, "预盖章回调", "收到签章平台预盖章回调,签章平台合同ID: " + contractId, params); return R.success("回调接收成功"); } catch (Exception e) { log.error("处理预盖章回调失败", e); - String contractId = (String) params.get("contractId"); + String contractId = stringValue(params.get("contractId")); Long internalContractId = sealLogService.findContractIdBySealContractId(contractId); sealLogService.logSealAction(internalContractId, ContractLaunchConstants.ACTION_SEAL_CALLBACK, "预盖章回调", ContractLaunchConstants.ACTION_STATUS_FAILED, "处理预盖章回调失败: " + e.getMessage(), params); - return R.error("回调处理失败: " + e.getMessage()); + return R.error("回调处理失败: " + buildErrorMessage(e)); } } + @PostMapping("/presign/mock-success") + public R mockPresignSuccess(@RequestBody Map body) { + Map payload = new LinkedHashMap<>(); + payload.put("requestId", body.get("requestId")); + payload.put("contractId", body.get("contractId")); + payload.put("signer", body.get("signer")); + payload.put("signerId", body.get("signerId")); + payload.put("contractStatus", body.getOrDefault("contractStatus", 20)); + return presignCallback(payload); + } + @PostMapping("/trusteeship") public R trusteeshipCallback(@RequestBody Map params) { log.info("收到托管章回调: {}", JSON.toJSONString(params)); - try { - String requestId = (String) params.get("requestId"); + String requestId = stringValue(params.get("requestId")); webSocketHandler.sendCallbackMessage(requestId, "trusteeship", params); - // 托管章回调记录日志 sealLogService.logSealAction(null, ContractLaunchConstants.ACTION_SEAL_CALLBACK, "托管章回调", "收到签章平台托管章回调,requestId: " + requestId, params); return R.success("回调接收成功"); @@ -130,11 +127,9 @@ public class SealCallbackController { @PostMapping("/signQrCode") public R signQrCodeCallback(@RequestBody Map params) { log.info("收到签名二维码回调: {}", JSON.toJSONString(params)); - try { - String requestId = (String) params.get("requestId"); + String requestId = stringValue(params.get("requestId")); webSocketHandler.sendCallbackMessage(requestId, "signQrCode", params); - // 签名二维码回调记录日志 sealLogService.logSealAction(null, ContractLaunchConstants.ACTION_SEAL_CALLBACK, "签名二维码回调", "收到签章平台签名二维码回调,requestId: " + requestId, params); return R.success("回调接收成功"); @@ -149,7 +144,18 @@ public class SealCallbackController { @GetMapping("/poll/{requestId}") public R> pollCallback(@PathVariable String requestId) { - Map callbackData = webSocketHandler.getCallbackData(requestId); - return R.success(callbackData); + return R.success(webSocketHandler.getCallbackData(requestId)); + } + + private String buildErrorMessage(Exception e) { + if (e == null) { + return "未知异常"; + } + String message = e.getMessage(); + return message == null || message.isBlank() ? e.toString() : message; + } + + private String stringValue(Object value) { + return value == null ? null : String.valueOf(value).trim(); } } diff --git a/src/main/java/com/nbport/zgwl/seal/handler/SealWebSocketHandler.java b/src/main/java/com/nbport/zgwl/seal/handler/SealWebSocketHandler.java index f93d788..26d48a7 100644 --- a/src/main/java/com/nbport/zgwl/seal/handler/SealWebSocketHandler.java +++ b/src/main/java/com/nbport/zgwl/seal/handler/SealWebSocketHandler.java @@ -10,6 +10,7 @@ import org.springframework.web.socket.WebSocketSession; import org.springframework.web.socket.handler.TextWebSocketHandler; import java.io.IOException; +import java.util.LinkedHashMap; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; @@ -96,9 +97,13 @@ public class SealWebSocketHandler extends TextWebSocketHandler { return; } - Map cacheEntry = new ConcurrentHashMap<>(); + // 回调报文里可能出现 signerId 等可选字段为 null 的情况, + // ConcurrentHashMap 不允许 null value,这里使用普通 Map 缓存原始回调内容。 + Map cacheEntry = new LinkedHashMap<>(); cacheEntry.put("callbackType", callbackType); - cacheEntry.putAll(data); + if (data != null) { + cacheEntry.putAll(data); + } callbackCache.put(requestId, cacheEntry); String sessionId = requestSessionMap.get(requestId); diff --git a/src/main/resources/mapper/contractlaunch/ContractLaunchMapper.xml b/src/main/resources/mapper/contractlaunch/ContractLaunchMapper.xml index c0a3287..e950bcb 100644 --- a/src/main/resources/mapper/contractlaunch/ContractLaunchMapper.xml +++ b/src/main/resources/mapper/contractlaunch/ContractLaunchMapper.xml @@ -21,6 +21,8 @@ + + @@ -199,7 +201,7 @@ select ID, CONTRACT_SYS_ID, CONTRACT_CODE, LAW_CONTRACT_CODE, LAW_CONTRACT_ID, CONTRACT_NAME, CONTRACT_TYPE, CONTRACT_TYPE_CODE, CONTRACT_SOURCE, CREATION_MODE, TEMPLATE_ID, TEMPLATE_CODE, TEMPLATE_NAME, STATUS, APPROVE_STATUS, APPROVE_MESSAGE, - SEAL_CONTRACT_ID, SEAL_CONTRACT_STATUS, CONTRACT_AMOUNT, CURRENCY_NAME, + SEAL_CONTRACT_ID, SEAL_CONTRACT_STATUS, SEAL_POS_CONFIGURED, SEAL_PDF_IMAGE_LIST, CONTRACT_AMOUNT, CURRENCY_NAME, CONTRACT_PERIOD_TYPE, PERIOD_EXPLAIN, EFFECTIVE_START, EFFECTIVE_END, PAYMENT_METHOD, PRIMARY_CONTENT, AMOUNT_EXPLAIN, VALUATION_MODE, NEED_AMENDMENTS, ESTIMATED_AMOUNT, SEAL_TYPE, SEAL_QUANTITY, TEXT_SOURCE, MANDATE_TYPE, CARGO_TYPE, ASSIST_DEPARTMENT_NAME, EXECUTOR_ACCOUNT, @@ -374,7 +376,7 @@ ID, CONTRACT_SYS_ID, CONTRACT_CODE, LAW_CONTRACT_CODE, LAW_CONTRACT_ID, CONTRACT_NAME, CONTRACT_TYPE, CONTRACT_TYPE_CODE, CONTRACT_SOURCE, CREATION_MODE, TEMPLATE_ID, TEMPLATE_CODE, TEMPLATE_NAME, STATUS, APPROVE_STATUS, APPROVE_MESSAGE, - SEAL_CONTRACT_ID, SEAL_CONTRACT_STATUS, CONTRACT_AMOUNT, CURRENCY_NAME, + SEAL_CONTRACT_ID, SEAL_CONTRACT_STATUS, SEAL_POS_CONFIGURED, SEAL_PDF_IMAGE_LIST, CONTRACT_AMOUNT, CURRENCY_NAME, CONTRACT_PERIOD_TYPE, PERIOD_EXPLAIN, EFFECTIVE_START, EFFECTIVE_END, PAYMENT_METHOD, PRIMARY_CONTENT, AMOUNT_EXPLAIN, VALUATION_MODE, NEED_AMENDMENTS, ESTIMATED_AMOUNT, SEAL_TYPE, SEAL_QUANTITY, TEXT_SOURCE, MANDATE_TYPE, CARGO_TYPE, ASSIST_DEPARTMENT_NAME, EXECUTOR_ACCOUNT, @@ -394,7 +396,7 @@ #{id}, #{contractSysId}, #{contractCode}, #{lawContractCode}, #{lawContractId}, #{contractName}, #{contractType}, #{contractTypeCode}, #{contractSource}, #{creationMode}, #{templateId}, #{templateCode}, #{templateName}, #{status}, #{approveStatus}, #{approveMessage}, - #{sealContractId}, #{sealContractStatus}, #{contractAmount}, #{currencyName}, + #{sealContractId}, #{sealContractStatus}, #{sealPosConfigured}, #{sealPdfImageList}, #{contractAmount}, #{currencyName}, #{contractPeriodType}, #{periodExplain}, #{effectiveStart}, #{effectiveEnd}, #{paymentMethod}, #{primaryContent}, #{amountExplain}, #{valuationMode}, #{needAmendments}, #{estimatedAmount}, #{sealType}, #{sealQuantity}, #{textSource}, #{mandateType}, #{cargoType}, #{assistDepartmentName}, #{executorAccount}, @@ -431,6 +433,8 @@ APPROVE_MESSAGE = #{approveMessage}, SEAL_CONTRACT_ID = #{sealContractId}, SEAL_CONTRACT_STATUS = #{sealContractStatus}, + SEAL_POS_CONFIGURED = #{sealPosConfigured}, + SEAL_PDF_IMAGE_LIST = #{sealPdfImageList}, CONTRACT_AMOUNT = #{contractAmount}, CURRENCY_NAME = #{currencyName}, CONTRACT_PERIOD_TYPE = #{contractPeriodType}, diff --git a/src/main/resources/mapper/partner/PartnerManageMapper.xml b/src/main/resources/mapper/partner/PartnerManageMapper.xml index 2b2aca6..bd27821 100644 --- a/src/main/resources/mapper/partner/PartnerManageMapper.xml +++ b/src/main/resources/mapper/partner/PartnerManageMapper.xml @@ -2,14 +2,10 @@ - select SEQ_ILDM_FEE_CUSTOMER_INFO.NEXTVAL from dual - - @@ -243,7 +239,7 @@ ) VALUES ( - #{item.id}, #{item.partnerRecordId}, #{item.contactName}, #{item.contactPhone}, + SEQ_SEAL_PARTNER_CONTACT.NEXTVAL, #{item.partnerRecordId}, #{item.contactName}, #{item.contactPhone}, #{item.postalAddress}, #{item.email}, #{item.sortOrder}, #{delFlag}, #{createBy}, #{createTime}, #{updateBy}, #{updateTime}, #{item.remark} )