Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
271eac382e | ||
|
|
52403c6f95 |
@@ -57,6 +57,7 @@ jobs:
|
||||
npm run build
|
||||
npx playwright install --with-deps chromium
|
||||
npm run test:e2e
|
||||
npm run test:dist
|
||||
|
||||
- name: Verify release contracts and scripts
|
||||
run: |
|
||||
|
||||
@@ -4,6 +4,8 @@ frontend/dist/
|
||||
frontend/node_modules/
|
||||
frontend/playwright-report/
|
||||
frontend/test-results/
|
||||
frontend/playwright-report-dist/
|
||||
frontend/test-results-dist/
|
||||
.idea/
|
||||
.vscode/
|
||||
*.iml
|
||||
|
||||
@@ -49,7 +49,7 @@ npm run build
|
||||
Gitea API 只用于确认 Release tag 和资产名称;下载地址由受信 API 域名、仓库路径和 tag 重新构造,不采用
|
||||
Gitea 响应中可能错误指向内网地址的 `browser_download_url`。
|
||||
本版只支持由运维人员预先准备的外部 MySQL 8.4.x;安装器不会安装 MySQL、创建数据库容器或修改现有
|
||||
PostgreSQL 18。PostgreSQL 兼容开发已冻结,不属于本次 Preview.12 发布范围。
|
||||
PostgreSQL 18。PostgreSQL 兼容开发已冻结,不属于本次 Preview 发布范围。
|
||||
|
||||
执行命令的机器需预装 `bash`、`sudo`、`curl`、`mktemp` 和 `sha256sum`,并能访问目标 Gitea;`jq` 和 Java
|
||||
由安装器补齐;Java 17 优先使用服务器已有运行时,没有合适版本时才从官方 Azul 下载。数据库服务和数据库客户端均不会被安装。默认首次安装向导直接使用 JDBC 连接浏览器中填写的
|
||||
@@ -59,14 +59,14 @@ PostgreSQL 18。PostgreSQL 兼容开发已冻结,不属于本次 Preview.12
|
||||
### 直接 curl 安装
|
||||
|
||||
```bash
|
||||
curl -fsSL https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.16/install.sh | sudo bash
|
||||
curl -fsSL https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.18/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.16/install.sh \
|
||||
curl -fsSL https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.18/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.16/install.sh -o /tmp/kaidi-install.sh
|
||||
curl -fsSL https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.18/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.16 --depth 1 https://git.awaioi.com/ERP-Team/kaidi.git kaidi-preview
|
||||
git clone --branch v1.0.0-preview.18 --depth 1 https://git.awaioi.com/ERP-Team/kaidi.git kaidi-preview
|
||||
cd kaidi-preview
|
||||
./deploy/install-from-git.sh
|
||||
```
|
||||
@@ -100,7 +100,7 @@ cd kaidi-preview
|
||||
- 不安装或创建数据库;在 `/setup` 中连接运维人员预先准备的外部 MySQL 8.4.x。
|
||||
- 识别 `x86_64`、`aarch64`、`armv7` 或 32 位 `i386/i486/i586/i686`;已有 Java 17 直接复用,否则校验 SHA-256 后下载对应的官方 Azul Java 17 JRE。
|
||||
- 使用安装器内置的 SHA-256 指纹校验 Release 公钥,再用该公钥验证发布清单 RSA 签名。
|
||||
- 首次安装默认启用 `/setup` 向导,不在命令行保存数据库密码;向导只接受 MySQL 8.4.x,并在提交前验证 DDL/DML 权限。
|
||||
- 首次安装默认启用 `/setup` 向导,不在命令行保存数据库密码;向导只接受 MySQL 8.4.x,并在提交前验证完整迁移权限。
|
||||
- 安装签名 Release 到 `/opt/kaidi/releases/<version>`,以 `/opt/kaidi/current` 原子切换当前版本。
|
||||
- 安装 `kaidi-finance.service`、更新监听服务和健康检查;Java 同时托管 TDesign 前端静态资源及 Vue 路由回退。
|
||||
- 向导只初始化一个由操作者填写的 `SYSTEM_ADMIN` 管理员,不创建项目、财务、资料或演示账号。
|
||||
@@ -141,11 +141,12 @@ 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.16/install.sh | sudo bash
|
||||
curl -fsSL https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.18/install.sh | sudo bash
|
||||
```
|
||||
|
||||
无论主机架构如何,安装器都不会安装 MySQL、数据库客户端或创建数据库容器。在打开向导前,需要预先创建 `kaidi_finance`,并授予安装账号该库的
|
||||
DDL、DML 权限。向导会连接数据库、核验 MySQL 8.4.x 版本并用临时表验证权限,全部通过后 Flyway 才会建表。
|
||||
完整迁移权限。向导会连接数据库、核验 MySQL 8.4.x 版本,并实际探测建表、改表、索引、外键、存储过程、
|
||||
临时表及读写权限,全部通过后 Flyway 才会建表。
|
||||
数据库管理员可在 MySQL 8.4 中按实际应用服务器地址执行以下基线 SQL:
|
||||
|
||||
```sql
|
||||
@@ -161,7 +162,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.16/install.sh \
|
||||
curl -fsSL https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.18/install.sh \
|
||||
| sudo env KAIDI_REINSTALL=true KAIDI_SETUP_WIZARD=false bash
|
||||
```
|
||||
|
||||
@@ -171,7 +172,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.16/install.sh \
|
||||
curl -fsSL https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.18/install.sh \
|
||||
| sudo env KAIDI_REINSTALL=true bash
|
||||
```
|
||||
|
||||
@@ -207,8 +208,8 @@ act_runner 提供 `ubuntu-24.04` 标签,并在 tag 发布时执行后端、前
|
||||
Preview 属性由 SemVer 版本名表达。之后推送 tag 即会构建、测试、签名并发布:
|
||||
|
||||
```bash
|
||||
git tag v1.0.0-preview.16
|
||||
git push origin v1.0.0-preview.16
|
||||
git tag v1.0.0-preview.18
|
||||
git push origin v1.0.0-preview.18
|
||||
```
|
||||
|
||||
在线更新使用独立的 TDesign 页面:隔离的系统管理员进入“系统治理 → 系统更新”。权限与配置页只管理用户、角色、数据范围、表单模板和参数版本,不配置系统名称或域名。
|
||||
@@ -260,7 +261,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.16
|
||||
./scripts/package-release.sh 1.0.0-preview.18
|
||||
KAIDI_TRUSTED_RELEASE_PUBLIC_KEY_SHA256=807c6aec1dc3f7ce494db16aa9d763c66f292033c38f328afd0390d2715a8cd9 \
|
||||
./scripts/verify-release.sh dist/release
|
||||
```
|
||||
|
||||
@@ -17,6 +17,6 @@ public class SetupLockedController {
|
||||
@GetMapping("/status")
|
||||
@PreAuthorize("true")
|
||||
public ApiResponse<SetupViews.Status> status() {
|
||||
return ApiResponse.ok(new SetupViews.Status(false, true, List.of("MYSQL"), "系统已完成安装"));
|
||||
return ApiResponse.ok(new SetupViews.Status(false, true, true, List.of("MYSQL"), "系统已完成安装"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +25,9 @@ import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import org.flywaydb.core.Flyway;
|
||||
import org.flywaydb.core.api.FlywayException;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
|
||||
@@ -37,8 +40,12 @@ import org.springframework.transaction.support.TransactionTemplate;
|
||||
@ConditionalOnProperty(name = "finance.setup.enabled", havingValue = "true")
|
||||
public class SetupService {
|
||||
|
||||
private static final Logger LOGGER = LoggerFactory.getLogger(SetupService.class);
|
||||
private static final String MYSQL_PREFIX = "jdbc:mysql://";
|
||||
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;
|
||||
private static final int LOGIN_REDIRECT_DELAY_SECONDS = 10;
|
||||
|
||||
private final SetupProperties properties;
|
||||
private final AtomicBoolean locked = new AtomicBoolean(false);
|
||||
@@ -56,7 +63,7 @@ public class SetupService {
|
||||
|
||||
public SetupViews.Status status() {
|
||||
boolean isLocked = locked.get() || Files.exists(Path.of(properties.markerFile()));
|
||||
return new SetupViews.Status(!isLocked, isLocked, SUPPORTED_TYPES,
|
||||
return new SetupViews.Status(!isLocked, isLocked, false, SUPPORTED_TYPES,
|
||||
isLocked ? "安装向导已锁定" : "请完成数据库和管理员初始化");
|
||||
}
|
||||
|
||||
@@ -74,8 +81,9 @@ public class SetupService {
|
||||
false, "PostgreSQL 连接可用,但当前 Preview 的业务迁移仅支持 MySQL 8.4");
|
||||
}
|
||||
ConnectionResult connection = testConnection(settings);
|
||||
validateSchemaForInstallation(settings);
|
||||
return new SetupViews.Connection(true, settings.type().name(), connection.version(), true,
|
||||
"MySQL 8.4 连接和 DDL/DML 权限验证通过");
|
||||
"MySQL 8.4 连接和完整迁移权限验证通过");
|
||||
}
|
||||
|
||||
public SetupViews.Completed complete(SetupContracts.CompleteRequest request) {
|
||||
@@ -106,7 +114,7 @@ public class SetupService {
|
||||
if (properties.restartAfterComplete()) {
|
||||
Thread restart = new Thread(() -> {
|
||||
try {
|
||||
Thread.sleep(750L);
|
||||
Thread.sleep(RESTART_TRIGGER_DELAY_MILLIS);
|
||||
} catch (InterruptedException exception) {
|
||||
Thread.currentThread().interrupt();
|
||||
}
|
||||
@@ -116,18 +124,13 @@ public class SetupService {
|
||||
restart.start();
|
||||
}
|
||||
return new SetupViews.Completed(true, request.adminUsername(),
|
||||
"安装完成,系统正在切换到正式模式", 10);
|
||||
"安装完成,系统正在切换到正式模式", LOGIN_REDIRECT_DELAY_SECONDS);
|
||||
}
|
||||
|
||||
private void migrateAndCreateAdministrator(DatabaseSettings settings, SetupContracts.CompleteRequest request) {
|
||||
try {
|
||||
ensureCompatibleSchema(settings);
|
||||
Flyway.configure()
|
||||
.dataSource(settings.jdbcUrl(), settings.username(), settings.password())
|
||||
.locations("classpath:db/migration")
|
||||
.cleanDisabled(true)
|
||||
.load()
|
||||
.migrate();
|
||||
validateSchemaForInstallation(settings);
|
||||
flyway(settings).migrate();
|
||||
DriverManagerDataSource dataSource = new DriverManagerDataSource(settings.jdbcUrl(), settings.username(),
|
||||
settings.password());
|
||||
JdbcTemplate jdbc = new JdbcTemplate(dataSource);
|
||||
@@ -137,12 +140,13 @@ public class SetupService {
|
||||
if (exception instanceof SetupException setupException) {
|
||||
throw setupException;
|
||||
}
|
||||
LOGGER.error("Database migration or administrator initialization failed", exception);
|
||||
throw new SetupException(org.springframework.http.HttpStatus.UNPROCESSABLE_ENTITY,
|
||||
"DATABASE_INITIALIZATION_FAILED", "数据库迁移或管理员初始化失败,请检查账号的 DDL/DML 权限");
|
||||
"DATABASE_INITIALIZATION_FAILED", databaseInitializationFailureMessage(exception));
|
||||
}
|
||||
}
|
||||
|
||||
private void ensureCompatibleSchema(DatabaseSettings settings) {
|
||||
private boolean ensureCompatibleSchema(DatabaseSettings settings) {
|
||||
JdbcTemplate jdbc = new JdbcTemplate(new DriverManagerDataSource(settings.jdbcUrl(), settings.username(),
|
||||
settings.password()));
|
||||
Integer tableCount = jdbc.queryForObject("""
|
||||
@@ -160,7 +164,42 @@ public class SetupService {
|
||||
throw new SetupException(org.springframework.http.HttpStatus.UNPROCESSABLE_ENTITY,
|
||||
"DATABASE_NOT_EMPTY", "请选择空数据库,或提供已有 Kaidi Flyway 数据库");
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private void validateSchemaForInstallation(DatabaseSettings settings) {
|
||||
try {
|
||||
if (!ensureCompatibleSchema(settings)) {
|
||||
return;
|
||||
}
|
||||
flyway(settings).validate();
|
||||
} catch (SetupException exception) {
|
||||
throw exception;
|
||||
} catch (FlywayException exception) {
|
||||
LOGGER.error("Database migration history validation failed", exception);
|
||||
throw new SetupException(org.springframework.http.HttpStatus.UNPROCESSABLE_ENTITY,
|
||||
"DATABASE_MIGRATION_STATE_INVALID",
|
||||
"数据库迁移历史校验失败,请使用空数据库,或先修复已有迁移状态:" + safeErrorMessage(exception));
|
||||
} catch (RuntimeException exception) {
|
||||
LOGGER.error("Database schema inspection failed", exception);
|
||||
SQLException sqlException = findSqlException(exception);
|
||||
String detail = sqlException == null
|
||||
? "数据库结构检查失败:" + safeErrorMessage(exception)
|
||||
: databaseConnectionFailureMessage(sqlException);
|
||||
throw new SetupException(org.springframework.http.HttpStatus.UNPROCESSABLE_ENTITY,
|
||||
"DATABASE_SCHEMA_INSPECTION_FAILED", detail);
|
||||
}
|
||||
}
|
||||
|
||||
private Flyway flyway(DatabaseSettings settings) {
|
||||
return Flyway.configure()
|
||||
.dataSource(settings.jdbcUrl(), settings.username(), settings.password())
|
||||
.locations("classpath:db/migration")
|
||||
.ignoreMigrationPatterns("*:pending")
|
||||
.cleanDisabled(true)
|
||||
.load();
|
||||
}
|
||||
|
||||
private void initializeInstallation(JdbcTemplate jdbc, SetupContracts.CompleteRequest request) {
|
||||
@@ -280,18 +319,148 @@ public class SetupService {
|
||||
throw new SetupException(org.springframework.http.HttpStatus.UNPROCESSABLE_ENTITY,
|
||||
"MYSQL_VERSION_UNSUPPORTED", "当前版本要求 MySQL 8.4.x,检测到 " + version);
|
||||
}
|
||||
String probe = "kaidi_setup_probe_" + UUID.randomUUID().toString().replace("-", "");
|
||||
try {
|
||||
statement.execute("CREATE TABLE " + probe + " (id INT NOT NULL PRIMARY KEY)");
|
||||
statement.execute("INSERT INTO " + probe + " (id) VALUES (1)");
|
||||
statement.execute("UPDATE " + probe + " SET id = 2 WHERE id = 1");
|
||||
} finally {
|
||||
statement.execute("DROP TABLE IF EXISTS " + probe);
|
||||
}
|
||||
verifyMigrationPrivileges(connection, statement, settings.type());
|
||||
return new ConnectionResult(true, version);
|
||||
} catch (SQLException exception) {
|
||||
LOGGER.warn("Database connection or migration privilege verification failed: SQL state={}, errorCode={}",
|
||||
exception.getSQLState(), exception.getErrorCode(), exception);
|
||||
throw new SetupException(org.springframework.http.HttpStatus.UNPROCESSABLE_ENTITY,
|
||||
"DATABASE_CONNECTION_FAILED", "数据库连接或 DDL/DML 权限验证失败");
|
||||
"DATABASE_CONNECTION_FAILED", databaseConnectionFailureMessage(exception));
|
||||
}
|
||||
}
|
||||
|
||||
private String databaseConnectionFailureMessage(SQLException exception) {
|
||||
if (isPermissionError(exception)) {
|
||||
return "数据库账号缺少完整迁移权限" + sqlErrorSummary(exception);
|
||||
}
|
||||
if (exception.getSQLState() != null && exception.getSQLState().startsWith("08")) {
|
||||
return "数据库连接超时或中断" + sqlErrorSummary(exception);
|
||||
}
|
||||
return "数据库连接或完整迁移权限验证失败" + sqlErrorSummary(exception);
|
||||
}
|
||||
|
||||
private String databaseInitializationFailureMessage(Throwable exception) {
|
||||
SQLException sqlException = findSqlException(exception);
|
||||
if (sqlException != null) {
|
||||
if (isPermissionError(sqlException)) {
|
||||
return "数据库账号缺少完成迁移所需权限" + sqlErrorSummary(sqlException);
|
||||
}
|
||||
if (sqlException.getSQLState() != null && sqlException.getSQLState().startsWith("08")) {
|
||||
return "数据库迁移期间连接超时或中断" + sqlErrorSummary(sqlException);
|
||||
}
|
||||
return "数据库迁移或管理员初始化失败" + sqlErrorSummary(sqlException);
|
||||
}
|
||||
return "数据库迁移或管理员初始化失败:" + safeErrorMessage(exception);
|
||||
}
|
||||
|
||||
private SQLException findSqlException(Throwable exception) {
|
||||
Throwable current = exception;
|
||||
while (current != null) {
|
||||
if (current instanceof SQLException sqlException) {
|
||||
return sqlException;
|
||||
}
|
||||
current = current.getCause();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private boolean isPermissionError(SQLException exception) {
|
||||
return MYSQL_PERMISSION_ERROR_CODES.contains(exception.getErrorCode());
|
||||
}
|
||||
|
||||
private String sqlErrorSummary(SQLException exception) {
|
||||
String state = exception.getSQLState() == null ? "UNKNOWN" : exception.getSQLState();
|
||||
return "(SQLState " + state + ",错误码 " + exception.getErrorCode() + "):"
|
||||
+ safeErrorMessage(exception);
|
||||
}
|
||||
|
||||
private String safeErrorMessage(Throwable exception) {
|
||||
Throwable current = exception;
|
||||
while (current.getCause() != null) {
|
||||
current = current.getCause();
|
||||
}
|
||||
String message = current.getMessage() == null ? current.getClass().getSimpleName() : current.getMessage();
|
||||
String sanitized = message.replaceAll("(?i)(password|pwd)=([^\\s&;]+)", "$1=***")
|
||||
.replaceAll("\\s+", " ").trim();
|
||||
return sanitized.length() <= 240 ? sanitized : sanitized.substring(0, 240) + "...";
|
||||
}
|
||||
|
||||
private void verifyMigrationPrivileges(Connection connection, Statement statement, DatabaseType databaseType)
|
||||
throws SQLException {
|
||||
String suffix = UUID.randomUUID().toString().replace("-", "");
|
||||
String parentTable = "kaidi_setup_probe_parent_" + suffix;
|
||||
String childTable = "kaidi_setup_probe_child_" + suffix;
|
||||
String foreignKey = "fk_setup_probe_" + suffix;
|
||||
String index = "idx_setup_probe_" + suffix;
|
||||
String routine = "kaidi_setup_probe_routine_" + suffix;
|
||||
String temporaryTable = "kaidi_setup_probe_temp_" + suffix;
|
||||
try {
|
||||
statement.execute("CREATE TABLE " + parentTable + " (id INT NOT NULL PRIMARY KEY)");
|
||||
if (databaseType == DatabaseType.MYSQL) {
|
||||
statement.execute("CREATE TABLE " + childTable
|
||||
+ " (id INT NOT NULL PRIMARY KEY, parent_id INT NOT NULL, CONSTRAINT " + foreignKey
|
||||
+ " FOREIGN KEY (parent_id) REFERENCES " + parentTable + " (id))");
|
||||
statement.execute("ALTER TABLE " + childTable + " ADD COLUMN note VARCHAR(32) NULL");
|
||||
statement.execute("CREATE INDEX " + index + " ON " + childTable + " (parent_id)");
|
||||
statement.execute("CREATE PROCEDURE " + routine + "() SELECT 1 AS probe_value");
|
||||
try (Statement call = connection.createStatement();
|
||||
ResultSet result = call.executeQuery("CALL " + routine + "()")) {
|
||||
if (!result.next() || result.getInt(1) != 1) {
|
||||
throw new SQLException("Migration privilege routine probe returned an invalid result");
|
||||
}
|
||||
}
|
||||
statement.execute("INSERT INTO " + parentTable + " (id) VALUES (1)");
|
||||
statement.execute("INSERT INTO " + childTable + " (id, parent_id, note) VALUES (1, 1, 'probe')");
|
||||
try (ResultSet result = statement.executeQuery("SELECT note FROM " + childTable + " WHERE id = 1")) {
|
||||
if (!result.next() || !"probe".equals(result.getString(1))) {
|
||||
throw new SQLException("Migration privilege SELECT probe returned an invalid result");
|
||||
}
|
||||
}
|
||||
statement.execute("CREATE TEMPORARY TABLE " + temporaryTable
|
||||
+ " (id INT NOT NULL PRIMARY KEY)");
|
||||
statement.execute("INSERT INTO " + temporaryTable + " (id) VALUES (1)");
|
||||
statement.execute("DROP TEMPORARY TABLE " + temporaryTable);
|
||||
statement.execute("UPDATE " + childTable + " SET note = 'verified' WHERE id = 1");
|
||||
statement.execute("DELETE FROM " + childTable + " WHERE id = 1");
|
||||
statement.execute("DELETE FROM " + parentTable + " WHERE id = 1");
|
||||
} else {
|
||||
statement.execute("INSERT INTO " + parentTable + " (id) VALUES (1)");
|
||||
statement.execute("UPDATE " + parentTable + " SET id = 2 WHERE id = 1");
|
||||
statement.execute("DELETE FROM " + parentTable + " WHERE id = 2");
|
||||
}
|
||||
} catch (SQLException exception) {
|
||||
cleanupProbeObjects(statement, databaseType, routine, temporaryTable, childTable, parentTable, exception);
|
||||
throw exception;
|
||||
}
|
||||
cleanupProbeObjects(statement, databaseType, routine, temporaryTable, childTable, parentTable, null);
|
||||
}
|
||||
|
||||
private void cleanupProbeObjects(Statement statement, DatabaseType databaseType, String routine,
|
||||
String temporaryTable, String childTable, String parentTable,
|
||||
SQLException original)
|
||||
throws SQLException {
|
||||
SQLException cleanupFailure = null;
|
||||
List<String> cleanupStatements = databaseType == DatabaseType.MYSQL
|
||||
? List.of("DROP PROCEDURE IF EXISTS " + routine, "DROP TEMPORARY TABLE IF EXISTS " + temporaryTable,
|
||||
"DROP TABLE IF EXISTS " + childTable, "DROP TABLE IF EXISTS " + parentTable)
|
||||
: List.of("DROP TABLE IF EXISTS " + childTable, "DROP TABLE IF EXISTS " + parentTable);
|
||||
for (String sql : cleanupStatements) {
|
||||
try {
|
||||
statement.execute(sql);
|
||||
} catch (SQLException exception) {
|
||||
if (cleanupFailure == null) {
|
||||
cleanupFailure = exception;
|
||||
} else {
|
||||
cleanupFailure.addSuppressed(exception);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (cleanupFailure != null) {
|
||||
if (original != null) {
|
||||
original.addSuppressed(cleanupFailure);
|
||||
} else {
|
||||
throw cleanupFailure;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ public final class SetupViews {
|
||||
public record Status(
|
||||
boolean required,
|
||||
boolean locked,
|
||||
boolean ready,
|
||||
List<String> supportedDatabaseTypes,
|
||||
String message
|
||||
) {
|
||||
|
||||
@@ -63,6 +63,7 @@ class SetupApplicationIntegrationTest {
|
||||
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();
|
||||
assertThat(initial.getBody().path("data").path("ready").asBoolean()).isFalse();
|
||||
|
||||
Map<String, Object> database = databaseRequest("wrong-code");
|
||||
ResponseEntity<JsonNode> denied = rest.postForEntity(url("/api/v1/setup/test-connection"),
|
||||
@@ -76,6 +77,22 @@ 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("完整迁移权限验证通过");
|
||||
|
||||
try (Connection connection = DriverManager.getConnection(MYSQL.getJdbcUrl(), MYSQL.getUsername(),
|
||||
MYSQL.getPassword()); Statement statement = connection.createStatement()) {
|
||||
try (ResultSet result = statement.executeQuery("""
|
||||
SELECT
|
||||
(SELECT COUNT(*) FROM information_schema.tables
|
||||
WHERE table_schema = DATABASE() AND table_name LIKE 'kaidi_setup_probe_%')
|
||||
+
|
||||
(SELECT COUNT(*) FROM information_schema.routines
|
||||
WHERE routine_schema = DATABASE() AND routine_name LIKE 'kaidi_setup_probe_%')
|
||||
""")) {
|
||||
result.next();
|
||||
assertThat(result.getInt(1)).isZero();
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, Object> complete = new LinkedHashMap<>(database);
|
||||
complete.put("adminUsername", "setup-admin");
|
||||
@@ -121,6 +138,7 @@ class SetupApplicationIntegrationTest {
|
||||
ResponseEntity<JsonNode> locked = rest.getForEntity(url("/api/v1/setup/status"), JsonNode.class);
|
||||
assertThat(locked.getBody().path("data").path("required").asBoolean()).isFalse();
|
||||
assertThat(locked.getBody().path("data").path("locked").asBoolean()).isTrue();
|
||||
assertThat(locked.getBody().path("data").path("ready").asBoolean()).isFalse();
|
||||
}
|
||||
|
||||
private Map<String, Object> databaseRequest(String setupCode) {
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
package com.kaidi.finance.setup;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
class SetupReadinessTest {
|
||||
|
||||
private static final String TOKEN_SHA256 =
|
||||
"9158568c96987884c8141d363daceffda86bb17083054b36105934090aa7e166";
|
||||
|
||||
@TempDir
|
||||
private Path stateRoot;
|
||||
|
||||
@Test
|
||||
void distinguishesLockedSetupModeFromReadyApplicationMode() {
|
||||
SetupProperties properties = new SetupProperties(true, TOKEN_SHA256,
|
||||
stateRoot.resolve("application.env").toString(), stateRoot.resolve("locked").toString(), false);
|
||||
|
||||
SetupViews.Status setupMode = new SetupService(properties).status();
|
||||
SetupViews.Status applicationMode = new SetupLockedController().status().data();
|
||||
|
||||
assertThat(setupMode.required()).isTrue();
|
||||
assertThat(setupMode.ready()).isFalse();
|
||||
assertThat(applicationMode.required()).isFalse();
|
||||
assertThat(applicationMode.locked()).isTrue();
|
||||
assertThat(applicationMode.ready()).isTrue();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
test('release build boots the setup wizard without runtime errors', async ({ page }) => {
|
||||
const pageErrors: string[] = [];
|
||||
const consoleErrors: string[] = [];
|
||||
|
||||
page.on('pageerror', (error) => pageErrors.push(error.message));
|
||||
page.on('console', (message) => {
|
||||
if (message.type() === 'error') consoleErrors.push(message.text());
|
||||
});
|
||||
await page.route('**/api/v1/setup/status', async (route) => {
|
||||
await route.fulfill({
|
||||
json: {
|
||||
data: {
|
||||
required: true,
|
||||
locked: false,
|
||||
supportedDatabaseTypes: ['MYSQL'],
|
||||
message: '请完成安装',
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
const response = await page.goto('/setup');
|
||||
expect(response?.ok()).toBe(true);
|
||||
await expect(page.getByRole('heading', { name: '安装向导', exact: true })).toBeVisible();
|
||||
expect(pageErrors).toEqual([]);
|
||||
expect(consoleErrors).toEqual([]);
|
||||
});
|
||||
@@ -11,6 +11,7 @@ test('first-run wizard tests MySQL, creates the administrator and stays responsi
|
||||
data: {
|
||||
required: !completed,
|
||||
locked: completed,
|
||||
ready: completed,
|
||||
supportedDatabaseTypes: ['MYSQL'],
|
||||
message: completed ? '系统已完成安装' : '请完成安装',
|
||||
},
|
||||
@@ -30,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 连接和 DDL/DML 权限验证通过',
|
||||
message: 'MySQL 8.4 连接和完整迁移权限验证通过',
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -58,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 连接和 DDL/DML 权限验证通过')).toBeVisible();
|
||||
await expect(page.getByText('MySQL 8.4 连接和完整迁移权限验证通过')).toBeVisible();
|
||||
await page.getByRole('button', { name: '下一步' }).click();
|
||||
|
||||
await page.getByLabel('管理员密码').fill('SetupAdmin@2026Strong');
|
||||
@@ -74,3 +75,61 @@ test('first-run wizard tests MySQL, creates the administrator and stays responsi
|
||||
}));
|
||||
expect(layout.scrollWidth).toBeLessThanOrEqual(layout.clientWidth);
|
||||
});
|
||||
|
||||
test('confirms completion after the setup response is interrupted by a service restart', async ({ page }) => {
|
||||
let completionStarted = false;
|
||||
let recoveryChecks = 0;
|
||||
await page.route('**/api/v1/setup/**', async (route) => {
|
||||
const request = route.request();
|
||||
const pathname = new URL(request.url()).pathname;
|
||||
if (pathname === '/api/v1/setup/status') {
|
||||
if (completionStarted) recoveryChecks += 1;
|
||||
const ready = completionStarted && recoveryChecks >= 2;
|
||||
await route.fulfill({
|
||||
json: {
|
||||
data: {
|
||||
required: !completionStarted,
|
||||
locked: completionStarted,
|
||||
ready,
|
||||
supportedDatabaseTypes: ['MYSQL'],
|
||||
message: ready ? '系统已完成安装' : '请完成安装',
|
||||
},
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
const body = request.postDataJSON() as Record<string, unknown>;
|
||||
if (pathname.endsWith('/test-connection')) {
|
||||
await route.fulfill({
|
||||
json: {
|
||||
data: {
|
||||
successful: true,
|
||||
databaseType: 'MYSQL',
|
||||
serverVersion: '8.4.6',
|
||||
schemaReady: true,
|
||||
message: 'MySQL 8.4 连接和完整迁移权限验证通过',
|
||||
},
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
expect(body.adminUsername).toBe('admin');
|
||||
completionStarted = true;
|
||||
await route.abort('connectionreset');
|
||||
});
|
||||
|
||||
await page.goto('/setup');
|
||||
await page.getByLabel('数据库密码').fill('fixture-db-password');
|
||||
await page.getByLabel('安装码').fill('fixture-setup-code');
|
||||
await page.getByRole('button', { name: '测试连接' }).click();
|
||||
await page.getByRole('button', { name: '下一步' }).click();
|
||||
await page.getByLabel('管理员密码').fill('SetupAdmin@2026Strong');
|
||||
await page.getByLabel('确认密码').fill('SetupAdmin@2026Strong');
|
||||
await page.getByRole('button', { name: '完成安装' }).click();
|
||||
await page.getByRole('button', { name: '确定', exact: true }).click();
|
||||
|
||||
await expect(page.getByText('连接暂时中断,正在确认安装结果。')).toBeVisible();
|
||||
await expect(page.getByText('安装完成', { exact: true })).toBeVisible();
|
||||
await expect(page.getByText('安装已完成,系统正在切换到正式模式')).toBeVisible();
|
||||
await expect(page.getByText('网络请求失败')).toHaveCount(0);
|
||||
});
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
"site:preview": "npm run build && cp -r dist _site",
|
||||
"test": "vitest run",
|
||||
"test:e2e": "playwright test",
|
||||
"test:dist": "playwright test --config playwright.dist.config.ts",
|
||||
"test:a11y": "playwright test e2e/accessibility.e2e.ts",
|
||||
"audit:dependencies": "npm audit --audit-level=high --registry=https://registry.npmjs.org",
|
||||
"check:production": "node scripts/check-production-hygiene.mjs",
|
||||
|
||||
@@ -6,6 +6,7 @@ const baseURL = `http://127.0.0.1:${port}`;
|
||||
export default defineConfig({
|
||||
testDir: './e2e',
|
||||
testMatch: '**/*.e2e.ts',
|
||||
testIgnore: 'dist-smoke.e2e.ts',
|
||||
outputDir: 'test-results',
|
||||
timeout: 30_000,
|
||||
expect: { timeout: 8_000 },
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { defineConfig } from '@playwright/test';
|
||||
|
||||
const port = Number(process.env.PLAYWRIGHT_DIST_PORT || 3012);
|
||||
const baseURL = `http://127.0.0.1:${port}`;
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './e2e',
|
||||
testMatch: 'dist-smoke.e2e.ts',
|
||||
outputDir: 'test-results-dist',
|
||||
timeout: 30_000,
|
||||
expect: { timeout: 8_000 },
|
||||
fullyParallel: false,
|
||||
workers: 1,
|
||||
forbidOnly: Boolean(process.env.CI),
|
||||
retries: process.env.CI ? 1 : 0,
|
||||
reporter: process.env.CI ? [['list'], ['html', { open: 'never', outputFolder: 'playwright-report-dist' }]] : 'list',
|
||||
use: {
|
||||
baseURL,
|
||||
locale: 'zh-CN',
|
||||
timezoneId: 'Asia/Shanghai',
|
||||
screenshot: 'only-on-failure',
|
||||
trace: 'retain-on-failure',
|
||||
},
|
||||
webServer: {
|
||||
command: `npm run preview -- --host 127.0.0.1 --port ${port}`,
|
||||
url: baseURL,
|
||||
reuseExistingServer: false,
|
||||
timeout: 120_000,
|
||||
},
|
||||
projects: [{ name: 'chromium-dist', use: { browserName: 'chromium', viewport: { width: 1366, height: 768 } } }],
|
||||
});
|
||||
@@ -1,8 +1,11 @@
|
||||
import { request } from '@/utils/request';
|
||||
|
||||
const SETUP_COMPLETION_TIMEOUT_MS = 120_000;
|
||||
|
||||
export interface SetupStatus {
|
||||
required: boolean;
|
||||
locked: boolean;
|
||||
ready: boolean;
|
||||
supportedDatabaseTypes: string[];
|
||||
message: string;
|
||||
}
|
||||
@@ -43,6 +46,7 @@ function normalizeStatus(value: SetupStatus): SetupStatus {
|
||||
return {
|
||||
required: Boolean(value?.required),
|
||||
locked: Boolean(value?.locked),
|
||||
ready: Boolean(value?.ready),
|
||||
supportedDatabaseTypes: Array.isArray(value?.supportedDatabaseTypes) ? value.supportedDatabaseTypes : [],
|
||||
message: value?.message || '',
|
||||
};
|
||||
@@ -57,5 +61,7 @@ export function testSetupConnection(data: SetupDatabaseRequest) {
|
||||
}
|
||||
|
||||
export function completeSetup(data: SetupCompleteRequest) {
|
||||
return request.post<SetupCompleted>({ url: '/setup/complete', data }).then((value) => value);
|
||||
return request
|
||||
.post<SetupCompleted>({ url: '/setup/complete', data, timeout: SETUP_COMPLETION_TIMEOUT_MS })
|
||||
.then((value) => value);
|
||||
}
|
||||
|
||||
@@ -162,6 +162,10 @@
|
||||
</t-form-item>
|
||||
</div>
|
||||
|
||||
<t-alert v-if="completing" class="setup-progress" theme="info" :close-btn="false">
|
||||
{{ completionProgressMessage }}
|
||||
</t-alert>
|
||||
|
||||
<div class="setup-actions">
|
||||
<t-button variant="outline" :disabled="completing" @click="currentStep = 0">
|
||||
<template #icon><t-icon name="chevron-left" /></template>
|
||||
@@ -223,8 +227,13 @@ const errorMessage = ref('');
|
||||
const connectionResult = ref<SetupConnection | null>(null);
|
||||
const verifiedFingerprint = ref('');
|
||||
const completionMessage = ref('系统正在重启,请稍候。');
|
||||
const completionProgressMessage = ref('正在初始化数据库结构并创建管理员,请稍候。');
|
||||
const countdown = ref(10);
|
||||
let countdownTimer: number | undefined;
|
||||
let unmounted = false;
|
||||
|
||||
const SETUP_CONFIRMATION_TIMEOUT_MS = 60_000;
|
||||
const SETUP_CONFIRMATION_INTERVAL_MS = 2_000;
|
||||
|
||||
const databaseForm = reactive<SetupDatabaseRequest>({
|
||||
setupCode: '',
|
||||
@@ -317,21 +326,51 @@ async function finishSetup(context?: SubmitContext) {
|
||||
return;
|
||||
}
|
||||
completing.value = true;
|
||||
completionProgressMessage.value = '正在初始化数据库结构并创建管理员,请稍候。';
|
||||
errorMessage.value = '';
|
||||
try {
|
||||
const result = await completeSetup({ ...databaseForm, ...adminForm });
|
||||
if (!result.completed) throw new Error('安装服务未确认完成,请稍后重试');
|
||||
completionMessage.value = result.message;
|
||||
countdown.value = Math.max(1, result.restartAfterSeconds || 10);
|
||||
currentStep.value = 2;
|
||||
startCountdown();
|
||||
showCompleted(result.message, result.restartAfterSeconds);
|
||||
} catch (error) {
|
||||
errorMessage.value = (error as Error).message || '安装初始化失败';
|
||||
const requestError = error as Error & { status?: number };
|
||||
if (requestError.status === undefined || requestError.status >= 500) {
|
||||
completionProgressMessage.value = '连接暂时中断,正在确认安装结果。';
|
||||
if (await waitForSetupCompletion()) {
|
||||
showCompleted('安装已完成,系统正在切换到正式模式', 10);
|
||||
return;
|
||||
}
|
||||
errorMessage.value = '安装结果暂未确认,请稍后重新打开安装页面;若问题持续,请检查服务日志。';
|
||||
} else {
|
||||
errorMessage.value = requestError.message || '安装初始化失败';
|
||||
}
|
||||
} finally {
|
||||
completing.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForSetupCompletion() {
|
||||
const deadline = Date.now() + SETUP_CONFIRMATION_TIMEOUT_MS;
|
||||
while (Date.now() < deadline) {
|
||||
if (unmounted) return false;
|
||||
try {
|
||||
const status = await getSetupStatus();
|
||||
if (status.ready) return true;
|
||||
} catch {
|
||||
// A short connection failure is expected while systemd restarts the service.
|
||||
}
|
||||
await new Promise((resolve) => window.setTimeout(resolve, SETUP_CONFIRMATION_INTERVAL_MS));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function showCompleted(message: string, restartAfterSeconds: number) {
|
||||
completionMessage.value = message;
|
||||
countdown.value = Math.max(1, restartAfterSeconds || 10);
|
||||
currentStep.value = 2;
|
||||
startCountdown();
|
||||
}
|
||||
|
||||
function confirmSetup() {
|
||||
void finishSetup();
|
||||
}
|
||||
@@ -340,13 +379,18 @@ function startCountdown() {
|
||||
window.clearInterval(countdownTimer);
|
||||
countdownTimer = window.setInterval(() => {
|
||||
countdown.value -= 1;
|
||||
if (countdown.value <= 0) openLogin();
|
||||
if (countdown.value <= 0) void openLogin();
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
function openLogin() {
|
||||
async function openLogin() {
|
||||
window.clearInterval(countdownTimer);
|
||||
window.location.assign('/login');
|
||||
completionMessage.value = '系统正在启动,请稍候。';
|
||||
if (await waitForSetupCompletion()) {
|
||||
window.location.assign('/login');
|
||||
return;
|
||||
}
|
||||
completionMessage.value = '系统启动时间较长,请稍后刷新页面。';
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
@@ -364,7 +408,10 @@ onMounted(async () => {
|
||||
}
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => window.clearInterval(countdownTimer));
|
||||
onBeforeUnmount(() => {
|
||||
unmounted = true;
|
||||
window.clearInterval(countdownTimer);
|
||||
});
|
||||
</script>
|
||||
<style scoped>
|
||||
.setup-page {
|
||||
@@ -382,8 +429,7 @@ onBeforeUnmount(() => window.clearInterval(countdownTimer));
|
||||
}
|
||||
|
||||
.setup-panel {
|
||||
align-self: center;
|
||||
justify-self: center;
|
||||
place-self: center;
|
||||
width: min(760px, 100%);
|
||||
min-width: 0;
|
||||
padding: 32px;
|
||||
@@ -445,6 +491,10 @@ h1 {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.setup-progress {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.setup-complete {
|
||||
min-height: 280px;
|
||||
display: flex;
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const requestMocks = vi.hoisted(() => ({
|
||||
post: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/utils/request', () => ({
|
||||
request: {
|
||||
post: requestMocks.post,
|
||||
},
|
||||
}));
|
||||
|
||||
import { completeSetup } from '../src/api/setup';
|
||||
|
||||
describe('setup API', () => {
|
||||
beforeEach(() => {
|
||||
requestMocks.post.mockReset();
|
||||
requestMocks.post.mockResolvedValue({ completed: true });
|
||||
});
|
||||
|
||||
it('allows enough time for the first database migration', async () => {
|
||||
const request = {
|
||||
setupCode: 'fixture-code',
|
||||
databaseType: 'MYSQL' as const,
|
||||
host: '127.0.0.1',
|
||||
port: 3306,
|
||||
database: 'kaidi_finance',
|
||||
username: 'kaidi',
|
||||
password: 'fixture-password',
|
||||
adminUsername: 'admin',
|
||||
adminDisplayName: '系统管理员',
|
||||
adminPassword: 'SetupAdmin@2026Strong',
|
||||
adminPasswordConfirmation: 'SetupAdmin@2026Strong',
|
||||
};
|
||||
|
||||
await completeSetup(request);
|
||||
|
||||
expect(requestMocks.post).toHaveBeenCalledWith({
|
||||
url: '/setup/complete',
|
||||
data: request,
|
||||
timeout: 120_000,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -55,7 +55,6 @@ export default ({ mode }: ConfigEnv): UserConfig => {
|
||||
name: 'tdesign-vendor',
|
||||
test: /node_modules[\\/]tdesign-(?:vue-next|icons-vue-next)[\\/]/,
|
||||
priority: 30,
|
||||
maxSize: 480 * 1024,
|
||||
},
|
||||
{
|
||||
name: 'vue-vendor',
|
||||
|
||||
Reference in New Issue
Block a user