|
@@ -14,6 +14,14 @@ import com.aliyun.dingtalkyida_2_0.models.UpdateFormDataRequest;
|
|
|
import com.aliyun.dingtalkyida_2_0.models.UpdateFormDataResponse;
|
|
import com.aliyun.dingtalkyida_2_0.models.UpdateFormDataResponse;
|
|
|
import com.aliyun.tea.TeaException;
|
|
import com.aliyun.tea.TeaException;
|
|
|
import com.muzhi.changguchuan.seal.config.SealProperties;
|
|
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.SealApprovalRequest;
|
|
|
import com.muzhi.changguchuan.seal.model.SealResult;
|
|
import com.muzhi.changguchuan.seal.model.SealResult;
|
|
|
import com.muzhi.changguchuan.seal.service.SealService;
|
|
import com.muzhi.changguchuan.seal.service.SealService;
|
|
@@ -56,6 +64,7 @@ import java.util.List;
|
|
|
import java.util.Locale;
|
|
import java.util.Locale;
|
|
|
import java.util.Map;
|
|
import java.util.Map;
|
|
|
import java.util.UUID;
|
|
import java.util.UUID;
|
|
|
|
|
+import java.util.Collections;
|
|
|
|
|
|
|
|
/**
|
|
/**
|
|
|
* 印章生成业务实现。
|
|
* 印章生成业务实现。
|
|
@@ -67,6 +76,17 @@ import java.util.UUID;
|
|
|
@Slf4j
|
|
@Slf4j
|
|
|
public class SealServiceImpl implements SealService {
|
|
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");
|
|
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);
|
|
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 接口读取流程实例详情和表单数据。 */
|
|
/** 通过宜搭 GetInstanceById 接口读取流程实例详情和表单数据。 */
|
|
|
private JSONObject getInstance(String appType, String instanceId, String formUuid, String accessToken) {
|
|
private JSONObject getInstance(String appType, String instanceId, String formUuid, String accessToken) {
|
|
|
log.info("[seal.yida] GetInstanceById 开始,appType={}, instanceId={}, baseUrl={}, userId={}, systemToken={}",
|
|
log.info("[seal.yida] GetInstanceById 开始,appType={}, instanceId={}, baseUrl={}, userId={}, systemToken={}",
|
|
@@ -406,6 +586,8 @@ public class SealServiceImpl implements SealService {
|
|
|
int centerY = findBlankCenter(source);
|
|
int centerY = findBlankCenter(source);
|
|
|
// 字体大小根据图片尺寸计算,避免固定字号在不同分辨率下过大或过小。
|
|
// 字体大小根据图片尺寸计算,避免固定字号在不同分辨率下过大或过小。
|
|
|
int fontSize = Math.max(12, Math.min(source.getWidth(), source.getHeight()) / 10);
|
|
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);
|
|
Font font = new Font("宋体", Font.BOLD, fontSize);
|
|
|
if (font.getFamily(Locale.CHINA).equalsIgnoreCase("Dialog")) {
|
|
if (font.getFamily(Locale.CHINA).equalsIgnoreCase("Dialog")) {
|
|
|
font = new Font(Font.SERIF, Font.BOLD, fontSize);
|
|
font = new Font(Font.SERIF, Font.BOLD, fontSize);
|
|
@@ -413,13 +595,27 @@ public class SealServiceImpl implements SealService {
|
|
|
graphics.setFont(font);
|
|
graphics.setFont(font);
|
|
|
graphics.setColor(ink);
|
|
graphics.setColor(ink);
|
|
|
FontMetrics metrics = graphics.getFontMetrics();
|
|
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;
|
|
int x = (source.getWidth() - metrics.stringWidth(dateText)) / 2;
|
|
|
// drawString 的 y 是文字基线;用 ascent-descent 计算,才能让实际字形包围盒的中心
|
|
// drawString 的 y 是文字基线;用 ascent-descent 计算,才能让实际字形包围盒的中心
|
|
|
// 对齐印章上下横线之间的空白中心。旧公式把 descent 也向上扣除,导致日期整体偏上。
|
|
// 对齐印章上下横线之间的空白中心。旧公式把 descent 也向上扣除,导致日期整体偏上。
|
|
|
int baselineY = centerY + (metrics.getAscent() - metrics.getDescent()) / 2;
|
|
int baselineY = centerY + (metrics.getAscent() - metrics.getDescent()) / 2;
|
|
|
log.info("[seal.image] 日期文字定位,centerY={}, baselineY={}, x={}, ascent={}, descent={}",
|
|
log.info("[seal.image] 日期文字定位,centerY={}, baselineY={}, x={}, ascent={}, descent={}",
|
|
|
centerY, baselineY, x, metrics.getAscent(), metrics.getDescent());
|
|
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();
|
|
graphics.dispose();
|
|
|
|
|
|
|
|
ByteArrayOutputStream output = new ByteArrayOutputStream();
|
|
ByteArrayOutputStream output = new ByteArrayOutputStream();
|