package jnpf.bizcommon.audit.service.impl; import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; import jnpf.audit.AuditConsts; import jnpf.bizcommon.audit.entity.AuditEventEntity; import jnpf.bizcommon.audit.entity.model.AuditEventDetailVO; import jnpf.bizcommon.audit.entity.model.AuditEventListVO; import jnpf.bizcommon.audit.entity.model.AuditOperationGroupRow; import jnpf.bizcommon.audit.entity.model.AuditPageQuery; import jnpf.bizcommon.audit.mapper.AuditEventMapper; import jnpf.bizcommon.audit.mapper.AuditQueryMapper; import jnpf.bizcommon.audit.service.AuditFieldDiffViewService; import jnpf.bizcommon.audit.service.AuditFormRegistry; import jnpf.bizcommon.audit.service.AuditQueryService; import jnpf.bizcommon.audit.service.support.AuditQueryResponseSanitizer; import jnpf.bizcommon.audit.service.support.AuditEventCategories; import jnpf.base.vo.PageListVO; import jnpf.base.vo.PaginationVO; import jnpf.exception.DataException; import jnpf.util.JsonUtil; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import org.springframework.util.StringUtils; import java.util.ArrayList; import java.util.Collections; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; /** 审计事件查询服务;写入侧完成物理合并,查询侧仅按 operationId 组织展示关系。 */ @Service public class AuditQueryServiceImpl implements AuditQueryService { /** timeline 全量上限(spec:LIMIT 500) */ private static final int TIMELINE_LIMIT = 500; /** 列表只下发前 3 条子表标题 + 总数——保持「列表不含 extra 正文」的敏感面约定。 */ private static final int LIST_SUB_TITLE_LIMIT = 3; /** 分页大小上限(Step 2 契约:强制 pageSize<=100) */ private static final long MAX_PAGE_SIZE = 100L; private static final long DEFAULT_PAGE_SIZE = 20L; @Autowired private AuditQueryMapper auditQueryMapper; @Autowired private AuditEventMapper auditEventMapper; @Autowired private AuditFormRegistry auditFormRegistry; @Autowired private AuditFieldDiffViewService auditFieldDiffViewService; @Override public PageListVO page(AuditPageQuery query) { long pageSize = query.getPageSize(); if (pageSize <= 0) { pageSize = DEFAULT_PAGE_SIZE; } else if (pageSize > MAX_PAGE_SIZE) { pageSize = MAX_PAGE_SIZE; } long currentPage = query.getCurrentPage(); if (currentPage <= 0) { currentPage = 1L; } // Codex 批次三a Minor#3:OFFSET 溢出防护——currentPage 传超大值(如 Long.MAX_VALUE)时 // (currentPage-1)*pageSize 会 long 乘法溢出成负数,拼进 SQL LIMIT/OFFSET 语义未定义。 // Math.multiplyExact 显式检测溢出,命中则降级为空页(total 仍按条件正常返回,不查明细页)。 long offset; boolean offsetOverflow; try { offset = Math.multiplyExact(currentPage - 1, pageSize); offsetOverflow = false; } catch (ArithmeticException ex) { offset = 0L; offsetOverflow = true; } QueryWrapper ew = buildWrapper(query); long total = auditQueryMapper.countOperations(ew); List list; if (offsetOverflow) { list = Collections.emptyList(); } else { List groups = auditQueryMapper.selectOperationPage(ew, offset, pageSize); list = buildOperationRows(groups); } // 回写生效后的分页参数供返回包装读取(对照 LimsBizLogController:Pagination 承载 total, // JsonUtil.getJsonToBean 窄化成 PaginationVO——同一惯例,仅挪到 Service 层以贴合本任务 // 字面执行的 Controller 签名) query.setCurrentPage(currentPage); query.setPageSize(pageSize); query.setTotal(total); PageListVO vo = new PageListVO<>(); vo.setList(list); vo.setPagination(JsonUtil.getJsonToBean(query, PaginationVO.class)); return vo; } @Override public AuditEventDetailVO detail(Long id) { AuditEventEntity entity = auditEventMapper.selectById(id); if (entity == null) { throw new DataException("审计事件不存在:id=" + id); } return toDetailVo(entity); } @Override public List timeline(String targetId) { List groups = groupRows( auditQueryMapper.selectTimeline(targetId, TIMELINE_LIMIT)); // target_id 只属于业务目标行;签名等关联事件使用自己的 target_id,需按 operation_id 补查。 return buildOperationRows(groups); } private List buildOperationRows(List groups) { if (groups.isEmpty()) { return Collections.emptyList(); } List gids = new ArrayList<>(groups.size()); for (AuditOperationGroupRow group : groups) { gids.add(group.getGid()); } return buildOperationRows(groups, auditQueryMapper.selectByOperationGids(gids)); } /** 将已物理合并的事件挂到 BUSINESS 主行;这里不得再次组合 fieldDiffs。 */ private List buildOperationRows(List groups, List events) { Map> eventsByGid = new LinkedHashMap<>(); for (AuditEventEntity event : events) { String gid = operationGid(event); eventsByGid.computeIfAbsent(gid, ignored -> new ArrayList<>()).add(event); } List result = new ArrayList<>(groups.size()); for (AuditOperationGroupRow group : groups) { List related = eventsByGid.get(group.getGid()); if (related == null) { continue; } AuditEventEntity business = null; List signatures = new ArrayList<>(2); for (AuditEventEntity event : related) { if (AuditEventCategories.BUSINESS.equals(event.getEventCategory())) { business = event; } else if (AuditEventCategories.PRIMARY_SIGN.equals(event.getEventCategory()) || AuditEventCategories.REVIEW_SIGN.equals(event.getEventCategory())) { signatures.add(toListVo(event)); } } if (business == null) { continue; } AuditEventListVO main = toListVo(business); main.setSubEvents(signatures); main.setSigned(!signatures.isEmpty()); result.add(main); } return result; } private List groupRows(List events) { Map groups = new LinkedHashMap<>(); for (AuditEventEntity event : events) { String gid = operationGid(event); AuditOperationGroupRow group = groups.get(gid); if (group == null) { group = new AuditOperationGroupRow(); group.setGid(gid); group.setGrpTime(event.getEventTime()); groups.put(gid, group); } } return new ArrayList<>(groups.values()); } private String operationGid(AuditEventEntity event) { return StringUtils.hasText(event.getOperationId()) ? event.getOperationId() : event.getClientEventId(); } private QueryWrapper buildWrapper(AuditPageQuery query) { QueryWrapper ew = new QueryWrapper<>(); if (query.getStartTime() != null) { ew.ge("event_time", query.getStartTime()); } if (query.getEndTime() != null) { ew.le("event_time", query.getEndTime()); } if (StringUtils.hasText(query.getOperatorName())) { ew.like("operator_name", query.getOperatorName()); } if (StringUtils.hasText(query.getAppName())) { ew.eq("app_name", query.getAppName()); } // Codex 批次三a Important#1:biz_module 是用户视角的业务应用筛选(app_name 层 0 固定为 jnpf-visualdev) if (StringUtils.hasText(query.getBizModule())) { ew.eq("biz_module", query.getBizModule()); } if (StringUtils.hasText(query.getEventType())) { ew.eq("event_type", query.getEventType()); } if (StringUtils.hasText(query.getActionCode())) { ew.eq("action_code", query.getActionCode()); } if (StringUtils.hasText(query.getTargetTable())) { ew.eq("target_table", query.getTargetTable()); } if (StringUtils.hasText(query.getBizCode())) { ew.eq("biz_code", query.getBizCode()); } if (StringUtils.hasText(query.getBizType())) { ew.eq("biz_type", query.getBizType()); } if (StringUtils.hasText(query.getKeyword())) { ew.like("field_diffs", query.getKeyword()); } return ew; } private AuditEventListVO toListVo(AuditEventEntity e) { AuditEventListVO vo = new AuditEventListVO(); vo.setId(e.getId()); vo.setOperationId(e.getOperationId()); vo.setClientEventId(e.getClientEventId()); vo.setEventTime(e.getEventTime()); vo.setTenantId(e.getTenantId()); vo.setOperatorId(e.getOperatorId()); vo.setOperatorName(e.getOperatorName()); vo.setOperatorOrgId(e.getOperatorOrgId()); vo.setAppName(e.getAppName()); vo.setBizModule(e.getBizModule()); vo.setIp(e.getIp()); vo.setIpRegion(e.getIpRegion()); vo.setBrowser(e.getBrowser()); vo.setOs(e.getOs()); vo.setRequestUri(e.getRequestUri()); vo.setEventType(e.getEventType()); if (AuditEventCategories.REVIEW_SIGN.equals(e.getEventCategory())) { vo.setSignatureRole("REVIEW"); } else if (AuditEventCategories.PRIMARY_SIGN.equals(e.getEventCategory())) { vo.setSignatureRole("PRIMARY"); } vo.setActionCode(e.getActionCode()); vo.setActionLabel(e.getActionLabel()); vo.setSourceLayer(e.getSourceLayer()); vo.setTargetTable(e.getTargetTable()); vo.setTargetId(AuditQueryResponseSanitizer.hidesTargetId(e.getTargetTable()) ? null : e.getTargetId()); vo.setBizType(e.getBizType()); vo.setBizCode(e.getBizCode()); vo.setDiffCount(computeDiffCount(e.getFieldDiffs())); vo.setReason(e.getReason()); vo.setRecordTitle(e.getRecordTitle()); vo.setEntryType(e.getEntryType()); vo.setEntryId(e.getEntryId()); vo.setEntryName(e.getEntryName()); vo.setCreatedAt(e.getCreatedAt()); // 子事件保留自身签名标记;BUSINESS 主行会在组装后按关联签名重新赋值。 vo.setSigned(AuditConsts.TYPE_E_SIGNATURE.equals(e.getEventType())); applySubTitles(vo, e.getExtra()); return vo; } /** * 从 extra.recordSubTitles 取子表标题行,只下发前 {@link #LIST_SUB_TITLE_LIMIT} 条 + 总数。 * 不整体下发 extra:正文里除 dataSnapshot 外还有各种来源标记与上下文,列表接口不该带出去。 */ @SuppressWarnings("unchecked") private void applySubTitles(AuditEventListVO vo, String extraJson) { if (!StringUtils.hasText(extraJson)) { return; } try { Map extra = JsonUtil.stringToMap(extraJson); Object raw = extra == null ? null : extra.get("recordSubTitles"); if (!(raw instanceof List)) { return; } List all = (List) raw; List head = new ArrayList<>(); for (Object item : all) { if (head.size() >= LIST_SUB_TITLE_LIMIT) { break; } if (item != null) { head.add(String.valueOf(item)); } } vo.setSubTitles(head); vo.setSubTitleCount(all.size()); } catch (Throwable ignored) { // extra 解析失败不影响列表主行——标题是锦上添花,不能炸掉整页 } } private AuditEventDetailVO toDetailVo(AuditEventEntity e) { AuditEventDetailVO vo = new AuditEventDetailVO(); vo.setId(e.getId()); vo.setClientEventId(e.getClientEventId()); vo.setOperationId(e.getOperationId()); vo.setEventTime(e.getEventTime()); vo.setTenantId(e.getTenantId()); vo.setOperatorId(e.getOperatorId()); vo.setOperatorName(e.getOperatorName()); vo.setOperatorOrgId(e.getOperatorOrgId()); vo.setAppName(e.getAppName()); vo.setBizModule(e.getBizModule()); vo.setIp(e.getIp()); vo.setIpRegion(e.getIpRegion()); vo.setBrowser(e.getBrowser()); vo.setOs(e.getOs()); vo.setRequestUri(e.getRequestUri()); vo.setEventType(e.getEventType()); vo.setActionCode(e.getActionCode()); vo.setActionLabel(e.getActionLabel()); vo.setSourceLayer(e.getSourceLayer()); vo.setTargetTable(e.getTargetTable()); vo.setTargetId(AuditQueryResponseSanitizer.hidesTargetId(e.getTargetTable()) ? null : e.getTargetId()); vo.setBizType(e.getBizType()); vo.setBizCode(e.getBizCode()); String modelId = AuditQueryResponseSanitizer.sourceModelId(e.getExtra()); Map metadata = auditFormRegistry.resolveFieldMetadata(modelId, e.getTargetTable()); String sanitizedFieldDiffs = AuditQueryResponseSanitizer.sanitizeFieldDiffs( e.getFieldDiffs(), metadata); vo.setFieldDiffs(sanitizedFieldDiffs); vo.setFieldDiffList(auditFieldDiffViewService.build(e, sanitizedFieldDiffs, metadata)); vo.setExtra(AuditQueryResponseSanitizer.sanitizeJson(e.getExtra())); vo.setDataSnapshot(AuditQueryResponseSanitizer.sanitizeJson(e.getDataSnapshot(), metadata)); vo.setReason(e.getReason()); vo.setRecordTitle(e.getRecordTitle()); vo.setEntryType(e.getEntryType()); vo.setEntryId(e.getEntryId()); vo.setEntryName(e.getEntryName()); vo.setPrevHash(e.getPrevHash()); vo.setCreatedAt(e.getCreatedAt()); return vo; } /** field_diffs 是 VisualLogModel[] JSON;空/非法 JSON 一律降级为 0,不让摘要计算炸列表接口。 */ private Integer computeDiffCount(String fieldDiffs) { if (!StringUtils.hasText(fieldDiffs)) { return 0; } return AuditQueryResponseSanitizer.effectiveDiffCount(fieldDiffs); } }