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<RelatedSign> relatedSigns = normalizeRelatedSigns(businessEvent);
|
if (relatedSigns.isEmpty()) {
|
return;
|
}
|
List<String> signIds = new ArrayList<>(relatedSigns.size());
|
for (RelatedSign related : relatedSigns) {
|
signIds.add(related.signId);
|
}
|
Map<String, AuditSignEvidence> evidences = signEvidenceReader.read(
|
signIds, businessEvent.getTenantId());
|
Set<String> 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<String> 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<String, Object> 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<RelatedSign> normalizeRelatedSigns(AuditEventDTO event) {
|
Map<String, RelatedSign> 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<String> affectedTargetIds(AuditEventDTO event) {
|
Set<String> 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<String, Object> extra = JsonUtil.stringToMap(event.getExtra());
|
Object targets = extra == null ? null : extra.get("affectedTargets");
|
if (targets instanceof Iterable) {
|
for (Object target : (Iterable<Object>) targets) {
|
if (!(target instanceof Map)) continue;
|
Object resourceId = ((Map<String, Object>) 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;
|
}
|
}
|
}
|