Parcourir la source

禀议书子表赋值时间戳修改

CRK il y a 3 semaines
Parent
commit
dfdba1448c

+ 78 - 1
SEAL_DEPLOYMENT.md

@@ -7,7 +7,84 @@ POST /api/changguchuan/seal/approval-complete
 GET  /api/changguchuan/seal/health
 ```
 
-请求体最少需要传入当前宜搭流程实例 ID:
+## 按审批记录生成个人时间戳印章
+
+新增接口:`POST /api/changguchuan/seal/approval-records`。只需传业务表单的宜搭数据 ID,不需要传审批人、审批时间、节点或模板 URL:
+
+```json
+{
+  "formInstanceId": "FINST-实际业务数据ID"
+}
+```
+
+也支持 query 参数 `formInstanceId`,以及 `formInstId`、`instanceId`、`processInstanceId` 同义字段。
+如果已设置 `SEAL_CONNECTOR_TOKEN`,同样需要请求头 `X-Seal-Connector-Token`。
+
+处理范围固定为以下用户指定的表单和字段:
+
+| 用途 | 表单 / 字段 |
+| --- | --- |
+| 业务审批表单 | `FORM-64A9D5065C234383AB2ACF7AB4F20975DW6M` |
+| 审批人个人模板表单 | `FORM-EADCA8349AB34236A8873ECA7BCFBE7CDMG4` |
+| 模板人员字段 | `employeeField_mv7hjsv3`,按审批人的钉钉 userId 精确匹配 |
+| 模板图片字段 | `imageField_mubc5ugk` |
+| 回写子表 | `tableField_mv7h9ypq` |
+| 子表节点名称 | `textField_mv7h9yps` |
+| 子表时间戳印章 | `imageField_mv7h9ypr`,图片对象数组 |
+
+接口先通过 v2.0 流程实例详情取得真实 `processInstanceId` 并验证所属表单,再通过
+`GetOperationRecords`(`/v1.0/yida/processes/operationRecords`)获取审批历史。
+会签 `domainList` 会展开为每个实际审批人;同意及拒绝/驳回记录均保留,并在返回值中标明
+`approvalAction`。提交、抄送、转交和待审批记录不会盖章。同一人在不同节点或重新审批时
+分别保留,只有完全相同的任务、人员、时间、节点和动作才去重,结果按实际审批时间升序排列。
+
+图片使用该审批人的模板,日期和时分秒分两行绘制(`yyyy-MM-dd` / `HH:mm:ss`),默认时区
+`Asia/Shanghai`。带时区时间和 Unix 时间戳先转换至配置时区,无偏移的时间文本按配置时区
+解读。审批时间缺失或无法解析会报错,不使用服务器当前日期代替。
+
+所有图片生成成功后,通过流程 `UpdateInstance` 一次性回写完整子表,仅提交该子表字段。
+这会**替换指定子表的原有行**,而不是追加;请将该子表作为此接口生成的审批印章明细使用。
+模板缺失、模板不唯一、图片为空、下载失败或没有已执行审批记录时返回错误,不写入部分列表,
+也不清空已有子表。个人模板下载失败不会使用旧接口的固定底图或本地通用底图替代。
+错误发生前已生成的本地图片可能留在 `sealFile` 中;重复请求会重新生成图片并替换子表行。
+
+成功返回的 `data.list` 示例(省略图片文件大小等元数据):
+
+```json
+{
+  "success": true,
+  "code": 200,
+  "data": {
+    "formInstanceId": "FINST-实际业务数据ID",
+    "list": [
+      {
+        "approverUserId": "钉钉userId",
+        "approverName": "审批人姓名",
+        "approvalTime": "2026-10-14 09:02:03",
+        "approvalAction": "agree",
+        "nodeName": "部门审核",
+        "fileUrl": "http://139.196.11.100:8101/sealFile/2026/10/14/seal-FINST-xxx-uuid.png",
+        "sealImages": [{"url": "http://139.196.11.100:8101/sealFile/2026/10/14/seal-FINST-xxx-uuid.png"}]
+      }
+    ]
+  }
+}
+```
+
+服务仍使用现有 `seal.yida.*` 凭证和 `sealFile` 静态目录。目标应用需授权流程实例读取、审批记录
+读取、模板表单查询、图片免登以及流程实例更新权限。首次联调使用真实业务数据 ID,验证返回的
+节点、审批时间和个人印章,以及宜搭子表的显示;本地测试均使用模拟宜搭响应,没有修改线上记录。
+
+验证命令(只运行隔离的印章测试,避免执行旧的真实接口测试):
+
+```powershell
+mvn "-Dtest=ApprovalRecordParserTest,SealApprovalRecordsServiceTest,SealApprovalRecordsControllerTest,SealControllerTest,SealServiceImplTest" test
+mvn clean package -DskipTests
+```
+
+## 原审批通过单张印章接口
+
+原 `approval-complete` 接口的请求体最少需要传入当前宜搭流程实例 ID:
 
 ```json
 {

+ 38 - 0
src/main/java/com/muzhi/changguchuan/seal/controller/SealController.java

@@ -4,6 +4,8 @@ import com.muzhi.changguchuan.common.R;
 import com.muzhi.changguchuan.seal.config.SealProperties;
 import com.muzhi.changguchuan.seal.model.SealApprovalRequest;
 import com.muzhi.changguchuan.seal.model.SealResult;
+import com.muzhi.changguchuan.seal.model.SealApprovalRecordsRequest;
+import com.muzhi.changguchuan.seal.model.SealApprovalRecordResult;
 import com.muzhi.changguchuan.seal.service.SealService;
 import io.swagger.annotations.Api;
 import io.swagger.annotations.ApiOperation;
@@ -22,6 +24,7 @@ import org.springframework.web.bind.annotation.RequestParam;
 import org.springframework.web.bind.annotation.RestController;
 
 import java.util.Map;
+import java.util.List;
 
 /**
  * 印章生成 HTTP 接口。
@@ -101,6 +104,41 @@ public class SealController {
         }
     }
 
+    /**
+     * 按宜搭数据 ID 读取实际审批明细,生成个人印章并回写审批子表。
+     * @param body JSON 请求,只需 formInstanceId
+     * @param params 连接器 query 参数,兼容 formInstId/instanceId/processInstanceId
+     * @param connectorToken 与原印章接口相同的连接器令牌
+     * @return data.list 为审批时间、审批人、节点及时间戳印章列表
+     */
+    @ApiOperation(value = "根据宜搭数据ID生成审批记录时间戳印章并回写子表")
+    @PostMapping("/approval-records")
+    public ResponseEntity<R> approvalRecords(
+            @RequestBody(required = false) SealApprovalRecordsRequest body,
+            @RequestParam Map<String, String> params,
+            @RequestHeader(value = "X-Seal-Connector-Token", required = false) String connectorToken) {
+        if (!StringUtils.isEmpty(properties.getConnectorToken())
+                && !properties.getConnectorToken().equals(connectorToken)) {
+            return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(R.error().message("连接器鉴权失败"));
+        }
+        String instanceId = body == null ? null : body.getFormInstanceId();
+        if (instanceId == null || instanceId.trim().isEmpty()) {
+            instanceId = params == null ? null : first(params, "formInstanceId", "formInstId", "instanceId", "processInstanceId");
+        }
+        if (instanceId == null || instanceId.trim().isEmpty()) {
+            return ResponseEntity.badRequest().body(R.error().message("缺少宜搭数据 ID(formInstanceId)"));
+        }
+        try {
+            List<SealApprovalRecordResult> results = sealService.generateApprovalRecordsAndWrite(instanceId.trim());
+            return ResponseEntity.ok(R.ok().message("审批时间戳印章已生成并回写子表")
+                    .data("formInstanceId", instanceId.trim()).data("list", results));
+        } catch (Exception e) {
+            log.error("[seal.http] 审批记录印章失败,formInstanceId={}, error={}", instanceId, e.getMessage(), e);
+            return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
+                    .body(R.error().message(e.getMessage() == null ? "生成审批记录印章失败" : e.getMessage()));
+        }
+    }
+
     /** 返回简单健康状态,供 Nginx、云服务器和部署检查使用。 */
     @ApiOperation(value = "印章服务健康检查")
     @GetMapping("/health")

+ 9 - 0
src/main/java/com/muzhi/changguchuan/seal/service/SealService.java

@@ -2,6 +2,8 @@ package com.muzhi.changguchuan.seal.service;
 
 import com.muzhi.changguchuan.seal.model.SealApprovalRequest;
 import com.muzhi.changguchuan.seal.model.SealResult;
+import com.muzhi.changguchuan.seal.model.SealApprovalRecordResult;
+import java.util.List;
 
 public interface SealService {
 
@@ -12,4 +14,11 @@ public interface SealService {
      * @return 图片路径、图片 URL 和审批日期等处理结果
      */
     SealResult generateAndWrite(SealApprovalRequest request);
+
+    /**
+     * 从实际审批记录生成每位审批人的印章,并替换业务实例的审批印章子表。
+     * @param formInstanceId 目标表单的宜搭数据 ID
+     * @return 按实际审批时间排序的印章及节点列表
+     */
+    List<SealApprovalRecordResult> generateApprovalRecordsAndWrite(String formInstanceId);
 }

+ 197 - 1
src/main/java/com/muzhi/changguchuan/seal/service/impl/SealServiceImpl.java

@@ -14,6 +14,14 @@ import com.aliyun.dingtalkyida_2_0.models.UpdateFormDataRequest;
 import com.aliyun.dingtalkyida_2_0.models.UpdateFormDataResponse;
 import com.aliyun.tea.TeaException;
 import com.muzhi.changguchuan.seal.config.SealProperties;
+import com.muzhi.changguchuan.seal.client.SealApprovalYidaClient;
+import com.muzhi.changguchuan.seal.model.SealApprovalRecordResult;
+import com.aliyun.dingtalkyida_1_0.models.GetOperationRecordsRequest;
+import com.aliyun.dingtalkyida_1_0.models.GetOperationRecordsResponse;
+import com.aliyun.dingtalkyida_1_0.models.SearchFormDatasRequest;
+import com.aliyun.dingtalkyida_1_0.models.SearchFormDatasResponse;
+import com.aliyun.dingtalkyida_1_0.models.UpdateInstanceRequest;
+import com.aliyun.dingtalkyida_1_0.models.UpdateInstanceResponse;
 import com.muzhi.changguchuan.seal.model.SealApprovalRequest;
 import com.muzhi.changguchuan.seal.model.SealResult;
 import com.muzhi.changguchuan.seal.service.SealService;
@@ -56,6 +64,7 @@ import java.util.List;
 import java.util.Locale;
 import java.util.Map;
 import java.util.UUID;
+import java.util.Collections;
 
 /**
  * 印章生成业务实现。
@@ -67,6 +76,17 @@ import java.util.UUID;
 @Slf4j
 public class SealServiceImpl implements SealService {
 
+    static final String APPROVAL_FORM = "FORM-64A9D5065C234383AB2ACF7AB4F20975DW6M";
+    static final String APPROVER_TEMPLATE_FORM = "FORM-EADCA8349AB34236A8873ECA7BCFBE7CDMG4";
+    static final String APPROVER_FIELD = "employeeField_mv7hjsv3";
+    static final String APPROVER_IMAGE_FIELD = "imageField_mubc5ugk";
+    static final String APPROVAL_TABLE = "tableField_mv7h9ypq";
+    static final String NODE_FIELD = "textField_mv7h9yps";
+    static final String STAMP_FIELD = "imageField_mv7h9ypr";
+
+    @Autowired
+    private SealApprovalYidaClient approvalYidaClient;
+
     /** 文件夹按年/月/日分层,便于长期运行时管理文件。 */
     private static final DateTimeFormatter DIRECTORY_DATE = DateTimeFormatter.ofPattern("yyyy/MM/dd");
     /** 图片中显示的审批日期格式。 */
@@ -139,6 +159,166 @@ public class SealServiceImpl implements SealService {
         return new SealResult(instanceId, formUuid, storedFile.path.toString(), storedFile.url, approvalDate);
     }
 
+    /**
+     * 按实际审批记录生成个人时间戳印章,全部成功后统一替换指定子表。
+     * @param formInstanceId 业务表单的宜搭数据 ID
+     * @return 按审批时间排序的印章列表
+     */
+    @Override
+    public List<SealApprovalRecordResult> generateApprovalRecordsAndWrite(String formInstanceId) {
+        if (isBlank(formInstanceId)) throw new IllegalArgumentException("缺少宜搭数据 ID(formInstanceId)");
+        String instanceId = formInstanceId.trim();
+        String appType = properties.getAppType();
+        String systemToken = requiredSystemToken();
+        String token = required(accessTokenService.getAccessToken(), "无法获取钉钉 AccessToken");
+        ZoneId zone = ZoneId.of(properties.getTimeZone());
+        try {
+            GetInstanceByIdResponse instanceResponse = approvalYidaClient.getInstance(instanceId,
+                    new GetInstanceByIdRequest().setAppType(appType).setSystemToken(systemToken)
+                            .setUserId(properties.getYidaUserId()).setFormUuid(APPROVAL_FORM), token);
+            validateApprovalResponse(instanceResponse == null ? null : instanceResponse.getStatusCode(), "读取业务流程实例");
+            JSONObject instance = unwrapContent(instanceResponse.getBody());
+            if (instance == null || !APPROVAL_FORM.equals(instance.getString("formUuid"))) {
+                throw new IllegalStateException("宜搭数据不属于指定审批表单:" + APPROVAL_FORM);
+            }
+            // 数据 ID 与流程 ID 不能自行互换;以查询结果中的 processInstanceId 调用流程接口。
+            String processId = required(instance.getString("processInstanceId"), "业务数据没有关联的流程实例 ID");
+            GetOperationRecordsResponse operations = approvalYidaClient.getOperationRecords(
+                    new GetOperationRecordsRequest().setAppType(appType).setSystemToken(systemToken)
+                            .setUserId(properties.getYidaUserId()).setProcessInstanceId(processId).setLanguage("zh_CN"), token);
+            validateApprovalResponse(operations == null ? null : operations.getStatusCode(), "查询审批记录");
+            JSONObject operationBody = unwrapContent(operations.getBody());
+            List<ApprovalRecordParser.ApprovalRecord> records = ApprovalRecordParser.parse(
+                    operationBody == null ? null : operationBody.getJSONArray("result"), zone);
+            if (records.isEmpty()) throw new IllegalStateException("尚无已执行的人工审批记录,不回写审批印章子表");
+
+            Map<String, byte[]> templateCache = new HashMap<>();
+            List<SealApprovalRecordResult> results = new ArrayList<>();
+            DateTimeFormatter timestamp = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss").withZone(zone);
+            for (ApprovalRecordParser.ApprovalRecord record : records) {
+                byte[] template = templateCache.get(record.getUserId());
+                if (template == null) {
+                    String templateUrl = findApproverTemplateUrl(record.getUserId(), appType, systemToken, token);
+                    template = loadApproverTemplate(templateUrl, appType, token);
+                    templateCache.put(record.getUserId(), template);
+                }
+                String approvalTime = timestamp.format(record.getTime());
+                // 日期和时分秒分两行绘制,避免完整时间在个人印章上被截断。
+                byte[] stamped = renderSeal(template, approvalTime.replace(' ', '\n'));
+                StoredFile file = store(stamped, instanceId, approvalTime);
+                results.add(new SealApprovalRecordResult(record.getUserId(), record.getName(), approvalTime,
+                        record.getAction(), record.getNodeName(), file.url, Collections.singletonList(imageValue(file))));
+            }
+            Map<String, Object> update = new LinkedHashMap<>();
+            update.put(APPROVAL_TABLE, approvalRows(results));
+            UpdateInstanceResponse writeResponse = approvalYidaClient.updateInstance(new UpdateInstanceRequest()
+                    .setAppType(appType).setSystemToken(systemToken).setUserId(properties.getYidaUserId())
+                    .setProcessInstanceId(processId).setUpdateFormDataJson(JSON.toJSONString(update)), token);
+            validateApprovalResponse(writeResponse == null ? null : writeResponse.getStatusCode(), "回写审批印章子表");
+            log.info("[seal.records] 审批印章子表回写成功,formInstanceId={}, processInstanceId={}, rows={}",
+                    instanceId, processId, results.size());
+            return results;
+        } catch (RuntimeException e) {
+            throw e;
+        } catch (Exception e) {
+            throw new IllegalStateException("审批记录印章处理失败:" + summarize(e), e);
+        }
+    }
+
+    private String findApproverTemplateUrl(String userId, String appType, String systemToken, String token) throws Exception {
+        Map<String, Object> search = new LinkedHashMap<>();
+        search.put(APPROVER_FIELD, Collections.singletonList(userId));
+        String selectedUrl = null;
+        int matched = 0;
+        for (int page = 1; page <= 1000; page++) {
+            SearchFormDatasResponse response = approvalYidaClient.searchFormDatas(new SearchFormDatasRequest()
+                    .setAppType(appType).setSystemToken(systemToken).setUserId(properties.getYidaUserId())
+                    .setFormUuid(APPROVER_TEMPLATE_FORM).setSearchFieldJson(JSON.toJSONString(search))
+                    .setCurrentPage(page).setPageSize(100), token);
+            validateApprovalResponse(response == null ? null : response.getStatusCode(), "查询审批人印章模板");
+            JSONObject body = unwrapContent(response.getBody());
+            if (body == null || body.getJSONArray("data") == null) throw new IllegalStateException("印章模板查询响应缺少 data");
+            JSONArray data = body.getJSONArray("data");
+            for (int i = 0; i < data.size(); i++) {
+                JSONObject fields = getFieldContainer(data.getJSONObject(i));
+                if (fields == null || !memberContains(fields, userId)) continue;
+                matched++;
+                if (matched > 1) throw new IllegalStateException("审批人匹配到多条印章模板,请保留唯一模板:" + userId);
+                selectedUrl = extractImageUrl(data.getJSONObject(i), APPROVER_IMAGE_FIELD);
+                if (isBlank(selectedUrl)) throw new IllegalStateException("审批人印章模板图片为空:" + userId);
+            }
+            Long total = body.getLong("totalCount");
+            if (data.isEmpty()) {
+                if (total != null && total > (long) (page - 1) * 100) throw new IllegalStateException("印章模板分页提前返回空数据");
+                break;
+            }
+            if (total != null ? (long) page * 100 >= total : data.size() < 100) break;
+            if (page == 1000) throw new IllegalStateException("印章模板分页超过安全上限");
+        }
+        if (matched == 0) throw new IllegalStateException("未找到审批人的个人印章模板:" + userId);
+        return selectedUrl;
+    }
+
+    static boolean memberContains(JSONObject fields, String userId) {
+        Object value = fields.get(APPROVER_FIELD + "_id");
+        if (value == null) value = fields.get(APPROVER_FIELD);
+        return memberValueContains(value, userId);
+    }
+
+    private static boolean memberValueContains(Object raw, String userId) {
+        if (raw instanceof Iterable) {
+            for (Object value : (Iterable<?>) raw) if (memberValueContains(value, userId)) return true;
+        } else if (raw instanceof Map) {
+            Map<?, ?> member = (Map<?, ?>) raw;
+            return userId.equals(member.get("userId")) || userId.equals(member.get("emplId")) || userId.equals(member.get("id"));
+        } else if (raw != null) {
+            String text = String.valueOf(raw).trim();
+            if (userId.equals(text)) return true;
+            if (text.startsWith("[")) return memberValueContains(JSON.parseArray(text), userId);
+        }
+        return false;
+    }
+
+    /** 个人模板下载失败直接报错,禁止回退到其他人的固定模板。 */
+    byte[] loadApproverTemplate(String url, String appType, String token) {
+        String openUrl = getOpenUrl(appType, url, token);
+        return download(firstNonBlank(openUrl, url));
+    }
+
+    static List<Map<String, Object>> approvalRows(List<SealApprovalRecordResult> results) {
+        List<Map<String, Object>> rows = new ArrayList<>();
+        for (SealApprovalRecordResult result : results) {
+            Map<String, Object> row = new LinkedHashMap<>();
+            row.put(NODE_FIELD, result.getNodeName());
+            row.put(STAMP_FIELD, result.getSealImages());
+            rows.add(row);
+        }
+        return rows;
+    }
+
+    private JSONObject unwrapContent(Object body) {
+        if (body == null) return null;
+        JSONObject object = JSON.parseObject(JSON.toJSONString(body));
+        return object.getJSONObject("content") == null ? object : object.getJSONObject("content");
+    }
+
+    private void validateApprovalResponse(Integer status, String operation) {
+        if (status == null || status < 200 || status >= 300) {
+            throw new IllegalStateException(operation + "失败,HTTP 状态码:" + status);
+        }
+    }
+
+    private Map<String, Object> imageValue(StoredFile file) {
+        Map<String, Object> image = new LinkedHashMap<>();
+        image.put("name", file.name);
+        image.put("size", file.size);
+        image.put("fileUuid", file.url);
+        image.put("url", file.url);
+        image.put("downloadUrl", file.url);
+        image.put("previewUrl", file.url);
+        return image;
+    }
+
     /** 通过宜搭 GetInstanceById 接口读取流程实例详情和表单数据。 */
     private JSONObject getInstance(String appType, String instanceId, String formUuid, String accessToken) {
         log.info("[seal.yida] GetInstanceById 开始,appType={}, instanceId={}, baseUrl={}, userId={}, systemToken={}",
@@ -406,6 +586,8 @@ public class SealServiceImpl implements SealService {
             int centerY = findBlankCenter(source);
             // 字体大小根据图片尺寸计算,避免固定字号在不同分辨率下过大或过小。
             int fontSize = Math.max(12, Math.min(source.getWidth(), source.getHeight()) / 10);
+            String[] lines = dateText.split("\\n");
+            if (lines.length > 1) fontSize = Math.max(1, Math.min(fontSize, source.getHeight() / (lines.length * 4)));
             Font font = new Font("宋体", Font.BOLD, fontSize);
             if (font.getFamily(Locale.CHINA).equalsIgnoreCase("Dialog")) {
                 font = new Font(Font.SERIF, Font.BOLD, fontSize);
@@ -413,13 +595,27 @@ public class SealServiceImpl implements SealService {
             graphics.setFont(font);
             graphics.setColor(ink);
             FontMetrics metrics = graphics.getFontMetrics();
+            if (lines.length > 1) {
+                int widest = 0;
+                for (String line : lines) widest = Math.max(widest, metrics.stringWidth(line));
+                while (widest > source.getWidth() * 0.85 && fontSize > 1) {
+                    graphics.setFont(font.deriveFont((float) --fontSize));
+                    metrics = graphics.getFontMetrics();
+                    widest = 0;
+                    for (String line : lines) widest = Math.max(widest, metrics.stringWidth(line));
+                }
+            }
             int x = (source.getWidth() - metrics.stringWidth(dateText)) / 2;
             // drawString 的 y 是文字基线;用 ascent-descent 计算,才能让实际字形包围盒的中心
             // 对齐印章上下横线之间的空白中心。旧公式把 descent 也向上扣除,导致日期整体偏上。
             int baselineY = centerY + (metrics.getAscent() - metrics.getDescent()) / 2;
             log.info("[seal.image] 日期文字定位,centerY={}, baselineY={}, x={}, ascent={}, descent={}",
                     centerY, baselineY, x, metrics.getAscent(), metrics.getDescent());
-            graphics.drawString(dateText, x, baselineY);
+            for (int i = 0; i < lines.length; i++) {
+                int lineX = (source.getWidth() - metrics.stringWidth(lines[i])) / 2;
+                int lineY = baselineY + (int) Math.round((i - (lines.length - 1) / 2.0) * metrics.getHeight());
+                graphics.drawString(lines[i], lineX, lineY);
+            }
             graphics.dispose();
 
             ByteArrayOutputStream output = new ByteArrayOutputStream();