diff --git a/README.md b/README.md
index cb38ce3..a39832d 100644
--- a/README.md
+++ b/README.md
@@ -254,6 +254,25 @@ Linux 默认路径:
使用 `--no-service` 只用于开发验收:启动器会在当前用户下运行,但默认设置 `OA_UPDATE_ENABLED=false`。生产环境应使用 systemd/launchd,让在线更新可以在 Java 进程退出后自动拉起新版本。
+### HTTPS 反向代理
+
+正式服务监听 `8090`。Nginx、宝塔、Caddy 或 CDN 终止 HTTPS 后,必须把公网协议和主机转发给 Spring Boot;否则浏览器的同源 API 请求会被误判为跨域,并收到纯文本 `403 Invalid CORS request`,前端表现为“响应非 JSON (HTTP 403)”。Nginx 的代理位置至少包含:
+
+```nginx
+location / {
+ proxy_pass http://127.0.0.1:8090;
+ proxy_http_version 1.1;
+ proxy_set_header Host $host;
+ proxy_set_header X-Forwarded-Host $host;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ proxy_set_header X-Forwarded-Port $server_port;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+}
+```
+
+如果 Nginx 前面还有 Cloudflare 等上游代理,`X-Forwarded-Proto` 必须保留浏览器实际使用的 `https`,不能被内层 HTTP 链路覆盖。前端与 `/api/oa/*` 推荐使用同一个公网域名;不要在 Nginx 中附加 `Access-Control-Allow-Origin *`。
+
## 在线更新与回滚
管理员登录后进入:
@@ -417,6 +436,10 @@ Gitea 仓库尚未发布首个可安装版本,或 Release 缺少四个必需
运行 `ngrok config check` 确认配置有效,并检查固定域名是否属于当前 ngrok 账号。可以通过 `ERP_RUN_NGROK_BIN`、`ERP_RUN_NGROK_CONFIG` 和 `ERP_RUN_NGROK_API_PORT` 覆盖路径与端口。
+### 反向代理后提示“响应非 JSON (HTTP 403)”
+
+应用自身的 401/403 权限错误始终是 JSON。该提示表示 Nginx、WAF 或 Spring CORS 层提前返回了纯文本/HTML。先确认代理目标为 `http://127.0.0.1:8090`,再按“HTTPS 反向代理”一节补齐 `Host` 和 `X-Forwarded-*` 请求头;响应正文为 `Invalid CORS request` 时即可确认是协议/主机转发不完整。
+
### 签名验证失败
不要跳过验证。确认 Release 的四个资产来自同一次构建、`SHA256SUMS` 未被改写、签名私钥与 `distribution/release-public-key.pem` 匹配。
diff --git a/oa-backend/src/installer/resources/application.yml b/oa-backend/src/installer/resources/application.yml
index 9ecfd8d..c91162e 100644
--- a/oa-backend/src/installer/resources/application.yml
+++ b/oa-backend/src/installer/resources/application.yml
@@ -1,6 +1,7 @@
server:
port: ${SERVER_PORT:8091}
shutdown: graceful
+ forward-headers-strategy: framework
spring:
application:
diff --git a/oa-backend/src/installer/resources/static/index.html b/oa-backend/src/installer/resources/static/index.html
index abb9a93..32a5fb0 100644
--- a/oa-backend/src/installer/resources/static/index.html
+++ b/oa-backend/src/installer/resources/static/index.html
@@ -190,6 +190,44 @@
.progress-item.running .progress-dot { border-color: #2f6fed; background: #2f6fed; box-shadow: 0 0 0 4px #eaf1fd; }
.progress-item.done { color: #187149; }
.progress-item.done .progress-dot { border-color: #25805a; background: #25805a; }
+ .install-progress { margin-top: 22px; padding-top: 18px; border-top: 1px solid #e8ebf0; }
+ .install-progress-head, .install-progress-meta, .install-console-head {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ }
+ .install-progress-label { color: #334057; font-size: 13px; font-weight: 650; }
+ .install-progress-value { color: #1d3557; font-size: 14px; font-variant-numeric: tabular-nums; }
+ .install-progress-track {
+ height: 10px;
+ margin-top: 10px;
+ overflow: hidden;
+ border-radius: 4px;
+ background: #e5eaf1;
+ }
+ .install-progress-bar {
+ width: 0;
+ height: 100%;
+ background: #2f6fed;
+ transition: width .35s ease;
+ }
+ .install-progress-meta { margin-top: 8px; color: #707c90; font-size: 12px; }
+ .install-console { margin-top: 14px; overflow: hidden; border: 1px solid #28364a; border-radius: 6px; background: #182233; }
+ .install-console-head { min-height: 36px; padding: 0 12px; border-bottom: 1px solid #314158; background: #111a28; }
+ .install-console-title { color: #dce5f2; font-size: 12px; font-weight: 650; }
+ .install-console-state { color: #7dd3a8; font-size: 12px; }
+ .install-log {
+ height: 148px;
+ margin: 0;
+ overflow: auto;
+ padding: 10px 12px;
+ color: #c9d5e5;
+ font: 12px/1.65 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
+ letter-spacing: 0;
+ overflow-wrap: anywhere;
+ white-space: pre-wrap;
+ }
.complete-mark {
width: 58px;
height: 58px;
@@ -220,6 +258,8 @@
.summary-row { grid-template-columns: 1fr; gap: 5px; }
.actions { flex-wrap: wrap-reverse; }
.btn { flex: 1 1 120px; }
+ .install-progress-meta { align-items: flex-start; flex-direction: column; gap: 4px; }
+ .install-log { height: 172px; }
}
@@ -299,6 +339,23 @@
@@ -323,7 +380,18 @@
history.replaceState(null, '', clean);
}
- const state = { step: 1, database: null, databaseFingerprint: '', administrator: null, installing: false };
+ const state = {
+ step: 1,
+ database: null,
+ databaseFingerprint: '',
+ administrator: null,
+ installing: false,
+ installStartedAt: 0,
+ installPercent: 0,
+ installLogEntries: [],
+ healthPolling: false,
+ };
+ const FORMAL_SERVICE_TIMEOUT_MS = 240000;
const $ = (selector) => document.querySelector(selector);
const $$ = (selector) => Array.from(document.querySelectorAll(selector));
@@ -421,24 +489,95 @@
});
}
+ function formatElapsed(milliseconds) {
+ const seconds = Math.max(0, Math.floor(milliseconds / 1000));
+ if (seconds < 60) return `${seconds} 秒`;
+ return `${Math.floor(seconds / 60)} 分 ${seconds % 60} 秒`;
+ }
+
+ function setInstallMeter(percent, phase, detail) {
+ const value = Math.max(state.installPercent, Math.max(0, Math.min(100, Math.round(percent))));
+ state.installPercent = value;
+ $('#install-progress-bar').style.width = `${value}%`;
+ $('#install-percent').textContent = `${value}%`;
+ $('#install-phase').textContent = phase;
+ $('#install-check').textContent = detail;
+ $('#install-elapsed').textContent = `本页已等待 ${formatElapsed(Date.now() - state.installStartedAt)}`;
+ const track = $('#install-progress-track');
+ track.setAttribute('aria-valuenow', String(value));
+ track.setAttribute('aria-valuetext', `${phase},${value}%`);
+ }
+
+ function appendInstallLog(message) {
+ const timestamp = new Date().toLocaleTimeString('zh-CN', { hour12: false });
+ state.installLogEntries.push(`[${timestamp}] ${message}`);
+ state.installLogEntries = state.installLogEntries.slice(-16);
+ const output = $('#install-log');
+ output.textContent = state.installLogEntries.join('\n');
+ output.scrollTop = output.scrollHeight;
+ }
+
+ function beginInstallTracking(resumed) {
+ state.installStartedAt = Date.now();
+ state.installLogEntries = [];
+ $('#install-message').className = 'notice info';
+ $('#install-console-state').textContent = '运行中';
+ if (resumed) {
+ setInstallMeter(70, '恢复安装任务', '准备检查正式服务');
+ appendInstallLog('检测到尚未完成的安装任务,继续等待正式服务。');
+ } else {
+ setInstallMeter(5, '提交安装任务', '准备验证数据库');
+ appendInstallLog('安装请求已提交,正在验证 PostgreSQL 连接和权限。');
+ }
+ }
+
async function pollFormalService() {
+ if (state.healthPolling) return;
+ state.healthPolling = true;
+ if (!state.installStartedAt) beginInstallTracking(true);
setProgress(4);
- $('#install-message').textContent = '初始化完成,正在等待正式服务通过健康检查。';
- const deadline = Date.now() + 240000;
+ setInstallMeter(Math.max(state.installPercent, 72), '启动正式服务', '等待第 1 次健康检查');
+ appendInstallLog('数据库初始化完成,安装器正在切换到正式服务。');
+ $('#install-message').textContent = '初始化完成,正在等待正式服务通过健康检查。页面会持续显示检查进度。';
+ const healthStartedAt = Date.now();
+ const deadline = healthStartedAt + FORMAL_SERVICE_TIMEOUT_MS;
+ let attempt = 0;
while (Date.now() < deadline) {
await new Promise((resolve) => setTimeout(resolve, 2000));
+ attempt += 1;
+ const healthElapsed = Date.now() - healthStartedAt;
+ const estimated = 72 + Math.floor((healthElapsed / FORMAL_SERVICE_TIMEOUT_MS) * 23);
+ setInstallMeter(Math.min(95, estimated), '等待正式服务健康检查', `第 ${attempt} 次检查`);
try {
const response = await fetch('/api/oa/health', { cache: 'no-store' });
const body = await response.json();
- if (response.ok && (body?.data?.status === 'UP' || body?.status === 'UP')) {
+ const serviceStatus = body?.data?.status || body?.status || `HTTP ${response.status}`;
+ const installLocked = body?.data?.installLocked ?? body?.installLocked;
+ if (response.ok && serviceStatus === 'UP' && (installLocked === true || installLocked === 'true')) {
+ setInstallMeter(100, '安装完成', '正式服务已就绪');
+ appendInstallLog(`健康检查第 ${attempt} 次通过,安装锁已确认。`);
+ $('#install-console-state').textContent = '已完成';
$$('[data-progress]').forEach((item) => { item.classList.remove('running'); item.classList.add('done'); });
$('#installing-view').classList.add('hidden');
$('#complete-view').classList.remove('hidden');
sessionStorage.removeItem('kaidi.setup.token');
+ state.healthPolling = false;
return;
}
- } catch { /* Service is changing from installer to the formal application. */ }
+ if (response.ok && serviceStatus === 'UP') {
+ setInstallMeter(98, '正式服务已通过健康检查', '等待写入安装锁');
+ appendInstallLog(`健康检查第 ${attempt} 次通过,正在等待后台写入安装锁。`);
+ continue;
+ }
+ appendInstallLog(`健康检查第 ${attempt} 次:服务状态 ${serviceStatus}。`);
+ } catch {
+ appendInstallLog(`健康检查第 ${attempt} 次:服务端口正在切换,继续等待。`);
+ }
}
+ state.healthPolling = false;
+ setInstallMeter(95, '正式服务尚未就绪', `${Math.round(FORMAL_SERVICE_TIMEOUT_MS / 1000)} 秒检查超时`);
+ appendInstallLog('健康检查超时,安装状态已保留,未写入完成锁。');
+ $('#install-console-state').textContent = '需要检查';
$('#install-message').className = 'notice error';
$('#install-message').textContent = '正式服务尚未就绪。请查看 systemctl 日志或 /var/lib/kaidi-erp/install-formal.log;安装文件和待确认状态均已保留。';
}
@@ -506,10 +645,16 @@
state.installing = true;
$('#confirm-view').classList.add('hidden');
$('#installing-view').classList.remove('hidden');
+ beginInstallTracking(false);
setProgress(1);
+ let migrationTimer;
try {
const request = { database: state.database, administrator: state.administrator };
- setTimeout(() => setProgress(2), 700);
+ migrationTimer = setTimeout(() => {
+ setProgress(2);
+ setInstallMeter(28, '执行数据库初始化', '迁移结构和基础数据');
+ appendInstallLog('数据库连接验证通过,正在执行结构迁移和基础数据初始化。');
+ }, 700);
try {
await api('/api/install/complete', { method: 'POST', body: JSON.stringify(request) });
} catch (error) {
@@ -518,12 +663,20 @@
// is flushed; the formal health probe is the authoritative result.
if (!(error instanceof TypeError) && error.message !== '安装服务响应无效') throw error;
}
+ clearTimeout(migrationTimer);
setProgress(3);
+ setInstallMeter(68, '创建管理员并切换服务', '数据库初始化已完成');
+ appendInstallLog('数据库迁移和管理员初始化已提交,准备启动正式服务。');
state.administrator.password = '';
state.database.password = '';
await pollFormalService();
} catch (error) {
+ clearTimeout(migrationTimer);
state.installing = false;
+ state.healthPolling = false;
+ setInstallMeter(state.installPercent, '安装已中断', '请处理错误后重试');
+ appendInstallLog(`安装中断:${error.message}`);
+ $('#install-console-state').textContent = '已中断';
$('#install-message').className = 'notice error';
$('#install-message').textContent = error.message;
const actions = document.createElement('div');
@@ -548,6 +701,7 @@
go(4);
$('#confirm-view').classList.add('hidden');
$('#installing-view').classList.remove('hidden');
+ beginInstallTracking(true);
return pollFormalService();
}
$('#java-detail').textContent = `Java ${status.javaVersion},最低要求 Java ${status.minimumJava}`;
diff --git a/oa-backend/src/main/java/com/kaidi/oa/web/HealthController.java b/oa-backend/src/main/java/com/kaidi/oa/web/HealthController.java
index a02b79a..20243d3 100644
--- a/oa-backend/src/main/java/com/kaidi/oa/web/HealthController.java
+++ b/oa-backend/src/main/java/com/kaidi/oa/web/HealthController.java
@@ -1,6 +1,7 @@
package com.kaidi.oa.web;
import com.kaidi.oa.common.ApiResp;
+import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
@@ -8,6 +9,9 @@ import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import javax.sql.DataSource;
+import java.nio.file.Files;
+import java.nio.file.InvalidPathException;
+import java.nio.file.Path;
import java.sql.Connection;
import java.sql.ResultSet;
import java.sql.Statement;
@@ -19,9 +23,12 @@ import java.util.Map;
public class HealthController {
private final DataSource dataSource;
+ private final Path installLockFile;
- public HealthController(DataSource dataSource) {
+ public HealthController(DataSource dataSource,
+ @Value("${ERP_INSTALL_LOCK_FILE:}") String installLockFile) {
this.dataSource = dataSource;
+ this.installLockFile = resolveInstallLockFile(installLockFile);
}
@GetMapping("/health")
@@ -30,12 +37,31 @@ public class HealthController {
Statement statement = connection.createStatement();
ResultSet result = statement.executeQuery("SELECT 1")) {
if (connection.isValid(2) && result.next() && result.getInt(1) == 1) {
- return ResponseEntity.ok(ApiResp.ok(Map.of("status", "UP", "database", "UP")));
+ return ResponseEntity.ok(ApiResp.ok(readiness("UP", "UP")));
}
} catch (Exception ignored) {
// Health responses deliberately avoid exposing connection details.
}
return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE)
- .body(new ApiResp<>(50301, "database unavailable", Map.of("status", "DOWN", "database", "DOWN")));
+ .body(new ApiResp<>(50301, "database unavailable", readiness("DOWN", "DOWN")));
+ }
+
+ private Map
readiness(String status, String database) {
+ return Map.of(
+ "status", status,
+ "database", database,
+ "installLocked", Boolean.toString(installLockFile != null && Files.isRegularFile(installLockFile))
+ );
+ }
+
+ private static Path resolveInstallLockFile(String value) {
+ if (value == null || value.isBlank()) {
+ return null;
+ }
+ try {
+ return Path.of(value).toAbsolutePath().normalize();
+ } catch (InvalidPathException ignored) {
+ return null;
+ }
}
}
diff --git a/oa-backend/src/main/resources/application.yml b/oa-backend/src/main/resources/application.yml
index 8f460f8..0a77272 100644
--- a/oa-backend/src/main/resources/application.yml
+++ b/oa-backend/src/main/resources/application.yml
@@ -1,5 +1,8 @@
server:
port: 8090
+ # Reconstruct the public scheme/host when running behind Nginx, Caddy or a
+ # tunnel so same-origin HTTPS requests are not rejected as cross-origin.
+ forward-headers-strategy: framework
spring:
application:
diff --git a/oa-backend/src/test/java/com/kaidi/oa/web/HealthControllerTest.java b/oa-backend/src/test/java/com/kaidi/oa/web/HealthControllerTest.java
new file mode 100644
index 0000000..3859c7b
--- /dev/null
+++ b/oa-backend/src/test/java/com/kaidi/oa/web/HealthControllerTest.java
@@ -0,0 +1,56 @@
+package com.kaidi.oa.web;
+
+import com.kaidi.oa.common.ApiResp;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+import org.springframework.http.ResponseEntity;
+
+import javax.sql.DataSource;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.sql.Connection;
+import java.sql.ResultSet;
+import java.sql.Statement;
+import java.util.Map;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+class HealthControllerTest {
+
+ @TempDir
+ Path tempDir;
+
+ @Test
+ void reportsTheActualInstallationLockState() throws Exception {
+ DataSource dataSource = mock(DataSource.class);
+ Connection connection = mock(Connection.class);
+ Statement statement = mock(Statement.class);
+ ResultSet resultSet = mock(ResultSet.class);
+ when(dataSource.getConnection()).thenReturn(connection);
+ when(connection.createStatement()).thenReturn(statement);
+ when(connection.isValid(2)).thenReturn(true);
+ when(statement.executeQuery("SELECT 1")).thenReturn(resultSet);
+ when(resultSet.next()).thenReturn(true);
+ when(resultSet.getInt(1)).thenReturn(1);
+
+ Path lockFile = tempDir.resolve("install.lock");
+ HealthController controller = new HealthController(dataSource, lockFile.toString());
+
+ assertThat(healthData(controller.health())).containsEntry("installLocked", "false");
+
+ Files.writeString(lockFile, "locked\n");
+
+ assertThat(healthData(controller.health()))
+ .containsEntry("status", "UP")
+ .containsEntry("database", "UP")
+ .containsEntry("installLocked", "true");
+ }
+
+ private static Map healthData(
+ ResponseEntity>> response) {
+ assertThat(response.getBody()).isNotNull();
+ return response.getBody().data();
+ }
+}
diff --git a/tests/release-scripts.test.sh b/tests/release-scripts.test.sh
index 5065261..8a7578a 100755
--- a/tests/release-scripts.test.sh
+++ b/tests/release-scripts.test.sh
@@ -692,6 +692,23 @@ test_installer_runs_when_piped_to_bash() (
[[ "$output" != *'BASH_SOURCE'* ]]
)
+test_installer_shows_live_health_progress() (
+ local page="$PROJECT_ROOT/oa-backend/src/installer/resources/static/index.html"
+ grep -Fq 'role="progressbar"' "$page"
+ grep -Fq 'id="install-log"' "$page"
+ grep -Fq 'Math.min(95, estimated)' "$page"
+ grep -Fq '健康检查第 ${attempt} 次' "$page"
+ grep -Fq '本页已等待 ${formatElapsed' "$page"
+ grep -Fq 'installLocked' "$page"
+)
+
+test_app_honors_reverse_proxy_headers() (
+ grep -Fqx ' forward-headers-strategy: framework' \
+ "$PROJECT_ROOT/oa-backend/src/main/resources/application.yml"
+ grep -Fqx ' forward-headers-strategy: framework' \
+ "$PROJECT_ROOT/oa-backend/src/installer/resources/application.yml"
+)
+
run_test 'erp-run preserves Java option arguments' test_erp_run_preserves_java_option_arguments
run_test 'erp-run finalizes a healthy pending installation' test_erp_run_finalizes_healthy_pending_install
run_test 'erp-run preserves a failed pending installation' test_erp_run_preserves_failed_pending_install
@@ -713,6 +730,8 @@ run_test 'uninstaller aborts while the service remains active' test_uninstaller_
run_test 'no-service install disables online update' test_no_service_install_disables_online_update
run_test 'release workflow uses the scoped Gitea job token' test_release_workflow_uses_scoped_job_token
run_test 'installer starts correctly when piped to bash' test_installer_runs_when_piped_to_bash
+run_test 'installer shows live formal-service health progress' test_installer_shows_live_health_progress
+run_test 'application honors standard reverse-proxy headers' test_app_honors_reverse_proxy_headers
printf 'RESULT pass=%s fail=%s\n' "$PASS" "$FAIL"
[[ "$FAIL" -eq 0 ]]