package jnpf.bizcommon.audit.service.impl;
|
|
import com.alibaba.fastjson.JSON;
|
import jnpf.audit.model.AuditEventDTO;
|
import jnpf.audit.AuditConsts;
|
import jnpf.bizcommon.audit.entity.AuditEventEntity;
|
import jnpf.bizcommon.audit.mapper.AuditEventMapper;
|
import jnpf.bizcommon.audit.service.AuditEventService;
|
import jnpf.bizcommon.audit.service.AuditFormRegistry;
|
import jnpf.bizcommon.audit.service.support.AuditDiffKey;
|
import jnpf.bizcommon.audit.service.support.AuditEventCategories;
|
import jnpf.util.JsonUtil;
|
import lombok.extern.slf4j.Slf4j;
|
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.stereotype.Service;
|
import org.springframework.transaction.annotation.Transactional;
|
|
import java.util.ArrayList;
|
import java.util.Collection;
|
import java.util.Collections;
|
import java.util.Date;
|
import java.util.LinkedHashMap;
|
import java.util.LinkedHashSet;
|
import java.util.List;
|
import java.util.Locale;
|
import java.util.Map;
|
import java.util.Set;
|
import java.util.function.Function;
|
|
@Slf4j
|
@Service
|
public class AuditEventServiceImpl implements AuditEventService {
|
|
@Autowired
|
private AuditEventMapper auditEventMapper;
|
|
@Autowired
|
private AuditFormRegistry auditFormRegistry;
|
|
@Override
|
@Transactional(rollbackFor = Exception.class)
|
public int saveIdempotent(AuditEventDTO dto) {
|
validate(dto);
|
AuditEventEntity entity = toEntity(dto);
|
entity.setEventCategory(AuditEventCategories.resolve(entity.getEventType(), entity.getExtra()));
|
entity.setFieldDiffs(scopeFieldDiffs(entity.getFieldDiffs(),
|
entity.getTargetTable(), entity.getTargetId()));
|
entity.setFieldDiffs(filterFieldDiffs(entity.getFieldDiffs(), entity.getTargetTable(),
|
auditFormRegistry::resolvePhysicalColumns));
|
int rows = auditEventMapper.insertIgnore(entity);
|
if (rows > 0 || isBlank(entity.getOperationId())) {
|
return rows;
|
}
|
|
AuditEventEntity existing = auditEventMapper.selectByOperationCategoryForUpdate(
|
entity.getOperationId(), entity.getEventCategory());
|
if (existing == null || entity.getClientEventId().equals(existing.getClientEventId())) {
|
log.info("[audit] duplicate client_event_id ignored, clientEventId={}", dto.getClientEventId());
|
return 0;
|
}
|
mergeInto(existing, entity);
|
auditEventMapper.updateMerged(existing);
|
log.info("[audit] merged operation event, operationId={}, category={}, sourceClientEventId={}",
|
entity.getOperationId(), entity.getEventCategory(), entity.getClientEventId());
|
return 1;
|
}
|
|
private void mergeInto(AuditEventEntity existing, AuditEventEntity incoming) {
|
// 升级前的已存事件没有逐项 targetId;参与迟到事件/重试合并时,用事件顶层主键补齐。
|
// 只修改本次锁定的内存实体,历史数据无需离线迁移。
|
existing.setFieldDiffs(scopeFieldDiffs(existing.getFieldDiffs(),
|
existing.getTargetTable(), existing.getTargetId()));
|
Map<String, Object> existingExtra = parseExtra(existing.getExtra());
|
Map<String, Object> incomingExtra = parseExtra(incoming.getExtra());
|
String existingSnapshotKey = snapshotKey(existing.getTargetTable(), existing.getTargetId());
|
String existingSnapshot = existing.getDataSnapshot();
|
String incomingSnapshotKey = snapshotKey(incoming.getTargetTable(), incoming.getTargetId());
|
String incomingSnapshot = incoming.getDataSnapshot();
|
long existingTime = time(existing.getEventTime());
|
long incomingTime = time(incoming.getEventTime());
|
long firstTime = longValue(existingExtra.get("mergeFirstEventTime"), existingTime);
|
long lastTime = longValue(existingExtra.get("mergeLastEventTime"), existingTime);
|
boolean incomingIsEarlier = incomingTime < firstTime;
|
boolean incomingIsLater = incomingTime >= lastTime;
|
boolean business = AuditEventCategories.BUSINESS.equals(existing.getEventCategory());
|
boolean existingIsFormSource = business && Integer.valueOf(0).equals(existing.getSourceLayer());
|
boolean existingHasFormSource = business && (existingIsFormSource
|
|| Boolean.TRUE.equals(existingExtra.get("mergeHasFormSource")));
|
boolean incomingIsFormSource = business && Integer.valueOf(0).equals(incoming.getSourceLayer());
|
long lastSupplementTime = longValue(existingExtra.get("mergeLastSupplementEventTime"),
|
existingIsFormSource ? Long.MIN_VALUE : existingTime);
|
boolean incomingIsLatestSupplement = !incomingIsFormSource && incomingTime >= lastSupplementTime;
|
Set<String> formProtectedFields = normalizedProtectedFields(
|
existingExtra.get("mergeFormProtectedFields"));
|
if (business && Integer.valueOf(0).equals(existing.getSourceLayer())) {
|
formProtectedFields.addAll(protectedFormFields(existing.getFieldDiffs()));
|
}
|
if (incomingIsFormSource) {
|
formProtectedFields.addAll(protectedFormFields(incoming.getFieldDiffs()));
|
}
|
|
String mergedDiffs = mergeFieldDiffs(
|
existing.getFieldDiffs(), incoming.getFieldDiffs(), incomingIsEarlier,
|
existingHasFormSource ? incomingIsLatestSupplement : incomingIsLater,
|
existingHasFormSource, incomingIsFormSource, formProtectedFields);
|
String storedFormSnapshot = strValue(existingExtra.get("mergeFormSnapshot"));
|
String storedFormSnapshotKey = strValue(existingExtra.get("mergeFormSnapshotKey"));
|
String formSnapshot = incomingIsFormSource ? incomingSnapshot
|
: (storedFormSnapshot != null ? storedFormSnapshot
|
: (existingIsFormSource ? existingSnapshot : null));
|
String mergedSnapshot = mergeSnapshots(existingSnapshot, incomingSnapshot,
|
incomingIsLater, incomingIsLatestSupplement, business,
|
existingHasFormSource, incomingIsFormSource,
|
formSnapshot);
|
int incomingPriority = sourcePriority(existing.getEventCategory(), incoming.getSourceLayer());
|
int existingPriority = sourcePriority(existing.getEventCategory(), existing.getSourceLayer());
|
boolean preferIncoming = incomingPriority > existingPriority
|
|| (incomingPriority == existingPriority && incomingIsLater);
|
if (preferIncoming) {
|
copyPresentation(existing, incoming);
|
}
|
existing.setEventTime(new Date(Math.max(lastTime, incomingTime)));
|
existing.setFieldDiffs(mergedDiffs);
|
existing.setDataSnapshot(mergedSnapshot);
|
existing.setExtra(mergeExtra(existingExtra, incomingExtra, existing.getClientEventId(),
|
incoming.getClientEventId(), Math.min(firstTime, incomingTime), Math.max(lastTime, incomingTime),
|
existingSnapshotKey, existingSnapshot, incomingSnapshotKey, incomingSnapshot,
|
existingHasFormSource || incomingIsFormSource, formProtectedFields,
|
formSnapshot, incomingIsFormSource ? incomingSnapshotKey
|
: (storedFormSnapshotKey != null
|
? storedFormSnapshotKey
|
: (existingIsFormSource ? existingSnapshotKey : null)),
|
incomingIsLatestSupplement ? incomingTime : lastSupplementTime));
|
}
|
|
static String mergeFieldDiffs(String existingJson, String incomingJson,
|
boolean incomingIsEarlier, boolean incomingIsLater) {
|
return mergeFieldDiffs(existingJson, incomingJson, incomingIsEarlier, incomingIsLater,
|
false, false, Collections.emptySet());
|
}
|
|
static String mergeFieldDiffs(String existingJson, String incomingJson,
|
boolean incomingIsEarlier, boolean incomingIsLater,
|
boolean existingHasFormSource, boolean incomingIsFormSource,
|
Set<String> formProtectedFields) {
|
if (incomingIsFormSource) {
|
// 无论 MQ 谁先到,已有接口差异都只能作为表单基线的补充。
|
return mergeFormFirstDiffs(incomingJson, existingJson, true, formProtectedFields);
|
}
|
if (existingHasFormSource) {
|
return mergeFormFirstDiffs(existingJson, incomingJson, incomingIsLater, formProtectedFields);
|
}
|
LinkedHashMap<String, Map<String, Object>> merged = new LinkedHashMap<>();
|
addDiffs(merged, existingJson, false, true);
|
addDiffs(merged, incomingJson, incomingIsEarlier, incomingIsLater);
|
return merged.isEmpty() ? null : JSON.toJSONString(new ArrayList<>(merged.values()));
|
}
|
|
/** 表单差异决定字段顺序、初始值与展示元数据;接口只补充表单缺失、空值或零值字段。 */
|
private static String mergeFormFirstDiffs(String formFirstJson, String supplementalJson,
|
boolean supplementIsLater,
|
Set<String> formProtectedFields) {
|
LinkedHashMap<String, Map<String, Object>> merged = new LinkedHashMap<>();
|
addDiffs(merged, formFirstJson, false, true);
|
addSupplementalDiffs(merged, supplementalJson, supplementIsLater, formProtectedFields);
|
return merged.isEmpty() ? null : JSON.toJSONString(new ArrayList<>(merged.values()));
|
}
|
|
@SuppressWarnings("unchecked")
|
private static void addSupplementalDiffs(LinkedHashMap<String, Map<String, Object>> merged,
|
String json, boolean replaceNew,
|
Set<String> formProtectedFields) {
|
if (isBlank(json)) {
|
return;
|
}
|
try {
|
Object parsed = JSON.parse(json);
|
if (!(parsed instanceof List)) {
|
return;
|
}
|
for (Object raw : (List<?>) parsed) {
|
if (!(raw instanceof Map)) {
|
continue;
|
}
|
Map<String, Object> diff = new LinkedHashMap<>((Map<String, Object>) raw);
|
String field = strValue(diff.get("field"));
|
if (field == null) {
|
continue;
|
}
|
Map<String, Object> current = merged.get(recordDiffKey(diff));
|
if (current == null) {
|
merged.put(recordDiffKey(diff), diff);
|
} else if (!isFormProtected(formProtectedFields, diff)
|
&& hasDataValue(diff.get("newData"))
|
&& (replaceNew || !hasDataValue(current.get("newData")))) {
|
copyIfPresent(diff, current, "newData");
|
if (diff.containsKey("newDisplay")) {
|
current.put("newDisplay", diff.get("newDisplay"));
|
} else if (diff.containsKey("newData")) {
|
current.remove("newDisplay");
|
}
|
}
|
}
|
} catch (Throwable ignored) {
|
// 损坏的补充 diff 不得破坏已经取得的表单基线。
|
}
|
}
|
|
private static String strValue(Object value) {
|
if (value == null) {
|
return null;
|
}
|
String text = String.valueOf(value).trim();
|
return text.isEmpty() ? null : text;
|
}
|
|
private static Set<String> protectedFormFields(String json) {
|
Set<String> fields = new LinkedHashSet<>();
|
if (isBlank(json)) {
|
return fields;
|
}
|
try {
|
Object parsed = JSON.parse(json);
|
if (!(parsed instanceof List)) {
|
return fields;
|
}
|
for (Object raw : (List<?>) parsed) {
|
if (!(raw instanceof Map)) {
|
continue;
|
}
|
Map<?, ?> diff = (Map<?, ?>) raw;
|
String field = strValue(diff.get("field"));
|
if (field != null && (hasDataValue(diff.get("newData")) || hasChildDiff(diff))) {
|
fields.add(fieldDiffKey(diff));
|
}
|
}
|
} catch (Throwable ignored) {
|
// 解析失败时不猜保护字段,原始表单差异仍会作为合并基线保留。
|
}
|
return fields;
|
}
|
|
private static boolean isFormProtected(Set<String> protectedFields, Map<String, Object> diff) {
|
if (protectedFields.contains(fieldDiffKey(diff))) {
|
return true;
|
}
|
boolean hasScopedKey = protectedFields.stream().anyMatch(AuditDiffKey::isScoped);
|
// 兼容修复前已写入 extra 的无表作用域字段名。
|
return !hasScopedKey && protectedFields.contains(strValue(diff.get("field")));
|
}
|
|
/** 用户定义的空值规则:null、空字符串及数值零可由接口补充,其余表单值不可覆盖。 */
|
private static boolean hasDataValue(Object value) {
|
if (value == null) {
|
return false;
|
}
|
if (value instanceof Number) {
|
return ((Number) value).doubleValue() != 0D;
|
}
|
String text = String.valueOf(value).trim();
|
if (text.isEmpty()) {
|
return false;
|
}
|
try {
|
return new java.math.BigDecimal(text).compareTo(java.math.BigDecimal.ZERO) != 0;
|
} catch (NumberFormatException ignored) {
|
return true;
|
}
|
}
|
|
private static boolean hasChildDiff(Map<?, ?> diff) {
|
Object data = diff == null ? null : diff.get("chidData");
|
return data instanceof Collection && !((Collection<?>) data).isEmpty();
|
}
|
|
static String mergeSnapshots(String existingJson, String incomingJson,
|
boolean incomingIsLater, boolean incomingIsLatestSupplement,
|
boolean business,
|
boolean existingHasFormSource, boolean incomingIsFormSource,
|
String formSnapshot) {
|
if (business && !isBlank(formSnapshot)
|
&& (existingHasFormSource || incomingIsFormSource)) {
|
if (incomingIsFormSource) {
|
return mergeFormFirstSnapshots(formSnapshot, existingJson);
|
}
|
return incomingIsLatestSupplement
|
? mergeFormFirstSnapshots(formSnapshot, existingJson, incomingJson)
|
: mergeFormFirstSnapshots(formSnapshot, incomingJson, existingJson);
|
}
|
return incomingJson != null && incomingIsLater ? incomingJson : existingJson;
|
}
|
|
/** 表单快照决定字段顺序和有效值;其他来源只补字段,或填充表单中的 null、空串和零值。 */
|
@SuppressWarnings("unchecked")
|
static String mergeFormFirstSnapshots(String formJson, String... supplementalJson) {
|
Map<String, Object> form = parseJsonObject(formJson);
|
if (form == null) {
|
return formJson;
|
}
|
Map<String, Object> merged = new LinkedHashMap<>(form);
|
Set<String> protectedFields = new LinkedHashSet<>();
|
for (Map.Entry<String, Object> entry : form.entrySet()) {
|
if (hasDataValue(entry.getValue())) {
|
protectedFields.add(entry.getKey());
|
}
|
}
|
for (String json : supplementalJson) {
|
Map<String, Object> supplemental = parseJsonObject(json);
|
if (supplemental == null) {
|
continue;
|
}
|
for (Map.Entry<String, Object> entry : supplemental.entrySet()) {
|
if (!protectedFields.contains(entry.getKey()) && hasDataValue(entry.getValue())) {
|
merged.put(entry.getKey(), entry.getValue());
|
}
|
}
|
}
|
return JSON.toJSONString(merged);
|
}
|
|
@SuppressWarnings("unchecked")
|
private static Map<String, Object> parseJsonObject(String json) {
|
if (isBlank(json)) {
|
return null;
|
}
|
try {
|
Object parsed = JSON.parse(json);
|
return parsed instanceof Map
|
? new LinkedHashMap<>((Map<String, Object>) parsed) : null;
|
} catch (Throwable ignored) {
|
return null;
|
}
|
}
|
|
@SuppressWarnings("unchecked")
|
private static void addDiffs(LinkedHashMap<String, Map<String, Object>> merged, String json,
|
boolean replaceOld, boolean replaceNew) {
|
if (isBlank(json)) {
|
return;
|
}
|
try {
|
Object parsed = JSON.parse(json);
|
if (!(parsed instanceof List)) {
|
return;
|
}
|
for (Object raw : (List<?>) parsed) {
|
if (!(raw instanceof Map)) {
|
continue;
|
}
|
Map<String, Object> diff = new LinkedHashMap<>((Map<String, Object>) raw);
|
Object rawField = diff.get("field");
|
if (rawField == null || isBlank(String.valueOf(rawField))) {
|
continue;
|
}
|
String field = String.valueOf(rawField);
|
String key = recordDiffKey(diff);
|
Map<String, Object> current = merged.get(key);
|
if (current == null) {
|
merged.put(key, diff);
|
continue;
|
}
|
if (replaceOld) {
|
copyIfPresent(diff, current, "oldData");
|
copyIfPresent(diff, current, "oldDisplay");
|
}
|
if (replaceNew) {
|
copyIfPresent(diff, current, "newData");
|
copyIfPresent(diff, current, "newDisplay");
|
copyMetadata(diff, current);
|
}
|
}
|
} catch (Throwable ignored) {
|
// 损坏的历史 diff 不参与组合,后续合法来源仍可补齐。
|
}
|
}
|
|
private static void copyMetadata(Map<String, Object> source, Map<String, Object> target) {
|
String[] keys = {"fieldName", "changeType", "componentType", "jnpfKey", "masked",
|
"nameModified", "technical", "type", "valueType", "chidData", "chidField",
|
"targetTable", "targetId"};
|
for (String key : keys) {
|
Object value = source.get(key);
|
if (value != null && !String.valueOf(value).isEmpty()) {
|
target.put(key, value);
|
}
|
}
|
}
|
|
private static String fieldDiffKey(Map<?, ?> diff) {
|
String field = strValue(diff == null ? null : diff.get("field"));
|
String table = strValue(diff == null ? null : diff.get("targetTable"));
|
return AuditDiffKey.of(table, field);
|
}
|
|
/**
|
* 批量操作必须按记录隔离同名字段;旧日志没有 targetId 时退回原有表+字段键。
|
*/
|
private static String recordDiffKey(Map<?, ?> diff) {
|
String fieldKey = fieldDiffKey(diff);
|
String targetId = strValue(diff == null ? null : diff.get("targetId"));
|
return targetId == null ? fieldKey
|
: fieldKey + "@target:" + targetId.length() + ":" + targetId;
|
}
|
|
/**
|
* 单记录生产端无需重复填写作用域;批量生产端显式携带的逐项 targetId 优先保留。
|
*/
|
@SuppressWarnings("unchecked")
|
static String scopeFieldDiffs(String json, String defaultTable, String defaultTargetId) {
|
if (isBlank(json)) {
|
return json;
|
}
|
try {
|
Object parsed = JSON.parse(json);
|
if (!(parsed instanceof List)) {
|
return json;
|
}
|
List<Map<String, Object>> scoped = new ArrayList<>();
|
for (Object raw : (List<?>) parsed) {
|
if (!(raw instanceof Map)) {
|
continue;
|
}
|
Map<String, Object> item = new LinkedHashMap<>((Map<String, Object>) raw);
|
if (strValue(item.get("targetTable")) == null && !isBlank(defaultTable)) {
|
item.put("targetTable", defaultTable);
|
}
|
if (strValue(item.get("targetId")) == null && !isBlank(defaultTargetId)) {
|
item.put("targetId", defaultTargetId);
|
}
|
scoped.add(item);
|
}
|
return JSON.toJSONString(scoped);
|
} catch (Throwable ignored) {
|
return json;
|
}
|
}
|
|
/** 每个字段只按其所属物理表校验;元数据不可用时保留原始差异,避免静默丢审计。 */
|
@SuppressWarnings("unchecked")
|
static String filterFieldDiffs(String json, String defaultTable,
|
Function<String, Set<String>> columnResolver) {
|
if (isBlank(json) || columnResolver == null) {
|
return json;
|
}
|
try {
|
Object parsed = JSON.parse(json);
|
if (!(parsed instanceof List)) {
|
return json;
|
}
|
List<Map<String, Object>> filtered = new ArrayList<>();
|
for (Object raw : (List<?>) parsed) {
|
if (!(raw instanceof Map)) {
|
continue;
|
}
|
Map<String, Object> item = new LinkedHashMap<>((Map<String, Object>) raw);
|
boolean child = "table".equalsIgnoreCase(strValue(item.get("jnpfKey")));
|
String table = strValue(item.get("targetTable"));
|
if (!child && table == null) {
|
table = strValue(defaultTable);
|
}
|
if (table == null) {
|
// 旧子表事件没有物理表归属,无法安全判断;保留证据,不拿主表列误删。
|
filtered.add(item);
|
continue;
|
}
|
table = table.toLowerCase(Locale.ROOT);
|
item.put("targetTable", table);
|
Set<String> resolved = columnResolver.apply(table);
|
Set<String> columns = lowerSet(resolved);
|
if (columns.isEmpty()) {
|
filtered.add(item);
|
continue;
|
}
|
if (child) {
|
Map<String, Object> childItem = filterChildDiff(item, columns);
|
if (childItem != null) {
|
filtered.add(childItem);
|
}
|
continue;
|
}
|
String field = strValue(item.get("field"));
|
if (field != null && columns.contains(field.toLowerCase(Locale.ROOT))) {
|
filtered.add(item);
|
}
|
}
|
return JSON.toJSONString(filtered);
|
} catch (Throwable ignored) {
|
return json;
|
}
|
}
|
|
@SuppressWarnings("unchecked")
|
private static Map<String, Object> filterChildDiff(Map<String, Object> item, Set<String> columns) {
|
List<Map<String, Object>> fields = new ArrayList<>();
|
Object rawFields = item.get("chidField");
|
if (rawFields instanceof List) {
|
for (Object raw : (List<?>) rawFields) {
|
if (!(raw instanceof Map)) {
|
continue;
|
}
|
Map<String, Object> field = new LinkedHashMap<>((Map<String, Object>) raw);
|
String prop = strValue(field.get("prop"));
|
if (prop != null && columns.contains(prop.toLowerCase(Locale.ROOT))) {
|
fields.add(field);
|
}
|
}
|
}
|
if (fields.isEmpty()) {
|
return null;
|
}
|
|
Set<String> allowed = new LinkedHashSet<>();
|
for (Map<String, Object> field : fields) {
|
allowed.add(strValue(field.get("prop")).toLowerCase(Locale.ROOT));
|
}
|
List<Map<String, Object>> rows = new ArrayList<>();
|
Object rawRows = item.get("chidData");
|
if (rawRows instanceof List) {
|
for (Object raw : (List<?>) rawRows) {
|
if (!(raw instanceof Map)) {
|
continue;
|
}
|
Map<String, Object> row = new LinkedHashMap<>();
|
boolean hasBusinessField = false;
|
for (Map.Entry<?, ?> entry : ((Map<?, ?>) raw).entrySet()) {
|
String key = entry.getKey() == null ? null : String.valueOf(entry.getKey());
|
if (key == null) {
|
continue;
|
}
|
String normalized = key.toLowerCase(Locale.ROOT);
|
String actual = normalized.startsWith("jnpf_old_")
|
? normalized.substring("jnpf_old_".length()) : normalized;
|
if (allowed.contains(actual)) {
|
row.put(key, entry.getValue());
|
hasBusinessField = true;
|
} else if ("jnpf_type".equals(normalized)) {
|
row.put(key, entry.getValue());
|
}
|
}
|
if (hasBusinessField) {
|
rows.add(row);
|
}
|
}
|
}
|
if (rows.isEmpty()) {
|
return null;
|
}
|
item.put("chidField", fields);
|
item.put("chidData", rows);
|
return item;
|
}
|
|
private static Set<String> lowerSet(Set<String> values) {
|
if (values == null || values.isEmpty()) {
|
return Collections.emptySet();
|
}
|
Set<String> result = new LinkedHashSet<>();
|
for (String value : values) {
|
if (value != null && !value.trim().isEmpty()) {
|
result.add(value.trim().toLowerCase(Locale.ROOT));
|
}
|
}
|
return result;
|
}
|
|
private static void copyIfPresent(Map<String, Object> source, Map<String, Object> target, String key) {
|
if (source.containsKey(key)) {
|
target.put(key, source.get(key));
|
}
|
}
|
|
private static String mergeExtra(Map<String, Object> existing, Map<String, Object> incoming,
|
String existingClientId, String incomingClientId,
|
long firstTime, long lastTime,
|
String existingSnapshotKey, String existingSnapshot,
|
String incomingSnapshotKey, String incomingSnapshot,
|
boolean hasFormSource, Set<String> formProtectedFields,
|
String formSnapshot, String formSnapshotKey,
|
long lastSupplementEventTime) {
|
Map<String, Object> merged = new LinkedHashMap<>(existing);
|
merged.putAll(incoming);
|
Set<String> sourceIds = new LinkedHashSet<>();
|
addStrings(sourceIds, existing.get("mergedClientEventIds"));
|
sourceIds.add(existingClientId);
|
sourceIds.add(incomingClientId);
|
merged.put("mergedClientEventIds", new ArrayList<>(sourceIds));
|
merged.put("mergeFirstEventTime", firstTime);
|
merged.put("mergeLastEventTime", lastTime);
|
if (hasFormSource) {
|
merged.put("mergeHasFormSource", true);
|
merged.put("mergeFormProtectedFields", new ArrayList<>(formProtectedFields));
|
if (formSnapshot != null) {
|
merged.put("mergeFormSnapshot", formSnapshot);
|
}
|
if (formSnapshotKey != null) {
|
merged.put("mergeFormSnapshotKey", formSnapshotKey);
|
}
|
}
|
if (lastSupplementEventTime != Long.MIN_VALUE) {
|
merged.put("mergeLastSupplementEventTime", lastSupplementEventTime);
|
}
|
Map<String, Object> snapshots = objectMap(existing.get("mergedSnapshots"));
|
if (existingSnapshotKey != null && existingSnapshot != null) {
|
snapshots.put(existingSnapshotKey, existingSnapshot);
|
}
|
if (incomingSnapshotKey != null && incomingSnapshot != null) {
|
snapshots.put(incomingSnapshotKey, incomingSnapshot);
|
}
|
if (!snapshots.isEmpty()) {
|
merged.put("mergedSnapshots", snapshots);
|
}
|
return JsonUtil.getObjectToString(merged);
|
}
|
|
@SuppressWarnings("unchecked")
|
private static Map<String, Object> objectMap(Object value) {
|
if (value instanceof Map) {
|
return new LinkedHashMap<>((Map<String, Object>) value);
|
}
|
return new LinkedHashMap<>();
|
}
|
|
private static String snapshotKey(String targetTable, String targetId) {
|
return isBlank(targetTable) || isBlank(targetId) ? null : targetTable + ":" + targetId;
|
}
|
|
private static Map<String, Object> parseExtra(String json) {
|
if (!isBlank(json)) {
|
try {
|
Map<String, Object> parsed = JsonUtil.stringToMap(json);
|
if (parsed != null) {
|
return new LinkedHashMap<>(parsed);
|
}
|
} catch (Throwable ignored) {
|
// 原文在下面保留,避免静默丢失损坏的历史 extra。
|
}
|
}
|
Map<String, Object> result = new LinkedHashMap<>();
|
if (!isBlank(json)) {
|
result.put("mergedOriginalExtra", json);
|
}
|
return result;
|
}
|
|
private static void addStrings(Set<String> target, Object value) {
|
if (value instanceof Iterable) {
|
for (Object item : (Iterable<?>) value) {
|
if (item != null) {
|
target.add(String.valueOf(item));
|
}
|
}
|
}
|
}
|
|
static Set<String> normalizedProtectedFields(Object value) {
|
Set<String> result = new LinkedHashSet<>();
|
if (value instanceof Iterable) {
|
for (Object item : (Iterable<?>) value) {
|
if (item != null) {
|
String normalized = AuditDiffKey.normalizeStored(String.valueOf(item));
|
if (normalized != null) {
|
result.add(normalized);
|
}
|
}
|
}
|
}
|
return result;
|
}
|
|
static int sourcePriority(String category, Integer sourceLayer) {
|
if (AuditEventCategories.BUSINESS.equals(category)) {
|
return Integer.valueOf(0).equals(sourceLayer) ? 3
|
: (Integer.valueOf(2).equals(sourceLayer) ? 2 : 1);
|
}
|
if (Integer.valueOf(0).equals(sourceLayer)) {
|
return 3; // 层 0 是实际签名凭证,优先于 SIGN_USED 等使用记录。
|
}
|
return Integer.valueOf(2).equals(sourceLayer) ? 2 : 1;
|
}
|
|
private static void copyPresentation(AuditEventEntity target, AuditEventEntity source) {
|
target.setTenantId(source.getTenantId());
|
target.setOperatorId(source.getOperatorId());
|
target.setOperatorName(source.getOperatorName());
|
target.setOperatorOrgId(source.getOperatorOrgId());
|
target.setAppName(source.getAppName());
|
target.setBizModule(source.getBizModule());
|
target.setIp(source.getIp());
|
target.setIpRegion(source.getIpRegion());
|
target.setBrowser(source.getBrowser());
|
target.setOs(source.getOs());
|
target.setRequestUri(source.getRequestUri());
|
target.setEventType(source.getEventType());
|
target.setActionCode(source.getActionCode());
|
target.setActionLabel(source.getActionLabel());
|
target.setSourceLayer(source.getSourceLayer());
|
target.setTargetTable(source.getTargetTable());
|
target.setTargetId(source.getTargetId());
|
target.setBizType(source.getBizType());
|
target.setBizCode(source.getBizCode());
|
target.setReason(source.getReason());
|
target.setRecordTitle(source.getRecordTitle());
|
target.setEntryType(source.getEntryType());
|
target.setEntryId(source.getEntryId());
|
target.setEntryName(source.getEntryName());
|
target.setPrevHash(source.getPrevHash());
|
}
|
|
private static long time(Date value) {
|
return value == null ? 0L : value.getTime();
|
}
|
|
private static long longValue(Object value, long fallback) {
|
if (value instanceof Number) {
|
return ((Number) value).longValue();
|
}
|
try {
|
return value == null ? fallback : Long.parseLong(String.valueOf(value));
|
} catch (NumberFormatException ignored) {
|
return fallback;
|
}
|
}
|
|
/**
|
* 校验规则(spec v2.2 裁定 + 追加裁定,逐字执行):
|
* eventType 必须在 {@link AuditConsts#VALID_TYPES} 内;
|
* operatorId/appName/actionCode/eventTime/tenantId 任一为空则拒绝;
|
* clientEventId 为空则拒绝(DB NOT NULL + UNIQUE 幂等键,null 无幂等语义);
|
* sourceLayer 为 null 则拒绝(DB NOT NULL 列即必填事实源,早抛可读异常优于 DB 层裸抛;
|
* 取值范围 0-3 留给 DB CHECK 约束校验,不在此重复)。
|
*
|
* <p><b>标识类字段超长一律拒绝、不截断</b>(C1 评审 I-5):{@code clientEventId} 是幂等键,
|
* 截断会让两个不同事件塌成同一个键、被 ON CONFLICT 当成重复而丢弃;{@code operationId} 截断
|
* 会把本不相干的操作折叠到一组。这两者截断造成的错误比拒绝更隐蔽,故宁可拒绝——
|
* 而且它们都由 SDK/服务自己构造(层 0 是 "L0-"+雪花=22 字符),超长本身就说明调用方有问题。
|
* 描述类字段则相反,见 {@link #toEntity}。
|
*/
|
private void validate(AuditEventDTO dto) {
|
if (isBlank(dto.getClientEventId())) {
|
throw new IllegalArgumentException("clientEventId 不能为空");
|
}
|
if (dto.getClientEventId().length() > LEN_CLIENT_EVENT_ID) {
|
throw new IllegalArgumentException("clientEventId 超过 " + LEN_CLIENT_EVENT_ID
|
+ " 字符,截断会破坏幂等语义,拒绝入库: " + dto.getClientEventId());
|
}
|
if (dto.getOperationId() != null && dto.getOperationId().length() > LEN_OPERATION_ID) {
|
throw new IllegalArgumentException("operationId 超过 " + LEN_OPERATION_ID
|
+ " 字符,截断会破坏操作关联语义,拒绝入库: " + dto.getOperationId());
|
}
|
if (dto.getEventType() == null || !AuditConsts.VALID_TYPES.contains(dto.getEventType())) {
|
throw new IllegalArgumentException("eventType 不在受控枚举 VALID_TYPES 内: " + dto.getEventType());
|
}
|
if (isBlank(dto.getOperatorId())) {
|
throw new IllegalArgumentException("operatorId 不能为空");
|
}
|
if (isBlank(dto.getAppName())) {
|
throw new IllegalArgumentException("appName 不能为空");
|
}
|
if (isBlank(dto.getActionCode())) {
|
throw new IllegalArgumentException("actionCode 不能为空");
|
}
|
if (dto.getEventTime() == null) {
|
throw new IllegalArgumentException("eventTime 不能为空");
|
}
|
if (isBlank(dto.getTenantId())) {
|
throw new IllegalArgumentException("tenantId 不能为空");
|
}
|
if (dto.getSourceLayer() == null) {
|
throw new IllegalArgumentException("sourceLayer 不能为空");
|
}
|
}
|
|
/**
|
* DTO → 实体,**描述类 varchar 列按列宽截断**(C1 评审 I-5)。
|
*
|
* <p>不截断会怎样:{@code biz_code} 按设计直取业务原始值(层 0 取表单字段、层 2 取 SpEL 求值),
|
* {@code browser}/{@code os}/{@code request_uri} 则由客户端 header 决定——任一超长,PG 抛
|
* {@code 22001 value too long},而 {@code ON CONFLICT} 在冲突判定之前就已经抛了。异常冒泡回
|
* 采集侧被 {@code catch(Throwable)} 兜住(层 0 的 L002 防御、层 1 的总兜底都如此),结果是
|
* **整条审计事件永久消失,只剩一行 error 日志**。
|
*
|
* <p>更该警惕的是**它不自愈**:spec §6 层 0 的容错前提是「这次失败了,下次事件会自举补上」,
|
* 但字段若是**长期**超长(某类单据的编号天生就长),该行的每一条事件都会丢,永远补不回来——
|
* 直接突破了 spec 声明的可靠性包络。
|
*
|
* <p><b>截断必须留痕</b>(与 spec §8 迁移脚本「禁静默截断」同一条纪律):被截断的列名记进
|
* {@code extra.truncatedFields}。审计可以记得不全,但不能让人以为记全了。
|
*/
|
private AuditEventEntity toEntity(AuditEventDTO dto) {
|
AuditEventEntity entity = new AuditEventEntity();
|
List<String> truncated = new ArrayList<>();
|
// 标识类:已在 validate 里卡死长度,这里原样搬运
|
entity.setClientEventId(dto.getClientEventId());
|
entity.setOperationId(dto.getOperationId());
|
entity.setEventTime(dto.getEventTime());
|
entity.setTenantId(clip(dto.getTenantId(), LEN_TENANT_ID, "tenant_id", truncated));
|
entity.setOperatorId(clip(dto.getOperatorId(), LEN_OPERATOR_ID, "operator_id", truncated));
|
entity.setOperatorName(clip(dto.getOperatorName(), LEN_OPERATOR_NAME, "operator_name", truncated));
|
entity.setOperatorOrgId(clip(dto.getOperatorOrgId(), LEN_OPERATOR_ORG_ID, "operator_org_id", truncated));
|
entity.setAppName(clip(dto.getAppName(), LEN_APP_NAME, "app_name", truncated));
|
entity.setBizModule(clip(dto.getBizModule(), LEN_BIZ_MODULE, "biz_module", truncated));
|
entity.setIp(clip(dto.getIp(), LEN_IP, "ip", truncated));
|
entity.setIpRegion(clip(dto.getIpRegion(), LEN_IP_REGION, "ip_region", truncated));
|
entity.setBrowser(clip(dto.getBrowser(), LEN_BROWSER, "browser", truncated));
|
entity.setOs(clip(dto.getOs(), LEN_OS, "os", truncated));
|
entity.setRequestUri(clip(dto.getRequestUri(), LEN_REQUEST_URI, "request_uri", truncated));
|
entity.setEventType(dto.getEventType()); // 受控枚举,validate 已挡
|
entity.setActionCode(clip(dto.getActionCode(), LEN_ACTION_CODE, "action_code", truncated));
|
entity.setActionLabel(clip(dto.getActionLabel(), LEN_ACTION_LABEL, "action_label", truncated));
|
entity.setSourceLayer(dto.getSourceLayer());
|
entity.setTargetTable(clip(dto.getTargetTable(), LEN_TARGET_TABLE, "target_table", truncated));
|
entity.setTargetId(clip(dto.getTargetId(), LEN_TARGET_ID, "target_id", truncated));
|
entity.setBizType(clip(dto.getBizType(), LEN_BIZ_TYPE, "biz_type", truncated));
|
entity.setBizCode(clip(dto.getBizCode(), LEN_BIZ_CODE, "biz_code", truncated));
|
// 以下都是 text 列,无长度约束
|
entity.setFieldDiffs(dto.getFieldDiffs());
|
entity.setDataSnapshot(dto.getDataSnapshot());
|
entity.setReason(dto.getReason());
|
entity.setRecordTitle(clip(dto.getRecordTitle(), LEN_RECORD_TITLE, "record_title", truncated));
|
entity.setEntryType(clip(dto.getEntryType(), LEN_ENTRY_TYPE, "entry_type", truncated));
|
entity.setEntryId(clip(dto.getEntryId(), LEN_ENTRY_ID, "entry_id", truncated));
|
entity.setEntryName(clip(dto.getEntryName(), LEN_ENTRY_NAME, "entry_name", truncated));
|
entity.setPrevHash(clip(dto.getPrevHash(), LEN_PREV_HASH, "prev_hash", truncated));
|
entity.setExtra(truncated.isEmpty() ? dto.getExtra() : noteTruncation(dto.getExtra(), truncated));
|
return entity;
|
}
|
|
/**
|
* 列宽常量,与 {@code audit_events} 的 DDL 逐列对齐(PG-only,见 spec §4 表定义)。
|
* DDL 若放宽,这里只是截得早一点(保守、不致命);DDL 若收窄,必须同步改这里。
|
*/
|
private static final int LEN_CLIENT_EVENT_ID = 70;
|
private static final int LEN_OPERATION_ID = 64;
|
private static final int LEN_TENANT_ID = 64;
|
private static final int LEN_OPERATOR_ID = 64;
|
private static final int LEN_OPERATOR_NAME = 100;
|
private static final int LEN_OPERATOR_ORG_ID = 64;
|
private static final int LEN_APP_NAME = 50;
|
private static final int LEN_BIZ_MODULE = 100;
|
private static final int LEN_IP = 50;
|
private static final int LEN_IP_REGION = 100;
|
private static final int LEN_BROWSER = 100;
|
private static final int LEN_OS = 100;
|
private static final int LEN_REQUEST_URI = 500;
|
private static final int LEN_ACTION_CODE = 50;
|
private static final int LEN_ACTION_LABEL = 200;
|
private static final int LEN_TARGET_TABLE = 100;
|
private static final int LEN_TARGET_ID = 64;
|
private static final int LEN_BIZ_TYPE = 50;
|
private static final int LEN_BIZ_CODE = 100;
|
private static final int LEN_RECORD_TITLE = 500;
|
private static final int LEN_ENTRY_TYPE = 20;
|
private static final int LEN_ENTRY_ID = 50;
|
private static final int LEN_ENTRY_NAME = 200;
|
private static final int LEN_PREV_HASH = 64;
|
|
/** 超长则截断并把列名记进 {@code truncated};否则原样返回。 */
|
private static String clip(String value, int max, String column, List<String> truncated) {
|
if (value == null || value.length() <= max) {
|
return value;
|
}
|
truncated.add(column);
|
log.warn("[audit] {} 超长({} > {}),已截断入库并记入 extra.truncatedFields", column, value.length(), max);
|
return value.substring(0, max);
|
}
|
|
/**
|
* 把截断记录并进 {@code extra}。
|
*
|
* <p>原 {@code extra} 不是合法 JSON 时**不能就这么丢掉留痕**(那又变回静默截断),
|
* 改为包一层并把原文完整保留在 {@code originalExtra} 里——{@code extra} 是 text 列,装得下。
|
*/
|
private String noteTruncation(String extra, List<String> truncated) {
|
Map<String, Object> merged = null;
|
if (extra != null && !extra.trim().isEmpty()) {
|
try {
|
merged = JsonUtil.stringToMap(extra);
|
} catch (Throwable ignore) {
|
merged = null;
|
}
|
}
|
if (merged == null) {
|
merged = new LinkedHashMap<>();
|
if (extra != null && !extra.trim().isEmpty()) {
|
merged.put("originalExtra", extra); // 原文不是 JSON,原样兜住,不丢信息
|
}
|
}
|
merged.put("truncatedFields", truncated);
|
return JsonUtil.getObjectToString(merged);
|
}
|
|
private static boolean isBlank(String s) {
|
return s == null || s.trim().isEmpty();
|
}
|
}
|