fix: harden first-run database setup
Release / release (push) Canceled after 0s

This commit is contained in:
Qiufeng
2026-08-17 22:05:10 +08:00
parent 52403c6f95
commit 271eac382e
10 changed files with 429 additions and 50 deletions
+61 -2
View File
@@ -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);
});
+7 -1
View File
@@ -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);
}
+61 -11
View File
@@ -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;
+44
View File
@@ -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,
});
});
});