From d933639518823e963ef85f2aa726767f6b4fddde Mon Sep 17 00:00:00 2001 From: huangping Date: Mon, 25 May 2026 01:38:48 +0800 Subject: [PATCH] feat(m10): add audit event search and CSV export --- .../platform/api/audit/AuditController.java | 58 +++++- .../platform/api/service/AuditService.java | 35 +++- web/delivery-platform-ui/src/api/platform.js | 18 +- web/delivery-platform-ui/src/router/index.js | 6 + .../src/views/AuditSearchView.vue | 168 ++++++++++++++++++ 5 files changed, 275 insertions(+), 10 deletions(-) create mode 100644 web/delivery-platform-ui/src/views/AuditSearchView.vue diff --git a/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/audit/AuditController.java b/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/audit/AuditController.java index 9ef1f68..9a44e22 100644 --- a/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/audit/AuditController.java +++ b/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/audit/AuditController.java @@ -5,14 +5,20 @@ import cn.craftlabs.platform.api.web.dto.AuditEventResponse; import cn.craftlabs.platform.api.web.dto.PageResponse; import jakarta.validation.constraints.Max; import jakarta.validation.constraints.Min; -import jakarta.validation.constraints.NotBlank; -import jakarta.validation.constraints.NotNull; +import org.springframework.core.io.ByteArrayResource; +import org.springframework.core.io.Resource; +import org.springframework.http.HttpHeaders; +import org.springframework.http.ResponseEntity; import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; +import java.nio.charset.StandardCharsets; +import java.time.LocalDate; +import java.util.List; + @RestController @RequestMapping("/api/v1/audit-events") @Validated @@ -26,10 +32,54 @@ public class AuditController { @GetMapping public PageResponse list( - @RequestParam("entityType") @NotBlank String entityType, - @RequestParam("entityId") @NotNull Long entityId, + @RequestParam(required = false) String entityType, + @RequestParam(required = false) Long entityId, @RequestParam(value = "page", defaultValue = "0") @Min(0) int page, @RequestParam(value = "size", defaultValue = "20") @Min(1) @Max(200) int size) { return auditService.page(entityType, entityId, page, size); } + + @GetMapping("/export") + public ResponseEntity exportAuditEvents( + @RequestParam(required = false) String entityType, + @RequestParam(required = false) Long entityId, + @RequestParam(required = false) String from, + @RequestParam(required = false) String to) { + + List events = auditService.searchAuditEvents(entityType, entityId, from, to); + + StringBuilder sb = new StringBuilder(); + sb.append("时间,操作者,动作,实体类型,实体ID,摘要,详情\n"); + for (AuditEventResponse e : events) { + sb.append(escapeCsv(e.getCreatedAt() != null ? e.getCreatedAt().toString() : "")).append(","); + sb.append(escapeCsv(e.getActorUserId())).append(","); + sb.append(escapeCsv(e.getAction())).append(","); + sb.append(escapeCsv(e.getEntityType())).append(","); + sb.append(e.getEntityId() != null ? String.valueOf(e.getEntityId()) : "").append(","); + sb.append(escapeCsv(e.getFieldName())).append(","); + sb.append(escapeCsv(e.getOldValue())).append("\n"); + } + + byte[] bytes = sb.toString().getBytes(StandardCharsets.UTF_8); + byte[] bom = {(byte) 0xEF, (byte) 0xBB, (byte) 0xBF}; + byte[] withBom = new byte[bom.length + bytes.length]; + System.arraycopy(bom, 0, withBom, 0, bom.length); + System.arraycopy(bytes, 0, withBom, bom.length, bytes.length); + + ByteArrayResource resource = new ByteArrayResource(withBom); + + return ResponseEntity.ok() + .header(HttpHeaders.CONTENT_DISPOSITION, + "attachment; filename=audit-events-" + LocalDate.now() + ".csv") + .header(HttpHeaders.CONTENT_TYPE, "text/csv; charset=utf-8") + .body(resource); + } + + private static String escapeCsv(String value) { + if (value == null) return ""; + if (value.contains(",") || value.contains("\"") || value.contains("\n")) { + return "\"" + value.replace("\"", "\"\"") + "\""; + } + return value; + } } diff --git a/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/service/AuditService.java b/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/service/AuditService.java index 4958309..93b42a4 100644 --- a/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/service/AuditService.java +++ b/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/service/AuditService.java @@ -50,11 +50,7 @@ public class AuditService { @Transactional(readOnly = true) public PageResponse page( String entityType, Long entityId, int page, int size) { - LambdaQueryWrapper q = - Wrappers.lambdaQuery(PlatformAuditEvent.class) - .eq(PlatformAuditEvent::getEntityType, entityType.trim()) - .eq(PlatformAuditEvent::getEntityId, entityId) - .orderByDesc(PlatformAuditEvent::getId); + LambdaQueryWrapper q = buildQuery(entityType, entityId, null, null); Page mpPage = new Page<>(page + 1L, size); auditEventMapper.selectPage(mpPage, q); List content = @@ -62,6 +58,35 @@ public class AuditService { return new PageResponse<>(content, mpPage.getTotal(), page, size); } + @Transactional(readOnly = true) + public List searchAuditEvents( + String entityType, Long entityId, String from, String to) { + LambdaQueryWrapper q = buildQuery(entityType, entityId, from, to); + return auditEventMapper.selectList(q).stream() + .map(this::toResponse) + .collect(Collectors.toList()); + } + + private LambdaQueryWrapper buildQuery( + String entityType, Long entityId, String from, String to) { + LambdaQueryWrapper q = + Wrappers.lambdaQuery(PlatformAuditEvent.class) + .orderByDesc(PlatformAuditEvent::getId); + if (entityType != null && !entityType.isBlank()) { + q.eq(PlatformAuditEvent::getEntityType, entityType.trim()); + } + if (entityId != null) { + q.eq(PlatformAuditEvent::getEntityId, entityId); + } + if (from != null && !from.isBlank()) { + q.ge(PlatformAuditEvent::getCreatedAt, OffsetDateTime.parse(from + "T00:00:00Z")); + } + if (to != null && !to.isBlank()) { + q.le(PlatformAuditEvent::getCreatedAt, OffsetDateTime.parse(to + "T23:59:59Z")); + } + return q; + } + private AuditEventResponse toResponse(PlatformAuditEvent e) { AuditEventResponse r = new AuditEventResponse(); r.setId(e.getId()); diff --git a/web/delivery-platform-ui/src/api/platform.js b/web/delivery-platform-ui/src/api/platform.js index a90a44e..8f5061b 100644 --- a/web/delivery-platform-ui/src/api/platform.js +++ b/web/delivery-platform-ui/src/api/platform.js @@ -117,12 +117,28 @@ export function patchContractStatus(id, body) { /** * M10-F01 审计分页:`GET /api/v1/audit-events`。 - * @param {{ entityType: string, entityId: string | number, page?: number, size?: number }} params + * @param {{ entityType?: string, entityId?: string | number, page?: number, size?: number }} params */ export function listAuditEvents(params) { return axios.get("/api/v1/audit-events", { params }); } +/** + * M10-F02 审计检索:`GET /api/v1/audit-events`(无分页)。 + * @param {{ entityType?: string, entityId?: string | number, from?: string, to?: string }} params + */ +export function searchAuditEvents(params) { + return axios.get("/api/v1/audit-events", { params }); +} + +/** + * M10-F03 审计导出 CSV:`GET /api/v1/audit-events/export`。 + * @param {{ entityType?: string, entityId?: string | number, from?: string, to?: string }} params + */ +export function exportAuditEvents(params) { + return axios.get("/api/v1/audit-events/export", { params, responseType: 'blob' }); +} + /** * @param {{ page?: number, size?: number, projectId?: string | number, keyword?: string }} params */ diff --git a/web/delivery-platform-ui/src/router/index.js b/web/delivery-platform-ui/src/router/index.js index a2ce79f..113e596 100644 --- a/web/delivery-platform-ui/src/router/index.js +++ b/web/delivery-platform-ui/src/router/index.js @@ -182,6 +182,12 @@ const routes = [ component: () => import("../views/ProjectHealthView.vue"), meta: { roles: ["SYS_ADMIN"], title: "项目健康度" }, }, + { + path: "audit", + name: "audit", + component: () => import("../views/AuditSearchView.vue"), + meta: { roles: ["SYS_ADMIN"], title: "审计日志" }, + }, ], }, { path: "/403", name: "forbidden", component: () => import("../views/ForbiddenView.vue") }, diff --git a/web/delivery-platform-ui/src/views/AuditSearchView.vue b/web/delivery-platform-ui/src/views/AuditSearchView.vue new file mode 100644 index 0000000..89d5572 --- /dev/null +++ b/web/delivery-platform-ui/src/views/AuditSearchView.vue @@ -0,0 +1,168 @@ + + + + +