Compare commits

...
Author SHA1 Message Date
Qiufeng 1d45be4e97 fix: synchronize release notes after update restart
Release / release (push) Canceled after 0s
2026-08-19 09:04:33 +08:00
Qiufeng 6b04d8dd0a feat: show signed release changelog in update history
Release / release (push) Canceled after 0s
2026-08-19 08:27:08 +08:00
Qiufeng fae8225299 fix: keep update page heading accessible
Release / release (push) Canceled after 0s
2026-08-19 07:43:11 +08:00
Qiufeng de63fcffff feat: show system update history as timeline
Release / release (push) Canceled after 0s
2026-08-19 07:26:48 +08:00
Qiufeng 12d9c46323 fix: persist system update history
Release / release (push) Canceled after 0s
2026-08-19 00:22:23 +08:00
Qiufeng 1230116a6f fix: rebalance system update toolbar layout
Release / release (push) Canceled after 0s
2026-08-18 23:41:42 +08:00
19 changed files with 1167 additions and 100 deletions
+1 -1
View File
@@ -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'
+18 -13
View File
@@ -70,7 +70,8 @@ PostgreSQL 18 兼容工作继续冻结。
- MySQL 是**已有数据库**,应用只通过向导写入的 `DB_URL`、`DB_USERNAME`、`DB_PASSWORD` 连接它;安装器不安装 MySQL、不创建数据库、不修改数据库服务。填写 `127.0.0.1` 表示 MySQL 与 Java 应用在同一台服务器,端口以实际监听端口为准,不默认假设 `3307`。
- 安装阶段只校验 JDBC 配置格式,不调用 `mysql`/`mariadb` 客户端,也不执行 `CREATE`、`INSERT`、`UPDATE`、`DELETE` 或 `DROP`。首次向导点击“完成安装”后,应用才会在**专用空 schema**中运行 Flyway 建表并初始化管理员;这是业务初始化,不是安装 MySQL 服务。在线更新默认跳过数据库备份;需要备份时由运维显式设置 `KAIDI_DB_BACKUP_MODE=mysqldump`,更新器只调用已有工具,不会安装数据库。
- `KAIDI_APP_PORT` 只决定 Java 回环监听端口,默认 `18080`;反向代理必须指向安装器输出的 `PROXY_TARGET`。安装器不会替你修改 Nginx 或宝塔站点配置。
- 发布归档使用无顶层目录的 `tar.gz`,只包含 `app.jar`、`public/`、`ops/`、`VERSION`;打包阶段禁用 macOS 扩展属性并拒绝开发数据库回退值。
- 发布归档使用无顶层目录的 `tar.gz`,包含 `app.jar`、`public/`、`ops/`、`VERSION` 和签名绑定的
`release-metadata.json`;打包阶段禁用 macOS 扩展属性并拒绝开发数据库回退值。
### 32 位 Linux 支持边界
@@ -83,7 +84,7 @@ PostgreSQL 18 兼容工作继续冻结。
先在宝塔面板停止并删除当前错误的 Java 项目,再执行:
```bash
curl -fsSL https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.42/install.sh | sudo env KAIDI_APP_PORT=18080 bash
curl -fsSL https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.48/install.sh | sudo env KAIDI_APP_PORT=18080 bash
```
该命令只安装程序运行所需的 systemd 单元,自动创建 `kaidi` 用户并检测现有 Java 17(包括
@@ -96,7 +97,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.42/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.48/purge.sh' | sudo env KAIDI_PURGE_CONFIRM=DELETE_LOCAL_KAIDI_INSTALLATION bash
```
上面是一条完整命令:脚本通过管道直接交给 root 执行,不创建临时安装文件,避免终端自动换行导致 `-o` 参数丢失。
@@ -110,16 +111,16 @@ Nginx/反向代理和外部 MySQL 属于外部资源,卸载程序不会误删
`0600`,确认新安装及数据无误后再由 root 删除。脚本不删除外部 MySQL、系统 Java、Nginx 或宝塔站点配置;
新安装必须连接一个新的空 MySQL 数据库,旧数据库保留用于回滚。
### Preview.42 直链与宝塔手动部署
### Preview.43 直链与宝塔手动部署
本版不使用一键安装脚本。发布物是一个不带顶层目录的压缩包,下载后直接解压到版本目录,再由宝塔面板创建
本版提供 systemd 一键安装脚本和宝塔手动部署两种互斥方式。手动部署使用一个不带顶层目录的压缩包,下载后直接解压到版本目录,再由宝塔面板创建
Spring Boot 项目。数据库、JDK、Nginx 和宝塔本身都由运维人员准备;程序不会自动安装 MySQL 或任何第三方服务。
下载地址:
`https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.42/kaidi-finance-1.0.0-preview.42.tar.gz`
`https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.48/kaidi-finance-1.0.0-preview.48.tar.gz`
校验文件:`https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.42/SHA256SUMS`
校验文件:`https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.48/SHA256SUMS`
服务器要求:Linux、Java 17(宝塔项目选择 JDK 17)、可访问外部 MySQL 8.4.x;**systemd 一键安装**还需要
systemd/systemd-analyze,**宝塔手动部署**不要求 systemd。32 位 Linux 需要宿主机
@@ -136,7 +137,7 @@ systemd/systemd-analyze,**宝塔手动部署**不要求 systemd。32 位 Linux
必须直接位于 `RELEASE_ROOT`,不能再嵌套一层目录。
```bash
VERSION=1.0.0-preview.42
VERSION=1.0.0-preview.48
APP_ROOT=/www/wwwroot/kaidi
RELEASE_ROOT="$APP_ROOT/releases/$VERSION"
sudo install -d -m 0755 "$RELEASE_ROOT"
@@ -245,7 +246,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.42/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.48/purge.sh' | sudo env KAIDI_PURGE_CONFIRM=DELETE_LOCAL_KAIDI_INSTALLATION KAIDI_PURGE_DELETE_BACKUP=true bash
```
执行前先在宝塔停止并删除 `kaidi-finance` Java 项目;宝塔面板元数据属于外部资源,必须由面板先停用,
@@ -268,11 +269,15 @@ Actions 页面显示 “No matching online runner”,先启动并注册该标
工作流先建立不可见草稿,再显式上传并核对 10 个资产的名称和大小,最后才发布为 `/releases/latest`。Gitea 的
`latest` 会排除 `prerelease=true`,因此即使 tag 名含 `preview`,发布记录的 `prerelease` 也固定为 `false`;
Preview 属性由 SemVer 版本名表达。之后推送 tag 即会构建、测试、签名并发布:
Preview 属性由 SemVer 版本名表达。每个版本必须先在 `release-notes/<version>.md` 写好面向用户的更新日志。
打包器会把同一份内容写入签名 `release-manifest.json`,并把同一份签名元数据随应用制品写入
`release-metadata.json`。后台在线检查优先读取 Release 清单;应用重启后若旧版更新器没有把说明写入状态文件,
则从当前已验签制品中的元数据恢复,再写入终态审计,因此不依赖重启后的 Gitea 网络请求。发布脚本也会用
清单生成 Gitea Release 正文,避免页面、清单和制品三处内容不一致。之后推送 tag 即会构建、测试、签名并发布:
```bash
git tag v1.0.0-preview.42
git push origin v1.0.0-preview.42
git tag v1.0.0-preview.48
git push origin v1.0.0-preview.48
```
在线更新仍使用独立的 TDesign 页面:系统管理员进入“系统治理 → 系统更新”。更新源由 root 在
@@ -324,7 +329,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.42
./scripts/package-release.sh 1.0.0-preview.48
KAIDI_TRUSTED_RELEASE_PUBLIC_KEY_SHA256=807c6aec1dc3f7ce494db16aa9d763c66f292033c38f328afd0390d2715a8cd9 \
./scripts/verify-release.sh dist/release
```
@@ -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) {
}
}
@@ -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,86 @@ 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) {
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;
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<String, Object> terminal = new LinkedHashMap<>();
terminal.put("state", normalizedState);
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);
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 +166,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;
@@ -34,15 +34,21 @@ import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.Objects;
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_METADATA_BYTES = 16 * 1024;
private static final int MAX_RELEASE_API_BYTES = 256 * 1024;
private static final int MAX_STATUS_BYTES = 64 * 1024;
private static final int MAX_EVENT_LOG_BYTES = 256 * 1024;
@@ -71,6 +77,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 +97,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 +108,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 +131,7 @@ public class SystemUpdateApplicationService {
throw validation("当前已是相同或更高版本");
}
UpdateStatus currentStatus = readStatus();
persistTerminalAudit(currentStatus);
if ("READY".equals(currentStatus.state()) && requested.equals(currentStatus.targetVersion())) {
throw validation("该版本已经下载并通过校验,请确认安装");
}
@@ -137,6 +149,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 +382,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("系统更新队列锁定失败");
@@ -406,15 +420,71 @@ public class SystemUpdateApplicationService {
}
private UpdateStatus readStatus() {
UpdateStatus resolved;
if (!effectiveEnabled()) {
return new UpdateStatus("DISABLED", "在线更新未配置", null, null);
resolved = new UpdateStatus("DISABLED", "在线更新未配置", null, null);
} else {
UpdateStatus persisted = readPersistedStatus();
if (persisted != null && BUSY_STATES.contains(persisted.state())) {
resolved = persisted;
} else if (persisted != null && "FAILED".equals(persisted.state()) && hasProcessingRequest()) {
resolved = persisted;
} else {
UpdateStatus queued = pendingStatus();
resolved = queued != null
? queued
: (persisted == null ? new UpdateStatus("IDLE", "尚未执行在线更新", null, null) : persisted);
}
}
return enrichWithEmbeddedReleaseMetadata(resolved);
}
/**
* The updater which performed an older release switch may not have known about release notes yet.
* New artifacts therefore carry the signed notes inside the release directory as well. Reading that
* immutable, artifact-hashed file lets the first application process after an upgrade reconstruct the
* terminal audit snapshot without a network request or a database migration.
*/
private UpdateStatus enrichWithEmbeddedReleaseMetadata(UpdateStatus status) {
if (status == null) return null;
ReleaseMetadata metadata = readEmbeddedReleaseMetadata();
if (metadata == null) return status;
String current = currentVersion();
boolean matchesCurrent = metadata.version().equals(current);
boolean matchesTarget = status.targetVersion() != null && metadata.version().equals(status.targetVersion());
if (!matchesCurrent && !matchesTarget) return status;
String notes = status.releaseNotes() == null || status.releaseNotes().isBlank()
? metadata.releaseNotes() : status.releaseNotes();
Instant publishedAt = status.publishedAt() == null ? metadata.publishedAt() : status.publishedAt();
if (notes.equals(status.releaseNotes()) && Objects.equals(publishedAt, status.publishedAt())) return status;
return new UpdateStatus(status.state(), status.message(), status.targetVersion(), status.updatedAt(),
status.downloadedBytes(), status.totalBytes(), status.bytesPerSecond(), status.downloadPercent(),
status.restartExpectedSeconds(), status.requestId(), status.action(), notes, publishedAt);
}
private ReleaseMetadata readEmbeddedReleaseMetadata() {
Path versionFile = properties.currentVersionFile();
if (versionFile == null) return null;
Path releaseDirectory = versionFile.toAbsolutePath().normalize().getParent();
if (releaseDirectory == null) return null;
Path metadataFile = releaseDirectory.resolve("release-metadata.json").normalize();
if (!metadataFile.startsWith(releaseDirectory)
|| !Files.isRegularFile(metadataFile, LinkOption.NOFOLLOW_LINKS)
|| Files.isSymbolicLink(metadataFile)) {
return null;
}
try {
if (Files.size(metadataFile) > MAX_RELEASE_METADATA_BYTES) return null;
JsonNode root = objectMapper.readTree(Files.readAllBytes(metadataFile));
String version = blank(root.path("version").asText(null));
String notes = boundedText(root, "releaseNotes", 4000);
Instant publishedAt = parseInstant(root.path("publishedAt").asText(null));
if (version == null || !VERSION.matcher(version).matches() || notes == null) return null;
return new ReleaseMetadata(version, notes, publishedAt);
} catch (IOException | RuntimeException exception) {
log.debug("嵌入式 Release 更新日志读取失败:{}", metadataFile, exception);
return null;
}
UpdateStatus persisted = readPersistedStatus();
if (persisted != null && BUSY_STATES.contains(persisted.state())) return persisted;
if (persisted != null && "FAILED".equals(persisted.state()) && hasProcessingRequest()) return persisted;
UpdateStatus queued = pendingStatus();
if (queued != null) return queued;
return persisted == null ? new UpdateStatus("IDLE", "尚未执行在线更新", null, null) : persisted;
}
private UpdateStatus readPersistedStatus() {
@@ -431,7 +501,9 @@ 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)),
boundedText(root, "releaseNotes", 4000), parseInstant(root.path("publishedAt").asText(null)));
} catch (IOException exception) {
return new UpdateStatus("UNKNOWN", "更新状态读取失败", null, null);
}
@@ -453,9 +525,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));
}
@@ -525,10 +601,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 +710,40 @@ 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(),
status.releaseNotes(), status.publishedAt());
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);
}
@@ -647,11 +760,21 @@ public class SystemUpdateApplicationService {
String releaseNotes) {
}
private record ReleaseMetadata(String version, String releaseNotes, Instant publishedAt) {
}
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, String releaseNotes, Instant publishedAt) {
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, 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,
null, null);
}
}
@@ -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);
@@ -0,0 +1,88 @@
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\","
+ "\"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(),
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,
"Preview update", Instant.parse("2026-08-16T00:00:00Z"));
String secondKey = service.recordSystemUpdateTerminal("01M00000000000000000000092", "INSTALL",
"SUCCEEDED", "1.0.0-preview.44", "新版本已通过健康检查", completedAt,
"Preview update", Instant.parse("2026-08-16T00:00:00Z"));
assertEquals(firstKey, secondKey);
assertTrue(firstKey.startsWith("SYSUPD:"));
ArgumentCaptor<AuditEntry> 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"));
assertTrue(persisted.afterJson().contains("Preview update"));
assertTrue(persisted.afterJson().contains("publishedAt"));
}
@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<AuditEntry> entry = ArgumentCaptor.forClass(AuditEntry.class);
verify(mapper).insertSystemUpdateTerminal(entry.capture(), anyString(), any());
assertEquals("01M00000000000000000000088", entry.getValue().requestId());
assertEquals("SYSTEM_UPDATE_SUCCEEDED", entry.getValue().actionCode());
}
}
@@ -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,105 @@ 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","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"),
"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"), "Preview update",
java.time.Instant.parse("2026-08-16T00:00: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"), 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"), null, null);
}
@Test
void restoresEmbeddedReleaseNotesAfterLegacyUpdaterSwitch() throws Exception {
Path inbox = Files.createDirectory(tempDir.resolve("embedded-notes-inbox"));
Path versionFile = tempDir.resolve("current/VERSION");
Files.createDirectories(versionFile.getParent());
Files.writeString(versionFile, "1.0.0-preview.47\n");
Files.writeString(versionFile.resolveSibling("release-metadata.json"), """
{"version":"1.0.0-preview.47","publishedAt":"2026-08-19T00:28:41.701Z",
"releaseNotes":"从已签名制品恢复的更新日志"}
""");
Path statusFile = tempDir.resolve("embedded-notes-status.json");
Files.writeString(statusFile, """
{"state":"SUCCEEDED","message":"Release 1.0.0-preview.47 is running",
"targetVersion":"1.0.0-preview.47","updatedAt":"2026-08-19T00:30:00Z"}
""");
AuditService auditService = mock(AuditService.class);
when(auditService.recordSystemUpdateTerminal(null, null, "SUCCEEDED", "1.0.0-preview.47",
"Release 1.0.0-preview.47 is running", java.time.Instant.parse("2026-08-19T00:30:00Z"),
"从已签名制品恢复的更新日志", java.time.Instant.parse("2026-08-19T00:28:41.701Z")))
.thenReturn("SYSUPD:embedded-notes");
SystemUpdateApplicationService service = serviceForStatus(inbox, statusFile, auditService, versionFile);
var status = service.status();
assertEquals("从已签名制品恢复的更新日志", status.releaseNotes());
assertEquals(java.time.Instant.parse("2026-08-19T00:28:41.701Z"), status.publishedAt());
verify(auditService).recordSystemUpdateTerminal(null, null, "SUCCEEDED", "1.0.0-preview.47",
"Release 1.0.0-preview.47 is running", java.time.Instant.parse("2026-08-19T00:30:00Z"),
"从已签名制品恢复的更新日志", java.time.Instant.parse("2026-08-19T00:28:41.701Z"));
}
private SystemUpdateApplicationService serviceForStatus(Path inbox, Path statusFile, AuditService auditService) {
return serviceForStatus(inbox, statusFile, auditService, null);
}
private SystemUpdateApplicationService serviceForStatus(Path inbox, Path statusFile, AuditService auditService,
Path versionFile) {
SystemUpdateProperties properties = new SystemUpdateProperties(true, "1.0.0-preview.43", versionFile,
"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);
@@ -322,6 +423,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"));
+1 -1
View File
@@ -15,7 +15,7 @@ FILE_SCANNER_ENABLED=true
FINANCE_BOOTSTRAP_ENABLED=false
FINANCE_BOOTSTRAP_PASSWORD=
APP_VERSION=1.0.0-preview.42
APP_VERSION=1.0.0-preview.48
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.
+42
View File
@@ -41,6 +41,10 @@ WORK_DIR=
CACHE_TEMP=
RELEASE_AUTH_HEADER_FILE=
TARGET_VERSION=
REQUEST_ID=
REQUEST_ACTION=
RELEASE_NOTES=
RELEASE_PUBLISHED_AT=
TERMINAL_STATUS_WRITTEN=false
DOWNLOAD_PID=
DOWNLOAD_PGID=
@@ -293,10 +297,34 @@ status() {
restart_expected_seconds=${8:-}
previous_state=
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)
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)
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=
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}
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" \
@@ -307,9 +335,17 @@ 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 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)
@@ -1035,6 +1071,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
@@ -1095,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
+46 -4
View File
@@ -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<Record<string, unknown>> = [];
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,17 @@ 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_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: [],
});
@@ -156,6 +175,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 +215,10 @@ 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');
expect(query.get('size')).toBe('100');
return route.fulfill({
json: {
data: history,
@@ -393,6 +420,21 @@ 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 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('Preview update');
await timelineItem.getByRole('button', { name: '查看更新日志', exact: true }).click();
const historyDialog = page.getByRole('dialog', { name: '更新日志详情' });
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 }) => {
@@ -2,14 +2,18 @@
<div class="system-update-page">
<h1 class="page-title-sr-only">系统更新</h1>
<header class="page-header page-header--actions-only" aria-label="页面操作">
<t-button variant="outline" :loading="statusLoading || historyLoading" @click="refreshPage">
<template #icon><t-icon name="refresh" /></template>
刷新状态
</t-button>
</header>
<section class="update-panel" aria-label="系统版本与更新操作">
<header class="update-toolbar" aria-label="系统更新工具栏">
<div class="update-toolbar__copy">
<div class="update-toolbar__title">系统更新</div>
<p>获取版本、下载校验并安全重启应用</p>
</div>
<t-button variant="outline" :loading="statusLoading || historyLoading" @click="refreshPage">
<template #icon><t-icon name="refresh" /></template>
刷新状态
</t-button>
</header>
<div class="update-heading">
<div class="version-grid">
<div class="version-item">
@@ -107,9 +111,9 @@
<div class="section-heading">
<div>
<h2>历史更新记录</h2>
<p>记录版本获取、下载和重启安装操作。</p>
<p>按版本展示签名 Release 提供的更新日志与最终结果。</p>
</div>
<span>最近 {{ historyRows.length }} 条</span>
<span>已记录 {{ historyTimeline.length }} 个版本</span>
</div>
<t-alert v-if="historyError" theme="error" :close-btn="false">
@@ -118,23 +122,47 @@
<t-link theme="primary" @click="() => loadHistory()">重新加载</t-link>
</div>
</t-alert>
<t-table
v-if="historyLoading || historyRows.length"
:columns="historyColumns"
:data="historyRows"
row-key="publicId"
:loading="historyLoading"
table-layout="fixed"
:horizontal-scroll-affixed-bottom="true"
>
<template #occurredAt="{ row }">{{ formatDateTime(row.occurredAt) }}</template>
<template #actionCode="{ row }">{{ actionLabel(row.actionCode) }}</template>
<template #version="{ row }">{{ historyVersion(row) }}</template>
<template #resultCode="{ row }">
<t-tag :theme="resultTheme(row.resultCode)" variant="light">{{ resultLabel(row.resultCode) }}</t-tag>
</template>
<template #reason="{ row }">{{ row.reason || '-' }}</template>
</t-table>
<div v-if="historyLoading" class="history-loading" aria-live="polite">
<t-loading text="正在加载历史更新记录" />
</div>
<div v-else-if="historyTimeline.length" class="update-timeline" aria-label="按版本排列的更新时间线">
<article
v-for="item in historyTimeline"
:key="item.key"
class="update-timeline__item"
:data-version="item.version"
>
<div class="update-timeline__rail" aria-hidden="true">
<span class="update-timeline__marker" :class="`update-timeline__marker--${resultTheme(item.resultCode)}`">
<t-icon :name="item.resultCode === 'SUCCESS' ? 'check' : 'error-circle'" />
</span>
</div>
<div class="timeline-card">
<div class="timeline-card__header">
<div class="timeline-card__identity">
<strong>{{ item.version }}</strong>
<span>{{ formatDateTime(item.occurredAt) }} · {{ item.username || '系统' }}</span>
</div>
<t-tag :theme="resultTheme(item.resultCode)" variant="light">{{ resultLabel(item.resultCode) }}</t-tag>
</div>
<div class="timeline-card__notes">
<span>更新日志</span>
<p>{{ item.releaseNotes || '该版本的签名 Release 未提供更新日志。' }}</p>
</div>
<p class="timeline-card__reason">{{ item.reason || '更新结果已记录。' }}</p>
<div class="timeline-card__published">
<span v-if="item.publishedAt">发布时间:{{ formatDateTime(item.publishedAt) }}</span>
<span>记录时间:{{ formatDateTime(item.occurredAt) }} · {{ item.username || '系统' }}</span>
</div>
<div class="timeline-card__footer">
<span>目标版本 {{ item.version }}</span>
<t-button variant="text" size="small" @click="openHistoryDetail(item)">查看更新日志</t-button>
</div>
</div>
</article>
</div>
<t-empty v-else description="暂无系统更新记录" />
</section>
@@ -219,11 +247,41 @@
</div>
</div>
</t-dialog>
<t-dialog
v-model:visible="historyDetailVisible"
header="更新日志详情"
aria-label="更新日志详情"
width="min(680px, calc(100vw - 32px))"
:footer="false"
>
<div v-if="selectedHistory" class="history-detail" role="dialog" aria-label="更新日志详情" aria-modal="true">
<div class="history-detail__header">
<div>
<span>目标版本</span>
<strong>{{ selectedHistory.version }}</strong>
</div>
<t-tag :theme="resultTheme(selectedHistory.resultCode)" variant="light">
{{ resultLabel(selectedHistory.resultCode) }}
</t-tag>
</div>
<div class="history-detail__metadata">
<span v-if="selectedHistory.publishedAt">发布时间:{{ formatDateTime(selectedHistory.publishedAt) }}</span>
<span
>记录时间:{{ formatDateTime(selectedHistory.occurredAt) }} · {{ selectedHistory.username || '系统' }}</span
>
</div>
<div class="history-detail__notes">
<span>更新日志</span>
<p>{{ selectedHistory.releaseNotes || '该版本的签名 Release 未提供更新日志。' }}</p>
</div>
<p class="history-detail__reason">{{ selectedHistory.reason || '更新结果已记录。' }}</p>
</div>
</t-dialog>
</div>
</template>
<script setup lang="ts">
import { isAxiosError } from 'axios';
import type { PrimaryTableCol } from 'tdesign-vue-next';
import { MessagePlugin } from 'tdesign-vue-next';
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
@@ -240,6 +298,17 @@ import {
defineOptions({ name: 'SystemUpdatePage' });
type TagTheme = 'default' | 'primary' | 'success' | 'warning' | 'danger';
interface HistoryTimelineItem {
key: string;
version: string;
occurredAt: string;
username: string;
resultCode: string;
reason: string;
releaseNotes: string;
publishedAt: string;
}
const UPDATE_PENDING_KEY = 'kaidi-system-update-pending';
const updateStatus = ref<SystemUpdateView | null>(null);
@@ -255,6 +324,8 @@ const historyError = ref('');
const updateRefreshSeconds = ref<number | null>(null);
const restartCountdown = ref<number | null>(null);
const updateDialogVisible = ref(false);
const historyDetailVisible = ref(false);
const selectedHistory = ref<HistoryTimelineItem | null>(null);
const connectionInterrupted = ref(false);
const localEvents = ref<SystemUpdateEvent[]>([]);
const awaitingRefresh = ref(false);
@@ -383,14 +454,37 @@ const updateStateTheme = computed<TagTheme>(() => {
return 'default';
});
const historyColumns: PrimaryTableCol[] = [
{ colKey: 'occurredAt', title: '操作时间', width: 180 },
{ colKey: 'version', title: '版本', width: 170, ellipsis: true },
{ colKey: 'actionCode', title: '操作', width: 150 },
{ colKey: 'username', title: '操作人', width: 130, ellipsis: true },
{ colKey: 'resultCode', title: '结果', width: 100 },
{ colKey: 'reason', title: '说明', minWidth: 220, ellipsis: true },
];
const historyTimeline = computed<HistoryTimelineItem[]>(() => {
const grouped = new Map<string, AuditLog[]>();
for (const row of historyRows.value) {
const version = historyVersion(row);
const key = version === '-' ? `unknown:${row.requestId || row.publicId}` : `version:${version}`;
const rows = grouped.get(key) || [];
rows.push(row);
grouped.set(key, rows);
}
return [...grouped.entries()]
.map(([key, rows]) => {
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 releaseNotes = newestFirst.map((row) => historyReleaseNotes(row)).find(Boolean) || '';
const publishedAt = newestFirst.map((row) => historyPublishedAt(row)).find(Boolean) || '';
return {
key,
version: representative ? historyVersion(representative) : '-',
occurredAt: representative?.occurredAt || '',
username: representative?.username || '',
resultCode: representative?.resultCode || 'UNKNOWN',
reason: representative?.reason || '',
releaseNotes,
publishedAt,
};
})
.sort((left, right) => compareHistoryDates(right.occurredAt, left.occurredAt));
});
async function loadStatus(silent = false) {
if (!silent) {
@@ -442,10 +536,11 @@ async function loadHistory(silent = false) {
}
try {
const result = await getAuditLogs({
occurredFrom: '1970-01-01T00:00:00Z',
objectType: 'SYSTEM_UPDATE',
sort: 'occurredAt,desc',
page: 1,
size: 20,
size: 100,
});
historyRows.value = result.items;
} catch (error) {
@@ -700,28 +795,64 @@ function eventStageLabel(stage: string) {
);
}
function compareHistoryDates(left: string, right: string) {
const leftTime = Date.parse(left);
const rightTime = Date.parse(right);
if (Number.isNaN(leftTime) || Number.isNaN(rightTime)) return left.localeCompare(right);
return leftTime - rightTime;
}
function isTerminalHistoryAction(action: string) {
return ['SYSTEM_UPDATE_SUCCEEDED', 'SYSTEM_UPDATE_FAILED', 'SYSTEM_UPDATE_RECOVERY_REQUIRED'].includes(action);
}
function parseHistoryPayload(source?: string | null) {
if (!source) return null;
try {
const parsed: unknown = JSON.parse(source);
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? (parsed as Record<string, unknown>) : null;
} catch {
return null;
}
}
function historyVersion(row: AuditLog) {
for (const source of [row.afterJson, row.beforeJson]) {
if (!source) continue;
try {
const parsed = JSON.parse(source) as Record<string, unknown>;
const version = parsed.targetVersion || parsed.latestVersion || parsed.currentVersion || parsed.version;
if (version) return String(version);
} catch {
continue;
}
const parsed = parseHistoryPayload(source);
const version = parsed?.targetVersion || parsed?.latestVersion || parsed?.currentVersion || parsed?.version;
if (version !== undefined && version !== null && String(version).trim()) return String(version);
}
return '-';
}
function actionLabel(action: string) {
return (
{
SYSTEM_UPDATE_CHECK: '获取版本',
SYSTEM_UPDATE_DOWNLOAD_REQUEST: '下载更新包',
SYSTEM_UPDATE_REQUEST: '立即更新并重启',
}[action] || action
);
function historyReleaseNotes(row: AuditLog) {
for (const source of [row.afterJson, row.beforeJson]) {
const parsed = parseHistoryPayload(source);
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 '';
}
function openHistoryDetail(item: HistoryTimelineItem) {
selectedHistory.value = item;
historyDetailVisible.value = true;
}
function resultLabel(result: string) {
@@ -782,7 +913,7 @@ onBeforeUnmount(() => {
min-width: 0;
}
.page-header,
.update-toolbar,
.update-heading,
.update-actions,
.section-heading,
@@ -791,10 +922,6 @@ onBeforeUnmount(() => {
align-items: center;
}
.page-header {
justify-content: flex-end;
}
.update-panel,
.history-panel {
width: 100%;
@@ -818,6 +945,31 @@ onBeforeUnmount(() => {
min-width: 0;
}
.update-toolbar {
justify-content: space-between;
gap: 20px;
min-width: 0;
padding-bottom: 2px;
}
.update-toolbar__copy {
min-width: 0;
}
.update-toolbar__title {
margin: 0;
color: var(--td-text-color-primary);
font-size: 18px;
line-height: 26px;
}
.update-toolbar__copy p {
margin: 4px 0 0;
color: var(--td-text-color-secondary);
font-size: 13px;
line-height: 20px;
}
.version-grid {
display: grid;
grid-template-columns: repeat(3, minmax(150px, 1fr));
@@ -897,6 +1049,192 @@ onBeforeUnmount(() => {
line-height: 24px;
}
.history-loading {
display: flex;
justify-content: center;
min-height: 160px;
padding: 32px 0;
}
.update-timeline {
display: flex;
flex-direction: column;
gap: 18px;
}
.update-timeline__item {
display: grid;
grid-template-columns: 24px minmax(0, 1fr);
gap: 14px;
min-width: 0;
}
.update-timeline__rail {
position: relative;
display: flex;
justify-content: center;
}
.update-timeline__rail::after {
position: absolute;
top: 30px;
bottom: -18px;
width: 1px;
background: var(--td-component-border);
content: '';
}
.update-timeline__item:last-child .update-timeline__rail::after {
display: none;
}
.update-timeline__marker {
z-index: 1;
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
color: var(--td-text-color-secondary);
background: var(--td-bg-color-container);
border: 2px solid var(--td-component-border);
border-radius: 50%;
}
.update-timeline__marker--success {
color: #fff;
background: var(--td-success-color);
border-color: var(--td-success-color);
}
.update-timeline__marker--danger {
color: #fff;
background: var(--td-error-color);
border-color: var(--td-error-color);
}
.update-timeline__marker--warning {
color: #fff;
background: var(--td-warning-color);
border-color: var(--td-warning-color);
}
.timeline-card {
min-width: 0;
padding: 16px;
background: var(--td-bg-color-container);
border: 1px solid var(--td-component-border);
border-radius: 6px;
}
.timeline-card__header,
.timeline-card__footer,
.history-detail__header {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
}
.timeline-card__header,
.timeline-card__footer,
.history-detail__header {
justify-content: space-between;
}
.timeline-card__identity {
display: flex;
flex-direction: column;
gap: 3px;
min-width: 0;
}
.timeline-card__identity strong {
overflow: hidden;
color: var(--td-text-color-primary);
font-size: 16px;
line-height: 24px;
text-overflow: ellipsis;
white-space: nowrap;
}
.timeline-card__identity span,
.timeline-card__footer,
.history-detail__header span,
.history-detail__metadata {
color: var(--td-text-color-secondary);
font-size: 12px;
}
.timeline-card__reason,
.history-detail__reason {
margin: 12px 0 0;
color: var(--td-text-color-primary);
line-height: 22px;
overflow-wrap: anywhere;
}
.timeline-card__notes,
.history-detail__notes {
display: grid;
grid-template-columns: 72px minmax(0, 1fr);
gap: 10px;
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid var(--td-component-border);
}
.timeline-card__notes > span,
.history-detail__notes > span {
color: var(--td-text-color-secondary);
font-size: 12px;
}
.timeline-card__notes p,
.history-detail__notes p {
margin: 0;
color: var(--td-text-color-primary);
line-height: 22px;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.timeline-card__published,
.history-detail__metadata {
display: flex;
flex-wrap: wrap;
gap: 6px 16px;
margin-top: 10px;
color: var(--td-text-color-secondary);
font-size: 12px;
line-height: 20px;
}
.timeline-card__footer {
margin-top: 14px;
padding-top: 10px;
border-top: 1px solid var(--td-component-border);
}
.history-detail {
display: flex;
flex-direction: column;
gap: 14px;
min-width: 0;
}
.history-detail__header > div {
display: flex;
flex-direction: column;
gap: 4px;
}
.history-detail__header strong {
color: var(--td-text-color-primary);
font-size: 18px;
line-height: 26px;
}
.alert-content {
justify-content: space-between;
gap: 12px;
@@ -1022,6 +1360,16 @@ onBeforeUnmount(() => {
gap: 12px;
}
.update-toolbar {
align-items: flex-start;
flex-direction: column;
gap: 10px;
}
.update-toolbar :deep(.t-button) {
width: 100%;
}
.update-actions,
.update-actions :deep(.t-button) {
width: 100%;
@@ -1038,6 +1386,30 @@ onBeforeUnmount(() => {
gap: 8px;
}
.update-timeline__item {
grid-template-columns: 18px minmax(0, 1fr);
gap: 8px;
}
.update-timeline__marker {
width: 18px;
height: 18px;
}
.update-timeline__rail::after {
top: 24px;
}
.timeline-card {
padding: 12px;
}
.timeline-card__notes,
.history-detail__notes {
grid-template-columns: 1fr;
gap: 4px;
}
.download-progress__meta,
.dialog-status-line,
.dialog-actions {
+11
View File
@@ -0,0 +1,11 @@
更新日志(Preview 47)
本版本聚焦在线更新的可追溯性与发布说明展示,不改变财务业务数据和审批规则。
• 更新历史改为按版本展示 Release 更新日志,不再把获取、下载、重启等内部执行步骤当作历史内容。
• 历史详情直接展示签名 Release 的发布说明、发布时间、目标版本和最终结果。
• 更新器在验签后把 releaseNotes 与 publishedAt 写入状态文件;应用重启后仍能显示完整发布说明。
• 终态审计记录保存发布说明并保持幂等,历史记录不再只显示“更新成功”一条信息。
• 发布打包、验签和 Gitea Release 正文统一读取 release-notes/<version>.md,避免页面、清单和 Release 描述不一致。
升级说明:本版本不要求安装 MySQL,也不会自动修改数据库服务;更新器默认跳过数据库备份,仅使用已验签的 Release 制品完成应用切换。
+11
View File
@@ -0,0 +1,11 @@
更新日志(Preview 48)
本版本修复在线更新完成后后台看不到 Release 更新日志的问题。
• 更新日志继续以签名 release-manifest.json 为唯一发布来源,Gitea Release 正文与清单使用同一份内容。
• 发布制品新增 release-metadata.json,并由签名制品携带版本、发布时间和完整更新说明。
• 应用重启后会从当前已验签制品恢复更新说明;即使由旧版更新器完成版本切换,也不会再丢失日志。
• 更新完成后的终态审计会保存 Release 更新说明,系统更新页按版本显示完整日志,而不是只显示“更新成功”。
• 增加发布清单与制品内元数据的一致性校验,避免版本、发布时间和更新说明发生漂移。
升级说明:本版本不要求安装 MySQL,也不会自动修改数据库服务;更新器默认跳过数据库备份,仅使用已验签的 Release 制品完成应用切换。
+28 -2
View File
@@ -94,6 +94,22 @@ if grep -Eq '127\.0\.0\.1:3307|kaidi_local_2026' \
exit 1
fi
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; }
PUBLISHED_AT=$(node -e 'process.stdout.write(new Date().toISOString())')
rm -rf "$OUTPUT_DIR"
mkdir -p "$OUTPUT_DIR"
STAGE="$WORK/stage"
@@ -112,6 +128,15 @@ cp "$ROOT/deploy/systemd/kaidi-finance.service" "$STAGE/ops/kaidi-finance.servic
cp "$ROOT/deploy/systemd/kaidi-update.service" "$STAGE/ops/kaidi-update.service"
cp "$ROOT/deploy/systemd/kaidi-update.path" "$STAGE/ops/kaidi-update.path"
chmod 0755 "$STAGE/ops/update.sh" "$STAGE/ops/baota-start.sh" "$STAGE/ops/baota-init.sh"
VERSION="$VERSION" RELEASE_NOTES="$RELEASE_NOTES_VALUE" PUBLISHED_AT="$PUBLISHED_AT" \
node <<'NODE' > "$STAGE/release-metadata.json"
const metadata = {
version: process.env.VERSION,
publishedAt: process.env.PUBLISHED_AT,
releaseNotes: process.env.RELEASE_NOTES,
};
process.stdout.write(`${JSON.stringify(metadata, null, 2)}\n`);
NODE
if find "$STAGE" -type l -print -quit | grep -q .; then
printf 'Release stage contains a symbolic link\n' >&2
@@ -187,9 +212,9 @@ 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"}
VERSION="$VERSION" ARTIFACT="$ARTIFACT" SHA256="$SHA256" ARTIFACT_SIZE_BYTES="$ARTIFACT_SIZE_BYTES" \
RELEASE_NOTES="$RELEASE_NOTES_VALUE" \
PUBLISHED_AT="$PUBLISHED_AT" \
BACKEND_SBOM_SHA256="$BACKEND_SBOM_SHA256" FRONTEND_SBOM_SHA256="$FRONTEND_SBOM_SHA256" \
BACKEND_BUILD_VERSION="$BACKEND_BUILD_VERSION" FRONTEND_BUILD_VERSION="$FRONTEND_BUILD_VERSION" \
INSTALLER_SHA256="$INSTALLER_SHA256" PURGER_SHA256="$PURGER_SHA256" \
@@ -202,7 +227,8 @@ const manifest = {
artifact: process.env.ARTIFACT,
sha256: process.env.SHA256,
artifactSizeBytes: Number(process.env.ARTIFACT_SIZE_BYTES),
publishedAt: new Date().toISOString(),
publishedAt: process.env.PUBLISHED_AT,
releaseMetadata: 'release-metadata.json',
minimumJava: 17,
source: {
revision: process.env.SOURCE_REVISION,
+10 -3
View File
@@ -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"
+1
View File
@@ -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
+23 -2
View File
@@ -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,13 @@ 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'
[ "$(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" ] \
@@ -447,6 +457,11 @@ 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'
[ "$(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'
@@ -522,6 +537,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 +568,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'
+26
View File
@@ -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" ] \
@@ -161,6 +166,27 @@ if tar -tvzf "$ARTIFACT" | grep -Eq '^l'; then
printf 'Release archive contains a symbolic link\n' >&2
exit 1
fi
RELEASE_METADATA_PATH=$(jq -r '.releaseMetadata // empty' release-manifest.json)
if [ -n "$RELEASE_METADATA_PATH" ]; then
[ "$RELEASE_METADATA_PATH" = "release-metadata.json" ] \
|| { printf 'Release metadata path is invalid\n' >&2; exit 1; }
tar -xOf "$ARTIFACT" "./$RELEASE_METADATA_PATH" > "$WORK/release-metadata.json" \
|| { printf 'Release archive is missing embedded release metadata\n' >&2; exit 1; }
METADATA_VERSION=$(jq -er '.version | strings | select(length > 0)' "$WORK/release-metadata.json") \
|| { printf 'Embedded release metadata version is invalid\n' >&2; exit 1; }
METADATA_NOTES=$(jq -er '.releaseNotes | strings | select(length > 0 and length <= 4000)' \
"$WORK/release-metadata.json") \
|| { printf 'Embedded release metadata notes are invalid\n' >&2; exit 1; }
METADATA_PUBLISHED_AT=$(jq -er '.publishedAt | strings | select(length > 0)' \
"$WORK/release-metadata.json") \
|| { printf 'Embedded release metadata publish time is invalid\n' >&2; exit 1; }
MANIFEST_PUBLISHED_AT=$(jq -er '.publishedAt | strings | select(length > 0)' release-manifest.json) \
|| { printf 'Release manifest publish time is invalid\n' >&2; exit 1; }
[ "$METADATA_VERSION" = "$VERSION" ] \
&& [ "$METADATA_NOTES" = "$RELEASE_NOTES" ] \
&& [ "$METADATA_PUBLISHED_AT" = "$MANIFEST_PUBLISHED_AT" ] \
|| { printf 'Embedded release metadata does not match the signed manifest\n' >&2; exit 1; }
fi
[ "$(tar -xOf "$ARTIFACT" ./VERSION)" = "$VERSION" ] \
|| { printf 'Release archive version does not match the manifest\n' >&2; exit 1; }
tar -xOf "$ARTIFACT" ./app.jar > "$WORK/app.jar"