Compare commits

..
Author SHA1 Message Date
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
12 changed files with 1049 additions and 74 deletions
+11 -11
View File
@@ -83,7 +83,7 @@ PostgreSQL 18 兼容工作继续冻结。
先在宝塔面板停止并删除当前错误的 Java 项目,再执行: 先在宝塔面板停止并删除当前错误的 Java 项目,再执行:
```bash ```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.46/install.sh | sudo env KAIDI_APP_PORT=18080 bash
``` ```
该命令只安装程序运行所需的 systemd 单元,自动创建 `kaidi` 用户并检测现有 Java 17(包括 该命令只安装程序运行所需的 systemd 单元,自动创建 `kaidi` 用户并检测现有 Java 17(包括
@@ -96,7 +96,7 @@ curl -fsSL https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-previe
随后执行一键清理: 随后执行一键清理:
```bash ```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.46/purge.sh' | sudo env KAIDI_PURGE_CONFIRM=DELETE_LOCAL_KAIDI_INSTALLATION bash
``` ```
上面是一条完整命令:脚本通过管道直接交给 root 执行,不创建临时安装文件,避免终端自动换行导致 `-o` 参数丢失。 上面是一条完整命令:脚本通过管道直接交给 root 执行,不创建临时安装文件,避免终端自动换行导致 `-o` 参数丢失。
@@ -110,16 +110,16 @@ Nginx/反向代理和外部 MySQL 属于外部资源,卸载程序不会误删
`0600`,确认新安装及数据无误后再由 root 删除。脚本不删除外部 MySQL、系统 Java、Nginx 或宝塔站点配置; `0600`,确认新安装及数据无误后再由 root 删除。脚本不删除外部 MySQL、系统 Java、Nginx 或宝塔站点配置;
新安装必须连接一个新的空 MySQL 数据库,旧数据库保留用于回滚。 新安装必须连接一个新的空 MySQL 数据库,旧数据库保留用于回滚。
### Preview.42 直链与宝塔手动部署 ### Preview.43 直链与宝塔手动部署
本版不使用一键安装脚本。发布物是一个不带顶层目录的压缩包,下载后直接解压到版本目录,再由宝塔面板创建 本版提供 systemd 一键安装脚本和宝塔手动部署两种互斥方式。手动部署使用一个不带顶层目录的压缩包,下载后直接解压到版本目录,再由宝塔面板创建
Spring Boot 项目。数据库、JDK、Nginx 和宝塔本身都由运维人员准备;程序不会自动安装 MySQL 或任何第三方服务。 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.46/kaidi-finance-1.0.0-preview.46.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.46/SHA256SUMS`
服务器要求:Linux、Java 17(宝塔项目选择 JDK 17)、可访问外部 MySQL 8.4.x;**systemd 一键安装**还需要 服务器要求:Linux、Java 17(宝塔项目选择 JDK 17)、可访问外部 MySQL 8.4.x;**systemd 一键安装**还需要
systemd/systemd-analyze,**宝塔手动部署**不要求 systemd。32 位 Linux 需要宿主机 systemd/systemd-analyze,**宝塔手动部署**不要求 systemd。32 位 Linux 需要宿主机
@@ -136,7 +136,7 @@ systemd/systemd-analyze,**宝塔手动部署**不要求 systemd。32 位 Linux
必须直接位于 `RELEASE_ROOT`,不能再嵌套一层目录。 必须直接位于 `RELEASE_ROOT`,不能再嵌套一层目录。
```bash ```bash
VERSION=1.0.0-preview.42 VERSION=1.0.0-preview.46
APP_ROOT=/www/wwwroot/kaidi APP_ROOT=/www/wwwroot/kaidi
RELEASE_ROOT="$APP_ROOT/releases/$VERSION" RELEASE_ROOT="$APP_ROOT/releases/$VERSION"
sudo install -d -m 0755 "$RELEASE_ROOT" sudo install -d -m 0755 "$RELEASE_ROOT"
@@ -245,7 +245,7 @@ MySQL 8.4;提前准备外部 MySQL,完成上述向导即可。systemd 在线
`KAIDI_PURGE_DELETE_BACKUP=true`,实现本地受管文件和恢复包一并清除: `KAIDI_PURGE_DELETE_BACKUP=true`,实现本地受管文件和恢复包一并清除:
```bash ```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.46/purge.sh' | sudo env KAIDI_PURGE_CONFIRM=DELETE_LOCAL_KAIDI_INSTALLATION KAIDI_PURGE_DELETE_BACKUP=true bash
``` ```
执行前先在宝塔停止并删除 `kaidi-finance` Java 项目;宝塔面板元数据属于外部资源,必须由面板先停用, 执行前先在宝塔停止并删除 `kaidi-finance` Java 项目;宝塔面板元数据属于外部资源,必须由面板先停用,
@@ -271,8 +271,8 @@ Actions 页面显示 “No matching online runner”,先启动并注册该标
Preview 属性由 SemVer 版本名表达。之后推送 tag 即会构建、测试、签名并发布: Preview 属性由 SemVer 版本名表达。之后推送 tag 即会构建、测试、签名并发布:
```bash ```bash
git tag v1.0.0-preview.42 git tag v1.0.0-preview.46
git push origin v1.0.0-preview.42 git push origin v1.0.0-preview.46
``` ```
在线更新仍使用独立的 TDesign 页面:系统管理员进入“系统治理 → 系统更新”。更新源由 root 在 在线更新仍使用独立的 TDesign 页面:系统管理员进入“系统治理 → 系统更新”。更新源由 root 在
@@ -324,7 +324,7 @@ cat /var/lib/kaidi-update/status.json
```bash ```bash
KAIDI_RELEASE_SIGNING_KEY=/secure/release-signing-private.pem \ KAIDI_RELEASE_SIGNING_KEY=/secure/release-signing-private.pem \
KAIDI_TRUSTED_RELEASE_PUBLIC_KEY_SHA256=807c6aec1dc3f7ce494db16aa9d763c66f292033c38f328afd0390d2715a8cd9 \ KAIDI_TRUSTED_RELEASE_PUBLIC_KEY_SHA256=807c6aec1dc3f7ce494db16aa9d763c66f292033c38f328afd0390d2715a8cd9 \
./scripts/package-release.sh 1.0.0-preview.42 ./scripts/package-release.sh 1.0.0-preview.46
KAIDI_TRUSTED_RELEASE_PUBLIC_KEY_SHA256=807c6aec1dc3f7ce494db16aa9d763c66f292033c38f328afd0390d2715a8cd9 \ KAIDI_TRUSTED_RELEASE_PUBLIC_KEY_SHA256=807c6aec1dc3f7ce494db16aa9d763c66f292033c38f328afd0390d2715a8cd9 \
./scripts/verify-release.sh dist/release ./scripts/verify-release.sh dist/release
``` ```
@@ -22,6 +22,54 @@ public interface AuditMapper {
""") """)
int insert(AuditEntry entry); 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("""
SELECT created_at, ip_address, user_agent SELECT created_at, ip_address, user_agent
FROM audit_log FROM audit_log
@@ -36,4 +84,9 @@ public interface AuditMapper {
record LoginAuditRow(LocalDateTime occurredAt, String ipAddress, String userAgent) { 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 com.kaidi.finance.shared.infrastructure.RequestContext;
import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpSession; 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.Authentication;
import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@@ -54,6 +64,71 @@ public class AuditService {
null, null); null, null);
} }
/**
* Persists the updater's terminal state as a separate immutable audit event.
*
* The shell updater finishes while the application is restarting, so this method is invoked by the first
* successful status read after restart. A database-level dedupe key makes repeated polling and application
* restarts idempotent. When possible the terminal event inherits the actor and request id from the original
* download/install request; legacy status files without a request id fall back to the most recent matching
* target version.
*
* @return the stable dedupe key, or {@code null} when the supplied state is not persistable
*/
public String recordSystemUpdateTerminal(String updateRequestId, String updateAction, String state,
String targetVersion, String message, Instant updatedAt) {
String normalizedState = normalizeTerminalState(state);
String normalizedVersion = clean(targetVersion, 128);
if (normalizedState == null || normalizedVersion == null || updatedAt == null) return null;
String normalizedRequestId = validRequestId(updateRequestId) ? updateRequestId : null;
String normalizedAction = clean(updateAction, 16);
if (normalizedAction != null) normalizedAction = normalizedAction.toUpperCase(Locale.ROOT);
String sourceAction = sourceAction(normalizedState, normalizedAction);
AuditMapper.SystemUpdateAuditSource source = findSystemUpdateSource(
normalizedRequestId, normalizedVersion, sourceAction, updatedAt);
if (source == null && normalizedAction == null && !"SYSTEM_UPDATE_DOWNLOAD_REQUEST".equals(sourceAction)) {
source = findSystemUpdateSource(normalizedRequestId, normalizedVersion,
"SYSTEM_UPDATE_DOWNLOAD_REQUEST", updatedAt);
}
String correlation = normalizedRequestId == null
? normalizedVersion + '|' + normalizedState + '|' + updatedAt
: normalizedRequestId + '|' + normalizedState;
String dedupeKey = "SYSUPD:" + sha256(correlation);
String terminalAction = switch (normalizedState) {
case "SUCCEEDED" -> "SYSTEM_UPDATE_SUCCEEDED";
case "RECOVERY_REQUIRED" -> "SYSTEM_UPDATE_RECOVERY_REQUIRED";
default -> "SYSTEM_UPDATE_FAILED";
};
String result = "SUCCEEDED".equals(normalizedState)
? "SUCCESS" : ("RECOVERY_REQUIRED".equals(normalizedState) ? "BLOCKED" : "FAILED");
String terminalReason = truncate(sanitizeReason(clean(message, 1000)), 500);
Map<String, Object> terminal = new LinkedHashMap<>();
terminal.put("state", normalizedState);
terminal.put("targetVersion", normalizedVersion);
terminal.put("message", terminalReason == null ? "" : terminalReason);
terminal.put("updatedAt", updatedAt);
if (normalizedAction != null) terminal.put("action", normalizedAction);
if (normalizedRequestId != null) terminal.put("requestId", normalizedRequestId);
Actor actor = source == null
? currentActor() : new Actor(source.userPublicId(), source.username(), source.activeRole());
AuditEntry entry = new AuditEntry(
ulidGenerator.next(), source == null ? RequestContext.requestId() : source.requestId(),
actor.publicId(), actor.username(), actor.activeRole(),
source == null ? null : source.companyPublicId(), source == null ? null : source.projectPublicId(),
terminalAction, "SYSTEM_UPDATE", "SYSTEM_UPDATE", result, terminalReason,
source == null ? null : source.afterJson(), json(terminal),
source == null ? clientIp() : source.ipAddress(), source == null
? truncate(request.getHeader("User-Agent"), 500) : source.userAgent()
);
auditMapper.insertSystemUpdateTerminal(entry, dedupeKey,
LocalDateTime.ofInstant(updatedAt, ZoneOffset.UTC));
return dedupeKey;
}
private void insert(Actor actor, String companyPublicId, String projectPublicId, String action, private void insert(Actor actor, String companyPublicId, String projectPublicId, String action,
String objectType, String objectPublicId, String result, String reason, String objectType, String objectPublicId, String result, String reason,
Object before, Object after) { Object before, Object after) {
@@ -76,6 +151,49 @@ public class AuditService {
return new Actor(null, null, null); 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) { private String json(Object value) {
if (value == null) { if (value == null) {
return null; return null;
@@ -36,12 +36,16 @@ import java.util.List;
import java.util.Locale; import java.util.Locale;
import java.util.regex.Matcher; import java.util.regex.Matcher;
import java.util.regex.Pattern; import java.util.regex.Pattern;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@Service @Service
public class SystemUpdateApplicationService { 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_MANIFEST_BYTES = 64 * 1024;
private static final int MAX_RELEASE_API_BYTES = 256 * 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_STATUS_BYTES = 64 * 1024;
@@ -71,6 +75,7 @@ public class SystemUpdateApplicationService {
private final HttpClient httpClient; private final HttpClient httpClient;
private volatile ReleaseManifest lastManifest; private volatile ReleaseManifest lastManifest;
private volatile Instant lastCheckedAt; private volatile Instant lastCheckedAt;
private volatile String lastTerminalAuditFingerprint;
public SystemUpdateApplicationService(SystemUpdateProperties properties, public SystemUpdateApplicationService(SystemUpdateProperties properties,
AuthorizationService authorizationService, AuthorizationService authorizationService,
@@ -90,7 +95,9 @@ public class SystemUpdateApplicationService {
public SystemUpdateView status() { public SystemUpdateView status() {
requireIsolatedSystemAdministrator(); requireIsolatedSystemAdministrator();
return view(lastManifest, readStatus()); UpdateStatus status = readStatus();
persistTerminalAudit(status);
return view(lastManifest, status);
} }
public SystemUpdateView check() { public SystemUpdateView check() {
@@ -99,7 +106,9 @@ public class SystemUpdateApplicationService {
ReleaseManifest manifest = fetchManifest(); ReleaseManifest manifest = fetchManifest();
lastManifest = manifest; lastManifest = manifest;
lastCheckedAt = Instant.now(); 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, auditService.record("SYSTEM_UPDATE_CHECK", "SYSTEM_UPDATE", "SYSTEM_UPDATE", "SUCCESS", null, null,
result); result);
return result; return result;
@@ -120,6 +129,7 @@ public class SystemUpdateApplicationService {
throw validation("当前已是相同或更高版本"); throw validation("当前已是相同或更高版本");
} }
UpdateStatus currentStatus = readStatus(); UpdateStatus currentStatus = readStatus();
persistTerminalAudit(currentStatus);
if ("READY".equals(currentStatus.state()) && requested.equals(currentStatus.targetVersion())) { if ("READY".equals(currentStatus.state()) && requested.equals(currentStatus.targetVersion())) {
throw validation("该版本已经下载并通过校验,请确认安装"); throw validation("该版本已经下载并通过校验,请确认安装");
} }
@@ -137,6 +147,7 @@ public class SystemUpdateApplicationService {
requireConfigured(); requireConfigured();
String requested = request.version().trim(); String requested = request.version().trim();
UpdateStatus ready = readStatus(); UpdateStatus ready = readStatus();
persistTerminalAudit(ready);
if (!"READY".equals(ready.state()) || !requested.equals(ready.targetVersion())) { if (!"READY".equals(ready.state()) || !requested.equals(ready.targetVersion())) {
throw validation("该版本尚未完成下载和签名校验"); throw validation("该版本尚未完成下载和签名校验");
} }
@@ -369,7 +380,8 @@ public class SystemUpdateApplicationService {
"已有系统更新请求等待执行"); "已有系统更新请求等待执行");
} }
writeRequest(requestFile, version, reason, action); 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); return new QueueTransition(currentStatus, queued);
} catch (IOException exception) { } catch (IOException exception) {
throw storage("系统更新队列锁定失败"); throw storage("系统更新队列锁定失败");
@@ -431,7 +443,8 @@ public class SystemUpdateApplicationService {
blank(root.path("targetVersion").asText(null)), parseInstant(root.path("updatedAt").asText(null)), blank(root.path("targetVersion").asText(null)), parseInstant(root.path("updatedAt").asText(null)),
nonNegativeLong(root, "downloadedBytes"), nonNegativeLong(root, "totalBytes"), nonNegativeLong(root, "downloadedBytes"), nonNegativeLong(root, "totalBytes"),
nonNegativeLong(root, "bytesPerSecond"), boundedInteger(root, "downloadPercent", 0, 100), nonNegativeLong(root, "bytesPerSecond"), boundedInteger(root, "downloadPercent", 0, 100),
boundedInteger(root, "restartExpectedSeconds", 0, 300)); boundedInteger(root, "restartExpectedSeconds", 0, 300),
boundedText(root, "requestId", 64), updateAction(root.path("action").asText(null)));
} catch (IOException exception) { } catch (IOException exception) {
return new UpdateStatus("UNKNOWN", "更新状态读取失败", null, null); return new UpdateStatus("UNKNOWN", "更新状态读取失败", null, null);
} }
@@ -525,10 +538,13 @@ public class SystemUpdateApplicationService {
String version = blank(root.path("version").asText(null)); String version = blank(root.path("version").asText(null));
String action = root.path("action").asText("INSTALL").toUpperCase(Locale.ROOT); String action = root.path("action").asText("INSTALL").toUpperCase(Locale.ROOT);
Instant requestedAt = parseInstant(root.path("requestedAt").asText(null)); Instant requestedAt = parseInstant(root.path("requestedAt").asText(null));
String requestId = boundedText(root, "requestId", 64);
if ("DOWNLOAD".equals(action)) { 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) { } catch (IOException exception) {
return new UpdateStatus("UNKNOWN", "更新请求读取失败", null, null); return new UpdateStatus("UNKNOWN", "更新请求读取失败", null, null);
} }
@@ -631,6 +647,39 @@ public class SystemUpdateApplicationService {
return parsed < minimum || parsed > maximum ? null : parsed; return parsed < minimum || parsed > maximum ? null : parsed;
} }
private static String boundedText(JsonNode root, String field, int maximum) {
JsonNode value = root.path(field);
if (!value.isTextual()) return null;
String parsed = blank(value.asText(null));
return parsed != null && parsed.length() <= maximum ? parsed : null;
}
private static String updateAction(String value) {
String action = blank(value);
if (action == null) return null;
action = action.toUpperCase(Locale.ROOT);
return "DOWNLOAD".equals(action) || "INSTALL".equals(action) ? action : null;
}
private void persistTerminalAudit(UpdateStatus status) {
if (status == null || status.updatedAt() == null || status.targetVersion() == null
|| !("SUCCEEDED".equals(status.state()) || "FAILED".equals(status.state())
|| "RECOVERY_REQUIRED".equals(status.state()))) {
return;
}
String fingerprint = String.join("|", status.state(), String.valueOf(status.requestId()),
status.targetVersion(), status.updatedAt().toString(), String.valueOf(status.action()));
if (fingerprint.equals(lastTerminalAuditFingerprint)) return;
try {
String persistedKey = auditService.recordSystemUpdateTerminal(status.requestId(), status.action(),
status.state(), status.targetVersion(), status.message(), status.updatedAt());
if (persistedKey != null) lastTerminalAuditFingerprint = fingerprint;
} catch (RuntimeException exception) {
log.warn("系统更新终态审计写入失败:state={}, targetVersion={}", status.state(),
status.targetVersion(), exception);
}
}
private BusinessException validation(String message) { private BusinessException validation(String message) {
return new BusinessException(HttpStatus.UNPROCESSABLE_ENTITY, ErrorCode.VALIDATION_FAILED, message); return new BusinessException(HttpStatus.UNPROCESSABLE_ENTITY, ErrorCode.VALIDATION_FAILED, message);
} }
@@ -649,9 +698,15 @@ public class SystemUpdateApplicationService {
private record UpdateStatus(String state, String message, String targetVersion, Instant updatedAt, private record UpdateStatus(String state, String message, String targetVersion, Instant updatedAt,
Long downloadedBytes, Long totalBytes, Long bytesPerSecond, Long downloadedBytes, Long totalBytes, Long bytesPerSecond,
Integer downloadPercent, Integer restartExpectedSeconds) { Integer downloadPercent, Integer restartExpectedSeconds,
String requestId, String action) {
private UpdateStatus(String state, String message, String targetVersion, Instant updatedAt) { private UpdateStatus(String state, String message, String targetVersion, Instant updatedAt) {
this(state, message, targetVersion, updatedAt, null, null, null, null, null); this(state, message, targetVersion, updatedAt, null, null, null, null, null, null, null);
}
private UpdateStatus(String state, String message, String targetVersion, Instant updatedAt,
String requestId, String action) {
this(state, message, targetVersion, updatedAt, null, null, null, null, null, requestId, action);
} }
} }
@@ -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,83 @@
package com.kaidi.finance.shared.audit;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.kaidi.finance.shared.id.UlidGenerator;
import java.time.Instant;
import java.time.LocalDateTime;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.springframework.mock.web.MockHttpServletRequest;
class AuditServiceTest {
@Test
void terminalUpdateInheritsTheOriginalActorAndProducesAStableDedupeKey() {
AuditMapper mapper = mock(AuditMapper.class);
UlidGenerator ulids = mock(UlidGenerator.class);
when(ulids.next()).thenReturn("01M00000000000000000000999");
when(mapper.findSystemUpdateSourceByRequestId(
"01M00000000000000000000092", "SYSTEM_UPDATE_REQUEST"))
.thenReturn(new AuditMapper.SystemUpdateAuditSource(
"01M00000000000000000000092", "01M00000000000000000000001", "admin", "SYSTEM_ADMIN",
null, null, "{\"state\":\"INSTALL_QUEUED\",\"targetVersion\":\"1.0.0-preview.44\"}",
"127.0.0.1", "fixture-agent"));
when(mapper.insertSystemUpdateTerminal(any(), anyString(), any())).thenReturn(1);
AuditService service = new AuditService(mapper, ulids, new ObjectMapper().findAndRegisterModules(),
new MockHttpServletRequest("GET", "/api/v1/admin/system-update"));
Instant completedAt = Instant.parse("2026-08-19T00:10:00Z");
String firstKey = service.recordSystemUpdateTerminal("01M00000000000000000000092", "INSTALL",
"SUCCEEDED", "1.0.0-preview.44", "新版本已通过健康检查", completedAt);
String secondKey = service.recordSystemUpdateTerminal("01M00000000000000000000092", "INSTALL",
"SUCCEEDED", "1.0.0-preview.44", "新版本已通过健康检查", completedAt);
assertEquals(firstKey, secondKey);
assertTrue(firstKey.startsWith("SYSUPD:"));
ArgumentCaptor<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"));
}
@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.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock; 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 static org.mockito.Mockito.when;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
@@ -286,6 +288,64 @@ class SystemUpdateApplicationServiceTest {
} }
} }
@Test
void persistsCorrelatedTerminalUpdateAuditOnlyOncePerApplicationProcess() throws Exception {
Path inbox = Files.createDirectory(tempDir.resolve("terminal-inbox"));
Path statusFile = tempDir.resolve("terminal-status.json");
Files.writeString(statusFile, """
{"state":"SUCCEEDED","message":"新版本已通过健康检查","targetVersion":"1.0.0-preview.44",
"updatedAt":"2026-08-19T00:10:00Z","requestId":"01M00000000000000000000092",
"action":"INSTALL"}
""");
AuditService auditService = mock(AuditService.class);
when(auditService.recordSystemUpdateTerminal("01M00000000000000000000092", "INSTALL", "SUCCEEDED",
"1.0.0-preview.44", "新版本已通过健康检查", java.time.Instant.parse("2026-08-19T00:10:00Z")))
.thenReturn("SYSUPD:terminal");
SystemUpdateApplicationService service = serviceForStatus(inbox, statusFile, auditService);
assertEquals("SUCCEEDED", service.status().state());
assertEquals("SUCCEEDED", service.status().state());
verify(auditService, times(1)).recordSystemUpdateTerminal("01M00000000000000000000092", "INSTALL",
"SUCCEEDED", "1.0.0-preview.44", "新版本已通过健康检查",
java.time.Instant.parse("2026-08-19T00:10:00Z"));
}
@Test
void persistsLegacyTerminalStatusWithoutRequestCorrelation() throws Exception {
Path inbox = Files.createDirectory(tempDir.resolve("legacy-terminal-inbox"));
Path statusFile = tempDir.resolve("legacy-terminal-status.json");
Files.writeString(statusFile, """
{"state":"SUCCEEDED","message":"Release 1.0.0-preview.43 is running",
"targetVersion":"1.0.0-preview.43","updatedAt":"2026-08-18T23:50:00Z"}
""");
AuditService auditService = mock(AuditService.class);
when(auditService.recordSystemUpdateTerminal(null, null, "SUCCEEDED", "1.0.0-preview.43",
"Release 1.0.0-preview.43 is running", java.time.Instant.parse("2026-08-18T23:50:00Z")))
.thenReturn("SYSUPD:legacy");
SystemUpdateApplicationService service = serviceForStatus(inbox, statusFile, auditService);
assertEquals("SUCCEEDED", service.status().state());
verify(auditService).recordSystemUpdateTerminal(null, null, "SUCCEEDED", "1.0.0-preview.43",
"Release 1.0.0-preview.43 is running", java.time.Instant.parse("2026-08-18T23:50:00Z"));
}
private SystemUpdateApplicationService serviceForStatus(Path inbox, Path statusFile, AuditService auditService) {
SystemUpdateProperties properties = new SystemUpdateProperties(true, "1.0.0-preview.43", null,
"https://release.fixture.invalid/", null, null, inbox.resolve("request.json"), statusFile,
Duration.ofSeconds(2), Duration.ofSeconds(2), true);
AuthorizationService authorization = mock(AuthorizationService.class);
when(authorization.hasPermission(any())).thenReturn(true);
IdentityContext identity = mock(IdentityContext.class);
when(identity.requireActiveRole()).thenReturn("SYSTEM_ADMIN");
when(identity.requirePrincipal()).thenReturn(new FinancePrincipal(1, "01M00000000000000000000001",
"admin", "系统管理员", "信息中心", false,
List.of(new RoleAssignment(1, "SYSTEM_ADMIN", "系统管理员", "系统治理"))));
return new SystemUpdateApplicationService(properties, authorization, identity, auditService,
new ObjectMapper());
}
@Test @Test
void checksConfiguredManifestAndQueuesOnlyItsLatestVersion() throws Exception { void checksConfiguredManifestAndQueuesOnlyItsLatestVersion() throws Exception {
HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
+1 -1
View File
@@ -15,7 +15,7 @@ FILE_SCANNER_ENABLED=true
FINANCE_BOOTSTRAP_ENABLED=false FINANCE_BOOTSTRAP_ENABLED=false
FINANCE_BOOTSTRAP_PASSWORD= FINANCE_BOOTSTRAP_PASSWORD=
APP_VERSION=1.0.0-preview.42 APP_VERSION=1.0.0-preview.46
UPDATE_CURRENT_VERSION_FILE=/opt/kaidi/current/VERSION UPDATE_CURRENT_VERSION_FILE=/opt/kaidi/current/VERSION
FINANCE_UPDATE_ENABLED=true FINANCE_UPDATE_ENABLED=true
# Use either a stable direct asset base URL or the public Gitea latest-release API. # Use either a stable direct asset base URL or the public Gitea latest-release API.
+24
View File
@@ -41,6 +41,8 @@ WORK_DIR=
CACHE_TEMP= CACHE_TEMP=
RELEASE_AUTH_HEADER_FILE= RELEASE_AUTH_HEADER_FILE=
TARGET_VERSION= TARGET_VERSION=
REQUEST_ID=
REQUEST_ACTION=
TERMINAL_STATUS_WRITTEN=false TERMINAL_STATUS_WRITTEN=false
DOWNLOAD_PID= DOWNLOAD_PID=
DOWNLOAD_PGID= DOWNLOAD_PGID=
@@ -293,10 +295,26 @@ status() {
restart_expected_seconds=${8:-} restart_expected_seconds=${8:-}
previous_state= previous_state=
previous_message= previous_message=
previous_request_id=
previous_action=
if [ -f "$STATUS_FILE" ] && [ ! -L "$STATUS_FILE" ]; then if [ -f "$STATUS_FILE" ] && [ ! -L "$STATUS_FILE" ]; then
previous_state=$(jq -r '.state // empty' "$STATUS_FILE" 2>/dev/null || true) 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_message=$(jq -r '.message // empty' "$STATUS_FILE" 2>/dev/null || true)
previous_request_id=$(jq -r '.requestId // empty | strings | select(length <= 64)' \
"$STATUS_FILE" 2>/dev/null || true)
previous_action=$(jq -r '.action // empty | strings | ascii_upcase \
| select(. == "DOWNLOAD" or . == "INSTALL")' "$STATUS_FILE" 2>/dev/null || true)
fi fi
status_request_id=
status_action=
if [ -f "$PROCESSING_FILE" ] && [ ! -L "$PROCESSING_FILE" ]; then
status_request_id=$(jq -r '.requestId // empty | strings | select(length > 0 and length <= 64)' \
"$PROCESSING_FILE" 2>/dev/null || true)
status_action=$(jq -r '.action // empty | strings | ascii_upcase \
| select(. == "DOWNLOAD" or . == "INSTALL")' "$PROCESSING_FILE" 2>/dev/null || true)
fi
[ -n "$status_request_id" ] || status_request_id=${REQUEST_ID:-$previous_request_id}
[ -n "$status_action" ] || status_action=${REQUEST_ACTION:-$previous_action}
tmp="$STATUS_FILE.tmp.$$" tmp="$STATUS_FILE.tmp.$$"
jq -n \ jq -n \
--arg state "$state" \ --arg state "$state" \
@@ -307,9 +325,13 @@ status() {
--arg bytesPerSecond "$bytes_per_second" \ --arg bytesPerSecond "$bytes_per_second" \
--arg downloadPercent "$download_percent" \ --arg downloadPercent "$download_percent" \
--arg restartExpectedSeconds "$restart_expected_seconds" \ --arg restartExpectedSeconds "$restart_expected_seconds" \
--arg requestId "$status_request_id" \
--arg action "$status_action" \
--arg updatedAt "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ --arg updatedAt "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
'{state:$state,message:$message,updatedAt:$updatedAt} '{state:$state,message:$message,updatedAt:$updatedAt}
+ (if ($version | length) > 0 then {targetVersion:$version} else {} end) + (if ($version | length) > 0 then {targetVersion:$version} else {} end)
+ (if ($requestId | length) > 0 then {requestId:$requestId} else {} end)
+ (if ($action == "DOWNLOAD" or $action == "INSTALL") then {action:$action} else {} end)
+ (if ($downloadedBytes | test("^[0-9]+$")) then {downloadedBytes:($downloadedBytes | tonumber)} else {} end) + (if ($downloadedBytes | test("^[0-9]+$")) then {downloadedBytes:($downloadedBytes | tonumber)} else {} end)
+ (if ($totalBytes | test("^[0-9]+$")) then {totalBytes:($totalBytes | 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) + (if ($bytesPerSecond | test("^[0-9]+$")) then {bytesPerSecond:($bytesPerSecond | tonumber)} else {} end)
@@ -1035,6 +1057,8 @@ TARGET_VERSION=$REQUESTED_VERSION
REQUEST_ACTION=$(jq -er '(.action // "INSTALL") | strings | ascii_upcase REQUEST_ACTION=$(jq -er '(.action // "INSTALL") | strings | ascii_upcase
| select(. == "DOWNLOAD" or . == "INSTALL")' "$PROCESSING_FILE") \ | select(. == "DOWNLOAD" or . == "INSTALL")' "$PROCESSING_FILE") \
|| fail "Update request action is invalid" || 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 prepare_update_layout
reset_event_log reset_event_log
load_runtime_database_env load_runtime_database_env
+38 -4
View File
@@ -1,7 +1,13 @@
import type { Page, Route } from '@playwright/test'; import type { Page, Route } from '@playwright/test';
import { expect, test } 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) { function envelope(data: unknown) {
return { data, requestId: '01M00000000000000000000090' }; return { data, requestId: '01M00000000000000000000090' };
@@ -111,8 +117,13 @@ async function installFixture(
let state = recoveryPending ? 'RECOVERY_REQUIRED' : initialState || 'IDLE'; let state = recoveryPending ? 'RECOVERY_REQUIRED' : initialState || 'IDLE';
let progressDeadline = 0; let progressDeadline = 0;
let restartDeadline = 0; let restartDeadline = 0;
let terminalHistoryRecorded = false;
const history: Array<Record<string, unknown>> = []; 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({ history.unshift({
publicId: `01M00000000000000000000${String(history.length + 1).padStart(3, '0')}`, publicId: `01M00000000000000000000${String(history.length + 1).padStart(3, '0')}`,
eventSequence: history.length + 1, eventSequence: history.length + 1,
@@ -123,9 +134,13 @@ async function installFixture(
objectType: 'SYSTEM_UPDATE', objectType: 'SYSTEM_UPDATE',
objectPublicId: 'SYSTEM_UPDATE', objectPublicId: 'SYSTEM_UPDATE',
resultCode: 'SUCCESS', resultCode: 'SUCCESS',
reason: actionCode === 'SYSTEM_UPDATE_REQUEST' ? '管理员确认立即更新并重启' : null, reason,
beforeJson: null, beforeJson: null,
afterJson: JSON.stringify({ targetVersion }), afterJson: JSON.stringify({
targetVersion,
...(actionCode === 'SYSTEM_UPDATE_CHECK' ? { releaseNotes: 'Preview update' } : {}),
...(actionCode === 'SYSTEM_UPDATE_SUCCEEDED' ? { state: 'SUCCEEDED' } : {}),
}),
occurredAt: '2026-08-16T00:02:00Z', occurredAt: '2026-08-16T00:02:00Z',
allowedActions: [], allowedActions: [],
}); });
@@ -156,6 +171,10 @@ async function installFixture(
return route.abort('connectionrefused'); return route.abort('connectionrefused');
} }
if (state === 'RUNNING' && restartDeadline > 0) state = 'SUCCEEDED'; 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); const response = updateView(state, checked, enabled, recoveryPending);
if (state === 'DOWNLOADING' && Date.now() >= progressDeadline) state = 'READY'; if (state === 'DOWNLOADING' && Date.now() >= progressDeadline) state = 'READY';
if (completeBusyAfterFirstRead && state === 'INSTALLING') state = 'SUCCEEDED'; if (completeBusyAfterFirstRead && state === 'INSTALLING') state = 'SUCCEEDED';
@@ -192,6 +211,9 @@ async function installFixture(
return route.fulfill({ json: envelope(queued) }); return route.fulfill({ json: envelope(queued) });
} }
if (pathname === '/api/v1/audit/logs' && request.method() === 'GET') { if (pathname === '/api/v1/audit/logs' && request.method() === 'GET') {
const query = new URL(request.url()).searchParams;
expect(query.get('occurredFrom')).toBe('1970-01-01T00:00:00Z');
expect(query.get('objectType')).toBe('SYSTEM_UPDATE');
return route.fulfill({ return route.fulfill({
json: { json: {
data: history, data: history,
@@ -393,6 +415,18 @@ test('successful installation starts the ten-second automatic refresh countdown'
await expect(page.getByText('新版本已通过健康检查,页面将在 10 秒后自动刷新。', { exact: true })).toBeVisible({ await expect(page.getByText('新版本已通过健康检查,页面将在 10 秒后自动刷新。', { exact: true })).toBeVisible({
timeout: 5_000, 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('下载更新包');
await expect(historyDialog).toContainText('立即更新并重启');
await expect(historyDialog).toContainText('更新完成');
}); });
test('reloading during installation resumes polling and starts the refresh countdown', async ({ page }) => { test('reloading during installation resumes polling and starts the refresh countdown', async ({ page }) => {
@@ -2,14 +2,18 @@
<div class="system-update-page"> <div class="system-update-page">
<h1 class="page-title-sr-only">系统更新</h1> <h1 class="page-title-sr-only">系统更新</h1>
<header class="page-header page-header--actions-only" aria-label="页面操作"> <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"> <t-button variant="outline" :loading="statusLoading || historyLoading" @click="refreshPage">
<template #icon><t-icon name="refresh" /></template> <template #icon><t-icon name="refresh" /></template>
刷新状态 刷新状态
</t-button> </t-button>
</header> </header>
<section class="update-panel" aria-label="系统版本与更新操作">
<div class="update-heading"> <div class="update-heading">
<div class="version-grid"> <div class="version-grid">
<div class="version-item"> <div class="version-item">
@@ -107,9 +111,9 @@
<div class="section-heading"> <div class="section-heading">
<div> <div>
<h2>历史更新记录</h2> <h2>历史更新记录</h2>
<p>记录版本获取、下载和重启安装操作。</p> <p>按版本汇总获取、下载、重启安装和最终执行结果。</p>
</div> </div>
<span>最近 {{ historyRows.length }} 条</span> <span>最近 {{ historyTimeline.length }} 个版本 · {{ historyRows.length }} 条记录</span>
</div> </div>
<t-alert v-if="historyError" theme="error" :close-btn="false"> <t-alert v-if="historyError" theme="error" :close-btn="false">
@@ -118,23 +122,65 @@
<t-link theme="primary" @click="() => loadHistory()">重新加载</t-link> <t-link theme="primary" @click="() => loadHistory()">重新加载</t-link>
</div> </div>
</t-alert> </t-alert>
<t-table <div v-if="historyLoading" class="history-loading" aria-live="polite">
v-if="historyLoading || historyRows.length" <t-loading text="正在加载历史更新记录" />
:columns="historyColumns" </div>
:data="historyRows" <div v-else-if="historyTimeline.length" class="update-timeline" aria-label="按版本排列的更新时间线">
row-key="publicId" <article
:loading="historyLoading" v-for="item in historyTimeline"
table-layout="fixed" :key="item.key"
:horizontal-scroll-affixed-bottom="true" class="update-timeline__item"
:data-version="item.version"
> >
<template #occurredAt="{ row }">{{ formatDateTime(row.occurredAt) }}</template> <div class="update-timeline__rail" aria-hidden="true">
<template #actionCode="{ row }">{{ actionLabel(row.actionCode) }}</template> <span class="update-timeline__marker" :class="`update-timeline__marker--${resultTheme(item.resultCode)}`">
<template #version="{ row }">{{ historyVersion(row) }}</template> <t-icon :name="item.resultCode === 'SUCCESS' ? 'check' : 'error-circle'" />
<template #resultCode="{ row }"> </span>
<t-tag :theme="resultTheme(row.resultCode)" variant="light">{{ resultLabel(row.resultCode) }}</t-tag> </div>
</template> <div class="timeline-card">
<template #reason="{ row }">{{ row.reason || '-' }}</template> <div class="timeline-card__header">
</t-table> <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__summary">
<span class="timeline-card__label">执行流程</span>
<span>{{ item.operations.join(' · ') }}</span>
<span class="timeline-card__count">{{ item.steps.length }} 条记录</span>
</div>
<p class="timeline-card__reason">{{ item.reason || '已记录本次版本更新操作。' }}</p>
<div v-if="item.releaseNotes" class="timeline-card__notes">
<span>版本说明</span>
<p>{{ item.releaseNotes }}</p>
</div>
<div class="timeline-steps" aria-label="更新步骤">
<div v-for="step in item.steps" :key="step.row.publicId" class="timeline-step">
<span class="timeline-step__dot" :class="`timeline-step__dot--${resultTheme(step.row.resultCode)}`" />
<div class="timeline-step__body">
<div class="timeline-step__heading">
<strong>{{ actionLabel(step.row.actionCode) }}</strong>
<t-tag size="small" :theme="resultTheme(step.row.resultCode)" variant="light">
{{ resultLabel(step.row.resultCode) }}
</t-tag>
<time>{{ formatDateTime(step.row.occurredAt) }}</time>
</div>
<p>{{ step.row.reason || '操作已记录' }} · {{ step.row.username || '系统' }}</p>
</div>
</div>
</div>
<div class="timeline-card__footer">
<span>最后更新:{{ formatDateTime(item.occurredAt) }}</span>
<t-button variant="text" size="small" @click="openHistoryDetail(item)">查看完整记录</t-button>
</div>
</div>
</article>
</div>
<t-empty v-else description="暂无系统更新记录" /> <t-empty v-else description="暂无系统更新记录" />
</section> </section>
@@ -219,11 +265,47 @@
</div> </div>
</div> </div>
</t-dialog> </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>
<p class="history-detail__reason">{{ selectedHistory.reason || '已记录本次版本更新操作。' }}</p>
<div v-if="selectedHistory.releaseNotes" class="history-detail__notes">
<span>版本说明</span>
<p>{{ selectedHistory.releaseNotes }}</p>
</div>
<div class="history-detail__steps" aria-label="更新日志">
<div v-for="step in selectedHistory.steps" :key="step.row.publicId" class="history-detail__step">
<div>
<strong>{{ actionLabel(step.row.actionCode) }}</strong>
<t-tag size="small" :theme="resultTheme(step.row.resultCode)" variant="light">
{{ resultLabel(step.row.resultCode) }}
</t-tag>
</div>
<time>{{ formatDateTime(step.row.occurredAt) }} · {{ step.row.username || '系统' }}</time>
<p>{{ step.row.reason || '操作已记录' }}</p>
</div>
</div>
</div>
</t-dialog>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { isAxiosError } from 'axios'; import { isAxiosError } from 'axios';
import type { PrimaryTableCol } from 'tdesign-vue-next';
import { MessagePlugin } from 'tdesign-vue-next'; import { MessagePlugin } from 'tdesign-vue-next';
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'; import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
@@ -240,6 +322,22 @@ import {
defineOptions({ name: 'SystemUpdatePage' }); defineOptions({ name: 'SystemUpdatePage' });
type TagTheme = 'default' | 'primary' | 'success' | 'warning' | 'danger'; type TagTheme = 'default' | 'primary' | 'success' | 'warning' | 'danger';
interface HistoryTimelineStep {
row: AuditLog;
}
interface HistoryTimelineItem {
key: string;
version: string;
occurredAt: string;
username: string;
resultCode: string;
reason: string;
operations: string[];
releaseNotes: string;
steps: HistoryTimelineStep[];
}
const UPDATE_PENDING_KEY = 'kaidi-system-update-pending'; const UPDATE_PENDING_KEY = 'kaidi-system-update-pending';
const updateStatus = ref<SystemUpdateView | null>(null); const updateStatus = ref<SystemUpdateView | null>(null);
@@ -255,6 +353,8 @@ const historyError = ref('');
const updateRefreshSeconds = ref<number | null>(null); const updateRefreshSeconds = ref<number | null>(null);
const restartCountdown = ref<number | null>(null); const restartCountdown = ref<number | null>(null);
const updateDialogVisible = ref(false); const updateDialogVisible = ref(false);
const historyDetailVisible = ref(false);
const selectedHistory = ref<HistoryTimelineItem | null>(null);
const connectionInterrupted = ref(false); const connectionInterrupted = ref(false);
const localEvents = ref<SystemUpdateEvent[]>([]); const localEvents = ref<SystemUpdateEvent[]>([]);
const awaitingRefresh = ref(false); const awaitingRefresh = ref(false);
@@ -383,14 +483,39 @@ const updateStateTheme = computed<TagTheme>(() => {
return 'default'; return 'default';
}); });
const historyColumns: PrimaryTableCol[] = [ const historyTimeline = computed<HistoryTimelineItem[]>(() => {
{ colKey: 'occurredAt', title: '操作时间', width: 180 }, const grouped = new Map<string, AuditLog[]>();
{ colKey: 'version', title: '版本', width: 170, ellipsis: true }, for (const row of historyRows.value) {
{ colKey: 'actionCode', title: '操作', width: 150 }, const version = historyVersion(row);
{ colKey: 'username', title: '操作人', width: 130, ellipsis: true }, const key = version === '-' ? `unknown:${row.requestId || row.publicId}` : `version:${version}`;
{ colKey: 'resultCode', title: '结果', width: 100 }, const rows = grouped.get(key) || [];
{ colKey: 'reason', title: '说明', minWidth: 220, ellipsis: true }, 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 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) || '';
return {
key,
version: representative ? historyVersion(representative) : '-',
occurredAt: representative?.occurredAt || '',
username: representative?.username || '',
resultCode: representative?.resultCode || 'UNKNOWN',
reason: representative?.reason || '',
operations,
releaseNotes,
steps: chronological.map((row) => ({ row })),
};
})
.sort((left, right) => compareHistoryDates(right.occurredAt, left.occurredAt));
});
async function loadStatus(silent = false) { async function loadStatus(silent = false) {
if (!silent) { if (!silent) {
@@ -442,6 +567,7 @@ async function loadHistory(silent = false) {
} }
try { try {
const result = await getAuditLogs({ const result = await getAuditLogs({
occurredFrom: '1970-01-01T00:00:00Z',
objectType: 'SYSTEM_UPDATE', objectType: 'SYSTEM_UPDATE',
sort: 'occurredAt,desc', sort: 'occurredAt,desc',
page: 1, page: 1,
@@ -700,26 +826,58 @@ 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) { function historyVersion(row: AuditLog) {
for (const source of [row.afterJson, row.beforeJson]) { for (const source of [row.afterJson, row.beforeJson]) {
if (!source) continue; const parsed = parseHistoryPayload(source);
try { const version = parsed?.targetVersion || parsed?.latestVersion || parsed?.currentVersion || parsed?.version;
const parsed = JSON.parse(source) as Record<string, unknown>; if (version !== undefined && version !== null && String(version).trim()) return String(version);
const version = parsed.targetVersion || parsed.latestVersion || parsed.currentVersion || parsed.version;
if (version) return String(version);
} catch {
continue;
}
} }
return '-'; return '-';
} }
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;
}
return '';
}
function openHistoryDetail(item: HistoryTimelineItem) {
selectedHistory.value = item;
historyDetailVisible.value = true;
}
function actionLabel(action: string) { function actionLabel(action: string) {
return ( return (
{ {
SYSTEM_UPDATE_CHECK: '获取版本', SYSTEM_UPDATE_CHECK: '获取版本',
SYSTEM_UPDATE_DOWNLOAD_REQUEST: '下载更新包', SYSTEM_UPDATE_DOWNLOAD_REQUEST: '下载更新包',
SYSTEM_UPDATE_REQUEST: '立即更新并重启', SYSTEM_UPDATE_REQUEST: '立即更新并重启',
SYSTEM_UPDATE_SUCCEEDED: '更新完成',
SYSTEM_UPDATE_FAILED: '更新失败',
SYSTEM_UPDATE_RECOVERY_REQUIRED: '更新需人工恢复',
}[action] || action }[action] || action
); );
} }
@@ -782,7 +940,7 @@ onBeforeUnmount(() => {
min-width: 0; min-width: 0;
} }
.page-header, .update-toolbar,
.update-heading, .update-heading,
.update-actions, .update-actions,
.section-heading, .section-heading,
@@ -791,10 +949,6 @@ onBeforeUnmount(() => {
align-items: center; align-items: center;
} }
.page-header {
justify-content: flex-end;
}
.update-panel, .update-panel,
.history-panel { .history-panel {
width: 100%; width: 100%;
@@ -818,6 +972,31 @@ onBeforeUnmount(() => {
min-width: 0; 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 { .version-grid {
display: grid; display: grid;
grid-template-columns: repeat(3, minmax(150px, 1fr)); grid-template-columns: repeat(3, minmax(150px, 1fr));
@@ -897,6 +1076,313 @@ onBeforeUnmount(() => {
line-height: 24px; 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__summary,
.timeline-card__footer,
.timeline-step__heading,
.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__count,
.timeline-card__footer,
.timeline-step__heading time,
.history-detail__header span,
.history-detail__step time {
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;
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-steps {
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;
color: var(--td-text-color-secondary);
font-size: 12px;
line-height: 20px;
overflow-wrap: anywhere;
}
.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;
}
.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 { .alert-content {
justify-content: space-between; justify-content: space-between;
gap: 12px; gap: 12px;
@@ -1022,6 +1508,16 @@ onBeforeUnmount(() => {
gap: 12px; gap: 12px;
} }
.update-toolbar {
align-items: flex-start;
flex-direction: column;
gap: 10px;
}
.update-toolbar :deep(.t-button) {
width: 100%;
}
.update-actions, .update-actions,
.update-actions :deep(.t-button) { .update-actions :deep(.t-button) {
width: 100%; width: 100%;
@@ -1038,6 +1534,40 @@ onBeforeUnmount(() => {
gap: 8px; 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__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;
gap: 4px;
}
.download-progress__meta, .download-progress__meta,
.dialog-status-line, .dialog-status-line,
.dialog-actions { .dialog-actions {
+17 -2
View File
@@ -297,9 +297,12 @@ write_request() {
local fixture=$1 local fixture=$1
local version=$2 local version=$2
local action=$3 local action=$3
local request_id=01M00000000000000000000091
[ "$action" = INSTALL ] && request_id=01M00000000000000000000092
mkdir -p "$fixture/state/inbox" mkdir -p "$fixture/state/inbox"
jq -n --arg action "$action" --arg version "$version" \ jq -n --arg action "$action" --arg version "$version" --arg requestId "$request_id" \
'{action:$action,version:$version,reason:"fixture"}' > "$fixture/state/inbox/request.json" '{action:$action,version:$version,reason:"fixture",requestId:$requestId,
requestedAt:"2026-08-19T00:00:00Z"}' > "$fixture/state/inbox/request.json"
} }
download_and_prepare_install() { download_and_prepare_install() {
@@ -309,6 +312,9 @@ download_and_prepare_install() {
run_update "$fixture" success run_update "$fixture" success
[ "$(jq -r '.state' "$fixture/state/status.json")" = READY ] \ [ "$(jq -r '.state' "$fixture/state/status.json")" = READY ] \
|| fail 'download phase did not persist READY' || fail 'download phase did not persist READY'
[ "$(jq -r '.requestId' "$fixture/state/status.json")" = 01M00000000000000000000091 ] \
&& [ "$(jq -r '.action' "$fixture/state/status.json")" = DOWNLOAD ] \
|| fail 'download phase did not preserve request correlation'
[ "$(readlink "$fixture/app/current")" = "$fixture/app/releases/1.0.0-preview.1" ] \ [ "$(readlink "$fixture/app/current")" = "$fixture/app/releases/1.0.0-preview.1" ] \
|| fail 'download phase changed the active application' || fail 'download phase changed the active application'
[ -s "$fixture/state/cache/$version/release.tar.gz" ] \ [ -s "$fixture/state/cache/$version/release.tar.gz" ] \
@@ -447,6 +453,9 @@ assert_success_case() {
|| fail 'success case did not activate the signed updater' || fail 'success case did not activate the signed updater'
[ "$(jq -r '.state' "$fixture/state/status.json")" = SUCCEEDED ] \ [ "$(jq -r '.state' "$fixture/state/status.json")" = SUCCEEDED ] \
|| fail 'success case did not persist SUCCEEDED' || fail 'success case did not persist SUCCEEDED'
[ "$(jq -r '.requestId' "$fixture/state/status.json")" = 01M00000000000000000000092 ] \
&& [ "$(jq -r '.action' "$fixture/state/status.json")" = INSTALL ] \
|| fail 'success case did not preserve install request correlation'
[ ! -e "$fixture/state/processing/request.json" ] \ [ ! -e "$fixture/state/processing/request.json" ] \
|| fail 'success case left a claimed request behind' || fail 'success case left a claimed request behind'
grep -qx 'daemon-reload' "$fixture/systemctl.log" || fail 'systemd units were not reloaded' grep -qx 'daemon-reload' "$fixture/systemctl.log" || fail 'systemd units were not reloaded'
@@ -522,6 +531,9 @@ assert_rollback_case() {
|| fail 'rollback case did not restart both the candidate and restored releases' || fail 'rollback case did not restart both the candidate and restored releases'
[ "$(jq -r '.state' "$fixture/state/status.json")" = FAILED ] \ [ "$(jq -r '.state' "$fixture/state/status.json")" = FAILED ] \
|| fail 'rollback case did not persist 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" ] \ [ ! -e "$fixture/app/releases/$version" ] \
|| fail 'rollback case left the failed release installed' || fail 'rollback case left the failed release installed'
find "$fixture/state/failed" -type f -name 'request-*.json' -print -quit | grep -q . \ find "$fixture/state/failed" -type f -name 'request-*.json' -print -quit | grep -q . \
@@ -550,6 +562,9 @@ assert_incomplete_rollback_requires_manual_recovery_case() {
|| fail 'incomplete rollback case did not quarantine transaction evidence' || fail 'incomplete rollback case did not quarantine transaction evidence'
[ "$(jq -r '.state' "$fixture/state/status.json")" = RECOVERY_REQUIRED ] \ [ "$(jq -r '.state' "$fixture/state/status.json")" = RECOVERY_REQUIRED ] \
|| fail 'incomplete rollback case did not lock the updater for recovery' || 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 . \ find "$fixture/state/failed" -type f -name 'request-*.json' -print -quit | grep -q . \
|| fail 'incomplete rollback case did not archive its claimed request' || fail 'incomplete rollback case did not archive its claimed request'