package jnpf.bizcommon.audit.service; import jnpf.audit.AuditConsts; import jnpf.audit.model.AuditEventDTO; import jnpf.audit.model.AuditRelatedSignDTO; import jnpf.bizcommon.audit.service.sign.AuditSignEvidence; import jnpf.bizcommon.audit.service.sign.AuditSignEvidenceReader; import jnpf.bizcommon.audit.service.sign.AuditSignMetaData; import jnpf.util.JsonUtil; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import java.nio.charset.StandardCharsets; import java.security.MessageDigest; import java.util.ArrayList; import java.util.LinkedHashSet; import java.util.List; import java.util.LinkedHashMap; import java.util.Map; import java.util.Set; /** 为数据接口、{@code @AuditLog} 和 DMS 受控操作补充请求所携带的电子签名事实。 */ @Slf4j @Service @RequiredArgsConstructor public class DataInterfaceSignAuditService { private static final String SIGN_ACTION = "SIGN"; private static final String REQUEST_SIGN_FIELD = "__request_biz_sign__"; private static final String DATA_INTERFACE = "DATA_INTERFACE"; private static final String DMS_FOLDER = "DMS_FOLDER"; private static final String DMS_FILE = "DMS_FILE"; private static final String DMS_PERMISSION = "DMS_PERMISSION"; private static final String DMS_LOAN_COPY = "DMS_LOAN_COPY"; private static final String PRIMARY = "PRIMARY"; private static final String REVIEW = "REVIEW"; private final AuditSignEvidenceReader signEvidenceReader; private final AuditEventService auditEventService; /** * 处理旧 relatedSignId 和新 relatedSigns。异常向上交给审计投递器进入 spool 重试, * 调用发生在审计服务内部,不会影响已经提交的原始业务请求。 */ public void recordRelatedSign(AuditEventDTO businessEvent) { if (businessEvent == null || !isBusinessEvent(businessEvent.getEventType())) { return; } List relatedSigns = normalizeRelatedSigns(businessEvent); if (relatedSigns.isEmpty()) { return; } List signIds = new ArrayList<>(relatedSigns.size()); for (RelatedSign related : relatedSigns) { signIds.add(related.signId); } Map evidences = signEvidenceReader.read( signIds, businessEvent.getTenantId()); Set roles = new LinkedHashSet<>(); for (RelatedSign related : relatedSigns) { recordRelatedSign(businessEvent, related, evidences.get(related.signId), roles); } } private void recordRelatedSign(AuditEventDTO businessEvent, RelatedSign related, AuditSignEvidence evidence, Set roles) { if (evidence == null) { missingEvidence(businessEvent, "未查询到同租户签名记录", related.signId); return; } if (isBlank(evidence.operatorId()) || !evidence.operatorId().equals(businessEvent.getOperatorId())) { missingEvidence(businessEvent, "签名归属与当前操作人不一致", related.signId); return; } AuditSignMetaData metaData = AuditSignMetaData.parse(evidence.metaData()); String evidenceRole = metaData.isReviewButton() ? REVIEW : PRIMARY; String signatureRole = related.role == null ? evidenceRole : related.role; if (isDmsControlledType(businessEvent.getBizType()) && related.role != null && !related.role.equals(evidenceRole)) { throw new IllegalArgumentException("DMS 签名角色与签名凭证不一致: " + related.signId); } if (!roles.add(signatureRole)) { throw new IllegalArgumentException("同一操作不能包含多个 " + signatureRole + " 签名"); } Map extra = new LinkedHashMap<>(); extra.put("signField", REQUEST_SIGN_FIELD); extra.put("signatureRole", signatureRole); extra.put("auditSource", businessEvent.getBizType()); extra.put("businessTargetTable", businessEvent.getTargetTable()); extra.put("affectedTargetIds", affectedTargetIds(businessEvent)); if (!isBlank(evidence.accountName())) { extra.put("accountName", evidence.accountName()); } if (!isBlank(evidence.opType())) { extra.put("opType", evidence.opType()); } AuditEventDTO signEvent = AuditEventDTO.builder() .clientEventId(signClientEventId(businessEvent, related.signId, signatureRole)) .operationId(businessEvent.getOperationId()) .eventTime(evidence.signedAt() == null ? businessEvent.getEventTime() : evidence.signedAt()) .tenantId(businessEvent.getTenantId()) .operatorId(evidence.operatorId()) .operatorName(businessEvent.getOperatorName()) .operatorOrgId(businessEvent.getOperatorOrgId()) .appName(businessEvent.getAppName()) .bizModule(businessEvent.getBizModule()) .ip(businessEvent.getIp()) .ipRegion(businessEvent.getIpRegion()) .browser(businessEvent.getBrowser()) .os(businessEvent.getOs()) .requestUri(businessEvent.getRequestUri()) .eventType(AuditConsts.TYPE_E_SIGNATURE) .actionCode(SIGN_ACTION) .actionLabel(actionLabel(signatureRole)) .sourceLayer(3) .targetTable(AuditSignEvidenceReader.SIGN_TABLE) .targetId(related.signId) .bizType(businessEvent.getBizType()) .bizCode(businessEvent.getBizCode()) .reason(evidence.note()) .extra(JsonUtil.getObjectToString(extra)) .recordTitle(businessEvent.getRecordTitle()) .entryType(businessEvent.getEntryType()) .entryId(businessEvent.getEntryId()) .entryName(businessEvent.getEntryName()) .build(); auditEventService.saveIdempotent(signEvent); } private static List normalizeRelatedSigns(AuditEventDTO event) { Map normalized = new LinkedHashMap<>(); if (!isBlank(event.getRelatedSignId())) { String signId = event.getRelatedSignId().trim(); normalized.put(signId, new RelatedSign(signId, null)); } if (event.getRelatedSigns() != null) { for (AuditRelatedSignDTO item : event.getRelatedSigns()) { if (item == null || isBlank(item.getSignId())) { continue; } String signId = item.getSignId().trim(); String role = normalizeRole(item.getRole()); RelatedSign previous = normalized.put(signId, new RelatedSign(signId, role)); if (previous != null && previous.role != null && role != null && !previous.role.equals(role)) { throw new IllegalArgumentException("同一签名 ID 不能同时声明为主签和复核签: " + signId); } } } return new ArrayList<>(normalized.values()); } private void missingEvidence(AuditEventDTO event, String message, String signId) { if (isDmsControlledType(event.getBizType())) { throw new IllegalStateException(message + ": " + signId); } log.warn("[RELATED-SIGN-AUDIT] {},跳过签名事件 clientEventId={} signId={}", message, event.getClientEventId(), signId); } private static String signClientEventId(AuditEventDTO event, String signId, String role) { if (DATA_INTERFACE.equals(event.getBizType())) { return digestId("DIS-", value(event.getOperationId()) + "\n" + value(signId)); } String prefix = DMS_FILE.equals(event.getBizType()) ? "DFF-" : (DMS_PERMISSION.equals(event.getBizType()) ? "DFP-" : (DMS_FOLDER.equals(event.getBizType()) ? "DFS-" : (DMS_LOAN_COPY.equals(event.getBizType()) ? "DLC-" : "ARS-"))); String bizTypePart = "ARS-".equals(prefix) ? value(event.getBizType()) + "\n" : ""; return digestId(prefix, bizTypePart + value(event.getOperationId()) + "\n" + value(signId) + "\n" + value(role)); } private static String digestId(String prefix, String source) { try { byte[] digest = MessageDigest.getInstance("SHA-256") .digest(source.getBytes(StandardCharsets.UTF_8)); StringBuilder hex = new StringBuilder(prefix); for (byte item : digest) { hex.append(String.format("%02x", item & 0xff)); } return hex.toString(); } catch (Exception e) { throw new IllegalStateException("生成关联签名事件幂等键失败", e); } } private static String actionLabel(String role) { return REVIEW.equals(role) ? "复审签名" : "电子签名"; } private static boolean isBusinessEvent(String eventType) { return AuditConsts.TYPE_DATA_CHANGE.equals(eventType) || AuditConsts.TYPE_BIZ_ACTION.equals(eventType); } private static boolean isDmsControlledType(String bizType) { return DMS_FOLDER.equals(bizType) || DMS_FILE.equals(bizType) || DMS_PERMISSION.equals(bizType) || DMS_LOAN_COPY.equals(bizType); } @SuppressWarnings("unchecked") private static List affectedTargetIds(AuditEventDTO event) { Set ids = new LinkedHashSet<>(); if (!isBlank(event.getTargetId())) { ids.add(event.getTargetId()); } if (!DMS_PERMISSION.equals(event.getBizType()) || isBlank(event.getExtra())) { return new ArrayList<>(ids); } try { Map extra = JsonUtil.stringToMap(event.getExtra()); Object targets = extra == null ? null : extra.get("affectedTargets"); if (targets instanceof Iterable) { for (Object target : (Iterable) targets) { if (!(target instanceof Map)) continue; Object resourceId = ((Map) target).get("resourceId"); if (resourceId != null && !String.valueOf(resourceId).trim().isEmpty()) { ids.add(String.valueOf(resourceId).trim()); } } } } catch (RuntimeException ex) { log.warn("[RELATED-SIGN-AUDIT] DMS permission affectedTargets parse failed clientEventId={}", event.getClientEventId(), ex); } return new ArrayList<>(ids); } private static String normalizeRole(String role) { if (isBlank(role)) { return null; } String normalized = role.trim().toUpperCase(java.util.Locale.ROOT); if (!PRIMARY.equals(normalized) && !REVIEW.equals(normalized)) { throw new IllegalArgumentException("签名角色只允许 PRIMARY 或 REVIEW"); } return normalized; } private static String value(String text) { return text == null ? "" : text; } private static boolean isBlank(String text) { return text == null || text.trim().isEmpty(); } private static final class RelatedSign { private final String signId; private final String role; private RelatedSign(String signId, String role) { this.signId = signId; this.role = role; } } }