package jnpf.bizcommon.audit.service.impl; import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; import jnpf.audit.AuditConsts; import jnpf.audit.AuditApiConsts; import jnpf.base.UserInfo; import jnpf.bizcommon.audit.entity.AuditEventEntity; import jnpf.bizcommon.audit.entity.model.AuditEventDetailVO; import jnpf.bizcommon.audit.entity.model.AuditFieldDiffViewVO; 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.entity.model.DmsFolderAuditDetailVO; import jnpf.bizcommon.audit.entity.model.DmsFolderAuditPageQuery; 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 jnpf.util.UserProvider; 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 { private static final String SIGNATURE_TARGET_TABLE = "lims_sign"; private static final String DMS_APP = "jnpf-dms"; private static final String DMS_MODULE = "DMS"; private static final String DMS_FOLDER_TYPE = "DMS_FOLDER"; private static final String DMS_FOLDER_TABLE = "dms_folders"; /** 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); ew.and(wrapper -> wrapper .isNull("biz_type") .or() .ne("biz_type", DMS_FOLDER_TYPE)); 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 PageListVO signaturePage(AuditPageQuery query) { AuditPageQuery safeQuery = query == null ? new AuditPageQuery() : query; safeQuery.setTargetTable(SIGNATURE_TARGET_TABLE); long pageSize = safeQuery.getPageSize(); if (pageSize <= 0) { pageSize = DEFAULT_PAGE_SIZE; } else if (pageSize > MAX_PAGE_SIZE) { pageSize = MAX_PAGE_SIZE; } long currentPage = safeQuery.getCurrentPage(); if (currentPage <= 0) { currentPage = 1L; } long offset; boolean offsetOverflow; try { offset = Math.multiplyExact(currentPage - 1, pageSize); offsetOverflow = false; } catch (ArithmeticException ex) { offset = 0L; offsetOverflow = true; } QueryWrapper ew = buildWrapper(safeQuery); long total = auditQueryMapper.countEvents(ew); List list = new ArrayList<>(); if (!offsetOverflow) { for (AuditEventEntity event : auditQueryMapper.selectEventPage(ew, offset, pageSize)) { list.add(toListVo(event)); } } safeQuery.setCurrentPage(currentPage); safeQuery.setPageSize(pageSize); safeQuery.setTotal(total); PageListVO vo = new PageListVO<>(); vo.setList(list); vo.setPagination(JsonUtil.getJsonToBean(safeQuery, 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 AuditEventDetailVO signatureDetail(Long id) { AuditEventEntity entity = auditQueryMapper.selectEventDetail(id, SIGNATURE_TARGET_TABLE); 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); } @Override public PageListVO dmsFolderPage(DmsFolderAuditPageQuery query) { DmsFolderAuditPageQuery safeQuery = query == null ? new DmsFolderAuditPageQuery() : query; long pageSize = normalizedPageSize(safeQuery.getPageSize()); long currentPage = safeQuery.getCurrentPage() <= 0 ? 1L : safeQuery.getCurrentPage(); long offset; boolean overflow; try { offset = Math.multiplyExact(currentPage - 1, pageSize); overflow = false; } catch (ArithmeticException ex) { offset = 0L; overflow = true; } QueryWrapper ew = dmsFolderWrapper(safeQuery); long total = auditQueryMapper.countOperations(ew); List list = overflow ? Collections.emptyList() : buildDmsFolderOperationRows( auditQueryMapper.selectOperationPage(ew, offset, pageSize)); safeQuery.setCurrentPage(currentPage); safeQuery.setPageSize(pageSize); safeQuery.setTotal(total); PageListVO result = new PageListVO<>(); result.setList(list); result.setPagination(JsonUtil.getJsonToBean(safeQuery, PaginationVO.class)); return result; } @Override public DmsFolderAuditDetailVO dmsFolderDetail(Long id) { AuditEventEntity business = auditEventMapper.selectOne(dmsFolderBaseWrapper() .eq("id", id)); if (business == null) { throw new DataException("DMS 文件夹审计事件不存在:id=" + id); } DmsFolderAuditDetailVO result = new DmsFolderAuditDetailVO(); AuditEventDetailVO businessDetail = toDetailVo(business); applyDmsFolderDiffDisplays(businessDetail, business.getExtra()); result.setBusiness(businessDetail); if (StringUtils.hasText(business.getOperationId())) { List related = auditEventMapper.selectList( new QueryWrapper() .eq("tenant_id", currentTenantId()) .eq("operation_id", business.getOperationId()) .eq("app_name", DMS_APP) .eq("biz_module", DMS_MODULE) .eq("biz_type", DMS_FOLDER_TYPE) .in("event_category", AuditEventCategories.PRIMARY_SIGN, AuditEventCategories.REVIEW_SIGN) .orderByAsc("event_time", "id")); List signatures = new ArrayList<>(related.size()); for (AuditEventEntity event : related) { signatures.add(toDetailVo(event)); } result.setSignatures(signatures); } return result; } @Override public List dmsFolderTimeline(String folderId) { if (!StringUtils.hasText(folderId)) { throw new DataException("文件夹 ID 不能为空"); } List events = auditEventMapper.selectList(dmsFolderBaseWrapper() .eq("target_id", folderId.trim()) .orderByAsc("id") .last("LIMIT " + TIMELINE_LIMIT)); return buildDmsFolderOperationRows(groupRows(events)); } private List buildDmsFolderOperationRows( 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.selectDmsFolderByOperationGids( gids, currentTenantId(), DMS_APP, DMS_MODULE, DMS_FOLDER_TYPE)); } 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 QueryWrapper dmsFolderWrapper(DmsFolderAuditPageQuery query) { QueryWrapper ew = dmsFolderBaseWrapper(); 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().trim()); } if (StringUtils.hasText(query.getActionCode())) { ew.eq("action_code", query.getActionCode().trim()); } if (StringUtils.hasText(query.getFolderId())) { ew.eq("target_id", query.getFolderId().trim()); } if (StringUtils.hasText(query.getKeyword())) { String keyword = query.getKeyword().trim(); ew.and(item -> item.like("record_title", keyword) .or().like("field_diffs", keyword) .or().like("extra", keyword)); } if (query.getSigned() != null) { String exists = "SELECT 1 FROM audit_events sign_event " + "WHERE sign_event.tenant_id = audit_events.tenant_id " + "AND sign_event.operation_id = audit_events.operation_id " + "AND sign_event.app_name = 'jnpf-dms' " + "AND sign_event.biz_module = 'DMS' " + "AND sign_event.biz_type = 'DMS_FOLDER' " + "AND sign_event.event_category IN ('PRIMARY_SIGN','REVIEW_SIGN')"; ew.apply((query.getSigned() ? "EXISTS (" : "NOT EXISTS (") + exists + ")"); } return ew; } private QueryWrapper dmsFolderBaseWrapper() { return new QueryWrapper() .eq("tenant_id", currentTenantId()) .eq("app_name", DMS_APP) .eq("biz_module", DMS_MODULE) .eq("biz_type", DMS_FOLDER_TYPE) .eq("target_table", DMS_FOLDER_TABLE) .eq("event_category", AuditEventCategories.BUSINESS); } private long normalizedPageSize(long pageSize) { if (pageSize <= 0) { return DEFAULT_PAGE_SIZE; } return Math.min(pageSize, MAX_PAGE_SIZE); } private String currentTenantId() { try { UserInfo user = UserProvider.getUser(); if (user != null && StringUtils.hasText(user.getTenantId())) { return user.getTenantId().trim(); } } catch (RuntimeException | LinkageError ignored) { // Use the platform single-tenant sentinel when no request user is available. } return AuditApiConsts.DEFAULT_TENANT_ID; } 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.setParentBizModule(e.getParentBizModule()); 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()); applyDmsFolderNames(vo, e); return vo; } private void applyDmsFolderNames(AuditEventListVO vo, AuditEventEntity event) { if (!DMS_FOLDER_TYPE.equals(event.getBizType()) || !DMS_FOLDER_TABLE.equals(event.getTargetTable()) || !StringUtils.hasText(event.getDataSnapshot())) { return; } try { Map snapshot = JsonUtil.stringToMap(event.getDataSnapshot()); if (snapshot != null) { vo.setFolderName(text(snapshot.get("name"))); vo.setParentFolderName(text(snapshot.get("parentName"))); } } catch (Throwable ignored) { // Snapshot summaries are optional and must never fail the audit list. } } private void applyDmsFolderDiffDisplays(AuditEventDetailVO detail, String extraJson) { if (detail == null || detail.getFieldDiffList() == null || !StringUtils.hasText(extraJson)) { return; } try { Map extra = JsonUtil.stringToMap(extraJson); String oldParentName = folderParentDisplay(extra, "oldParentName", "oldPath"); String newParentName = folderParentDisplay(extra, "newParentName", "newPath"); for (AuditFieldDiffViewVO diff : detail.getFieldDiffList()) { if (diff != null && "parent_id".equalsIgnoreCase(diff.getField())) { if (StringUtils.hasText(oldParentName)) { diff.setOldDisplay(oldParentName); } if (StringUtils.hasText(newParentName)) { diff.setNewDisplay(newParentName); } } } } catch (Throwable ignored) { // Display-name enrichment is best-effort; raw UUID evidence remains available. } } private static String folderParentDisplay(Map extra, String nameKey, String pathKey) { if (extra == null) { return null; } String path = text(extra.get(pathKey)); if (StringUtils.hasText(path)) { String[] segments = path.split("/"); List names = new ArrayList<>(); for (String segment : segments) { if (StringUtils.hasText(segment)) { names.add(segment.trim()); } } if (!names.isEmpty()) { return "/" + String.join("/", names); } } String name = text(extra.get(nameKey)); return !StringUtils.hasText(name) || name.startsWith("/") ? name : "/" + name; } private static String text(Object value) { return value == null || !StringUtils.hasText(String.valueOf(value)) ? null : String.valueOf(value).trim(); } /** * 从 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.setParentBizModule(e.getParentBizModule()); 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); } }