diff --git a/apps/preview-deployer-web/src/PreviewDeployerApp.test.tsx b/apps/preview-deployer-web/src/PreviewDeployerApp.test.tsx
index 4a366fdaa..29a4dbb5d 100644
--- a/apps/preview-deployer-web/src/PreviewDeployerApp.test.tsx
+++ b/apps/preview-deployer-web/src/PreviewDeployerApp.test.tsx
@@ -59,6 +59,32 @@ test('requires an access token before showing deployments', async () => {
expect(await screen.findByText('构建并发布一个分支')).toBeTruthy();
});
+test('prefers the public preview domain when the control plane exposes one', async () => {
+ vi.mocked(api.listDeployments).mockResolvedValue([
+ {
+ id: '77',
+ branch: 'feature/public-preview',
+ resolvedCommit: '1234567890abcdef',
+ status: 'running',
+ health: 'healthy',
+ webPort: 8400,
+ webUrl: 'http://192.168.35.82:8400',
+ webPublicUrl:
+ 'https://preview-63d38d3da6bc9b06.preview.genarrative.world',
+ createdAt: 1_787_270_400,
+ updatedAt: 1_787_270_460,
+ },
+ ]);
+ render();
+
+ const publicLink = await screen.findByRole('link', {
+ name: /打开公网预览/u,
+ });
+ expect(publicLink.getAttribute('href')).toBe(
+ 'https://preview-63d38d3da6bc9b06.preview.genarrative.world',
+ );
+});
+
test('submits a branch with an optional commit hash', async () => {
const user = userEvent.setup();
render();
diff --git a/apps/preview-deployer-web/src/PreviewDeployerApp.tsx b/apps/preview-deployer-web/src/PreviewDeployerApp.tsx
index ec5e30773..ea23c3836 100644
--- a/apps/preview-deployer-web/src/PreviewDeployerApp.tsx
+++ b/apps/preview-deployer-web/src/PreviewDeployerApp.tsx
@@ -797,6 +797,16 @@ function DeploymentCard({
+ {deployment.webUrl && deployment.webPublicUrl ? (
+
+ 打开公网预览
+
+ ) : null}
{deployment.webUrl ? (
.<后缀>,留空则只展示内网地址。
+GENARRATIVE_PREVIEW_DEPLOYER_WEB_DOMAIN=preview.genarrative.world
GENARRATIVE_PREVIEW_DEPLOYER_STATE_FILE=/var/lib/genarrative/preview-deployer/state.json
GENARRATIVE_PREVIEW_DEPLOYER_STATIC_DIR=/opt/genarrative/preview-deployer/web
GENARRATIVE_PREVIEW_DEPLOYER_SECURE_COOKIE=false
diff --git a/server-rs/crates/preview-deployer-server/src/config.rs b/server-rs/crates/preview-deployer-server/src/config.rs
index f693697bb..ff8d637ab 100644
--- a/server-rs/crates/preview-deployer-server/src/config.rs
+++ b/server-rs/crates/preview-deployer-server/src/config.rs
@@ -18,6 +18,7 @@ pub struct Config {
pub allowed_hosts: Vec,
pub allowed_origins: Vec,
pub preview_web_host: String,
+ pub preview_web_domain: Option,
pub secure_cookie: bool,
pub static_dir: Option,
pub state_file: PathBuf,
@@ -49,6 +50,7 @@ impl fmt::Debug for Config {
.field("allowed_hosts", &self.allowed_hosts)
.field("allowed_origins", &self.allowed_origins)
.field("preview_web_host", &self.preview_web_host)
+ .field("preview_web_domain", &self.preview_web_domain)
.field("secure_cookie", &self.secure_cookie)
.field("static_dir", &self.static_dir)
.field("state_file", &self.state_file)
@@ -116,6 +118,15 @@ impl Config {
.to_string(),
);
}
+ let preview_web_domain = env::var("GENARRATIVE_PREVIEW_DEPLOYER_WEB_DOMAIN")
+ .ok()
+ .map(|value| value.trim().to_string())
+ .filter(|value| !value.is_empty())
+ .map(|value| {
+ validate_preview_web_domain(&value)
+ .map_err(|reason| format!("GENARRATIVE_PREVIEW_DEPLOYER_WEB_DOMAIN {reason}"))
+ })
+ .transpose()?;
for origin in &allowed_origins {
let parsed =
Url::parse(origin).map_err(|_| format!("无效 allowed origin: {origin}"))?;
@@ -162,6 +173,7 @@ impl Config {
allowed_hosts,
allowed_origins,
preview_web_host,
+ preview_web_domain,
secure_cookie,
static_dir,
state_file,
@@ -217,6 +229,28 @@ fn validate_state_file(path: &std::path::Path) -> Result<(), String> {
Ok(())
}
+pub(crate) fn validate_preview_web_domain(value: &str) -> Result {
+ if value.len() > 253 || !value.contains('.') {
+ return Err("必须是不带协议和端口的 DNS 域名".to_string());
+ }
+ for label in value.split('.') {
+ if label.is_empty() || label.len() > 63 {
+ return Err("必须是不带协议和端口的 DNS 域名".to_string());
+ }
+ let bytes = label.as_bytes();
+ if bytes[0] == b'-' || bytes[bytes.len() - 1] == b'-' {
+ return Err("每段标签不能以短横线开头或结尾".to_string());
+ }
+ if !bytes
+ .iter()
+ .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || *byte == b'-')
+ {
+ return Err("只允许小写字母、数字、短横线和点号".to_string());
+ }
+ }
+ Ok(value.to_string())
+}
+
fn required(name: &str) -> Result {
env::var(name)
.ok()
diff --git a/server-rs/crates/preview-deployer-server/src/lib.rs b/server-rs/crates/preview-deployer-server/src/lib.rs
index 223255b1b..e0165799b 100644
--- a/server-rs/crates/preview-deployer-server/src/lib.rs
+++ b/server-rs/crates/preview-deployer-server/src/lib.rs
@@ -115,6 +115,8 @@ pub struct Deployment {
pub web_port: Option,
#[serde(skip_serializing_if = "Option::is_none")]
pub web_url: Option,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub web_public_url: Option,
#[serde(skip_serializing_if = "Option::is_none")]
pub jenkins_build_url: Option,
pub created_at: u64,
@@ -633,6 +635,7 @@ async fn create_deployment(
health: HealthStatus::Pending,
web_port: None,
web_url: None,
+ web_public_url: None,
jenkins_build_url: None,
created_at: now,
updated_at: now,
@@ -959,6 +962,14 @@ async fn apply_outcome(state: &AppState, id: &str, outcome: JenkinsOutcome) {
if let Some(value) = result.web_port {
record.public.web_port = Some(value);
}
+ record.public.web_public_url = if record.public.web_url.is_some() {
+ public_web_url(
+ state.config.preview_web_domain.as_deref(),
+ &record.instance_id,
+ )
+ } else {
+ None
+ };
if let Some(value) = result.health {
record.public.health = value;
}
@@ -1005,6 +1016,7 @@ async fn apply_outcome(state: &AppState, id: &str, outcome: JenkinsOutcome) {
record.public.health = HealthStatus::Unknown;
record.public.web_port = None;
record.public.web_url = None;
+ record.public.web_public_url = None;
record.public.can_uninstall = false;
if record.public.message.is_none() {
record.public.message = Some("预览实例已卸载".to_string());
@@ -1090,6 +1102,11 @@ fn load_deployments(config: &Config) -> Result
if record.public.web_port.is_none() {
record.public.web_port = url_port;
}
+ record.public.web_public_url = if record.public.web_url.is_some() {
+ public_web_url(config.preview_web_domain.as_deref(), &record.instance_id)
+ } else {
+ None
+ };
if deployments
.insert(record.instance_id.clone(), record)
.is_some()
@@ -1326,6 +1343,16 @@ fn map_artifact_health(value: &str) -> Option {
}
}
+// 公网入口由控制面自己派生,主机名固定为「实例 ID + 配置的预览域名」,
+// 不接受 Jenkins 产物或状态文件提供的任意地址。
+fn public_web_url(domain: Option<&str>, instance_id: &str) -> Option {
+ let domain = domain?;
+ if validate_deployment_id(instance_id).is_err() {
+ return None;
+ }
+ Some(format!("https://{instance_id}.{domain}"))
+}
+
fn is_safe_web_url(value: &str, expected_host: &str) -> bool {
url::Url::parse(value).is_ok_and(|url| {
url.scheme() == "http"
diff --git a/server-rs/crates/preview-deployer-server/src/tests.rs b/server-rs/crates/preview-deployer-server/src/tests.rs
index 98b2b5511..7874d5169 100644
--- a/server-rs/crates/preview-deployer-server/src/tests.rs
+++ b/server-rs/crates/preview-deployer-server/src/tests.rs
@@ -171,6 +171,7 @@ fn test_config(jenkins_base_url: Url) -> Config {
allowed_hosts: vec![HOST.to_string()],
allowed_origins: vec![ORIGIN.to_string()],
preview_web_host: "192.168.35.82".to_string(),
+ preview_web_domain: Some("preview.genarrative.world".to_string()),
secure_cookie: false,
static_dir: None,
state_file: std::env::temp_dir().join(format!(
@@ -378,6 +379,10 @@ async fn deploy_and_uninstall_use_fixed_job_and_apply_owned_artifacts() {
);
assert_eq!(deployment.health, super::HealthStatus::Healthy);
assert_eq!(deployment.web_port, Some(8400));
+ assert_eq!(
+ deployment.web_public_url.as_deref(),
+ Some("https://preview-63d38d3da6bc9b06.preview.genarrative.world")
+ );
assert_eq!(
deployment.web_url.as_deref(),
Some("http://192.168.35.82:8400")
@@ -436,6 +441,7 @@ async fn deploy_and_uninstall_use_fixed_job_and_apply_owned_artifacts() {
assert_eq!(deployment.health, super::HealthStatus::Unknown);
assert_eq!(deployment.web_port, None);
assert_eq!(deployment.web_url, None);
+ assert_eq!(deployment.web_public_url, None);
assert!(!deployment.can_uninstall);
let list_request = axum::http::Request::builder()
@@ -605,6 +611,7 @@ async fn duplicate_active_branch_is_rejected_without_second_jenkins_trigger() {
health: super::HealthStatus::Pending,
web_port: None,
web_url: None,
+ web_public_url: None,
jenkins_build_url: None,
created_at: now,
updated_at: now,
@@ -652,6 +659,40 @@ async fn wait_for_status(
Err(())
}
+#[test]
+fn public_web_url_is_derived_from_instance_id_and_configured_domain() {
+ assert_eq!(
+ super::public_web_url(
+ Some("preview.genarrative.world"),
+ "preview-63d38d3da6bc9b06"
+ )
+ .as_deref(),
+ Some("https://preview-63d38d3da6bc9b06.preview.genarrative.world")
+ );
+ assert_eq!(
+ super::public_web_url(None, "preview-63d38d3da6bc9b06"),
+ None
+ );
+ assert_eq!(
+ super::public_web_url(Some("preview.genarrative.world"), "feature/demo"),
+ None
+ );
+}
+
+#[test]
+fn preview_web_domain_config_is_strict() {
+ assert!(super::config::validate_preview_web_domain("preview.genarrative.world").is_ok());
+ assert!(super::config::validate_preview_web_domain("preview.genarrative.world:443").is_err());
+ assert!(
+ super::config::validate_preview_web_domain("https://preview.genarrative.world").is_err()
+ );
+ assert!(super::config::validate_preview_web_domain("Preview.Genarrative.World").is_err());
+ assert!(super::config::validate_preview_web_domain("preview").is_err());
+ assert!(super::config::validate_preview_web_domain("-preview.genarrative.world").is_err());
+ assert!(super::config::validate_preview_web_domain("preview..genarrative.world").is_err());
+ assert!(super::config::validate_preview_web_domain("").is_err());
+}
+
#[test]
fn branch_commit_and_web_url_validation_are_strict() {
assert!(super::validate_branch("feature/preview-ui").is_ok());
@@ -758,6 +799,7 @@ async fn expired_terminal_records_are_pruned_but_uninstallable_failures_are_reta
health: super::HealthStatus::Unknown,
web_port: None,
web_url: None,
+ web_public_url: None,
jenkins_build_url: None,
created_at: old,
updated_at: old,
@@ -781,6 +823,7 @@ async fn expired_terminal_records_are_pruned_but_uninstallable_failures_are_reta
health: super::HealthStatus::Unknown,
web_port: Some(8401),
web_url: Some("http://192.168.35.82:8401".to_string()),
+ web_public_url: None,
jenkins_build_url: None,
created_at: old,
updated_at: old,