实现 Pingora 发行网关路由并关闭两处路由漂移待办
Project CI / AI game creator shell Rust crates (push) Failing after 1m17s
Project CI / AI game creator shell Rust smoke (push) Successful in 2m1s
Project CI / AI game creator shell Rust lane 2/2 (push) Has been cancelled
Project CI / Backend tests (push) Has been cancelled
Project CI / Frontend tests (push) Has been cancelled
Project CI / Repository checks (push) Has been cancelled
Project CI / AI game creator shell web tests (push) Has been cancelled
Project CI / AI game creator shell Rust lane 1/2 (push) Has been cancelled
Project CI / Native shell tests (push) Has been cancelled

- 新增 RouteDecision::ReleaseGateway:/games/game_<32 位小写十六进制 id>/… 重写上游路径到 /api/game-distribution/releases/<gameId><asset>、清空 Cookie、不进 SPA fallback、不套接流保护分组、不受维护闸拦截,与 nginx 同名 location 同口径(只认小写 32 位十六进制)
- 路由矩阵新增 games_release_gateway 用例(含 upstreamPath 期望值)与对应断言;parity 门禁支持 release_gateway 并把 games_spa_fallback、games_release_gateway 列入必查清单
- check:pingora-gateway-smoke 新增三条运行时断言:重写到发行网关且上游拿不到 Cookie、/games/detail 仍走 SPA、/games/game/index.html 仍是真实 404
- 文档:Pingora 试点文档补「平台同源发行入口」语义;pitfalls 记录 SPA allowlist 三处真相源与「发行入口不是 SPA」;按 docs/project-memory/README.md 删除两份已关闭待办,关闭记录写入开放事项索引第五节
- 验证:网关 39 passed、check:pingora-route-parity OK(23 路由)、check:nginx-spa-routes OK(12 路由 / 3 模板)、check:pingora-gateway-smoke 通过、cargo fmt --all --check、check:production-ops、encoding / doc-index / diff 检查全绿;变异验证:矩阵 upstreamPath 写错或拿掉 /games/detail 都会立刻变红
This commit is contained in:
kdletters
2026-09-29 07:24:52 +08:00
parent 16ff10111f
commit 5fee115f10
9 changed files with 202 additions and 84 deletions
@@ -849,6 +849,12 @@ enum RouteDecision {
target: ProxyTarget,
body_limit: Option<u64>,
},
/// 平台同源发行入口:`/games/game_<32 位小写十六进制 id>(/<asset>)?`。
/// 与 Nginx 的同名 location 同口径:走 api 上游,但在代理阶段把路径重写成
/// `/api/game-distribution/releases/<gameId><asset 路径>` 并清空 Cookie。
ReleaseGateway {
upstream_path: String,
},
Local(LocalResponse),
}
@@ -879,6 +885,8 @@ impl RouteDecision {
fn proxy_target(&self) -> Option<ProxyTarget> {
match self {
RouteDecision::Proxy { target, .. } => Some(*target),
// 发行入口同样代理到 api 上游,只有路径与请求头在代理阶段被重写。
RouteDecision::ReleaseGateway { .. } => Some(ProxyTarget::Api),
RouteDecision::Local(_) => None,
}
}
@@ -886,6 +894,8 @@ impl RouteDecision {
fn body_limit(&self) -> Option<u64> {
match self {
RouteDecision::Proxy { body_limit, .. } => *body_limit,
// 发行入口只服务静态资源读取,Nginx 侧也没有请求体上限指令。
RouteDecision::ReleaseGateway { .. } => None,
RouteDecision::Local(_) => None,
}
}
@@ -1182,6 +1192,7 @@ impl ProxyHttp for GenarrativeGateway {
match &ctx.route {
RouteDecision::Proxy { .. } => Ok(false),
RouteDecision::ReleaseGateway { .. } => Ok(false),
RouteDecision::Local(LocalResponse::RedirectPermanent { location }) => {
respond_redirect(session, location).await?;
Ok(true)
@@ -1296,6 +1307,14 @@ impl ProxyHttp for GenarrativeGateway {
upstream_request.insert_header("X-Forwarded-For", forwarded_for.as_str())?;
}
// 中文注释:平台同源发行入口按 Nginx 的 proxy_pass 口径重写路径——换成
// /api/game-distribution/releases/<gameId><asset 路径>,原来的 query 不再拼接;
// 同时清空 Cookie,发行内容不读账号凭证。
if let RouteDecision::ReleaseGateway { upstream_path } = &ctx.route {
upstream_request.set_raw_path(upstream_path.as_bytes())?;
upstream_request.remove_header("cookie");
}
// 中文注释:SpacetimeDB 订阅走 WebSocket Upgrade;普通 API 连接头保持干净,贴近当前 Nginx 模板。
if ctx.route.proxy_target() == Some(ProxyTarget::Spacetime) && is_upgrade_request(session) {
upstream_request.insert_header("Connection", "Upgrade")?;
@@ -1623,6 +1642,31 @@ fn is_generic_api_proxy_path(path: &str) -> bool {
path == "/api" || path.starts_with("/api/")
}
/// 平台同源发行入口:`/games/game_<32 位小写十六进制 id>` 可选跟一段 `/…` 资源路径。
///
/// 与 Nginx 的 `location ~ "^/games/(?<game_id>game_[0-9a-f]{32})(?<game_path>/.*)?$"` 同口径:
/// 只认小写十六进制、固定 32 位,不做大小写放宽;命中后上游路径是
/// `/api/game-distribution/releases/<gameId><asset 路径>`,其余返回 `None` 交给后面的 SPA / 静态分支。
fn release_gateway_upstream_path(path: &str) -> Option<String> {
let rest = path.strip_prefix("/games/")?;
let (game_id, asset_path) = match rest.find('/') {
Some(index) => (&rest[..index], &rest[index..]),
None => (rest, ""),
};
let hex = game_id.strip_prefix("game_")?;
if hex.len() != 32
|| !hex
.bytes()
.all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
{
return None;
}
Some(format!(
"/api/game-distribution/releases/{game_id}{asset_path}"
))
}
fn protection_class_for_route(route: &RouteDecision, path: &str) -> Option<ProtectionClass> {
match route {
RouteDecision::Proxy {
@@ -1641,6 +1685,8 @@ fn protection_class_for_route(route: &RouteDecision, path: &str) -> Option<Prote
target: ProxyTarget::Gitea,
..
} => None,
// 发行入口在 Nginx 侧没有任何 limit_conn / limit_req 指令,保持同口径。
RouteDecision::ReleaseGateway { .. } => None,
RouteDecision::Local(_) => None,
}
}
@@ -1868,6 +1914,10 @@ fn classify_path(path: &str) -> RouteDecision {
return RouteDecision::Local(LocalResponse::NotFound);
}
if let Some(upstream_path) = release_gateway_upstream_path(path) {
return RouteDecision::ReleaseGateway { upstream_path };
}
if path.starts_with("/admin/assets/") || path.starts_with("/assets/") {
return RouteDecision::Local(LocalResponse::Static {
root: StaticRoot::Web,
@@ -3065,6 +3115,7 @@ mod tests {
mode: Option<String>,
location: Option<String>,
protection_class: Option<String>,
upstream_path: Option<String>,
}
#[derive(Deserialize)]
@@ -3108,6 +3159,15 @@ mod tests {
case.sample_path
);
}
("release_gateway", RouteDecision::ReleaseGateway { upstream_path }) => {
assert_eq!(
Some(upstream_path.as_str()),
case.expect.upstream_path.as_deref(),
"route parity release gateway upstream path mismatch: {} {}",
case.id,
case.sample_path
);
}
(
"redirect_permanent",
RouteDecision::Local(LocalResponse::RedirectPermanent { location }),
@@ -3872,6 +3932,54 @@ mod tests {
}
}
#[test]
fn classifies_platform_same_origin_release_paths() {
let game_id = "game_0123456789abcdef0123456789abcdef";
assert_eq!(
release_gateway_upstream_path(&format!("/games/{game_id}/index.html")),
Some(format!(
"/api/game-distribution/releases/{game_id}/index.html"
))
);
// 不带资源路径(尾斜杠缺失)时与 Nginx 一样仍然命中,只是上游没有 asset 段。
assert_eq!(
release_gateway_upstream_path(&format!("/games/{game_id}")),
Some(format!("/api/game-distribution/releases/{game_id}"))
);
assert!(matches!(
classify_path(&format!("/games/{game_id}/assets/app.js")),
RouteDecision::ReleaseGateway { .. }
));
// 大小写、位数、字符集、以及「像但不是」的路径都不许被吞进发行网关。
for path in [
"/games/detail",
"/games/mine",
"/games/game_0123456789ABCDEF0123456789ABCDEF/index.html",
"/games/game_0123456789abcdef0123456789abcde/index.html",
"/games/game_0123456789abcdef0123456789abcdef0/index.html",
"/games/game_zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz/index.html",
"/games/game/index.html",
] {
assert_eq!(release_gateway_upstream_path(path), None, "path: {path}");
}
// 这些路径仍然按 SPA / 精确静态分类,不受发行入口影响。
assert_eq!(
classify_path("/games/detail"),
RouteDecision::Local(LocalResponse::Static {
root: StaticRoot::Web,
mode: StaticMode::SpaFallback,
})
);
assert_eq!(
classify_path("/games/game/index.html"),
RouteDecision::Local(LocalResponse::Static {
root: StaticRoot::Web,
mode: StaticMode::Exact,
})
);
}
#[test]
fn protection_rejects_when_concurrency_is_exhausted() {
let config = ProtectionConfig {