Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cea46b494d | ||
|
|
b679381946 | ||
|
|
3480d810d4 |
@@ -59,14 +59,14 @@ PostgreSQL 18。PostgreSQL 兼容开发已冻结,不属于本次 Preview 发
|
||||
### 直接 curl 安装
|
||||
|
||||
```bash
|
||||
curl -fsSL https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.18/install.sh | sudo bash
|
||||
curl -fsSL https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.19/install.sh | sudo bash
|
||||
```
|
||||
|
||||
这条命令会提示填写 Java 应用端口,直接回车使用 `18080`;随后安装最新签名 Release,并默认进入 `/setup`
|
||||
安装向导。Java 默认只监听 `127.0.0.1:所选端口`,前端页面、API 和健康检查均由同一端口提供。无人值守安装可直接指定:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.18/install.sh \
|
||||
curl -fsSL https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.19/install.sh \
|
||||
| sudo env KAIDI_APP_PORT=19090 bash
|
||||
```
|
||||
|
||||
@@ -74,7 +74,7 @@ curl -fsSL https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-previe
|
||||
时才设置 `KAIDI_SERVER_ADDRESS=0.0.0.0`,通常应保持默认回环绑定并由本机反向代理访问。需要在执行前独立校验安装脚本时使用:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.18/install.sh -o /tmp/kaidi-install.sh
|
||||
curl -fsSL https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.19/install.sh -o /tmp/kaidi-install.sh
|
||||
printf '%s %s\n' bc9001197af843dc323907a98023064ccc4f184e000543d2086b8a6f8161dbc7 /tmp/kaidi-install.sh | sha256sum -c -
|
||||
sudo bash /tmp/kaidi-install.sh
|
||||
rm -f /tmp/kaidi-install.sh
|
||||
@@ -86,7 +86,7 @@ rm -f /tmp/kaidi-install.sh
|
||||
包装器会在 `sudo` 前校验 `deploy/install.sh` 的固定 SHA-256,再按同一公钥信任链安装最新签名 Release。
|
||||
|
||||
```bash
|
||||
git clone --branch v1.0.0-preview.18 --depth 1 https://git.awaioi.com/ERP-Team/kaidi.git kaidi-preview
|
||||
git clone --branch v1.0.0-preview.19 --depth 1 https://git.awaioi.com/ERP-Team/kaidi.git kaidi-preview
|
||||
cd kaidi-preview
|
||||
./deploy/install-from-git.sh
|
||||
```
|
||||
@@ -141,12 +141,13 @@ sudo cat /root/kaidi-first-login.txt
|
||||
32 位服务端镜像,因此 32 位主机需要预先连接一台 MySQL 8.4 数据库,之后仍然只执行一个安装命令:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.18/install.sh | sudo bash
|
||||
curl -fsSL https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.19/install.sh | sudo bash
|
||||
```
|
||||
|
||||
无论主机架构如何,安装器都不会安装 MySQL、数据库客户端或创建数据库容器。在打开向导前,需要预先创建 `kaidi_finance`,并授予安装账号该库的
|
||||
完整迁移权限。向导会连接数据库、核验 MySQL 8.4.x 版本,并实际探测建表、改表、索引、外键、存储过程、
|
||||
临时表及读写权限,全部通过后 Flyway 才会建表。
|
||||
临时表及读写权限;专用数据库不是 `utf8mb4/utf8mb4_0900_ai_ci` 时,向导会在迁移前自动统一默认字符集和
|
||||
排序规则,全部通过后 Flyway 才会建表。
|
||||
数据库管理员可在 MySQL 8.4 中按实际应用服务器地址执行以下基线 SQL:
|
||||
|
||||
```sql
|
||||
@@ -162,7 +163,7 @@ GRANT ALL PRIVILEGES ON kaidi_finance.* TO 'kaidi'@'KAIDI_SERVER_IP';
|
||||
管理员数据和运维人员新增的环境变量:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.18/install.sh \
|
||||
curl -fsSL https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.19/install.sh \
|
||||
| sudo env KAIDI_REINSTALL=true KAIDI_SETUP_WIZARD=false bash
|
||||
```
|
||||
|
||||
@@ -172,7 +173,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.18/install.sh \
|
||||
curl -fsSL https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.19/install.sh \
|
||||
| sudo env KAIDI_REINSTALL=true bash
|
||||
```
|
||||
|
||||
@@ -208,8 +209,8 @@ act_runner 提供 `ubuntu-24.04` 标签,并在 tag 发布时执行后端、前
|
||||
Preview 属性由 SemVer 版本名表达。之后推送 tag 即会构建、测试、签名并发布:
|
||||
|
||||
```bash
|
||||
git tag v1.0.0-preview.18
|
||||
git push origin v1.0.0-preview.18
|
||||
git tag v1.0.0-preview.19
|
||||
git push origin v1.0.0-preview.19
|
||||
```
|
||||
|
||||
在线更新使用独立的 TDesign 页面:隔离的系统管理员进入“系统治理 → 系统更新”。权限与配置页只管理用户、角色、数据范围、表单模板和参数版本,不配置系统名称或域名。
|
||||
@@ -261,7 +262,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.18
|
||||
./scripts/package-release.sh 1.0.0-preview.19
|
||||
KAIDI_TRUSTED_RELEASE_PUBLIC_KEY_SHA256=807c6aec1dc3f7ce494db16aa9d763c66f292033c38f328afd0390d2715a8cd9 \
|
||||
./scripts/verify-release.sh dist/release
|
||||
```
|
||||
|
||||
@@ -42,6 +42,9 @@ public class SetupService {
|
||||
|
||||
private static final Logger LOGGER = LoggerFactory.getLogger(SetupService.class);
|
||||
private static final String MYSQL_PREFIX = "jdbc:mysql://";
|
||||
private static final String MYSQL_CHARACTER_SET = "utf8mb4";
|
||||
private static final String MYSQL_COLLATION = "utf8mb4_0900_ai_ci";
|
||||
private static final String RECOVERABLE_V068_SCRIPT = "V068__source_table_column_validation_metadata.sql";
|
||||
private static final List<String> SUPPORTED_TYPES = List.of(DatabaseType.MYSQL.name());
|
||||
private static final Set<Integer> MYSQL_PERMISSION_ERROR_CODES = Set.of(1044, 1045, 1142, 1227, 1370);
|
||||
private static final long RESTART_TRIGGER_DELAY_MILLIS = 2_000L;
|
||||
@@ -81,9 +84,10 @@ public class SetupService {
|
||||
false, "PostgreSQL 连接可用,但当前 Preview 的业务迁移仅支持 MySQL 8.4");
|
||||
}
|
||||
ConnectionResult connection = testConnection(settings);
|
||||
prepareDatabaseForInstallation(settings);
|
||||
validateSchemaForInstallation(settings);
|
||||
return new SetupViews.Connection(true, settings.type().name(), connection.version(), true,
|
||||
"MySQL 8.4 连接和完整迁移权限验证通过");
|
||||
"MySQL 8.4 连接、排序规则和完整迁移权限验证通过");
|
||||
}
|
||||
|
||||
public SetupViews.Completed complete(SetupContracts.CompleteRequest request) {
|
||||
@@ -129,6 +133,7 @@ public class SetupService {
|
||||
|
||||
private void migrateAndCreateAdministrator(DatabaseSettings settings, SetupContracts.CompleteRequest request) {
|
||||
try {
|
||||
prepareDatabaseForInstallation(settings);
|
||||
validateSchemaForInstallation(settings);
|
||||
flyway(settings).migrate();
|
||||
DriverManagerDataSource dataSource = new DriverManagerDataSource(settings.jdbcUrl(), settings.username(),
|
||||
@@ -146,6 +151,105 @@ public class SetupService {
|
||||
}
|
||||
}
|
||||
|
||||
private void prepareDatabaseForInstallation(DatabaseSettings settings) {
|
||||
JdbcTemplate jdbc = new JdbcTemplate(new DriverManagerDataSource(settings.jdbcUrl(), settings.username(),
|
||||
settings.password()));
|
||||
try {
|
||||
DatabaseCollation current = databaseCollation(jdbc, settings.database());
|
||||
if (!MYSQL_CHARACTER_SET.equalsIgnoreCase(current.characterSet())
|
||||
|| !MYSQL_COLLATION.equalsIgnoreCase(current.collation())) {
|
||||
jdbc.execute("ALTER DATABASE " + quoteIdentifier(settings.database())
|
||||
+ " CHARACTER SET " + MYSQL_CHARACTER_SET + " COLLATE " + MYSQL_COLLATION);
|
||||
DatabaseCollation updated = databaseCollation(jdbc, settings.database());
|
||||
if (!MYSQL_CHARACTER_SET.equalsIgnoreCase(updated.characterSet())
|
||||
|| !MYSQL_COLLATION.equalsIgnoreCase(updated.collation())) {
|
||||
throw new SetupException(org.springframework.http.HttpStatus.UNPROCESSABLE_ENTITY,
|
||||
"DATABASE_COLLATION_UNSUPPORTED", "数据库字符集或排序规则未能统一为 "
|
||||
+ MYSQL_CHARACTER_SET + "/" + MYSQL_COLLATION);
|
||||
}
|
||||
LOGGER.info("Normalized database {} from {}/{} to {}/{}", settings.database(),
|
||||
current.characterSet(), current.collation(), updated.characterSet(), updated.collation());
|
||||
}
|
||||
recoverV068CollationFailure(jdbc, settings.database());
|
||||
} catch (SetupException exception) {
|
||||
throw exception;
|
||||
} catch (RuntimeException exception) {
|
||||
LOGGER.error("Database collation preparation failed", exception);
|
||||
SQLException sqlException = findSqlException(exception);
|
||||
String detail = sqlException == null
|
||||
? "数据库字符集和排序规则初始化失败:" + safeErrorMessage(exception)
|
||||
: "数据库字符集和排序规则初始化失败" + sqlErrorSummary(sqlException);
|
||||
throw new SetupException(org.springframework.http.HttpStatus.UNPROCESSABLE_ENTITY,
|
||||
"DATABASE_COLLATION_PREPARATION_FAILED", detail);
|
||||
}
|
||||
}
|
||||
|
||||
private DatabaseCollation databaseCollation(JdbcTemplate jdbc, String database) {
|
||||
return jdbc.queryForObject("""
|
||||
SELECT default_character_set_name, default_collation_name
|
||||
FROM information_schema.schemata
|
||||
WHERE schema_name = ?
|
||||
""", (result, row) -> new DatabaseCollation(
|
||||
result.getString("default_character_set_name"), result.getString("default_collation_name")),
|
||||
database);
|
||||
}
|
||||
|
||||
private void recoverV068CollationFailure(JdbcTemplate jdbc, String database) {
|
||||
Integer historyCount = jdbc.queryForObject("""
|
||||
SELECT COUNT(*)
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = ? AND table_name = 'flyway_schema_history'
|
||||
""", Integer.class, database);
|
||||
if (historyCount == null || historyCount == 0) {
|
||||
return;
|
||||
}
|
||||
List<FailedMigration> failures = jdbc.query("""
|
||||
SELECT installed_rank, version, script
|
||||
FROM flyway_schema_history
|
||||
WHERE success = FALSE
|
||||
ORDER BY installed_rank
|
||||
""", (result, row) -> new FailedMigration(result.getInt("installed_rank"),
|
||||
result.getString("version"), result.getString("script")));
|
||||
if (failures.size() != 1) {
|
||||
return;
|
||||
}
|
||||
FailedMigration failure = failures.get(0);
|
||||
if (!("68".equals(failure.version()) || "068".equals(failure.version()))
|
||||
|| !RECOVERABLE_V068_SCRIPT.equals(failure.script())) {
|
||||
return;
|
||||
}
|
||||
Integer predecessorCount = jdbc.queryForObject("""
|
||||
SELECT COUNT(*)
|
||||
FROM flyway_schema_history
|
||||
WHERE version IN ('67', '067') AND success = TRUE
|
||||
""", Integer.class);
|
||||
if (predecessorCount == null || predecessorCount != 1) {
|
||||
return;
|
||||
}
|
||||
Integer laterSuccessCount = jdbc.queryForObject("""
|
||||
SELECT COUNT(*)
|
||||
FROM flyway_schema_history
|
||||
WHERE installed_rank > ? AND success = TRUE
|
||||
""", Integer.class, failure.installedRank());
|
||||
if (laterSuccessCount != null && laterSuccessCount > 0) {
|
||||
return;
|
||||
}
|
||||
int deleted = jdbc.update("""
|
||||
DELETE FROM flyway_schema_history
|
||||
WHERE installed_rank = ? AND version = ? AND script = ? AND success = FALSE
|
||||
""", failure.installedRank(), failure.version(), failure.script());
|
||||
if (deleted != 1) {
|
||||
throw new SetupException(org.springframework.http.HttpStatus.CONFLICT,
|
||||
"DATABASE_MIGRATION_RECOVERY_CONFLICT", "V068 迁移恢复状态已变化,请重新测试数据库连接");
|
||||
}
|
||||
LOGGER.warn("Removed the recoverable failed {} history row after database collation normalization",
|
||||
RECOVERABLE_V068_SCRIPT);
|
||||
}
|
||||
|
||||
private String quoteIdentifier(String identifier) {
|
||||
return "`" + identifier.replace("`", "``") + "`";
|
||||
}
|
||||
|
||||
private boolean ensureCompatibleSchema(DatabaseSettings settings) {
|
||||
JdbcTemplate jdbc = new JdbcTemplate(new DriverManagerDataSource(settings.jdbcUrl(), settings.username(),
|
||||
settings.password()));
|
||||
@@ -580,6 +684,12 @@ public class SetupService {
|
||||
private record ConnectionResult(boolean successful, String version) {
|
||||
}
|
||||
|
||||
private record DatabaseCollation(String characterSet, String collation) {
|
||||
}
|
||||
|
||||
private record FailedMigration(int installedRank, String version, String script) {
|
||||
}
|
||||
|
||||
private record InstallationState(String setupTokenSha256, long administratorUserId,
|
||||
String administratorUsername) {
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.kaidi.finance.setup;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.kaidi.setup.SetupApplication;
|
||||
@@ -13,6 +14,7 @@ import java.sql.Statement;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import org.flywaydb.core.Flyway;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
@@ -60,6 +62,8 @@ class SetupApplicationIntegrationTest {
|
||||
|
||||
@Test
|
||||
void firstRunTestsDatabaseCreatesAdministratorAndLocksWizard() throws Exception {
|
||||
seedRecoverableV068CollationFailure();
|
||||
|
||||
ResponseEntity<JsonNode> initial = rest.getForEntity(url("/api/v1/setup/status"), JsonNode.class);
|
||||
assertThat(initial.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
assertThat(initial.getBody().path("data").path("required").asBoolean()).isTrue();
|
||||
@@ -77,7 +81,8 @@ class SetupApplicationIntegrationTest {
|
||||
assertThat(tested.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
assertThat(tested.getBody().path("data").path("successful").asBoolean()).isTrue();
|
||||
assertThat(tested.getBody().path("data").path("schemaReady").asBoolean()).isTrue();
|
||||
assertThat(tested.getBody().path("data").path("message").asText()).contains("完整迁移权限验证通过");
|
||||
assertThat(tested.getBody().path("data").path("message").asText())
|
||||
.contains("排序规则和完整迁移权限验证通过");
|
||||
|
||||
try (Connection connection = DriverManager.getConnection(MYSQL.getJdbcUrl(), MYSQL.getUsername(),
|
||||
MYSQL.getPassword()); Statement statement = connection.createStatement()) {
|
||||
@@ -92,6 +97,22 @@ class SetupApplicationIntegrationTest {
|
||||
result.next();
|
||||
assertThat(result.getInt(1)).isZero();
|
||||
}
|
||||
try (ResultSet result = statement.executeQuery("""
|
||||
SELECT default_collation_name
|
||||
FROM information_schema.schemata
|
||||
WHERE schema_name = DATABASE()
|
||||
""")) {
|
||||
result.next();
|
||||
assertThat(result.getString(1)).isEqualTo("utf8mb4_0900_ai_ci");
|
||||
}
|
||||
try (ResultSet result = statement.executeQuery("""
|
||||
SELECT COUNT(*)
|
||||
FROM flyway_schema_history
|
||||
WHERE success = FALSE
|
||||
""")) {
|
||||
result.next();
|
||||
assertThat(result.getInt(1)).isZero();
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, Object> complete = new LinkedHashMap<>(database);
|
||||
@@ -141,6 +162,33 @@ class SetupApplicationIntegrationTest {
|
||||
assertThat(locked.getBody().path("data").path("ready").asBoolean()).isFalse();
|
||||
}
|
||||
|
||||
private void seedRecoverableV068CollationFailure() throws Exception {
|
||||
try (Connection connection = DriverManager.getConnection(MYSQL.getJdbcUrl(), MYSQL.getUsername(),
|
||||
MYSQL.getPassword()); Statement statement = connection.createStatement()) {
|
||||
statement.execute("ALTER DATABASE `" + MYSQL.getDatabaseName()
|
||||
+ "` CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci");
|
||||
}
|
||||
assertThatThrownBy(() -> Flyway.configure()
|
||||
.dataSource(MYSQL.getJdbcUrl(), MYSQL.getUsername(), MYSQL.getPassword())
|
||||
.locations("classpath:db/migration")
|
||||
.target("68")
|
||||
.load()
|
||||
.migrate())
|
||||
.hasMessageContaining("V068__source_table_column_validation_metadata.sql");
|
||||
try (Connection connection = DriverManager.getConnection(MYSQL.getJdbcUrl(), MYSQL.getUsername(),
|
||||
MYSQL.getPassword()); Statement statement = connection.createStatement();
|
||||
ResultSet result = statement.executeQuery("""
|
||||
SELECT COUNT(*)
|
||||
FROM flyway_schema_history
|
||||
WHERE version = '68'
|
||||
AND script = 'V068__source_table_column_validation_metadata.sql'
|
||||
AND success = FALSE
|
||||
""")) {
|
||||
result.next();
|
||||
assertThat(result.getInt(1)).isEqualTo(1);
|
||||
}
|
||||
}
|
||||
|
||||
private Map<String, Object> databaseRequest(String setupCode) {
|
||||
Map<String, Object> request = new LinkedHashMap<>();
|
||||
request.put("setupCode", setupCode);
|
||||
|
||||
+49
-20
@@ -18,6 +18,7 @@ RELEASE_API_URL=${UPDATE_RELEASE_API_URL:-}
|
||||
RELEASE_TOKEN=${UPDATE_RELEASE_TOKEN:-}
|
||||
CACHE_ROOT=${KAIDI_UPDATE_CACHE_ROOT:-$STATE_ROOT/cache}
|
||||
SERVICE_NAME=${KAIDI_SERVICE_NAME:-kaidi-finance.service}
|
||||
UPDATE_PATH_NAME=${KAIDI_UPDATE_PATH_NAME:-kaidi-update.path}
|
||||
SERVICE_USER=${KAIDI_SERVICE_USER:-kaidi}
|
||||
SERVICE_GROUP=${KAIDI_SERVICE_GROUP:-kaidi}
|
||||
HEALTH_URL=${KAIDI_HEALTH_URL:-http://127.0.0.1:18080/actuator/health}
|
||||
@@ -272,6 +273,22 @@ atomic_install() {
|
||||
mv -f "$install_temporary" "$install_destination"
|
||||
}
|
||||
|
||||
install_operation() {
|
||||
operation_source=$1
|
||||
operation_destination=$2
|
||||
operation_mode=$3
|
||||
operation_is_systemd=$4
|
||||
if [ -f "$operation_destination" ] && [ ! -L "$operation_destination" ] \
|
||||
&& cmp -s "$operation_source" "$operation_destination"; then
|
||||
chmod "$operation_mode" "$operation_destination"
|
||||
return
|
||||
fi
|
||||
atomic_install "$operation_source" "$operation_destination" "$operation_mode" || return 1
|
||||
if [ "$operation_is_systemd" = true ]; then
|
||||
SYSTEMD_OPERATIONS_CHANGED=true
|
||||
fi
|
||||
}
|
||||
|
||||
backup_managed_file() {
|
||||
backup_source=$1
|
||||
backup_name=$2
|
||||
@@ -311,14 +328,16 @@ backup_operations() {
|
||||
}
|
||||
|
||||
apply_operations() {
|
||||
atomic_install "$RELEASE_DIR/ops/update.sh" "$UPDATER_PATH" 0755 \
|
||||
&& atomic_install "$RELEASE_DIR/ops/kaidi-finance.service" \
|
||||
"$SYSTEMD_ROOT/kaidi-finance.service" 0644 \
|
||||
&& atomic_install "$RELEASE_DIR/ops/kaidi-update.service" \
|
||||
"$SYSTEMD_ROOT/kaidi-update.service" 0644 \
|
||||
&& atomic_install "$RELEASE_DIR/ops/kaidi-update.path" \
|
||||
"$SYSTEMD_ROOT/kaidi-update.path" 0644 \
|
||||
&& systemctl daemon-reload
|
||||
SYSTEMD_OPERATIONS_CHANGED=false
|
||||
install_operation "$RELEASE_DIR/ops/update.sh" "$UPDATER_PATH" 0755 false \
|
||||
&& install_operation "$RELEASE_DIR/ops/kaidi-finance.service" \
|
||||
"$SYSTEMD_ROOT/kaidi-finance.service" 0644 true \
|
||||
&& install_operation "$RELEASE_DIR/ops/kaidi-update.service" \
|
||||
"$SYSTEMD_ROOT/kaidi-update.service" 0644 true \
|
||||
&& install_operation "$RELEASE_DIR/ops/kaidi-update.path" \
|
||||
"$SYSTEMD_ROOT/kaidi-update.path" 0644 true \
|
||||
|| return 1
|
||||
[ "$SYSTEMD_OPERATIONS_CHANGED" = false ] || systemctl daemon-reload
|
||||
}
|
||||
|
||||
restore_operations() {
|
||||
@@ -357,24 +376,28 @@ rollback_active_transaction() {
|
||||
previous_target=$(transaction_value previous-target)
|
||||
failed_release=$(transaction_value release-dir)
|
||||
OPS_BACKUP="$ACTIVE_TRANSACTION/operations-backup"
|
||||
rollback_ok=true
|
||||
link_restored=true
|
||||
operations_restored=true
|
||||
|
||||
systemctl stop "$SERVICE_NAME" || true
|
||||
if [ -n "$previous_target" ] && [ -d "$previous_target" ]; then
|
||||
if ! ln -sfn "$previous_target" "$APP_ROOT/current.next" \
|
||||
|| ! mv -Tf "$APP_ROOT/current.next" "$APP_ROOT/current"; then
|
||||
rollback_ok=false
|
||||
link_restored=false
|
||||
fi
|
||||
else
|
||||
rollback_ok=false
|
||||
link_restored=false
|
||||
fi
|
||||
restore_operations || rollback_ok=false
|
||||
restore_operations || operations_restored=false
|
||||
systemctl reset-failed "$SERVICE_NAME" >/dev/null 2>&1 || true
|
||||
systemctl start "$SERVICE_NAME" || rollback_ok=false
|
||||
if [ "$rollback_ok" = true ] && verify_app_surface; then
|
||||
systemctl start "$SERVICE_NAME" || true
|
||||
if [ "$link_restored" = true ] && verify_app_surface; then
|
||||
remove_failed_release "$failed_release"
|
||||
rm -rf "$ACTIVE_TRANSACTION"
|
||||
fail "$reason; previous release was restored and verified"
|
||||
if [ "$operations_restored" = true ]; then
|
||||
fail "$reason; previous release was restored and verified"
|
||||
fi
|
||||
fail "$reason; previous release is running, but operations restoration requires manual review"
|
||||
fi
|
||||
TERMINAL_STATUS_WRITTEN=true
|
||||
status FAILED "$reason; rollback is incomplete and will be retried" "${TARGET_VERSION:-}"
|
||||
@@ -600,15 +623,21 @@ write_transaction_value phase APP_SWITCHED
|
||||
status RUNNING "Starting and verifying release $TARGET_VERSION" "$TARGET_VERSION"
|
||||
write_transaction_value phase HEALTH_CHECKING
|
||||
systemctl reset-failed "$SERVICE_NAME" >/dev/null 2>&1 || true
|
||||
if systemctl start "$SERVICE_NAME" \
|
||||
&& systemctl is-active --quiet kaidi-update.path \
|
||||
&& verify_app_surface; then
|
||||
systemctl start "$SERVICE_NAME" || true
|
||||
if verify_app_surface; then
|
||||
success_message="Release $TARGET_VERSION is running"
|
||||
if ! systemctl start "$UPDATE_PATH_NAME" >/dev/null 2>&1; then
|
||||
success_message="$success_message; automatic update watcher could not be started"
|
||||
fi
|
||||
write_transaction_value phase COMMITTED
|
||||
status SUCCEEDED "Release $TARGET_VERSION is running" "$TARGET_VERSION"
|
||||
status SUCCEEDED "$success_message" "$TARGET_VERSION"
|
||||
TERMINAL_STATUS_WRITTEN=true
|
||||
complete_request
|
||||
rm -rf "$ACTIVE_TRANSACTION"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
rollback_active_transaction "Release health or application-surface verification failed"
|
||||
service_state=$(systemctl is-active "$SERVICE_NAME" 2>/dev/null || true)
|
||||
[ -n "$service_state" ] || service_state=unknown
|
||||
rollback_active_transaction \
|
||||
"Release health or application-surface verification failed (service state: $service_state)"
|
||||
|
||||
@@ -31,7 +31,7 @@ test('first-run wizard tests MySQL, creates the administrator and stays responsi
|
||||
databaseType: 'MYSQL',
|
||||
serverVersion: '8.4.6',
|
||||
schemaReady: true,
|
||||
message: 'MySQL 8.4 连接和完整迁移权限验证通过',
|
||||
message: 'MySQL 8.4 连接、排序规则和完整迁移权限验证通过',
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -59,7 +59,7 @@ test('first-run wizard tests MySQL, creates the administrator and stays responsi
|
||||
await page.getByLabel('数据库密码').fill('fixture-db-password');
|
||||
await page.getByLabel('安装码').fill('fixture-setup-code');
|
||||
await page.getByRole('button', { name: '测试连接' }).click();
|
||||
await expect(page.getByText('MySQL 8.4 连接和完整迁移权限验证通过')).toBeVisible();
|
||||
await expect(page.getByText('MySQL 8.4 连接、排序规则和完整迁移权限验证通过')).toBeVisible();
|
||||
await page.getByRole('button', { name: '下一步' }).click();
|
||||
|
||||
await page.getByLabel('管理员密码').fill('SetupAdmin@2026Strong');
|
||||
@@ -107,7 +107,7 @@ test('confirms completion after the setup response is interrupted by a service r
|
||||
databaseType: 'MYSQL',
|
||||
serverVersion: '8.4.6',
|
||||
schemaReady: true,
|
||||
message: 'MySQL 8.4 连接和完整迁移权限验证通过',
|
||||
message: 'MySQL 8.4 连接、排序规则和完整迁移权限验证通过',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -178,6 +178,16 @@ test('system administrator checks and queues a signed online update', async ({ p
|
||||
expect(installBodies).toEqual([{ version: '1.0.0-preview.2', reason: '管理员确认立即重启' }]);
|
||||
});
|
||||
|
||||
test('brand logo returns the active identity to its workbench', async ({ page }) => {
|
||||
await installFixture(page, [], []);
|
||||
await page.goto('/governance/update');
|
||||
|
||||
await page.locator('.brand-logo').click();
|
||||
|
||||
await expect(page).toHaveURL(/\/governance\/settings$/);
|
||||
await expect(page.getByText('选择工作身份', { exact: true })).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('disabled online updates expose status without an executable check action', async ({ page }) => {
|
||||
await installFixture(page, [], [], false);
|
||||
await page.goto('/governance/update');
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<div :class="layoutCls">
|
||||
<t-head-menu :class="menuCls" :theme="menuTheme" expand-type="popup" :value="active">
|
||||
<template #logo>
|
||||
<span v-if="showLogo" class="header-logo-container" @click="handleNav('/role-select')">
|
||||
<span v-if="showLogo" class="header-logo-container" @click="handleNav(user.workbenchRoute)">
|
||||
<span class="header-brand">财务系统</span>
|
||||
</span>
|
||||
<div v-else class="header-operate-left">
|
||||
|
||||
@@ -45,7 +45,7 @@ import { useRouter } from 'vue-router';
|
||||
import { prefix } from '@/config/global';
|
||||
import { t } from '@/locales';
|
||||
import { getActive } from '@/router';
|
||||
import { useSettingStore } from '@/store';
|
||||
import { useSettingStore, useUserStore } from '@/store';
|
||||
import type { MenuRoute, ModeType } from '@/types/interface';
|
||||
|
||||
import pgk from '../../../package.json';
|
||||
@@ -155,6 +155,7 @@ const menuCls = computed(() => {
|
||||
|
||||
const router = useRouter();
|
||||
const settingStore = useSettingStore();
|
||||
const userStore = useUserStore();
|
||||
|
||||
const autoCollapsed = () => {
|
||||
const isCompact = window.innerWidth <= MIN_POINT;
|
||||
@@ -175,7 +176,7 @@ onUnmounted(() => {
|
||||
});
|
||||
|
||||
const goHome = () => {
|
||||
router.push('/role-select');
|
||||
router.push(userStore.workbenchRoute);
|
||||
};
|
||||
</script>
|
||||
<style lang="less" scoped>
|
||||
|
||||
@@ -73,16 +73,7 @@ const choose = async (roleCode: string) => {
|
||||
try {
|
||||
await userStore.selectRole(roleCode);
|
||||
permissionStore.initRoutes();
|
||||
const target = userStore.roles.find((role) => role.code === roleCode)?.workbenchRoute;
|
||||
const fallback =
|
||||
roleCode === 'PROJECT_MANAGER'
|
||||
? '/workbench/project'
|
||||
: roleCode === 'ARCHIVE_MANAGER'
|
||||
? '/workbench/archive'
|
||||
: roleCode === 'SYSTEM_ADMIN'
|
||||
? '/governance/settings'
|
||||
: '/workbench/finance';
|
||||
await router.replace(target || fallback);
|
||||
await router.replace(userStore.workbenchRoute);
|
||||
} catch (error) {
|
||||
MessagePlugin.error((error as Error).message || '身份选择失败,请重试');
|
||||
} finally {
|
||||
|
||||
@@ -5,6 +5,7 @@ import * as authApi from '@/api/auth';
|
||||
import { usePermissionStore } from '@/store';
|
||||
|
||||
import { hasPermissionAccess } from './menu-access';
|
||||
import { resolveWorkbenchRoute } from './workbench-route';
|
||||
|
||||
const emptyUser: UserView = {
|
||||
publicId: '',
|
||||
@@ -28,6 +29,7 @@ export const useUserStore = defineStore('user', {
|
||||
getters: {
|
||||
displayName: (state) => state.user.displayName || state.user.username,
|
||||
roleName: (state) => state.roles.find((role) => role.code === state.currentRole)?.name || '',
|
||||
workbenchRoute: (state) => resolveWorkbenchRoute(state.currentRole, state.roles),
|
||||
hasRole: (state) => (roleCode: string) => state.roles.some((role) => role.code === roleCode),
|
||||
hasPermission: (state) => (permission: string) =>
|
||||
hasPermissionAccess(state.currentRole, state.permissions, permission),
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { RoleView } from '@/api/auth';
|
||||
|
||||
const fallbackWorkbenchRoutes: Record<string, string> = {
|
||||
PROJECT_MANAGER: '/workbench/project',
|
||||
FINANCE_MANAGER: '/workbench/finance',
|
||||
ARCHIVE_MANAGER: '/workbench/archive',
|
||||
SYSTEM_ADMIN: '/governance/settings',
|
||||
};
|
||||
|
||||
export function resolveWorkbenchRoute(currentRole: string | null, roles: RoleView[]): string {
|
||||
const configured = roles.find((role) => role.code === currentRole)?.workbenchRoute?.trim();
|
||||
return configured || fallbackWorkbenchRoutes[currentRole || ''] || '/role-select';
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import type { RoleView } from '../src/api/auth';
|
||||
import { resolveWorkbenchRoute } from '../src/store/modules/workbench-route';
|
||||
|
||||
const roles: RoleView[] = [
|
||||
{ code: 'SYSTEM_ADMIN', name: '系统管理员', workbenchRoute: '/governance/settings' },
|
||||
{ code: 'FINANCE_MANAGER', name: '财务管理人员', workbenchRoute: '/workbench/finance' },
|
||||
];
|
||||
|
||||
describe('active identity workbench route', () => {
|
||||
it('uses the route supplied for the active role', () => {
|
||||
expect(resolveWorkbenchRoute('SYSTEM_ADMIN', roles)).toBe('/governance/settings');
|
||||
expect(resolveWorkbenchRoute('FINANCE_MANAGER', roles)).toBe('/workbench/finance');
|
||||
});
|
||||
|
||||
it('falls back to the built-in role workbench when the server route is absent', () => {
|
||||
expect(resolveWorkbenchRoute('PROJECT_MANAGER', [])).toBe('/workbench/project');
|
||||
expect(resolveWorkbenchRoute('ARCHIVE_MANAGER', [])).toBe('/workbench/archive');
|
||||
});
|
||||
|
||||
it('keeps role selection for sessions without an active identity', () => {
|
||||
expect(resolveWorkbenchRoute(null, roles)).toBe('/role-select');
|
||||
});
|
||||
});
|
||||
@@ -92,6 +92,7 @@ SH
|
||||
cat > "$mock_bin/systemctl" <<'SH'
|
||||
#!/bin/sh
|
||||
printf '%s\n' "$*" >> "$MOCK_SYSTEMCTL_LOG"
|
||||
[ "${MOCK_UPDATE_PATH_START:-success}:$*" != 'fail:start kaidi-update.path' ] || exit 1
|
||||
exit 0
|
||||
SH
|
||||
|
||||
@@ -170,6 +171,7 @@ SH
|
||||
build_release() {
|
||||
local fixture=$1
|
||||
local version=$2
|
||||
local unit_prefix=${3:-new}
|
||||
local stage="$fixture/stage"
|
||||
mkdir -p "$fixture/release" "$stage/public" "$stage/ops"
|
||||
printf 'new application\n' > "$stage/app.jar"
|
||||
@@ -178,7 +180,7 @@ build_release() {
|
||||
printf '#!/bin/sh\nprintf "new updater\\n"\n' > "$stage/ops/update.sh"
|
||||
chmod 0755 "$stage/ops/update.sh"
|
||||
for name in kaidi-finance.service kaidi-update.service kaidi-update.path; do
|
||||
printf 'new %s\n' "$name" > "$stage/ops/$name"
|
||||
printf '%s %s\n' "$unit_prefix" "$name" > "$stage/ops/$name"
|
||||
done
|
||||
|
||||
local artifact="kaidi-finance-$version.tar.gz"
|
||||
@@ -272,6 +274,7 @@ run_update() {
|
||||
MOCK_RELEASE_ORIGIN="${FIXTURE_RELEASE_ORIGIN:-https://release.fixture.invalid}" \
|
||||
MOCK_EXPECT_RELEASE_TOKEN="${FIXTURE_RELEASE_TOKEN-}" \
|
||||
MOCK_SYSTEMCTL_LOG="$fixture/systemctl.log" \
|
||||
MOCK_UPDATE_PATH_START="${MOCK_UPDATE_PATH_START:-success}" \
|
||||
KAIDI_SERVICE_USER="$(id -un)" \
|
||||
KAIDI_SERVICE_GROUP="$(id -gn)" \
|
||||
KAIDI_APP_ROOT="$fixture/app" \
|
||||
@@ -368,6 +371,41 @@ assert_success_case() {
|
||||
[ "$(mode_of "$fixture/app/current/ops/update.sh")" = 750 ] || fail 'release updater mode is not 0750'
|
||||
}
|
||||
|
||||
assert_identical_systemd_operations_case() {
|
||||
local fixture="$WORK/identical-systemd"
|
||||
local version='1.0.0-preview.2'
|
||||
mkdir -p "$fixture"
|
||||
write_mock_commands "$fixture/mock-bin"
|
||||
build_release "$fixture" "$version" old
|
||||
prepare_installation "$fixture" "$version"
|
||||
|
||||
download_and_prepare_install "$fixture" "$version"
|
||||
: > "$fixture/systemctl.log"
|
||||
run_update "$fixture" success
|
||||
[ "$(readlink "$fixture/app/current")" = "$fixture/app/releases/$version" ] \
|
||||
|| fail 'identical systemd case did not activate the new application'
|
||||
! grep -qx 'daemon-reload' "$fixture/systemctl.log" \
|
||||
|| fail 'identical systemd units triggered an unnecessary daemon reload'
|
||||
}
|
||||
|
||||
assert_update_path_failure_keeps_application_case() {
|
||||
local fixture="$WORK/path-watcher-failure"
|
||||
local version='1.0.0-preview.2'
|
||||
mkdir -p "$fixture"
|
||||
write_mock_commands "$fixture/mock-bin"
|
||||
build_release "$fixture" "$version"
|
||||
prepare_installation "$fixture" "$version"
|
||||
|
||||
download_and_prepare_install "$fixture" "$version"
|
||||
MOCK_UPDATE_PATH_START=fail run_update "$fixture" success
|
||||
[ "$(readlink "$fixture/app/current")" = "$fixture/app/releases/$version" ] \
|
||||
|| fail 'path watcher failure rolled back a healthy application'
|
||||
[ "$(jq -r '.state' "$fixture/state/status.json")" = SUCCEEDED ] \
|
||||
|| fail 'path watcher failure did not preserve successful application state'
|
||||
grep -Fq 'automatic update watcher could not be started' "$fixture/state/status.json" \
|
||||
|| fail 'path watcher failure did not preserve its diagnostic'
|
||||
}
|
||||
|
||||
assert_rollback_case() {
|
||||
local fixture="$WORK/rollback"
|
||||
local version='1.0.0-preview.2'
|
||||
@@ -480,6 +518,8 @@ assert_install_without_verified_cache_rejected() {
|
||||
}
|
||||
|
||||
assert_success_case
|
||||
assert_identical_systemd_operations_case
|
||||
assert_update_path_failure_keeps_application_case
|
||||
assert_rollback_case
|
||||
assert_download_failure_case
|
||||
assert_database_failure_case
|
||||
|
||||
Reference in New Issue
Block a user