From 12d9c46323dedb9e6b61132a7d04f9c8074069be Mon Sep 17 00:00:00 2001 From: Qiufeng Date: Wed, 19 Aug 2026 00:22:23 +0800 Subject: [PATCH] fix: persist system update history --- README.md | 18 +-- .../finance/shared/audit/AuditMapper.java | 53 ++++++++ .../finance/shared/audit/AuditService.java | 118 ++++++++++++++++++ .../SystemUpdateApplicationService.java | 71 +++++++++-- .../V073__system_update_terminal_audit.sql | 3 + .../shared/audit/AuditServiceTest.java | 83 ++++++++++++ .../SystemUpdateApplicationServiceTest.java | 60 +++++++++ deploy/env.production.example | 2 +- deploy/update.sh | 24 ++++ frontend/e2e/system-update.e2e.ts | 33 ++++- .../src/pages/governance/SystemUpdatePage.vue | 6 +- scripts/test-update-fixture.sh | 19 ++- 12 files changed, 465 insertions(+), 25 deletions(-) create mode 100644 backend/src/main/resources/db/migration/V073__system_update_terminal_audit.sql create mode 100644 backend/src/test/java/com/kaidi/finance/shared/audit/AuditServiceTest.java diff --git a/README.md b/README.md index 8eef1b0..5f1f8a8 100644 --- a/README.md +++ b/README.md @@ -83,7 +83,7 @@ PostgreSQL 18 兼容工作继续冻结。 先在宝塔面板停止并删除当前错误的 Java 项目,再执行: ```bash -curl -fsSL https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.43/install.sh | sudo env KAIDI_APP_PORT=18080 bash +curl -fsSL https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.44/install.sh | sudo env KAIDI_APP_PORT=18080 bash ``` 该命令只安装程序运行所需的 systemd 单元,自动创建 `kaidi` 用户并检测现有 Java 17(包括 @@ -96,7 +96,7 @@ curl -fsSL https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-previe 随后执行一键清理: ```bash -curl -fsSL 'https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.43/purge.sh' | sudo env KAIDI_PURGE_CONFIRM=DELETE_LOCAL_KAIDI_INSTALLATION bash +curl -fsSL 'https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.44/purge.sh' | sudo env KAIDI_PURGE_CONFIRM=DELETE_LOCAL_KAIDI_INSTALLATION bash ``` 上面是一条完整命令:脚本通过管道直接交给 root 执行,不创建临时安装文件,避免终端自动换行导致 `-o` 参数丢失。 @@ -117,9 +117,9 @@ Spring Boot 项目。数据库、JDK、Nginx 和宝塔本身都由运维人员 下载地址: -`https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.43/kaidi-finance-1.0.0-preview.43.tar.gz` +`https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.44/kaidi-finance-1.0.0-preview.44.tar.gz` -校验文件:`https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.43/SHA256SUMS` +校验文件:`https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.44/SHA256SUMS` 服务器要求:Linux、Java 17(宝塔项目选择 JDK 17)、可访问外部 MySQL 8.4.x;**systemd 一键安装**还需要 systemd/systemd-analyze,**宝塔手动部署**不要求 systemd。32 位 Linux 需要宿主机 @@ -136,7 +136,7 @@ systemd/systemd-analyze,**宝塔手动部署**不要求 systemd。32 位 Linux 必须直接位于 `RELEASE_ROOT`,不能再嵌套一层目录。 ```bash -VERSION=1.0.0-preview.43 +VERSION=1.0.0-preview.44 APP_ROOT=/www/wwwroot/kaidi RELEASE_ROOT="$APP_ROOT/releases/$VERSION" sudo install -d -m 0755 "$RELEASE_ROOT" @@ -245,7 +245,7 @@ MySQL 8.4;提前准备外部 MySQL,完成上述向导即可。systemd 在线 `KAIDI_PURGE_DELETE_BACKUP=true`,实现本地受管文件和恢复包一并清除: ```bash -curl -fsSL 'https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.43/purge.sh' | sudo env KAIDI_PURGE_CONFIRM=DELETE_LOCAL_KAIDI_INSTALLATION KAIDI_PURGE_DELETE_BACKUP=true bash +curl -fsSL 'https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.44/purge.sh' | sudo env KAIDI_PURGE_CONFIRM=DELETE_LOCAL_KAIDI_INSTALLATION KAIDI_PURGE_DELETE_BACKUP=true bash ``` 执行前先在宝塔停止并删除 `kaidi-finance` Java 项目;宝塔面板元数据属于外部资源,必须由面板先停用, @@ -271,8 +271,8 @@ Actions 页面显示 “No matching online runner”,先启动并注册该标 Preview 属性由 SemVer 版本名表达。之后推送 tag 即会构建、测试、签名并发布: ```bash -git tag v1.0.0-preview.43 -git push origin v1.0.0-preview.43 +git tag v1.0.0-preview.44 +git push origin v1.0.0-preview.44 ``` 在线更新仍使用独立的 TDesign 页面:系统管理员进入“系统治理 → 系统更新”。更新源由 root 在 @@ -324,7 +324,7 @@ cat /var/lib/kaidi-update/status.json ```bash KAIDI_RELEASE_SIGNING_KEY=/secure/release-signing-private.pem \ KAIDI_TRUSTED_RELEASE_PUBLIC_KEY_SHA256=807c6aec1dc3f7ce494db16aa9d763c66f292033c38f328afd0390d2715a8cd9 \ - ./scripts/package-release.sh 1.0.0-preview.43 + ./scripts/package-release.sh 1.0.0-preview.44 KAIDI_TRUSTED_RELEASE_PUBLIC_KEY_SHA256=807c6aec1dc3f7ce494db16aa9d763c66f292033c38f328afd0390d2715a8cd9 \ ./scripts/verify-release.sh dist/release ``` diff --git a/backend/src/main/java/com/kaidi/finance/shared/audit/AuditMapper.java b/backend/src/main/java/com/kaidi/finance/shared/audit/AuditMapper.java index fea6cc2..243e6ed 100644 --- a/backend/src/main/java/com/kaidi/finance/shared/audit/AuditMapper.java +++ b/backend/src/main/java/com/kaidi/finance/shared/audit/AuditMapper.java @@ -22,6 +22,54 @@ public interface AuditMapper { """) int insert(AuditEntry entry); + @Select(""" + SELECT request_id, user_public_id, username, active_role, company_public_id, project_public_id, + CAST(after_json AS CHAR) AS after_json, ip_address, user_agent + FROM audit_log + WHERE object_type = 'SYSTEM_UPDATE' + AND action_code = #{actionCode} + AND request_id = #{requestId} + ORDER BY id DESC + LIMIT 1 + """) + SystemUpdateAuditSource findSystemUpdateSourceByRequestId(@Param("requestId") String requestId, + @Param("actionCode") String actionCode); + + @Select(""" + SELECT request_id, user_public_id, username, active_role, company_public_id, project_public_id, + CAST(after_json AS CHAR) AS after_json, ip_address, user_agent + FROM audit_log + WHERE object_type = 'SYSTEM_UPDATE' + AND action_code = #{actionCode} + AND ( + JSON_UNQUOTE(JSON_EXTRACT(after_json, '$.targetVersion')) = #{targetVersion} + OR JSON_UNQUOTE(JSON_EXTRACT(after_json, '$.latestVersion')) = #{targetVersion} + ) + AND created_at >= DATE_SUB(#{completedAt}, INTERVAL 7 DAY) + AND created_at <= DATE_ADD(#{completedAt}, INTERVAL 5 MINUTE) + ORDER BY id DESC + LIMIT 1 + """) + SystemUpdateAuditSource findSystemUpdateSourceByVersion(@Param("targetVersion") String targetVersion, + @Param("actionCode") String actionCode, + @Param("completedAt") LocalDateTime completedAt); + + @Insert(""" + INSERT INTO audit_log ( + public_id, request_id, user_public_id, username, active_role, company_public_id, + project_public_id, action_code, object_type, object_public_id, result_code, reason, + before_json, after_json, ip_address, user_agent, dedupe_key, created_at + ) VALUES ( + #{entry.publicId}, #{entry.requestId}, #{entry.userPublicId}, #{entry.username}, #{entry.activeRole}, + #{entry.companyPublicId}, #{entry.projectPublicId}, #{entry.actionCode}, #{entry.objectType}, + #{entry.objectPublicId}, #{entry.resultCode}, #{entry.reason}, #{entry.beforeJson}, #{entry.afterJson}, + #{entry.ipAddress}, #{entry.userAgent}, #{dedupeKey}, #{occurredAt} + ) + ON DUPLICATE KEY UPDATE dedupe_key = #{dedupeKey} + """) + int insertSystemUpdateTerminal(@Param("entry") AuditEntry entry, @Param("dedupeKey") String dedupeKey, + @Param("occurredAt") LocalDateTime occurredAt); + @Select(""" SELECT created_at, ip_address, user_agent FROM audit_log @@ -36,4 +84,9 @@ public interface AuditMapper { record LoginAuditRow(LocalDateTime occurredAt, String ipAddress, String userAgent) { } + + record SystemUpdateAuditSource(String requestId, String userPublicId, String username, String activeRole, + String companyPublicId, String projectPublicId, String afterJson, + String ipAddress, String userAgent) { + } } diff --git a/backend/src/main/java/com/kaidi/finance/shared/audit/AuditService.java b/backend/src/main/java/com/kaidi/finance/shared/audit/AuditService.java index 5b3b360..da2dfb9 100644 --- a/backend/src/main/java/com/kaidi/finance/shared/audit/AuditService.java +++ b/backend/src/main/java/com/kaidi/finance/shared/audit/AuditService.java @@ -7,6 +7,16 @@ import com.kaidi.finance.shared.id.UlidGenerator; import com.kaidi.finance.shared.infrastructure.RequestContext; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpSession; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.util.HexFormat; +import java.util.LinkedHashMap; +import java.util.Locale; +import java.util.Map; import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.stereotype.Service; @@ -54,6 +64,71 @@ public class AuditService { null, null); } + /** + * Persists the updater's terminal state as a separate immutable audit event. + * + * The shell updater finishes while the application is restarting, so this method is invoked by the first + * successful status read after restart. A database-level dedupe key makes repeated polling and application + * restarts idempotent. When possible the terminal event inherits the actor and request id from the original + * download/install request; legacy status files without a request id fall back to the most recent matching + * target version. + * + * @return the stable dedupe key, or {@code null} when the supplied state is not persistable + */ + public String recordSystemUpdateTerminal(String updateRequestId, String updateAction, String state, + String targetVersion, String message, Instant updatedAt) { + String normalizedState = normalizeTerminalState(state); + String normalizedVersion = clean(targetVersion, 128); + if (normalizedState == null || normalizedVersion == null || updatedAt == null) return null; + + String normalizedRequestId = validRequestId(updateRequestId) ? updateRequestId : null; + String normalizedAction = clean(updateAction, 16); + if (normalizedAction != null) normalizedAction = normalizedAction.toUpperCase(Locale.ROOT); + String sourceAction = sourceAction(normalizedState, normalizedAction); + AuditMapper.SystemUpdateAuditSource source = findSystemUpdateSource( + normalizedRequestId, normalizedVersion, sourceAction, updatedAt); + if (source == null && normalizedAction == null && !"SYSTEM_UPDATE_DOWNLOAD_REQUEST".equals(sourceAction)) { + source = findSystemUpdateSource(normalizedRequestId, normalizedVersion, + "SYSTEM_UPDATE_DOWNLOAD_REQUEST", updatedAt); + } + + String correlation = normalizedRequestId == null + ? normalizedVersion + '|' + normalizedState + '|' + updatedAt + : normalizedRequestId + '|' + normalizedState; + String dedupeKey = "SYSUPD:" + sha256(correlation); + String terminalAction = switch (normalizedState) { + case "SUCCEEDED" -> "SYSTEM_UPDATE_SUCCEEDED"; + case "RECOVERY_REQUIRED" -> "SYSTEM_UPDATE_RECOVERY_REQUIRED"; + default -> "SYSTEM_UPDATE_FAILED"; + }; + String result = "SUCCEEDED".equals(normalizedState) + ? "SUCCESS" : ("RECOVERY_REQUIRED".equals(normalizedState) ? "BLOCKED" : "FAILED"); + String terminalReason = truncate(sanitizeReason(clean(message, 1000)), 500); + + Map terminal = new LinkedHashMap<>(); + terminal.put("state", normalizedState); + terminal.put("targetVersion", normalizedVersion); + terminal.put("message", terminalReason == null ? "" : terminalReason); + terminal.put("updatedAt", updatedAt); + if (normalizedAction != null) terminal.put("action", normalizedAction); + if (normalizedRequestId != null) terminal.put("requestId", normalizedRequestId); + + Actor actor = source == null + ? currentActor() : new Actor(source.userPublicId(), source.username(), source.activeRole()); + AuditEntry entry = new AuditEntry( + ulidGenerator.next(), source == null ? RequestContext.requestId() : source.requestId(), + actor.publicId(), actor.username(), actor.activeRole(), + source == null ? null : source.companyPublicId(), source == null ? null : source.projectPublicId(), + terminalAction, "SYSTEM_UPDATE", "SYSTEM_UPDATE", result, terminalReason, + source == null ? null : source.afterJson(), json(terminal), + source == null ? clientIp() : source.ipAddress(), source == null + ? truncate(request.getHeader("User-Agent"), 500) : source.userAgent() + ); + auditMapper.insertSystemUpdateTerminal(entry, dedupeKey, + LocalDateTime.ofInstant(updatedAt, ZoneOffset.UTC)); + return dedupeKey; + } + private void insert(Actor actor, String companyPublicId, String projectPublicId, String action, String objectType, String objectPublicId, String result, String reason, Object before, Object after) { @@ -76,6 +151,49 @@ public class AuditService { return new Actor(null, null, null); } + private AuditMapper.SystemUpdateAuditSource findSystemUpdateSource(String requestId, String targetVersion, + String actionCode, Instant completedAt) { + AuditMapper.SystemUpdateAuditSource source = requestId == null + ? null : auditMapper.findSystemUpdateSourceByRequestId(requestId, actionCode); + return source == null ? auditMapper.findSystemUpdateSourceByVersion(targetVersion, actionCode, + LocalDateTime.ofInstant(completedAt, ZoneOffset.UTC)) : source; + } + + private static String normalizeTerminalState(String state) { + String normalized = clean(state, 32); + if (normalized == null) return null; + normalized = normalized.toUpperCase(Locale.ROOT); + return switch (normalized) { + case "SUCCEEDED", "FAILED", "RECOVERY_REQUIRED" -> normalized; + default -> null; + }; + } + + private static String sourceAction(String state, String action) { + if ("SUCCEEDED".equals(state) || "INSTALL".equals(action)) return "SYSTEM_UPDATE_REQUEST"; + if ("DOWNLOAD".equals(action)) return "SYSTEM_UPDATE_DOWNLOAD_REQUEST"; + return "SYSTEM_UPDATE_REQUEST"; + } + + private static boolean validRequestId(String requestId) { + return requestId != null && requestId.matches("^[0-9A-HJKMNP-TV-Z]{26}$"); + } + + private static String clean(String value, int max) { + if (value == null || value.isBlank()) return null; + String cleaned = value.trim(); + return cleaned.length() <= max ? cleaned : cleaned.substring(0, max); + } + + private static String sha256(String value) { + try { + return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256") + .digest(value.getBytes(StandardCharsets.UTF_8))); + } catch (NoSuchAlgorithmException exception) { + throw new IllegalStateException("SHA-256 is unavailable", exception); + } + } + private String json(Object value) { if (value == null) { return null; diff --git a/backend/src/main/java/com/kaidi/finance/update/application/SystemUpdateApplicationService.java b/backend/src/main/java/com/kaidi/finance/update/application/SystemUpdateApplicationService.java index 8793403..0039fb2 100644 --- a/backend/src/main/java/com/kaidi/finance/update/application/SystemUpdateApplicationService.java +++ b/backend/src/main/java/com/kaidi/finance/update/application/SystemUpdateApplicationService.java @@ -36,12 +36,16 @@ import java.util.List; import java.util.Locale; import java.util.regex.Matcher; import java.util.regex.Pattern; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.http.HttpStatus; import org.springframework.stereotype.Service; @Service public class SystemUpdateApplicationService { + private static final Logger log = LoggerFactory.getLogger(SystemUpdateApplicationService.class); + private static final int MAX_MANIFEST_BYTES = 64 * 1024; private static final int MAX_RELEASE_API_BYTES = 256 * 1024; private static final int MAX_STATUS_BYTES = 64 * 1024; @@ -71,6 +75,7 @@ public class SystemUpdateApplicationService { private final HttpClient httpClient; private volatile ReleaseManifest lastManifest; private volatile Instant lastCheckedAt; + private volatile String lastTerminalAuditFingerprint; public SystemUpdateApplicationService(SystemUpdateProperties properties, AuthorizationService authorizationService, @@ -90,7 +95,9 @@ public class SystemUpdateApplicationService { public SystemUpdateView status() { requireIsolatedSystemAdministrator(); - return view(lastManifest, readStatus()); + UpdateStatus status = readStatus(); + persistTerminalAudit(status); + return view(lastManifest, status); } public SystemUpdateView check() { @@ -99,7 +106,9 @@ public class SystemUpdateApplicationService { ReleaseManifest manifest = fetchManifest(); lastManifest = manifest; lastCheckedAt = Instant.now(); - SystemUpdateView result = view(manifest, readStatus()); + UpdateStatus status = readStatus(); + persistTerminalAudit(status); + SystemUpdateView result = view(manifest, status); auditService.record("SYSTEM_UPDATE_CHECK", "SYSTEM_UPDATE", "SYSTEM_UPDATE", "SUCCESS", null, null, result); return result; @@ -120,6 +129,7 @@ public class SystemUpdateApplicationService { throw validation("当前已是相同或更高版本"); } UpdateStatus currentStatus = readStatus(); + persistTerminalAudit(currentStatus); if ("READY".equals(currentStatus.state()) && requested.equals(currentStatus.targetVersion())) { throw validation("该版本已经下载并通过校验,请确认安装"); } @@ -137,6 +147,7 @@ public class SystemUpdateApplicationService { requireConfigured(); String requested = request.version().trim(); UpdateStatus ready = readStatus(); + persistTerminalAudit(ready); if (!"READY".equals(ready.state()) || !requested.equals(ready.targetVersion())) { throw validation("该版本尚未完成下载和签名校验"); } @@ -369,7 +380,8 @@ public class SystemUpdateApplicationService { "已有系统更新请求等待执行"); } writeRequest(requestFile, version, reason, action); - UpdateStatus queued = new UpdateStatus(queuedState, queuedMessage, version, Instant.now()); + UpdateStatus queued = new UpdateStatus(queuedState, queuedMessage, version, Instant.now(), + RequestContext.requestId(), action); return new QueueTransition(currentStatus, queued); } catch (IOException exception) { throw storage("系统更新队列锁定失败"); @@ -431,7 +443,8 @@ public class SystemUpdateApplicationService { blank(root.path("targetVersion").asText(null)), parseInstant(root.path("updatedAt").asText(null)), nonNegativeLong(root, "downloadedBytes"), nonNegativeLong(root, "totalBytes"), nonNegativeLong(root, "bytesPerSecond"), boundedInteger(root, "downloadPercent", 0, 100), - boundedInteger(root, "restartExpectedSeconds", 0, 300)); + boundedInteger(root, "restartExpectedSeconds", 0, 300), + boundedText(root, "requestId", 64), updateAction(root.path("action").asText(null))); } catch (IOException exception) { return new UpdateStatus("UNKNOWN", "更新状态读取失败", null, null); } @@ -525,10 +538,13 @@ public class SystemUpdateApplicationService { String version = blank(root.path("version").asText(null)); String action = root.path("action").asText("INSTALL").toUpperCase(Locale.ROOT); Instant requestedAt = parseInstant(root.path("requestedAt").asText(null)); + String requestId = boundedText(root, "requestId", 64); if ("DOWNLOAD".equals(action)) { - return new UpdateStatus("DOWNLOAD_QUEUED", "下载请求已排队,等待更新服务处理", version, requestedAt); + return new UpdateStatus("DOWNLOAD_QUEUED", "下载请求已排队,等待更新服务处理", version, + requestedAt, requestId, action); } - return new UpdateStatus("INSTALL_QUEUED", "安装请求已排队,等待更新服务处理", version, requestedAt); + return new UpdateStatus("INSTALL_QUEUED", "安装请求已排队,等待更新服务处理", version, + requestedAt, requestId, action); } catch (IOException exception) { return new UpdateStatus("UNKNOWN", "更新请求读取失败", null, null); } @@ -631,6 +647,39 @@ public class SystemUpdateApplicationService { return parsed < minimum || parsed > maximum ? null : parsed; } + private static String boundedText(JsonNode root, String field, int maximum) { + JsonNode value = root.path(field); + if (!value.isTextual()) return null; + String parsed = blank(value.asText(null)); + return parsed != null && parsed.length() <= maximum ? parsed : null; + } + + private static String updateAction(String value) { + String action = blank(value); + if (action == null) return null; + action = action.toUpperCase(Locale.ROOT); + return "DOWNLOAD".equals(action) || "INSTALL".equals(action) ? action : null; + } + + private void persistTerminalAudit(UpdateStatus status) { + if (status == null || status.updatedAt() == null || status.targetVersion() == null + || !("SUCCEEDED".equals(status.state()) || "FAILED".equals(status.state()) + || "RECOVERY_REQUIRED".equals(status.state()))) { + return; + } + String fingerprint = String.join("|", status.state(), String.valueOf(status.requestId()), + status.targetVersion(), status.updatedAt().toString(), String.valueOf(status.action())); + if (fingerprint.equals(lastTerminalAuditFingerprint)) return; + try { + String persistedKey = auditService.recordSystemUpdateTerminal(status.requestId(), status.action(), + status.state(), status.targetVersion(), status.message(), status.updatedAt()); + if (persistedKey != null) lastTerminalAuditFingerprint = fingerprint; + } catch (RuntimeException exception) { + log.warn("系统更新终态审计写入失败:state={}, targetVersion={}", status.state(), + status.targetVersion(), exception); + } + } + private BusinessException validation(String message) { return new BusinessException(HttpStatus.UNPROCESSABLE_ENTITY, ErrorCode.VALIDATION_FAILED, message); } @@ -649,9 +698,15 @@ public class SystemUpdateApplicationService { private record UpdateStatus(String state, String message, String targetVersion, Instant updatedAt, Long downloadedBytes, Long totalBytes, Long bytesPerSecond, - Integer downloadPercent, Integer restartExpectedSeconds) { + Integer downloadPercent, Integer restartExpectedSeconds, + String requestId, String action) { private UpdateStatus(String state, String message, String targetVersion, Instant updatedAt) { - this(state, message, targetVersion, updatedAt, null, null, null, null, null); + this(state, message, targetVersion, updatedAt, null, null, null, null, null, null, null); + } + + private UpdateStatus(String state, String message, String targetVersion, Instant updatedAt, + String requestId, String action) { + this(state, message, targetVersion, updatedAt, null, null, null, null, null, requestId, action); } } diff --git a/backend/src/main/resources/db/migration/V073__system_update_terminal_audit.sql b/backend/src/main/resources/db/migration/V073__system_update_terminal_audit.sql new file mode 100644 index 0000000..f9ee3c6 --- /dev/null +++ b/backend/src/main/resources/db/migration/V073__system_update_terminal_audit.sql @@ -0,0 +1,3 @@ +ALTER TABLE audit_log + ADD COLUMN dedupe_key VARCHAR(96) NULL AFTER user_agent, + ADD UNIQUE KEY uk_audit_log_dedupe_key (dedupe_key); diff --git a/backend/src/test/java/com/kaidi/finance/shared/audit/AuditServiceTest.java b/backend/src/test/java/com/kaidi/finance/shared/audit/AuditServiceTest.java new file mode 100644 index 0000000..826f437 --- /dev/null +++ b/backend/src/test/java/com/kaidi/finance/shared/audit/AuditServiceTest.java @@ -0,0 +1,83 @@ +package com.kaidi.finance.shared.audit; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.kaidi.finance.shared.id.UlidGenerator; +import java.time.Instant; +import java.time.LocalDateTime; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.mock.web.MockHttpServletRequest; + +class AuditServiceTest { + + @Test + void terminalUpdateInheritsTheOriginalActorAndProducesAStableDedupeKey() { + AuditMapper mapper = mock(AuditMapper.class); + UlidGenerator ulids = mock(UlidGenerator.class); + when(ulids.next()).thenReturn("01M00000000000000000000999"); + when(mapper.findSystemUpdateSourceByRequestId( + "01M00000000000000000000092", "SYSTEM_UPDATE_REQUEST")) + .thenReturn(new AuditMapper.SystemUpdateAuditSource( + "01M00000000000000000000092", "01M00000000000000000000001", "admin", "SYSTEM_ADMIN", + null, null, "{\"state\":\"INSTALL_QUEUED\",\"targetVersion\":\"1.0.0-preview.44\"}", + "127.0.0.1", "fixture-agent")); + when(mapper.insertSystemUpdateTerminal(any(), anyString(), any())).thenReturn(1); + AuditService service = new AuditService(mapper, ulids, new ObjectMapper().findAndRegisterModules(), + new MockHttpServletRequest("GET", "/api/v1/admin/system-update")); + Instant completedAt = Instant.parse("2026-08-19T00:10:00Z"); + + String firstKey = service.recordSystemUpdateTerminal("01M00000000000000000000092", "INSTALL", + "SUCCEEDED", "1.0.0-preview.44", "新版本已通过健康检查", completedAt); + String secondKey = service.recordSystemUpdateTerminal("01M00000000000000000000092", "INSTALL", + "SUCCEEDED", "1.0.0-preview.44", "新版本已通过健康检查", completedAt); + + assertEquals(firstKey, secondKey); + assertTrue(firstKey.startsWith("SYSUPD:")); + ArgumentCaptor entry = ArgumentCaptor.forClass(AuditEntry.class); + verify(mapper, org.mockito.Mockito.times(2)).insertSystemUpdateTerminal(entry.capture(), + org.mockito.ArgumentMatchers.eq(firstKey), any()); + AuditEntry persisted = entry.getAllValues().get(0); + assertEquals("01M00000000000000000000092", persisted.requestId()); + assertEquals("01M00000000000000000000001", persisted.userPublicId()); + assertEquals("SYSTEM_UPDATE_SUCCEEDED", persisted.actionCode()); + assertEquals("SUCCESS", persisted.resultCode()); + assertTrue(persisted.beforeJson().contains("INSTALL_QUEUED")); + assertTrue(persisted.afterJson().contains("1.0.0-preview.44")); + assertTrue(persisted.afterJson().contains("SUCCEEDED")); + } + + @Test + void legacyTerminalStatusFindsTheRecentInstallRequestByTargetVersion() { + AuditMapper mapper = mock(AuditMapper.class); + UlidGenerator ulids = mock(UlidGenerator.class); + when(ulids.next()).thenReturn("01M00000000000000000000998"); + LocalDateTime completedAt = LocalDateTime.parse("2026-08-18T23:50:00"); + when(mapper.findSystemUpdateSourceByVersion( + "1.0.0-preview.43", "SYSTEM_UPDATE_REQUEST", completedAt)) + .thenReturn(new AuditMapper.SystemUpdateAuditSource( + "01M00000000000000000000088", "01M00000000000000000000001", "admin", "SYSTEM_ADMIN", + null, null, "{\"state\":\"INSTALL_QUEUED\",\"targetVersion\":\"1.0.0-preview.43\"}", + "127.0.0.1", "fixture-agent")); + when(mapper.insertSystemUpdateTerminal(any(), anyString(), any())).thenReturn(1); + AuditService service = new AuditService(mapper, ulids, new ObjectMapper().findAndRegisterModules(), + new MockHttpServletRequest("GET", "/api/v1/admin/system-update")); + + service.recordSystemUpdateTerminal(null, null, "SUCCEEDED", "1.0.0-preview.43", + "Release 1.0.0-preview.43 is running", Instant.parse("2026-08-18T23:50:00Z")); + + verify(mapper).findSystemUpdateSourceByVersion( + "1.0.0-preview.43", "SYSTEM_UPDATE_REQUEST", completedAt); + ArgumentCaptor entry = ArgumentCaptor.forClass(AuditEntry.class); + verify(mapper).insertSystemUpdateTerminal(entry.capture(), anyString(), any()); + assertEquals("01M00000000000000000000088", entry.getValue().requestId()); + assertEquals("SYSTEM_UPDATE_SUCCEEDED", entry.getValue().actionCode()); + } +} diff --git a/backend/src/test/java/com/kaidi/finance/update/application/SystemUpdateApplicationServiceTest.java b/backend/src/test/java/com/kaidi/finance/update/application/SystemUpdateApplicationServiceTest.java index 1fadf2a..1de4843 100644 --- a/backend/src/test/java/com/kaidi/finance/update/application/SystemUpdateApplicationServiceTest.java +++ b/backend/src/test/java/com/kaidi/finance/update/application/SystemUpdateApplicationServiceTest.java @@ -6,6 +6,8 @@ import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import com.fasterxml.jackson.databind.ObjectMapper; @@ -286,6 +288,64 @@ class SystemUpdateApplicationServiceTest { } } + @Test + void persistsCorrelatedTerminalUpdateAuditOnlyOncePerApplicationProcess() throws Exception { + Path inbox = Files.createDirectory(tempDir.resolve("terminal-inbox")); + Path statusFile = tempDir.resolve("terminal-status.json"); + Files.writeString(statusFile, """ + {"state":"SUCCEEDED","message":"新版本已通过健康检查","targetVersion":"1.0.0-preview.44", + "updatedAt":"2026-08-19T00:10:00Z","requestId":"01M00000000000000000000092", + "action":"INSTALL"} + """); + AuditService auditService = mock(AuditService.class); + when(auditService.recordSystemUpdateTerminal("01M00000000000000000000092", "INSTALL", "SUCCEEDED", + "1.0.0-preview.44", "新版本已通过健康检查", java.time.Instant.parse("2026-08-19T00:10:00Z"))) + .thenReturn("SYSUPD:terminal"); + SystemUpdateApplicationService service = serviceForStatus(inbox, statusFile, auditService); + + assertEquals("SUCCEEDED", service.status().state()); + assertEquals("SUCCEEDED", service.status().state()); + + verify(auditService, times(1)).recordSystemUpdateTerminal("01M00000000000000000000092", "INSTALL", + "SUCCEEDED", "1.0.0-preview.44", "新版本已通过健康检查", + java.time.Instant.parse("2026-08-19T00:10:00Z")); + } + + @Test + void persistsLegacyTerminalStatusWithoutRequestCorrelation() throws Exception { + Path inbox = Files.createDirectory(tempDir.resolve("legacy-terminal-inbox")); + Path statusFile = tempDir.resolve("legacy-terminal-status.json"); + Files.writeString(statusFile, """ + {"state":"SUCCEEDED","message":"Release 1.0.0-preview.43 is running", + "targetVersion":"1.0.0-preview.43","updatedAt":"2026-08-18T23:50:00Z"} + """); + AuditService auditService = mock(AuditService.class); + when(auditService.recordSystemUpdateTerminal(null, null, "SUCCEEDED", "1.0.0-preview.43", + "Release 1.0.0-preview.43 is running", java.time.Instant.parse("2026-08-18T23:50:00Z"))) + .thenReturn("SYSUPD:legacy"); + SystemUpdateApplicationService service = serviceForStatus(inbox, statusFile, auditService); + + assertEquals("SUCCEEDED", service.status().state()); + + verify(auditService).recordSystemUpdateTerminal(null, null, "SUCCEEDED", "1.0.0-preview.43", + "Release 1.0.0-preview.43 is running", java.time.Instant.parse("2026-08-18T23:50:00Z")); + } + + private SystemUpdateApplicationService serviceForStatus(Path inbox, Path statusFile, AuditService auditService) { + SystemUpdateProperties properties = new SystemUpdateProperties(true, "1.0.0-preview.43", null, + "https://release.fixture.invalid/", null, null, inbox.resolve("request.json"), statusFile, + Duration.ofSeconds(2), Duration.ofSeconds(2), true); + AuthorizationService authorization = mock(AuthorizationService.class); + when(authorization.hasPermission(any())).thenReturn(true); + IdentityContext identity = mock(IdentityContext.class); + when(identity.requireActiveRole()).thenReturn("SYSTEM_ADMIN"); + when(identity.requirePrincipal()).thenReturn(new FinancePrincipal(1, "01M00000000000000000000001", + "admin", "系统管理员", "信息中心", false, + List.of(new RoleAssignment(1, "SYSTEM_ADMIN", "系统管理员", "系统治理")))); + return new SystemUpdateApplicationService(properties, authorization, identity, auditService, + new ObjectMapper()); + } + @Test void checksConfiguredManifestAndQueuesOnlyItsLatestVersion() throws Exception { HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); diff --git a/deploy/env.production.example b/deploy/env.production.example index 932868d..5fe38c8 100644 --- a/deploy/env.production.example +++ b/deploy/env.production.example @@ -15,7 +15,7 @@ FILE_SCANNER_ENABLED=true FINANCE_BOOTSTRAP_ENABLED=false FINANCE_BOOTSTRAP_PASSWORD= -APP_VERSION=1.0.0-preview.43 +APP_VERSION=1.0.0-preview.44 UPDATE_CURRENT_VERSION_FILE=/opt/kaidi/current/VERSION FINANCE_UPDATE_ENABLED=true # Use either a stable direct asset base URL or the public Gitea latest-release API. diff --git a/deploy/update.sh b/deploy/update.sh index 7e5bdff..3e443a7 100755 --- a/deploy/update.sh +++ b/deploy/update.sh @@ -41,6 +41,8 @@ WORK_DIR= CACHE_TEMP= RELEASE_AUTH_HEADER_FILE= TARGET_VERSION= +REQUEST_ID= +REQUEST_ACTION= TERMINAL_STATUS_WRITTEN=false DOWNLOAD_PID= DOWNLOAD_PGID= @@ -293,10 +295,26 @@ status() { restart_expected_seconds=${8:-} previous_state= previous_message= + previous_request_id= + previous_action= if [ -f "$STATUS_FILE" ] && [ ! -L "$STATUS_FILE" ]; then previous_state=$(jq -r '.state // empty' "$STATUS_FILE" 2>/dev/null || true) previous_message=$(jq -r '.message // empty' "$STATUS_FILE" 2>/dev/null || true) + previous_request_id=$(jq -r '.requestId // empty | strings | select(length <= 64)' \ + "$STATUS_FILE" 2>/dev/null || true) + previous_action=$(jq -r '.action // empty | strings | ascii_upcase \ + | select(. == "DOWNLOAD" or . == "INSTALL")' "$STATUS_FILE" 2>/dev/null || true) fi + status_request_id= + status_action= + if [ -f "$PROCESSING_FILE" ] && [ ! -L "$PROCESSING_FILE" ]; then + status_request_id=$(jq -r '.requestId // empty | strings | select(length > 0 and length <= 64)' \ + "$PROCESSING_FILE" 2>/dev/null || true) + status_action=$(jq -r '.action // empty | strings | ascii_upcase \ + | select(. == "DOWNLOAD" or . == "INSTALL")' "$PROCESSING_FILE" 2>/dev/null || true) + fi + [ -n "$status_request_id" ] || status_request_id=${REQUEST_ID:-$previous_request_id} + [ -n "$status_action" ] || status_action=${REQUEST_ACTION:-$previous_action} tmp="$STATUS_FILE.tmp.$$" jq -n \ --arg state "$state" \ @@ -307,9 +325,13 @@ status() { --arg bytesPerSecond "$bytes_per_second" \ --arg downloadPercent "$download_percent" \ --arg restartExpectedSeconds "$restart_expected_seconds" \ + --arg requestId "$status_request_id" \ + --arg action "$status_action" \ --arg updatedAt "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ '{state:$state,message:$message,updatedAt:$updatedAt} + (if ($version | length) > 0 then {targetVersion:$version} else {} end) + + (if ($requestId | length) > 0 then {requestId:$requestId} else {} end) + + (if ($action == "DOWNLOAD" or $action == "INSTALL") then {action:$action} else {} end) + (if ($downloadedBytes | test("^[0-9]+$")) then {downloadedBytes:($downloadedBytes | tonumber)} else {} end) + (if ($totalBytes | test("^[0-9]+$")) then {totalBytes:($totalBytes | tonumber)} else {} end) + (if ($bytesPerSecond | test("^[0-9]+$")) then {bytesPerSecond:($bytesPerSecond | tonumber)} else {} end) @@ -1035,6 +1057,8 @@ TARGET_VERSION=$REQUESTED_VERSION REQUEST_ACTION=$(jq -er '(.action // "INSTALL") | strings | ascii_upcase | select(. == "DOWNLOAD" or . == "INSTALL")' "$PROCESSING_FILE") \ || fail "Update request action is invalid" +REQUEST_ID=$(jq -r '.requestId // empty | strings | select(length > 0 and length <= 64)' \ + "$PROCESSING_FILE" 2>/dev/null || true) prepare_update_layout reset_event_log load_runtime_database_env diff --git a/frontend/e2e/system-update.e2e.ts b/frontend/e2e/system-update.e2e.ts index c519165..aa8ff0b 100644 --- a/frontend/e2e/system-update.e2e.ts +++ b/frontend/e2e/system-update.e2e.ts @@ -1,7 +1,13 @@ import type { Page, Route } from '@playwright/test'; import { expect, test } from '@playwright/test'; -const permissions = ['admin:user:view', 'admin:user:create', 'admin:update:view', 'admin:update:execute']; +const permissions = [ + 'admin:user:view', + 'admin:user:create', + 'admin:update:view', + 'admin:update:execute', + 'audit:log:view', +]; function envelope(data: unknown) { return { data, requestId: '01M00000000000000000000090' }; @@ -111,8 +117,13 @@ async function installFixture( let state = recoveryPending ? 'RECOVERY_REQUIRED' : initialState || 'IDLE'; let progressDeadline = 0; let restartDeadline = 0; + let terminalHistoryRecorded = false; const history: Array> = []; - const recordHistory = (actionCode: string, targetVersion = '1.0.0-preview.2') => { + const recordHistory = ( + actionCode: string, + targetVersion = '1.0.0-preview.2', + reason = actionCode === 'SYSTEM_UPDATE_REQUEST' ? '管理员确认立即更新并重启' : null, + ) => { history.unshift({ publicId: `01M00000000000000000000${String(history.length + 1).padStart(3, '0')}`, eventSequence: history.length + 1, @@ -123,9 +134,12 @@ async function installFixture( objectType: 'SYSTEM_UPDATE', objectPublicId: 'SYSTEM_UPDATE', resultCode: 'SUCCESS', - reason: actionCode === 'SYSTEM_UPDATE_REQUEST' ? '管理员确认立即更新并重启' : null, + reason, beforeJson: null, - afterJson: JSON.stringify({ targetVersion }), + afterJson: JSON.stringify({ + targetVersion, + ...(actionCode === 'SYSTEM_UPDATE_SUCCEEDED' ? { state: 'SUCCEEDED' } : {}), + }), occurredAt: '2026-08-16T00:02:00Z', allowedActions: [], }); @@ -156,6 +170,10 @@ async function installFixture( return route.abort('connectionrefused'); } if (state === 'RUNNING' && restartDeadline > 0) state = 'SUCCEEDED'; + if (state === 'SUCCEEDED' && !terminalHistoryRecorded) { + recordHistory('SYSTEM_UPDATE_SUCCEEDED', '1.0.0-preview.2', '新版本已通过健康检查'); + terminalHistoryRecorded = true; + } const response = updateView(state, checked, enabled, recoveryPending); if (state === 'DOWNLOADING' && Date.now() >= progressDeadline) state = 'READY'; if (completeBusyAfterFirstRead && state === 'INSTALLING') state = 'SUCCEEDED'; @@ -192,6 +210,9 @@ async function installFixture( return route.fulfill({ json: envelope(queued) }); } if (pathname === '/api/v1/audit/logs' && request.method() === 'GET') { + const query = new URL(request.url()).searchParams; + expect(query.get('occurredFrom')).toBe('1970-01-01T00:00:00Z'); + expect(query.get('objectType')).toBe('SYSTEM_UPDATE'); return route.fulfill({ json: { data: history, @@ -393,6 +414,10 @@ test('successful installation starts the ten-second automatic refresh countdown' await expect(page.getByText('新版本已通过健康检查,页面将在 10 秒后自动刷新。', { exact: true })).toBeVisible({ timeout: 5_000, }); + const historyPanel = page.locator('.history-panel'); + const completedRow = historyPanel.locator('tr').filter({ hasText: '更新完成' }); + await expect(completedRow).toContainText('1.0.0-preview.2'); + await expect(completedRow).toContainText('新版本已通过健康检查'); }); test('reloading during installation resumes polling and starts the refresh countdown', async ({ page }) => { diff --git a/frontend/src/pages/governance/SystemUpdatePage.vue b/frontend/src/pages/governance/SystemUpdatePage.vue index d3e82c2..9e7254e 100644 --- a/frontend/src/pages/governance/SystemUpdatePage.vue +++ b/frontend/src/pages/governance/SystemUpdatePage.vue @@ -111,7 +111,7 @@

历史更新记录

-

记录版本获取、下载和重启安装操作。

+

记录版本获取、下载、重启安装和最终执行结果。

最近 {{ historyRows.length }} 条
@@ -446,6 +446,7 @@ async function loadHistory(silent = false) { } try { const result = await getAuditLogs({ + occurredFrom: '1970-01-01T00:00:00Z', objectType: 'SYSTEM_UPDATE', sort: 'occurredAt,desc', page: 1, @@ -724,6 +725,9 @@ function actionLabel(action: string) { SYSTEM_UPDATE_CHECK: '获取版本', SYSTEM_UPDATE_DOWNLOAD_REQUEST: '下载更新包', SYSTEM_UPDATE_REQUEST: '立即更新并重启', + SYSTEM_UPDATE_SUCCEEDED: '更新完成', + SYSTEM_UPDATE_FAILED: '更新失败', + SYSTEM_UPDATE_RECOVERY_REQUIRED: '更新需人工恢复', }[action] || action ); } diff --git a/scripts/test-update-fixture.sh b/scripts/test-update-fixture.sh index 9047853..d6bcf9c 100755 --- a/scripts/test-update-fixture.sh +++ b/scripts/test-update-fixture.sh @@ -297,9 +297,12 @@ write_request() { local fixture=$1 local version=$2 local action=$3 + local request_id=01M00000000000000000000091 + [ "$action" = INSTALL ] && request_id=01M00000000000000000000092 mkdir -p "$fixture/state/inbox" - jq -n --arg action "$action" --arg version "$version" \ - '{action:$action,version:$version,reason:"fixture"}' > "$fixture/state/inbox/request.json" + jq -n --arg action "$action" --arg version "$version" --arg requestId "$request_id" \ + '{action:$action,version:$version,reason:"fixture",requestId:$requestId, + requestedAt:"2026-08-19T00:00:00Z"}' > "$fixture/state/inbox/request.json" } download_and_prepare_install() { @@ -309,6 +312,9 @@ download_and_prepare_install() { run_update "$fixture" success [ "$(jq -r '.state' "$fixture/state/status.json")" = READY ] \ || fail 'download phase did not persist READY' + [ "$(jq -r '.requestId' "$fixture/state/status.json")" = 01M00000000000000000000091 ] \ + && [ "$(jq -r '.action' "$fixture/state/status.json")" = DOWNLOAD ] \ + || fail 'download phase did not preserve request correlation' [ "$(readlink "$fixture/app/current")" = "$fixture/app/releases/1.0.0-preview.1" ] \ || fail 'download phase changed the active application' [ -s "$fixture/state/cache/$version/release.tar.gz" ] \ @@ -447,6 +453,9 @@ assert_success_case() { || fail 'success case did not activate the signed updater' [ "$(jq -r '.state' "$fixture/state/status.json")" = SUCCEEDED ] \ || fail 'success case did not persist SUCCEEDED' + [ "$(jq -r '.requestId' "$fixture/state/status.json")" = 01M00000000000000000000092 ] \ + && [ "$(jq -r '.action' "$fixture/state/status.json")" = INSTALL ] \ + || fail 'success case did not preserve install request correlation' [ ! -e "$fixture/state/processing/request.json" ] \ || fail 'success case left a claimed request behind' grep -qx 'daemon-reload' "$fixture/systemctl.log" || fail 'systemd units were not reloaded' @@ -522,6 +531,9 @@ assert_rollback_case() { || fail 'rollback case did not restart both the candidate and restored releases' [ "$(jq -r '.state' "$fixture/state/status.json")" = FAILED ] \ || fail 'rollback case did not persist FAILED' + [ "$(jq -r '.requestId' "$fixture/state/status.json")" = 01M00000000000000000000092 ] \ + && [ "$(jq -r '.action' "$fixture/state/status.json")" = INSTALL ] \ + || fail 'rollback case did not preserve install request correlation' [ ! -e "$fixture/app/releases/$version" ] \ || fail 'rollback case left the failed release installed' find "$fixture/state/failed" -type f -name 'request-*.json' -print -quit | grep -q . \ @@ -550,6 +562,9 @@ assert_incomplete_rollback_requires_manual_recovery_case() { || fail 'incomplete rollback case did not quarantine transaction evidence' [ "$(jq -r '.state' "$fixture/state/status.json")" = RECOVERY_REQUIRED ] \ || fail 'incomplete rollback case did not lock the updater for recovery' + [ "$(jq -r '.requestId' "$fixture/state/status.json")" = 01M00000000000000000000092 ] \ + && [ "$(jq -r '.action' "$fixture/state/status.json")" = INSTALL ] \ + || fail 'incomplete rollback case did not preserve install request correlation' find "$fixture/state/failed" -type f -name 'request-*.json' -print -quit | grep -q . \ || fail 'incomplete rollback case did not archive its claimed request'