diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/claude_code_cli.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/claude_code_cli.rs index 84fa98c2c..641ace7bb 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/claude_code_cli.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/claude_code_cli.rs @@ -212,7 +212,7 @@ async fn run_sidecar_turn( .take() .ok_or_else(|| "Claude Agent SDK sidecar stderr 未建立".to_string())?; let stderr_task = tokio::spawn(read_capped(stderr, CLAUDE_CODE_OUTPUT_MAX_BYTES)); - let result = tokio::time::timeout(Duration::from_millis(timeout_ms.max(1)), async { + let result = match tokio::time::timeout(Duration::from_millis(timeout_ms.max(1)), async { let mut lines = BufReader::new(stdout).lines(); let mut events = Vec::new(); let mut terminal = None; @@ -257,7 +257,21 @@ async fn run_sidecar_turn( Ok(SidecarTurnResult { events, result }) }) .await - .map_err(|_| "Claude Agent SDK sidecar 回合超时".to_string())??; + { + Ok(result) => result?, + Err(_) => { + let detail = stderr_task + .await + .ok() + .and_then(Result::ok) + .and_then(|bytes| String::from_utf8(bytes).ok()) + .map(|value| value.trim().chars().take(512).collect::()) + .filter(|value| !value.is_empty()) + .map(|value| format!(":{value}")) + .unwrap_or_default(); + return Err(format!("Claude Agent SDK sidecar 回合超时{detail}")); + } + }; let _ = stderr_task.await; Ok(result) } @@ -318,6 +332,14 @@ pub(crate) fn game_creator_claude_code_cli_route_error( if model.is_empty() || model.chars().count() > CLAUDE_CODE_MODEL_MAX_CHARS { return Some("cc 模式需要有效的 Claude Code 模型标识".to_string()); } + if llm.api_key.trim().is_empty() + && !llm.custom_enabled + && std::env::var_os("ANTHROPIC_API_KEY").is_none() + && std::env::var_os("ANTHROPIC_AUTH_TOKEN").is_none() + && crate::platform_session::current_platform_session().is_none() + { + return Some("cc 模式需要先登录陶泥儿账号".to_string()); + } None } @@ -386,12 +408,44 @@ fn configure_claude_code_environment( { command.env("ANTHROPIC_AUTH_TOKEN", llm.api_key.trim()); } + // Official AGC model entries use the account session rather than a + // user-supplied API key. The Claude SDK only reads Anthropic auth + // from its child-process environment, so bridge the already-held + // platform token without persisting or exposing it in argv/logs. + if llm.api_key.trim().is_empty() + && !llm.custom_enabled + && std::env::var_os("ANTHROPIC_API_KEY").is_none() + && std::env::var_os("ANTHROPIC_AUTH_TOKEN").is_none() + { + if let Some(session) = crate::platform_session::current_platform_session() { + command.env("ANTHROPIC_AUTH_TOKEN", session.access_token); + } + } if !llm.model.trim().is_empty() { command.env("ANTHROPIC_MODEL", llm.model.trim()); } } } +fn claude_result_error_detail(value: &serde_json::Value) -> Option { + let candidates = [ + value.get("result").and_then(serde_json::Value::as_str), + value.get("error").and_then(serde_json::Value::as_str), + value.get("message").and_then(serde_json::Value::as_str), + value + .get("errors") + .and_then(serde_json::Value::as_array) + .and_then(|errors| errors.first()) + .and_then(serde_json::Value::as_str), + ]; + candidates + .into_iter() + .flatten() + .map(str::trim) + .find(|detail| !detail.is_empty()) + .map(|detail| detail.chars().take(512).collect()) +} + fn configure_claude_code_process(command: &mut tokio::process::Command) { super::codex_cli::configure_game_creator_codex_cli_process(command); } @@ -496,9 +550,12 @@ fn parse_claude_code_result( platform_llm::LlmError::Deserialize("Claude Code JSON 输出无效".to_string()) })?; if value.get("is_error").and_then(serde_json::Value::as_bool) == Some(true) { - return Err(platform_llm::LlmError::Transport( - "Claude Code 返回失败终态".to_string(), - )); + let detail = claude_result_error_detail(&value) + .map(|detail| format!(":{detail}")) + .unwrap_or_default(); + return Err(platform_llm::LlmError::Transport(format!( + "Claude Code 返回失败终态{detail}" + ))); } let response_id = value .get("session_id") @@ -749,7 +806,10 @@ fn parse_direct_stream_result( } } else if event_type == Some("result") { if event.get("is_error").and_then(serde_json::Value::as_bool) == Some(true) { - return Err("Claude Code 返回失败终态".to_string()); + let detail = claude_result_error_detail(&event) + .map(|detail| format!(":{detail}")) + .unwrap_or_default(); + return Err(format!("Claude Code 返回失败终态{detail}")); } result = event .get("result") @@ -804,6 +864,16 @@ mod tests { assert_eq!(response.tool_calls[0].name, "runtime_tool_file_read"); } + #[test] + fn preserves_claude_terminal_error_detail() { + let error = parse_claude_code_result( + br#"{"is_error":true,"result":"Authentication failed"}"#, + &request(false), + ) + .expect_err("failed terminal must be rejected"); + assert!(error.to_string().contains("Authentication failed")); + } + #[test] fn parses_direct_stream_result_and_final_text() { let mut observed = Vec::new(); @@ -822,4 +892,16 @@ mod tests { [DirectCodexTurnObservation::AgentMessageSegment(_)] )); } + + #[test] + fn preserves_direct_stream_terminal_error_detail() { + let error = parse_direct_stream_result( + br#"{"type":"result","is_error":true,"result":"Authentication failed"} +"#, + "turn-1", + None, + ) + .expect_err("failed terminal must be rejected"); + assert!(error.contains("Authentication failed")); + } } diff --git a/apps/ai-game-creator-shell/src/features/app-shell/homeWebPreflight.ts b/apps/ai-game-creator-shell/src/features/app-shell/homeWebPreflight.ts index ff489639c..674160eb8 100644 --- a/apps/ai-game-creator-shell/src/features/app-shell/homeWebPreflight.ts +++ b/apps/ai-game-creator-shell/src/features/app-shell/homeWebPreflight.ts @@ -32,7 +32,10 @@ export function homeWebPreflightErrorMessage(value: unknown): string { function isHostPreflightError(value: unknown): boolean { if (!(value instanceof Error)) return false; - return value.message === HOME_WEB_PREFLIGHT_FAILURE || Boolean(knownPreflightCode(value.message)); + return ( + value.message === HOME_WEB_PREFLIGHT_FAILURE || + Boolean(knownPreflightCode(value.message)) + ); } export async function ensureHomeWebCreationEnvironment( diff --git a/apps/ai-game-creator-shell/tests/homeWebPreflight.test.tsx b/apps/ai-game-creator-shell/tests/homeWebPreflight.test.tsx index 39e0a32c3..e24a39d30 100644 --- a/apps/ai-game-creator-shell/tests/homeWebPreflight.test.tsx +++ b/apps/ai-game-creator-shell/tests/homeWebPreflight.test.tsx @@ -72,7 +72,9 @@ it('awaits the real home preflight before naming or creating a game', async () = it('blocks naming, creation, and first generation with the host error code', async () => { const { result, calls } = mount(async () => { - throw new Error('Web 游戏环境预检未通过:web-preflight-browser-failed;尚未启动生成'); + throw new Error( + 'Web 游戏环境预检未通过:web-preflight-browser-failed;尚未启动生成', + ); }); let status = ''; await act(async () => { @@ -81,7 +83,9 @@ it('blocks naming, creation, and first generation with the host error code', asy 'direct-build', ); }); - expect(status).toBe('Web 游戏环境预检未通过(web-preflight-browser-failed);尚未开始生成。'); + expect(status).toBe( + 'Web 游戏环境预检未通过(web-preflight-browser-failed);尚未开始生成。', + ); expect(calls).toEqual(['preflight_web_game_creation']); expect(result.current.currentProjectContext).toBeNull(); }); @@ -98,7 +102,10 @@ it('distinguishes an IPC failure from a host preflight failure', async () => { ); }); expect(status).toBe(HOME_WEB_PREFLIGHT_IPC_FAILURE); - expect(calls).toEqual(['preflight_web_game_creation', 'preflight_web_game_creation']); + expect(calls).toEqual([ + 'preflight_web_game_creation', + 'preflight_web_game_creation', + ]); expect(result.current.currentProjectContext).toBeNull(); }); diff --git a/deploy/nginx/genarrative.conf b/deploy/nginx/genarrative.conf index 981a7c932..ebf5d4520 100644 --- a/deploy/nginx/genarrative.conf +++ b/deploy/nginx/genarrative.conf @@ -12,6 +12,9 @@ upstream genarrative_api { } limit_conn_zone $binary_remote_addr zone=genarrative_api_conn:10m; +# 中文注释:公开作品架 / 自定义世界画廊的读取 QPS 远高于普通创作接口,单独使用高阈值限流 zone; +# 连接数上限仍复用 genarrative_api_conn,避免绕过 API 侧的整体连接保护。 +limit_req_zone $binary_remote_addr zone=genarrative_gallery_rps:10m rate=5000r/s; limit_req_zone $binary_remote_addr zone=genarrative_api_rps:10m rate=300r/s; limit_req_zone $binary_remote_addr zone=genarrative_admin_rps:10m rate=30r/s; @@ -135,6 +138,11 @@ server { try_files $uri =404; } + # 中文注释:以下为生产机 host-only 覆盖(内部工具页 / 官网页 / 公开画廊读取限流)。 + # 这些路径不属于平台共享发布产物,也不在 Pingora 路由矩阵覆盖范围内, + # 因此独立成 snippet:新增平台路由仍必须走矩阵 + Pingora 门禁。 + include /etc/nginx/snippets/genarrative-host-extras.conf; + # 临时兼容主站仍在使用的 /api/* HTTP facade;前端完成 SpacetimeDB SDK 迁移后删除。 location ~ ^/api(?:/|$) { diff --git a/deploy/nginx/snippets/genarrative-host-extras.conf b/deploy/nginx/snippets/genarrative-host-extras.conf new file mode 100644 index 000000000..e78971432 --- /dev/null +++ b/deploy/nginx/snippets/genarrative-host-extras.conf @@ -0,0 +1,152 @@ +# 生产机 host-only 覆盖:内部工具页、主站域名上的官网页入口、公开画廊读取限流。 +# 中文注释:这些路径依赖生产机本地静态根(/srv/genarrative/tools、/srv/genarrative/corporate-site-current), +# 不属于平台共享发布产物,因此不进入 deploy/pingora/nginx-route-parity.matrix.json 的 Pingora 路由矩阵。 +# 新增平台路由仍必须在主模板内声明并同步矩阵与 Pingora 网关;只有确认与网关无关的生产机专属路径才放这里。 +# Pingora 接公网 443 前,必须为这里的路径单独确认处理方式,否则这些页面与限流会在切换后丢失。 + +# 中文注释:内部工具静态页与主站 Web 根分离,页面产物放在 /srv/genarrative/tools/<页面>/; +# 这里只声明精确入口,且不接入维护窗口,避免把内部工具页并入公网维护开关。 +location = /finance-forecast { + return 301 /finance-forecast/; +} + +location ^~ /finance-forecast/ { + root /srv/genarrative/tools; + index index.html; + try_files $uri $uri/ /finance-forecast/index.html; +} + +location = /medical-science { + return 301 /medical-science/; +} + +location ^~ /medical-science/ { + root /srv/genarrative/tools; + index index.html; + try_files $uri $uri/ /medical-science/index.html; +} + +# 中文注释:公开作品架 / 自定义世界画廊读取接口使用专用高阈值限流(zone 在主模板 http 段声明); +# 其余 /api 请求继续走主模板的通用 location,保持默认保护强度。 +location = /api/runtime/puzzle/gallery { + default_type application/json; + limit_conn genarrative_api_conn 320; + limit_req zone=genarrative_gallery_rps burst=4096 nodelay; + + if ($genarrative_maintenance) { + return 503 '{"ok":false,"error":{"code":"MAINTENANCE","message":"服务维护中"}}'; + } + + proxy_pass http://genarrative_api; + proxy_http_version 1.1; + proxy_buffering off; + proxy_read_timeout 3600s; + proxy_send_timeout 3600s; + add_header X-Accel-Buffering no always; + proxy_set_header Connection ""; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Host $host; + proxy_set_header X-Request-Id $request_id; +} + +location = /api/runtime/custom-world-gallery { + default_type application/json; + limit_conn genarrative_api_conn 320; + limit_req zone=genarrative_gallery_rps burst=4096 nodelay; + + if ($genarrative_maintenance) { + return 503 '{"ok":false,"error":{"code":"MAINTENANCE","message":"服务维护中"}}'; + } + + proxy_pass http://genarrative_api; + proxy_http_version 1.1; + proxy_buffering off; + proxy_read_timeout 3600s; + proxy_send_timeout 3600s; + add_header X-Accel-Buffering no always; + proxy_set_header Connection ""; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Host $host; + proxy_set_header X-Request-Id $request_id; +} + +location ~ ^/api/runtime/puzzle/gallery/[^/]+$ { + default_type application/json; + limit_conn genarrative_api_conn 32; + limit_req zone=genarrative_api_rps burst=32 nodelay; + + if ($genarrative_maintenance) { + return 503 '{"ok":false,"error":{"code":"MAINTENANCE","message":"服务维护中"}}'; + } + + proxy_pass http://genarrative_api; + proxy_http_version 1.1; + proxy_buffering off; + proxy_read_timeout 3600s; + proxy_send_timeout 3600s; + add_header X-Accel-Buffering no always; + proxy_set_header Connection ""; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Host $host; + proxy_set_header X-Request-Id $request_id; +} + +location ~ ^/api/runtime/custom-world-gallery/[^/]+/[^/]+$ { + default_type application/json; + limit_conn genarrative_api_conn 32; + limit_req zone=genarrative_api_rps burst=32 nodelay; + + if ($genarrative_maintenance) { + return 503 '{"ok":false,"error":{"code":"MAINTENANCE","message":"服务维护中"}}'; + } + + proxy_pass http://genarrative_api; + proxy_http_version 1.1; + proxy_buffering off; + proxy_read_timeout 3600s; + proxy_send_timeout 3600s; + add_header X-Accel-Buffering no always; + proxy_set_header Connection ""; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Host $host; + proxy_set_header X-Request-Id $request_id; +} + +# BEGIN CORPORATE SITE HOME ROUTE +# 中文注释:主站域名保留清智创游官网首页入口,静态根与官网独立 vhost +# (/etc/nginx/conf.d/tsingnova-games.conf)共用同一份官网构建;只有 /home 与 /home/ 生效。 +# 该入口是 2026-08-11 官网拆分前的历史遗留,是否退役需要与官网侧一起确认; +# 退役时必须同时删除本块与线上对应 location,避免两侧再次漂移。 +location = /home { + error_page 503 /maintenance.html; + + if ($genarrative_maintenance) { + return 503; + } + + return 301 /home/; +} + +location ^~ /home/ { + error_page 503 /maintenance.html; + root /srv/genarrative/corporate-site-current; + + if ($genarrative_maintenance) { + return 503; + } + + try_files $uri $uri/ /home/index.html; +} +# END CORPORATE SITE HOME ROUTE diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 1dff250dc..f942f60e2 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -26,6 +26,13 @@ - 实现边界:新增私有 `game_distribution_review` 表和三条评价 API,游戏/账号组合主键保证唯一;可见性与写入同事务,分页与均分来自一致快照,作者资料读时关联账号。网站用游戏/账号上下文隔离草稿,以读取序号隔离旧成功、错误和加载结束,保存期间冻结输入。 - 权威入口:[平台入口与玩法链路](../../【玩法创作】平台入口与玩法链路-2026-05-15.md#网站游戏评分与评价合同);执行验收使用[独立里程碑](../plans/【里程碑】网站游戏评分与评价-2026-09-30.md),不得把既有发行或本次文档检查当作评价功能上线证据。 +## 2026-10-01 生产 Nginx 以模板为唯一来源:host-only 块独立成 snippet、退役路由不做显式 404 + +- 背景:线上主站 `genarrative.conf` 长期手工维护,`profile` 未进 SPA allowlist 导致 `/profile` 刷新 404,`client_max_body_size` 也停在 `64m`;同时线上存在 4 处仓库模板没有的 host-only 块(画廊读取限流、`/finance-forecast/`、`/medical-science/`、`/home/` 官网首页入口),直接用 `Genarrative-Server-Provision` 覆盖会静默删除它们。 +- 决策:生产 vhost 以 `deploy/nginx/genarrative.conf` 为唯一来源;生产机专属路径收进 `deploy/nginx/snippets/genarrative-host-extras.conf`,由主模板 include、由 Server-Provision 安装,新增平台路由仍必须在主模板内声明并同步 `deploy/pingora/nginx-route-parity.matrix.json` 与 Pingora 网关。snippet 内的 location 有意不在矩阵覆盖范围,Pingora 接公网 443 前必须单独确认这些路径的处理方式。 +- 边界:退役路由(`/match3d`、`/puzzle`、`/runtime/*`、`/gallery/*/detail`、`/works/detail`、`/worlds/detail`、`/bark-battle` 等)不再进 SPA allowlist,也不配置显式 404,统一落 `location /` 的 `error_page 404 /404.html`。`/home/` 是 2026-08-11 官网拆分前的历史入口,是否退役需与官网侧一起决定。 +- 验证方式:`node scripts/check-nginx-spa-routes.mjs`、`node scripts/check-pingora-route-parity.mjs`、`npm run check:production-ops`、`npm run check:encoding`、`git diff --check`、`bash -n scripts/jenkins-server-provision.sh`;线上改后按生效配置烟测 `/profile`、模板 12 条 SPA 路由、`/admin/`、`/home/`、`/finance-forecast/`、`/medical-science/`、画廊 API 与 `/games/game_/…` 发行网关。 + ## 2026-10-01 DirectProject 审批拒绝原因留痕 - 决策:宿主拒绝 app-server 的审批 / 交互请求时,原因必须落 AppData `RUST` 日志。稳定键 `agent.direct_codex.approval.denied`,字段为 `thread_id` / `method` / `reason` / `distinct_reasons`;未绑定宿主执行器时另记 `agent.direct_codex.interaction.no_adapter`(`method` / `outcome`,`outcome` 区分 `decline` / `empty-permissions` / `unsupported-method`);回包未送达另记 `agent.direct_codex.approval.response_delivery_failed`,`cause` 区分 `response-write-failed` 与 `turn-bind-mismatch`。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 2bb5a853b..817cf6a64 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -2,6 +2,13 @@ 这里只记录对当前开发仍有用的症状、根因、排查方法和风险边界。同一事实保留一个当前口径;退役对象的专属过程与单轮测试结果由 Git 历史追溯。遇到旧路径或版本时,以现行代码和专题文档为准。 +## 2026-10-01 线上 Nginx 手工维护漂移:`/profile` 刷新 404、发行包请求体仍限 64m + +- **现象**:登录后刷新 `https://www.genarrative.world/profile` 直接 404,返回 Nginx 默认 404 页(162 字节);应用内点击进入正常。`curl` 复现:`/`、`/project`、`/creation`、`/components`、`/design-system`、`/games*`、`/editor/canvas` 全部 200 且正文与 `/` 同一份 SPA 外壳,只有 `/profile`(含 `/PROFILE`、`/profile/`)404。 +- **原因**:刷新是真实 HTTP 请求,命中线上 `/etc/nginx/conf.d/genarrative.conf` 的 SPA allowlist;线上白名单是手工维护的(留档 `genarrative.conf.bak-sparoutes-20260923T153751Z`),`profile` 从未加入,该深链落回 `location /` 的 `try_files $uri $uri/ =404`。仓库三份模板当时都写了 `profile`,而 `npm run check:nginx-spa-routes` 只校验仓库模板,查不出线上漂移。同一份线上文件还把 `client_max_body_size` 停在 `64m`(模板 210m,会让 200 MiB 发行包上传 413),并保有 4 处模板没有的 host-only 块(画廊读取限流 location、`/finance-forecast/`、`/medical-science/`、`/home/` 官网首页入口),所以直接拿模板整文件覆盖会删掉这些线上能力。 +- **处理**:线上只收敛 SPA allowlist(12 条,含 `/profile`)并补 `error_page 404 /404.html`;host-only 块收进 `deploy/nginx/snippets/genarrative-host-extras.conf`,由主模板 include、由 `Genarrative-Server-Provision` 安装。退役路由不进白名单,也不需要显式 404 配置,落 `location /` 即得品牌 404(非 HTML 客户端保持纯 404)。 +- **判据/取证**:改前 `grep -c profile /etc/nginx/conf.d/genarrative.conf` 为 0;改后 `curl -s -o /dev/null -w '%{http_code}' https://www.genarrative.world/profile` 为 200(正文 sha 与 `/` 一致),`sudo nginx -T | grep -c 'editor/canvas|profile|project'` 为 1,`match3d` 计数为 0。 +- **长期口径**:任何 SPA/路由白名单改动都要同时核对**生效配置**(`sudo nginx -T`)与仓库模板;线上主站 vhost 只由模板 + `genarrative-host-extras.conf` 生成,不再手工追加路由。snippet 内的 location 不在 Pingora 路由矩阵覆盖范围,Pingora 接公网 443 前要单独确认这些 host-only 路径。 ## 2026-09-30 构建期 staging 撞上不装 npm 依赖的 Linux 门禁:AGC 壳 Rust lane 全红 - **现象**:`Project CI` 的 AGC 壳 Rust 三条 lane(`npm run check:native-shells:agc-rust-shard-*`)在 `fb130d184` 之后全部失败,日志只有 `error: failed to run custom build command for genarrative-ai-game-creator-shell` 与 `thread 'main' panicked at build.rs:65:28: Claude Agent SDK 缺失;请先执行 npm ci`(run 3083 / job 17521 实测,1 分钟即失败)。 @@ -6184,6 +6191,11 @@ Cocos Creator 根目录由 `package.json.creator.version` 与普通 `assets/` - **验证**:真机尺寸下用 `document.elementFromPoint(按钮中心)` 断言命中的是按钮自身而不是底部菜单(`overlay.parentElement === document.body`);顶栏断点矩阵(320–768)断言单行且无横向溢出。 - **关联**:`src/components/common/SquareImageCropModal.tsx`、`src/components/platform-entry/PlatformProfileModalShell.tsx`、`src/components/platform-entry/PlatformEntryActiveFlowShell.tsx`、`src/index.css`。 +## 2026-10-01 AGC 官方 Claude 模型缺少账号令牌会被误报为通道断开 + +- **现象**:模型目录把回合路由到 `cc`,本地 `game-creator.config.json` 的 `llm.apiKey` 为空时,Claude Agent SDK 返回失败终态;界面只显示“执行通道未能建立或已断开”。 +- **根因**:Claude sidecar 只从 `ANTHROPIC_API_KEY` / `ANTHROPIC_AUTH_TOKEN` 或本地 `llm.apiKey` 读取认证,没有复用已登录的 AGC 平台会话;同时失败终态解析丢弃了上游错误摘要。 +- **处理**:官方模型且未启用自定义目录时,将当前平台会话令牌仅注入 sidecar 子进程环境;保留最多 512 字符的 Claude 终态错误摘要,继续由统一诊断层脱敏,避免凭据落盘。 ## 2026-10-01 AGC 首页把 Web 预检错误与 Tauri IPC 错误合并,造成无法诊断的生成阻拦 - **现象**:用户在首页点击「做游戏」后看到「Web 游戏环境预检未通过,请检查 Node/npm 或浏览器」,但同一安装包的 `--environment-check` 可能已经返回 `status=ready`;首页仍会阻止自动命名、建项和首次生成。 diff --git a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md index ec8a2ef39..4f73e66ef 100644 --- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md +++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md @@ -23,6 +23,30 @@ grep -i '^cache-control: no-store' ~/data/tmp/tsingnova-client-download.headers 验收必须同时满足:接口返回 `200 application/json`,响应包含 `downloads` 与 `unavailablePlatforms`,并带 `Cache-Control: no-store`;`https://tsingnovagames.com/` 仍返回官网首页。若 API 直连 `http://127.0.0.1:8082/api/client-downloads` 是 `200`、官网域名仍是 `404`,说明官网 vhost 没有 include 下载 snippet,不能靠 reload 旧配置解决。 +### 主站 SPA 白名单与 host-only 覆盖 + +主站 SPA 深链(`/profile`、`/project`、`/games/*` 等)只在 `deploy/nginx/genarrative.conf` 的 `# BEGIN GENARRATIVE MAIN SPA ROUTES` allowlist 内回退 `/index.html`;不在白名单的路径落回 `location /` 的 `try_files $uri $uri/ =404`,再经 `error_page 404 /404.html` 返回品牌 404 页。非 HTML 客户端(`Accept` 不含 `text/html`)保持纯 404,由维护 snippet 的 `location = /404.html` 决定,属预期行为。 + +2026-10-01 线上 `/profile` 刷新 404 就是白名单漂移:线上 `genarrative.conf` 是手工维护的,`profile` 从未加进 allowlist,而仓库三份模板都写了它;`npm run check:nginx-spa-routes` 只校验仓库模板,查不出线上漂移。同一份线上文件还把 `client_max_body_size` 停在 `64m`,并保有 4 处模板当时没有的 host-only 块,因此**不能**直接拿模板整文件覆盖线上。 + +- 生产 vhost 以 `deploy/nginx/genarrative.conf` 为唯一来源;生产机专属路径收在 `deploy/nginx/snippets/genarrative-host-extras.conf`(`/finance-forecast/`、`/medical-science/`、公开画廊读取限流 location、`/home/` 官网首页入口),由主模板 include,并由 `Genarrative-Server-Provision` 安装到 `/etc/nginx/snippets/`。 +- snippet 内的 location 不在 `deploy/pingora/nginx-route-parity.matrix.json` 覆盖范围;新增**平台**路由必须在主模板内声明并同步矩阵与 Pingora 网关,否则 `npm run check:pingora-route-parity` 的「模板 location 必须被矩阵覆盖」会失败。 +- 退役路由(`/match3d`、`/puzzle`、`/runtime/*`、`/gallery/*/detail`、`/works/detail`、`/worlds/detail` 等)不需要任何显式 404 配置,也不进白名单,落 `location /` 即得品牌 404。 + +改动或排障后按生效配置核对,不要只看仓库模板: + +```bash +sudo nginx -T | grep -n -A6 'GENARRATIVE MAIN SPA ROUTES' # allowlist 生效内容 +sudo nginx -T | grep -n 'client_max_body_size' # 期望 210m +sudo nginx -T | grep -n 'genarrative-host-extras' # host-only snippet 已 include +for p in / /profile /project /creation /components /design-system /games /games/detail \ + /games/mine /games/play /games/publish /editor/canvas; do + printf '%s %s\n' "$p" "$(curl -s -o /dev/null -w '%{http_code}' https://www.genarrative.world$p)" +done # 期望全部 200,且正文与 / 的 index.html 一致 +curl -s -o /dev/null -w '%{http_code}\n' https://www.genarrative.world/match3d # 期望 404 +sudo nginx -t && sudo systemctl reload nginx +``` + ## 构建回归的隔离与发布文件权限 Git hook 的临时仓库测试必须清除子进程继承的仓库定位环境(例如 `GIT_DIR`、`GIT_WORK_TREE`、`GIT_INDEX_FILE`);仅设置 `cwd` 不能隔离 Git。回归应从带这些变量的外层仓库运行,验证外层引用、索引与配置不变。临时测试文件必须留在独立目录并清理,不得通过测试生成主仓库提交或覆盖 ESLint、Prettier 配置。修复 lint 配置时保留原有规则、忽略范围与零警告门禁,不以关闭规则代替排障。 diff --git a/scripts/check-production-ops-guardrails.mjs b/scripts/check-production-ops-guardrails.mjs index a4ef58b77..d412db2c3 100644 --- a/scripts/check-production-ops-guardrails.mjs +++ b/scripts/check-production-ops-guardrails.mjs @@ -2079,6 +2079,12 @@ const checks = [ reason: 'Server-Provision 必须安装 Pingora 真实路径 canary snippet,供人工启用直连前中间验证。', }, + { + file: 'scripts/jenkins-server-provision.sh', + includes: 'genarrative-host-extras.conf', + reason: + 'Server-Provision 必须安装生产机 host-only snippet,避免 provision 后内部工具页与官网首页入口丢失。', + }, { file: 'scripts/jenkins-server-provision.sh', includes: 'genarrative-pingora-gateway', diff --git a/scripts/jenkins-server-provision.sh b/scripts/jenkins-server-provision.sh index 9d2bda38a..dc20e57f1 100755 --- a/scripts/jenkins-server-provision.sh +++ b/scripts/jenkins-server-provision.sh @@ -1094,13 +1094,16 @@ install_nginx_config_with_rollback() { local snippet_target="/etc/nginx/snippets/genarrative-maintenance.conf" local pingora_canary_snippet_target="/etc/nginx/snippets/genarrative-pingora-canary.conf" local pingora_realpath_canary_snippet_target="/etc/nginx/snippets/genarrative-pingora-realpath-canary.conf" + local host_extras_snippet_target="/etc/nginx/snippets/genarrative-host-extras.conf" local config_source local rendered_config rendered_snippet rendered_pingora_canary_snippet rendered_pingora_realpath_canary_snippet - local config_backup snippet_backup pingora_canary_snippet_backup pingora_realpath_canary_snippet_backup disabled_sites + local rendered_host_extras_snippet + local config_backup snippet_backup pingora_canary_snippet_backup pingora_realpath_canary_snippet_backup host_extras_snippet_backup disabled_sites local had_config="false" local had_snippet="false" local had_pingora_canary_snippet="false" local had_pingora_realpath_canary_snippet="false" + local had_host_extras_snippet="false" run_cmd mkdir -p /etc/nginx/snippets /etc/nginx/conf.d if [[ "${NGINX_CONFIG_MODE}" == "production-https" ]]; then @@ -1116,6 +1119,7 @@ install_nginx_config_with_rollback() { echo "+ install -m 0644 deploy/nginx/snippets/genarrative-maintenance.conf ${snippet_target}" echo "+ install -m 0644 deploy/nginx/snippets/genarrative-pingora-canary.conf ${pingora_canary_snippet_target}" echo "+ install -m 0644 deploy/nginx/snippets/genarrative-pingora-realpath-canary.conf ${pingora_realpath_canary_snippet_target}" + echo "+ install -m 0644 deploy/nginx/snippets/genarrative-host-extras.conf ${host_extras_snippet_target}" if [[ "${DRY_RUN}" == "true" ]]; then echo "+ disable /etc/nginx/sites-enabled/default* if present" @@ -1128,10 +1132,12 @@ install_nginx_config_with_rollback() { rendered_snippet="$(mktemp)" rendered_pingora_canary_snippet="$(mktemp)" rendered_pingora_realpath_canary_snippet="$(mktemp)" + rendered_host_extras_snippet="$(mktemp)" config_backup="$(mktemp)" snippet_backup="$(mktemp)" pingora_canary_snippet_backup="$(mktemp)" pingora_realpath_canary_snippet_backup="$(mktemp)" + host_extras_snippet_backup="$(mktemp)" disabled_sites="$(mktemp)" if [[ "${NGINX_CONFIG_MODE}" == "production-https" ]]; then validate_nginx_tls @@ -1142,6 +1148,7 @@ install_nginx_config_with_rollback() { cp deploy/nginx/snippets/genarrative-maintenance.conf "${rendered_snippet}" cp deploy/nginx/snippets/genarrative-pingora-canary.conf "${rendered_pingora_canary_snippet}" cp deploy/nginx/snippets/genarrative-pingora-realpath-canary.conf "${rendered_pingora_realpath_canary_snippet}" + cp deploy/nginx/snippets/genarrative-host-extras.conf "${rendered_host_extras_snippet}" if [[ -f "${config_target}" ]]; then cp -p "${config_target}" "${config_backup}" @@ -1159,11 +1166,16 @@ install_nginx_config_with_rollback() { cp -p "${pingora_realpath_canary_snippet_target}" "${pingora_realpath_canary_snippet_backup}" had_pingora_realpath_canary_snippet="true" fi + if [[ -f "${host_extras_snippet_target}" ]]; then + cp -p "${host_extras_snippet_target}" "${host_extras_snippet_backup}" + had_host_extras_snippet="true" + fi install -m 0644 "${rendered_config}" "${config_target}" install -m 0644 "${rendered_snippet}" "${snippet_target}" install -m 0644 "${rendered_pingora_canary_snippet}" "${pingora_canary_snippet_target}" install -m 0644 "${rendered_pingora_realpath_canary_snippet}" "${pingora_realpath_canary_snippet_target}" + install -m 0644 "${rendered_host_extras_snippet}" "${host_extras_snippet_target}" disable_nginx_default_sites_enabled "${disabled_sites}" if ! nginx -t; then @@ -1188,14 +1200,19 @@ install_nginx_config_with_rollback() { else rm -f "${pingora_realpath_canary_snippet_target}" fi + if [[ "${had_host_extras_snippet}" == "true" ]]; then + cp -p "${host_extras_snippet_backup}" "${host_extras_snippet_target}" + else + rm -f "${host_extras_snippet_target}" + fi restore_nginx_default_sites_enabled "${disabled_sites}" - rm -f "${rendered_config}" "${rendered_snippet}" "${rendered_pingora_canary_snippet}" "${rendered_pingora_realpath_canary_snippet}" "${config_backup}" "${snippet_backup}" "${pingora_canary_snippet_backup}" "${pingora_realpath_canary_snippet_backup}" "${disabled_sites}" + rm -f "${rendered_config}" "${rendered_snippet}" "${rendered_pingora_canary_snippet}" "${rendered_pingora_realpath_canary_snippet}" "${rendered_host_extras_snippet}" "${config_backup}" "${snippet_backup}" "${pingora_canary_snippet_backup}" "${pingora_realpath_canary_snippet_backup}" "${host_extras_snippet_backup}" "${disabled_sites}" exit 1 fi echo "+ nginx -s reload" nginx -s reload - rm -f "${rendered_config}" "${rendered_snippet}" "${rendered_pingora_canary_snippet}" "${rendered_pingora_realpath_canary_snippet}" "${config_backup}" "${snippet_backup}" "${pingora_canary_snippet_backup}" "${pingora_realpath_canary_snippet_backup}" "${disabled_sites}" + rm -f "${rendered_config}" "${rendered_snippet}" "${rendered_pingora_canary_snippet}" "${rendered_pingora_realpath_canary_snippet}" "${rendered_host_extras_snippet}" "${config_backup}" "${snippet_backup}" "${pingora_canary_snippet_backup}" "${pingora_realpath_canary_snippet_backup}" "${host_extras_snippet_backup}" "${disabled_sites}" } cleanup_placeholder_nginx_config() { @@ -1417,6 +1434,7 @@ require_path deploy/nginx/genarrative-dev-http.conf require_path deploy/nginx/snippets/genarrative-maintenance.conf require_path deploy/nginx/snippets/genarrative-pingora-canary.conf require_path deploy/nginx/snippets/genarrative-pingora-realpath-canary.conf +require_path deploy/nginx/snippets/genarrative-host-extras.conf require_path deploy/logrotate/genarrative-pingora-gateway require_path deploy/env/api-server.env.example require_path deploy/env/external-generation-worker.env.example