From 6b04d8dd0aa822034f8a349d2804df6dbf78e8a7 Mon Sep 17 00:00:00 2001 From: Qiufeng Date: Wed, 19 Aug 2026 08:27:08 +0800 Subject: [PATCH] feat: show signed release changelog in update history --- .gitea/workflows/release.yml | 2 +- README.md | 22 +- .../finance/shared/audit/AuditService.java | 15 ++ .../SystemUpdateApplicationService.java | 21 +- .../shared/audit/AuditServiceTest.java | 11 +- .../SystemUpdateApplicationServiceTest.java | 17 +- deploy/update.sh | 18 ++ frontend/e2e/system-update.e2e.ts | 24 +- .../src/pages/governance/SystemUpdatePage.vue | 252 ++++-------------- release-notes/1.0.0-preview.47.md | 11 + scripts/package-release.sh | 15 +- scripts/publish-gitea-release.sh | 13 +- scripts/test-gitea-publish-fixture.sh | 1 + scripts/test-update-fixture.sh | 6 + scripts/verify-release.sh | 5 + 15 files changed, 190 insertions(+), 243 deletions(-) create mode 100644 release-notes/1.0.0-preview.47.md diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index 83a11ba..25dc352 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -98,7 +98,7 @@ jobs: RELEASE_SIGNING_KEY_B64: ${{ secrets.RELEASE_SIGNING_KEY_B64 }} RELEASE_VERSION: ${{ steps.release_meta.outputs.version }} KAIDI_TRUSTED_RELEASE_PUBLIC_KEY_SHA256: ${{ vars.KAIDI_RELEASE_PUBLIC_KEY_SHA256 }} - KAIDI_RELEASE_NOTES: Kaidi Finance ${{ steps.release_meta.outputs.ref }} + KAIDI_REQUIRE_RELEASE_NOTES: 'true' KAIDI_SOURCE_REVISION: ${{ steps.release_meta.outputs.revision }} KAIDI_SOURCE_REF: ${{ steps.release_meta.outputs.ref }} KAIDI_SOURCE_DIRTY: 'false' diff --git a/README.md b/README.md index 5c0de95..bed062c 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.46/install.sh | sudo env KAIDI_APP_PORT=18080 bash +curl -fsSL https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.47/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.46/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.47/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.46/kaidi-finance-1.0.0-preview.46.tar.gz` +`https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.47/kaidi-finance-1.0.0-preview.47.tar.gz` -校验文件:`https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.46/SHA256SUMS` +校验文件:`https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.47/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.46 +VERSION=1.0.0-preview.47 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.46/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.47/purge.sh' | sudo env KAIDI_PURGE_CONFIRM=DELETE_LOCAL_KAIDI_INSTALLATION KAIDI_PURGE_DELETE_BACKUP=true bash ``` 执行前先在宝塔停止并删除 `kaidi-finance` Java 项目;宝塔面板元数据属于外部资源,必须由面板先停用, @@ -268,11 +268,13 @@ Actions 页面显示 “No matching online runner”,先启动并注册该标 工作流先建立不可见草稿,再显式上传并核对 10 个资产的名称和大小,最后才发布为 `/releases/latest`。Gitea 的 `latest` 会排除 `prerelease=true`,因此即使 tag 名含 `preview`,发布记录的 `prerelease` 也固定为 `false`; -Preview 属性由 SemVer 版本名表达。之后推送 tag 即会构建、测试、签名并发布: +Preview 属性由 SemVer 版本名表达。每个版本必须先在 `release-notes/.md` 写好面向用户的更新日志。 +打包器会把同一份内容写入签名 `release-manifest.json`,后台历史从该清单读取;发布脚本也会用它生成 +Gitea Release 正文,避免三处内容不一致。之后推送 tag 即会构建、测试、签名并发布: ```bash -git tag v1.0.0-preview.46 -git push origin v1.0.0-preview.46 +git tag v1.0.0-preview.47 +git push origin v1.0.0-preview.47 ``` 在线更新仍使用独立的 TDesign 页面:系统管理员进入“系统治理 → 系统更新”。更新源由 root 在 @@ -324,7 +326,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.46 + ./scripts/package-release.sh 1.0.0-preview.47 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/AuditService.java b/backend/src/main/java/com/kaidi/finance/shared/audit/AuditService.java index da2dfb9..d4c0c45 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 @@ -77,6 +77,18 @@ public class AuditService { */ public String recordSystemUpdateTerminal(String updateRequestId, String updateAction, String state, String targetVersion, String message, Instant updatedAt) { + return recordSystemUpdateTerminal(updateRequestId, updateAction, state, targetVersion, message, updatedAt, + null, null); + } + + /** + * Persists a terminal update event together with the release metadata that was verified before the switch. + * Keeping the signed release notes in the audit snapshot lets the history page work after a restart or when + * the release host is temporarily unavailable. + */ + public String recordSystemUpdateTerminal(String updateRequestId, String updateAction, String state, + String targetVersion, String message, Instant updatedAt, + String releaseNotes, Instant publishedAt) { String normalizedState = normalizeTerminalState(state); String normalizedVersion = clean(targetVersion, 128); if (normalizedState == null || normalizedVersion == null || updatedAt == null) return null; @@ -110,6 +122,9 @@ public class AuditService { terminal.put("targetVersion", normalizedVersion); terminal.put("message", terminalReason == null ? "" : terminalReason); terminal.put("updatedAt", updatedAt); + String normalizedNotes = clean(releaseNotes, 4000); + if (normalizedNotes != null) terminal.put("releaseNotes", normalizedNotes); + if (publishedAt != null) terminal.put("publishedAt", publishedAt); if (normalizedAction != null) terminal.put("action", normalizedAction); if (normalizedRequestId != null) terminal.put("requestId", normalizedRequestId); 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 0039fb2..9c782ff 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 @@ -444,7 +444,8 @@ public class SystemUpdateApplicationService { nonNegativeLong(root, "downloadedBytes"), nonNegativeLong(root, "totalBytes"), nonNegativeLong(root, "bytesPerSecond"), boundedInteger(root, "downloadPercent", 0, 100), boundedInteger(root, "restartExpectedSeconds", 0, 300), - boundedText(root, "requestId", 64), updateAction(root.path("action").asText(null))); + boundedText(root, "requestId", 64), updateAction(root.path("action").asText(null)), + boundedText(root, "releaseNotes", 4000), parseInstant(root.path("publishedAt").asText(null))); } catch (IOException exception) { return new UpdateStatus("UNKNOWN", "更新状态读取失败", null, null); } @@ -466,9 +467,13 @@ public class SystemUpdateApplicationService { && authorizationService.hasPermission("admin:update:execute")) { actions.add(ready ? "INSTALL" : "DOWNLOAD"); } + String releaseNotes = manifest != null && manifest.releaseNotes() != null && !manifest.releaseNotes().isBlank() + ? manifest.releaseNotes() : status.releaseNotes(); + Instant publishedAt = manifest != null && manifest.publishedAt() != null + ? manifest.publishedAt() : status.publishedAt(); return new SystemUpdateView(enabled, current, candidateVersion, - available, status.state(), status.message(), manifest == null ? null : manifest.releaseNotes(), - manifest == null ? null : manifest.publishedAt(), lastCheckedAt, status.updatedAt(), + available, status.state(), status.message(), releaseNotes, + publishedAt, lastCheckedAt, status.updatedAt(), status.downloadedBytes(), status.totalBytes(), status.bytesPerSecond(), status.downloadPercent(), status.restartExpectedSeconds(), readUpdateEvents(), List.copyOf(actions)); } @@ -672,7 +677,8 @@ public class SystemUpdateApplicationService { if (fingerprint.equals(lastTerminalAuditFingerprint)) return; try { String persistedKey = auditService.recordSystemUpdateTerminal(status.requestId(), status.action(), - status.state(), status.targetVersion(), status.message(), status.updatedAt()); + status.state(), status.targetVersion(), status.message(), status.updatedAt(), + status.releaseNotes(), status.publishedAt()); if (persistedKey != null) lastTerminalAuditFingerprint = fingerprint; } catch (RuntimeException exception) { log.warn("系统更新终态审计写入失败:state={}, targetVersion={}", status.state(), @@ -699,14 +705,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, - String requestId, String action) { + String requestId, String action, String releaseNotes, Instant publishedAt) { private UpdateStatus(String state, String message, String targetVersion, Instant updatedAt) { - this(state, message, targetVersion, updatedAt, null, null, null, null, null, null, null); + this(state, message, targetVersion, updatedAt, null, null, 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); + this(state, message, targetVersion, updatedAt, null, null, null, null, null, requestId, action, + null, null); } } 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 index 826f437..58c61f4 100644 --- a/backend/src/test/java/com/kaidi/finance/shared/audit/AuditServiceTest.java +++ b/backend/src/test/java/com/kaidi/finance/shared/audit/AuditServiceTest.java @@ -27,7 +27,8 @@ class AuditServiceTest { "01M00000000000000000000092", "SYSTEM_UPDATE_REQUEST")) .thenReturn(new AuditMapper.SystemUpdateAuditSource( "01M00000000000000000000092", "01M00000000000000000000001", "admin", "SYSTEM_ADMIN", - null, null, "{\"state\":\"INSTALL_QUEUED\",\"targetVersion\":\"1.0.0-preview.44\"}", + null, null, "{\"state\":\"INSTALL_QUEUED\",\"targetVersion\":\"1.0.0-preview.44\"," + + "\"releaseNotes\":\"Preview update\"}", "127.0.0.1", "fixture-agent")); when(mapper.insertSystemUpdateTerminal(any(), anyString(), any())).thenReturn(1); AuditService service = new AuditService(mapper, ulids, new ObjectMapper().findAndRegisterModules(), @@ -35,9 +36,11 @@ class AuditServiceTest { Instant completedAt = Instant.parse("2026-08-19T00:10:00Z"); String firstKey = service.recordSystemUpdateTerminal("01M00000000000000000000092", "INSTALL", - "SUCCEEDED", "1.0.0-preview.44", "新版本已通过健康检查", completedAt); + "SUCCEEDED", "1.0.0-preview.44", "新版本已通过健康检查", completedAt, + "Preview update", Instant.parse("2026-08-16T00:00:00Z")); String secondKey = service.recordSystemUpdateTerminal("01M00000000000000000000092", "INSTALL", - "SUCCEEDED", "1.0.0-preview.44", "新版本已通过健康检查", completedAt); + "SUCCEEDED", "1.0.0-preview.44", "新版本已通过健康检查", completedAt, + "Preview update", Instant.parse("2026-08-16T00:00:00Z")); assertEquals(firstKey, secondKey); assertTrue(firstKey.startsWith("SYSUPD:")); @@ -52,6 +55,8 @@ class AuditServiceTest { assertTrue(persisted.beforeJson().contains("INSTALL_QUEUED")); assertTrue(persisted.afterJson().contains("1.0.0-preview.44")); assertTrue(persisted.afterJson().contains("SUCCEEDED")); + assertTrue(persisted.afterJson().contains("Preview update")); + assertTrue(persisted.afterJson().contains("publishedAt")); } @Test 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 1de4843..6149011 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 @@ -294,21 +294,26 @@ class SystemUpdateApplicationServiceTest { 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", + "updatedAt":"2026-08-19T00:10:00Z","releaseNotes":"Preview update", + "publishedAt":"2026-08-16T00:00: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"))) + "1.0.0-preview.44", "新版本已通过健康检查", java.time.Instant.parse("2026-08-19T00:10:00Z"), + "Preview update", java.time.Instant.parse("2026-08-16T00:00:00Z"))) .thenReturn("SYSUPD:terminal"); SystemUpdateApplicationService service = serviceForStatus(inbox, statusFile, auditService); assertEquals("SUCCEEDED", service.status().state()); + assertEquals("Preview update", service.status().releaseNotes()); + assertEquals(java.time.Instant.parse("2026-08-16T00:00:00Z"), service.status().publishedAt()); 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")); + java.time.Instant.parse("2026-08-19T00:10:00Z"), "Preview update", + java.time.Instant.parse("2026-08-16T00:00:00Z")); } @Test @@ -321,14 +326,14 @@ class SystemUpdateApplicationServiceTest { """); 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"))) + "Release 1.0.0-preview.43 is running", java.time.Instant.parse("2026-08-18T23:50:00Z"), null, null)) .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")); + "Release 1.0.0-preview.43 is running", java.time.Instant.parse("2026-08-18T23:50:00Z"), null, null); } private SystemUpdateApplicationService serviceForStatus(Path inbox, Path statusFile, AuditService auditService) { @@ -382,6 +387,8 @@ class SystemUpdateApplicationServiceTest { var checked = service.check(); assertTrue(checked.updateAvailable()); assertEquals("1.0.0-preview.2+build.7", checked.latestVersion()); + assertEquals("Preview update", checked.releaseNotes()); + assertEquals(java.time.Instant.parse("2026-08-16T00:00:00Z"), checked.publishedAt()); assertTrue(checked.allowedActions().contains("DOWNLOAD")); assertFalse(checked.allowedActions().contains("INSTALL")); diff --git a/deploy/update.sh b/deploy/update.sh index 3e443a7..a3739d4 100755 --- a/deploy/update.sh +++ b/deploy/update.sh @@ -43,6 +43,8 @@ RELEASE_AUTH_HEADER_FILE= TARGET_VERSION= REQUEST_ID= REQUEST_ACTION= +RELEASE_NOTES= +RELEASE_PUBLISHED_AT= TERMINAL_STATUS_WRITTEN=false DOWNLOAD_PID= DOWNLOAD_PGID= @@ -297,6 +299,8 @@ status() { previous_message= previous_request_id= previous_action= + previous_release_notes= + previous_published_at= 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) @@ -304,6 +308,10 @@ status() { "$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) + previous_release_notes=$(jq -r '(.releaseNotes // "") | strings | .[0:4000]' \ + "$STATUS_FILE" 2>/dev/null || true) + previous_published_at=$(jq -r '(.publishedAt // "") | strings | .[0:128]' \ + "$STATUS_FILE" 2>/dev/null || true) fi status_request_id= status_action= @@ -315,6 +323,8 @@ status() { fi [ -n "$status_request_id" ] || status_request_id=${REQUEST_ID:-$previous_request_id} [ -n "$status_action" ] || status_action=${REQUEST_ACTION:-$previous_action} + status_release_notes=${RELEASE_NOTES:-$previous_release_notes} + status_published_at=${RELEASE_PUBLISHED_AT:-$previous_published_at} tmp="$STATUS_FILE.tmp.$$" jq -n \ --arg state "$state" \ @@ -327,11 +337,15 @@ status() { --arg restartExpectedSeconds "$restart_expected_seconds" \ --arg requestId "$status_request_id" \ --arg action "$status_action" \ + --arg releaseNotes "$status_release_notes" \ + --arg publishedAt "$status_published_at" \ --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 ($releaseNotes | length) > 0 then {releaseNotes:$releaseNotes} else {} end) + + (if ($publishedAt | length) > 0 then {publishedAt:$publishedAt} 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) @@ -1119,6 +1133,10 @@ EXPECTED_SHA=$(jq -er '.sha256 | strings | ascii_downcase | select(test("^[0-9a- "$WORK_DIR/release-manifest.json") || fail "Release SHA-256 is invalid" EXPECTED_SIZE=$(jq -er '(.artifactSizeBytes // 0) | numbers | floor | select(. >= 0)' \ "$WORK_DIR/release-manifest.json") || fail "Release artifact size is invalid" +RELEASE_NOTES=$(jq -r '(.releaseNotes // "") | strings | .[0:4000]' \ + "$WORK_DIR/release-manifest.json") || fail "Release notes are invalid" +RELEASE_PUBLISHED_AT=$(jq -r '(.publishedAt // "") | strings | .[0:128]' \ + "$WORK_DIR/release-manifest.json") || fail "Release publish time is invalid" CURRENT_VERSION=$(cat "$APP_ROOT/current/VERSION" 2>/dev/null || true) if [ "$CURRENT_VERSION" = "$TARGET_VERSION" ]; then diff --git a/frontend/e2e/system-update.e2e.ts b/frontend/e2e/system-update.e2e.ts index 81fa892..c610750 100644 --- a/frontend/e2e/system-update.e2e.ts +++ b/frontend/e2e/system-update.e2e.ts @@ -138,8 +138,12 @@ async function installFixture( beforeJson: null, afterJson: JSON.stringify({ targetVersion, - ...(actionCode === 'SYSTEM_UPDATE_CHECK' ? { releaseNotes: 'Preview update' } : {}), - ...(actionCode === 'SYSTEM_UPDATE_SUCCEEDED' ? { state: 'SUCCEEDED' } : {}), + ...(actionCode === 'SYSTEM_UPDATE_CHECK' + ? { releaseNotes: 'Preview update', publishedAt: '2026-08-16T00:00:00Z' } + : {}), + ...(actionCode === 'SYSTEM_UPDATE_SUCCEEDED' + ? { state: 'SUCCEEDED', releaseNotes: 'Preview update', publishedAt: '2026-08-16T00:00:00Z' } + : {}), }), occurredAt: '2026-08-16T00:02:00Z', allowedActions: [], @@ -214,6 +218,7 @@ async function installFixture( const query = new URL(request.url()).searchParams; expect(query.get('occurredFrom')).toBe('1970-01-01T00:00:00Z'); expect(query.get('objectType')).toBe('SYSTEM_UPDATE'); + expect(query.get('size')).toBe('100'); return route.fulfill({ json: { data: history, @@ -418,15 +423,18 @@ test('successful installation starts the ten-second automatic refresh countdown' const historyPanel = page.locator('.history-panel'); const timelineItem = historyPanel.locator('.update-timeline__item[data-version="1.0.0-preview.2"]'); await expect(timelineItem).toHaveCount(1); - await expect(timelineItem).toContainText('更新完成'); + await expect(timelineItem).toContainText('成功'); await expect(timelineItem).toContainText('新版本已通过健康检查'); await expect(timelineItem).toContainText('Preview update'); - await timelineItem.getByRole('button', { name: '查看完整记录', exact: true }).click(); + await timelineItem.getByRole('button', { name: '查看更新日志', exact: true }).click(); const historyDialog = page.getByRole('dialog', { name: '更新日志详情' }); - await expect(historyDialog).toContainText('获取版本'); - await expect(historyDialog).toContainText('下载更新包'); - await expect(historyDialog).toContainText('立即更新并重启'); - await expect(historyDialog).toContainText('更新完成'); + await expect(historyDialog).toContainText('更新日志'); + await expect(historyDialog).toContainText('Preview update'); + await expect(historyDialog).toContainText('发布时间'); + await expect(historyDialog.locator('.history-detail__steps')).toHaveCount(0); + await expect(historyDialog).not.toContainText('获取版本'); + await expect(historyDialog).not.toContainText('下载更新包'); + await expect(historyDialog).not.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 ceb24ea..fa3175d 100644 --- a/frontend/src/pages/governance/SystemUpdatePage.vue +++ b/frontend/src/pages/governance/SystemUpdatePage.vue @@ -111,9 +111,9 @@

历史更新记录

-

按版本汇总获取、下载、重启安装和最终执行结果。

+

按版本展示签名 Release 提供的更新日志与最终结果。

- 最近 {{ historyTimeline.length }} 个版本 · {{ historyRows.length }} 条记录 + 已记录 {{ historyTimeline.length }} 个版本
@@ -146,37 +146,19 @@ {{ resultLabel(item.resultCode) }} -
- 执行流程 - {{ item.operations.join(' · ') }} - {{ item.steps.length }} 条记录 +
+ 更新日志 +

{{ item.releaseNotes || '该版本的签名 Release 未提供更新日志。' }}

-

{{ item.reason || '已记录本次版本更新操作。' }}

- -
- 版本说明 -

{{ item.releaseNotes }}

-
- -
-
- -
-
- {{ actionLabel(step.row.actionCode) }} - - {{ resultLabel(step.row.resultCode) }} - - -
-

{{ step.row.reason || '操作已记录' }} · {{ step.row.username || '系统' }}

-
-
+

{{ item.reason || '更新结果已记录。' }}

+
+ 发布时间:{{ formatDateTime(item.publishedAt) }} + 记录时间:{{ formatDateTime(item.occurredAt) }} · {{ item.username || '系统' }}
@@ -283,23 +265,17 @@ {{ resultLabel(selectedHistory.resultCode) }}
-

{{ selectedHistory.reason || '已记录本次版本更新操作。' }}

-
- 版本说明 -

{{ selectedHistory.releaseNotes }}

+ -
-
-
- {{ actionLabel(step.row.actionCode) }} - - {{ resultLabel(step.row.resultCode) }} - -
- -

{{ step.row.reason || '操作已记录' }}

-
+
+ 更新日志 +

{{ selectedHistory.releaseNotes || '该版本的签名 Release 未提供更新日志。' }}

+

{{ selectedHistory.reason || '更新结果已记录。' }}

@@ -322,10 +298,6 @@ import { defineOptions({ name: 'SystemUpdatePage' }); type TagTheme = 'default' | 'primary' | 'success' | 'warning' | 'danger'; -interface HistoryTimelineStep { - row: AuditLog; -} - interface HistoryTimelineItem { key: string; version: string; @@ -333,9 +305,8 @@ interface HistoryTimelineItem { username: string; resultCode: string; reason: string; - operations: string[]; releaseNotes: string; - steps: HistoryTimelineStep[]; + publishedAt: string; } const UPDATE_PENDING_KEY = 'kaidi-system-update-pending'; @@ -498,9 +469,8 @@ const historyTimeline = computed(() => { const newestFirst = rows.slice().sort((left, right) => compareHistoryDates(right.occurredAt, left.occurredAt)); const terminal = newestFirst.find((row) => isTerminalHistoryAction(row.actionCode)); const representative = terminal || newestFirst[0]; - const chronological = newestFirst.slice().reverse(); - const operations = [...new Set(chronological.map((row) => actionLabel(row.actionCode)).filter(Boolean))]; const releaseNotes = newestFirst.map((row) => historyReleaseNotes(row)).find(Boolean) || ''; + const publishedAt = newestFirst.map((row) => historyPublishedAt(row)).find(Boolean) || ''; return { key, @@ -509,9 +479,8 @@ const historyTimeline = computed(() => { username: representative?.username || '', resultCode: representative?.resultCode || 'UNKNOWN', reason: representative?.reason || '', - operations, releaseNotes, - steps: chronological.map((row) => ({ row })), + publishedAt, }; }) .sort((left, right) => compareHistoryDates(right.occurredAt, left.occurredAt)); @@ -571,7 +540,7 @@ async function loadHistory(silent = false) { objectType: 'SYSTEM_UPDATE', sort: 'occurredAt,desc', page: 1, - size: 20, + size: 100, }); historyRows.value = result.items; } catch (error) { @@ -859,7 +828,24 @@ function historyVersion(row: AuditLog) { function historyReleaseNotes(row: AuditLog) { for (const source of [row.afterJson, row.beforeJson]) { const parsed = parseHistoryPayload(source); - if (typeof parsed?.releaseNotes === 'string' && parsed.releaseNotes.trim()) return parsed.releaseNotes; + for (const key of ['releaseNotes', 'changelog', 'updateLog', 'notes']) { + const value = parsed?.[key]; + if (typeof value === 'string' && value.trim()) return value.trim(); + if (Array.isArray(value)) { + const lines = value + .filter((item): item is string => typeof item === 'string' && Boolean(item.trim())) + .map((item) => item.trim()); + if (lines.length) return lines.join('\n'); + } + } + } + return ''; +} + +function historyPublishedAt(row: AuditLog) { + for (const source of [row.afterJson, row.beforeJson]) { + const parsed = parseHistoryPayload(source); + if (typeof parsed?.publishedAt === 'string' && parsed.publishedAt.trim()) return parsed.publishedAt; } return ''; } @@ -869,19 +855,6 @@ function openHistoryDetail(item: HistoryTimelineItem) { historyDetailVisible.value = true; } -function actionLabel(action: string) { - return ( - { - SYSTEM_UPDATE_CHECK: '获取版本', - SYSTEM_UPDATE_DOWNLOAD_REQUEST: '下载更新包', - SYSTEM_UPDATE_REQUEST: '立即更新并重启', - SYSTEM_UPDATE_SUCCEEDED: '更新完成', - SYSTEM_UPDATE_FAILED: '更新失败', - SYSTEM_UPDATE_RECOVERY_REQUIRED: '更新需人工恢复', - }[action] || action - ); -} - function resultLabel(result: string) { return { SUCCESS: '成功', FAILED: '失败', DENIED: '已拒绝', BLOCKED: '已阻断' }[result] || result; } @@ -1155,9 +1128,7 @@ onBeforeUnmount(() => { } .timeline-card__header, -.timeline-card__summary, .timeline-card__footer, -.timeline-step__heading, .history-detail__header { display: flex; align-items: center; @@ -1188,35 +1159,13 @@ onBeforeUnmount(() => { } .timeline-card__identity span, -.timeline-card__count, .timeline-card__footer, -.timeline-step__heading time, .history-detail__header span, -.history-detail__step time { +.history-detail__metadata { color: var(--td-text-color-secondary); font-size: 12px; } -.timeline-card__summary { - flex-wrap: wrap; - margin-top: 12px; - padding: 9px 10px; - color: var(--td-text-color-primary); - font-size: 13px; - line-height: 20px; - background: var(--td-bg-color-secondarycontainer); - border-radius: 4px; -} - -.timeline-card__label { - color: var(--td-text-color-secondary); -} - -.timeline-card__count { - margin-left: auto; - white-space: nowrap; -} - .timeline-card__reason, .history-detail__reason { margin: 12px 0 0; @@ -1250,65 +1199,15 @@ onBeforeUnmount(() => { overflow-wrap: anywhere; } -.timeline-steps { +.timeline-card__published, +.history-detail__metadata { display: flex; - flex-direction: column; - gap: 10px; - margin-top: 14px; -} - -.timeline-step { - display: grid; - grid-template-columns: 10px minmax(0, 1fr); - gap: 10px; - min-width: 0; -} - -.timeline-step__dot { - width: 8px; - height: 8px; - margin-top: 7px; - background: var(--td-component-border); - border-radius: 50%; -} - -.timeline-step__dot--success { - background: var(--td-success-color); -} - -.timeline-step__dot--danger { - background: var(--td-error-color); -} - -.timeline-step__dot--warning { - background: var(--td-warning-color); -} - -.timeline-step__body { - min-width: 0; -} - -.timeline-step__heading { flex-wrap: wrap; -} - -.timeline-step__heading strong { - color: var(--td-text-color-primary); - font-size: 13px; - font-weight: 600; -} - -.timeline-step__heading time { - margin-left: auto; - white-space: nowrap; -} - -.timeline-step__body p { - margin: 3px 0 0; + gap: 6px 16px; + margin-top: 10px; color: var(--td-text-color-secondary); font-size: 12px; line-height: 20px; - overflow-wrap: anywhere; } .timeline-card__footer { @@ -1336,53 +1235,6 @@ onBeforeUnmount(() => { line-height: 26px; } -.history-detail__steps { - display: flex; - flex-direction: column; - gap: 12px; - max-height: 420px; - padding: 12px; - overflow: auto; - background: var(--td-bg-color-secondarycontainer); - border: 1px solid var(--td-component-border); - border-radius: 6px; -} - -.history-detail__step { - padding-bottom: 12px; - border-bottom: 1px solid var(--td-component-border); -} - -.history-detail__step:last-child { - padding-bottom: 0; - border-bottom: 0; -} - -.history-detail__step > div { - display: flex; - align-items: center; - flex-wrap: wrap; - gap: 8px; -} - -.history-detail__step strong { - color: var(--td-text-color-primary); - font-size: 13px; -} - -.history-detail__step time { - display: block; - margin-top: 4px; -} - -.history-detail__step p { - margin: 4px 0 0; - color: var(--td-text-color-secondary); - font-size: 13px; - line-height: 20px; - overflow-wrap: anywhere; -} - .alert-content { justify-content: space-between; gap: 12px; @@ -1552,16 +1404,6 @@ onBeforeUnmount(() => { padding: 12px; } - .timeline-card__count { - width: 100%; - margin-left: 0; - } - - .timeline-step__heading time { - width: 100%; - margin-left: 0; - } - .timeline-card__notes, .history-detail__notes { grid-template-columns: 1fr; diff --git a/release-notes/1.0.0-preview.47.md b/release-notes/1.0.0-preview.47.md new file mode 100644 index 0000000..5ff01dc --- /dev/null +++ b/release-notes/1.0.0-preview.47.md @@ -0,0 +1,11 @@ +更新日志(Preview 47) + +本版本聚焦在线更新的可追溯性与发布说明展示,不改变财务业务数据和审批规则。 + +• 更新历史改为按版本展示 Release 更新日志,不再把获取、下载、重启等内部执行步骤当作历史内容。 +• 历史详情直接展示签名 Release 的发布说明、发布时间、目标版本和最终结果。 +• 更新器在验签后把 releaseNotes 与 publishedAt 写入状态文件;应用重启后仍能显示完整发布说明。 +• 终态审计记录保存发布说明并保持幂等,历史记录不再只显示“更新成功”一条信息。 +• 发布打包、验签和 Gitea Release 正文统一读取 release-notes/.md,避免页面、清单和 Release 描述不一致。 + +升级说明:本版本不要求安装 MySQL,也不会自动修改数据库服务;更新器默认跳过数据库备份,仅使用已验签的 Release 制品完成应用切换。 diff --git a/scripts/package-release.sh b/scripts/package-release.sh index e6a57da..59ee559 100755 --- a/scripts/package-release.sh +++ b/scripts/package-release.sh @@ -187,7 +187,20 @@ KAIDI_PURGER_SHA256=$PURGER_SHA256 EOF BOOTSTRAP_SHA256=$(sha256_file "$OUTPUT_DIR/bootstrap-checksums.txt") -RELEASE_NOTES_VALUE=${KAIDI_RELEASE_NOTES:-"Kaidi Finance Preview $VERSION"} +RELEASE_NOTES_FILE=${KAIDI_RELEASE_NOTES_FILE:-$ROOT/release-notes/$VERSION.md} +if [ -n "${KAIDI_RELEASE_NOTES+x}" ]; then + RELEASE_NOTES_VALUE=$KAIDI_RELEASE_NOTES +elif [ -f "$RELEASE_NOTES_FILE" ] && [ ! -L "$RELEASE_NOTES_FILE" ]; then + RELEASE_NOTES_VALUE=$(sed 's/\r$//' "$RELEASE_NOTES_FILE") +elif [ "$SOURCE_REF" != local ] || [ "${KAIDI_REQUIRE_RELEASE_NOTES:-false}" = true ]; then + printf 'Release notes file is missing: %s\n' "$RELEASE_NOTES_FILE" >&2 + exit 1 +else + RELEASE_NOTES_VALUE="Kaidi Finance Preview $VERSION" +fi +RELEASE_NOTES_BYTES=$(printf '%s' "$RELEASE_NOTES_VALUE" | wc -c | tr -d '[:space:]') +[ "$RELEASE_NOTES_BYTES" -gt 0 ] && [ "$RELEASE_NOTES_BYTES" -le 4000 ] \ + || { printf 'Release notes must contain 1 to 4000 bytes\n' >&2; exit 1; } VERSION="$VERSION" ARTIFACT="$ARTIFACT" SHA256="$SHA256" ARTIFACT_SIZE_BYTES="$ARTIFACT_SIZE_BYTES" \ RELEASE_NOTES="$RELEASE_NOTES_VALUE" \ BACKEND_SBOM_SHA256="$BACKEND_SBOM_SHA256" FRONTEND_SBOM_SHA256="$FRONTEND_SBOM_SHA256" \ diff --git a/scripts/publish-gitea-release.sh b/scripts/publish-gitea-release.sh index 3e8a4dc..8b26e00 100755 --- a/scripts/publish-gitea-release.sh +++ b/scripts/publish-gitea-release.sh @@ -10,9 +10,7 @@ SOURCE_SHA=${GITEA_SHA:-} TOKEN=${GITEA_TOKEN:-} RELEASE_DIR=${KAIDI_RELEASE_DIR:-dist/release} RELEASE_NAME=${KAIDI_RELEASE_NAME:-Kaidi Finance $TAG} -RELEASE_BODY=${KAIDI_RELEASE_BODY:-Kaidi Finance $TAG - -Source: $SOURCE_SHA} +RELEASE_BODY=${KAIDI_RELEASE_BODY-} WORK=$(mktemp -d) AUTH_HEADER=$WORK/gitea-auth-header @@ -38,6 +36,15 @@ esac || fail 'GITEA_TOKEN is invalid' [ -d "$RELEASE_DIR" ] || fail 'release directory is missing' +if [ -z "${KAIDI_RELEASE_BODY+x}" ]; then + RELEASE_NOTES=$(jq -er '.releaseNotes | strings | select(length > 0 and length <= 4000)' \ + "$RELEASE_DIR/release-manifest.json") \ + || fail 'release manifest notes are missing or invalid' + RELEASE_BODY="$RELEASE_NOTES + +Source: $SOURCE_SHA" +fi + printf 'Authorization: token %s\nAccept: application/json\n' "$TOKEN" > "$AUTH_HEADER" chmod 0600 "$AUTH_HEADER" diff --git a/scripts/test-gitea-publish-fixture.sh b/scripts/test-gitea-publish-fixture.sh index c047ac0..f9f1854 100755 --- a/scripts/test-gitea-publish-fixture.sh +++ b/scripts/test-gitea-publish-fixture.sh @@ -29,6 +29,7 @@ for name in \ SHA256SUMS; do printf 'fixture asset %s\n' "$name" > "$RELEASE_DIR/$name" done +printf '%s\n' '{"releaseNotes":"Fixture release notes"}' > "$RELEASE_DIR/release-manifest.json" cat > "$MOCK_BIN/curl" <<'SH' #!/usr/bin/env bash diff --git a/scripts/test-update-fixture.sh b/scripts/test-update-fixture.sh index d6bcf9c..76e018c 100755 --- a/scripts/test-update-fixture.sh +++ b/scripts/test-update-fixture.sh @@ -315,6 +315,10 @@ download_and_prepare_install() { [ "$(jq -r '.requestId' "$fixture/state/status.json")" = 01M00000000000000000000091 ] \ && [ "$(jq -r '.action' "$fixture/state/status.json")" = DOWNLOAD ] \ || fail 'download phase did not preserve request correlation' + [ "$(jq -r '.releaseNotes' "$fixture/state/status.json")" = fixture ] \ + || fail 'download phase did not persist the signed release notes' + [ "$(jq -r '.publishedAt' "$fixture/state/status.json")" = 2026-08-16T00:00:00Z ] \ + || fail 'download phase did not persist the signed release publish time' [ "$(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" ] \ @@ -456,6 +460,8 @@ assert_success_case() { [ "$(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' + [ "$(jq -r '.releaseNotes' "$fixture/state/status.json")" = fixture ] \ + || fail 'success case did not retain the signed release notes' [ ! -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' diff --git a/scripts/verify-release.sh b/scripts/verify-release.sh index 6af8d3e..2e466f0 100755 --- a/scripts/verify-release.sh +++ b/scripts/verify-release.sh @@ -30,6 +30,11 @@ openssl dgst -sha256 -verify release-public.pem \ VERSION=$(jq -er '.version | strings | select(length > 0)' release-manifest.json) "$ROOT/scripts/check-semver.sh" "$VERSION" \ || { printf 'Release version is not valid SemVer\n' >&2; exit 1; } +RELEASE_NOTES=$(jq -er '.releaseNotes | strings | select(length > 0 and length <= 4000)' release-manifest.json) \ + || { printf 'Release manifest must contain 1 to 4000 bytes of release notes\n' >&2; exit 1; } +RELEASE_NOTES_BYTES=$(printf '%s' "$RELEASE_NOTES" | wc -c | tr -d '[:space:]') +[ "$RELEASE_NOTES_BYTES" -le 4000 ] \ + || { printf 'Release manifest release notes exceed 4000 bytes\n' >&2; exit 1; } ARTIFACT=$(jq -er '.artifact | strings | select(length > 0)' release-manifest.json) [ -s "$ARTIFACT" ] || { printf 'Release artifact is missing\n' >&2; exit 1; } [ "$ARTIFACT" = "kaidi-finance-$VERSION.tar.gz" ] \